reactoradar 1.6.0 → 1.6.2

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.
package/app.js CHANGED
@@ -203,6 +203,7 @@ function clearActiveTab() {
203
203
  function clearAll() {
204
204
  state.console.logs = [];
205
205
  _consolePending = [];
206
+ _lastLogMsg = ''; _lastLogRow = null; _lastLogCount = 1;
206
207
  state.network.requests = {};
207
208
  state.network.order = [];
208
209
  state.network.selectedId = null;
@@ -213,14 +214,31 @@ function clearAll() {
213
214
  state.storage.entries = {};
214
215
  state.storage.keys = [];
215
216
  state.storage.selected = null;
217
+ // GA4
218
+ ga4State.events = [];
219
+ ga4State.selected = -1;
220
+ ga4State.searchFilter = '';
221
+ const ga4Search = $('ga4Search');
222
+ if (ga4Search) ga4Search.value = '';
223
+ const ga4Detail = $('ga4Detail');
224
+ if (ga4Detail) ga4Detail.innerHTML = '';
225
+ // Native logs
226
+ _nativeState.logs = [];
227
+ const nativeList = $('nativeLogList');
228
+ if (nativeList) nativeList.innerHTML = '';
229
+ // Badges
216
230
  $('cBadge').textContent = '0';
217
231
  $('nBadge').textContent = '0';
218
232
  $('rBadge').textContent = '0';
219
233
  $('sBadge').textContent = '0';
234
+ if ($('ga4Badge')) $('ga4Badge').textContent = '0';
235
+ if ($('nativeBadge')) $('nativeBadge').textContent = '0';
236
+ // Re-render all
220
237
  renderConsole();
221
238
  renderNetwork();
222
239
  renderRedux();
223
240
  renderStorage();
241
+ if (typeof renderGA4List === 'function') { renderGA4List(); renderGA4Summary(); }
224
242
  }
225
243
 
226
244
  // ─── CDP Button ───────────────────────────────────────────────────────────────
@@ -658,6 +676,10 @@ const MAX_CONSOLE_LOGS = 5000;
658
676
 
659
677
  function addConsoleLog(event) {
660
678
  state.console.logs.push(event);
679
+ // Cap in-memory logs to prevent memory leak
680
+ if (state.console.logs.length > MAX_CONSOLE_LOGS) {
681
+ state.console.logs = state.console.logs.slice(-MAX_CONSOLE_LOGS);
682
+ }
661
683
  _consolePending.push(event);
662
684
 
663
685
  // Batch DOM updates via rAF — only one paint per frame
@@ -2038,9 +2060,15 @@ function initGA4Panel() {
2038
2060
  $('ga4Clear').addEventListener('click', () => {
2039
2061
  ga4State.events = [];
2040
2062
  ga4State.selected = -1;
2063
+ ga4State.searchFilter = '';
2064
+ const search = $('ga4Search');
2065
+ if (search) search.value = '';
2041
2066
  $('ga4Badge').textContent = '0';
2042
2067
  renderGA4List();
2043
2068
  renderGA4Summary();
2069
+ // Clear detail pane
2070
+ const detail = $('ga4Detail');
2071
+ if (detail) detail.innerHTML = '<div class="ga4-detail-empty" style="color:var(--text-dim);padding:20px;text-align:center;font-size:11px">Select an event to view details</div>';
2044
2072
  });
2045
2073
 
2046
2074
  $('ga4SortBtn').addEventListener('click', () => {
@@ -2154,7 +2182,7 @@ function renderGA4List() {
2154
2182
  const row = document.createElement('div');
2155
2183
  row.className = 'ga4-row' + (e.index === ga4State.selected ? ' selected' : '');
2156
2184
 
2157
- const time = new Date(e.ts).toLocaleTimeString('en', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit', fractionalSecondDigits: 3 });
2185
+ const time = new Date(e.ts).toLocaleTimeString('en', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' });
2158
2186
 
2159
2187
  const evtColor = _ga4EventColor(e.name);
2160
2188
  const colorStyle = evtColor ? `color:${evtColor}` : '';
@@ -2184,12 +2212,15 @@ function renderGA4List() {
2184
2212
  }
2185
2213
 
2186
2214
  function renderGA4Detail(e) {
2187
- const detail = $('ga4Detail');
2215
+ let detail = $('ga4Detail');
2188
2216
  if (!detail) return;
2189
2217
 
2190
- const time = new Date(e.ts).toLocaleTimeString('en', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit', fractionalSecondDigits: 3 });
2218
+ const time = new Date(e.ts).toLocaleTimeString('en', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' });
2191
2219
 
2192
- detail.innerHTML = '';
2220
+ // Clone-replace to remove stale event listeners
2221
+ const fresh = detail.cloneNode(false);
2222
+ detail.parentNode.replaceChild(fresh, detail);
2223
+ detail = fresh;
2193
2224
 
2194
2225
  // Header info
2195
2226
  const header = document.createElement('div');
@@ -2748,8 +2779,8 @@ function initStoragePanel() {
2748
2779
  <input id="storageSearch" class="net-search-input" placeholder="Filter keys..." />
2749
2780
  </div>
2750
2781
  </div>
2751
- <div class="storage-layout">
2752
- <div class="storage-keys">
2782
+ <div class="storage-layout" id="storageLayout">
2783
+ <div class="storage-keys" id="storageKeysPane">
2753
2784
  <div class="panel-toolbar" style="height:32px">
2754
2785
  <span style="font-size:10px;color:var(--text-dim);text-transform:uppercase;letter-spacing:1px">Keys</span>
2755
2786
  </div>
@@ -2757,10 +2788,11 @@ function initStoragePanel() {
2757
2788
  <div class="empty-state" id="storageEmpty">
2758
2789
  <div class="icon">💾</div>
2759
2790
  <div class="label">No storage data</div>
2760
- <div class="hint">Add storage plugin to RNDebugPlugin</div>
2791
+ <div class="hint">AsyncStorage data will appear here</div>
2761
2792
  </div>
2762
2793
  </div>
2763
2794
  </div>
2795
+ <div class="storage-resize-handle" id="storageResizeHandle"></div>
2764
2796
  <div class="storage-value-view">
2765
2797
  <div class="storage-value-toolbar">
2766
2798
  <span style="font-size:10px;color:var(--text-dim);text-transform:uppercase;letter-spacing:1px">Value</span>
@@ -2776,6 +2808,35 @@ function initStoragePanel() {
2776
2808
  state.storage.searchFilter = e.target.value.toLowerCase().trim();
2777
2809
  renderStorage();
2778
2810
  });
2811
+
2812
+ // Drag resize handle for key list width
2813
+ const handle = $('storageResizeHandle');
2814
+ const layout = $('storageLayout');
2815
+ const keysPane = $('storageKeysPane');
2816
+ if (handle && layout && keysPane) {
2817
+ let dragging = false;
2818
+ let startX = 0;
2819
+ let startW = 0;
2820
+ handle.addEventListener('mousedown', (e) => {
2821
+ e.preventDefault();
2822
+ dragging = true;
2823
+ startX = e.clientX;
2824
+ startW = keysPane.offsetWidth;
2825
+ document.body.style.cursor = 'col-resize';
2826
+ document.body.style.userSelect = 'none';
2827
+ });
2828
+ document.addEventListener('mousemove', (e) => {
2829
+ if (!dragging) return;
2830
+ const newW = Math.max(120, Math.min(600, startW + (e.clientX - startX)));
2831
+ layout.style.gridTemplateColumns = `${newW}px 4px 1fr`;
2832
+ });
2833
+ document.addEventListener('mouseup', () => {
2834
+ if (!dragging) return;
2835
+ dragging = false;
2836
+ document.body.style.cursor = '';
2837
+ document.body.style.userSelect = '';
2838
+ });
2839
+ }
2779
2840
  }
2780
2841
 
2781
2842
  let _storageRAF = null;
@@ -2851,7 +2912,7 @@ function renderStorage() {
2851
2912
  }
2852
2913
 
2853
2914
  function renderStorageValue() {
2854
- const body = $('storageValueBody');
2915
+ let body = $('storageValueBody');
2855
2916
  const keyLabel = $('storageSelectedKey');
2856
2917
  if (!body) return;
2857
2918
  const { selected, entries } = state.storage;
@@ -2861,7 +2922,29 @@ function renderStorageValue() {
2861
2922
  return;
2862
2923
  }
2863
2924
  if (keyLabel) keyLabel.textContent = selected;
2864
- body.innerHTML = renderJSON(entries[selected]);
2925
+ // Clone-replace to remove stale event listeners
2926
+ const fresh = body.cloneNode(false);
2927
+ body.parentNode.replaceChild(fresh, body);
2928
+ body = fresh;
2929
+
2930
+ let val = entries[selected];
2931
+ // Try to parse JSON strings into objects for tree display
2932
+ if (typeof val === 'string') {
2933
+ try { val = JSON.parse(val); } catch {}
2934
+ }
2935
+
2936
+ if (val && typeof val === 'object') {
2937
+ body.appendChild(createTreeNode(null, val, false));
2938
+ body.addEventListener('contextmenu', (e) => {
2939
+ e.preventDefault();
2940
+ showContextMenu(e, [
2941
+ { label: 'Copy Value', action: () => navigator.clipboard.writeText(JSON.stringify(val, null, 2)) },
2942
+ { label: 'Copy Key', action: () => navigator.clipboard.writeText(selected) },
2943
+ ]);
2944
+ });
2945
+ } else {
2946
+ body.innerHTML = renderJSON(val);
2947
+ }
2865
2948
  }
2866
2949
 
2867
2950
  function formatSize(bytes) {
@@ -2872,6 +2955,257 @@ function formatSize(bytes) {
2872
2955
  // ─────────────────────────────────────────────────────────────────────────────
2873
2956
  // REACT TREE PANEL
2874
2957
  // ─────────────────────────────────────────────────────────────────────────────
2958
+ // ─────────────────────────────────────────────────────────────────────────────
2959
+ // NATIVE LOGS PANEL
2960
+ // ─────────────────────────────────────────────────────────────────────────────
2961
+ const _nativeState = { logs: [], connected: false, platform: null, levelFilter: 'all', searchFilter: '' };
2962
+ const MAX_NATIVE_LOGS = 2000;
2963
+
2964
+ function initNativeLogsPanel() {
2965
+ const panel = $('panel-native');
2966
+ if (!panel) return;
2967
+ panel.innerHTML = `
2968
+ <div class="panel-toolbar">
2969
+ <span class="panel-label">Native Logs</span>
2970
+ <span class="badge" id="nativeBadge">0</span>
2971
+ <div class="ml-auto" style="display:flex;align-items:center;gap:6px">
2972
+ <span class="native-status" id="nativeStatus">Detecting...</span>
2973
+ <button class="panel-clear-btn" id="nativeClear">Clear</button>
2974
+ </div>
2975
+ </div>
2976
+ <div class="native-connect-panel" id="nativeConnectPanel">
2977
+ <div class="native-hero">
2978
+ <div style="font-size:36px;opacity:0.15;margin-bottom:12px">📱</div>
2979
+ <div style="font-size:14px;font-weight:600;color:var(--text);margin-bottom:6px">Native Logs</div>
2980
+ <div style="font-size:11px;color:var(--text-dim);max-width:420px;line-height:1.7;margin-bottom:20px">
2981
+ Stream native crash logs, errors, and warnings directly in ReactoRadar.<br/>
2982
+ No need to open Android Studio or Xcode.
2983
+ </div>
2984
+ <div class="native-platform-cards">
2985
+ <div class="native-card" id="nativeCardAndroid">
2986
+ <div class="native-card-icon">🤖</div>
2987
+ <div class="native-card-title">Android</div>
2988
+ <div class="native-card-hint">Requires: <code>adb</code> in PATH (Android SDK)</div>
2989
+ <div class="native-card-prereq">
2990
+ <div class="native-prereq-step"><b>Prerequisites:</b></div>
2991
+ <div class="native-prereq-step">1. Enable <b>Developer Options</b> on device<br/><span style="color:var(--text-dim);font-size:9px">Settings → About Phone → Tap Build Number 7 times</span></div>
2992
+ <div class="native-prereq-step">2. Enable <b>USB Debugging</b><br/><span style="color:var(--text-dim);font-size:9px">Settings → Developer Options → USB Debugging → ON</span></div>
2993
+ <div class="native-prereq-step">3. Connect device via USB and accept the prompt</div>
2994
+ <div class="native-prereq-step">4. Verify: run <code>adb devices</code> in terminal</div>
2995
+ </div>
2996
+ <div id="nativeAndroidStatus" class="native-detect-status"></div>
2997
+ <button class="native-connect-btn" id="nativeConnectAndroid">Connect Android</button>
2998
+ </div>
2999
+ <div class="native-card" id="nativeCardIOS">
3000
+ <div class="native-card-icon">🍎</div>
3001
+ <div class="native-card-title">iOS</div>
3002
+ <div class="native-card-hint">Simulator or USB device</div>
3003
+ <div class="native-card-prereq">
3004
+ <div class="native-prereq-step"><b>Simulator:</b></div>
3005
+ <div class="native-prereq-step">Requires Xcode Command Line Tools<br/><code>xcode-select --install</code></div>
3006
+ <div class="native-prereq-step" style="margin-top:6px"><b>Real Device (USB):</b></div>
3007
+ <div class="native-prereq-step">1. Install: <code>brew install libimobiledevice</code></div>
3008
+ <div class="native-prereq-step">2. Connect device, tap <b>Trust</b> on the prompt</div>
3009
+ <div class="native-prereq-step">3. Verify: <code>idevice_id -l</code> shows device UDID</div>
3010
+ </div>
3011
+ <div id="nativeIOSStatus" class="native-detect-status"></div>
3012
+ <div style="display:flex;gap:6px;margin-top:8px">
3013
+ <button class="native-connect-btn" id="nativeConnectIOSSim">Simulator</button>
3014
+ <button class="native-connect-btn" id="nativeConnectIOSDevice">USB Device</button>
3015
+ </div>
3016
+ </div>
3017
+ </div>
3018
+ </div>
3019
+ </div>
3020
+ <div class="native-logs-area" id="nativeLogsArea" style="display:none">
3021
+ <div class="native-filter-bar">
3022
+ <input id="nativeSearch" class="net-search-input" placeholder="Filter logs..." />
3023
+ <div class="native-level-filters" id="nativeLevelFilters">
3024
+ <button class="net-status-btn active" data-level="all">All</button>
3025
+ <button class="net-status-btn" data-level="fatal">Fatal</button>
3026
+ <button class="net-status-btn" data-level="error">Error</button>
3027
+ <button class="net-status-btn" data-level="warn">Warn</button>
3028
+ <button class="net-status-btn" data-level="info">Info</button>
3029
+ <button class="net-status-btn" data-level="debug">Debug</button>
3030
+ </div>
3031
+ <div style="margin-left:auto;display:flex;gap:6px;align-items:center">
3032
+ <button class="panel-clear-btn" id="nativeLogsClear">Clear</button>
3033
+ <button class="panel-clear-btn" id="nativeDisconnect" style="color:var(--red)">Disconnect</button>
3034
+ </div>
3035
+ </div>
3036
+ <div class="native-log-list" id="nativeLogList"></div>
3037
+ </div>`;
3038
+
3039
+ // Connect buttons
3040
+ $('nativeConnectAndroid')?.addEventListener('click', () => window.electronAPI?.startNativeLogs('android'));
3041
+ $('nativeConnectIOSSim')?.addEventListener('click', () => window.electronAPI?.startNativeLogs('ios-sim'));
3042
+ $('nativeConnectIOSDevice')?.addEventListener('click', () => window.electronAPI?.startNativeLogs('ios-device'));
3043
+ $('nativeDisconnect')?.addEventListener('click', () => window.electronAPI?.stopNativeLogs());
3044
+
3045
+ // Clear buttons (toolbar + logs area)
3046
+ $('nativeClear')?.addEventListener('click', _clearNativeLogs);
3047
+ $('nativeLogsClear')?.addEventListener('click', _clearNativeLogs);
3048
+
3049
+ // Level filter
3050
+ $('nativeLevelFilters')?.addEventListener('click', (e) => {
3051
+ const btn = e.target.closest('.net-status-btn');
3052
+ if (!btn) return;
3053
+ $('nativeLevelFilters').querySelectorAll('.net-status-btn').forEach(b => b.classList.remove('active'));
3054
+ btn.classList.add('active');
3055
+ _nativeState.levelFilter = btn.dataset.level;
3056
+ _renderNativeLogs();
3057
+ });
3058
+
3059
+ // Search
3060
+ $('nativeSearch')?.addEventListener('input', (e) => {
3061
+ _nativeState.searchFilter = e.target.value.toLowerCase().trim();
3062
+ _renderNativeLogs();
3063
+ });
3064
+
3065
+ // IPC: receive native logs
3066
+ window.electronAPI?.on('native-log', (log) => {
3067
+ if (!isTabEnabled('native')) return;
3068
+ _nativeState.logs.push(log);
3069
+ if (_nativeState.logs.length > MAX_NATIVE_LOGS) {
3070
+ _nativeState.logs = _nativeState.logs.slice(-MAX_NATIVE_LOGS);
3071
+ }
3072
+ $('nativeBadge').textContent = _nativeState.logs.length;
3073
+ _appendNativeLog(log);
3074
+ });
3075
+
3076
+ // IPC: connection status
3077
+ window.electronAPI?.on('native-status', (status) => {
3078
+ _nativeState.connected = status.connected;
3079
+ _nativeState.platform = status.platform || null;
3080
+ const statusEl = $('nativeStatus');
3081
+ const connectPanel = $('nativeConnectPanel');
3082
+ const logsArea = $('nativeLogsArea');
3083
+
3084
+ if (status.connected) {
3085
+ if (statusEl) { statusEl.textContent = `Connected (${status.platform})`; statusEl.style.color = 'var(--green)'; }
3086
+ if (connectPanel) connectPanel.style.display = 'none';
3087
+ if (logsArea) logsArea.style.display = 'flex';
3088
+ } else {
3089
+ if (statusEl) {
3090
+ statusEl.textContent = status.error || 'Not connected';
3091
+ statusEl.style.color = status.error ? 'var(--red)' : 'var(--text-dim)';
3092
+ }
3093
+ if (connectPanel) connectPanel.style.display = 'flex';
3094
+ if (logsArea) logsArea.style.display = 'none';
3095
+ }
3096
+ });
3097
+
3098
+ // Auto-detect platform and auto-connect
3099
+ _autoDetectNative();
3100
+ }
3101
+
3102
+ function _clearNativeLogs() {
3103
+ _nativeState.logs = [];
3104
+ if ($('nativeBadge')) $('nativeBadge').textContent = '0';
3105
+ const list = $('nativeLogList');
3106
+ if (list) list.innerHTML = '';
3107
+ }
3108
+
3109
+ async function _autoDetectNative() {
3110
+ const statusEl = $('nativeStatus');
3111
+ try {
3112
+ const result = await window.electronAPI?.detectNativePlatform();
3113
+ if (!result) { if (statusEl) { statusEl.textContent = 'Detection unavailable'; statusEl.style.color = 'var(--text-dim)'; } return; }
3114
+
3115
+ // Update card statuses
3116
+ const androidStatus = $('nativeAndroidStatus');
3117
+ const iosStatus = $('nativeIOSStatus');
3118
+ if (androidStatus) {
3119
+ if (result.android) { androidStatus.innerHTML = '<span style="color:var(--green)">Device detected</span>'; }
3120
+ else if (result.adbPath) { androidStatus.innerHTML = '<span style="color:var(--orange)">adb found — no device connected</span>'; }
3121
+ else { androidStatus.innerHTML = '<span style="color:var(--text-dim)">adb not found</span>'; }
3122
+ }
3123
+ if (iosStatus) {
3124
+ const parts = [];
3125
+ if (result.iosSim) parts.push('<span style="color:var(--green)">Simulator running</span>');
3126
+ if (result.iosDevice) parts.push('<span style="color:var(--green)">USB device detected</span>');
3127
+ if (!parts.length) parts.push('<span style="color:var(--text-dim)">No device detected</span>');
3128
+ iosStatus.innerHTML = parts.join(' · ');
3129
+ }
3130
+
3131
+ // Show detection result — user clicks Connect to start
3132
+ if (result.android || result.iosSim || result.iosDevice) {
3133
+ const detected = [result.android ? 'Android' : '', result.iosSim ? 'iOS Sim' : '', result.iosDevice ? 'iOS Device' : ''].filter(Boolean).join(', ');
3134
+ if (statusEl) { statusEl.textContent = `Detected: ${detected} — click Connect to start`; statusEl.style.color = 'var(--accent)'; }
3135
+ } else {
3136
+ if (statusEl) { statusEl.textContent = 'No device detected'; statusEl.style.color = 'var(--text-dim)'; }
3137
+ }
3138
+ } catch {
3139
+ if (statusEl) { statusEl.textContent = 'Detection failed'; statusEl.style.color = 'var(--text-dim)'; }
3140
+ }
3141
+ }
3142
+
3143
+ function _appendNativeLog(log) {
3144
+ const list = $('nativeLogList');
3145
+ if (!list) return;
3146
+
3147
+ // Check filters
3148
+ if (_nativeState.levelFilter !== 'all' && log.level !== _nativeState.levelFilter) return;
3149
+ if (_nativeState.searchFilter && !log.message?.toLowerCase().includes(_nativeState.searchFilter) && !log.tag?.toLowerCase().includes(_nativeState.searchFilter)) return;
3150
+
3151
+ const isExpandable = log.level === 'error' || log.level === 'fatal' || (log.message || '').length > 200;
3152
+ const row = document.createElement('div');
3153
+ row.className = `native-log-row native-${log.level || 'info'}`;
3154
+
3155
+ const time = log.time || new Date(log.ts).toLocaleTimeString('en', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' });
3156
+
3157
+ // Header line (always visible)
3158
+ const header = document.createElement('div');
3159
+ header.className = 'native-log-header';
3160
+ header.innerHTML = `<span class="native-log-time">${esc(time)}</span>`
3161
+ + `<span class="native-log-level">${esc((log.level || 'info').toUpperCase())}</span>`
3162
+ + (log.tag ? `<span class="native-log-tag">${esc(log.tag)}</span>` : '')
3163
+ + `<span class="native-log-preview">${esc((log.message || '').split('\\n')[0].slice(0, 200))}</span>`;
3164
+ row.appendChild(header);
3165
+
3166
+ // Expandable full message (for errors and long messages)
3167
+ if (isExpandable) {
3168
+ const fullMsg = document.createElement('div');
3169
+ fullMsg.className = 'native-log-full';
3170
+ fullMsg.style.display = 'none';
3171
+ fullMsg.textContent = log.message || '';
3172
+ row.appendChild(fullMsg);
3173
+
3174
+ header.style.cursor = 'pointer';
3175
+ header.addEventListener('click', () => {
3176
+ const open = fullMsg.style.display !== 'none';
3177
+ fullMsg.style.display = open ? 'none' : 'block';
3178
+ row.classList.toggle('expanded', !open);
3179
+ });
3180
+ }
3181
+
3182
+ // Right-click to copy
3183
+ row.addEventListener('contextmenu', (e) => {
3184
+ e.preventDefault();
3185
+ showContextMenu(e, [
3186
+ { label: 'Copy Message', action: () => navigator.clipboard.writeText(log.message || '') },
3187
+ { label: 'Copy Raw Line', action: () => navigator.clipboard.writeText(log.raw || log.message || '') },
3188
+ ...(log.tag ? [{ label: `Copy Tag (${log.tag})`, action: () => navigator.clipboard.writeText(log.tag) }] : []),
3189
+ ]);
3190
+ });
3191
+
3192
+ list.appendChild(row);
3193
+
3194
+ // Cap DOM rows
3195
+ while (list.children.length > 1000) list.firstChild.remove();
3196
+
3197
+ // Auto-scroll if near bottom
3198
+ const atBottom = (list.scrollHeight - list.scrollTop - list.clientHeight) < 150;
3199
+ if (atBottom) list.scrollTop = list.scrollHeight;
3200
+ }
3201
+
3202
+ function _renderNativeLogs() {
3203
+ const list = $('nativeLogList');
3204
+ if (!list) return;
3205
+ list.innerHTML = '';
3206
+ _nativeState.logs.forEach(log => _appendNativeLog(log));
3207
+ }
3208
+
2875
3209
  function initReactPanel() {
2876
3210
  const panel = $('panel-react');
2877
3211
  panel.innerHTML = `
@@ -2969,24 +3303,25 @@ function _updateHiddenBadge() {
2969
3303
 
2970
3304
  // ─── Tab Visibility ──────────────────────────────────────────────────────────
2971
3305
  const TAB_CONFIG = [
2972
- { id: 'console', label: 'Console', icon: '🖥', essential: true },
2973
- { id: 'network', label: 'Network', icon: '📡', essential: true },
2974
- { id: 'redux', label: 'Redux', icon: '🔲', essential: false },
2975
- { id: 'ga4', label: 'GA4 Events', icon: '📊', essential: false },
2976
- { id: 'storage', label: 'Storage', icon: '💾', essential: false },
2977
- { id: 'memory', label: 'Memory', icon: '🧠', essential: false },
2978
- { id: 'performance', label: 'Performance', icon: '⚡', essential: false },
2979
- { id: 'react', label: 'React Tree', icon: '⚛️', essential: false },
3306
+ { id: 'console', label: 'Console', icon: '🖥', essential: true },
3307
+ { id: 'network', label: 'Network', icon: '📡', essential: true },
3308
+ { id: 'redux', label: 'Redux', icon: '🔲', essential: false },
3309
+ { id: 'ga4', label: 'GA4 Events', icon: '📊', essential: false },
3310
+ { id: 'storage', label: 'AsyncStorage', icon: '💾', essential: false },
3311
+ { id: 'memory', label: 'Memory', icon: '🧠', essential: false, defaultHidden: true },
3312
+ { id: 'performance', label: 'Performance', icon: '⚡', essential: false, defaultHidden: true },
3313
+ { id: 'react', label: 'React Tree', icon: '⚛️', essential: false },
3314
+ { id: 'native', label: 'Native Logs', icon: '📱', essential: false, defaultHidden: true },
2980
3315
  ];
2981
3316
  function getTabVisibility() {
2982
3317
  try {
2983
3318
  const saved = JSON.parse(localStorage.getItem('rn-debug-tab-visibility') || '{}');
2984
3319
  const result = {};
2985
- TAB_CONFIG.forEach(t => { result[t.id] = saved[t.id] !== undefined ? saved[t.id] : true; });
3320
+ TAB_CONFIG.forEach(t => { result[t.id] = saved[t.id] !== undefined ? saved[t.id] : !t.defaultHidden; });
2986
3321
  return result;
2987
3322
  } catch {
2988
3323
  const result = {};
2989
- TAB_CONFIG.forEach(t => { result[t.id] = true; });
3324
+ TAB_CONFIG.forEach(t => { result[t.id] = !t.defaultHidden; });
2990
3325
  return result;
2991
3326
  }
2992
3327
  }
@@ -2996,7 +3331,13 @@ function setTabVisibility(vis) {
2996
3331
  function getTabOrder() {
2997
3332
  try {
2998
3333
  const saved = JSON.parse(localStorage.getItem('rn-debug-tab-order') || '[]');
2999
- if (saved.length === TAB_CONFIG.length) return saved;
3334
+ if (saved.length) {
3335
+ // Merge: keep saved order, append any new tabs not in saved list
3336
+ const allIds = TAB_CONFIG.map(t => t.id);
3337
+ const merged = saved.filter(id => allIds.includes(id));
3338
+ allIds.forEach(id => { if (!merged.includes(id)) merged.push(id); });
3339
+ return merged;
3340
+ }
3000
3341
  } catch {}
3001
3342
  return TAB_CONFIG.map(t => t.id);
3002
3343
  }
@@ -3463,7 +3804,7 @@ applyTabVisibility();
3463
3804
  window.electronAPI?.setMetroPort(getStoredMetroPort());
3464
3805
 
3465
3806
  // ─────────────────────────────────────────────────────────────────────────────
3466
- // SOURCES PANEL — CDP-based file browser + breakpoints
3807
+ // SOURCES PANEL (placeholder use JS Debugger button for breakpoints)
3467
3808
  // ─────────────────────────────────────────────────────────────────────────────
3468
3809
  function initSourcesPanel() {
3469
3810
  const panel = $('panel-sources');
@@ -3944,4 +4285,5 @@ initMemoryPanel();
3944
4285
  initReduxPanel();
3945
4286
  initStoragePanel();
3946
4287
  initReactPanel();
4288
+ initNativeLogsPanel();
3947
4289
  initSettingsPanel();
package/index.html CHANGED
@@ -47,9 +47,9 @@
47
47
  <svg viewBox="0 0 20 20"><path d="M10 2v16M6 6l4-4 4 4M3 18h14" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" fill="none"/><circle cx="5" cy="13" r="2" stroke="currentColor" stroke-width="1.2" fill="none"/><circle cx="10" cy="9" r="2" stroke="currentColor" stroke-width="1.2" fill="none"/><circle cx="15" cy="12" r="2" stroke="currentColor" stroke-width="1.2" fill="none"/></svg>
48
48
  <span>GA4</span>
49
49
  </button>
50
- <button class="nav-btn" data-panel="storage" title="Application">
50
+ <button class="nav-btn" data-panel="storage" title="AsyncStorage">
51
51
  <svg viewBox="0 0 20 20"><ellipse cx="10" cy="6" rx="7" ry="3" stroke="currentColor" stroke-width="1.5" fill="none"/><path d="M3 6v4c0 1.66 3.13 3 7 3s7-1.34 7-3V6" stroke="currentColor" stroke-width="1.5" fill="none"/><path d="M3 10v4c0 1.66 3.13 3 7 3s7-1.34 7-3v-4" stroke="currentColor" stroke-width="1.5" fill="none"/></svg>
52
- <span>App</span>
52
+ <span>Async<br/>Storage</span>
53
53
  </button>
54
54
  <button class="nav-btn" data-panel="memory" title="Memory">
55
55
  <svg viewBox="0 0 20 20"><rect x="3" y="8" width="3" height="8" rx="0.5" stroke="currentColor" stroke-width="1.2" fill="none"/><rect x="8.5" y="4" width="3" height="12" rx="0.5" stroke="currentColor" stroke-width="1.2" fill="none"/><rect x="14" y="6" width="3" height="10" rx="0.5" stroke="currentColor" stroke-width="1.2" fill="none"/></svg>
@@ -59,6 +59,10 @@
59
59
  <svg viewBox="0 0 20 20"><polyline points="2,16 6,10 10,13 14,5 18,8" stroke="currentColor" stroke-width="1.5" fill="none" stroke-linecap="round" stroke-linejoin="round"/></svg>
60
60
  <span>Perf</span>
61
61
  </button>
62
+ <button class="nav-btn" data-panel="native" title="Native Logs">
63
+ <svg viewBox="0 0 20 20"><path d="M4 4h12v12H4z" stroke="currentColor" stroke-width="1.5" fill="none" rx="2"/><path d="M7 8h6M7 11h4" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/><circle cx="14" cy="14" r="3" fill="var(--red)" stroke="currentColor" stroke-width="1"/><path d="M13 13l2 2M15 13l-2 2" stroke="#fff" stroke-width="1" stroke-linecap="round"/></svg>
64
+ <span>Native</span>
65
+ </button>
62
66
  <button class="nav-btn" data-panel="react" title="React Tree">
63
67
  <svg viewBox="0 0 20 20"><circle cx="10" cy="10" r="2" fill="currentColor"/><ellipse cx="10" cy="10" rx="8" ry="3.5" stroke="currentColor" stroke-width="1.5" fill="none"/><ellipse cx="10" cy="10" rx="8" ry="3.5" stroke="currentColor" stroke-width="1.5" fill="none" transform="rotate(60 10 10)"/><ellipse cx="10" cy="10" rx="8" ry="3.5" stroke="currentColor" stroke-width="1.5" fill="none" transform="rotate(120 10 10)"/></svg>
64
68
  <span>React</span>
@@ -80,6 +84,7 @@
80
84
  <div id="panel-redux" class="panel"></div>
81
85
  <div id="panel-storage" class="panel"></div>
82
86
  <div id="panel-react" class="panel"></div>
87
+ <div id="panel-native" class="panel"></div>
83
88
  <div id="panel-settings" class="panel"></div>
84
89
  </main>
85
90
 
package/main.js CHANGED
@@ -20,6 +20,7 @@ const PORTS = {
20
20
  // ─── Windows ──────────────────────────────────────────────────────────────────
21
21
  let mainWindow = null;
22
22
  let devtoolsWindow = null; // hosts the embedded CDP DevTools frontend
23
+ let _forceQuit = false;
23
24
 
24
25
  // Safe IPC send — prevents "Object has been destroyed" crash
25
26
  function _send(channel, ...args) {
@@ -76,14 +77,12 @@ if (gotLock) app.whenReady().then(async () => {
76
77
  // Send version to renderer — try package.json, fallback to app.getVersion()
77
78
  let appVersion;
78
79
  try { appVersion = require('./package.json').version; } catch { appVersion = app.getVersion(); }
79
- // Send multiple times to ensure renderer catches it
80
+ // Send multiple times to ensure renderer catches it (covers race conditions)
80
81
  mainWindow.webContents.on('did-finish-load', () => {
81
- [200, 1000, 3000].forEach(delay => {
82
- setTimeout(() => {
83
- if (mainWindow && !mainWindow.isDestroyed()) {
84
- _send('app-version', appVersion);
85
- }
86
- }, delay);
82
+ // Send immediately + retries
83
+ _send('app-version', appVersion);
84
+ [500, 2000, 5000].forEach(delay => {
85
+ setTimeout(() => _send('app-version', appVersion), delay);
87
86
  });
88
87
  });
89
88
 
@@ -102,6 +101,7 @@ app.on('window-all-closed', () => {
102
101
  });
103
102
 
104
103
  app.on('before-quit', () => {
104
+ _forceQuit = true;
105
105
  // Close all WS servers gracefully
106
106
  if (reactDTServer) {
107
107
  reactDTServer.close();
@@ -139,6 +139,26 @@ async function createMainWindow() {
139
139
  mainWindow.webContents.on('did-finish-load', () => {
140
140
  _send('ports', PORTS);
141
141
  });
142
+
143
+ // Close confirmation dialog
144
+ mainWindow.on('close', (e) => {
145
+ if (_forceQuit) return;
146
+ e.preventDefault();
147
+ dialog.showMessageBox(mainWindow, {
148
+ type: 'question',
149
+ buttons: ['Quit', 'Cancel'],
150
+ defaultId: 1,
151
+ title: 'Close ReactoRadar',
152
+ message: 'Are you sure you want to quit?',
153
+ detail: 'Active debug sessions will be disconnected.',
154
+ }).then(({ response }) => {
155
+ if (response === 0) {
156
+ _forceQuit = true;
157
+ if (mainWindow && !mainWindow.isDestroyed()) mainWindow.close();
158
+ app.quit();
159
+ }
160
+ });
161
+ });
142
162
  }
143
163
 
144
164
  // ─── Update Checker ──────────────────────────────────────────────────────────
@@ -417,17 +437,32 @@ function setupIPC() {
417
437
  ipcMain.on('open-react-devtools', () => {
418
438
  // Start the relay server if not already running
419
439
  if (!reactDTServer) startReactDevToolsServer();
420
- // Open standalone react-devtools window
421
- const rdtWin = new BrowserWindow({
422
- width: 1100,
423
- height: 700,
424
- titleBarStyle: 'hiddenInset',
425
- backgroundColor: '#0a0b0e',
426
- title: 'React DevTools',
427
- webPreferences: { nodeIntegration: false, contextIsolation: true, sandbox: true },
428
- });
429
- // Metro serves the React DevTools frontend at /debugger-ui
430
- rdtWin.loadURL(`http://localhost:${PORTS.METRO}/debugger-ui`);
440
+ // Launch standalone react-devtools via npx in a background process
441
+ try {
442
+ const { spawn } = require('child_process');
443
+ const env = { ...process.env };
444
+ // Remove ELECTRON_RUN_AS_NODE to prevent npx from running in Electron's Node
445
+ delete env.ELECTRON_RUN_AS_NODE;
446
+ const child = spawn('npx', ['react-devtools'], {
447
+ stdio: 'ignore',
448
+ detached: true,
449
+ env,
450
+ });
451
+ child.unref();
452
+ console.log('[ReactDevTools] Launched standalone react-devtools');
453
+ _send('react-dt-status', 'launched');
454
+ } catch (e) {
455
+ console.error('[ReactDevTools] Failed to launch:', e.message);
456
+ // Fallback: show instructions in a dialog
457
+ const { dialog } = require('electron');
458
+ dialog.showMessageBox(mainWindow, {
459
+ type: 'info',
460
+ title: 'React DevTools',
461
+ message: 'Could not launch React DevTools automatically.',
462
+ detail: 'Run this command in your terminal:\n\nnpx react-devtools\n\nIt will connect to your app on port 8097.',
463
+ buttons: ['OK'],
464
+ });
465
+ }
431
466
  });
432
467
 
433
468
  // clear-all is handled by renderer via clear-all-ui IPC from menu
@@ -574,6 +609,143 @@ function setupIPC() {
574
609
  }
575
610
  });
576
611
 
612
+ // ─── Native Log Streaming ──────────────────────────────────────────────────
613
+ let _nativeLogProcess = null;
614
+
615
+ // Auto-detect which native platform is available
616
+ ipcMain.handle('detect-native-platform', () => {
617
+ const { execSync } = require('child_process');
618
+ function tryCmd(cmd) { try { return execSync(cmd, { encoding: 'utf8', stdio: ['pipe','pipe','pipe'], timeout: 5000 }).trim(); } catch { return ''; } }
619
+
620
+ const result = { android: false, iosSim: false, iosDevice: false, adbPath: false };
621
+
622
+ // Check adb
623
+ const adbCheck = tryCmd('which adb');
624
+ result.adbPath = !!adbCheck;
625
+ if (adbCheck) {
626
+ const devices = tryCmd('adb devices');
627
+ result.android = devices.includes('emulator') || /\b[A-Z0-9]{6,}\s+device\b/i.test(devices);
628
+ }
629
+
630
+ // Check iOS simulator
631
+ const simCheck = tryCmd('xcrun simctl list devices booted 2>/dev/null');
632
+ result.iosSim = simCheck.includes('Booted');
633
+
634
+ // Check iOS device
635
+ const idevice = tryCmd('idevice_id -l 2>/dev/null');
636
+ result.iosDevice = !!(idevice && idevice.trim().length > 0);
637
+
638
+ return result;
639
+ });
640
+
641
+ ipcMain.on('start-native-logs', (_, platform) => {
642
+ // Kill existing process group
643
+ if (_nativeLogProcess) {
644
+ try { process.kill(-_nativeLogProcess.pid); } catch {}
645
+ try { _nativeLogProcess.kill(); } catch {}
646
+ _nativeLogProcess = null;
647
+ }
648
+
649
+ const { spawn } = require('child_process');
650
+ let cmd, args;
651
+
652
+ if (platform === 'android') {
653
+ // adb logcat — filter for app-relevant logs
654
+ cmd = 'adb';
655
+ args = ['logcat', '-v', 'threadtime', '*:W']; // Warnings and above
656
+ } else if (platform === 'ios-sim') {
657
+ // xcrun simctl for iOS Simulator — use syslog style for parseable output
658
+ cmd = 'xcrun';
659
+ args = ['simctl', 'spawn', 'booted', 'log', 'stream', '--style', 'syslog', '--level', 'error'];
660
+ } else if (platform === 'ios-device') {
661
+ // idevicesyslog for real iOS device
662
+ cmd = 'idevicesyslog';
663
+ args = [];
664
+ } else {
665
+ _send('native-status', { connected: false, error: `Unknown platform: ${platform}` });
666
+ return;
667
+ }
668
+
669
+ try {
670
+ _nativeLogProcess = spawn(cmd, args, { stdio: ['ignore', 'pipe', 'pipe'], detached: true });
671
+
672
+ _send('native-status', { connected: true, platform });
673
+
674
+ let buffer = '';
675
+ _nativeLogProcess.stdout.on('data', (chunk) => {
676
+ buffer += chunk.toString();
677
+ const lines = buffer.split('\n');
678
+ buffer = lines.pop(); // keep incomplete line
679
+ lines.forEach(line => {
680
+ if (!line.trim()) return;
681
+ const parsed = _parseNativeLog(line, platform);
682
+ if (parsed) _send('native-log', parsed);
683
+ });
684
+ });
685
+
686
+ _nativeLogProcess.stderr.on('data', (chunk) => {
687
+ const text = chunk.toString().trim();
688
+ if (text) _send('native-log', { level: 'error', message: text, source: 'stderr', ts: Date.now() });
689
+ });
690
+
691
+ _nativeLogProcess.on('close', (code) => {
692
+ _nativeLogProcess = null;
693
+ _send('native-status', { connected: false, error: code ? `Process exited with code ${code}` : 'Disconnected' });
694
+ });
695
+
696
+ _nativeLogProcess.on('error', (err) => {
697
+ _nativeLogProcess = null;
698
+ _send('native-status', { connected: false, error: `Failed to start ${cmd}: ${err.message}. Is it installed?` });
699
+ });
700
+
701
+ } catch (e) {
702
+ _send('native-status', { connected: false, error: e.message });
703
+ }
704
+ });
705
+
706
+ ipcMain.on('stop-native-logs', () => {
707
+ if (_nativeLogProcess) {
708
+ try { _nativeLogProcess.kill(); } catch {}
709
+ _nativeLogProcess = null;
710
+ _send('native-status', { connected: false });
711
+ }
712
+ });
713
+
714
+ // Clean up on quit
715
+ app.on('before-quit', () => {
716
+ if (_nativeLogProcess) { try { _nativeLogProcess.kill(); } catch {} }
717
+ });
718
+
719
+ function _parseNativeLog(line, platform) {
720
+ if (platform === 'android') {
721
+ // Android logcat format: "06-05 10:30:45.123 1234 5678 E TAG: message"
722
+ const m = line.match(/^\d{2}-\d{2}\s+(\d{2}:\d{2}:\d{2})\.\d+\s+\d+\s+\d+\s+([VDIWEF])\s+([^:]+):\s*(.*)/);
723
+ if (m) {
724
+ const levelMap = { V: 'verbose', D: 'debug', I: 'info', W: 'warn', E: 'error', F: 'fatal' };
725
+ return { ts: Date.now(), time: m[1], level: levelMap[m[2]] || 'info', tag: m[3].trim(), message: m[4], raw: line };
726
+ }
727
+ return { ts: Date.now(), level: 'info', message: line, raw: line };
728
+ }
729
+ if (platform === 'ios-sim' || platform === 'ios-device') {
730
+ // syslog style: "2026-06-05 10:30:45.123456+0530 localhost process[pid]: (subsystem) [category] <Level>: message"
731
+ const m1 = line.match(/(\d{2}:\d{2}:\d{2})\.\d+[^\s]*\s+\S+\s+(\S+)\[\d+\].*?<(\w+)>:\s*(.*)/);
732
+ if (m1) {
733
+ const levelMap = { Notice: 'info', Info: 'info', Default: 'info', Debug: 'debug', Error: 'error', Fault: 'fatal' };
734
+ return { ts: Date.now(), time: m1[1], level: levelMap[m1[3]] || 'info', tag: m1[2], message: m1[4], raw: line };
735
+ }
736
+ // idevicesyslog format: "Jun 5 10:30:45 iPhone MyApp(libsystem)[123] <Error>: message"
737
+ const m2 = line.match(/\w+\s+\d+\s+(\d{2}:\d{2}:\d{2})\s+\S+\s+(\S+?)[\[(].*?<(\w+)>:\s*(.*)/);
738
+ if (m2) {
739
+ const levelMap = { Notice: 'info', Info: 'info', Debug: 'debug', Warning: 'warn', Error: 'error', Critical: 'fatal' };
740
+ return { ts: Date.now(), time: m2[1], level: levelMap[m2[3]] || 'info', tag: m2[2], message: m2[4], raw: line };
741
+ }
742
+ // Fallback
743
+ const timeMatch = line.match(/(\d{2}:\d{2}:\d{2})/);
744
+ return { ts: Date.now(), time: timeMatch ? timeMatch[1] : '', level: 'info', message: line, raw: line };
745
+ }
746
+ return { ts: Date.now(), level: 'info', message: line, raw: line };
747
+ }
748
+
577
749
  ipcMain.on('set-theme', (_, theme) => {
578
750
  nativeTheme.themeSource = theme === 'light' ? 'light' : 'dark';
579
751
  const bg = theme === 'light' ? '#f5f6f8' : '#0a0b0e';
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "reactoradar",
3
3
  "productName": "ReactoRadar",
4
- "version": "1.6.0",
4
+ "version": "1.6.2",
5
5
  "description": "macOS debugger for React Native — Console, Sources, Network, Performance, Memory, Redux, AsyncStorage, React tree. Supports RN 0.74+ with Hermes and New Architecture.",
6
6
  "main": "main.js",
7
7
  "bin": {
package/preload.js CHANGED
@@ -11,6 +11,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
11
11
  'ports', 'cdp-targets', 'redux-event', 'storage-event', 'network-event',
12
12
  'console-event', 'perf-event', 'ga4-event', 'redux-connected', 'storage-connected', 'network-connected',
13
13
  'react-dt-status', 'trigger-open-cdp', 'clear-all-ui', 'theme-changed', 'update-available', 'update-downloaded', 'app-version', 'focus-search',
14
+ 'native-log', 'native-status',
14
15
  ];
15
16
  if (allowed.includes(channel)) {
16
17
  ipcRenderer.removeAllListeners(channel);
@@ -31,4 +32,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
31
32
  openExternal: (url) => ipcRenderer.send('open-external', url),
32
33
  installUpdate: () => ipcRenderer.send('install-update'),
33
34
  captureScreenshot: () => ipcRenderer.send('capture-screenshot'),
35
+ startNativeLogs: (platform) => ipcRenderer.send('start-native-logs', platform),
36
+ stopNativeLogs: () => ipcRenderer.send('stop-native-logs'),
37
+ detectNativePlatform: () => ipcRenderer.invoke('detect-native-platform'),
34
38
  });
package/styles.css CHANGED
@@ -1039,7 +1039,9 @@ body {
1039
1039
  /* ─────────────────────────────────────────────────────────────────────────────
1040
1040
  ASYNC STORAGE PANEL
1041
1041
  ───────────────────────────────────────────────────────────────────────────── */
1042
- .storage-layout { display: grid; grid-template-columns: 240px 1fr; height: 100%; }
1042
+ .storage-layout { display: grid; grid-template-columns: 240px 4px 1fr; height: 100%; }
1043
+ .storage-resize-handle { background: var(--border); cursor: col-resize; transition: background 0.1s; }
1044
+ .storage-resize-handle:hover { background: var(--accent); }
1043
1045
  .storage-keys { display: flex; flex-direction: column; border-right: 1px solid var(--border); overflow: hidden; }
1044
1046
  .storage-keys-list { flex: 1; overflow-y: auto; }
1045
1047
  .storage-keys-list::-webkit-scrollbar { width: 3px; }
@@ -1637,6 +1639,49 @@ mark { background: rgba(79,172,255,.2); color: var(--accent); border-radius: 2px
1637
1639
  .net-hidden-unhide { font-size: 9px; background: transparent; border: 1px solid var(--border); color: var(--accent); border-radius: 3px; padding: 2px 6px; cursor: pointer; flex-shrink: 0; }
1638
1640
  .net-hidden-unhide:hover { background: rgba(61,136,255,.1); }
1639
1641
 
1642
+ /* ── Native Logs Panel ─────────────────────────────────────────────────────── */
1643
+ .native-connect-panel { flex: 1; display: flex; align-items: center; justify-content: center; overflow-y: auto; padding: 24px; }
1644
+ .native-hero { text-align: center; max-width: 520px; width: 100%; }
1645
+ .native-platform-cards { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; margin-top: 16px; }
1646
+ .native-card { background: var(--bg2); border: 1px solid var(--border); border-radius: 8px; padding: 16px 18px; text-align: left; display: flex; flex-direction: column; }
1647
+ .native-card-icon { font-size: 22px; margin-bottom: 8px; }
1648
+ .native-card-title { font-size: 12px; font-weight: 700; color: var(--text); margin-bottom: 4px; }
1649
+ .native-card-hint { font-size: 10px; color: var(--text-dim); margin-bottom: 12px; }
1650
+ .native-card-hint code { background: var(--bg3); padding: 1px 4px; border-radius: 3px; color: var(--accent); }
1651
+ .native-card-prereq { font-size: 10px; color: var(--text-mid); line-height: 1.8; flex: 1; }
1652
+ .native-prereq-step { margin-bottom: 3px; padding-left: 4px; }
1653
+ .native-prereq-step code { background: var(--bg3); padding: 1px 4px; border-radius: 3px; color: var(--accent); font-size: 10px; }
1654
+ .native-connect-btn { margin-top: 12px; padding: 7px 18px; border-radius: 6px; border: 1px solid var(--accent); background: transparent; color: var(--accent); font-size: 11px; font-weight: 600; cursor: pointer; transition: all 0.12s; }
1655
+ .native-connect-btn:hover { background: var(--accent); color: #fff; }
1656
+ .native-status { font-size: 10px; color: var(--text-dim); }
1657
+ .native-logs-area { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
1658
+ .native-filter-bar { display: flex; align-items: center; gap: 8px; padding: 6px 12px; border-bottom: 1px solid var(--border); background: var(--bg2); }
1659
+ .native-level-filters { display: flex; gap: 4px; }
1660
+ .native-log-list { flex: 1; overflow-y: auto; font-size: 11px; line-height: 1.5; }
1661
+ .native-log-row { display: flex; gap: 8px; padding: 3px 12px; border-bottom: 1px solid var(--border); font-family: inherit; align-items: baseline; }
1662
+ .native-log-row:hover { background: var(--bg3); }
1663
+ .native-log-time { color: var(--text-dim); font-size: 10px; flex-shrink: 0; width: 65px; }
1664
+ .native-log-level { font-size: 9px; font-weight: 700; flex-shrink: 0; width: 42px; text-align: center; padding: 1px 0; border-radius: 3px; }
1665
+ .native-log-tag { color: var(--accent); font-size: 10px; font-weight: 600; flex-shrink: 0; max-width: 120px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1666
+ .native-log-header { display: flex; gap: 8px; align-items: baseline; }
1667
+ .native-log-preview { color: var(--text); flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
1668
+ .native-log-full { display: none; padding: 6px 12px 8px 85px; font-size: 11px; line-height: 1.6; white-space: pre-wrap; word-break: break-word; color: var(--text); background: var(--bg2); border-top: 1px solid var(--border); user-select: text; -webkit-user-select: text; }
1669
+ .native-log-row.expanded { background: var(--bg2); }
1670
+ .native-log-row.expanded .native-log-preview { white-space: normal; }
1671
+ .native-error .native-log-full, .native-fatal .native-log-full { color: var(--red); }
1672
+ .native-detect-status { font-size: 10px; margin: 8px 0 4px; min-height: 14px; }
1673
+ .native-verbose .native-log-level { color: var(--text-dim); background: var(--bg3); }
1674
+ .native-debug .native-log-level { color: var(--text-mid); background: var(--bg3); }
1675
+ .native-info .native-log-level { color: var(--green); background: rgba(61,214,140,.1); }
1676
+ .native-warn .native-log-level { color: var(--orange); background: rgba(255,165,0,.1); }
1677
+ .native-warn { background: rgba(255,165,0,.03); }
1678
+ .native-error .native-log-level { color: var(--red); background: rgba(255,94,114,.1); }
1679
+ .native-error { background: rgba(255,94,114,.04); }
1680
+ .native-error .native-log-msg { color: var(--red); }
1681
+ .native-fatal .native-log-level { color: #fff; background: var(--red); }
1682
+ .native-fatal { background: rgba(255,94,114,.08); }
1683
+ .native-fatal .native-log-msg { color: var(--red); font-weight: 700; }
1684
+
1640
1685
  /* ── Support Button ────────────────────────────────────────────────────────── */
1641
1686
  .support-btn { background: linear-gradient(135deg, #ff813f, #ff5e72); color: #fff; border: none; padding: 8px 20px; border-radius: 8px; font-size: 12px; font-weight: 700; cursor: pointer; transition: all 0.15s; letter-spacing: 0.3px; }
1642
1687
  .support-btn:hover { transform: translateY(-1px); box-shadow: 0 4px 12px rgba(255,94,114,.3); }