|
| 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 | +} |
0 commit comments