diff --git a/TugboatCaptureRuntime.podspec b/TugboatCaptureRuntime.podspec index c398b3e..bd9ed0c 100644 --- a/TugboatCaptureRuntime.podspec +++ b/TugboatCaptureRuntime.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'TugboatCaptureRuntime' - s.version = '0.1.0' + s.version = '0.1.1' s.summary = 'Tugboat native CPU screenshot capture for Apple platforms.' s.description = <<-DESC Experimental Apple capture runtime. Renders the live Flutter layer into a diff --git a/docs/releases/compatibility.md b/docs/releases/compatibility.md index 693fb68..d42a225 100644 --- a/docs/releases/compatibility.md +++ b/docs/releases/compatibility.md @@ -2,6 +2,7 @@ | Adapter | Adapter version | Native runtime | | --- | --- | --- | +| Apple `TugboatCaptureRuntime` | 0.1.1 | Rejects transparent and near-white captures and validates explicit view-hierarchy capture before encoding. | | Flutter `tugboat` | 0.8.16 | Same hosted runtimes as 0.8.15. iOS plugin looks up `registrar.viewController` at runtime so Flutter 3.35 hosts compile. | | Flutter `tugboat` | 0.8.15 | Android `com.gettugboat.sdk:capture-runtime:0.1.0` from Maven Central. Apple `TugboatCaptureRuntime` `0.1.0` from CocoaPods trunk. Plugin iOS floor 15. | | Flutter `tugboat` | 0.8.14 | Android `com.gettugboat.sdk:capture-runtime:0.1.0` from Maven Central. Apple `TugboatCaptureRuntime` 0.1.x compiled from monorepo sources (unpublished CocoaPod is not required). Plugin iOS floor 12; native capture still reports unsupported below iOS 15. | diff --git a/platforms/apple/Sources/TugboatCaptureRuntime/CaptureRuntime.swift b/platforms/apple/Sources/TugboatCaptureRuntime/CaptureRuntime.swift index e733724..dea5510 100644 --- a/platforms/apple/Sources/TugboatCaptureRuntime/CaptureRuntime.swift +++ b/platforms/apple/Sources/TugboatCaptureRuntime/CaptureRuntime.swift @@ -74,14 +74,13 @@ public final class CaptureRuntime { } let started = DispatchTime.now() - let bitmap = drawOnMain(view: view, request: request) + let drawOutcome = drawOnMain(view: view, request: request) let copyTimings = CaptureTimings(surfaceCopyMicros: elapsedMicros(from: started)) if let early = haltIfNeeded(request, started, copyTimings) { return early } - guard let bitmap else { - let status: CaptureStatus = - view.bounds.width <= 0 || view.bounds.height <= 0 ? .surfaceUnavailable : .pixelCopyFailed + guard let bitmap = drawOutcome.bitmap else { + let status: CaptureStatus = drawOutcome.hasSurface ? .pixelCopyFailed : .surfaceUnavailable return reply(request, status, timings: copyTimings) } @@ -102,7 +101,7 @@ public final class CaptureRuntime { return reply( request, .skippedByDHash, - coverage: coverage, + coverage: bitmap.coverage, width: bitmap.width, height: bitmap.height, dHash: processed.dHash, @@ -131,7 +130,7 @@ public final class CaptureRuntime { return reply( request, .ok, - coverage: coverage, + coverage: bitmap.coverage, jpeg: jpeg, width: bitmap.width, height: bitmap.height, @@ -142,9 +141,16 @@ public final class CaptureRuntime { ) } - private func drawOnMain(view: UIView, request: CaptureRequest) -> NativeBitmap? { + private func drawOnMain( + view: UIView, + request: CaptureRequest + ) -> (bitmap: NativeBitmap?, hasSurface: Bool) { var bitmap: NativeBitmap? + var hasSurface = false let draw = { + view.layoutIfNeeded() + hasSurface = view.bounds.width > 0 && view.bounds.height > 0 + guard hasSurface else { return } bitmap = AppleViewCapture.capture( view: view, pixelWidth: request.pixelWidth, @@ -157,7 +163,7 @@ public final class CaptureRuntime { } else { DispatchQueue.main.sync(execute: draw) } - return bitmap + return (bitmap, hasSurface) } private func haltIfNeeded( diff --git a/platforms/apple/Sources/TugboatCaptureRuntime/Internal/AppleViewCapture.swift b/platforms/apple/Sources/TugboatCaptureRuntime/Internal/AppleViewCapture.swift index 104eaaa..5d99c80 100644 --- a/platforms/apple/Sources/TugboatCaptureRuntime/Internal/AppleViewCapture.swift +++ b/platforms/apple/Sources/TugboatCaptureRuntime/Internal/AppleViewCapture.swift @@ -6,6 +6,7 @@ final class NativeBitmap { let height: Int let stride: Int let pixels: UnsafeMutableRawPointer + let coverage: CaptureCoverage let incomplete: Bool private var context: CGContext? @@ -15,16 +16,41 @@ final class NativeBitmap { stride: Int, pixels: UnsafeMutableRawPointer, context: CGContext, + coverage: CaptureCoverage, incomplete: Bool ) { self.width = width self.height = height self.stride = stride self.pixels = pixels + self.coverage = coverage self.context = context self.incomplete = incomplete } + /// Sample both coverage modes for visible, non-near-white content before + /// publishing. This rejects untouched buffers and blank Flutter Metal frames + /// with constant work at large capture sizes. + var hasCapturedContent: Bool { + let bytes = pixels.assumingMemoryBound(to: UInt8.self) + let totalPixels = width * height + let sampleLimit = 4_096 + let step = max(1, (totalPixels + sampleLimit - 1) / sampleLimit) + var linearIndex = 0 + while linearIndex < totalPixels { + let y = linearIndex / width + let x = linearIndex % width + let pixel = bytes.advanced(by: y * stride + x * 4) + let isVisible = pixel[3] != 0 + let isNearWhite = pixel[0] >= 250 && pixel[1] >= 250 && pixel[2] >= 250 + if isVisible && !isNearWhite { + return true + } + linearIndex += step + } + return false + } + func jpegData() -> Data? { guard let context else { return nil } return JpegEncoder.encode(from: context) @@ -42,6 +68,59 @@ enum AppleViewCapture { pixelWidth: Int, pixelHeight: Int, coverage: CaptureCoverage + ) -> NativeBitmap? { + switch coverage { + case .engineSurface: + guard let bitmap = captureOnce( + view: view, + pixelWidth: pixelWidth, + pixelHeight: pixelHeight, + coverage: .engineSurface, + afterScreenUpdates: false + ), bitmap.hasCapturedContent else { return nil } + return bitmap + case .viewHierarchy: + return captureHierarchy( + view: view, + pixelWidth: pixelWidth, + pixelHeight: pixelHeight + ) + } + } + + private static func captureHierarchy( + view: UIView, + pixelWidth: Int, + pixelHeight: Int + ) -> NativeBitmap? { + var incompleteCapture: NativeBitmap? + for afterScreenUpdates in [false, true] { + guard + let bitmap = captureOnce( + view: view, + pixelWidth: pixelWidth, + pixelHeight: pixelHeight, + coverage: .viewHierarchy, + afterScreenUpdates: afterScreenUpdates + ), + bitmap.hasCapturedContent + else { + continue + } + if !bitmap.incomplete { + return bitmap + } + incompleteCapture = bitmap + } + return incompleteCapture + } + + private static func captureOnce( + view: UIView, + pixelWidth: Int, + pixelHeight: Int, + coverage: CaptureCoverage, + afterScreenUpdates: Bool ) -> NativeBitmap? { let bounds = view.bounds if bounds.width <= 0 || bounds.height <= 0 { @@ -75,13 +154,20 @@ enum AppleViewCapture { x: CGFloat(pixelWidth) / bounds.width, y: -CGFloat(pixelHeight) / bounds.height ) - let incomplete = render(view: view, bounds: bounds, context: context, coverage: coverage) + let incomplete = render( + view: view, + bounds: bounds, + context: context, + coverage: coverage, + afterScreenUpdates: afterScreenUpdates + ) return NativeBitmap( width: pixelWidth, height: pixelHeight, stride: stride, pixels: pixels, context: context, + coverage: coverage, incomplete: incomplete ) } @@ -90,19 +176,20 @@ enum AppleViewCapture { view: UIView, bounds: CGRect, context: CGContext, - coverage: CaptureCoverage + coverage: CaptureCoverage, + afterScreenUpdates: Bool ) -> Bool { switch coverage { case .engineSurface: // FlutterView implements CALayerDelegate. Rendering the live layer is - // the experimental CPU path. Core Graphics does not copy CAMetalLayer - // contents by itself; physical Metal coverage is a device-lab gate. - // snapshotView copies lose the engine delegate and go blank. + // the fast CPU path. Core Graphics does not copy CAMetalLayer contents + // by itself. Blank output returns pixelCopyFailed so the caller can + // choose an explicit compatibility path. view.layer.render(in: context) return false case .viewHierarchy: UIGraphicsPushContext(context) - let drawn = view.drawHierarchy(in: bounds, afterScreenUpdates: false) + let drawn = view.drawHierarchy(in: bounds, afterScreenUpdates: afterScreenUpdates) UIGraphicsPopContext() return !drawn } diff --git a/platforms/apple/Tests/TugboatCaptureRuntimeTests/CaptureRuntimeModeTests.swift b/platforms/apple/Tests/TugboatCaptureRuntimeTests/CaptureRuntimeModeTests.swift index 3ebc428..fa92229 100644 --- a/platforms/apple/Tests/TugboatCaptureRuntimeTests/CaptureRuntimeModeTests.swift +++ b/platforms/apple/Tests/TugboatCaptureRuntimeTests/CaptureRuntimeModeTests.swift @@ -13,15 +13,69 @@ final class CaptureRuntimeModeTests: XCTestCase { } func testHierarchyCompatibilityCaptureReportsViewHierarchyCoverage() throws { - let result = try capture(runtime: CaptureRuntime(coverage: .viewHierarchy)) + let view = HierarchyDrawingView( + frame: CGRect(x: 0, y: 0, width: 40, height: 60), + fillColor: .green + ) + + let result = try capture(runtime: CaptureRuntime(coverage: .viewHierarchy), view: view) XCTAssertEqual(result.coverage, .viewHierarchy) XCTAssertFalse(result.jpeg.isEmpty) } + func testEngineSurfaceRejectsBlankInsteadOfBroadeningCoverage() throws { + let view = HierarchyDrawingView( + frame: CGRect(x: 0, y: 0, width: 40, height: 60), + fillColor: .green + ) + + let result = try capture(runtime: CaptureRuntime(), view: view) + + XCTAssertEqual(result.status, .pixelCopyFailed) + XCTAssertNil(result.coverage) + XCTAssertTrue(result.jpeg.isEmpty) + } + + func testTransparentCaptureFailsInsteadOfPublishingBlankJpeg() throws { + let view = HierarchyDrawingView( + frame: CGRect(x: 0, y: 0, width: 40, height: 60), + fillColor: .clear + ) + + let result = try capture( + runtime: CaptureRuntime(coverage: .viewHierarchy), + view: view + ) + + XCTAssertEqual(result.status, .pixelCopyFailed) + XCTAssertNil(result.coverage) + XCTAssertTrue(result.jpeg.isEmpty) + } + + func testOpaqueWhiteCaptureFailsInsteadOfPublishingBlankJpeg() throws { + let view = HierarchyDrawingView( + frame: CGRect(x: 0, y: 0, width: 40, height: 60), + fillColor: .white + ) + + let result = try capture( + runtime: CaptureRuntime(coverage: .viewHierarchy), + view: view + ) + + XCTAssertEqual(result.status, .pixelCopyFailed) + XCTAssertNil(result.coverage) + XCTAssertTrue(result.jpeg.isEmpty) + } + private func capture(runtime: CaptureRuntime) throws -> CaptureResult { let view = UIView(frame: CGRect(x: 0, y: 0, width: 40, height: 60)) view.backgroundColor = .red + return try capture(runtime: runtime, view: view) + } + + private func capture(runtime: CaptureRuntime, view: UIView) throws -> CaptureResult { let completed = expectation(description: "capture completes") var captured: CaptureResult? @@ -42,3 +96,30 @@ final class CaptureRuntimeModeTests: XCTestCase { return try XCTUnwrap(captured) } } + +private final class NoRenderLayer: CALayer { + override func render(in context: CGContext) {} +} + +private final class HierarchyDrawingView: UIView { + private let fillColor: UIColor + + init(frame: CGRect, fillColor: UIColor) { + self.fillColor = fillColor + super.init(frame: frame) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override class var layerClass: AnyClass { + NoRenderLayer.self + } + + override func drawHierarchy(in rect: CGRect, afterScreenUpdates: Bool) -> Bool { + fillColor.setFill() + UIRectFill(rect) + return true + } +}