Workspace Profiles: a Mac App to Switch Displays and Audio in One Click
3 September 2025
Or, how I turned an annoying daily routine into a Mac app.
Changing settings by hand, several times a day
Like many developers, I switch contexts a lot during the day. I plug my MacBook into a docking station at the office, connect it to an external monitor at home, put on a headset for a call… and each time I do the same thing: open System Settings, find the right Display panel, rearrange my screens, then go to the Sound panel and select the correct microphone and speakers. It only takes a few seconds, but I do it several times a day, and it became really annoying.
So I wanted a simple, lightweight app that lives in the menu bar and switches between my configurations with a single click. I built it, and Workspace Profiles is now available on the Mac App Store!
The Workspace Profiles settings window where you can configure your different profiles.
macOS remembers some configurations, but it’s far from perfect. If you use multiple external monitors or various audio devices at different times, the system can get confused.
Here is how I use my Mac:
- At the office: my MacBook is connected to two Dell monitors. I want the left monitor to be my primary display, and the audio must come out of the monitor’s built-in speakers.
- At home: I plug it into my Studio Display. This screen should become the primary one, and the sound needs to go through my external USB speakers.
- On the go or podcasting: just the MacBook, but I want my external Rode NT-USE mic to be the default audio input as soon as it’s connected.
Each of these changes meant reconfiguring everything by hand. I wanted an app that would see where I was and adapt automatically.
How profiles work
The app is built around profiles. A profile is a snapshot of the configuration you want. For each profile, you can define:
- The display arrangement: an extended desktop or mirrored displays
- The primary display: which of your monitors gets the menu bar and the Dock
- The audio devices: which microphone (input) and speakers/headphones (output) to use by default
Once you’ve created your profiles (e.g., “Office”, “Home”, “Presentation”), you can switch between them in three ways:
- Manually, from the menu bar icon
- With a global keyboard shortcut that you can set for each profile
- Automatically, with “Triggers”
Triggers are my favorite part. You link a profile to the connection of a specific device. For example: “When my Dell monitor is connected, automatically activate the ‘Office’ profile.”
How it works inside
To build this app I had to use the low-level APIs of macOS, far from the usual comfort of SwiftUI. Here are the problems that took me the most time.
Detecting hardware changes
First, the app must know when a display or audio device is connected or disconnected. For this I had to use the C-based frameworks of macOS.
For displays, I used the CGDisplayRegisterReconfigurationCallback function from the Core Graphics framework. It registers a callback function that the system calls whenever the display configuration changes.
For audio, it’s a similar approach with AudioObjectAddPropertyListener from the Core Audio framework. It listens for changes to the kAudioHardwarePropertyDevices property on the global system object.
In Swift, the code to bridge these C calls looks like this:
// In HardwareManager.swift
func startMonitoringDisplayChanges() {
CGDisplayRegisterReconfigurationCallback(displayReconfigurationCallBack, nil)
}
func startMonitoringAudioChanges() {
var propertyAddress = AudioObjectPropertyAddress(
mSelector: kAudioHardwarePropertyDevices,
mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain
)
AudioObjectAddPropertyListener(
AudioObjectID(kAudioObjectSystemObject), &propertyAddress, audioDevicesPropertyListener, nil)
}
These callbacks then post a notification via Swift’s NotificationCenter, so the rest of the app can react cleanly.
Handling notification “bursts”
I quickly ran into a problem, especially with audio devices: the system sometimes sends a burst of notifications for a single event (e.g., a device initializing). If I applied the profile on every notification, the app would do the same work several times.
The fix is a classic technique called “debouncing”. I don’t handle the event immediately. I schedule it with a DispatchWorkItem to run after a one-second delay. If a new notification comes in during that second, I cancel the scheduled task and plan a new one. This way, the app only processes the last event of a rapid series.
// In ProfileManager.swift
private var audioChangeWorkItem: DispatchWorkItem?
@objc private func scheduleAudioChangeHandler() {
self.audioChangeWorkItem?.cancel() // Cancel the previous one
let newWorkItem = DispatchWorkItem { [weak self] in
self?.handleAudioChange() // Run the actual logic
}
// Schedule the new one
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0, execute: newWorkItem)
self.audioChangeWorkItem = newWorkItem
}
Global keyboard shortcuts
To trigger profiles from anywhere in macOS, I needed system-wide (or “global”) hotkeys. Modern Swift and AppKit APIs are sandboxed and don’t make this easy. So I went back to an older C-based framework that still does the job: Carbon.
With the RegisterEventHotKey function, the app tells the system to listen for a specific key combination, no matter which application is active. The system then sends an event to my app when the hotkey is pressed. It feels a bit like archeology, but it’s the most reliable way to get this done on macOS.
Try it
I learned a lot building Workspace Profiles, from the first idea (born out of my own frustration) to an app on the Mac App Store. I built it natively with SwiftUI so it feels at home on a Mac.
You can check it out on the Mac App Store. If you find a bug or want a new feature, please open an issue on the project’s GitHub repository.
