diff --git a/.swiftlint.yml b/.swiftlint.yml
index 4bd60f320..8f8513e4e 100644
--- a/.swiftlint.yml
+++ b/.swiftlint.yml
@@ -1,4 +1,5 @@
included:
+ - MenuBarCaptureService
- MenuBarItemService
- Shared
- Thaw
diff --git a/MenuBarCaptureService/Listener.swift b/MenuBarCaptureService/Listener.swift
new file mode 100644
index 000000000..962f62d96
--- /dev/null
+++ b/MenuBarCaptureService/Listener.swift
@@ -0,0 +1,214 @@
+//
+// Listener.swift
+// Project: Thaw
+//
+// Copyright (Ice) © 2023–2025 Jordan Baird
+// Copyright (Thaw) © 2026 Toni Förster
+// Licensed under the GNU GPLv3
+
+import CoreGraphics
+import Foundation
+import XPC
+
+/// XPC listener for offscreen SkyLight capture.
+///
+/// Explicitly nonisolated: XPC callbacks arrive on arbitrary threads, and the
+/// target's default actor isolation is MainActor.
+final nonisolated class Listener: @unchecked Sendable {
+ private let diagLog = DiagLog(category: "CaptureListener")
+ static let shared = Listener()
+
+ private let name = MenuBarCaptureService.name
+ private var xpcListener: XPCListener?
+ private let captureLock = NSLock()
+ private let instanceID = UInt64.random(in: .min ... .max)
+ private var captureCount = 0
+
+ private init() {}
+
+ deinit {
+ cancel()
+ }
+
+ private func handleMessage(_ message: XPCReceivedMessage) -> MenuBarCaptureService.Response? {
+ do {
+ let request = try message.decode(as: MenuBarCaptureService.Request.self)
+ switch request {
+ case .start:
+ return .start
+ case let .configureLogging(filePath):
+ let requested = URL(fileURLWithPath: filePath)
+ .standardizedFileURL.resolvingSymlinksInPath()
+ let approvedDir = DiagnosticLogger.shared.logDirectory
+ .standardizedFileURL.resolvingSymlinksInPath()
+ guard requested.path.hasPrefix(approvedDir.path + "/") else {
+ diagLog.error(
+ "Capture listener rejected configureLogging path outside approved log directory: \(filePath)"
+ )
+ return nil
+ }
+ DiagnosticLogger.shared.attachToFile(at: requested)
+ return .configureLogging
+ case let .captureBatch(batch):
+ return capture(batch)
+ case .recycle:
+ scheduleExit()
+ return .recycle
+ }
+ } catch {
+ diagLog.error("Capture listener failed to handle message with error \(error)")
+ return nil
+ }
+ }
+
+ private func capture(_ request: MenuBarCaptureService.CaptureBatchRequest) -> MenuBarCaptureService.Response {
+ captureLock.lock()
+ defer { captureLock.unlock() }
+
+ let frames = autoreleasepool { () -> [MenuBarCaptureService.Frame] in
+ captureFrames(request)
+ }
+ if MenuBarCaptureService.shouldRecycle(captureCount: captureCount) {
+ scheduleExit()
+ }
+ return .captureBatch(
+ MenuBarCaptureService.CaptureBatchResponse(
+ requestID: request.requestID,
+ instanceID: instanceID,
+ frames: frames
+ )
+ )
+ }
+
+ private func captureFrames(
+ _ request: MenuBarCaptureService.CaptureBatchRequest
+ ) -> [MenuBarCaptureService.Frame] {
+ let scale = CGFloat(request.expectedScale)
+ guard scale > 0, scale.isFinite else { return [] }
+
+ let allowed = Set(Bridging.getMenuBarWindowList(option: .itemsOnly))
+ let windowIDs = MenuBarCaptureService.validatedWindowIDs(request.windowIDs, allowed: allowed)
+ guard !windowIDs.isEmpty else { return [] }
+
+ var storage = [CGWindowID: CGRect]()
+ var orderedIDs = [CGWindowID]()
+ var boundsUnion = CGRect.null
+ for windowID in windowIDs {
+ guard let bounds = Bridging.getWindowBounds(for: windowID) else { continue }
+ guard Bridging.isValidCaptureBounds(bounds, scale: scale) else { continue }
+ storage[windowID] = bounds
+ orderedIDs.append(windowID)
+ boundsUnion = boundsUnion.union(bounds)
+ }
+ guard !orderedIDs.isEmpty,
+ Bridging.isValidCaptureBounds(boundsUnion, scale: scale)
+ else {
+ return []
+ }
+
+ let options = CGWindowImageOption(rawValue: request.optionRawValue)
+ let composite = Bridging.captureWindowsImage(
+ windowIDs: orderedIDs,
+ options: options
+ )
+ guard let composite else {
+ diagLog.debug("captureFrames: SkyLight returned nil for \(orderedIDs.count) windows")
+ return []
+ }
+ captureCount += 1
+
+ let expectedWidth = boundsUnion.width * scale
+ guard abs(CGFloat(composite.width) - expectedWidth) < 1 else {
+ diagLog.debug(
+ "captureFrames: width mismatch (expected \(expectedWidth), got \(composite.width))"
+ )
+ return []
+ }
+
+ if let encoded = MenuBarCaptureService.encodeBGRA(composite),
+ MenuBarCaptureService.isFullyTransparentBGRA(
+ pixels: encoded.pixels,
+ width: composite.width,
+ height: composite.height,
+ bytesPerRow: encoded.bytesPerRow
+ )
+ {
+ return []
+ }
+
+ var frames = [MenuBarCaptureService.Frame]()
+ var batchBytes = 0
+ for windowID in orderedIDs {
+ guard let 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 cropped = composite.cropping(to: cropRect),
+ let encoded = MenuBarCaptureService.encodeBGRA(cropped)
+ else {
+ continue
+ }
+ batchBytes += encoded.pixels.count
+ guard batchBytes <= 16 * 1_024 * 1_024 else { break }
+ frames.append(
+ MenuBarCaptureService.Frame(
+ windowID: windowID,
+ width: cropped.width,
+ height: cropped.height,
+ bytesPerRow: encoded.bytesPerRow,
+ scale: request.expectedScale,
+ pixels: encoded.pixels
+ )
+ )
+ }
+ return frames
+ }
+
+ private func scheduleExit() {
+ DispatchQueue.main.async {
+ exit(0)
+ }
+ }
+
+ private func uncheckedActivateWithSameTeamRequirement() throws {
+ xpcListener = try XPCListener(service: name, requirement: .isFromSameTeam()) { request in
+ request.accept { [self] message in
+ self.handleMessage(message)
+ }
+ }
+ }
+
+ private func uncheckedActivateWithoutPeerRequirement() throws {
+ xpcListener = try XPCListener(service: name) { request in
+ request.accept { [self] message in
+ self.handleMessage(message)
+ }
+ }
+ diagLog.warning(
+ "Capture listener is active WITHOUT peer validation (ad-hoc/teamless build): any local process may connect"
+ )
+ }
+
+ func activate() {
+ guard xpcListener == nil else {
+ diagLog.notice("Capture listener is already active")
+ return
+ }
+ do {
+ if CodeSigningInfo.processTeamIdentifier == nil {
+ try uncheckedActivateWithoutPeerRequirement()
+ } else {
+ try uncheckedActivateWithSameTeamRequirement()
+ }
+ } catch {
+ diagLog.error("Failed to activate capture listener with error \(error)")
+ }
+ }
+
+ func cancel() {
+ xpcListener.take()?.cancel()
+ }
+}
diff --git a/MenuBarCaptureService/Resources/Info.plist b/MenuBarCaptureService/Resources/Info.plist
new file mode 100644
index 000000000..084f78f63
--- /dev/null
+++ b/MenuBarCaptureService/Resources/Info.plist
@@ -0,0 +1,17 @@
+
+
+
+
+ ThawSkyLightFrameworkPath
+ /System/Library/PrivateFrameworks/SkyLight.framework/SkyLight
+ XPCService
+
+ JoinExistingSession
+
+ RunLoopType
+ NSRunLoop
+ ServiceType
+ Application
+
+
+
diff --git a/MenuBarCaptureService/main.swift b/MenuBarCaptureService/main.swift
new file mode 100644
index 000000000..f95cc9bbd
--- /dev/null
+++ b/MenuBarCaptureService/main.swift
@@ -0,0 +1,17 @@
+//
+// main.swift
+// Project: Thaw
+//
+// Copyright (Ice) © 2023–2025 Jordan Baird
+// Copyright (Thaw) © 2026 Toni Förster
+// Licensed under the GNU GPLv3
+
+import Foundation
+
+Listener.shared.activate()
+
+while true {
+ autoreleasepool {
+ _ = RunLoop.current.run(mode: .default, before: Date(timeIntervalSinceNow: 60))
+ }
+}
diff --git a/Shared/Services/MenuBarCaptureService.swift b/Shared/Services/MenuBarCaptureService.swift
new file mode 100644
index 000000000..425d85bd8
--- /dev/null
+++ b/Shared/Services/MenuBarCaptureService.swift
@@ -0,0 +1,203 @@
+//
+// MenuBarCaptureService.swift
+// Project: Thaw
+//
+// Copyright (Ice) © 2023–2025 Jordan Baird
+// Copyright (Thaw) © 2026 Toni Förster
+// Licensed under the GNU GPLv3
+
+import CoreGraphics
+import Foundation
+
+/// Offscreen menu-bar capture XPC vocabulary.
+///
+/// SkyLight's `SLWindowListCreateImageFromArray` leaks a small dictionary in
+/// the calling process per successful call (commit `0e045faf`). This service
+/// exists so that leak can be reclaimed by exiting the helper. Visible-section
+/// icons stay on ScreenCaptureKit in the app; Always Hidden stays at 1 fps.
+nonisolated enum MenuBarCaptureService {
+ static let name = "com.stonerl.Thaw.MenuBarCaptureService"
+
+ /// Exit after this many successful SkyLight composites.
+ ///
+ /// Commit `0e045faf` measured ~168 B per leaked dictionary. 1,800 calls is
+ /// about 60 s at 30 fps and ~300 KB of helper growth before recycle.
+ static let recycleAfterCaptureCount = 1_800
+
+ static let maxWindowCount = 64
+ static let maxBytesPerFrame = 4 * 1_024 * 1_024
+ static let minAlwaysHiddenInterval: TimeInterval = 1
+
+ /// Premultiplied BGRA, little-endian — the capture-path pixel layout.
+ static let bgraBitmapInfo: UInt32 =
+ CGImageAlphaInfo.premultipliedFirst.rawValue
+ | CGBitmapInfo.byteOrder32Little.rawValue
+}
+
+nonisolated extension MenuBarCaptureService {
+ struct CaptureBatchRequest: Codable, Equatable {
+ var requestID: UInt64
+ var windowIDs: [CGWindowID]
+ var optionRawValue: UInt32
+ var expectedScale: Double
+ }
+
+ struct Frame: Codable, Equatable {
+ var windowID: CGWindowID
+ var width: Int
+ var height: Int
+ var bytesPerRow: Int
+ var scale: Double
+ var pixels: Data
+ }
+
+ struct CaptureBatchResponse: Codable, Equatable {
+ var requestID: UInt64
+ var instanceID: UInt64
+ var frames: [Frame]
+ }
+
+ enum Request: Codable, Equatable {
+ case start
+ case configureLogging(filePath: String)
+ case captureBatch(CaptureBatchRequest)
+ case recycle
+ }
+
+ enum Response: Codable, Equatable {
+ case start
+ case configureLogging
+ case captureBatch(CaptureBatchResponse)
+ case recycle
+ }
+}
+
+nonisolated extension MenuBarCaptureService {
+ /// Drops zeros, duplicates, non-members, and anything past ``maxWindowCount``.
+ static func validatedWindowIDs(
+ _ ids: [CGWindowID],
+ allowed: Set
+ ) -> [CGWindowID] {
+ var seen = Set()
+ var result = [CGWindowID]()
+ result.reserveCapacity(min(ids.count, maxWindowCount))
+ for id in ids {
+ guard id != 0, !seen.contains(id), allowed.contains(id) else { continue }
+ seen.insert(id)
+ result.append(id)
+ if result.count == maxWindowCount { break }
+ }
+ return result
+ }
+
+ static func isValidBGRAFrame(
+ width: Int,
+ height: Int,
+ bytesPerRow: Int,
+ pixelCount: Int
+ ) -> Bool {
+ guard width > 0, height > 0 else { return false }
+ guard width <= Bridging.maximumCaptureDimension,
+ height <= Bridging.maximumCaptureDimension
+ else { return false }
+ guard bytesPerRow >= width * 4 else { return false }
+ let expected = bytesPerRow * height
+ guard pixelCount >= expected, expected <= maxBytesPerFrame else { return false }
+ return true
+ }
+
+ static func shouldRecycle(
+ captureCount: Int,
+ budget: Int = recycleAfterCaptureCount
+ ) -> Bool {
+ captureCount >= budget
+ }
+
+ static func acceptedResponse(
+ requestID: UInt64,
+ response: Response
+ ) -> CaptureBatchResponse? {
+ guard case let .captureBatch(batch) = response, batch.requestID == requestID else {
+ return nil
+ }
+ return batch
+ }
+
+ static func encodeBGRA(_ image: CGImage) -> (pixels: Data, bytesPerRow: Int)? {
+ let width = image.width
+ let height = image.height
+ guard width > 0, height > 0 else { return nil }
+ guard let context = CGContext(
+ data: nil,
+ width: width,
+ height: height,
+ bitsPerComponent: 8,
+ bytesPerRow: 0,
+ space: CGColorSpaceCreateDeviceRGB(),
+ bitmapInfo: bgraBitmapInfo
+ ), let dataPtr = context.data else {
+ return nil
+ }
+ context.draw(image, in: CGRect(x: 0, y: 0, width: width, height: height))
+ let stride = context.bytesPerRow
+ let count = stride * height
+ guard count > 0, count <= maxBytesPerFrame else { return nil }
+ return (Data(bytes: dataPtr, count: count), stride)
+ }
+
+ static func makeImage(from frame: Frame) -> CGImage? {
+ guard isValidBGRAFrame(
+ width: frame.width,
+ height: frame.height,
+ bytesPerRow: frame.bytesPerRow,
+ pixelCount: frame.pixels.count
+ ) else {
+ return nil
+ }
+ guard frame.scale > 0, frame.scale.isFinite else { return nil }
+ guard let provider = CGDataProvider(data: frame.pixels as CFData) else {
+ return nil
+ }
+ return CGImage(
+ width: frame.width,
+ height: frame.height,
+ bitsPerComponent: 8,
+ bitsPerPixel: 32,
+ bytesPerRow: frame.bytesPerRow,
+ space: CGColorSpaceCreateDeviceRGB(),
+ bitmapInfo: CGBitmapInfo(rawValue: bgraBitmapInfo),
+ provider: provider,
+ decode: nil,
+ shouldInterpolate: false,
+ intent: .defaultIntent
+ )
+ }
+
+ static func isFullyTransparentBGRA(
+ pixels: Data,
+ width: Int,
+ height: Int,
+ bytesPerRow: Int
+ ) -> Bool {
+ guard isValidBGRAFrame(
+ width: width,
+ height: height,
+ bytesPerRow: bytesPerRow,
+ pixelCount: pixels.count
+ ) else {
+ return true
+ }
+ return pixels.withUnsafeBytes { buffer in
+ guard let base = buffer.bindMemory(to: UInt8.self).baseAddress else {
+ return true
+ }
+ for row in 0 ..< height {
+ let rowBase = base + row * bytesPerRow
+ for column in 0 ..< width where rowBase[column * 4 + 3] != 0 {
+ return false
+ }
+ }
+ return true
+ }
+ }
+}
diff --git a/Thaw.xcodeproj/project.pbxproj b/Thaw.xcodeproj/project.pbxproj
index 6f5f3c342..c3f502903 100644
--- a/Thaw.xcodeproj/project.pbxproj
+++ b/Thaw.xcodeproj/project.pbxproj
@@ -15,6 +15,8 @@
7127A9FF2C4886D100D99DEF /* IfritStatic in Frameworks */ = {isa = PBXBuildFile; productRef = 7127A9FE2C4886D100D99DEF /* IfritStatic */; };
7168EE532E281CBC00FF9830 /* AXSwift6 in Frameworks */ = {isa = PBXBuildFile; productRef = 7168EE522E281CBC00FF9830 /* AXSwift6 */; };
7188A68C2E27F9ED008F131D /* MenuBarItemService.xpc in Embed XPC Services */ = {isa = PBXBuildFile; fileRef = 7188A6832E27F9ED008F131D /* MenuBarItemService.xpc */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
+ C4PT00012F3A100000000010 /* AXSwift6 in Frameworks */ = {isa = PBXBuildFile; productRef = C4PT00012F3A10000000000F /* AXSwift6 */; };
+ C4PT00012F3A100000000005 /* MenuBarCaptureService.xpc in Embed XPC Services */ = {isa = PBXBuildFile; fileRef = C4PT00012F3A100000000001 /* MenuBarCaptureService.xpc */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
765B9E4A97DD40A18EE1F15D /* Collections in Frameworks */ = {isa = PBXBuildFile; productRef = 6937234B615A4E0BA19DFCF5 /* Collections */; };
9570E4A96CDEC0BEBAA5573F /* AsyncAlgorithms in Frameworks */ = {isa = PBXBuildFile; productRef = 43C5F05D006747974015EBD9 /* AsyncAlgorithms */; };
A6DC49597D7F903C5E1B425F /* Algorithms in Frameworks */ = {isa = PBXBuildFile; productRef = 84489DC7DD2441B27C7B8E6C /* Algorithms */; };
@@ -29,6 +31,13 @@
remoteGlobalIDString = 7188A6822E27F9ED008F131D;
remoteInfo = MenuBarItemService;
};
+ C4PT00012F3A100000000006 /* PBXContainerItemProxy */ = {
+ isa = PBXContainerItemProxy;
+ containerPortal = 716683222A767E6A006ABF84 /* Project object */;
+ proxyType = 1;
+ remoteGlobalIDString = C4PT00012F3A100000000002;
+ remoteInfo = MenuBarCaptureService;
+ };
E2TESTS0012F2C000000002 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 716683222A767E6A006ABF84 /* Project object */;
@@ -45,6 +54,7 @@
dstSubfolder = Product;
files = (
7188A68C2E27F9ED008F131D /* MenuBarItemService.xpc in Embed XPC Services */,
+ C4PT00012F3A100000000005 /* MenuBarCaptureService.xpc in Embed XPC Services */,
);
name = "Embed XPC Services";
};
@@ -53,6 +63,7 @@
/* Begin PBXFileReference section */
7166832A2A767E6A006ABF84 /* Thaw.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Thaw.app; sourceTree = BUILT_PRODUCTS_DIR; };
7188A6832E27F9ED008F131D /* MenuBarItemService.xpc */ = {isa = PBXFileReference; explicitFileType = "wrapper.xpc-service"; includeInIndex = 0; path = MenuBarItemService.xpc; sourceTree = BUILT_PRODUCTS_DIR; };
+ C4PT00012F3A100000000001 /* MenuBarCaptureService.xpc */ = {isa = PBXFileReference; explicitFileType = "wrapper.xpc-service"; includeInIndex = 0; path = MenuBarCaptureService.xpc; sourceTree = BUILT_PRODUCTS_DIR; };
E2TESTS0012F2C000000001 /* ThawTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = ThawTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
/* End PBXFileReference section */
@@ -64,6 +75,13 @@
);
target = 7188A6822E27F9ED008F131D /* MenuBarItemService */;
};
+ C4PT00012F3A100000000004 /* Exceptions for "MenuBarCaptureService" folder in "MenuBarCaptureService" target */ = {
+ isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
+ membershipExceptions = (
+ Resources/Info.plist,
+ );
+ target = C4PT00012F3A100000000002 /* MenuBarCaptureService */;
+ };
E2B782F92F2BF98100CF0BBD /* Exceptions for "Thaw" folder in "Thaw" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
@@ -82,6 +100,14 @@
path = MenuBarItemService;
sourceTree = "";
};
+ C4PT00012F3A100000000003 /* MenuBarCaptureService */ = {
+ isa = PBXFileSystemSynchronizedRootGroup;
+ exceptions = (
+ C4PT00012F3A100000000004 /* Exceptions for "MenuBarCaptureService" folder in "MenuBarCaptureService" target */,
+ );
+ path = MenuBarCaptureService;
+ sourceTree = "";
+ };
7188A69E2E280BB4008F131D /* Shared */ = {
isa = PBXFileSystemSynchronizedRootGroup;
path = Shared;
@@ -124,6 +150,12 @@
29808AAF4CA568551CD0CC6B /* Algorithms in Frameworks */,
);
};
+ C4PT00012F3A10000000000C /* Frameworks */ = {
+ isa = PBXFrameworksBuildPhase;
+ files = (
+ C4PT00012F3A100000000010 /* AXSwift6 in Frameworks */,
+ );
+ };
E2TESTS0012F2C000000004 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
files = (
@@ -138,6 +170,7 @@
7188A69E2E280BB4008F131D /* Shared */,
71BDFBE12C978E2A00EF145F /* Thaw */,
7188A6842E27F9ED008F131D /* MenuBarItemService */,
+ C4PT00012F3A100000000003 /* MenuBarCaptureService */,
E2TESTS0012F2C000000003 /* ThawTests */,
7166832B2A767E6A006ABF84 /* Products */,
);
@@ -148,6 +181,7 @@
children = (
7166832A2A767E6A006ABF84 /* Thaw.app */,
7188A6832E27F9ED008F131D /* MenuBarItemService.xpc */,
+ C4PT00012F3A100000000001 /* MenuBarCaptureService.xpc */,
E2TESTS0012F2C000000001 /* ThawTests.xctest */,
);
name = Products;
@@ -171,6 +205,7 @@
);
dependencies = (
7188A68B2E27F9ED008F131D /* PBXTargetDependency */,
+ C4PT00012F3A100000000007 /* PBXTargetDependency */,
);
fileSystemSynchronizedGroups = (
7188A69E2E280BB4008F131D /* Shared */,
@@ -215,6 +250,29 @@
productReference = 7188A6832E27F9ED008F131D /* MenuBarItemService.xpc */;
productType = "com.apple.product-type.xpc-service";
};
+ C4PT00012F3A100000000002 /* MenuBarCaptureService */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = C4PT00012F3A10000000000A /* Build configuration list for PBXNativeTarget "MenuBarCaptureService" */;
+ buildPhases = (
+ C4PT00012F3A10000000000B /* Sources */,
+ C4PT00012F3A10000000000C /* Frameworks */,
+ C4PT00012F3A10000000000D /* Resources */,
+ C4PT00012F3A10000000000E /* Stamp Git SHA */,
+ );
+ buildRules = (
+ );
+ fileSystemSynchronizedGroups = (
+ C4PT00012F3A100000000003 /* MenuBarCaptureService */,
+ 7188A69E2E280BB4008F131D /* Shared */,
+ );
+ name = MenuBarCaptureService;
+ packageProductDependencies = (
+ C4PT00012F3A10000000000F /* AXSwift6 */,
+ );
+ productName = MenuBarCaptureService;
+ productReference = C4PT00012F3A100000000001 /* MenuBarCaptureService.xpc */;
+ productType = "com.apple.product-type.xpc-service";
+ };
E2TESTS0012F2C000000007 /* ThawTests */ = {
isa = PBXNativeTarget;
buildConfigurationList = E2TESTS0012F2C00000000D /* Build configuration list for PBXNativeTarget "ThawTests" */;
@@ -252,6 +310,9 @@
7188A6822E27F9ED008F131D = {
CreatedOnToolsVersion = 26.0;
};
+ C4PT00012F3A100000000002 = {
+ CreatedOnToolsVersion = 26.0;
+ };
E2TESTS0012F2C000000007 = {
CreatedOnToolsVersion = 26.0;
TestTargetID = 716683292A767E6A006ABF84;
@@ -303,6 +364,7 @@
targets = (
716683292A767E6A006ABF84 /* Thaw */,
7188A6822E27F9ED008F131D /* MenuBarItemService */,
+ C4PT00012F3A100000000002 /* MenuBarCaptureService */,
E2TESTS0012F2C000000007 /* ThawTests */,
);
};
@@ -319,6 +381,11 @@
files = (
);
};
+ C4PT00012F3A10000000000D /* Resources */ = {
+ isa = PBXResourcesBuildPhase;
+ files = (
+ );
+ };
E2TESTS0012F2C000000006 /* Resources */ = {
isa = PBXResourcesBuildPhase;
files = (
@@ -387,6 +454,36 @@
"",
);
};
+ C4PT00012F3A10000000000E /* Stamp Git SHA */ = {
+ isa = PBXShellScriptBuildPhase;
+ alwaysOutOfDate = 1;
+ name = "Stamp Git SHA";
+ shellPath = /bin/sh;
+ shellScript = (
+ "set -eu",
+ "",
+ "if SHA=$(git -C \"${SRCROOT}\" rev-parse --short HEAD 2>/dev/null); then",
+ " if ! git -C \"${SRCROOT}\" diff --quiet 2>/dev/null \\",
+ " || ! git -C \"${SRCROOT}\" diff --cached --quiet 2>/dev/null; then",
+ " SHA=\"${SHA}-dirty\"",
+ " fi",
+ "else",
+ " SHA=\"unknown\"",
+ "fi",
+ "",
+ "PLIST=\"${TARGET_BUILD_DIR}/${INFOPLIST_PATH}\"",
+ "if [ ! -f \"$PLIST\" ]; then",
+ " echo \"warning: stamp-git-sha: plist not found at $PLIST\"",
+ " exit 0",
+ "fi",
+ "",
+ "/usr/libexec/PlistBuddy -c \"Set :GitCommitSHA ${SHA}\" \"$PLIST\" 2>/dev/null \\",
+ " || /usr/libexec/PlistBuddy -c \"Add :GitCommitSHA string ${SHA}\" \"$PLIST\"",
+ "",
+ "echo \"stamp-git-sha: stamped ${SHA} into ${PLIST}\"",
+ "",
+ );
+ };
1720D48F2BB9B60500A7AC63 /* SwiftLint */ = {
isa = PBXShellScriptBuildPhase;
inputFileListPaths = (
@@ -425,6 +522,11 @@
files = (
);
};
+ C4PT00012F3A10000000000B /* Sources */ = {
+ isa = PBXSourcesBuildPhase;
+ files = (
+ );
+ };
E2TESTS0012F2C000000005 /* Sources */ = {
isa = PBXSourcesBuildPhase;
files = (
@@ -438,6 +540,11 @@
target = 7188A6822E27F9ED008F131D /* MenuBarItemService */;
targetProxy = 7188A68A2E27F9ED008F131D /* PBXContainerItemProxy */;
};
+ C4PT00012F3A100000000007 /* PBXTargetDependency */ = {
+ isa = PBXTargetDependency;
+ target = C4PT00012F3A100000000002 /* MenuBarCaptureService */;
+ targetProxy = C4PT00012F3A100000000006 /* PBXContainerItemProxy */;
+ };
E2TESTS0012F2C000000008 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = 716683292A767E6A006ABF84 /* Thaw */;
@@ -586,7 +693,7 @@
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES;
ASSETCATALOG_COMPILER_SKIP_APP_STORE_DEPLOYMENT = YES;
CODE_SIGN_ENTITLEMENTS = "";
- "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
+ "CODE_SIGN_IDENTITY[sdk=macosx*]" = "-";
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 50;
@@ -627,7 +734,7 @@
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES;
ASSETCATALOG_COMPILER_SKIP_APP_STORE_DEPLOYMENT = YES;
CODE_SIGN_ENTITLEMENTS = "";
- "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
+ "CODE_SIGN_IDENTITY[sdk=macosx*]" = "-";
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 50;
@@ -663,6 +770,7 @@
7188A68E2E27F9ED008F131D /* Debug configuration for PBXNativeTarget "MenuBarItemService" */ = {
isa = XCBuildConfiguration;
buildSettings = {
+ "CODE_SIGN_IDENTITY[sdk=macosx*]" = "-";
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 50;
@@ -695,6 +803,7 @@
7188A68F2E27F9ED008F131D /* Release configuration for PBXNativeTarget "MenuBarItemService" */ = {
isa = XCBuildConfiguration;
buildSettings = {
+ "CODE_SIGN_IDENTITY[sdk=macosx*]" = "-";
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 50;
@@ -723,6 +832,71 @@
};
name = Release;
};
+ C4PT00012F3A100000000008 /* Debug configuration for PBXNativeTarget "MenuBarCaptureService" */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ "CODE_SIGN_IDENTITY[sdk=macosx*]" = "-";
+ CODE_SIGN_STYLE = Automatic;
+ COMBINE_HIDPI_IMAGES = YES;
+ CURRENT_PROJECT_VERSION = 50;
+ DEVELOPMENT_TEAM = A7CKWF99ML;
+ ENABLE_APP_SANDBOX = NO;
+ ENABLE_HARDENED_RUNTIME = YES;
+ ENABLE_USER_SCRIPT_SANDBOXING = NO;
+ GCC_C_LANGUAGE_STANDARD = gnu17;
+ GENERATE_INFOPLIST_FILE = YES;
+ INFOPLIST_FILE = MenuBarCaptureService/Resources/Info.plist;
+ INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2026 Toni Förster\nCopyright © 2023–2025 Jordan Baird (Ice) ";
+ LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
+ MACOSX_DEPLOYMENT_TARGET = 26.0;
+ MARKETING_VERSION = "2.0.0-rc.3";
+ PRODUCT_BUNDLE_IDENTIFIER = com.stonerl.Thaw.MenuBarCaptureService;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ REGISTER_APP_GROUPS = YES;
+ SKIP_INSTALL = YES;
+ STRING_CATALOG_GENERATE_SYMBOLS = YES;
+ SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
+ SWIFT_APPROACHABLE_CONCURRENCY = YES;
+ SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
+ SWIFT_EMIT_LOC_STRINGS = YES;
+ SWIFT_STRICT_CONCURRENCY = complete;
+ SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
+ SWIFT_VERSION = 6.2;
+ };
+ name = Debug;
+ };
+ C4PT00012F3A100000000009 /* Release configuration for PBXNativeTarget "MenuBarCaptureService" */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ "CODE_SIGN_IDENTITY[sdk=macosx*]" = "-";
+ CODE_SIGN_STYLE = Automatic;
+ COMBINE_HIDPI_IMAGES = YES;
+ CURRENT_PROJECT_VERSION = 50;
+ DEVELOPMENT_TEAM = A7CKWF99ML;
+ ENABLE_APP_SANDBOX = NO;
+ ENABLE_HARDENED_RUNTIME = YES;
+ ENABLE_USER_SCRIPT_SANDBOXING = NO;
+ GCC_C_LANGUAGE_STANDARD = gnu17;
+ GENERATE_INFOPLIST_FILE = YES;
+ INFOPLIST_FILE = MenuBarCaptureService/Resources/Info.plist;
+ INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2026 Toni Förster\nCopyright © 2023–2025 Jordan Baird (Ice) ";
+ LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
+ MACOSX_DEPLOYMENT_TARGET = 26.0;
+ MARKETING_VERSION = "2.0.0-rc.3";
+ PRODUCT_BUNDLE_IDENTIFIER = com.stonerl.Thaw.MenuBarCaptureService;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ REGISTER_APP_GROUPS = YES;
+ SKIP_INSTALL = YES;
+ STRING_CATALOG_GENERATE_SYMBOLS = YES;
+ SWIFT_APPROACHABLE_CONCURRENCY = YES;
+ SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
+ SWIFT_EMIT_LOC_STRINGS = YES;
+ SWIFT_STRICT_CONCURRENCY = complete;
+ SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
+ SWIFT_VERSION = 6.2;
+ };
+ name = Release;
+ };
E2TESTS0012F2C000000009 /* Debug configuration for PBXNativeTarget "ThawTests" */ = {
isa = XCBuildConfiguration;
buildSettings = {
@@ -792,6 +966,14 @@
);
defaultConfigurationName = Release;
};
+ C4PT00012F3A10000000000A /* Build configuration list for PBXNativeTarget "MenuBarCaptureService" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ C4PT00012F3A100000000008 /* Debug configuration for PBXNativeTarget "MenuBarCaptureService" */,
+ C4PT00012F3A100000000009 /* Release configuration for PBXNativeTarget "MenuBarCaptureService" */,
+ );
+ defaultConfigurationName = Release;
+ };
E2TESTS0012F2C00000000D /* Build configuration list for PBXNativeTarget "ThawTests" */ = {
isa = XCConfigurationList;
buildConfigurations = (
@@ -923,6 +1105,11 @@
package = 1787C4252B16890B002F50DF /* XCRemoteSwiftPackageReference "AXSwift6" */;
productName = AXSwift6;
};
+ C4PT00012F3A10000000000F /* AXSwift6 */ = {
+ isa = XCSwiftPackageProductDependency;
+ package = 1787C4252B16890B002F50DF /* XCRemoteSwiftPackageReference "AXSwift6" */;
+ productName = AXSwift6;
+ };
84489DC7DD2441B27C7B8E6C /* Algorithms */ = {
isa = XCSwiftPackageProductDependency;
package = 1FE809086FD6E22D3C218F67 /* XCRemoteSwiftPackageReference "swift-algorithms" */;
diff --git a/Thaw.xcodeproj/xcshareddata/xcschemes/MenuBarCaptureService.xcscheme b/Thaw.xcodeproj/xcshareddata/xcschemes/MenuBarCaptureService.xcscheme
new file mode 100644
index 000000000..ad0c46922
--- /dev/null
+++ b/Thaw.xcodeproj/xcshareddata/xcschemes/MenuBarCaptureService.xcscheme
@@ -0,0 +1,67 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Thaw/Main/AppState.swift b/Thaw/Main/AppState.swift
index 5d7b981d9..c03f3edf2 100644
--- a/Thaw/Main/AppState.swift
+++ b/Thaw/Main/AppState.swift
@@ -121,6 +121,11 @@ final class AppState {
diagLog.debug("setupTask: starting MenuBarItemService XPC connection")
await MenuBarItemService.Connection.shared.start()
diagLog.debug("setupTask: MenuBarItemService XPC connection started")
+ // Capture is optional: don't block item/manager setup if the helper is slow.
+ Task {
+ await MenuBarCaptureService.Connection.shared.start()
+ }
+ diagLog.debug("setupTask: MenuBarCaptureService XPC start kicked off")
appearanceManager.performSetup(with: self)
hidEventManager.performSetup(with: self)
diff --git a/Thaw/MenuBar/MenuBarItems/MenuBarCaptureServiceConnection.swift b/Thaw/MenuBar/MenuBarItems/MenuBarCaptureServiceConnection.swift
new file mode 100644
index 000000000..15d66bae6
--- /dev/null
+++ b/Thaw/MenuBar/MenuBarItems/MenuBarCaptureServiceConnection.swift
@@ -0,0 +1,247 @@
+//
+// MenuBarCaptureServiceConnection.swift
+// Project: Thaw
+//
+// Copyright (Ice) © 2023–2025 Jordan Baird
+// Copyright (Thaw) © 2026 Toni Förster
+// Licensed under the GNU GPLv3
+
+import CoreGraphics
+import Foundation
+import os.lock
+import XPC
+
+extension MenuBarCaptureService {
+ /// A connection to the `MenuBarCaptureService` XPC service.
+ final class Connection: Sendable {
+ static let shared = Connection()
+
+ private let session: Session
+ private let queue: DispatchQueue
+ private let diagLog: DiagLog
+ private let requestIDs = OSAllocatedUnfairLock(initialState: UInt64(0))
+
+ private init() {
+ let queue = DispatchQueue.targetingGlobal(
+ label: "MenuBarCaptureService.Connection.queue",
+ qos: .userInteractive,
+ attributes: .concurrent
+ )
+ let diagLog = DiagLog(category: "MenuBarCaptureService.Connection")
+ self.session = Session(queue: queue, diagLog: diagLog)
+ self.queue = queue
+ self.diagLog = diagLog
+ }
+
+ func start() async {
+ if let logFile = DiagnosticLogger.shared.currentLogFile {
+ _ = await session.sendAsync(request: .configureLogging(filePath: logFile.path))
+ }
+ _ = await session.sendAsync(request: .start)
+ }
+
+ func recycle() async {
+ _ = await session.sendAsync(request: .recycle)
+ session.cancel(reason: "recycle")
+ }
+
+ func capture(
+ windowIDs: [CGWindowID],
+ scale: CGFloat,
+ option: CGWindowImageOption
+ ) async -> [Frame] {
+ if let frames = await sendCapture(
+ windowIDs: windowIDs,
+ scale: scale,
+ option: option
+ ) {
+ return frames
+ }
+ session.cancel(reason: "retry after interruption")
+ return await sendCapture(
+ windowIDs: windowIDs,
+ scale: scale,
+ option: option
+ ) ?? []
+ }
+
+ private func nextRequestID() -> UInt64 {
+ requestIDs.withLock { value in
+ value += 1
+ return value
+ }
+ }
+
+ private func sendCapture(
+ windowIDs: [CGWindowID],
+ scale: CGFloat,
+ option: CGWindowImageOption
+ ) async -> [Frame]? {
+ let requestID = nextRequestID()
+ let request = Request.captureBatch(
+ CaptureBatchRequest(
+ requestID: requestID,
+ windowIDs: windowIDs,
+ optionRawValue: option.rawValue,
+ expectedScale: Double(scale)
+ )
+ )
+ guard let response = await session.sendAsync(request: request) else {
+ return nil
+ }
+ guard let batch = acceptedResponse(requestID: requestID, response: response) else {
+ diagLog.error("Capture request \(requestID) got a stale or invalid response")
+ return []
+ }
+ return batch.frames.filter { frame in
+ isValidBGRAFrame(
+ width: frame.width,
+ height: frame.height,
+ bytesPerRow: frame.bytesPerRow,
+ pixelCount: frame.pixels.count
+ ) && frame.scale > 0 && frame.scale.isFinite
+ }
+ }
+ }
+}
+
+extension MenuBarCaptureService {
+ private final nonisolated class Session: Sendable {
+ private final nonisolated class Storage: @unchecked Sendable {
+ private struct Slot {
+ var session: XPCSession?
+ var generation: UInt64 = 0
+ }
+
+ private let name = MenuBarCaptureService.name
+ private let slot = OSAllocatedUnfairLock(initialState: Slot())
+ private let queue: DispatchQueue
+ private let diagLog: DiagLog
+
+ init(queue: DispatchQueue, diagLog: DiagLog) {
+ self.queue = queue
+ self.diagLog = diagLog
+ }
+
+ func getSession() throws -> XPCSession {
+ if let session = slot.withLock({ $0.session }) {
+ return session
+ }
+ let generation = slot.withLock { state -> UInt64 in
+ state.generation += 1
+ return state.generation
+ }
+ let session = try XPCSession(xpcService: name, options: .inactive) { [weak self] error in
+ guard let self else { return }
+ self.diagLog.warning(
+ "Capture session was cancelled with error \(error.localizedDescription)"
+ )
+ self.slot.withLock { state in
+ if state.generation == generation {
+ state.session = nil
+ }
+ }
+ }
+ if CodeSigningInfo.processTeamIdentifier != nil {
+ session.setPeerRequirement(.isFromSameTeam())
+ }
+ session.setTargetQueue(queue)
+ try session.activate()
+ let superseded = slot.withLock { state -> Bool in
+ guard state.generation == generation else { return true }
+ state.session = session
+ return false
+ }
+ if superseded {
+ session.cancel(reason: "superseded")
+ }
+ return session
+ }
+
+ func cancel(reason: String) {
+ let session = slot.withLock { state -> XPCSession? in
+ state.generation += 1
+ return state.session.take()
+ }
+ session?.cancel(reason: reason)
+ }
+ }
+
+ private let storage: OSAllocatedUnfairLock
+ private let diagLog: DiagLog
+
+ init(queue: DispatchQueue, diagLog: DiagLog) {
+ self.storage = OSAllocatedUnfairLock(initialState: Storage(queue: queue, diagLog: diagLog))
+ self.diagLog = diagLog
+ }
+
+ deinit {
+ cancel(reason: "Session deinitialized")
+ }
+
+ func cancel(reason: String) {
+ storage.withLock { $0.cancel(reason: reason) }
+ }
+
+ func sendAsync(request: Request) async -> Response? {
+ let xpcSession: XPCSession
+ do {
+ xpcSession = try storage.withLock { try $0.getSession() }
+ } catch {
+ diagLog.error("Failed to get or create capture XPC session: \(error)")
+ return nil
+ }
+
+ typealias Cont = CheckedContinuation
+ let box = OSAllocatedUnfairLock(initialState: nil)
+
+ return await withTaskCancellationHandler {
+ await withCheckedContinuation { (continuation: Cont) in
+ performXPCSend(xpcSession, request: request, box: box, continuation: continuation)
+ }
+ } onCancel: {
+ if let cont = box.withLock({ $0.take() }) {
+ cont.resume(returning: nil)
+ }
+ }
+ }
+
+ private func performXPCSend(
+ _ xpcSession: XPCSession,
+ request: Request,
+ box: OSAllocatedUnfairLock?>,
+ continuation: CheckedContinuation
+ ) {
+ box.withLock { $0 = continuation }
+ if Task.isCancelled {
+ if let cont = box.withLock({ $0.take() }) {
+ cont.resume(returning: nil)
+ }
+ return
+ }
+
+ do {
+ try xpcSession.send(request) { (result: Result) in
+ guard let cont = box.withLock({ $0.take() }) else { return }
+ switch result {
+ case let .success(message):
+ do {
+ cont.resume(returning: try message.decode(as: Response.self))
+ } catch {
+ self.diagLog.error("Capture XPC reply decode failed: \(error)")
+ cont.resume(returning: nil)
+ }
+ case let .failure(error):
+ self.diagLog.error("Capture XPC session send failed: \(error)")
+ cont.resume(returning: nil)
+ }
+ }
+ } catch {
+ diagLog.error("Capture XPC session send failed: \(error)")
+ if let cont = box.withLock({ $0.take() }) {
+ cont.resume(returning: nil)
+ }
+ }
+ }
+ }
+}
diff --git a/Thaw/MenuBar/MenuBarItems/MenuBarItemImageCache.swift b/Thaw/MenuBar/MenuBarItems/MenuBarItemImageCache.swift
index 6733146b8..a35586374 100644
--- a/Thaw/MenuBar/MenuBarItems/MenuBarItemImageCache.swift
+++ b/Thaw/MenuBar/MenuBarItems/MenuBarItemImageCache.swift
@@ -102,12 +102,12 @@ final class MenuBarItemImageCache: @unchecked Sendable {
/// Returns whether two optional captured images have equivalent visual content.
///
- /// Uses pointer equality on `CGImage` as a fast path, falling back to
- /// dimension and pixel-data comparison when instances differ.
+ /// Pointer-equal `CGImage`s are a fast path, but scale still has to match.
+ /// Otherwise compare dimensions and pixel data.
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
+ return old.scale == new.scale
}
guard old.scale == new.scale,
old.cgImage.width == new.cgImage.width,
@@ -228,13 +228,11 @@ final class MenuBarItemImageCache: @unchecked Sendable {
/// The currently running live-refresh task, if any.
private var liveRefreshTask: Task?
- /// Timestamp of the last offscreen SkyLight batch capture, used to
- /// rate-limit how often the leaking `SLSWindowListCreateImageFromArrayProxying`
- /// path is invoked (defense in depth for #759).
- private var lastSkyLightBatchAt: ContinuousClock.Instant?
+ /// Timestamp of the last Hidden-section capture.
+ private var lastHiddenRefreshAt: ContinuousClock.Instant?
- /// Minimum spacing enforced between offscreen SkyLight batch captures.
- private static let minSkyLightBatchInterval: Duration = .seconds(1)
+ /// Timestamp of the last Always Hidden-section capture.
+ private var lastAlwaysHiddenRefreshAt: ContinuousClock.Instant?
/// Timestamp of the last visible-section SCK capture, used to rate-limit
/// the on-screen path the same way the offscreen one already is.
@@ -242,20 +240,14 @@ final class MenuBarItemImageCache: @unchecked Sendable {
/// Maximum icon refresh rate the UI may offer, in frames per second.
///
- /// The slider ceiling and the SCK capture floor are the same number so
- /// they cannot drift apart: the UI never promises a rate the engine will
- /// not deliver. 30 matches the historical slider top. Higher rates pin a
- /// core while Search / Layout / Thaw Bar stay open (composite SCK +
- /// per-item crop); leave the SkyLight offscreen floor at 1 s separately.
+ /// The slider ceiling and the SCK / Hidden capture floor are the same
+ /// number so they cannot drift apart. Always Hidden stays at 1 fps.
nonisolated static let maxIconRefreshRate: Double = 30
/// Minimum spacing enforced between visible-section SCK captures, in seconds.
/// Reciprocal of ``maxIconRefreshRate``.
nonisolated static let minIconRefreshInterval: TimeInterval = 1.0 / maxIconRefreshRate
- /// Minimum spacing enforced between visible-section SCK captures.
- private static let minSCKRefreshInterval: Duration = .seconds(minIconRefreshInterval)
-
/// Tracks whether the MenuBarLayoutSettingsPane is currently open.
/// Used to gate background cache prewarming so captures only occur while the
/// user has the layout settings open, rather than staying stuck on for the
@@ -327,8 +319,7 @@ final class MenuBarItemImageCache: @unchecked Sendable {
/// Marks that the MenuBarLayoutSettingsPane has been closed.
/// Call this from the pane's onDisappear to stop background cache prewarming
- /// once the pane is no longer visible, bounding how long perpetual background
- /// captures (including the leaking SkyLight offscreen path) can run (#759).
+ /// once the pane is no longer visible.
@MainActor
func markSettingsPaneClosed() {
isSettingsPaneOpen = false
@@ -738,16 +729,21 @@ final class MenuBarItemImageCache: @unchecked Sendable {
MenuBarItemImageCache.diagLog.debug(
"Starting live refresh (iceBar=\(nav.isIceBarPresented), search=\(nav.isSearchPresented), settings=\(nav.isSettingsPresented))"
)
+ lastSCKRefreshAt = nil
+ lastHiddenRefreshAt = nil
+ lastAlwaysHiddenRefreshAt = nil
self.liveRefreshTask = Task { [weak self] in
guard let self else { return }
await self.runLiveRefreshLoop()
}
} else {
- if self.liveRefreshTask != nil {
- MenuBarItemImageCache.diagLog.debug("Stopping live refresh")
- }
- self.liveRefreshTask?.cancel()
+ guard let task = self.liveRefreshTask else { return }
+ MenuBarItemImageCache.diagLog.debug("Stopping live refresh")
self.liveRefreshTask = nil
+ task.cancel()
+ await task.value
+ guard self.liveRefreshTask == nil else { return }
+ await MenuBarCaptureService.Connection.shared.recycle()
}
}
}
@@ -771,16 +767,13 @@ final class MenuBarItemImageCache: @unchecked Sendable {
try? await Task.sleep(for: .seconds(1))
continue
}
- // Floor the sleep so a sub-millisecond stored interval cannot
- // truncate to a zero-length sleep and spin the main actor.
- try? await Task.sleep(for: .seconds(max(interval, Self.minIconRefreshInterval)))
- guard !Task.isCancelled else { break }
let nav = appState.navigationState
let preferredDisplayID = appState.itemManager.itemCache.displayID
guard let resolvedScreen = Self.resolveScreen(preferredDisplayID: preferredDisplayID) else {
MenuBarItemImageCache.diagLog.warning("liveRefresh: no connected screens available, skipping")
+ try? await Task.sleep(for: .seconds(max(interval, Self.minIconRefreshInterval)))
continue
}
let screen = resolvedScreen.screen
@@ -822,74 +815,128 @@ final class MenuBarItemImageCache: @unchecked Sendable {
{
sections = [current]
} else {
- // No consumer visible on this tick — keep looping so the
- // Combine observer can properly cancel the task. Using
- // `break` here would race with IceBar close() where
- // currentSection is nilled before isIceBarPresented.
+ try? await Task.sleep(for: .milliseconds(50))
continue
}
- // Hoisted: these are tick-global, not per-section.
- if appState.itemManager.lastMoveOperationOccurred(within: .seconds(2)) {
- continue
- }
- if appState.itemManager.isResettingLayout {
+ if appState.itemManager.lastMoveOperationOccurred(within: .seconds(2))
+ || appState.itemManager.isResettingLayout
+ {
+ try? await Task.sleep(for: .seconds(max(interval, Self.minIconRefreshInterval)))
continue
}
let scale = screen.backingScaleFactor
+ let now = ContinuousClock.now
+ var nextWake = now + .seconds(max(interval, Self.minIconRefreshInterval))
- // Partition by capture path: visible items refresh via SCK
- // (leak-free); hidden + always-hidden items refresh via SkyLight,
- // batched into a single call per tick to amortize the irreducible
- // per-call dictionary leak.
- var offscreenBatch = [MenuBarItem]()
- var offscreenSectionLabels = [String]()
+ var hiddenItems = [MenuBarItem]()
+ var alwaysHiddenItems = [MenuBarItem]()
for section in sections {
let items = appState.itemManager.itemCache.managedItems(for: section)
guard !items.isEmpty else { continue }
-
- if section == .visible {
- // Rate-limit the on-screen SCK path, mirroring the
- // offscreen SkyLight limit below. Skips only this section
- // for this tick, so the offscreen batch still gets its
- // chance at its own cadence.
- let now = ContinuousClock.now
- if let lastSCKRefreshAt,
- now - lastSCKRefreshAt < Self.minSCKRefreshInterval
- {
- MenuBarItemImageCache.diagLog.debug("liveRefresh (SCK): skipping \(items.count) visible items, rate-limited")
- continue
- }
- lastSCKRefreshAt = now
- MenuBarItemImageCache.diagLog.debug("liveRefresh (SCK): section=\(section.logString) displayID=\(screen.displayID) backingScaleFactor=\(Double(scale)) hasNotch=\(screen.hasNotch) items=\(items.count) menuBarHeight=\(Double(screen.getMenuBarHeightEstimate()))")
- await withCapturePermit {
- await refreshImages(of: items, scale: scale, viaSCK: true)
+ guard let sectionInterval = MenuBarLiveRefreshPolicy.refreshInterval(
+ for: section,
+ target: interval
+ ) else { continue }
+ let duration = Duration.seconds(sectionInterval)
+
+ switch section {
+ case .visible:
+ if MenuBarLiveRefreshPolicy.isDue(
+ lastCaptureAt: lastSCKRefreshAt,
+ now: now,
+ interval: duration
+ ) {
+ lastSCKRefreshAt = now
+ MenuBarItemImageCache.diagLog.debug(
+ "liveRefresh (SCK): section=\(section.logString) items=\(items.count)"
+ )
+ await withCapturePermit {
+ await refreshImages(of: items, scale: scale, viaSCK: true)
+ }
}
- } else {
- offscreenBatch.append(contentsOf: items)
- offscreenSectionLabels.append("\(section.logString)=\(items.count)")
+ nextWake = min(
+ nextWake,
+ MenuBarLiveRefreshPolicy.nextDeadline(
+ capturedAt: lastSCKRefreshAt ?? now,
+ interval: duration,
+ now: ContinuousClock.now
+ )
+ )
+ case .hidden:
+ hiddenItems = items
+ case .alwaysHidden:
+ alwaysHiddenItems = items
}
}
- if !offscreenBatch.isEmpty {
- // Rate-limit the leaking SkyLight offscreen path: skip this
- // tick's batch if the last one ran too recently (defense in
- // depth for #759). Visible-section SCK captures above are
- // unaffected and keep their normal cadence.
- let now = ContinuousClock.now
- if let lastSkyLightBatchAt,
- now - lastSkyLightBatchAt < Self.minSkyLightBatchInterval
- {
- MenuBarItemImageCache.diagLog.debug("liveRefresh (SkyLight): skipping batch of \(offscreenBatch.count) offscreen items, rate-limited")
- } else {
- lastSkyLightBatchAt = now
- MenuBarItemImageCache.diagLog.debug("liveRefresh (SkyLight): batched \(offscreenBatch.count) offscreen items [\(offscreenSectionLabels.joined(separator: ", "))]")
- await withCapturePermit {
- await refreshImages(of: offscreenBatch, scale: scale)
- }
+ // One offscreen request in flight. Always Hidden goes first when
+ // both are due so Hidden at 30 fps cannot starve its 1 fps slot.
+ let hiddenInterval = MenuBarLiveRefreshPolicy.refreshInterval(for: .hidden, target: interval)
+ let alwaysInterval = MenuBarLiveRefreshPolicy.refreshInterval(for: .alwaysHidden, target: interval)
+ let hiddenDue = !hiddenItems.isEmpty
+ && hiddenInterval != nil
+ && MenuBarLiveRefreshPolicy.isDue(
+ lastCaptureAt: lastHiddenRefreshAt,
+ now: now,
+ interval: .seconds(hiddenInterval ?? interval)
+ )
+ let alwaysDue = !alwaysHiddenItems.isEmpty
+ && alwaysInterval != nil
+ && MenuBarLiveRefreshPolicy.isDue(
+ lastCaptureAt: lastAlwaysHiddenRefreshAt,
+ now: now,
+ interval: .seconds(alwaysInterval ?? 1)
+ )
+
+ switch MenuBarLiveRefreshPolicy.nextOffscreenSection(
+ hiddenDue: hiddenDue,
+ alwaysHiddenDue: alwaysDue
+ ) {
+ case .hidden:
+ lastHiddenRefreshAt = now
+ MenuBarItemImageCache.diagLog.debug("liveRefresh (capture): hidden items=\(hiddenItems.count)")
+ await withCapturePermit {
+ await refreshImages(of: hiddenItems, scale: scale)
+ }
+ case .alwaysHidden:
+ lastAlwaysHiddenRefreshAt = now
+ MenuBarItemImageCache.diagLog.debug(
+ "liveRefresh (capture): alwaysHidden items=\(alwaysHiddenItems.count)"
+ )
+ await withCapturePermit {
+ await refreshImages(of: alwaysHiddenItems, scale: scale)
}
+ case .visible, nil:
+ break
+ }
+
+ if let hiddenInterval, !hiddenItems.isEmpty {
+ nextWake = min(
+ nextWake,
+ MenuBarLiveRefreshPolicy.nextDeadline(
+ capturedAt: lastHiddenRefreshAt ?? now,
+ interval: .seconds(hiddenInterval),
+ now: ContinuousClock.now
+ )
+ )
+ }
+ if let alwaysInterval, !alwaysHiddenItems.isEmpty {
+ nextWake = min(
+ nextWake,
+ MenuBarLiveRefreshPolicy.nextDeadline(
+ capturedAt: lastAlwaysHiddenRefreshAt ?? now,
+ interval: .seconds(alwaysInterval),
+ now: ContinuousClock.now
+ )
+ )
+ }
+
+ let sleep = nextWake - ContinuousClock.now
+ if sleep > .zero {
+ try? await Task.sleep(for: sleep)
}
}
@@ -1256,6 +1303,11 @@ final class MenuBarItemImageCache: @unchecked Sendable {
scale: CGFloat,
viaSCK: Bool = false
) async {
+ if !viaSCK {
+ await refreshImagesFromCaptureService(items: items, scale: scale)
+ return
+ }
+
var windowIDs = [CGWindowID]()
var storage = [CGWindowID: (MenuBarItem, CGRect)]()
var boundsUnion = CGRect.null
@@ -1276,23 +1328,10 @@ final class MenuBarItemImageCache: @unchecked Sendable {
// Capture path: SCK is leak-free but display-bounded, so only use it
// when the caller knows all items are on-screen (visible section).
- // SkyLight is required for items positioned past the display's left
- // edge (hidden / always-hidden); both SCK filter shapes fail there
- // (display+including → -3812, desktopIndependentWindow → -3811). Each
- // SkyLight call leaks one CFMutableDictionary inside
- // SLSWindowListCreateImageFromArrayProxying; that floor stays until
- // Apple fixes SCK or the framework leak.
- let compositeImage: CGImage? = if viaSCK {
- await ScreenCapture.captureWindowsAsync(
- with: windowIDs,
- option: captureOption
- )
- } else {
- ScreenCapture.captureWindows(
- with: windowIDs,
- option: captureOption
- )
- }
+ let compositeImage = await ScreenCapture.captureWindowsAsync(
+ with: windowIDs,
+ option: captureOption
+ )
guard let compositeImage else {
MenuBarItemImageCache.diagLog.debug("refreshImages: capture failed, skipping")
return
@@ -1329,17 +1368,50 @@ final class MenuBarItemImageCache: @unchecked Sendable {
}
guard !newImages.isEmpty, !Task.isCancelled else { return }
+ await applyRefreshedImages(newImages)
+ }
- await MainActor.run { [newImages] in
- var updatedCount = 0
- for (tag, newImage) in newImages where !CapturedImage.isVisuallyEqual(self.images[tag], newImage) {
- self.images[tag] = newImage
- updateAccessOrder(for: tag)
- updatedCount += 1
- }
- if updatedCount > 0 {
- MenuBarItemImageCache.diagLog.debug("refreshImages: ✓ updated \(updatedCount)/\(newImages.count) items (visually changed)")
- }
+ /// Offscreen items go through the recyclable SkyLight helper so the
+ /// per-call dictionary leak stays out of the UI process.
+ private nonisolated func refreshImagesFromCaptureService(
+ items: [MenuBarItem],
+ scale: CGFloat
+ ) async {
+ let windowIDs = items.map(\.windowID)
+ guard !windowIDs.isEmpty else { return }
+ var storage = [CGWindowID: MenuBarItem]()
+ for item in items {
+ storage[item.windowID] = item
+ }
+ let frames = await MenuBarCaptureService.Connection.shared.capture(
+ windowIDs: windowIDs,
+ scale: scale,
+ option: captureOption
+ )
+ guard !frames.isEmpty, !Task.isCancelled else { return }
+
+ var newImages = [MenuBarItemTag: CapturedImage]()
+ for frame in frames {
+ guard let item = storage[frame.windowID],
+ let image = MenuBarCaptureService.makeImage(from: frame)
+ else { continue }
+ newImages[item.tag] = CapturedImage(cgImage: image, scale: CGFloat(frame.scale))
+ }
+ guard !newImages.isEmpty, !Task.isCancelled else { return }
+ await applyRefreshedImages(newImages)
+ }
+
+ private func applyRefreshedImages(_ newImages: [MenuBarItemTag: CapturedImage]) {
+ var updatedCount = 0
+ for (tag, newImage) in newImages where !CapturedImage.isVisuallyEqual(images[tag], newImage) {
+ images[tag] = newImage
+ updateAccessOrder(for: tag)
+ updatedCount += 1
+ }
+ if updatedCount > 0 {
+ MenuBarItemImageCache.diagLog.debug(
+ "refreshImages: ✓ updated \(updatedCount)/\(newImages.count) items (visually changed)"
+ )
}
}
diff --git a/Thaw/MenuBar/MenuBarItems/MenuBarLiveRefreshPolicy.swift b/Thaw/MenuBar/MenuBarItems/MenuBarLiveRefreshPolicy.swift
new file mode 100644
index 000000000..534fd654c
--- /dev/null
+++ b/Thaw/MenuBar/MenuBarItems/MenuBarLiveRefreshPolicy.swift
@@ -0,0 +1,68 @@
+//
+// MenuBarLiveRefreshPolicy.swift
+// Project: Thaw
+//
+// Copyright (Ice) © 2023–2025 Jordan Baird
+// Copyright (Thaw) © 2026 Toni Förster
+// Licensed under the GNU GPLv3
+
+import Foundation
+
+/// Pure live-refresh cadence and backend decisions.
+nonisolated enum MenuBarLiveRefreshPolicy {
+ /// Capture backend for a section's live icons.
+ enum Backend: Equatable {
+ /// On-screen items: ScreenCaptureKit in the app process.
+ case screenCaptureKit
+ /// Offscreen items: recyclable SkyLight capture XPC.
+ case captureService
+ }
+
+ /// Returns the live interval for `section`, or `nil` when refresh is Off.
+ static func refreshInterval(
+ for section: MenuBarSection.Name,
+ target: TimeInterval
+ ) -> TimeInterval? {
+ guard target > 0 else { return nil }
+ switch section {
+ case .visible, .hidden:
+ return max(target, MenuBarItemImageCache.minIconRefreshInterval)
+ case .alwaysHidden:
+ return max(target, MenuBarCaptureService.minAlwaysHiddenInterval)
+ }
+ }
+
+ static func backend(for section: MenuBarSection.Name) -> Backend {
+ section == .visible ? .screenCaptureKit : .captureService
+ }
+
+ /// One offscreen request in flight. When both are due, Always Hidden goes
+ /// first: `isDue` already caps it at 1 fps, so this cannot raise its rate,
+ /// and Hidden retries on the next wake instead of starving it.
+ static func nextOffscreenSection(hiddenDue: Bool, alwaysHiddenDue: Bool) -> MenuBarSection.Name? {
+ if alwaysHiddenDue { return .alwaysHidden }
+ if hiddenDue { return .hidden }
+ return nil
+ }
+
+ /// First frame is due immediately; later frames wait for `interval`.
+ static func isDue(
+ lastCaptureAt: ContinuousClock.Instant?,
+ now: ContinuousClock.Instant,
+ interval: Duration
+ ) -> Bool {
+ guard let lastCaptureAt else { return true }
+ return now - lastCaptureAt >= interval
+ }
+
+ /// Drops missed frames: if capture overran `interval`, wait a full interval
+ /// from `now` instead of queuing catch-up work.
+ static func nextDeadline(
+ capturedAt: ContinuousClock.Instant,
+ interval: Duration,
+ now: ContinuousClock.Instant
+ ) -> ContinuousClock.Instant {
+ let due = capturedAt + interval
+ return due > now ? due : now + interval
+ }
+}
diff --git a/Thaw/Resources/Localizable.xcstrings b/Thaw/Resources/Localizable.xcstrings
index 331c4ba74..6f96a8b10 100644
--- a/Thaw/Resources/Localizable.xcstrings
+++ b/Thaw/Resources/Localizable.xcstrings
@@ -23144,8 +23144,16 @@
}
}
},
+ "How often animated icons refresh in the visible section, Hidden Thaw Bar, Search, and Layout. Always Hidden is capped at 1 fps. Higher values use more CPU." : {
+ "comment" : "Description of the icon refresh rate setting in the layout settings pane."
+ },
+ "How often animated icons refresh in the visible section, Hidden Thaw Bar, Search, and Layout. Always Hidden stays at 1 fps. Higher values use more CPU." : {
+ "comment" : "Description of the icon refresh rate setting in the layout settings pane.",
+ "extractionState" : "stale"
+ },
"How often animated menu bar icons are refreshed in panels. Higher values are smoother but use more CPU." : {
"comment" : "Description of the icon refresh rate setting in the advanced settings pane.",
+ "extractionState" : "stale",
"localizations" : {
"cs" : {
"stringUnit" : {
diff --git a/Thaw/Settings/SettingsPanes/MenuBarLayoutSettingsPane.swift b/Thaw/Settings/SettingsPanes/MenuBarLayoutSettingsPane.swift
index 1b563e79f..ce8d0de3c 100644
--- a/Thaw/Settings/SettingsPanes/MenuBarLayoutSettingsPane.swift
+++ b/Thaw/Settings/SettingsPanes/MenuBarLayoutSettingsPane.swift
@@ -200,7 +200,7 @@ struct MenuBarLayoutSettingsPane: View {
maxSliderLabelWidth = max(maxSliderLabelWidth, frame.width)
}
}
- .annotation("How often animated menu bar icons are refreshed in panels. Higher values are smoother but use more CPU.")
+ .annotation("How often animated icons refresh in the visible section, Hidden Thaw Bar, Search, and Layout. Always Hidden is capped at 1 fps. Higher values use more CPU.")
}
/// The name of the display whose layout the bars below are showing.
diff --git a/Thaw/Utilities/ScreenCapture.swift b/Thaw/Utilities/ScreenCapture.swift
index fb4d383c1..91ef8e989 100644
--- a/Thaw/Utilities/ScreenCapture.swift
+++ b/Thaw/Utilities/ScreenCapture.swift
@@ -76,17 +76,16 @@ nonisolated enum ScreenCapture {
// MARK: Capture Window(s)
// NOTE: The synchronous captureWindows / captureWindow below intentionally
- // route through the deprecated SkyLight private API
- // (SLWindowListCreateImageFromArray) for the menu-bar refresh path. On
- // macOS 26 SCShareableContent.excludingDesktopWindows(_: onScreenWindowsOnly:
- // false) *does* enumerate offscreen menu-bar overflow items, but SCK
- // capture rejects them: SCContentFilter(display: including:) returns error
- // -3812 (sourceRect outside display bounds) and SCContentFilter(
- // desktopIndependentWindow:) returns -3811 (stream start failure). SkyLight
- // is the only public API on macOS 26 that can capture status-item windows
- // positioned at large negative x. It leaks one CFMutableDictionary per
- // call inside SLSWindowListCreateImageFromArrayProxying; that's a system
- // bug awaiting an Apple fix.
+ // route through SkyLight's private API (SLWindowListCreateImageFromArray)
+ // for offscreen menu-bar items. On macOS 26 SCShareableContent enumerates
+ // those windows, but SCK capture rejects them: SCContentFilter(display:
+ // including:) returns error -3812 (sourceRect outside display bounds) and
+ // SCContentFilter(desktopIndependentWindow:) returns -3811 (stream start
+ // failure). SkyLight is the only API on macOS 26 that can capture status-item
+ // windows positioned at large negative x. It leaks one CFMutableDictionary
+ // per call inside SLSWindowListCreateImageFromArrayProxying; live Hidden
+ // refresh therefore runs that path in MenuBarCaptureService, which exits
+ // after a capture budget so the leak can be reclaimed.
//
// The async captureWindowsAsync / captureWindowAsync below route through
// ScreenCaptureKit and are leak-free. Use those for any capture whose
diff --git a/ThawTests/MenuBar/Items/CapturedImageVisualEqualityTests.swift b/ThawTests/MenuBar/Items/CapturedImageVisualEqualityTests.swift
new file mode 100644
index 000000000..fbfdf27f9
--- /dev/null
+++ b/ThawTests/MenuBar/Items/CapturedImageVisualEqualityTests.swift
@@ -0,0 +1,74 @@
+//
+// CapturedImageVisualEqualityTests.swift
+// Project: Thaw
+//
+// Copyright (Ice) © 2023–2025 Jordan Baird
+// Copyright (Thaw) © 2026 Toni Förster
+// Licensed under the GNU GPLv3
+
+import CoreGraphics
+import Testing
+@testable import Thaw
+
+@Suite("Captured image visual equality")
+struct CapturedImageVisualEqualityTests {
+ @Test("Nil pairs are equal; mixed nil is not")
+ func nilPairs() throws {
+ #expect(MenuBarItemImageCache.CapturedImage.isVisuallyEqual(nil, nil))
+ let image = MenuBarItemImageCache.CapturedImage(
+ cgImage: try makeOpaqueImage(width: 2, height: 2),
+ scale: 1
+ )
+ #expect(!MenuBarItemImageCache.CapturedImage.isVisuallyEqual(image, nil))
+ #expect(!MenuBarItemImageCache.CapturedImage.isVisuallyEqual(nil, image))
+ }
+
+ @Test("Pointer-equal images are visually equal")
+ func pointerEquality() throws {
+ let cgImage = try makeOpaqueImage(width: 4, height: 4)
+ let a = MenuBarItemImageCache.CapturedImage(cgImage: cgImage, scale: 2)
+ let b = MenuBarItemImageCache.CapturedImage(cgImage: cgImage, scale: 2)
+ #expect(MenuBarItemImageCache.CapturedImage.isVisuallyEqual(a, b))
+ }
+
+ @Test("Identical pixels at the same scale are visually equal")
+ func identicalPixels() throws {
+ let a = MenuBarItemImageCache.CapturedImage(
+ cgImage: try makeOpaqueImage(width: 3, height: 2),
+ scale: 1
+ )
+ let b = MenuBarItemImageCache.CapturedImage(
+ cgImage: try makeOpaqueImage(width: 3, height: 2),
+ scale: 1
+ )
+ #expect(MenuBarItemImageCache.CapturedImage.isVisuallyEqual(a, b))
+ }
+
+ @Test("A scale or pixel mismatch is not visually equal")
+ func mismatch() throws {
+ let small = try makeOpaqueImage(width: 2, height: 2)
+ let large = try makeOpaqueImage(width: 3, height: 2)
+ let a = MenuBarItemImageCache.CapturedImage(cgImage: small, scale: 1)
+ let b = MenuBarItemImageCache.CapturedImage(cgImage: large, scale: 1)
+ let scaled = MenuBarItemImageCache.CapturedImage(cgImage: small, scale: 2)
+ #expect(!MenuBarItemImageCache.CapturedImage.isVisuallyEqual(a, b))
+ #expect(!MenuBarItemImageCache.CapturedImage.isVisuallyEqual(a, scaled))
+
+ let encoded = try #require(MenuBarCaptureService.encodeBGRA(small))
+ var pixels = encoded.pixels
+ pixels[0] ^= 0xFF
+ let mutatedFrame = MenuBarCaptureService.Frame(
+ windowID: 1,
+ width: small.width,
+ height: small.height,
+ bytesPerRow: encoded.bytesPerRow,
+ scale: 1,
+ pixels: pixels
+ )
+ let mutated = MenuBarItemImageCache.CapturedImage(
+ cgImage: try #require(MenuBarCaptureService.makeImage(from: mutatedFrame)),
+ scale: 1
+ )
+ #expect(!MenuBarItemImageCache.CapturedImage.isVisuallyEqual(a, mutated))
+ }
+}
diff --git a/ThawTests/MenuBar/Items/MenuBarCaptureServiceTests.swift b/ThawTests/MenuBar/Items/MenuBarCaptureServiceTests.swift
new file mode 100644
index 000000000..4a2c3ba3c
--- /dev/null
+++ b/ThawTests/MenuBar/Items/MenuBarCaptureServiceTests.swift
@@ -0,0 +1,181 @@
+//
+// MenuBarCaptureServiceTests.swift
+// Project: Thaw
+//
+// Copyright (Ice) © 2023–2025 Jordan Baird
+// Copyright (Thaw) © 2026 Toni Förster
+// Licensed under the GNU GPLv3
+
+import CoreGraphics
+import Foundation
+import Testing
+@testable import Thaw
+
+@Suite("Menu bar capture service")
+struct MenuBarCaptureServiceTests {
+ @Test("The service name is the reverse-DNS Mach service identifier")
+ func serviceName() {
+ #expect(MenuBarCaptureService.name == "com.stonerl.Thaw.MenuBarCaptureService")
+ }
+
+ @Test("A capture batch request survives a round trip")
+ func captureBatchRequestRoundTrip() throws {
+ let original = MenuBarCaptureService.Request.captureBatch(
+ MenuBarCaptureService.CaptureBatchRequest(
+ requestID: 7,
+ windowIDs: [12, 34],
+ optionRawValue: 1,
+ expectedScale: 2
+ )
+ )
+ let data = try JSONEncoder().encode(original)
+ let decoded = try JSONDecoder().decode(MenuBarCaptureService.Request.self, from: data)
+ #expect(decoded == original)
+ }
+
+ @Test("A capture batch response keeps frames across a round trip")
+ func captureBatchResponseRoundTrip() throws {
+ let frame = MenuBarCaptureService.Frame(
+ windowID: 99,
+ width: 2,
+ height: 1,
+ bytesPerRow: 8,
+ scale: 2,
+ pixels: Data([1, 2, 3, 4, 5, 6, 7, 8])
+ )
+ let original = MenuBarCaptureService.Response.captureBatch(
+ MenuBarCaptureService.CaptureBatchResponse(
+ requestID: 3,
+ instanceID: 11,
+ frames: [frame]
+ )
+ )
+ let data = try JSONEncoder().encode(original)
+ let decoded = try JSONDecoder().decode(MenuBarCaptureService.Response.self, from: data)
+ #expect(decoded == original)
+ }
+
+ @Test("Window ID validation drops zeros, duplicates, strangers, and overflow")
+ func windowIDValidation() {
+ let allowed: Set = [1, 2, 3]
+ #expect(
+ MenuBarCaptureService.validatedWindowIDs([0, 1, 1, 2, 99, 3], allowed: allowed)
+ == [1, 2, 3]
+ )
+ #expect(MenuBarCaptureService.validatedWindowIDs([], allowed: allowed).isEmpty)
+
+ let tooMany = (1 ... 80).map { CGWindowID($0) }
+ let manyAllowed = Set(tooMany)
+ #expect(
+ MenuBarCaptureService.validatedWindowIDs(tooMany, allowed: manyAllowed).count
+ == MenuBarCaptureService.maxWindowCount
+ )
+ }
+
+ @Test("BGRA frame validation rejects empty, oversized, and short buffers")
+ func bgraValidation() {
+ #expect(
+ MenuBarCaptureService.isValidBGRAFrame(
+ width: 2,
+ height: 2,
+ bytesPerRow: 8,
+ pixelCount: 16
+ )
+ )
+ #expect(
+ !MenuBarCaptureService.isValidBGRAFrame(
+ width: 0,
+ height: 2,
+ bytesPerRow: 8,
+ pixelCount: 16
+ )
+ )
+ #expect(
+ !MenuBarCaptureService.isValidBGRAFrame(
+ width: 2,
+ height: 2,
+ bytesPerRow: 4,
+ pixelCount: 16
+ )
+ )
+ #expect(
+ !MenuBarCaptureService.isValidBGRAFrame(
+ width: 2,
+ height: 2,
+ bytesPerRow: 8,
+ pixelCount: 8
+ )
+ )
+ #expect(
+ !MenuBarCaptureService.isValidBGRAFrame(
+ width: Bridging.maximumCaptureDimension + 1,
+ height: 1,
+ bytesPerRow: (Bridging.maximumCaptureDimension + 1) * 4,
+ pixelCount: (Bridging.maximumCaptureDimension + 1) * 4
+ )
+ )
+ }
+
+ @Test("A stale capture response is dropped")
+ func staleResponseIsDropped() {
+ let response = MenuBarCaptureService.Response.captureBatch(
+ MenuBarCaptureService.CaptureBatchResponse(
+ requestID: 2,
+ instanceID: 1,
+ frames: []
+ )
+ )
+ #expect(MenuBarCaptureService.acceptedResponse(requestID: 1, response: response) == nil)
+ #expect(MenuBarCaptureService.acceptedResponse(requestID: 2, response: response) != nil)
+ #expect(MenuBarCaptureService.acceptedResponse(requestID: 2, response: .start) == nil)
+ }
+
+ @Test("Recycle trips at the capture budget")
+ func recycleBudget() {
+ #expect(!MenuBarCaptureService.shouldRecycle(captureCount: 1_799, budget: 1_800))
+ #expect(MenuBarCaptureService.shouldRecycle(captureCount: 1_800, budget: 1_800))
+ #expect(MenuBarCaptureService.shouldRecycle(captureCount: 1_801, budget: 1_800))
+ }
+
+ @Test("BGRA encode and decode round-trips pixels")
+ func bgraRoundTrip() throws {
+ let source = try makeOpaqueImage(width: 4, height: 3)
+ let encoded = try #require(MenuBarCaptureService.encodeBGRA(source))
+ let frame = MenuBarCaptureService.Frame(
+ windowID: 1,
+ width: source.width,
+ height: source.height,
+ bytesPerRow: encoded.bytesPerRow,
+ scale: 2,
+ pixels: encoded.pixels
+ )
+ let decoded = try #require(MenuBarCaptureService.makeImage(from: frame))
+ #expect(decoded.width == source.width)
+ #expect(decoded.height == source.height)
+ let roundTrip = try #require(MenuBarCaptureService.encodeBGRA(decoded))
+ #expect(roundTrip.pixels == encoded.pixels)
+ }
+
+ @Test("A fully transparent BGRA buffer is detected")
+ func transparentBGRA() {
+ let pixels = Data(repeating: 0, count: 16)
+ #expect(
+ MenuBarCaptureService.isFullyTransparentBGRA(
+ pixels: pixels,
+ width: 2,
+ height: 2,
+ bytesPerRow: 8
+ )
+ )
+ var opaque = pixels
+ opaque[3] = 255
+ #expect(
+ !MenuBarCaptureService.isFullyTransparentBGRA(
+ pixels: opaque,
+ width: 2,
+ height: 2,
+ bytesPerRow: 8
+ )
+ )
+ }
+}
diff --git a/ThawTests/MenuBar/Items/MenuBarLiveRefreshPolicyTests.swift b/ThawTests/MenuBar/Items/MenuBarLiveRefreshPolicyTests.swift
new file mode 100644
index 000000000..287ae80bd
--- /dev/null
+++ b/ThawTests/MenuBar/Items/MenuBarLiveRefreshPolicyTests.swift
@@ -0,0 +1,137 @@
+//
+// MenuBarLiveRefreshPolicyTests.swift
+// Project: Thaw
+//
+// Copyright (Ice) © 2023–2025 Jordan Baird
+// Copyright (Thaw) © 2026 Toni Förster
+// Licensed under the GNU GPLv3
+
+import Testing
+@testable import Thaw
+
+@Suite("Menu bar live refresh policy")
+struct MenuBarLiveRefreshPolicyTests {
+ @Test("Off disables every section")
+ func offDisablesAllSections() {
+ for section in MenuBarSection.Name.allCases {
+ #expect(MenuBarLiveRefreshPolicy.refreshInterval(for: section, target: 0) == nil)
+ }
+ }
+
+ @Test("Hidden and visible follow the target interval")
+ func hiddenAndVisibleFollowTarget() {
+ let target = 1.0 / 30.0
+ #expect(MenuBarLiveRefreshPolicy.refreshInterval(for: .visible, target: target) == target)
+ #expect(MenuBarLiveRefreshPolicy.refreshInterval(for: .hidden, target: target) == target)
+ #expect(
+ MenuBarLiveRefreshPolicy.refreshInterval(for: .alwaysHidden, target: target)
+ == MenuBarCaptureService.minAlwaysHiddenInterval
+ )
+ }
+
+ @Test("Always Hidden never goes faster than 1 fps")
+ func alwaysHiddenCeiling() {
+ #expect(MenuBarLiveRefreshPolicy.refreshInterval(for: .alwaysHidden, target: 0.2) == 1)
+ #expect(MenuBarLiveRefreshPolicy.refreshInterval(for: .alwaysHidden, target: 2) == 2)
+ }
+
+ @Test("Visible uses ScreenCaptureKit; offscreen uses the capture service")
+ func backends() {
+ #expect(MenuBarLiveRefreshPolicy.backend(for: .visible) == .screenCaptureKit)
+ #expect(MenuBarLiveRefreshPolicy.backend(for: .hidden) == .captureService)
+ #expect(MenuBarLiveRefreshPolicy.backend(for: .alwaysHidden) == .captureService)
+ }
+
+ @Test("The first frame is due immediately")
+ func firstFrameIsDue() {
+ let now = ContinuousClock.now
+ #expect(
+ MenuBarLiveRefreshPolicy.isDue(
+ lastCaptureAt: nil,
+ now: now,
+ interval: .milliseconds(33)
+ )
+ )
+ #expect(
+ !MenuBarLiveRefreshPolicy.isDue(
+ lastCaptureAt: now,
+ now: now,
+ interval: .seconds(1)
+ )
+ )
+ #expect(
+ MenuBarLiveRefreshPolicy.isDue(
+ lastCaptureAt: now,
+ now: now + .seconds(1),
+ interval: .seconds(1)
+ )
+ )
+ }
+
+ @Test("An overrun drops missed frames instead of queuing")
+ func overrunDropsMissedFrames() {
+ let capturedAt = ContinuousClock.now
+ let now = capturedAt + .milliseconds(80)
+ let deadline = MenuBarLiveRefreshPolicy.nextDeadline(
+ capturedAt: capturedAt,
+ interval: .milliseconds(33),
+ now: now
+ )
+ #expect(deadline == now + .milliseconds(33))
+ }
+
+ @Test("When both are due, Always Hidden goes first so Hidden cannot starve it")
+ func alwaysHiddenNotStarvedWhenBothDue() {
+ #expect(
+ MenuBarLiveRefreshPolicy.nextOffscreenSection(hiddenDue: true, alwaysHiddenDue: true)
+ == .alwaysHidden
+ )
+ #expect(
+ MenuBarLiveRefreshPolicy.nextOffscreenSection(hiddenDue: true, alwaysHiddenDue: false)
+ == .hidden
+ )
+ #expect(
+ MenuBarLiveRefreshPolicy.nextOffscreenSection(hiddenDue: false, alwaysHiddenDue: true)
+ == .alwaysHidden
+ )
+ #expect(
+ MenuBarLiveRefreshPolicy.nextOffscreenSection(hiddenDue: false, alwaysHiddenDue: false)
+ == nil
+ )
+ }
+
+ @Test("Sustained Hidden due-ticks still serve Always Hidden once per cycle")
+ func sustainedHiddenStillServesAlwaysHidden() {
+ var hiddenCaptures = 0
+ var alwaysCaptures = 0
+ var alwaysHiddenDue = true
+ for _ in 0 ..< 30 {
+ switch MenuBarLiveRefreshPolicy.nextOffscreenSection(
+ hiddenDue: true,
+ alwaysHiddenDue: alwaysHiddenDue
+ ) {
+ case .alwaysHidden:
+ alwaysCaptures += 1
+ alwaysHiddenDue = false
+ case .hidden:
+ hiddenCaptures += 1
+ case .visible, nil:
+ break
+ }
+ }
+ #expect(alwaysCaptures == 1)
+ #expect(hiddenCaptures == 29)
+ }
+
+ @Test("A fast capture waits out the remainder of the interval")
+ func fastCaptureSubtractsCaptureTime() {
+ let capturedAt = ContinuousClock.now
+ let now = capturedAt + .milliseconds(10)
+ let deadline = MenuBarLiveRefreshPolicy.nextDeadline(
+ capturedAt: capturedAt,
+ interval: .milliseconds(33),
+ now: now
+ )
+ #expect(deadline == capturedAt + .milliseconds(33))
+ }
+}
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index b89cf0d11..d7b666e3d 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -24,6 +24,7 @@ profiles, and customizes menu bar appearance. It is a maintained fork of
| `Thaw/` | Main application target (UI, menu bar logic, settings, events, permissions) |
| `Shared/` | Code shared between the app and helper processes (bridging, XPC client types, utilities) |
| `MenuBarItemService/` | XPC helper process for menu-bar item source PID resolution and related work off the main app |
+| `MenuBarCaptureService/` | Recyclable XPC helper that runs SkyLight offscreen icon capture so the per-call dictionary leak stays out of the UI process |
| `ThawCtl/` | Small SwiftPM CLI / control utilities |
| `ThawTests/` | XCTest suite run in CI |
| `docs/` | User/developer documentation (e.g. URI schemes) |
@@ -48,6 +49,8 @@ External dependencies are declared via Swift Package Manager and locked in
│ ▼ │
│ MenuBarItemService.xpc │
│ (source PID cache / listener) │
+│ MenuBarCaptureService.xpc │
+│ (offscreen SkyLight capture; recycled) │
└─────────────────────────────────────────────────────────────┘
│ │
▼ ▼
@@ -77,6 +80,20 @@ small Codable request/response surface (`start`, `configureLogging`,
`sourcePID` / `sourcePIDs`). Logging to a shared diagnostic file is configured
by the main app after launch.
+### `MenuBarCaptureService` (XPC)
+
+A second Application XPC (`com.stonerl.Thaw.MenuBarCaptureService`) captures
+offscreen Hidden / Always Hidden status-item windows via SkyLight. Each
+successful `SLWindowListCreateImageFromArray` call leaks a small dictionary in
+the caller; the helper exits after a capture budget (and when the last live
+consumer closes) so that growth can be reclaimed. Requests carry a request ID
+and window IDs only; the helper recomputes bounds, accepts only menu-bar item
+windows, and returns cropped premultiplied-BGRA frames. Visible-section icons
+stay on ScreenCaptureKit in the app. Always Hidden is capped at 1 fps;
+Hidden follows the icon refresh slider. This dictionary leak is in the
+capture caller (commit `0e045faf`); it is separate from the Core Animation
+fence-port leak tracked as issue #933.
+
### External interfaces
| Interface | Direction | Notes |
diff --git a/docs/ASSURANCE_CASE.md b/docs/ASSURANCE_CASE.md
index 3a6e3b784..a6c912133 100644
--- a/docs/ASSURANCE_CASE.md
+++ b/docs/ASSURANCE_CASE.md
@@ -75,13 +75,13 @@ of Apple’s notarization infrastructure.
┌──────────────────┼──────────────────┐
▼ ▼ ▼
MenuBarItemService macOS TCC / AX Sparkle (HTTPS)
- (XPC) WindowServer EdDSA verify
+ MenuBarCaptureService WindowServer EdDSA verify
```
| Boundary | What crosses | Controls |
| --- | --- | --- |
| External URL → settings | `thaw://set` etc. | Allowlisted keys; range checks; sender whitelist + `SecCode` Team ID binding |
-| App ↔ XPC helper | Codable requests | Fixed service name; limited request vocabulary |
+| App ↔ XPC helper | Codable requests | Fixed service names; limited request vocabulary; capture requests accept only menu-bar window IDs, validated bounds, and BGRA size limits; same-team peer requirement when signed |
| App ↔ network | Appcast / downloads | HTTPS; Sparkle EdDSA (`SUPublicEDKey`); Apple notarization on shipped builds |
| App ↔ OS | AX / capture | Explicit TCC; features degrade without grants |
@@ -89,11 +89,11 @@ of Apple’s notarization infrastructure.
| Principle | How Thaw applies it |
| --- | --- |
-| **Economy of mechanism** | Small XPC protocol; URI settings limited to known keys |
+| **Economy of mechanism** | Small XPC protocols; URI settings limited to known keys |
| **Fail-safe defaults** | Settings URI mutation denied unless allowlisted; permissions off until user grants |
| **Complete mediation** | Each settings URI request re-checks whitelist + signature; numeric keys clamped to ranges |
| **Open design** | GPL-3.0; public repo; security policy and this assurance case |
-| **Separation of privilege** | XPC helper separated from UI; release signing keys not on the public download host as long-lived plaintext |
+| **Separation of privilege** | XPC helpers separated from UI (PID lookup vs recyclable SkyLight capture); release signing keys not on the public download host as long-lived plaintext |
| **Least privilege** | Requests only needed TCC rights; no root requirement for normal use |
| **Least common mechanism** | Per-user install; no shared multi-user daemon for core features |
| **Psychological acceptability** | Clear permission prompts; authorize dialog lists what automation can do |
@@ -120,7 +120,11 @@ defect classes before merge.
1. Accessibility by design can manipulate other apps’ menu bar items — users
must trust Thaw similarly to other AX utilities.
2. Private/undocumented WindowServer interactions increase compatibility and
- maintenance risk across macOS versions.
+ maintenance risk across macOS versions. Offscreen icon capture uses SkyLight
+ in `MenuBarCaptureService` rather than the UI process; the helper is recycled
+ after a capture budget because each `SLWindowListCreateImageFromArray` call
+ leaks a small dictionary in the caller. That contains growth in Thaw but does
+ not remove the leak from the platform API.
3. Update delivery depends on a GitHub Pages path. The Sparkle appcast is
served from a `github.io` origin, which does **not** redirect across a
repository owner transfer, so builds released before the move to
@@ -165,6 +169,7 @@ defect classes before merge.
| Date | Note |
| --- | --- |
+| 2026-08-15 | Documented `MenuBarCaptureService`: same-team XPC, menu-bar-only window IDs, BGRA size limits, and helper recycle to contain the SkyLight dictionary leak |
| 2026-07-27 | Initial version from `development` for OpenSSF docs |
| 2026-07-27 | Deferred Silver coverage claim; measured ~44% |
| 2026-07-31 | Claimed Silver `test_statement_coverage80`; measured 80.8% after migrating the suite to Swift Testing and documenting the exclusion set. Corrected the SonarCloud project key to `thaw-app_Thaw` |
diff --git a/scripts/generate-swiftlint-inputs.sh b/scripts/generate-swiftlint-inputs.sh
index ea6ab0fab..1b078384a 100755
--- a/scripts/generate-swiftlint-inputs.sh
+++ b/scripts/generate-swiftlint-inputs.sh
@@ -12,7 +12,7 @@ cd "$root"
# on the Linux CI runner; locale collation would otherwise reorder entries that
# differ only in case and fail the verification step.
git ls-files '*.swift' \
- | grep -E '^(MenuBarItemService|Shared|Thaw)/' \
+ | grep -E '^(MenuBarCaptureService|MenuBarItemService|Shared|Thaw)/' \
| LC_ALL=C sort \
| sed 's|^|$(SRCROOT)/|' \
> "$output"
diff --git a/scripts/swiftlint-inputs.xcfilelist b/scripts/swiftlint-inputs.xcfilelist
index 8849d665f..a4b726ba6 100644
--- a/scripts/swiftlint-inputs.xcfilelist
+++ b/scripts/swiftlint-inputs.xcfilelist
@@ -1,8 +1,11 @@
+$(SRCROOT)/MenuBarCaptureService/Listener.swift
+$(SRCROOT)/MenuBarCaptureService/main.swift
$(SRCROOT)/MenuBarItemService/Listener.swift
$(SRCROOT)/MenuBarItemService/SourcePIDCache.swift
$(SRCROOT)/MenuBarItemService/main.swift
$(SRCROOT)/Shared/Bridging/Bridging.swift
$(SRCROOT)/Shared/Bridging/Shims.swift
+$(SRCROOT)/Shared/Services/MenuBarCaptureService.swift
$(SRCROOT)/Shared/Services/MenuBarItemService.swift
$(SRCROOT)/Shared/Utilities/AXHelpers.swift
$(SRCROOT)/Shared/Utilities/CodeSigningInfo.swift
@@ -61,6 +64,7 @@ $(SRCROOT)/Thaw/MenuBar/MenuBarItems/AXItemActivator.swift
$(SRCROOT)/Thaw/MenuBar/MenuBarItems/ClickReactionVerifier.swift
$(SRCROOT)/Thaw/MenuBar/MenuBarItems/LayoutReconciler.swift
$(SRCROOT)/Thaw/MenuBar/MenuBarItems/LayoutSolver.swift
+$(SRCROOT)/Thaw/MenuBar/MenuBarItems/MenuBarCaptureServiceConnection.swift
$(SRCROOT)/Thaw/MenuBar/MenuBarItems/MenuBarItem+Enumeration.swift
$(SRCROOT)/Thaw/MenuBar/MenuBarItems/MenuBarItem+Ordering.swift
$(SRCROOT)/Thaw/MenuBar/MenuBarItems/MenuBarItem.swift
@@ -69,6 +73,7 @@ $(SRCROOT)/Thaw/MenuBar/MenuBarItems/MenuBarItemImageCache.swift
$(SRCROOT)/Thaw/MenuBar/MenuBarItems/MenuBarItemManager.swift
$(SRCROOT)/Thaw/MenuBar/MenuBarItems/MenuBarItemServiceConnection.swift
$(SRCROOT)/Thaw/MenuBar/MenuBarItems/MenuBarItemTag.swift
+$(SRCROOT)/Thaw/MenuBar/MenuBarItems/MenuBarLiveRefreshPolicy.swift
$(SRCROOT)/Thaw/MenuBar/MenuBarItems/PendingLedger.swift
$(SRCROOT)/Thaw/MenuBar/MenuBarItems/StaleIdentifierLedger.swift
$(SRCROOT)/Thaw/MenuBar/MenuBarManager.swift
diff --git a/sonar-project.properties b/sonar-project.properties
index 4c83486bf..ec64e617a 100644
--- a/sonar-project.properties
+++ b/sonar-project.properties
@@ -6,7 +6,7 @@ sonar.organization=thaw-app
sonar.projectName = Thaw
sonar.projectVersion = 2.0.0-rc.2
-sonar.sources = Thaw,Shared,MenuBarItemService
+sonar.sources = Thaw,Shared,MenuBarItemService,MenuBarCaptureService
sonar.tests = ThawTests
sonar.exclusions = \
@@ -72,6 +72,7 @@ sonar.coverage.exclusions = \
Thaw/Main/IceApp.swift,\
Thaw/Main/Updates.swift,\
MenuBarItemService/*.swift,\
+ MenuBarCaptureService/*.swift,\
\
Thaw/Events/*.swift,\
Thaw/Hotkeys/HotkeyRegistry.swift,\
@@ -97,6 +98,7 @@ sonar.coverage.exclusions = \
Thaw/MenuBar/Spacing/MenuBarItemSpacingManager.swift,\
Thaw/MenuBar/MenuBarItems/MenuBarItemImageCache.swift,\
Thaw/MenuBar/MenuBarItems/MenuBarItemServiceConnection.swift,\
+ Thaw/MenuBar/MenuBarItems/MenuBarCaptureServiceConnection.swift,\
Thaw/MenuBar/ControlItem/ControlItem.swift,\
Thaw/MenuBar/Search/*.swift,\
Thaw/MenuBar/Appearance/MenuBarAppearanceManager.swift,\