Home
Showcase(11)
Blog(16)
Lab(15)
YouTube
Publications
Loading post...
Montek KundanMontek Kundan
  • Home
  • Showcase(11)
  • Blog(16)
  • Lab(15)
XInstagramGitHubLinkedInYouTube

Build using basehub.com

Inspired by basement.studio

Montek 2026

XInstagramGitHubLinkedInYouTube

Montek 2026

Inspired by basement.studio

ZenLock: Building a macOS Menu-Bar Focus Timer

Blog

May 12, 2025 montek.dev

0 views

Development

...


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:

  • File → Add Packages… → https://github.com/sindresorhus/KeyboardShortcuts

  • Created KeyboardShortcutsController to listen for .toggleTimer.

  • Registered a default in ZenLockApp.init():

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
TOC
0.01

More from the same blog category

Creating Player Locomotion in Unreal Engine

Creating Player Locomotion in Unreal Engine

Dec 12, 2023

Making your Own CDN

Making your Own CDN

Apr 10, 2025

Building a Full-Stack Web App with Yew and Actix

Building a Full-Stack Web App with Yew and Actix

May 5, 2025

Comments

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

Leave comment