Skip to content

Phase 5: lock-screen shell + trigger#5

Open
tirthfx wants to merge 1 commit into
mainfrom
phase-5-lockscreen-shell
Open

Phase 5: lock-screen shell + trigger#5
tirthfx wants to merge 1 commit into
mainfrom
phase-5-lockscreen-shell

Conversation

@tirthfx

@tirthfx tirthfx commented Jul 10, 2026

Copy link
Copy Markdown
Owner

What changed

Kicks off Surface B, the fullscreen lock screen (PRD §3.2) after the dual-surface pivot. This is Phase 5 — the shell + trigger only.

  • PseudoLockScreenController — a borderless, fullscreen NSWindow on the built-in display at .screenSaver level, joining all Spaces. Draws a lock-screen backdrop: the current desktop wallpaper (NSWorkspace.desktopImageURL, dark-gradient fallback) with a large SF Pro rounded clock + date updating each second (reference image 1). Esc / the hotkey / (later) click-away dismiss it.
  • GlobalHotKey — a permission-free process-global ⌃⌘L via Carbon RegisterEventHotKey (an NSEvent global monitor would need Accessibility/Input Monitoring and can't consume the event). Toggles the lock screen from anywhere.
  • Menu bar gains a "Toggle Lock Screen (⌃⌘L)" item.

No now-playing widget yet — that's Phase 6.

Why

The reference screenshots showed the lock-screen flow (idle → click-expand → swipe-lyrics) was always the real hero; main was reset and the docs rewritten to make it a co-equal core surface alongside the notch. This is the first build step of that surface.

Base

Branches off the freshly-reset main (clean 3-commit history: scaffold → notch baseline → dual-surface docs). The old stacked PRs #3/#4 were closed; the notch code is preserved on main and in the backup/notch-phases-1-4 tag.

Notes for review — needs your eyes in Xcode

  • I could not verify the visuals headlessly — synthesized keystrokes and screenshots are both blocked without Accessibility/Screen Recording permission. Confirmed only that the app launches cleanly with the hotkey registered (no crash) and the notch still works. Please run it and press ⌃⌘L (or menu → Toggle Lock Screen): expect fullscreen wallpaper + big clock/date, Esc to dismiss.
  • .screenSaver window level makes it cover the menu bar for a true "takeover" feel. If you'd rather it sit below the menu bar, that's a one-line change.
  • The global hotkey uses Carbon (no permission prompt). If ⌃⌘L clashes with something on your machine, easy to rebind.

🤖 Generated with Claude Code

First piece of Surface B (PRD §3.2). Adds PseudoLockScreenController: a
borderless fullscreen window on the built-in display at screenSaver level that
draws a lock-screen backdrop — the desktop wallpaper (NSWorkspace
desktopImageURL, gradient fallback) with a large SF Pro clock + date updating
each second (reference image 1). Shown/hidden via a menu-bar toggle and a
permission-free global ⌃⌘L hotkey (Carbon RegisterEventHotKey, not an NSEvent
monitor); Esc or the hotkey dismisses. No now-playing yet — that's phase 6.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a pseudo lock-screen feature (Surface B) for NotchBeat, adding a fullscreen borderless window that displays the desktop wallpaper and a clock, toggled via a global hotkey (⌃⌘L) or a menu bar item. The review feedback highlights several critical improvements: resolving a retain cycle in GlobalHotKey by using weak references in the registry, loading the wallpaper image asynchronously to avoid blocking the main thread, and passing the specific NSScreen instance to properly support multi-monitor setups. Additionally, it is recommended to use SwiftUI's native .keyboardShortcut modifier for the menu item and to remove redundant key-down handling in LockScreenWindow.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines +25 to +36
// Carbon dispatches to a C function pointer with no captured context, so we
// route hotkey presses back to instances through a shared id → instance map.
private static var registry: [UInt32: GlobalHotKey] = [:]
private static var nextID: UInt32 = 1
private static var handlerInstalled = false
private static let signature: OSType = 0x4E_54_42_54 // 'NTBT'

init(_ combo: Combo, callback: @escaping () -> Void) {
self.callback = callback
self.id = GlobalHotKey.nextID
GlobalHotKey.nextID += 1
GlobalHotKey.registry[id] = self

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The registry dictionary holds a strong reference to GlobalHotKey instances. Since each instance registers itself in init, this creates a retain cycle where the registry keeps the instance alive indefinitely, preventing deinit from ever being called. This leaks the hotkey and its callback. Use a weak wrapper to store weak references in the registry.

    private struct WeakBox {
        weak var value: GlobalHotKey?
    }

    // Carbon dispatches to a C function pointer with no captured context, so we
    // route hotkey presses back to instances through a shared id → instance map.
    // We use weak references to avoid a retain cycle, as the registry would otherwise
    // keep the hotkey instances alive indefinitely.
    private static var registry: [UInt32: WeakBox] = [:]
    private static var nextID: UInt32 = 1
    private static var handlerInstalled = false
    private static let signature: OSType = 0x4E_54_42_54 // 'NTBT'

    init(_ combo: Combo, callback: @escaping () -> Void) {
        self.callback = callback
        self.id = GlobalHotKey.nextID
        GlobalHotKey.nextID += 1
        GlobalHotKey.registry[id] = WeakBox(value: self)

Comment on lines +50 to +53
deinit {
if let ref { UnregisterEventHotKey(ref) }
GlobalHotKey.registry[id] = nil
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Update deinit to remove the entry from the registry safely on the main thread.

    deinit {
        if let ref { UnregisterEventHotKey(ref) }
        let targetID = id
        DispatchQueue.main.async {
            GlobalHotKey.registry.removeValue(forKey: targetID)
        }
    }

Comment on lines +78 to +80
DispatchQueue.main.async {
GlobalHotKey.registry[targetID]?.callback()
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Update the event handler dispatch to access the weak reference's value.

Suggested change
DispatchQueue.main.async {
GlobalHotKey.registry[targetID]?.callback()
}
DispatchQueue.main.async {
GlobalHotKey.registry[targetID]?.value?.callback()
}

Comment on lines +46 to +50
private func loadWallpaper() {
guard let screen = NSScreen.main,
let url = NSWorkspace.shared.desktopImageURL(for: screen) else { return }
image = NSImage(contentsOf: url)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

NSImage(contentsOf:) performs synchronous disk I/O to load and decode the wallpaper image. Running this on the main thread (which is the default context for SwiftUI .task blocks) can cause noticeable UI stuttering or freezes when presenting the lock screen. Load the image asynchronously on a background queue.

    private func loadWallpaper() {
        guard let url = NSWorkspace.shared.desktopImageURL(for: screen) else { return }
        
        // Load the image asynchronously on a background queue to avoid blocking the main thread
        DispatchQueue.global(qos: .userInitiated).async {
            if let loadedImage = NSImage(contentsOf: url) {
                DispatchQueue.main.async {
                    self.image = loadedImage
                }
            }
        }
    }

Comment on lines +40 to +47
RegisterEventHotKey(
combo.keyCode,
combo.modifiers,
hotKeyID,
GetApplicationEventTarget(),
0,
&ref
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Check the return status of RegisterEventHotKey to detect and log registration failures (e.g., if another application has already registered the shortcut).

Suggested change
RegisterEventHotKey(
combo.keyCode,
combo.modifiers,
hotKeyID,
GetApplicationEventTarget(),
0,
&ref
)
let status = RegisterEventHotKey(
combo.keyCode,
combo.modifiers,
hotKeyID,
GetApplicationEventTarget(),
0,
&ref
)
if status != noErr {
print("Failed to register global hotkey: \(status)")
}

Comment on lines +7 to +17
struct LockScreenBackdropView: View {
var body: some View {
ZStack(alignment: .top) {
WallpaperView()
ClockView()
.padding(.top, 64)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.ignoresSafeArea()
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Update LockScreenBackdropView to accept and pass the target NSScreen to WallpaperView.

Suggested change
struct LockScreenBackdropView: View {
var body: some View {
ZStack(alignment: .top) {
WallpaperView()
ClockView()
.padding(.top, 64)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.ignoresSafeArea()
}
}
struct LockScreenBackdropView: View {
let screen: NSScreen
var body: some View {
ZStack(alignment: .top) {
WallpaperView(screen: screen)
ClockView()
.padding(.top, 64)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.ignoresSafeArea()
}
}

Comment on lines +21 to +24
private struct WallpaperView: View {
@State private var image: NSImage?

var body: some View {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Pass the specific NSScreen instance to WallpaperView instead of relying on NSScreen.main. This ensures the correct wallpaper is loaded on multi-monitor setups where the lock screen window might be shown on a different screen than the active key window.

private struct WallpaperView: View {
    let screen: NSScreen
    @State private var image: NSImage?

    var body: some View {

window.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .stationary, .ignoresCycle]
window.isReleasedWhenClosed = false
window.onDismiss = { [weak self] in self?.hide() }
window.contentView = NSHostingView(rootView: LockScreenBackdropView())

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Pass the target screen to LockScreenBackdropView when initializing the hosting view.

Suggested change
window.contentView = NSHostingView(rootView: LockScreenBackdropView())
window.contentView = NSHostingView(rootView: LockScreenBackdropView(screen: screen))

Comment on lines +79 to +85
override func keyDown(with event: NSEvent) {
if event.keyCode == 53 { // Esc, as a backup to cancelOperation
onDismiss?()
} else {
super.keyDown(with: event)
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The keyDown override is redundant because cancelOperation(_:) is already overridden and is the standard, system-provided way AppKit handles the Escape key. Bypassing or duplicating this with a hardcoded key code 53 is unnecessary and can be removed.

    // Rely on cancelOperation(_:) to handle the Escape key idiomatic to AppKit.

Comment on lines +29 to +31
Button("Toggle Lock Screen (⌃⌘L)") {
onToggleLockScreen()
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Instead of hardcoding the shortcut string (⌃⌘L) in the button's title, use SwiftUI's .keyboardShortcut modifier. This allows macOS to automatically render the shortcut beautifully on the right side of the menu item and ensures it works natively when the menu is open.

Suggested change
Button("Toggle Lock Screen (⌃⌘L)") {
onToggleLockScreen()
}
Button("Toggle Lock Screen") {
onToggleLockScreen()
}
.keyboardShortcut("l", modifiers: [.control, .command])

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant