Atoll/DynamicIsland/managers/LockScreenLiveActivityWindowManager.swift
2026-01-09 12:32:00 +05:30

268 lines
9.1 KiB
Swift

//
// LockScreenLiveActivityWindowManager.swift
// DynamicIsland
//
// Delegates the lock screen live activity layout to SkyLight so it remains visible when the display is locked.
//
import AppKit
import Defaults
import SkyLightWindow
import SwiftUI
import QuartzCore
@MainActor
class LockScreenLiveActivityWindowManager {
static let shared = LockScreenLiveActivityWindowManager()
private var window: NSWindow?
private var hasDelegated = false
private var hideTask: Task<Void, Never>?
private var hostingView: NSHostingView<LockScreenLiveActivityOverlay>?
private let overlayModel = LockScreenLiveActivityOverlayModel()
private let overlayAnimator = LockIconAnimator(initiallyLocked: LockScreenManager.shared.isLocked)
private weak var viewModel: DynamicIslandViewModel?
private var screenChangeObserver: NSObjectProtocol?
private var workspaceObservers: [NSObjectProtocol] = []
private var currentNotchSize: CGSize?
private init() {
registerScreenChangeObservers()
}
private func timestamp() -> String {
let formatter = DateFormatter()
formatter.dateFormat = "HH:mm:ss.SSS"
return formatter.string(from: Date())
}
private func windowSize(for notchSize: CGSize) -> CGSize {
let indicatorWidth = max(0, notchSize.height - 12)
let horizontalPadding = cornerRadiusInsets.closed.bottom
let totalWidth = notchSize.width + (indicatorWidth * 2) + (horizontalPadding * 2)
return CGSize(width: totalWidth, height: notchSize.height)
}
private func frame(for windowSize: CGSize, on screen: NSScreen) -> NSRect {
let screenFrame = screen.frame
let originX = screenFrame.origin.x + (screenFrame.width / 2) - (windowSize.width / 2)
let originY = screenFrame.origin.y + screenFrame.height - windowSize.height
return NSRect(x: originX, y: originY, width: windowSize.width, height: windowSize.height)
}
private func ensureWindow(windowSize: CGSize, screen: NSScreen) -> NSWindow {
if let window {
return window
}
let window = NSWindow(
contentRect: frame(for: windowSize, on: screen),
styleMask: [.borderless, .nonactivatingPanel],
backing: .buffered,
defer: false
)
window.isOpaque = false
window.backgroundColor = .clear
window.isReleasedWhenClosed = false
window.ignoresMouseEvents = false
window.hasShadow = false
window.level = NSWindow.Level(rawValue: Int(CGShieldingWindowLevel()))
window.collectionBehavior = [.canJoinAllSpaces, .stationary, .fullScreenAuxiliary]
window.alphaValue = 0
window.animationBehavior = .none
ScreenCaptureVisibilityManager.shared.register(window, scope: .entireInterface)
self.window = window
self.hasDelegated = false
return window
}
private func lockContext() -> (notchSize: CGSize, screen: NSScreen)? {
guard let screen = LockScreenDisplayContextProvider.shared.contextSnapshot()?.screen ?? NSScreen.main else {
print("[\(timestamp())] LockScreenLiveActivityWindowManager: no main screen available")
return nil
}
guard let viewModel else {
print("[\(timestamp())] LockScreenLiveActivityWindowManager: no view model configured")
return nil
}
var notchSize = viewModel.closedNotchSize
if notchSize.width <= 0 || notchSize.height <= 0 {
notchSize = getClosedNotchSize(screen: screen.localizedName)
}
return (notchSize, screen)
}
private func registerScreenChangeObservers() {
screenChangeObserver = NotificationCenter.default.addObserver(
forName: NSApplication.didChangeScreenParametersNotification,
object: nil,
queue: .main
) { [weak self] _ in
self?.handleScreenGeometryChange(reason: "screen-parameters")
}
let workspaceCenter = NSWorkspace.shared.notificationCenter
let wakeObserver = workspaceCenter.addObserver(
forName: NSWorkspace.screensDidWakeNotification,
object: nil,
queue: .main
) { [weak self] _ in
self?.handleScreenGeometryChange(reason: "screens-did-wake")
}
workspaceObservers = [wakeObserver]
}
private func handleScreenGeometryChange(reason: String) {
guard let window else { return }
guard window.isVisible || window.alphaValue > 0.01 else { return }
guard let context = lockContext() else { return }
let windowSize = windowSize(for: context.notchSize)
let targetFrame = frame(for: windowSize, on: context.screen)
if window.frame != targetFrame {
window.setFrame(targetFrame, display: true)
}
if let hostingView {
hostingView.frame = CGRect(origin: .zero, size: targetFrame.size)
hostingView.rootView = LockScreenLiveActivityOverlay(model: overlayModel, animator: overlayAnimator, notchSize: context.notchSize)
}
currentNotchSize = context.notchSize
print("[\(timestamp())] LockScreenLiveActivityWindowManager: realigned window due to \(reason)")
}
private func present(notchSize: CGSize, on screen: NSScreen) {
guard Defaults[.enableLockScreenLiveActivity] else {
hideImmediately()
return
}
let windowSize = windowSize(for: notchSize)
let window = ensureWindow(windowSize: windowSize, screen: screen)
let targetFrame = frame(for: windowSize, on: screen)
window.setFrame(targetFrame, display: true)
let overlayView = LockScreenLiveActivityOverlay(model: overlayModel, animator: overlayAnimator, notchSize: notchSize)
if let hostingView {
hostingView.rootView = overlayView
hostingView.frame = CGRect(origin: .zero, size: targetFrame.size)
} else {
let view = NSHostingView(rootView: overlayView)
view.frame = CGRect(origin: .zero, size: targetFrame.size)
hostingView = view
window.contentView = view
}
if window.contentView !== hostingView {
window.contentView = hostingView
}
window.displayIfNeeded()
if !hasDelegated {
SkyLightOperator.shared.delegateWindow(window)
hasDelegated = true
}
window.orderFrontRegardless()
window.alphaValue = 1
currentNotchSize = notchSize
}
func showLocked() {
hideTask?.cancel()
guard let context = lockContext() else { return }
let collapsedScale = LockScreenLiveActivityOverlay.collapsedScale(for: context.notchSize)
overlayAnimator.update(isLocked: true)
overlayModel.scale = collapsedScale
overlayModel.opacity = 0
present(notchSize: context.notchSize, on: context.screen)
DispatchQueue.main.async {
withAnimation(.spring(response: 0.45, dampingFraction: 0.82)) {
self.overlayModel.scale = 1
}
withAnimation(.easeOut(duration: 0.18)) {
self.overlayModel.opacity = 1
}
}
print("[\(timestamp())] LockScreenLiveActivityWindowManager: showing locked state")
}
func showUnlockAndScheduleHide() {
hideTask?.cancel()
guard let context = lockContext() else { return }
overlayModel.scale = 1
overlayModel.opacity = 1
present(notchSize: context.notchSize, on: context.screen)
overlayAnimator.update(isLocked: false)
hideTask = Task { [weak self] in
try? await Task.sleep(for: .seconds(LockScreenAnimationTimings.unlockCollapse))
guard let self, !Task.isCancelled else { return }
await MainActor.run {
self.hideWithAnimation()
}
}
}
func hideImmediately() {
hideTask?.cancel()
hideTask = nil
hideWithAnimation()
}
private func hideWithAnimation() {
guard let window else { return }
let targetScale: CGFloat
if let notchSize = currentNotchSize {
targetScale = LockScreenLiveActivityOverlay.collapsedScale(for: notchSize)
} else {
targetScale = 0.7
}
withAnimation(.smooth(duration: LockScreenAnimationTimings.unlockCollapse)) {
overlayModel.opacity = 0
overlayModel.scale = targetScale
}
NSAnimationContext.runAnimationGroup { context in
context.duration = LockScreenAnimationTimings.unlockCollapse
context.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
window.animator().alphaValue = 0
}
DispatchQueue.main.asyncAfter(deadline: .now() + LockScreenAnimationTimings.unlockCollapse + 0.02) {
window.orderOut(nil)
self.currentNotchSize = nil
}
print("[\(timestamp())] LockScreenLiveActivityWindowManager: HUD hidden")
}
func configure(viewModel: DynamicIslandViewModel) {
self.viewModel = viewModel
}
}