Skip to content

Commit 80ff30d

Browse files
committed
fix: Shadow DOM tap/snapshot + serve port reuse (v0.9.6)
- tap: use _dqAll('*') instead of narrow tag list so custom elements (faceplate-button, shreddit-*, etc.) are found by text match - snapshot: second pass collects button/[role=button]/[type=submit] elements nested inside shadow roots that the selector sweep missed - serve: bind with shared:true (SO_REUSEADDR) to avoid TIME_WAIT "Address already in use" crashes on restart
1 parent 9681f53 commit 80ff30d

File tree

3 files changed

+434
-161
lines changed

3 files changed

+434
-161
lines changed

lib/src/bridge/cdp_driver.dart

Lines changed: 104 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ class CdpDriver implements AppDriver {
8181
bool get isConnected => _connected;
8282

8383
@override
84+
8485
/// Whether connect() found an existing tab matching the target URL
8586
/// (skipped navigation to avoid duplicate tabs).
8687
bool connectedToExistingTab = false;
@@ -107,14 +108,16 @@ class CdpDriver implements AppDriver {
107108

108109
// Discover tabs via CDP JSON endpoint
109110
var wsUrl = await _discoverTarget();
110-
111+
111112
// No suitable tab found — create a new one via CDP HTTP API
112113
if (wsUrl == null && _url.isNotEmpty) {
113114
try {
114-
final client = HttpClient()..connectionTimeout = const Duration(seconds: 2);
115+
final client = HttpClient()
116+
..connectionTimeout = const Duration(seconds: 2);
115117
final encodedUrl = Uri.encodeComponent(_url);
116118
// Chrome 145+ requires PUT for /json/new
117-
final request = await client.openUrl('PUT', Uri.parse('http://127.0.0.1:$_port/json/new?$encodedUrl'));
119+
final request = await client.openUrl(
120+
'PUT', Uri.parse('http://127.0.0.1:$_port/json/new?$encodedUrl'));
118121
final response = await request.close();
119122
final body = await response.transform(utf8.decoder).join();
120123
client.close();
@@ -124,9 +127,10 @@ class CdpDriver implements AppDriver {
124127
if (wsUrl != null) connectedToExistingTab = true;
125128
} catch (_) {}
126129
}
127-
130+
128131
if (wsUrl == null) {
129-
throw Exception('Could not find or create a debuggable tab on port $_port. '
132+
throw Exception(
133+
'Could not find or create a debuggable tab on port $_port. '
130134
'Ensure Chrome is running with --remote-debugging-port=$_port');
131135
}
132136

@@ -154,16 +158,22 @@ class CdpDriver implements AppDriver {
154158
(currentUrl != null && _url.isNotEmpty && currentUrl.startsWith(_url));
155159

156160
// Also check root domain match (e.g. passport.csdn.net redirected to csdn.net)
157-
final sameRootDomain = !alreadyOnTarget && currentUrl != null && _url.isNotEmpty && (() {
158-
final targetHost = Uri.tryParse(_url)?.host ?? '';
159-
final currentHost = Uri.tryParse(currentUrl)?.host ?? '';
160-
if (targetHost.isEmpty || currentHost.isEmpty) return false;
161-
final tp = targetHost.split('.');
162-
final cp = currentHost.split('.');
163-
final tr = tp.length >= 2 ? tp.sublist(tp.length - 2).join('.') : targetHost;
164-
final cr = cp.length >= 2 ? cp.sublist(cp.length - 2).join('.') : currentHost;
165-
return tr == cr;
166-
})();
161+
final sameRootDomain = !alreadyOnTarget &&
162+
currentUrl != null &&
163+
_url.isNotEmpty &&
164+
(() {
165+
final targetHost = Uri.tryParse(_url)?.host ?? '';
166+
final currentHost = Uri.tryParse(currentUrl)?.host ?? '';
167+
if (targetHost.isEmpty || currentHost.isEmpty) return false;
168+
final tp = targetHost.split('.');
169+
final cp = currentHost.split('.');
170+
final tr =
171+
tp.length >= 2 ? tp.sublist(tp.length - 2).join('.') : targetHost;
172+
final cr = cp.length >= 2
173+
? cp.sublist(cp.length - 2).join('.')
174+
: currentHost;
175+
return tr == cr;
176+
})();
167177

168178
// Navigate to URL and wait for load event.
169179
final skipNav = _url.isEmpty ||
@@ -187,8 +197,10 @@ class CdpDriver implements AppDriver {
187197
/// Check if CDP port is already responding
188198
Future<bool> _isCdpPortAlive() async {
189199
try {
190-
final client = HttpClient()..connectionTimeout = const Duration(seconds: 2);
191-
final request = await client.getUrl(Uri.parse('http://127.0.0.1:$_port/json/version'));
200+
final client = HttpClient()
201+
..connectionTimeout = const Duration(seconds: 2);
202+
final request = await client
203+
.getUrl(Uri.parse('http://127.0.0.1:$_port/json/version'));
192204
final response = await request.close();
193205
await response.drain<void>();
194206
client.close();
@@ -624,7 +636,22 @@ class CdpDriver implements AppDriver {
624636
} catch(e) { /* cross-origin iframe — skip */ }
625637
}
626638
} catch(e) {}
627-
const combinedEls = [...allEls, ...iframeEls];
639+
// Second pass: find clickable custom elements inside shadow roots that the
640+
// selector-based dqAll may have missed (e.g. Reddit's faceplate-button).
641+
function findShadowInteractive(root) {
642+
let results = [];
643+
for (const el of root.querySelectorAll('*')) {
644+
if (el.shadowRoot) {
645+
for (const inner of el.shadowRoot.querySelectorAll('button, [role="button"], [type="submit"]')) {
646+
if (!allEls.includes(inner)) results.push(inner);
647+
}
648+
results = results.concat(findShadowInteractive(el.shadowRoot));
649+
}
650+
}
651+
return results;
652+
}
653+
const shadowEls = findShadowInteractive(document);
654+
const combinedEls = [...allEls, ...iframeEls, ...shadowEls];
628655
629656
const vw = window.innerWidth;
630657
const vh = window.innerHeight;
@@ -778,10 +805,18 @@ class CdpDriver implements AppDriver {
778805
bool dispatchRealEvents = false,
779806
}) async {
780807
// Escape parameters for JS
781-
final jsText = text?.replaceAll('\\', '\\\\').replaceAll("'", "\\'").replaceAll('\n', '\\n') ?? '';
808+
final jsText = text
809+
?.replaceAll('\\', '\\\\')
810+
.replaceAll("'", "\\'")
811+
.replaceAll('\n', '\\n') ??
812+
'';
782813
final jsKey = key?.replaceAll('\\', '\\\\').replaceAll("'", "\\'") ?? '';
783814
final jsRef = ref?.replaceAll('\\', '\\\\').replaceAll("'", "\\'") ?? '';
784-
final jsValue = value?.replaceAll('\\', '\\\\').replaceAll("'", "\\'").replaceAll('\n', '\\n') ?? '';
815+
final jsValue = value
816+
?.replaceAll('\\', '\\\\')
817+
.replaceAll("'", "\\'")
818+
.replaceAll('\n', '\\n') ??
819+
'';
785820

786821
// Single JS eval: find + scroll + act
787822
final result = await _evalJs('''
@@ -914,12 +949,16 @@ class CdpDriver implements AppDriver {
914949
if (parsed != null) {
915950
// For click actions needing real mouse events (e.g., custom components),
916951
// fall back to CDP Input.dispatch
917-
if (dispatchRealEvents && parsed['success'] == true && parsed['position'] != null) {
952+
if (dispatchRealEvents &&
953+
parsed['success'] == true &&
954+
parsed['position'] != null) {
918955
final pos = parsed['position'] as Map<String, dynamic>;
919956
final cx = (pos['x'] as num).toDouble();
920957
final cy = (pos['y'] as num).toDouble();
921-
await _dispatchMouseEvent('mousePressed', cx, cy, button: 'left', clickCount: 1);
922-
await _dispatchMouseEvent('mouseReleased', cx, cy, button: 'left', clickCount: 1);
958+
await _dispatchMouseEvent('mousePressed', cx, cy,
959+
button: 'left', clickCount: 1);
960+
await _dispatchMouseEvent('mouseReleased', cx, cy,
961+
button: 'left', clickCount: 1);
923962
}
924963
return parsed;
925964
}
@@ -1685,8 +1724,7 @@ class CdpDriver implements AppDriver {
16851724
.replaceAll("'", "\\'")
16861725
.replaceAll('\n', '\\n')
16871726
.replaceAll('\t', '\\t');
1688-
await _evalJs(
1689-
"document.execCommand('insertText', false, '$escaped')");
1727+
await _evalJs("document.execCommand('insertText', false, '$escaped')");
16901728

16911729
// 2. If still failed, try per-char dispatchKeyEvent as last resort
16921730
final fallbackResult = await _evalJs('''
@@ -1863,8 +1901,10 @@ class CdpDriver implements AppDriver {
18631901
final content = html ?? text ?? '';
18641902
final isHtml = html != null;
18651903
final sel = selector ?? '[contenteditable="true"]';
1866-
final escapedContent =
1867-
content.replaceAll('\\', '\\\\').replaceAll('`', '\\`').replaceAll('\$', '\\\$');
1904+
final escapedContent = content
1905+
.replaceAll('\\', '\\\\')
1906+
.replaceAll('`', '\\`')
1907+
.replaceAll('\$', '\\\$');
18681908

18691909
final result = await _evalJs('''
18701910
(() => {
@@ -2002,7 +2042,8 @@ class CdpDriver implements AppDriver {
20022042
} else if (captchaType == 'image') {
20032043
// For image CAPTCHA, download and send base64
20042044
final imgSrc = detected['imgSrc'] as String?;
2005-
if (imgSrc == null) return {"success": false, "message": "No CAPTCHA image found"};
2045+
if (imgSrc == null)
2046+
return {"success": false, "message": "No CAPTCHA image found"};
20062047
final imgResponse = await httpClient.get(Uri.parse(imgSrc));
20072048
params['method'] = 'base64';
20082049
params['body'] = base64Encode(imgResponse.bodyBytes);
@@ -2011,16 +2052,21 @@ class CdpDriver implements AppDriver {
20112052
final response = await httpClient.post(submitUrl, body: params);
20122053
final submitResult = jsonDecode(response.body) as Map<String, dynamic>;
20132054
if (submitResult['status'] != 1) {
2014-
return {"success": false, "message": "Submit failed: ${submitResult['request']}"};
2055+
return {
2056+
"success": false,
2057+
"message": "Submit failed: ${submitResult['request']}"
2058+
};
20152059
}
20162060
taskId = submitResult['request'] as String;
20172061

20182062
// Step 3: Poll for result
20192063
for (int i = 0; i < 60; i++) {
20202064
await Future.delayed(const Duration(seconds: 5));
2021-
final pollUrl = Uri.parse('http://2captcha.com/res.php?key=$apiKey&action=get&id=$taskId&json=1');
2065+
final pollUrl = Uri.parse(
2066+
'http://2captcha.com/res.php?key=$apiKey&action=get&id=$taskId&json=1');
20222067
final pollResponse = await httpClient.get(pollUrl);
2023-
final pollResult = jsonDecode(pollResponse.body) as Map<String, dynamic>;
2068+
final pollResult =
2069+
jsonDecode(pollResponse.body) as Map<String, dynamic>;
20242070
if (pollResult['status'] == 1) {
20252071
final token = pollResult['request'] as String;
20262072

@@ -2073,17 +2119,27 @@ class CdpDriver implements AppDriver {
20732119
return {
20742120
"success": true,
20752121
"type": captchaType,
2076-
"token": token.length > 50 ? '${token.substring(0, 50)}...' : token,
2122+
"token":
2123+
token.length > 50 ? '${token.substring(0, 50)}...' : token,
20772124
"message": "CAPTCHA solved and injected"
20782125
};
20792126
}
20802127
if (pollResult['request'] != 'CAPCHA_NOT_READY') {
2081-
return {"success": false, "message": "Solve failed: ${pollResult['request']}"};
2128+
return {
2129+
"success": false,
2130+
"message": "Solve failed: ${pollResult['request']}"
2131+
};
20822132
}
20832133
}
2084-
return {"success": false, "message": "Timeout waiting for CAPTCHA solution"};
2134+
return {
2135+
"success": false,
2136+
"message": "Timeout waiting for CAPTCHA solution"
2137+
};
20852138
} else {
2086-
return {"success": false, "message": "Service '$service' not supported yet. Use 'twocaptcha'."};
2139+
return {
2140+
"success": false,
2141+
"message": "Service '$service' not supported yet. Use 'twocaptcha'."
2142+
};
20872143
}
20882144
} finally {
20892145
httpClient.close();
@@ -2242,13 +2298,11 @@ class CdpDriver implements AppDriver {
22422298

22432299
// macOS: remove quarantine attribute
22442300
if (Platform.isMacOS) {
2245-
final appDir = Directory(installDir)
2246-
.listSync()
2247-
.whereType<Directory>()
2248-
.firstWhere(
2249-
(d) => d.path.contains('chrome-mac'),
2250-
orElse: () => Directory(installDir),
2251-
);
2301+
final appDir =
2302+
Directory(installDir).listSync().whereType<Directory>().firstWhere(
2303+
(d) => d.path.contains('chrome-mac'),
2304+
orElse: () => Directory(installDir),
2305+
);
22522306
await Process.run('xattr', ['-cr', appDir.path]);
22532307
}
22542308

@@ -2431,7 +2485,10 @@ class CdpDriver implements AppDriver {
24312485
client.close();
24322486

24332487
final tabs = jsonDecode(body) as List;
2434-
final pageTabs = tabs.where((t) => t is Map && t['type'] == 'page').cast<Map>().toList();
2488+
final pageTabs = tabs
2489+
.where((t) => t is Map && t['type'] == 'page')
2490+
.cast<Map>()
2491+
.toList();
24352492

24362493
// Parse target host for domain matching
24372494
final targetUri = _url.isNotEmpty ? Uri.tryParse(_url) : null;
@@ -2482,7 +2539,9 @@ class CdpDriver implements AppDriver {
24822539
// NEVER navigate an unrelated site's tab to our URL.
24832540
for (final tab in pageTabs) {
24842541
final tabUrl = tab['url']?.toString() ?? '';
2485-
if (tabUrl == 'about:blank' || tabUrl == 'chrome://newtab/' || tabUrl == 'chrome://new-tab-page/') {
2542+
if (tabUrl == 'about:blank' ||
2543+
tabUrl == 'chrome://newtab/' ||
2544+
tabUrl == 'chrome://new-tab-page/') {
24862545
return tab['webSocketDebuggerUrl'] as String?;
24872546
}
24882547
}
@@ -2754,7 +2813,7 @@ function _dqAll(sel, root) {
27542813
s -= Math.min((e.textContent || '').length, 999);
27552814
return s;
27562815
}
2757-
const all = _dqAll('a, button, input, select, textarea, label, span, p, h1, h2, h3, h4, h5, h6, div, li, td, th, [role]');
2816+
const all = _dqAll('*');
27582817
// Exact match — pick best scored
27592818
let best = null, bestScore = -Infinity;
27602819
for (const e of all) {

0 commit comments

Comments
 (0)