@jobshimo/browser-link 0.23.7 → 0.23.9
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/agent-instructions/content.js +17 -7
- package/dist/agent-instructions/content.js.map +1 -1
- package/dist/cdp/flow.d.ts +67 -10
- package/dist/cdp/flow.js +51 -20
- package/dist/cdp/flow.js.map +1 -1
- package/dist/cdp/transport.js +5 -0
- package/dist/cdp/transport.js.map +1 -1
- package/dist/commands/about.js +2 -2
- package/dist/commands/about.js.map +1 -1
- package/dist/extension/background.js +485 -282
- package/dist/extension/background.js.map +1 -1
- package/dist/extension/flow.d.ts +67 -10
- package/dist/extension/flow.js +51 -20
- package/dist/extension/flow.js.map +1 -1
- package/dist/extension/manifest.json +1 -1
- package/dist/extension/popup.js +59 -0
- package/dist/extension/popup.js.map +1 -1
- package/dist/extension/reconnect-policy.d.ts +137 -0
- package/dist/extension/reconnect-policy.js +169 -0
- package/dist/extension/reconnect-policy.js.map +1 -0
- package/dist/map/tools.js +2 -2
- package/dist/map/tools.js.map +1 -1
- package/dist/permissions.js +1 -1
- package/dist/permissions.js.map +1 -1
- package/dist/tools/browser-definitions.js +33 -5
- package/dist/tools/browser-definitions.js.map +1 -1
- package/dist/tools/browser-dispatch.js +62 -2
- package/dist/tools/browser-dispatch.js.map +1 -1
- package/dist/tools/server-instructions.js +10 -4
- package/dist/tools/server-instructions.js.map +1 -1
- package/package.json +1 -1
|
@@ -6,6 +6,7 @@ import { DEFAULT_IDLE_TTL_MINUTES, IDLE_TTL_STORAGE_KEY, IDLE_TTL_UPDATED_AT_STO
|
|
|
6
6
|
import { DEFAULT_FLOW_RECORDING_ENABLED, FLOW_RECORDING_STORAGE_KEY, FLOW_RECORDING_UPDATED_AT_STORAGE_KEY, normalizeFlowRecordingEnabled, parseIncomingFlowRecordingSettings, } from './flow-recording-policy.js';
|
|
7
7
|
import { buildRecorderJs, buildStopRecorderJs } from './inpage/recorder.js';
|
|
8
8
|
import { DISCARDED_WHILE_SAVING_MESSAGE, appendRecordingStep, buildAmbiguousNote, buildNavigationWaitForStep, generateRecordingSession, isNavigationForRecording, isSameRecordingSession, parseRecordedPayload, toFlowStep, } from './recording.js';
|
|
9
|
+
import { CONNECT_IN_PROGRESS_ERROR, createReconnectScheduler, isInvoluntaryClose, isReconnectStateFresh, parseStoredReconnectState, reconnectStorageKey, tabIdFromReconnectKey, } from './reconnect-policy.js';
|
|
9
10
|
const WS_URL = 'ws://127.0.0.1:17529';
|
|
10
11
|
const CDP_VERSION = '1.3';
|
|
11
12
|
const CONSOLE_BUFFER_MAX = 200;
|
|
@@ -631,8 +632,9 @@ async function waitForLoad(tabId, timeoutMs = 20_000) {
|
|
|
631
632
|
}
|
|
632
633
|
/**
|
|
633
634
|
* `performFind` / `performClick` / `performType` / `performPress` /
|
|
634
|
-
* `performWaitFor` — the extracted bodies of the
|
|
635
|
-
* `click` / `type` / `press` / `wait_for`
|
|
635
|
+
* `performWaitFor` / `performDrag` — the extracted bodies of the
|
|
636
|
+
* standalone `find` / `click` / `type` / `press` / `wait_for` / `drag`
|
|
637
|
+
* cases in `handleTool` below.
|
|
636
638
|
*
|
|
637
639
|
* Both the standalone `case` handlers AND `browser.flow`'s step executor
|
|
638
640
|
* (`runFlow` in `./flow.js`) call these exact functions — there is one
|
|
@@ -864,6 +866,279 @@ async function performWaitFor(tabId, state, params) {
|
|
|
864
866
|
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
|
865
867
|
}
|
|
866
868
|
}
|
|
869
|
+
async function performDrag(tabId, params) {
|
|
870
|
+
try {
|
|
871
|
+
// Same numeric guard the wire boundary used before this body was
|
|
872
|
+
// extracted out of `handleDrag`: anything non-finite or negative is
|
|
873
|
+
// treated as absent, so flow drag steps get identical protection.
|
|
874
|
+
const num = (v) => typeof v === 'number' && Number.isFinite(v) && v >= 0 ? v : undefined;
|
|
875
|
+
const clamp = (v, max) => Math.min(v, max);
|
|
876
|
+
const fromSelector = params.from_selector;
|
|
877
|
+
const toSelector = params.to_selector;
|
|
878
|
+
const fromXRaw = num(params.from_x);
|
|
879
|
+
const fromYRaw = num(params.from_y);
|
|
880
|
+
const toXRaw = num(params.to_x);
|
|
881
|
+
const toYRaw = num(params.to_y);
|
|
882
|
+
// Cap every duration that ends up in a setTimeout to keep CodeQL's
|
|
883
|
+
// "user-controlled timer duration" check happy and to prevent a
|
|
884
|
+
// misbehaving agent from parking the bridge for hours on a typo.
|
|
885
|
+
const durationMs = clamp(num(params.duration_ms) ?? 1500, MAX_DRAG_DURATION_MS);
|
|
886
|
+
const holdBeforeMoveMs = clamp(num(params.hold_before_move_ms) ?? 0, MAX_DRAG_HOLD_MS);
|
|
887
|
+
const holdBeforeReleaseMs = clamp(num(params.hold_before_release_ms) ?? 0, MAX_DRAG_HOLD_MS);
|
|
888
|
+
const hasFromCoords = fromXRaw !== undefined && fromYRaw !== undefined;
|
|
889
|
+
const hasToCoords = toXRaw !== undefined && toYRaw !== undefined;
|
|
890
|
+
if (!fromSelector && !hasFromCoords) {
|
|
891
|
+
return { ok: false, error: 'drag: provide from_selector or both from_x and from_y' };
|
|
892
|
+
}
|
|
893
|
+
if (!toSelector && !hasToCoords) {
|
|
894
|
+
return { ok: false, error: 'drag: provide to_selector or both to_x and to_y' };
|
|
895
|
+
}
|
|
896
|
+
const probeExpr = buildDragProbeJs({
|
|
897
|
+
from_selector: fromSelector,
|
|
898
|
+
to_selector: toSelector,
|
|
899
|
+
from_x: fromXRaw,
|
|
900
|
+
from_y: fromYRaw,
|
|
901
|
+
to_x: toXRaw,
|
|
902
|
+
to_y: toYRaw,
|
|
903
|
+
});
|
|
904
|
+
const probe = await evaluateInTab(tabId, probeExpr);
|
|
905
|
+
if (probe.err) {
|
|
906
|
+
return { ok: false, error: `drag: ${probe.err}` };
|
|
907
|
+
}
|
|
908
|
+
if (!probe.from || !probe.to) {
|
|
909
|
+
return { ok: false, error: 'drag: could not resolve coordinates' };
|
|
910
|
+
}
|
|
911
|
+
if (!probe.from.in_viewport) {
|
|
912
|
+
return {
|
|
913
|
+
ok: false,
|
|
914
|
+
error: 'drag: source point is offscreen — scroll first or pass viewport coords',
|
|
915
|
+
};
|
|
916
|
+
}
|
|
917
|
+
if (!probe.to.in_viewport) {
|
|
918
|
+
return {
|
|
919
|
+
ok: false,
|
|
920
|
+
error: 'drag: destination point is offscreen — scroll first or pass viewport coords',
|
|
921
|
+
};
|
|
922
|
+
}
|
|
923
|
+
const fromX = probe.from.x;
|
|
924
|
+
const fromY = probe.from.y;
|
|
925
|
+
const toX = probe.to.x;
|
|
926
|
+
const toY = probe.to.y;
|
|
927
|
+
const isDraggable = !!probe.draggable;
|
|
928
|
+
// ~30fps interpolation; minimum 2 steps so the path actually has a midpoint.
|
|
929
|
+
const steps = durationMs > 0 ? Math.max(2, Math.round(durationMs / 33)) : 1;
|
|
930
|
+
const stepDelayMs = steps > 0 ? durationMs / steps : 0;
|
|
931
|
+
const eventsFired = [];
|
|
932
|
+
// Every branch below reassigns `dragMode` before the final `return`
|
|
933
|
+
// reads it. (The old `case`-block extraction needed a
|
|
934
|
+
// no-useless-assignment disable here; inside this try/catch scope the
|
|
935
|
+
// linter no longer flags the initializer, so the directive is gone.)
|
|
936
|
+
let dragMode = 'pointer';
|
|
937
|
+
let interceptReceived = false;
|
|
938
|
+
const dragStart = Date.now();
|
|
939
|
+
let interceptionEnabled = false;
|
|
940
|
+
const interpolate = (t) => ({
|
|
941
|
+
x: fromX + (toX - fromX) * t,
|
|
942
|
+
y: fromY + (toY - fromY) * t,
|
|
943
|
+
});
|
|
944
|
+
// Mouse move WITH the left button held down. Chrome's HTML5 drag
|
|
945
|
+
// system only treats movement as a drag when the button state is
|
|
946
|
+
// signalled on every move after mousePressed; omitting it makes
|
|
947
|
+
// Blink ignore the move for drag-detection purposes.
|
|
948
|
+
const moveHeld = (x, y) => cdp(tabId, 'Input.dispatchMouseEvent', {
|
|
949
|
+
type: 'mouseMoved',
|
|
950
|
+
x,
|
|
951
|
+
y,
|
|
952
|
+
button: 'left',
|
|
953
|
+
buttons: 1,
|
|
954
|
+
});
|
|
955
|
+
// Wiggle distance has to comfortably clear `kDragThreshold` (~5px on
|
|
956
|
+
// Mac, 4px on Linux/Windows). 5px landed *right* at the boundary and
|
|
957
|
+
// Chrome did not start the drag — 20px crosses it on every platform.
|
|
958
|
+
const WIGGLE_PX = 20;
|
|
959
|
+
// Time to wait for Input.dragIntercepted after the wiggle. 120ms was
|
|
960
|
+
// enough on local-only synthetic tests but flaky in real Chrome —
|
|
961
|
+
// 250ms is comfortable without dragging out the overall latency.
|
|
962
|
+
const INTERCEPT_TIMEOUT_MS = 250;
|
|
963
|
+
try {
|
|
964
|
+
if (isDraggable) {
|
|
965
|
+
try {
|
|
966
|
+
await cdp(tabId, 'Input.setInterceptDrags', { enabled: true });
|
|
967
|
+
interceptionEnabled = true;
|
|
968
|
+
}
|
|
969
|
+
catch {
|
|
970
|
+
// setInterceptDrags is experimental — fall back to pointer mode silently.
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
if (interceptionEnabled) {
|
|
974
|
+
// Arm the listener BEFORE the press+wiggle that may trigger it.
|
|
975
|
+
const interceptPromise = waitForCdpEvent(tabId, 'Input.dragIntercepted', INTERCEPT_TIMEOUT_MS);
|
|
976
|
+
await cdp(tabId, 'Input.dispatchMouseEvent', {
|
|
977
|
+
type: 'mouseMoved',
|
|
978
|
+
x: fromX,
|
|
979
|
+
y: fromY,
|
|
980
|
+
});
|
|
981
|
+
await cdp(tabId, 'Input.dispatchMouseEvent', {
|
|
982
|
+
type: 'mousePressed',
|
|
983
|
+
x: fromX,
|
|
984
|
+
y: fromY,
|
|
985
|
+
button: 'left',
|
|
986
|
+
clickCount: 1,
|
|
987
|
+
});
|
|
988
|
+
if (holdBeforeMoveMs > 0)
|
|
989
|
+
await sleep(holdBeforeMoveMs);
|
|
990
|
+
// Wiggle toward the destination so Chrome's native drag system
|
|
991
|
+
// crosses its activation threshold. Direction matters for libs
|
|
992
|
+
// that have direction-sensitive activation constraints.
|
|
993
|
+
const dx = toX - fromX;
|
|
994
|
+
const dy = toY - fromY;
|
|
995
|
+
const len = Math.hypot(dx, dy) || 1;
|
|
996
|
+
const wx = fromX + (dx / len) * WIGGLE_PX;
|
|
997
|
+
const wy = fromY + (dy / len) * WIGGLE_PX;
|
|
998
|
+
await moveHeld(wx, wy);
|
|
999
|
+
const intercepted = await interceptPromise;
|
|
1000
|
+
if (intercepted) {
|
|
1001
|
+
dragMode = 'html5';
|
|
1002
|
+
interceptReceived = true;
|
|
1003
|
+
eventsFired.push('Input.dragIntercepted');
|
|
1004
|
+
const dragData = intercepted.data;
|
|
1005
|
+
for (let i = 1; i <= steps; i++) {
|
|
1006
|
+
const t = i / steps;
|
|
1007
|
+
const { x, y } = interpolate(t);
|
|
1008
|
+
await moveHeld(x, y);
|
|
1009
|
+
await cdp(tabId, 'Input.dispatchDragEvent', {
|
|
1010
|
+
type: 'dragOver',
|
|
1011
|
+
x,
|
|
1012
|
+
y,
|
|
1013
|
+
data: dragData,
|
|
1014
|
+
});
|
|
1015
|
+
if (i === 1)
|
|
1016
|
+
eventsFired.push('dragOver');
|
|
1017
|
+
if (stepDelayMs > 0)
|
|
1018
|
+
await sleep(stepDelayMs);
|
|
1019
|
+
}
|
|
1020
|
+
await cdp(tabId, 'Input.dispatchDragEvent', {
|
|
1021
|
+
type: 'dragEnter',
|
|
1022
|
+
x: toX,
|
|
1023
|
+
y: toY,
|
|
1024
|
+
data: dragData,
|
|
1025
|
+
});
|
|
1026
|
+
eventsFired.push('dragEnter');
|
|
1027
|
+
await cdp(tabId, 'Input.dispatchDragEvent', {
|
|
1028
|
+
type: 'dragOver',
|
|
1029
|
+
x: toX,
|
|
1030
|
+
y: toY,
|
|
1031
|
+
data: dragData,
|
|
1032
|
+
});
|
|
1033
|
+
if (holdBeforeReleaseMs > 0)
|
|
1034
|
+
await sleep(holdBeforeReleaseMs);
|
|
1035
|
+
await cdp(tabId, 'Input.dispatchDragEvent', {
|
|
1036
|
+
type: 'drop',
|
|
1037
|
+
x: toX,
|
|
1038
|
+
y: toY,
|
|
1039
|
+
data: dragData,
|
|
1040
|
+
});
|
|
1041
|
+
eventsFired.push('drop');
|
|
1042
|
+
await cdp(tabId, 'Input.dispatchMouseEvent', {
|
|
1043
|
+
type: 'mouseReleased',
|
|
1044
|
+
x: toX,
|
|
1045
|
+
y: toY,
|
|
1046
|
+
button: 'left',
|
|
1047
|
+
clickCount: 1,
|
|
1048
|
+
});
|
|
1049
|
+
}
|
|
1050
|
+
else {
|
|
1051
|
+
// Element was tagged draggable but no native drag fired — the page
|
|
1052
|
+
// either preventDefault'd dragstart or wires its own pointer logic.
|
|
1053
|
+
// Continue with pointer-only events from where we already pressed.
|
|
1054
|
+
dragMode = 'pointer';
|
|
1055
|
+
for (let i = 1; i <= steps; i++) {
|
|
1056
|
+
const t = i / steps;
|
|
1057
|
+
const { x, y } = interpolate(t);
|
|
1058
|
+
await moveHeld(x, y);
|
|
1059
|
+
if (stepDelayMs > 0)
|
|
1060
|
+
await sleep(stepDelayMs);
|
|
1061
|
+
}
|
|
1062
|
+
if (holdBeforeReleaseMs > 0)
|
|
1063
|
+
await sleep(holdBeforeReleaseMs);
|
|
1064
|
+
await cdp(tabId, 'Input.dispatchMouseEvent', {
|
|
1065
|
+
type: 'mouseReleased',
|
|
1066
|
+
x: toX,
|
|
1067
|
+
y: toY,
|
|
1068
|
+
button: 'left',
|
|
1069
|
+
clickCount: 1,
|
|
1070
|
+
});
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
1073
|
+
else {
|
|
1074
|
+
// Pointer-only branch: no native drag involvement at all.
|
|
1075
|
+
dragMode = 'pointer';
|
|
1076
|
+
await cdp(tabId, 'Input.dispatchMouseEvent', {
|
|
1077
|
+
type: 'mouseMoved',
|
|
1078
|
+
x: fromX,
|
|
1079
|
+
y: fromY,
|
|
1080
|
+
});
|
|
1081
|
+
await cdp(tabId, 'Input.dispatchMouseEvent', {
|
|
1082
|
+
type: 'mousePressed',
|
|
1083
|
+
x: fromX,
|
|
1084
|
+
y: fromY,
|
|
1085
|
+
button: 'left',
|
|
1086
|
+
clickCount: 1,
|
|
1087
|
+
});
|
|
1088
|
+
if (holdBeforeMoveMs > 0)
|
|
1089
|
+
await sleep(holdBeforeMoveMs);
|
|
1090
|
+
for (let i = 1; i <= steps; i++) {
|
|
1091
|
+
const t = i / steps;
|
|
1092
|
+
const { x, y } = interpolate(t);
|
|
1093
|
+
await moveHeld(x, y);
|
|
1094
|
+
if (stepDelayMs > 0)
|
|
1095
|
+
await sleep(stepDelayMs);
|
|
1096
|
+
}
|
|
1097
|
+
if (holdBeforeReleaseMs > 0)
|
|
1098
|
+
await sleep(holdBeforeReleaseMs);
|
|
1099
|
+
await cdp(tabId, 'Input.dispatchMouseEvent', {
|
|
1100
|
+
type: 'mouseReleased',
|
|
1101
|
+
x: toX,
|
|
1102
|
+
y: toY,
|
|
1103
|
+
button: 'left',
|
|
1104
|
+
clickCount: 1,
|
|
1105
|
+
});
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
finally {
|
|
1109
|
+
// Critical: leaving interception on would block the human user from
|
|
1110
|
+
// dragging anything in this tab. Best-effort cleanup, swallow errors.
|
|
1111
|
+
if (interceptionEnabled) {
|
|
1112
|
+
try {
|
|
1113
|
+
await cdp(tabId, 'Input.setInterceptDrags', { enabled: false });
|
|
1114
|
+
}
|
|
1115
|
+
catch {
|
|
1116
|
+
// ignore
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
return {
|
|
1121
|
+
ok: true,
|
|
1122
|
+
result: {
|
|
1123
|
+
from: { x: fromX, y: fromY, selector: fromSelector ?? null },
|
|
1124
|
+
to: { x: toX, y: toY, selector: toSelector ?? null },
|
|
1125
|
+
duration_ms_actual: Date.now() - dragStart,
|
|
1126
|
+
drag_mode: dragMode,
|
|
1127
|
+
interception_attempted: interceptionEnabled,
|
|
1128
|
+
intercept_received: interceptReceived,
|
|
1129
|
+
events_fired: eventsFired,
|
|
1130
|
+
},
|
|
1131
|
+
};
|
|
1132
|
+
}
|
|
1133
|
+
catch (err) {
|
|
1134
|
+
// The pre-extraction `case 'drag'` let thrown errors bubble to
|
|
1135
|
+
// `handleTool`'s outer catch, which produced this exact same
|
|
1136
|
+
// `err.message` string — converting here instead keeps the standalone
|
|
1137
|
+
// wire behavior identical while giving `runFlow` the `ActionOutcome`
|
|
1138
|
+
// shape it expects from every other perform* function.
|
|
1139
|
+
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
|
1140
|
+
}
|
|
1141
|
+
}
|
|
867
1142
|
/**
|
|
868
1143
|
* `handlePing` through `handleSetPermission` — the extracted bodies of the
|
|
869
1144
|
* remaining standalone tool cases in `handleTool` below that never got the
|
|
@@ -960,6 +1235,7 @@ async function handleFlow(tabId, state, msg, p) {
|
|
|
960
1235
|
performType: (params) => performType(tabId, params),
|
|
961
1236
|
performPress: (params) => performPress(tabId, params),
|
|
962
1237
|
performWaitFor: (params) => performWaitFor(tabId, state, params),
|
|
1238
|
+
performDrag: (params) => performDrag(tabId, params),
|
|
963
1239
|
buildRecoverySnapshot: () => evaluateInTab(tabId, buildSnapshotJs({ only_interactive: true, max_interactive: 40 })),
|
|
964
1240
|
});
|
|
965
1241
|
// The wire-level response is ok:true whenever the flow RAN (even a
|
|
@@ -969,285 +1245,29 @@ async function handleFlow(tabId, state, msg, p) {
|
|
|
969
1245
|
return { kind: 'tool.response', id: msg.id, ok: true, result: flowResult };
|
|
970
1246
|
}
|
|
971
1247
|
async function handleDrag(tabId, msg, p, optStr) {
|
|
1248
|
+
// Thin wire wrapper over performDrag — the same pattern the standalone
|
|
1249
|
+
// click/type/press/wait_for cases use over their perform* functions.
|
|
1250
|
+
// Numbers pass through with a plain typeof check; performDrag applies
|
|
1251
|
+
// the full finite/non-negative guard and the duration/hold clamps.
|
|
972
1252
|
const optNum = (key) => {
|
|
973
1253
|
const v = p[key];
|
|
974
|
-
return typeof v === 'number'
|
|
1254
|
+
return typeof v === 'number' ? v : undefined;
|
|
975
1255
|
};
|
|
976
|
-
const
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
const durationMs = clamp(optNum('duration_ms') ?? 1500, MAX_DRAG_DURATION_MS);
|
|
987
|
-
const holdBeforeMoveMs = clamp(optNum('hold_before_move_ms') ?? 0, MAX_DRAG_HOLD_MS);
|
|
988
|
-
const holdBeforeReleaseMs = clamp(optNum('hold_before_release_ms') ?? 0, MAX_DRAG_HOLD_MS);
|
|
989
|
-
const hasFromCoords = fromXRaw !== undefined && fromYRaw !== undefined;
|
|
990
|
-
const hasToCoords = toXRaw !== undefined && toYRaw !== undefined;
|
|
991
|
-
if (!fromSelector && !hasFromCoords) {
|
|
992
|
-
return {
|
|
993
|
-
kind: 'tool.response',
|
|
994
|
-
id: msg.id,
|
|
995
|
-
ok: false,
|
|
996
|
-
error: 'drag: provide from_selector or both from_x and from_y',
|
|
997
|
-
};
|
|
998
|
-
}
|
|
999
|
-
if (!toSelector && !hasToCoords) {
|
|
1000
|
-
return {
|
|
1001
|
-
kind: 'tool.response',
|
|
1002
|
-
id: msg.id,
|
|
1003
|
-
ok: false,
|
|
1004
|
-
error: 'drag: provide to_selector or both to_x and to_y',
|
|
1005
|
-
};
|
|
1006
|
-
}
|
|
1007
|
-
const probeExpr = buildDragProbeJs({
|
|
1008
|
-
from_selector: fromSelector,
|
|
1009
|
-
to_selector: toSelector,
|
|
1010
|
-
from_x: fromXRaw,
|
|
1011
|
-
from_y: fromYRaw,
|
|
1012
|
-
to_x: toXRaw,
|
|
1013
|
-
to_y: toYRaw,
|
|
1014
|
-
});
|
|
1015
|
-
const probe = await evaluateInTab(tabId, probeExpr);
|
|
1016
|
-
if (probe.err) {
|
|
1017
|
-
return { kind: 'tool.response', id: msg.id, ok: false, error: `drag: ${probe.err}` };
|
|
1018
|
-
}
|
|
1019
|
-
if (!probe.from || !probe.to) {
|
|
1020
|
-
return {
|
|
1021
|
-
kind: 'tool.response',
|
|
1022
|
-
id: msg.id,
|
|
1023
|
-
ok: false,
|
|
1024
|
-
error: 'drag: could not resolve coordinates',
|
|
1025
|
-
};
|
|
1026
|
-
}
|
|
1027
|
-
if (!probe.from.in_viewport) {
|
|
1028
|
-
return {
|
|
1029
|
-
kind: 'tool.response',
|
|
1030
|
-
id: msg.id,
|
|
1031
|
-
ok: false,
|
|
1032
|
-
error: 'drag: source point is offscreen — scroll first or pass viewport coords',
|
|
1033
|
-
};
|
|
1034
|
-
}
|
|
1035
|
-
if (!probe.to.in_viewport) {
|
|
1036
|
-
return {
|
|
1037
|
-
kind: 'tool.response',
|
|
1038
|
-
id: msg.id,
|
|
1039
|
-
ok: false,
|
|
1040
|
-
error: 'drag: destination point is offscreen — scroll first or pass viewport coords',
|
|
1041
|
-
};
|
|
1042
|
-
}
|
|
1043
|
-
const fromX = probe.from.x;
|
|
1044
|
-
const fromY = probe.from.y;
|
|
1045
|
-
const toX = probe.to.x;
|
|
1046
|
-
const toY = probe.to.y;
|
|
1047
|
-
const isDraggable = !!probe.draggable;
|
|
1048
|
-
// ~30fps interpolation; minimum 2 steps so the path actually has a midpoint.
|
|
1049
|
-
const steps = durationMs > 0 ? Math.max(2, Math.round(durationMs / 33)) : 1;
|
|
1050
|
-
const stepDelayMs = steps > 0 ? durationMs / steps : 0;
|
|
1051
|
-
const eventsFired = [];
|
|
1052
|
-
// Every branch below reassigns `dragMode` before the final `return` reads it.
|
|
1053
|
-
let dragMode = 'pointer'; // eslint-disable-line no-useless-assignment -- see comment above
|
|
1054
|
-
let interceptReceived = false;
|
|
1055
|
-
const dragStart = Date.now();
|
|
1056
|
-
let interceptionEnabled = false;
|
|
1057
|
-
const interpolate = (t) => ({
|
|
1058
|
-
x: fromX + (toX - fromX) * t,
|
|
1059
|
-
y: fromY + (toY - fromY) * t,
|
|
1256
|
+
const outcome = await performDrag(tabId, {
|
|
1257
|
+
from_selector: optStr('from_selector'),
|
|
1258
|
+
from_x: optNum('from_x'),
|
|
1259
|
+
from_y: optNum('from_y'),
|
|
1260
|
+
to_selector: optStr('to_selector'),
|
|
1261
|
+
to_x: optNum('to_x'),
|
|
1262
|
+
to_y: optNum('to_y'),
|
|
1263
|
+
duration_ms: optNum('duration_ms'),
|
|
1264
|
+
hold_before_move_ms: optNum('hold_before_move_ms'),
|
|
1265
|
+
hold_before_release_ms: optNum('hold_before_release_ms'),
|
|
1060
1266
|
});
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
// signalled on every move after mousePressed; omitting it makes
|
|
1064
|
-
// Blink ignore the move for drag-detection purposes.
|
|
1065
|
-
const moveHeld = (x, y) => cdp(tabId, 'Input.dispatchMouseEvent', {
|
|
1066
|
-
type: 'mouseMoved',
|
|
1067
|
-
x,
|
|
1068
|
-
y,
|
|
1069
|
-
button: 'left',
|
|
1070
|
-
buttons: 1,
|
|
1071
|
-
});
|
|
1072
|
-
// Wiggle distance has to comfortably clear `kDragThreshold` (~5px on
|
|
1073
|
-
// Mac, 4px on Linux/Windows). 5px landed *right* at the boundary and
|
|
1074
|
-
// Chrome did not start the drag — 20px crosses it on every platform.
|
|
1075
|
-
const WIGGLE_PX = 20;
|
|
1076
|
-
// Time to wait for Input.dragIntercepted after the wiggle. 120ms was
|
|
1077
|
-
// enough on local-only synthetic tests but flaky in real Chrome —
|
|
1078
|
-
// 250ms is comfortable without dragging out the overall latency.
|
|
1079
|
-
const INTERCEPT_TIMEOUT_MS = 250;
|
|
1080
|
-
try {
|
|
1081
|
-
if (isDraggable) {
|
|
1082
|
-
try {
|
|
1083
|
-
await cdp(tabId, 'Input.setInterceptDrags', { enabled: true });
|
|
1084
|
-
interceptionEnabled = true;
|
|
1085
|
-
}
|
|
1086
|
-
catch {
|
|
1087
|
-
// setInterceptDrags is experimental — fall back to pointer mode silently.
|
|
1088
|
-
}
|
|
1089
|
-
}
|
|
1090
|
-
if (interceptionEnabled) {
|
|
1091
|
-
// Arm the listener BEFORE the press+wiggle that may trigger it.
|
|
1092
|
-
const interceptPromise = waitForCdpEvent(tabId, 'Input.dragIntercepted', INTERCEPT_TIMEOUT_MS);
|
|
1093
|
-
await cdp(tabId, 'Input.dispatchMouseEvent', {
|
|
1094
|
-
type: 'mouseMoved',
|
|
1095
|
-
x: fromX,
|
|
1096
|
-
y: fromY,
|
|
1097
|
-
});
|
|
1098
|
-
await cdp(tabId, 'Input.dispatchMouseEvent', {
|
|
1099
|
-
type: 'mousePressed',
|
|
1100
|
-
x: fromX,
|
|
1101
|
-
y: fromY,
|
|
1102
|
-
button: 'left',
|
|
1103
|
-
clickCount: 1,
|
|
1104
|
-
});
|
|
1105
|
-
if (holdBeforeMoveMs > 0)
|
|
1106
|
-
await sleep(holdBeforeMoveMs);
|
|
1107
|
-
// Wiggle toward the destination so Chrome's native drag system
|
|
1108
|
-
// crosses its activation threshold. Direction matters for libs
|
|
1109
|
-
// that have direction-sensitive activation constraints.
|
|
1110
|
-
const dx = toX - fromX;
|
|
1111
|
-
const dy = toY - fromY;
|
|
1112
|
-
const len = Math.hypot(dx, dy) || 1;
|
|
1113
|
-
const wx = fromX + (dx / len) * WIGGLE_PX;
|
|
1114
|
-
const wy = fromY + (dy / len) * WIGGLE_PX;
|
|
1115
|
-
await moveHeld(wx, wy);
|
|
1116
|
-
const intercepted = await interceptPromise;
|
|
1117
|
-
if (intercepted) {
|
|
1118
|
-
dragMode = 'html5';
|
|
1119
|
-
interceptReceived = true;
|
|
1120
|
-
eventsFired.push('Input.dragIntercepted');
|
|
1121
|
-
const dragData = intercepted.data;
|
|
1122
|
-
for (let i = 1; i <= steps; i++) {
|
|
1123
|
-
const t = i / steps;
|
|
1124
|
-
const { x, y } = interpolate(t);
|
|
1125
|
-
await moveHeld(x, y);
|
|
1126
|
-
await cdp(tabId, 'Input.dispatchDragEvent', {
|
|
1127
|
-
type: 'dragOver',
|
|
1128
|
-
x,
|
|
1129
|
-
y,
|
|
1130
|
-
data: dragData,
|
|
1131
|
-
});
|
|
1132
|
-
if (i === 1)
|
|
1133
|
-
eventsFired.push('dragOver');
|
|
1134
|
-
if (stepDelayMs > 0)
|
|
1135
|
-
await sleep(stepDelayMs);
|
|
1136
|
-
}
|
|
1137
|
-
await cdp(tabId, 'Input.dispatchDragEvent', {
|
|
1138
|
-
type: 'dragEnter',
|
|
1139
|
-
x: toX,
|
|
1140
|
-
y: toY,
|
|
1141
|
-
data: dragData,
|
|
1142
|
-
});
|
|
1143
|
-
eventsFired.push('dragEnter');
|
|
1144
|
-
await cdp(tabId, 'Input.dispatchDragEvent', {
|
|
1145
|
-
type: 'dragOver',
|
|
1146
|
-
x: toX,
|
|
1147
|
-
y: toY,
|
|
1148
|
-
data: dragData,
|
|
1149
|
-
});
|
|
1150
|
-
if (holdBeforeReleaseMs > 0)
|
|
1151
|
-
await sleep(holdBeforeReleaseMs);
|
|
1152
|
-
await cdp(tabId, 'Input.dispatchDragEvent', {
|
|
1153
|
-
type: 'drop',
|
|
1154
|
-
x: toX,
|
|
1155
|
-
y: toY,
|
|
1156
|
-
data: dragData,
|
|
1157
|
-
});
|
|
1158
|
-
eventsFired.push('drop');
|
|
1159
|
-
await cdp(tabId, 'Input.dispatchMouseEvent', {
|
|
1160
|
-
type: 'mouseReleased',
|
|
1161
|
-
x: toX,
|
|
1162
|
-
y: toY,
|
|
1163
|
-
button: 'left',
|
|
1164
|
-
clickCount: 1,
|
|
1165
|
-
});
|
|
1166
|
-
}
|
|
1167
|
-
else {
|
|
1168
|
-
// Element was tagged draggable but no native drag fired — the page
|
|
1169
|
-
// either preventDefault'd dragstart or wires its own pointer logic.
|
|
1170
|
-
// Continue with pointer-only events from where we already pressed.
|
|
1171
|
-
dragMode = 'pointer';
|
|
1172
|
-
for (let i = 1; i <= steps; i++) {
|
|
1173
|
-
const t = i / steps;
|
|
1174
|
-
const { x, y } = interpolate(t);
|
|
1175
|
-
await moveHeld(x, y);
|
|
1176
|
-
if (stepDelayMs > 0)
|
|
1177
|
-
await sleep(stepDelayMs);
|
|
1178
|
-
}
|
|
1179
|
-
if (holdBeforeReleaseMs > 0)
|
|
1180
|
-
await sleep(holdBeforeReleaseMs);
|
|
1181
|
-
await cdp(tabId, 'Input.dispatchMouseEvent', {
|
|
1182
|
-
type: 'mouseReleased',
|
|
1183
|
-
x: toX,
|
|
1184
|
-
y: toY,
|
|
1185
|
-
button: 'left',
|
|
1186
|
-
clickCount: 1,
|
|
1187
|
-
});
|
|
1188
|
-
}
|
|
1189
|
-
}
|
|
1190
|
-
else {
|
|
1191
|
-
// Pointer-only branch: no native drag involvement at all.
|
|
1192
|
-
dragMode = 'pointer';
|
|
1193
|
-
await cdp(tabId, 'Input.dispatchMouseEvent', {
|
|
1194
|
-
type: 'mouseMoved',
|
|
1195
|
-
x: fromX,
|
|
1196
|
-
y: fromY,
|
|
1197
|
-
});
|
|
1198
|
-
await cdp(tabId, 'Input.dispatchMouseEvent', {
|
|
1199
|
-
type: 'mousePressed',
|
|
1200
|
-
x: fromX,
|
|
1201
|
-
y: fromY,
|
|
1202
|
-
button: 'left',
|
|
1203
|
-
clickCount: 1,
|
|
1204
|
-
});
|
|
1205
|
-
if (holdBeforeMoveMs > 0)
|
|
1206
|
-
await sleep(holdBeforeMoveMs);
|
|
1207
|
-
for (let i = 1; i <= steps; i++) {
|
|
1208
|
-
const t = i / steps;
|
|
1209
|
-
const { x, y } = interpolate(t);
|
|
1210
|
-
await moveHeld(x, y);
|
|
1211
|
-
if (stepDelayMs > 0)
|
|
1212
|
-
await sleep(stepDelayMs);
|
|
1213
|
-
}
|
|
1214
|
-
if (holdBeforeReleaseMs > 0)
|
|
1215
|
-
await sleep(holdBeforeReleaseMs);
|
|
1216
|
-
await cdp(tabId, 'Input.dispatchMouseEvent', {
|
|
1217
|
-
type: 'mouseReleased',
|
|
1218
|
-
x: toX,
|
|
1219
|
-
y: toY,
|
|
1220
|
-
button: 'left',
|
|
1221
|
-
clickCount: 1,
|
|
1222
|
-
});
|
|
1223
|
-
}
|
|
1224
|
-
}
|
|
1225
|
-
finally {
|
|
1226
|
-
// Critical: leaving interception on would block the human user from
|
|
1227
|
-
// dragging anything in this tab. Best-effort cleanup, swallow errors.
|
|
1228
|
-
if (interceptionEnabled) {
|
|
1229
|
-
try {
|
|
1230
|
-
await cdp(tabId, 'Input.setInterceptDrags', { enabled: false });
|
|
1231
|
-
}
|
|
1232
|
-
catch {
|
|
1233
|
-
// ignore
|
|
1234
|
-
}
|
|
1235
|
-
}
|
|
1267
|
+
if (!outcome.ok) {
|
|
1268
|
+
return { kind: 'tool.response', id: msg.id, ok: false, error: outcome.error };
|
|
1236
1269
|
}
|
|
1237
|
-
return {
|
|
1238
|
-
kind: 'tool.response',
|
|
1239
|
-
id: msg.id,
|
|
1240
|
-
ok: true,
|
|
1241
|
-
result: {
|
|
1242
|
-
from: { x: fromX, y: fromY, selector: fromSelector ?? null },
|
|
1243
|
-
to: { x: toX, y: toY, selector: toSelector ?? null },
|
|
1244
|
-
duration_ms_actual: Date.now() - dragStart,
|
|
1245
|
-
drag_mode: dragMode,
|
|
1246
|
-
interception_attempted: interceptionEnabled,
|
|
1247
|
-
intercept_received: interceptReceived,
|
|
1248
|
-
events_fired: eventsFired,
|
|
1249
|
-
},
|
|
1250
|
-
};
|
|
1270
|
+
return { kind: 'tool.response', id: msg.id, ok: true, result: outcome.result };
|
|
1251
1271
|
}
|
|
1252
1272
|
async function handleDialogRespond(tabId, msg, p, optStr) {
|
|
1253
1273
|
const accept = p.accept === true;
|
|
@@ -1440,6 +1460,16 @@ async function cleanup(tabId) {
|
|
|
1440
1460
|
const state = tabStates.get(tabId);
|
|
1441
1461
|
if (!state)
|
|
1442
1462
|
return;
|
|
1463
|
+
// Delete the tracking entry SYNCHRONOUSLY, before any await below: the
|
|
1464
|
+
// reconnect policy's CloseContext.tracked contract promises that every
|
|
1465
|
+
// extension-initiated teardown deletes state BEFORE the WS close event
|
|
1466
|
+
// dispatches. ws.close() further down fires that event on a later turn,
|
|
1467
|
+
// so deleting here keeps the promise — the close handler then sees an
|
|
1468
|
+
// untracked socket and never auto-reconnects a teardown the extension
|
|
1469
|
+
// itself initiated. The awaited steps below only need the captured
|
|
1470
|
+
// `state` and raw CDP calls, never the map entry — and deleting first
|
|
1471
|
+
// also makes a re-entrant cleanup a no-op instead of a double detach.
|
|
1472
|
+
tabStates.delete(tabId);
|
|
1443
1473
|
// Recording never survives a disconnect — there is no server connection
|
|
1444
1474
|
// left to save to, and leaving captured-but-unsaved steps around after
|
|
1445
1475
|
// the tab drops its bridge would be a surprising place for them to
|
|
@@ -1471,7 +1501,6 @@ async function cleanup(tabId) {
|
|
|
1471
1501
|
// Best effort — the resource may already be detached/closed.
|
|
1472
1502
|
}
|
|
1473
1503
|
}
|
|
1474
|
-
tabStates.delete(tabId);
|
|
1475
1504
|
}
|
|
1476
1505
|
chrome.debugger.onEvent.addListener((source, method, params) => {
|
|
1477
1506
|
if (source.tabId === undefined)
|
|
@@ -1517,7 +1546,131 @@ async function forgetTabId(chromeTabId) {
|
|
|
1517
1546
|
/* ignore */
|
|
1518
1547
|
}
|
|
1519
1548
|
}
|
|
1549
|
+
/* Auto-reconnect after an involuntary WS close.
|
|
1550
|
+
*
|
|
1551
|
+
* When a REGISTERED tab's WebSocket dies under us (unclean close — the
|
|
1552
|
+
* primary MCP server crashed or restarted), the scheduler below retries
|
|
1553
|
+
* `connectTab` with per-tab backoff (see RECONNECT_DELAYS_MS in
|
|
1554
|
+
* reconnect-policy.ts), reusing the preserved `prevTabId:*` id so the new
|
|
1555
|
+
* primary can keep the same tab_id (or emit `tab-renamed` if it can't —
|
|
1556
|
+
* existing contract, nothing new on the wire). Every EXTENSION-initiated
|
|
1557
|
+
* teardown (explicit disconnect, tab removal, idle park, debugger detach)
|
|
1558
|
+
* runs `cleanup` BEFORE the close event dispatches, so the close handler
|
|
1559
|
+
* sees an untracked socket and never schedules; a CLEAN close from the
|
|
1560
|
+
* server (browser.reset) is a deliberate goodbye and never schedules
|
|
1561
|
+
* either — the popup Connect button stays the fallback for both, as today.
|
|
1562
|
+
*
|
|
1563
|
+
* MV3 durability: each scheduled attempt is mirrored into
|
|
1564
|
+
* `chrome.storage.session` (`pendingReconnect:*`, right next to the
|
|
1565
|
+
* `prevTabId:*` keys). Plain timers cover the common case — the backoff
|
|
1566
|
+
* span sits inside the service worker's idle grace, and every attempt
|
|
1567
|
+
* touches chrome.* APIs which resets the idle clock — but if the worker is
|
|
1568
|
+
* killed anyway, `resumePendingReconnects` below picks the budget back up
|
|
1569
|
+
* on the next wake, and entries older than RECONNECT_STATE_TTL_MS are
|
|
1570
|
+
* discarded rather than surprising the user minutes later. */
|
|
1571
|
+
async function storeReconnectState(tabId, attempt) {
|
|
1572
|
+
try {
|
|
1573
|
+
await chrome.storage.session.set({
|
|
1574
|
+
[reconnectStorageKey(tabId)]: { attempt, updatedAt: Date.now() },
|
|
1575
|
+
});
|
|
1576
|
+
}
|
|
1577
|
+
catch {
|
|
1578
|
+
/* non-fatal — worst case a killed service worker cannot resume this retry */
|
|
1579
|
+
}
|
|
1580
|
+
}
|
|
1581
|
+
async function clearReconnectState(tabId) {
|
|
1582
|
+
try {
|
|
1583
|
+
await chrome.storage.session.remove(reconnectStorageKey(tabId));
|
|
1584
|
+
}
|
|
1585
|
+
catch {
|
|
1586
|
+
/* ignore */
|
|
1587
|
+
}
|
|
1588
|
+
}
|
|
1589
|
+
const reconnectScheduler = createReconnectScheduler({
|
|
1590
|
+
attempt: async (tabId) => {
|
|
1591
|
+
try {
|
|
1592
|
+
await chrome.tabs.get(tabId);
|
|
1593
|
+
}
|
|
1594
|
+
catch {
|
|
1595
|
+
// The Chrome tab itself is gone — nothing left to reconnect.
|
|
1596
|
+
return 'stop';
|
|
1597
|
+
}
|
|
1598
|
+
const existing = tabStates.get(tabId);
|
|
1599
|
+
if (existing) {
|
|
1600
|
+
// A live bridge already exists (e.g. the user pressed Connect while
|
|
1601
|
+
// a retry was pending) — done. A state whose socket is not OPEN yet
|
|
1602
|
+
// belongs to a connect attempt still mid-flight (cleanup deletes
|
|
1603
|
+
// state synchronously, so it never lingers here); keep the backoff
|
|
1604
|
+
// going instead of declaring victory early.
|
|
1605
|
+
return existing.ws?.readyState === WebSocket.OPEN ? 'connected' : 'retry';
|
|
1606
|
+
}
|
|
1607
|
+
try {
|
|
1608
|
+
const result = await connectTab(tabId);
|
|
1609
|
+
return result.ok ? 'connected' : 'retry';
|
|
1610
|
+
}
|
|
1611
|
+
catch {
|
|
1612
|
+
return 'retry';
|
|
1613
|
+
}
|
|
1614
|
+
},
|
|
1615
|
+
onStateChange: (tabId, state) => {
|
|
1616
|
+
if (state)
|
|
1617
|
+
void storeReconnectState(tabId, state.attempt);
|
|
1618
|
+
else
|
|
1619
|
+
void clearReconnectState(tabId);
|
|
1620
|
+
},
|
|
1621
|
+
});
|
|
1622
|
+
/** Resume reconnect cycles a killed service worker left behind. Runs once
|
|
1623
|
+
* per worker start (top-level call below) — `chrome.storage.session`
|
|
1624
|
+
* survives worker restarts but not browser restarts, which is exactly the
|
|
1625
|
+
* lifetime the retry budget should have. */
|
|
1626
|
+
async function resumePendingReconnects() {
|
|
1627
|
+
let stored;
|
|
1628
|
+
try {
|
|
1629
|
+
stored = await chrome.storage.session.get(null);
|
|
1630
|
+
}
|
|
1631
|
+
catch {
|
|
1632
|
+
return;
|
|
1633
|
+
}
|
|
1634
|
+
const now = Date.now();
|
|
1635
|
+
for (const [key, value] of Object.entries(stored)) {
|
|
1636
|
+
const tabId = tabIdFromReconnectKey(key);
|
|
1637
|
+
if (tabId === null)
|
|
1638
|
+
continue;
|
|
1639
|
+
const state = parseStoredReconnectState(value);
|
|
1640
|
+
if (state && isReconnectStateFresh(state.updatedAt, now) && !tabStates.has(tabId)) {
|
|
1641
|
+
reconnectScheduler.schedule(tabId, state.attempt);
|
|
1642
|
+
}
|
|
1643
|
+
else {
|
|
1644
|
+
void clearReconnectState(tabId);
|
|
1645
|
+
}
|
|
1646
|
+
}
|
|
1647
|
+
}
|
|
1648
|
+
void resumePendingReconnects();
|
|
1649
|
+
/** Tabs with a `connectTab` currently in flight. The `tabStates.has` check
|
|
1650
|
+
* inside `doConnectTab` alone cannot prevent two concurrent connects — the
|
|
1651
|
+
* state lands only after three awaited chrome.* calls — so this SYNCHRONOUS
|
|
1652
|
+
* check-and-set is the real per-tab reentrancy guard. */
|
|
1653
|
+
const connectsInFlight = new Set();
|
|
1520
1654
|
async function connectTab(tabId) {
|
|
1655
|
+
if (connectsInFlight.has(tabId)) {
|
|
1656
|
+
// A concurrent connect (popup Connect racing a scheduled reconnect
|
|
1657
|
+
// attempt, or vice versa) — the in-flight attempt owns the tab.
|
|
1658
|
+
// Answer benignly instead of letting a second attempt fail its
|
|
1659
|
+
// debugger attach with a confusing error; the popup renders this
|
|
1660
|
+
// exact message as the "Reconnecting" card, not an error.
|
|
1661
|
+
return { ok: false, error: CONNECT_IN_PROGRESS_ERROR };
|
|
1662
|
+
}
|
|
1663
|
+
connectsInFlight.add(tabId);
|
|
1664
|
+
try {
|
|
1665
|
+
return await doConnectTab(tabId);
|
|
1666
|
+
}
|
|
1667
|
+
finally {
|
|
1668
|
+
connectsInFlight.delete(tabId);
|
|
1669
|
+
}
|
|
1670
|
+
}
|
|
1671
|
+
/** Body of `connectTab` — call only through the wrapper above, which holds
|
|
1672
|
+
* the per-tab in-flight lock for the full duration of the attempt. */
|
|
1673
|
+
async function doConnectTab(tabId) {
|
|
1521
1674
|
if (tabStates.has(tabId)) {
|
|
1522
1675
|
return { ok: false, error: 'This tab is already connected' };
|
|
1523
1676
|
}
|
|
@@ -1591,6 +1744,13 @@ async function connectTab(tabId) {
|
|
|
1591
1744
|
// new kind into the tool.request handler.
|
|
1592
1745
|
switch (msg.kind) {
|
|
1593
1746
|
case 'tab.registered': {
|
|
1747
|
+
// A successful registration ends any reconnect cycle for this
|
|
1748
|
+
// tab. Covers the resume path re-arming a tab the user just
|
|
1749
|
+
// connected manually (resumePendingReconnects's tabStates
|
|
1750
|
+
// check races connectTab); when the SCHEDULER drove this very
|
|
1751
|
+
// connect, its in-flight guard makes this cancel a clean,
|
|
1752
|
+
// notify-once end of the cycle.
|
|
1753
|
+
reconnectScheduler.cancel(tabId);
|
|
1594
1754
|
state.serverTabId = msg.payload.tabId;
|
|
1595
1755
|
// serverVersion is optional on the wire so older servers that
|
|
1596
1756
|
// predate the field still parse — narrow on `typeof string`.
|
|
@@ -1652,21 +1812,47 @@ async function connectTab(tabId) {
|
|
|
1652
1812
|
}
|
|
1653
1813
|
})();
|
|
1654
1814
|
});
|
|
1815
|
+
// A WebSocket 'error' is always followed by a 'close', so teardown
|
|
1816
|
+
// lives in the close handler alone — this one only settles a
|
|
1817
|
+
// pre-registration connect failure with the friendlier diagnosis (for
|
|
1818
|
+
// a post-registration drop `settle` is already spent and this no-ops).
|
|
1655
1819
|
ws.addEventListener('error', () => {
|
|
1656
|
-
void cleanup(tabId).catch(() => {
|
|
1657
|
-
// Best effort — already-detached state is fine.
|
|
1658
|
-
});
|
|
1659
1820
|
settle({ ok: false, error: 'WebSocket connection failed. Is the MCP server running?' });
|
|
1660
1821
|
});
|
|
1661
|
-
ws.addEventListener('close', () => {
|
|
1822
|
+
ws.addEventListener('close', (ev) => {
|
|
1823
|
+
const current = tabStates.get(tabId);
|
|
1824
|
+
if (current !== undefined && current.ws !== ws) {
|
|
1825
|
+
// A newer connection already owns this tab — this close belongs to
|
|
1826
|
+
// a superseded socket, and tearing state down here would kill the
|
|
1827
|
+
// NEW bridge rather than the one that just died.
|
|
1828
|
+
settle({ ok: false, error: 'WebSocket closed before registration' });
|
|
1829
|
+
return;
|
|
1830
|
+
}
|
|
1831
|
+
// Decide BEFORE cleanup wipes the evidence: an unclean close of a
|
|
1832
|
+
// still-tracked, registered connection means the server died under
|
|
1833
|
+
// us — the one case the auto-reconnect scheduler exists for. See
|
|
1834
|
+
// the reconnect section doc above storeReconnectState for why every
|
|
1835
|
+
// other close (explicit disconnect, tab removal, idle park,
|
|
1836
|
+
// browser.reset's clean goodbye) falls out of this predicate.
|
|
1837
|
+
const involuntary = isInvoluntaryClose({
|
|
1838
|
+
tracked: current?.ws === ws,
|
|
1839
|
+
registered: current?.serverTabId !== undefined,
|
|
1840
|
+
wasClean: ev.wasClean,
|
|
1841
|
+
});
|
|
1662
1842
|
void cleanup(tabId).catch(() => {
|
|
1663
1843
|
// Best effort — already-detached state is fine.
|
|
1664
1844
|
});
|
|
1665
1845
|
settle({ ok: false, error: 'WebSocket closed before registration' });
|
|
1846
|
+
if (involuntary)
|
|
1847
|
+
reconnectScheduler.schedule(tabId);
|
|
1666
1848
|
});
|
|
1667
1849
|
});
|
|
1668
1850
|
}
|
|
1669
1851
|
async function disconnectTab(tabId) {
|
|
1852
|
+
// Explicit user intent beats any pending auto-reconnect — cancel it
|
|
1853
|
+
// first so a scheduled retry cannot resurrect the bridge right after
|
|
1854
|
+
// the user asked for it to go away.
|
|
1855
|
+
reconnectScheduler.cancel(tabId);
|
|
1670
1856
|
await cleanup(tabId);
|
|
1671
1857
|
// Explicit user-driven disconnect → forget the previous tab id so the
|
|
1672
1858
|
// next "Connect" starts from a clean slate (vs. an involuntary
|
|
@@ -1676,8 +1862,17 @@ async function disconnectTab(tabId) {
|
|
|
1676
1862
|
}
|
|
1677
1863
|
function getTabStatus(tabId) {
|
|
1678
1864
|
const state = tabStates.get(tabId);
|
|
1679
|
-
if (!state)
|
|
1865
|
+
if (!state) {
|
|
1866
|
+
// Keep the popup truthful while auto-reconnect is working: "not
|
|
1867
|
+
// connected, but actively retrying" rather than a flat idle state.
|
|
1868
|
+
// `connectsInFlight` covers the short gap inside a live attempt
|
|
1869
|
+
// before its TabState lands — without it the popup's poll could
|
|
1870
|
+
// misread that gap as idle and freeze the card there.
|
|
1871
|
+
if (reconnectScheduler.isPending(tabId) || connectsInFlight.has(tabId)) {
|
|
1872
|
+
return { connected: false, reconnecting: true };
|
|
1873
|
+
}
|
|
1680
1874
|
return { connected: false };
|
|
1875
|
+
}
|
|
1681
1876
|
return { connected: true, serverTabId: state.serverTabId };
|
|
1682
1877
|
}
|
|
1683
1878
|
/**
|
|
@@ -2041,6 +2236,12 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
|
|
|
2041
2236
|
if (!isRuntimeMessage(msg))
|
|
2042
2237
|
return false;
|
|
2043
2238
|
if (msg.action === 'connect') {
|
|
2239
|
+
// An explicit Connect cancels any FUTURE auto-reconnect retries for
|
|
2240
|
+
// this tab. If a scheduled attempt is already mid-flight (cancel
|
|
2241
|
+
// cannot abort it), connectTab's per-tab in-flight lock turns this
|
|
2242
|
+
// call into a benign "already in progress" result instead of a
|
|
2243
|
+
// second concurrent connect.
|
|
2244
|
+
reconnectScheduler.cancel(msg.tabId);
|
|
2044
2245
|
void connectTab(msg.tabId).then(sendResponse);
|
|
2045
2246
|
return true;
|
|
2046
2247
|
}
|
|
@@ -2086,6 +2287,8 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
|
|
|
2086
2287
|
return false;
|
|
2087
2288
|
});
|
|
2088
2289
|
chrome.tabs.onRemoved.addListener((tabId) => {
|
|
2290
|
+
// No tab, no reconnect — abort any pending retry cycle for it.
|
|
2291
|
+
reconnectScheduler.cancel(tabId);
|
|
2089
2292
|
void cleanup(tabId).catch(() => {
|
|
2090
2293
|
// Best effort.
|
|
2091
2294
|
});
|