Skip to main content

victauri_plugin/
js_bridge.rs

1/// JS bridge log capacity configuration.
2pub struct BridgeCapacities {
3    /// Maximum console log entries to retain.
4    pub console_logs: usize,
5    /// Maximum DOM mutation batches to retain.
6    pub mutation_log: usize,
7    /// Maximum network request entries to retain.
8    pub network_log: usize,
9    /// Maximum navigation history entries to retain.
10    pub navigation_log: usize,
11    /// Maximum dialog event entries to retain.
12    pub dialog_log: usize,
13    /// Maximum long task entries to retain.
14    pub long_tasks: usize,
15}
16
17impl Default for BridgeCapacities {
18    fn default() -> Self {
19        Self {
20            console_logs: 1000,
21            mutation_log: 500,
22            network_log: 1000,
23            navigation_log: 200,
24            dialog_log: 100,
25            long_tasks: 100,
26        }
27    }
28}
29
30/// Generate the JS init script with custom log capacities.
31#[must_use]
32pub fn init_script(caps: &BridgeCapacities) -> String {
33    format!(
34        "\n(function() {{\
35        \n    if (window.__VICTAURI__) return;\
36        \n\
37        \n    var CAP_CONSOLE = {console_logs};\
38        \n    var CAP_MUTATION = {mutation_log};\
39        \n    var CAP_NETWORK = {network_log};\
40        \n    var CAP_NAVIGATION = {navigation_log};\
41        \n    var CAP_DIALOG = {dialog_log};\
42        \n    var CAP_LONG_TASKS = {long_tasks};\
43        \n",
44        console_logs = caps.console_logs,
45        mutation_log = caps.mutation_log,
46        network_log = caps.network_log,
47        navigation_log = caps.navigation_log,
48        dialog_log = caps.dialog_log,
49        long_tasks = caps.long_tasks,
50    )
51    // Inject the crate version into the JS bridge's self-reported version so it ALWAYS
52    // equals `get_plugin_info`'s `BRIDGE_VERSION` (= CARGO_PKG_VERSION). Previously the
53    // JS version was a hand-maintained literal that the bump script find-replaced each
54    // release — it silently drifted (stuck at 0.7.8 through 0.7.10), so `get_diagnostics`
55    // reported a stale `bridge_version` and the startup self-check logged a false
56    // "Bridge version mismatch" on every launch. Deriving it here makes drift impossible.
57        + &INIT_SCRIPT_BODY.replace("__VICTAURI_BRIDGE_VERSION__", env!("CARGO_PKG_VERSION"))
58}
59
60/// The body of the init script (after capacity variable declarations).
61/// Uses CAP_* variables for all log limits.
62const INIT_SCRIPT_BODY: &str = r#"
63    var refMap = new Map();
64    var refCounter = 0;
65    var weakRefMap = new Map();
66
67    function resolveRef(refId) {
68        var direct = refMap.get(refId);
69        if (direct) {
70            if (direct.isConnected) return direct;
71            refMap.delete(refId);
72            return null;
73        }
74        var weak = weakRefMap.get(refId);
75        if (weak) {
76            var el = weak.deref();
77            if (el && el.isConnected) return el;
78            weakRefMap.delete(refId);
79            return null;
80        }
81        return null;
82    }
83
84    var REF_MAP_LIMIT = 10000;
85
86    function registerRef(node) {
87        var ref_id = 'e' + (refCounter++);
88        if (refMap.size >= REF_MAP_LIMIT) {
89            var oldest = refMap.keys().next().value;
90            refMap.delete(oldest);
91            weakRefMap.delete(oldest);
92        }
93        refMap.set(ref_id, node);
94        if (typeof WeakRef !== 'undefined') {
95            weakRefMap.set(ref_id, new WeakRef(node));
96        }
97        return ref_id;
98    }
99
100    function getStaleRefs() {
101        var stale = [];
102        weakRefMap.forEach(function(weak, refId) {
103            var el = weak.deref();
104            if (!el || !el.isConnected) {
105                stale.push(refId);
106                weakRefMap.delete(refId);
107                refMap.delete(refId);
108            }
109        });
110        return stale;
111    }
112    var consoleLogs = [];
113    var mutationLog = [];
114    var networkLog = [];
115    var networkCounter = 0;
116    // Tauri's IPC transport URL is platform-dependent: WebView2 (Windows) uses
117    // `http://ipc.localhost/<cmd>`, while WebKitGTK (Linux) and WKWebView (macOS)
118    // use the custom `ipc://localhost/<cmd>` scheme. Match BOTH so the IPC-derived
119    // tools (getIpcLog / ghost detection / integrity / event stream) work on every
120    // platform — not just Windows. Returns the (still URL-encoded) command path if
121    // the URL is a Tauri IPC URL, else null.
122    var IPC_PREFIXES = ['http://ipc.localhost/', 'ipc://localhost/'];
123    function ipcCommandPath(url) {
124        for (var pi = 0; pi < IPC_PREFIXES.length; pi++) {
125            if (url.indexOf(IPC_PREFIXES[pi]) === 0) return url.substring(IPC_PREFIXES[pi].length);
126        }
127        return null;
128    }
129    function isIpcUrl(url) { return ipcCommandPath(url) !== null; }
130    var navigationLog = [];
131    var dialogLog = [];
132    var interactionLog = [];
133    var CAP_INTERACTION = 500;
134    var ipcWaiters = [];
135    var longTasks = [];
136    var listenerCount = 0;
137
138    // ── Network route rules (Phase 1: interception / mock / block / delay) ──
139    var routeRules = [];
140    var routeCounter = 0;
141    var routeMatchLog = [];
142    var CAP_ROUTE_MATCHES = 200;
143
144    // Convert a glob ("*" wildcard) to a RegExp. Other chars are escaped.
145    function globToRegExp(glob) {
146        var re = glob.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*');
147        return new RegExp('^' + re + '$');
148    }
149
150    // Find the first active route rule matching url+method, or null.
151    // Never matches Victauri's own internal IPC traffic.
152    function matchRoute(url, method) {
153        if (!routeRules.length) return null;
154        if (url.indexOf('plugin%3Avictauri%7C') !== -1 || url.indexOf('plugin:victauri|') !== -1) {
155            return null;
156        }
157        var m = (method || 'GET').toUpperCase();
158        for (var i = 0; i < routeRules.length; i++) {
159            var r = routeRules[i];
160            if (r.times && r.triggered >= r.times) continue;
161            if (r.method && r.method.toUpperCase() !== m) continue;
162            var hit = false;
163            try {
164                if (r.match_type === 'exact') hit = (url === r.pattern);
165                else if (r.match_type === 'regex') hit = new RegExp(r.pattern).test(url);
166                else if (r.match_type === 'glob') hit = globToRegExp(r.pattern).test(url);
167                else hit = (url.indexOf(r.pattern) !== -1); // substring (default)
168            } catch (e) { hit = false; }
169            if (hit) return r;
170        }
171        return null;
172    }
173
174    function recordRouteMatch(rule, url, method) {
175        rule.triggered = (rule.triggered || 0) + 1;
176        routeMatchLog.push({
177            rule_id: rule.id, action: rule.action, url: url,
178            method: (method || 'GET').toUpperCase(), timestamp: Date.now(),
179            trigger_count: rule.triggered,
180        });
181        if (routeMatchLog.length > CAP_ROUTE_MATCHES) routeMatchLog.shift();
182    }
183
184    function checkActionable(el) {
185        if (!el || !el.isConnected) return { error: 'element is detached from DOM', hint: 'RETRY_LATER' };
186        if (el.disabled) return { error: 'element is disabled (disabled attribute)', hint: 'RETRY_LATER' };
187        if (el.getAttribute && el.getAttribute('aria-disabled') === 'true') return { error: 'element is disabled (aria-disabled)', hint: 'RETRY_LATER' };
188        // Use the element's OWN document/window so the viewport and occlusion
189        // (elementFromPoint) checks are correct for elements inside same-origin
190        // iframes — getBoundingClientRect() is relative to the element's own
191        // frame viewport, not the top document.
192        var doc = el.ownerDocument || document;
193        var win = doc.defaultView || window;
194        var cs = win.getComputedStyle(el);
195        if (cs.display === 'none') return { error: 'element is not visible (display: none)', hint: 'RETRY_LATER' };
196        if (cs.visibility === 'hidden') return { error: 'element is not visible (visibility: hidden)', hint: 'RETRY_LATER' };
197        if (parseFloat(cs.opacity) < 0.01) return { error: 'element is not visible (opacity: ' + cs.opacity + ')', hint: 'RETRY_LATER' };
198        var rect = el.getBoundingClientRect();
199        if (rect.width === 0 && rect.height === 0) return { error: 'element has zero size', hint: 'RETRY_LATER' };
200        if (cs.pointerEvents === 'none') return { error: 'element has pointer-events: none', hint: 'RETRY_LATER' };
201        var vw = win.innerWidth || doc.documentElement.clientWidth;
202        var vh = win.innerHeight || doc.documentElement.clientHeight;
203        if (rect.bottom < 0 || rect.top > vh || rect.right < 0 || rect.left > vw) {
204            el.scrollIntoView({ block: 'center', inline: 'center', behavior: 'instant' });
205            rect = el.getBoundingClientRect();
206            if (rect.bottom < 0 || rect.top > vh || rect.right < 0 || rect.left > vw) {
207                return { error: 'element is outside viewport after scroll attempt', hint: 'CHECK_INPUT' };
208            }
209        }
210        var cx = rect.left + rect.width / 2;
211        var cy = rect.top + rect.height / 2;
212        var topEl = doc.elementFromPoint(cx, cy);
213        if (topEl && topEl !== el && !el.contains(topEl) && !topEl.contains(el)) {
214            var tag = topEl.tagName ? topEl.tagName.toLowerCase() : 'unknown';
215            var info = tag;
216            if (topEl.id) info += '#' + topEl.id;
217            else if (topEl.className && typeof topEl.className === 'string') {
218                var cls = topEl.className.trim().split(/\s+/)[0];
219                if (cls) info += '.' + cls;
220            }
221            return { error: 'element is covered by ' + info + ' at (' + Math.round(cx) + ',' + Math.round(cy) + ')', hint: 'RETRY_LATER' };
222        }
223        return null;
224    }
225
226    function withAutoWait(refId, timeoutMs, actionFn) {
227        return new Promise(function(resolve) {
228            var deadline = Date.now() + (timeoutMs || 5000);
229            function attempt() {
230                var el = resolveRef(refId);
231                if (!el) {
232                    if (Date.now() >= deadline) { resolve({ ok: false, error: 'ref not found: ' + refId, hint: 'CHECK_INPUT' }); return; }
233                    setTimeout(attempt, 50); return;
234                }
235                var check = checkActionable(el);
236                if (check) {
237                    if (check.hint === 'CHECK_INPUT' || Date.now() >= deadline) {
238                        var msg = Date.now() >= deadline ? 'timeout (' + (timeoutMs || 5000) + 'ms): ' + check.error : check.error;
239                        resolve({ ok: false, error: msg, hint: check.hint || 'RETRY_LATER' }); return;
240                    }
241                    setTimeout(attempt, 50); return;
242                }
243                try { var r = actionFn(el); resolve(r || { ok: true }); }
244                catch (e) { resolve({ ok: false, error: 'action threw: ' + e.message, hint: 'CHECK_INPUT' }); }
245            }
246            attempt();
247        });
248    }
249
250    // ── Public API ───────────────────────────────────────────────────────────
251
252    window.__VICTAURI__ = {
253        version: '__VICTAURI_BRIDGE_VERSION__',
254        _captureIpcBodies: true,
255
256        // ── DOM ──────────────────────────────────────────────────────────────
257
258        snapshot: function(format) {
259            var previousRefs = new Set(refMap.keys());
260            refMap.clear();
261            var fmt = format || 'compact';
262            var tree;
263            if (fmt === 'json') {
264                tree = walkDom(document.body);
265            } else {
266                tree = walkDomCompact(document.body, 0);
267            }
268            var currentRefs = new Set(refMap.keys());
269            var stale = [];
270            previousRefs.forEach(function(refId) {
271                if (!currentRefs.has(refId)) {
272                    var weak = weakRefMap.get(refId);
273                    if (weak) {
274                        var el = weak.deref();
275                        if (!el || !el.isConnected) {
276                            stale.push(refId);
277                            weakRefMap.delete(refId);
278                        }
279                    } else {
280                        stale.push(refId);
281                    }
282                }
283            });
284            weakRefMap.forEach(function(weak, rid) {
285                if (!weak.deref()) {
286                    weakRefMap.delete(rid);
287                    stale.push(rid);
288                }
289            });
290            return { tree: tree, stale_refs: stale, format: fmt };
291        },
292
293        getRef: function(refId) {
294            return resolveRef(refId);
295        },
296
297        getStaleRefs: function() {
298            return getStaleRefs();
299        },
300
301        findElements: function(query) {
302            var results = [];
303            var maxResults = query.max_results || 10;
304
305            if (query.css) {
306                try { document.body.matches(query.css); } catch(e) {
307                    return { error: 'invalid CSS selector: ' + query.css + ' — ' + e.message };
308                }
309            }
310
311            function matches(el) {
312                if (query.text) {
313                    var txt = (el.textContent || '').trim();
314                    if (query.exact) {
315                        if (txt !== query.text) return false;
316                    } else {
317                        if (txt.toLowerCase().indexOf(query.text.toLowerCase()) === -1) return false;
318                    }
319                }
320                if (query.role) {
321                    var role = el.getAttribute('role') || inferRole(el);
322                    if (role !== query.role) return false;
323                }
324                if (query.test_id) {
325                    if (el.getAttribute('data-testid') !== query.test_id) return false;
326                }
327                if (query.css) {
328                    if (!el.matches(query.css)) return false;
329                }
330                if (query.name) {
331                    var name = el.getAttribute('aria-label')
332                        || el.getAttribute('title')
333                        || el.getAttribute('placeholder') || '';
334                    if (name.toLowerCase().indexOf(query.name.toLowerCase()) === -1) return false;
335                }
336                if (query.tag) {
337                    if (el.tagName.toLowerCase() !== query.tag.toLowerCase()) return false;
338                }
339                if (query.placeholder) {
340                    if ((el.getAttribute('placeholder') || '').toLowerCase().indexOf(query.placeholder.toLowerCase()) === -1) return false;
341                }
342                if (query.alt) {
343                    if ((el.getAttribute('alt') || '').toLowerCase().indexOf(query.alt.toLowerCase()) === -1) return false;
344                }
345                if (query.title_attr) {
346                    if ((el.getAttribute('title') || '').toLowerCase().indexOf(query.title_attr.toLowerCase()) === -1) return false;
347                }
348                if (query.enabled === true && el.disabled) return false;
349                if (query.enabled === false && !el.disabled) return false;
350                return true;
351            }
352
353            function buildResult(node, style) {
354                var existingRef = null;
355                refMap.forEach(function(el, refId) {
356                    if (el === node) existingRef = refId;
357                });
358                var ref_id = existingRef || registerRef(node);
359                var role = node.getAttribute('role') || inferRole(node);
360                var rect = node.getBoundingClientRect();
361                var vis = true;
362                if (style) {
363                    vis = style.display !== 'none' && style.visibility !== 'hidden';
364                }
365                return {
366                    ref_id: ref_id,
367                    tag: node.tagName.toLowerCase(),
368                    role: role,
369                    name: node.getAttribute('aria-label') || node.getAttribute('title') || null,
370                    text: (node.textContent || '').trim().substring(0, 100),
371                    bounds: { x: Math.round(rect.x), y: Math.round(rect.y), width: Math.round(rect.width), height: Math.round(rect.height) },
372                    visible: vis,
373                    enabled: !node.disabled,
374                    value: node.value || null
375                };
376            }
377
378            if (query.label) {
379                var labels = document.querySelectorAll('label');
380                for (var li = 0; li < labels.length && results.length < maxResults; li++) {
381                    var lbl = labels[li];
382                    if ((lbl.textContent || '').toLowerCase().indexOf(query.label.toLowerCase()) === -1) continue;
383                    var target = null;
384                    var forAttr = lbl.getAttribute('for');
385                    if (forAttr) {
386                        target = document.getElementById(forAttr);
387                    }
388                    if (!target) {
389                        target = lbl.querySelector('input, textarea, select');
390                    }
391                    if (target) {
392                        var ts = window.getComputedStyle(target);
393                        results.push(buildResult(target, ts));
394                    }
395                }
396                return results;
397            }
398
399            function search(node) {
400                if (results.length >= maxResults) return;
401                if (!node || node.nodeType !== 1) return;
402                var style = window.getComputedStyle(node);
403                if (style.display === 'none' || style.visibility === 'hidden') return;
404
405                if (matches(node)) {
406                    results.push(buildResult(node, style));
407                }
408
409                for (var c = 0; c < node.children.length; c++) {
410                    search(node.children[c]);
411                }
412                if (node.shadowRoot) {
413                    for (var s = 0; s < node.shadowRoot.children.length; s++) {
414                        search(node.shadowRoot.children[s]);
415                    }
416                }
417                // Same-origin iframe traversal.
418                if (node.tagName === 'IFRAME' || node.tagName === 'FRAME') {
419                    try {
420                        var idoc = node.contentDocument;
421                        if (idoc && idoc.body) search(idoc.body);
422                    } catch (e) { /* cross-origin: skip */ }
423                }
424            }
425
426            search(document.body);
427            return results;
428        },
429
430        // ── Interactions ─────────────────────────────────────────────────────
431
432        click: function(refId, timeoutMs) {
433            return withAutoWait(refId, timeoutMs, function(el) {
434                el.click();
435                return { ok: true };
436            });
437        },
438
439        doubleClick: function(refId, timeoutMs) {
440            return withAutoWait(refId, timeoutMs, function(el) {
441                el.dispatchEvent(new MouseEvent('dblclick', { bubbles: true, cancelable: true }));
442                return { ok: true };
443            });
444        },
445
446        hover: function(refId, timeoutMs) {
447            return withAutoWait(refId, timeoutMs, function(el) {
448                el.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true }));
449                el.dispatchEvent(new MouseEvent('mouseover', { bubbles: true }));
450                return { ok: true };
451            });
452        },
453
454        fill: function(refId, value, timeoutMs) {
455            return withAutoWait(refId, timeoutMs, function(el) {
456                if (!el.matches('input, textarea, [contenteditable="true"]')) {
457                    return { ok: false, error: 'element is not fillable (not input, textarea, or contenteditable): ' + (el.tagName || '').toLowerCase(), hint: 'CHECK_INPUT' };
458                }
459                var proto = el instanceof HTMLTextAreaElement
460                    ? HTMLTextAreaElement.prototype
461                    : HTMLInputElement.prototype;
462                var desc = Object.getOwnPropertyDescriptor(proto, 'value');
463                if (desc && desc.set) {
464                    desc.set.call(el, value);
465                } else {
466                    el.value = value;
467                }
468                el.dispatchEvent(new Event('input', { bubbles: true }));
469                el.dispatchEvent(new Event('change', { bubbles: true }));
470                return { ok: true };
471            });
472        },
473
474        type: function(refId, text, timeoutMs) {
475            return withAutoWait(refId, timeoutMs, function(el) {
476                el.focus();
477                var proto = el instanceof HTMLTextAreaElement
478                    ? HTMLTextAreaElement.prototype
479                    : HTMLInputElement.prototype;
480                var desc = Object.getOwnPropertyDescriptor(proto, 'value');
481                for (var i = 0; i < text.length; i++) {
482                    var ch = text[i];
483                    el.dispatchEvent(new KeyboardEvent('keydown', { key: ch, bubbles: true }));
484                    el.dispatchEvent(new KeyboardEvent('keypress', { key: ch, bubbles: true }));
485                    var current = el.value || '';
486                    if (desc && desc.set) {
487                        desc.set.call(el, current + ch);
488                    } else {
489                        el.value = current + ch;
490                    }
491                    el.dispatchEvent(new InputEvent('input', { bubbles: true, data: ch, inputType: 'insertText' }));
492                    el.dispatchEvent(new KeyboardEvent('keyup', { key: ch, bubbles: true }));
493                }
494                el.dispatchEvent(new Event('change', { bubbles: true }));
495                return { ok: true };
496            });
497        },
498
499        pressKey: function(key) {
500            var target = document.activeElement || document.body;
501            var parts = key.split('+');
502            if (parts.length === 1 || (parts.length === 2 && parts[0] === '' && parts[1] === '')) {
503                var k = parts.length === 1 ? key : '+';
504                target.dispatchEvent(new KeyboardEvent('keydown', { key: k, bubbles: true }));
505                target.dispatchEvent(new KeyboardEvent('keyup', { key: k, bubbles: true }));
506                return { ok: true };
507            }
508            var finalKey = parts.pop();
509            var mods = { ctrlKey: false, shiftKey: false, altKey: false, metaKey: false };
510            for (var m = 0; m < parts.length; m++) {
511                var mod = parts[m];
512                if (mod === 'Control' || mod === 'Ctrl') mods.ctrlKey = true;
513                else if (mod === 'Shift') mods.shiftKey = true;
514                else if (mod === 'Alt') mods.altKey = true;
515                else if (mod === 'Meta' || mod === 'Command' || mod === 'Cmd') mods.metaKey = true;
516            }
517            var modKeys = [];
518            if (mods.ctrlKey) modKeys.push('Control');
519            if (mods.shiftKey) modKeys.push('Shift');
520            if (mods.altKey) modKeys.push('Alt');
521            if (mods.metaKey) modKeys.push('Meta');
522            for (var i = 0; i < modKeys.length; i++) {
523                target.dispatchEvent(new KeyboardEvent('keydown', { key: modKeys[i], bubbles: true, ctrlKey: mods.ctrlKey, shiftKey: mods.shiftKey, altKey: mods.altKey, metaKey: mods.metaKey }));
524            }
525            target.dispatchEvent(new KeyboardEvent('keydown', { key: finalKey, bubbles: true, ctrlKey: mods.ctrlKey, shiftKey: mods.shiftKey, altKey: mods.altKey, metaKey: mods.metaKey }));
526            target.dispatchEvent(new KeyboardEvent('keyup', { key: finalKey, bubbles: true, ctrlKey: mods.ctrlKey, shiftKey: mods.shiftKey, altKey: mods.altKey, metaKey: mods.metaKey }));
527            for (var j = modKeys.length - 1; j >= 0; j--) {
528                target.dispatchEvent(new KeyboardEvent('keyup', { key: modKeys[j], bubbles: true, ctrlKey: mods.ctrlKey, shiftKey: mods.shiftKey, altKey: mods.altKey, metaKey: mods.metaKey }));
529            }
530            return { ok: true };
531        },
532
533        selectOption: function(refId, values, timeoutMs) {
534            return withAutoWait(refId, timeoutMs, function(el) {
535                if (el.tagName !== 'SELECT') {
536                    return { ok: false, error: 'element is not a <select>', hint: 'CHECK_INPUT' };
537                }
538                var valSet = new Set(values);
539                for (var i = 0; i < el.options.length; i++) {
540                    el.options[i].selected = valSet.has(el.options[i].value);
541                }
542                el.dispatchEvent(new Event('change', { bubbles: true }));
543                return { ok: true };
544            });
545        },
546
547        scrollTo: function(refId, x, y, timeoutMs) {
548            if (refId) {
549                return withAutoWait(refId, timeoutMs, function(el) {
550                    el.scrollIntoView({ behavior: 'smooth', block: 'center' });
551                    return { ok: true };
552                });
553            } else {
554                window.scrollTo({ left: x || 0, top: y || 0, behavior: 'smooth' });
555                return Promise.resolve({ ok: true });
556            }
557        },
558
559        focusElement: function(refId, timeoutMs) {
560            return withAutoWait(refId, timeoutMs, function(el) {
561                el.focus();
562                return { ok: true, tag: el.tagName.toLowerCase() };
563            });
564        },
565
566        // ── IPC Log ──────────────────────────────────────────────────────────
567
568        getIpcLog: function(limit) {
569            var victauriPrefix = 'plugin%3Avictauri%7C';
570            var entries = [];
571            for (var i = 0; i < networkLog.length; i++) {
572                var n = networkLog[i];
573                var raw = ipcCommandPath(n.url);
574                if (raw === null) continue;
575                if (raw.indexOf(victauriPrefix) === 0) continue;
576                var command;
577                try { command = decodeURIComponent(raw); } catch(e) { command = raw; }
578                // Classify by COMMAND outcome, not just HTTP status. Tauri returns
579                // HTTP 200 for a failed command (incl. "command not found") and signals
580                // the real result via the `Tauri-Response` header captured as ipc_response.
581                // Precedence: pending > transport error (HTTP >= 400 / 'error') > command
582                // error (ipc_response 'error') > ok.
583                var st;
584                if (n.status === 'pending') { st = 'pending'; }
585                else if (n.status !== 200 && n.status !== 'ok') { st = 'error'; }
586                else if (n.ipc_response === 'error') { st = 'error'; }
587                else { st = 'ok'; }
588                var errText = null;
589                if (st === 'error') {
590                    if (n.status !== 200 && n.status !== 'ok' && n.status !== 'pending') {
591                        errText = 'HTTP ' + n.status;
592                    } else if (n.response_body != null) {
593                        // Command-level error: the body carries the error message.
594                        errText = typeof n.response_body === 'string'
595                            ? n.response_body : JSON.stringify(n.response_body);
596                    } else {
597                        errText = 'command error';
598                    }
599                }
600                entries.push({
601                    id: n.id,
602                    command: command,
603                    args: n.request_args || {},
604                    timestamp: n.timestamp,
605                    status: st,
606                    duration_ms: n.duration_ms,
607                    result: n.response_body || null,
608                    error: errText,
609                });
610            }
611            if (limit) return entries.slice(-limit);
612            return entries;
613        },
614
615        clearIpcLog: function() {
616            for (var i = networkLog.length - 1; i >= 0; i--) {
617                if (isIpcUrl(networkLog[i].url)) networkLog.splice(i, 1);
618            }
619        },
620
621        waitForIpcComplete: function(timeoutMs) {
622            var log = window.__VICTAURI__.getIpcLog();
623            if (log.length > 0) {
624                var last = log[log.length - 1];
625                if (last.duration_ms !== null && last.duration_ms !== undefined && last.result !== null) {
626                    return Promise.resolve(true);
627                }
628            }
629            return new Promise(function(resolve) {
630                var timer = setTimeout(function() {
631                    var idx = ipcWaiters.indexOf(waiterFn);
632                    if (idx !== -1) ipcWaiters.splice(idx, 1);
633                    resolve(false);
634                }, timeoutMs || 500);
635                function waiterFn() {
636                    clearTimeout(timer);
637                    resolve(true);
638                }
639                ipcWaiters.push(waiterFn);
640            });
641        },
642
643        // ── Console ──────────────────────────────────────────────────────────
644
645        getConsoleLogs: function(since) {
646            if (since) return consoleLogs.filter(function(l) { return l.timestamp >= since; });
647            return consoleLogs;
648        },
649
650        clearConsoleLogs: function() {
651            consoleLogs.length = 0;
652        },
653
654        // ── Mutations ────────────────────────────────────────────────────────
655
656        getMutationLog: function(since) {
657            if (since) return mutationLog.filter(function(m) { return m.timestamp >= since; });
658            return mutationLog;
659        },
660
661        clearMutationLog: function() {
662            mutationLog.length = 0;
663        },
664
665        // ── Network ──────────────────────────────────────────────────────────
666
667        getNetworkLog: function(filter, limit) {
668            var log = networkLog;
669            if (filter) {
670                log = log.filter(function(e) { return e.url.indexOf(filter) !== -1; });
671            }
672            if (limit) log = log.slice(-limit);
673            return log;
674        },
675
676        clearNetworkLog: function() {
677            networkLog.length = 0;
678        },
679
680        // ── Network routing (interception / mock / block / delay) ──────────────
681        // Add a route rule. `rule` is an object: { pattern, match_type, method,
682        // action ('block'|'fulfill'|'delay'), status, status_text, headers,
683        // body, content_type, delay_ms, times }. Returns the assigned id.
684        addRoute: function(rule) {
685            if (typeof rule === 'string') { try { rule = JSON.parse(rule); } catch (e) { return { ok: false, error: 'invalid rule JSON' }; } }
686            if (!rule || !rule.pattern) return { ok: false, error: 'route rule requires a pattern' };
687            var r = {
688                id: ++routeCounter,
689                pattern: String(rule.pattern),
690                match_type: rule.match_type || 'substring',
691                method: rule.method || null,
692                action: rule.action || 'fulfill',
693                status: typeof rule.status === 'number' ? rule.status : 200,
694                status_text: rule.status_text || '',
695                headers: rule.headers || {},
696                body: (rule.body === undefined || rule.body === null) ? '' : rule.body,
697                content_type: rule.content_type || 'application/json',
698                delay_ms: typeof rule.delay_ms === 'number' ? rule.delay_ms : 0,
699                times: typeof rule.times === 'number' ? rule.times : 0,
700                triggered: 0,
701            };
702            routeRules.push(r);
703            return { ok: true, id: r.id, rule: r };
704        },
705
706        getRouteRules: function() { return routeRules; },
707
708        clearRoute: function(id) {
709            var before = routeRules.length;
710            routeRules = routeRules.filter(function(r) { return r.id !== id; });
711            return { ok: true, removed: before - routeRules.length };
712        },
713
714        clearRoutes: function() {
715            var n = routeRules.length;
716            routeRules = [];
717            return { ok: true, removed: n };
718        },
719
720        getRouteMatches: function(limit) {
721            return limit ? routeMatchLog.slice(-limit) : routeMatchLog;
722        },
723
724        // ── Storage ──────────────────────────────────────────────────────────
725
726        getLocalStorage: function(key) {
727            if (key !== undefined && key !== null) {
728                var v = localStorage.getItem(key);
729                try { return JSON.parse(v); } catch(e) { return v; }
730            }
731            var obj = {};
732            for (var i = 0; i < localStorage.length; i++) {
733                var k = localStorage.key(i);
734                var val = localStorage.getItem(k);
735                try { obj[k] = JSON.parse(val); } catch(e) { obj[k] = val; }
736            }
737            return obj;
738        },
739
740        setLocalStorage: function(key, value) {
741            localStorage.setItem(key, typeof value === 'string' ? value : JSON.stringify(value));
742            return { ok: true };
743        },
744
745        deleteLocalStorage: function(key) {
746            localStorage.removeItem(key);
747            return { ok: true };
748        },
749
750        getSessionStorage: function(key) {
751            if (key !== undefined && key !== null) {
752                var v = sessionStorage.getItem(key);
753                try { return JSON.parse(v); } catch(e) { return v; }
754            }
755            var obj = {};
756            for (var i = 0; i < sessionStorage.length; i++) {
757                var k = sessionStorage.key(i);
758                var val = sessionStorage.getItem(k);
759                try { obj[k] = JSON.parse(val); } catch(e) { obj[k] = val; }
760            }
761            return obj;
762        },
763
764        setSessionStorage: function(key, value) {
765            sessionStorage.setItem(key, typeof value === 'string' ? value : JSON.stringify(value));
766            return { ok: true };
767        },
768
769        deleteSessionStorage: function(key) {
770            sessionStorage.removeItem(key);
771            return { ok: true };
772        },
773
774        getCookies: function() {
775            if (!document.cookie) return [];
776            return document.cookie.split(';').map(function(c) {
777                var parts = c.trim().split('=');
778                return { name: parts[0], value: parts.slice(1).join('=') };
779            });
780        },
781
782        // ── Navigation ───────────────────────────────────────────────────────
783
784        getNavigationLog: function() {
785            return navigationLog;
786        },
787
788        navigate: function(url) {
789            window.location.href = url;
790            return { ok: true };
791        },
792
793        navigateBack: function() {
794            history.back();
795            return { ok: true };
796        },
797
798        // ── Dialogs ──────────────────────────────────────────────────────────
799
800        getDialogLog: function() {
801            return dialogLog;
802        },
803
804        clearDialogLog: function() {
805            dialogLog.length = 0;
806        },
807
808        setDialogAutoResponse: function(type, action, text) {
809            dialogAutoResponses[type] = { action: action, text: text };
810            return { ok: true };
811        },
812
813        // ── Combined Event Stream ────────────────────────────────────────────
814
815        getEventStream: function(since) {
816            var events = [];
817            var ts = since || 0;
818
819            consoleLogs.forEach(function(l) {
820                if (l.timestamp >= ts) {
821                    events.push({ type: 'console', level: l.level, message: l.message, timestamp: l.timestamp });
822                }
823            });
824
825            mutationLog.forEach(function(m) {
826                if (m.timestamp >= ts) {
827                    events.push({ type: 'dom_mutation', count: m.count, timestamp: m.timestamp });
828                }
829            });
830
831            var victauriPrefix = 'plugin%3Avictauri%7C';
832            networkLog.forEach(function(n) {
833                if (n.timestamp < ts) return;
834                var raw = ipcCommandPath(n.url);
835                if (raw === null || raw.indexOf(victauriPrefix) === 0) return;
836                var cmd; try { cmd = decodeURIComponent(raw); } catch(e) { cmd = raw; }
837                events.push({ type: 'ipc', command: cmd, status: n.status === 200 ? 'ok' : (n.status === 'pending' ? 'pending' : 'error'), duration_ms: n.duration_ms, timestamp: n.timestamp });
838            });
839
840            networkLog.forEach(function(n) {
841                if (n.timestamp >= ts) {
842                    events.push({ type: 'network', method: n.method, url: n.url, status: n.status, duration_ms: n.duration_ms, timestamp: n.timestamp });
843                }
844            });
845
846            navigationLog.forEach(function(n) {
847                if (n.timestamp >= ts) {
848                    events.push({ type: 'navigation', url: n.url, nav_type: n.type, timestamp: n.timestamp });
849                }
850            });
851
852            interactionLog.forEach(function(i) {
853                if (i.timestamp >= ts) {
854                    events.push({ type: 'dom_interaction', action: i.action, selector: i.selector, value: i.value, timestamp: i.timestamp });
855                }
856            });
857
858            events.sort(function(a, b) { return a.timestamp - b.timestamp; });
859            return events;
860        },
861
862        // ── Wait ─────────────────────────────────────────────────────────────
863
864        waitFor: function(opts) {
865            return new Promise(function(resolve) {
866                var timeout = opts.timeout_ms || 10000;
867                var poll = opts.poll_ms || 200;
868                var start = Date.now();
869
870                function check() {
871                    var elapsed = Date.now() - start;
872                    if (elapsed >= timeout) {
873                        resolve({ ok: false, error: 'timeout after ' + timeout + 'ms', elapsed_ms: elapsed });
874                        return;
875                    }
876
877                    function getFullText(root) {
878                        var text = root.innerText || '';
879                        var els = root.querySelectorAll('*');
880                        for (var j = 0; j < els.length; j++) {
881                            if (els[j].shadowRoot) text += ' ' + getFullText(els[j].shadowRoot);
882                        }
883                        return text;
884                    }
885                    var met = false;
886                    if (opts.condition === 'text' && opts.value) {
887                        met = getFullText(document.body).indexOf(opts.value) !== -1;
888                    } else if (opts.condition === 'text_gone' && opts.value) {
889                        met = getFullText(document.body).indexOf(opts.value) === -1;
890                    } else if (opts.condition === 'selector' && opts.value) {
891                        met = !!document.querySelector(opts.value);
892                    } else if (opts.condition === 'selector_gone' && opts.value) {
893                        met = !document.querySelector(opts.value);
894                    } else if (opts.condition === 'url' && opts.value) {
895                        met = window.location.href.indexOf(opts.value) !== -1;
896                    } else if (opts.condition === 'ipc_idle') {
897                        met = networkLog.filter(function(n) { return isIpcUrl(n.url); }).every(function(n) { return n.status !== 'pending'; });
898                    } else if (opts.condition === 'network_idle') {
899                        met = networkLog.every(function(n) { return n.status !== 'pending'; });
900                    }
901
902                    if (met) {
903                        resolve({ ok: true, elapsed_ms: Date.now() - start });
904                    } else {
905                        setTimeout(check, poll);
906                    }
907                }
908                check();
909            });
910        },
911        // ── CSS / Style Introspection ────────────────────────────────────────
912
913        getStyles: function(refId, properties) {
914            var el = resolveRef(refId);
915            if (!el) return { error: 'ref not found: ' + refId };
916            var computed = window.getComputedStyle(el);
917            var result = {};
918            if (properties && properties.length > 0) {
919                for (var i = 0; i < properties.length; i++) {
920                    result[properties[i]] = computed.getPropertyValue(properties[i]);
921                }
922            } else {
923                var important = ['display','position','width','height','margin','padding',
924                    'color','background-color','font-size','font-family','font-weight',
925                    'border','border-radius','opacity','visibility','overflow','z-index',
926                    'flex-direction','justify-content','align-items','gap','grid-template-columns',
927                    'box-shadow','transform','transition','cursor','pointer-events','text-align',
928                    'line-height','letter-spacing','white-space','text-overflow','max-width',
929                    'max-height','min-width','min-height','top','right','bottom','left'];
930                // Interactivity-critical props are always shown (when non-empty),
931                // even at 'none'/'hidden'/'auto' — `display:none`,
932                // `visibility:hidden`, and `pointer-events:none` are exactly the
933                // "why can't I interact with this?" answers, and the compactness
934                // filter below would otherwise drop them as if they were defaults.
935                var alwaysShow = ['display', 'visibility', 'pointer-events'];
936                for (var i = 0; i < important.length; i++) {
937                    var v = computed.getPropertyValue(important[i]);
938                    var critical = alwaysShow.indexOf(important[i]) !== -1;
939                    if (v && v !== '' && (critical
940                        || (v !== 'none' && v !== 'normal' && v !== 'auto'
941                            && v !== '0px' && v !== 'rgba(0, 0, 0, 0)'))) {
942                        result[important[i]] = v;
943                    }
944                }
945            }
946            return { ref_id: refId, tag: el.tagName.toLowerCase(), styles: result };
947        },
948
949        getBoundingBoxes: function(refIds) {
950            var results = [];
951            for (var i = 0; i < refIds.length; i++) {
952                var el = resolveRef(refIds[i]);
953                if (!el) { results.push({ ref_id: refIds[i], error: 'ref not found' }); continue; }
954                var rect = el.getBoundingClientRect();
955                var computed = window.getComputedStyle(el);
956                results.push({
957                    ref_id: refIds[i],
958                    tag: el.tagName.toLowerCase(),
959                    x: Math.round(rect.x),
960                    y: Math.round(rect.y),
961                    width: Math.round(rect.width),
962                    height: Math.round(rect.height),
963                    margin: {
964                        top: parseInt(computed.marginTop) || 0,
965                        right: parseInt(computed.marginRight) || 0,
966                        bottom: parseInt(computed.marginBottom) || 0,
967                        left: parseInt(computed.marginLeft) || 0,
968                    },
969                    padding: {
970                        top: parseInt(computed.paddingTop) || 0,
971                        right: parseInt(computed.paddingRight) || 0,
972                        bottom: parseInt(computed.paddingBottom) || 0,
973                        left: parseInt(computed.paddingLeft) || 0,
974                    },
975                    border: {
976                        top: parseInt(computed.borderTopWidth) || 0,
977                        right: parseInt(computed.borderRightWidth) || 0,
978                        bottom: parseInt(computed.borderBottomWidth) || 0,
979                        left: parseInt(computed.borderLeftWidth) || 0,
980                    },
981                });
982            }
983            return results;
984        },
985
986        // ── Visual Debug Overlays ────────────────────────────────────────────
987
988        highlightElement: function(refId, color, label) {
989            var el = resolveRef(refId);
990            if (!el) return { error: 'ref not found: ' + refId };
991            var c = color || 'rgba(255, 0, 0, 0.3)';
992            var overlay = document.createElement('div');
993            overlay.className = '__victauri_highlight__';
994            overlay.setAttribute('data-victauri-ref', refId);
995            var rect = el.getBoundingClientRect();
996            overlay.style.cssText = 'position:fixed;pointer-events:none;z-index:2147483647;' +
997                'border:2px solid ' + c + ';background:' + c + ';' +
998                'left:' + rect.left + 'px;top:' + rect.top + 'px;' +
999                'width:' + rect.width + 'px;height:' + rect.height + 'px;' +
1000                'transition:all 0.2s ease;';
1001            if (label) {
1002                var tag = document.createElement('span');
1003                tag.textContent = label;
1004                tag.style.cssText = 'position:absolute;top:-20px;left:0;background:#222;color:#fff;' +
1005                    'font-size:11px;padding:2px 6px;border-radius:3px;white-space:nowrap;font-family:monospace;';
1006                overlay.appendChild(tag);
1007            }
1008            document.body.appendChild(overlay);
1009            return { ok: true, ref_id: refId };
1010        },
1011
1012        clearHighlights: function() {
1013            var overlays = document.querySelectorAll('.__victauri_highlight__');
1014            for (var i = 0; i < overlays.length; i++) overlays[i].remove();
1015            return { ok: true, removed: overlays.length };
1016        },
1017
1018        // ── CSS Injection ────────────────────────────────────────────────────
1019
1020        injectCss: function(css) {
1021            var existing = document.getElementById('__victauri_injected_css__');
1022            if (existing) existing.remove();
1023            var style = document.createElement('style');
1024            style.id = '__victauri_injected_css__';
1025            style.textContent = css;
1026            document.head.appendChild(style);
1027            return { ok: true, length: css.length };
1028        },
1029
1030        removeInjectedCss: function() {
1031            var existing = document.getElementById('__victauri_injected_css__');
1032            if (!existing) return { ok: true, removed: false };
1033            existing.remove();
1034            return { ok: true, removed: true };
1035        },
1036
1037        // ── Accessibility Audit ──────────────────────────────────────────────
1038
1039        auditAccessibility: function() {
1040            var violations = [];
1041            var warnings = [];
1042
1043            // Images without alt text
1044            var imgs = document.querySelectorAll('img');
1045            for (var i = 0; i < imgs.length; i++) {
1046                if (!imgs[i].hasAttribute('alt')) {
1047                    violations.push({ rule: 'img-alt', severity: 'critical', element: describeEl(imgs[i]),
1048                        message: 'Image missing alt attribute' });
1049                } else if (imgs[i].alt.trim() === '') {
1050                    warnings.push({ rule: 'img-alt-empty', severity: 'minor', element: describeEl(imgs[i]),
1051                        message: 'Image has empty alt (ok if decorative)' });
1052                }
1053            }
1054
1055            // Form inputs without labels
1056            var inputs = document.querySelectorAll('input, select, textarea');
1057            for (var i = 0; i < inputs.length; i++) {
1058                var inp = inputs[i];
1059                if (inp.type === 'hidden') continue;
1060                var hasLabel = false;
1061                if (inp.id) {
1062                    try { hasLabel = !!document.querySelector('label[for=\"' + CSS.escape(inp.id) + '\"]'); }
1063                    catch(e) { /* malformed id — skip */ }
1064                }
1065                var hasAria = inp.getAttribute('aria-label') || inp.getAttribute('aria-labelledby');
1066                var hasTitle = inp.title;
1067                var hasPlaceholder = inp.placeholder;
1068                if (!hasLabel && !hasAria && !hasTitle && !hasPlaceholder) {
1069                    violations.push({ rule: 'input-label', severity: 'serious', element: describeEl(inp),
1070                        message: 'Form input has no accessible label' });
1071                }
1072            }
1073
1074            // Buttons without accessible text
1075            var buttons = document.querySelectorAll('button, [role="button"]');
1076            for (var i = 0; i < buttons.length; i++) {
1077                var btn = buttons[i];
1078                var text = (btn.textContent || '').trim();
1079                var ariaLabel = btn.getAttribute('aria-label');
1080                var ariaLabelledBy = btn.getAttribute('aria-labelledby');
1081                if (!text && !ariaLabel && !ariaLabelledBy && !btn.title) {
1082                    var hasImg = btn.querySelector('img[alt], svg[aria-label]');
1083                    if (!hasImg) {
1084                        violations.push({ rule: 'button-name', severity: 'serious', element: describeEl(btn),
1085                            message: 'Button has no accessible name' });
1086                    }
1087                }
1088            }
1089
1090            // Links without text
1091            var links = document.querySelectorAll('a[href]');
1092            for (var i = 0; i < links.length; i++) {
1093                var link = links[i];
1094                var text = (link.textContent || '').trim();
1095                var ariaLabel = link.getAttribute('aria-label');
1096                if (!text && !ariaLabel && !link.title) {
1097                    violations.push({ rule: 'link-name', severity: 'serious', element: describeEl(link),
1098                        message: 'Link has no accessible text' });
1099                }
1100            }
1101
1102            // Missing document language
1103            if (!document.documentElement.lang) {
1104                violations.push({ rule: 'html-lang', severity: 'serious', element: '<html>',
1105                    message: 'Document missing lang attribute' });
1106            }
1107
1108            // Heading hierarchy
1109            var headings = document.querySelectorAll('h1, h2, h3, h4, h5, h6');
1110            var prevLevel = 0;
1111            for (var i = 0; i < headings.length; i++) {
1112                var level = parseInt(headings[i].tagName.charAt(1));
1113                if (level > prevLevel + 1 && prevLevel > 0) {
1114                    warnings.push({ rule: 'heading-order', severity: 'moderate', element: describeEl(headings[i]),
1115                        message: 'Heading level skipped from h' + prevLevel + ' to h' + level });
1116                }
1117                prevLevel = level;
1118            }
1119
1120            // Missing page title
1121            if (!document.title || document.title.trim() === '') {
1122                violations.push({ rule: 'document-title', severity: 'serious', element: '<head>',
1123                    message: 'Document has no title' });
1124            }
1125
1126            // Color contrast (simplified — checks text elements against backgrounds)
1127            var textEls = document.querySelectorAll('p, span, a, button, h1, h2, h3, h4, h5, h6, li, td, th, label, div');
1128            var contrastIssues = 0;
1129            for (var i = 0; i < textEls.length && contrastIssues < 10; i++) {
1130                var el = textEls[i];
1131                if (!el.textContent || el.textContent.trim() === '') continue;
1132                if (el.children.length > 0 && el.children[0].textContent === el.textContent) continue;
1133                var cs = window.getComputedStyle(el);
1134                var fg = parseColor(cs.color);
1135                var bg = parseColor(cs.backgroundColor);
1136                if (fg && bg && bg.a > 0) {
1137                    var ratio = contrastRatio(fg, bg);
1138                    var fontSize = parseFloat(cs.fontSize);
1139                    var isBold = parseInt(cs.fontWeight) >= 700;
1140                    var isLarge = fontSize >= 24 || (fontSize >= 18.66 && isBold);
1141                    var threshold = isLarge ? 3 : 4.5;
1142                    if (ratio < threshold) {
1143                        contrastIssues++;
1144                        warnings.push({ rule: 'color-contrast', severity: 'serious',
1145                            element: describeEl(el),
1146                            message: 'Contrast ratio ' + ratio.toFixed(2) + ':1 (needs ' + threshold + ':1)',
1147                            details: { fg: cs.color, bg: cs.backgroundColor, ratio: ratio.toFixed(2) } });
1148                    }
1149                }
1150            }
1151
1152            // ARIA role validity
1153            var ariaEls = document.querySelectorAll('[role]');
1154            var validRoles = new Set(['alert','alertdialog','application','article','banner','button',
1155                'cell','checkbox','columnheader','combobox','complementary','contentinfo','definition',
1156                'dialog','directory','document','feed','figure','form','grid','gridcell','group',
1157                'heading','img','link','list','listbox','listitem','log','main','marquee','math',
1158                'menu','menubar','menuitem','menuitemcheckbox','menuitemradio','meter','navigation',
1159                'none','note','option','presentation','progressbar','radio','radiogroup','region',
1160                'row','rowgroup','rowheader','scrollbar','search','searchbox','separator','slider',
1161                'spinbutton','status','switch','tab','table','tablist','tabpanel','term','textbox',
1162                'timer','toolbar','tooltip','tree','treegrid','treeitem']);
1163            for (var i = 0; i < ariaEls.length; i++) {
1164                var role = ariaEls[i].getAttribute('role');
1165                if (role && !validRoles.has(role)) {
1166                    warnings.push({ rule: 'aria-role', severity: 'moderate', element: describeEl(ariaEls[i]),
1167                        message: 'Invalid ARIA role: ' + role });
1168                }
1169            }
1170
1171            // Tab index > 0
1172            var tabbable = document.querySelectorAll('[tabindex]');
1173            for (var i = 0; i < tabbable.length; i++) {
1174                var ti = parseInt(tabbable[i].getAttribute('tabindex'));
1175                if (ti > 0) {
1176                    warnings.push({ rule: 'tabindex-positive', severity: 'moderate', element: describeEl(tabbable[i]),
1177                        message: 'Positive tabindex disrupts natural tab order (tabindex=' + ti + ')' });
1178                }
1179            }
1180
1181            return {
1182                violations: violations,
1183                warnings: warnings,
1184                summary: {
1185                    critical: violations.filter(function(v) { return v.severity === 'critical'; }).length,
1186                    serious: violations.filter(function(v) { return v.severity === 'serious'; }).length + warnings.filter(function(w) { return w.severity === 'serious'; }).length,
1187                    moderate: warnings.filter(function(w) { return w.severity === 'moderate'; }).length,
1188                    minor: warnings.filter(function(w) { return w.severity === 'minor'; }).length,
1189                    total: violations.length + warnings.length,
1190                }
1191            };
1192        },
1193
1194        // ── Performance Metrics ──────────────────────────────────────────────
1195
1196        getPerformanceMetrics: function() {
1197            var result = {};
1198
1199            // Navigation timing
1200            var nav = performance.getEntriesByType('navigation')[0];
1201            if (nav) {
1202                result.navigation = {
1203                    dns_ms: Math.round(nav.domainLookupEnd - nav.domainLookupStart),
1204                    connect_ms: Math.round(nav.connectEnd - nav.connectStart),
1205                    ttfb_ms: Math.round(nav.responseStart - nav.requestStart),
1206                    response_ms: Math.round(nav.responseEnd - nav.responseStart),
1207                    dom_interactive_ms: Math.round(nav.domInteractive - nav.startTime),
1208                    dom_complete_ms: Math.round(nav.domComplete - nav.startTime),
1209                    load_event_ms: Math.round(nav.loadEventEnd - nav.startTime),
1210                    transfer_size: nav.transferSize || 0,
1211                    encoded_body_size: nav.encodedBodySize || 0,
1212                    decoded_body_size: nav.decodedBodySize || 0,
1213                };
1214            }
1215
1216            // Resource summary
1217            var resources = performance.getEntriesByType('resource');
1218            var byType = {};
1219            var totalTransfer = 0;
1220            for (var i = 0; i < resources.length; i++) {
1221                var r = resources[i];
1222                var type = r.initiatorType || 'other';
1223                if (!byType[type]) byType[type] = { count: 0, total_ms: 0, total_bytes: 0 };
1224                byType[type].count++;
1225                byType[type].total_ms += r.duration;
1226                byType[type].total_bytes += r.transferSize || 0;
1227                totalTransfer += r.transferSize || 0;
1228            }
1229            result.resources = {
1230                total_count: resources.length,
1231                total_transfer_bytes: totalTransfer,
1232                by_type: byType,
1233                slowest: resources.sort(function(a, b) { return b.duration - a.duration; }).slice(0, 5).map(function(r) {
1234                    return { name: r.name.split('/').pop().split('?')[0], duration_ms: Math.round(r.duration), size: r.transferSize || 0, type: r.initiatorType };
1235                }),
1236            };
1237
1238            // Engine capability probe. Several perf APIs below are Chromium/WebView2-
1239            // ONLY and are simply undefined on WebKit (WKWebView/macOS, WebKitGTK/Linux)
1240            // — Victauri's moat platforms. Without this, those fields silently vanish
1241            // there and an agent reads "no heap / no long tasks / no paint" as real data
1242            // (and a heap-budget assertion passes regardless of memory). Feature-detect
1243            // explicitly so the unavailability is reported, never silent.
1244            var supportedEntryTypes = (typeof PerformanceObserver !== 'undefined' && PerformanceObserver.supportedEntryTypes) || [];
1245            result.engine = {
1246                js_heap_supported: typeof performance.memory !== 'undefined',
1247                long_task_supported: supportedEntryTypes.indexOf('longtask') !== -1,
1248                paint_timing_supported: supportedEntryTypes.indexOf('paint') !== -1,
1249                user_agent: navigator.userAgent,
1250            };
1251
1252            // Paint timing (Chromium-first; Safari ~14.1; WebKitGTK varies)
1253            var paints = performance.getEntriesByType('paint');
1254            if (paints.length === 0 && !result.engine.paint_timing_supported) {
1255                result.paint = { unavailable: true, reason: 'Paint Timing API not supported on this webview engine' };
1256            } else {
1257                result.paint = {};
1258                for (var i = 0; i < paints.length; i++) {
1259                    result.paint[paints[i].name] = Math.round(paints[i].startTime);
1260                }
1261            }
1262
1263            // JS heap — performance.memory is Chromium/WebView2-only.
1264            if (performance.memory) {
1265                result.js_heap = {
1266                    used_mb: Math.round(performance.memory.usedJSHeapSize / 1048576 * 100) / 100,
1267                    total_mb: Math.round(performance.memory.totalJSHeapSize / 1048576 * 100) / 100,
1268                    limit_mb: Math.round(performance.memory.jsHeapSizeLimit / 1048576 * 100) / 100,
1269                };
1270            } else {
1271                result.js_heap = { unavailable: true, reason: 'performance.memory is Chromium/WebView2-only; undefined on WebKit (WKWebView/WebKitGTK)' };
1272            }
1273
1274            // Long tasks — Long Tasks API ('longtask' entry type) is Chromium-only.
1275            if (longTasks.length > 0) {
1276                result.long_tasks = {
1277                    count: longTasks.length,
1278                    total_ms: Math.round(longTasks.reduce(function(s, t) { return s + t.duration; }, 0)),
1279                    worst_ms: Math.round(Math.max.apply(null, longTasks.map(function(t) { return t.duration; }))),
1280                };
1281            } else if (!result.engine.long_task_supported) {
1282                result.long_tasks = { unavailable: true, reason: 'Long Tasks API is Chromium-only' };
1283            } else {
1284                result.long_tasks = { count: 0, total_ms: 0, worst_ms: 0 };
1285            }
1286
1287            // DOM stats
1288            result.dom = {
1289                elements: document.querySelectorAll('*').length,
1290                max_depth: (function() { var d = 0; var walk = function(el, depth) { if (depth > d) d = depth; for (var i = 0; i < el.children.length && i < 5; i++) walk(el.children[i], depth + 1); }; walk(document.body, 0); return d; })(),
1291                event_listeners: listenerCount,
1292            };
1293
1294            return result;
1295        },
1296
1297        getDiagnostics: function() {
1298            var diag = { warnings: [], info: {} };
1299
1300            // Service worker detection
1301            if (navigator.serviceWorker && navigator.serviceWorker.controller) {
1302                diag.warnings.push({
1303                    id: 'service-worker-active',
1304                    severity: 'high',
1305                    message: 'Active service worker detected — may intercept fetch calls to ipc.localhost, causing IPC log gaps',
1306                    details: { scope: navigator.serviceWorker.controller.scriptURL }
1307                });
1308            }
1309
1310            // Closed shadow DOM detection
1311            var allEls = document.querySelectorAll('*');
1312            var closedShadowCount = 0;
1313            for (var i = 0; i < allEls.length; i++) {
1314                if (allEls[i].attachShadow && !allEls[i].shadowRoot) {
1315                    var tagName = allEls[i].tagName.toLowerCase();
1316                    if (tagName.includes('-')) closedShadowCount++;
1317                }
1318            }
1319            if (closedShadowCount > 0) {
1320                diag.warnings.push({
1321                    id: 'closed-shadow-dom',
1322                    severity: 'medium',
1323                    message: closedShadowCount + ' custom element(s) may use closed shadow DOM — their contents are invisible to dom_snapshot',
1324                    details: { count: closedShadowCount }
1325                });
1326            }
1327
1328            // iframe detection
1329            var iframes = document.querySelectorAll('iframe');
1330            if (iframes.length > 0) {
1331                diag.warnings.push({
1332                    id: 'iframes-present',
1333                    severity: 'medium',
1334                    message: iframes.length + ' iframe(s) found — Victauri bridge is not injected inside iframes (Tauri limitation)',
1335                    details: { count: iframes.length, srcs: Array.from(iframes).slice(0, 5).map(function(f) { return f.src || '(empty)'; }) }
1336                });
1337            }
1338
1339            // DOM size warning
1340            var elementCount = allEls.length;
1341            if (elementCount > 5000) {
1342                diag.warnings.push({
1343                    id: 'large-dom',
1344                    severity: 'low',
1345                    message: 'DOM has ' + elementCount + ' elements — dom_snapshot may be slow (>100ms)',
1346                    details: { count: elementCount }
1347                });
1348            }
1349
1350            // CSP detection (best-effort)
1351            var cspMeta = document.querySelector('meta[http-equiv="Content-Security-Policy"]');
1352            if (cspMeta) {
1353                var cspContent = cspMeta.getAttribute('content') || '';
1354                diag.info.csp_meta = cspContent;
1355                if (cspContent.indexOf('unsafe-eval') === -1 && cspContent.indexOf('script-src') !== -1) {
1356                    diag.info.csp_note = 'CSP restricts eval — Victauri uses native webview.eval() which bypasses CSP on most platforms';
1357                }
1358            }
1359
1360            // Environment info
1361            diag.info.bridge_version = window.__VICTAURI__.version;
1362            diag.info.user_agent = navigator.userAgent;
1363            diag.info.url = window.location.href;
1364            diag.info.dom_elements = elementCount;
1365            diag.info.open_shadow_roots = (function() { var c = 0; for (var i = 0; i < allEls.length; i++) { if (allEls[i].shadowRoot) c++; } return c; })();
1366            diag.info.event_listeners = listenerCount;
1367            diag.info.protocol = window.location.protocol;
1368
1369            return diag;
1370        },
1371
1372        // ── Animation Introspection (Web Animations API) ─────────────────────
1373        // Reads the running CSS animations/transitions so an agent can see what
1374        // the webview's animation engine is actually doing: declared timing,
1375        // easing, keyframes, current progress, and the animating element. Pure
1376        // standard DOM — works identically on WebView2/WKWebView/WebKitGTK.
1377        listAnimations: function(selector) {
1378            function rect(el) {
1379                if (!el || !el.getBoundingClientRect) return null;
1380                var b = el.getBoundingClientRect();
1381                return { x: Math.round(b.x), y: Math.round(b.y),
1382                         w: Math.round(b.width), h: Math.round(b.height) };
1383            }
1384            function describe(el) {
1385                if (!el) return null;
1386                var cls = (el.className && el.className.toString)
1387                    ? el.className.toString().substring(0, 60) : null;
1388                return { tag: el.tagName ? el.tagName.toLowerCase() : null,
1389                         id: el.id || null, cls: cls, rect: rect(el) };
1390            }
1391            var anims;
1392            try {
1393                if (selector) {
1394                    var scope = document.querySelectorAll(selector);
1395                    anims = [];
1396                    for (var i = 0; i < scope.length; i++) {
1397                        if (scope[i].getAnimations) {
1398                            anims = anims.concat(scope[i].getAnimations());
1399                        }
1400                    }
1401                } else {
1402                    anims = document.getAnimations ? document.getAnimations() : [];
1403                }
1404            } catch (e) {
1405                return { error: 'getAnimations failed: ' + (e && e.message) };
1406            }
1407            return anims.map(function(a) {
1408                var e = a.effect;
1409                var t = (e && e.getTiming) ? e.getTiming() : {};
1410                var ct = (e && e.getComputedTiming) ? e.getComputedTiming() : {};
1411                var kf = [];
1412                try { kf = (e && e.getKeyframes) ? e.getKeyframes() : []; } catch (_) {}
1413                return {
1414                    type: a.constructor ? a.constructor.name : 'Animation',
1415                    id: a.id || null,
1416                    animation_name: a.animationName || null,
1417                    transition_property: a.transitionProperty || null,
1418                    play_state: a.playState,
1419                    current_time: a.currentTime,
1420                    playback_rate: a.playbackRate,
1421                    timing: { duration: t.duration, delay: t.delay, end_delay: t.endDelay,
1422                              easing: t.easing, iterations: t.iterations,
1423                              direction: t.direction, fill: t.fill },
1424                    computed: { active_duration: ct.activeDuration, end_time: ct.endTime,
1425                                progress: ct.progress, current_iteration: ct.currentIteration },
1426                    target: describe(e && e.target),
1427                    keyframes: kf
1428                };
1429            });
1430        },
1431
1432        // ── Deterministic animation scrubbing ────────────────────────────────
1433        // Pause the target's WAAPI animations and hold state across calls so the
1434        // Rust side can seek to evenly-spaced progress points and capture a
1435        // jank-free frame at each. The paused+seeked frame is frozen, so the
1436        // (slow) native screenshot has nothing to race — this is why scrubbing
1437        // beats real-time capture for fast animations.
1438        scrubPrepare: function(selector) {
1439            var el = selector ? document.querySelector(selector) : null;
1440            if (!el) {
1441                var all = document.getAnimations ? document.getAnimations() : [];
1442                for (var i = 0; i < all.length; i++) {
1443                    if (all[i].effect && all[i].effect.target) { el = all[i].effect.target; break; }
1444                }
1445            }
1446            if (!el) {
1447                return Promise.resolve({ error: 'no target: selector matched nothing and no '
1448                    + 'animation is currently running. Trigger the animation, then scrub.',
1449                    anim_count: 0 });
1450            }
1451            var anims = (el.getAnimations ? el.getAnimations() : []).filter(function(a) {
1452                var ct = (a.effect && a.effect.getComputedTiming) ? a.effect.getComputedTiming() : null;
1453                return ct && isFinite(ct.endTime) && ct.endTime > 0;
1454            });
1455            if (!anims.length) {
1456                return Promise.resolve({ error: 'no seekable WAAPI animation on target — it may '
1457                    + 'be JS/requestAnimationFrame-driven (not seekable). Use animation sample '
1458                    + 'instead.', anim_count: 0 });
1459            }
1460            var ends = anims.map(function(a) { return a.effect.getComputedTiming().endTime; });
1461            var duration = Math.max.apply(null, ends);
1462            anims.forEach(function(a) { try { a.pause(); } catch (e) {} });
1463            window.__VICTAURI_SCRUB__ = { el: el, anims: anims, ends: ends, duration: duration };
1464            return Promise.all(anims.map(function(a) { return a.ready.catch(function(){}); }))
1465                .then(function() {
1466                    var b = el.getBoundingClientRect();
1467                    return { prepared: true, anim_count: anims.length, duration: duration,
1468                        target: { tag: el.tagName.toLowerCase(), id: el.id || null,
1469                            rect: { x: Math.round(b.x), y: Math.round(b.y),
1470                                    w: Math.round(b.width), h: Math.round(b.height) } } };
1471                });
1472        },
1473
1474        scrubSeek: function(progress) {
1475            var S = window.__VICTAURI_SCRUB__;
1476            if (!S) return Promise.resolve({ error: 'not prepared — scrubPrepare first' });
1477            var t = progress * S.duration;
1478            for (var i = 0; i < S.anims.length; i++) {
1479                try { S.anims[i].currentTime = Math.max(0, Math.min(t, S.ends[i])); } catch (e) {}
1480            }
1481            return Promise.all(S.anims.map(function(a) { return a.ready.catch(function(){}); }))
1482                .then(function() {
1483                    return new Promise(function(res) {
1484                        requestAnimationFrame(function() { requestAnimationFrame(res); });
1485                    });
1486                })
1487                .then(function() {
1488                    var el = S.el, b = el.getBoundingClientRect(), cs = window.getComputedStyle(el);
1489                    var tf = (function(s) {
1490                        if (!s || s.indexOf('matrix') !== 0) return { tx: 0, ty: 0, sx: 1, sy: 1 };
1491                        var m = s.match(/-?[\d.eE+]+/g);
1492                        if (!m) return { tx: 0, ty: 0, sx: 1, sy: 1 };
1493                        m = m.map(Number);
1494                        return m.length === 6
1495                            ? { tx: m[4], ty: m[5], sx: m[0], sy: m[3] }
1496                            : { tx: m[12], ty: m[13], sx: m[0], sy: m[5] };
1497                    })(cs.transform);
1498                    var r2 = function(n) { return Math.round(n * 100) / 100; };
1499                    return { progress: progress, t: r2(t),
1500                        rect: { x: r2(b.x), y: r2(b.y), w: Math.round(b.width), h: Math.round(b.height) },
1501                        transform: { tx: r2(tf.tx), ty: r2(tf.ty), sx: tf.sx, sy: tf.sy },
1502                        opacity: parseFloat(cs.opacity) };
1503                });
1504        },
1505
1506        scrubRestore: function(resume) {
1507            var S = window.__VICTAURI_SCRUB__;
1508            if (!S) return { restored: false };
1509            S.anims.forEach(function(a) { try { if (resume) a.play(); } catch (e) {} });
1510            window.__VICTAURI_SCRUB__ = null;
1511            return { restored: true, resumed: !!resume };
1512        },
1513
1514        // ── Real-time motion + jank recorder ─────────────────────────────────
1515        // Arm a requestAnimationFrame watcher that samples the target's geometry
1516        // every frame while it animates. Decoupled from the (blocking) eval call
1517        // so event-triggered sweeps are catchable: arm it, trigger the sweep,
1518        // then read back the measured curve + dropped-frame (jank) stats.
1519        installSweepRecorder: function(selector) {
1520            var R = (window.__VICTAURI_SWEEP__ = { sel: selector || null,
1521                sessions: [], cur: null });
1522            var matrix = function(el) {
1523                var s = getComputedStyle(el).transform;
1524                if (!s || s.indexOf('matrix') !== 0) return { tx: 0, ty: 0, sx: 1 };
1525                var m = s.match(/-?[\d.eE+]+/g);
1526                if (!m) return { tx: 0, ty: 0, sx: 1 };
1527                m = m.map(Number);
1528                return m.length === 6 ? { tx: m[4], ty: m[5], sx: m[0] }
1529                                      : { tx: m[12], ty: m[13], sx: m[0] };
1530            };
1531            var pick = function() {
1532                if (R.sel) return document.querySelector(R.sel);
1533                var list = document.getAnimations ? document.getAnimations() : [];
1534                for (var i = 0; i < list.length; i++) {
1535                    if (list[i].playState === 'running' && list[i].effect && list[i].effect.target) {
1536                        return list[i].effect.target;
1537                    }
1538                }
1539                return null;
1540            };
1541            var tick = function() {
1542                // Stop if a newer recorder superseded this one.
1543                if (window.__VICTAURI_SWEEP__ !== R) return;
1544                var el = pick();
1545                var anims = (el && el.getAnimations) ? el.getAnimations() : [];
1546                var running = anims.some(function(a) { return a.playState === 'running'; });
1547                if (running && !R.cur) {
1548                    var e = anims[0] && anims[0].effect;
1549                    R.cur = { t0: performance.now(), samples: [],
1550                        timing: (e && e.getTiming) ? e.getTiming() : {},
1551                        keyframes: (function() {
1552                            try { return (e && e.getKeyframes) ? e.getKeyframes() : []; }
1553                            catch (_) { return []; }
1554                        })() };
1555                }
1556                if (R.cur && el) {
1557                    var b = el.getBoundingClientRect(), tf = matrix(el);
1558                    R.cur.samples.push({ t: performance.now() - R.cur.t0,
1559                        x: b.x, y: b.y, w: b.width, h: b.height,
1560                        tx: tf.tx, ty: tf.ty, sx: tf.sx,
1561                        opacity: parseFloat(getComputedStyle(el).opacity) });
1562                    if (R.cur.samples.length > 2000) R.cur.samples.shift();
1563                    if (!running) {
1564                        R.sessions.push(R.cur);
1565                        if (R.sessions.length > 10) R.sessions.shift();
1566                        R.cur = null;
1567                    }
1568                }
1569                requestAnimationFrame(tick);
1570            };
1571            requestAnimationFrame(tick);
1572            return { installed: true, selector: R.sel };
1573        },
1574
1575        readSweep: function(clear) {
1576            var R = window.__VICTAURI_SWEEP__;
1577            if (!R) {
1578                return { error: 'no recorder armed — call sample with record=true first, then '
1579                    + 'trigger the animation' };
1580            }
1581            var r2 = function(n) { return Math.round(n * 100) / 100; };
1582            var out = R.sessions.map(function(s) {
1583                var f = s.samples, gaps = [];
1584                for (var i = 1; i < f.length; i++) gaps.push(f[i].t - f[i - 1].t);
1585                var jank = gaps.filter(function(g) { return g > 25; }).length;
1586                var maxGap = gaps.length ? Math.max.apply(null, gaps) : 0;
1587                return {
1588                    measured_duration_ms: f.length ? r2(f[f.length - 1].t) : 0,
1589                    declared: { duration: s.timing.duration, easing: s.timing.easing,
1590                                delay: s.timing.delay },
1591                    frames: f.length, jank_frames: jank, max_frame_gap_ms: r2(maxGap),
1592                    start: f.length ? { x: r2(f[0].x), tx: r2(f[0].tx), opacity: f[0].opacity } : null,
1593                    end: f.length ? { x: r2(f[f.length - 1].x), tx: r2(f[f.length - 1].tx),
1594                                      opacity: f[f.length - 1].opacity } : null,
1595                    keyframes: s.keyframes,
1596                    curve: f.map(function(p) {
1597                        return { t: r2(p.t), x: r2(p.x), tx: r2(p.tx), op: p.opacity };
1598                    })
1599                };
1600            });
1601            var active = !!R.cur;
1602            if (clear) R.sessions = [];
1603            return { armed: true, selector: R.sel, recording_active: active,
1604                     session_count: out.length, sessions: out };
1605        },
1606    };
1607
1608    try {
1609        Object.freeze(window.__VICTAURI__);
1610        Object.defineProperty(window, '__VICTAURI__', {
1611            value: window.__VICTAURI__,
1612            configurable: false,
1613            writable: false,
1614        });
1615    } catch(e) {}
1616
1617    // ── Accessibility Helpers ────────────────────────────────────────────────
1618
1619    function describeEl(el) {
1620        var s = '<' + el.tagName.toLowerCase();
1621        if (el.id) s += ' id="' + el.id + '"';
1622        if (el.className && typeof el.className === 'string') {
1623            var cls = el.className.trim();
1624            if (cls) s += ' class="' + cls.substring(0, 50) + '"';
1625        }
1626        s += '>';
1627        return s;
1628    }
1629
1630    function parseColor(str) {
1631        if (!str) return null;
1632        var m = str.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\)/);
1633        if (!m) return null;
1634        return { r: parseInt(m[1]), g: parseInt(m[2]), b: parseInt(m[3]), a: m[4] !== undefined ? parseFloat(m[4]) : 1 };
1635    }
1636
1637    function luminance(c) {
1638        var rs = c.r / 255, gs = c.g / 255, bs = c.b / 255;
1639        var r = rs <= 0.03928 ? rs / 12.92 : Math.pow((rs + 0.055) / 1.055, 2.4);
1640        var g = gs <= 0.03928 ? gs / 12.92 : Math.pow((gs + 0.055) / 1.055, 2.4);
1641        var b = bs <= 0.03928 ? bs / 12.92 : Math.pow((bs + 0.055) / 1.055, 2.4);
1642        return 0.2126 * r + 0.7152 * g + 0.0722 * b;
1643    }
1644
1645    function contrastRatio(fg, bg) {
1646        var l1 = luminance(fg), l2 = luminance(bg);
1647        var lighter = Math.max(l1, l2), darker = Math.min(l1, l2);
1648        return (lighter + 0.05) / (darker + 0.05);
1649    }
1650
1651    // ── Long Task Observer ──────────────────────────────────────────────────
1652
1653    try {
1654        var ltObserver = new PerformanceObserver(function(list) {
1655            var entries = list.getEntries();
1656            for (var i = 0; i < entries.length; i++) {
1657                longTasks.push({ duration: entries[i].duration, startTime: entries[i].startTime });
1658                if (longTasks.length > CAP_LONG_TASKS) longTasks.shift();
1659            }
1660        });
1661        ltObserver.observe({ type: 'longtask', buffered: true });
1662    } catch(e) {}
1663
1664    // ── Event Listener Counter ──────────────────────────────────────────────
1665
1666    (function() {
1667        var origAdd = EventTarget.prototype.addEventListener;
1668        var origRemove = EventTarget.prototype.removeEventListener;
1669        EventTarget.prototype.addEventListener = function() {
1670            listenerCount++;
1671            return origAdd.apply(this, arguments);
1672        };
1673        EventTarget.prototype.removeEventListener = function() {
1674            if (listenerCount > 0) listenerCount--;
1675            return origRemove.apply(this, arguments);
1676        };
1677    })();
1678
1679    // ── DOM Walking ──────────────────────────────────────────────────────────
1680
1681    function walkDom(node) {
1682        if (!node || node.nodeType !== 1) return null;
1683
1684        var style = window.getComputedStyle(node);
1685        var visible = style.display !== 'none'
1686            && style.visibility !== 'hidden'
1687            && style.opacity !== '0';
1688
1689        if (!visible) return null;
1690
1691        var ref_id = registerRef(node);
1692
1693        var rect = node.getBoundingClientRect();
1694        var role = node.getAttribute('role') || inferRole(node);
1695        var name = node.getAttribute('aria-label')
1696            || node.getAttribute('title')
1697            || node.getAttribute('placeholder')
1698            || (node.tagName === 'BUTTON' ? node.textContent.trim().substring(0, 80) : null)
1699            || (node.tagName === 'A' ? node.textContent.trim().substring(0, 80) : null);
1700
1701        var element = {
1702            ref_id: ref_id,
1703            tag: node.tagName.toLowerCase(),
1704            role: role,
1705            name: name,
1706            text: getDirectText(node),
1707            value: (node.tagName === 'INPUT' && (node.getAttribute('type') || '').toLowerCase() === 'password') ? '[REDACTED]' : (node.value || null),
1708            enabled: !node.disabled,
1709            visible: true,
1710            focusable: node.tabIndex >= 0 || ['INPUT','BUTTON','SELECT','TEXTAREA','A'].indexOf(node.tagName) !== -1,
1711            bounds: { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
1712            children: [],
1713            attributes: {}
1714        };
1715
1716        var interestingAttrs = ['data-testid', 'id', 'type', 'href', 'src', 'checked', 'selected'];
1717        for (var a = 0; a < interestingAttrs.length; a++) {
1718            if (node.hasAttribute(interestingAttrs[a])) {
1719                element.attributes[interestingAttrs[a]] = node.getAttribute(interestingAttrs[a]);
1720            }
1721        }
1722
1723        for (var c = 0; c < node.children.length; c++) {
1724            var childEl = walkDom(node.children[c]);
1725            if (childEl) element.children.push(childEl);
1726        }
1727
1728        if (node.shadowRoot) {
1729            for (var s = 0; s < node.shadowRoot.children.length; s++) {
1730                var shadowChild = walkDom(node.shadowRoot.children[s]);
1731                if (shadowChild) element.children.push(shadowChild);
1732            }
1733        }
1734
1735        // Same-origin iframe traversal: descend into accessible frame documents.
1736        // Cross-origin frames throw on contentDocument access — mark and skip.
1737        if (node.tagName === 'IFRAME' || node.tagName === 'FRAME') {
1738            try {
1739                var idoc = node.contentDocument;
1740                if (idoc && idoc.body) {
1741                    var frameChild = walkDom(idoc.body);
1742                    if (frameChild) {
1743                        frameChild.frame = true;
1744                        element.children.push(frameChild);
1745                    }
1746                } else {
1747                    element.attributes['cross_origin_frame'] = 'true';
1748                }
1749            } catch (e) {
1750                element.attributes['cross_origin_frame'] = 'true';
1751            }
1752        }
1753
1754        return element;
1755    }
1756
1757    function walkDomCompact(node, depth) {
1758        if (!node || node.nodeType !== 1) return '';
1759
1760        var style = window.getComputedStyle(node);
1761        var visible = style.display !== 'none'
1762            && style.visibility !== 'hidden'
1763            && style.opacity !== '0';
1764
1765        if (!visible) return '';
1766
1767        var ref_id = registerRef(node);
1768        var indent = '';
1769        for (var d = 0; d < depth; d++) indent += '  ';
1770
1771        var role = node.getAttribute('role') || inferRole(node);
1772        var name = node.getAttribute('aria-label')
1773            || node.getAttribute('title')
1774            || node.getAttribute('placeholder')
1775            || '';
1776        var text = getDirectText(node) || '';
1777        var tag = node.tagName.toLowerCase();
1778
1779        var line = indent + '[' + ref_id + '] ';
1780
1781        if (role && role !== tag) {
1782            line += role;
1783        } else {
1784            line += tag;
1785        }
1786
1787        if (name) {
1788            line += ' "' + name.substring(0, 60) + '"';
1789        } else if (text && text.length <= 60) {
1790            line += ' "' + text + '"';
1791        } else if (text) {
1792            line += ' "' + text.substring(0, 57) + '..."';
1793        }
1794
1795        if (node.disabled) line += ' [disabled]';
1796        if (node.value) {
1797            var isPassword = node.tagName === 'INPUT' && (node.getAttribute('type') || '').toLowerCase() === 'password';
1798            line += ' value=' + JSON.stringify(isPassword ? '[REDACTED]' : node.value.substring(0, 40));
1799        }
1800
1801        var testId = node.getAttribute('data-testid');
1802        if (testId) line += ' @' + testId;
1803
1804        var type = node.getAttribute('type');
1805        if (type && tag === 'input') line += ' type=' + type;
1806
1807        var href = node.getAttribute('href');
1808        if (href && tag === 'a') line += ' href=' + href.substring(0, 60);
1809
1810        var result = line + '\n';
1811
1812        for (var c = 0; c < node.children.length; c++) {
1813            result += walkDomCompact(node.children[c], depth + 1);
1814        }
1815
1816        if (node.shadowRoot) {
1817            for (var s = 0; s < node.shadowRoot.children.length; s++) {
1818                result += walkDomCompact(node.shadowRoot.children[s], depth + 1);
1819            }
1820        }
1821
1822        // Same-origin iframe traversal (see walkDom for rationale).
1823        if (node.tagName === 'IFRAME' || node.tagName === 'FRAME') {
1824            try {
1825                var idoc = node.contentDocument;
1826                if (idoc && idoc.body) {
1827                    result += indent + '  ⤷ iframe content:\n';
1828                    result += walkDomCompact(idoc.body, depth + 2);
1829                } else {
1830                    result += indent + '  ⤷ [cross-origin iframe]\n';
1831                }
1832            } catch (e) {
1833                result += indent + '  ⤷ [cross-origin iframe]\n';
1834            }
1835        }
1836
1837        return result;
1838    }
1839
1840    function inferRole(node) {
1841        var tag = node.tagName;
1842        var roles = {
1843            'BUTTON': 'button', 'A': 'link', 'INPUT': 'textbox',
1844            'SELECT': 'combobox', 'TEXTAREA': 'textbox', 'IMG': 'img',
1845            'NAV': 'navigation', 'MAIN': 'main', 'HEADER': 'banner',
1846            'FOOTER': 'contentinfo', 'ASIDE': 'complementary',
1847            'H1': 'heading', 'H2': 'heading', 'H3': 'heading',
1848            'H4': 'heading', 'H5': 'heading', 'H6': 'heading',
1849            'UL': 'list', 'OL': 'list', 'LI': 'listitem',
1850            'TABLE': 'table', 'FORM': 'form', 'DIALOG': 'dialog',
1851        };
1852        if (tag === 'INPUT') {
1853            var type = node.getAttribute('type');
1854            if (type === 'checkbox') return 'checkbox';
1855            if (type === 'radio') return 'radio';
1856            if (type === 'range') return 'slider';
1857            if (type === 'submit' || type === 'button') return 'button';
1858        }
1859        return roles[tag] || null;
1860    }
1861
1862    function getDirectText(node) {
1863        var text = '';
1864        for (var i = 0; i < node.childNodes.length; i++) {
1865            if (node.childNodes[i].nodeType === 3) text += node.childNodes[i].textContent;
1866        }
1867        text = text.trim();
1868        return text.length > 0 ? text.substring(0, 200) : null;
1869    }
1870
1871    // ── Console Hooking ──────────────────────────────────────────────────────
1872
1873    var originalConsole = {
1874        log: console.log, warn: console.warn,
1875        error: console.error, info: console.info, debug: console.debug
1876    };
1877
1878    var CTRL_RE = /[\x00-\x08\x0B\x0C\x0E-\x1F\x7F\x1B]/g;
1879
1880    function hookConsole(level) {
1881        console[level] = function() {
1882            var args = Array.prototype.slice.call(arguments);
1883            var msg = args.map(String).join(' ').replace(CTRL_RE, '');
1884            consoleLogs.push({ level: level, message: msg, timestamp: Date.now() });
1885            if (consoleLogs.length > CAP_CONSOLE) consoleLogs.shift();
1886            originalConsole[level].apply(console, args);
1887        };
1888    }
1889
1890    hookConsole('log');
1891    hookConsole('warn');
1892    hookConsole('error');
1893    hookConsole('info');
1894    hookConsole('debug');
1895
1896    // ── Global Error Capture ────────────────────────────────────────────────
1897
1898    window.addEventListener('error', function(e) {
1899        var msg = e.message || 'Unknown error';
1900        if (e.filename) msg += ' at ' + e.filename + ':' + e.lineno + ':' + e.colno;
1901        consoleLogs.push({ level: 'error', message: ('[uncaught] ' + msg).replace(CTRL_RE, ''), timestamp: Date.now() });
1902        if (consoleLogs.length > CAP_CONSOLE) consoleLogs.shift();
1903    });
1904
1905    window.addEventListener('unhandledrejection', function(e) {
1906        var msg = e.reason ? (e.reason.message || String(e.reason)) : 'Unhandled promise rejection';
1907        consoleLogs.push({ level: 'error', message: ('[unhandled rejection] ' + msg).replace(CTRL_RE, ''), timestamp: Date.now() });
1908        if (consoleLogs.length > CAP_CONSOLE) consoleLogs.shift();
1909    });
1910
1911    // ── Interaction Observer (for record mode) ────────────────────────────────
1912
1913    function bestSelector(el) {
1914        if (el.dataset && el.dataset.testid) return '[data-testid="' + el.dataset.testid + '"]';
1915        if (el.id) return '#' + el.id;
1916        if (el.getAttribute && el.getAttribute('role')) {
1917            var role = el.getAttribute('role');
1918            var text = (el.textContent || '').trim().substring(0, 50);
1919            if (text) return '[role="' + role + '"]:has-text("' + text + '")';
1920            return '[role="' + role + '"]';
1921        }
1922        var tag = (el.tagName || 'div').toLowerCase();
1923        var text = (el.textContent || '').trim().substring(0, 50);
1924        if (text && ['button', 'a', 'label', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'span'].indexOf(tag) !== -1) {
1925            return tag + ':has-text("' + text + '")';
1926        }
1927        if (el.name) return tag + '[name="' + el.name + '"]';
1928        if (el.className && typeof el.className === 'string') {
1929            var cls = el.className.trim().split(/\s+/).slice(0, 2).join('.');
1930            if (cls) return tag + '.' + cls;
1931        }
1932        return tag;
1933    }
1934
1935    function pushInteraction(action, el, value) {
1936        interactionLog.push({
1937            type: 'dom_interaction',
1938            action: action,
1939            selector: bestSelector(el),
1940            value: value || null,
1941            timestamp: Date.now()
1942        });
1943        if (interactionLog.length > CAP_INTERACTION) interactionLog.shift();
1944    }
1945
1946    document.addEventListener('click', function(e) {
1947        if (e.isTrusted && e.target) pushInteraction('click', e.target, null);
1948    }, true);
1949
1950    document.addEventListener('dblclick', function(e) {
1951        if (e.isTrusted && e.target) pushInteraction('double_click', e.target, null);
1952    }, true);
1953
1954    document.addEventListener('change', function(e) {
1955        if (!e.isTrusted || !e.target) return;
1956        var el = e.target;
1957        var tag = (el.tagName || '').toLowerCase();
1958        if (tag === 'select') {
1959            pushInteraction('select', el, el.value);
1960        } else if (tag === 'input' || tag === 'textarea') {
1961            var isPassword = tag === 'input' && el.type === 'password';
1962            pushInteraction('fill', el, isPassword ? '[REDACTED]' : el.value);
1963        }
1964    }, true);
1965
1966    document.addEventListener('keydown', function(e) {
1967        if (!e.isTrusted) return;
1968        if (['Enter', 'Escape', 'Tab', 'Backspace', 'Delete', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].indexOf(e.key) !== -1) {
1969            pushInteraction('key_press', e.target || document.body, e.key);
1970        }
1971    }, true);
1972
1973    // ── Mutation Observer (deferred) ─────────────────────────────────────────
1974
1975    var mutationBatchCount = 0;
1976    var mutationBatchTimer = null;
1977    var __mutationObserver = null;
1978
1979    function startMutationObserver() {
1980        if (!document.documentElement) return false;
1981        __mutationObserver = new MutationObserver(function(mutations) {
1982            mutationBatchCount += mutations.length;
1983            if (!mutationBatchTimer) {
1984                mutationBatchTimer = setTimeout(function() {
1985                    mutationLog.push({ count: mutationBatchCount, timestamp: Date.now() });
1986                    if (mutationLog.length > CAP_MUTATION) mutationLog.shift();
1987                    mutationBatchCount = 0;
1988                    mutationBatchTimer = null;
1989                }, 100);
1990            }
1991        });
1992        __mutationObserver.observe(document.documentElement, {
1993            childList: true, subtree: true, attributes: true, characterData: true,
1994        });
1995        return true;
1996    }
1997
1998    if (!startMutationObserver()) {
1999        document.addEventListener('DOMContentLoaded', startMutationObserver);
2000    }
2001
2002    // IPC logging is derived from the network log: Tauri 2.0 sends all IPC
2003    // via fetch to http://ipc.localhost/<command>. The fetch interceptor below
2004    // captures these, and getIpcLog() filters them from networkLog. This avoids
2005    // the need to patch __TAURI_INTERNALS__.invoke, which Tauri freezes with
2006    // configurable:false, writable:false.
2007
2008    // ── Network Interception ─────────────────────────────────────────────────
2009
2010    (function interceptNetwork() {
2011        // fetch
2012        var origFetch = window.fetch;
2013        if (origFetch) {
2014            window.fetch = function(input, init) {
2015                var id = ++networkCounter;
2016                var url = typeof input === 'string' ? input : (input && input.url ? input.url : String(input));
2017                var method = (init && init.method) || (input && input.method) || 'GET';
2018                var isIpc = isIpcUrl(url);
2019                var isVictauriInternal = isIpc && url.indexOf('plugin%3Avictauri%7C') !== -1;
2020                var entry = { id: id, method: method.toUpperCase(), url: url, timestamp: Date.now(), status: 'pending', duration_ms: null };
2021
2022                if (isIpc && !isVictauriInternal && init && init.body && window.__VICTAURI__._captureIpcBodies !== false) {
2023                    try {
2024                        var bodyStr = typeof init.body === 'string' ? init.body : null;
2025                        if (bodyStr) {
2026                            var parsed = JSON.parse(bodyStr);
2027                            entry.request_args = parsed;
2028                        }
2029                    } catch(e) {}
2030                }
2031
2032                if (!isVictauriInternal) {
2033                    networkLog.push(entry);
2034                    if (networkLog.length > CAP_NETWORK) networkLog.shift();
2035                }
2036
2037                var self = this;
2038                function flushIpcWaiters() {
2039                    for (var w = ipcWaiters.length - 1; w >= 0; w--) { ipcWaiters[w](); }
2040                    ipcWaiters.length = 0;
2041                }
2042
2043                // Phase 1: apply a matching route rule (block / fulfill / delay).
2044                var route = matchRoute(url, method);
2045                if (route) {
2046                    recordRouteMatch(route, url, method);
2047                    if (route.action === 'block') {
2048                        entry.status = 'blocked';
2049                        entry.blocked = true;
2050                        entry.duration_ms = Date.now() - entry.timestamp;
2051                        if (isIpc) flushIpcWaiters();
2052                        return Promise.reject(new TypeError('victauri: request blocked by route #' + route.id + ' (' + url + ')'));
2053                    }
2054                    if (route.action === 'fulfill') {
2055                        var makeResp = function() {
2056                            var bodyStr = (typeof route.body === 'string') ? route.body : JSON.stringify(route.body);
2057                            var hdrs = { 'content-type': route.content_type };
2058                            for (var k in route.headers) { if (Object.prototype.hasOwnProperty.call(route.headers, k)) hdrs[k] = route.headers[k]; }
2059                            entry.status = route.status;
2060                            entry.status_text = route.status_text;
2061                            entry.mocked = true;
2062                            entry.duration_ms = Date.now() - entry.timestamp;
2063                            if (isIpc) {
2064                                try { entry.response_body = JSON.parse(bodyStr); } catch (e) { entry.response_body = bodyStr; }
2065                                flushIpcWaiters();
2066                            }
2067                            return new Response(bodyStr, { status: route.status, statusText: route.status_text, headers: hdrs });
2068                        };
2069                        return route.delay_ms > 0
2070                            ? new Promise(function(res) { setTimeout(function() { res(makeResp()); }, route.delay_ms); })
2071                            : Promise.resolve(makeResp());
2072                    }
2073                    if (route.action === 'delay' && route.delay_ms > 0) {
2074                        return new Promise(function(resolve, reject) {
2075                            setTimeout(function() { doRealFetch().then(resolve, reject); }, route.delay_ms);
2076                        });
2077                    }
2078                }
2079                return doRealFetch();
2080
2081                function doRealFetch() {
2082                    return origFetch.call(self, input, init).then(function(response) {
2083                        entry.status = response.status;
2084                        entry.status_text = response.statusText;
2085                        entry.duration_ms = Date.now() - entry.timestamp;
2086
2087                        if (isIpc) {
2088                            // Capture Tauri's command-outcome signal. The HTTP status is 200
2089                            // for BOTH a successful command AND a failed/"not found" one — the
2090                            // real Ok/Err result is carried in the `Tauri-Response` header
2091                            // ('ok' | 'error'). Without this, every IPC call logs as "ok",
2092                            // which blinds ghost detection (an unregistered command looks like
2093                            // a verified handler). 'ok' | 'error' | null (older Tauri / no hdr).
2094                            try { entry.ipc_response = response.headers.get('Tauri-Response'); } catch (e) {}
2095                            if (window.__VICTAURI__._captureIpcBodies !== false) {
2096                                var cloned = response.clone();
2097                                cloned.text().then(function(text) {
2098                                    try { entry.response_body = JSON.parse(text); } catch(e) { entry.response_body = text; }
2099                                }).catch(function() {}).then(function() {
2100                                    flushIpcWaiters();
2101                                });
2102                            } else {
2103                                flushIpcWaiters();
2104                            }
2105                        }
2106
2107                        return response;
2108                    }, function(err) {
2109                        entry.status = 'error';
2110                        entry.error = String(err);
2111                        entry.duration_ms = Date.now() - entry.timestamp;
2112                        flushIpcWaiters();
2113                        throw err;
2114                    });
2115                }
2116            };
2117        }
2118
2119        // XMLHttpRequest
2120        var origOpen = XMLHttpRequest.prototype.open;
2121        var origSend = XMLHttpRequest.prototype.send;
2122        XMLHttpRequest.prototype.open = function(method, url) {
2123            this.__victauri_net = { method: method, url: url };
2124            return origOpen.apply(this, arguments);
2125        };
2126        XMLHttpRequest.prototype.send = function() {
2127            if (this.__victauri_net) {
2128                var isVictauriInternal = this.__victauri_net.url.indexOf('plugin%3Avictauri%7C') !== -1
2129                    || this.__victauri_net.url.indexOf('plugin:victauri|') !== -1;
2130                if (isVictauriInternal) {
2131                    return origSend.apply(this, arguments);
2132                }
2133                var id = ++networkCounter;
2134                var entry = {
2135                    id: id,
2136                    method: this.__victauri_net.method.toUpperCase(),
2137                    url: this.__victauri_net.url,
2138                    timestamp: Date.now(),
2139                    status: 'pending',
2140                    duration_ms: null,
2141                };
2142                networkLog.push(entry);
2143                if (networkLog.length > CAP_NETWORK) networkLog.shift();
2144                var self = this;
2145                this.addEventListener('load', function() {
2146                    entry.status = self.status;
2147                    entry.status_text = self.statusText;
2148                    entry.duration_ms = Date.now() - entry.timestamp;
2149                });
2150                this.addEventListener('error', function() {
2151                    entry.status = 'error';
2152                    entry.duration_ms = Date.now() - entry.timestamp;
2153                });
2154
2155                // Phase 1 routing for XHR: block + delay are supported here.
2156                // `fulfill` (synthetic response) is fetch-only — faking the full
2157                // XHR response surface is unreliable; document as a limitation.
2158                var xroute = matchRoute(this.__victauri_net.url, this.__victauri_net.method);
2159                if (xroute) {
2160                    recordRouteMatch(xroute, this.__victauri_net.url, this.__victauri_net.method);
2161                    if (xroute.action === 'block') {
2162                        entry.status = 'blocked';
2163                        entry.blocked = true;
2164                        entry.duration_ms = Date.now() - entry.timestamp;
2165                        var blockedXhr = this;
2166                        setTimeout(function() {
2167                            try { blockedXhr.dispatchEvent(new Event('error')); } catch (e) {}
2168                        }, 0);
2169                        return; // do not send
2170                    }
2171                    if ((xroute.action === 'delay' || xroute.action === 'fulfill') && xroute.delay_ms > 0) {
2172                        var dArgs = arguments, dSelf = this;
2173                        setTimeout(function() { origSend.apply(dSelf, dArgs); }, xroute.delay_ms);
2174                        return;
2175                    }
2176                }
2177            }
2178            return origSend.apply(this, arguments);
2179        };
2180    })();
2181
2182    // ── Navigation Tracking ──────────────────────────────────────────────────
2183
2184    (function trackNavigation() {
2185        navigationLog.push({ url: window.location.href, timestamp: Date.now(), type: 'initial' });
2186
2187        var origPushState = history.pushState;
2188        var origReplaceState = history.replaceState;
2189        history.pushState = function() {
2190            var result = origPushState.apply(this, arguments);
2191            navigationLog.push({ url: window.location.href, timestamp: Date.now(), type: 'pushState' });
2192            if (navigationLog.length > CAP_NAVIGATION) navigationLog.shift();
2193            return result;
2194        };
2195        history.replaceState = function() {
2196            var result = origReplaceState.apply(this, arguments);
2197            navigationLog.push({ url: window.location.href, timestamp: Date.now(), type: 'replaceState' });
2198            if (navigationLog.length > CAP_NAVIGATION) navigationLog.shift();
2199            return result;
2200        };
2201        window.addEventListener('popstate', function() {
2202            navigationLog.push({ url: window.location.href, timestamp: Date.now(), type: 'popstate' });
2203        });
2204        window.addEventListener('hashchange', function(e) {
2205            navigationLog.push({ url: window.location.href, timestamp: Date.now(), type: 'hashchange', old_url: e.oldURL });
2206        });
2207    })();
2208
2209    // ── Dialog Capture ───────────────────────────────────────────────────────
2210
2211    // Default fail-CLOSED (audit #32): merely loading the bridge must not silently
2212    // auto-approve "are you sure?" gates. confirm() -> false, prompt() -> null until
2213    // an explicit set_dialog_response opts into accepting.
2214    var dialogAutoResponses = { alert: { action: 'accept' }, confirm: { action: 'dismiss' }, prompt: { action: 'dismiss', text: '' } };
2215
2216    // ── Resource Cleanup ────────────────────────────────────────────────────
2217
2218    window.addEventListener('pagehide', function() {
2219        if (__mutationObserver) { __mutationObserver.disconnect(); __mutationObserver = null; }
2220        if (mutationBatchTimer) { clearTimeout(mutationBatchTimer); mutationBatchTimer = null; }
2221        console.log = originalConsole.log;
2222        console.warn = originalConsole.warn;
2223        console.error = originalConsole.error;
2224        console.info = originalConsole.info;
2225        console.debug = originalConsole.debug;
2226        consoleLogs.length = 0;
2227        mutationLog.length = 0;
2228        networkLog.length = 0;
2229        navigationLog.length = 0;
2230        dialogLog.length = 0;
2231        interactionLog.length = 0;
2232        refMap.clear();
2233        weakRefMap.clear();
2234        refCounter = 0;
2235    });
2236
2237    (function captureDialogs() {
2238        window.alert = function(msg) {
2239            dialogLog.push({ type: 'alert', message: String(msg || ''), timestamp: Date.now() });
2240            if (dialogLog.length > CAP_DIALOG) dialogLog.shift();
2241        };
2242        window.confirm = function(msg) {
2243            var resp = dialogAutoResponses.confirm;
2244            var result = resp.action === 'accept';
2245            dialogLog.push({ type: 'confirm', message: String(msg || ''), timestamp: Date.now(), result: result });
2246            if (dialogLog.length > CAP_DIALOG) dialogLog.shift();
2247            return result;
2248        };
2249        window.prompt = function(msg, defaultValue) {
2250            var resp = dialogAutoResponses.prompt;
2251            var result = resp.action === 'accept' ? (resp.text || defaultValue || '') : null;
2252            dialogLog.push({ type: 'prompt', message: String(msg || ''), timestamp: Date.now(), result: result });
2253            if (dialogLog.length > CAP_DIALOG) dialogLog.shift();
2254            return result;
2255        };
2256    })();
2257
2258    // Signal to the Rust backend that the JS bridge is fully initialized.
2259    try {
2260        window.__TAURI_INTERNALS__.invoke('plugin:victauri|victauri_eval_callback', {
2261            id: '__victauri_bridge_ready__',
2262            result: ''
2263        });
2264    } catch(e) {}
2265})();
2266"#;