How to Configure macOS AppKit SwiftUI Plugin Workflows: A Complete Guide
Configure macOS AppKit SwiftUI plugin workflows by scaffolding a SwiftUI-first application and wrapping specific AppKit controls in thin NSViewRepresentable bridges with coordinators that mediate state changes without leaking AppKit types into your declarative view hierarchy.
The OpenAI Plugins repository defines a strict architectural pattern to configure macOS AppKit SwiftUI plugin workflows that maximizes code reuse and maintainability. By keeping the bulk of the UI in SwiftUI and isolating AppKit dependencies to narrow, well-defined bridges, you maintain declarative state management while accessing platform-specific capabilities that SwiftUI does not yet expose.
The Three-Phase Workflow
The workflow to configure macOS AppKit SwiftUI plugin workflows consists of three distinct phases: scaffolding the SwiftUI shell, identifying where AppKit interop is necessary, and implementing a thin bridge with proper state ownership.
Phase 1: Scaffold the SwiftUI Shell
Start by creating a pure SwiftUI application structure. According to plugins/build-macos-apps/skills/appkit-interop/references/app-wiring.md, this involves:
- Creating a
@mainApp struct conforming to the SwiftUIAppprotocol - Defining an
AppTabenum for navigation state - Wrapping the root view in a
NavigationStackorTabViewscaffold - Injecting environment objects for global state
This establishes the SwiftUI-first foundation, ensuring that AppKit types remain confined to specific integration points rather than spreading throughout the codebase.
Phase 2: Determine the AppKit Interop Pattern
Before writing bridge code, consult the decision matrix documented in plugins/build-macos-apps/skills/appkit-interop/SKILL.md under the Good fits for AppKit section. Choose the appropriate bridge type based on the required functionality:
NSViewRepresentable– For wrapping individual AppKit views likeNSTextView,NSScrollView, or customNSViewsubclasses that require complex drawing or text handlingNSViewControllerRepresentable– When you need to embed a fullNSViewControllerwith its own lifecycle, such as a complex preference pane or document-based interface- Direct
NSWindoworNSPanelaccess – For menus, floating panels, or responder-chain handling that SwiftUI cannot replicate, as detailed inplugins/build-macos-apps/skills/appkit-interop/references/window-panels.md
This selection prevents the "AppKit escape hatches" anti-pattern by enforcing that you only bridge when SwiftUI APIs are genuinely insufficient.
Phase 3: Implement the Representable Bridge
Using the boilerplate from plugins/build-macos-apps/skills/appkit-interop/references/representables.md, create a thin wrapper that contains three essential components:
- The Representable struct conforming to
NSViewRepresentableorNSViewControllerRepresentable - A Coordinator class that acts as the delegate for AppKit callbacks and mediates state changes back to SwiftUI
- An
updateNSVieworupdateUIViewControllermethod that performs diffing to avoid infinite loops by only pushing changes when the data actually differs
The coordinator must own @Binding properties to ensure state flows bidirectionally: from SwiftUI into the AppKit view via the update method, and from AppKit back to SwiftUI via delegate callbacks.
macOS AppKit SwiftUI Bridge Implementation Example
The following example from the OpenAI Plugins repository demonstrates wrapping an NSTextView inside SwiftUI while maintaining clean state ownership:
import SwiftUI
import AppKit
// A thin NSViewRepresentable wrapper for an AppKit text view.
struct LegacyTextView: NSViewRepresentable {
@Binding var text: String // SwiftUI <-> AppKit binding
func makeCoordinator() -> Coordinator {
Coordinator(text: $text)
}
func makeNSView(context: Context) -> NSScrollView {
let scrollView = NSScrollView()
let textView = NSTextView()
textView.delegate = context.coordinator // delegate <-> coordinator
scrollView.documentView = textView
return scrollView
}
func updateNSView(_ nsView: NSScrollView, context: Context) {
guard let textView = nsView.documentView as? NSTextView else { return }
// Avoid infinite loops: only push when the strings differ
if textView.string != text {
textView.string = text
}
}
// Coordinator mediates AppKit -> SwiftUI events
final class Coordinator: NSObject, NSTextViewDelegate {
@Binding var text: String
init(text: Binding<String>) { _text = text }
func textDidChange(_ notification: Notification) {
guard let textView = notification.object as? NSTextView else { return }
text = textView.string // updates SwiftUI @State
}
}
}
// Using the representable inside a SwiftUI view
struct ContentView: View {
@State private var message = "Hello, AppKit!"
var body: some View {
VStack {
LegacyTextView(text: $message) // bridge inserted here
.frame(height: 120)
.border(Color.gray)
Text("SwiftUI sees: \(message)")
.padding()
}
.padding()
}
}
In this implementation, the LegacyTextView struct lives entirely within the representable pattern described in plugins/build-macos-apps/skills/appkit-interop/references/representables.md. The Coordinator class conforms to NSTextViewDelegate to capture user input, while the updateNSView method prevents feedback loops by checking textView.string != text before assignment.
Build and Test the Plugin
After implementing the bridge, build and launch the macOS application using the commands specified in plugins/build-macos-apps/skills/build-run-debug/SKILL.md:
swift build
open .build/debug/YourApp.app
Verify that:
- The AppKit view renders correctly within the SwiftUI layout
- State changes propagate bidirectionally without causing duplicate updates or infinite loops
- Dark mode transitions and accessibility features work as expected
If the bridge introduces performance issues such as heavy redraws, consult plugins/build-macos-apps/skills/swiftui-ui-patterns/references/performance.md for identity and lazy-container guidelines.
Summary
- SwiftUI-first architecture keeps the majority of UI code declarative and portable, with AppKit isolated to specific representable wrappers.
- Three-phase workflow involves scaffolding the app shell, selecting the appropriate interop pattern from the decision matrix in
appkit-interop/SKILL.md, and implementing a thin bridge. - Coordinator pattern is essential for mediating delegate callbacks from AppKit back to SwiftUI
@Stateor@Bindingproperties. - Diffing in
updateNSViewprevents infinite loops by only pushing data when values actually change. - File references: The primary documentation resides in
plugins/build-macos-apps/skills/appkit-interop/SKILL.md, with specific boilerplate inreferences/representables.mdand build commands inbuild-run-debug/SKILL.md.
Frequently Asked Questions
What is the difference between NSViewRepresentable and NSViewControllerRepresentable?
NSViewRepresentable wraps a single NSView instance for narrow integration tasks like embedding a complex text editor or custom drawing view. NSViewControllerRepresentable wraps an entire NSViewController, making it suitable for scenarios requiring full view controller lifecycle management, such as preference panes or document windows that rely on NSDocument architecture. According to the OpenAI Plugins source code, choose the former for view-level bridges and the latter only when controller-level features are mandatory.
How do I prevent infinite loops when binding AppKit delegate callbacks to SwiftUI state?
Implement defensive checks in the updateNSView or updateUIViewController method to verify that the new value differs from the current AppKit view's value before assignment, as shown in the if textView.string != text guard clause. The coordinator should update the SwiftUI binding in the delegate callback, but the update method must skip redundant sets to break the feedback cycle.
Where should AppKit-specific state live in a mixed SwiftUI/AppKit plugin?
AppKit-specific state should remain confined to the coordinator or the AppKit view itself, while business logic and UI state belong to SwiftUI @State, @Binding, or @Observable objects. Follow the state-ownership matrix documented in plugins/build-macos-apps/skills/swiftui-ui-patterns/SKILL.md to ensure the bridge only receives binding values and does not expose AppKit types to the rest of the SwiftUI view hierarchy.
How do I build and run the macOS plugin from the command line?
Use the standard Swift Package Manager build command followed by opening the generated app bundle, as documented in plugins/build-macos-apps/skills/build-run-debug/SKILL.md:
swift build && open .build/debug/YourApp.app
This ensures the GUI app launches with proper macOS sandboxing and entitlements applied, rather than running as a headless process.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →