Skip to content

Commit 70e5e9f

Browse files
committed
feat(tugboat): own Device Farm launch inputs in SDK plugin
Expose getLaunchOptions on the tugboat/launch channel from TugboatPlugin (Android Intent extras, iOS process environment) so host apps need no native code. Add TugboatLaunchOptions, TugboatLaunchParsers (single bool/local-URL policy), resolveTugboatCollectorBaseUrl (release-guarded), and TugboatReplayConfig.withDeviceFarmOverrides for one-call merge.
1 parent 302781a commit 70e5e9f

7 files changed

Lines changed: 487 additions & 0 deletions

File tree

sdks/flutter/packages/tugboat/CHANGELOG.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,17 @@
1+
## Unreleased
2+
3+
### Added
4+
5+
- Device Farm launch inputs owned by the SDK: `TugboatLaunchOptions`
6+
reads Android Intent extras (`tugboat_emit_scene_inventory`,
7+
`tugboat_accept_action_context`, `tugboat_collector_base_url`) and iOS
8+
process environment (`TUGBOAT_EMIT_SCENE_INVENTORY`,
9+
`TUGBOAT_ACCEPT_ACTION_CONTEXT`, `TUGBOAT_COLLECTOR_BASE_URL`) through the
10+
`tugboat/launch` plugin channel, so host apps need no native code.
11+
- `TugboatReplayConfig.withDeviceFarmOverrides()`: additive merge of launch
12+
capabilities plus a release-guarded, local-only collector URL override
13+
(`resolveTugboatCollectorBaseUrl`). Hosts collapse to a single call.
14+
115
## 0.9.0
216

317
### Changed

sdks/flutter/packages/tugboat/android/src/main/kotlin/com/tugboat/flutter/TugboatPlugin.kt

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package com.tugboat.flutter
22

33
import android.app.Activity
4+
import android.os.Bundle
45
import android.os.Handler
56
import android.os.Looper
67
import android.view.View
@@ -18,6 +19,15 @@ import io.flutter.embedding.android.FlutterView
1819
import io.flutter.embedding.engine.plugins.FlutterPlugin
1920
import io.flutter.embedding.engine.plugins.activity.ActivityAware
2021
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
22+
import io.flutter.plugin.common.MethodChannel
23+
24+
/// Device Farm launch extras read from the host `Intent`.
25+
///
26+
/// Natives pass raw strings through; all `1`/`true`/`yes` normalization and
27+
/// local-URL validation lives in `TugboatLaunchParsers` on the Dart side.
28+
const val TUGBOAT_EMIT_SCENE_INVENTORY = "tugboat_emit_scene_inventory"
29+
const val TUGBOAT_ACCEPT_ACTION_CONTEXT = "tugboat_accept_action_context"
30+
const val TUGBOAT_COLLECTOR_BASE_URL = "tugboat_collector_base_url"
2131

2232
class TugboatPlugin :
2333
FlutterPlugin,
@@ -26,13 +36,25 @@ class TugboatPlugin :
2636
private var runtime: CaptureRuntime? = null
2737
private var activity: Activity? = null
2838
private val mainHandler = Handler(Looper.getMainLooper())
39+
private var launchChannel: MethodChannel? = null
2940

3041
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
3142
runtime = CaptureRuntime()
3243
NativeCaptureHostApi.setUp(binding.binaryMessenger, this)
44+
launchChannel = MethodChannel(binding.binaryMessenger, "tugboat/launch").also { channel ->
45+
channel.setMethodCallHandler { call, result ->
46+
if (call.method == "getLaunchOptions") {
47+
result.success(launchOptions())
48+
} else {
49+
result.notImplemented()
50+
}
51+
}
52+
}
3353
}
3454

3555
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
56+
launchChannel?.setMethodCallHandler(null)
57+
launchChannel = null
3658
NativeCaptureHostApi.setUp(binding.binaryMessenger, null)
3759
runtime?.dispose()
3860
runtime = null
@@ -92,6 +114,22 @@ class TugboatPlugin :
92114
return findFlutterView(root)
93115
}
94116

117+
private fun launchOptions(): Map<String, Any?> {
118+
val intent = activity?.intent
119+
val extras = intent?.extras
120+
return mapOf(
121+
"emitSceneInventory" to rawExtra(extras, TUGBOAT_EMIT_SCENE_INVENTORY),
122+
"acceptActionContext" to rawExtra(extras, TUGBOAT_ACCEPT_ACTION_CONTEXT),
123+
"collectorBaseUrl" to (
124+
intent?.getStringExtra(TUGBOAT_COLLECTOR_BASE_URL)
125+
?: rawExtra(extras, TUGBOAT_COLLECTOR_BASE_URL)
126+
),
127+
)
128+
}
129+
130+
private fun rawExtra(extras: Bundle?, name: String): String? =
131+
extras?.get(name)?.toString()
132+
95133
private fun findFlutterView(view: View): View? {
96134
if (view is FlutterView) return view
97135
if (view is ViewGroup) {

sdks/flutter/packages/tugboat/ios/Classes/TugboatPlugin.swift

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ public class TugboatPlugin: NSObject, FlutterPlugin, NativeCaptureHostApi {
66
private var engineRuntime: CaptureRuntime?
77
private var hierarchyRuntime: CaptureRuntime?
88
private weak var registrar: FlutterPluginRegistrar?
9+
private var launchChannel: FlutterMethodChannel?
910
private let callbackQueue = DispatchQueue.main
1011
private let stateLock = NSLock()
1112
private var disposed = false
@@ -14,6 +15,28 @@ public class TugboatPlugin: NSObject, FlutterPlugin, NativeCaptureHostApi {
1415
let instance = TugboatPlugin()
1516
instance.registrar = registrar
1617
instance.engineRuntime = CaptureRuntime()
18+
// Device Farm launch inputs from the runner's process environment.
19+
// Raw strings pass through; Dart-side `TugboatLaunchParsers` normalizes.
20+
let launchChannel = FlutterMethodChannel(
21+
name: "tugboat/launch",
22+
binaryMessenger: registrar.messenger()
23+
)
24+
instance.launchChannel = launchChannel
25+
launchChannel.setMethodCallHandler { call, result in
26+
guard call.method == "getLaunchOptions" else {
27+
result(FlutterMethodNotImplemented)
28+
return
29+
}
30+
let environment = ProcessInfo.processInfo.environment
31+
result([
32+
"emitSceneInventory":
33+
environment["TUGBOAT_EMIT_SCENE_INVENTORY"] ?? NSNull(),
34+
"acceptActionContext":
35+
environment["TUGBOAT_ACCEPT_ACTION_CONTEXT"] ?? NSNull(),
36+
"collectorBaseUrl":
37+
environment["TUGBOAT_COLLECTOR_BASE_URL"] ?? NSNull(),
38+
])
39+
}
1740
NativeCaptureHostApiSetup.setUp(binaryMessenger: registrar.messenger(), api: instance)
1841
}
1942

sdks/flutter/packages/tugboat/lib/src/collector_config.dart

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,4 +205,22 @@ class TugboatCollectorConfig {
205205
maxPendingFrames: maxPendingFrames,
206206
);
207207
}
208+
209+
/// Returns a copy pointed at [baseUrl] (Device Farm local override).
210+
TugboatCollectorConfig withBaseUrl(String baseUrl) {
211+
return TugboatCollectorConfig(
212+
baseUrl: baseUrl,
213+
apiKey: apiKey,
214+
userId: userId,
215+
appInfo: appInfo,
216+
deviceInfo: deviceInfo,
217+
ipInfo: ipInfo,
218+
locale: locale,
219+
eventBatchSize: eventBatchSize,
220+
eventFlushInterval: eventFlushInterval,
221+
maxPendingBatches: maxPendingBatches,
222+
maxPendingEvents: maxPendingEvents,
223+
maxPendingFrames: maxPendingFrames,
224+
);
225+
}
208226
}
Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
import 'package:flutter/foundation.dart';
2+
import 'package:flutter/services.dart';
3+
4+
import 'replay_config.dart';
5+
6+
/// Device Farm launch inputs for Tugboat capture.
7+
///
8+
/// The SDK reads these at runtime from the host platform so host apps need
9+
/// no native code of their own:
10+
///
11+
/// * Android: `Intent` extras `tugboat_emit_scene_inventory`,
12+
/// `tugboat_accept_action_context`, `tugboat_collector_base_url`
13+
/// (e.g. `adb shell am start ... -e tugboat_emit_scene_inventory 1`).
14+
/// * iOS: process environment `TUGBOAT_EMIT_SCENE_INVENTORY`,
15+
/// `TUGBOAT_ACCEPT_ACTION_CONTEXT`, `TUGBOAT_COLLECTOR_BASE_URL`
16+
/// (set by the XCUITest/Device Farm runner).
17+
///
18+
/// Values `1`, `true`, and `yes` (case-insensitive) enable a capability.
19+
/// Everything else — including absent — means off. The collector URL is
20+
/// accepted only for local `http` hosts; see
21+
/// [TugboatLaunchParsers.parseLocalCollectorUrl]. Release builds always use
22+
/// the configured production collector; the override never replaces it.
23+
class TugboatLaunchOptions {
24+
const TugboatLaunchOptions({
25+
this.emitSceneInventory = false,
26+
this.acceptActionContext = false,
27+
this.collectorBaseUrl,
28+
});
29+
30+
static const MethodChannel channel = MethodChannel('tugboat/launch');
31+
32+
static const String keyEmitSceneInventory = 'emitSceneInventory';
33+
static const String keyAcceptActionContext = 'acceptActionContext';
34+
static const String keyCollectorBaseUrl = 'collectorBaseUrl';
35+
36+
final bool emitSceneInventory;
37+
final bool acceptActionContext;
38+
39+
/// Raw (unvalidated) collector URL from the launch environment, if any.
40+
///
41+
/// Validate with [TugboatLaunchParsers.parseLocalCollectorUrl] before use.
42+
final String? collectorBaseUrl;
43+
44+
bool get captureRequested => emitSceneInventory || acceptActionContext;
45+
46+
/// Reads launch inputs from the host platform. Never throws: a missing
47+
/// plugin, platform error, or unexpected payload yields defaults (off).
48+
///
49+
/// Pass [channel] in tests to avoid touching the real binary messenger.
50+
static Future<TugboatLaunchOptions> fromPlatform({
51+
MethodChannel? channel,
52+
}) async {
53+
try {
54+
final values = await (channel ?? TugboatLaunchOptions.channel)
55+
.invokeMapMethod<String, Object?>('getLaunchOptions');
56+
return TugboatLaunchOptions.fromMap(values ?? const {});
57+
} on MissingPluginException {
58+
return const TugboatLaunchOptions();
59+
} on PlatformException {
60+
return const TugboatLaunchOptions();
61+
}
62+
}
63+
64+
factory TugboatLaunchOptions.fromMap(Map<String, Object?> values) {
65+
final rawBaseUrl = values[keyCollectorBaseUrl] as String?;
66+
return TugboatLaunchOptions(
67+
emitSceneInventory: TugboatLaunchParsers.parseBool(
68+
values[keyEmitSceneInventory],
69+
),
70+
acceptActionContext: TugboatLaunchParsers.parseBool(
71+
values[keyAcceptActionContext],
72+
),
73+
collectorBaseUrl: rawBaseUrl?.trim().isEmpty == true
74+
? null
75+
: rawBaseUrl?.trim(),
76+
);
77+
}
78+
79+
Map<String, Object?> toJson() => {
80+
'captureRequested': captureRequested,
81+
'emitSceneInventory': emitSceneInventory,
82+
'acceptActionContext': acceptActionContext,
83+
};
84+
}
85+
86+
/// Pure parsers for launch inputs. Single source of truth so Android,
87+
/// iOS, and Dart never drift (natives pass raw strings through).
88+
abstract final class TugboatLaunchParsers {
89+
TugboatLaunchParsers._();
90+
91+
static const localHosts = {'127.0.0.1', 'localhost', '10.0.2.2'};
92+
93+
static bool parseBool(Object? value) {
94+
if (value is bool) return value;
95+
final normalized = value?.toString().trim().toLowerCase();
96+
return normalized == '1' || normalized == 'true' || normalized == 'yes';
97+
}
98+
99+
/// Returns [value] only when it is a local `http` collector endpoint.
100+
///
101+
/// Rejects `https`, user-info, queries, fragments, and non-root paths so a
102+
/// farmed launch can never redirect evidence to an arbitrary host.
103+
static String? parseLocalCollectorUrl(String? value) {
104+
final normalized = value?.trim();
105+
if (normalized == null || normalized.isEmpty) return null;
106+
final uri = Uri.tryParse(normalized);
107+
if (uri == null ||
108+
uri.scheme != 'http' ||
109+
uri.userInfo.isNotEmpty ||
110+
uri.hasQuery ||
111+
uri.hasFragment ||
112+
(uri.path.isNotEmpty && uri.path != '/')) {
113+
return null;
114+
}
115+
return localHosts.contains(uri.host.toLowerCase()) ? normalized : null;
116+
}
117+
}
118+
119+
/// Resolves the effective collector base URL for one app launch.
120+
///
121+
/// Precedence: validated runtime (Device Farm) URL > validated build-time
122+
/// (`--dart-define`) URL > [localFallbackBaseUrl] (e.g. `.env → local`
123+
/// mapping, already validated or null) > [productionBaseUrl].
124+
/// Release builds always return [productionBaseUrl].
125+
String resolveTugboatCollectorBaseUrl({
126+
required bool isRelease,
127+
required String productionBaseUrl,
128+
String? runtimeBaseUrl,
129+
String? buildBaseUrl,
130+
String? localFallbackBaseUrl,
131+
}) {
132+
if (isRelease) return productionBaseUrl;
133+
return TugboatLaunchParsers.parseLocalCollectorUrl(runtimeBaseUrl) ??
134+
TugboatLaunchParsers.parseLocalCollectorUrl(buildBaseUrl) ??
135+
localFallbackBaseUrl ??
136+
productionBaseUrl;
137+
}
138+
139+
/// Device Farm merge for [TugboatReplayConfig]. Additive only: enabling a
140+
/// launch capability never changes masking, limits, transport, or lifecycle
141+
/// behavior beyond what the matching config field already does.
142+
extension TugboatDeviceFarmConfig on TugboatReplayConfig {
143+
/// Returns `this` merged with [launchOptions] (read from the platform when
144+
/// omitted).
145+
///
146+
/// * `enabled` becomes true when any launch capability is requested.
147+
/// * `emitSceneInventory` / `acceptActionContext` are OR-ed in.
148+
/// * `collector.baseUrl` is replaced only by a validated local URL in
149+
/// non-release builds; release builds keep the configured endpoint.
150+
/// * Emits one `TUGBOAT_LAUNCH` debug line when launch capture is
151+
/// requested so Device Farm log scraping can confirm the merge without
152+
/// leaking tokens or URLs. Pass `logLaunch: false` to silence it.
153+
Future<TugboatReplayConfig> withDeviceFarmOverrides({
154+
TugboatLaunchOptions? launchOptions,
155+
bool? isRelease,
156+
String? buildCollectorBaseUrl,
157+
String? defaultLocalBaseUrl,
158+
bool logLaunch = true,
159+
}) async {
160+
final launch = launchOptions ?? await TugboatLaunchOptions.fromPlatform();
161+
final release = isRelease ?? kReleaseMode;
162+
var config = this;
163+
if (launch.captureRequested) {
164+
config = config.copyWith(
165+
enabled: true,
166+
emitSceneInventory: emitSceneInventory || launch.emitSceneInventory,
167+
acceptActionContext: acceptActionContext || launch.acceptActionContext,
168+
);
169+
}
170+
final currentCollector = config.collector;
171+
if (currentCollector != null) {
172+
final baseUrl = resolveTugboatCollectorBaseUrl(
173+
isRelease: release,
174+
productionBaseUrl: currentCollector.baseUrl,
175+
runtimeBaseUrl: launch.collectorBaseUrl,
176+
buildBaseUrl: buildCollectorBaseUrl,
177+
localFallbackBaseUrl: defaultLocalBaseUrl,
178+
);
179+
if (baseUrl != currentCollector.baseUrl) {
180+
config = config.copyWith(
181+
collector: currentCollector.withBaseUrl(baseUrl),
182+
);
183+
}
184+
}
185+
if (logLaunch && launch.captureRequested) {
186+
// No launch token, API key, or URL is included here.
187+
debugPrint('TUGBOAT_LAUNCH ${launch.toJson()}');
188+
}
189+
return config;
190+
}
191+
}

sdks/flutter/packages/tugboat/lib/tugboat.dart

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,12 @@ export 'src/replay_config.dart'
5858
TugboatViewportSemanticPolicy,
5959
TugboatScreenshotBudgetConfig,
6060
resolveViewportSemanticPolicy;
61+
export 'src/launch_options.dart'
62+
show
63+
TugboatLaunchOptions,
64+
TugboatLaunchParsers,
65+
TugboatDeviceFarmConfig,
66+
resolveTugboatCollectorBaseUrl;
6167
export 'src/screenshot_capture_backend.dart'
6268
show TugboatScreenshotCaptureBackend;
6369
export 'src/tugboat.dart';

0 commit comments

Comments
 (0)