PasteShelf Plugin Development Guide
This guide explains how to create plugins for PasteShelf using the PasteShelfPluginKit SDK.
Table of Contents
Section titled “Table of Contents”- Overview
- Getting Started
- Plugin Structure
- Creating Your First Plugin
- Plugin Protocols
- Working with Context
- Permissions
- Best Practices
- Debugging
- Distribution
Overview
Section titled “Overview”PasteShelf plugins extend the clipboard manager with custom functionality:
- Content Transformers: Modify clipboard content (format JSON, shorten URLs, etc.)
- Integrations: Send content to external services (Notion, GitHub Gist, etc.)
- UI Extensions: Add menu items and context actions
Plugins are Swift bundles (.pasteshelfplugin) that implement the PasteShelfPlugin protocol.
Requirements
Section titled “Requirements”- macOS 14.0+ (Sonoma)
- Swift 5.9+
- Xcode 15+
- PasteShelf installed
Getting Started
Section titled “Getting Started”1. Add the SDK to Your Project
Section titled “1. Add the SDK to Your Project”Add PasteShelfPluginKit as a Swift Package dependency:
dependencies: [ .package(url: "https://github.com/pasteshelf/PasteShelfPluginKit.git", from: "1.0.0")]Or in Xcode: File > Add Package Dependencies > Enter the repository URL.
2. Create a Bundle Target
Section titled “2. Create a Bundle Target”- In Xcode, create a new target: File > New > Target
- Select “Bundle” under macOS
- Name it with
.pasteshelfpluginextension - Set the principal class in Info.plist
3. Configure Info.plist
Section titled “3. Configure Info.plist”Every plugin requires an Info.plist with these keys:
<?xml version="1.0" encoding="UTF-8"?><!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"><plist version="1.0"><dict> <!-- Required: Unique reverse-DNS identifier --> <key>PSPluginIdentifier</key> <string>com.yourcompany.plugins.myplugin</string>
<!-- Required: Display name --> <key>PSPluginName</key> <string>My Plugin</string>
<!-- Required: Semantic version --> <key>PSPluginVersion</key> <string>1.0.0</string>
<!-- Required: Principal class name (must match @objc attribute) --> <key>NSPrincipalClass</key> <string>MyPlugin</string>
<!-- Optional: Author name --> <key>PSPluginAuthor</key> <string>Your Name</string>
<!-- Optional: Author website --> <key>PSPluginWebsite</key> <string>https://yourwebsite.com</string>
<!-- Optional: Plugin description --> <key>PSPluginDescription</key> <string>A brief description of what your plugin does.</string>
<!-- Required: Minimum PasteShelf version --> <key>PSMinimumVersion</key> <string>1.3.0</string>
<!-- Required: Permissions your plugin needs --> <key>PSPluginPermissions</key> <array> <string>clipboard.read</string> <string>storage</string> </array>
<!-- Optional: Content types your plugin handles --> <key>PSPluginSupportedTypes</key> <array> <string>public.utf8-plain-text</string> <string>public.url</string> </array></dict></plist>Plugin Structure
Section titled “Plugin Structure”A typical plugin bundle structure:
MyPlugin.pasteshelfplugin/├── Contents/│ ├── Info.plist # Plugin manifest│ ├── MacOS/│ │ └── MyPlugin # Compiled binary│ └── Resources/│ ├── icon.png # Plugin icon (optional)│ └── Localizable.strings # Localization (optional)Creating Your First Plugin
Section titled “Creating Your First Plugin”Here’s a complete example of a simple text transformer plugin:
import Foundationimport PasteShelfPluginKit
/// A plugin that converts text to uppercase.@objc(UppercasePlugin)public final class UppercasePlugin: NSObject, PasteShelfPlugin, PasteShelfPluginExtended {
// MARK: - Properties
private var context: (any PluginContext)?
// MARK: - PasteShelfPlugin
public func didLoad(with context: any PluginContext) { self.context = context context.logger.info("Uppercase plugin loaded!") }
public func willUnload() { context?.logger.info("Uppercase plugin unloading") }
public func menuItems() -> [PluginMenuItem] { [ PluginMenuItem( title: "Convert to Uppercase", iconName: "textformat.size.larger", shortcutKey: "U+command+shift" ) { [weak self] content in try await self?.transform(content: content) } ] }
// MARK: - PasteShelfPluginExtended
public func transform(content: PluginClipboardContent) async throws -> PluginClipboardContent? { guard let text = content.text else { return nil }
let result = PluginClipboardContent(text: text.uppercased()) result.metadata["transformedBy"] = "UppercasePlugin" return result }
public func supports(contentType: ContentType) -> Bool { contentType == .plainText }}Key points:
-
@objc(UppercasePlugin): Required for runtime loading. The name must matchNSPrincipalClassin Info.plist. -
NSObjectinheritance: Required for Objective-C runtime compatibility. -
didLoad(with:): Store the context reference here. It provides access to storage, logging, and other APIs. -
willUnload(): Clean up resources before the plugin is unloaded. -
menuItems(): Return menu items that appear in the PasteShelf UI.
Plugin Protocols
Section titled “Plugin Protocols”PasteShelfPlugin (Required)
Section titled “PasteShelfPlugin (Required)”The base protocol all plugins must implement:
@objc public protocol PasteShelfPlugin: NSObjectProtocol { /// Called when the plugin is loaded @objc func didLoad(with context: any PluginContext)
/// Called before the plugin is unloaded (optional) @objc optional func willUnload()
/// Returns menu items for the UI (optional) @objc optional func menuItems() -> [PluginMenuItem]}PasteShelfPluginExtended (Optional)
Section titled “PasteShelfPluginExtended (Optional)”For plugins that transform clipboard content:
public protocol PasteShelfPluginExtended: PasteShelfPlugin { /// Transforms clipboard content func transform(content: PluginClipboardContent) async throws -> PluginClipboardContent?
/// Checks if the plugin supports a content type func supports(contentType: ContentType) -> Bool}PasteShelfPluginWithSettings (Optional)
Section titled “PasteShelfPluginWithSettings (Optional)”For plugins that have a settings UI:
public protocol PasteShelfPluginWithSettings: PasteShelfPlugin { /// Returns a SwiftUI view for plugin settings func settingsView() -> AnyView?}Working with Context
Section titled “Working with Context”The PluginContext provides access to PasteShelf APIs:
Storage
Section titled “Storage”Persistent key-value storage isolated to your plugin:
// Store valuescontext.storage.setString("api-key-here", forKey: "apiKey")context.storage.setBool(true, forKey: "enabled")context.storage.setInteger(5, forKey: "retryCount")
// Retrieve valueslet apiKey = context.storage.string(forKey: "apiKey")let enabled = context.storage.bool(forKey: "enabled")let retries = context.storage.integer(forKey: "retryCount")
// Store Codable objectsstruct Settings: Codable { var theme: String var fontSize: Int}let settings = Settings(theme: "dark", fontSize: 14)context.storage.set("settings", value: settings)let loaded: Settings? = context.storage.get("settings")Logger
Section titled “Logger”Logging for debugging and diagnostics:
context.logger.debug("Debug message")context.logger.info("Info message")context.logger.warning("Warning message")context.logger.error("Error message")Network (Requires Permission)
Section titled “Network (Requires Permission)”Make HTTP requests:
guard let network = context.network else { throw MyError.networkPermissionRequired}
// Simple GETlet data = try await network.get(URL(string: "https://api.example.com/data")!)
// POST with JSONlet responseData = try await network.post( URL(string: "https://api.example.com/submit")!, body: jsonData, contentType: "application/json")
// Custom requestvar request = URLRequest(url: url)request.httpMethod = "PATCH"request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")let (data, response) = try await network.request(request)Clipboard (Requires Permission)
Section titled “Clipboard (Requires Permission)”Access clipboard content:
guard let clipboard = context.clipboard else { throw MyError.clipboardPermissionRequired}
// Get current contentif let content = clipboard.currentContent() { print("Current text: \(content.text ?? "none")")}
// Get recent itemslet recentItems = await clipboard.recentItems(limit: 10)
// Write to clipboardlet newContent = PluginClipboardContent(text: "Modified text")clipboard.writeToClipboard(newContent)Permissions
Section titled “Permissions”Plugins must declare required permissions in Info.plist. Users approve permissions when installing/enabling the plugin.
| Permission | Key | Description |
|---|---|---|
| Read Clipboard | clipboard.read | Read clipboard history and current content |
| Write Clipboard | clipboard.write | Write content to the clipboard |
| Network | network | Make HTTP requests |
| Notifications | notifications | Show system notifications |
| Storage | storage | Persist data (always granted) |
| Automation | automation | Integrate with automation rules |
Requesting Permissions at Runtime
Section titled “Requesting Permissions at Runtime”For permissions declared but not yet granted:
// Check if permission is grantedif !context.hasPermission(.network) { // Request it let granted = await context.requestPermission(.network) if !granted { throw MyError.networkPermissionDenied }}Best Practices
Section titled “Best Practices”1. Handle Missing Permissions Gracefully
Section titled “1. Handle Missing Permissions Gracefully”public func transform(content: PluginClipboardContent) async throws -> PluginClipboardContent? { guard let network = context?.network else { // Provide clear error message throw PluginError.permissionRequired("Network access is required. Enable it in plugin settings.") } // ... use network}2. Use Weak Self in Closures
Section titled “2. Use Weak Self in Closures”PluginMenuItem(title: "Action") { [weak self] content in try await self?.performAction(content)}3. Validate Input
Section titled “3. Validate Input”public func transform(content: PluginClipboardContent) async throws -> PluginClipboardContent? { guard let text = content.text, !text.isEmpty else { return nil // Nothing to transform } // ... process text}4. Provide Meaningful Metadata
Section titled “4. Provide Meaningful Metadata”let result = PluginClipboardContent(text: transformed)result.metadata["originalLength"] = text.countresult.metadata["transformedBy"] = Self.identifierresult.metadata["timestamp"] = ISO8601DateFormatter().string(from: Date())return result5. Keep Operations Fast
Section titled “5. Keep Operations Fast”Plugins run in-process. Long operations block the UI. For slow operations:
// Show progress if availablecontext?.logger.info("Starting long operation...")
// Consider breaking into smaller chunksfor chunk in chunks { // Process chunk try Task.checkCancellation() // Allow cancellation}6. Clean Up Resources
Section titled “6. Clean Up Resources”public func willUnload() { // Cancel any pending operations pendingTask?.cancel()
// Release resources cachedData = nil
context?.logger.info("Plugin unloaded cleanly")}Debugging
Section titled “Debugging”View Plugin Logs
Section titled “View Plugin Logs”Plugin logs appear in Console.app under the PasteShelf process. Filter by your plugin ID.
Common Issues
Section titled “Common Issues”Plugin doesn’t load:
- Verify
NSPrincipalClassmatches your@objc(ClassName)attribute exactly - Ensure the class inherits from
NSObject - Check code signature (unsigned plugins are rejected by default)
Permission errors:
- Verify permissions are declared in Info.plist
- Check if user has granted the permission in settings
Network requests fail:
- Ensure
networkpermission is declared and granted - Verify URLs use HTTPS (HTTP is blocked)
Storage not persisting:
- Storage uses UserDefaults scoped to your plugin ID
- Data persists across app restarts but not plugin reinstalls
Distribution
Section titled “Distribution”Code Signing
Section titled “Code Signing”Plugins should be code-signed for security:
codesign --sign "Developer ID Application: Your Name" \ --options runtime \ --timestamp \ MyPlugin.pasteshelfpluginNotarization (Recommended)
Section titled “Notarization (Recommended)”For distribution outside the App Store:
# Create a ZIP for notarizationzip -r MyPlugin.zip MyPlugin.pasteshelfplugin
# Submit for notarizationxcrun notarytool submit MyPlugin.zip \ --team-id "TEAMID" \ --password "@keychain:AC_PASSWORD" \ --wait
# Staple the ticketxcrun stapler staple MyPlugin.pasteshelfpluginInstallation Directory
Section titled “Installation Directory”Users install plugins to:
~/Library/Application Support/PasteShelf/Plugins/Bundled plugins (included with PasteShelf) are in the app bundle’s Resources folder.
Example Plugins
Section titled “Example Plugins”PasteShelf includes several built-in plugins as references:
| Plugin | Description | Permissions |
|---|---|---|
| JSON Beautifier | Format/minify JSON | storage |
| Markdown Formatter | HTML to Markdown conversion | storage |
| URL Shortener | Shorten URLs via public APIs | network, storage |
| GitHub Gist | Create gists from clipboard | network, storage |
| Notion | Send content to Notion pages | network, storage |
View the source code in PasteShelf/Core/Plugins/BuiltIn/ for implementation patterns.
API Reference
Section titled “API Reference”For complete API documentation, see the PasteShelfPluginKit API Reference.
Support
Section titled “Support”- GitHub Issues: Report bugs and request features
- Documentation: https://pasteshelf.com/docs/plugins
- Community: https://github.com/pasteshelf/discussions
PasteShelf Plugin SDK v1.0.0