Swift Source Plugins
The simplest way to create a BetterTouchTool plugin. Write a single .swift file, drop it into the Plugins folder, and BTT compiles and loads it automatically - no Xcode project required.
Requirements
- Xcode Command Line Tools must be installed:
xcode-select --install
How It Works
- Create a
.swiftfile containing your plugin class - Drop it onto the BTT preferences window, or copy it to either
~/Library/Application Support/BetterTouchTool/Plugins/or/Library/Application Support/BetterTouchTool/Plugins/ - BTT detects the file and asks: "Compile & Load?"
- On approval, BTT compiles it with
swiftcinto a plugin bundle - The plugin becomes available immediately
If you edit the .swift file, BTT detects the change and offers to recompile. Deleting the .swift file automatically removes the compiled bundle.
Metadata Comments
Put metadata comments in the first 50 lines of your file. Set an explicit, stable identifier so renaming the file does not change the plugin identity:
// BTT-Plugin-Name: My Widget
// BTT-Plugin-Identifier: com.myname.mywidget
// BTT-Plugin-Type: FloatingMenuWidget
// BTT-Plugin-Icon: star.fill
| Comment | Description | Default if omitted |
|---|---|---|
BTT-Plugin-Name | Display name in BTT | Filename without extension |
BTT-Plugin-Identifier | Unique reverse-domain identifier | com.btt.swift.<filename> |
BTT-Plugin-Type | Plugin type | Inferred from protocol conformance |
BTT-Plugin-Icon | SF Symbol name for the icon | None |
Supported BTT-Plugin-Type values: FloatingMenuWidget, Action, StreamDeck, TouchBar, Trigger, Launcher
Protocols and Bridging Header
All plugin protocols are defined in BTTSwiftPluginHeader.h, shipped inside the BTT app bundle. The bridging header is automatically provided during compilation - you don't need to import it manually. Use the protocols, models, delegates and form constants exposed by that header, together with the installed macOS SDK. BTT app internals such as LauncherStore, APPDEL and internal SwiftUI modifiers are not plugin APIs.
Keep the principal declaration on one line, for example class MyWidget: NSObject, BTTFloatingMenuWidgetInterface. The source metadata parser uses that declaration to discover your class. Let Swift provide its module-qualified Objective-C name; a custom @objc(Name) can conflict with the previous version during hot reload.
Callback Signatures
Use the exact Swift signatures imported from BTT's header. Untyped Objective-C dictionaries use AnyHashable keys:
| Plugin type | Configuration parameter |
|---|---|
| Floating Menu Widget, Launcher, Trigger | [AnyHashable: Any]? in didReceiveNewConfigurationValues(_:) |
| Stream Deck, Touch Bar | [AnyHashable: Any] in didReceiveNewConfigurationValues(_:) |
| Action | [AnyHashable: Any]? in executeAction(withConfiguration:completionBlock:) |
A warning that a method “nearly matches” an optional protocol requirement means BTT may never call that method. Correct the signature; do not hide the warning by making the method private. Builds containing this warning are rejected by BTT's updated plugin compiler, leaving the previous compiled bundle intact. Older BTT builds may accept the warning, so check diagnostics when supporting older installations.
Keep AppKit and SwiftUI updates on the main thread. Move blocking work out of callbacks, preserve stable item IDs, and release timers and observers when the plugin stops or its view disappears.
Creating and Editing With AI
Use the BTT Plugin Creator / Editor skills and retrieve the reference for the intended plugin type before generating code. The AI authoring workflow lists the matching tools.
For a new Swift plugin, pass source_code and plugin_file_name directly to compile_swift_plugin. For an existing plugin, read its source, make focused edits with the available file tools, then compile with swift_file_path. Preserve literal backslashes and Swift interpolation when moving source through JSON or JavaScript.
The compile tool manages its file-watcher suppression. A BTT-AI-Managed comment has no effect on the current loader. A SUCCESS: result confirms compilation and loading; verify the configured plugin's behavior separately.
Examples by Plugin Type
Floating Menu Widget
// BTT-Plugin-Name: Hello Widget
// BTT-Plugin-Identifier: com.example.hellowidget
// BTT-Plugin-Type: FloatingMenuWidget
// BTT-Plugin-Icon: hand.wave.fill
import Cocoa
class HelloWidget: NSObject, BTTFloatingMenuWidgetInterface {
weak var delegate: (any BTTFloatingMenuWidgetDelegate)?
static func widgetName() -> String { "Hello Widget" }
static func widgetDescription() -> String { "A simple greeting widget" }
static func widgetIcon() -> String { "hand.wave.fill" }
// Override the menu item's configured size (optional)
static func widgetMinWidth() -> CGFloat { 200 }
static func widgetMinHeight() -> CGFloat { 80 }
func makeWidgetView() -> NSView {
let label = NSTextField(labelWithString: "Hello from BTT!")
label.font = .systemFont(ofSize: 24, weight: .medium)
label.textColor = .white
return label
}
func widgetDidAppear() {}
func widgetWillDisappear() {}
}
See Floating Menu Widget Plugins for the full protocol reference, SwiftUI support, and custom action categories.
Stream Deck
// BTT-Plugin-Name: Counter
// BTT-Plugin-Identifier: com.example.counter
// BTT-Plugin-Type: StreamDeck
// BTT-Plugin-Icon: number.circle.fill
import Cocoa
class Counter: NSObject, BTTStreamDeckPluginInterface {
weak var delegate: (any BTTStreamDeckPluginDelegate)?
private var count = 0
static func configurationFormItems() -> BTTPluginFormItem? { nil }
func widgetTitleStrings() -> [String]? {
return ["\(count)"]
}
func buttonDown(_ identifier: String) -> Bool {
count += 1
delegate?.requestUpdate(self)
return false // return true to cancel assigned BTT actions
}
func buttonUp(_ identifier: String) -> Bool {
return false
}
}
See Stream Deck Plugins for the full protocol reference and all rendering options.
Touch Bar
// BTT-Plugin-Name: Clock Text
// BTT-Plugin-Identifier: com.example.clocktext
// BTT-Plugin-Type: TouchBar
// BTT-Plugin-Icon: clock
import Cocoa
class ClockText: NSObject, BTTPluginInterface {
weak var delegate: (any BTTTouchBarPluginDelegate)?
static func configurationFormItems() -> BTTPluginFormItem? { nil }
func touchBarTitleString() -> String? {
let formatter = DateFormatter()
formatter.dateFormat = "HH:mm:ss"
return formatter.string(from: Date())
}
}
See Touch Bar Plugins for the full protocol reference and all rendering options.
Action
// BTT-Plugin-Name: Show Greeting
// BTT-Plugin-Identifier: com.example.showgreeting
// BTT-Plugin-Type: Action
// BTT-Plugin-Icon: bubble.left.fill
import Cocoa
class ShowGreeting: NSObject, BTTActionPluginInterface {
weak var delegate: (any BTTActionPluginDelegate)?
static func configurationFormItems() -> BTTPluginFormItem? { nil }
func executeAction(
withConfiguration config: [AnyHashable: Any]?,
completionBlock: ((@Sendable (Any?) -> Void)?)
) {
let alert = NSAlert()
alert.messageText = "Hello from a BTT Action Plugin!"
alert.runModal()
completionBlock?("done")
}
}
See Action Plugins for the full protocol reference.
Trigger
// BTT-Plugin-Name: Clipboard Change
// BTT-Plugin-Identifier: com.example.clipboardchangetrigger
// BTT-Plugin-Type: Trigger
// BTT-Plugin-Icon: doc.on.clipboard
import Cocoa
class ClipboardChangeTrigger: NSObject, BTTTriggerPluginInterface {
weak var delegate: (any BTTTriggerPluginDelegate)?
private var timer: Timer?
private var lastChangeCount = NSPasteboard.general.changeCount
static func triggerName() -> String { "Clipboard Change" }
static func triggerDescription() -> String { "Fires when clipboard content changes" }
static func triggerIcon() -> String { "doc.on.clipboard" }
static func configurationFormItems() -> BTTPluginFormItem? { nil }
func didReceiveNewConfigurationValues(_ config: [AnyHashable: Any]?) {}
func startObserving() {
stopObserving()
lastChangeCount = NSPasteboard.general.changeCount
timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
guard let self else { return }
let current = NSPasteboard.general.changeCount
if current != self.lastChangeCount {
self.lastChangeCount = current
let content = NSPasteboard.general.string(forType: .string) ?? ""
self.delegate?.triggerFired(self, withContext: ["clipboardContent": content])
}
}
}
func stopObserving() {
timer?.invalidate()
timer = nil
}
}
See Trigger Plugins for the full protocol reference, lifecycle management, and more examples.
Launcher
// BTT-Plugin-Name: Demo Launcher Plugin
// BTT-Plugin-Identifier: com.example.demolauncherplugin
// BTT-Plugin-Type: Launcher
// BTT-Plugin-Icon: sparkles.rectangle.stack
import AppKit
import SwiftUI
class DemoLauncherPlugin: NSObject, BTTLauncherPluginInterface {
weak var delegate: (any BTTLauncherPluginDelegate)?
static func launcherPluginName() -> String { "Demo Launcher Plugin" }
static func launcherPluginDescription() -> String { "Shows custom launcher results" }
static func launcherPluginIcon() -> String { "sparkles.rectangle.stack" }
func launcherResults(for context: BTTLauncherPluginContext) -> [BTTLauncherPluginResult]? {
let result = BTTLauncherPluginResult()
result.itemIdentifier = "demo-root"
result.title = "Launcher Plugin Demo"
result.subtitle = "Returned by a native launcher plugin"
result.systemImageName = "sparkles.rectangle.stack"
result.surfaceIdentifier = "demo-surface"
return [result]
}
func launcherSurface(
forItemIdentifier itemIdentifier: String,
surfaceIdentifier: String?,
context: BTTLauncherPluginContext
) -> (any BTTLauncherPluginSurfaceInterface)? {
DemoLauncherSurface()
}
}
final class DemoLauncherSurface: NSObject, BTTLauncherPluginSurfaceInterface {
func makeLauncherSurfaceView() -> NSView {
NSHostingView(rootView: DemoLauncherSurfaceView())
}
}
struct DemoLauncherSurfaceView: View {
var body: some View {
VStack(alignment: .leading, spacing: 10) {
Label("Hello from a launcher surface", systemImage: "sparkles.rectangle.stack")
.font(.title3.weight(.semibold))
Text("This view is rendered with SwiftUI and hosted inside the BetterTouchTool Launcher.")
.foregroundStyle(.secondary)
}
.padding(24)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
}
}
See Launcher Plugins for the full protocol reference, commands, launcher surfaces, context values, and Xcode bundle setup.
BTT Variable Access
All plugin types have access to BTT variables through their delegate:
// Set a variable (accessible as {variable_name} throughout BTT)
delegate?.setVariable("my_var", value: "hello")
// Read a variable
let value = delegate?.getVariable("my_var")
Adding Configuration Forms
Any Swift source plugin can include user-configurable settings by implementing configurationFormItems(). This fragment uses the nullable callback for a floating-menu widget; choose the signature from the table above for other types:
static func configurationFormItems() -> BTTPluginFormItem? {
let group = BTTPluginFormItem()
group.formFieldType = BTTFormTypeFormGroup
let textField = BTTPluginFormItem()
textField.formFieldType = BTTFormTypeTextField
textField.formLabel1 = "Label Text"
textField.formFieldID = "labelText"
textField.defaultValue = "Hello!"
group.formOptions = [textField]
return group
}
func didReceiveNewConfigurationValues(_ configurationValues: [AnyHashable: Any]?) {
// Values arrive with a "plugin_var_" prefix on the formFieldID
if let text = configurationValues?["plugin_var_labelText"] as? String {
labelText = text
}
}
Use plain field IDs in configurationFormItems(), for example labelText; BTT applies the plugin_var_ prefix when saving and delivering configuration values. For trigger plugins, configuration is saved per configured BTT trigger instance and is delivered before startObserving() runs.
See the Configuration Forms Reference for all available field types.