ZenLock: Building a macOS Menu-Bar Focus Timer

·5 min read
zenlock cover image

When I first set out to build ZenLock, I knew I wanted something small and sleek: a menu-bar app that would help me lock in focus sessions, automatically toggle Do Not Disturb, and unobtrusively live in my Mac’s status bar. Over the course of this project, I tackled everything from SwiftUI UI design to shell scripting, debugged linker errors, and even wrestled with macOS privacy settings. In this post, I’ll share my step-by-step process, the hurdles I encountered, and the solutions I applied to arrive at a polished, nimble productivity companion.

Project Setup & Initial Goals

At the very beginning, I had two core goals:

  1. Timer functionality with customizable durations (presets and custom input).

  2. Integration with macOS Focus/Do Not Disturb, so enabling a session would silence notifications automatically. ( could not apply it, faced lots of problems )

I created a new macOS SwiftUI App project in Xcode named ZenLock, added an AppDelegate to handle notifications and permissions, and set up a MenuBarExtra scene in ZenLockApp.swift. This gave me a base status-bar icon and blank pop-up.

Initial folder structure:

code
ZenLock
├ AppDelegate.swift
├ ZenLockApp.swift
├ ContentView.swift
├ Info.plist
└ Assets.xcassets/

Building the Timer UI in SwiftUI

Adding Preset Buttons

I wanted three quick presets—5, 10, and 15 minutes—and a clean countdown display. SwiftUI made the layout straightforward:

swift
HStack(spacing: 12) {
    presetButton(minutes: 5)
    presetButton(minutes: 10)
    presetButton(minutes: 15)
}

With a helper:

swift
private func presetButton(minutes: Int) -> some View {
    Button { selectPreset(minutes * 60) } label: {
        Label("\(minutes)", systemImage: "timer")
    }
}

Custom Time Input (MM:SS)

Next, I needed custom entry in MM:SS format (or single seconds). I added a TextField bound to @State private var customTime: String and a method to parse:

swift
TextField("MM:SS", text: $customTime)
    .frame(width: 60)
    .onSubmit { setCustomTime() }

And in setCustomTime(), I split by ":", converted to minutes and seconds, and reset the timer.

Initially I went with just adding a number and it would update the minutes value to any which user gives, but then to test the applications and for my use case, I wanted seconds to be added too, hence went with this approach.

Countdown Logic with Combine

Using Timer.publish(every: 1, on: .main, in: .common).autoconnect(), I decremented @State private var remaining: Int and displayed it in a monospaced font:

swift
.onReceive(timer) { _ in
    guard timerRunning else { return }
    if remaining > 0 { remaining -= 1 } else { finishSession() }
}

This formed the core timer UI.


Sound Effects & Built-In macOS Sounds

Rather than embedding MP3s, I tapped into macOS’s built-in library, so thats its easier for me to just use sounds rather than managing a assets folder:

swift
NSSound(named: .init("Ping"))?.play()  // session start
NSSound(named: .init("Funk"))?.play()  // session end

This gave a satisfying “ting” and alarm without extra assets.


Do Not Disturb Integration

AppleScript Attempts

My first approach was AppleScript:

swift
let script = "tell application \"System Events\" to set doNotDisturb to true"
NSAppleScript(source: script)!.executeAndReturnError(&error)

But I ran into frequent errors such as:

code
Application isn’t running.

AppleScript was brittle and blocking the UI. I did not understand why I could not link my custom focus to the app.
I did try up looking the docs, but did not go deeper.

Shell Defaults & NotificationCenter Reload

Switching to writing com.apple.notificationcenterui defaults:

swift
Process.launched(...) // defaults write doNotDisturb true
Process.launched(...) // killall NotificationCenter

I was not able to test this in build mode, as it did not trigger DND for my, maybe some mistakes I was doing, or I guess it will work when its deployed ( less likely to be the case ). Anyway lets keep this feature pinned away for the moment till we have a solid solution.


