From 603c99b35ef00b4a5253e70dbbf4786cd752f167 Mon Sep 17 00:00:00 2001 From: alexey1312 Date: Sun, 1 Feb 2026 20:34:27 +0500 Subject: [PATCH 1/2] feat: add ClipboardService to manage clipboard operations Introduce `ClipboardService` to enable clipboard management, such as reading, writing, and listing clipboard contents. Update `ServerController` to integrate this new service with a default 'enabled' state in user preferences. These enhancements increase the functionality by allowing manipulation of clipboard data programmatically. --- App/Controllers/ServerController.swift | 11 ++ App/Services/Clipboard.swift | 166 +++++++++++++++++++++++++ 2 files changed, 177 insertions(+) create mode 100644 App/Services/Clipboard.swift diff --git a/App/Controllers/ServerController.swift b/App/Controllers/ServerController.swift index 255c2bb6..26b76c97 100644 --- a/App/Controllers/ServerController.swift +++ b/App/Controllers/ServerController.swift @@ -59,6 +59,7 @@ enum ServiceRegistry { RemindersService.shared, ShortcutsService.shared, UtilitiesService.shared, + ClipboardService.shared, ] #if WEATHERKIT_AVAILABLE services.append(WeatherService.shared) @@ -69,6 +70,7 @@ enum ServiceRegistry { static func configureServices( calendarEnabled: Binding, captureEnabled: Binding, + clipboardEnabled: Binding, contactsEnabled: Binding, locationEnabled: Binding, mapsEnabled: Binding, @@ -93,6 +95,13 @@ enum ServiceRegistry { service: CaptureService.shared, binding: captureEnabled ), + ServiceConfig( + name: "Clipboard", + iconName: "doc.on.clipboard.fill", + color: .teal, + service: ClipboardService.shared, + binding: clipboardEnabled + ), ServiceConfig( name: "Contacts", iconName: "person.crop.square.filled.and.at.rectangle.fill", @@ -167,6 +176,7 @@ final class ServerController: ObservableObject { // MARK: - AppStorage for Service Enablement States @AppStorage("calendarEnabled") private var calendarEnabled = false @AppStorage("captureEnabled") private var captureEnabled = false + @AppStorage("clipboardEnabled") private var clipboardEnabled = true @AppStorage("contactsEnabled") private var contactsEnabled = false @AppStorage("locationEnabled") private var locationEnabled = false @AppStorage("mapsEnabled") private var mapsEnabled = true // Default enabled @@ -184,6 +194,7 @@ final class ServerController: ObservableObject { ServiceRegistry.configureServices( calendarEnabled: $calendarEnabled, captureEnabled: $captureEnabled, + clipboardEnabled: $clipboardEnabled, contactsEnabled: $contactsEnabled, locationEnabled: $locationEnabled, mapsEnabled: $mapsEnabled, diff --git a/App/Services/Clipboard.swift b/App/Services/Clipboard.swift new file mode 100644 index 00000000..8ec51dbf --- /dev/null +++ b/App/Services/Clipboard.swift @@ -0,0 +1,166 @@ +import AppKit +import JSONSchema +import MCP +import OSLog + +private let log = Logger.service("clipboard") + +/// Error types for clipboard operations. +enum ClipboardError: LocalizedError { + case missingContent + case writeFailed + + var errorDescription: String? { + switch self { + case .missingContent: + return "Missing required 'content' parameter" + case .writeFailed: + return "Failed to write content to clipboard" + } + } +} + +final class ClipboardService: Service { + static let shared = ClipboardService() + + var tools: [Tool] { + Tool( + name: "clipboard_read", + description: + "Read the current clipboard contents. Returns text, image data, or file URLs depending on what's in the clipboard.", + inputSchema: .object( + properties: [:], + additionalProperties: false + ), + annotations: .init( + title: "Read Clipboard", + readOnlyHint: true, + openWorldHint: false + ) + ) { _ in + try await self.readClipboard() + } + + Tool( + name: "clipboard_write", + description: "Write text content to the clipboard, replacing any existing content.", + inputSchema: .object( + properties: [ + "content": .string(description: "Text content to write to clipboard") + ], + required: ["content"], + additionalProperties: false + ), + annotations: .init( + title: "Write to Clipboard", + destructiveHint: true, + openWorldHint: false + ) + ) { arguments in + try await self.writeClipboard(arguments: arguments) + } + + Tool( + name: "clipboard_types", + description: "List available data types in the current clipboard.", + inputSchema: .object( + properties: [:], + additionalProperties: false + ), + annotations: .init( + title: "List Clipboard Types", + readOnlyHint: true, + openWorldHint: false + ) + ) { _ in + try await self.listClipboardTypes() + } + } + + // MARK: - Private Implementation + + @MainActor + private func readClipboard() async throws -> Value { + let pasteboard = NSPasteboard.general + let types = pasteboard.types ?? [] + + // Priority: files → image → text + // Files first because Finder copies include both file URL and filename as text + + // Check for file URLs (copied files from Finder) + if types.contains(.fileURL), + let urls = pasteboard.readObjects(forClasses: [NSURL.self], options: nil) as? [URL], + !urls.isEmpty + { + log.debug("Clipboard contains \(urls.count) file URLs") + return .object([ + "type": .string("files"), + "urls": .array(urls.map { .string($0.absoluteString) }), + "filenames": .array(urls.map { .string($0.lastPathComponent) }), + ]) + } + + // Check for image data (TIFF is the standard macOS image format) + if let imageData = pasteboard.data(forType: .tiff) { + log.debug("Clipboard contains image (\(imageData.count) bytes)") + return .data(mimeType: "image/tiff", imageData) + } + + // Check for PNG image + if let imageData = pasteboard.data(forType: .png) { + log.debug("Clipboard contains PNG image (\(imageData.count) bytes)") + return .data(mimeType: "image/png", imageData) + } + + // Fallback to text + if let string = pasteboard.string(forType: .string) { + log.debug("Clipboard contains text (\(string.count) characters)") + return .object([ + "type": .string("text"), + "content": .string(string), + ]) + } + + log.debug("Clipboard is empty") + return .object([ + "type": .string("empty"), + "content": .null, + ]) + } + + @MainActor + private func writeClipboard(arguments: [String: Value]) async throws -> Value { + guard let content = arguments["content"]?.stringValue else { + log.error("clipboard_write called without content parameter") + throw ClipboardError.missingContent + } + + let pasteboard = NSPasteboard.general + pasteboard.clearContents() + let success = pasteboard.setString(content, forType: .string) + + if success { + log.info("Wrote \(content.count) characters to clipboard") + } else { + log.error("Failed to write to clipboard") + } + + return .object([ + "success": .bool(success), + "length": .int(content.count), + ]) + } + + @MainActor + private func listClipboardTypes() async throws -> Value { + let pasteboard = NSPasteboard.general + let types = pasteboard.types ?? [] + + log.debug("Clipboard has \(types.count) types available") + + return .object([ + "types": .array(types.map { .string($0.rawValue) }), + "count": .int(types.count), + ]) + } +} From 62301733fc1172bfff0d6380eba33cca2ccf027f Mon Sep 17 00:00:00 2001 From: alexey1312 Date: Sun, 1 Feb 2026 20:46:16 +0500 Subject: [PATCH 2/2] refactor: enhance clipboard handling and logging Improve error handling in `readClipboard` by adding checks for unavailable pasteboard types, enhancing fault tolerance. --- App/Services/Clipboard.swift | 34 ++++++++++++++++++++++++++-------- 1 file changed, 26 insertions(+), 8 deletions(-) diff --git a/App/Services/Clipboard.swift b/App/Services/Clipboard.swift index 8ec51dbf..f76302ae 100644 --- a/App/Services/Clipboard.swift +++ b/App/Services/Clipboard.swift @@ -1,6 +1,5 @@ import AppKit import JSONSchema -import MCP import OSLog private let log = Logger.service("clipboard") @@ -82,7 +81,16 @@ final class ClipboardService: Service { @MainActor private func readClipboard() async throws -> Value { let pasteboard = NSPasteboard.general - let types = pasteboard.types ?? [] + + guard let types = pasteboard.types else { + log.warning("Failed to get pasteboard types - pasteboard may be unavailable") + return .object([ + "type": .string("error"), + "message": .string( + "Unable to read clipboard. The system clipboard service may be temporarily unavailable." + ), + ]) + } // Priority: files → image → text // Files first because Finder copies include both file URL and filename as text @@ -121,6 +129,15 @@ final class ClipboardService: Service { ]) } + // Clipboard has types but none we support + if !types.isEmpty { + log.debug("Clipboard contains unsupported types: \(types.map { $0.rawValue })") + return .object([ + "type": .string("unsupported"), + "availableTypes": .array(types.map { .string($0.rawValue) }), + ]) + } + log.debug("Clipboard is empty") return .object([ "type": .string("empty"), @@ -136,17 +153,18 @@ final class ClipboardService: Service { } let pasteboard = NSPasteboard.general - pasteboard.clearContents() - let success = pasteboard.setString(content, forType: .string) + let changeCount = pasteboard.clearContents() + log.debug("Cleared clipboard, change count: \(changeCount)") - if success { - log.info("Wrote \(content.count) characters to clipboard") - } else { + let success = pasteboard.setString(content, forType: .string) + guard success else { log.error("Failed to write to clipboard") + throw ClipboardError.writeFailed } + log.info("Wrote \(content.count) characters to clipboard") return .object([ - "success": .bool(success), + "success": .bool(true), "length": .int(content.count), ]) }