A lightweight macOS menu bar app that synchronizes your keyboard backlight brightness with your display brightness. Designed for M4 MacBook Pro (and other Apple Silicon Macs) where dedicated keyboard brightness keys were removed.
- Live Sync: Instantly syncs when you change brightness (keys, Control Center slider, etc.)
- Timed Sync: Optional background check every 10 seconds as a safety net (disabled by default)
- Battery Aware: Option to pause background checking when on battery
- Minimal Resource Usage: ~12MB memory, zero CPU when idle, runs silently in the menu bar
- Persistent Settings: Saves preferences to
~/.twinkley.json - Auto-Updates: Built-in update system (Sparkle 2) - stay current effortlessly
- Privacy First: Zero data collection - everything runs locally (Privacy Policy)
- Auto-start: Optional LaunchAgent for login startup
TwinKley watches for macOS brightness changes and automatically syncs your keyboard backlight to match.
Live Sync (Instant)
When you change your display brightness, macOS sends out an internal "brightness changed" notification. TwinKley listens for these notifications and immediately updates your keyboard backlight. This works with:
- ✅ Brightness keys (Fn+F1/F2 or media keys)
- ✅ Control Center brightness slider
- ✅ Touch Bar brightness controls
- ✅ Automation tools and scripts
This is instant - no waiting, no polling, no battery drain.
Timed Sync (Safety Net)
Some apps or tools might change brightness without triggering macOS notifications. The timed sync checks every 10 seconds as a fallback to catch these rare cases. If Live Sync is working perfectly for you, you can disable this in the menu.
Which should you use?
- Live Sync only (default) - Works for most users with zero battery impact
- Enable Timed Sync only if you notice missed syncs when using third-party brightness tools
- macOS 13.0+ (Ventura or later)
- Accessibility permissions (required for keypress detection)
- Mac with keyboard backlight
| Status | Hardware |
|---|---|
| ✅ Tested | M4 MacBook Pro (macOS Tahoe 26.2) |
| 🔄 Should work | All Apple Silicon Macs (M1, M2, M3, M4 series) |
| ❓ Unknown | Intel Macs with keyboard backlight |
The app uses standard macOS APIs (NX_SYSDEFINED events with keyCodes 2/3) that are consistent across Mac models. If you test on other hardware, let us know!
git clone https://github.com/emrikol/TwinKley.git
cd TwinKley
./build.shThis creates /Applications/TwinKley.app and a LaunchAgent.
- Open System Settings > Privacy & Security > Accessibility
- Click the + button and add
TwinKley.app - Enable the toggle
open /Applications/TwinKley.appOr double-click the app in Finder.
- About: Shows app info (double-click icon to toggle debug mode)
- Check for Updates: Manually check for new versions
- Status: Shows if the app is active
- Live Sync: Toggle instant sync (responds to brightness changes immediately)
- Timed Sync: Toggle background check every 10 seconds
- Sync Now: Manually sync brightness right now
- Preferences: Open settings window (⌘,)
- Help: Open documentation
- Quit: Exit the app
Enable:
launchctl load ~/Library/LaunchAgents/com.emrikol.TwinKley.plistDisable:
launchctl unload ~/Library/LaunchAgents/com.emrikol.TwinKley.plistDebug logs are written to ~/.twinkley-debug.log and include timestamps, brightness values, power state changes, and sync operations. Debug logs do not contain keystrokes or personal input. Log files older than 7 days or larger than 1 MB are automatically cleaned up.
Enable at startup:
/Applications/TwinKley.app/Contents/MacOS/TwinKley --debugToggle during runtime (no restart needed):
- Click the menu bar icon → "About TwinK[l]ey"
- Double-click the app icon at the top
- Debug mode will toggle on/off with a confirmation message
This is useful for diagnosing issues without restarting the app. Perfect for investigating sync problems after sleep/wake cycles or other events.
Note: The Debug Window also includes a "Capture Events" feature that temporarily captures ALL keypresses to help diagnose brightness key detection. A privacy warning is displayed when this is active. See the Privacy Policy for details.
Settings are stored in ~/.twinkley.json:
{
"liveSyncEnabled": true,
"timedSyncEnabled": false,
"timedSyncIntervalMs": 10000,
"pauseTimedSyncOnBattery": false,
"pauseTimedSyncOnLowBattery": true,
"brightnessGamma": 1.5
}| Setting | Description | Default |
|---|---|---|
liveSyncEnabled |
Instant sync when brightness changes | true |
timedSyncEnabled |
Background check every 10s as safety net | false |
timedSyncIntervalMs |
Polling interval (100-60000ms) | 10000 |
pauseTimedSyncOnBattery |
Pause polling when on battery | false |
pauseTimedSyncOnLowBattery |
Pause polling when battery < 20% | true |
brightnessGamma |
Gamma correction for brightness curve (0.5-4.0) | 1.5 |
Custom Brightness Key Codes
TwinKley detects brightness changes by listening for macOS NX_SYSDEFINED events with specific key codes.
| Setting | Description | Default |
|---|---|---|
brightnessKeyCodes |
Array of NX key codes to treat as brightness events | [2, 3] |
Default key codes:
2-NX_KEYTYPE_BRIGHTNESS_UP3-NX_KEYTYPE_BRIGHTNESS_DOWN
These are the standard brightness key codes defined in Apple's IOKit headers and work consistently across all Mac models (including M4 MacBook Pro) when read correctly.
When to customize: In rare cases, if brightness detection isn't working on your Mac, use the Debug Window's "Capture Events" feature to see which key codes your system generates, then add them to this array.
Note: Restart TwinKley after changing this setting.
TL;DR: The default gamma of 1.5 dims the keyboard at low display brightness levels. Adjust if needed.
Why is this needed?
Human eyes perceive brightness non-linearly—we're more sensitive to changes in dark tones than bright ones. Without gamma correction, the keyboard may appear too bright or turn off before the display at low brightness levels.
The gamma correction formula applies a power curve:
keyboardBrightness = pow(displayBrightness, gamma)
Examples with gamma = 1.5:
| Display | Keyboard (γ=1.0 linear) | Keyboard (γ=1.5) |
|---|---|---|
| 100% | 100% | 100% |
| 50% | 50% | 35% |
| 25% | 25% | 12.5% |
| 10% | 10% | 3.2% |
| 6.25% | 6.25% | 1.6% |
Key findings:
- Both display and keyboard brightness range from 0.0 (off) to 1.0 (full brightness)
- Physical brightness keys can reach true 0.0 and 1.0
- Gamma values suppress low brightness without creating "sticky zones" where the keyboard turns off before the display
Recommended values
| Value | Effect |
|---|---|
1.0 |
No correction (linear) - keyboard tracks display 1:1 |
1.5 |
Mild correction (default) - dims keyboard slightly at low levels |
2.0 |
Moderate correction - keyboard noticeably dimmer at low levels |
2.2 |
sRGB-like - aggressive dimming at low brightness |
Start with 1.5 (default) and adjust based on preference:
- If keyboard is too bright at low display brightness → increase gamma
- If keyboard turns off before display → decrease gamma
- Lower values = brighter keyboard at low display levels
References
- Gamma Correction - Wikipedia - Background on why displays use gamma curves
- LED Brightness and Human Perception - Why LEDs need gamma correction for perceived linearity
Note: Apple doesn't publish documentation about keyboard backlight brightness response curves. The 2.2 gamma recommendation is based on standard sRGB gamma and empirical testing.
The app uses Apple's private frameworks:
- CoreBrightness.framework:
KeyboardBrightnessClientto control keyboard backlight - DisplayServices.framework:
DisplayServicesGetBrightnessto read display brightness
Brightness change detection uses CGEventTap to intercept macOS brightness events (NX_SYSDEFINED events).
Technical Details: How macOS Brightness Events Work
Our Theory (Based on Testing):
When you change display brightness, macOS posts internal "brightness changed" notifications. We believe this happens AFTER the brightness changes, not as a trigger for the change. Evidence:
| Source | Observed Behavior |
|---|---|
| Physical keys | Generate discrete brightness steps (e.g., 0.5000 → 0.4375 → 0.3750) |
| Control Center slider | Generate continuous analog values (e.g., 0.5009 → 0.4910 → 0.3830) |
| Both | Post the same event type: NX_SYSDEFINED with keyCode=2 (up) or keyCode=3 (down) |
When you drag the Control Center slider from 0% to 100%, macOS fires multiple NX_SYSDEFINED events - one for each brightness level crossed. This is similar to holding down a physical brightness key.
Why this matters:
- "Live Sync" isn't just for keypresses - it catches ALL brightness events
- Control Center, Touch Bar, physical keys - all work instantly
- The only time the 10-second timer is needed is for apps that bypass the event system entirely
See docs/macos-media-keys-reference.md for technical details on macOS brightness events.
TwinKley is designed to be invisible to your battery. We benchmarked both sync modes over 5 minutes and extrapolated to an 8-hour workday:
| Metric | Live Sync Only | Timed Sync (10s) |
|---|---|---|
| CPU wake-ups (8 hrs) | 0 | 2,880 |
| CPU time (8 hrs) | ~1 second | ~3 seconds |
| Memory | ~12 MB | ~12 MB |
Live Sync is truly event-driven. The app sleeps until macOS sends a brightness change notification, then wakes briefly to sync and immediately sleeps again. During our 5-minute benchmark, Live Sync caused zero CPU wake-ups.
Timed Sync polls every 10 seconds as a fallback. Over an 8-hour workday, that's 2,880 timer fires—each one briefly waking the CPU. While each wake-up is tiny (~0.001s), they add up and prevent the CPU from entering deeper sleep states.
- Use Live Sync only (default) for best battery life
- Enable Timed Sync only if you use third-party tools that change brightness without triggering system events
# 5 minutes per mode (10 min total)
./scripts/benchmark-sync-modes.sh
# Custom duration (e.g., 60 seconds per mode)
./scripts/benchmark-sync-modes.sh 60Brightness keys not detected after rebuild/update
macOS caches code signing information in the TCC (Transparency, Consent, and Control) database. If you rebuild TwinKley or the code signature changes, the cached permission may become stale.
Fix: Toggle Accessibility permission off and on:
- Open System Settings → Privacy & Security → Accessibility
- Find TwinKley and turn it OFF
- Turn it back ON
- Restart TwinKley
This refreshes the code signing validation in the TCC database.
Debug brightness detection issues
- Enable debug mode (double-click the icon in About dialog)
- Open the Debug Window from the menu bar
- Click "Capture Events" and press brightness keys
- Check which key codes are generated
- Add any missing codes to
brightnessKeyCodesin~/.twinkley.json
Keyboard brightness locked by macOS
Sometimes macOS locks the keyboard backlight and prevents all adjustments - both manual (Control Center slider) and automatic (TwinKley). This is an intentional power-saving feature triggered by the ambient light sensor.
Symptoms:
- Control Center keyboard brightness slider is unresponsive
- TwinKley shows "Status: Locked" in the menu
- TwinKley sync commands are accepted but ignored
- Neither manual nor automatic control works
Technical details:
- Apple's private CoreBrightness framework calls this state "saturated" (via
isBacklightSaturatedOnKeyboard:) - When
saturated=truewithbrightness=0andautoBrightnessEnabled=true, the ambient light sensor has forced the keyboard to minimum - The ambient light sensor is located near the FaceTime camera
- This is separate from the "Adjust keyboard brightness in low light" setting (that setting enables/disables auto-brightness entirely)
This is macOS behavior, not a TwinKley bug. TwinKley respects macOS having primary control over keyboard brightness.
Options:
- Wait - macOS will release control when lighting conditions change
- Cover the sensor - Briefly covering the ambient light sensor (near the camera) will re-enable manual control
- Disable auto-brightness - Turn off "Adjust keyboard brightness in low light" in System Settings → Keyboard (gives you full manual control, but loses the power-saving benefit)
Note: TwinKley automatically resumes syncing when macOS releases control.
- All Macs (including M4 MacBook Pro) use
keyCode=2(brightness up) andkeyCode=3(brightness down) forNX_SYSDEFINEDevents - The app requires Accessibility permissions for the event tap to function
- Keyboard brightness control uses Apple's private
CoreBrightness.framework(KeyboardBrightnessClient) - The "locked" state is detected via
isBacklightSaturatedOnKeyboard:(notisBacklightSuppressedOnKeyboard:- these are different states) - Event data must be read via NSEvent conversion, not directly from CGEvent (see
docs/macos-media-keys-reference.md) - See
docs/macos-media-keys-reference.mdfor technical details on macOS brightness events
TwinKley collects zero user data. Everything runs locally on your Mac.
- ❌ No analytics or telemetry
- ❌ No crash reports
- ❌ No network connections (except update checks)
- ✅ Settings stored locally in
~/.twinkley.json - ✅ Optional debug logs in
~/.twinkley-debug.log(brightness values and sync operations only, no keystrokes)
Auto-updates use Sparkle framework and only transmit: app version, macOS version, and CPU architecture. No personally identifiable information.
Debug note: An optional event capture feature in the Debug Window can temporarily log all keypresses for diagnostics. This is disabled by default, displays a privacy warning when active, and all data stays local. See the Privacy Policy for details.
Read our full Privacy Policy for complete details.
This project is licensed under the GNU General Public License v3.0 - see the LICENSE file for details.
This software is provided as-is with no support.
- ✅ You may use, modify, and redistribute it freely under the GPL-3.0 license
- ❌ No support, bug fixes, or feature requests accepted
- ❌ Issues are disabled - Please do not contact the maintainer for help
- ❌ Pull requests accepted only from collaborators
- 💡 For modifications, please fork and rename to avoid confusion
TwinK[l]ey uses private macOS frameworks (CoreBrightness.framework, DisplayServices.framework) that may break in future OS updates. It's designed for M4 MacBook Pro but may work on other Apple Silicon Macs. Supporting all hardware/software combinations is beyond the scope of this project.
If it works for you: great! If not: please fork it and adapt it to your needs.
If you fork and redistribute modified versions:
- Please use a different project name and branding to avoid confusion
- You must comply with the GPL-3.0 license terms (share source, same license)
- No attribution to the original maintainer is implied for forks





