Files
wehub-resource-sync 1d1286fadb
Build / Build and test (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:18:38 +08:00

442 lines
16 KiB
Swift

import AppKit
import Combine
import CoreGraphics
import Darwin
// MARK: - MenuBarHiddenIconCache
//
// Captures live menu-bar item screenshots via the same SkyLight window capture
// path used by Thaw. ScreenCaptureKit does not reliably capture menu-bar item
// windows once the hidden divider has pushed them off the visible display.
// Screen Recording permission is mandatory; without it there are no icons to
// show (this plugin gates its UI on the permission check).
//
// Cache layout: keyed by `MenuBarItemTag`, with window-ID-insensitive lookup
// for refreshed items. Cache size is bounded so scrolling through many apps
// does not grow memory without limit.
@MainActor
final class MenuBarHiddenIconCache: ObservableObject {
struct CapturedImage {
let cgImage: CGImage
let scale: CGFloat
var scaledSize: CGSize {
CGSize(
width: CGFloat(cgImage.width) / scale,
height: CGFloat(cgImage.height) / scale
)
}
var nsImage: NSImage {
NSImage(cgImage: cgImage, size: scaledSize)
}
static func isVisuallyEqual(_ old: CapturedImage?, _ new: CapturedImage?) -> Bool {
guard let old, let new else { return old == nil && new == nil }
if old.cgImage === new.cgImage { return true }
guard old.scale == new.scale,
old.cgImage.width == new.cgImage.width,
old.cgImage.height == new.cgImage.height
else {
return false
}
guard let oldData = old.cgImage.dataProvider?.data,
let newData = new.cgImage.dataProvider?.data
else {
return false
}
return oldData == newData
}
}
@Published private(set) var images: [MenuBarItemTag: CapturedImage] = [:]
var onLayoutMetricsChange: (() -> Void)?
private static let maxCacheSize = 200
private var captureTask: Task<Void, Never>?
private var pendingRefresh: (groups: [[MenuBarItem]], displayID: CGDirectDisplayID)?
private var recentlyFailed = [MenuBarItemTag: Date]()
private let failureCooldown: TimeInterval = 2
/// Refresh icons for the given section groups. Requests are coalesced
/// instead of cancelling in-flight captures so rapid divider frame updates
/// cannot starve the cache and leave the layout bar blank.
func refresh(groups: [[MenuBarItem]], displayID: CGDirectDisplayID) {
pendingRefresh = (groups.filter { !$0.isEmpty }, displayID)
guard captureTask == nil else { return }
captureTask = Task { [weak self] in
await self?.drainRefreshRequests()
}
}
func image(for tag: MenuBarItemTag) -> CapturedImage? {
if let image = images[tag] {
return image
}
return images.first { $0.key.matchesIgnoringWindowID(tag) }?.value
}
func purgeMissing(keepingKeys validKeys: Set<String>) {
images = images.filter { validKeys.contains($0.key.stableKey) }
}
// MARK: - Private
private func drainRefreshRequests() async {
while let request = pendingRefresh {
pendingRefresh = nil
await captureAll(groups: request.groups, displayID: request.displayID)
}
captureTask = nil
}
private func captureAll(groups: [[MenuBarItem]], displayID: CGDirectDisplayID) async {
let items = groups.flatMap { $0 }
guard !items.isEmpty else { return }
var next = images
let validTags = Set(items.map(\.tag))
let scale = Self.backingScaleFactor(for: displayID)
var capturedImages = [MenuBarItemTag: CapturedImage]()
for group in groups {
guard !Task.isCancelled else { return }
capturedImages.merge(refreshImages(of: group, scale: scale)) { _, new in new }
}
next = next.filter { cachedTag, _ in
validTags.contains { $0.matchesIgnoringWindowID(cachedTag) }
}
var didChangeLayoutMetrics = false
for (tag, image) in capturedImages {
for oldTag in next.keys where oldTag != tag && oldTag.matchesIgnoringWindowID(tag) {
next.removeValue(forKey: oldTag)
}
if !CapturedImage.isVisuallyEqual(next[tag], image) {
if next[tag]?.scaledSize != image.scaledSize {
didChangeLayoutMetrics = true
}
next[tag] = image
}
}
if next.count > Self.maxCacheSize {
let validTags = Set(items.map(\.tag))
let tagsToRemove = next.keys
.filter { tag in !validTags.contains { $0.matchesIgnoringWindowID(tag) } }
.prefix(next.count - Self.maxCacheSize)
for tag in tagsToRemove {
next.removeValue(forKey: tag)
}
}
MenuBarHiddenLog.plugin.debug(
"Menu bar icon cache updated: displayID=\(displayID), scale=\(Double(scale)), items=\(items.count), captured=\(capturedImages.count), icons=\(next.count)"
)
images = next
if didChangeLayoutMetrics {
onLayoutMetricsChange?()
}
}
private static func backingScaleFactor(for displayID: CGDirectDisplayID) -> CGFloat {
let screen = NSScreen.screens.first { screen in
(screen.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? NSNumber)?.uint32Value == displayID
}
return screen?.backingScaleFactor ?? NSScreen.main?.backingScaleFactor ?? 1
}
private func refreshImages(of items: [MenuBarItem], scale: CGFloat) -> [MenuBarItemTag: CapturedImage] {
let capturableItems = items.filter { !shouldSkipCapture(for: $0) }
guard !capturableItems.isEmpty else { return [:] }
var windowIDs = [CGWindowID]()
var storage = [CGWindowID: (MenuBarItem, CGRect)]()
var boundsUnion = CGRect.null
for item in capturableItems {
guard let bounds = Self.validBounds(MenuBarHiddenCaptureWindowServer.screenRect(for: item.windowID) ?? item.bounds)
else {
continue
}
windowIDs.append(item.windowID)
storage[item.windowID] = (item, bounds)
boundsUnion = boundsUnion.union(bounds)
}
guard !windowIDs.isEmpty, !boundsUnion.isNull, !boundsUnion.isEmpty else {
MenuBarHiddenLog.plugin.debug("refreshImages: no items with bounds, skipping")
return [:]
}
guard let compositeImage = MenuBarHiddenSkyLight.captureWindowsImage(
windowIDs: windowIDs,
options: [.boundsIgnoreFraming, .bestResolution]
) else {
MenuBarHiddenLog.plugin.debug("SkyLight menu-bar icon capture failed for \(windowIDs.count) windows")
return captureIndividualImages(of: capturableItems, scale: scale)
}
let expectedWidth = boundsUnion.width * scale
guard CGFloat(compositeImage.width) == expectedWidth else {
MenuBarHiddenLog.plugin.debug(
"refreshImages: width mismatch (expected \(expectedWidth), got \(compositeImage.width)), skipping"
)
return captureIndividualImages(of: capturableItems, scale: scale)
}
guard !Self.isFullyTransparent(compositeImage) else {
MenuBarHiddenLog.plugin.debug("refreshImages: composite is transparent, skipping")
return captureIndividualImages(of: capturableItems, scale: scale)
}
var newImages = [MenuBarItemTag: CapturedImage]()
var cropNilCount = 0
for windowID in windowIDs {
guard let (item, bounds) = storage[windowID] else { continue }
let cropRect = CGRect(
x: (bounds.origin.x - boundsUnion.origin.x) * scale,
y: (bounds.origin.y - boundsUnion.origin.y) * scale,
width: bounds.width * scale,
height: bounds.height * scale
)
guard let image = compositeImage.cropping(to: cropRect) else {
cropNilCount += 1
recordCaptureFailure(for: item)
continue
}
recordCaptureSuccess(for: item)
newImages[item.tag] = CapturedImage(cgImage: image, scale: scale)
}
MenuBarHiddenLog.plugin.debug(
"refreshImages: captured \(newImages.count)/\(windowIDs.count) items, cropNil=\(cropNilCount)"
)
return newImages
}
private func captureIndividualImages(of items: [MenuBarItem], scale: CGFloat) -> [MenuBarItemTag: CapturedImage] {
var images = [MenuBarItemTag: CapturedImage]()
var nilCount = 0
var transparentCount = 0
for item in items where !shouldSkipCapture(for: item) {
guard let image = MenuBarHiddenSkyLight.captureWindowsImage(
windowIDs: [item.windowID],
options: [.boundsIgnoreFraming, .bestResolution]
) else {
nilCount += 1
recordCaptureFailure(for: item)
continue
}
guard !Self.isFullyTransparent(image) else {
transparentCount += 1
recordCaptureFailure(for: item)
continue
}
recordCaptureSuccess(for: item)
images[item.tag] = CapturedImage(cgImage: image, scale: scale)
}
MenuBarHiddenLog.plugin.debug(
"individual refreshImages: captured \(images.count)/\(items.count), nil=\(nilCount), transparent=\(transparentCount)"
)
return images
}
private func shouldSkipCapture(for item: MenuBarItem) -> Bool {
guard let lastFailedAt = recentlyFailed[item.tag] else { return false }
if Date().timeIntervalSince(lastFailedAt) < failureCooldown {
return true
}
recentlyFailed.removeValue(forKey: item.tag)
return false
}
private func recordCaptureFailure(for item: MenuBarItem) {
recentlyFailed[item.tag] = Date()
}
private func recordCaptureSuccess(for item: MenuBarItem) {
recentlyFailed.removeValue(forKey: item.tag)
}
private static func validBounds(_ bounds: CGRect?) -> CGRect? {
guard
let bounds,
!bounds.isNull,
!bounds.isEmpty,
bounds.minX.isFinite,
bounds.maxX.isFinite,
bounds.minY.isFinite,
bounds.maxY.isFinite
else {
return nil
}
return bounds
}
private static func isFullyTransparent(_ image: CGImage) -> Bool {
guard image.width > 0, image.height > 0 else { return true }
let bytesPerPixel = image.bitsPerPixel / 8
guard bytesPerPixel == 4 else { return isFullyTransparentSlow(image) }
switch image.alphaInfo {
case .none, .noneSkipFirst, .noneSkipLast:
return false
case .premultipliedFirst, .first, .premultipliedLast, .last, .alphaOnly:
break
@unknown default:
return isFullyTransparentSlow(image)
}
let byteOrder = CGBitmapInfo(rawValue: image.bitmapInfo.rawValue).intersection(.byteOrderMask)
let isLittleEndian: Bool
switch byteOrder {
case .byteOrder32Little:
isLittleEndian = true
case .byteOrder32Big:
isLittleEndian = false
default:
return isFullyTransparentSlow(image)
}
let alphaOffset: Int
switch (image.alphaInfo, isLittleEndian) {
case (.premultipliedFirst, true), (.first, true):
alphaOffset = 3
case (.premultipliedLast, true), (.last, true):
alphaOffset = 0
case (.premultipliedFirst, false), (.first, false):
alphaOffset = 0
case (.premultipliedLast, false), (.last, false):
alphaOffset = 3
default:
return isFullyTransparentSlow(image)
}
guard let data = image.dataProvider?.data, let bytes = CFDataGetBytePtr(data) else {
return isFullyTransparentSlow(image)
}
let dataLength = CFDataGetLength(data)
let requiredLength = (image.height - 1) * image.bytesPerRow
+ (image.width - 1) * bytesPerPixel
+ alphaOffset
+ 1
guard dataLength >= requiredLength else { return isFullyTransparentSlow(image) }
return withExtendedLifetime(data) {
for row in 0 ..< image.height {
let rowStart = row * image.bytesPerRow
for column in 0 ..< image.width {
if bytes[rowStart + column * bytesPerPixel + alphaOffset] > 0 {
return false
}
}
}
return true
}
}
private static func isFullyTransparentSlow(_ image: CGImage) -> Bool {
let width = image.width
let height = image.height
guard width > 0, height > 0 else { return true }
var pixels = [UInt8](repeating: 0, count: width * height * 4)
guard let context = CGContext(
data: &pixels,
width: width,
height: height,
bitsPerComponent: 8,
bytesPerRow: width * 4,
space: CGColorSpaceCreateDeviceRGB(),
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
) else {
return false
}
context.draw(image, in: CGRect(x: 0, y: 0, width: width, height: height))
return stride(from: 3, to: pixels.count, by: 4).allSatisfy { pixels[$0] == 0 }
}
}
private enum MenuBarHiddenCaptureWindowServer {
static func screenRect(for windowID: CGWindowID) -> CGRect? {
var rect = CGRect.zero
let result = menuBarHiddenCaptureCGSGetScreenRectForWindow(
menuBarHiddenCaptureCGSDefaultConnectionForThread(),
windowID,
&rect
)
guard result == .success else { return nil }
return rect
}
}
private typealias MenuBarHiddenCaptureCGSConnectionID = Int32
@_silgen_name("CGSDefaultConnectionForThread")
private func menuBarHiddenCaptureCGSDefaultConnectionForThread() -> MenuBarHiddenCaptureCGSConnectionID
@_silgen_name("CGSGetScreenRectForWindow")
private func menuBarHiddenCaptureCGSGetScreenRectForWindow(
_ cid: MenuBarHiddenCaptureCGSConnectionID,
_ wid: CGWindowID,
_ outRect: inout CGRect
) -> CGError
private enum MenuBarHiddenSkyLight {
private typealias SLWindowListCreateImageFromArray = @convention(c) (
CGRect,
CFArray,
CGWindowImageOption
) -> Unmanaged<CGImage>?
private nonisolated(unsafe) static let handle: UnsafeMutableRawPointer? = {
dlopen("/System/Library/PrivateFrameworks/SkyLight.framework/SkyLight", RTLD_NOW)
}()
private static let captureFunction: SLWindowListCreateImageFromArray? = {
guard let handle, let symbol = dlsym(handle, "SLWindowListCreateImageFromArray") else {
return nil
}
return unsafeBitCast(symbol, to: SLWindowListCreateImageFromArray.self)
}()
static func captureWindowsImage(
windowIDs: [CGWindowID],
options: CGWindowImageOption
) -> CGImage? {
guard let captureFunction, let windowArray = cgWindowArray(with: windowIDs) else {
return nil
}
return captureFunction(.null, windowArray, options)?.takeRetainedValue()
}
private static func cgWindowArray(with windowIDs: [CGWindowID]) -> CFArray? {
var pointers: [UnsafeRawPointer?] = windowIDs.compactMap {
UnsafeRawPointer(bitPattern: UInt($0))
}
guard !pointers.isEmpty else { return nil }
var callbacks = CFArrayCallBacks(
version: 0,
retain: nil,
release: nil,
copyDescription: nil,
equal: nil
)
return CFArrayCreate(nil, &pointers, pointers.count, &callbacks)
}
}