@reticlehq/browser 2.4.0 → 2.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/actions/actions-dom.d.ts +8 -1
- package/dist/actions/actions-dom.js +26 -12
- package/dist/actions/actions.d.ts +46 -0
- package/dist/actions/actions.js +114 -26
- package/dist/actions/appeared-text.d.ts +27 -0
- package/dist/actions/appeared-text.js +92 -0
- package/dist/actions/scroll.js +1 -1
- package/dist/commands/commands.js +45 -22
- package/dist/connect-options.d.ts +6 -0
- package/dist/dom/a11y.js +20 -20
- package/dist/dom/auto-anchor.js +1 -1
- package/dist/dom/blind-spots.js +1 -1
- package/dist/dom/chart.js +5 -5
- package/dist/dom/desktop-capture.d.ts +2 -1
- package/dist/dom/desktop-capture.js +4 -4
- package/dist/dom/occlusion.js +2 -2
- package/dist/dom/query.js +68 -25
- package/dist/dom/realm.js +13 -13
- package/dist/dom/refs.d.ts +14 -0
- package/dist/dom/refs.js +20 -1
- package/dist/dom/shadow-registry.js +2 -2
- package/dist/dom/snapshot.js +32 -11
- package/dist/dom/source.js +3 -3
- package/dist/dom/theme.js +6 -6
- package/dist/dom/virtualized.js +1 -1
- package/dist/observers/blind-spots.js +2 -2
- package/dist/observers/console.js +3 -3
- package/dist/observers/dom.js +5 -5
- package/dist/observers/download.d.ts +2 -1
- package/dist/observers/download.js +2 -2
- package/dist/observers/focus.js +1 -1
- package/dist/observers/frames.js +1 -1
- package/dist/observers/health.js +70 -2
- package/dist/observers/install-all.d.ts +2 -1
- package/dist/observers/ipc.d.ts +2 -7
- package/dist/observers/ipc.js +7 -7
- package/dist/observers/network-body.js +2 -2
- package/dist/observers/network-redact.js +5 -5
- package/dist/observers/network-stream.js +2 -2
- package/dist/observers/network.d.ts +3 -3
- package/dist/observers/network.js +9 -9
- package/dist/observers/perf.js +1 -1
- package/dist/observers/scroll.js +1 -1
- package/dist/observers/state.js +1 -1
- package/dist/observers/storage.js +7 -7
- package/dist/presenter/presenter-brand.d.ts +19 -0
- package/dist/presenter/presenter-brand.js +21 -0
- package/dist/presenter/presenter-config.d.ts +17 -0
- package/dist/presenter/presenter-config.js +21 -0
- package/dist/presenter/presenter-controls.d.ts +1 -11
- package/dist/presenter/presenter-controls.js +6 -7
- package/dist/presenter/presenter-log.js +2 -2
- package/dist/presenter/presenter-run-state.js +1 -1
- package/dist/presenter/presenter-styles.d.ts +1 -1
- package/dist/presenter/presenter-styles.js +20 -9
- package/dist/presenter/presenter-tally.js +3 -3
- package/dist/presenter/presenter-test-helpers.d.ts +2 -1
- package/dist/presenter/presenter-test-helpers.js +2 -2
- package/dist/presenter/presenter-verbs.js +16 -11
- package/dist/presenter/presenter.js +13 -10
- package/dist/recorder/recorder.js +10 -9
- package/dist/registry/store-adapters.js +1 -1
- package/dist/registry/stores.js +3 -3
- package/dist/reticle-presenter-helpers.js +3 -3
- package/dist/reticle.js +31 -13
- package/dist/review/annotator.js +7 -7
- package/dist/security/serialization.js +9 -9
- package/dist/session-continuity.d.ts +17 -0
- package/dist/session-continuity.js +42 -0
- package/dist/timers/clock.js +28 -15
- package/dist/timers/native-console.js +1 -1
- package/dist/timers/native-timers.js +4 -4
- package/dist/transport/transport.d.ts +23 -0
- package/dist/transport/transport.js +85 -25
- package/package.json +6 -4
package/dist/dom/realm.js
CHANGED
|
@@ -15,12 +15,12 @@
|
|
|
15
15
|
*/
|
|
16
16
|
/** The constructor named `name` in the realm that owns `node`, or undefined if there isn't one. */
|
|
17
17
|
function ctorIn(node, name) {
|
|
18
|
-
if (
|
|
18
|
+
if (null === node || typeof node !== 'object')
|
|
19
19
|
return undefined;
|
|
20
20
|
// A Document owns itself; every other node reports an ownerDocument.
|
|
21
21
|
const doc = node.ownerDocument ?? node;
|
|
22
22
|
const view = doc.defaultView;
|
|
23
|
-
if (
|
|
23
|
+
if (null === view || view === undefined)
|
|
24
24
|
return undefined;
|
|
25
25
|
return view[name];
|
|
26
26
|
}
|
|
@@ -32,17 +32,17 @@ function ctorIn(node, name) {
|
|
|
32
32
|
*/
|
|
33
33
|
function isIn(node, name, ambient) {
|
|
34
34
|
const ctor = ctorIn(node, name) ?? ambient;
|
|
35
|
-
return typeof ctor
|
|
35
|
+
return 'function' === typeof ctor && node instanceof ctor;
|
|
36
36
|
}
|
|
37
|
-
export const isElement = (n) => isIn(n, 'Element', typeof Element
|
|
38
|
-
export const isImage = (n) => isIn(n, 'HTMLImageElement', typeof HTMLImageElement
|
|
39
|
-
export const isHtmlElement = (n) => isIn(n, 'HTMLElement', typeof HTMLElement
|
|
40
|
-
export const isInput = (n) => isIn(n, 'HTMLInputElement', typeof HTMLInputElement
|
|
41
|
-
export const isTextArea = (n) => isIn(n, 'HTMLTextAreaElement', typeof HTMLTextAreaElement
|
|
42
|
-
export const isSelect = (n) => isIn(n, 'HTMLSelectElement', typeof HTMLSelectElement
|
|
43
|
-
export const isButton = (n) => isIn(n, 'HTMLButtonElement', typeof HTMLButtonElement
|
|
44
|
-
export const isForm = (n) => isIn(n, 'HTMLFormElement', typeof HTMLFormElement
|
|
45
|
-
export const isFrame = (n) => isIn(n, 'HTMLIFrameElement', typeof HTMLIFrameElement
|
|
37
|
+
export const isElement = (n) => isIn(n, 'Element', 'undefined' === typeof Element ? undefined : Element);
|
|
38
|
+
export const isImage = (n) => isIn(n, 'HTMLImageElement', 'undefined' === typeof HTMLImageElement ? undefined : HTMLImageElement);
|
|
39
|
+
export const isHtmlElement = (n) => isIn(n, 'HTMLElement', 'undefined' === typeof HTMLElement ? undefined : HTMLElement);
|
|
40
|
+
export const isInput = (n) => isIn(n, 'HTMLInputElement', 'undefined' === typeof HTMLInputElement ? undefined : HTMLInputElement);
|
|
41
|
+
export const isTextArea = (n) => isIn(n, 'HTMLTextAreaElement', 'undefined' === typeof HTMLTextAreaElement ? undefined : HTMLTextAreaElement);
|
|
42
|
+
export const isSelect = (n) => isIn(n, 'HTMLSelectElement', 'undefined' === typeof HTMLSelectElement ? undefined : HTMLSelectElement);
|
|
43
|
+
export const isButton = (n) => isIn(n, 'HTMLButtonElement', 'undefined' === typeof HTMLButtonElement ? undefined : HTMLButtonElement);
|
|
44
|
+
export const isForm = (n) => isIn(n, 'HTMLFormElement', 'undefined' === typeof HTMLFormElement ? undefined : HTMLFormElement);
|
|
45
|
+
export const isFrame = (n) => isIn(n, 'HTMLIFrameElement', 'undefined' === typeof HTMLIFrameElement ? undefined : HTMLIFrameElement);
|
|
46
46
|
/**
|
|
47
47
|
* The prototype whose native value setter drives `el` — React and friends install their own accessor
|
|
48
48
|
* on the instance, so a value write has to go through the prototype's. It must come from the
|
|
@@ -54,5 +54,5 @@ export function valuePrototypeOf(el) {
|
|
|
54
54
|
if (typeof ctor !== 'function')
|
|
55
55
|
return undefined;
|
|
56
56
|
const proto = ctor.prototype;
|
|
57
|
-
return typeof proto
|
|
57
|
+
return 'object' === typeof proto && proto !== null ? proto : undefined;
|
|
58
58
|
}
|
package/dist/dom/refs.d.ts
CHANGED
|
@@ -17,6 +17,20 @@ import { type Ref } from '@reticlehq/core';
|
|
|
17
17
|
* ref is one agent turn, and this is far more than any one turn produces.
|
|
18
18
|
*/
|
|
19
19
|
export declare const MAX_TRACKED_REFS = 10000;
|
|
20
|
+
/**
|
|
21
|
+
* A ref, made safe to interpolate into an error message.
|
|
22
|
+
*
|
|
23
|
+
* A ref Reticle mints is `e7` — three characters. A ref a CALLER passes is whatever they typed, and
|
|
24
|
+
* an agent guessing at the schema can pass 100KB of it. Every stale-ref message interpolates that
|
|
25
|
+
* value directly, so the caller's own bad argument came back as a 100KB error.
|
|
26
|
+
*
|
|
27
|
+
* The server caps error messages centrally, but it cannot help here: the browser's transport
|
|
28
|
+
* serializer truncates from the TAIL first, which severs "… no longer resolves to an element" — the
|
|
29
|
+
* exact substring the server's recovery table matches on. A stale ref then arrives classified as a
|
|
30
|
+
* possible Reticle defect. So the echo is bounded at the source, where the untrusted value enters
|
|
31
|
+
* the message.
|
|
32
|
+
*/
|
|
33
|
+
export declare function echoRef(ref: string): string;
|
|
20
34
|
export declare class RefRegistry {
|
|
21
35
|
#private;
|
|
22
36
|
/** How many reverse entries are currently retained. Exposed so the bound can be asserted. */
|
package/dist/dom/refs.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { asRef } from '@reticlehq/core';
|
|
1
|
+
import { TRANSPORT_LIMITS, asRef } from '@reticlehq/core';
|
|
2
2
|
/**
|
|
3
3
|
* Stable, human-meaningful element handles. Each element gets a ref like `e7`; the same
|
|
4
4
|
* element keeps its ref across snapshots, and a ref re-resolves to its element as long as
|
|
@@ -17,6 +17,25 @@ import { asRef } from '@reticlehq/core';
|
|
|
17
17
|
* ref is one agent turn, and this is far more than any one turn produces.
|
|
18
18
|
*/
|
|
19
19
|
export const MAX_TRACKED_REFS = 10000;
|
|
20
|
+
/** Marks a ref that was too long to echo in full. */
|
|
21
|
+
const REF_ELISION = '…';
|
|
22
|
+
/**
|
|
23
|
+
* A ref, made safe to interpolate into an error message.
|
|
24
|
+
*
|
|
25
|
+
* A ref Reticle mints is `e7` — three characters. A ref a CALLER passes is whatever they typed, and
|
|
26
|
+
* an agent guessing at the schema can pass 100KB of it. Every stale-ref message interpolates that
|
|
27
|
+
* value directly, so the caller's own bad argument came back as a 100KB error.
|
|
28
|
+
*
|
|
29
|
+
* The server caps error messages centrally, but it cannot help here: the browser's transport
|
|
30
|
+
* serializer truncates from the TAIL first, which severs "… no longer resolves to an element" — the
|
|
31
|
+
* exact substring the server's recovery table matches on. A stale ref then arrives classified as a
|
|
32
|
+
* possible Reticle defect. So the echo is bounded at the source, where the untrusted value enters
|
|
33
|
+
* the message.
|
|
34
|
+
*/
|
|
35
|
+
export function echoRef(ref) {
|
|
36
|
+
const max = TRANSPORT_LIMITS.MAX_REF_LENGTH;
|
|
37
|
+
return max >= ref.length ? ref : `${ref.slice(0, max)}${REF_ELISION}`;
|
|
38
|
+
}
|
|
20
39
|
/**
|
|
21
40
|
* Full-sweep cadence. The deref-sweep is O(retained), and a page legitimately holding ≥ MAX_TRACKED_REFS
|
|
22
41
|
* live elements (a 10k-node dashboard) can never get under the cap that way, so running it on EVERY mint
|
|
@@ -65,7 +65,7 @@ function sweep() {
|
|
|
65
65
|
}
|
|
66
66
|
while (roots.size > MAX_TRACKED_ROOTS) {
|
|
67
67
|
const oldest = roots.values().next();
|
|
68
|
-
if (oldest.done
|
|
68
|
+
if (true === oldest.done)
|
|
69
69
|
break;
|
|
70
70
|
roots.delete(oldest.value);
|
|
71
71
|
}
|
|
@@ -144,7 +144,7 @@ export function installShadowRegistry() {
|
|
|
144
144
|
const sweep = (node) => {
|
|
145
145
|
for (const el of node.querySelectorAll('*')) {
|
|
146
146
|
const root = el.shadowRoot;
|
|
147
|
-
if (
|
|
147
|
+
if (null === root)
|
|
148
148
|
continue;
|
|
149
149
|
record(root, el); // idempotent — a root the patch already caught is skipped inside
|
|
150
150
|
sweep(root);
|
package/dist/dom/snapshot.js
CHANGED
|
@@ -20,6 +20,24 @@ const INTERACTIVE = new Set([
|
|
|
20
20
|
'option',
|
|
21
21
|
]);
|
|
22
22
|
const SKIP_TAGS = new Set(['script', 'style', 'noscript', 'template', 'head', 'meta', 'link']);
|
|
23
|
+
/** Roles whose whole purpose is announcing a change; both imply an implicit aria-live. */
|
|
24
|
+
const ANNOUNCE_ROLES = new Set(['alert', 'status']);
|
|
25
|
+
/**
|
|
26
|
+
* Is this element the app SAYING something changed?
|
|
27
|
+
*
|
|
28
|
+
* INTERACTIVE mode keeps only actionable elements, which silently drops the one node that explains
|
|
29
|
+
* why an action did nothing — the error the app just rendered. An agent told to prefer the cheaper
|
|
30
|
+
* mode was structurally blind to the failure it had caused: the click reports settled with a DOM
|
|
31
|
+
* mutation, and the lean snapshot shows the same controls as before. Live regions exist precisely
|
|
32
|
+
* to announce state changes to a consumer that cannot see the screen, which is exactly the agent,
|
|
33
|
+
* so they are the principled exception — and a bounded one, since a page has very few.
|
|
34
|
+
*/
|
|
35
|
+
function announces(el, role) {
|
|
36
|
+
if (ANNOUNCE_ROLES.has(role))
|
|
37
|
+
return true;
|
|
38
|
+
const live = el.getAttribute('aria-live');
|
|
39
|
+
return live !== null && 'off' !== live;
|
|
40
|
+
}
|
|
23
41
|
/** Cap on inlined text content so a verbose node can't blow up the snapshot. */
|
|
24
42
|
const TEXT_MAX = 80;
|
|
25
43
|
/**
|
|
@@ -31,7 +49,7 @@ const TEXT_MAX = 80;
|
|
|
31
49
|
function directText(el) {
|
|
32
50
|
let out = '';
|
|
33
51
|
for (const node of el.childNodes) {
|
|
34
|
-
if (node.nodeType
|
|
52
|
+
if (3 === node.nodeType /* Node.TEXT_NODE */)
|
|
35
53
|
out += node.textContent ?? '';
|
|
36
54
|
}
|
|
37
55
|
const collapsed = out.replace(/\s+/g, ' ').trim();
|
|
@@ -43,7 +61,7 @@ function skipEarly(el) {
|
|
|
43
61
|
return true;
|
|
44
62
|
if (isIgnored(el))
|
|
45
63
|
return true; // Reticle overlay + known dev overlays
|
|
46
|
-
if (el.getAttribute('aria-hidden')
|
|
64
|
+
if ('true' === el.getAttribute('aria-hidden'))
|
|
47
65
|
return true;
|
|
48
66
|
if (el instanceof HTMLElement && el.hidden)
|
|
49
67
|
return true;
|
|
@@ -75,13 +93,13 @@ function formatTextLine(depth, text) {
|
|
|
75
93
|
* snapshot is blind to it; this line makes it visible. Empty for non-container elements.
|
|
76
94
|
*/
|
|
77
95
|
function layoutSignature(style) {
|
|
78
|
-
if (
|
|
96
|
+
if (null === style)
|
|
79
97
|
return '';
|
|
80
98
|
const display = style.display;
|
|
81
99
|
// Grid track templates are the high-signal CLS case (column/row count + sizing) and there are
|
|
82
100
|
// few grid containers per page. Flex is intentionally excluded: nearly every row is a flex
|
|
83
101
|
// box, so signing them all floods the snapshot for little regression value.
|
|
84
|
-
if (
|
|
102
|
+
if ('grid' === display || 'inline-grid' === display) {
|
|
85
103
|
const cols = style.gridTemplateColumns;
|
|
86
104
|
return cols !== '' && cols !== 'none' ? `grid-cols:${cols}` : 'grid';
|
|
87
105
|
}
|
|
@@ -116,7 +134,7 @@ function pierceChildren(parent) {
|
|
|
116
134
|
}
|
|
117
135
|
return out;
|
|
118
136
|
}
|
|
119
|
-
function walk(parent, depth, ctx) {
|
|
137
|
+
function walk(parent, depth, ctx, inLive = false) {
|
|
120
138
|
if (depth > ctx.maxDepth)
|
|
121
139
|
return;
|
|
122
140
|
for (const child of pierceChildren(parent)) {
|
|
@@ -130,28 +148,31 @@ function walk(parent, depth, ctx) {
|
|
|
130
148
|
// display-none skip and the layout signature — was two forced style resolutions per node.
|
|
131
149
|
const view = child.ownerDocument.defaultView;
|
|
132
150
|
const style = view !== null ? view.getComputedStyle(child) : null;
|
|
133
|
-
if (style !== null && style.display
|
|
151
|
+
if (style !== null && 'none' === style.display)
|
|
134
152
|
continue;
|
|
135
153
|
const role = getRole(child);
|
|
136
154
|
const name = getAccessibleName(child);
|
|
137
155
|
const interactive = INTERACTIVE.has(role);
|
|
156
|
+
// Announcements are exempt from leanness, and so is everything inside one: a live region whose
|
|
157
|
+
// message sits in a child element would otherwise be included as a contentless `- generic`.
|
|
158
|
+
const announce = inLive || announces(child, role);
|
|
159
|
+
const lean = ctx.mode === SnapshotMode.INTERACTIVE && !announce;
|
|
138
160
|
// A generic, unnamed container's own text content — only consulted outside INTERACTIVE mode,
|
|
139
161
|
// so the actionable-only view stays lean while FULL/meaningful views see content regressions.
|
|
140
|
-
const
|
|
141
|
-
const text = !lean && role === 'generic' && name.length === 0 ? directText(child) : '';
|
|
162
|
+
const text = !lean && 'generic' === role && 0 === name.length ? directText(child) : '';
|
|
142
163
|
// Layout signature for grid/flex containers — makes CLS/layout regressions visible.
|
|
143
164
|
const layout = lean ? '' : layoutSignature(style);
|
|
144
165
|
const meaningful = interactive || role !== 'generic' || name.length > 0 || text.length > 0 || layout.length > 0;
|
|
145
166
|
const include = lean ? interactive : meaningful;
|
|
146
167
|
if (include) {
|
|
147
168
|
ctx.nodes += 1;
|
|
148
|
-
ctx.lines.push(text.length > 0 && name.length
|
|
169
|
+
ctx.lines.push(text.length > 0 && 0 === name.length && 0 === layout.length
|
|
149
170
|
? formatTextLine(depth, text)
|
|
150
171
|
: formatLine(child, depth, role, name, layout));
|
|
151
|
-
walk(child, depth + 1, ctx);
|
|
172
|
+
walk(child, depth + 1, ctx, announce);
|
|
152
173
|
}
|
|
153
174
|
else {
|
|
154
|
-
walk(child, depth, ctx);
|
|
175
|
+
walk(child, depth, ctx, announce);
|
|
155
176
|
}
|
|
156
177
|
}
|
|
157
178
|
}
|
package/dist/dom/source.js
CHANGED
|
@@ -24,14 +24,14 @@ import { DATA_RETICLE_SOURCE_ATTR } from '@reticlehq/core';
|
|
|
24
24
|
const SOURCE_ATTR = DATA_RETICLE_SOURCE_ATTR;
|
|
25
25
|
/** Parse a `data-reticle-source="file:line:column"` value into `{ file, line }` (column dropped). */
|
|
26
26
|
export function parseSourceAttr(value) {
|
|
27
|
-
if (
|
|
27
|
+
if (null === value)
|
|
28
28
|
return undefined;
|
|
29
29
|
const m = /^(.*):(\d+):(\d+)$/.exec(value);
|
|
30
|
-
if (
|
|
30
|
+
if (null === m)
|
|
31
31
|
return undefined;
|
|
32
32
|
const file = m[1];
|
|
33
33
|
const line = Number(m[2]);
|
|
34
|
-
if (file === undefined || file.length
|
|
34
|
+
if (file === undefined || 0 === file.length || !Number.isFinite(line))
|
|
35
35
|
return undefined;
|
|
36
36
|
return { file, line };
|
|
37
37
|
}
|
package/dist/dom/theme.js
CHANGED
|
@@ -11,12 +11,12 @@
|
|
|
11
11
|
let cached = null;
|
|
12
12
|
/** Resolve any CSS color string to canonical computed `rgb...)`/`rgba...)`, or null if not a color. */
|
|
13
13
|
function toRgb(value) {
|
|
14
|
-
if (value.length
|
|
14
|
+
if (0 === value.length)
|
|
15
15
|
return null;
|
|
16
16
|
const probe = document.createElement('span');
|
|
17
17
|
probe.style.color = '';
|
|
18
18
|
probe.style.color = value; // invalid colors leave it empty (the setter rejects them)
|
|
19
|
-
if (probe.style.color
|
|
19
|
+
if ('' === probe.style.color)
|
|
20
20
|
return null;
|
|
21
21
|
probe.style.position = 'absolute';
|
|
22
22
|
probe.style.pointerEvents = 'none';
|
|
@@ -36,7 +36,7 @@ function collectTokens() {
|
|
|
36
36
|
catch {
|
|
37
37
|
continue;
|
|
38
38
|
}
|
|
39
|
-
if (
|
|
39
|
+
if (null === rules)
|
|
40
40
|
continue;
|
|
41
41
|
for (const rule of Array.from(rules)) {
|
|
42
42
|
if (!(rule instanceof CSSStyleRule))
|
|
@@ -66,15 +66,15 @@ function palette() {
|
|
|
66
66
|
}
|
|
67
67
|
/** True for a color that carries no visual weight (fully transparent) — never flagged off-theme. */
|
|
68
68
|
function isTransparent(rgb) {
|
|
69
|
-
return
|
|
69
|
+
return 'rgba(0, 0, 0, 0)' === rgb || 'transparent' === rgb;
|
|
70
70
|
}
|
|
71
71
|
/** Report an element's theme compliance from its computed style. */
|
|
72
72
|
export function themeReport(cs) {
|
|
73
73
|
const p = palette();
|
|
74
74
|
const colorToken = p.byColor.get(cs.color) ?? null;
|
|
75
75
|
const backgroundToken = p.byColor.get(cs.backgroundColor) ?? null;
|
|
76
|
-
const colorOff = !isTransparent(cs.color) &&
|
|
77
|
-
const bgOff = !isTransparent(cs.backgroundColor) &&
|
|
76
|
+
const colorOff = !isTransparent(cs.color) && null === colorToken;
|
|
77
|
+
const bgOff = !isTransparent(cs.backgroundColor) && null === backgroundToken;
|
|
78
78
|
return {
|
|
79
79
|
colorToken,
|
|
80
80
|
backgroundToken,
|
package/dist/dom/virtualized.js
CHANGED
|
@@ -26,7 +26,7 @@ function rowHost(container) {
|
|
|
26
26
|
let host = container;
|
|
27
27
|
for (let depth = 0; depth < 3; depth += 1) {
|
|
28
28
|
const children = Array.from(host.children).filter((c) => c instanceof HTMLElement);
|
|
29
|
-
const only = children.length
|
|
29
|
+
const only = 1 === children.length ? children[0] : undefined;
|
|
30
30
|
if (only === undefined)
|
|
31
31
|
return host;
|
|
32
32
|
// A spacer is a single child that fills (or over-fills) the scroll area.
|
|
@@ -51,10 +51,10 @@ export function countClosedShadowRoots() {
|
|
|
51
51
|
*/
|
|
52
52
|
export function isCrossOriginFrame(frame) {
|
|
53
53
|
const src = frame.getAttribute('src');
|
|
54
|
-
if (
|
|
54
|
+
if (null === src || 0 === src.length)
|
|
55
55
|
return false;
|
|
56
56
|
try {
|
|
57
|
-
return frame.contentDocument
|
|
57
|
+
return null === frame.contentDocument;
|
|
58
58
|
}
|
|
59
59
|
catch {
|
|
60
60
|
return true; // SecurityError on access is itself proof of cross-origin
|
|
@@ -12,7 +12,7 @@ const METHOD_EVENT = {
|
|
|
12
12
|
function stringifyArgs(args) {
|
|
13
13
|
return args
|
|
14
14
|
.map((a) => {
|
|
15
|
-
if (typeof a
|
|
15
|
+
if ('string' === typeof a)
|
|
16
16
|
return a;
|
|
17
17
|
if (a instanceof Error)
|
|
18
18
|
return a.message;
|
|
@@ -23,7 +23,7 @@ function stringifyArgs(args) {
|
|
|
23
23
|
/** Stacks can be long; cap so a deep async trace never blows the event budget. */
|
|
24
24
|
const MAX_STACK_LEN = TRANSPORT_LIMITS.MAX_STACK_LENGTH;
|
|
25
25
|
function capStack(stack) {
|
|
26
|
-
if (stack === undefined || stack.length
|
|
26
|
+
if (stack === undefined || 0 === stack.length)
|
|
27
27
|
return undefined;
|
|
28
28
|
return stack.length > MAX_STACK_LEN ? stack.slice(0, MAX_STACK_LEN) : stack;
|
|
29
29
|
}
|
|
@@ -47,7 +47,7 @@ export function installConsole(emit) {
|
|
|
47
47
|
const callOriginal = original.bind(console);
|
|
48
48
|
const wrapper = (...args) => {
|
|
49
49
|
// Only console.error carries a stack — the diagnosis case; log/warn stay lean.
|
|
50
|
-
const stack =
|
|
50
|
+
const stack = 'error' === method ? firstErrorStack(args) : undefined;
|
|
51
51
|
emit(METHOD_EVENT[method], {
|
|
52
52
|
message: stringifyArgs(args),
|
|
53
53
|
...(stack === undefined ? {} : { stack }),
|
package/dist/observers/dom.js
CHANGED
|
@@ -9,7 +9,7 @@ import { refs } from '../dom/refs.js';
|
|
|
9
9
|
*/
|
|
10
10
|
function regionKeyOf(target) {
|
|
11
11
|
const el = isElement(target) ? target : null;
|
|
12
|
-
if (
|
|
12
|
+
if (null === el)
|
|
13
13
|
return undefined;
|
|
14
14
|
const labelled = el.closest('[data-testid]');
|
|
15
15
|
return labelled?.getAttribute('data-testid') ?? refs.refFor(el);
|
|
@@ -37,7 +37,7 @@ const WATCHED_ATTRS = [
|
|
|
37
37
|
/** Attribute/text values are capped per event — style/src can be huge and would bloat the ledger. */
|
|
38
38
|
const MAX_ATTR_VALUE_LEN = 120;
|
|
39
39
|
function capValue(value) {
|
|
40
|
-
if (
|
|
40
|
+
if (null === value)
|
|
41
41
|
return undefined;
|
|
42
42
|
return value.length > MAX_ATTR_VALUE_LEN ? `${value.slice(0, MAX_ATTR_VALUE_LEN)}…` : value;
|
|
43
43
|
}
|
|
@@ -82,7 +82,7 @@ export function installDom(emit) {
|
|
|
82
82
|
// (it can include not-yet-inspected noise) but `dropped > 0` honestly means "this batch was capped".
|
|
83
83
|
let dropped = 0;
|
|
84
84
|
for (const record of records) {
|
|
85
|
-
if (record.type
|
|
85
|
+
if ('attributes' === record.type) {
|
|
86
86
|
const target = record.target;
|
|
87
87
|
if (isElement(target) && record.attributeName !== null && !isReticleOverlay(target)) {
|
|
88
88
|
if (changed >= MAX_PER_BATCH) {
|
|
@@ -101,7 +101,7 @@ export function installDom(emit) {
|
|
|
101
101
|
}
|
|
102
102
|
continue;
|
|
103
103
|
}
|
|
104
|
-
if (record.type
|
|
104
|
+
if ('characterData' === record.type) {
|
|
105
105
|
// In-place text change inside an existing subtree (wizard steps, inline edits) —
|
|
106
106
|
// childList-only would miss this.
|
|
107
107
|
const parent = record.target.parentElement;
|
|
@@ -158,7 +158,7 @@ export function installDom(emit) {
|
|
|
158
158
|
emit(EventType.DOM_ADDED, { role, name, region: regionKeyOf(record.target) }, ref);
|
|
159
159
|
if (DIALOG_ROLES.has(role) ||
|
|
160
160
|
LIVE_ROLES.has(role) ||
|
|
161
|
-
node.getAttribute('aria-modal')
|
|
161
|
+
'true' === node.getAttribute('aria-modal')) {
|
|
162
162
|
if (isVisible(node))
|
|
163
163
|
emit(EventType.VISIBLE_SHOWN, { role, name }, ref);
|
|
164
164
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Emit, Teardown } from './types.js';
|
|
2
|
-
|
|
2
|
+
interface DownloadOptions {
|
|
3
3
|
/** Include a content preview. Gated for the same reason request bodies are: exports hold PII. */
|
|
4
4
|
capturePreview?: boolean;
|
|
5
5
|
}
|
|
6
6
|
export declare function installDownload(emit: Emit, opts?: DownloadOptions): Teardown;
|
|
7
|
+
export {};
|
|
@@ -63,7 +63,7 @@ export function installDownload(emit, opts = {}) {
|
|
|
63
63
|
// The LINE COUNT is reported even without preview capture: it is a number, not content, and it
|
|
64
64
|
// is the whole tell for "25 rows in a file the toast calls 128".
|
|
65
65
|
data['lines'] = text.split('\n').filter((l) => l.length > 0).length;
|
|
66
|
-
if (opts.capturePreview
|
|
66
|
+
if (true === opts.capturePreview) {
|
|
67
67
|
const { body, truncated } = projectBody(text.slice(0, PREVIEW_CHARS), record.type);
|
|
68
68
|
data['preview'] = body;
|
|
69
69
|
if (truncated)
|
|
@@ -79,7 +79,7 @@ export function installDownload(emit, opts = {}) {
|
|
|
79
79
|
const onClick = (event) => {
|
|
80
80
|
const target = event.target;
|
|
81
81
|
const anchor = target instanceof Element ? target.closest('a[download]') : null;
|
|
82
|
-
if (
|
|
82
|
+
if (null === anchor)
|
|
83
83
|
return;
|
|
84
84
|
report(anchor.getAttribute('href') ?? '', anchor.getAttribute('download') ?? undefined);
|
|
85
85
|
};
|
package/dist/observers/focus.js
CHANGED
|
@@ -9,7 +9,7 @@ export function focusLabel(el) {
|
|
|
9
9
|
return name.length > 0 ? `${role} "${name}"` : role;
|
|
10
10
|
}
|
|
11
11
|
function toBody(el) {
|
|
12
|
-
return
|
|
12
|
+
return null === el || el === document.body;
|
|
13
13
|
}
|
|
14
14
|
/**
|
|
15
15
|
* Track element focus movement. Focus dropping to <body> after an act — a modal that closed and stranded
|
package/dist/observers/frames.js
CHANGED
package/dist/observers/health.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { EventType, HealthReason, RETICLE_IPC_GLOBAL, SESSION_HEALTH } from '@reticlehq/core';
|
|
1
|
+
import { BrowserBrand, EventType, HealthReason, RETICLE_IPC_GLOBAL, SESSION_HEALTH, } from '@reticlehq/core';
|
|
2
2
|
import { nativeSetInterval } from '../timers/native-timers.js';
|
|
3
3
|
/**
|
|
4
4
|
* Which shell this page runs in. Reported with health because it is what makes a timeout
|
|
@@ -27,12 +27,80 @@ function detectEngine() {
|
|
|
27
27
|
return 'gecko';
|
|
28
28
|
return 'webkit';
|
|
29
29
|
}
|
|
30
|
+
/**
|
|
31
|
+
* Brand names as the Chromium family and the UA string spell them, mapped to our closed vocabulary.
|
|
32
|
+
* Matched on the WHOLE lowercased name, never a substring: brand strings are attacker-influenced
|
|
33
|
+
* free text, and a substring rule is how "Search Browser" becomes `arc`.
|
|
34
|
+
*/
|
|
35
|
+
const BRAND_BY_NAME = {
|
|
36
|
+
'google chrome': BrowserBrand.CHROME,
|
|
37
|
+
chrome: BrowserBrand.CHROME,
|
|
38
|
+
'microsoft edge': BrowserBrand.EDGE,
|
|
39
|
+
edge: BrowserBrand.EDGE,
|
|
40
|
+
brave: BrowserBrand.BRAVE,
|
|
41
|
+
opera: BrowserBrand.OPERA,
|
|
42
|
+
arc: BrowserBrand.ARC,
|
|
43
|
+
dia: BrowserBrand.DIA,
|
|
44
|
+
firefox: BrowserBrand.FIREFOX,
|
|
45
|
+
safari: BrowserBrand.SAFARI,
|
|
46
|
+
};
|
|
47
|
+
/** Chromium's own filler entries: the greased `Not_A Brand` (spelt differently every release) and the generic engine name. */
|
|
48
|
+
function isFiller(name) {
|
|
49
|
+
return 'chromium' === name || (name.includes('not') && name.includes('brand'));
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* The brand, from `navigator.userAgentData.brands` where it exists and the UA string where it does
|
|
53
|
+
* not (Firefox and Safari expose no userAgentData at all). Every fork claims `Google Chrome`
|
|
54
|
+
* alongside its own entry, so a non-Chrome match always wins; a Chromium that names nothing but
|
|
55
|
+
* filler is `other`, because calling it Chrome would be a guess.
|
|
56
|
+
*
|
|
57
|
+
* Never throws — a desktop webview may expose a `userAgentData` of any shape, and health reporting
|
|
58
|
+
* must survive it.
|
|
59
|
+
*/
|
|
60
|
+
function detectBrand() {
|
|
61
|
+
try {
|
|
62
|
+
const nav = navigator;
|
|
63
|
+
const brands = nav.userAgentData?.brands;
|
|
64
|
+
if (Array.isArray(brands)) {
|
|
65
|
+
let chrome;
|
|
66
|
+
for (const entry of brands) {
|
|
67
|
+
const name = 'string' === typeof entry?.brand ? entry.brand.toLowerCase() : '';
|
|
68
|
+
if ('' === name || isFiller(name))
|
|
69
|
+
continue;
|
|
70
|
+
const known = BRAND_BY_NAME[name];
|
|
71
|
+
if (known === undefined)
|
|
72
|
+
continue;
|
|
73
|
+
if (BrowserBrand.CHROME === known)
|
|
74
|
+
chrome = known;
|
|
75
|
+
else
|
|
76
|
+
return known;
|
|
77
|
+
}
|
|
78
|
+
return chrome ?? BrowserBrand.OTHER;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
/* a health report must never depend on an exotic userAgentData */
|
|
83
|
+
}
|
|
84
|
+
const ua = navigator.userAgent;
|
|
85
|
+
if (/Edg\//.test(ua))
|
|
86
|
+
return BrowserBrand.EDGE;
|
|
87
|
+
if (/OPR\/|Opera/.test(ua))
|
|
88
|
+
return BrowserBrand.OPERA;
|
|
89
|
+
if (/Firefox/.test(ua))
|
|
90
|
+
return BrowserBrand.FIREFOX;
|
|
91
|
+
if (/Chrome|Chromium/.test(ua))
|
|
92
|
+
return BrowserBrand.CHROME;
|
|
93
|
+
if (/Safari/.test(ua))
|
|
94
|
+
return BrowserBrand.SAFARI;
|
|
95
|
+
return BrowserBrand.OTHER;
|
|
96
|
+
}
|
|
30
97
|
function snapshotHealth() {
|
|
31
98
|
return {
|
|
32
|
-
hidden: document.visibilityState
|
|
99
|
+
hidden: 'hidden' === document.visibilityState,
|
|
33
100
|
focused: document.hasFocus(),
|
|
34
101
|
runtime: detectRuntime(),
|
|
35
102
|
engine: detectEngine(),
|
|
103
|
+
brand: detectBrand(),
|
|
36
104
|
};
|
|
37
105
|
}
|
|
38
106
|
/**
|
package/dist/observers/ipc.d.ts
CHANGED
|
@@ -1,10 +1,4 @@
|
|
|
1
1
|
import type { Emit, Teardown } from './types.js';
|
|
2
|
-
/**
|
|
3
|
-
* How Tauri v2 reports a command's verdict. The transport answers HTTP 200 whether the Rust command
|
|
4
|
-
* returned Ok or Err — the verdict is in this response header, which Tauri explicitly CORS-exposes so
|
|
5
|
-
* JS may read it. Without translating it, every failed command is recorded as a successful request.
|
|
6
|
-
*/
|
|
7
|
-
export declare const TAURI_RESPONSE_HEADER_NAME = "Tauri-Response";
|
|
8
2
|
/**
|
|
9
3
|
* Fields to override on a NET_REQUEST that turned out to be a Tauri IPC call, or null when the
|
|
10
4
|
* request was ordinary HTTP. Pure — the network observer calls this with the response header it
|
|
@@ -15,8 +9,9 @@ export declare function ipcNetOverrides(url: string, header: (name: string) => s
|
|
|
15
9
|
* Subscribe to the Electron preload shim, if this app installed it. Inert on Tauri (whose calls the
|
|
16
10
|
* network observer handles) and on a plain web page.
|
|
17
11
|
*/
|
|
18
|
-
|
|
12
|
+
interface IpcOptions {
|
|
19
13
|
/** Forward the IPC payloads the preload recorded. Off by default, like the HTTP body capture. */
|
|
20
14
|
captureBodies?: boolean;
|
|
21
15
|
}
|
|
22
16
|
export declare function installIpc(emit: Emit, options?: IpcOptions): Teardown;
|
|
17
|
+
export {};
|
package/dist/observers/ipc.js
CHANGED
|
@@ -5,7 +5,7 @@ import { observeSafely } from './types.js';
|
|
|
5
5
|
* returned Ok or Err — the verdict is in this response header, which Tauri explicitly CORS-exposes so
|
|
6
6
|
* JS may read it. Without translating it, every failed command is recorded as a successful request.
|
|
7
7
|
*/
|
|
8
|
-
|
|
8
|
+
const TAURI_RESPONSE_HEADER_NAME = 'Tauri-Response';
|
|
9
9
|
const TAURI_RESPONSE_ERROR = 'error';
|
|
10
10
|
/** Rust/IPC vocabulary rather than HTTP's, so the text never claims a transport status it lacks. */
|
|
11
11
|
const IPC_STATUS_TEXT = { OK: 'Ok', ERROR: 'Err' };
|
|
@@ -70,7 +70,7 @@ export function installIpc(emit, options = {}) {
|
|
|
70
70
|
const token = channel.subscribe((record) => {
|
|
71
71
|
observeSafely(() => {
|
|
72
72
|
const url = `${IPC_URL_SCHEME}${record.channel}`;
|
|
73
|
-
if (record.phase
|
|
73
|
+
if ('start' === record.phase) {
|
|
74
74
|
emit(EventType.NET_PENDING, {
|
|
75
75
|
id: record.id,
|
|
76
76
|
method: NetInitiator.IPC,
|
|
@@ -83,18 +83,18 @@ export function installIpc(emit, options = {}) {
|
|
|
83
83
|
// main process handled it. Emitting `ok/status` anyway would manufacture the success nobody
|
|
84
84
|
// reported, so both are omitted and `oneWay` says why. `reticle_network { ok }` then matches
|
|
85
85
|
// such a record in neither direction, which is the honest answer to "did it work".
|
|
86
|
-
const verdictless = record.oneWay
|
|
86
|
+
const verdictless = true === record.oneWay && record.ok === undefined;
|
|
87
87
|
// Declared, not inferred: the server cannot tell "no verdict" from "not yet settled" without
|
|
88
88
|
// being told, and a send with nothing to observe must not read as a clean green.
|
|
89
89
|
if (verdictless)
|
|
90
90
|
emit(EventType.BLIND_SPOT, { kind: BlindSpotKind.VERDICTLESS_SEND, count: 1 });
|
|
91
|
-
const ok = record.ok
|
|
91
|
+
const ok = true === record.ok;
|
|
92
92
|
emit(EventType.NET_REQUEST, {
|
|
93
93
|
id: record.id,
|
|
94
94
|
method: NetInitiator.IPC,
|
|
95
95
|
url,
|
|
96
96
|
...(verdictless ? {} : { ok, status: ok ? IpcStatus.OK : IpcStatus.ERROR }),
|
|
97
|
-
...(record.oneWay
|
|
97
|
+
...(true === record.oneWay ? { oneWay: true } : {}),
|
|
98
98
|
durationMs: record.durationMs ?? 0,
|
|
99
99
|
initiator: NetInitiator.IPC,
|
|
100
100
|
...(record.error === undefined ? {} : { error: record.error }),
|
|
@@ -102,11 +102,11 @@ export function installIpc(emit, options = {}) {
|
|
|
102
102
|
// write returned ok and its payload went unread" from the size alone, and it cannot infer that
|
|
103
103
|
// from an absent field — an unread payload and an empty one would look identical.
|
|
104
104
|
...(record.responseSize === undefined ? {} : { responseSize: record.responseSize }),
|
|
105
|
-
...(options.captureBodies
|
|
105
|
+
...(true === options.captureBodies
|
|
106
106
|
? {
|
|
107
107
|
...(record.requestBody === undefined ? {} : { requestBody: record.requestBody }),
|
|
108
108
|
...(record.responseBody === undefined ? {} : { responseBody: record.responseBody }),
|
|
109
|
-
...(record.responseBodyTruncated
|
|
109
|
+
...(true === record.responseBodyTruncated ? { responseBodyTruncated: true } : {}),
|
|
110
110
|
}
|
|
111
111
|
: {}),
|
|
112
112
|
});
|
|
@@ -45,7 +45,7 @@ const ENDLESS_BY_DESIGN = /event-stream/i;
|
|
|
45
45
|
export function isStreamingBody(contentType, contentLength) {
|
|
46
46
|
if (contentType !== null && ENDLESS_BY_DESIGN.test(contentType))
|
|
47
47
|
return false;
|
|
48
|
-
return
|
|
48
|
+
return null === contentLength;
|
|
49
49
|
}
|
|
50
50
|
/**
|
|
51
51
|
* Longest the SDK will wait on a response-body clone before emitting without it.
|
|
@@ -84,7 +84,7 @@ export async function withBodyDeadline(read) {
|
|
|
84
84
|
}
|
|
85
85
|
}
|
|
86
86
|
export function isCapturableType(contentType) {
|
|
87
|
-
if (
|
|
87
|
+
if (null === contentType)
|
|
88
88
|
return false;
|
|
89
89
|
if (STREAMING_CONTENT.test(contentType))
|
|
90
90
|
return false;
|