Nib is a two-process system. Python owns the logic. Swift owns the screen. They talk over a Unix socket using MessagePack.
The Two-Process Model¶
Every Nib application runs as two cooperating processes:
Python process -- your code. It owns the app logic, the view tree, state management, and event handling. When something changes, Python computes the new UI and sends it across the socket.
Swift process -- the runtime. It owns native macOS rendering via SwiftUI, system APIs (notifications, clipboard, file dialogs, keychain), and the menu bar icon. It receives view descriptions from Python, renders them natively, and sends user interactions back.
Neither process directly accesses the other's memory. All communication happens through serialized messages over a Unix domain socket.
Communication Protocol¶
Messages are sent as length-prefixed MessagePack payloads. Each message has a 4-byte big-endian length header followed by the packed data.
[4 bytes: length][MessagePack payload]
Python and Swift exchange three categories of messages:
render -- Full View Tree¶
Sent from Python to Swift. Contains the complete view tree as a flat node list, along with app configuration (icon, title, window size, menu items, hotkeys, fonts).
This is sent on every update. Python re-serializes the entire view tree and sends it to Swift, which replaces the current UI.
# Internal structure (you don't build this manually)
{
"type": "flatRender",
"payload": {
"nodes": [...], # Flat list of view nodes
"rootId": "0", # ID of the root node
"statusBar": {
"icon": "star.fill",
"title": "My App"
},
"window": {
"width": 300.0,
"height": 400.0
},
"menu": [...], # Right-click menu items
"hotkeys": ["cmd+k"], # Global keyboard shortcuts
"fonts": {...} # Custom font paths
}
}
patch -- Incremental Updates¶
Defined in the protocol but currently unused. Nib sends full renders on every update instead of computing incremental patches. The diff engine exists (diff.py) but full renders proved more reliable for the Swift-side view reconciliation.
event -- User Interactions¶
Sent from Swift to Python. Carries a node ID and an event string describing what happened.
# Internal structure
{
"type": "event",
"nodeId": "0.1.0", # Which view triggered the event
"event": "tap" # What happened
}
Event string formats:
| Event | Format | Example |
|---|---|---|
| Button tap | tap |
tap |
| Value change | change:<value> |
change:hello |
| Submit | submit:<value> |
submit:search query |
| Drop files | drop:<paths> |
drop:/path/to/file.txt |
| Hover | hover:<bool> |
hover:true |
| Click | click |
click |
| Canvas pan | pan:<type>:<x>,<y> |
pan:update:150.0,200.0 |
| Hotkey | hotkey:<shortcut> |
hotkey:cmd+shift+n |
| Lifecycle | appear / disappear |
appear |
Execution Modes¶
Development Mode (nib run)¶
Python is the parent process. It launches the Swift runtime as a subprocess, passing the socket path via environment variable.
Python (parent)
|
+-- Spawns Swift runtime subprocess
|
+-- Creates Unix socket at /tmp/nib-<pid>.sock
|
+-- Connects and starts message loop
In this mode, nib run also watches your Python files for changes and restarts the app automatically (hot reload).
Bundled Mode (standalone .app)¶
Swift is the parent process. The .app bundle contains both the Swift runtime and your Python code. When launched, Swift starts and spawns Python as a child process.
Swift Runtime (parent, inside .app bundle)
|
+-- Creates Unix socket
|
+-- Spawns Python with NIB_SOCKET env var
|
+-- Python connects to the provided socket
The NIB_SOCKET environment variable tells Python it is running in bundled mode and where to find the socket.
Same protocol, different launcher
The message protocol is identical in both modes. The only difference is which process starts first and who creates the socket. Your Python code does not need to change between development and production.
Data Flow: From Property Change to Pixel¶
Here is the complete path when you mutate a view property:
1. Python: text.content = "new value"
|
2. View.__setattr__ detects change, calls app._trigger_rerender()
|
3. App sets _render_requested event (threading.Event)
|
4. Render loop thread wakes up, calls _render()
|
5. App calls body() to get the root view
|
6. App calls _collect_actions() to assign IDs and register callbacks
|
7. Root view serialized to flat node list via to_flat_list()
|
8. Connection.send_flat_render() packs with MessagePack, sends over socket
|
9. Swift SocketServer reads length-prefixed message, unpacks
|
10. Swift AppDelegate routes to ViewStore
|
11. SwiftUI observes ViewStore change, re-renders affected views
|
12. Native macOS pixels update on screen
Coalesced rendering
Multiple rapid property changes are coalesced into a single render. The render loop uses a threading Event that is set on any change and cleared when a render begins. This means if you change three properties in quick succession, only one render happens. The render loop is throttled to approximately 500 frames per second maximum.
Other Message Types¶
Beyond the core three, Python sends additional message types for system integration:
| Message Type | Direction | Purpose |
|---|---|---|
quit |
Python to Swift | Terminate the runtime |
notify |
Python to Swift | Show a macOS notification |
clipboard |
Python to Swift | Read/write the system clipboard |
fileDialog |
Python to Swift | Open/save file picker dialogs |
userDefaults |
Python to Swift | Persist settings via UserDefaults |
service |
Python to Swift | Query system services (battery, screen, etc.) |
action |
Python to Swift | Trigger view-specific actions (WebView reload, etc.) |
settingsRender |
Python to Swift | Send settings window UI |
notification |
Python to Swift | Push/schedule/cancel notifications |
serviceResponse |
Swift to Python | System service query results |
notificationResponse |
Swift to Python | Notification action responses |
fileDialogResponse |
Swift to Python | File dialog selections |