├── .gitignore ├── ClipboardManager ├── ClipboardManager.xcodeproj │ ├── project.pbxproj │ ├── project.xcworkspace │ │ ├── contents.xcworkspacedata │ │ └── xcshareddata │ │ │ ├── WorkspaceSettings.xcsettings │ │ │ └── swiftpm │ │ │ └── Package.resolved │ └── xcshareddata │ │ └── xcschemes │ │ └── ClipboardManager.xcscheme └── ClipboardManager │ ├── Assets.xcassets │ ├── AccentColor.colorset │ │ └── Contents.json │ ├── AppIcon.appiconset │ │ ├── Contents.json │ │ ├── icon_1024.png │ │ ├── icon_128.png │ │ ├── icon_128@2x.png │ │ ├── icon_16.png │ │ ├── icon_16@2x.png │ │ ├── icon_256.png │ │ ├── icon_256@2x.png │ │ ├── icon_32.png │ │ ├── icon_32@2x.png │ │ ├── icon_512.png │ │ └── icon_512@2x.png │ └── Contents.json │ ├── ClipboardManager.entitlements │ ├── ClipboardManagerApp.swift │ ├── DeleteConfirmationWindowController.swift │ ├── EffectView.swift │ ├── HotKeyManager.swift │ ├── Info.plist │ ├── Models.swift │ ├── Networking │ └── APIClient.swift │ ├── PanelView.swift │ ├── Preview Content │ └── Preview Assets.xcassets │ │ └── Contents.json │ ├── Protocols.swift │ ├── Resources │ └── .gitkeep │ ├── SettingsHostingController.swift │ ├── SettingsView.swift │ ├── SettingsWindowController.swift │ ├── SoundManager.swift │ ├── Utils │ ├── Logger.swift │ └── SearchManager.swift │ └── Views │ ├── ClipboardHistoryView.swift │ ├── ClipboardItemContentView.swift │ ├── ClipboardItemView.swift │ ├── DeleteConfirmationView.swift │ ├── EmptyStateView.swift │ ├── LoadingView.swift │ ├── SearchBar.swift │ ├── SingleClipPanelView.swift │ ├── StatusView.swift │ └── ToastView.swift ├── INSTALL.md ├── LICENSE ├── README.md ├── cmd ├── clipboard-manager │ └── main.go └── test-history │ └── main.go ├── examples ├── cli │ └── search.go ├── clipboard_history.go ├── core_usage.go └── tui │ └── main.go ├── generate_icons.js ├── go.mod ├── go.sum ├── icon_generator.html ├── images ├── app.png ├── cover.png └── search.png ├── internal ├── clipboard │ ├── monitor.go │ └── monitor_darwin.go ├── obsidian │ └── sync.go ├── server │ ├── pid.go │ ├── server.go │ └── websocket.go ├── service │ ├── clipboard_service.go │ └── handler.go └── storage │ ├── constants.go │ ├── models.go │ ├── search.go │ ├── sqlite │ ├── OPTIMIZATIONS.md │ ├── search.go │ ├── sqlite.go │ ├── sqlite_benchmark_test.go │ └── sqlite_test.go │ └── storage.go ├── package-lock.json ├── package.json ├── pkg └── types │ └── clip.go ├── releases └── ClipboardManager.v.1.2 │ └── ClipboardManager.app │ └── Contents │ ├── Info.plist │ ├── MacOS │ └── ClipboardManager │ ├── PkgInfo │ ├── Resources │ ├── AppIcon.icns │ ├── Assets.car │ └── Info.plist │ └── _CodeSignature │ └── CodeResources └── rockstar.rb /.gitignore: -------------------------------------------------------------------------------- 1 | # Already in your .gitignore 2 | .idea 3 | bin 4 | 5 | # Build artifacts 6 | **/clipboard-manager 7 | **/clipboard-manager-bin 8 | **/build/ 9 | 10 | # Go specific 11 | *.exe 12 | *.test 13 | *.out 14 | 15 | # macOS system files 16 | .DS_Store 17 | .AppleDouble 18 | .LSOverride 19 | Icon? 20 | ._* 21 | 22 | # Xcode 23 | xcuserdata/ 24 | *.xcuserstate 25 | DerivedData/ 26 | *.moved-aside 27 | *.xccheckout 28 | *.xcscmblueprint 29 | *.hmap 30 | *.ipa 31 | *.dSYM.zip 32 | *.dSYM 33 | *.xcuserstate 34 | *.xcworkspace/xcuserdata/ 35 | 36 | # Swift Package Manager 37 | .build/ 38 | .swiftpm/ 39 | 40 | # IDE - VSCode 41 | .vscode/ 42 | *.code-workspace 43 | 44 | # Temporary files 45 | *.tmp 46 | *.temp 47 | *.swp 48 | *~ 49 | 50 | node_modules 51 | *.dmg 52 | -------------------------------------------------------------------------------- /ClipboardManager/ClipboardManager.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /ClipboardManager/ClipboardManager.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /ClipboardManager/ClipboardManager.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "originHash" : "01f286328852d165d87f8fbee99b01f9c2c936f57ac234f76396f74f9d2496dd", 3 | "pins" : [ 4 | { 5 | "identity" : "hotkey", 6 | "kind" : "remoteSourceControl", 7 | "location" : "https://github.com/soffes/HotKey", 8 | "state" : { 9 | "branch" : "main", 10 | "revision" : "4d02d80de143d69b7eeb5962729591a157a57ede" 11 | } 12 | } 13 | ], 14 | "version" : 3 15 | } 16 | -------------------------------------------------------------------------------- /ClipboardManager/ClipboardManager.xcodeproj/xcshareddata/xcschemes/ClipboardManager.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 9 | 10 | 16 | 22 | 23 | 24 | 25 | 26 | 32 | 33 | 36 | 42 | 43 | 44 | 47 | 53 | 54 | 55 | 56 | 57 | 67 | 69 | 75 | 76 | 77 | 78 | 84 | 86 | 92 | 93 | 94 | 95 | 97 | 98 | 101 | 102 | 103 | -------------------------------------------------------------------------------- /ClipboardManager/ClipboardManager/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /ClipboardManager/ClipboardManager/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "icon_16.png", 5 | "idiom" : "mac", 6 | "scale" : "1x", 7 | "size" : "16x16" 8 | }, 9 | { 10 | "filename" : "icon_32.png", 11 | "idiom" : "mac", 12 | "scale" : "2x", 13 | "size" : "16x16" 14 | }, 15 | { 16 | "filename" : "icon_32.png", 17 | "idiom" : "mac", 18 | "scale" : "1x", 19 | "size" : "32x32" 20 | }, 21 | { 22 | "filename" : "icon_32@2x.png", 23 | "idiom" : "mac", 24 | "scale" : "2x", 25 | "size" : "32x32" 26 | }, 27 | { 28 | "filename" : "icon_128.png", 29 | "idiom" : "mac", 30 | "scale" : "1x", 31 | "size" : "128x128" 32 | }, 33 | { 34 | "filename" : "icon_256.png", 35 | "idiom" : "mac", 36 | "scale" : "2x", 37 | "size" : "128x128" 38 | }, 39 | { 40 | "filename" : "icon_256.png", 41 | "idiom" : "mac", 42 | "scale" : "1x", 43 | "size" : "256x256" 44 | }, 45 | { 46 | "filename" : "icon_512.png", 47 | "idiom" : "mac", 48 | "scale" : "2x", 49 | "size" : "256x256" 50 | }, 51 | { 52 | "filename" : "icon_512.png", 53 | "idiom" : "mac", 54 | "scale" : "1x", 55 | "size" : "512x512" 56 | }, 57 | { 58 | "filename" : "icon_1024.png", 59 | "idiom" : "mac", 60 | "scale" : "2x", 61 | "size" : "512x512" 62 | } 63 | ], 64 | "info" : { 65 | "author" : "xcode", 66 | "version" : 1 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /ClipboardManager/ClipboardManager/Assets.xcassets/AppIcon.appiconset/icon_1024.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hp77-creator/rockstar/6d91a69b6b9f560b7e50c732ed7bbdef0788cfd6/ClipboardManager/ClipboardManager/Assets.xcassets/AppIcon.appiconset/icon_1024.png -------------------------------------------------------------------------------- /ClipboardManager/ClipboardManager/Assets.xcassets/AppIcon.appiconset/icon_128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hp77-creator/rockstar/6d91a69b6b9f560b7e50c732ed7bbdef0788cfd6/ClipboardManager/ClipboardManager/Assets.xcassets/AppIcon.appiconset/icon_128.png -------------------------------------------------------------------------------- /ClipboardManager/ClipboardManager/Assets.xcassets/AppIcon.appiconset/icon_128@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hp77-creator/rockstar/6d91a69b6b9f560b7e50c732ed7bbdef0788cfd6/ClipboardManager/ClipboardManager/Assets.xcassets/AppIcon.appiconset/icon_128@2x.png -------------------------------------------------------------------------------- /ClipboardManager/ClipboardManager/Assets.xcassets/AppIcon.appiconset/icon_16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hp77-creator/rockstar/6d91a69b6b9f560b7e50c732ed7bbdef0788cfd6/ClipboardManager/ClipboardManager/Assets.xcassets/AppIcon.appiconset/icon_16.png -------------------------------------------------------------------------------- /ClipboardManager/ClipboardManager/Assets.xcassets/AppIcon.appiconset/icon_16@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hp77-creator/rockstar/6d91a69b6b9f560b7e50c732ed7bbdef0788cfd6/ClipboardManager/ClipboardManager/Assets.xcassets/AppIcon.appiconset/icon_16@2x.png -------------------------------------------------------------------------------- /ClipboardManager/ClipboardManager/Assets.xcassets/AppIcon.appiconset/icon_256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hp77-creator/rockstar/6d91a69b6b9f560b7e50c732ed7bbdef0788cfd6/ClipboardManager/ClipboardManager/Assets.xcassets/AppIcon.appiconset/icon_256.png -------------------------------------------------------------------------------- /ClipboardManager/ClipboardManager/Assets.xcassets/AppIcon.appiconset/icon_256@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hp77-creator/rockstar/6d91a69b6b9f560b7e50c732ed7bbdef0788cfd6/ClipboardManager/ClipboardManager/Assets.xcassets/AppIcon.appiconset/icon_256@2x.png -------------------------------------------------------------------------------- /ClipboardManager/ClipboardManager/Assets.xcassets/AppIcon.appiconset/icon_32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hp77-creator/rockstar/6d91a69b6b9f560b7e50c732ed7bbdef0788cfd6/ClipboardManager/ClipboardManager/Assets.xcassets/AppIcon.appiconset/icon_32.png -------------------------------------------------------------------------------- /ClipboardManager/ClipboardManager/Assets.xcassets/AppIcon.appiconset/icon_32@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hp77-creator/rockstar/6d91a69b6b9f560b7e50c732ed7bbdef0788cfd6/ClipboardManager/ClipboardManager/Assets.xcassets/AppIcon.appiconset/icon_32@2x.png -------------------------------------------------------------------------------- /ClipboardManager/ClipboardManager/Assets.xcassets/AppIcon.appiconset/icon_512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hp77-creator/rockstar/6d91a69b6b9f560b7e50c732ed7bbdef0788cfd6/ClipboardManager/ClipboardManager/Assets.xcassets/AppIcon.appiconset/icon_512.png -------------------------------------------------------------------------------- /ClipboardManager/ClipboardManager/Assets.xcassets/AppIcon.appiconset/icon_512@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hp77-creator/rockstar/6d91a69b6b9f560b7e50c732ed7bbdef0788cfd6/ClipboardManager/ClipboardManager/Assets.xcassets/AppIcon.appiconset/icon_512@2x.png -------------------------------------------------------------------------------- /ClipboardManager/ClipboardManager/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /ClipboardManager/ClipboardManager/ClipboardManager.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.app-sandbox 6 | 7 | com.apple.security.network.client 8 | 9 | com.apple.security.network.server 10 | 11 | com.apple.security.temporary-exception.files.absolute-path.read-write 12 | 13 | / 14 | 15 | com.apple.security.temporary-exception.apple-events 16 | 17 | com.apple.systemevents 18 | 19 | com.apple.security.automation.apple-events 20 | 21 | com.apple.security.temporary-exception.accessibility 22 | 23 | com.apple.security.accessibility 24 | 25 | com.apple.security.cs.allow-jit 26 | 27 | com.apple.security.cs.allow-unsigned-executable-memory 28 | 29 | com.apple.security.cs.disable-library-validation 30 | 31 | com.apple.security.cs.allow-dyld-environment-variables 32 | 33 | com.apple.security.get-task-allow 34 | 35 | com.apple.security.application-groups 36 | 37 | com.hp77.ClipboardManager 38 | 39 | com.apple.security.inherit 40 | 41 | com.apple.security.files.user-selected.read-write 42 | 43 | com.apple.security.files.bookmarks.app-scope 44 | 45 | com.apple.security.files.downloads.read-write 46 | 47 | com.apple.security.files.user-selected.executable 48 | 49 | 50 | 51 | -------------------------------------------------------------------------------- /ClipboardManager/ClipboardManager/ClipboardManagerApp.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import AppKit 3 | 4 | // Coordinator to handle app lifecycle and state 5 | class AppCoordinator: ObservableObject { 6 | private var observers: [NSObjectProtocol] = [] 7 | private var hotKeyManager: HotKeyManager? 8 | private var appState: AppState? 9 | 10 | deinit { 11 | observers.forEach { NotificationCenter.default.removeObserver($0) } 12 | } 13 | 14 | func setup(appState: AppState, hotKeyManager: HotKeyManager) { 15 | Logger.debug("AppCoordinator setup starting") 16 | self.hotKeyManager = hotKeyManager 17 | self.appState = appState 18 | 19 | // Set up termination notification observer 20 | let terminationObserver = NotificationCenter.default.addObserver( 21 | forName: NSApplication.willTerminateNotification, 22 | object: nil, 23 | queue: .main 24 | ) { [weak appState] _ in 25 | Logger.debug("Application will terminate, cleaning up...") 26 | hotKeyManager.unregister() 27 | appState?.cleanup() 28 | } 29 | 30 | // Store observers for cleanup 31 | observers.append(terminationObserver) 32 | 33 | // Register hotkey 34 | hotKeyManager.register(appState: appState) 35 | } 36 | } 37 | 38 | class AppDelegate: NSObject, NSApplicationDelegate { 39 | var coordinator: AppCoordinator? 40 | 41 | func applicationDidFinishLaunching(_ notification: Notification) { 42 | Logger.debug("App launching") 43 | 44 | // Set as regular app first to ensure proper event handling 45 | NSApp.setActivationPolicy(.regular) 46 | 47 | // Enable background operation 48 | NSApp.activate(ignoringOtherApps: true) 49 | 50 | // Wait a bit to ensure event handling is set up 51 | DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { 52 | // Then switch to accessory mode (menu bar app without dock icon) 53 | NSApp.setActivationPolicy(.accessory) 54 | 55 | Logger.debug("App activation policy set to accessory") 56 | Logger.debug("App activation state: \(NSApp.isActive)") 57 | Logger.debug("App responds to events: \(NSApp.isRunning)") 58 | } 59 | } 60 | 61 | func applicationDidBecomeActive(_ notification: Notification) { 62 | Logger.debug("App did become active") 63 | } 64 | 65 | func applicationDidResignActive(_ notification: Notification) { 66 | Logger.debug("App did resign active") 67 | } 68 | } 69 | 70 | @main 71 | struct ClipboardManagerApp: App { 72 | @StateObject private var appState = AppState() 73 | @StateObject private var hotKeyManager = HotKeyManager.shared 74 | @StateObject private var coordinator = AppCoordinator() 75 | @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate 76 | @State private var showingSettings = false 77 | 78 | init() { 79 | Logger.debug("ClipboardManagerApp initializing...") 80 | 81 | // Disable accessibility logs in Release builds 82 | #if !DEBUG 83 | UserDefaults.standard.set(false, forKey: "_UIAccessibilityEnabled") 84 | UserDefaults.standard.set(false, forKey: "UIAccessibilityEnabled") 85 | #endif 86 | } 87 | 88 | var body: some Scene { 89 | MenuBarExtra("Clipboard Manager", systemImage: "clipboard") { 90 | VStack(spacing: 8) { 91 | if !hotKeyManager.hasAccessibilityPermissions { 92 | VStack(spacing: 8) { 93 | Image(systemName: "keyboard") 94 | .font(.largeTitle) 95 | .foregroundColor(.orange) 96 | 97 | Text("Keyboard Shortcuts Disabled") 98 | .font(.headline) 99 | 100 | Text("Grant accessibility access to use Cmd+Shift+V shortcut") 101 | .font(.caption) 102 | .foregroundColor(.secondary) 103 | .multilineTextAlignment(.center) 104 | 105 | Button("Open System Settings") { 106 | if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility") { 107 | NSWorkspace.shared.open(url) 108 | } 109 | } 110 | .buttonStyle(.borderedProminent) 111 | 112 | Button("Force Permission Check") { 113 | self.hotKeyManager.forcePermissionCheck() 114 | } 115 | .buttonStyle(.borderless) 116 | .foregroundColor(.blue) 117 | 118 | Text("Look for 'ClipboardManager' in\nPrivacy & Security > Accessibility") 119 | .font(.caption2) 120 | .foregroundColor(.secondary) 121 | .multilineTextAlignment(.center) 122 | } 123 | .frame(maxWidth: .infinity) 124 | .padding() 125 | .background(Color(.windowBackgroundColor)) 126 | .cornerRadius(8) 127 | } 128 | 129 | if appState.isDebugMode { 130 | VStack(spacing: 4) { 131 | Text("Debug Info") 132 | .font(.caption) 133 | .foregroundColor(.secondary) 134 | 135 | Text("Accessibility: \(hotKeyManager.hasAccessibilityPermissions ? "✅" : "❌")") 136 | .font(.caption) 137 | .foregroundColor(hotKeyManager.hasAccessibilityPermissions ? .green : .red) 138 | 139 | Text("HotKey: \(hotKeyManager.isRegistered ? "✅" : "❌")") 140 | .font(.caption) 141 | .foregroundColor(hotKeyManager.isRegistered ? .green : .red) 142 | 143 | Text("Bundle ID: \(Bundle.main.bundleIdentifier ?? "unknown")") 144 | .font(.caption) 145 | .foregroundColor(.secondary) 146 | } 147 | .padding(.vertical, 4) 148 | } 149 | 150 | 151 | if appState.isLoading { 152 | ProgressView("Starting service...") 153 | .progressViewStyle(.circular) 154 | .scaleEffect(0.8) 155 | } else if let error = appState.error { 156 | VStack(spacing: 4) { 157 | Text("Error: \(error)") 158 | .foregroundColor(.red) 159 | .font(.caption) 160 | .multilineTextAlignment(.center) 161 | 162 | Button("Retry Connection (⌘R)") { 163 | self.appState.startGoService() 164 | } 165 | .buttonStyle(.borderless) 166 | .foregroundColor(.blue) 167 | .keyboardShortcut("r") 168 | } 169 | .padding(.horizontal) 170 | } 171 | 172 | if appState.isDebugMode { 173 | HStack { 174 | Circle() 175 | .fill(appState.isServiceRunning ? Color.green : Color.red) 176 | .frame(width: 8, height: 8) 177 | Text(appState.isServiceRunning ? "Service Running" : "Service Stopped") 178 | .font(.caption) 179 | .foregroundColor(.secondary) 180 | } 181 | 182 | Text("Clips count: \(appState.clips.count)") 183 | .font(.caption) 184 | .foregroundColor(.secondary) 185 | } 186 | 187 | ClipboardHistoryView() 188 | .environmentObject(appState) 189 | 190 | Divider() 191 | 192 | Button(action: { 193 | NSApp.activate(ignoringOtherApps: true) 194 | SettingsWindowController.showSettings() 195 | }) { 196 | HStack { 197 | Image(systemName: "gear") 198 | Text("Settings") 199 | } 200 | } 201 | .buttonStyle(.borderless) 202 | .foregroundColor(.secondary) 203 | 204 | Divider() 205 | 206 | VStack(spacing: 2) { 207 | Button("Quit Clipboard Manager (⌘Q)") { 208 | Logger.debug("Quit button pressed") 209 | self.appState.cleanup() 210 | NSApplication.shared.terminate(nil) 211 | } 212 | .buttonStyle(.borderless) 213 | .foregroundColor(.secondary) 214 | .keyboardShortcut("q") 215 | 216 | if let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String { 217 | Text("Version \(version)") 218 | .font(.caption2) 219 | .foregroundColor(.secondary) 220 | } 221 | } 222 | .padding(.vertical, 4) 223 | } 224 | .frame(width: 300) 225 | .task { 226 | // Setup coordinator when view appears 227 | coordinator.setup(appState: appState, hotKeyManager: hotKeyManager) 228 | appDelegate.coordinator = coordinator 229 | } 230 | } 231 | .menuBarExtraStyle(.window) 232 | } 233 | } 234 | -------------------------------------------------------------------------------- /ClipboardManager/ClipboardManager/DeleteConfirmationWindowController.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import AppKit 3 | 4 | class DeleteConfirmationWindowController: NSWindowController, NSWindowDelegate { 5 | private var onDelete: () -> Void = {} 6 | private var eventMonitor: Any? 7 | 8 | convenience init(clipToDelete: ClipboardItem, onDelete: @escaping () -> Void) { 9 | self.init(window: nil) 10 | self.onDelete = onDelete 11 | 12 | let rootView = DeleteConfirmationView( 13 | clipToDelete: clipToDelete, 14 | onDelete: { [weak self] in 15 | onDelete() 16 | self?.close() 17 | }, 18 | onCancel: { [weak self] in 19 | self?.close() 20 | } 21 | ) 22 | 23 | let controller = NSHostingController(rootView: rootView) 24 | let window = NSWindow( 25 | contentRect: NSRect(x: 0, y: 0, width: 300, height: 120), 26 | styleMask: [.titled, .closable, .fullSizeContentView, .hudWindow], 27 | backing: .buffered, 28 | defer: false 29 | ) 30 | 31 | window.contentViewController = controller 32 | window.title = "Delete Confirmation" 33 | window.titlebarAppearsTransparent = true 34 | window.center() 35 | window.setFrameAutosaveName("DeleteConfirmationWindow") 36 | window.isMovable = true 37 | window.isMovableByWindowBackground = true 38 | window.backgroundColor = NSColor.windowBackgroundColor.withAlphaComponent(0.98) 39 | window.isReleasedWhenClosed = false 40 | window.level = .modalPanel 41 | window.titleVisibility = .hidden 42 | window.isOpaque = false 43 | window.hasShadow = true 44 | window.standardWindowButton(.closeButton)?.isHidden = true 45 | 46 | // Handle escape key 47 | eventMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in 48 | if event.keyCode == 53 { // Escape key 49 | self?.close() 50 | return nil 51 | } 52 | return event 53 | } 54 | self.window = window 55 | window.delegate = self 56 | } 57 | 58 | private static var shared: DeleteConfirmationWindowController? 59 | 60 | static func showDeleteConfirmation(for clip: ClipboardItem, onDelete: @escaping () -> Void) { 61 | // Always create a new instance to ensure fresh state 62 | shared = DeleteConfirmationWindowController(clipToDelete: clip, onDelete: onDelete) 63 | shared?.showWindow(nil) 64 | shared?.window?.makeKeyAndOrderFront(nil) 65 | 66 | // Center on active screen 67 | if let window = shared?.window, let screen = NSScreen.main { 68 | let screenRect = screen.visibleFrame 69 | let newOrigin = NSPoint( 70 | x: screenRect.midX - window.frame.width / 2, 71 | y: screenRect.midY - window.frame.height / 2 72 | ) 73 | window.setFrameOrigin(newOrigin) 74 | } 75 | } 76 | 77 | func windowWillClose(_ notification: Notification) { 78 | if let monitor = eventMonitor { 79 | NSEvent.removeMonitor(monitor) 80 | eventMonitor = nil 81 | } 82 | Self.shared = nil 83 | } 84 | 85 | required init?(coder: NSCoder) { 86 | super.init(coder: coder) 87 | } 88 | 89 | override init(window: NSWindow?) { 90 | super.init(window: window) 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /ClipboardManager/ClipboardManager/EffectView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import AppKit 3 | 4 | struct EffectView: NSViewRepresentable { 5 | let material: NSVisualEffectView.Material 6 | 7 | init(_ material: NSVisualEffectView.Material) { 8 | self.material = material 9 | } 10 | 11 | func makeNSView(context: Context) -> NSVisualEffectView { 12 | let view = NSVisualEffectView() 13 | view.material = material 14 | view.blendingMode = .behindWindow 15 | view.state = .active 16 | return view 17 | } 18 | 19 | func updateNSView(_ nsView: NSVisualEffectView, context: Context) { 20 | nsView.material = material 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /ClipboardManager/ClipboardManager/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIconFile 10 | AppIcon 11 | CFBundleIconName 12 | AppIcon 13 | CFBundleIdentifier 14 | com.hp77.ClipboardManager 15 | CFBundleInfoDictionaryVersion 16 | 6.0 17 | CFBundleName 18 | ClipboardManager 19 | CFBundleDisplayName 20 | ClipboardManager 21 | CFBundlePackageType 22 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 23 | CFBundleShortVersionString 24 | 0.1.1 25 | CFBundleVersion 26 | 1 27 | LSMinimumSystemVersion 28 | 13.0 29 | LSApplicationCategoryType 30 | public.app-category.utilities 31 | LSUIElement 32 | 33 | NSHighResolutionCapable 34 | 35 | NSPrincipalClass 36 | NSApplication 37 | NSAppleEventsUsageDescription 38 | This app needs to access clipboard data 39 | NSPasteboardUsageDescription 40 | This app needs to access clipboard data 41 | NSAccessibilityUsageDescription 42 | This app needs accessibility access to detect keyboard shortcuts (Cmd+Shift+V) 43 | NSLocalNetworkUsageDescription 44 | This app needs to communicate with the local clipboard service 45 | NSBonjourServices 46 | 47 | _clipboard-manager._tcp 48 | 49 | NSAppTransportSecurity 50 | 51 | NSAllowsLocalNetworking 52 | 53 | 54 | NSAppleEventsEnabled 55 | 56 | NSSystemAdministrationUsageDescription 57 | This app needs accessibility permissions to detect keyboard shortcuts 58 | NSRequiresAquaSystemAppearance 59 | 60 | NSSupportsAutomaticGraphicsSwitching 61 | 62 | 63 | 64 | -------------------------------------------------------------------------------- /ClipboardManager/ClipboardManager/PanelView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import AppKit 3 | 4 | class ClipboardPanel: NSPanel { 5 | init(contentRect: NSRect) { 6 | super.init( 7 | contentRect: contentRect, 8 | styleMask: [.nonactivatingPanel, .titled, .resizable, .closable], 9 | backing: .buffered, 10 | defer: false 11 | ) 12 | 13 | self.level = .floating 14 | self.isFloatingPanel = true 15 | self.titlebarAppearsTransparent = true 16 | self.titleVisibility = .hidden 17 | self.isMovableByWindowBackground = true 18 | self.backgroundColor = .clear 19 | 20 | // Close panel when it loses focus 21 | self.hidesOnDeactivate = true 22 | } 23 | 24 | // Override to enable clicking through the panel 25 | override var canBecomeKey: Bool { 26 | return true 27 | } 28 | 29 | override var canBecomeMain: Bool { 30 | return true 31 | } 32 | } 33 | 34 | struct PanelView: View { 35 | @EnvironmentObject private var appState: AppState 36 | @Environment(\.colorScheme) private var colorScheme 37 | @State private var selectedIndex = 0 38 | @State private var showingSettings = false 39 | @State private var autopasteTimer: Timer? 40 | 41 | private func resetAutoPasteTimer() { 42 | autopasteTimer?.invalidate() 43 | autopasteTimer = Timer.scheduledTimer(withTimeInterval: 5.0, repeats: false) { _ in 44 | if !appState.clips.isEmpty { 45 | Task { 46 | do { 47 | try await appState.pasteClip(at: selectedIndex) 48 | DispatchQueue.main.async { 49 | PanelWindowManager.hidePanel() 50 | } 51 | } catch { 52 | print("Failed to paste clip: \(error)") 53 | } 54 | } 55 | } 56 | } 57 | } 58 | 59 | var body: some View { 60 | VStack(spacing: 0) { 61 | HStack { 62 | Spacer() 63 | Button(action: { showingSettings.toggle() }) { 64 | Image(systemName: "gear") 65 | .foregroundColor(.secondary) 66 | } 67 | .buttonStyle(.plain) 68 | .padding(.trailing, 8) 69 | .padding(.top, 8) 70 | } 71 | ClipboardHistoryView(isInPanel: true, selectedIndex: $selectedIndex) 72 | .environmentObject(appState) 73 | } 74 | .frame(width: 300, height: 400) 75 | .background(colorScheme == .dark ? Color(NSColor.windowBackgroundColor) : .white) 76 | .cornerRadius(8) 77 | .shadow(radius: 5) 78 | .onAppear { 79 | // Reset selection when panel appears 80 | selectedIndex = 0 81 | resetAutoPasteTimer() 82 | 83 | NSEvent.addLocalMonitorForEvents(matching: .keyDown) { event in 84 | switch event.keyCode { 85 | case 123, 126: // Left arrow or Up arrow 86 | if !appState.clips.isEmpty { 87 | selectedIndex = max(selectedIndex - 1, 0) 88 | resetAutoPasteTimer() 89 | } 90 | return nil 91 | case 124, 125: // Right arrow or Down arrow 92 | if !appState.clips.isEmpty { 93 | selectedIndex = min(selectedIndex + 1, appState.clips.count - 1) 94 | resetAutoPasteTimer() 95 | } 96 | return nil 97 | case 36, 76: // Return key or numpad enter 98 | if !appState.clips.isEmpty { 99 | Task { 100 | do { 101 | try await appState.pasteClip(at: selectedIndex) 102 | DispatchQueue.main.async { 103 | PanelWindowManager.hidePanel() 104 | } 105 | } catch { 106 | print("Failed to paste clip: \(error)") 107 | } 108 | } 109 | } 110 | return nil 111 | case 53: // Escape key 112 | PanelWindowManager.hidePanel() 113 | return nil 114 | default: 115 | return event 116 | } 117 | } 118 | } 119 | .onDisappear { 120 | autopasteTimer?.invalidate() 121 | autopasteTimer = nil 122 | } 123 | } 124 | } 125 | 126 | // Helper view to manage panel window 127 | struct PanelWindowManager { 128 | private static var panel: ClipboardPanel? 129 | 130 | static func showPanel(with appState: AppState) { 131 | DispatchQueue.main.async { 132 | if panel == nil { 133 | // Get the current mouse location 134 | let mouseLocation = NSEvent.mouseLocation 135 | let screen = NSScreen.screens.first { screen in 136 | screen.frame.contains(mouseLocation) 137 | } ?? NSScreen.main ?? NSScreen.screens.first! 138 | 139 | // Convert mouse location to screen coordinates 140 | let screenFrame = screen.visibleFrame 141 | 142 | // Calculate panel position 143 | let panelWidth: CGFloat = 300 144 | let panelHeight: CGFloat = 400 145 | 146 | // Start with mouse position 147 | var panelX = mouseLocation.x - panelWidth/2 148 | var panelY = mouseLocation.y - panelHeight - 10 // 10px below cursor 149 | 150 | // Ensure panel stays within screen bounds 151 | if panelX + panelWidth > screenFrame.maxX { 152 | panelX = screenFrame.maxX - panelWidth - 10 153 | } 154 | if panelX < screenFrame.minX { 155 | panelX = screenFrame.minX + 10 156 | } 157 | if panelY + panelHeight > screenFrame.maxY { 158 | panelY = screenFrame.maxY - panelHeight - 10 159 | } 160 | if panelY < screenFrame.minY { 161 | panelY = screenFrame.minY + 10 162 | } 163 | 164 | let panelRect = NSRect( 165 | x: panelX, 166 | y: panelY, 167 | width: panelWidth, 168 | height: panelHeight 169 | ) 170 | panel = ClipboardPanel(contentRect: panelRect) 171 | 172 | let hostingView = NSHostingView( 173 | rootView: PanelView() 174 | .environmentObject(appState) 175 | ) 176 | panel?.contentView = hostingView 177 | } 178 | 179 | panel?.makeKeyAndOrderFront(nil) 180 | NSApp.activate(ignoringOtherApps: true) 181 | } 182 | } 183 | 184 | static func hidePanel() { 185 | DispatchQueue.main.async { 186 | panel?.orderOut(nil) 187 | } 188 | } 189 | 190 | static func togglePanel(with appState: AppState) { 191 | DispatchQueue.main.async { 192 | if panel?.isVisible == true { 193 | hidePanel() 194 | } else { 195 | showPanel(with: appState) 196 | } 197 | } 198 | } 199 | } 200 | -------------------------------------------------------------------------------- /ClipboardManager/ClipboardManager/Preview Content/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /ClipboardManager/ClipboardManager/Protocols.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | protocol ClipboardUpdateDelegate: AnyObject { 4 | func didReceiveNewClip(_ clip: ClipboardItem) 5 | } 6 | -------------------------------------------------------------------------------- /ClipboardManager/ClipboardManager/Resources/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hp77-creator/rockstar/6d91a69b6b9f560b7e50c732ed7bbdef0788cfd6/ClipboardManager/ClipboardManager/Resources/.gitkeep -------------------------------------------------------------------------------- /ClipboardManager/ClipboardManager/SettingsHostingController.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import AppKit 3 | 4 | class SettingsHostingController: NSHostingController { 5 | override func loadView() { 6 | view = NSView() 7 | view.setFrameSize(NSSize(width: 350, height: 150)) 8 | } 9 | 10 | override func viewDidLoad() { 11 | super.viewDidLoad() 12 | 13 | if let window = view.window { 14 | window.title = "Settings" 15 | window.titlebarAppearsTransparent = true 16 | window.styleMask = [.titled, .closable] 17 | window.center() 18 | window.isMovableByWindowBackground = true 19 | window.backgroundColor = .windowBackgroundColor 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /ClipboardManager/ClipboardManager/SettingsWindowController.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import AppKit 3 | 4 | class SettingsWindowController: NSWindowController, NSWindowDelegate { 5 | convenience init(rootView: SettingsView) { 6 | let controller = NSHostingController(rootView: rootView) 7 | let window = NSWindow( 8 | contentRect: NSRect(x: 0, y: 0, width: 350, height: 150), 9 | styleMask: [.titled, .closable, .miniaturizable], 10 | backing: .buffered, 11 | defer: false 12 | ) 13 | window.contentViewController = controller 14 | window.title = "Settings" 15 | window.titlebarAppearsTransparent = true 16 | window.center() 17 | window.isMovable = true 18 | window.isMovableByWindowBackground = true 19 | window.backgroundColor = .windowBackgroundColor 20 | window.isReleasedWhenClosed = false 21 | window.level = .floating 22 | self.init(window: window) 23 | window.delegate = self 24 | } 25 | 26 | private static var shared: SettingsWindowController? 27 | 28 | static func showSettings() { 29 | if shared == nil { 30 | shared = SettingsWindowController(rootView: SettingsView()) 31 | } 32 | shared?.showWindow(nil) 33 | shared?.window?.makeKeyAndOrderFront(nil) 34 | } 35 | 36 | func windowWillClose(_ notification: Notification) { 37 | // Keep the controller in memory 38 | Self.shared = nil 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /ClipboardManager/ClipboardManager/SoundManager.swift: -------------------------------------------------------------------------------- 1 | import AppKit 2 | import Foundation 3 | 4 | // Available system sounds 5 | public enum SystemSound: String, CaseIterable { 6 | case tink = "Tink" 7 | case pop = "Pop" 8 | case glass = "Glass" 9 | case hero = "Hero" 10 | case purr = "Purr" 11 | 12 | public var displayName: String { 13 | switch self { 14 | case .tink: return "Tink (Light)" 15 | case .pop: return "Pop (Soft)" 16 | case .glass: return "Glass (Clear)" 17 | case .hero: return "Hero (Bold)" 18 | case .purr: return "Purr (Gentle)" 19 | } 20 | } 21 | } 22 | 23 | // Debug print to verify sound settings 24 | public class SoundManager { 25 | public static let shared = SoundManager() 26 | private var sounds: [SystemSound: NSSound] = [:] 27 | 28 | private init() { 29 | // Try to load all system sounds 30 | for soundType in SystemSound.allCases { 31 | if let sound = NSSound(named: soundType.rawValue) { 32 | Logger.debug("Successfully loaded \(soundType.rawValue) sound") 33 | sound.volume = 0.3 // Moderate volume for clear feedback 34 | sounds[soundType] = sound 35 | } else { 36 | Logger.debug("Failed to load \(soundType.rawValue) sound") 37 | } 38 | } 39 | } 40 | 41 | public func playCopySound() { 42 | let shouldPlaySound = UserDefaults.standard.bool(forKey: UserDefaultsKeys.playSoundOnCopy) 43 | Logger.debug("Should play sound: \(shouldPlaySound)") 44 | if shouldPlaySound { 45 | let selectedSound = UserDefaults.standard.string(forKey: UserDefaultsKeys.selectedSound) ?? SystemSound.tink.rawValue 46 | if let soundType = SystemSound(rawValue: selectedSound), 47 | let sound = sounds[soundType] { 48 | Logger.debug("Playing \(soundType.rawValue) sound") 49 | sound.play() 50 | } else { 51 | Logger.debug("Playing fallback sound") 52 | NSSound.beep() 53 | } 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /ClipboardManager/ClipboardManager/Utils/Logger.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import os.log 3 | 4 | /// A logging utility that provides different levels of logging with build configuration awareness 5 | class Logger { 6 | // MARK: - Private Properties 7 | 8 | /// The underlying logger instance 9 | private static let logger = Logger() 10 | 11 | /// OSLog subsystem identifier - should match your bundle identifier 12 | private static let subsystem = Bundle.main.bundleIdentifier ?? "com.clipboard.manager" 13 | 14 | /// Different log categories for better filtering 15 | private static let debugLog = OSLog(subsystem: subsystem, category: "debug") 16 | private static let errorLog = OSLog(subsystem: subsystem, category: "error") 17 | private static let warnLog = OSLog(subsystem: subsystem, category: "warning") 18 | 19 | // MARK: - Public Properties 20 | 21 | /// Enable debug logs via UserDefaults (useful for user-triggered debug mode) 22 | static var isDebugEnabled: Bool { 23 | UserDefaults.standard.bool(forKey: "DEBUG_ENABLED") 24 | } 25 | 26 | // MARK: - Public Methods 27 | 28 | /// Log debug messages - only in DEBUG builds or if explicitly enabled 29 | static func debug(_ message: String) { 30 | #if DEBUG 31 | os_log(.debug, log: debugLog, "%{public}@", message) 32 | #else 33 | if isDebugEnabled { 34 | os_log(.debug, log: debugLog, "%{public}@", message) 35 | } 36 | #endif 37 | } 38 | 39 | /// Log error messages - only with basic info in Release 40 | static func error(_ message: String) { 41 | #if DEBUG 42 | os_log(.error, log: errorLog, "%{public}@", message) 43 | #else 44 | // In Release, we might want to log errors without sensitive information 45 | os_log(.error, log: errorLog, "An error occurred") 46 | #endif 47 | } 48 | 49 | /// Log warning messages - only with basic info in Release 50 | static func warn(_ message: String) { 51 | #if DEBUG 52 | os_log(.info, log: warnLog, "%{public}@", message) 53 | #else 54 | // In Release, we might want to log warnings without sensitive information 55 | os_log(.info, log: warnLog, "A warning occurred") 56 | #endif 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /ClipboardManager/ClipboardManager/Utils/SearchManager.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import SwiftUI 3 | 4 | actor SearchManager { 5 | private var searchTask: Task? 6 | private let pageSize: Int 7 | 8 | init(pageSize: Int = 20) { 9 | self.pageSize = pageSize 10 | } 11 | 12 | func cancelCurrentSearch() { 13 | searchTask?.cancel() 14 | } 15 | 16 | private func performLocalSearch(in clips: [ClipboardItem], searchTerm: String) -> [ClipboardItem] { 17 | clips.filter { clip in 18 | if let text = clip.contentString?.lowercased(), 19 | text.contains(searchTerm) { 20 | return true 21 | } 22 | if let sourceApp = clip.metadata.sourceApp?.lowercased(), 23 | sourceApp.contains(searchTerm) { 24 | return true 25 | } 26 | if let category = clip.metadata.category?.lowercased(), 27 | category.contains(searchTerm) { 28 | return true 29 | } 30 | if let tags = clip.metadata.tags, 31 | tags.contains(where: { $0.lowercased().contains(searchTerm) }) { 32 | return true 33 | } 34 | return false 35 | } 36 | } 37 | 38 | func search( 39 | query: String, 40 | in clips: [ClipboardItem], 41 | apiClient: APIClient 42 | ) async -> ClipboardSearchResult { 43 | cancelCurrentSearch() 44 | 45 | if query.isEmpty { 46 | return .resetToInitial 47 | } 48 | 49 | // Add debounce delay 50 | try? await Task.sleep(nanoseconds: 300_000_000) // 300ms 51 | if Task.isCancelled { return .cancelled } 52 | 53 | let searchTerm = query.lowercased() 54 | let localResults = performLocalSearch(in: clips, searchTerm: searchTerm) 55 | 56 | if Task.isCancelled { return .cancelled } 57 | 58 | if !localResults.isEmpty { 59 | return .localResults(localResults) 60 | } 61 | 62 | // Perform backend search if no local results 63 | do { 64 | let results = try await apiClient.searchClips( 65 | query: query, 66 | offset: 0, 67 | limit: pageSize 68 | ) 69 | if Task.isCancelled { return .cancelled } 70 | return .backendResults(results, hasMore: results.count == pageSize) 71 | } catch { 72 | Logger.error("Search error: \(error)") 73 | return .error(error) 74 | } 75 | } 76 | } 77 | 78 | enum ClipboardSearchResult { 79 | case localResults([ClipboardItem]) 80 | case backendResults([ClipboardItem], hasMore: Bool) 81 | case resetToInitial 82 | case cancelled 83 | case error(Error) 84 | } 85 | 86 | // Extension to handle search state updates 87 | extension ClipboardSearchResult { 88 | func updateState(_ state: inout SearchState) { 89 | switch self { 90 | case .localResults(let results): 91 | state.clips = results 92 | state.selectedIndex = 0 93 | state.hasMoreContent = false // Disable pagination for local results 94 | state.currentPage = 0 95 | state.isSearching = false 96 | 97 | case .backendResults(let results, let hasMore): 98 | state.clips = results 99 | state.selectedIndex = 0 100 | state.hasMoreContent = hasMore 101 | state.currentPage = 0 102 | state.isSearching = false 103 | 104 | case .resetToInitial: 105 | state.isSearching = false 106 | state.currentPage = 0 107 | state.hasMoreContent = true 108 | // Note: Initial page load should be handled separately 109 | 110 | case .cancelled: 111 | state.isSearching = false 112 | 113 | case .error: 114 | state.isSearching = false 115 | // Could add error state handling here if needed 116 | } 117 | } 118 | } 119 | 120 | // State container for search-related state 121 | struct SearchState { 122 | var clips: [ClipboardItem] 123 | var selectedIndex: Int 124 | var currentPage: Int 125 | var hasMoreContent: Bool 126 | var isSearching: Bool 127 | 128 | static func initial() -> SearchState { 129 | SearchState( 130 | clips: [], 131 | selectedIndex: 0, 132 | currentPage: 0, 133 | hasMoreContent: true, 134 | isSearching: false 135 | ) 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /ClipboardManager/ClipboardManager/Views/ClipboardHistoryView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import UserNotifications 3 | import AppKit 4 | 5 | struct ClipboardHistoryView: View { 6 | @State private var showToast = false 7 | @State private var searchText = "" 8 | @State private var searchState = SearchState.initial() 9 | @State private var hasInitialized = false 10 | @State private var isLoadingMore = false 11 | @State private var showDeleteConfirmation = false 12 | @State private var clipToDelete: ClipboardItem? 13 | @State private var showClearConfirmation = false 14 | @EnvironmentObject private var appState: AppState 15 | var isInPanel: Bool = false 16 | @Environment(\.dismiss) private var dismiss 17 | @Binding var selectedIndex: Int 18 | 19 | @AppStorage(UserDefaultsKeys.maxClipsShown) private var maxClipsShown: Int = 10 20 | private let searchManager = SearchManager() 21 | 22 | private func formatSize(_ bytes: Int) -> String { 23 | let kb = Double(bytes) / 1024.0 24 | if kb < 1024 { 25 | return String(format: "%.1f KB", kb) 26 | } 27 | let mb = kb / 1024.0 28 | return String(format: "%.1f MB", mb) 29 | } 30 | 31 | private var totalSize: Int { 32 | searchState.clips.reduce(0) { $0 + $1.content.count } 33 | } 34 | 35 | init(isInPanel: Bool = false, selectedIndex: Binding = .constant(0)) { 36 | self.isInPanel = isInPanel 37 | self._selectedIndex = selectedIndex 38 | } 39 | 40 | private func loadPage(_ page: Int) async { 41 | guard !isLoadingMore else { return } 42 | isLoadingMore = true 43 | 44 | do { 45 | let newClips = try await appState.apiClient.getClips(offset: page * maxClipsShown, limit: maxClipsShown) 46 | await MainActor.run { 47 | if page == 0 { 48 | searchState.clips = newClips 49 | } else { 50 | searchState.clips.append(contentsOf: newClips) 51 | } 52 | searchState.hasMoreContent = newClips.count == maxClipsShown 53 | searchState.currentPage = page 54 | isLoadingMore = false 55 | } 56 | } catch { 57 | print("Error loading page \(page): \(error)") 58 | await MainActor.run { 59 | isLoadingMore = false 60 | } 61 | } 62 | } 63 | 64 | private func loadMoreContentIfNeeded(currentItem item: ClipboardItem) { 65 | let thresholdIndex = searchState.clips.index(searchState.clips.endIndex, offsetBy: -5) 66 | if let itemIndex = searchState.clips.firstIndex(where: { $0.id == item.id }), 67 | itemIndex == thresholdIndex { 68 | Task { 69 | await loadPage(searchState.currentPage + 1) 70 | } 71 | } 72 | } 73 | 74 | private func handleSearch(_ query: String) async { 75 | guard hasInitialized else { 76 | hasInitialized = true 77 | return 78 | } 79 | 80 | searchState.isSearching = true 81 | let result = await searchManager.search( 82 | query: query, 83 | in: appState.clips, 84 | apiClient: appState.apiClient 85 | ) 86 | 87 | await MainActor.run { 88 | result.updateState(&searchState) 89 | if case .resetToInitial = result { 90 | Task { 91 | await loadPage(0) 92 | } 93 | } 94 | } 95 | } 96 | 97 | var body: some View { 98 | VStack(spacing: 12) { 99 | // Search bar 100 | SearchBar( 101 | searchText: $searchText, 102 | onClearAll: { showClearConfirmation = true }, 103 | isEnabled: !searchState.clips.isEmpty 104 | ) 105 | .padding(.horizontal, 8) 106 | .onChange(of: searchText) { newValue in 107 | Task { 108 | await handleSearch(newValue) 109 | } 110 | } 111 | 112 | // Total clips count 113 | if !searchState.clips.isEmpty { 114 | Text("\(searchState.clips.count) clips (\(formatSize(totalSize)))") 115 | .font(.caption) 116 | .foregroundColor(.secondary) 117 | .padding(.horizontal, 8) 118 | } 119 | 120 | // Debug status 121 | if appState.isDebugMode && !isInPanel { 122 | StatusView( 123 | isServiceRunning: appState.isServiceRunning, 124 | error: appState.error 125 | ) 126 | } 127 | 128 | // Main content 129 | Group { 130 | if searchState.isSearching { 131 | LoadingView(message: "Searching...") 132 | } else if appState.isLoading && searchState.clips.isEmpty { 133 | LoadingView(message: "Loading clips...") 134 | } else if searchState.clips.isEmpty { 135 | EmptyStateView(isServiceRunning: appState.isServiceRunning) 136 | } else { 137 | ScrollView { 138 | LazyVStack(spacing: 8) { 139 | ForEach(Array(searchState.clips.enumerated()), id: \.offset) { index, clip in 140 | ClipboardItemView( 141 | item: clip, 142 | isSelected: index == searchState.selectedIndex, 143 | onDelete: { 144 | withAnimation { 145 | clipToDelete = clip 146 | showDeleteConfirmation = true 147 | } 148 | } 149 | ) { 150 | try await appState.pasteClip(at: index) 151 | if isInPanel { 152 | PanelWindowManager.hidePanel() 153 | } 154 | NSHapticFeedbackManager.defaultPerformer.perform(.generic, performanceTime: .default) 155 | // Always show toast to inform user they need to press Cmd+V 156 | showToast = true 157 | DispatchQueue.main.asyncAfter(deadline: .now() + 2) { 158 | showToast = false 159 | } 160 | } 161 | .padding(.horizontal, 12) 162 | .help("Click to copy, then use Cmd+V to paste") 163 | .background(index == searchState.selectedIndex ? Color.blue.opacity(0.2) : Color.clear) 164 | .onAppear { 165 | loadMoreContentIfNeeded(currentItem: clip) 166 | } 167 | } 168 | 169 | if isLoadingMore && searchState.hasMoreContent { 170 | ProgressView() 171 | .progressViewStyle(.circular) 172 | .scaleEffect(0.8) 173 | .frame(height: 40) 174 | } 175 | } 176 | } 177 | .frame(width: 300, height: isInPanel ? 300 : 400) 178 | } 179 | } 180 | } 181 | .padding(.horizontal, 16) 182 | .padding(.vertical, 12) 183 | .confirmationDialog( 184 | "Delete Clip", 185 | isPresented: $showDeleteConfirmation, 186 | presenting: clipToDelete 187 | ) { clip in 188 | Button("Delete", role: .destructive) { 189 | Task { 190 | do { 191 | try await appState.apiClient.deleteClip(id: clip.id) 192 | await loadPage(0) 193 | await MainActor.run { 194 | withAnimation { 195 | showDeleteConfirmation = false 196 | clipToDelete = nil 197 | } 198 | } 199 | } catch { 200 | print("Error deleting clip: \(error)") 201 | await MainActor.run { 202 | withAnimation { 203 | showDeleteConfirmation = false 204 | clipToDelete = nil 205 | } 206 | } 207 | } 208 | } 209 | } 210 | Button("Cancel", role: .cancel) { 211 | showDeleteConfirmation = false 212 | clipToDelete = nil 213 | } 214 | } message: { clip in 215 | Text("Are you sure you want to delete this clip?") 216 | } 217 | .confirmationDialog( 218 | "Clear All Clips", 219 | isPresented: $showClearConfirmation 220 | ) { 221 | Button("Clear All", role: .destructive) { 222 | Task { 223 | do { 224 | try await appState.apiClient.clearClips() 225 | await loadPage(0) 226 | await MainActor.run { 227 | showClearConfirmation = false 228 | if isInPanel { 229 | PanelWindowManager.hidePanel() 230 | } 231 | } 232 | } catch { 233 | print("Error clearing clips: \(error)") 234 | await MainActor.run { 235 | showClearConfirmation = false 236 | } 237 | } 238 | } 239 | } 240 | Button("Cancel", role: .cancel) {} 241 | } message: { 242 | Text("Are you sure you want to clear all clips? This action cannot be undone.") 243 | } 244 | .onAppear { 245 | appState.viewActivated() 246 | Task { 247 | await loadPage(0) 248 | } 249 | } 250 | .onDisappear { 251 | // Clean up all modal states 252 | showDeleteConfirmation = false 253 | showClearConfirmation = false 254 | clipToDelete = nil 255 | appState.viewDeactivated() 256 | } 257 | .overlay { 258 | if showToast { 259 | ToastView(message: "Content copied! Press Cmd+V to paste") 260 | .animation(.easeInOut, value: showToast) 261 | } 262 | } 263 | } 264 | } 265 | -------------------------------------------------------------------------------- /ClipboardManager/ClipboardManager/Views/ClipboardItemContentView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct ClipboardItemContentView: View { 4 | let type: String 5 | let content: Data 6 | let contentString: String? 7 | 8 | var body: some View { 9 | switch type { 10 | case "text/plain": 11 | if let text = contentString { 12 | Text(text) 13 | .font(.system(.body, design: .monospaced)) 14 | } else { 15 | Text("Invalid text content") 16 | .font(.system(.body)) 17 | .foregroundColor(.red) 18 | } 19 | case "image/png", "image/jpeg", "image/gif": 20 | if let image = NSImage(data: content) { 21 | Image(nsImage: image) 22 | .resizable() 23 | .aspectRatio(contentMode: .fit) 24 | .frame(maxHeight: 100) 25 | } else { 26 | Text("📸 Invalid image data") 27 | .font(.system(.body)) 28 | .foregroundColor(.red) 29 | } 30 | case "text/html": 31 | if let text = contentString { 32 | Text("🌐 " + text) 33 | .font(.system(.body)) 34 | } else { 35 | Text("🌐 Invalid HTML content") 36 | .font(.system(.body)) 37 | .foregroundColor(.red) 38 | } 39 | default: 40 | if let text = contentString { 41 | Text(text) 42 | .font(.system(.body)) 43 | } else { 44 | Text("Unknown content type: \(type)") 45 | .font(.system(.body)) 46 | .foregroundColor(.red) 47 | } 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /ClipboardManager/ClipboardManager/Views/ClipboardItemView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct ClipboardItemView: View { 4 | let item: ClipboardItem 5 | let isSelected: Bool 6 | let onDelete: () -> Void 7 | let onPaste: () async throws -> Void 8 | 9 | @State private var isHovered = false 10 | @State private var isPasting = false 11 | @State private var isDeleting = false 12 | @State private var error: Error? 13 | 14 | var body: some View { 15 | HStack(spacing: 8) { 16 | // Main content with paste action 17 | VStack(alignment: .leading, spacing: 4) { 18 | ClipboardItemContentView( 19 | type: item.type, 20 | content: item.content, 21 | contentString: item.contentString 22 | ) 23 | .lineLimit(2) 24 | 25 | // Metadata 26 | HStack { 27 | if let sourceApp = item.metadata.sourceApp { 28 | HStack(spacing: 4) { 29 | Image(systemName: "app.badge") 30 | .foregroundColor(.secondary) 31 | Text(sourceApp) 32 | } 33 | .font(.caption) 34 | .foregroundColor(.secondary) 35 | } 36 | 37 | Spacer() 38 | 39 | if isPasting { 40 | ProgressView() 41 | .scaleEffect(0.5) 42 | .frame(width: 16, height: 16) 43 | } else { 44 | HStack(spacing: 4) { 45 | Image(systemName: "clock") 46 | Text(item.createdAt, style: .relative) 47 | } 48 | .font(.caption) 49 | .foregroundColor(.secondary) 50 | } 51 | } 52 | } 53 | .frame(maxWidth: .infinity, alignment: .leading) 54 | .contentShape(Rectangle()) 55 | .onTapGesture { 56 | Task { 57 | isPasting = true 58 | do { 59 | try await onPaste() 60 | } catch { 61 | print("Failed to paste clip: \(error)") 62 | self.error = error 63 | } 64 | try? await Task.sleep(nanoseconds: 500_000_000) 65 | isPasting = false 66 | } 67 | } 68 | 69 | // Delete button area 70 | if isHovered && !isPasting { 71 | Button(action: { 72 | guard !isDeleting else { return } 73 | DeleteConfirmationWindowController.showDeleteConfirmation( 74 | for: item, 75 | onDelete: { 76 | isDeleting = true 77 | onDelete() 78 | } 79 | ) 80 | }) { 81 | Image(systemName: "trash") 82 | .foregroundColor(.red) 83 | .frame(width: 24, height: 24) 84 | .opacity(isDeleting ? 0.7 : 1.0) 85 | } 86 | .buttonStyle(PlainButtonStyle()) 87 | .frame(width: 32) 88 | .padding(.trailing, 4) 89 | .transition(.opacity) 90 | } else { 91 | Color.clear 92 | .frame(width: 32) 93 | .padding(.trailing, 4) 94 | } 95 | } 96 | .padding(.vertical, 4) 97 | .background( 98 | Group { 99 | if isSelected { 100 | Color.blue.opacity(0.2) 101 | } else if isHovered { 102 | Color.gray.opacity(0.1) 103 | } else { 104 | Color.clear 105 | } 106 | } 107 | ) 108 | .cornerRadius(6) 109 | .onHover { hovering in 110 | withAnimation(.easeInOut(duration: 0.2)) { 111 | isHovered = hovering 112 | } 113 | } 114 | .alert("Error", isPresented: .constant(error != nil)) { 115 | Button("OK") { 116 | error = nil 117 | } 118 | } message: { 119 | if let error = error { 120 | Text(error.localizedDescription) 121 | } 122 | } 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /ClipboardManager/ClipboardManager/Views/DeleteConfirmationView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct DeleteConfirmationView: View { 4 | let clipToDelete: ClipboardItem 5 | let onDelete: () -> Void 6 | let onCancel: () -> Void 7 | 8 | var body: some View { 9 | VStack(spacing: 16) { 10 | Text("Delete Clip") 11 | .font(.headline) 12 | 13 | Text("Are you sure you want to delete this clip?") 14 | .font(.body) 15 | .foregroundColor(.secondary) 16 | 17 | HStack(spacing: 12) { 18 | Button("Cancel") { 19 | onCancel() 20 | } 21 | .keyboardShortcut(.escape) 22 | 23 | Button("Delete") { 24 | onDelete() 25 | } 26 | .keyboardShortcut(.defaultAction) 27 | .foregroundColor(.red) 28 | } 29 | .padding(.top, 8) 30 | } 31 | .padding(20) 32 | .frame(width: 300) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /ClipboardManager/ClipboardManager/Views/EmptyStateView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct EmptyStateView: View { 4 | let isServiceRunning: Bool 5 | 6 | var body: some View { 7 | VStack(spacing: 8) { 8 | if isServiceRunning { 9 | Image(systemName: "clipboard") 10 | .font(.largeTitle) 11 | .foregroundColor(.secondary) 12 | Text("No clips available") 13 | .foregroundColor(.secondary) 14 | Text("Copy something to get started") 15 | .font(.caption) 16 | .foregroundColor(.secondary) 17 | } else { 18 | Image(systemName: "exclamationmark.triangle") 19 | .font(.largeTitle) 20 | .foregroundColor(.orange) 21 | Text("Waiting for service to start...") 22 | .foregroundColor(.secondary) 23 | } 24 | } 25 | .frame(width: 300, height: 400) 26 | } 27 | } 28 | 29 | struct EmptyStateView_Previews: PreviewProvider { 30 | static var previews: some View { 31 | Group { 32 | EmptyStateView(isServiceRunning: true) 33 | .previewDisplayName("Service Running") 34 | 35 | EmptyStateView(isServiceRunning: false) 36 | .previewDisplayName("Service Stopped") 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /ClipboardManager/ClipboardManager/Views/LoadingView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct LoadingView: View { 4 | let message: String 5 | 6 | var body: some View { 7 | VStack { 8 | ProgressView() 9 | .progressViewStyle(.circular) 10 | .scaleEffect(0.8) 11 | Text(message) 12 | .font(.caption) 13 | .foregroundColor(.secondary) 14 | } 15 | .frame(width: 300, height: 400) 16 | } 17 | } 18 | 19 | struct LoadingView_Previews: PreviewProvider { 20 | static var previews: some View { 21 | Group { 22 | LoadingView(message: "Loading clips...") 23 | .previewDisplayName("Loading") 24 | 25 | LoadingView(message: "Searching...") 26 | .previewDisplayName("Searching") 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /ClipboardManager/ClipboardManager/Views/SearchBar.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct SearchBar: View { 4 | @Binding var searchText: String 5 | let onClearAll: () -> Void 6 | let isEnabled: Bool 7 | 8 | var body: some View { 9 | HStack { 10 | // Search field 11 | TextField("Search clips...", text: $searchText) 12 | .textFieldStyle(RoundedBorderTextFieldStyle()) 13 | 14 | // Clear all button 15 | Button(action: onClearAll) { 16 | Image(systemName: "trash") 17 | .foregroundColor(.red) 18 | } 19 | .disabled(!isEnabled) 20 | .help("Clear all clips") 21 | } 22 | .padding(.horizontal) 23 | } 24 | } 25 | 26 | struct SearchBar_Previews: PreviewProvider { 27 | static var previews: some View { 28 | SearchBar( 29 | searchText: .constant(""), 30 | onClearAll: {}, 31 | isEnabled: true 32 | ) 33 | .frame(width: 300) 34 | .padding() 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /ClipboardManager/ClipboardManager/Views/SingleClipPanelView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import AppKit 3 | 4 | struct SingleClipPanelView: View { 5 | @EnvironmentObject private var appState: AppState 6 | @Environment(\.colorScheme) private var colorScheme 7 | @State private var selectedIndex = 0 8 | @State private var modifierMonitor: Any? 9 | @State private var initialModifiersActive = true 10 | 11 | private func updateClipboard() { 12 | if !appState.clips.isEmpty { 13 | Task { 14 | do { 15 | Logger.debug("Setting clipboard content for index: \(selectedIndex)") 16 | try await appState.pasteClip(at: selectedIndex) 17 | } catch { 18 | Logger.error("Failed to set clipboard: \(error)") 19 | } 20 | } 21 | } 22 | } 23 | 24 | private func simulatePaste() { 25 | Logger.debug("Simulating paste...") 26 | 27 | if let previousApp = SingleClipPanelManager.previousApp { 28 | Logger.debug("Found stored previous app: \(previousApp.localizedName ?? "unknown")") 29 | 30 | // Hide our panel first 31 | SingleClipPanelManager.hidePanel() 32 | 33 | // Activate the previous app with focus 34 | let activated = previousApp.activate(options: [.activateIgnoringOtherApps, .activateAllWindows]) 35 | Logger.debug("Activated previous app: \(activated)") 36 | 37 | // Small delay to ensure app is active 38 | Thread.sleep(forTimeInterval: 0.1) 39 | 40 | // Create and post the paste events 41 | let source = CGEventSource(stateID: .hidSystemState) 42 | 43 | let vKeyDown = CGEvent(keyboardEventSource: source, virtualKey: 0x09, keyDown: true) 44 | vKeyDown?.flags = .maskCommand 45 | vKeyDown?.post(tap: .cghidEventTap) 46 | 47 | Thread.sleep(forTimeInterval: 0.05) 48 | 49 | let vKeyUp = CGEvent(keyboardEventSource: source, virtualKey: 0x09, keyDown: false) 50 | vKeyUp?.flags = .maskCommand 51 | vKeyUp?.post(tap: .cghidEventTap) 52 | 53 | Logger.debug("Paste events posted") 54 | } else { 55 | Logger.error("No previous app stored") 56 | SingleClipPanelManager.hidePanel() 57 | } 58 | } 59 | 60 | private func startMonitoringModifiers() { 61 | // Remove any existing monitor first 62 | if let monitor = modifierMonitor { 63 | NSEvent.removeMonitor(monitor) 64 | modifierMonitor = nil 65 | } 66 | 67 | // Start new monitor 68 | modifierMonitor = NSEvent.addLocalMonitorForEvents(matching: .flagsChanged) { event in 69 | let flags = event.modifierFlags 70 | 71 | // If both modifiers are released 72 | if !flags.contains(.command) && !flags.contains(.shift) { 73 | Logger.debug("Both modifiers released, initiating paste") 74 | Task { 75 | do { 76 | try await appState.pasteClip(at: selectedIndex) 77 | DispatchQueue.main.async { 78 | simulatePaste() 79 | } 80 | } catch { 81 | Logger.error("Failed to paste on modifier release: \(error)") 82 | SingleClipPanelManager.hidePanel() 83 | } 84 | } 85 | } 86 | return event 87 | } 88 | } 89 | 90 | var body: some View { 91 | VStack(spacing: 8) { 92 | if !appState.clips.isEmpty { 93 | let clip = appState.clips[selectedIndex] 94 | ClipboardItemContentView( 95 | type: clip.type, 96 | content: clip.content, 97 | contentString: clip.contentString 98 | ) 99 | .padding() 100 | .frame(maxWidth: CGFloat.infinity, maxHeight: 200, alignment: .leading) 101 | 102 | HStack { 103 | Text("\(selectedIndex + 1) of \(appState.clips.count)") 104 | .font(.caption) 105 | .foregroundColor(.secondary) 106 | Spacer() 107 | Text("Use ←→ to navigate") 108 | .font(.caption) 109 | .foregroundColor(.secondary) 110 | } 111 | .padding(.horizontal) 112 | .padding(.bottom, 8) 113 | } 114 | } 115 | .frame(width: 400, height: 250) 116 | .background(colorScheme == .dark ? Color(NSColor.windowBackgroundColor) : .white) 117 | .cornerRadius(8) 118 | .shadow(radius: 5) 119 | .onAppear { 120 | selectedIndex = 0 121 | updateClipboard() 122 | startMonitoringModifiers() 123 | 124 | NSEvent.addLocalMonitorForEvents(matching: .keyDown) { event in 125 | switch event.keyCode { 126 | case 123: // Left arrow 127 | if !appState.clips.isEmpty { 128 | selectedIndex = max(selectedIndex - 1, 0) 129 | updateClipboard() 130 | } 131 | return nil 132 | case 124: // Right arrow 133 | if !appState.clips.isEmpty { 134 | selectedIndex = min(selectedIndex + 1, appState.clips.count - 1) 135 | updateClipboard() 136 | } 137 | return nil 138 | case 36, 76: // Return key or numpad enter 139 | if !appState.clips.isEmpty { 140 | let flags = event.modifierFlags 141 | if flags.contains(.command) || flags.contains(.shift) { 142 | Task { 143 | do { 144 | try await appState.pasteClip(at: selectedIndex) 145 | DispatchQueue.main.async { 146 | simulatePaste() 147 | } 148 | } catch { 149 | Logger.error("Failed to paste clip: \(error)") 150 | SingleClipPanelManager.hidePanel() 151 | } 152 | } 153 | } 154 | } 155 | return nil 156 | case 53: // Escape key 157 | DispatchQueue.main.async { 158 | SingleClipPanelManager.hidePanel() 159 | } 160 | return nil 161 | default: 162 | return event 163 | } 164 | } 165 | } 166 | .onDisappear { 167 | // Remove modifier monitor 168 | if let monitor = modifierMonitor { 169 | NSEvent.removeMonitor(monitor) 170 | modifierMonitor = nil 171 | } 172 | } 173 | } 174 | } 175 | 176 | class SingleClipPanel: NSPanel { 177 | init(contentRect: NSRect) { 178 | super.init( 179 | contentRect: contentRect, 180 | styleMask: [.nonactivatingPanel, .titled, .resizable, .closable], 181 | backing: .buffered, 182 | defer: false 183 | ) 184 | 185 | self.level = .floating 186 | self.isFloatingPanel = true 187 | self.titlebarAppearsTransparent = true 188 | self.titleVisibility = .hidden 189 | self.isMovableByWindowBackground = true 190 | self.backgroundColor = .clear 191 | self.hidesOnDeactivate = true 192 | } 193 | 194 | override var canBecomeKey: Bool { true } 195 | override var canBecomeMain: Bool { true } 196 | } 197 | 198 | struct SingleClipPanelManager { 199 | private static var panel: SingleClipPanel? 200 | static var previousApp: NSRunningApplication? 201 | 202 | static func showPanel(with appState: AppState) { 203 | DispatchQueue.main.async { 204 | // Store the currently active app before showing our panel 205 | previousApp = NSWorkspace.shared.frontmostApplication 206 | Logger.debug("Storing previous app: \(previousApp?.localizedName ?? "unknown")") 207 | 208 | if panel == nil { 209 | let mouseLocation = NSEvent.mouseLocation 210 | let screen = NSScreen.screens.first { screen in 211 | screen.frame.contains(mouseLocation) 212 | } ?? NSScreen.main ?? NSScreen.screens.first! 213 | 214 | let screenFrame = screen.visibleFrame 215 | let panelWidth: CGFloat = 400 216 | let panelHeight: CGFloat = 250 217 | 218 | var panelX = mouseLocation.x - panelWidth/2 219 | var panelY = mouseLocation.y - panelHeight - 10 220 | 221 | // Keep panel within screen bounds 222 | if panelX + panelWidth > screenFrame.maxX { 223 | panelX = screenFrame.maxX - panelWidth - 10 224 | } 225 | if panelX < screenFrame.minX { 226 | panelX = screenFrame.minX + 10 227 | } 228 | if panelY + panelHeight > screenFrame.maxY { 229 | panelY = screenFrame.maxY - panelHeight - 10 230 | } 231 | if panelY < screenFrame.minY { 232 | panelY = screenFrame.minY + 10 233 | } 234 | 235 | let panelRect = NSRect( 236 | x: panelX, 237 | y: panelY, 238 | width: panelWidth, 239 | height: panelHeight 240 | ) 241 | 242 | panel = SingleClipPanel(contentRect: panelRect) 243 | panel?.contentView = NSHostingView( 244 | rootView: SingleClipPanelView() 245 | .environmentObject(appState) 246 | ) 247 | } 248 | 249 | panel?.makeKeyAndOrderFront(nil) 250 | NSApp.activate(ignoringOtherApps: true) 251 | } 252 | } 253 | 254 | static func hidePanel() { 255 | DispatchQueue.main.async { 256 | if let panel = panel { 257 | panel.orderOut(nil) 258 | } 259 | } 260 | } 261 | 262 | static func togglePanel(with appState: AppState) { 263 | DispatchQueue.main.async { 264 | if panel?.isVisible == true { 265 | hidePanel() 266 | } else { 267 | showPanel(with: appState) 268 | } 269 | } 270 | } 271 | } 272 | -------------------------------------------------------------------------------- /ClipboardManager/ClipboardManager/Views/StatusView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct StatusView: View { 4 | let isServiceRunning: Bool 5 | let error: String? 6 | 7 | var body: some View { 8 | VStack(spacing: 4) { 9 | // Status indicator 10 | HStack { 11 | Circle() 12 | .fill(isServiceRunning ? Color.green : Color.red) 13 | .frame(width: 8, height: 8) 14 | Text(isServiceRunning ? "Service Running" : "Service Stopped") 15 | .font(.caption) 16 | .foregroundColor(.secondary) 17 | } 18 | 19 | if let error = error { 20 | Text("Error: \(error)") 21 | .foregroundColor(.red) 22 | .padding() 23 | .multilineTextAlignment(.center) 24 | } 25 | } 26 | } 27 | } 28 | 29 | struct StatusView_Previews: PreviewProvider { 30 | static var previews: some View { 31 | Group { 32 | StatusView( 33 | isServiceRunning: true, 34 | error: nil 35 | ) 36 | 37 | StatusView( 38 | isServiceRunning: false, 39 | error: "Failed to connect" 40 | ) 41 | } 42 | .previewLayout(.sizeThatFits) 43 | .padding() 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /ClipboardManager/ClipboardManager/Views/ToastView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct ToastView: View { 4 | let message: String 5 | 6 | var body: some View { 7 | VStack { 8 | Spacer() 9 | Text(message) 10 | .foregroundColor(.white) 11 | .padding() 12 | .background(Color.black.opacity(0.75)) 13 | .cornerRadius(10) 14 | .padding(.bottom) 15 | } 16 | .transition(.move(edge: .bottom).combined(with: .opacity)) 17 | } 18 | } 19 | 20 | struct ToastView_Previews: PreviewProvider { 21 | static var previews: some View { 22 | ToastView(message: "Content copied! Press Cmd+V to paste") 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /INSTALL.md: -------------------------------------------------------------------------------- 1 | # Installation Guide 2 | 3 | ## For Users 4 | 5 | ### Installing from DMG 6 | 1. Download the ClipboardManager DMG file 7 | 2. Double-click the DMG file to mount it 8 | 3. Drag the ClipboardManager app to your Applications folder 9 | 4. Since the app isn't signed with an Apple Developer ID, you'll need to: 10 | - Right-click the app and select "Open" the first time 11 | - Click "Open" in the security dialog that appears 12 | - Or go to System Preferences > Security & Privacy and click "Open Anyway" 13 | 14 | ## For Developers 15 | 16 | ### Creating a Release 17 | 18 | 1. Archive the app in Xcode: 19 | - Select Product > Archive 20 | - Once archived, copy the app to the releases folder with appropriate version 21 | 22 | 2. Create DMG for distribution: 23 | ```bash 24 | # Install create-dmg if not already installed 25 | brew install create-dmg 26 | 27 | # Create DMG file 28 | create-dmg \ 29 | --volname "ClipboardManager" \ 30 | --window-pos 200 120 \ 31 | --window-size 800 400 \ 32 | --icon-size 100 \ 33 | --icon "ClipboardManager.app" 200 190 \ 34 | --app-drop-link 600 185 \ 35 | "ClipboardManager-[VERSION].dmg" \ 36 | "releases/ClipboardManager.v.[VERSION]/ClipboardManager.app" 37 | ``` 38 | Replace [VERSION] with the actual version number (e.g., 1.2) 39 | 40 | ### Notes 41 | - The DMG creation process will automatically: 42 | - Create a window with custom positioning 43 | - Add an Applications folder shortcut for easy installation 44 | - Set appropriate icon sizes and positions 45 | - Compress the final DMG file 46 | 47 | ### Future Improvements 48 | - Code signing with Apple Developer ID 49 | - Notarization for improved security 50 | - Automatic update mechanism 51 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Himanshu Pandey 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Rockstar - Clipboard Manager 2 | 3 | ![Cover](images/cover.png) 4 | 5 | A powerful clipboard manager for macOS that seamlessly integrates with your workflow. Copy any content - text, images, or files - and access them quickly through a native macOS app. Optionally sync your clipboard history with Obsidian for permanent storage and organization. 6 | 7 | 8 | rockstar - clipboard manager - A clipboardmanager to clip text, images, files for MacOS | Product Hunt 9 | 10 | 11 | ![Clipboard Manager App](images/app.png) 12 | 13 | [Demo on youtube](https://youtu.be/RYRlP_skwrs) 14 | 15 | ## Features 16 | 17 | - **Quick Access**: Access your clipboard history instantly through the menu bar 18 | - **Universal Search**: Find any copied content quickly with the powerful search interface 19 | - **Rich Content Support**: 20 | - Text with formatting 21 | - Images and files 22 | - Source application tracking 23 | - Automatic metadata extraction 24 | - **Obsidian Integration**: Sync selected clips to your Obsidian vault 25 | - **Privacy-Focused**: All data stored locally on your machine 26 | 27 | ## Architecture 28 | 29 | ```ascii 30 | ┌─────────────────┐ ┌──────────────┐ 31 | │ Native UI │ │ Obsidian │ 32 | └────────┬────────┘ └───────┬──────┘ 33 | │ │ 34 | ┌────┴──────────────────────┴────┐ 35 | │ Core Go Service │ 36 | ├────────────────┬──────────────┐│ 37 | │ Clip Manager │ Categorizer ││ 38 | ├────────────────┼──────────────┤│ 39 | │ Search Engine │ Sync Manager ││ 40 | └────────────────┴──────────────┘│ 41 | │ │ 42 | ┌────┴────────────┐ ┌───┴────┐ 43 | │ SQLite + FTS5 │ │ Backup │ 44 | └─────────────────┘ └────────┘ 45 | ``` 46 | 47 | The application is built with a hybrid architecture: 48 | - **Frontend**: Native macOS app built with SwiftUI for optimal performance and integration 49 | - **Backend**: Go service handling clipboard monitoring, storage, and search 50 | - **Storage**: SQLite with FTS5 for efficient full-text search 51 | - **Sync**: Optional Obsidian integration for permanent storage 52 | 53 | ## Installation 54 | 55 | ### Prerequisites 56 | - macOS 12.0 or later 57 | - [Download the latest release](https://github.com/workhunters/clipboard-manager/releases) 58 | 59 | ### Quick Setup 60 | 1. Download and mount the DMG file 61 | 2. Drag ClipboardManager.app to your Applications folder 62 | 3. Launch the app - it will appear in your menu bar 63 | 4. Grant necessary permissions when prompted 64 | 65 | For detailed installation instructions and developer guide for creating releases, see [INSTALL.md](INSTALL.md). 66 | 67 | ## Development 68 | 69 | ### Prerequisites 70 | - Go 1.21 or later 71 | - Xcode 14.0 or later 72 | - SQLite 73 | 74 | ### Project Structure 75 | ``` 76 | . 77 | ├── ClipboardManager/ # Native macOS SwiftUI app 78 | ├── cmd/ # Command-line tools 79 | ├── internal/ # Core Go implementation 80 | │ ├── clipboard/ # Clipboard monitoring 81 | │ ├── obsidian/ # Obsidian integration 82 | │ ├── server/ # HTTP API server 83 | │ ├── service/ # Core service 84 | │ └── storage/ # Storage implementation 85 | └── examples/ # Example implementations 86 | ``` 87 | 88 | ### Building from Source 89 | 90 | 1. Build the Go backend: 91 | ```bash 92 | go build ./cmd/clipboard-manager 93 | ``` 94 | 95 | 2. Open the Xcode project: 96 | ```bash 97 | open ClipboardManager/ClipboardManager.xcodeproj 98 | ``` 99 | 100 | 3. Build and run the macOS app through Xcode 101 | 102 | ### Testing 103 | ```bash 104 | go test ./... 105 | ``` 106 | 107 | ## Contributing 108 | 109 | 1. Fork the repository 110 | 2. Create your feature branch 111 | 3. Commit your changes 112 | 4. Push to the branch 113 | 5. Create a new Pull Request 114 | 115 | ## License 116 | 117 | MIT License - see LICENSE file 118 | -------------------------------------------------------------------------------- /cmd/clipboard-manager/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "clipboard-manager/internal/clipboard" 5 | "clipboard-manager/internal/server" 6 | "clipboard-manager/internal/service" 7 | "clipboard-manager/internal/storage" 8 | "clipboard-manager/internal/storage/sqlite" 9 | "flag" 10 | "log" 11 | "os" 12 | "os/signal" 13 | "path/filepath" 14 | "syscall" 15 | ) 16 | 17 | func main() { 18 | log.SetFlags(log.Ldate | log.Ltime | log.Lmicroseconds | log.Lshortfile) 19 | 20 | // Configuration flags 21 | var ( 22 | dbPath = flag.String("db", "", "Database path (default: ~/.clipboard-manager/clipboard.db)") 23 | fsPath = flag.String("fs", "", "File storage path (default: ~/.clipboard-manager/files)") 24 | port = flag.Int("port", 54321, "HTTP server port") 25 | ) 26 | 27 | flag.Parse() 28 | 29 | log.Printf("Starting clipboard manager...") 30 | 31 | // Set up storage paths 32 | homeDir, err := os.UserHomeDir() 33 | if err != nil { 34 | log.Fatalf("Failed to get home directory: %v", err) 35 | } 36 | 37 | baseDir := filepath.Join(homeDir, ".clipboard-manager") 38 | if err := os.MkdirAll(baseDir, 0755); err != nil { 39 | log.Fatalf("Failed to create base directory: %v", err) 40 | } 41 | 42 | // Use provided paths or defaults 43 | if *dbPath == "" { 44 | *dbPath = filepath.Join(baseDir, "clipboard.db") 45 | } 46 | if *fsPath == "" { 47 | *fsPath = filepath.Join(baseDir, "files") 48 | } 49 | 50 | // Initialize storage 51 | store, err := sqlite.New(storage.Config{ 52 | DBPath: *dbPath, 53 | FSPath: *fsPath, 54 | }) 55 | if err != nil { 56 | log.Fatalf("Failed to initialize storage: %v", err) 57 | } 58 | 59 | // Initialize monitor 60 | monitor := clipboard.NewMonitor() 61 | 62 | // Create and start clipboard service 63 | clipService := service.New(monitor, store) 64 | if err := clipService.Start(); err != nil { 65 | log.Fatalf("Failed to start clipboard service: %v", err) 66 | } 67 | 68 | log.Printf("Using configuration:") 69 | log.Printf("- Database: %s", *dbPath) 70 | log.Printf("- File storage: %s", *fsPath) 71 | log.Printf("- HTTP server port: %d", *port) 72 | 73 | // Initialize HTTP server 74 | httpServer, err := server.New(clipService, server.Config{ 75 | Port: *port, 76 | }) 77 | if err != nil { 78 | log.Fatalf("Failed to initialize HTTP server: %v", err) 79 | } 80 | 81 | // Start HTTP server 82 | log.Printf("Starting HTTP server...") 83 | if err := httpServer.Start(); err != nil { 84 | log.Fatalf("Failed to start HTTP server: %v", err) 85 | } 86 | 87 | // Wait for interrupt signal 88 | sigChan := make(chan os.Signal, 1) 89 | signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) 90 | <-sigChan 91 | 92 | // Clean shutdown 93 | log.Println("Shutting down...") 94 | 95 | // Stop HTTP server first 96 | if err := httpServer.Stop(); err != nil { 97 | log.Printf("Error stopping HTTP server: %v", err) 98 | } 99 | 100 | // Stop clipboard service 101 | if err := clipService.Stop(); err != nil { 102 | log.Printf("Error stopping service: %v", err) 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /cmd/test-history/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "clipboard-manager/examples" 4 | 5 | func main() { 6 | examples.RunClipboardHistoryTest() 7 | } 8 | -------------------------------------------------------------------------------- /examples/cli/search.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "clipboard-manager/internal/storage" 5 | "clipboard-manager/pkg/types" 6 | "fmt" 7 | "os" 8 | "os/exec" 9 | "runtime" 10 | "strings" 11 | "text/tabwriter" 12 | "time" 13 | 14 | "github.com/progrium/darwinkit/macos/appkit" 15 | ) 16 | 17 | // SearchCommand handles searching and pasting clipboard history 18 | type SearchCommand struct { 19 | store storage.SearchService 20 | } 21 | 22 | // NewSearchCommand creates a new search command 23 | func NewSearchCommand(store storage.SearchService) *SearchCommand { 24 | return &SearchCommand{store: store} 25 | } 26 | 27 | // Search searches clipboard history and displays results 28 | func (c *SearchCommand) Search(query string, limit int) error { 29 | opts := storage.SearchOptions{ 30 | Query: query, 31 | Limit: limit, 32 | SortBy: "last_used", 33 | SortOrder: "desc", 34 | } 35 | 36 | results, err := c.store.Search(opts) 37 | if err != nil { 38 | return fmt.Errorf("search failed: %w", err) 39 | } 40 | 41 | if len(results) == 0 { 42 | fmt.Println("No results found") 43 | return nil 44 | } 45 | 46 | // Create tabwriter for aligned output 47 | w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) 48 | fmt.Fprintln(w, "ID\tType\tSource\tPreview\tLast Used") 49 | fmt.Fprintln(w, "--\t----\t------\t-------\t---------") 50 | 51 | for _, result := range results { 52 | preview := getPreview(result.Clip) 53 | lastUsed := result.LastUsed.Format(time.RFC822) 54 | fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", 55 | result.Clip.ID, 56 | result.Clip.Type, 57 | result.Clip.Metadata.SourceApp, 58 | preview, 59 | lastUsed, 60 | ) 61 | } 62 | w.Flush() 63 | 64 | return nil 65 | } 66 | 67 | // Paste copies the content with given ID to clipboard and simulates Command+V 68 | func (c *SearchCommand) Paste(id string) error { 69 | // Get the clip 70 | results, err := c.store.Search(storage.SearchOptions{ 71 | Query: id, 72 | Limit: 1, 73 | }) 74 | if err != nil { 75 | return fmt.Errorf("failed to get clip: %w", err) 76 | } 77 | 78 | if len(results) == 0 { 79 | return fmt.Errorf("no clip found with ID: %s", id) 80 | } 81 | 82 | clip := results[0].Clip 83 | 84 | // Get pasteboard 85 | pb := appkit.Pasteboard_GeneralPasteboard() 86 | 87 | // Set content based on type 88 | switch clip.Type { 89 | case "text": 90 | pb.SetStringForType(string(clip.Content), appkit.PasteboardType("public.utf8-plain-text")) 91 | case "image/png": 92 | pb.SetDataForType(clip.Content, appkit.PasteboardType("public.png")) 93 | case "image/tiff": 94 | pb.SetDataForType(clip.Content, appkit.PasteboardType("public.tiff")) 95 | case "file": 96 | pb.SetStringForType(string(clip.Content), appkit.PasteboardType("public.file-url")) 97 | default: 98 | return fmt.Errorf("unsupported content type: %s", clip.Type) 99 | } 100 | 101 | // Simulate Command+V using osascript 102 | if runtime.GOOS == "darwin" { 103 | cmd := exec.Command("osascript", "-e", ` 104 | tell application "System Events" 105 | keystroke "v" using command down 106 | end tell 107 | `) 108 | if err := cmd.Run(); err != nil { 109 | return fmt.Errorf("failed to simulate paste: %w", err) 110 | } 111 | } 112 | 113 | return nil 114 | } 115 | 116 | // getPreview returns a preview string for a clip 117 | func getPreview(clip *types.Clip) string { 118 | const maxPreviewLength = 50 119 | 120 | switch clip.Type { 121 | case "text": 122 | text := string(clip.Content) 123 | text = strings.ReplaceAll(text, "\n", " ") 124 | if len(text) > maxPreviewLength { 125 | text = text[:maxPreviewLength] + "..." 126 | } 127 | return text 128 | case "image/png", "image/tiff": 129 | return fmt.Sprintf("[Image %d bytes]", len(clip.Content)) 130 | case "file": 131 | return fmt.Sprintf("[File URL: %s]", string(clip.Content)) 132 | default: 133 | return fmt.Sprintf("[%s %d bytes]", clip.Type, len(clip.Content)) 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /examples/clipboard_history.go: -------------------------------------------------------------------------------- 1 | package examples 2 | 3 | import ( 4 | "clipboard-manager/internal/clipboard" 5 | "clipboard-manager/internal/service" 6 | "clipboard-manager/internal/storage" 7 | "clipboard-manager/internal/storage/sqlite" 8 | "context" 9 | "fmt" 10 | "log" 11 | "os" 12 | "path/filepath" 13 | "time" 14 | ) 15 | 16 | func RunClipboardHistoryTest() { 17 | // 1. Set up storage in a temporary directory 18 | tempDir, err := os.MkdirTemp("", "clipboard-test-*") 19 | if err != nil { 20 | log.Fatal(err) 21 | } 22 | defer os.RemoveAll(tempDir) 23 | 24 | store, err := sqlite.New(storage.Config{ 25 | DBPath: filepath.Join(tempDir, "clipboard.db"), 26 | FSPath: filepath.Join(tempDir, "files"), 27 | }) 28 | if err != nil { 29 | log.Fatal(err) 30 | } 31 | 32 | // 2. Create clipboard service 33 | monitor := clipboard.NewMonitor() 34 | clipService := service.New(monitor, store) 35 | 36 | // 3. Start monitoring 37 | if err := clipService.Start(); err != nil { 38 | log.Fatal(err) 39 | } 40 | defer clipService.Stop() 41 | 42 | // Context for operations 43 | ctx := context.Background() 44 | 45 | fmt.Println("Starting clipboard history test...") 46 | fmt.Println("1. Copy some text to your clipboard") 47 | time.Sleep(3 * time.Second) 48 | 49 | // Debug: List clips after first copy 50 | clips, err := store.List(ctx, storage.ListFilter{Limit: 10}) 51 | if err != nil { 52 | log.Printf("Error listing clips: %v", err) 53 | } else { 54 | fmt.Printf("Found %d clips after first copy\n", len(clips)) 55 | for i, clip := range clips { 56 | fmt.Printf("Clip %d: Type=%s, Content=%s\n", i, clip.Type, string(clip.Content)) 57 | } 58 | } 59 | 60 | fmt.Println("\n2. Copy different text to your clipboard") 61 | time.Sleep(3 * time.Second) 62 | 63 | // Debug: List clips after second copy 64 | clips, err = store.List(ctx, storage.ListFilter{Limit: 10}) 65 | if err != nil { 66 | log.Printf("Error listing clips: %v", err) 67 | } else { 68 | fmt.Printf("Found %d clips after second copy\n", len(clips)) 69 | for i, clip := range clips { 70 | fmt.Printf("Clip %d: Type=%s, Content=%s\n", i, clip.Type, string(clip.Content)) 71 | } 72 | } 73 | 74 | fmt.Println("\n3. Getting second most recent clip...") 75 | if err := clipService.PasteByIndex(ctx, 1); err != nil { 76 | fmt.Printf("Error getting second clip: %v\n", err) 77 | } else { 78 | fmt.Println("Successfully set clipboard to second most recent clip") 79 | fmt.Println("Check if your clipboard contains the first text you copied") 80 | } 81 | 82 | time.Sleep(2 * time.Second) 83 | 84 | fmt.Println("4. Getting most recent clip...") 85 | if err := clipService.PasteByIndex(ctx, 0); err != nil { 86 | fmt.Printf("Error getting most recent clip: %v\n", err) 87 | } else { 88 | fmt.Println("Successfully set clipboard to most recent clip") 89 | fmt.Println("Check if your clipboard contains the second text you copied") 90 | } 91 | 92 | fmt.Println("\nTest completed!") 93 | } 94 | -------------------------------------------------------------------------------- /examples/core_usage.go: -------------------------------------------------------------------------------- 1 | package examples 2 | 3 | import ( 4 | "clipboard-manager/internal/clipboard" 5 | "clipboard-manager/internal/service" 6 | "clipboard-manager/internal/storage" 7 | "clipboard-manager/internal/storage/sqlite" 8 | "clipboard-manager/pkg/types" 9 | "context" 10 | "fmt" 11 | "log" 12 | "os" 13 | "path/filepath" 14 | ) 15 | 16 | // Example shows how to use the clipboard manager core functionality 17 | func Example() { 18 | // 1. Set up storage 19 | homeDir, err := os.UserHomeDir() 20 | if err != nil { 21 | log.Fatal(err) 22 | } 23 | 24 | baseDir := filepath.Join(homeDir, ".clipboard-manager") 25 | store, err := sqlite.New(storage.Config{ 26 | DBPath: filepath.Join(baseDir, "clipboard.db"), 27 | FSPath: filepath.Join(baseDir, "files"), 28 | }) 29 | if err != nil { 30 | log.Fatal(err) 31 | } 32 | 33 | // 2. Create clipboard monitor 34 | monitor := clipboard.NewMonitor() 35 | 36 | // 3. Create clipboard service 37 | clipService := service.New(monitor, store) 38 | 39 | // 4. Start monitoring clipboard 40 | if err := clipService.Start(); err != nil { 41 | log.Fatal(err) 42 | } 43 | defer clipService.Stop() 44 | 45 | // 5. Search functionality example 46 | results, err := store.Search(storage.SearchOptions{ 47 | Query: "example", // Search for specific content 48 | Type: storage.TypeText, // Filter by type 49 | SortBy: "last_used", // Sort by timestamp 50 | SortOrder: "desc", // Most recent first 51 | Limit: 10, // Limit results 52 | }) 53 | if err != nil { 54 | log.Fatal(err) 55 | } 56 | 57 | // 6. Process search results 58 | for _, result := range results { 59 | fmt.Printf("Found clip: %s (type: %s)\n", 60 | string(result.Clip.Content), 61 | result.Clip.Type, 62 | ) 63 | } 64 | 65 | // 7. Get recent clips 66 | recent, err := store.GetRecent(5) 67 | if err != nil { 68 | log.Fatal(err) 69 | } 70 | fmt.Printf("Recent clips: %d\n", len(recent)) 71 | 72 | // 8. Get clips by type 73 | images, err := store.GetByType(storage.TypeImage, 5) 74 | if err != nil { 75 | log.Fatal(err) 76 | } 77 | fmt.Printf("Recent images: %d\n", len(images)) 78 | 79 | // 9. Manual clipboard operations 80 | ctx := context.Background() 81 | content := []byte("Example content") 82 | metadata := types.Metadata{ 83 | SourceApp: "Example App", 84 | Category: "Example", 85 | Tags: []string{"example", "test"}, 86 | } 87 | 88 | // Store new content 89 | clip, err := store.Store(ctx, content, storage.TypeText, metadata) 90 | if err != nil { 91 | log.Fatal(err) 92 | } 93 | fmt.Printf("Stored clip with ID: %s\n", clip.ID) 94 | 95 | // Retrieve content 96 | retrieved, err := store.Get(ctx, clip.ID) 97 | if err != nil { 98 | log.Fatal(err) 99 | } 100 | fmt.Printf("Retrieved clip: %s\n", string(retrieved.Content)) 101 | 102 | // Delete content 103 | if err := store.Delete(ctx, clip.ID); err != nil { 104 | log.Fatal(err) 105 | } 106 | fmt.Println("Deleted clip") 107 | 108 | // 10. Clipboard history operations 109 | // Get the second most recent clip (index 1) 110 | if err := clipService.PasteByIndex(ctx, 1); err != nil { 111 | log.Printf("Failed to paste clip: %v", err) 112 | } 113 | 114 | // Get a specific clip and set it as current clipboard content 115 | if clip, err := clipService.GetClipByIndex(ctx, 0); err == nil { 116 | if err := clipService.SetClipboard(ctx, clip); err != nil { 117 | log.Printf("Failed to set clipboard: %v", err) 118 | } 119 | } 120 | } 121 | 122 | // CustomStorage shows how to implement a custom storage backend 123 | type CustomStorage struct { 124 | // Your storage fields 125 | } 126 | 127 | func (s *CustomStorage) Store(ctx context.Context, content []byte, clipType string, metadata types.Metadata) (*types.Clip, error) { 128 | // Your implementation 129 | return nil, nil 130 | } 131 | 132 | func (s *CustomStorage) Get(ctx context.Context, id string) (*types.Clip, error) { 133 | // Your implementation 134 | return nil, nil 135 | } 136 | 137 | func (s *CustomStorage) Delete(ctx context.Context, id string) error { 138 | // Your implementation 139 | return nil 140 | } 141 | 142 | func (s *CustomStorage) List(ctx context.Context, filter storage.ListFilter) ([]*types.Clip, error) { 143 | // Your implementation 144 | return nil, nil 145 | } 146 | 147 | // CustomMonitor shows how to implement a custom clipboard monitor 148 | type CustomMonitor struct { 149 | // Your monitor fields 150 | } 151 | 152 | func (m *CustomMonitor) Start() error { 153 | // Your implementation 154 | return nil 155 | } 156 | 157 | func (m *CustomMonitor) Stop() error { 158 | // Your implementation 159 | return nil 160 | } 161 | 162 | func (m *CustomMonitor) OnChange(handler func(types.Clip)) { 163 | // Your implementation 164 | } 165 | 166 | func (m *CustomMonitor) SetContent(clip types.Clip) error { 167 | // Your implementation 168 | return nil 169 | } 170 | 171 | // ExampleCustomImplementation shows how to use custom storage and monitor 172 | func ExampleCustomImplementation() { 173 | // Create custom components 174 | store := &CustomStorage{} 175 | monitor := &CustomMonitor{} 176 | 177 | // Create service with custom components 178 | clipService := service.New(monitor, store) 179 | 180 | // Use the service as normal 181 | if err := clipService.Start(); err != nil { 182 | log.Fatal(err) 183 | } 184 | defer clipService.Stop() 185 | } 186 | -------------------------------------------------------------------------------- /examples/tui/main.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "clipboard-manager/internal/storage" 5 | "fmt" 6 | "github.com/gdamore/tcell/v2" 7 | "strings" 8 | ) 9 | 10 | type InteractiveMode struct { 11 | store storage.SearchService 12 | screen tcell.Screen 13 | results []storage.SearchResult 14 | selected int 15 | offset int 16 | searchMode bool 17 | searchText string 18 | } 19 | 20 | func NewInteractiveMode(store storage.SearchService) (*InteractiveMode, error) { 21 | screen, err := tcell.NewScreen() 22 | if err != nil { 23 | return nil, fmt.Errorf("failed to create screen: %w", err) 24 | } 25 | 26 | if err := screen.Init(); err != nil { 27 | return nil, fmt.Errorf("failed to initialize screen: %w", err) 28 | } 29 | 30 | // Set default style 31 | screen.SetStyle(tcell.StyleDefault. 32 | Background(tcell.ColorReset). 33 | Foreground(tcell.ColorReset)) 34 | 35 | return &InteractiveMode{ 36 | store: store, 37 | screen: screen, 38 | selected: 0, 39 | offset: 0, 40 | }, nil 41 | } 42 | 43 | func (im *InteractiveMode) Run() error { 44 | defer im.screen.Fini() 45 | 46 | if err := im.loadResults(""); err != nil { 47 | return err 48 | } 49 | 50 | for { 51 | im.draw() 52 | 53 | switch ev := im.screen.PollEvent().(type) { 54 | case *tcell.EventResize: 55 | im.screen.Sync() 56 | case *tcell.EventKey: 57 | if im.searchMode { 58 | switch ev.Key() { 59 | case tcell.KeyEscape: 60 | im.searchMode = false 61 | im.searchText = "" 62 | if err := im.loadResults(""); err != nil { 63 | return err 64 | } 65 | case tcell.KeyEnter: 66 | im.searchMode = false 67 | if err := im.loadResults(im.searchText); err != nil { 68 | return err 69 | } 70 | case tcell.KeyBackspace, tcell.KeyBackspace2: 71 | if len(im.searchText) > 0 { 72 | im.searchText = im.searchText[:len(im.searchText)-1] 73 | } 74 | case tcell.KeyRune: 75 | im.searchText += string(ev.Rune()) 76 | } 77 | continue 78 | } 79 | 80 | switch ev.Key() { 81 | case tcell.KeyEscape, tcell.KeyCtrlC: 82 | return nil 83 | case tcell.KeyUp, tcell.KeyCtrlP: 84 | im.moveSelection(-1) 85 | case tcell.KeyDown, tcell.KeyCtrlN: 86 | im.moveSelection(1) 87 | case tcell.KeyHome, tcell.KeyCtrlA: 88 | im.selected = 0 89 | case tcell.KeyEnd, tcell.KeyCtrlE: 90 | im.selected = len(im.results) - 1 91 | case tcell.KeyPgUp: 92 | im.moveSelection(-10) 93 | case tcell.KeyPgDn: 94 | im.moveSelection(10) 95 | case tcell.KeyEnter, tcell.KeyCtrlV: 96 | if len(im.results) > 0 { 97 | return im.pasteSelected() 98 | } 99 | case tcell.KeyRune: 100 | switch ev.Rune() { 101 | case 'j': 102 | im.moveSelection(1) 103 | case 'k': 104 | im.moveSelection(-1) 105 | case 'g': 106 | im.selected = 0 107 | case 'G': 108 | im.selected = len(im.results) - 1 109 | case '/': 110 | im.searchMode = true 111 | im.searchText = "" 112 | case 'q': 113 | return nil 114 | } 115 | } 116 | } 117 | } 118 | } 119 | 120 | func (im *InteractiveMode) loadResults(query string) error { 121 | results, err := im.store.Search(storage.SearchOptions{ 122 | Query: query, 123 | SortBy: "last_used", 124 | SortOrder: "desc", 125 | }) 126 | if err != nil { 127 | return fmt.Errorf("failed to load clips: %w", err) 128 | } 129 | im.results = results 130 | im.selected = 0 131 | im.offset = 0 132 | return nil 133 | } 134 | 135 | func (im *InteractiveMode) pasteSelected() error { 136 | selected := im.results[im.selected] 137 | searchCmd := NewSearchCommand(im.store) 138 | im.screen.Fini() 139 | return searchCmd.Paste(selected.Clip.ID) 140 | } 141 | 142 | func (im *InteractiveMode) moveSelection(delta int) { 143 | im.selected += delta 144 | if im.selected < 0 { 145 | im.selected = 0 146 | } 147 | if im.selected >= len(im.results) { 148 | im.selected = len(im.results) - 1 149 | } 150 | 151 | // Adjust offset for scrolling 152 | _, height := im.screen.Size() 153 | visibleHeight := height - 5 // Account for header and footer 154 | 155 | if im.selected-im.offset >= visibleHeight { 156 | im.offset = im.selected - visibleHeight + 1 157 | } else if im.selected < im.offset { 158 | im.offset = im.selected 159 | } 160 | } 161 | 162 | func (im *InteractiveMode) draw() { 163 | im.screen.Clear() 164 | width, height := im.screen.Size() 165 | 166 | // Draw header 167 | headerStyle := tcell.StyleDefault.Reverse(true) 168 | header := " Clipboard History " 169 | drawStringCenter(im.screen, 0, header, headerStyle) 170 | 171 | // Draw help text 172 | helpStyle := tcell.StyleDefault.Foreground(tcell.ColorYellow) 173 | help := "↑/k:Up ↓/j:Down Enter:Paste g/G:Top/Bottom /:Search Esc/q:Quit" 174 | drawStringCenter(im.screen, 1, help, helpStyle) 175 | 176 | // Draw search bar if in search mode 177 | if im.searchMode { 178 | searchStyle := tcell.StyleDefault.Reverse(true) 179 | searchPrompt := fmt.Sprintf(" Search: %s█", im.searchText) 180 | drawString(im.screen, 0, 2, searchPrompt, searchStyle) 181 | } else { 182 | // Draw separator 183 | drawString(im.screen, 0, 2, strings.Repeat("─", width), tcell.StyleDefault) 184 | } 185 | 186 | // Draw results 187 | visibleHeight := height - 5 188 | endIdx := im.offset + visibleHeight 189 | if endIdx > len(im.results) { 190 | endIdx = len(im.results) 191 | } 192 | 193 | for i, result := range im.results[im.offset:endIdx] { 194 | y := i + 3 195 | style := tcell.StyleDefault 196 | 197 | if i+im.offset == im.selected { 198 | style = style.Reverse(true) 199 | } 200 | 201 | preview := getPreview(result.Clip) 202 | if len(preview) > width-20 { 203 | preview = preview[:width-23] + "..." 204 | } 205 | 206 | line := fmt.Sprintf(" %-3s %-10s %s", 207 | result.Clip.ID, 208 | truncate(result.Clip.Type, 10), 209 | preview, 210 | ) 211 | drawString(im.screen, 0, y, line, style) 212 | } 213 | 214 | // Draw footer 215 | if len(im.results) > 0 { 216 | status := fmt.Sprintf(" %d/%d ", im.selected+1, len(im.results)) 217 | drawString(im.screen, width-len(status), height-1, status, tcell.StyleDefault) 218 | } 219 | 220 | im.screen.Show() 221 | } 222 | 223 | func drawString(s tcell.Screen, x, y int, str string, style tcell.Style) { 224 | for i, r := range str { 225 | s.SetContent(x+i, y, r, nil, style) 226 | } 227 | } 228 | 229 | func drawStringCenter(s tcell.Screen, y int, str string, style tcell.Style) { 230 | w, _ := s.Size() 231 | x := (w - len(str)) / 2 232 | if x < 0 { 233 | x = 0 234 | } 235 | drawString(s, x, y, str, style) 236 | } 237 | 238 | func truncate(s string, maxLen int) string { 239 | if len(s) <= maxLen { 240 | return s + strings.Repeat(" ", maxLen-len(s)) 241 | } 242 | return s[:maxLen-3] + "..." 243 | } 244 | -------------------------------------------------------------------------------- /generate_icons.js: -------------------------------------------------------------------------------- 1 | import puppeteer from 'puppeteer'; 2 | import fs from 'fs'; 3 | import { promises as fsPromises } from 'fs'; 4 | import path from 'path'; 5 | import { exec } from 'child_process'; 6 | import { promisify } from 'util'; 7 | 8 | const execAsync = promisify(exec); 9 | const sleep = ms => new Promise(resolve => setTimeout(resolve, ms)); 10 | 11 | async function generateIcons() { 12 | try { 13 | const iconsetPath = 'ClipboardManager/ClipboardManager/Assets.xcassets/AppIcon.appiconset'; 14 | await fsPromises.mkdir(iconsetPath, { recursive: true }); 15 | 16 | // Launch browser with high DPI 17 | const browser = await puppeteer.launch({ 18 | headless: 'new', 19 | args: ['--no-sandbox'], 20 | defaultViewport: { 21 | width: 1024, 22 | height: 1024, 23 | deviceScaleFactor: 2 24 | } 25 | }); 26 | 27 | const page = await browser.newPage(); 28 | 29 | // Load the icon page 30 | const htmlPath = path.join(process.cwd(), 'icon_generator.html'); 31 | const htmlContent = await fsPromises.readFile(htmlPath, 'utf8'); 32 | await page.setContent(htmlContent); 33 | 34 | // Wait for any animations to settle 35 | await sleep(1000); 36 | 37 | // Capture the icon at highest resolution 38 | const iconElement = await page.$('.icon'); 39 | if (!iconElement) { 40 | throw new Error('Could not find icon element'); 41 | } 42 | 43 | // Save the base 1024x1024 icon 44 | await iconElement.screenshot({ 45 | path: path.join(iconsetPath, 'icon_1024.png'), 46 | omitBackground: true 47 | }); 48 | 49 | await browser.close(); 50 | 51 | // Generate all required sizes including 64px for 32@2x 52 | const sizes = [ 53 | { size: 16, name: 'icon_16.png' }, 54 | { size: 32, name: 'icon_32.png' }, 55 | { size: 64, name: 'icon_64.png' }, // For 32@2x 56 | { size: 128, name: 'icon_128.png' }, 57 | { size: 256, name: 'icon_256.png' }, 58 | { size: 512, name: 'icon_512.png' } 59 | ]; 60 | 61 | // Generate all sizes 62 | for (const { size, name } of sizes) { 63 | await execAsync( 64 | `sips -z ${size} ${size} "${path.join(iconsetPath, 'icon_1024.png')}" --out "${path.join(iconsetPath, name)}"` 65 | ); 66 | console.log(`Generated ${name}`); 67 | } 68 | 69 | // Create 2x versions by copying appropriate sizes 70 | const copies = [ 71 | { from: 'icon_32.png', to: 'icon_16@2x.png' }, 72 | { from: 'icon_64.png', to: 'icon_32@2x.png' }, 73 | { from: 'icon_256.png', to: 'icon_128@2x.png' }, 74 | { from: 'icon_512.png', to: 'icon_256@2x.png' }, 75 | { from: 'icon_1024.png', to: 'icon_512@2x.png' } 76 | ]; 77 | 78 | for (const { from, to } of copies) { 79 | await execAsync( 80 | `cp "${path.join(iconsetPath, from)}" "${path.join(iconsetPath, to)}"` 81 | ); 82 | console.log(`Created ${to} from ${from}`); 83 | } 84 | 85 | // Clean up intermediate files 86 | await execAsync(`rm "${path.join(iconsetPath, 'icon_64.png')}"`); 87 | 88 | console.log('Icon generation complete! Generated all required sizes for macOS app icon.'); 89 | } catch (error) { 90 | console.error('Error generating icons:', error); 91 | process.exit(1); 92 | } 93 | } 94 | 95 | generateIcons().catch(console.error); 96 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module clipboard-manager 2 | 3 | go 1.21 4 | 5 | require ( 6 | github.com/gdamore/tcell/v2 v2.7.4 7 | github.com/go-chi/chi/v5 v5.2.0 8 | github.com/gorilla/websocket v1.5.3 9 | github.com/progrium/darwinkit v0.5.0 10 | gorm.io/driver/sqlite v1.5.7 11 | gorm.io/gorm v1.25.12 12 | ) 13 | 14 | require ( 15 | github.com/gdamore/encoding v1.0.0 // indirect 16 | github.com/jinzhu/inflection v1.0.0 // indirect 17 | github.com/jinzhu/now v1.1.5 // indirect 18 | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect 19 | github.com/mattn/go-runewidth v0.0.15 // indirect 20 | github.com/mattn/go-sqlite3 v1.14.22 // indirect 21 | github.com/rivo/uniseg v0.4.3 // indirect 22 | golang.org/x/sys v0.17.0 // indirect 23 | golang.org/x/term v0.17.0 // indirect 24 | golang.org/x/text v0.14.0 // indirect 25 | ) 26 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/gdamore/encoding v1.0.0 h1:+7OoQ1Bc6eTm5niUzBa0Ctsh6JbMW6Ra+YNuAtDBdko= 2 | github.com/gdamore/encoding v1.0.0/go.mod h1:alR0ol34c49FCSBLjhosxzcPHQbf2trDkoo5dl+VrEg= 3 | github.com/gdamore/tcell/v2 v2.7.4 h1:sg6/UnTM9jGpZU+oFYAsDahfchWAFW8Xx2yFinNSAYU= 4 | github.com/gdamore/tcell/v2 v2.7.4/go.mod h1:dSXtXTSK0VsW1biw65DZLZ2NKr7j0qP/0J7ONmsraWg= 5 | github.com/go-chi/chi/v5 v5.2.0 h1:Aj1EtB0qR2Rdo2dG4O94RIU35w2lvQSj6BRA4+qwFL0= 6 | github.com/go-chi/chi/v5 v5.2.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= 7 | github.com/go-test/deep v1.1.0 h1:WOcxcdHcvdgThNXjw0t76K42FXTU7HpNQWHpA2HHNlg= 8 | github.com/go-test/deep v1.1.0/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= 9 | github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= 10 | github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= 11 | github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= 12 | github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= 13 | github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= 14 | github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= 15 | github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= 16 | github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 17 | github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= 18 | github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 19 | github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= 20 | github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= 21 | github.com/progrium/darwinkit v0.5.0 h1:SwchcMbTOG1py3CQsINmGlsRmYKdlFrbnv3dE4aXA0s= 22 | github.com/progrium/darwinkit v0.5.0/go.mod h1:PxQhZuftnALLkCVaR8LaHtUOfoo4pm8qUDG+3C/sXNs= 23 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 24 | github.com/rivo/uniseg v0.4.3 h1:utMvzDsuh3suAEnhH0RdHmoPbU648o6CvXxTx4SBMOw= 25 | github.com/rivo/uniseg v0.4.3/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 26 | github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 27 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 28 | golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 29 | golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= 30 | golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= 31 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 32 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 33 | golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= 34 | golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= 35 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 36 | golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 37 | golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 38 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 39 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 40 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 41 | golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 42 | golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 43 | golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 44 | golang.org/x/sys v0.17.0 h1:25cE3gD+tdBA7lp7QfhuV+rJiE9YXTcS3VG1SqssI/Y= 45 | golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 46 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 47 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 48 | golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= 49 | golang.org/x/term v0.17.0 h1:mkTF7LCd6WGJNL3K1Ad7kwxNfYAW6a8a8QqtMblp/4U= 50 | golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= 51 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 52 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 53 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 54 | golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 55 | golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= 56 | golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= 57 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 58 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 59 | golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= 60 | golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= 61 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 62 | gorm.io/driver/sqlite v1.5.7 h1:8NvsrhP0ifM7LX9G4zPB97NwovUakUxc+2V2uuf3Z1I= 63 | gorm.io/driver/sqlite v1.5.7/go.mod h1:U+J8craQU6Fzkcvu8oLeAQmi50TkwPEhHDEjQZXDah4= 64 | gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8= 65 | gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ= 66 | -------------------------------------------------------------------------------- /icon_generator.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 117 | 118 | 119 |
120 |
121 |
122 |
123 |
124 |
125 |
126 |
127 |
128 |
129 | 130 | 131 | -------------------------------------------------------------------------------- /images/app.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hp77-creator/rockstar/6d91a69b6b9f560b7e50c732ed7bbdef0788cfd6/images/app.png -------------------------------------------------------------------------------- /images/cover.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hp77-creator/rockstar/6d91a69b6b9f560b7e50c732ed7bbdef0788cfd6/images/cover.png -------------------------------------------------------------------------------- /images/search.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hp77-creator/rockstar/6d91a69b6b9f560b7e50c732ed7bbdef0788cfd6/images/search.png -------------------------------------------------------------------------------- /internal/clipboard/monitor.go: -------------------------------------------------------------------------------- 1 | package clipboard 2 | 3 | import "clipboard-manager/pkg/types" 4 | 5 | type Monitor interface { 6 | Start() error 7 | Stop() error 8 | OnChange(handler func(types.Clip)) 9 | // SetContent sets the system clipboard content 10 | SetContent(clip types.Clip) error 11 | } 12 | -------------------------------------------------------------------------------- /internal/clipboard/monitor_darwin.go: -------------------------------------------------------------------------------- 1 | package clipboard 2 | 3 | import ( 4 | "clipboard-manager/pkg/types" 5 | "fmt" 6 | "os" 7 | "runtime" 8 | "sync" 9 | "time" 10 | 11 | "github.com/progrium/darwinkit/macos/appkit" 12 | ) 13 | 14 | var debugMode = os.Getenv("DEBUG") == "1" 15 | 16 | func debugLog(format string, args ...interface{}) { 17 | if debugMode { 18 | fmt.Printf("[DEBUG] "+format, args...) 19 | } 20 | } 21 | 22 | type pasteboardOp struct { 23 | clip types.Clip 24 | done chan error 25 | } 26 | 27 | type DarwinMonitor struct { 28 | handler func(types.Clip) 29 | pasteboard appkit.Pasteboard 30 | changeCount int 31 | mutex sync.RWMutex 32 | stopChan chan struct{} 33 | opChan chan pasteboardOp 34 | } 35 | 36 | func init() { 37 | // Ensure we're on the main thread for AppKit operations 38 | runtime.LockOSThread() 39 | } 40 | 41 | func NewMonitor() Monitor { 42 | m := &DarwinMonitor{ 43 | pasteboard: appkit.Pasteboard_GeneralPasteboard(), 44 | stopChan: make(chan struct{}), 45 | opChan: make(chan pasteboardOp), 46 | } 47 | 48 | // Start a goroutine on the main thread to handle pasteboard operations 49 | go func() { 50 | runtime.LockOSThread() 51 | for { 52 | select { 53 | case <-m.stopChan: 54 | return 55 | case op := <-m.opChan: 56 | op.done <- m.setPasteboardContent(op.clip) 57 | } 58 | } 59 | }() 60 | 61 | return m 62 | } 63 | 64 | func (m *DarwinMonitor) Start() error { 65 | m.mutex.Lock() 66 | initialCount := m.pasteboard.ChangeCount() 67 | m.changeCount = initialCount 68 | m.mutex.Unlock() 69 | 70 | go func() { 71 | ticker := time.NewTicker(1 * time.Second) 72 | defer ticker.Stop() 73 | 74 | for { 75 | select { 76 | case <-ticker.C: 77 | m.checkForChanges() 78 | case <-m.stopChan: 79 | return 80 | } 81 | } 82 | }() 83 | 84 | return nil 85 | } 86 | 87 | func (m *DarwinMonitor) Stop() error { 88 | close(m.stopChan) 89 | return nil 90 | } 91 | 92 | // GetPasteboardTypes returns all available types in the pasteboard 93 | func (m *DarwinMonitor) GetPasteboardTypes() []string { 94 | m.mutex.RLock() 95 | defer m.mutex.RUnlock() 96 | 97 | var types []string 98 | for _, t := range m.pasteboard.Types() { 99 | types = append(types, string(t)) 100 | } 101 | 102 | // Add some common types to check if they exist 103 | commonTypes := []string{ 104 | "com.apple.pasteboard.promised-file-url", 105 | "com.apple.pasteboard.source", 106 | "com.apple.pasteboard.app", 107 | "com.apple.pasteboard.bundleid", 108 | "com.apple.pasteboard.application-name", 109 | "com.apple.pasteboard.creator", 110 | "com.apple.cocoa.pasteboard.source-type", 111 | } 112 | 113 | for _, t := range commonTypes { 114 | if data := m.pasteboard.StringForType(appkit.PasteboardType(t)); data != "" { 115 | types = append(types, t+" = "+data) 116 | } 117 | } 118 | 119 | return types 120 | } 121 | 122 | func (m *DarwinMonitor) OnChange(handler func(types.Clip)) { 123 | m.mutex.Lock() 124 | m.handler = handler 125 | m.mutex.Unlock() 126 | } 127 | 128 | // setPasteboardContent performs the actual pasteboard operations on the main thread 129 | func (m *DarwinMonitor) setPasteboardContent(clip types.Clip) error { 130 | m.mutex.Lock() 131 | defer m.mutex.Unlock() 132 | 133 | debugLog("Debug: Setting pasteboard content - Type: %s, Content Length: %d\n", clip.Type, len(clip.Content)) 134 | 135 | // Clear the pasteboard first 136 | m.pasteboard.ClearContents() 137 | 138 | switch clip.Type { 139 | case "text/plain": 140 | m.pasteboard.SetStringForType(string(clip.Content), appkit.PasteboardType("public.utf8-plain-text")) 141 | case "text": 142 | m.pasteboard.SetStringForType(string(clip.Content), appkit.PasteboardType("public.utf8-plain-text")) 143 | case "image/png": 144 | m.pasteboard.SetDataForType(clip.Content, appkit.PasteboardType("public.png")) 145 | case "image/tiff": 146 | m.pasteboard.SetDataForType(clip.Content, appkit.PasteboardType("public.tiff")) 147 | case "screenshot": 148 | // For screenshots, try PNG first, then TIFF 149 | m.pasteboard.SetDataForType(clip.Content, appkit.PasteboardType("public.png")) 150 | case "file": 151 | m.pasteboard.SetStringForType(string(clip.Content), appkit.PasteboardType("public.file-url")) 152 | case "text/html": 153 | // For HTML content, set both HTML and plain text 154 | m.pasteboard.SetStringForType(string(clip.Content), appkit.PasteboardType("public.html")) 155 | if plainText := string(clip.Content); plainText != "" { 156 | m.pasteboard.SetStringForType(plainText, appkit.PasteboardType("public.utf8-plain-text")) 157 | } 158 | default: 159 | // Try as plain text for unknown types 160 | if plainText := string(clip.Content); plainText != "" { 161 | m.pasteboard.SetStringForType(plainText, appkit.PasteboardType("public.utf8-plain-text")) 162 | debugLog("Debug: Set unknown type as plain text, length: %d\n", len(plainText)) 163 | return nil 164 | } 165 | return fmt.Errorf("unsupported content type: %s", clip.Type) 166 | } 167 | 168 | // Update change count to prevent re-triggering the monitor 169 | m.changeCount = m.pasteboard.ChangeCount() 170 | debugLog("Debug: Successfully set pasteboard content, new count: %d\n", m.changeCount) 171 | debugLog("Debug: Content set to clipboard - ready for manual paste\n") 172 | return nil 173 | } 174 | 175 | // SetContent sets the system clipboard content by sending the operation to the main thread 176 | func (m *DarwinMonitor) SetContent(clip types.Clip) error { 177 | done := make(chan error, 1) 178 | m.opChan <- pasteboardOp{ 179 | clip: clip, 180 | done: done, 181 | } 182 | return <-done 183 | } 184 | 185 | func (m *DarwinMonitor) checkForChanges() { 186 | m.mutex.Lock() 187 | currentCount := m.pasteboard.ChangeCount() 188 | previousCount := m.changeCount 189 | m.mutex.Unlock() 190 | 191 | if currentCount != previousCount { 192 | debugLog("Debug: Clipboard change detected (count: %d -> %d)\n", previousCount, currentCount) 193 | 194 | // Get clipboard content 195 | var clip types.Clip 196 | clip.CreatedAt = time.Now() 197 | 198 | m.mutex.Lock() 199 | m.changeCount = currentCount 200 | m.mutex.Unlock() 201 | 202 | // Try different content types in order 203 | handled := false 204 | 205 | // Check for text content 206 | if text := m.pasteboard.StringForType(appkit.PasteboardType("public.utf8-plain-text")); text != "" { 207 | clip.Content = []byte(text) 208 | clip.Type = "text/plain" 209 | handled = true 210 | } 211 | 212 | // Check for screenshot or image content 213 | if !handled { 214 | // Try PNG 215 | if data := m.pasteboard.DataForType(appkit.PasteboardType("public.png")); len(data) > 0 { 216 | clip.Content = data 217 | clip.Type = "image/png" 218 | 219 | // Check if it's a screenshot by looking for screenshot-specific metadata 220 | hasWindowID := false 221 | for _, t := range m.pasteboard.Types() { 222 | if t == appkit.PasteboardType("com.apple.screencapture.window-id") { 223 | hasWindowID = true 224 | break 225 | } 226 | } 227 | if hasWindowID { 228 | clip.Type = "screenshot" 229 | if windowTitle := m.pasteboard.StringForType(appkit.PasteboardType("com.apple.screencapture.window-name")); windowTitle != "" { 230 | clip.Metadata.SourceApp = windowTitle 231 | } 232 | } 233 | 234 | handled = true 235 | } 236 | } 237 | 238 | // Check for TIFF image 239 | if !handled { 240 | if data := m.pasteboard.DataForType(appkit.PasteboardType("public.tiff")); len(data) > 0 { 241 | clip.Content = data 242 | clip.Type = "image/tiff" 243 | 244 | // Similar screenshot check for TIFF 245 | hasWindowID := false 246 | for _, t := range m.pasteboard.Types() { 247 | if t == appkit.PasteboardType("com.apple.screencapture.window-id") { 248 | hasWindowID = true 249 | break 250 | } 251 | } 252 | if hasWindowID { 253 | clip.Type = "screenshot" 254 | if windowTitle := m.pasteboard.StringForType(appkit.PasteboardType("com.apple.screencapture.window-name")); windowTitle != "" { 255 | clip.Metadata.SourceApp = windowTitle 256 | } 257 | } 258 | 259 | handled = true 260 | } 261 | } 262 | 263 | // Check for file URLs 264 | if !handled { 265 | if urls := m.pasteboard.StringForType(appkit.PasteboardType("public.file-url")); urls != "" { 266 | clip.Content = []byte(urls) 267 | clip.Type = "file" 268 | handled = true 269 | } 270 | } 271 | 272 | if handled { 273 | m.mutex.Lock() 274 | types := m.pasteboard.Types() 275 | m.mutex.Unlock() 276 | 277 | // Print all pasteboard types in debug mode 278 | if debugMode { 279 | debugLog("Available pasteboard types:\n") 280 | for _, t := range types { 281 | m.mutex.Lock() 282 | val := m.pasteboard.StringForType(t) 283 | m.mutex.Unlock() 284 | 285 | if val != "" { 286 | debugLog(" %s = %s\n", t, val) 287 | } else { 288 | debugLog(" %s (no string value)\n", t) 289 | } 290 | } 291 | } 292 | 293 | // Try to determine source application using multiple methods 294 | m.mutex.Lock() 295 | sourceURL := m.pasteboard.StringForType(appkit.PasteboardType("org.chromium.source-url")) 296 | m.mutex.Unlock() 297 | 298 | if sourceURL != "" { 299 | // Content is from a web browser 300 | if sourceURL != "" { 301 | clip.Metadata.SourceApp = "Chrome" 302 | debugLog("Debug: Source from Chrome URL: %s\n", sourceURL) 303 | } 304 | } else { 305 | // Try other methods 306 | m.mutex.Lock() 307 | sourceApp := m.pasteboard.StringForType(appkit.PasteboardType("com.apple.pasteboard.app")) 308 | m.mutex.Unlock() 309 | 310 | if sourceApp != "" { 311 | clip.Metadata.SourceApp = sourceApp 312 | debugLog("Debug: Source from pasteboard metadata: %s\n", sourceApp) 313 | } else { 314 | m.mutex.Lock() 315 | bundleID := m.pasteboard.StringForType(appkit.PasteboardType("com.apple.pasteboard.bundleid")) 316 | m.mutex.Unlock() 317 | 318 | if bundleID != "" { 319 | if apps := appkit.RunningApplication_RunningApplicationsWithBundleIdentifier(bundleID); len(apps) > 0 { 320 | clip.Metadata.SourceApp = apps[0].LocalizedName() 321 | debugLog("Debug: Source from bundle ID: %s (%s)\n", apps[0].LocalizedName(), bundleID) 322 | } 323 | } else if app := appkit.Workspace_SharedWorkspace().FrontmostApplication(); app.LocalizedName() != "" { 324 | // Only use frontmost app if it's not VS Code (which might just be our active editor) 325 | if app.BundleIdentifier() != "com.microsoft.VSCode" { 326 | clip.Metadata.SourceApp = app.LocalizedName() 327 | debugLog("Debug: Source from frontmost app: %s (%s)\n", 328 | app.LocalizedName(), app.BundleIdentifier()) 329 | } else { 330 | debugLog("Debug: Ignoring VS Code as source\n") 331 | } 332 | } 333 | } 334 | } 335 | 336 | if clip.Metadata.SourceApp == "" { 337 | debugLog("Debug: Could not determine source application\n") 338 | } 339 | 340 | if m.handler != nil { 341 | m.handler(clip) 342 | } 343 | } 344 | } 345 | } 346 | -------------------------------------------------------------------------------- /internal/obsidian/sync.go: -------------------------------------------------------------------------------- 1 | package obsidian 2 | 3 | import ( 4 | "clipboard-manager/internal/storage" 5 | "context" 6 | "fmt" 7 | "log" 8 | "os" 9 | "path/filepath" 10 | "strings" 11 | "sync" 12 | "time" 13 | ) 14 | 15 | // SyncService handles syncing clipboard content to Obsidian vault 16 | type SyncService struct { 17 | store storage.Storage 18 | vaultPath string 19 | syncTicker *time.Ticker 20 | done chan struct{} 21 | mu sync.RWMutex // Protects vaultPath 22 | } 23 | 24 | // UpdateVaultPath updates the vault path while the service is running 25 | func (s *SyncService) UpdateVaultPath(path string) error { 26 | // Verify new path exists 27 | if _, err := os.Stat(path); os.IsNotExist(err) { 28 | return fmt.Errorf("new vault path does not exist: %s", path) 29 | } 30 | 31 | s.mu.Lock() 32 | defer s.mu.Unlock() 33 | 34 | log.Printf("Updating vault path from %s to %s", s.vaultPath, path) 35 | s.vaultPath = path 36 | return nil 37 | } 38 | 39 | // Config holds configuration for the Obsidian sync service 40 | type Config struct { 41 | VaultPath string 42 | SyncInterval time.Duration 43 | } 44 | 45 | // New creates a new Obsidian sync service 46 | func New(store storage.Storage, config Config) (*SyncService, error) { 47 | if config.VaultPath == "" { 48 | return nil, fmt.Errorf("vault path is required") 49 | } 50 | 51 | // Verify vault path exists 52 | if _, err := os.Stat(config.VaultPath); os.IsNotExist(err) { 53 | return nil, fmt.Errorf("vault path does not exist: %s", config.VaultPath) 54 | } 55 | 56 | // Validate sync interval 57 | if config.SyncInterval <= 0 { 58 | return nil, fmt.Errorf("sync interval must be positive, got: %v", config.SyncInterval) 59 | } 60 | 61 | return &SyncService{ 62 | store: store, 63 | vaultPath: config.VaultPath, 64 | syncTicker: time.NewTicker(config.SyncInterval), 65 | done: make(chan struct{}), 66 | }, nil 67 | } 68 | 69 | // Start begins the sync service 70 | func (s *SyncService) Start(ctx context.Context) error { 71 | log.Printf("Starting Obsidian sync service (vault: %s)", s.vaultPath) 72 | 73 | // Perform initial sync 74 | if err := s.sync(ctx); err != nil { 75 | log.Printf("Initial sync error: %v", err) 76 | } 77 | 78 | go func() { 79 | for { 80 | select { 81 | case <-ctx.Done(): 82 | log.Printf("Obsidian sync service stopped (context done)") 83 | return 84 | case <-s.done: 85 | log.Printf("Obsidian sync service stopped (done signal)") 86 | return 87 | case <-s.syncTicker.C: 88 | log.Printf("Running scheduled sync...") 89 | if err := s.sync(ctx); err != nil { 90 | log.Printf("Error during sync: %v", err) 91 | } 92 | } 93 | } 94 | }() 95 | 96 | return nil 97 | } 98 | 99 | // Stop stops the sync service 100 | func (s *SyncService) Stop() { 101 | log.Printf("Stopping Obsidian sync service") 102 | if s.syncTicker != nil { 103 | s.syncTicker.Stop() 104 | } 105 | select { 106 | case <-s.done: 107 | // Already closed 108 | default: 109 | close(s.done) 110 | } 111 | log.Printf("Obsidian sync service stopped") 112 | } 113 | 114 | // UpdateSyncInterval updates the sync interval while the service is running 115 | func (s *SyncService) UpdateSyncInterval(interval time.Duration) { 116 | if interval <= 0 { 117 | log.Printf("Warning: Ignoring non-positive sync interval: %v", interval) 118 | return 119 | } 120 | log.Printf("Updating sync interval to %v", interval) 121 | if s.syncTicker != nil { 122 | s.syncTicker.Reset(interval) 123 | } 124 | } 125 | 126 | // sync performs the actual synchronization 127 | func (s *SyncService) sync(ctx context.Context) error { 128 | log.Printf("Starting sync operation in vault: %s", s.vaultPath) 129 | 130 | // Get current vault path (thread-safe) 131 | s.mu.RLock() 132 | vaultPath := s.vaultPath 133 | s.mu.RUnlock() 134 | 135 | // Verify vault path still exists and is accessible 136 | if info, err := os.Stat(vaultPath); err != nil { 137 | return fmt.Errorf("vault path error: %w", err) 138 | } else { 139 | log.Printf("Vault path verified: %s (%s)", vaultPath, info.Mode()) 140 | } 141 | 142 | // Get unsynced clips 143 | clips, err := s.store.ListUnsynced(ctx, 100) // Adjust limit as needed 144 | if err != nil { 145 | return fmt.Errorf("failed to list clips: %w", err) 146 | } 147 | log.Printf("Found %d clips to process", len(clips)) 148 | 149 | for _, clip := range clips { 150 | // Process clip content 151 | log.Printf("Processing clip - ID: %s, Type: %s", clip.ID, clip.Type) 152 | 153 | // Convert content bytes to string 154 | content := string(clip.Content) 155 | if content == "" { 156 | log.Printf("Skipping empty content") 157 | continue 158 | } 159 | log.Printf("Content length: %d bytes", len(content)) 160 | 161 | // Generate filename based on date 162 | filename := fmt.Sprintf("%s.md", clip.CreatedAt.Format("2006-01-02")) 163 | clipboardDir := filepath.Join(vaultPath, "Clipboard") 164 | path := filepath.Join(clipboardDir, filename) 165 | 166 | log.Printf("File operations:") 167 | log.Printf("- Filename: %s", filename) 168 | log.Printf("- Clipboard dir: %s", clipboardDir) 169 | log.Printf("- Full path: %s", path) 170 | 171 | // Ensure Clipboard directory exists with proper permissions 172 | if err := os.MkdirAll(clipboardDir, 0755); err != nil { 173 | log.Printf("Failed to create directory: %v", err) 174 | return fmt.Errorf("failed to create directory: %w", err) 175 | } 176 | 177 | // Verify directory permissions 178 | if info, err := os.Stat(clipboardDir); err != nil { 179 | log.Printf("Failed to verify directory: %v", err) 180 | return fmt.Errorf("failed to verify directory: %w", err) 181 | } else { 182 | log.Printf("Directory permissions: %v", info.Mode().Perm()) 183 | if info.Mode().Perm()&0200 == 0 { // Check write permission 184 | log.Printf("Warning: No write permission on directory") 185 | return fmt.Errorf("no write permission on directory: %s", clipboardDir) 186 | } 187 | } 188 | log.Printf("Clipboard directory created/verified with write permissions") 189 | 190 | // Get tags from metadata 191 | tags := clip.Metadata.Tags 192 | log.Printf("Tags: %v", tags) 193 | 194 | // Generate entry content based on type 195 | var entryContent string 196 | if strings.HasPrefix(clip.Type, "image/") { 197 | // Create assets directory if it doesn't exist 198 | assetsDir := filepath.Join(clipboardDir, "assets") 199 | if err := os.MkdirAll(assetsDir, 0755); err != nil { 200 | log.Printf("Failed to create assets directory: %v", err) 201 | return fmt.Errorf("failed to create assets directory: %w", err) 202 | } 203 | 204 | // Generate unique image filename using timestamp 205 | imageFilename := fmt.Sprintf("%s-%s%s", 206 | clip.CreatedAt.Format("20060102-150405"), 207 | clip.ID, 208 | s.getImageExtension(clip.Type)) 209 | imagePath := filepath.Join(assetsDir, imageFilename) 210 | 211 | // Save image file 212 | if err := os.WriteFile(imagePath, clip.Content, 0644); err != nil { 213 | log.Printf("Failed to write image file: %v", err) 214 | return fmt.Errorf("failed to write image file: %w", err) 215 | } 216 | 217 | // Use relative path for markdown 218 | relImagePath := filepath.Join("assets", imageFilename) 219 | entryContent = fmt.Sprintf("![[%s]]", relImagePath) 220 | } else { 221 | entryContent = content 222 | } 223 | 224 | // Generate entry with metadata and content 225 | entry := fmt.Sprintf(` 226 | ## %s 227 | --- 228 | source: %s 229 | tags: [clipboard%s] 230 | type: %s 231 | --- 232 | 233 | %s 234 | 235 | `, 236 | clip.CreatedAt.Format("15:04:05"), 237 | clip.Metadata.SourceApp, 238 | s.formatTags(tags), 239 | clip.Type, 240 | entryContent) 241 | 242 | var fileContent string 243 | if _, err := os.Stat(path); os.IsNotExist(err) { 244 | // Create new file with date heading 245 | fileContent = fmt.Sprintf("# %s\n%s", 246 | clip.CreatedAt.Format("2006-01-02"), 247 | entry) 248 | } else { 249 | // Read existing file 250 | existingContent, err := os.ReadFile(path) 251 | if err != nil { 252 | log.Printf("Failed to read existing file: %v", err) 253 | return fmt.Errorf("failed to read existing file: %w", err) 254 | } 255 | fileContent = string(existingContent) + entry 256 | } 257 | 258 | // Write to file with explicit permissions 259 | log.Printf("Writing/Updating note: %s", path) 260 | if err := os.WriteFile(path, []byte(fileContent), 0644); err != nil { 261 | log.Printf("Failed to write file: %v", err) 262 | return fmt.Errorf("failed to write file: %w", err) 263 | } 264 | 265 | // Verify file was created with correct permissions 266 | if info, err := os.Stat(path); err != nil { 267 | log.Printf("Failed to verify file: %v", err) 268 | return fmt.Errorf("failed to verify file: %w", err) 269 | } else { 270 | log.Printf("File created with permissions: %v", info.Mode().Perm()) 271 | } 272 | 273 | log.Printf("Successfully created note: %s", filename) 274 | 275 | // Mark clip as synced 276 | if err := s.store.MarkAsSynced(ctx, clip.ID); err != nil { 277 | log.Printf("Failed to mark clip as synced: %v", err) 278 | return fmt.Errorf("failed to mark clip as synced: %w", err) 279 | } 280 | log.Printf("Marked clip %s as synced", clip.ID) 281 | } 282 | 283 | log.Printf("Sync operation completed") 284 | return nil 285 | } 286 | 287 | // getImageExtension returns the appropriate file extension based on MIME type 288 | func (s *SyncService) getImageExtension(mimeType string) string { 289 | switch mimeType { 290 | case "image/png": 291 | return ".png" 292 | case "image/jpeg", "image/jpg": 293 | return ".jpg" 294 | case "image/gif": 295 | return ".gif" 296 | case "image/webp": 297 | return ".webp" 298 | case "image/svg+xml": 299 | return ".svg" 300 | default: 301 | return ".png" // default to png if unknown 302 | } 303 | } 304 | 305 | // formatTags formats tags for frontmatter 306 | func (s *SyncService) formatTags(tags []string) string { 307 | if len(tags) == 0 { 308 | return "" 309 | } 310 | 311 | var formattedTags []string 312 | for _, tag := range tags { 313 | // Clean tag: remove spaces and special characters 314 | cleanTag := strings.Map(func(r rune) rune { 315 | if r == ' ' { 316 | return '-' 317 | } 318 | return r 319 | }, tag) 320 | formattedTags = append(formattedTags, cleanTag) 321 | } 322 | 323 | return ", " + strings.Join(formattedTags, ", ") 324 | } 325 | -------------------------------------------------------------------------------- /internal/server/pid.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | "strconv" 8 | "syscall" 9 | ) 10 | 11 | // pidFile manages the PID file for the server 12 | type pidFile struct { 13 | path string 14 | } 15 | 16 | // newPIDFile creates a new PID file manager 17 | func newPIDFile() (*pidFile, error) { 18 | homeDir, err := os.UserHomeDir() 19 | if err != nil { 20 | return nil, fmt.Errorf("failed to get home directory: %w", err) 21 | } 22 | 23 | // Use same directory as other app files 24 | pidDir := filepath.Join(homeDir, ".clipboard-manager") 25 | if err := os.MkdirAll(pidDir, 0755); err != nil { 26 | return nil, fmt.Errorf("failed to create PID directory: %w", err) 27 | } 28 | 29 | return &pidFile{ 30 | path: filepath.Join(pidDir, "clipboard-manager.pid"), 31 | }, nil 32 | } 33 | 34 | // write writes the current process PID to the PID file 35 | func (p *pidFile) write() error { 36 | pid := os.Getpid() 37 | return os.WriteFile(p.path, []byte(strconv.Itoa(pid)), 0644) 38 | } 39 | 40 | // read reads the PID from the PID file 41 | func (p *pidFile) read() (int, error) { 42 | data, err := os.ReadFile(p.path) 43 | if err != nil { 44 | if os.IsNotExist(err) { 45 | return 0, nil 46 | } 47 | return 0, err 48 | } 49 | 50 | pid, err := strconv.Atoi(string(data)) 51 | if err != nil { 52 | return 0, fmt.Errorf("invalid PID in file: %w", err) 53 | } 54 | 55 | return pid, nil 56 | } 57 | 58 | // remove removes the PID file 59 | func (p *pidFile) remove() error { 60 | if err := os.Remove(p.path); err != nil && !os.IsNotExist(err) { 61 | return fmt.Errorf("failed to remove PID file: %w", err) 62 | } 63 | return nil 64 | } 65 | 66 | // isRunning checks if a process with the given PID is running 67 | func isRunning(pid int) bool { 68 | process, err := os.FindProcess(pid) 69 | if err != nil { 70 | return false 71 | } 72 | 73 | // On Unix systems, FindProcess always succeeds, so we need to check if the process actually exists 74 | err = process.Signal(syscall.Signal(0)) 75 | return err == nil 76 | } 77 | 78 | // killProcess attempts to kill a process with the given PID 79 | func killProcess(pid int) error { 80 | process, err := os.FindProcess(pid) 81 | if err != nil { 82 | return fmt.Errorf("failed to find process: %w", err) 83 | } 84 | 85 | // First try SIGTERM for graceful shutdown 86 | if err := process.Signal(syscall.SIGTERM); err != nil { 87 | // If SIGTERM fails, force kill with SIGKILL 88 | if err := process.Kill(); err != nil { 89 | return fmt.Errorf("failed to kill process: %w", err) 90 | } 91 | } 92 | 93 | return nil 94 | } 95 | -------------------------------------------------------------------------------- /internal/server/server.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "clipboard-manager/internal/service" 5 | "clipboard-manager/internal/storage" 6 | "context" 7 | "encoding/json" 8 | "fmt" 9 | "log" 10 | "net/http" 11 | "strconv" 12 | "time" 13 | 14 | "github.com/go-chi/chi/v5" 15 | "github.com/go-chi/chi/v5/middleware" 16 | ) 17 | 18 | type Server struct { 19 | clipService *service.ClipboardService 20 | srv *http.Server 21 | config Config 22 | pidFile *pidFile 23 | hub *Hub 24 | } 25 | 26 | type Config struct { 27 | Port int 28 | } 29 | 30 | func New(clipService *service.ClipboardService, config Config) (*Server, error) { 31 | pidFile, err := newPIDFile() 32 | if err != nil { 33 | return nil, fmt.Errorf("failed to create PID file manager: %w", err) 34 | } 35 | 36 | hub := newHub() 37 | go hub.run() 38 | 39 | // Create server instance 40 | server := &Server{ 41 | clipService: clipService, 42 | config: config, 43 | pidFile: pidFile, 44 | hub: hub, 45 | } 46 | 47 | // Register the hub as a clipboard change handler 48 | clipService.RegisterHandler(hub) 49 | 50 | return server, nil 51 | } 52 | 53 | func (s *Server) Start() error { 54 | // Check for existing process 55 | if existingPID, err := s.pidFile.read(); err != nil { 56 | return fmt.Errorf("failed to read PID file: %w", err) 57 | } else if existingPID != 0 { 58 | if isRunning(existingPID) { 59 | log.Printf("Found existing clipboard manager process (PID: %d), attempting to terminate", existingPID) 60 | if err := killProcess(existingPID); err != nil { 61 | return fmt.Errorf("failed to terminate existing process: %w", err) 62 | } 63 | // Give the process time to cleanup 64 | time.Sleep(500 * time.Millisecond) 65 | } 66 | // Clean up stale PID file 67 | if err := s.pidFile.remove(); err != nil { 68 | return fmt.Errorf("failed to remove stale PID file: %w", err) 69 | } 70 | } 71 | 72 | // Write current PID 73 | if err := s.pidFile.write(); err != nil { 74 | return fmt.Errorf("failed to write PID file: %w", err) 75 | } 76 | 77 | r := chi.NewRouter() 78 | 79 | // Middleware 80 | r.Use(middleware.Logger) 81 | r.Use(middleware.Recoverer) 82 | r.Use(middleware.Timeout(10 * time.Second)) 83 | 84 | // Routes 85 | r.Get("/status", s.handleStatus) 86 | r.Get("/ws", s.serveWs) // WebSocket endpoint 87 | r.Route("/api", func(r chi.Router) { 88 | r.Get("/clips", s.handleGetClips) 89 | r.Get("/clips/{index}", s.handleGetClip) 90 | r.Post("/clips/{index}/paste", s.handlePasteClip) 91 | r.Delete("/clips/id/{id}", s.handleDeleteClip) 92 | r.Delete("/clips", s.handleClearClips) 93 | r.Get("/search", s.handleSearch) 94 | }) 95 | 96 | // Try different addresses if one fails 97 | addresses := []string{ 98 | fmt.Sprintf("localhost:%d", s.config.Port), 99 | fmt.Sprintf("127.0.0.1:%d", s.config.Port), 100 | } 101 | 102 | var lastErr error 103 | for _, addr := range addresses { 104 | s.srv = &http.Server{ 105 | Addr: addr, 106 | Handler: r, 107 | } 108 | 109 | log.Printf("Attempting to start HTTP server on %s", addr) 110 | 111 | // Create a channel to signal server start 112 | serverErr := make(chan error, 1) 113 | 114 | go func() { 115 | if err := s.srv.ListenAndServe(); err != http.ErrServerClosed { 116 | serverErr <- fmt.Errorf("http server error on %s: %w", addr, err) 117 | } 118 | }() 119 | 120 | // Wait up to 2 seconds for server to start successfully 121 | select { 122 | case err := <-serverErr: 123 | lastErr = err 124 | log.Printf("Failed to start server on %s: %v", addr, err) 125 | continue 126 | case <-time.After(2 * time.Second): 127 | // Try to make a test request to verify server is responding 128 | client := &http.Client{Timeout: time.Second} 129 | resp, err := client.Get(fmt.Sprintf("http://%s/status", addr)) 130 | if err != nil { 131 | lastErr = fmt.Errorf("server health check failed: %v", err) 132 | log.Printf("Failed to verify server on %s: %v", addr, err) 133 | continue 134 | } 135 | resp.Body.Close() 136 | 137 | log.Printf("Server started and verified successfully on %s", addr) 138 | return nil 139 | } 140 | } 141 | 142 | return fmt.Errorf("failed to start server on any address: %v", lastErr) 143 | } 144 | 145 | func (s *Server) Stop() error { 146 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 147 | defer cancel() 148 | 149 | if err := s.srv.Shutdown(ctx); err != nil { 150 | return fmt.Errorf("error shutting down server: %w", err) 151 | } 152 | 153 | // Clean up PID file 154 | if err := s.pidFile.remove(); err != nil { 155 | log.Printf("Warning: failed to remove PID file: %v", err) 156 | } 157 | 158 | return nil 159 | } 160 | 161 | func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) { 162 | log.Printf("Status check from %s", r.RemoteAddr) 163 | w.Header().Set("Content-Type", "application/json") 164 | json.NewEncoder(w).Encode(map[string]string{ 165 | "status": "ok", 166 | "time": time.Now().Format(time.RFC3339), 167 | "addr": s.srv.Addr, 168 | }) 169 | } 170 | 171 | func (s *Server) handleGetClips(w http.ResponseWriter, r *http.Request) { 172 | // Get limit and offset from query params 173 | limit := 10 // default 174 | offset := 0 175 | if l := r.URL.Query().Get("limit"); l != "" { 176 | if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 { 177 | limit = parsed 178 | } 179 | } 180 | if o := r.URL.Query().Get("offset"); o != "" { 181 | if parsed, err := strconv.Atoi(o); err == nil && parsed >= 0 { 182 | offset = parsed 183 | } 184 | } 185 | 186 | clips, err := s.clipService.GetClips(r.Context(), limit, offset) 187 | if err != nil { 188 | http.Error(w, err.Error(), http.StatusInternalServerError) 189 | return 190 | } 191 | 192 | json.NewEncoder(w).Encode(clips) 193 | } 194 | 195 | func (s *Server) handleGetClip(w http.ResponseWriter, r *http.Request) { 196 | index, err := strconv.Atoi(chi.URLParam(r, "index")) 197 | if err != nil { 198 | http.Error(w, "invalid index", http.StatusBadRequest) 199 | return 200 | } 201 | 202 | clip, err := s.clipService.GetClipByIndex(r.Context(), index) 203 | if err != nil { 204 | http.Error(w, err.Error(), http.StatusNotFound) 205 | return 206 | } 207 | 208 | json.NewEncoder(w).Encode(clip) 209 | } 210 | 211 | func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) { 212 | query := r.URL.Query().Get("q") 213 | if query == "" { 214 | http.Error(w, "search query is required", http.StatusBadRequest) 215 | return 216 | } 217 | 218 | results, err := s.clipService.Search(r.Context(), storage.SearchOptions{ 219 | Query: query, 220 | Limit: 50, // reasonable default 221 | }) 222 | if err != nil { 223 | http.Error(w, err.Error(), http.StatusInternalServerError) 224 | return 225 | } 226 | 227 | json.NewEncoder(w).Encode(results) 228 | } 229 | 230 | func (s *Server) handleDeleteClip(w http.ResponseWriter, r *http.Request) { 231 | id := chi.URLParam(r, "id") 232 | if id == "" { 233 | http.Error(w, "clip ID is required", http.StatusBadRequest) 234 | return 235 | } 236 | 237 | if err := s.clipService.DeleteClip(r.Context(), id); err != nil { 238 | log.Printf("Error deleting clip %s: %v", id, err) 239 | http.Error(w, err.Error(), http.StatusInternalServerError) 240 | return 241 | } 242 | 243 | w.WriteHeader(http.StatusOK) 244 | } 245 | 246 | func (s *Server) handleClearClips(w http.ResponseWriter, r *http.Request) { 247 | if err := s.clipService.ClearClips(r.Context()); err != nil { 248 | log.Printf("Error clearing clips: %v", err) 249 | http.Error(w, err.Error(), http.StatusInternalServerError) 250 | return 251 | } 252 | 253 | w.WriteHeader(http.StatusOK) 254 | } 255 | 256 | func (s *Server) handlePasteClip(w http.ResponseWriter, r *http.Request) { 257 | index, err := strconv.Atoi(chi.URLParam(r, "index")) 258 | if err != nil { 259 | log.Printf("Invalid index parameter: %v", err) 260 | http.Error(w, "invalid index", http.StatusBadRequest) 261 | return 262 | } 263 | 264 | log.Printf("Handling paste request for index: %d", index) 265 | 266 | if err := s.clipService.PasteByIndex(r.Context(), index); err != nil { 267 | log.Printf("Error pasting clip at index %d: %v", index, err) 268 | 269 | // Create a detailed error response 270 | errorResponse := map[string]string{ 271 | "error": err.Error(), 272 | "detail": fmt.Sprintf("Failed to paste clip at index %d", index), 273 | } 274 | 275 | w.Header().Set("Content-Type", "application/json") 276 | w.WriteHeader(http.StatusInternalServerError) 277 | json.NewEncoder(w).Encode(errorResponse) 278 | return 279 | } 280 | 281 | log.Printf("Successfully pasted clip at index %d", index) 282 | w.WriteHeader(http.StatusOK) 283 | } 284 | -------------------------------------------------------------------------------- /internal/server/websocket.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "clipboard-manager/pkg/types" 5 | "encoding/json" 6 | "log" 7 | "net/http" 8 | "strings" 9 | "sync" 10 | 11 | "github.com/gorilla/websocket" 12 | ) 13 | 14 | var upgrader = websocket.Upgrader{ 15 | ReadBufferSize: 1024, 16 | WriteBufferSize: 1024, 17 | CheckOrigin: func(r *http.Request) bool { 18 | return true // Allow all origins in development 19 | }, 20 | } 21 | 22 | // Hub maintains the set of active clients and broadcasts messages to them 23 | type Hub struct { 24 | clients map[*Client]bool 25 | broadcast chan []byte 26 | register chan *Client 27 | unregister chan *Client 28 | mu sync.RWMutex 29 | } 30 | 31 | // Client is a middleman between the websocket connection and the hub 32 | type Client struct { 33 | hub *Hub 34 | conn *websocket.Conn 35 | send chan []byte 36 | } 37 | 38 | func newHub() *Hub { 39 | return &Hub{ 40 | broadcast: make(chan []byte), 41 | register: make(chan *Client), 42 | unregister: make(chan *Client), 43 | clients: make(map[*Client]bool), 44 | } 45 | } 46 | 47 | func (h *Hub) run() { 48 | for { 49 | select { 50 | case client := <-h.register: 51 | h.mu.Lock() 52 | h.clients[client] = true 53 | h.mu.Unlock() 54 | log.Printf("New client connected. Total clients: %d", len(h.clients)) 55 | 56 | case client := <-h.unregister: 57 | h.mu.Lock() 58 | if _, ok := h.clients[client]; ok { 59 | delete(h.clients, client) 60 | close(client.send) 61 | } 62 | h.mu.Unlock() 63 | log.Printf("Client disconnected. Total clients: %d", len(h.clients)) 64 | 65 | case message := <-h.broadcast: 66 | h.mu.RLock() 67 | for client := range h.clients { 68 | select { 69 | case client.send <- message: 70 | default: 71 | close(client.send) 72 | delete(h.clients, client) 73 | } 74 | } 75 | h.mu.RUnlock() 76 | } 77 | } 78 | } 79 | 80 | // HandleClipboardChange implements service.ClipboardChangeHandler 81 | func (h *Hub) HandleClipboardChange(clip types.Clip) { 82 | // Create a notification message 83 | notification := struct { 84 | Type string `json:"type"` 85 | Payload types.Clip `json:"payload"` 86 | }{ 87 | Type: "clipboard_change", 88 | Payload: clip, 89 | } 90 | 91 | // Marshal the notification 92 | message, err := json.Marshal(notification) 93 | if err != nil { 94 | log.Printf("Error marshaling clipboard notification: %v", err) 95 | return 96 | } 97 | 98 | h.broadcast <- message 99 | } 100 | 101 | // writePump pumps messages from the hub to the websocket connection 102 | func (c *Client) writePump() { 103 | defer func() { 104 | c.conn.Close() 105 | }() 106 | 107 | for { 108 | select { 109 | case message, ok := <-c.send: 110 | if !ok { 111 | // The hub closed the channel 112 | c.conn.WriteMessage(websocket.CloseMessage, []byte{}) 113 | return 114 | } 115 | 116 | w, err := c.conn.NextWriter(websocket.TextMessage) 117 | if err != nil { 118 | return 119 | } 120 | w.Write(message) 121 | 122 | if err := w.Close(); err != nil { 123 | return 124 | } 125 | } 126 | } 127 | } 128 | 129 | // serveWs handles websocket requests from clients 130 | func (s *Server) serveWs(w http.ResponseWriter, r *http.Request) { 131 | log.Printf("WebSocket connection attempt from %s", r.RemoteAddr) 132 | log.Printf("Request headers: %+v", r.Header) 133 | 134 | // Check if it's a websocket upgrade request 135 | if !websocket.IsWebSocketUpgrade(r) { 136 | log.Printf("Not a WebSocket upgrade request from %s", r.RemoteAddr) 137 | http.Error(w, "Expected WebSocket Upgrade", http.StatusBadRequest) 138 | return 139 | } 140 | 141 | conn, err := upgrader.Upgrade(w, r, nil) 142 | if err != nil { 143 | log.Printf("Error upgrading connection from %s: %v", r.RemoteAddr, err) 144 | // Check for specific upgrade errors 145 | if strings.Contains(err.Error(), "websocket: missing client key") { 146 | log.Printf("Client did not provide Sec-WebSocket-Key header") 147 | } 148 | if strings.Contains(err.Error(), "websocket: version != 13") { 149 | log.Printf("Unsupported WebSocket version") 150 | } 151 | return 152 | } 153 | 154 | log.Printf("WebSocket connection established with %s", r.RemoteAddr) 155 | 156 | client := &Client{ 157 | hub: s.hub, 158 | conn: conn, 159 | send: make(chan []byte, 256), 160 | } 161 | client.hub.register <- client 162 | 163 | // Start the write pump in a new goroutine 164 | go client.writePump() 165 | } 166 | -------------------------------------------------------------------------------- /internal/service/handler.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import "clipboard-manager/pkg/types" 4 | 5 | // ClipboardChangeHandler is implemented by components that need to be notified of clipboard changes 6 | type ClipboardChangeHandler interface { 7 | HandleClipboardChange(clip types.Clip) 8 | } 9 | -------------------------------------------------------------------------------- /internal/storage/constants.go: -------------------------------------------------------------------------------- 1 | package storage 2 | 3 | import "errors" 4 | 5 | const ( 6 | // Size thresholds 7 | MaxInlineStorageSize = 10 * 1024 * 1024 // 10MB - store in DB 8 | MaxStorageSize = 100 * 1024 * 1024 // 100MB - max total size 9 | 10 | // Content types 11 | TypeText = "text" 12 | TypeImage = "image" 13 | TypeFile = "file" 14 | ) 15 | 16 | // Storage errors 17 | var ( 18 | ErrFileTooLarge = errors.New("file size exceeds maximum allowed size") 19 | ErrInvalidType = errors.New("invalid content type") 20 | ) 21 | -------------------------------------------------------------------------------- /internal/storage/models.go: -------------------------------------------------------------------------------- 1 | package storage 2 | 3 | import ( 4 | "clipboard-manager/pkg/types" 5 | "database/sql/driver" // Provides interfaces for database interaction 6 | "encoding/json" // For JSON encoding/decoding 7 | "gorm.io/gorm" 8 | "strconv" 9 | "time" 10 | ) 11 | 12 | type JSON json.RawMessage 13 | 14 | // StringArray represents a string array that can be stored in SQLite 15 | // We implement sql.Scanner and driver.Valuer interfaces to handle 16 | // conversion between Go slice and SQLite JSON storage 17 | type StringArray []string 18 | 19 | // Scan implements sql.Scanner interface 20 | // This method is called when reading from database 21 | // It converts the stored JSON back into a Go string slice 22 | func (sa *StringArray) Scan(value interface{}) error { 23 | // Handle nil case (no tags) 24 | if value == nil { 25 | *sa = StringArray{} 26 | return nil 27 | } 28 | 29 | // Type assertion to handle different input types 30 | // value could be []byte or string depending on the driver 31 | var bytes []byte 32 | switch v := value.(type) { 33 | case []byte: 34 | bytes = v 35 | case string: 36 | bytes = []byte(v) 37 | default: 38 | bytes = []byte{} 39 | } 40 | 41 | // Convert JSON bytes back to string slice 42 | return json.Unmarshal(bytes, sa) 43 | } 44 | 45 | // Value implements driver.Valuer interface 46 | // This method is called when writing to database 47 | // It converts our string slice into JSON for storage 48 | func (sa StringArray) Value() (driver.Value, error) { 49 | if sa == nil { 50 | return nil, nil 51 | } 52 | // Convert slice to JSON bytes 53 | return json.Marshal(sa) 54 | } 55 | 56 | // ClipModel represents a clipboard entry in storage 57 | type ClipModel struct { 58 | gorm.Model 59 | ContentHash string `gorm:"type:string;uniqueIndex"` // SHA-256 hash for deduplication 60 | Content []byte `gorm:"type:blob"` // For inline storage 61 | StoragePath string `gorm:"type:string"` // For filesystem storage 62 | IsExternal bool `gorm:"type:boolean"` // Whether stored in filesystem 63 | Size int64 `gorm:"type:bigint"` // Content size in bytes 64 | Type string `gorm:"type:string;not null"` 65 | Metadata JSON `gorm:"type:json"` 66 | SourceApp string 67 | Category string `gorm:"index"` 68 | Tags StringArray `gorm:"type:json"` // Store as JSON in SQLite 69 | LastUsed time.Time `gorm:"index"` // Track when content was last accessed 70 | SyncedToObsidian bool `gorm:"type:boolean;default:false"` // Track if synced to Obsidian 71 | } 72 | 73 | // ToClip converts ClipModel to public Clip type 74 | func (cm *ClipModel) ToClip() *types.Clip { 75 | return &types.Clip{ 76 | ID: strconv.FormatUint(uint64(cm.ID), 10), 77 | Content: cm.Content, 78 | Type: cm.Type, 79 | Metadata: types.Metadata{ 80 | SourceApp: cm.SourceApp, 81 | Tags: cm.Tags, 82 | Category: cm.Category, 83 | }, 84 | CreatedAt: cm.CreatedAt, 85 | } 86 | } 87 | 88 | // FromClip creates a ClipModel from public Clip type 89 | func FromClip(clip *types.Clip) *ClipModel { 90 | return &ClipModel{ 91 | Content: clip.Content, 92 | Type: clip.Type, 93 | SourceApp: clip.Metadata.SourceApp, 94 | Category: clip.Metadata.Category, 95 | Tags: clip.Metadata.Tags, 96 | LastUsed: time.Now(), 97 | } 98 | } 99 | 100 | // BeforeSave GORM hook to update LastUsed timestamp 101 | func (cm *ClipModel) BeforeSave(tx *gorm.DB) error { 102 | cm.LastUsed = time.Now() 103 | return nil 104 | } 105 | -------------------------------------------------------------------------------- /internal/storage/search.go: -------------------------------------------------------------------------------- 1 | package storage 2 | 3 | import ( 4 | "clipboard-manager/pkg/types" 5 | "time" 6 | ) 7 | 8 | // SearchOptions defines criteria for searching clips 9 | type SearchOptions struct { 10 | // Text search query 11 | Query string 12 | 13 | // Filter by content type 14 | Type string 15 | 16 | // Filter by source application 17 | SourceApp string 18 | 19 | // Filter by category 20 | Category string 21 | 22 | // Filter by tags (all tags must match) 23 | Tags []string 24 | 25 | // Time range 26 | From time.Time 27 | To time.Time 28 | 29 | // Pagination 30 | Limit int 31 | Offset int 32 | 33 | // Sort options 34 | SortBy string // "created_at", "last_used" 35 | SortOrder string // "asc", "desc" 36 | } 37 | 38 | // SearchResult represents a search result with metadata 39 | type SearchResult struct { 40 | // The matching clip 41 | Clip *types.Clip 42 | 43 | // Search result metadata 44 | Score float64 // Relevance score 45 | Matches []string // Matched terms 46 | LastUsed time.Time // When this clip was last accessed 47 | UseCount int // Number of times this clip was accessed 48 | } 49 | 50 | // SearchService defines the interface for searching clips 51 | type SearchService interface { 52 | // Search returns clips matching the given criteria 53 | Search(opts SearchOptions) ([]SearchResult, error) 54 | 55 | // GetRecent returns the most recently used clips 56 | GetRecent(limit int) ([]SearchResult, error) 57 | 58 | // GetMostUsed returns the most frequently used clips 59 | GetMostUsed(limit int) ([]SearchResult, error) 60 | 61 | // GetByType returns clips of a specific type 62 | GetByType(clipType string, limit int) ([]SearchResult, error) 63 | } 64 | -------------------------------------------------------------------------------- /internal/storage/sqlite/OPTIMIZATIONS.md: -------------------------------------------------------------------------------- 1 | # SQLite Storage Optimizations 2 | 3 | This document outlines the performance optimizations implemented in the SQLite storage layer and their impact on various operations. 4 | 5 | ## Optimizations Applied 6 | 7 | ### 1. Write-Ahead Logging (WAL) 8 | - Enabled WAL mode for better concurrency and write performance 9 | - Allows multiple readers with a single writer 10 | - Provides better crash recovery 11 | ```sql 12 | PRAGMA journal_mode = WAL; 13 | ``` 14 | 15 | ### 2. Memory & Cache Settings 16 | - Increased SQLite page cache to 16MB 17 | - Enabled memory-mapped I/O for better read performance 18 | ```sql 19 | PRAGMA cache_size = -4000; -- 16MB (4000 pages * 4KB per page) 20 | PRAGMA mmap_size = 268435456; -- 256MB for memory-mapped I/O 21 | ``` 22 | 23 | ### 3. Synchronization Settings 24 | - Optimized for performance while maintaining durability 25 | ```sql 26 | PRAGMA synchronous = NORMAL; -- Safe in WAL mode 27 | PRAGMA busy_timeout = 5000; -- 5 second timeout for busy connections 28 | ``` 29 | 30 | ### 4. Indexing 31 | - Added indexes for frequently accessed columns 32 | ```sql 33 | CREATE INDEX idx_clips_content_hash ON clip_models(content_hash); 34 | CREATE INDEX idx_clips_last_used ON clip_models(last_used); 35 | ``` 36 | 37 | ### 5. Connection Pool Configuration 38 | ```go 39 | sqlDB.SetMaxOpenConns(1) // SQLite supports one writer at a time 40 | sqlDB.SetMaxIdleConns(1) 41 | sqlDB.SetConnMaxLifetime(time.Hour) 42 | ``` 43 | 44 | ## Performance Benchmarks 45 | 46 | All benchmarks were run on Apple M1 Pro processor with the following test data: 47 | - Single record size: 1KB 48 | - Bulk operations: 100 records per transaction 49 | 50 | ### Single Operations 51 | | Operation | Time per Operation | Memory per Operation | Allocations | 52 | |-----------|-------------------|---------------------|-------------| 53 | | Store | 116 μs | 27.5 KB | 330 | 54 | | Get | 114 μs | 27.4 KB | 330 | 55 | | List | 36 μs | 16.2 KB | 144 | 56 | 57 | ### Bulk Operations 58 | | Operation | Time per Operation | Memory per Operation | Allocations | 59 | |------------|-------------------|---------------------|-------------| 60 | | BulkStore | 5.04 ms | 941 KB | 19,826 | 61 | | Per Record | ~50 μs | ~9.4 KB | ~198 | 62 | 63 | ## Key Findings 64 | 65 | 1. **Bulk Operations Efficiency** 66 | - Bulk storing is more efficient per record (~50μs) compared to individual stores (116μs) 67 | - Memory usage in bulk operations is optimized due to transaction reuse 68 | 69 | 2. **Read Performance** 70 | - List operations are fastest (36μs) due to memory-mapped I/O and indexing 71 | - Get operations benefit from the increased cache size 72 | 73 | 3. **Memory Usage** 74 | - Single operations maintain consistent memory usage (~27KB) 75 | - Bulk operations use more total memory but less per record 76 | 77 | ## References 78 | 79 | 1. [SQLite WAL Mode Documentation](https://sqlite.org/wal.html) 80 | 2. [SQLite Performance Optimization](https://sqlite.org/speed.html) 81 | 3. [SQLite Pragma Statements](https://sqlite.org/pragma.html) 82 | 4. [GORM Documentation](https://gorm.io/docs/) 83 | 84 | ## Running Benchmarks 85 | 86 | To run these benchmarks yourself: 87 | 88 | ```bash 89 | cd internal/storage/sqlite 90 | go test -bench=. -benchmem 91 | ``` 92 | -------------------------------------------------------------------------------- /internal/storage/sqlite/search.go: -------------------------------------------------------------------------------- 1 | package sqlite 2 | 3 | import ( 4 | "clipboard-manager/internal/storage" 5 | "fmt" 6 | "os" 7 | "path/filepath" 8 | "strings" 9 | ) 10 | 11 | // Search implements storage.SearchService interface 12 | func (s *SQLiteStorage) Search(opts storage.SearchOptions) ([]storage.SearchResult, error) { 13 | query := s.db.Model(&storage.ClipModel{}) 14 | 15 | // Apply text search if query provided 16 | if opts.Query != "" { 17 | // Case-insensitive search in content, source app, and metadata 18 | searchTerm := strings.ToLower(opts.Query) 19 | 20 | // First, get all text clips that match the search term 21 | query = query.Where( 22 | "(type LIKE 'text%' AND ("+ 23 | " (is_external = 0 AND LOWER(CAST(content AS TEXT)) LIKE ?) OR "+ 24 | " LOWER(content_hash) LIKE ?"+ 25 | ")) OR "+ 26 | "LOWER(source_app) LIKE ? OR "+ 27 | "LOWER(category) LIKE ? OR "+ 28 | "LOWER(tags) LIKE ?", 29 | "%"+searchTerm+"%", 30 | "%"+searchTerm+"%", 31 | "%"+searchTerm+"%", 32 | "%"+searchTerm+"%", 33 | "%"+searchTerm+"%", 34 | ) 35 | 36 | // Also get external text clips 37 | var externalClips []storage.ClipModel 38 | s.db.Where("type LIKE 'text%' AND is_external = 1").Find(&externalClips) 39 | 40 | // Search through external content 41 | for _, clip := range externalClips { 42 | if content, err := s.loadExternalContent(&clip); err == nil { 43 | if strings.Contains(strings.ToLower(string(content)), searchTerm) { 44 | query = query.Or("id = ?", clip.ID) 45 | } 46 | } 47 | } 48 | } 49 | 50 | // Apply filters 51 | if opts.Type != "" { 52 | query = query.Where("type = ?", opts.Type) 53 | } 54 | if opts.SourceApp != "" { 55 | query = query.Where("source_app = ?", opts.SourceApp) 56 | } 57 | if opts.Category != "" { 58 | query = query.Where("category = ?", opts.Category) 59 | } 60 | if len(opts.Tags) > 0 { 61 | for _, tag := range opts.Tags { 62 | query = query.Where("tags LIKE ?", "%"+tag+"%") 63 | } 64 | } 65 | 66 | // Apply time range 67 | if !opts.From.IsZero() { 68 | query = query.Where("created_at >= ?", opts.From) 69 | } 70 | if !opts.To.IsZero() { 71 | query = query.Where("created_at <= ?", opts.To) 72 | } 73 | 74 | // Apply sorting 75 | if opts.SortBy != "" { 76 | direction := "DESC" 77 | if strings.ToLower(opts.SortOrder) == "asc" { 78 | direction = "ASC" 79 | } 80 | 81 | switch opts.SortBy { 82 | case "created_at": 83 | query = query.Order(fmt.Sprintf("created_at %s", direction)) 84 | case "last_used": 85 | query = query.Order(fmt.Sprintf("last_used %s", direction)) 86 | } 87 | } else { 88 | // Default sort by last used time 89 | query = query.Order("last_used DESC") 90 | } 91 | 92 | // Apply pagination 93 | if opts.Limit > 0 { 94 | query = query.Limit(opts.Limit) 95 | } 96 | if opts.Offset > 0 { 97 | query = query.Offset(opts.Offset) 98 | } 99 | 100 | var models []storage.ClipModel 101 | if err := query.Find(&models).Error; err != nil { 102 | return nil, fmt.Errorf("failed to search clips: %w", err) 103 | } 104 | 105 | // Convert to search results 106 | results := make([]storage.SearchResult, len(models)) 107 | for i, model := range models { 108 | clip := model.ToClip() 109 | 110 | // Load external content if needed 111 | if model.IsExternal { 112 | if content, err := s.loadExternalContent(&model); err == nil { 113 | clip.Content = content 114 | } 115 | } 116 | 117 | results[i] = storage.SearchResult{ 118 | Clip: clip, 119 | LastUsed: model.LastUsed, 120 | // For now, we'll use a simple relevance score based on recency 121 | Score: float64(model.LastUsed.Unix()), 122 | } 123 | } 124 | 125 | return results, nil 126 | } 127 | 128 | // GetRecent implements storage.SearchService interface 129 | func (s *SQLiteStorage) GetRecent(limit int) ([]storage.SearchResult, error) { 130 | return s.Search(storage.SearchOptions{ 131 | Limit: limit, 132 | SortBy: "last_used", 133 | SortOrder: "desc", 134 | }) 135 | } 136 | 137 | // GetMostUsed implements storage.SearchService interface 138 | func (s *SQLiteStorage) GetMostUsed(limit int) ([]storage.SearchResult, error) { 139 | // For now, we'll use last_used as a proxy for usage frequency 140 | // In the future, we could add a use_count field to track this properly 141 | return s.Search(storage.SearchOptions{ 142 | Limit: limit, 143 | SortBy: "last_used", 144 | SortOrder: "desc", 145 | }) 146 | } 147 | 148 | // GetByType implements storage.SearchService interface 149 | func (s *SQLiteStorage) GetByType(clipType string, limit int) ([]storage.SearchResult, error) { 150 | return s.Search(storage.SearchOptions{ 151 | Type: clipType, 152 | Limit: limit, 153 | SortBy: "last_used", 154 | SortOrder: "desc", 155 | }) 156 | } 157 | 158 | // loadExternalContent loads content from filesystem for external storage 159 | func (s *SQLiteStorage) loadExternalContent(model *storage.ClipModel) ([]byte, error) { 160 | if !model.IsExternal || model.StoragePath == "" { 161 | return nil, fmt.Errorf("not an external clip") 162 | } 163 | 164 | return s.readExternalFile(model.StoragePath) 165 | } 166 | 167 | // readExternalFile reads a file from the external storage directory 168 | func (s *SQLiteStorage) readExternalFile(filename string) ([]byte, error) { 169 | path := filepath.Join(s.fsPath, filename) 170 | content, err := os.ReadFile(path) 171 | if err != nil { 172 | return nil, fmt.Errorf("failed to read file %s: %w", path, err) 173 | } 174 | return content, nil 175 | } 176 | -------------------------------------------------------------------------------- /internal/storage/sqlite/sqlite.go: -------------------------------------------------------------------------------- 1 | package sqlite 2 | 3 | import ( 4 | "clipboard-manager/internal/storage" 5 | "clipboard-manager/pkg/types" 6 | "context" 7 | "crypto/sha256" 8 | "encoding/hex" 9 | "fmt" 10 | "os" 11 | "path/filepath" 12 | "time" 13 | 14 | "gorm.io/driver/sqlite" 15 | "gorm.io/gorm" 16 | ) 17 | 18 | type SQLiteStorage struct { 19 | db *gorm.DB 20 | fsPath string // Base path for file system storage 21 | } 22 | 23 | // New creates a new SQLite storage instance with optimized configuration 24 | func New(config storage.Config) (*SQLiteStorage, error) { 25 | // Open database with WAL mode enabled 26 | db, err := gorm.Open(sqlite.Open(config.DBPath), &gorm.Config{}) 27 | if err != nil { 28 | return nil, fmt.Errorf("failed to open database: %w", err) 29 | } 30 | 31 | // Get the underlying *sql.DB instance 32 | sqlDB, err := db.DB() 33 | if err != nil { 34 | return nil, fmt.Errorf("failed to get underlying *sql.DB: %w", err) 35 | } 36 | 37 | // Configure connection pool 38 | sqlDB.SetMaxOpenConns(1) // SQLite only supports one writer at a time 39 | sqlDB.SetMaxIdleConns(1) 40 | sqlDB.SetConnMaxLifetime(time.Hour) 41 | 42 | // Auto-migrate the schema first 43 | if err := db.AutoMigrate(&storage.ClipModel{}); err != nil { 44 | return nil, fmt.Errorf("failed to migrate schema: %w", err) 45 | } 46 | 47 | // Apply performance optimizations 48 | if err := db.Exec(` 49 | -- Enable WAL mode for better concurrency and performance 50 | PRAGMA journal_mode = WAL; 51 | 52 | -- NORMAL provides good durability with better performance 53 | -- In WAL mode, NORMAL is safe as WAL provides durability 54 | PRAGMA synchronous = NORMAL; 55 | 56 | -- Increase cache size to 16MB (4000 pages * 4KB per page) 57 | PRAGMA cache_size = -4000; 58 | 59 | -- Enable memory-mapped I/O for reading 60 | PRAGMA mmap_size = 268435456; -- 256MB 61 | 62 | -- Set busy timeout to 5 seconds 63 | PRAGMA busy_timeout = 5000; 64 | 65 | -- Enable foreign key constraints 66 | PRAGMA foreign_keys = ON; 67 | `).Error; err != nil { 68 | return nil, fmt.Errorf("failed to set PRAGMA options: %w", err) 69 | } 70 | 71 | // Create indexes after table creation 72 | if err := db.Exec(` 73 | CREATE INDEX IF NOT EXISTS idx_clips_content_hash ON clip_models(content_hash); 74 | CREATE INDEX IF NOT EXISTS idx_clips_last_used ON clip_models(last_used); 75 | `).Error; err != nil { 76 | return nil, fmt.Errorf("failed to create indexes: %w", err) 77 | } 78 | 79 | // Create storage directory if it doesn't exist 80 | if err := os.MkdirAll(config.FSPath, 0755); err != nil { 81 | return nil, fmt.Errorf("failed to create storage directory: %w", err) 82 | } 83 | 84 | return &SQLiteStorage{ 85 | db: db, 86 | fsPath: config.FSPath, 87 | }, nil 88 | } 89 | 90 | // calculateHash generates SHA-256 hash of content 91 | func calculateHash(content []byte) string { 92 | hash := sha256.Sum256(content) 93 | return hex.EncodeToString(hash[:]) 94 | } 95 | 96 | // Close closes the database connection and cleans up WAL files 97 | func (s *SQLiteStorage) Close() error { 98 | sqlDB, err := s.db.DB() 99 | if err != nil { 100 | return fmt.Errorf("failed to get underlying *sql.DB: %w", err) 101 | } 102 | 103 | // Checkpoint WAL file and merge it with the main database 104 | if err := s.db.Exec("PRAGMA wal_checkpoint(TRUNCATE);").Error; err != nil { 105 | return fmt.Errorf("failed to checkpoint WAL: %w", err) 106 | } 107 | 108 | // Close database connection 109 | if err := sqlDB.Close(); err != nil { 110 | return fmt.Errorf("failed to close database: %w", err) 111 | } 112 | 113 | return nil 114 | } 115 | 116 | // Store implements storage.Storage interface 117 | func (s *SQLiteStorage) Store(ctx context.Context, content []byte, clipType string, metadata types.Metadata) (*types.Clip, error) { 118 | size := int64(len(content)) 119 | if size > storage.MaxStorageSize { 120 | return nil, storage.ErrFileTooLarge 121 | } 122 | 123 | // Calculate content hash 124 | contentHash := calculateHash(content) 125 | 126 | // Check for existing content with same hash 127 | var existing storage.ClipModel 128 | if err := s.db.Where("content_hash = ?", contentHash).First(&existing).Error; err == nil { 129 | // Content exists, update LastUsed timestamp 130 | existing.LastUsed = time.Now() 131 | if err := s.db.Save(&existing).Error; err != nil { 132 | return nil, fmt.Errorf("failed to update existing clip: %w", err) 133 | } 134 | return existing.ToClip(), nil 135 | } else if err != gorm.ErrRecordNotFound { 136 | return nil, fmt.Errorf("failed to check for existing content: %w", err) 137 | } 138 | 139 | // Create new clip model 140 | model := &storage.ClipModel{ 141 | ContentHash: contentHash, 142 | Type: clipType, 143 | Size: size, 144 | SourceApp: metadata.SourceApp, 145 | Category: metadata.Category, 146 | Tags: metadata.Tags, 147 | LastUsed: time.Now(), 148 | } 149 | 150 | if size > storage.MaxInlineStorageSize { 151 | // Store in filesystem 152 | filename := contentHash 153 | path := filepath.Join(s.fsPath, filename) 154 | 155 | if err := os.WriteFile(path, content, 0644); err != nil { 156 | return nil, fmt.Errorf("failed to write file: %w", err) 157 | } 158 | 159 | model.StoragePath = filename 160 | model.IsExternal = true 161 | } else { 162 | // Store in database 163 | model.Content = content 164 | } 165 | 166 | if err := s.db.Create(model).Error; err != nil { 167 | return nil, fmt.Errorf("failed to create clip: %w", err) 168 | } 169 | 170 | return model.ToClip(), nil 171 | } 172 | 173 | // Get implements storage.Storage interface 174 | func (s *SQLiteStorage) Get(ctx context.Context, id string) (*types.Clip, error) { 175 | var model storage.ClipModel 176 | if err := s.db.First(&model, id).Error; err != nil { 177 | return nil, fmt.Errorf("failed to get clip: %w", err) 178 | } 179 | 180 | // Load external content if needed 181 | if model.IsExternal { 182 | path := filepath.Join(s.fsPath, model.StoragePath) 183 | content, err := os.ReadFile(path) 184 | if err != nil { 185 | return nil, fmt.Errorf("failed to read external content: %w", err) 186 | } 187 | model.Content = content 188 | } 189 | 190 | // Update LastUsed timestamp 191 | model.LastUsed = time.Now() 192 | if err := s.db.Save(&model).Error; err != nil { 193 | return nil, fmt.Errorf("failed to update last used time: %w", err) 194 | } 195 | 196 | return model.ToClip(), nil 197 | } 198 | 199 | // Delete implements storage.Storage interface 200 | func (s *SQLiteStorage) Delete(ctx context.Context, id string) error { 201 | var model storage.ClipModel 202 | if err := s.db.First(&model, id).Error; err != nil { 203 | return fmt.Errorf("failed to get clip: %w", err) 204 | } 205 | 206 | // Delete external file if exists 207 | if model.IsExternal { 208 | path := filepath.Join(s.fsPath, model.StoragePath) 209 | if err := os.Remove(path); err != nil && !os.IsNotExist(err) { 210 | return fmt.Errorf("failed to delete external file: %w", err) 211 | } 212 | } 213 | 214 | if err := s.db.Delete(&model).Error; err != nil { 215 | return fmt.Errorf("failed to delete clip: %w", err) 216 | } 217 | 218 | return nil 219 | } 220 | 221 | // List implements storage.Storage interface 222 | func (s *SQLiteStorage) List(ctx context.Context, filter storage.ListFilter) ([]*types.Clip, error) { 223 | query := s.db.Model(&storage.ClipModel{}) 224 | 225 | if filter.Type != "" { 226 | query = query.Where("type = ?", filter.Type) 227 | } 228 | if filter.Category != "" { 229 | query = query.Where("category = ?", filter.Category) 230 | } 231 | if len(filter.Tags) > 0 { 232 | query = query.Where("tags @> ?", filter.Tags) 233 | } 234 | 235 | // Apply pagination 236 | if filter.Limit > 0 { 237 | query = query.Limit(filter.Limit) 238 | } 239 | if filter.Offset > 0 { 240 | query = query.Offset(filter.Offset) 241 | } 242 | 243 | // Order by last used time to show most recent clips first 244 | query = query.Order("last_used DESC") 245 | 246 | var models []storage.ClipModel 247 | if err := query.Find(&models).Error; err != nil { 248 | return nil, fmt.Errorf("failed to list clips: %w", err) 249 | } 250 | 251 | clips := make([]*types.Clip, len(models)) 252 | for i, model := range models { 253 | // Load external content if needed 254 | if model.IsExternal { 255 | path := filepath.Join(s.fsPath, model.StoragePath) 256 | content, err := os.ReadFile(path) 257 | if err != nil { 258 | return nil, fmt.Errorf("failed to read external content for clip %d: %w", model.ID, err) 259 | } 260 | model.Content = content 261 | } 262 | clips[i] = model.ToClip() 263 | } 264 | 265 | return clips, nil 266 | } 267 | 268 | // MarkAsSynced implements storage.Storage interface 269 | func (s *SQLiteStorage) MarkAsSynced(ctx context.Context, id string) error { 270 | result := s.db.Model(&storage.ClipModel{}). 271 | Where("id = ?", id). 272 | Update("synced_to_obsidian", true) 273 | 274 | if result.Error != nil { 275 | return fmt.Errorf("failed to mark clip as synced: %w", result.Error) 276 | } 277 | 278 | if result.RowsAffected == 0 { 279 | return fmt.Errorf("no clip found with id: %s", id) 280 | } 281 | 282 | return nil 283 | } 284 | 285 | // ListUnsynced implements storage.Storage interface 286 | func (s *SQLiteStorage) ListUnsynced(ctx context.Context, limit int) ([]*types.Clip, error) { 287 | var models []storage.ClipModel 288 | 289 | query := s.db.Model(&storage.ClipModel{}). 290 | Where("synced_to_obsidian = ?", false). 291 | Order("created_at DESC") 292 | 293 | if limit > 0 { 294 | query = query.Limit(limit) 295 | } 296 | 297 | if err := query.Find(&models).Error; err != nil { 298 | return nil, fmt.Errorf("failed to list unsynced clips: %w", err) 299 | } 300 | 301 | clips := make([]*types.Clip, len(models)) 302 | for i, model := range models { 303 | // Load external content if needed 304 | if model.IsExternal { 305 | path := filepath.Join(s.fsPath, model.StoragePath) 306 | content, err := os.ReadFile(path) 307 | if err != nil { 308 | return nil, fmt.Errorf("failed to read external content for clip %d: %w", model.ID, err) 309 | } 310 | model.Content = content 311 | } 312 | clips[i] = model.ToClip() 313 | } 314 | 315 | return clips, nil 316 | } 317 | -------------------------------------------------------------------------------- /internal/storage/sqlite/sqlite_benchmark_test.go: -------------------------------------------------------------------------------- 1 | package sqlite 2 | 3 | import ( 4 | "clipboard-manager/internal/storage" 5 | "clipboard-manager/pkg/types" 6 | "context" 7 | "encoding/json" 8 | "fmt" 9 | "os" 10 | "testing" 11 | "time" 12 | 13 | "gorm.io/gorm" 14 | ) 15 | 16 | func setupBenchmarkDB(b *testing.B) (*SQLiteStorage, func()) { 17 | err := os.MkdirAll("./testdata", 0755) 18 | if err != nil { 19 | b.Fatal(err) 20 | } 21 | 22 | dbPath := fmt.Sprintf("./testdata/test_%d.db", time.Now().UnixNano()) 23 | fsPath := fmt.Sprintf("./testdata/fs_%d", time.Now().UnixNano()) 24 | 25 | storage, err := New(storage.Config{ 26 | DBPath: dbPath, 27 | FSPath: fsPath, 28 | }) 29 | if err != nil { 30 | b.Fatal(err) 31 | } 32 | 33 | cleanup := func() { 34 | if err := storage.Close(); err != nil { 35 | b.Error(err) 36 | } 37 | os.RemoveAll("./testdata") 38 | } 39 | 40 | return storage, cleanup 41 | } 42 | 43 | func generateTestData(size int) []byte { 44 | data := make([]byte, size) 45 | for i := range data { 46 | data[i] = byte(i % 256) 47 | } 48 | return data 49 | } 50 | 51 | func BenchmarkStore(b *testing.B) { 52 | storage, cleanup := setupBenchmarkDB(b) 53 | defer cleanup() 54 | 55 | ctx := context.Background() 56 | data := generateTestData(1024) // 1KB of test data 57 | 58 | b.ResetTimer() 59 | for i := 0; i < b.N; i++ { 60 | metadata := types.Metadata{ 61 | SourceApp: "benchmark", 62 | Category: "test", 63 | Tags: []string{"benchmark", "test"}, 64 | } 65 | _, err := storage.Store(ctx, data, "text/plain", metadata) 66 | if err != nil { 67 | b.Fatal(err) 68 | } 69 | } 70 | } 71 | 72 | func BenchmarkGet(b *testing.B) { 73 | storage, cleanup := setupBenchmarkDB(b) 74 | defer cleanup() 75 | 76 | ctx := context.Background() 77 | data := generateTestData(1024) 78 | metadata := types.Metadata{ 79 | SourceApp: "benchmark", 80 | Category: "test", 81 | Tags: []string{"benchmark", "test"}, 82 | } 83 | 84 | // Store initial data 85 | clip, err := storage.Store(ctx, data, "text/plain", metadata) 86 | if err != nil { 87 | b.Fatal(err) 88 | } 89 | 90 | b.ResetTimer() 91 | for i := 0; i < b.N; i++ { 92 | _, err := storage.Get(ctx, fmt.Sprint(clip.ID)) 93 | if err != nil { 94 | b.Fatal(err) 95 | } 96 | } 97 | } 98 | 99 | func BenchmarkList(b *testing.B) { 100 | storage, cleanup := setupBenchmarkDB(b) 101 | defer cleanup() 102 | 103 | ctx := context.Background() 104 | data := generateTestData(1024) 105 | 106 | // Store 100 items 107 | for i := 0; i < 100; i++ { 108 | metadata := types.Metadata{ 109 | SourceApp: "benchmark", 110 | Category: "test", 111 | Tags: []string{"benchmark", "test"}, 112 | } 113 | _, err := storage.Store(ctx, data, "text/plain", metadata) 114 | if err != nil { 115 | b.Fatal(err) 116 | } 117 | } 118 | 119 | listFilter := struct { 120 | Type string 121 | Category string 122 | Tags []string 123 | Limit int 124 | Offset int 125 | SyncedToObsidian *bool 126 | }{ 127 | Type: "", 128 | Category: "", 129 | Tags: nil, 130 | Limit: 50, 131 | Offset: 0, 132 | } 133 | 134 | b.ResetTimer() 135 | for i := 0; i < b.N; i++ { 136 | _, err := storage.List(ctx, listFilter) 137 | if err != nil { 138 | b.Fatal(err) 139 | } 140 | } 141 | } 142 | 143 | func BenchmarkBulkStore(b *testing.B) { 144 | storage, cleanup := setupBenchmarkDB(b) 145 | defer cleanup() 146 | 147 | data := generateTestData(1024) 148 | batchSize := 100 149 | 150 | b.ResetTimer() 151 | for i := 0; i < b.N; i++ { 152 | err := storage.db.Transaction(func(tx *gorm.DB) error { 153 | for j := 0; j < batchSize; j++ { 154 | // Make each record unique by adding a counter to the data 155 | uniqueData := []byte(fmt.Sprintf("%d-%d-%x", time.Now().UnixNano(), j, data[:32])) 156 | metadata := types.Metadata{ 157 | SourceApp: fmt.Sprintf("benchmark-%d", j), 158 | Category: "test", 159 | Tags: []string{"benchmark", "test"}, 160 | } 161 | // Convert tags to JSON string for storage 162 | tagsJSON, err := json.Marshal(metadata.Tags) 163 | if err != nil { 164 | return fmt.Errorf("failed to marshal tags: %w", err) 165 | } 166 | 167 | // Use map to avoid GORM struct issues 168 | model := map[string]interface{}{ 169 | "content": []byte(uniqueData), 170 | "type": "text/plain", 171 | "source_app": metadata.SourceApp, 172 | "category": metadata.Category, 173 | "tags": string(tagsJSON), 174 | "last_used": time.Now(), 175 | "content_hash": calculateHash([]byte(uniqueData)), 176 | "size": int64(len(uniqueData)), 177 | "is_external": false, 178 | } 179 | if err := tx.Table("clip_models").Create(&model).Error; err != nil { 180 | return err 181 | } 182 | } 183 | return nil 184 | }) 185 | if err != nil { 186 | b.Fatal(err) 187 | } 188 | } 189 | } 190 | -------------------------------------------------------------------------------- /internal/storage/sqlite/sqlite_test.go: -------------------------------------------------------------------------------- 1 | package sqlite 2 | 3 | import ( 4 | "clipboard-manager/internal/storage" 5 | "clipboard-manager/pkg/types" 6 | "context" 7 | "os" 8 | "path/filepath" 9 | "testing" 10 | "time" 11 | ) 12 | 13 | func setupTestDB(t *testing.T) (*SQLiteStorage, func()) { 14 | // Create temp directories for test 15 | tempDir, err := os.MkdirTemp("", "clipboard-test-*") 16 | if err != nil { 17 | t.Fatalf("failed to create temp dir: %v", err) 18 | } 19 | 20 | dbPath := filepath.Join(tempDir, "test.db") 21 | fsPath := filepath.Join(tempDir, "files") 22 | 23 | // Initialize storage 24 | store, err := New(storage.Config{ 25 | DBPath: dbPath, 26 | FSPath: fsPath, 27 | }) 28 | if err != nil { 29 | os.RemoveAll(tempDir) 30 | t.Fatalf("failed to create storage: %v", err) 31 | } 32 | 33 | // Return cleanup function 34 | cleanup := func() { 35 | os.RemoveAll(tempDir) 36 | } 37 | 38 | return store, cleanup 39 | } 40 | 41 | func TestStore_BasicOperations(t *testing.T) { 42 | store, cleanup := setupTestDB(t) 43 | defer cleanup() 44 | 45 | ctx := context.Background() 46 | content := []byte("test content") 47 | metadata := types.Metadata{ 48 | SourceApp: "test", 49 | Category: "test", 50 | Tags: []string{"test"}, 51 | } 52 | 53 | // Test Store 54 | clip, err := store.Store(ctx, content, storage.TypeText, metadata) 55 | if err != nil { 56 | t.Fatalf("failed to store clip: %v", err) 57 | } 58 | if clip.ID == "" { 59 | t.Error("clip ID should not be empty") 60 | } 61 | 62 | // Test Get 63 | retrieved, err := store.Get(ctx, clip.ID) 64 | if err != nil { 65 | t.Fatalf("failed to get clip: %v", err) 66 | } 67 | if string(retrieved.Content) != string(content) { 68 | t.Errorf("content mismatch: got %s, want %s", retrieved.Content, content) 69 | } 70 | 71 | // Test List 72 | clips, err := store.List(ctx, storage.ListFilter{ 73 | Type: storage.TypeText, 74 | Limit: 10, 75 | }) 76 | if err != nil { 77 | t.Fatalf("failed to list clips: %v", err) 78 | } 79 | if len(clips) != 1 { 80 | t.Errorf("expected 1 clip, got %d", len(clips)) 81 | } 82 | 83 | // Test Delete 84 | if err := store.Delete(ctx, clip.ID); err != nil { 85 | t.Fatalf("failed to delete clip: %v", err) 86 | } 87 | 88 | // Verify deletion 89 | _, err = store.Get(ctx, clip.ID) 90 | if err == nil { 91 | t.Error("expected error getting deleted clip") 92 | } 93 | } 94 | 95 | func TestStore_Deduplication(t *testing.T) { 96 | store, cleanup := setupTestDB(t) 97 | defer cleanup() 98 | 99 | ctx := context.Background() 100 | content := []byte("duplicate content") 101 | metadata := types.Metadata{ 102 | SourceApp: "test", 103 | Category: "test", 104 | } 105 | 106 | // Store first copy 107 | clip1, err := store.Store(ctx, content, storage.TypeText, metadata) 108 | if err != nil { 109 | t.Fatalf("failed to store first clip: %v", err) 110 | } 111 | 112 | // Small delay to ensure different timestamps 113 | time.Sleep(time.Millisecond * 100) 114 | 115 | // Store duplicate content 116 | clip2, err := store.Store(ctx, content, storage.TypeText, metadata) 117 | if err != nil { 118 | t.Fatalf("failed to store second clip: %v", err) 119 | } 120 | 121 | if clip1.ID != clip2.ID { 122 | t.Error("deduplication failed: got different IDs for same content") 123 | } 124 | 125 | // Verify LastUsed was updated 126 | var model storage.ClipModel 127 | if err := store.db.First(&model, clip1.ID).Error; err != nil { 128 | t.Fatalf("failed to get clip model: %v", err) 129 | } 130 | 131 | if !model.LastUsed.After(clip1.CreatedAt) { 132 | t.Error("LastUsed timestamp was not updated") 133 | } 134 | } 135 | 136 | func TestStore_SizeLimits(t *testing.T) { 137 | store, cleanup := setupTestDB(t) 138 | defer cleanup() 139 | 140 | ctx := context.Background() 141 | metadata := types.Metadata{SourceApp: "test"} 142 | 143 | // Test file too large 144 | largeContent := make([]byte, storage.MaxStorageSize+1) 145 | _, err := store.Store(ctx, largeContent, storage.TypeFile, metadata) 146 | if err != storage.ErrFileTooLarge { 147 | t.Errorf("expected ErrFileTooLarge, got %v", err) 148 | } 149 | 150 | // Test file stored in filesystem 151 | mediumContent := make([]byte, storage.MaxInlineStorageSize+1) 152 | clip, err := store.Store(ctx, mediumContent, storage.TypeFile, metadata) 153 | if err != nil { 154 | t.Fatalf("failed to store medium file: %v", err) 155 | } 156 | 157 | // Verify content is stored externally 158 | var model storage.ClipModel 159 | if err := store.db.First(&model, clip.ID).Error; err != nil { 160 | t.Fatalf("failed to get clip model: %v", err) 161 | } 162 | if !model.IsExternal { 163 | t.Error("content should be stored externally") 164 | } 165 | if model.StoragePath == "" { 166 | t.Error("storage path should not be empty") 167 | } 168 | 169 | // Verify content can be retrieved 170 | retrieved, err := store.Get(ctx, clip.ID) 171 | if err != nil { 172 | t.Fatalf("failed to get clip: %v", err) 173 | } 174 | if len(retrieved.Content) != len(mediumContent) { 175 | t.Errorf("content length mismatch: got %d, want %d", len(retrieved.Content), len(mediumContent)) 176 | } 177 | } 178 | -------------------------------------------------------------------------------- /internal/storage/storage.go: -------------------------------------------------------------------------------- 1 | package storage 2 | 3 | import ( 4 | "clipboard-manager/pkg/types" 5 | "context" 6 | ) 7 | 8 | // Storage defines the interface for clipboard data persistence 9 | type Storage interface { 10 | // Store saves clipboard content and returns a clip ID 11 | Store(ctx context.Context, content []byte, clipType string, metadata types.Metadata) (*types.Clip, error) 12 | 13 | // Get retrieves clipboard content by ID 14 | Get(ctx context.Context, id string) (*types.Clip, error) 15 | 16 | // Delete removes clipboard content 17 | Delete(ctx context.Context, id string) error 18 | 19 | // List returns clips matching the filter 20 | List(ctx context.Context, filter ListFilter) ([]*types.Clip, error) 21 | 22 | // MarkAsSynced marks a clip as synced to Obsidian 23 | MarkAsSynced(ctx context.Context, id string) error 24 | 25 | // ListUnsynced returns clips that haven't been synced to Obsidian 26 | ListUnsynced(ctx context.Context, limit int) ([]*types.Clip, error) 27 | } 28 | 29 | // ListFilter defines criteria for listing clips 30 | type ListFilter struct { 31 | Type string 32 | Category string 33 | Tags []string 34 | Limit int 35 | Offset int 36 | SyncedToObsidian *bool // Optional filter for sync status 37 | } 38 | 39 | // Config holds storage configuration 40 | type Config struct { 41 | DBPath string // Path to SQLite database 42 | FSPath string // Path to filesystem storage for large files 43 | } 44 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "rockstar-icon-generator", 3 | "version": "1.0.0", 4 | "type": "module", 5 | "dependencies": { 6 | "canvas": "^2.11.2", 7 | "puppeteer": "^23.11.1" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /pkg/types/clip.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | import "time" 4 | 5 | type Clip struct { 6 | ID string 7 | Content []byte 8 | Type string // supported types -> text, image, file(will have to check) 9 | Metadata Metadata 10 | CreatedAt time.Time 11 | } 12 | 13 | type Metadata struct { 14 | SourceApp string 15 | Tags []string 16 | Category string 17 | } 18 | -------------------------------------------------------------------------------- /releases/ClipboardManager.v.1.2/ClipboardManager.app/Contents/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | BuildMachineOSBuild 6 | 24C101 7 | CFBundleDevelopmentRegion 8 | en 9 | CFBundleExecutable 10 | ClipboardManager 11 | CFBundleIconFile 12 | AppIcon 13 | CFBundleIconName 14 | AppIcon 15 | CFBundleIdentifier 16 | com.hp77.ClipboardManager 17 | CFBundleInfoDictionaryVersion 18 | 6.0 19 | CFBundleName 20 | ClipboardManager 21 | CFBundlePackageType 22 | APPL 23 | CFBundleShortVersionString 24 | 1.2 25 | CFBundleSupportedPlatforms 26 | 27 | MacOSX 28 | 29 | CFBundleVersion 30 | 2 31 | DTCompiler 32 | com.apple.compilers.llvm.clang.1_0 33 | DTPlatformBuild 34 | 24C94 35 | DTPlatformName 36 | macosx 37 | DTPlatformVersion 38 | 15.2 39 | DTSDKBuild 40 | 24C94 41 | DTSDKName 42 | macosx15.2 43 | DTXcode 44 | 1620 45 | DTXcodeBuild 46 | 16C5032a 47 | LSMinimumSystemVersion 48 | 15.2 49 | UIApplicationSupportsIndirectInputEvents 50 | 51 | UIStatusBarStyle 52 | 53 | UISupportedInterfaceOrientations~ipad 54 | 55 | UIInterfaceOrientationPortrait 56 | UIInterfaceOrientationPortraitUpsideDown 57 | UIInterfaceOrientationLandscapeLeft 58 | UIInterfaceOrientationLandscapeRight 59 | 60 | UISupportedInterfaceOrientations~iphone 61 | 62 | UIInterfaceOrientationPortrait 63 | UIInterfaceOrientationLandscapeLeft 64 | UIInterfaceOrientationLandscapeRight 65 | 66 | 67 | 68 | -------------------------------------------------------------------------------- /releases/ClipboardManager.v.1.2/ClipboardManager.app/Contents/MacOS/ClipboardManager: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hp77-creator/rockstar/6d91a69b6b9f560b7e50c732ed7bbdef0788cfd6/releases/ClipboardManager.v.1.2/ClipboardManager.app/Contents/MacOS/ClipboardManager -------------------------------------------------------------------------------- /releases/ClipboardManager.v.1.2/ClipboardManager.app/Contents/PkgInfo: -------------------------------------------------------------------------------- 1 | APPL???? -------------------------------------------------------------------------------- /releases/ClipboardManager.v.1.2/ClipboardManager.app/Contents/Resources/AppIcon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hp77-creator/rockstar/6d91a69b6b9f560b7e50c732ed7bbdef0788cfd6/releases/ClipboardManager.v.1.2/ClipboardManager.app/Contents/Resources/AppIcon.icns -------------------------------------------------------------------------------- /releases/ClipboardManager.v.1.2/ClipboardManager.app/Contents/Resources/Assets.car: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hp77-creator/rockstar/6d91a69b6b9f560b7e50c732ed7bbdef0788cfd6/releases/ClipboardManager.v.1.2/ClipboardManager.app/Contents/Resources/Assets.car -------------------------------------------------------------------------------- /releases/ClipboardManager.v.1.2/ClipboardManager.app/Contents/Resources/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIconFile 10 | AppIcon 11 | CFBundleIconName 12 | AppIcon 13 | CFBundleIdentifier 14 | com.hp77.ClipboardManager 15 | CFBundleInfoDictionaryVersion 16 | 6.0 17 | CFBundleName 18 | ClipboardManager 19 | CFBundleDisplayName 20 | ClipboardManager 21 | CFBundlePackageType 22 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 23 | CFBundleShortVersionString 24 | 0.1.1 25 | CFBundleVersion 26 | 1 27 | LSMinimumSystemVersion 28 | 13.0 29 | LSApplicationCategoryType 30 | public.app-category.utilities 31 | LSUIElement 32 | 33 | NSHighResolutionCapable 34 | 35 | NSPrincipalClass 36 | NSApplication 37 | NSAppleEventsUsageDescription 38 | This app needs to access clipboard data 39 | NSPasteboardUsageDescription 40 | This app needs to access clipboard data 41 | NSAccessibilityUsageDescription 42 | This app needs accessibility access to detect keyboard shortcuts (Cmd+Shift+V) 43 | NSLocalNetworkUsageDescription 44 | This app needs to communicate with the local clipboard service 45 | NSBonjourServices 46 | 47 | _clipboard-manager._tcp 48 | 49 | NSAppTransportSecurity 50 | 51 | NSAllowsLocalNetworking 52 | 53 | 54 | NSAppleEventsEnabled 55 | 56 | NSSystemAdministrationUsageDescription 57 | This app needs accessibility permissions to detect keyboard shortcuts 58 | NSRequiresAquaSystemAppearance 59 | 60 | NSSupportsAutomaticGraphicsSwitching 61 | 62 | 63 | 64 | -------------------------------------------------------------------------------- /releases/ClipboardManager.v.1.2/ClipboardManager.app/Contents/_CodeSignature/CodeResources: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | files 6 | 7 | Resources/AppIcon.icns 8 | 9 | X23sqD1dVsO+Bg7aWfive6jamBc= 10 | 11 | Resources/Assets.car 12 | 13 | gIIoV/F97HTVx782hDd0rCUEbjk= 14 | 15 | Resources/Info.plist 16 | 17 | Di+djnJrMdRSlPujtXvrtSuY6mM= 18 | 19 | Resources/clipboard-manager 20 | 21 | UhNJIDNClNs0tqRraGEyJ5TNJYw= 22 | 23 | 24 | files2 25 | 26 | Resources/AppIcon.icns 27 | 28 | hash2 29 | 30 | rY7tLdCKeseaeGVHD3bKnw9IsM1xJKTrzJFrlGVguPA= 31 | 32 | 33 | Resources/Assets.car 34 | 35 | hash2 36 | 37 | gXSjXWUiAAjfztXl7EjjBQ21SzEaAsofB0UuQBWIfM4= 38 | 39 | 40 | Resources/Info.plist 41 | 42 | hash2 43 | 44 | qDQQC3MhxSuC0ukDXzOV+DdjX52ogazCQvz4pA2uDAc= 45 | 46 | 47 | Resources/clipboard-manager 48 | 49 | hash2 50 | 51 | Cn8Y2IiuV0eBoa7+hfjCosEgKZQlicIyvjNecpsrBho= 52 | 53 | 54 | 55 | rules 56 | 57 | ^Resources/ 58 | 59 | ^Resources/.*\.lproj/ 60 | 61 | optional 62 | 63 | weight 64 | 1000 65 | 66 | ^Resources/.*\.lproj/locversion.plist$ 67 | 68 | omit 69 | 70 | weight 71 | 1100 72 | 73 | ^Resources/Base\.lproj/ 74 | 75 | weight 76 | 1010 77 | 78 | ^version.plist$ 79 | 80 | 81 | rules2 82 | 83 | .*\.dSYM($|/) 84 | 85 | weight 86 | 11 87 | 88 | ^(.*/)?\.DS_Store$ 89 | 90 | omit 91 | 92 | weight 93 | 2000 94 | 95 | ^(Frameworks|SharedFrameworks|PlugIns|Plug-ins|XPCServices|Helpers|MacOS|Library/(Automator|Spotlight|LoginItems))/ 96 | 97 | nested 98 | 99 | weight 100 | 10 101 | 102 | ^.* 103 | 104 | ^Info\.plist$ 105 | 106 | omit 107 | 108 | weight 109 | 20 110 | 111 | ^PkgInfo$ 112 | 113 | omit 114 | 115 | weight 116 | 20 117 | 118 | ^Resources/ 119 | 120 | weight 121 | 20 122 | 123 | ^Resources/.*\.lproj/ 124 | 125 | optional 126 | 127 | weight 128 | 1000 129 | 130 | ^Resources/.*\.lproj/locversion.plist$ 131 | 132 | omit 133 | 134 | weight 135 | 1100 136 | 137 | ^Resources/Base\.lproj/ 138 | 139 | weight 140 | 1010 141 | 142 | ^[^/]+$ 143 | 144 | nested 145 | 146 | weight 147 | 10 148 | 149 | ^embedded\.provisionprofile$ 150 | 151 | weight 152 | 20 153 | 154 | ^version\.plist$ 155 | 156 | weight 157 | 20 158 | 159 | 160 | 161 | 162 | -------------------------------------------------------------------------------- /rockstar.rb: -------------------------------------------------------------------------------- 1 | cask "rockstar" do 2 | version "0.1.1" # Update this version to match your release version 3 | sha256 "REPLACE_WITH_ACTUAL_SHA256" # You'll need to update this after creating the DMG 4 | 5 | url "https://github.com/hp77-creator/rockstar/releases/download/v#{version}/Rockstar.dmg" 6 | name "Rockstar" 7 | desc "A powerful clipboard manager for macOS with Obsidian sync capabilities" 8 | homepage "https://github.com/hp77-creator/rockstar" 9 | 10 | depends_on macos: ">= :ventura" # Based on LSMinimumSystemVersion in Info.plist (13.0) 11 | 12 | app "Rockstar.app" 13 | 14 | zap trash: [ 15 | "~/Library/Application Support/Rockstar", 16 | "~/Library/Preferences/com.hp77.ClipboardManager.plist", 17 | "~/Library/Caches/Rockstar", 18 | "~/Library/Logs/Rockstar" 19 | ] 20 | end 21 | --------------------------------------------------------------------------------