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,\