ZenLock: Building a macOS Menu-Bar Focus Timer

Development

...

May 12, 2025 montek.dev

0 views

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:

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:

1HStack(spacing: 12) {
2    presetButton(minutes: 5)
3    presetButton(minutes: 10)
4    presetButton(minutes: 15)
5}
swift

With a helper:

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

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:

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

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:

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

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:

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

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


Do Not Disturb Integration

AppleScript Attempts

My first approach was AppleScript:

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

But I ran into frequent errors such as:

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.

Blog image

Shell Defaults & NotificationCenter Reload

Switching to writing com.apple.notificationcenterui defaults:

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

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:

1if .toggleTimer.shortcut == nil {
2    .toggleTimer.shortcut = .init(.t, modifiers: [.command, .shift])
3}
4
swift
  • 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:

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

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:

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

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

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:

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

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

Blog image

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

1<key>LSUIElement</key>
2<true/>
xml

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.

Blog image

Final Project Structure

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.


May 12, 2025 montek.dev

0 views

Comments

Join the discussion! Share your thoughts and engage with other readers.

Leave comment