From c296d63d8f9fd464b22cb0ab63aaaa4906cb933c Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 2 Sep 2026 15:11:56 +0000 Subject: [PATCH 1/6] Pin Flutter plugin to TugboatCaptureRuntime 0.1.1. --- docs/releases/compatibility.md | 1 + sdks/flutter/packages/tugboat/CHANGELOG.md | 4 ++++ sdks/flutter/packages/tugboat/android/build.gradle | 2 +- sdks/flutter/packages/tugboat/example/pubspec.yaml | 2 +- sdks/flutter/packages/tugboat/ios/tugboat.podspec | 4 ++-- sdks/flutter/packages/tugboat/lib/src/sdk_version.dart | 2 +- sdks/flutter/packages/tugboat/pubspec.yaml | 2 +- sdks/flutter/packages/tugboat_dio/CHANGELOG.md | 7 +++++++ sdks/flutter/packages/tugboat_dio/pubspec.yaml | 4 ++-- 9 files changed, 20 insertions(+), 8 deletions(-) diff --git a/docs/releases/compatibility.md b/docs/releases/compatibility.md index d42a225..abcf818 100644 --- a/docs/releases/compatibility.md +++ b/docs/releases/compatibility.md @@ -2,6 +2,7 @@ | Adapter | Adapter version | Native runtime | | --- | --- | --- | +| Flutter `tugboat` | 0.8.17 | Android `com.gettugboat.sdk:capture-runtime:0.1.0` from Maven Central. Apple `TugboatCaptureRuntime` `0.1.1` from CocoaPods trunk. Plugin iOS floor 15. | | 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. | diff --git a/sdks/flutter/packages/tugboat/CHANGELOG.md b/sdks/flutter/packages/tugboat/CHANGELOG.md index 2b014d4..29be8ab 100644 --- a/sdks/flutter/packages/tugboat/CHANGELOG.md +++ b/sdks/flutter/packages/tugboat/CHANGELOG.md @@ -1,3 +1,7 @@ +## 0.8.17 + +Depend on CocoaPods `TugboatCaptureRuntime:0.1.1`. + ## 0.8.16 ### Fixed diff --git a/sdks/flutter/packages/tugboat/android/build.gradle b/sdks/flutter/packages/tugboat/android/build.gradle index 94e8b9b..8555cf7 100644 --- a/sdks/flutter/packages/tugboat/android/build.gradle +++ b/sdks/flutter/packages/tugboat/android/build.gradle @@ -1,5 +1,5 @@ group = "com.tugboat.flutter" -version = "0.8.16" +version = "0.8.17" buildscript { ext.kotlin_version = "2.2.20" diff --git a/sdks/flutter/packages/tugboat/example/pubspec.yaml b/sdks/flutter/packages/tugboat/example/pubspec.yaml index ce6ef96..3611071 100644 --- a/sdks/flutter/packages/tugboat/example/pubspec.yaml +++ b/sdks/flutter/packages/tugboat/example/pubspec.yaml @@ -32,7 +32,7 @@ resolution: workspace dependencies: flutter: sdk: flutter - tugboat: ^0.8.16 + tugboat: ^0.8.17 # The following adds the Cupertino Icons font to your application. # Use with the CupertinoIcons class for iOS style icons. diff --git a/sdks/flutter/packages/tugboat/ios/tugboat.podspec b/sdks/flutter/packages/tugboat/ios/tugboat.podspec index d755c5e..272be40 100644 --- a/sdks/flutter/packages/tugboat/ios/tugboat.podspec +++ b/sdks/flutter/packages/tugboat/ios/tugboat.podspec @@ -4,7 +4,7 @@ # Pod::Spec.new do |s| s.name = 'tugboat' - s.version = '0.8.16' + s.version = '0.8.17' s.summary = 'Screenshot-based session replay with compact interaction anchors for Tugboat.' s.description = <<-DESC Flutter adapter for Tugboat session replay. Native CPU capture is experimental @@ -17,7 +17,7 @@ depends on CocoaPods TugboatCaptureRuntime and requires iOS 15. s.author = { 'Tugboat' => 'dev@bijatech.com' } s.source = { :path => '.' } s.dependency 'Flutter' - s.dependency 'TugboatCaptureRuntime', '0.1.0' + s.dependency 'TugboatCaptureRuntime', '0.1.1' s.platform = :ios, '15.0' s.static_framework = true s.swift_version = '5.9' diff --git a/sdks/flutter/packages/tugboat/lib/src/sdk_version.dart b/sdks/flutter/packages/tugboat/lib/src/sdk_version.dart index b79f075..c515f17 100644 --- a/sdks/flutter/packages/tugboat/lib/src/sdk_version.dart +++ b/sdks/flutter/packages/tugboat/lib/src/sdk_version.dart @@ -1,3 +1,3 @@ // Keep this in sync with this package's pubspec.yaml. The SDK version test // reads pubspec.yaml directly so release bumps fail fast if this drifts. -const tugboatSdkVersion = '0.8.16'; +const tugboatSdkVersion = '0.8.17'; diff --git a/sdks/flutter/packages/tugboat/pubspec.yaml b/sdks/flutter/packages/tugboat/pubspec.yaml index 5d2fe56..b4916ad 100644 --- a/sdks/flutter/packages/tugboat/pubspec.yaml +++ b/sdks/flutter/packages/tugboat/pubspec.yaml @@ -1,7 +1,7 @@ name: tugboat description: >- Screenshot-based session replay with compact interaction anchors for Tugboat. -version: 0.8.16 +version: 0.8.17 repository: https://github.com/blendto/tugboat-flutter issue_tracker: https://github.com/blendto/tugboat-flutter/issues homepage: https://github.com/blendto/tugboat-flutter diff --git a/sdks/flutter/packages/tugboat_dio/CHANGELOG.md b/sdks/flutter/packages/tugboat_dio/CHANGELOG.md index bb6cdb4..03bdb59 100644 --- a/sdks/flutter/packages/tugboat_dio/CHANGELOG.md +++ b/sdks/flutter/packages/tugboat_dio/CHANGELOG.md @@ -1,3 +1,10 @@ +## 0.8.17 + +### Changed + +- Compatibility release for `tugboat` 0.8.17. The Dio adapter has no runtime + behavior change. + ## 0.8.16 ### Changed diff --git a/sdks/flutter/packages/tugboat_dio/pubspec.yaml b/sdks/flutter/packages/tugboat_dio/pubspec.yaml index dd87e64..bf520a3 100644 --- a/sdks/flutter/packages/tugboat_dio/pubspec.yaml +++ b/sdks/flutter/packages/tugboat_dio/pubspec.yaml @@ -2,7 +2,7 @@ name: tugboat_dio description: >- Dio interceptor that records safe, bounded network evidence into an active Tugboat capture session. -version: 0.8.16 +version: 0.8.17 repository: https://github.com/blendto/tugboat-flutter issue_tracker: https://github.com/blendto/tugboat-flutter/issues homepage: https://github.com/blendto/tugboat-flutter @@ -18,7 +18,7 @@ dependencies: dio: ^5.4.0 flutter: sdk: flutter - tugboat: ^0.8.16 + tugboat: ^0.8.17 dev_dependencies: flutter_lints: ^5.0.0 From 79fe71b0f785569c923bcdc3f094896bb44acdc0 Mon Sep 17 00:00:00 2001 From: Chinmay Kabi Date: Wed, 2 Sep 2026 20:43:30 +0530 Subject: [PATCH 2/6] fix(flutter): recover iOS native capture quality --- .../tugboat/ios/Classes/TugboatPlugin.swift | 98 ++++++++++++++----- .../tugboat/lib/src/replay_config.dart | 2 +- .../test/replay/native_cpu_backend_test.dart | 28 ++++++ .../replay/screenshot_fresh_paint_test.dart | 27 +++++ 4 files changed, 129 insertions(+), 26 deletions(-) diff --git a/sdks/flutter/packages/tugboat/ios/Classes/TugboatPlugin.swift b/sdks/flutter/packages/tugboat/ios/Classes/TugboatPlugin.swift index fe60a9f..7ca31ec 100644 --- a/sdks/flutter/packages/tugboat/ios/Classes/TugboatPlugin.swift +++ b/sdks/flutter/packages/tugboat/ios/Classes/TugboatPlugin.swift @@ -3,19 +3,21 @@ import TugboatCaptureRuntime import UIKit public class TugboatPlugin: NSObject, FlutterPlugin, NativeCaptureHostApi { - private var runtime: CaptureRuntime? + private var engineRuntime: CaptureRuntime? + private var hierarchyRuntime: CaptureRuntime? private weak var registrar: FlutterPluginRegistrar? private let callbackQueue = DispatchQueue.main public static func register(with registrar: FlutterPluginRegistrar) { let instance = TugboatPlugin() instance.registrar = registrar - instance.runtime = CaptureRuntime() + instance.engineRuntime = CaptureRuntime() + instance.hierarchyRuntime = CaptureRuntime(coverage: .viewHierarchy) NativeCaptureHostApiSetup.setUp(binaryMessenger: registrar.messenger(), api: instance) } func getCapabilities() throws -> NativeCaptureCapabilities { - NativeCaptureMapping.capabilities(requireRuntime().capabilities()) + NativeCaptureMapping.capabilities(requireEngineRuntime().capabilities()) } func capture( @@ -26,42 +28,76 @@ public class TugboatPlugin: NSObject, FlutterPlugin, NativeCaptureHostApi { completion(.success(NativeCaptureMapping.failed(request, .surfaceUnavailable))) return } - requireRuntime().capture(view: view, request: NativeCaptureMapping.request(request)) { result in - self.callbackQueue.async { - completion(.success(NativeCaptureMapping.result(result))) + let nativeRequest = NativeCaptureMapping.request(request) + requireEngineRuntime().capture(view: view, request: nativeRequest) { result in + guard result.status == .pixelCopyFailed else { + self.complete(result, completion: completion) + return + } + + self.requireHierarchyRuntime().capture(view: view, request: nativeRequest) { retry in + self.complete(retry, completion: completion) } } } func cancel(requestId: Int64) throws { - runtime?.cancel(requestId: requestId) + engineRuntime?.cancel(requestId: requestId) + hierarchyRuntime?.cancel(requestId: requestId) } func dispose() throws { - runtime?.dispose() - runtime = nil + engineRuntime?.dispose() + hierarchyRuntime?.dispose() + engineRuntime = nil + hierarchyRuntime = nil + } + + private func complete( + _ result: CaptureResult, + completion: @escaping (Result) -> Void + ) { + callbackQueue.async { + completion(.success(NativeCaptureMapping.result(result))) + } } - private func requireRuntime() -> CaptureRuntime { - if let runtime { - return runtime + private func requireEngineRuntime() -> CaptureRuntime { + if let engineRuntime { + return engineRuntime } let created = CaptureRuntime() - runtime = created + engineRuntime = created + return created + } + + private func requireHierarchyRuntime() -> CaptureRuntime { + if let hierarchyRuntime { + return hierarchyRuntime + } + let created = CaptureRuntime(coverage: .viewHierarchy) + hierarchyRuntime = created return created } private func flutterView() -> UIView? { - guard let controller = hostViewController() else { - return nil + if let controller = registrarViewController(), + let view = findFlutterView(controller.view) + { + return view + } + + for window in foregroundWindows() { + if let view = findFlutterView(window) { + return view + } } - return findFlutterView(controller.view) ?? controller.view + return nil } /// Flutter 3.38+ exposes `registrar.viewController`. The package floor is - /// Flutter 3.35, which does not declare that property, so look it up at - /// runtime and fall back to the key window. - private func hostViewController() -> UIViewController? { + /// Flutter 3.35, which does not declare that property, so look it up at runtime. + private func registrarViewController() -> UIViewController? { if let registrar { let selector = NSSelectorFromString("viewController") if registrar.responds(to: selector), @@ -71,15 +107,27 @@ public class TugboatPlugin: NSObject, FlutterPlugin, NativeCaptureHostApi { return controller } } - return keyWindowRootViewController() + return nil } - private func keyWindowRootViewController() -> UIViewController? { - let windows = UIApplication.shared.connectedScenes + /// Prefer the active key window. Do not use an arbitrary controller view when + /// FlutterView is absent because that can produce a valid but unrelated frame. + private func foregroundWindows() -> [UIWindow] { + let scenes = UIApplication.shared.connectedScenes .compactMap { $0 as? UIWindowScene } - .flatMap(\.windows) - let keyWindow = windows.first(where: \.isKeyWindow) ?? windows.first - return keyWindow?.rootViewController + let activeScenes = scenes.filter { $0.activationState == .foregroundActive } + let inactiveScenes = scenes.filter { $0.activationState == .foregroundInactive } + + return (activeScenes + inactiveScenes).flatMap { scene in + scene.windows + .filter { !$0.isHidden && $0.alpha > 0 } + .sorted { left, right in + if left.isKeyWindow != right.isKeyWindow { + return left.isKeyWindow + } + return left.windowLevel.rawValue < right.windowLevel.rawValue + } + } } private func findFlutterView(_ view: UIView) -> UIView? { diff --git a/sdks/flutter/packages/tugboat/lib/src/replay_config.dart b/sdks/flutter/packages/tugboat/lib/src/replay_config.dart index a2593f9..a36257a 100644 --- a/sdks/flutter/packages/tugboat/lib/src/replay_config.dart +++ b/sdks/flutter/packages/tugboat/lib/src/replay_config.dart @@ -94,7 +94,7 @@ class TugboatReplayConfig { this.capturePixelRatio = 0.75, this.captureMaxWidth, this.captureMaxHeight, - this.degradedCaptureScale = 0.67, + this.degradedCaptureScale = 0.80, this.enableGlobalPointerCapture = true, this.explorationCollectorUrl, this.explorationRunId, diff --git a/sdks/flutter/packages/tugboat/test/replay/native_cpu_backend_test.dart b/sdks/flutter/packages/tugboat/test/replay/native_cpu_backend_test.dart index af98379..e98bc1e 100644 --- a/sdks/flutter/packages/tugboat/test/replay/native_cpu_backend_test.dart +++ b/sdks/flutter/packages/tugboat/test/replay/native_cpu_backend_test.dart @@ -213,6 +213,34 @@ void main() { }, ); + test('native hierarchy retry reports its actual coverage', () async { + final api = _FakeHostApi( + captureHandler: (request) async => nativeCaptureResult( + requestId: request.requestId, + status: NativeCaptureStatus.ok, + jpeg: Uint8List.fromList(const [1, 2, 3, 4]), + width: 8, + height: 8, + dHash: '1' * 64, + contentHash: 'abc', + coverage: NativeCaptureCoverage.viewHierarchy, + ), + ); + final fallback = _RecordingFallback(); + final source = NativeCpuExperimentalPixelSource( + client: NativeCaptureClient(api: api), + fallback: fallback, + ); + + final result = await source.acquire( + _pixelRequest(boundary: RenderRepaintBoundary()), + ); + + expect(fallback.calls, 0); + expect(result.disposition, ScreenshotPixelDisposition.captured); + expect(result.trace.coverage, 'viewHierarchy'); + }); + test('native fallback status uses Flutter path once', () async { final api = _FakeHostApi( captureHandler: (request) async => nativeCaptureResult( diff --git a/sdks/flutter/packages/tugboat/test/replay/screenshot_fresh_paint_test.dart b/sdks/flutter/packages/tugboat/test/replay/screenshot_fresh_paint_test.dart index d9598d2..817e412 100644 --- a/sdks/flutter/packages/tugboat/test/replay/screenshot_fresh_paint_test.dart +++ b/sdks/flutter/packages/tugboat/test/replay/screenshot_fresh_paint_test.dart @@ -428,6 +428,33 @@ void main() { expect(capture.result!.height, 160); }); + testWidgets('default degraded scale retains more screenshot detail', ( + tester, + ) async { + const config = TugboatReplayConfig(); + final boundaryKey = GlobalKey(); + final capturer = ScreenshotCapturer( + boundaryKey: boundaryKey, + maskLevel: TugboatScreenshotMaskLevel.explicitOnly, + anchorResolver: AnchorResolver(rootKey: boundaryKey), + pixelRatio: config.capturePixelRatio, + degradedScale: config.degradedCaptureScale, + frameWaiter: () => Future.value(), + encoder: InlineScreenshotEncoder(), + ); + addTearDown(capturer.dispose); + await tester.pumpWidget(_scene(boundaryKey, Colors.red)); + + final degraded = await tester.runAsync( + () => capturer.captureAttempt(force: true, degraded: true), + ); + + expect(degraded, isNotNull); + expect(degraded!.result, isNotNull); + expect(degraded.result!.width, 48); + expect(degraded.result!.height, 48); + }); + test('copyWith can clear screenshot dimension bounds', () { const bounded = TugboatReplayConfig( captureMaxWidth: 540, From 4ac0c947c13c75dac31dbd21f88655982ca4790d Mon Sep 17 00:00:00 2001 From: Chinmay Kabi Date: Wed, 2 Sep 2026 20:44:45 +0530 Subject: [PATCH 3/6] docs(release): describe Flutter 0.8.17 --- docs/releases/compatibility.md | 2 +- sdks/flutter/packages/tugboat/CHANGELOG.md | 13 ++++++++++++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/docs/releases/compatibility.md b/docs/releases/compatibility.md index abcf818..72af907 100644 --- a/docs/releases/compatibility.md +++ b/docs/releases/compatibility.md @@ -2,7 +2,7 @@ | Adapter | Adapter version | Native runtime | | --- | --- | --- | -| Flutter `tugboat` | 0.8.17 | Android `com.gettugboat.sdk:capture-runtime:0.1.0` from Maven Central. Apple `TugboatCaptureRuntime` `0.1.1` from CocoaPods trunk. Plugin iOS floor 15. | +| Flutter `tugboat` | 0.8.17 | Android `capture-runtime` `0.1.0`; Apple `TugboatCaptureRuntime` `0.1.1`; blank iOS engine captures retry with explicit hierarchy coverage. | | 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. | diff --git a/sdks/flutter/packages/tugboat/CHANGELOG.md b/sdks/flutter/packages/tugboat/CHANGELOG.md index 29be8ab..3e83984 100644 --- a/sdks/flutter/packages/tugboat/CHANGELOG.md +++ b/sdks/flutter/packages/tugboat/CHANGELOG.md @@ -1,6 +1,17 @@ ## 0.8.17 -Depend on CocoaPods `TugboatCaptureRuntime:0.1.1`. +### Fixed + +- Reject blank iOS engine-surface captures and retry with explicit hierarchy + capture before falling back to Flutter capture. +- Resolve the live `FlutterView` without selecting an unrelated controller + view. + +### Changed + +- Raise the default degraded capture scale from `0.67` to `0.80` to retain + more screenshot detail when the capture budget is degraded. +- Depend on CocoaPods `TugboatCaptureRuntime` `0.1.1`. ## 0.8.16 From 36f35582f5e40c040ce670211704f0fc62ca2f52 Mon Sep 17 00:00:00 2001 From: Chinmay Kabi Date: Wed, 2 Sep 2026 20:45:55 +0530 Subject: [PATCH 4/6] test(flutter): match degraded capture rounding --- .../tugboat/test/replay/screenshot_fresh_paint_test.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/sdks/flutter/packages/tugboat/test/replay/screenshot_fresh_paint_test.dart b/sdks/flutter/packages/tugboat/test/replay/screenshot_fresh_paint_test.dart index 817e412..254d667 100644 --- a/sdks/flutter/packages/tugboat/test/replay/screenshot_fresh_paint_test.dart +++ b/sdks/flutter/packages/tugboat/test/replay/screenshot_fresh_paint_test.dart @@ -451,8 +451,8 @@ void main() { expect(degraded, isNotNull); expect(degraded!.result, isNotNull); - expect(degraded.result!.width, 48); - expect(degraded.result!.height, 48); + expect(degraded.result!.width, 49); + expect(degraded.result!.height, 49); }); test('copyWith can clear screenshot dimension bounds', () { From ead7fa0dfa4f48cba56ac5b6f378ab0c77daec65 Mon Sep 17 00:00:00 2001 From: Chinmay Kabi Date: Wed, 2 Sep 2026 21:03:16 +0530 Subject: [PATCH 5/6] fix(ios): bound hierarchy retry lifecycle --- .../tugboat/ios/Classes/TugboatPlugin.swift | 89 +++++++++++++++---- .../replay/screenshot_fresh_paint_test.dart | 7 +- 2 files changed, 78 insertions(+), 18 deletions(-) diff --git a/sdks/flutter/packages/tugboat/ios/Classes/TugboatPlugin.swift b/sdks/flutter/packages/tugboat/ios/Classes/TugboatPlugin.swift index 7ca31ec..d8789d5 100644 --- a/sdks/flutter/packages/tugboat/ios/Classes/TugboatPlugin.swift +++ b/sdks/flutter/packages/tugboat/ios/Classes/TugboatPlugin.swift @@ -7,62 +7,103 @@ public class TugboatPlugin: NSObject, FlutterPlugin, NativeCaptureHostApi { private var hierarchyRuntime: CaptureRuntime? private weak var registrar: FlutterPluginRegistrar? private let callbackQueue = DispatchQueue.main + private let stateLock = NSLock() + private var disposed = false public static func register(with registrar: FlutterPluginRegistrar) { let instance = TugboatPlugin() instance.registrar = registrar instance.engineRuntime = CaptureRuntime() - instance.hierarchyRuntime = CaptureRuntime(coverage: .viewHierarchy) NativeCaptureHostApiSetup.setUp(binaryMessenger: registrar.messenger(), api: instance) } func getCapabilities() throws -> NativeCaptureCapabilities { - NativeCaptureMapping.capabilities(requireEngineRuntime().capabilities()) + guard let runtime = engineCaptureRuntime() else { + return NativeCaptureCapabilities( + nativeCaptureSupported: false, + apiLevel: Int64(ProcessInfo.processInfo.operatingSystemVersion.majorVersion), + minNativeApi: Int64(CaptureRuntime.minNativeApi) + ) + } + return NativeCaptureMapping.capabilities(runtime.capabilities()) } func capture( request: NativeCaptureRequest, completion: @escaping (Result) -> Void ) { + guard let runtime = engineCaptureRuntime() else { + complete(NativeCaptureMapping.failed(request, .disposed), completion: completion) + return + } guard let view = flutterView() else { - completion(.success(NativeCaptureMapping.failed(request, .surfaceUnavailable))) + complete( + NativeCaptureMapping.failed(request, .surfaceUnavailable), + completion: completion + ) return } + let started = DispatchTime.now() let nativeRequest = NativeCaptureMapping.request(request) - requireEngineRuntime().capture(view: view, request: nativeRequest) { result in + runtime.capture(view: view, request: nativeRequest) { result in guard result.status == .pixelCopyFailed else { self.complete(result, completion: completion) return } - self.requireHierarchyRuntime().capture(view: view, request: nativeRequest) { retry in + let remainingMs = self.remainingTimeoutMs(since: started) + guard remainingMs > 0 else { + self.complete(NativeCaptureMapping.failed(request, .timeout), completion: completion) + return + } + guard let hierarchyRuntime = self.makeHierarchyRuntime(timeoutMs: remainingMs) else { + self.complete(NativeCaptureMapping.failed(request, .disposed), completion: completion) + return + } + hierarchyRuntime.capture(view: view, request: nativeRequest) { retry in self.complete(retry, completion: completion) } } } func cancel(requestId: Int64) throws { - engineRuntime?.cancel(requestId: requestId) - hierarchyRuntime?.cancel(requestId: requestId) + let runtimes = captureRuntimes() + runtimes.engine?.cancel(requestId: requestId) + runtimes.hierarchy?.cancel(requestId: requestId) } func dispose() throws { - engineRuntime?.dispose() - hierarchyRuntime?.dispose() + stateLock.lock() + disposed = true + let engine = engineRuntime + let hierarchy = hierarchyRuntime engineRuntime = nil hierarchyRuntime = nil + stateLock.unlock() + engine?.dispose() + hierarchy?.dispose() } private func complete( _ result: CaptureResult, completion: @escaping (Result) -> Void + ) { + complete(NativeCaptureMapping.result(result), completion: completion) + } + + private func complete( + _ result: NativeCaptureResult, + completion: @escaping (Result) -> Void ) { callbackQueue.async { - completion(.success(NativeCaptureMapping.result(result))) + completion(.success(result)) } } - private func requireEngineRuntime() -> CaptureRuntime { + private func engineCaptureRuntime() -> CaptureRuntime? { + stateLock.lock() + defer { stateLock.unlock() } + guard !disposed else { return nil } if let engineRuntime { return engineRuntime } @@ -71,15 +112,31 @@ public class TugboatPlugin: NSObject, FlutterPlugin, NativeCaptureHostApi { return created } - private func requireHierarchyRuntime() -> CaptureRuntime { - if let hierarchyRuntime { - return hierarchyRuntime - } - let created = CaptureRuntime(coverage: .viewHierarchy) + /// The controller serializes capture requests. Replace the prior retry runtime + /// so this attempt uses only the time left in the original request budget. + private func makeHierarchyRuntime(timeoutMs: Int64) -> CaptureRuntime? { + stateLock.lock() + defer { stateLock.unlock() } + guard !disposed else { return nil } + let created = CaptureRuntime(timeoutMs: timeoutMs, coverage: .viewHierarchy) + hierarchyRuntime?.dispose() hierarchyRuntime = created return created } + private func captureRuntimes() -> (engine: CaptureRuntime?, hierarchy: CaptureRuntime?) { + stateLock.lock() + defer { stateLock.unlock() } + return (engineRuntime, hierarchyRuntime) + } + + private func remainingTimeoutMs(since started: DispatchTime) -> Int64 { + let elapsedMs = Int64( + (DispatchTime.now().uptimeNanoseconds - started.uptimeNanoseconds) / 1_000_000 + ) + return max(0, CaptureRuntime.defaultTimeoutMs - elapsedMs) + } + private func flutterView() -> UIView? { if let controller = registrarViewController(), let view = findFlutterView(controller.view) diff --git a/sdks/flutter/packages/tugboat/test/replay/screenshot_fresh_paint_test.dart b/sdks/flutter/packages/tugboat/test/replay/screenshot_fresh_paint_test.dart index 254d667..0ea1f4f 100644 --- a/sdks/flutter/packages/tugboat/test/replay/screenshot_fresh_paint_test.dart +++ b/sdks/flutter/packages/tugboat/test/replay/screenshot_fresh_paint_test.dart @@ -451,8 +451,11 @@ void main() { expect(degraded, isNotNull); expect(degraded!.result, isNotNull); - expect(degraded.result!.width, 49); - expect(degraded.result!.height, 49); + final expectedRatio = + config.capturePixelRatio * config.degradedCaptureScale; + final expectedDimension = (80 * expectedRatio).ceil(); + expect(degraded.result!.width, expectedDimension); + expect(degraded.result!.height, expectedDimension); }); test('copyWith can clear screenshot dimension bounds', () { From 62307dacc275ee93b24832ab5fb0ed21a0786be5 Mon Sep 17 00:00:00 2001 From: Chinmay Kabi Date: Wed, 2 Sep 2026 21:07:43 +0530 Subject: [PATCH 6/6] fix(ios): dispose retry runtime outside state lock --- .../packages/tugboat/ios/Classes/TugboatPlugin.swift | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/sdks/flutter/packages/tugboat/ios/Classes/TugboatPlugin.swift b/sdks/flutter/packages/tugboat/ios/Classes/TugboatPlugin.swift index d8789d5..5512e94 100644 --- a/sdks/flutter/packages/tugboat/ios/Classes/TugboatPlugin.swift +++ b/sdks/flutter/packages/tugboat/ios/Classes/TugboatPlugin.swift @@ -116,11 +116,15 @@ public class TugboatPlugin: NSObject, FlutterPlugin, NativeCaptureHostApi { /// so this attempt uses only the time left in the original request budget. private func makeHierarchyRuntime(timeoutMs: Int64) -> CaptureRuntime? { stateLock.lock() - defer { stateLock.unlock() } - guard !disposed else { return nil } + guard !disposed else { + stateLock.unlock() + return nil + } let created = CaptureRuntime(timeoutMs: timeoutMs, coverage: .viewHierarchy) - hierarchyRuntime?.dispose() + let previous = hierarchyRuntime hierarchyRuntime = created + stateLock.unlock() + previous?.dispose() return created }