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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Removed

- Favorites and Paste Stack, simplifying the shelf around history and pinboards.

## [0.2.0] - 2026-09-03

The first independently maintained Copy release.
Expand Down
152 changes: 6 additions & 146 deletions Copy/App/AppCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,20 +26,6 @@ final class AppCoordinator {
private(set) lazy var ocrController = OCRController(store: store)
private(set) lazy var archiveController = ArchiveController(store: store, pinboardStore: pinboardStore)

/// Assigned eagerly in `init()` (from a local, not `self.pasteStackModel`) so the
/// monitor's `onCapture` closure can capture it directly for the auto-enqueue-while-
/// active behavior — `self` isn't fully initialized yet at that point in `init()`,
/// but a plain local reference to this same instance is fine to capture. Its
/// `onActiveChange` handler is wired afterward, once `self` is safe to capture.
private let pasteStackModel: PasteStackModel
private lazy var pasteStackController = PasteStackController(
model: pasteStackModel,
hideDuringScreenSharing: settings.hideDuringScreenSharing,
proDark: settings.shelfProDark)
private lazy var pasteStackEngine = PasteStackEngine(onIntercept: { [weak self] in
self?.pasteNextViaEngine()
})

/// How often the retention pruner re-runs while the app stays open.
private static let retentionInterval: TimeInterval = 12 * 60 * 60

Expand Down Expand Up @@ -249,9 +235,6 @@ final class AppCoordinator {
paste()
}
}
shelfViewModel.onAddToPasteStack = { [weak self] item in
self?.addToPasteStack(item)
}
shelfViewModel.onOpenURL = { [weak controller] url in
if let controller {
controller.hide(restoreFocus: true) {
Expand All @@ -278,9 +261,6 @@ final class AppCoordinator {
shelfViewModel.onNewItem = { [weak self] in
self?.newItem()
}
shelfViewModel.onTogglePasteStack = { [weak self] in
self?.togglePasteStack()
}
shelfViewModel.onTogglePrivacyMode = { [weak self] in
self?.togglePause()
}
Expand Down Expand Up @@ -357,10 +337,8 @@ final class AppCoordinator {
self.pinboardStore = pinboardStore
self.pasteService = PasteService(pasteboard: NSPasteboard.general,
keyPoster: CGKeyEventPoster())
let pasteStackModel = PasteStackModel(store: store)
self.pasteStackModel = pasteStackModel
if isDemo {
DemoData.seed(store: store, pinboards: pinboardStore, pasteStack: pasteStackModel)
DemoData.seed(store: store, pinboards: pinboardStore)
}
let settings = SettingsStore()
self.settings = settings
Expand All @@ -378,26 +356,18 @@ final class AppCoordinator {
let app = NSWorkspace.shared.frontmostApplication
return (app?.bundleIdentifier, app?.localizedName)
},
onCapture: { [persistQueue, linkFetcher, ocrController, settings, pasteStackModel] captured in
onCapture: { [persistQueue, linkFetcher, ocrController, settings] captured in
persistQueue.async {
do {
let saved = try store.save(captured)
DispatchQueue.main.async {
linkFetcher.fetchIfNeeded(for: saved, enabled: settings.fetchLinkPreviews)
ocrController.recognizeIfNeeded(for: saved, enabled: settings.recognizeImageText)
// While the stack is active, new copies join the queue too —
// "copying while the stack is active" enqueues automatically.
if pasteStackModel.isActive {
pasteStackModel.enqueue(saved)
}
// Already on the main queue; hop into main-actor isolation for
// the one-time activation nudges (see the methods).
// the one-time first-copy coach.
MainActor.assumeIsolated {
CopySoundPlayer.shared.play(settings.copySound)
AppCoordinator.showFirstCopyCoachIfNeeded()
if !pasteStackModel.isActive {
AppCoordinator.notePasteStackOpportunity()
}
}
}
} catch {
Expand All @@ -413,38 +383,16 @@ final class AppCoordinator {
}
settings.onHideDuringScreenSharingChange = { [weak self] hide in
self?.shelfController.setHideDuringScreenSharing(hide)
self?.pasteStackController.setHideDuringScreenSharing(hide)
}
settings.onCompactShelfChange = { [weak self] compact in
self?.shelfController.setCompactShelf(compact)
}
settings.onShelfProDarkChange = { [weak self] proDark in
self?.shelfController.setProDark(proDark)
self?.pasteStackController.setProDark(proDark)
}
settings.onShowOnboarding = { [weak self] in
self?.showOnboarding()
}
// `self` is fully initialized past this point, so it's safe to capture weakly
// in `onActiveChange` — the single fan-out point for palette visibility AND
// engine activation, so the two can never drift out of lockstep (see the
// `pasteStackModel` doc comment above).
pasteStackModel.onActiveChange = { [weak self] isActive in
guard let self else { return }
self.pasteStackController.syncVisibility(to: isActive)
self.shelfViewModel.isPasteStackOn = isActive
if isActive {
let tapCreated = self.pasteStackEngine.activate()
NSLog("Copy: Paste Stack tap \(tapCreated ? "activated" : "could not be created, falling back to hotkey")")
if !tapCreated {
KeyboardShortcuts.enable(.pasteNextFromStack)
HUD.show("Use Control Option Command N to paste the next item")
}
} else {
self.pasteStackEngine.deactivate()
KeyboardShortcuts.disable(.pasteNextFromStack)
}
}
}

func start() {
Expand Down Expand Up @@ -481,26 +429,8 @@ final class AppCoordinator {
HUD.show("Saved to Copy. Press \(hotkey) to open it.", duration: 2.8)
}

/// Tracks recent capture times; when an established user copies several things in
/// quick succession (exactly when a paste stack would help) and hasn't met the
/// feature yet, introduce it, once. Gated behind the first-copy coach so a brand-new
/// user copying a fast burst isn't double-nagged.
private static var recentCaptureTimes: [Date] = []
private static func notePasteStackOpportunity() {
let defaults = UserDefaults.standard
guard defaults.bool(forKey: "hasSeenFirstCopyCoach"),
!defaults.bool(forKey: "hasSeenPasteStackHint") else { return }
let now = Date()
recentCaptureTimes.append(now)
recentCaptureTimes = recentCaptureTimes.filter { now.timeIntervalSince($0) < 10 }
guard recentCaptureTimes.count >= 3 else { return }
defaults.set(true, forKey: "hasSeenPasteStackHint")
let hotkey = KeyboardShortcuts.getShortcut(for: .togglePasteStack)?.description ?? "⇧⌘C"
HUD.show("Collecting a few things? Press \(hotkey) for the paste stack.", duration: 3.0)
}

/// Prunes items past the configured retention window on the persist queue,
/// always sparing favorites and pinboard members (`ItemStore.prune` invariant).
/// always sparing pinboard members (`ItemStore.prune` invariant).
private func runRetentionPrune() {
let cutoff = settings.retention.cutoff
let store = self.store
Expand Down Expand Up @@ -606,15 +536,6 @@ final class AppCoordinator {
}
}

/// Enqueues `item` into the Paste Stack (activating the palette if it wasn't
/// already) from the card context menu's "Add to Paste Stack" action. The palette
/// re-fits its own size to the new row via `PasteStackView.onContentChange`, so
/// there's no need to poke the controller directly here.
func addToPasteStack(_ item: ClipItem) {
pasteStackModel.enqueue(item)
HUD.show("Added to Paste Stack")
}

/// Places the primary shelf card on the system clipboard without synthesizing a
/// paste. The self marker keeps the monitor from ingesting a duplicate; touching
/// the existing item makes it the current entry in history too. Returns whether the
Expand Down Expand Up @@ -669,70 +590,9 @@ final class AppCoordinator {
HUD.show("Color copied")
}

/// Whether the Paste Stack palette/engine are currently active — read by
/// `AppDelegate` to reflect a checkmark on the "Paste Stack" menu item.
var isPasteStackActive: Bool { pasteStackModel.isActive }

/// Flips the Paste Stack on or off. All activation/deactivation — the palette, the
/// CGEvent tap, and the `.pasteNextFromStack` fallback hotkey — fans out from
/// `pasteStackModel.isActive`'s `didSet`, wired to `onActiveChange` in `init()`.
func togglePasteStack() {
pasteStackModel.isActive.toggle()
}

/// Advances the queue and places the next item's representations on the
/// pasteboard, shared by both the engine-intercepted path and the
/// `.pasteNextFromStack` fallback hotkey. Returns `false` once the queue is
/// exhausted, having already deactivated the stack and shown the "finished" HUD.
@discardableResult
private func placeNextStackItem() -> Bool {
guard let reps = pasteStackModel.advanceAndResolve() else {
deactivatePasteStack()
HUD.show("Paste Stack finished")
return false
}
pasteService.place(reps, plainTextOnly: false)
return true
}

/// Called by `PasteStackEngine`'s `onIntercept` when the CGEvent tap swallows a
/// plain ⌘V. This path only runs with Accessibility granted (the tap itself
/// requires it), so after placing the item it's safe to re-synthesize a marked ⌘V
/// and have the frontmost app receive it automatically.
private func pasteNextViaEngine() {
guard placeNextStackItem() else { return }
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
PasteStackEngine.postMarkedPasteKeystroke()
}
}

/// `.pasteNextFromStack` fallback hotkey handler — only reachable while the tap
/// couldn't be created (no Accessibility; see `pasteStackModel.onActiveChange`).
/// Without Accessibility we can't reliably synthesize a ⌘V ourselves (same reason
/// `pasteFromShelf`/`onPasteMultiple` gate `sendPasteKeystroke()` behind
/// `AXIsProcessTrusted()`), so this places the item and lets the user's own ⌘V —
/// which the OS delivers directly, no synthesis needed — do the actual pasting.
func pasteNextFromStack() {
guard placeNextStackItem() else { return }
HUD.show("Ready to paste. Press Command V.")
}

private func deactivatePasteStack() {
pasteStackModel.isActive = false
}

/// Called from `AppDelegate.applicationWillTerminate` so the event tap never
/// outlives the app process. `pasteStackEngine.deactivate()` is called directly
/// too, as a defensive no-op, in case the tap was ever active without
/// `pasteStackModel.isActive` reflecting it.
func applicationWillTerminate() {
deactivatePasteStack()
pasteStackEngine.deactivate()
}

func clearHistory() {
do {
try store.clearHistory(keepFavorites: true)
try store.clearHistory()
} catch {
NSLog("Copy: failed to clear history: \(error)")
}
Expand All @@ -747,7 +607,7 @@ final class AppCoordinator {
func confirmAndClearHistory() {
let alert = NSAlert()
alert.messageText = "Clear clipboard history?"
alert.informativeText = "Favorites are kept. This cannot be undone."
alert.informativeText = "Pinboard items are kept. This cannot be undone."
alert.addButton(withTitle: "Clear")
alert.addButton(withTitle: "Cancel")
alert.window.level = .statusBar
Expand Down
25 changes: 0 additions & 25 deletions Copy/App/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,6 @@ import Sparkle

extension KeyboardShortcuts.Name {
static let toggleShelf = Self("toggleShelf", initial: .init(.v, modifiers: [.command, .shift]))
static let togglePasteStack = Self("togglePasteStack", initial: .init(.c, modifiers: [.command, .shift]))
static let pasteNextFromStack = Self("pasteNextFromStack", initial: .init(.n, modifiers: [.control, .option, .command]))
/// No default shortcut — the user opts in from Settings. Pastes the most recent
/// history item directly into the frontmost app without opening the shelf.
static let quickPasteLatest = Self("quickPasteLatest")
Expand Down Expand Up @@ -63,16 +61,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
KeyboardShortcuts.onKeyDown(for: .toggleShelf) { [weak self] in
self?.coordinator.toggleShelf()
}
KeyboardShortcuts.onKeyDown(for: .togglePasteStack) { [weak self] in
self?.coordinator.togglePasteStack()
}
// Fallback path only: `AppCoordinator` enables this while the Paste Stack is
// active and the CGEvent tap couldn't be created (no Accessibility), and
// disables it again on deactivation — see `pasteStackModel.onActiveChange`.
KeyboardShortcuts.onKeyDown(for: .pasteNextFromStack) { [weak self] in
self?.coordinator.pasteNextFromStack()
}
KeyboardShortcuts.disable(.pasteNextFromStack)
KeyboardShortcuts.onKeyDown(for: .quickPasteLatest) { [weak self] in
self?.coordinator.quickPasteLatest()
}
Expand Down Expand Up @@ -155,10 +143,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
return image
}

func applicationWillTerminate(_ notification: Notification) {
coordinator.applicationWillTerminate()
}

func menuNeedsUpdate(_ menu: NSMenu) {
menu.removeAllItems()

Expand Down Expand Up @@ -192,11 +176,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
// extras are "Open Copy" and the live recent-items list above, which are
// menu-bar-only by nature (the shelf is already open, and already shows items).
menu.addItem(.separator())
let pasteStack = NSMenuItem(title: "Paste Stack", action: #selector(togglePasteStack), keyEquivalent: "c")
pasteStack.keyEquivalentModifierMask = [.command, .shift]
pasteStack.target = self
pasteStack.state = coordinator.isPasteStackActive ? .on : .off
menu.addItem(pasteStack)
let pause = NSMenuItem(title: coordinator.isPaused ? "Resume Monitoring" : "Pause Monitoring",
action: #selector(togglePause), keyEquivalent: "")
pause.target = self
Expand Down Expand Up @@ -297,10 +276,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
updaterController.checkForUpdates(sender)
}

@objc private func togglePasteStack() {
coordinator.togglePasteStack()
}

@objc private func clearHistory() {
coordinator.confirmAndClearHistory()
}
Expand Down
21 changes: 4 additions & 17 deletions Copy/App/DemoData.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,16 +19,16 @@ enum DemoData {
private static let mail: App = ("com.apple.mail", "Mail")

@MainActor
static func seed(store: ItemStore, pinboards: PinboardStore, pasteStack: PasteStackModel) {
static func seed(store: ItemStore, pinboards: PinboardStore) {
do {
try seedThrowing(store: store, pinboards: pinboards, pasteStack: pasteStack)
try seedThrowing(store: store, pinboards: pinboards)
} catch {
NSLog("Copy: demo seed failed: \(error)")
}
}

@MainActor
private static func seedThrowing(store: ItemStore, pinboards: PinboardStore, pasteStack: PasteStackModel) throws {
private static func seedThrowing(store: ItemStore, pinboards: PinboardStore) throws {
let now = Date()
let minute = 60.0, hour = 3600.0, day = 86400.0
func ago(_ seconds: TimeInterval) -> Date { now.addingTimeInterval(-seconds) }
Expand Down Expand Up @@ -127,7 +127,7 @@ enum DemoData {
title: "Copy — Marketing site (Figma)",
from: figma, at: ago(34 * minute), favicon: .systemPurple)

try text("Standup at 10:30 — demo the smart search and paste stack. Can someone record the video? 🎥",
try text("Standup at 10:30 — demo the smart search and image previews. Can someone record the video? 🎥",
from: slack, at: ago(45 * minute))

let jsonCode = """
Expand Down Expand Up @@ -170,12 +170,6 @@ enum DemoData {
bottom: NSColor(calibratedRed: 0.10, green: 0.55, blue: 0.45, alpha: 1),
label: "Old concept")

// MARK: favorites (float above the divider)

for id in [codeID, githubID, invoiceID, brandColorID] {
try store.setFavorite(itemID: id, true)
}

// MARK: pinboards

let work = try pinboards.create(name: "Work", symbol: "briefcase", tint: "007AFF")
Expand All @@ -192,13 +186,6 @@ enum DemoData {
try pinboards.add(itemID: id, to: snippets.id!)
}

// MARK: paste stack (pre-filled; the palette isn't auto-shown)

let saved = try store.recentItems(limit: 200)
func uuid(of id: Int64) -> String? { saved.first { $0.id == id }?.uuid }
for uuid in [shellID, githubID, brandColorID].compactMap(uuid(of:)) {
pasteStack.queue.enqueue(uuid)
}
}

// MARK: image generation (no bundled assets)
Expand Down
Loading
Loading