@astur-mobile/cli 0.1.0-beta.4 → 0.3.0-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -35,6 +35,73 @@ export function startInspectorServer(inspector, device, options) {
35
35
  let gestureCommandInFlight = false;
36
36
  let lastGestureCommandAt = 0;
37
37
  const bundleUploads = new Map();
38
+ // ── WebView DOM probe ──────────────────────────────────────────────────────
39
+ // Pulling a WebView's DOM means opening a remote-debugging transport, which is
40
+ // far heavier than a native tree read — so probe it on its own adaptive cadence
41
+ // (fast while a WebView is present, slow while absent) and cache the raw DOM.
42
+ // Each native tree push re-splices the cached DOM against the *current* native
43
+ // nodes (cheap, in-memory) so coordinates track the live host node.
44
+ let cachedWebTree;
45
+ let webProbeNextAt = 0;
46
+ let webProbeInFlight = false;
47
+ const WEB_PROBE_PRESENT_MS = 1_500;
48
+ const WEB_PROBE_ABSENT_MS = 6_000;
49
+ // ── Device-action scheduler ────────────────────────────────────────────
50
+ // Serialize device actions ahead of the background screenshot/tree/WebView
51
+ // polls. While a fill/tap/swipe/etc. runs, polling is paused so it cannot
52
+ // pile commands onto the single-threaded agent, re-focus the field mid-type
53
+ // (the "autofill tag keeps reappearing" retry feel), or dispose the WebView
54
+ // context between two fills. After the last action settles, refresh once.
55
+ let pendingDeviceActions = 0;
56
+ let actionSettleTimer;
57
+ const ACTION_SETTLE_MS = 350;
58
+ const devicePollingPaused = () => pendingDeviceActions > 0 || actionSettleTimer !== undefined;
59
+ async function runDeviceAction(fn) {
60
+ pendingDeviceActions += 1;
61
+ if (actionSettleTimer) {
62
+ clearTimeout(actionSettleTimer);
63
+ actionSettleTimer = undefined;
64
+ }
65
+ try {
66
+ return await fn();
67
+ }
68
+ finally {
69
+ pendingDeviceActions -= 1;
70
+ if (pendingDeviceActions === 0) {
71
+ // Let the keyboard/scroll animation finish, then do a single refresh
72
+ // (one tree+frame read) instead of many mid-action reads.
73
+ actionSettleTimer = setTimeout(() => {
74
+ actionSettleTimer = undefined;
75
+ void syncInspectorState();
76
+ }, ACTION_SETTLE_MS);
77
+ }
78
+ }
79
+ }
80
+ function maybeProbeWebTree() {
81
+ if (!activeInspector.webSnapshot || webProbeInFlight || Date.now() < webProbeNextAt) {
82
+ return;
83
+ }
84
+ webProbeInFlight = true;
85
+ void (async () => {
86
+ try {
87
+ const web = await activeInspector.webSnapshot?.();
88
+ const had = Boolean(cachedWebTree);
89
+ cachedWebTree = web ?? undefined;
90
+ webProbeNextAt = Date.now() + (cachedWebTree ? WEB_PROBE_PRESENT_MS : WEB_PROBE_ABSENT_MS);
91
+ // Surface a newly found (or newly lost) WebView DOM promptly.
92
+ if (Boolean(cachedWebTree) !== had) {
93
+ void pushTree({ reportFirstError: false }).catch(() => undefined);
94
+ }
95
+ }
96
+ catch {
97
+ cachedWebTree = undefined;
98
+ webProbeNextAt = Date.now() + WEB_PROBE_ABSENT_MS;
99
+ }
100
+ finally {
101
+ webProbeInFlight = false;
102
+ }
103
+ })();
104
+ }
38
105
  // ── HTTP server ────────────────────────────────────────────────────────
39
106
  const server = createServer((req, res) => {
40
107
  const url = req.url ?? '/';
@@ -348,16 +415,18 @@ export function startInspectorServer(inspector, device, options) {
348
415
  }
349
416
  if (shouldRecordTap && suggestions[0]) {
350
417
  try {
351
- if (options.performTap) {
352
- await options.performTap({ x: event.x, y: event.y });
353
- }
354
- else {
355
- await activeInspector.executeAction({
356
- kind: 'tap',
357
- selector: suggestions[0].selector,
358
- options: { timeout: 2_000 }
359
- });
360
- }
418
+ await runDeviceAction(async () => {
419
+ if (options.performTap) {
420
+ await options.performTap({ x: event.x, y: event.y });
421
+ }
422
+ else {
423
+ await activeInspector.executeAction({
424
+ kind: 'tap',
425
+ selector: suggestions[0].selector,
426
+ options: { timeout: 2_000 }
427
+ });
428
+ }
429
+ });
361
430
  }
362
431
  catch (error) {
363
432
  ws.send(JSON.stringify({
@@ -373,30 +442,39 @@ export function startInspectorServer(inspector, device, options) {
373
442
  };
374
443
  steps.push(step);
375
444
  broadcast({ type: 'step', ...step });
376
- await syncInspectorState();
377
445
  broadcast({ type: 'status', message: 'Action OK: Tap recorded' });
378
446
  }
379
447
  break;
380
448
  }
381
449
  case 'direct_action': {
450
+ // The fill/tap buttons act on the currently selected node. When that is a
451
+ // spliced WebView DOM node, drive it by its DOM locator (in-page JS)
452
+ // instead of the native driver.
453
+ const selectedWebNode = selectedUid
454
+ ? currentNodes.find((node) => node.uid === selectedUid && node.web)
455
+ : undefined;
382
456
  try {
383
- if (event.action === 'fill') {
384
- await activeInspector.executeAction({
385
- kind: 'fill',
386
- selector: event.selector,
387
- value: event.value ?? '',
388
- options: { timeout: 2_000 }
389
- });
390
- }
391
- else {
392
- await activeInspector.executeAction({
393
- kind: 'tap',
394
- selector: event.selector,
395
- options: { timeout: 2_000 }
396
- });
397
- }
398
- await syncInspectorState();
399
- broadcast({ type: 'status', message: `Action OK: ${event.action === 'fill' ? 'Filled' : 'Tapped'}` });
457
+ await runDeviceAction(async () => {
458
+ if (selectedWebNode?.web && activeInspector.webAct) {
459
+ await activeInspector.webAct(selectedWebNode.web, event.action === 'fill' ? 'fill' : 'tap', event.value);
460
+ }
461
+ else if (event.action === 'fill') {
462
+ await activeInspector.executeAction({
463
+ kind: 'fill',
464
+ selector: event.selector,
465
+ value: event.value ?? '',
466
+ options: { timeout: 2_000 }
467
+ });
468
+ }
469
+ else {
470
+ await activeInspector.executeAction({
471
+ kind: 'tap',
472
+ selector: event.selector,
473
+ options: { timeout: 2_000 }
474
+ });
475
+ }
476
+ });
477
+ broadcast({ type: 'status', message: `Action OK: ${event.action === 'fill' ? 'Filled' : 'Tapped'}${selectedWebNode ? ' (web)' : ''}` });
400
478
  }
401
479
  catch (error) {
402
480
  ws.send(JSON.stringify({
@@ -491,6 +569,29 @@ export function startInspectorServer(inspector, device, options) {
491
569
  setTimeout(() => process.exit(0), 300);
492
570
  break;
493
571
  }
572
+ case 'release_session': {
573
+ if (!options.releaseSession) {
574
+ ws.send(JSON.stringify({ type: 'status', message: 'Action Error: Release is unavailable in this session.' }));
575
+ break;
576
+ }
577
+ broadcast({ type: 'status', message: 'Action Pending: Releasing session...' });
578
+ try {
579
+ await options.releaseSession();
580
+ }
581
+ catch (error) {
582
+ broadcast({
583
+ type: 'status',
584
+ message: `Action Error: Release failed: ${formatActionError(error)}`
585
+ });
586
+ break;
587
+ }
588
+ broadcast({
589
+ type: 'terminated',
590
+ message: 'Session released. The device session was closed; the emulator/simulator was left running. You can close this tab.'
591
+ });
592
+ setTimeout(() => process.exit(0), 300);
593
+ break;
594
+ }
494
595
  case 'app_action': {
495
596
  if (!options.performAppAction) {
496
597
  ws.send(JSON.stringify({ type: 'status', message: 'Action Error: App actions are unavailable in this session.' }));
@@ -689,7 +790,7 @@ export function startInspectorServer(inspector, device, options) {
689
790
  let frameTimer;
690
791
  let frameInFlight = false;
691
792
  async function pushFrame() {
692
- if (clients.size === 0 || frameInFlight) {
793
+ if (clients.size === 0 || frameInFlight || devicePollingPaused()) {
693
794
  return 'busy';
694
795
  }
695
796
  frameInFlight = true;
@@ -740,7 +841,9 @@ export function startInspectorServer(inspector, device, options) {
740
841
  let lastTreeErrorAt = 0;
741
842
  let consecutiveTreeErrors = 0;
742
843
  async function pushTree(options = {}) {
743
- if (treeInFlight) {
844
+ // Bootstrap/manual refreshes (reportFirstError) bypass the action gate;
845
+ // background polls pause while a device action is running or settling.
846
+ if (treeInFlight || (devicePollingPaused() && !options.reportFirstError)) {
744
847
  return 'busy';
745
848
  }
746
849
  // Avoid expensive UI-tree reads (e.g. adb `uiautomator dump`, broad XCUI
@@ -753,6 +856,17 @@ export function startInspectorServer(inspector, device, options) {
753
856
  try {
754
857
  for await (const update of activeInspector.subscribeTree({ maxUpdates: 1 })) {
755
858
  const nodes = flattenSnapshot(update.root);
859
+ // Splice any WebView DOM (cached from the adaptive probe) under its native
860
+ // host so its elements appear in the tree with real DOM locators.
861
+ maybeProbeWebTree();
862
+ if (cachedWebTree) {
863
+ try {
864
+ nodes.push(...buildWebNodes(cachedWebTree, nodes));
865
+ }
866
+ catch {
867
+ // Best-effort: never let WebView DOM block the native tree.
868
+ }
869
+ }
756
870
  const viewport = estimateViewport(nodes);
757
871
  currentNodes = nodes;
758
872
  currentViewport = viewport;
@@ -813,7 +927,12 @@ export function startInspectorServer(inspector, device, options) {
813
927
  return { frame, tree: 'busy' };
814
928
  }
815
929
  function scheduleTree() {
816
- const interval = clients.size === 0
930
+ // Back off hard when the tree read keeps failing/timing out — e.g. XCUITest
931
+ // stalling on a heavy WebView snapshot. Retrying every interval just piles
932
+ // commands onto an already-stuck agent; poll slowly until it recovers (a
933
+ // successful read resets consecutiveTreeErrors and snaps back to base).
934
+ const stalled = consecutiveTreeErrors >= 3;
935
+ const interval = clients.size === 0 || stalled
817
936
  ? maxIdleIntervalMs
818
937
  : nextPollInterval(baseTreeIntervalMs, treeIdleStreak, maxIdleIntervalMs);
819
938
  treeTimer = setTimeout(async () => {
@@ -929,6 +1048,98 @@ function flattenNode(node, depth, uid, parentUid) {
929
1048
  bounds: node.bounds,
930
1049
  };
931
1050
  }
1051
+ function webLocatorCode(descriptor) {
1052
+ switch (descriptor.strategy) {
1053
+ case 'testid': return `device.getByTestId(${JSON.stringify(descriptor.value)})`;
1054
+ case 'id': return `device.getById(${JSON.stringify(descriptor.value)})`;
1055
+ case 'role': return `device.getByRole(${JSON.stringify(descriptor.value)}${descriptor.name ? `, { name: ${JSON.stringify(descriptor.name)} }` : ''})`;
1056
+ case 'text': return `device.getByText(${JSON.stringify(descriptor.value)})`;
1057
+ default: return `device.locator(${JSON.stringify(descriptor.value)})`;
1058
+ }
1059
+ }
1060
+ function webLocatorSuggestion(descriptor) {
1061
+ const code = webLocatorCode(descriptor);
1062
+ // Web actions are routed by the DOM locator (node.web), not this selector, so it
1063
+ // only needs to be a valid placeholder for display/typing.
1064
+ const selector = descriptor.strategy === 'role' ? { strategy: 'role', value: descriptor.value, name: descriptor.name }
1065
+ : descriptor.strategy === 'text' ? { strategy: 'text', value: descriptor.value }
1066
+ : { strategy: 'id', value: descriptor.value };
1067
+ return { code, selector, score: 0.96, uniqueness: 1, stability: 0.9, readable: code.length <= 88, crossPlatform: true };
1068
+ }
1069
+ function webNodeTitle(node) {
1070
+ const tag = node.tag.toLowerCase();
1071
+ const handle = node.testId ? `[testid=${node.testId}]` : node.id ? `#${node.id}` : '';
1072
+ const name = (node.name ?? '').replace(/\s+/g, ' ').trim().slice(0, 40);
1073
+ return `<${tag}>${handle ? ` ${handle}` : ''}${name ? ` ${name}` : ''}`.trim();
1074
+ }
1075
+ /**
1076
+ * Locate the native node that hosts the WebView surface: the one whose device-px
1077
+ * bounds best match the web viewport scaled by the device pixel ratio. Used to
1078
+ * map DOM (CSS px) coordinates onto the device screen for overlays/hit-testing.
1079
+ */
1080
+ function findWebHostNode(web, nativeNodes) {
1081
+ const dpr = web.devicePixelRatio || 1;
1082
+ const targetW = web.viewport.width * dpr;
1083
+ const targetH = web.viewport.height * dpr;
1084
+ if (targetW < 8 || targetH < 8) {
1085
+ return undefined;
1086
+ }
1087
+ let best;
1088
+ let bestScore = Number.POSITIVE_INFINITY;
1089
+ for (const node of nativeNodes) {
1090
+ if (!node.visible || node.bounds.width < 8 || node.bounds.height < 8) {
1091
+ continue;
1092
+ }
1093
+ const dw = Math.abs(node.bounds.width - targetW);
1094
+ const dh = Math.abs(node.bounds.height - targetH);
1095
+ if (dw <= targetW * 0.25 && dh <= targetH * 0.25 && dw + dh < bestScore) {
1096
+ best = node;
1097
+ bestScore = dw + dh;
1098
+ }
1099
+ }
1100
+ return best;
1101
+ }
1102
+ /**
1103
+ * Flatten a WebView DOM snapshot into UiNodes spliced under its native host. DOM
1104
+ * actions run by locator (engine JS), so even when the host can't be matched the
1105
+ * nodes stay browsable/fillable — only the screenshot overlay needs the bounds.
1106
+ */
1107
+ function buildWebNodes(web, nativeNodes) {
1108
+ if (!web.root) {
1109
+ return [];
1110
+ }
1111
+ const host = findWebHostNode(web, nativeNodes);
1112
+ const dpr = web.devicePixelRatio || 1;
1113
+ const mapBounds = (b) => host
1114
+ ? {
1115
+ x: Math.round(host.bounds.x + b.x * dpr),
1116
+ y: Math.round(host.bounds.y + b.y * dpr),
1117
+ width: Math.round(b.width * dpr),
1118
+ height: Math.round(b.height * dpr)
1119
+ }
1120
+ : { x: 0, y: 0, width: 0, height: 0 };
1121
+ const out = [];
1122
+ const visit = (node, depth, uid, parentUid) => {
1123
+ out.push({
1124
+ uid,
1125
+ parentUid,
1126
+ depth,
1127
+ title: webNodeTitle(node),
1128
+ type: `web:${node.tag.toLowerCase()}`,
1129
+ id: node.id,
1130
+ label: node.name,
1131
+ text: node.role ? undefined : node.name,
1132
+ value: node.value,
1133
+ visible: node.visible,
1134
+ enabled: node.enabled,
1135
+ bounds: mapBounds(node.bounds),
1136
+ web: node.locator
1137
+ });
1138
+ node.children.forEach((child, index) => visit(child, depth + 1, `${uid}/${index}`, uid));
1139
+ };
1140
+ visit(web.root, host ? host.depth + 1 : 1, 'web', host?.uid);
1141
+ return out;
1142
+ }
932
1143
  function estimateViewport(nodes) {
933
1144
  const root = nodes[0];
934
1145
  if (root?.bounds && root.bounds.width > 0 && root.bounds.height > 0) {
@@ -995,6 +1206,10 @@ const INSPECTOR_ROLES = [
995
1206
  'textbox'
996
1207
  ];
997
1208
  function suggestLocatorsForNode(node, nodes) {
1209
+ if (node.web) {
1210
+ // DOM nodes carry the in-page best locator; the native ranking doesn't apply.
1211
+ return [webLocatorSuggestion(node.web)];
1212
+ }
998
1213
  const target = resolveInspectableNode(node, nodes);
999
1214
  const candidates = buildLocalLocatorCandidates(target);
1000
1215
  const suggestions = [];
@@ -1093,6 +1308,11 @@ function buildLocalLocatorCandidates(node) {
1093
1308
  return candidates;
1094
1309
  }
1095
1310
  function resolveInspectableNode(node, nodes, options = {}) {
1311
+ // WebView DOM nodes are already their own best target — never re-resolve them to
1312
+ // a native ancestor, or selection/action routing would lose the DOM locator.
1313
+ if (node.web) {
1314
+ return node;
1315
+ }
1096
1316
  const targetIsUsable = hasUsableLocator(node) && !isDecorativeNode(node);
1097
1317
  const targetIsActionable = targetIsUsable && isActionableNode(node);
1098
1318
  if (!options.preferActionable && targetIsUsable) {
@@ -1770,10 +1990,11 @@ html,body{height:100%;overflow:hidden;background:var(--bg);color:var(--text);fon
1770
1990
  ${renderInspectorDeviceActionMenu(device)}
1771
1991
  <div class="device-menu-section">
1772
1992
  <div class="device-menu-label">Session</div>
1773
- <div class="device-row">
1774
- <button type="button" class="device-action-btn danger" id="terminate-session-btn">Terminate session</button>
1993
+ <div class="device-menu-actions">
1994
+ <button type="button" class="device-action-btn icon" id="release-session-btn" title="Release session" aria-label="Release session">${iconSvg('<path d="M5 17h14"/><path d="M12 4 5 13h14z"/>')}</button>
1995
+ <button type="button" class="device-action-btn icon danger" id="terminate-session-btn" title="Terminate session" aria-label="Terminate session">${iconSvg('<path d="M12 2v10"/><path d="M18.4 6.6a9 9 0 1 1-12.8 0"/>')}</button>
1775
1996
  </div>
1776
- <div class="device-help">Closes the device session and powers off the emulator/simulator to free memory. Real devices stay on.</div>
1997
+ <div class="device-help"><strong>Release</strong> closes the device session but leaves the emulator/simulator running for reuse. <strong>Terminate</strong> also powers it off to free memory. Real devices stay on.</div>
1777
1998
  </div>
1778
1999
  </div>
1779
2000
  </div>
@@ -1842,7 +2063,11 @@ html,body{height:100%;overflow:hidden;background:var(--bg);color:var(--text);fon
1842
2063
  <div id="right-panel">
1843
2064
  <!-- Tree -->
1844
2065
  <div id="tree-panel">
1845
- <div class="panel-header">UI Tree <span id="tree-badge" style="font-weight:400;color:var(--text-muted)"></span></div>
2066
+ <div class="panel-header" style="display:flex;align-items:center;gap:8px">
2067
+ <span>UI Tree</span>
2068
+ <span id="tree-badge" style="font-weight:400;color:var(--text-muted)"></span>
2069
+ <button type="button" class="device-action-btn icon" id="tree-refresh-btn" title="Refresh UI tree" aria-label="Refresh UI tree" style="margin-left:auto;width:26px;height:24px">${inspectorDeviceActionIcon('tree.refresh')}</button>
2070
+ </div>
1846
2071
  <div id="tree-search-row">
1847
2072
  <input id="tree-search" type="search" placeholder="Search element…"/>
1848
2073
  </div>
@@ -1919,6 +2144,20 @@ let activeTab = 'code';
1919
2144
  let currentDevice = ${JSON.stringify(toBootstrapDevice(device))};
1920
2145
  let devices = [currentDevice];
1921
2146
  let busyCount = 0;
2147
+ let busyWatchdog = null;
2148
+ // Hard ceiling on how long the "Running action…" overlay may stay up. If the
2149
+ // device/agent stops responding (e.g. XCUITest stalls while snapshotting a heavy
2150
+ // WebView), no status ever comes back and the overlay would otherwise spin
2151
+ // forever, bricking the session. The watchdog force-clears it so the user can
2152
+ // keep working (switch screens, terminate, etc.).
2153
+ //
2154
+ // Must sit ABOVE the native agent command timeout (iOS default 30s) — otherwise a
2155
+ // slow-but-succeeding action gets a premature, misleading "timed out" while the
2156
+ // agent is still completing it (e.g. filling a field inside a never-idle WKWebView,
2157
+ // where each keystroke's bounded idle-wait makes a clear+type land around ~16s). At
2158
+ // 35s the agent's own success/timeout response always arrives first, so the user
2159
+ // sees the real outcome; this stays purely a backstop for a truly hung session.
2160
+ const BUSY_WATCHDOG_MS = 35000;
1922
2161
  let socketConnected = false;
1923
2162
  let hasFrame = false;
1924
2163
  let hasTree = false;
@@ -1990,6 +2229,8 @@ const revokePermissionBtn = $('revoke-permission-btn');
1990
2229
  const clearDataBtn = $('clear-data-btn');
1991
2230
  const clearCacheBtn = $('clear-cache-btn');
1992
2231
  const terminateSessionBtn = $('terminate-session-btn');
2232
+ const releaseSessionBtn = $('release-session-btn');
2233
+ const treeRefreshBtn = $('tree-refresh-btn');
1993
2234
  const main = $('main');
1994
2235
  const leftColumnSplitter = $('left-column-splitter');
1995
2236
  const rightColumnSplitter = $('right-column-splitter');
@@ -2194,6 +2435,25 @@ function toggleDeviceSwitcher() {
2194
2435
 
2195
2436
  function setBusy(active) {
2196
2437
  busyCount = Math.max(0, busyCount + (active ? 1 : -1));
2438
+ if (busyWatchdog) {
2439
+ clearTimeout(busyWatchdog);
2440
+ busyWatchdog = null;
2441
+ }
2442
+ if (busyCount > 0) {
2443
+ busyWatchdog = setTimeout(onBusyWatchdogExpired, BUSY_WATCHDOG_MS);
2444
+ }
2445
+ updateDeviceReadiness();
2446
+ }
2447
+
2448
+ // Fires when an action's overlay has been up too long with no response. Clears
2449
+ // the overlay and tells the user the session is still live so they can recover
2450
+ // (common when a native+web hybrid screen leaves XCUITest mid-snapshot).
2451
+ function onBusyWatchdogExpired() {
2452
+ busyWatchdog = null;
2453
+ if (busyCount === 0) return;
2454
+ busyCount = 0;
2455
+ releaseGestureLock();
2456
+ showDeviceStatus('Action timed out — the device is slow to respond (a heavy web view can stall the UI tree). The session is still live; try again or switch screens.', 'error');
2197
2457
  updateDeviceReadiness();
2198
2458
  }
2199
2459
 
@@ -2755,6 +3015,10 @@ function nodeRoles(node) {
2755
3015
  if (type.includes('switch')) roles.push('switch');
2756
3016
  if (type.includes('tab')) roles.push('tab');
2757
3017
  if (type.includes('edittext') || type.includes('textfield') || type.includes('securetextfield') || type.includes('searchfield') || type.includes('textinput')) roles.push('textbox');
3018
+ // Spliced WebView DOM nodes ('web:input', 'web:textarea'): text inputs are fillable
3019
+ // (and contenteditable surfaces). Without this the Fill control stays disabled for
3020
+ // web-context elements even though the server routes them through webAct.
3021
+ if (type === 'web:input' || type === 'web:textarea' || type.includes('contenteditable')) roles.push('textbox');
2758
3022
  return roles;
2759
3023
  }
2760
3024
 
@@ -2814,18 +3078,25 @@ function renderDetails(node) {
2814
3078
 
2815
3079
  // ── Mirror click ───────────────────────────────────────────────────────────
2816
3080
  function resolveMirrorSourceSize() {
2817
- const viewportWidth = Number(viewport.width || 0);
2818
- const viewportHeight = Number(viewport.height || 0);
2819
- if (viewportWidth > 1 && viewportHeight > 1) {
2820
- return { width: viewportWidth, height: viewportHeight };
2821
- }
2822
-
3081
+ // Size the stage to the screenshot's own pixels first: those are exactly what
3082
+ // gets drawn, and the image is sized with explicit width/height (no
3083
+ // object-fit), so any aspect-ratio mismatch stretches it. On rotation the
3084
+ // landscape frame arrives a tick before the tree's viewport updates — if we
3085
+ // trusted the (still portrait) viewport here, the landscape image would be
3086
+ // squeezed into a portrait stage. Fall back to the viewport, then a square,
3087
+ // until the first frame has loaded.
2823
3088
  const naturalWidth = Number(mirrorImg.naturalWidth || 0);
2824
3089
  const naturalHeight = Number(mirrorImg.naturalHeight || 0);
2825
3090
  if (naturalWidth > 1 && naturalHeight > 1) {
2826
3091
  return { width: naturalWidth, height: naturalHeight };
2827
3092
  }
2828
3093
 
3094
+ const viewportWidth = Number(viewport.width || 0);
3095
+ const viewportHeight = Number(viewport.height || 0);
3096
+ if (viewportWidth > 1 && viewportHeight > 1) {
3097
+ return { width: viewportWidth, height: viewportHeight };
3098
+ }
3099
+
2829
3100
  return { width: 1, height: 1 };
2830
3101
  }
2831
3102
 
@@ -3524,10 +3795,23 @@ terminateSessionBtn.addEventListener('click', () => {
3524
3795
  if (!confirm('Terminate this inspector session?\\n\\nThe device session will be closed and any emulator or simulator will be powered off. Real devices stay on.')) return;
3525
3796
  closeDeviceMenu();
3526
3797
  terminateSessionBtn.disabled = true;
3527
- terminateSessionBtn.textContent = 'Terminating…';
3798
+ releaseSessionBtn.disabled = true;
3528
3799
  send({ type: 'terminate_session' });
3529
3800
  });
3530
3801
 
3802
+ releaseSessionBtn.addEventListener('click', () => {
3803
+ if (!confirm('Release this inspector session?\\n\\nThe device session will be closed, but the emulator or simulator will stay running so it can be reused without a cold boot.')) return;
3804
+ closeDeviceMenu();
3805
+ releaseSessionBtn.disabled = true;
3806
+ terminateSessionBtn.disabled = true;
3807
+ send({ type: 'release_session' });
3808
+ });
3809
+
3810
+ treeRefreshBtn.addEventListener('click', () => {
3811
+ showDeviceStatus('Refreshing UI tree...', 'pending');
3812
+ send({ type: 'device_action', action: 'tree.refresh' });
3813
+ });
3814
+
3531
3815
  document.addEventListener('click', () => {
3532
3816
  closeDeviceMenu();
3533
3817
  closeDeviceSwitcher();