Merge pull request #3 from Ebullioscopic/feature/clipboard

Feature/clipboard
This commit is contained in:
Hariharan Mudaliar 2025-07-17 22:53:20 +05:30 • committed by GitHub
commit 2978a38e3c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 934 additions and 10 deletions

View file

@ -94,8 +94,8 @@ struct ContentView: View {
isHovering = hovering isHovering = hovering
} }
// Only close if mouse leaves and the notch is open // Only close if mouse leaves and the notch is open and no popovers are active
if !hovering && vm.notchState == .open { if !hovering && vm.notchState == .open && !vm.isBatteryPopoverActive && !vm.isClipboardPopoverActive {
vm.close() vm.close()
} }
} }
@ -148,6 +148,13 @@ struct ContentView: View {
} }
} }
} }
.onChange(of: vm.isClipboardPopoverActive) { _, newPopoverState in
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
if !newPopoverState && !isHovering && vm.notchState == .open && !vm.isBatteryPopoverActive {
vm.close()
}
}
}
.sensoryFeedback(.alignment, trigger: haptics) .sensoryFeedback(.alignment, trigger: haptics)
.contextMenu { .contextMenu {
SettingsLink(label: { SettingsLink(label: {
@ -477,8 +484,8 @@ struct ContentView: View {
isHovering = false isHovering = false
} }
// Close the notch if it's open and battery popover is not active // Close the notch if it's open and no popovers are active
if vm.notchState == .open && !vm.isBatteryPopoverActive { if vm.notchState == .open && !vm.isBatteryPopoverActive && !vm.isClipboardPopoverActive {
vm.close() vm.close()
} }
} }

View file

@ -268,6 +268,41 @@ class AppDelegate: NSObject, NSApplicationDelegate {
TimerManager.shared.startDemoTimer(duration: 300) TimerManager.shared.startDemoTimer(duration: 300)
} }
KeyboardShortcuts.onKeyDown(for: .clipboardHistoryPanel) { [weak self] in
guard let self = self else { return }
// Only open clipboard if the feature is enabled
guard Defaults[.enableClipboardManager] else { return }
// Find the appropriate view model based on mouse location
let mouseLocation = NSEvent.mouseLocation
var viewModel = self.vm
if Defaults[.showOnAllDisplays] {
for screen in NSScreen.screens {
if screen.frame.contains(mouseLocation) {
if let screenViewModel = self.viewModels[screen] {
viewModel = screenViewModel
break
}
}
}
}
// Open the notch if it's closed
if viewModel.notchState == .closed {
viewModel.open()
}
// Start clipboard monitoring if not already running
if !ClipboardManager.shared.isMonitoring {
ClipboardManager.shared.startMonitoring()
}
// Post notification to trigger clipboard popover
NotificationCenter.default.post(name: NSNotification.Name("ToggleClipboardPopover"), object: nil)
}
if !Defaults[.showOnAllDisplays] { if !Defaults[.showOnAllDisplays] {
let viewModel = self.vm let viewModel = self.vm
let window = createDynamicIslandWindow( let window = createDynamicIslandWindow(

View file

@ -46,6 +46,18 @@
}, },
"• New feature 1" : { "• New feature 1" : {
},
"3 items" : {
},
"5 items" : {
},
"7 items" : {
},
"10 items" : {
}, },
"About" : { "About" : {
"localizations" : { "localizations" : {
@ -70,6 +82,9 @@
}, },
"Access Denied" : { "Access Denied" : {
},
"Actions" : {
}, },
"Activate" : { "Activate" : {
@ -244,9 +259,21 @@
}, },
"Clear" : { "Clear" : {
},
"Clear Clipboard History" : {
}, },
"Clear Data" : { "Clear Data" : {
},
"Clipboard" : {
},
"Clipboard History" : {
},
"Clipboard Manager" : {
}, },
"Close" : { "Close" : {
@ -266,9 +293,18 @@
}, },
"Controls" : { "Controls" : {
},
"Copy something to get started" : {
}, },
"CPU Usage" : { "CPU Usage" : {
},
"Current History" : {
},
"Current Items" : {
}, },
"Custom color" : { "Custom color" : {
"extractionState" : "stale", "extractionState" : "stale",
@ -479,6 +515,9 @@
}, },
"High" : { "High" : {
},
"History Size" : {
}, },
"https://github.com/th-ch/youtube-music" : { "https://github.com/th-ch/youtube-music" : {
@ -616,6 +655,9 @@
}, },
"Mirror shape" : { "Mirror shape" : {
},
"Monitor clipboard changes and keep a history of recent copies. Use Cmd+Shift+V to quickly access clipboard history." : {
}, },
"Monitoring Status" : { "Monitoring Status" : {
@ -637,6 +679,9 @@
}, },
"Never hide" : { "Never hide" : {
},
"No clipboard history" : {
}, },
"No custom animation available" : { "No custom animation available" : {
@ -957,9 +1002,15 @@
}, },
"System Performance" : { "System Performance" : {
},
"The clipboard icon appears in the header next to the settings gear when enabled. Use the global shortcut Cmd+Shift+V or click the icon to access history." : {
}, },
"TheDynamicIsland" : { "TheDynamicIsland" : {
},
"This will permanently delete all stored clipboard history. The clipboard button is located to the left of the settings gear in the header." : {
}, },
"Time to Full Charge: %lld min" : { "Time to Full Charge: %lld min" : {

View file

@ -0,0 +1,203 @@
//
// ClipboardHistoryPopover.swift
// DynamicIsland
//
// Created by GitHub Copilot on 17/07/25.
//
import SwiftUI
struct ClipboardHistoryPopover: View {
@ObservedObject var clipboardManager = ClipboardManager.shared
@Binding var isPresented: Bool
@EnvironmentObject var vm: DynamicIslandViewModel
var body: some View {
VStack(alignment: .leading, spacing: 0) {
// Header
HStack {
Image(systemName: "doc.on.clipboard")
.foregroundColor(.white)
.font(.system(size: 16, weight: .medium))
Text("Clipboard History")
.font(.system(size: 14, weight: .medium))
.foregroundColor(.white)
Spacer()
Button(action: {
clipboardManager.clearHistory()
}) {
Image(systemName: "trash")
.foregroundColor(.red)
.font(.system(size: 12))
}
.buttonStyle(PlainButtonStyle())
.disabled(clipboardManager.clipboardHistory.isEmpty)
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
Divider()
.background(Color.gray.opacity(0.3))
// Content
if clipboardManager.clipboardHistory.isEmpty {
EmptyClipboardView()
} else {
ClipboardItemsList()
}
}
.frame(width: 280)
.frame(maxHeight: 200)
.background(Color.black.opacity(0.95))
.cornerRadius(12)
.overlay(
RoundedRectangle(cornerRadius: 12)
.stroke(Color.gray.opacity(0.2), lineWidth: 1)
)
.shadow(color: .black.opacity(0.3), radius: 10, x: 0, y: 5)
.onHover { hovering in
// Close popover and notch when mouse exits
if !hovering {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
isPresented = false
// Also close the notch if no other popovers are active
if vm.notchState == .open && !vm.isBatteryPopoverActive {
vm.close()
}
}
}
}
}
@ViewBuilder
private func EmptyClipboardView() -> some View {
VStack(spacing: 8) {
Image(systemName: "clipboard")
.font(.system(size: 24))
.foregroundColor(.gray)
Text("No clipboard history")
.font(.system(size: 12))
.foregroundColor(.gray)
Text("Copy something to get started")
.font(.system(size: 10))
.foregroundColor(.gray.opacity(0.7))
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding(.vertical, 20)
}
@ViewBuilder
private func ClipboardItemsList() -> some View {
ScrollView {
LazyVStack(spacing: 1) {
ForEach(clipboardManager.clipboardHistory) { item in
ClipboardItemRow(item: item) {
isPresented = false
}
}
}
}
.frame(maxHeight: 150)
}
}
struct ClipboardItemRow: View {
let item: ClipboardItem
let onCopy: () -> Void
@ObservedObject var clipboardManager = ClipboardManager.shared
@State private var isHovering = false
var body: some View {
HStack(alignment: .top, spacing: 8) {
// Type icon
Image(systemName: item.type.icon)
.font(.system(size: 12))
.foregroundColor(.blue)
.frame(width: 16)
// Content
VStack(alignment: .leading, spacing: 2) {
Text(item.preview)
.font(.system(size: 11))
.foregroundColor(.white)
.lineLimit(2)
.multilineTextAlignment(.leading)
HStack {
Text(item.type.displayName)
.font(.system(size: 9))
.foregroundColor(.gray)
Spacer()
Text(timeAgoString(from: item.timestamp))
.font(.system(size: 9))
.foregroundColor(.gray)
}
}
Spacer()
// Action buttons (shown on hover)
if isHovering {
HStack(spacing: 4) {
Button(action: {
clipboardManager.copyToClipboard(item)
onCopy()
}) {
Image(systemName: "doc.on.doc")
.font(.system(size: 10))
.foregroundColor(.green)
}
.buttonStyle(PlainButtonStyle())
Button(action: {
clipboardManager.deleteItem(item)
}) {
Image(systemName: "trash")
.font(.system(size: 10))
.foregroundColor(.red)
}
.buttonStyle(PlainButtonStyle())
}
}
}
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(isHovering ? Color.white.opacity(0.1) : Color.clear)
.onHover { hovering in
withAnimation(.easeInOut(duration: 0.2)) {
isHovering = hovering
}
}
.onTapGesture {
clipboardManager.copyToClipboard(item)
onCopy()
}
}
private func timeAgoString(from date: Date) -> String {
let interval = Date().timeIntervalSince(date)
if interval < 60 {
return "Just now"
} else if interval < 3600 {
let minutes = Int(interval / 60)
return "\(minutes)m ago"
} else if interval < 86400 {
let hours = Int(interval / 3600)
return "\(hours)h ago"
} else {
let days = Int(interval / 86400)
return "\(days)d ago"
}
}
}
#Preview {
ClipboardHistoryPopover(isPresented: .constant(true))
.padding()
.background(Color.gray)
}

View file

@ -13,7 +13,10 @@ struct DynamicIslandHeader: View {
@EnvironmentObject var webcamManager: WebcamManager @EnvironmentObject var webcamManager: WebcamManager
@ObservedObject var batteryModel = BatteryStatusViewModel.shared @ObservedObject var batteryModel = BatteryStatusViewModel.shared
@ObservedObject var coordinator = DynamicIslandViewCoordinator.shared @ObservedObject var coordinator = DynamicIslandViewCoordinator.shared
@ObservedObject var clipboardManager = ClipboardManager.shared
@StateObject var tvm = TrayDrop.shared @StateObject var tvm = TrayDrop.shared
@State private var showClipboardPopover = false
var body: some View { var body: some View {
HStack(spacing: 0) { HStack(spacing: 0) {
HStack { HStack {
@ -61,6 +64,41 @@ struct DynamicIslandHeader: View {
} }
.buttonStyle(PlainButtonStyle()) .buttonStyle(PlainButtonStyle())
} }
if Defaults[.enableClipboardManager] && Defaults[.showClipboardIcon] {
Button(action: {
showClipboardPopover.toggle()
}) {
Capsule()
.fill(.black)
.frame(width: 30, height: 30)
.overlay {
Image(systemName: "doc.on.clipboard")
.foregroundColor(.white)
.padding()
.imageScale(.medium)
}
}
.buttonStyle(PlainButtonStyle())
.popover(isPresented: $showClipboardPopover, arrowEdge: .bottom) {
ClipboardHistoryPopover(isPresented: $showClipboardPopover)
.onAppear {
vm.isClipboardPopoverActive = true
}
.onDisappear {
vm.isClipboardPopoverActive = false
}
}
.onChange(of: showClipboardPopover) { isOpen in
vm.isClipboardPopoverActive = isOpen
}
.onAppear {
if Defaults[.enableClipboardManager] && !clipboardManager.isMonitoring {
clipboardManager.startMonitoring()
}
}
}
if Defaults[.settingsIconInNotch] { if Defaults[.settingsIconInNotch] {
Button(action: { Button(action: {
SettingsWindowController.shared.showWindow() SettingsWindowController.shared.showWindow()
@ -100,6 +138,12 @@ struct DynamicIslandHeader: View {
} }
.foregroundColor(.gray) .foregroundColor(.gray)
.environmentObject(vm) .environmentObject(vm)
.onReceive(NotificationCenter.default.publisher(for: NSNotification.Name("ToggleClipboardPopover"))) { _ in
// Only toggle if clipboard is enabled
if Defaults[.enableClipboardManager] {
showClipboardPopover.toggle()
}
}
} }
} }

View file

@ -3,7 +3,7 @@
// DynamicIsland // DynamicIsland
// //
// Stats tab view for system performance monitoring // Stats tab view for system performance monitoring
// // Created by Hariharan Mudaliar
import SwiftUI import SwiftUI
import Defaults import Defaults

View file

@ -3,7 +3,7 @@
// DynamicIsland // DynamicIsland
// //
// Created by Richard Kunkli on 07/08/2024. // Created by Richard Kunkli on 07/08/2024.
// // Modified by Hariharan Mudaliar
import AVFoundation import AVFoundation
import Defaults import Defaults
@ -63,6 +63,9 @@ struct SettingsView: View {
NavigationLink(value: "Stats") { NavigationLink(value: "Stats") {
Label("Stats", systemImage: "chart.line.uptrend.xyaxis") Label("Stats", systemImage: "chart.line.uptrend.xyaxis")
} }
NavigationLink(value: "Clipboard") {
Label("Clipboard", systemImage: "doc.on.clipboard")
}
NavigationLink(value: "Shortcuts") { NavigationLink(value: "Shortcuts") {
Label("Shortcuts", systemImage: "keyboard") Label("Shortcuts", systemImage: "keyboard")
} }
@ -97,6 +100,8 @@ struct SettingsView: View {
Shelf() Shelf()
case "Stats": case "Stats":
Stats() Stats()
case "Clipboard":
ClipboardSettings()
case "Shortcuts": case "Shortcuts":
Shortcuts() Shortcuts()
case "Extensions": case "Extensions":
@ -1300,6 +1305,132 @@ func warningBadge(_ text: String, _ description: String) -> some View {
} }
} }
struct ClipboardSettings: View {
@ObservedObject var clipboardManager = ClipboardManager.shared
@Default(.enableClipboardManager) var enableClipboardManager
@Default(.clipboardHistorySize) var clipboardHistorySize
@Default(.showClipboardIcon) var showClipboardIcon
var body: some View {
Form {
Section {
Defaults.Toggle("Enable Clipboard Manager", key: .enableClipboardManager)
.onChange(of: enableClipboardManager) { enabled in
if enabled {
clipboardManager.startMonitoring()
} else {
clipboardManager.stopMonitoring()
}
}
} header: {
Text("Clipboard Manager")
} footer: {
Text("Monitor clipboard changes and keep a history of recent copies. Use Cmd+Shift+V to quickly access clipboard history.")
}
if enableClipboardManager {
Section {
Defaults.Toggle("Show Clipboard Icon", key: .showClipboardIcon)
HStack {
Text("History Size")
Spacer()
Picker("History Size", selection: $clipboardHistorySize) {
Text("3 items").tag(3)
Text("5 items").tag(5)
Text("7 items").tag(7)
Text("10 items").tag(10)
}
.pickerStyle(.menu)
.frame(width: 100)
}
HStack {
Text("Current Items")
Spacer()
Text("\(clipboardManager.clipboardHistory.count)")
.foregroundColor(.secondary)
}
HStack {
Text("Monitoring Status")
Spacer()
Text(clipboardManager.isMonitoring ? "Active" : "Stopped")
.foregroundColor(clipboardManager.isMonitoring ? .green : .secondary)
}
} header: {
Text("Settings")
} footer: {
Text("The clipboard icon appears in the header next to the settings gear when enabled. Use the global shortcut Cmd+Shift+V or click the icon to access history.")
}
Section {
Button("Clear Clipboard History") {
clipboardManager.clearHistory()
}
.foregroundColor(.red)
.disabled(clipboardManager.clipboardHistory.isEmpty)
} header: {
Text("Actions")
} footer: {
Text("This will permanently delete all stored clipboard history. The clipboard button is located to the left of the settings gear in the header.")
}
if !clipboardManager.clipboardHistory.isEmpty {
Section {
ForEach(clipboardManager.clipboardHistory) { item in
VStack(alignment: .leading, spacing: 4) {
HStack {
Image(systemName: item.type.icon)
.foregroundColor(.blue)
.frame(width: 16)
Text(item.type.displayName)
.font(.caption)
.foregroundColor(.secondary)
Spacer()
Text(timeAgoString(from: item.timestamp))
.font(.caption2)
.foregroundColor(.secondary)
}
Text(item.preview)
.font(.system(.body, design: .monospaced))
.lineLimit(2)
}
.padding(.vertical, 2)
}
} header: {
Text("Current History")
}
}
}
}
.formStyle(.grouped)
.navigationTitle("Clipboard")
.onAppear {
if enableClipboardManager && !clipboardManager.isMonitoring {
clipboardManager.startMonitoring()
}
}
}
private func timeAgoString(from date: Date) -> String {
let interval = Date().timeIntervalSince(date)
if interval < 60 {
return "Just now"
} else if interval < 3600 {
let minutes = Int(interval / 60)
return "\(minutes)m ago"
} else if interval < 86400 {
let hours = Int(interval / 3600)
return "\(hours)h ago"
} else {
let days = Int(interval / 86400)
return "\(days)d ago"
}
}
}
#Preview { #Preview {
HUD() HUD()
} }

View file

@ -3,7 +3,7 @@
// DynamicIsland // DynamicIsland
// //
// Created by Hugo Persson on 2024-08-25. // Created by Hugo Persson on 2024-08-25.
// // Modified by Hariharan Mudaliar
import SwiftUI import SwiftUI

View file

@ -3,7 +3,7 @@
// DynamicIsland // DynamicIsland
// //
// Created by Harsh Vardhan Goswami on 04/08/24. // Created by Harsh Vardhan Goswami on 04/08/24.
// // Modified by Hariharan Mudaliar
import Foundation import Foundation
import Defaults import Defaults

View file

@ -0,0 +1,447 @@
//
// ClipboardManager.swift
// DynamicIsland
//
// Created by GitHub Copilot on 17/07/25.
//
import AppKit
import SwiftUI
import Combine
import Foundation
import UniformTypeIdentifiers
import Defaults
// Clipboard item data structure
struct ClipboardItem: Identifiable, Codable {
let id = UUID()
let type: ClipboardItemType
let timestamp: Date
let preview: String
// Store different types of data - avoid large binary data in UserDefaults
let stringData: String?
let imageFileName: String? // Store filename instead of data
let fileURLs: [String]?
let rtfData: Data? // RTF is typically small, so we can keep this
init(stringData: String, type: ClipboardItemType) {
self.stringData = stringData
self.imageFileName = nil
self.fileURLs = nil
self.rtfData = nil
self.type = type
self.timestamp = Date()
self.preview = ClipboardItem.generatePreview(stringData: stringData, type: type)
}
init(imageData: Data) {
self.stringData = nil
self.fileURLs = nil
self.rtfData = nil
self.type = .image
self.timestamp = Date()
// Save image data to temporary file instead of storing in UserDefaults
let fileName = "clipboard_image_\(UUID().uuidString).png"
let fileURL = ClipboardManager.clipboardDataDirectory.appendingPathComponent(fileName)
do {
try imageData.write(to: fileURL)
self.imageFileName = fileName
self.preview = "Image (\(ByteCountFormatter.string(fromByteCount: Int64(imageData.count), countStyle: .file)))"
} catch {
print("Failed to save image data: \(error)")
self.imageFileName = nil
self.preview = "Image (failed to save)"
}
}
init(fileURLs: [String]) {
self.stringData = nil
self.imageFileName = nil
self.fileURLs = fileURLs
self.rtfData = nil
self.type = .file
self.timestamp = Date()
if fileURLs.count == 1, let url = URL(string: fileURLs.first!) {
self.preview = url.lastPathComponent
} else {
self.preview = "\(fileURLs.count) files"
}
}
init(rtfData: Data, plainText: String) {
// RTF data is typically small, so we can keep it in UserDefaults
self.stringData = plainText
self.imageFileName = nil
self.fileURLs = nil
self.rtfData = rtfData.count > 100000 ? nil : rtfData // Skip very large RTF files
self.type = .rtf
self.timestamp = Date()
self.preview = String(plainText.prefix(50))
}
// Helper to get image data from file
func getImageData() -> Data? {
guard let fileName = imageFileName else { return nil }
let fileURL = ClipboardManager.clipboardDataDirectory.appendingPathComponent(fileName)
return try? Data(contentsOf: fileURL)
}
static func generatePreview(stringData: String, type: ClipboardItemType) -> String {
switch type {
case .text:
return String(stringData.prefix(50))
case .url:
if let url = URL(string: stringData) {
return url.lastPathComponent.isEmpty ? url.host ?? stringData : url.lastPathComponent
}
return String(stringData.prefix(50))
case .file:
if let url = URL(string: stringData) {
return url.lastPathComponent
}
return "File"
case .image:
return "Image"
case .rtf:
return String(stringData.prefix(50))
case .unknown:
return String(stringData.prefix(50))
}
}
}
enum ClipboardItemType: String, CaseIterable, Codable {
case text = "text"
case url = "url"
case file = "file"
case image = "image"
case rtf = "rtf"
case unknown = "unknown"
var icon: String {
switch self {
case .text: return "doc.text"
case .url: return "link"
case .file: return "doc"
case .image: return "photo"
case .rtf: return "doc.richtext"
case .unknown: return "questionmark.circle"
}
}
var displayName: String {
switch self {
case .text: return "Text"
case .url: return "URL"
case .file: return "File"
case .image: return "Image"
case .rtf: return "Rich Text"
case .unknown: return "Unknown"
}
}
}
class ClipboardManager: ObservableObject {
static let shared = ClipboardManager()
@Published var clipboardHistory: [ClipboardItem] = []
@Published var isMonitoring: Bool = false
private var timer: Timer?
private var lastChangeCount: Int = 0
// Use configurable history size from settings
private var maxHistoryItems: Int {
return Defaults[.clipboardHistorySize]
}
// Directory for storing clipboard data files
static let clipboardDataDirectory: URL = {
let documentsPath = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!
let clipboardDir = documentsPath.appendingPathComponent("ClipboardData")
// Create directory if it doesn't exist
try? FileManager.default.createDirectory(at: clipboardDir, withIntermediateDirectories: true)
return clipboardDir
}()
private init() {
lastChangeCount = NSPasteboard.general.changeCount
loadHistoryFromDefaults()
cleanupOldFiles()
}
deinit {
stopMonitoring()
}
// MARK: - Public Methods
func startMonitoring() {
guard !isMonitoring else { return }
isMonitoring = true
timer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { [weak self] _ in
self?.checkClipboard()
}
}
func stopMonitoring() {
isMonitoring = false
timer?.invalidate()
timer = nil
}
func copyToClipboard(_ item: ClipboardItem) {
let pasteboard = NSPasteboard.general
pasteboard.clearContents()
switch item.type {
case .text, .url:
if let stringData = item.stringData {
pasteboard.setString(stringData, forType: .string)
}
case .image:
if let imageData = item.getImageData() {
pasteboard.setData(imageData, forType: .png)
}
case .file:
if let fileURLs = item.fileURLs {
let urls = fileURLs.compactMap { URL(string: $0) }
pasteboard.writeObjects(urls as [NSPasteboardWriting])
}
case .rtf:
if let rtfData = item.rtfData {
pasteboard.setData(rtfData, forType: .rtf)
}
// Also set plain text as fallback
if let stringData = item.stringData {
pasteboard.setString(stringData, forType: .string)
}
case .unknown:
if let stringData = item.stringData {
pasteboard.setString(stringData, forType: .string)
}
}
}
func deleteItem(_ item: ClipboardItem) {
// Clean up associated files
if let fileName = item.imageFileName {
let fileURL = ClipboardManager.clipboardDataDirectory.appendingPathComponent(fileName)
try? FileManager.default.removeItem(at: fileURL)
}
clipboardHistory.removeAll { $0.id == item.id }
saveHistoryToDefaults()
}
func clearHistory() {
// Clean up all associated files
for item in clipboardHistory {
if let fileName = item.imageFileName {
let fileURL = ClipboardManager.clipboardDataDirectory.appendingPathComponent(fileName)
try? FileManager.default.removeItem(at: fileURL)
}
}
clipboardHistory.removeAll()
saveHistoryToDefaults()
}
// MARK: - Private Methods
private func checkClipboard() {
let currentChangeCount = NSPasteboard.general.changeCount
guard currentChangeCount != lastChangeCount else { return }
lastChangeCount = currentChangeCount
guard let clipboardItem = getCurrentClipboardItem() else { return }
// Don't add duplicate items
if !clipboardHistory.contains(where: { isSameContent($0, clipboardItem) }) {
addToHistory(clipboardItem)
}
}
private func getCurrentClipboardItem() -> ClipboardItem? {
let pasteboard = NSPasteboard.general
// Step 1: Check what types are available
let hasFileURLs = pasteboard.canReadObject(forClasses: [NSURL.self], options: nil)
let hasImageData = pasteboard.data(forType: .png) != nil ||
pasteboard.data(forType: .tiff) != nil ||
pasteboard.data(forType: NSPasteboard.PasteboardType("public.jpeg")) != nil
let hasString = pasteboard.string(forType: .string) != nil
// Step 2: Smart detection based on context
// Priority 1: If there are file URLs AND the files are actual image files, treat as image files (from Finder)
if hasFileURLs {
if let fileURLs = pasteboard.readObjects(forClasses: [NSURL.self], options: nil) as? [URL] {
let realFileURLs = fileURLs.filter { url in
return url.isFileURL &&
!url.path.contains("/.file/id=") &&
!url.path.contains("/tmp/") &&
!url.path.hasPrefix("/private/var/") &&
!url.path.contains("/ClipboardViewer") &&
FileManager.default.fileExists(atPath: url.path)
}
if !realFileURLs.isEmpty {
// Check if these are image files - if so, try to load the actual image data
let imageExtensions = ["jpg", "jpeg", "png", "gif", "bmp", "tiff", "tif", "webp", "heic", "heif"]
let imageFiles = realFileURLs.filter { url in
imageExtensions.contains(url.pathExtension.lowercased())
}
// If we have image files from Finder, load the actual image data
if !imageFiles.isEmpty, let firstImageURL = imageFiles.first {
if let imageData = try? Data(contentsOf: firstImageURL) {
return ClipboardItem(imageData: imageData)
}
}
// Otherwise, treat as file(s)
let urlStrings = realFileURLs.map { $0.absoluteString }
return ClipboardItem(fileURLs: urlStrings)
}
}
}
// Priority 2: If there's ONLY image data without file URLs (screenshots, direct image paste)
if hasImageData && !hasFileURLs {
if let imageData = pasteboard.data(forType: .png) {
return ClipboardItem(imageData: imageData)
} else if let imageData = pasteboard.data(forType: .tiff) {
return ClipboardItem(imageData: imageData)
} else if let imageData = pasteboard.data(forType: NSPasteboard.PasteboardType("public.jpeg")) {
return ClipboardItem(imageData: imageData)
}
}
// Priority 3: Plain text (including copied text)
if let string = pasteboard.string(forType: .string), !string.isEmpty {
// Determine if it's a URL
if string.hasPrefix("http://") || string.hasPrefix("https://") {
return ClipboardItem(stringData: string, type: .url)
}
return ClipboardItem(stringData: string, type: .text)
}
// Priority 4: RTF
if let rtfData = pasteboard.data(forType: .rtf),
let rtfString = NSAttributedString(rtf: rtfData, documentAttributes: nil)?.string, !rtfString.isEmpty {
return ClipboardItem(rtfData: rtfData, plainText: rtfString)
}
// Priority 5: If we have image data WITH file URLs (document thumbnails),
// this is likely a document with a preview - ignore the thumbnail and treat as unknown
if hasImageData && hasFileURLs {
// This is likely a document with a thumbnail preview - we don't want the thumbnail
return nil
}
// Priority 6: URL strings
if let url = pasteboard.string(forType: .URL) {
return ClipboardItem(stringData: url, type: .url)
}
return nil
}
private func addToHistory(_ item: ClipboardItem) {
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
// Remove any existing items with the same data
let itemsToRemove = self.clipboardHistory.filter { existingItem in
return self.isSameContent(existingItem, item)
}
// Clean up files for items being removed
for oldItem in itemsToRemove {
if let fileName = oldItem.imageFileName {
let fileURL = ClipboardManager.clipboardDataDirectory.appendingPathComponent(fileName)
try? FileManager.default.removeItem(at: fileURL)
}
}
self.clipboardHistory.removeAll { existingItem in
return self.isSameContent(existingItem, item)
}
// Add to beginning of array
self.clipboardHistory.insert(item, at: 0)
// Keep only the most recent items and clean up old files
let itemsToDelete = Array(self.clipboardHistory.dropFirst(self.maxHistoryItems))
for oldItem in itemsToDelete {
if let fileName = oldItem.imageFileName {
let fileURL = ClipboardManager.clipboardDataDirectory.appendingPathComponent(fileName)
try? FileManager.default.removeItem(at: fileURL)
}
}
if self.clipboardHistory.count > self.maxHistoryItems {
self.clipboardHistory = Array(self.clipboardHistory.prefix(self.maxHistoryItems))
}
self.saveHistoryToDefaults()
}
}
// Helper to compare clipboard items for duplicates
private func isSameContent(_ item1: ClipboardItem, _ item2: ClipboardItem) -> Bool {
if item1.type != item2.type { return false }
switch item1.type {
case .text, .url, .unknown:
return item1.stringData == item2.stringData
case .image:
// For images, compare the actual data if both are available
let data1 = item1.getImageData()
let data2 = item2.getImageData()
return data1 == data2
case .file:
return item1.fileURLs == item2.fileURLs
case .rtf:
return item1.stringData == item2.stringData && item1.rtfData == item2.rtfData
}
}
// Clean up old image files that are no longer referenced
private func cleanupOldFiles() {
guard let files = try? FileManager.default.contentsOfDirectory(at: ClipboardManager.clipboardDataDirectory, includingPropertiesForKeys: nil) else { return }
let referencedFiles = Set(clipboardHistory.compactMap { $0.imageFileName })
for file in files {
let fileName = file.lastPathComponent
if !referencedFiles.contains(fileName) {
try? FileManager.default.removeItem(at: file)
}
}
}
// MARK: - Persistence
private func saveHistoryToDefaults() {
if let encoded = try? JSONEncoder().encode(clipboardHistory) {
UserDefaults.standard.set(encoded, forKey: "ClipboardHistory")
}
}
private func loadHistoryFromDefaults() {
if let data = UserDefaults.standard.data(forKey: "ClipboardHistory"),
let history = try? JSONDecoder().decode([ClipboardItem].self, from: data) {
clipboardHistory = history
}
}
}

View file

@ -3,7 +3,7 @@
// DynamicIsland // DynamicIsland
// //
// System performance monitoring for the Dynamic Island Stats feature // System performance monitoring for the Dynamic Island Stats feature
// // Created by Hariharan Mudaliar
import Foundation import Foundation
import Combine import Combine

View file

@ -3,7 +3,7 @@
// DynamicIsland // DynamicIsland
// //
// Created by Richard Kunkli on 2024. 10. 17.. // Created by Richard Kunkli on 2024. 10. 17..
// // Modified by Hariharan Mudaliar
import SwiftUI import SwiftUI
import Defaults import Defaults
@ -157,6 +157,11 @@ extension Defaults.Keys {
// MARK: Stats Feature // MARK: Stats Feature
static let enableStatsFeature = Key<Bool>("enableStatsFeature", default: false) static let enableStatsFeature = Key<Bool>("enableStatsFeature", default: false)
// MARK: Clipboard Feature
static let enableClipboardManager = Key<Bool>("enableClipboardManager", default: true)
static let clipboardHistorySize = Key<Int>("clipboardHistorySize", default: 3)
static let showClipboardIcon = Key<Bool>("showClipboardIcon", default: true)
// Helper to determine the default media controller based on macOS version // Helper to determine the default media controller based on macOS version
static var defaultMediaController: MediaControllerType { static var defaultMediaController: MediaControllerType {
if #available(macOS 15.4, *) { if #available(macOS 15.4, *) {

View file

@ -29,6 +29,7 @@ class DynamicIslandViewModel: NSObject, ObservableObject {
@Published var hideOnClosed: Bool = true @Published var hideOnClosed: Bool = true
@Published var isHoveringCalendar: Bool = false @Published var isHoveringCalendar: Bool = false
@Published var isBatteryPopoverActive: Bool = false @Published var isBatteryPopoverActive: Bool = false
@Published var isClipboardPopoverActive: Bool = false
@Published var screen: String? @Published var screen: String?