├── .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 | 
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 |
9 |
10 |
11 | 
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 |
--------------------------------------------------------------------------------