@oxvo/ai-live-assist 7.5.0 → 7.5.6
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/cjs/AiLiveAssist.d.ts +2 -1
- package/cjs/AiLiveAssist.js +56 -8
- package/cjs/context.d.ts +2 -0
- package/cjs/context.js +125 -59
- package/cjs/control.d.ts +9 -1
- package/cjs/control.js +81 -3
- package/cjs/liveControlChannel.d.ts +1 -1
- package/cjs/media.d.ts +1 -0
- package/cjs/media.js +3 -0
- package/cjs/types.d.ts +2 -0
- package/cjs/ui.js +2 -0
- package/cjs/version.d.ts +1 -1
- package/cjs/version.js +1 -1
- package/lib/AiLiveAssist.d.ts +2 -1
- package/lib/AiLiveAssist.js +56 -8
- package/lib/context.d.ts +2 -0
- package/lib/context.js +125 -59
- package/lib/control.d.ts +9 -1
- package/lib/control.js +81 -3
- package/lib/liveControlChannel.d.ts +1 -1
- package/lib/media.d.ts +1 -0
- package/lib/media.js +3 -0
- package/lib/types.d.ts +2 -0
- package/lib/ui.js +2 -0
- package/lib/version.d.ts +1 -1
- package/lib/version.js +1 -1
- package/package.json +1 -1
package/cjs/AiLiveAssist.d.ts
CHANGED
|
@@ -34,7 +34,7 @@ export declare const normalizeAiGuideOpenInput: (input?: AiGuideOpenInput) => Re
|
|
|
34
34
|
export declare const resolveAiGuideProtocolVersion: (bootstrap: Pick<BootstrapConfig, "experienceMode" | "minimumProtocolVersion" | "protocol">) => 1 | 2;
|
|
35
35
|
export default class AiLiveAssist {
|
|
36
36
|
private readonly options;
|
|
37
|
-
readonly version = "7.5.
|
|
37
|
+
readonly version = "7.5.6";
|
|
38
38
|
private readonly host;
|
|
39
39
|
private readonly guideAdmission;
|
|
40
40
|
private readonly standaloneAdmissionPending;
|
|
@@ -627,6 +627,7 @@ export default class AiLiveAssist {
|
|
|
627
627
|
private submitFeedback;
|
|
628
628
|
private retry;
|
|
629
629
|
private scheduleReconnect;
|
|
630
|
+
private canRecoverControlTransport;
|
|
630
631
|
private reconnectPendingHandoffControlInMemory;
|
|
631
632
|
private reconnectMessengerTerminalControl;
|
|
632
633
|
private clearReconnectTimer;
|
package/cjs/AiLiveAssist.js
CHANGED
|
@@ -7945,7 +7945,28 @@ class AiLiveAssist {
|
|
|
7945
7945
|
? this.navigationConsentResetReady
|
|
7946
7946
|
: null;
|
|
7947
7947
|
const resumeRecordAtConnect = this.readResume();
|
|
7948
|
-
|
|
7948
|
+
let recoveryPath = null;
|
|
7949
|
+
const channel = new control_js_1.ControlChannel(result, this.tabId, version_js_1.VERSION, () => this.scheduleReconnect(true), this.readResume()?.clientSequence ?? 0, (sequence) => this.updateResumeSequence(sequence), () => this.handleControlSequenceGap(), {
|
|
7950
|
+
canRecover: () => {
|
|
7951
|
+
const currentPath = path();
|
|
7952
|
+
if (!this.canRecoverControlTransport(channel, result, media) ||
|
|
7953
|
+
(recoveryPath !== null && recoveryPath !== currentPath))
|
|
7954
|
+
return false;
|
|
7955
|
+
recoveryPath = currentPath;
|
|
7956
|
+
return true;
|
|
7957
|
+
},
|
|
7958
|
+
onRecovered: async () => {
|
|
7959
|
+
assertCurrent();
|
|
7960
|
+
const fencingToken = channel.leaseToken;
|
|
7961
|
+
channel.startHeartbeat(this.heartbeatSeconds);
|
|
7962
|
+
await this.replayPreFenceAuthorityEnvelopes(channel, fencingToken, assertCurrent);
|
|
7963
|
+
assertCurrent();
|
|
7964
|
+
await this.context?.captureAndPublish();
|
|
7965
|
+
assertCurrent();
|
|
7966
|
+
this.flushPlaybackState();
|
|
7967
|
+
this.reapplyConservativePreFencePresence(channel, fencingToken);
|
|
7968
|
+
},
|
|
7969
|
+
});
|
|
7949
7970
|
const pauseHelloStateSignal = deferredSignal();
|
|
7950
7971
|
(this.pauseHelloStateSignals ?? (this.pauseHelloStateSignals = new WeakMap())).set(channel, pauseHelloStateSignal);
|
|
7951
7972
|
this.handoffContextPromptReceiptChannel = null;
|
|
@@ -8315,7 +8336,7 @@ class AiLiveAssist {
|
|
|
8315
8336
|
this.context === context;
|
|
8316
8337
|
context = new context_js_1.PageContextCollector({
|
|
8317
8338
|
onSnapshot: (snapshot) => {
|
|
8318
|
-
if (isCurrent() && this.channel?.
|
|
8339
|
+
if (isCurrent() && this.channel?.hasFencedAuthority) {
|
|
8319
8340
|
this.send("context.snapshot", snapshot);
|
|
8320
8341
|
}
|
|
8321
8342
|
},
|
|
@@ -9674,7 +9695,15 @@ class AiLiveAssist {
|
|
|
9674
9695
|
this.activePageAssistanceAuthorityEpoch === null &&
|
|
9675
9696
|
this.activeActionAuthorityEpoch === null;
|
|
9676
9697
|
if (!exactApplied || !localAuthorityInactive) {
|
|
9677
|
-
|
|
9698
|
+
// A resumed document still owes the original action's terminal result.
|
|
9699
|
+
// Preserve only its grantless outbox, never browser-control authority.
|
|
9700
|
+
// Delivery remains fenced by the server's exact already_started receipt.
|
|
9701
|
+
const preserveNavigationResult = source === "navigation_resume" &&
|
|
9702
|
+
this.awaitingNavigationConsentReset &&
|
|
9703
|
+
structurallyValid &&
|
|
9704
|
+
localAuthorityInactive &&
|
|
9705
|
+
this.pendingNavigationActionResult?.grant === null;
|
|
9706
|
+
this.invalidatePageAssistanceAuthority(false, preserveNavigationResult);
|
|
9678
9707
|
}
|
|
9679
9708
|
if (!structurallyValid ||
|
|
9680
9709
|
identity.authorityEpoch < this.minimumPageAssistanceAuthorityEpoch) {
|
|
@@ -11997,15 +12026,19 @@ class AiLiveAssist {
|
|
|
11997
12026
|
// intentionally result-less until navigation settles, preserving the
|
|
11998
12027
|
// cross-document recovery contract if this click unloads the page.
|
|
11999
12028
|
pendingExecution.result = deliveryResult;
|
|
12029
|
+
let navigationReleased = false;
|
|
12000
12030
|
try {
|
|
12001
|
-
|
|
12002
|
-
return deliveryResult;
|
|
12003
|
-
}
|
|
12004
|
-
deliveryResult = this.markDeferredNavigationUnconfirmed(pendingExecution, context.currentRevision);
|
|
12031
|
+
navigationReleased = await this.navigateTo(deferredNavigation);
|
|
12005
12032
|
}
|
|
12006
12033
|
catch {
|
|
12007
|
-
|
|
12034
|
+
// A rejected router and an outgoing document both settle here.
|
|
12008
12035
|
}
|
|
12036
|
+
// Pagehide stops the old collector and settles its pending router
|
|
12037
|
+
// callback. It must not replace the destination document's grantless
|
|
12038
|
+
// recovery record with an unconfirmed result from the outgoing page.
|
|
12039
|
+
if (navigationReleased || !authorityIsCurrent())
|
|
12040
|
+
return deliveryResult;
|
|
12041
|
+
deliveryResult = this.markDeferredNavigationUnconfirmed(pendingExecution, context.currentRevision);
|
|
12009
12042
|
}
|
|
12010
12043
|
else if (deferredNavigation) {
|
|
12011
12044
|
deliveryResult = this.markDeferredNavigationUnconfirmed(pendingExecution, context.currentRevision);
|
|
@@ -14669,6 +14702,21 @@ class AiLiveAssist {
|
|
|
14669
14702
|
}, delay + Math.floor(Math.random() * 250));
|
|
14670
14703
|
this.reconnectTimer = timer;
|
|
14671
14704
|
}
|
|
14705
|
+
canRecoverControlTransport(channel, result, media) {
|
|
14706
|
+
return (this.channel === channel &&
|
|
14707
|
+
this.result === result &&
|
|
14708
|
+
this.media === media && media.isConnected && navigator.onLine &&
|
|
14709
|
+
!this.ended && !this.destroyed && !this.terminalRequestPending &&
|
|
14710
|
+
!this.reconnecting && !this.launchInFlight && !this.isVoicePresence() &&
|
|
14711
|
+
(this.lifecyclePhase === "active_text" || this.lifecyclePhase === "active_voice") &&
|
|
14712
|
+
!this.pendingAction && !this.pendingConfirmation && !this.pendingPageAssistance &&
|
|
14713
|
+
!this.pendingNavigationActionResult && !this.legacyActionExecution &&
|
|
14714
|
+
this.actionGrantInFlight.size === 0 &&
|
|
14715
|
+
!this.pendingModeUpgrade && !this.pendingTextModeTransition &&
|
|
14716
|
+
!this.messengerTransferPending && !this.messengerTransferred &&
|
|
14717
|
+
!this.handoffRequestLifecycleOwner && !this.readResume()?.pauseCommand &&
|
|
14718
|
+
this.modeUpgradePathIsCurrent(path()));
|
|
14719
|
+
}
|
|
14672
14720
|
async reconnectPendingHandoffControlInMemory(result, record, clientSequence, fencingToken, reconnectGeneration) {
|
|
14673
14721
|
if (reconnectGeneration !== this.reconnectGeneration ||
|
|
14674
14722
|
this.reconnecting ||
|
package/cjs/context.d.ts
CHANGED
|
@@ -23,6 +23,7 @@ export declare class PageContextCollector {
|
|
|
23
23
|
private mutationObserver;
|
|
24
24
|
private captureTimer;
|
|
25
25
|
private mutationCaptureTimer;
|
|
26
|
+
private mutationCaptureStartedAt;
|
|
26
27
|
private targetIds;
|
|
27
28
|
private pageScrollIds;
|
|
28
29
|
private navigationTargetBindings;
|
|
@@ -74,6 +75,7 @@ export declare class PageContextCollector {
|
|
|
74
75
|
private waitForScrollSettlement;
|
|
75
76
|
private actionApplied;
|
|
76
77
|
private clippedGeometry;
|
|
78
|
+
private clearMutationCapture;
|
|
77
79
|
private scheduleCapture;
|
|
78
80
|
private scheduleMutationCapture;
|
|
79
81
|
private scheduleViewportCapture;
|
package/cjs/context.js
CHANGED
|
@@ -10,6 +10,7 @@ const MAX_SELECTED_TEXT = 2000;
|
|
|
10
10
|
const SELECTION_RETENTION_MS = 120000;
|
|
11
11
|
const CAPTURE_SETTLE_MS = 250;
|
|
12
12
|
const MUTATION_CAPTURE_DEBOUNCE_MS = 350;
|
|
13
|
+
const MUTATION_CAPTURE_MAX_WAIT_MS = 1000;
|
|
13
14
|
const SCROLL_SETTLEMENT_TIMEOUT_MS = 2000;
|
|
14
15
|
const SCROLL_SETTLEMENT_FRAME_FALLBACK_MS = 50;
|
|
15
16
|
const SCROLL_SETTLEMENT_POSITION_TOLERANCE_PX = 1;
|
|
@@ -78,6 +79,7 @@ const navigationTargetBinding = (target) => canonicalJson({
|
|
|
78
79
|
inputType: target.inputType ?? null,
|
|
79
80
|
autocomplete: target.autocomplete ?? null,
|
|
80
81
|
semanticRegion: target.semanticRegion ?? null,
|
|
82
|
+
...(target.navigationMenu === true ? { navigationMenu: true } : {}),
|
|
81
83
|
destinationPath: target.destinationPath ?? null,
|
|
82
84
|
sensitivity: target.sensitivity,
|
|
83
85
|
protectedRegion: target.protectedRegion ?? null,
|
|
@@ -122,6 +124,7 @@ const actionTargetFingerprintBinding = (target) => canonicalJson({
|
|
|
122
124
|
inputType: target.inputType ?? null,
|
|
123
125
|
autocomplete: target.autocomplete ?? null,
|
|
124
126
|
semanticRegion: target.semanticRegion ?? null,
|
|
127
|
+
...(target.navigationMenu === true ? { navigationMenu: true } : {}),
|
|
125
128
|
destinationPath: target.destinationPath ?? null,
|
|
126
129
|
sensitivity: target.sensitivity,
|
|
127
130
|
protectedRegion: target.protectedRegion ?? null,
|
|
@@ -145,47 +148,94 @@ const actionTargetFingerprint = async (target, subtle = globalThis.crypto?.subtl
|
|
|
145
148
|
};
|
|
146
149
|
exports.actionTargetFingerprint = actionTargetFingerprint;
|
|
147
150
|
const isHTMLElement = (value) => value instanceof HTMLElement;
|
|
151
|
+
// Clipping is checked in document geometry, not against the viewport: a
|
|
152
|
+
// rendered footer link remains discoverable before it is scrolled into view.
|
|
153
|
+
// No hidden descendant text is inspected to discover a navigation menu.
|
|
154
|
+
const clippedRect = (element) => {
|
|
155
|
+
const rect = element.getBoundingClientRect();
|
|
156
|
+
let { left, top, right, bottom } = rect;
|
|
157
|
+
let depth = 0;
|
|
158
|
+
for (let parent = element.parentElement; parent; parent = parent.parentElement) {
|
|
159
|
+
if (++depth > 64)
|
|
160
|
+
return null;
|
|
161
|
+
const style = window.getComputedStyle(parent);
|
|
162
|
+
if (Number.parseFloat(style.opacity) === 0 || style.visibility === 'hidden' || style.display === 'none' || style.contentVisibility === 'hidden')
|
|
163
|
+
return null;
|
|
164
|
+
if (parent === document.body || parent === document.documentElement)
|
|
165
|
+
continue;
|
|
166
|
+
const bounds = parent.getBoundingClientRect();
|
|
167
|
+
// Overflow scrolling can reveal an offscreen descendant. A collapsed or
|
|
168
|
+
// clipped disclosure cannot be revealed by scrolling the document to it.
|
|
169
|
+
if (['hidden', 'clip'].includes(style.overflowX)) {
|
|
170
|
+
left = Math.max(left, bounds.left);
|
|
171
|
+
right = Math.min(right, bounds.right);
|
|
172
|
+
}
|
|
173
|
+
if (['hidden', 'clip'].includes(style.overflowY)) {
|
|
174
|
+
top = Math.max(top, bounds.top);
|
|
175
|
+
bottom = Math.min(bottom, bounds.bottom);
|
|
176
|
+
}
|
|
177
|
+
if (right <= left || bottom <= top)
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
180
|
+
return right > left && bottom > top ? { left, top, right, bottom } : null;
|
|
181
|
+
};
|
|
148
182
|
const isRendered = (element) => {
|
|
149
|
-
if (!isHTMLElement(element) || !element.isConnected || element.closest(PRIVATE_SELECTOR))
|
|
183
|
+
if (!isHTMLElement(element) || !element.isConnected || element.closest(PRIVATE_SELECTOR))
|
|
150
184
|
return false;
|
|
151
|
-
}
|
|
152
185
|
const style = window.getComputedStyle(element);
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
style.visibility === 'hidden' ||
|
|
156
|
-
(Number.isFinite(opacity) && opacity === 0) ||
|
|
157
|
-
style.pointerEvents === 'none') {
|
|
186
|
+
if (style.display === 'none' || style.visibility === 'hidden' ||
|
|
187
|
+
Number.parseFloat(style.opacity) === 0 || style.pointerEvents === 'none')
|
|
158
188
|
return false;
|
|
159
|
-
|
|
160
|
-
const rect = element.getBoundingClientRect();
|
|
161
|
-
return rect.width > 0 && rect.height > 0;
|
|
189
|
+
return clippedRect(element) !== null;
|
|
162
190
|
};
|
|
163
|
-
const
|
|
191
|
+
const visibleRect = (element) => {
|
|
164
192
|
if (!isRendered(element))
|
|
165
|
-
return
|
|
166
|
-
const rect = element
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
193
|
+
return null;
|
|
194
|
+
const rect = clippedRect(element);
|
|
195
|
+
if (!rect)
|
|
196
|
+
return null;
|
|
197
|
+
const left = Math.max(0, rect.left), top = Math.max(0, rect.top);
|
|
198
|
+
const right = Math.min(window.innerWidth, rect.right), bottom = Math.min(window.innerHeight, rect.bottom);
|
|
199
|
+
return right > left && bottom > top ? { left, top, right, bottom } : null;
|
|
171
200
|
};
|
|
172
|
-
const
|
|
173
|
-
|
|
174
|
-
const
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
201
|
+
const isVisible = (element) => visibleRect(element) !== null;
|
|
202
|
+
const interactionPoint = (element, trustedGuideRoot = null) => {
|
|
203
|
+
const rect = visibleRect(element);
|
|
204
|
+
if (!rect)
|
|
205
|
+
return null;
|
|
206
|
+
// Five bounded hit tests permit an exposed part of a link without treating
|
|
207
|
+
// an ancestor, empty hit, or a covering sibling as an interactable target.
|
|
208
|
+
const insetX = Math.min(1, (rect.right - rect.left) / 4);
|
|
209
|
+
const insetY = Math.min(1, (rect.bottom - rect.top) / 4);
|
|
210
|
+
const points = [
|
|
211
|
+
[(rect.left + rect.right) / 2, (rect.top + rect.bottom) / 2],
|
|
212
|
+
[rect.left + insetX, rect.top + insetY], [rect.right - insetX, rect.top + insetY],
|
|
213
|
+
[rect.left + insetX, rect.bottom - insetY], [rect.right - insetX, rect.bottom - insetY],
|
|
214
|
+
];
|
|
215
|
+
for (const [x, y] of points) {
|
|
216
|
+
const top = trustedGuideRoot?.isConnected && typeof document.elementsFromPoint === 'function'
|
|
217
|
+
? document.elementsFromPoint(x, y).find(candidate => candidate !== trustedGuideRoot) ?? null
|
|
218
|
+
: document.elementFromPoint(x, y);
|
|
219
|
+
if (top !== null && (top === element || element.contains(top)))
|
|
220
|
+
return { x: x, y: y, hit: top };
|
|
221
|
+
}
|
|
222
|
+
return null;
|
|
223
|
+
};
|
|
224
|
+
const isCovered = (element, trustedGuideRoot = null) => interactionPoint(element, trustedGuideRoot) === null;
|
|
225
|
+
const isNavigationMenu = (element, role) => {
|
|
226
|
+
if (role !== 'button' || literalBooleanAttribute(element, 'aria-expanded') === undefined ||
|
|
227
|
+
element.hasAttribute('aria-pressed') || element.hasAttribute('aria-checked') ||
|
|
228
|
+
element instanceof HTMLButtonElement && element.form !== null && element.type !== 'button')
|
|
229
|
+
return false;
|
|
230
|
+
if (element.closest('nav,[role="navigation"]'))
|
|
231
|
+
return true;
|
|
232
|
+
// aria-controls supplies identity only. Do not capture hidden menu labels,
|
|
233
|
+
// links, values or instructions before native expansion and recapture.
|
|
234
|
+
const controls = (element.getAttribute('aria-controls') ?? '').trim().split(/\s+/).slice(0, 4);
|
|
235
|
+
return controls.some(control => {
|
|
236
|
+
const menu = document.getElementById(control);
|
|
237
|
+
return menu !== null && menu.matches('nav,[role="navigation"],[role="menu"]');
|
|
238
|
+
});
|
|
189
239
|
};
|
|
190
240
|
const safePageUrl = () => `${window.location.origin}${window.location.pathname}`;
|
|
191
241
|
const linkBrowsingTarget = (link) => (link.getAttribute('target') ?? document.querySelector('base[target]')?.getAttribute('target') ?? '').toLowerCase();
|
|
@@ -197,11 +247,13 @@ const sameNavigationDestination = (actual, expected) => {
|
|
|
197
247
|
return left.origin === right.origin && left.pathname === right.pathname && left.search === right.search;
|
|
198
248
|
};
|
|
199
249
|
const safeDestinationPath = (element) => {
|
|
200
|
-
|
|
250
|
+
// An empty href is a control, not authority to navigate to the current URL.
|
|
251
|
+
if (!(element instanceof HTMLAnchorElement) || !element.getAttribute('href')?.trim())
|
|
201
252
|
return null;
|
|
202
253
|
try {
|
|
203
254
|
const destination = new URL(element.href, window.location.href);
|
|
204
255
|
if (destination.origin !== window.location.origin ||
|
|
256
|
+
destination.username !== '' || destination.password !== '' ||
|
|
205
257
|
destination.search ||
|
|
206
258
|
destination.hash ||
|
|
207
259
|
!destination.pathname.startsWith('/') ||
|
|
@@ -444,15 +496,17 @@ const checkedStateFor = (element) => {
|
|
|
444
496
|
}
|
|
445
497
|
return framerToggleDescriptor(element)?.checked;
|
|
446
498
|
};
|
|
447
|
-
const dispatchFramerPrimaryPointerSequence = (element) => {
|
|
499
|
+
const dispatchFramerPrimaryPointerSequence = (element, trustedGuideRoot = null) => {
|
|
448
500
|
if (typeof window.PointerEvent !== 'function') {
|
|
449
501
|
throw new Error('Framer pointer interaction is unavailable.');
|
|
450
502
|
}
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
const
|
|
454
|
-
|
|
455
|
-
|
|
503
|
+
// Capture and dispatch must agree on the exposed point and exact descendant,
|
|
504
|
+
// including when our own mobile surface covers a link's router-owned logo.
|
|
505
|
+
const point = interactionPoint(element, trustedGuideRoot);
|
|
506
|
+
if (!point)
|
|
507
|
+
throw new Error('The approved pointer target is covered.');
|
|
508
|
+
const { x: clientX, y: clientY } = point;
|
|
509
|
+
const eventTarget = element instanceof HTMLAnchorElement ? point.hit : element;
|
|
456
510
|
const nestedLink = eventTarget.closest('a[href]');
|
|
457
511
|
if (element instanceof HTMLAnchorElement && nestedLink instanceof HTMLAnchorElement && nestedLink !== element &&
|
|
458
512
|
(!sameNavigationDestination(nestedLink.href, element.href) || nestedLink.hasAttribute('download') ||
|
|
@@ -524,11 +578,11 @@ const dispatchFramerPrimaryPointerSequence = (element) => {
|
|
|
524
578
|
detail: 1,
|
|
525
579
|
}));
|
|
526
580
|
};
|
|
527
|
-
const dispatchFramerTogglePrimaryPointer = (element) => {
|
|
581
|
+
const dispatchFramerTogglePrimaryPointer = (element, trustedGuideRoot = null) => {
|
|
528
582
|
if (!framerToggleDescriptor(element)) {
|
|
529
583
|
throw new Error('Framer toggle pointer interaction is unavailable.');
|
|
530
584
|
}
|
|
531
|
-
dispatchFramerPrimaryPointerSequence(element);
|
|
585
|
+
dispatchFramerPrimaryPointerSequence(element, trustedGuideRoot);
|
|
532
586
|
};
|
|
533
587
|
const semanticSourceSensitivity = (source) => {
|
|
534
588
|
if (source.closest(PRIVATE_SELECTOR) || source.querySelector(PRIVATE_SELECTOR)) {
|
|
@@ -698,6 +752,7 @@ class PageContextCollector {
|
|
|
698
752
|
this.mutationObserver = null;
|
|
699
753
|
this.captureTimer = null;
|
|
700
754
|
this.mutationCaptureTimer = null;
|
|
755
|
+
this.mutationCaptureStartedAt = null;
|
|
701
756
|
this.targetIds = new WeakMap();
|
|
702
757
|
this.pageScrollIds = new WeakMap();
|
|
703
758
|
this.navigationTargetBindings = new WeakMap();
|
|
@@ -725,9 +780,7 @@ class PageContextCollector {
|
|
|
725
780
|
this.pendingCapture = true;
|
|
726
781
|
return;
|
|
727
782
|
}
|
|
728
|
-
|
|
729
|
-
clearTimeout(this.mutationCaptureTimer);
|
|
730
|
-
this.mutationCaptureTimer = null;
|
|
783
|
+
this.clearMutationCapture();
|
|
731
784
|
if (this.captureTimer)
|
|
732
785
|
return;
|
|
733
786
|
this.captureTimer = setTimeout(() => {
|
|
@@ -744,12 +797,19 @@ class PageContextCollector {
|
|
|
744
797
|
}
|
|
745
798
|
if (this.captureTimer)
|
|
746
799
|
return;
|
|
800
|
+
// Decorative animation must not indefinitely hide newly available links.
|
|
801
|
+
// Retain trailing-edge batching, with at most one forced scan per second
|
|
802
|
+
// during continuous churn. Unchanged semantic snapshots are still deduped.
|
|
803
|
+
const now = performance.now();
|
|
804
|
+
this.mutationCaptureStartedAt ?? (this.mutationCaptureStartedAt = now);
|
|
805
|
+
const delay = Math.max(0, Math.min(MUTATION_CAPTURE_DEBOUNCE_MS, MUTATION_CAPTURE_MAX_WAIT_MS - (now - this.mutationCaptureStartedAt)));
|
|
747
806
|
if (this.mutationCaptureTimer)
|
|
748
807
|
clearTimeout(this.mutationCaptureTimer);
|
|
749
808
|
this.mutationCaptureTimer = setTimeout(() => {
|
|
750
809
|
this.mutationCaptureTimer = null;
|
|
810
|
+
this.mutationCaptureStartedAt = null;
|
|
751
811
|
void this.captureAndPublishIfChanged();
|
|
752
|
-
},
|
|
812
|
+
}, delay);
|
|
753
813
|
};
|
|
754
814
|
this.scheduleViewportCapture = () => {
|
|
755
815
|
this.scheduleMutationCapture();
|
|
@@ -822,9 +882,7 @@ class PageContextCollector {
|
|
|
822
882
|
if (this.captureTimer)
|
|
823
883
|
clearTimeout(this.captureTimer);
|
|
824
884
|
this.captureTimer = null;
|
|
825
|
-
|
|
826
|
-
clearTimeout(this.mutationCaptureTimer);
|
|
827
|
-
this.mutationCaptureTimer = null;
|
|
885
|
+
this.clearMutationCapture();
|
|
828
886
|
if (this.releasedNavigationSettlement &&
|
|
829
887
|
window.location.href !== this.releasedNavigationSettlement.fromUrl) {
|
|
830
888
|
this.finishReleasedNavigationSettlement(sameNavigationDestination(window.location.href, this.releasedNavigationSettlement.toUrl)
|
|
@@ -955,9 +1013,7 @@ class PageContextCollector {
|
|
|
955
1013
|
if (this.captureTimer)
|
|
956
1014
|
clearTimeout(this.captureTimer);
|
|
957
1015
|
this.captureTimer = null;
|
|
958
|
-
|
|
959
|
-
clearTimeout(this.mutationCaptureTimer);
|
|
960
|
-
this.mutationCaptureTimer = null;
|
|
1016
|
+
this.clearMutationCapture();
|
|
961
1017
|
window.removeEventListener('resize', this.scheduleViewportCapture);
|
|
962
1018
|
document.removeEventListener('scroll', this.scheduleViewportCapture, true);
|
|
963
1019
|
window.visualViewport?.removeEventListener('resize', this.scheduleViewportCapture);
|
|
@@ -1061,6 +1117,7 @@ class PageContextCollector {
|
|
|
1061
1117
|
}
|
|
1062
1118
|
: {}),
|
|
1063
1119
|
...(semanticRegion ? { semanticRegion } : {}),
|
|
1120
|
+
...(this.statefulButtonActionProof && isNavigationMenu(element, role) ? { navigationMenu: true } : {}),
|
|
1064
1121
|
...(destinationPath ? { destinationPath } : {}),
|
|
1065
1122
|
sensitivity,
|
|
1066
1123
|
protectedRegion: sensitivity !== 'none' || regionAssessment.matchedRuleDigests.length > 0,
|
|
@@ -1172,6 +1229,7 @@ class PageContextCollector {
|
|
|
1172
1229
|
pageId: this.pageId,
|
|
1173
1230
|
revision,
|
|
1174
1231
|
url: safePageUrl(),
|
|
1232
|
+
locationHasQuery: window.location.search !== '',
|
|
1175
1233
|
title: normalize(document.title, 512),
|
|
1176
1234
|
locale: normalize(document.documentElement.lang || navigator.language || 'en', 16),
|
|
1177
1235
|
viewport: {
|
|
@@ -1792,7 +1850,7 @@ class PageContextCollector {
|
|
|
1792
1850
|
};
|
|
1793
1851
|
window.addEventListener('click', preventNativeNavigation);
|
|
1794
1852
|
try {
|
|
1795
|
-
dispatchFramerPrimaryPointerSequence(navigation.source);
|
|
1853
|
+
dispatchFramerPrimaryPointerSequence(navigation.source, this.trustedGuideRoot);
|
|
1796
1854
|
}
|
|
1797
1855
|
finally {
|
|
1798
1856
|
window.removeEventListener('click', preventNativeNavigation);
|
|
@@ -1874,7 +1932,7 @@ class PageContextCollector {
|
|
|
1874
1932
|
if (desiredStates.length > 0 && desiredStates.every(Boolean))
|
|
1875
1933
|
return;
|
|
1876
1934
|
if (desiredChecked !== undefined && framerToggleDescriptor(element)) {
|
|
1877
|
-
dispatchFramerTogglePrimaryPointer(element);
|
|
1935
|
+
dispatchFramerTogglePrimaryPointer(element, this.trustedGuideRoot);
|
|
1878
1936
|
return;
|
|
1879
1937
|
}
|
|
1880
1938
|
if (element instanceof HTMLAnchorElement) {
|
|
@@ -2182,9 +2240,19 @@ class PageContextCollector {
|
|
|
2182
2240
|
height,
|
|
2183
2241
|
};
|
|
2184
2242
|
}
|
|
2243
|
+
clearMutationCapture() {
|
|
2244
|
+
if (this.mutationCaptureTimer)
|
|
2245
|
+
clearTimeout(this.mutationCaptureTimer);
|
|
2246
|
+
this.mutationCaptureTimer = null;
|
|
2247
|
+
this.mutationCaptureStartedAt = null;
|
|
2248
|
+
}
|
|
2185
2249
|
async captureAndPublishIfChanged() {
|
|
2186
|
-
if (this.navigationSettlement)
|
|
2250
|
+
if (!this.running || this.navigationSettlement)
|
|
2251
|
+
return;
|
|
2252
|
+
if (this.executingActionId) {
|
|
2253
|
+
this.pendingCapture = true;
|
|
2187
2254
|
return;
|
|
2255
|
+
}
|
|
2188
2256
|
const snapshot = this.captureSnapshot(false);
|
|
2189
2257
|
if (snapshot && this.running)
|
|
2190
2258
|
await this.callbacks.onSnapshot(snapshot);
|
|
@@ -2269,9 +2337,7 @@ class PageContextCollector {
|
|
|
2269
2337
|
if (this.captureTimer)
|
|
2270
2338
|
clearTimeout(this.captureTimer);
|
|
2271
2339
|
this.captureTimer = null;
|
|
2272
|
-
|
|
2273
|
-
clearTimeout(this.mutationCaptureTimer);
|
|
2274
|
-
this.mutationCaptureTimer = null;
|
|
2340
|
+
this.clearMutationCapture();
|
|
2275
2341
|
return settlement;
|
|
2276
2342
|
}
|
|
2277
2343
|
observeCausalNavigationAfterMutation(actionId, destinationPath, beforeActionUrl) {
|
package/cjs/control.d.ts
CHANGED
|
@@ -24,6 +24,10 @@ export type ControlOutboundOperation = {
|
|
|
24
24
|
type: string;
|
|
25
25
|
};
|
|
26
26
|
export type ReservedControlOperation = Readonly<ControlOutboundOperation>;
|
|
27
|
+
type TransportRecovery = {
|
|
28
|
+
canRecover: () => boolean;
|
|
29
|
+
onRecovered: () => Promise<void>;
|
|
30
|
+
};
|
|
27
31
|
export declare class ControlChannel {
|
|
28
32
|
private readonly result;
|
|
29
33
|
private readonly tabId;
|
|
@@ -31,6 +35,7 @@ export declare class ControlChannel {
|
|
|
31
35
|
private readonly onDisconnect;
|
|
32
36
|
private readonly onClientSequence;
|
|
33
37
|
private readonly onUnsafeDisconnect;
|
|
38
|
+
private readonly transportRecovery?;
|
|
34
39
|
private socket;
|
|
35
40
|
private clientSequence;
|
|
36
41
|
private serverSequence;
|
|
@@ -54,7 +59,9 @@ export declare class ControlChannel {
|
|
|
54
59
|
private pendingConnection;
|
|
55
60
|
private helloOperation;
|
|
56
61
|
private fencedAuthorityConfirmed;
|
|
57
|
-
|
|
62
|
+
private transportRecoveryAttempted;
|
|
63
|
+
private recoveringTransport;
|
|
64
|
+
constructor(result: RuntimeAuthorization, tabId: string, clientVersion: string, onDisconnect: () => void, initialClientSequence?: number, onClientSequence?: (sequence: number) => void, onUnsafeDisconnect?: () => void, transportRecovery?: TransportRecovery | undefined);
|
|
58
65
|
get leaseToken(): number;
|
|
59
66
|
get lastClientSequence(): number;
|
|
60
67
|
get isOpen(): boolean;
|
|
@@ -78,6 +85,7 @@ export declare class ControlChannel {
|
|
|
78
85
|
private handleMessage;
|
|
79
86
|
private recoverFromSequenceGap;
|
|
80
87
|
private notifyUnsafeDisconnect;
|
|
88
|
+
private recoverTransport;
|
|
81
89
|
private enqueueHandlers;
|
|
82
90
|
private dispatchHandlers;
|
|
83
91
|
private handleHandlerFailure;
|
package/cjs/control.js
CHANGED
|
@@ -6,6 +6,7 @@ const PROTOCOL_VIOLATION_CLOSE_CODE = 4008;
|
|
|
6
6
|
const SERVER_SEQUENCE_GAP_CLOSE_CODE = 4009;
|
|
7
7
|
const HANDLER_FAILURE_CLOSE_CODE = 4010;
|
|
8
8
|
const HANDLER_TIMEOUT_MS = 15000;
|
|
9
|
+
const TRANSPORT_RECOVERY_TIMEOUT_MS = 5000;
|
|
9
10
|
const MAX_PENDING_HANDLER_MESSAGES = 64;
|
|
10
11
|
const MAX_PENDING_STATEFUL_OPERATIONS = 64;
|
|
11
12
|
const MAX_SETTLED_OPERATION_HISTORY = 100;
|
|
@@ -38,13 +39,14 @@ const STATEFUL_CONTROL_TYPES = new Set([
|
|
|
38
39
|
]);
|
|
39
40
|
const outcomeKey = (messageId, sequence) => `${messageId}\u0000${sequence}`;
|
|
40
41
|
class ControlChannel {
|
|
41
|
-
constructor(result, tabId, clientVersion, onDisconnect, initialClientSequence = 0, onClientSequence = () => undefined, onUnsafeDisconnect = () => undefined) {
|
|
42
|
+
constructor(result, tabId, clientVersion, onDisconnect, initialClientSequence = 0, onClientSequence = () => undefined, onUnsafeDisconnect = () => undefined, transportRecovery) {
|
|
42
43
|
this.result = result;
|
|
43
44
|
this.tabId = tabId;
|
|
44
45
|
this.clientVersion = clientVersion;
|
|
45
46
|
this.onDisconnect = onDisconnect;
|
|
46
47
|
this.onClientSequence = onClientSequence;
|
|
47
48
|
this.onUnsafeDisconnect = onUnsafeDisconnect;
|
|
49
|
+
this.transportRecovery = transportRecovery;
|
|
48
50
|
this.socket = null;
|
|
49
51
|
this.serverSequence = 0;
|
|
50
52
|
this.serverBaselineEstablished = false;
|
|
@@ -67,6 +69,8 @@ class ControlChannel {
|
|
|
67
69
|
this.pendingConnection = null;
|
|
68
70
|
this.helloOperation = null;
|
|
69
71
|
this.fencedAuthorityConfirmed = false;
|
|
72
|
+
this.transportRecoveryAttempted = false;
|
|
73
|
+
this.recoveringTransport = false;
|
|
70
74
|
this.clientSequence =
|
|
71
75
|
Number.isSafeInteger(initialClientSequence) && initialClientSequence >= 0
|
|
72
76
|
? initialClientSequence
|
|
@@ -79,7 +83,7 @@ class ControlChannel {
|
|
|
79
83
|
return this.clientSequence;
|
|
80
84
|
}
|
|
81
85
|
get isOpen() {
|
|
82
|
-
return this.socket?.readyState === WebSocket.OPEN;
|
|
86
|
+
return (!this.recoveringTransport && this.socket?.readyState === WebSocket.OPEN);
|
|
83
87
|
}
|
|
84
88
|
get connectionHelloOperation() {
|
|
85
89
|
return this.helloOperation ? { ...this.helloOperation } : null;
|
|
@@ -180,10 +184,19 @@ class ControlChannel {
|
|
|
180
184
|
this.rejectPendingConnection(socket, generation, new Error("The AI Guide control channel could not connect."));
|
|
181
185
|
}
|
|
182
186
|
}, { once: true });
|
|
183
|
-
socket.addEventListener("close", () => {
|
|
187
|
+
socket.addEventListener("close", (event) => {
|
|
184
188
|
if (!this.isCurrentSocket(socket, generation))
|
|
185
189
|
return;
|
|
186
190
|
const wasIntentionallyClosed = this.intentionallyClosed;
|
|
191
|
+
// A settled transport loss is not a new session. Keep its provider
|
|
192
|
+
// connection alive for one bounded retry, but never carry ambiguous
|
|
193
|
+
// browser writes, an active handler, or a protocol failure across it.
|
|
194
|
+
const canRecover = event.code === 1006 &&
|
|
195
|
+
this.fencedAuthorityConfirmed &&
|
|
196
|
+
!this.transportRecoveryAttempted &&
|
|
197
|
+
!this.sequenceGapDetected &&
|
|
198
|
+
this.pendingHandlerMessages === 0 &&
|
|
199
|
+
this.unsettledStatefulOperations.size === 0;
|
|
187
200
|
this.socket = null;
|
|
188
201
|
this.socketGeneration += 1;
|
|
189
202
|
this.stopHeartbeat();
|
|
@@ -197,12 +210,24 @@ class ControlChannel {
|
|
|
197
210
|
this.pendingHandlerMessages = 0;
|
|
198
211
|
this.rejectPendingConnection(socket, generation, new Error("The AI Guide control channel closed before connecting."));
|
|
199
212
|
if (!wasIntentionallyClosed && !signal.aborted) {
|
|
213
|
+
try {
|
|
214
|
+
if (canRecover && this.transportRecovery?.canRecover()) {
|
|
215
|
+
void this.recoverTransport(signal);
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
catch {
|
|
220
|
+
/* A failed eligibility check uses full recovery. */
|
|
221
|
+
}
|
|
200
222
|
this.notifyUnsafeDisconnect();
|
|
201
223
|
}
|
|
202
224
|
});
|
|
203
225
|
});
|
|
204
226
|
}
|
|
205
227
|
send(type, payload, stableMessageId) {
|
|
228
|
+
if (this.recoveringTransport && type !== "hello" && type !== "heartbeat") {
|
|
229
|
+
throw new Error("The AI Guide control channel is reconnecting.");
|
|
230
|
+
}
|
|
206
231
|
if (!this.socket || this.socket.readyState !== WebSocket.OPEN) {
|
|
207
232
|
throw new Error("The AI Guide control channel is not connected.");
|
|
208
233
|
}
|
|
@@ -513,6 +538,59 @@ class ControlChannel {
|
|
|
513
538
|
this.onDisconnect();
|
|
514
539
|
}
|
|
515
540
|
}
|
|
541
|
+
async recoverTransport(signal) {
|
|
542
|
+
this.transportRecoveryAttempted = true;
|
|
543
|
+
this.recoveringTransport = true;
|
|
544
|
+
const controller = new AbortController();
|
|
545
|
+
const abort = () => controller.abort();
|
|
546
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
547
|
+
let timeout;
|
|
548
|
+
const deadline = new Promise((_resolve, reject) => {
|
|
549
|
+
timeout = setTimeout(() => {
|
|
550
|
+
controller.abort();
|
|
551
|
+
reject(new Error("The control reconnect timed out."));
|
|
552
|
+
}, TRANSPORT_RECOVERY_TIMEOUT_MS);
|
|
553
|
+
});
|
|
554
|
+
const fencingToken = this.fencingToken;
|
|
555
|
+
let recovered = false;
|
|
556
|
+
try {
|
|
557
|
+
await Promise.race([
|
|
558
|
+
deadline,
|
|
559
|
+
(async () => {
|
|
560
|
+
if (signal.aborted)
|
|
561
|
+
throw new Error("The control connection was retired.");
|
|
562
|
+
await this.connect(controller.signal, true);
|
|
563
|
+
const hello = await this.waitForHelloOutcome(2000);
|
|
564
|
+
if (hello.status !== "ack" || controller.signal.aborted) {
|
|
565
|
+
throw new Error("The control reconnect was not acknowledged.");
|
|
566
|
+
}
|
|
567
|
+
const authority = await this.proveFencedAuthority(2000);
|
|
568
|
+
if (authority.status !== "ack" ||
|
|
569
|
+
controller.signal.aborted ||
|
|
570
|
+
this.fencingToken !== fencingToken ||
|
|
571
|
+
!this.transportRecovery?.canRecover()) {
|
|
572
|
+
throw new Error("The control reconnect no longer owns this session.");
|
|
573
|
+
}
|
|
574
|
+
this.recoveringTransport = false;
|
|
575
|
+
await this.transportRecovery.onRecovered();
|
|
576
|
+
recovered = !controller.signal.aborted && this.hasFencedAuthority;
|
|
577
|
+
})(),
|
|
578
|
+
]);
|
|
579
|
+
}
|
|
580
|
+
catch {
|
|
581
|
+
// The existing full-resume path revokes local executable authority and
|
|
582
|
+
// handles expired credentials, changed ownership, and longer outages.
|
|
583
|
+
}
|
|
584
|
+
finally {
|
|
585
|
+
clearTimeout(timeout);
|
|
586
|
+
signal.removeEventListener("abort", abort);
|
|
587
|
+
this.recoveringTransport = false;
|
|
588
|
+
if (!recovered && !signal.aborted && !this.intentionallyClosed) {
|
|
589
|
+
this.retireSocket("control reconnect failed");
|
|
590
|
+
this.notifyUnsafeDisconnect();
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
}
|
|
516
594
|
enqueueHandlers(message, socket, generation) {
|
|
517
595
|
const isCurrent = () => this.isMessageSourceCurrent(socket, generation);
|
|
518
596
|
if (!isCurrent())
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type LiveDuplexMediaOptions, type LiveDuplexMediaDependencies } from './liveDuplexMedia.js';
|
|
2
|
-
export declare const LIVE_CONTROL_CLIENT_VERSION = "7.5.
|
|
2
|
+
export declare const LIVE_CONTROL_CLIENT_VERSION = "7.5.6";
|
|
3
3
|
export declare const LIVE_CONTROL_CLIENT_CAPABILITIES: readonly ["live_duplex_v1", "live_local_wake_v1", "live_fragment_captions_v1"];
|
|
4
4
|
declare const phases: readonly ["voice_off", "local_listening", "wake_buffering", "starting", "active", "tentative_idle", "closing", "uncertain", "muted", "suspended", "budget_blocked", "ended"];
|
|
5
5
|
export type LiveControlState = {
|