@flrande/bak-extension 0.6.4 → 0.6.5
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/.bak-e2e-build-stamp +1 -1
- package/dist/background.global.js +108 -47
- package/dist/manifest.json +1 -1
- package/dist/popup.global.js +92 -2
- package/dist/popup.html +72 -1
- package/package.json +2 -2
- package/public/popup.html +72 -1
- package/src/background.ts +218 -104
- package/src/popup.ts +135 -11
- package/src/session-binding.ts +2 -15
|
@@ -1 +1 @@
|
|
|
1
|
-
2026-03-
|
|
1
|
+
2026-03-13T06:30:44.568Z
|
|
@@ -62,7 +62,7 @@
|
|
|
62
62
|
// package.json
|
|
63
63
|
var package_default = {
|
|
64
64
|
name: "@flrande/bak-extension",
|
|
65
|
-
version: "0.6.
|
|
65
|
+
version: "0.6.5",
|
|
66
66
|
type: "module",
|
|
67
67
|
scripts: {
|
|
68
68
|
build: "tsup src/background.ts src/content.ts src/popup.ts --format iife --out-dir dist --clean && node scripts/copy-assets.mjs",
|
|
@@ -838,22 +838,9 @@
|
|
|
838
838
|
await this.browser.closeTab(resolvedTabId);
|
|
839
839
|
const remainingTabIds = ensured.binding.tabIds.filter((candidate) => candidate !== resolvedTabId);
|
|
840
840
|
if (remainingTabIds.length === 0) {
|
|
841
|
-
|
|
842
|
-
id: ensured.binding.id,
|
|
843
|
-
label: ensured.binding.label,
|
|
844
|
-
color: ensured.binding.color,
|
|
845
|
-
windowId: null,
|
|
846
|
-
groupId: null,
|
|
847
|
-
tabIds: [],
|
|
848
|
-
activeTabId: null,
|
|
849
|
-
primaryTabId: null
|
|
850
|
-
};
|
|
851
|
-
await this.storage.save(emptied);
|
|
841
|
+
await this.storage.delete(ensured.binding.id);
|
|
852
842
|
return {
|
|
853
|
-
binding:
|
|
854
|
-
...emptied,
|
|
855
|
-
tabs: []
|
|
856
|
-
},
|
|
843
|
+
binding: null,
|
|
857
844
|
closedTabId: resolvedTabId
|
|
858
845
|
};
|
|
859
846
|
}
|
|
@@ -1333,11 +1320,14 @@
|
|
|
1333
1320
|
var ws = null;
|
|
1334
1321
|
var reconnectTimer = null;
|
|
1335
1322
|
var nextReconnectInMs = null;
|
|
1323
|
+
var nextReconnectAt = null;
|
|
1336
1324
|
var reconnectAttempt = 0;
|
|
1337
1325
|
var lastError = null;
|
|
1338
1326
|
var manualDisconnect = false;
|
|
1339
1327
|
var sessionBindingStateMutationQueue = Promise.resolve();
|
|
1340
1328
|
var preserveHumanFocusDepth = 0;
|
|
1329
|
+
var lastBindingUpdateAt = null;
|
|
1330
|
+
var lastBindingUpdateReason = null;
|
|
1341
1331
|
async function getConfig() {
|
|
1342
1332
|
const stored = await chrome.storage.local.get([STORAGE_KEY_TOKEN, STORAGE_KEY_PORT, STORAGE_KEY_DEBUG_RICH_TEXT]);
|
|
1343
1333
|
return {
|
|
@@ -1374,6 +1364,7 @@
|
|
|
1374
1364
|
reconnectTimer = null;
|
|
1375
1365
|
}
|
|
1376
1366
|
nextReconnectInMs = null;
|
|
1367
|
+
nextReconnectAt = null;
|
|
1377
1368
|
}
|
|
1378
1369
|
function sendResponse(payload) {
|
|
1379
1370
|
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
@@ -1469,6 +1460,65 @@
|
|
|
1469
1460
|
async function listSessionBindingStates() {
|
|
1470
1461
|
return Object.values(await loadSessionBindingStateMap());
|
|
1471
1462
|
}
|
|
1463
|
+
function summarizeSessionBindings(states) {
|
|
1464
|
+
const items = states.map((state) => {
|
|
1465
|
+
const detached = state.windowId === null || state.tabIds.length === 0;
|
|
1466
|
+
return {
|
|
1467
|
+
id: state.id,
|
|
1468
|
+
label: state.label,
|
|
1469
|
+
tabCount: state.tabIds.length,
|
|
1470
|
+
activeTabId: state.activeTabId,
|
|
1471
|
+
windowId: state.windowId,
|
|
1472
|
+
groupId: state.groupId,
|
|
1473
|
+
detached
|
|
1474
|
+
};
|
|
1475
|
+
});
|
|
1476
|
+
return {
|
|
1477
|
+
count: items.length,
|
|
1478
|
+
attachedCount: items.filter((item) => !item.detached).length,
|
|
1479
|
+
detachedCount: items.filter((item) => item.detached).length,
|
|
1480
|
+
tabCount: items.reduce((sum, item) => sum + item.tabCount, 0),
|
|
1481
|
+
items
|
|
1482
|
+
};
|
|
1483
|
+
}
|
|
1484
|
+
async function buildPopupState() {
|
|
1485
|
+
const config = await getConfig();
|
|
1486
|
+
const sessionBindings = summarizeSessionBindings(await listSessionBindingStates());
|
|
1487
|
+
const reconnectRemainingMs = nextReconnectAt === null ? null : Math.max(0, nextReconnectAt - Date.now());
|
|
1488
|
+
let connectionState;
|
|
1489
|
+
if (!config.token) {
|
|
1490
|
+
connectionState = "missing-token";
|
|
1491
|
+
} else if (ws?.readyState === WebSocket.OPEN) {
|
|
1492
|
+
connectionState = "connected";
|
|
1493
|
+
} else if (ws?.readyState === WebSocket.CONNECTING) {
|
|
1494
|
+
connectionState = "connecting";
|
|
1495
|
+
} else if (manualDisconnect) {
|
|
1496
|
+
connectionState = "manual";
|
|
1497
|
+
} else if (nextReconnectInMs !== null) {
|
|
1498
|
+
connectionState = "reconnecting";
|
|
1499
|
+
} else {
|
|
1500
|
+
connectionState = "disconnected";
|
|
1501
|
+
}
|
|
1502
|
+
return {
|
|
1503
|
+
ok: true,
|
|
1504
|
+
connected: ws?.readyState === WebSocket.OPEN,
|
|
1505
|
+
connectionState,
|
|
1506
|
+
hasToken: Boolean(config.token),
|
|
1507
|
+
port: config.port,
|
|
1508
|
+
wsUrl: `ws://127.0.0.1:${config.port}/extension`,
|
|
1509
|
+
debugRichText: config.debugRichText,
|
|
1510
|
+
lastError: lastError?.message ?? null,
|
|
1511
|
+
lastErrorAt: lastError?.at ?? null,
|
|
1512
|
+
lastErrorContext: lastError?.context ?? null,
|
|
1513
|
+
reconnectAttempt,
|
|
1514
|
+
nextReconnectInMs: reconnectRemainingMs,
|
|
1515
|
+
manualDisconnect,
|
|
1516
|
+
extensionVersion: EXTENSION_VERSION,
|
|
1517
|
+
lastBindingUpdateAt,
|
|
1518
|
+
lastBindingUpdateReason,
|
|
1519
|
+
sessionBindings
|
|
1520
|
+
};
|
|
1521
|
+
}
|
|
1472
1522
|
async function saveSessionBindingState(state) {
|
|
1473
1523
|
await mutateSessionBindingStateMap((stateMap) => {
|
|
1474
1524
|
stateMap[state.id] = state;
|
|
@@ -1492,6 +1542,8 @@
|
|
|
1492
1542
|
};
|
|
1493
1543
|
}
|
|
1494
1544
|
function emitSessionBindingUpdated(bindingId, reason, state, extras = {}) {
|
|
1545
|
+
lastBindingUpdateAt = Date.now();
|
|
1546
|
+
lastBindingUpdateReason = reason;
|
|
1495
1547
|
sendEvent("sessionBinding.updated", {
|
|
1496
1548
|
bindingId,
|
|
1497
1549
|
reason,
|
|
@@ -3311,9 +3363,11 @@
|
|
|
3311
3363
|
const delayMs = computeReconnectDelayMs(reconnectAttempt);
|
|
3312
3364
|
reconnectAttempt += 1;
|
|
3313
3365
|
nextReconnectInMs = delayMs;
|
|
3366
|
+
nextReconnectAt = Date.now() + delayMs;
|
|
3314
3367
|
reconnectTimer = setTimeout(() => {
|
|
3315
3368
|
reconnectTimer = null;
|
|
3316
3369
|
nextReconnectInMs = null;
|
|
3370
|
+
nextReconnectAt = null;
|
|
3317
3371
|
void connectWebSocket();
|
|
3318
3372
|
}, delayMs);
|
|
3319
3373
|
if (!lastError) {
|
|
@@ -3334,19 +3388,23 @@
|
|
|
3334
3388
|
return;
|
|
3335
3389
|
}
|
|
3336
3390
|
const url = `ws://127.0.0.1:${config.port}/extension?token=${encodeURIComponent(config.token)}`;
|
|
3337
|
-
|
|
3338
|
-
ws
|
|
3391
|
+
const socket = new WebSocket(url);
|
|
3392
|
+
ws = socket;
|
|
3393
|
+
socket.addEventListener("open", () => {
|
|
3394
|
+
if (ws !== socket) {
|
|
3395
|
+
return;
|
|
3396
|
+
}
|
|
3339
3397
|
manualDisconnect = false;
|
|
3340
3398
|
reconnectAttempt = 0;
|
|
3341
3399
|
lastError = null;
|
|
3342
|
-
|
|
3400
|
+
socket.send(JSON.stringify({
|
|
3343
3401
|
type: "hello",
|
|
3344
3402
|
role: "extension",
|
|
3345
3403
|
version: EXTENSION_VERSION,
|
|
3346
3404
|
ts: Date.now()
|
|
3347
3405
|
}));
|
|
3348
3406
|
});
|
|
3349
|
-
|
|
3407
|
+
socket.addEventListener("message", (event) => {
|
|
3350
3408
|
try {
|
|
3351
3409
|
const request = JSON.parse(String(event.data));
|
|
3352
3410
|
if (!request.id || !request.method) {
|
|
@@ -3367,13 +3425,19 @@
|
|
|
3367
3425
|
});
|
|
3368
3426
|
}
|
|
3369
3427
|
});
|
|
3370
|
-
|
|
3428
|
+
socket.addEventListener("close", () => {
|
|
3429
|
+
if (ws !== socket) {
|
|
3430
|
+
return;
|
|
3431
|
+
}
|
|
3371
3432
|
ws = null;
|
|
3372
3433
|
scheduleReconnect("socket-closed");
|
|
3373
3434
|
});
|
|
3374
|
-
|
|
3435
|
+
socket.addEventListener("error", () => {
|
|
3436
|
+
if (ws !== socket) {
|
|
3437
|
+
return;
|
|
3438
|
+
}
|
|
3375
3439
|
setRuntimeError("Cannot connect to bak cli", "socket");
|
|
3376
|
-
|
|
3440
|
+
socket.close();
|
|
3377
3441
|
});
|
|
3378
3442
|
}
|
|
3379
3443
|
chrome.tabs.onRemoved.addListener((tabId) => {
|
|
@@ -3385,6 +3449,11 @@
|
|
|
3385
3449
|
continue;
|
|
3386
3450
|
}
|
|
3387
3451
|
const nextTabIds = state.tabIds.filter((id) => id !== tabId);
|
|
3452
|
+
if (nextTabIds.length === 0) {
|
|
3453
|
+
delete stateMap[bindingId];
|
|
3454
|
+
updates.push({ bindingId, state: null });
|
|
3455
|
+
continue;
|
|
3456
|
+
}
|
|
3388
3457
|
const fallbackTabId = nextTabIds[0] ?? null;
|
|
3389
3458
|
const nextState = {
|
|
3390
3459
|
...state,
|
|
@@ -3440,16 +3509,8 @@
|
|
|
3440
3509
|
if (state.windowId !== windowId) {
|
|
3441
3510
|
continue;
|
|
3442
3511
|
}
|
|
3443
|
-
|
|
3444
|
-
|
|
3445
|
-
windowId: null,
|
|
3446
|
-
groupId: null,
|
|
3447
|
-
tabIds: [],
|
|
3448
|
-
activeTabId: null,
|
|
3449
|
-
primaryTabId: null
|
|
3450
|
-
};
|
|
3451
|
-
stateMap[bindingId] = nextState;
|
|
3452
|
-
updates.push({ bindingId, state: nextState });
|
|
3512
|
+
delete stateMap[bindingId];
|
|
3513
|
+
updates.push({ bindingId, state: null });
|
|
3453
3514
|
}
|
|
3454
3515
|
return updates;
|
|
3455
3516
|
}).then((updates) => {
|
|
@@ -3468,8 +3529,9 @@
|
|
|
3468
3529
|
chrome.runtime.onMessage.addListener((message, _sender, sendResponse2) => {
|
|
3469
3530
|
if (message?.type === "bak.updateConfig") {
|
|
3470
3531
|
manualDisconnect = false;
|
|
3532
|
+
const token = typeof message.token === "string" ? message.token.trim() : "";
|
|
3471
3533
|
void setConfig({
|
|
3472
|
-
token:
|
|
3534
|
+
...token ? { token } : {},
|
|
3473
3535
|
port: Number(message.port ?? DEFAULT_PORT),
|
|
3474
3536
|
debugRichText: message.debugRichText === true
|
|
3475
3537
|
}).then(() => {
|
|
@@ -3479,19 +3541,8 @@
|
|
|
3479
3541
|
return true;
|
|
3480
3542
|
}
|
|
3481
3543
|
if (message?.type === "bak.getState") {
|
|
3482
|
-
void
|
|
3483
|
-
sendResponse2(
|
|
3484
|
-
ok: true,
|
|
3485
|
-
connected: ws?.readyState === WebSocket.OPEN,
|
|
3486
|
-
hasToken: Boolean(config.token),
|
|
3487
|
-
port: config.port,
|
|
3488
|
-
debugRichText: config.debugRichText,
|
|
3489
|
-
lastError: lastError?.message ?? null,
|
|
3490
|
-
lastErrorAt: lastError?.at ?? null,
|
|
3491
|
-
lastErrorContext: lastError?.context ?? null,
|
|
3492
|
-
reconnectAttempt,
|
|
3493
|
-
nextReconnectInMs
|
|
3494
|
-
});
|
|
3544
|
+
void buildPopupState().then((state) => {
|
|
3545
|
+
sendResponse2(state);
|
|
3495
3546
|
});
|
|
3496
3547
|
return true;
|
|
3497
3548
|
}
|
|
@@ -3499,11 +3550,21 @@
|
|
|
3499
3550
|
manualDisconnect = true;
|
|
3500
3551
|
clearReconnectTimer();
|
|
3501
3552
|
reconnectAttempt = 0;
|
|
3553
|
+
lastError = null;
|
|
3502
3554
|
ws?.close();
|
|
3503
3555
|
ws = null;
|
|
3504
3556
|
sendResponse2({ ok: true });
|
|
3505
3557
|
return false;
|
|
3506
3558
|
}
|
|
3559
|
+
if (message?.type === "bak.reconnectNow") {
|
|
3560
|
+
manualDisconnect = false;
|
|
3561
|
+
clearReconnectTimer();
|
|
3562
|
+
reconnectAttempt = 0;
|
|
3563
|
+
ws?.close();
|
|
3564
|
+
ws = null;
|
|
3565
|
+
void connectWebSocket().then(() => sendResponse2({ ok: true }));
|
|
3566
|
+
return true;
|
|
3567
|
+
}
|
|
3507
3568
|
return false;
|
|
3508
3569
|
});
|
|
3509
3570
|
})();
|
package/dist/manifest.json
CHANGED
package/dist/popup.global.js
CHANGED
|
@@ -6,18 +6,97 @@
|
|
|
6
6
|
var portInput = document.getElementById("port");
|
|
7
7
|
var debugRichTextInput = document.getElementById("debugRichText");
|
|
8
8
|
var saveBtn = document.getElementById("save");
|
|
9
|
+
var reconnectBtn = document.getElementById("reconnect");
|
|
9
10
|
var disconnectBtn = document.getElementById("disconnect");
|
|
11
|
+
var connectionStateEl = document.getElementById("connectionState");
|
|
12
|
+
var tokenStateEl = document.getElementById("tokenState");
|
|
13
|
+
var reconnectStateEl = document.getElementById("reconnectState");
|
|
14
|
+
var connectionUrlEl = document.getElementById("connectionUrl");
|
|
15
|
+
var lastErrorEl = document.getElementById("lastError");
|
|
16
|
+
var lastBindingUpdateEl = document.getElementById("lastBindingUpdate");
|
|
17
|
+
var extensionVersionEl = document.getElementById("extensionVersion");
|
|
18
|
+
var sessionSummaryEl = document.getElementById("sessionSummary");
|
|
19
|
+
var sessionListEl = document.getElementById("sessionList");
|
|
20
|
+
var latestState = null;
|
|
10
21
|
function setStatus(text, bad = false) {
|
|
11
22
|
statusEl.textContent = text;
|
|
12
23
|
statusEl.style.color = bad ? "#dc2626" : "#0f172a";
|
|
13
24
|
}
|
|
25
|
+
function formatTimeAgo(at) {
|
|
26
|
+
if (typeof at !== "number") {
|
|
27
|
+
return "never";
|
|
28
|
+
}
|
|
29
|
+
const deltaSeconds = Math.max(0, Math.round((Date.now() - at) / 1e3));
|
|
30
|
+
if (deltaSeconds < 5) {
|
|
31
|
+
return "just now";
|
|
32
|
+
}
|
|
33
|
+
if (deltaSeconds < 60) {
|
|
34
|
+
return `${deltaSeconds}s ago`;
|
|
35
|
+
}
|
|
36
|
+
const deltaMinutes = Math.round(deltaSeconds / 60);
|
|
37
|
+
if (deltaMinutes < 60) {
|
|
38
|
+
return `${deltaMinutes}m ago`;
|
|
39
|
+
}
|
|
40
|
+
const deltaHours = Math.round(deltaMinutes / 60);
|
|
41
|
+
return `${deltaHours}h ago`;
|
|
42
|
+
}
|
|
43
|
+
function renderSessionBindings(state) {
|
|
44
|
+
sessionSummaryEl.textContent = `${state.count} sessions, ${state.attachedCount} attached, ${state.tabCount} tabs, ${state.detachedCount} detached`;
|
|
45
|
+
sessionListEl.replaceChildren();
|
|
46
|
+
for (const item of state.items) {
|
|
47
|
+
const li = document.createElement("li");
|
|
48
|
+
const location = item.windowId === null ? "no window" : `window ${item.windowId}`;
|
|
49
|
+
const active = item.activeTabId === null ? "no active tab" : `active ${item.activeTabId}`;
|
|
50
|
+
li.textContent = `${item.label}: ${item.tabCount} tabs, ${location}, ${active}`;
|
|
51
|
+
if (item.detached) {
|
|
52
|
+
li.style.color = "#b45309";
|
|
53
|
+
}
|
|
54
|
+
sessionListEl.appendChild(li);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
function renderConnectionDetails(state) {
|
|
58
|
+
connectionStateEl.textContent = state.connectionState;
|
|
59
|
+
tokenStateEl.textContent = state.hasToken ? "configured" : "missing";
|
|
60
|
+
connectionUrlEl.textContent = state.wsUrl;
|
|
61
|
+
extensionVersionEl.textContent = state.extensionVersion;
|
|
62
|
+
if (state.manualDisconnect) {
|
|
63
|
+
reconnectStateEl.textContent = "manual disconnect";
|
|
64
|
+
} else if (typeof state.nextReconnectInMs === "number") {
|
|
65
|
+
const seconds = Math.max(0, Math.ceil(state.nextReconnectInMs / 100) / 10);
|
|
66
|
+
reconnectStateEl.textContent = `attempt ${state.reconnectAttempt}, retry in ${seconds}s`;
|
|
67
|
+
} else if (state.connected) {
|
|
68
|
+
reconnectStateEl.textContent = "connected";
|
|
69
|
+
} else {
|
|
70
|
+
reconnectStateEl.textContent = "idle";
|
|
71
|
+
}
|
|
72
|
+
if (state.lastError) {
|
|
73
|
+
const context = state.lastErrorContext ? `${state.lastErrorContext}: ` : "";
|
|
74
|
+
lastErrorEl.textContent = `${context}${state.lastError} (${formatTimeAgo(state.lastErrorAt)})`;
|
|
75
|
+
} else {
|
|
76
|
+
lastErrorEl.textContent = "none";
|
|
77
|
+
}
|
|
78
|
+
if (state.lastBindingUpdateReason) {
|
|
79
|
+
lastBindingUpdateEl.textContent = `${state.lastBindingUpdateReason} (${formatTimeAgo(state.lastBindingUpdateAt)})`;
|
|
80
|
+
} else {
|
|
81
|
+
lastBindingUpdateEl.textContent = "none";
|
|
82
|
+
}
|
|
83
|
+
}
|
|
14
84
|
async function refreshState() {
|
|
15
85
|
const state = await chrome.runtime.sendMessage({ type: "bak.getState" });
|
|
16
86
|
if (state.ok) {
|
|
87
|
+
latestState = state;
|
|
17
88
|
portInput.value = String(state.port);
|
|
18
89
|
debugRichTextInput.checked = Boolean(state.debugRichText);
|
|
90
|
+
renderConnectionDetails(state);
|
|
91
|
+
renderSessionBindings(state.sessionBindings);
|
|
19
92
|
if (state.connected) {
|
|
20
93
|
setStatus("Connected to bak CLI");
|
|
94
|
+
} else if (state.connectionState === "missing-token") {
|
|
95
|
+
setStatus("Pair token is required", true);
|
|
96
|
+
} else if (state.connectionState === "manual") {
|
|
97
|
+
setStatus("Disconnected manually");
|
|
98
|
+
} else if (state.connectionState === "reconnecting") {
|
|
99
|
+
setStatus("Reconnecting to bak CLI", true);
|
|
21
100
|
} else if (state.lastError) {
|
|
22
101
|
setStatus(`Disconnected: ${state.lastError}`, true);
|
|
23
102
|
} else {
|
|
@@ -28,7 +107,7 @@
|
|
|
28
107
|
saveBtn.addEventListener("click", async () => {
|
|
29
108
|
const token = tokenInput.value.trim();
|
|
30
109
|
const port = Number.parseInt(portInput.value.trim(), 10);
|
|
31
|
-
if (!token) {
|
|
110
|
+
if (!token && latestState?.hasToken !== true) {
|
|
32
111
|
setStatus("Pair token is required", true);
|
|
33
112
|
return;
|
|
34
113
|
}
|
|
@@ -38,10 +117,15 @@
|
|
|
38
117
|
}
|
|
39
118
|
await chrome.runtime.sendMessage({
|
|
40
119
|
type: "bak.updateConfig",
|
|
41
|
-
token,
|
|
120
|
+
...token ? { token } : {},
|
|
42
121
|
port,
|
|
43
122
|
debugRichText: debugRichTextInput.checked
|
|
44
123
|
});
|
|
124
|
+
tokenInput.value = "";
|
|
125
|
+
await refreshState();
|
|
126
|
+
});
|
|
127
|
+
reconnectBtn.addEventListener("click", async () => {
|
|
128
|
+
await chrome.runtime.sendMessage({ type: "bak.reconnectNow" });
|
|
45
129
|
await refreshState();
|
|
46
130
|
});
|
|
47
131
|
disconnectBtn.addEventListener("click", async () => {
|
|
@@ -49,4 +133,10 @@
|
|
|
49
133
|
await refreshState();
|
|
50
134
|
});
|
|
51
135
|
void refreshState();
|
|
136
|
+
var refreshInterval = window.setInterval(() => {
|
|
137
|
+
void refreshState();
|
|
138
|
+
}, 1e3);
|
|
139
|
+
window.addEventListener("unload", () => {
|
|
140
|
+
window.clearInterval(refreshInterval);
|
|
141
|
+
});
|
|
52
142
|
})();
|
package/dist/popup.html
CHANGED
|
@@ -70,9 +70,50 @@
|
|
|
70
70
|
background: #e2e8f0;
|
|
71
71
|
color: #0f172a;
|
|
72
72
|
}
|
|
73
|
+
#reconnect {
|
|
74
|
+
background: #dbeafe;
|
|
75
|
+
color: #1d4ed8;
|
|
76
|
+
}
|
|
73
77
|
#status {
|
|
74
78
|
margin-top: 10px;
|
|
75
79
|
font-size: 12px;
|
|
80
|
+
font-weight: 600;
|
|
81
|
+
}
|
|
82
|
+
.panel {
|
|
83
|
+
margin-top: 12px;
|
|
84
|
+
padding: 10px;
|
|
85
|
+
border: 1px solid #cbd5e1;
|
|
86
|
+
border-radius: 8px;
|
|
87
|
+
background: rgba(255, 255, 255, 0.8);
|
|
88
|
+
}
|
|
89
|
+
.panel h2 {
|
|
90
|
+
margin: 0 0 8px;
|
|
91
|
+
font-size: 12px;
|
|
92
|
+
}
|
|
93
|
+
.meta-grid {
|
|
94
|
+
display: grid;
|
|
95
|
+
grid-template-columns: auto 1fr;
|
|
96
|
+
gap: 6px 10px;
|
|
97
|
+
font-size: 11px;
|
|
98
|
+
}
|
|
99
|
+
.meta-grid dt {
|
|
100
|
+
color: #475569;
|
|
101
|
+
}
|
|
102
|
+
.meta-grid dd {
|
|
103
|
+
margin: 0;
|
|
104
|
+
color: #0f172a;
|
|
105
|
+
word-break: break-word;
|
|
106
|
+
}
|
|
107
|
+
#sessionList {
|
|
108
|
+
margin: 0;
|
|
109
|
+
padding-left: 16px;
|
|
110
|
+
font-size: 11px;
|
|
111
|
+
color: #0f172a;
|
|
112
|
+
}
|
|
113
|
+
#sessionList:empty::before {
|
|
114
|
+
content: "No tracked sessions";
|
|
115
|
+
color: #64748b;
|
|
116
|
+
margin-left: -16px;
|
|
76
117
|
}
|
|
77
118
|
.hint {
|
|
78
119
|
margin-top: 10px;
|
|
@@ -85,7 +126,7 @@
|
|
|
85
126
|
<h1>Browser Agent Kit</h1>
|
|
86
127
|
<label>
|
|
87
128
|
Pair token
|
|
88
|
-
<input id="token" placeholder="paste token from `bak pair`" />
|
|
129
|
+
<input id="token" placeholder="paste token from `bak pair` or leave blank to keep the saved token" />
|
|
89
130
|
</label>
|
|
90
131
|
<label>
|
|
91
132
|
CLI port
|
|
@@ -97,9 +138,39 @@
|
|
|
97
138
|
</label>
|
|
98
139
|
<div class="row">
|
|
99
140
|
<button id="save">Save & Connect</button>
|
|
141
|
+
<button id="reconnect">Reconnect</button>
|
|
142
|
+
</div>
|
|
143
|
+
<div class="row">
|
|
100
144
|
<button id="disconnect">Disconnect</button>
|
|
101
145
|
</div>
|
|
102
146
|
<div id="status">Checking...</div>
|
|
147
|
+
<div class="panel">
|
|
148
|
+
<h2>Connection</h2>
|
|
149
|
+
<dl class="meta-grid">
|
|
150
|
+
<dt>State</dt>
|
|
151
|
+
<dd id="connectionState">-</dd>
|
|
152
|
+
<dt>Token</dt>
|
|
153
|
+
<dd id="tokenState">-</dd>
|
|
154
|
+
<dt>Reconnect</dt>
|
|
155
|
+
<dd id="reconnectState">-</dd>
|
|
156
|
+
<dt>CLI URL</dt>
|
|
157
|
+
<dd id="connectionUrl">-</dd>
|
|
158
|
+
<dt>Last error</dt>
|
|
159
|
+
<dd id="lastError">-</dd>
|
|
160
|
+
<dt>Last binding</dt>
|
|
161
|
+
<dd id="lastBindingUpdate">-</dd>
|
|
162
|
+
<dt>Extension</dt>
|
|
163
|
+
<dd id="extensionVersion">-</dd>
|
|
164
|
+
</dl>
|
|
165
|
+
</div>
|
|
166
|
+
<div class="panel">
|
|
167
|
+
<h2>Sessions</h2>
|
|
168
|
+
<dl class="meta-grid">
|
|
169
|
+
<dt>Tracked</dt>
|
|
170
|
+
<dd id="sessionSummary">-</dd>
|
|
171
|
+
</dl>
|
|
172
|
+
<ul id="sessionList"></ul>
|
|
173
|
+
</div>
|
|
103
174
|
<div class="hint">Extension only connects to ws://127.0.0.1</div>
|
|
104
175
|
<script src="./popup.global.js"></script>
|
|
105
176
|
</body>
|
package/package.json
CHANGED
package/public/popup.html
CHANGED
|
@@ -70,9 +70,50 @@
|
|
|
70
70
|
background: #e2e8f0;
|
|
71
71
|
color: #0f172a;
|
|
72
72
|
}
|
|
73
|
+
#reconnect {
|
|
74
|
+
background: #dbeafe;
|
|
75
|
+
color: #1d4ed8;
|
|
76
|
+
}
|
|
73
77
|
#status {
|
|
74
78
|
margin-top: 10px;
|
|
75
79
|
font-size: 12px;
|
|
80
|
+
font-weight: 600;
|
|
81
|
+
}
|
|
82
|
+
.panel {
|
|
83
|
+
margin-top: 12px;
|
|
84
|
+
padding: 10px;
|
|
85
|
+
border: 1px solid #cbd5e1;
|
|
86
|
+
border-radius: 8px;
|
|
87
|
+
background: rgba(255, 255, 255, 0.8);
|
|
88
|
+
}
|
|
89
|
+
.panel h2 {
|
|
90
|
+
margin: 0 0 8px;
|
|
91
|
+
font-size: 12px;
|
|
92
|
+
}
|
|
93
|
+
.meta-grid {
|
|
94
|
+
display: grid;
|
|
95
|
+
grid-template-columns: auto 1fr;
|
|
96
|
+
gap: 6px 10px;
|
|
97
|
+
font-size: 11px;
|
|
98
|
+
}
|
|
99
|
+
.meta-grid dt {
|
|
100
|
+
color: #475569;
|
|
101
|
+
}
|
|
102
|
+
.meta-grid dd {
|
|
103
|
+
margin: 0;
|
|
104
|
+
color: #0f172a;
|
|
105
|
+
word-break: break-word;
|
|
106
|
+
}
|
|
107
|
+
#sessionList {
|
|
108
|
+
margin: 0;
|
|
109
|
+
padding-left: 16px;
|
|
110
|
+
font-size: 11px;
|
|
111
|
+
color: #0f172a;
|
|
112
|
+
}
|
|
113
|
+
#sessionList:empty::before {
|
|
114
|
+
content: "No tracked sessions";
|
|
115
|
+
color: #64748b;
|
|
116
|
+
margin-left: -16px;
|
|
76
117
|
}
|
|
77
118
|
.hint {
|
|
78
119
|
margin-top: 10px;
|
|
@@ -85,7 +126,7 @@
|
|
|
85
126
|
<h1>Browser Agent Kit</h1>
|
|
86
127
|
<label>
|
|
87
128
|
Pair token
|
|
88
|
-
<input id="token" placeholder="paste token from `bak pair`" />
|
|
129
|
+
<input id="token" placeholder="paste token from `bak pair` or leave blank to keep the saved token" />
|
|
89
130
|
</label>
|
|
90
131
|
<label>
|
|
91
132
|
CLI port
|
|
@@ -97,9 +138,39 @@
|
|
|
97
138
|
</label>
|
|
98
139
|
<div class="row">
|
|
99
140
|
<button id="save">Save & Connect</button>
|
|
141
|
+
<button id="reconnect">Reconnect</button>
|
|
142
|
+
</div>
|
|
143
|
+
<div class="row">
|
|
100
144
|
<button id="disconnect">Disconnect</button>
|
|
101
145
|
</div>
|
|
102
146
|
<div id="status">Checking...</div>
|
|
147
|
+
<div class="panel">
|
|
148
|
+
<h2>Connection</h2>
|
|
149
|
+
<dl class="meta-grid">
|
|
150
|
+
<dt>State</dt>
|
|
151
|
+
<dd id="connectionState">-</dd>
|
|
152
|
+
<dt>Token</dt>
|
|
153
|
+
<dd id="tokenState">-</dd>
|
|
154
|
+
<dt>Reconnect</dt>
|
|
155
|
+
<dd id="reconnectState">-</dd>
|
|
156
|
+
<dt>CLI URL</dt>
|
|
157
|
+
<dd id="connectionUrl">-</dd>
|
|
158
|
+
<dt>Last error</dt>
|
|
159
|
+
<dd id="lastError">-</dd>
|
|
160
|
+
<dt>Last binding</dt>
|
|
161
|
+
<dd id="lastBindingUpdate">-</dd>
|
|
162
|
+
<dt>Extension</dt>
|
|
163
|
+
<dd id="extensionVersion">-</dd>
|
|
164
|
+
</dl>
|
|
165
|
+
</div>
|
|
166
|
+
<div class="panel">
|
|
167
|
+
<h2>Sessions</h2>
|
|
168
|
+
<dl class="meta-grid">
|
|
169
|
+
<dt>Tracked</dt>
|
|
170
|
+
<dd id="sessionSummary">-</dd>
|
|
171
|
+
</dl>
|
|
172
|
+
<ul id="sessionList"></ul>
|
|
173
|
+
</div>
|
|
103
174
|
<div class="hint">Extension only connects to ws://127.0.0.1</div>
|
|
104
175
|
<script src="./popup.global.js"></script>
|
|
105
176
|
</body>
|
package/src/background.ts
CHANGED
|
@@ -59,11 +59,47 @@ interface ExtensionConfig {
|
|
|
59
59
|
debugRichText: boolean;
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
-
interface RuntimeErrorDetails {
|
|
63
|
-
message: string;
|
|
64
|
-
context: 'config' | 'socket' | 'request' | 'parse';
|
|
65
|
-
at: number;
|
|
66
|
-
}
|
|
62
|
+
interface RuntimeErrorDetails {
|
|
63
|
+
message: string;
|
|
64
|
+
context: 'config' | 'socket' | 'request' | 'parse';
|
|
65
|
+
at: number;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
interface PopupSessionBindingSummary {
|
|
69
|
+
id: string;
|
|
70
|
+
label: string;
|
|
71
|
+
tabCount: number;
|
|
72
|
+
activeTabId: number | null;
|
|
73
|
+
windowId: number | null;
|
|
74
|
+
groupId: number | null;
|
|
75
|
+
detached: boolean;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
interface PopupState {
|
|
79
|
+
ok: true;
|
|
80
|
+
connected: boolean;
|
|
81
|
+
connectionState: 'connected' | 'connecting' | 'reconnecting' | 'disconnected' | 'manual' | 'missing-token';
|
|
82
|
+
hasToken: boolean;
|
|
83
|
+
port: number;
|
|
84
|
+
wsUrl: string;
|
|
85
|
+
debugRichText: boolean;
|
|
86
|
+
lastError: string | null;
|
|
87
|
+
lastErrorAt: number | null;
|
|
88
|
+
lastErrorContext: RuntimeErrorDetails['context'] | null;
|
|
89
|
+
reconnectAttempt: number;
|
|
90
|
+
nextReconnectInMs: number | null;
|
|
91
|
+
manualDisconnect: boolean;
|
|
92
|
+
extensionVersion: string;
|
|
93
|
+
lastBindingUpdateAt: number | null;
|
|
94
|
+
lastBindingUpdateReason: string | null;
|
|
95
|
+
sessionBindings: {
|
|
96
|
+
count: number;
|
|
97
|
+
attachedCount: number;
|
|
98
|
+
detachedCount: number;
|
|
99
|
+
tabCount: number;
|
|
100
|
+
items: PopupSessionBindingSummary[];
|
|
101
|
+
};
|
|
102
|
+
}
|
|
67
103
|
|
|
68
104
|
const DEFAULT_PORT = 17373;
|
|
69
105
|
const STORAGE_KEY_TOKEN = 'pairToken';
|
|
@@ -112,11 +148,14 @@ const REPLAY_FORBIDDEN_HEADER_NAMES = new Set([
|
|
|
112
148
|
let ws: WebSocket | null = null;
|
|
113
149
|
let reconnectTimer: number | null = null;
|
|
114
150
|
let nextReconnectInMs: number | null = null;
|
|
151
|
+
let nextReconnectAt: number | null = null;
|
|
115
152
|
let reconnectAttempt = 0;
|
|
116
153
|
let lastError: RuntimeErrorDetails | null = null;
|
|
117
154
|
let manualDisconnect = false;
|
|
118
155
|
let sessionBindingStateMutationQueue: Promise<void> = Promise.resolve();
|
|
119
156
|
let preserveHumanFocusDepth = 0;
|
|
157
|
+
let lastBindingUpdateAt: number | null = null;
|
|
158
|
+
let lastBindingUpdateReason: string | null = null;
|
|
120
159
|
|
|
121
160
|
async function getConfig(): Promise<ExtensionConfig> {
|
|
122
161
|
const stored = await chrome.storage.local.get([STORAGE_KEY_TOKEN, STORAGE_KEY_PORT, STORAGE_KEY_DEBUG_RICH_TEXT]);
|
|
@@ -151,13 +190,14 @@ function setRuntimeError(message: string, context: RuntimeErrorDetails['context'
|
|
|
151
190
|
};
|
|
152
191
|
}
|
|
153
192
|
|
|
154
|
-
function clearReconnectTimer(): void {
|
|
155
|
-
if (reconnectTimer !== null) {
|
|
156
|
-
clearTimeout(reconnectTimer);
|
|
157
|
-
reconnectTimer = null;
|
|
158
|
-
}
|
|
159
|
-
nextReconnectInMs = null;
|
|
160
|
-
|
|
193
|
+
function clearReconnectTimer(): void {
|
|
194
|
+
if (reconnectTimer !== null) {
|
|
195
|
+
clearTimeout(reconnectTimer);
|
|
196
|
+
reconnectTimer = null;
|
|
197
|
+
}
|
|
198
|
+
nextReconnectInMs = null;
|
|
199
|
+
nextReconnectAt = null;
|
|
200
|
+
}
|
|
161
201
|
|
|
162
202
|
function sendResponse(payload: CliResponse): void {
|
|
163
203
|
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
@@ -264,9 +304,71 @@ async function loadSessionBindingState(bindingId: string): Promise<SessionBindin
|
|
|
264
304
|
return stateMap[bindingId] ?? null;
|
|
265
305
|
}
|
|
266
306
|
|
|
267
|
-
async function listSessionBindingStates(): Promise<SessionBindingRecord[]> {
|
|
268
|
-
return Object.values(await loadSessionBindingStateMap());
|
|
269
|
-
}
|
|
307
|
+
async function listSessionBindingStates(): Promise<SessionBindingRecord[]> {
|
|
308
|
+
return Object.values(await loadSessionBindingStateMap());
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function summarizeSessionBindings(states: SessionBindingRecord[]): PopupState['sessionBindings'] {
|
|
312
|
+
const items = states.map((state) => {
|
|
313
|
+
const detached = state.windowId === null || state.tabIds.length === 0;
|
|
314
|
+
return {
|
|
315
|
+
id: state.id,
|
|
316
|
+
label: state.label,
|
|
317
|
+
tabCount: state.tabIds.length,
|
|
318
|
+
activeTabId: state.activeTabId,
|
|
319
|
+
windowId: state.windowId,
|
|
320
|
+
groupId: state.groupId,
|
|
321
|
+
detached
|
|
322
|
+
} satisfies PopupSessionBindingSummary;
|
|
323
|
+
});
|
|
324
|
+
return {
|
|
325
|
+
count: items.length,
|
|
326
|
+
attachedCount: items.filter((item) => !item.detached).length,
|
|
327
|
+
detachedCount: items.filter((item) => item.detached).length,
|
|
328
|
+
tabCount: items.reduce((sum, item) => sum + item.tabCount, 0),
|
|
329
|
+
items
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
async function buildPopupState(): Promise<PopupState> {
|
|
334
|
+
const config = await getConfig();
|
|
335
|
+
const sessionBindings = summarizeSessionBindings(await listSessionBindingStates());
|
|
336
|
+
const reconnectRemainingMs = nextReconnectAt === null ? null : Math.max(0, nextReconnectAt - Date.now());
|
|
337
|
+
let connectionState: PopupState['connectionState'];
|
|
338
|
+
if (!config.token) {
|
|
339
|
+
connectionState = 'missing-token';
|
|
340
|
+
} else if (ws?.readyState === WebSocket.OPEN) {
|
|
341
|
+
connectionState = 'connected';
|
|
342
|
+
} else if (ws?.readyState === WebSocket.CONNECTING) {
|
|
343
|
+
connectionState = 'connecting';
|
|
344
|
+
} else if (manualDisconnect) {
|
|
345
|
+
connectionState = 'manual';
|
|
346
|
+
} else if (nextReconnectInMs !== null) {
|
|
347
|
+
connectionState = 'reconnecting';
|
|
348
|
+
} else {
|
|
349
|
+
connectionState = 'disconnected';
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
return {
|
|
353
|
+
ok: true,
|
|
354
|
+
connected: ws?.readyState === WebSocket.OPEN,
|
|
355
|
+
connectionState,
|
|
356
|
+
hasToken: Boolean(config.token),
|
|
357
|
+
port: config.port,
|
|
358
|
+
wsUrl: `ws://127.0.0.1:${config.port}/extension`,
|
|
359
|
+
debugRichText: config.debugRichText,
|
|
360
|
+
lastError: lastError?.message ?? null,
|
|
361
|
+
lastErrorAt: lastError?.at ?? null,
|
|
362
|
+
lastErrorContext: lastError?.context ?? null,
|
|
363
|
+
reconnectAttempt,
|
|
364
|
+
nextReconnectInMs: reconnectRemainingMs,
|
|
365
|
+
manualDisconnect,
|
|
366
|
+
extensionVersion: EXTENSION_VERSION,
|
|
367
|
+
lastBindingUpdateAt,
|
|
368
|
+
lastBindingUpdateReason,
|
|
369
|
+
sessionBindings
|
|
370
|
+
};
|
|
371
|
+
}
|
|
270
372
|
|
|
271
373
|
async function saveSessionBindingState(state: SessionBindingRecord): Promise<void> {
|
|
272
374
|
await mutateSessionBindingStateMap((stateMap) => {
|
|
@@ -299,6 +401,8 @@ function emitSessionBindingUpdated(
|
|
|
299
401
|
state: SessionBindingRecord | null,
|
|
300
402
|
extras: Record<string, unknown> = {}
|
|
301
403
|
): void {
|
|
404
|
+
lastBindingUpdateAt = Date.now();
|
|
405
|
+
lastBindingUpdateReason = reason;
|
|
302
406
|
sendEvent('sessionBinding.updated', {
|
|
303
407
|
bindingId,
|
|
304
408
|
reason,
|
|
@@ -2336,14 +2440,16 @@ function scheduleReconnect(reason: string): void {
|
|
|
2336
2440
|
return;
|
|
2337
2441
|
}
|
|
2338
2442
|
|
|
2339
|
-
const delayMs = computeReconnectDelayMs(reconnectAttempt);
|
|
2340
|
-
reconnectAttempt += 1;
|
|
2341
|
-
nextReconnectInMs = delayMs;
|
|
2342
|
-
|
|
2343
|
-
|
|
2344
|
-
|
|
2345
|
-
|
|
2346
|
-
|
|
2443
|
+
const delayMs = computeReconnectDelayMs(reconnectAttempt);
|
|
2444
|
+
reconnectAttempt += 1;
|
|
2445
|
+
nextReconnectInMs = delayMs;
|
|
2446
|
+
nextReconnectAt = Date.now() + delayMs;
|
|
2447
|
+
reconnectTimer = setTimeout(() => {
|
|
2448
|
+
reconnectTimer = null;
|
|
2449
|
+
nextReconnectInMs = null;
|
|
2450
|
+
nextReconnectAt = null;
|
|
2451
|
+
void connectWebSocket();
|
|
2452
|
+
}, delayMs) as unknown as number;
|
|
2347
2453
|
|
|
2348
2454
|
if (!lastError) {
|
|
2349
2455
|
setRuntimeError(`Reconnect scheduled: ${reason}`, 'socket');
|
|
@@ -2366,26 +2472,30 @@ async function connectWebSocket(): Promise<void> {
|
|
|
2366
2472
|
return;
|
|
2367
2473
|
}
|
|
2368
2474
|
|
|
2369
|
-
const url = `ws://127.0.0.1:${config.port}/extension?token=${encodeURIComponent(config.token)}`;
|
|
2370
|
-
|
|
2371
|
-
|
|
2372
|
-
|
|
2373
|
-
|
|
2374
|
-
|
|
2375
|
-
|
|
2376
|
-
|
|
2475
|
+
const url = `ws://127.0.0.1:${config.port}/extension?token=${encodeURIComponent(config.token)}`;
|
|
2476
|
+
const socket = new WebSocket(url);
|
|
2477
|
+
ws = socket;
|
|
2478
|
+
|
|
2479
|
+
socket.addEventListener('open', () => {
|
|
2480
|
+
if (ws !== socket) {
|
|
2481
|
+
return;
|
|
2482
|
+
}
|
|
2483
|
+
manualDisconnect = false;
|
|
2484
|
+
reconnectAttempt = 0;
|
|
2485
|
+
lastError = null;
|
|
2486
|
+
socket.send(JSON.stringify({
|
|
2377
2487
|
type: 'hello',
|
|
2378
2488
|
role: 'extension',
|
|
2379
2489
|
version: EXTENSION_VERSION,
|
|
2380
2490
|
ts: Date.now()
|
|
2381
2491
|
}));
|
|
2382
2492
|
});
|
|
2383
|
-
|
|
2384
|
-
|
|
2385
|
-
try {
|
|
2386
|
-
const request = JSON.parse(String(event.data)) as CliRequest;
|
|
2387
|
-
if (!request.id || !request.method) {
|
|
2388
|
-
return;
|
|
2493
|
+
|
|
2494
|
+
socket.addEventListener('message', (event) => {
|
|
2495
|
+
try {
|
|
2496
|
+
const request = JSON.parse(String(event.data)) as CliRequest;
|
|
2497
|
+
if (!request.id || !request.method) {
|
|
2498
|
+
return;
|
|
2389
2499
|
}
|
|
2390
2500
|
void handleRequest(request)
|
|
2391
2501
|
.then((result) => {
|
|
@@ -2402,29 +2512,40 @@ async function connectWebSocket(): Promise<void> {
|
|
|
2402
2512
|
ok: false,
|
|
2403
2513
|
error: toError('E_INTERNAL', error instanceof Error ? error.message : String(error))
|
|
2404
2514
|
});
|
|
2405
|
-
}
|
|
2406
|
-
});
|
|
2407
|
-
|
|
2408
|
-
|
|
2409
|
-
ws
|
|
2410
|
-
|
|
2411
|
-
|
|
2412
|
-
|
|
2413
|
-
|
|
2414
|
-
|
|
2415
|
-
|
|
2416
|
-
|
|
2417
|
-
|
|
2515
|
+
}
|
|
2516
|
+
});
|
|
2517
|
+
|
|
2518
|
+
socket.addEventListener('close', () => {
|
|
2519
|
+
if (ws !== socket) {
|
|
2520
|
+
return;
|
|
2521
|
+
}
|
|
2522
|
+
ws = null;
|
|
2523
|
+
scheduleReconnect('socket-closed');
|
|
2524
|
+
});
|
|
2525
|
+
|
|
2526
|
+
socket.addEventListener('error', () => {
|
|
2527
|
+
if (ws !== socket) {
|
|
2528
|
+
return;
|
|
2529
|
+
}
|
|
2530
|
+
setRuntimeError('Cannot connect to bak cli', 'socket');
|
|
2531
|
+
socket.close();
|
|
2532
|
+
});
|
|
2533
|
+
}
|
|
2418
2534
|
|
|
2419
2535
|
chrome.tabs.onRemoved.addListener((tabId) => {
|
|
2420
2536
|
dropNetworkCapture(tabId);
|
|
2421
2537
|
void mutateSessionBindingStateMap((stateMap) => {
|
|
2422
|
-
const updates: Array<{ bindingId: string; state: SessionBindingRecord }> = [];
|
|
2538
|
+
const updates: Array<{ bindingId: string; state: SessionBindingRecord | null }> = [];
|
|
2423
2539
|
for (const [bindingId, state] of Object.entries(stateMap)) {
|
|
2424
2540
|
if (!state.tabIds.includes(tabId)) {
|
|
2425
2541
|
continue;
|
|
2426
2542
|
}
|
|
2427
2543
|
const nextTabIds = state.tabIds.filter((id) => id !== tabId);
|
|
2544
|
+
if (nextTabIds.length === 0) {
|
|
2545
|
+
delete stateMap[bindingId];
|
|
2546
|
+
updates.push({ bindingId, state: null });
|
|
2547
|
+
continue;
|
|
2548
|
+
}
|
|
2428
2549
|
const fallbackTabId = nextTabIds[0] ?? null;
|
|
2429
2550
|
const nextState: SessionBindingRecord = {
|
|
2430
2551
|
...state,
|
|
@@ -2482,21 +2603,13 @@ chrome.tabs.onActivated.addListener((activeInfo) => {
|
|
|
2482
2603
|
|
|
2483
2604
|
chrome.windows.onRemoved.addListener((windowId) => {
|
|
2484
2605
|
void mutateSessionBindingStateMap((stateMap) => {
|
|
2485
|
-
const updates: Array<{ bindingId: string; state: SessionBindingRecord }> = [];
|
|
2606
|
+
const updates: Array<{ bindingId: string; state: SessionBindingRecord | null }> = [];
|
|
2486
2607
|
for (const [bindingId, state] of Object.entries(stateMap)) {
|
|
2487
2608
|
if (state.windowId !== windowId) {
|
|
2488
2609
|
continue;
|
|
2489
2610
|
}
|
|
2490
|
-
|
|
2491
|
-
|
|
2492
|
-
windowId: null,
|
|
2493
|
-
groupId: null,
|
|
2494
|
-
tabIds: [],
|
|
2495
|
-
activeTabId: null,
|
|
2496
|
-
primaryTabId: null
|
|
2497
|
-
};
|
|
2498
|
-
stateMap[bindingId] = nextState;
|
|
2499
|
-
updates.push({ bindingId, state: nextState });
|
|
2611
|
+
delete stateMap[bindingId];
|
|
2612
|
+
updates.push({ bindingId, state: null });
|
|
2500
2613
|
}
|
|
2501
2614
|
return updates;
|
|
2502
2615
|
}).then((updates) => {
|
|
@@ -2517,48 +2630,49 @@ chrome.runtime.onStartup.addListener(() => {
|
|
|
2517
2630
|
void connectWebSocket();
|
|
2518
2631
|
|
|
2519
2632
|
chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
|
|
2520
|
-
if (message?.type === 'bak.updateConfig') {
|
|
2521
|
-
manualDisconnect = false;
|
|
2522
|
-
|
|
2523
|
-
|
|
2524
|
-
|
|
2525
|
-
|
|
2526
|
-
|
|
2527
|
-
|
|
2528
|
-
|
|
2529
|
-
|
|
2530
|
-
|
|
2531
|
-
|
|
2532
|
-
|
|
2533
|
-
if (message?.type === 'bak.getState') {
|
|
2534
|
-
void getConfig().then((config) => {
|
|
2535
|
-
sendResponse({
|
|
2536
|
-
ok: true,
|
|
2537
|
-
connected: ws?.readyState === WebSocket.OPEN,
|
|
2538
|
-
hasToken: Boolean(config.token),
|
|
2539
|
-
port: config.port,
|
|
2540
|
-
debugRichText: config.debugRichText,
|
|
2541
|
-
lastError: lastError?.message ?? null,
|
|
2542
|
-
lastErrorAt: lastError?.at ?? null,
|
|
2543
|
-
lastErrorContext: lastError?.context ?? null,
|
|
2544
|
-
reconnectAttempt,
|
|
2545
|
-
nextReconnectInMs
|
|
2546
|
-
});
|
|
2547
|
-
});
|
|
2548
|
-
return true;
|
|
2549
|
-
}
|
|
2550
|
-
|
|
2551
|
-
if (message?.type === 'bak.disconnect') {
|
|
2552
|
-
manualDisconnect = true;
|
|
2553
|
-
clearReconnectTimer();
|
|
2554
|
-
reconnectAttempt = 0;
|
|
2555
|
-
ws?.close();
|
|
2556
|
-
ws = null;
|
|
2557
|
-
sendResponse({ ok: true });
|
|
2558
|
-
return false;
|
|
2559
|
-
}
|
|
2633
|
+
if (message?.type === 'bak.updateConfig') {
|
|
2634
|
+
manualDisconnect = false;
|
|
2635
|
+
const token = typeof message.token === 'string' ? message.token.trim() : '';
|
|
2636
|
+
void setConfig({
|
|
2637
|
+
...(token ? { token } : {}),
|
|
2638
|
+
port: Number(message.port ?? DEFAULT_PORT),
|
|
2639
|
+
debugRichText: message.debugRichText === true
|
|
2640
|
+
}).then(() => {
|
|
2641
|
+
ws?.close();
|
|
2642
|
+
void connectWebSocket().then(() => sendResponse({ ok: true }));
|
|
2643
|
+
});
|
|
2644
|
+
return true;
|
|
2645
|
+
}
|
|
2560
2646
|
|
|
2561
|
-
|
|
2562
|
-
|
|
2647
|
+
if (message?.type === 'bak.getState') {
|
|
2648
|
+
void buildPopupState().then((state) => {
|
|
2649
|
+
sendResponse(state);
|
|
2650
|
+
});
|
|
2651
|
+
return true;
|
|
2652
|
+
}
|
|
2653
|
+
|
|
2654
|
+
if (message?.type === 'bak.disconnect') {
|
|
2655
|
+
manualDisconnect = true;
|
|
2656
|
+
clearReconnectTimer();
|
|
2657
|
+
reconnectAttempt = 0;
|
|
2658
|
+
lastError = null;
|
|
2659
|
+
ws?.close();
|
|
2660
|
+
ws = null;
|
|
2661
|
+
sendResponse({ ok: true });
|
|
2662
|
+
return false;
|
|
2663
|
+
}
|
|
2664
|
+
|
|
2665
|
+
if (message?.type === 'bak.reconnectNow') {
|
|
2666
|
+
manualDisconnect = false;
|
|
2667
|
+
clearReconnectTimer();
|
|
2668
|
+
reconnectAttempt = 0;
|
|
2669
|
+
ws?.close();
|
|
2670
|
+
ws = null;
|
|
2671
|
+
void connectWebSocket().then(() => sendResponse({ ok: true }));
|
|
2672
|
+
return true;
|
|
2673
|
+
}
|
|
2674
|
+
|
|
2675
|
+
return false;
|
|
2676
|
+
});
|
|
2563
2677
|
|
|
2564
2678
|
|
package/src/popup.ts
CHANGED
|
@@ -3,28 +3,140 @@ const tokenInput = document.getElementById('token') as HTMLInputElement;
|
|
|
3
3
|
const portInput = document.getElementById('port') as HTMLInputElement;
|
|
4
4
|
const debugRichTextInput = document.getElementById('debugRichText') as HTMLInputElement;
|
|
5
5
|
const saveBtn = document.getElementById('save') as HTMLButtonElement;
|
|
6
|
+
const reconnectBtn = document.getElementById('reconnect') as HTMLButtonElement;
|
|
6
7
|
const disconnectBtn = document.getElementById('disconnect') as HTMLButtonElement;
|
|
8
|
+
const connectionStateEl = document.getElementById('connectionState') as HTMLDivElement;
|
|
9
|
+
const tokenStateEl = document.getElementById('tokenState') as HTMLDivElement;
|
|
10
|
+
const reconnectStateEl = document.getElementById('reconnectState') as HTMLDivElement;
|
|
11
|
+
const connectionUrlEl = document.getElementById('connectionUrl') as HTMLDivElement;
|
|
12
|
+
const lastErrorEl = document.getElementById('lastError') as HTMLDivElement;
|
|
13
|
+
const lastBindingUpdateEl = document.getElementById('lastBindingUpdate') as HTMLDivElement;
|
|
14
|
+
const extensionVersionEl = document.getElementById('extensionVersion') as HTMLDivElement;
|
|
15
|
+
const sessionSummaryEl = document.getElementById('sessionSummary') as HTMLDivElement;
|
|
16
|
+
const sessionListEl = document.getElementById('sessionList') as HTMLUListElement;
|
|
17
|
+
|
|
18
|
+
interface PopupState {
|
|
19
|
+
ok: boolean;
|
|
20
|
+
connected: boolean;
|
|
21
|
+
connectionState: 'connected' | 'connecting' | 'reconnecting' | 'disconnected' | 'manual' | 'missing-token';
|
|
22
|
+
hasToken: boolean;
|
|
23
|
+
port: number;
|
|
24
|
+
wsUrl: string;
|
|
25
|
+
debugRichText: boolean;
|
|
26
|
+
lastError: string | null;
|
|
27
|
+
lastErrorAt: number | null;
|
|
28
|
+
lastErrorContext: string | null;
|
|
29
|
+
reconnectAttempt: number;
|
|
30
|
+
nextReconnectInMs: number | null;
|
|
31
|
+
manualDisconnect: boolean;
|
|
32
|
+
extensionVersion: string;
|
|
33
|
+
lastBindingUpdateAt: number | null;
|
|
34
|
+
lastBindingUpdateReason: string | null;
|
|
35
|
+
sessionBindings: {
|
|
36
|
+
count: number;
|
|
37
|
+
attachedCount: number;
|
|
38
|
+
detachedCount: number;
|
|
39
|
+
tabCount: number;
|
|
40
|
+
items: Array<{
|
|
41
|
+
id: string;
|
|
42
|
+
label: string;
|
|
43
|
+
tabCount: number;
|
|
44
|
+
activeTabId: number | null;
|
|
45
|
+
windowId: number | null;
|
|
46
|
+
groupId: number | null;
|
|
47
|
+
detached: boolean;
|
|
48
|
+
}>;
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
let latestState: PopupState | null = null;
|
|
7
52
|
|
|
8
53
|
function setStatus(text: string, bad = false): void {
|
|
9
54
|
statusEl.textContent = text;
|
|
10
55
|
statusEl.style.color = bad ? '#dc2626' : '#0f172a';
|
|
11
56
|
}
|
|
12
57
|
|
|
58
|
+
function formatTimeAgo(at: number | null): string {
|
|
59
|
+
if (typeof at !== 'number') {
|
|
60
|
+
return 'never';
|
|
61
|
+
}
|
|
62
|
+
const deltaSeconds = Math.max(0, Math.round((Date.now() - at) / 1000));
|
|
63
|
+
if (deltaSeconds < 5) {
|
|
64
|
+
return 'just now';
|
|
65
|
+
}
|
|
66
|
+
if (deltaSeconds < 60) {
|
|
67
|
+
return `${deltaSeconds}s ago`;
|
|
68
|
+
}
|
|
69
|
+
const deltaMinutes = Math.round(deltaSeconds / 60);
|
|
70
|
+
if (deltaMinutes < 60) {
|
|
71
|
+
return `${deltaMinutes}m ago`;
|
|
72
|
+
}
|
|
73
|
+
const deltaHours = Math.round(deltaMinutes / 60);
|
|
74
|
+
return `${deltaHours}h ago`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function renderSessionBindings(state: PopupState['sessionBindings']): void {
|
|
78
|
+
sessionSummaryEl.textContent = `${state.count} sessions, ${state.attachedCount} attached, ${state.tabCount} tabs, ${state.detachedCount} detached`;
|
|
79
|
+
sessionListEl.replaceChildren();
|
|
80
|
+
for (const item of state.items) {
|
|
81
|
+
const li = document.createElement('li');
|
|
82
|
+
const location = item.windowId === null ? 'no window' : `window ${item.windowId}`;
|
|
83
|
+
const active = item.activeTabId === null ? 'no active tab' : `active ${item.activeTabId}`;
|
|
84
|
+
li.textContent = `${item.label}: ${item.tabCount} tabs, ${location}, ${active}`;
|
|
85
|
+
if (item.detached) {
|
|
86
|
+
li.style.color = '#b45309';
|
|
87
|
+
}
|
|
88
|
+
sessionListEl.appendChild(li);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function renderConnectionDetails(state: PopupState): void {
|
|
93
|
+
connectionStateEl.textContent = state.connectionState;
|
|
94
|
+
tokenStateEl.textContent = state.hasToken ? 'configured' : 'missing';
|
|
95
|
+
connectionUrlEl.textContent = state.wsUrl;
|
|
96
|
+
extensionVersionEl.textContent = state.extensionVersion;
|
|
97
|
+
|
|
98
|
+
if (state.manualDisconnect) {
|
|
99
|
+
reconnectStateEl.textContent = 'manual disconnect';
|
|
100
|
+
} else if (typeof state.nextReconnectInMs === 'number') {
|
|
101
|
+
const seconds = Math.max(0, Math.ceil(state.nextReconnectInMs / 100) / 10);
|
|
102
|
+
reconnectStateEl.textContent = `attempt ${state.reconnectAttempt}, retry in ${seconds}s`;
|
|
103
|
+
} else if (state.connected) {
|
|
104
|
+
reconnectStateEl.textContent = 'connected';
|
|
105
|
+
} else {
|
|
106
|
+
reconnectStateEl.textContent = 'idle';
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (state.lastError) {
|
|
110
|
+
const context = state.lastErrorContext ? `${state.lastErrorContext}: ` : '';
|
|
111
|
+
lastErrorEl.textContent = `${context}${state.lastError} (${formatTimeAgo(state.lastErrorAt)})`;
|
|
112
|
+
} else {
|
|
113
|
+
lastErrorEl.textContent = 'none';
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (state.lastBindingUpdateReason) {
|
|
117
|
+
lastBindingUpdateEl.textContent = `${state.lastBindingUpdateReason} (${formatTimeAgo(state.lastBindingUpdateAt)})`;
|
|
118
|
+
} else {
|
|
119
|
+
lastBindingUpdateEl.textContent = 'none';
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
13
123
|
async function refreshState(): Promise<void> {
|
|
14
|
-
const state = (await chrome.runtime.sendMessage({ type: 'bak.getState' })) as
|
|
15
|
-
ok: boolean;
|
|
16
|
-
connected: boolean;
|
|
17
|
-
hasToken: boolean;
|
|
18
|
-
port: number;
|
|
19
|
-
debugRichText: boolean;
|
|
20
|
-
lastError: string | null;
|
|
21
|
-
};
|
|
124
|
+
const state = (await chrome.runtime.sendMessage({ type: 'bak.getState' })) as PopupState;
|
|
22
125
|
|
|
23
126
|
if (state.ok) {
|
|
127
|
+
latestState = state;
|
|
24
128
|
portInput.value = String(state.port);
|
|
25
129
|
debugRichTextInput.checked = Boolean(state.debugRichText);
|
|
130
|
+
renderConnectionDetails(state);
|
|
131
|
+
renderSessionBindings(state.sessionBindings);
|
|
26
132
|
if (state.connected) {
|
|
27
133
|
setStatus('Connected to bak CLI');
|
|
134
|
+
} else if (state.connectionState === 'missing-token') {
|
|
135
|
+
setStatus('Pair token is required', true);
|
|
136
|
+
} else if (state.connectionState === 'manual') {
|
|
137
|
+
setStatus('Disconnected manually');
|
|
138
|
+
} else if (state.connectionState === 'reconnecting') {
|
|
139
|
+
setStatus('Reconnecting to bak CLI', true);
|
|
28
140
|
} else if (state.lastError) {
|
|
29
141
|
setStatus(`Disconnected: ${state.lastError}`, true);
|
|
30
142
|
} else {
|
|
@@ -37,7 +149,7 @@ saveBtn.addEventListener('click', async () => {
|
|
|
37
149
|
const token = tokenInput.value.trim();
|
|
38
150
|
const port = Number.parseInt(portInput.value.trim(), 10);
|
|
39
151
|
|
|
40
|
-
if (!token) {
|
|
152
|
+
if (!token && latestState?.hasToken !== true) {
|
|
41
153
|
setStatus('Pair token is required', true);
|
|
42
154
|
return;
|
|
43
155
|
}
|
|
@@ -49,11 +161,17 @@ saveBtn.addEventListener('click', async () => {
|
|
|
49
161
|
|
|
50
162
|
await chrome.runtime.sendMessage({
|
|
51
163
|
type: 'bak.updateConfig',
|
|
52
|
-
token,
|
|
164
|
+
...(token ? { token } : {}),
|
|
53
165
|
port,
|
|
54
166
|
debugRichText: debugRichTextInput.checked
|
|
55
167
|
});
|
|
56
168
|
|
|
169
|
+
tokenInput.value = '';
|
|
170
|
+
await refreshState();
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
reconnectBtn.addEventListener('click', async () => {
|
|
174
|
+
await chrome.runtime.sendMessage({ type: 'bak.reconnectNow' });
|
|
57
175
|
await refreshState();
|
|
58
176
|
});
|
|
59
177
|
|
|
@@ -62,4 +180,10 @@ disconnectBtn.addEventListener('click', async () => {
|
|
|
62
180
|
await refreshState();
|
|
63
181
|
});
|
|
64
182
|
|
|
65
|
-
void refreshState();
|
|
183
|
+
void refreshState();
|
|
184
|
+
const refreshInterval = window.setInterval(() => {
|
|
185
|
+
void refreshState();
|
|
186
|
+
}, 1000);
|
|
187
|
+
window.addEventListener('unload', () => {
|
|
188
|
+
window.clearInterval(refreshInterval);
|
|
189
|
+
});
|
package/src/session-binding.ts
CHANGED
|
@@ -451,22 +451,9 @@ class SessionBindingManager {
|
|
|
451
451
|
const remainingTabIds = ensured.binding.tabIds.filter((candidate) => candidate !== resolvedTabId);
|
|
452
452
|
|
|
453
453
|
if (remainingTabIds.length === 0) {
|
|
454
|
-
|
|
455
|
-
id: ensured.binding.id,
|
|
456
|
-
label: ensured.binding.label,
|
|
457
|
-
color: ensured.binding.color,
|
|
458
|
-
windowId: null,
|
|
459
|
-
groupId: null,
|
|
460
|
-
tabIds: [],
|
|
461
|
-
activeTabId: null,
|
|
462
|
-
primaryTabId: null
|
|
463
|
-
};
|
|
464
|
-
await this.storage.save(emptied);
|
|
454
|
+
await this.storage.delete(ensured.binding.id);
|
|
465
455
|
return {
|
|
466
|
-
binding:
|
|
467
|
-
...emptied,
|
|
468
|
-
tabs: []
|
|
469
|
-
},
|
|
456
|
+
binding: null,
|
|
470
457
|
closedTabId: resolvedTabId
|
|
471
458
|
};
|
|
472
459
|
}
|