-
Notifications
You must be signed in to change notification settings - Fork 0
Phase 5: lock-screen shell + trigger #5
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,90 @@ | ||||||||||||||||||||||||||||||||||||||||
| import AppKit | ||||||||||||||||||||||||||||||||||||||||
| import Carbon.HIToolbox | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| /// A process-global hotkey via Carbon's `RegisterEventHotKey` — the standard, | ||||||||||||||||||||||||||||||||||||||||
| /// permission-free way for a menu-bar (LSUIElement) agent to get a system-wide | ||||||||||||||||||||||||||||||||||||||||
| /// shortcut. NSEvent global monitors would need Accessibility / Input | ||||||||||||||||||||||||||||||||||||||||
| /// Monitoring permission and can't consume the event; RegisterEventHotKey | ||||||||||||||||||||||||||||||||||||||||
| /// needs neither and fires regardless of which app is focused. | ||||||||||||||||||||||||||||||||||||||||
| final class GlobalHotKey { | ||||||||||||||||||||||||||||||||||||||||
| struct Combo { | ||||||||||||||||||||||||||||||||||||||||
| let keyCode: UInt32 | ||||||||||||||||||||||||||||||||||||||||
| let modifiers: UInt32 | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| /// ⌃⌘L — toggle the lock-screen surface (PRD §3.2 trigger). | ||||||||||||||||||||||||||||||||||||||||
| static let toggleLockScreen = Combo( | ||||||||||||||||||||||||||||||||||||||||
| keyCode: UInt32(kVK_ANSI_L), | ||||||||||||||||||||||||||||||||||||||||
| modifiers: UInt32(controlKey | cmdKey) | ||||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| private var ref: EventHotKeyRef? | ||||||||||||||||||||||||||||||||||||||||
| private let id: UInt32 | ||||||||||||||||||||||||||||||||||||||||
| private let callback: () -> Void | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| // 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 | ||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+25
to
+36
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The 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) |
||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| GlobalHotKey.installDispatchHandlerIfNeeded() | ||||||||||||||||||||||||||||||||||||||||
| let hotKeyID = EventHotKeyID(signature: GlobalHotKey.signature, id: id) | ||||||||||||||||||||||||||||||||||||||||
| RegisterEventHotKey( | ||||||||||||||||||||||||||||||||||||||||
| combo.keyCode, | ||||||||||||||||||||||||||||||||||||||||
| combo.modifiers, | ||||||||||||||||||||||||||||||||||||||||
| hotKeyID, | ||||||||||||||||||||||||||||||||||||||||
| GetApplicationEventTarget(), | ||||||||||||||||||||||||||||||||||||||||
| 0, | ||||||||||||||||||||||||||||||||||||||||
| &ref | ||||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+40
to
+47
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Check the return status of
Suggested change
|
||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| deinit { | ||||||||||||||||||||||||||||||||||||||||
| if let ref { UnregisterEventHotKey(ref) } | ||||||||||||||||||||||||||||||||||||||||
| GlobalHotKey.registry[id] = nil | ||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+50
to
+53
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| private static func installDispatchHandlerIfNeeded() { | ||||||||||||||||||||||||||||||||||||||||
| guard !handlerInstalled else { return } | ||||||||||||||||||||||||||||||||||||||||
| handlerInstalled = true | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| var spec = EventTypeSpec( | ||||||||||||||||||||||||||||||||||||||||
| eventClass: OSType(kEventClassKeyboard), | ||||||||||||||||||||||||||||||||||||||||
| eventKind: UInt32(kEventHotKeyPressed) | ||||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||||
| InstallEventHandler( | ||||||||||||||||||||||||||||||||||||||||
| GetApplicationEventTarget(), | ||||||||||||||||||||||||||||||||||||||||
| { _, event, _ -> OSStatus in | ||||||||||||||||||||||||||||||||||||||||
| var hkID = EventHotKeyID() | ||||||||||||||||||||||||||||||||||||||||
| let status = GetEventParameter( | ||||||||||||||||||||||||||||||||||||||||
| event, | ||||||||||||||||||||||||||||||||||||||||
| EventParamName(kEventParamDirectObject), | ||||||||||||||||||||||||||||||||||||||||
| EventParamType(typeEventHotKeyID), | ||||||||||||||||||||||||||||||||||||||||
| nil, | ||||||||||||||||||||||||||||||||||||||||
| MemoryLayout<EventHotKeyID>.size, | ||||||||||||||||||||||||||||||||||||||||
| nil, | ||||||||||||||||||||||||||||||||||||||||
| &hkID | ||||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||||
| if status == noErr { | ||||||||||||||||||||||||||||||||||||||||
| let targetID = hkID.id | ||||||||||||||||||||||||||||||||||||||||
| DispatchQueue.main.async { | ||||||||||||||||||||||||||||||||||||||||
| GlobalHotKey.registry[targetID]?.callback() | ||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+78
to
+80
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||
| return noErr | ||||||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||||||
| 1, | ||||||||||||||||||||||||||||||||||||||||
| &spec, | ||||||||||||||||||||||||||||||||||||||||
| nil, | ||||||||||||||||||||||||||||||||||||||||
| nil | ||||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,69 @@ | ||||||||||||||||||||||||||||||||||||||||||||||||||
| import AppKit | ||||||||||||||||||||||||||||||||||||||||||||||||||
| import SwiftUI | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||
| /// The lock-screen backdrop (PRD §3.2 L1, reference image 1): the desktop | ||||||||||||||||||||||||||||||||||||||||||||||||||
| /// wallpaper filling the screen with a large clock + date near the top. The | ||||||||||||||||||||||||||||||||||||||||||||||||||
| /// now-playing widget is layered on in phase 6. | ||||||||||||||||||||||||||||||||||||||||||||||||||
| struct LockScreenBackdropView: View { | ||||||||||||||||||||||||||||||||||||||||||||||||||
| var body: some View { | ||||||||||||||||||||||||||||||||||||||||||||||||||
| ZStack(alignment: .top) { | ||||||||||||||||||||||||||||||||||||||||||||||||||
| WallpaperView() | ||||||||||||||||||||||||||||||||||||||||||||||||||
| ClockView() | ||||||||||||||||||||||||||||||||||||||||||||||||||
| .padding(.top, 64) | ||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||
| .frame(maxWidth: .infinity, maxHeight: .infinity) | ||||||||||||||||||||||||||||||||||||||||||||||||||
| .ignoresSafeArea() | ||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+7
to
+17
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Update
Suggested change
|
||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||
| /// Fills the window with the current desktop wallpaper (aspect-fill), falling | ||||||||||||||||||||||||||||||||||||||||||||||||||
| /// back to a dark gradient if it can't be read. | ||||||||||||||||||||||||||||||||||||||||||||||||||
| private struct WallpaperView: View { | ||||||||||||||||||||||||||||||||||||||||||||||||||
| @State private var image: NSImage? | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||
| var body: some View { | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+21
to
+24
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Pass the specific private struct WallpaperView: View {
let screen: NSScreen
@State private var image: NSImage?
var body: some View {
|
||||||||||||||||||||||||||||||||||||||||||||||||||
| GeometryReader { geo in | ||||||||||||||||||||||||||||||||||||||||||||||||||
| Group { | ||||||||||||||||||||||||||||||||||||||||||||||||||
| if let image { | ||||||||||||||||||||||||||||||||||||||||||||||||||
| Image(nsImage: image) | ||||||||||||||||||||||||||||||||||||||||||||||||||
| .resizable() | ||||||||||||||||||||||||||||||||||||||||||||||||||
| .aspectRatio(contentMode: .fill) | ||||||||||||||||||||||||||||||||||||||||||||||||||
| } else { | ||||||||||||||||||||||||||||||||||||||||||||||||||
| LinearGradient( | ||||||||||||||||||||||||||||||||||||||||||||||||||
| colors: [Color(white: 0.18), .black], | ||||||||||||||||||||||||||||||||||||||||||||||||||
| startPoint: .top, | ||||||||||||||||||||||||||||||||||||||||||||||||||
| endPoint: .bottom | ||||||||||||||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||
| .frame(width: geo.size.width, height: geo.size.height) | ||||||||||||||||||||||||||||||||||||||||||||||||||
| .clipped() | ||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||
| .ignoresSafeArea() | ||||||||||||||||||||||||||||||||||||||||||||||||||
| .task { loadWallpaper() } | ||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||
| private func loadWallpaper() { | ||||||||||||||||||||||||||||||||||||||||||||||||||
| guard let screen = NSScreen.main, | ||||||||||||||||||||||||||||||||||||||||||||||||||
| let url = NSWorkspace.shared.desktopImageURL(for: screen) else { return } | ||||||||||||||||||||||||||||||||||||||||||||||||||
| image = NSImage(contentsOf: url) | ||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+46
to
+50
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
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
}
}
}
} |
||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||
| /// Big time + date, updating each second, styled like the iOS/macOS lock screen. | ||||||||||||||||||||||||||||||||||||||||||||||||||
| private struct ClockView: View { | ||||||||||||||||||||||||||||||||||||||||||||||||||
| var body: some View { | ||||||||||||||||||||||||||||||||||||||||||||||||||
| TimelineView(.periodic(from: .now, by: 1)) { context in | ||||||||||||||||||||||||||||||||||||||||||||||||||
| VStack(spacing: 0) { | ||||||||||||||||||||||||||||||||||||||||||||||||||
| Text(context.date, format: .dateTime.weekday(.abbreviated).month(.abbreviated).day()) | ||||||||||||||||||||||||||||||||||||||||||||||||||
| .font(.system(size: 22, weight: .semibold, design: .rounded)) | ||||||||||||||||||||||||||||||||||||||||||||||||||
| .foregroundStyle(.white.opacity(0.92)) | ||||||||||||||||||||||||||||||||||||||||||||||||||
| Text(context.date, format: .dateTime.hour().minute()) | ||||||||||||||||||||||||||||||||||||||||||||||||||
| .font(.system(size: 96, weight: .bold, design: .rounded)) | ||||||||||||||||||||||||||||||||||||||||||||||||||
| .foregroundStyle(.white) | ||||||||||||||||||||||||||||||||||||||||||||||||||
| .monospacedDigit() | ||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||
| .shadow(color: .black.opacity(0.35), radius: 18, y: 4) | ||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,86 @@ | ||
| import AppKit | ||
| import SwiftUI | ||
|
|
||
| /// Surface B: a borderless, fullscreen window on the built-in display that | ||
| /// recreates a lock-screen backdrop (wallpaper + clock). It hosts the same | ||
| /// compact → expand → lyrics flow as the notch (added in phases 6–8); phase 5 | ||
| /// is just the shell + trigger. | ||
| /// | ||
| /// This is NOT the real macOS lock screen — that can't host third-party UI | ||
| /// (PRD §4). It's an ordinary top-level window shown while logged in; it does | ||
| /// not overlay the password screen or gate unlock. | ||
| @MainActor | ||
| final class PseudoLockScreenController { | ||
| private var window: LockScreenWindow? | ||
| private var hotKey: GlobalHotKey? | ||
| private(set) var isVisible = false | ||
|
|
||
| /// Registers the global ⌃⌘L toggle. Call once at launch. | ||
| func installHotKey() { | ||
| hotKey = GlobalHotKey(.toggleLockScreen) { [weak self] in | ||
| self?.toggle() | ||
| } | ||
| } | ||
|
|
||
| func toggle() { | ||
| isVisible ? hide() : show() | ||
| } | ||
|
|
||
| func show() { | ||
| guard !isVisible else { return } | ||
| // The lock screen belongs on the built-in display; fall back to the | ||
| // main screen if there's no notched screen (e.g. clamshell). | ||
| guard let screen = NotchMetrics.builtInNotchedScreen() ?? NSScreen.main else { return } | ||
|
|
||
| let window = LockScreenWindow( | ||
| contentRect: screen.frame, | ||
| styleMask: [.borderless], | ||
| backing: .buffered, | ||
| defer: false | ||
| ) | ||
| window.setFrame(screen.frame, display: true) | ||
| window.isOpaque = true | ||
| window.backgroundColor = .black | ||
| // Above the menu bar and normal windows so it reads as a full takeover, | ||
| // like a lock screen. Esc / the hotkey / click-away dismiss it. | ||
| window.level = .screenSaver | ||
| window.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .stationary, .ignoresCycle] | ||
| window.isReleasedWhenClosed = false | ||
| window.onDismiss = { [weak self] in self?.hide() } | ||
| window.contentView = NSHostingView(rootView: LockScreenBackdropView()) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
|
|
||
| window.makeKeyAndOrderFront(nil) | ||
| NSApp.activate(ignoringOtherApps: true) | ||
|
|
||
| self.window = window | ||
| isVisible = true | ||
| } | ||
|
|
||
| func hide() { | ||
| window?.orderOut(nil) | ||
| window = nil | ||
| isVisible = false | ||
| } | ||
| } | ||
|
|
||
| /// Borderless window that can take key focus (so Esc works) and reports | ||
| /// dismissal intents (Esc key) back to the controller. | ||
| final class LockScreenWindow: NSWindow { | ||
| var onDismiss: (() -> Void)? | ||
|
|
||
| override var canBecomeKey: Bool { true } | ||
| override var canBecomeMain: Bool { true } | ||
|
|
||
| /// Esc — AppKit routes it here as the standard "cancel" action. | ||
| override func cancelOperation(_ sender: Any?) { | ||
| onDismiss?() | ||
| } | ||
|
|
||
| override func keyDown(with event: NSEvent) { | ||
| if event.keyCode == 53 { // Esc, as a backup to cancelOperation | ||
| onDismiss?() | ||
| } else { | ||
| super.keyDown(with: event) | ||
| } | ||
| } | ||
|
Comment on lines
+79
to
+85
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The // Rely on cancelOperation(_:) to handle the Escape key idiomatic to AppKit. |
||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Instead of hardcoding the shortcut string
(⌃⌘L)in the button's title, use SwiftUI's.keyboardShortcutmodifier. 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.