Global Keyboard Shortcut

I wanted ⌘⇧T to toggle ZenLock. I imported the KeyboardShortcuts SPM:

swift
if .toggleTimer.shortcut == nil {
    .toggleTimer.shortcut = .init(.t, modifiers: [.command, .shift])
}
  • But I could not run the timer with the shortcut, to allow shortcuts from the app I wanted to have the accessibility permissions. To avoid instructing users manually, I prompted for Accessibility at launch:

swift
_ = AXIsProcessTrustedWithOptions([
    kAXTrustedCheckOptionPrompt: true
] as CFDictionary)

This triggered the system dialog, so users could grant control permissions.


App Intents & Native Focus Filters

My early AppleScript enumeration of focus filters failed on newer macOS versions. Then I tried App Intents framework:

  • Added an App Intents Extension with ZenLockFocusFilter.swift conforming to SetFocusFilterIntent.

  • Marked its @Parameter var focusName: String? optional (required by the protocol).

  • In perform(), posted:

swift
NotificationCenter.default.post(
    name: .zenLockFocusChanged,
    object: focusName
)
  • In ContentView, I call:

swift
let intent = try await ZenLockFocusFilter.current
currentFocus = intent.focusName ?? "None"

and listen for the notification to update live.

This still not give me first-class, future-proof integration with the system’s Focus feature—still need to further investigate this.


Settings Window & Keyboard Shortcut Editor

Rather than embedding settings in the menu pop-up, I created a standalone SettingsView.swift with:

  • A TabView: an About tab (“Made with ❤️ by Montek”) and a Shortcuts tab with KeyboardShortcuts.Recorder(for: .toggleTimer).

  • In ZenLockApp.swift, I defined:

swift
MenuBarExtra { ContentView() }
Window("Settings", id: "Settings") { SettingsView() }

Clicking the gear icon (.overlay in ContentView) now calls openWindow(id: "Settings") to bring up that window.


Hiding the Dock Icon

I wanted ZenLock completely headless—no Dock icon or Cmd-Tab entry. In the image below you see this dock icon, i didn’t want that

After trying setActivationPolicy(.accessory) in code, I settled on adding this key to Info.plist:

xml
<key>LSUIElement</key>
<true/>

In Xcode’s Info tab under Custom macOS Application Target Properties, I clicked “+”, entered Application is agent (UIElement), set the type to Boolean, and checked YES. This made ZenLock a pure menu-bar agent.


Final Project Structure

code
ZenLock
├── ZenLock.xcodeproj
├── ZenLock
│   ├── AppDelegate.swift       // notifications & Accessibility prompt
│   ├── ZenLockApp.swift        // @main, scenes, default shortcut
│   ├── ContentView.swift       // timer UI + gear overlay
│   ├── SettingsView.swift      // About & Shortcuts tabs
│   ├── DoNotDisturbManager.swift
│   ├── TimerManager.swift      // DispatchSourceTimer + notifications
│   ├── Info.plist              // LSUIElement = YES
│   └── Assets.xcassets
└── ZenLockFocusIntent          // App Intents extension
    └──Info.plist

Challenges & Lessons Learned

  • AppleScript vs. shell: AppleScript was unreliable; defaults + killall was also not working.

  • Blocking calls: moved script and preferences writes off the main thread to avoid UI jank.

  • Focus enumeration: App Intents is the official, supported API—ditch custom AppleScript/CFPreferences hacks.

  • Accessibility: proactively prompt so global shortcuts just work.

  • LSUIElement: essential for a menu-bar-only experience.

Every barrier—from linker errors to missing modules—taught me more about macOS internals. I hope this walkthrough helps you build your own menu-bar tools, whether for focus, timers, or other utilities.

Github repo: https://github.com/Montekkundan/ZenLock

For a similar tutorial you can watch this youtube video.