@flrande/bak-extension 0.6.8 → 0.6.10

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.
@@ -1,9 +1,9 @@
1
- export const DEFAULT_SESSION_BINDING_LABEL = 'bak agent';
2
- export const DEFAULT_SESSION_BINDING_COLOR = 'blue';
3
- export const DEFAULT_SESSION_BINDING_URL = 'about:blank';
4
- const WINDOW_LOOKUP_TIMEOUT_MS = 1_500;
5
- const GROUP_LOOKUP_TIMEOUT_MS = 1_000;
6
- const WINDOW_TABS_LOOKUP_TIMEOUT_MS = 1_500;
1
+ export const DEFAULT_SESSION_BINDING_LABEL = 'bak agent';
2
+ export const DEFAULT_SESSION_BINDING_COLOR = 'blue';
3
+ export const DEFAULT_SESSION_BINDING_URL = 'about:blank';
4
+ const WINDOW_LOOKUP_TIMEOUT_MS = 1_500;
5
+ const GROUP_LOOKUP_TIMEOUT_MS = 1_000;
6
+ const WINDOW_TABS_LOOKUP_TIMEOUT_MS = 1_500;
7
7
 
8
8
  export type SessionBindingColor = 'grey' | 'blue' | 'red' | 'yellow' | 'green' | 'pink' | 'purple' | 'cyan' | 'orange';
9
9
 
@@ -16,11 +16,11 @@ export interface SessionBindingTab {
16
16
  groupId: number | null;
17
17
  }
18
18
 
19
- export interface SessionBindingWindow {
20
- id: number;
21
- focused: boolean;
22
- initialTabId?: number | null;
23
- }
19
+ export interface SessionBindingWindow {
20
+ id: number;
21
+ focused: boolean;
22
+ initialTabId?: number | null;
23
+ }
24
24
 
25
25
  export interface SessionBindingGroup {
26
26
  id: number;
@@ -68,8 +68,8 @@ export interface SessionBindingStorage {
68
68
  list(): Promise<SessionBindingRecord[]>;
69
69
  }
70
70
 
71
- export interface SessionBindingBrowser {
72
- getTab(tabId: number): Promise<SessionBindingTab | null>;
71
+ export interface SessionBindingBrowser {
72
+ getTab(tabId: number): Promise<SessionBindingTab | null>;
73
73
  getActiveTab(): Promise<SessionBindingTab | null>;
74
74
  listTabs(filter?: { windowId?: number }): Promise<SessionBindingTab[]>;
75
75
  createTab(options: { windowId?: number; url?: string; active?: boolean }): Promise<SessionBindingTab>;
@@ -81,29 +81,23 @@ export interface SessionBindingBrowser {
81
81
  closeWindow(windowId: number): Promise<void>;
82
82
  getGroup(groupId: number): Promise<SessionBindingGroup | null>;
83
83
  groupTabs(tabIds: number[], groupId?: number): Promise<number>;
84
- updateGroup(groupId: number, options: { title?: string; color?: SessionBindingColor; collapsed?: boolean }): Promise<SessionBindingGroup>;
85
- }
86
-
87
- interface SessionBindingWindowOwnership {
88
- bindingTabs: SessionBindingTab[];
89
- sharedBindingTabs: SessionBindingTab[];
90
- foreignTabs: SessionBindingTab[];
91
- }
92
-
84
+ updateGroup(groupId: number, options: { title?: string; color?: SessionBindingColor; collapsed?: boolean }): Promise<SessionBindingGroup>;
85
+ }
86
+
93
87
  export interface SessionBindingEnsureOptions {
94
- bindingId?: string;
95
- focus?: boolean;
96
- initialUrl?: string;
97
- label?: string;
98
- }
99
-
100
- export interface SessionBindingOpenTabOptions {
101
- bindingId?: string;
102
- url?: string;
103
- active?: boolean;
104
- focus?: boolean;
105
- label?: string;
106
- }
88
+ bindingId?: string;
89
+ focus?: boolean;
90
+ initialUrl?: string;
91
+ label?: string;
92
+ }
93
+
94
+ export interface SessionBindingOpenTabOptions {
95
+ bindingId?: string;
96
+ url?: string;
97
+ active?: boolean;
98
+ focus?: boolean;
99
+ label?: string;
100
+ }
107
101
 
108
102
  export interface SessionBindingResolveTargetOptions {
109
103
  tabId?: number;
@@ -124,13 +118,13 @@ class SessionBindingManager {
124
118
  return this.inspectBinding(bindingId);
125
119
  }
126
120
 
127
- async ensureBinding(options: SessionBindingEnsureOptions = {}): Promise<SessionBindingEnsureResult> {
128
- const bindingId = this.normalizeBindingId(options.bindingId);
129
- const repairActions: string[] = [];
130
- const initialUrl = options.initialUrl ?? DEFAULT_SESSION_BINDING_URL;
131
- const persisted = await this.storage.load(bindingId);
132
- const created = !persisted;
133
- let state = this.normalizeState(persisted, bindingId, options.label);
121
+ async ensureBinding(options: SessionBindingEnsureOptions = {}): Promise<SessionBindingEnsureResult> {
122
+ const bindingId = this.normalizeBindingId(options.bindingId);
123
+ const repairActions: string[] = [];
124
+ const initialUrl = options.initialUrl ?? DEFAULT_SESSION_BINDING_URL;
125
+ const persisted = await this.storage.load(bindingId);
126
+ const created = !persisted;
127
+ let state = this.normalizeState(persisted, bindingId, options.label);
134
128
 
135
129
  const originalWindowId = state.windowId;
136
130
  let window = state.windowId !== null ? await this.waitForWindow(state.windowId) : null;
@@ -145,46 +139,53 @@ class SessionBindingManager {
145
139
  }
146
140
  }
147
141
  }
142
+ if (!window) {
143
+ const activeWindow = await this.attachBindingToActiveWindow(state);
144
+ if (activeWindow) {
145
+ window = activeWindow;
146
+ repairActions.push('attached-active-window');
147
+ }
148
+ }
148
149
  if (!window) {
149
150
  const sharedWindow = await this.findSharedBindingWindow(bindingId);
150
151
  if (sharedWindow) {
151
152
  state.windowId = sharedWindow.id;
152
153
  state.groupId = null;
153
- state.tabIds = [];
154
- state.activeTabId = null;
155
- state.primaryTabId = null;
156
- window = sharedWindow;
157
- repairActions.push('attached-shared-window');
158
- } else {
159
- const createdWindow = await this.browser.createWindow({
160
- url: initialUrl,
161
- focused: options.focus === true
162
- });
163
- state.windowId = createdWindow.id;
164
- state.groupId = null;
165
- state.tabIds = [];
166
- state.activeTabId = null;
167
- state.primaryTabId = null;
168
- window = createdWindow;
169
- const initialTab =
170
- typeof createdWindow.initialTabId === 'number'
171
- ? await this.waitForTrackedTab(createdWindow.initialTabId, createdWindow.id)
172
- : null;
173
- tabs = initialTab ? [initialTab] : await this.waitForWindowTabs(createdWindow.id);
174
- state.tabIds = tabs.map((tab) => tab.id);
175
- if (state.primaryTabId === null) {
176
- state.primaryTabId = initialTab?.id ?? tabs[0]?.id ?? null;
177
- }
178
- if (state.activeTabId === null) {
179
- state.activeTabId = tabs.find((tab) => tab.active)?.id ?? initialTab?.id ?? tabs[0]?.id ?? null;
180
- }
181
- repairActions.push(created ? 'created-window' : 'recreated-window');
182
- }
154
+ state.tabIds = [];
155
+ state.activeTabId = null;
156
+ state.primaryTabId = null;
157
+ window = sharedWindow;
158
+ repairActions.push('attached-shared-window');
159
+ } else {
160
+ const createdWindow = await this.browser.createWindow({
161
+ url: initialUrl,
162
+ focused: options.focus === true
163
+ });
164
+ state.windowId = createdWindow.id;
165
+ state.groupId = null;
166
+ state.tabIds = [];
167
+ state.activeTabId = null;
168
+ state.primaryTabId = null;
169
+ window = createdWindow;
170
+ const initialTab =
171
+ typeof createdWindow.initialTabId === 'number'
172
+ ? await this.waitForTrackedTab(createdWindow.initialTabId, createdWindow.id)
173
+ : null;
174
+ tabs = initialTab ? [initialTab] : await this.waitForWindowTabs(createdWindow.id);
175
+ state.tabIds = tabs.map((tab) => tab.id);
176
+ if (state.primaryTabId === null) {
177
+ state.primaryTabId = initialTab?.id ?? tabs[0]?.id ?? null;
178
+ }
179
+ if (state.activeTabId === null) {
180
+ state.activeTabId = tabs.find((tab) => tab.active)?.id ?? initialTab?.id ?? tabs[0]?.id ?? null;
181
+ }
182
+ repairActions.push(created ? 'created-window' : 'recreated-window');
183
+ }
183
184
  }
184
185
 
185
186
  tabs = tabs.length > 0 ? tabs : await this.readTrackedTabs(state.tabIds, state.windowId);
186
187
  const recoveredTabs = await this.recoverBindingTabs(state, tabs);
187
- if (recoveredTabs.length > tabs.length) {
188
+ if (recoveredTabs.length > tabs.length) {
188
189
  tabs = recoveredTabs;
189
190
  repairActions.push('recovered-tracked-tabs');
190
191
  }
@@ -193,26 +194,15 @@ class SessionBindingManager {
193
194
  }
194
195
  state.tabIds = tabs.map((tab) => tab.id);
195
196
 
196
- if (state.windowId !== null) {
197
- const ownership = await this.inspectBindingWindowOwnership(state, state.windowId);
198
- if (ownership.foreignTabs.length > 0 && ownership.bindingTabs.length > 0) {
199
- const migrated = await this.moveBindingIntoBakWindow(state, ownership, initialUrl);
200
- window = migrated.window;
201
- tabs = migrated.tabs;
202
- state.tabIds = tabs.map((tab) => tab.id);
203
- repairActions.push('evacuated-foreign-window');
204
- }
205
- }
206
-
207
197
  if (tabs.length === 0) {
208
- const primary = await this.createBindingTab({
209
- windowId: state.windowId,
210
- url: initialUrl,
211
- active: true
212
- });
213
- tabs = [primary];
214
- state.tabIds = [primary.id];
215
- state.primaryTabId = primary.id;
198
+ const primary = await this.createBindingTab({
199
+ windowId: state.windowId,
200
+ url: initialUrl,
201
+ active: options.focus === true
202
+ });
203
+ tabs = [primary];
204
+ state.tabIds = [primary.id];
205
+ state.primaryTabId = primary.id;
216
206
  state.activeTabId = primary.id;
217
207
  repairActions.push('created-primary-tab');
218
208
  }
@@ -227,7 +217,7 @@ class SessionBindingManager {
227
217
  repairActions.push('reassigned-active-tab');
228
218
  }
229
219
 
230
- let group = state.groupId !== null ? await this.waitForGroup(state.groupId) : null;
220
+ let group = state.groupId !== null ? await this.waitForGroup(state.groupId) : null;
231
221
  if (!group || group.windowId !== state.windowId) {
232
222
  const groupId = await this.browser.groupTabs(tabs.map((tab) => tab.id));
233
223
  group = await this.browser.updateGroup(groupId, {
@@ -265,12 +255,12 @@ class SessionBindingManager {
265
255
  }
266
256
  state.tabIds = [...new Set(tabs.map((tab) => tab.id))];
267
257
 
268
- if (options.focus === true && state.activeTabId !== null) {
269
- await this.browser.updateTab(state.activeTabId, { active: true });
270
- window = await this.browser.updateWindow(state.windowId!, { focused: true });
271
- void window;
272
- repairActions.push('focused-window');
273
- }
258
+ if (options.focus === true && state.windowId !== null && state.activeTabId !== null) {
259
+ await this.focusBindingWindow(state.windowId, state.activeTabId);
260
+ window = await this.waitForWindow(state.windowId, 300);
261
+ void window;
262
+ repairActions.push('focused-window');
263
+ }
274
264
 
275
265
  await this.storage.save(state);
276
266
 
@@ -285,15 +275,15 @@ class SessionBindingManager {
285
275
  };
286
276
  }
287
277
 
288
- async openTab(options: SessionBindingOpenTabOptions = {}): Promise<{ binding: SessionBindingInfo; tab: SessionBindingTab }> {
289
- const bindingId = this.normalizeBindingId(options.bindingId);
290
- const hadBinding = (await this.loadBindingRecord(bindingId)) !== null;
291
- const ensured = await this.ensureBinding({
292
- bindingId,
293
- focus: false,
294
- initialUrl: hadBinding ? options.url ?? DEFAULT_SESSION_BINDING_URL : DEFAULT_SESSION_BINDING_URL,
295
- label: options.label
296
- });
278
+ async openTab(options: SessionBindingOpenTabOptions = {}): Promise<{ binding: SessionBindingInfo; tab: SessionBindingTab }> {
279
+ const bindingId = this.normalizeBindingId(options.bindingId);
280
+ const hadBinding = (await this.loadBindingRecord(bindingId)) !== null;
281
+ const ensured = await this.ensureBinding({
282
+ bindingId,
283
+ focus: false,
284
+ initialUrl: hadBinding ? options.url ?? DEFAULT_SESSION_BINDING_URL : DEFAULT_SESSION_BINDING_URL,
285
+ label: options.label
286
+ });
297
287
  let state = { ...ensured.binding, tabIds: [...ensured.binding.tabIds], tabs: [...ensured.binding.tabs] };
298
288
  if (state.windowId !== null && state.tabs.length === 0) {
299
289
  const rebound = await this.rebindBindingWindow(state);
@@ -305,12 +295,12 @@ class SessionBindingManager {
305
295
  }
306
296
  const active = options.active === true;
307
297
  const desiredUrl = options.url ?? DEFAULT_SESSION_BINDING_URL;
308
- let reusablePrimaryTab = await this.resolveReusablePrimaryTab(
309
- state,
310
- ensured.created ||
311
- ensured.repairActions.includes('recreated-window') ||
312
- ensured.repairActions.includes('created-primary-tab')
313
- );
298
+ let reusablePrimaryTab = await this.resolveReusablePrimaryTab(
299
+ state,
300
+ ensured.created ||
301
+ ensured.repairActions.includes('recreated-window') ||
302
+ ensured.repairActions.includes('created-primary-tab')
303
+ );
314
304
 
315
305
  let createdTab: SessionBindingTab;
316
306
  try {
@@ -328,12 +318,12 @@ class SessionBindingManager {
328
318
  if (!this.isMissingWindowError(error)) {
329
319
  throw error;
330
320
  }
331
- const repaired = await this.ensureBinding({
332
- bindingId,
333
- focus: false,
334
- initialUrl: desiredUrl,
335
- label: options.label
336
- });
321
+ const repaired = await this.ensureBinding({
322
+ bindingId,
323
+ focus: false,
324
+ initialUrl: desiredUrl,
325
+ label: options.label
326
+ });
337
327
  state = { ...repaired.binding };
338
328
  reusablePrimaryTab = await this.resolveReusablePrimaryTab(state, true);
339
329
  createdTab = reusablePrimaryTab
@@ -361,14 +351,13 @@ class SessionBindingManager {
361
351
  windowId: state.windowId,
362
352
  groupId,
363
353
  tabIds: nextTabIds,
364
- activeTabId: active || options.focus === true ? createdTab.id : state.activeTabId ?? state.primaryTabId ?? createdTab.id,
354
+ activeTabId: active || options.focus === true ? createdTab.id : state.activeTabId ?? state.primaryTabId ?? createdTab.id,
365
355
  primaryTabId: state.primaryTabId ?? createdTab.id
366
356
  };
367
357
 
368
- if (options.focus === true) {
369
- await this.browser.updateTab(createdTab.id, { active: true });
370
- await this.browser.updateWindow(state.windowId!, { focused: true });
371
- }
358
+ if (options.focus === true && state.windowId !== null) {
359
+ await this.focusBindingWindow(state.windowId, createdTab.id);
360
+ }
372
361
 
373
362
  await this.storage.save(nextState);
374
363
  const tabs = await this.readTrackedTabs(nextState.tabIds, nextState.windowId);
@@ -442,96 +431,136 @@ class SessionBindingManager {
442
431
  }
443
432
 
444
433
  async focus(bindingId: string): Promise<{ ok: true; binding: SessionBindingInfo }> {
445
- const ensured = await this.ensureBinding({ bindingId, focus: false });
446
- if (ensured.binding.activeTabId !== null) {
447
- await this.browser.updateTab(ensured.binding.activeTabId, { active: true });
448
- }
449
- if (ensured.binding.windowId !== null) {
450
- await this.browser.updateWindow(ensured.binding.windowId, { focused: true });
451
- }
452
- const refreshed = await this.ensureBinding({ bindingId, focus: false });
453
- return { ok: true, binding: refreshed.binding };
454
- }
434
+ const normalizedBindingId = this.normalizeBindingId(bindingId);
435
+ let binding =
436
+ (await this.inspectBinding(normalizedBindingId)) ??
437
+ (
438
+ await this.ensureBinding({
439
+ bindingId: normalizedBindingId,
440
+ focus: false
441
+ })
442
+ ).binding;
443
+ let targetTabId = this.resolveFocusTabId(binding);
455
444
 
456
- async closeTab(bindingId: string, tabId?: number): Promise<{ binding: SessionBindingInfo | null; closedTabId: number }> {
457
- const binding = await this.inspectBinding(bindingId);
458
- if (!binding) {
459
- throw new Error(`Binding ${bindingId} does not exist`);
460
- }
461
- const resolvedTabId =
462
- typeof tabId === 'number'
463
- ? tabId
464
- : binding.activeTabId ?? binding.primaryTabId ?? binding.tabs[0]?.id;
465
- if (typeof resolvedTabId !== 'number' || !binding.tabIds.includes(resolvedTabId)) {
466
- throw new Error(`Tab ${tabId ?? 'active'} does not belong to binding ${bindingId}`);
445
+ if (binding.windowId === null || targetTabId === null) {
446
+ binding = (
447
+ await this.ensureBinding({
448
+ bindingId: normalizedBindingId,
449
+ focus: false
450
+ })
451
+ ).binding;
452
+ targetTabId = this.resolveFocusTabId(binding);
467
453
  }
468
454
 
469
- await this.browser.closeTab(resolvedTabId);
470
- const remainingTabIds = binding.tabIds.filter((candidate) => candidate !== resolvedTabId);
471
-
472
- if (remainingTabIds.length === 0) {
473
- await this.storage.delete(binding.id);
474
- return {
475
- binding: null,
476
- closedTabId: resolvedTabId
477
- };
455
+ if (binding.windowId !== null && targetTabId !== null) {
456
+ try {
457
+ await this.focusBindingWindow(binding.windowId, targetTabId);
458
+ } catch (error) {
459
+ if (!this.isMissingWindowError(error)) {
460
+ throw error;
461
+ }
462
+ binding = (
463
+ await this.ensureBinding({
464
+ bindingId: normalizedBindingId,
465
+ focus: false
466
+ })
467
+ ).binding;
468
+ targetTabId = this.resolveFocusTabId(binding);
469
+ if (binding.windowId !== null && targetTabId !== null) {
470
+ await this.focusBindingWindow(binding.windowId, targetTabId);
471
+ }
472
+ }
478
473
  }
479
474
 
480
- const tabs = await this.readLooseTrackedTabs(remainingTabIds);
481
- const nextPrimaryTabId =
482
- binding.primaryTabId === resolvedTabId ? tabs[0]?.id ?? null : binding.primaryTabId;
483
- const nextActiveTabId =
484
- binding.activeTabId === resolvedTabId
485
- ? tabs.find((candidate) => candidate.active)?.id ?? nextPrimaryTabId ?? tabs[0]?.id ?? null
486
- : binding.activeTabId;
487
- const nextState: SessionBindingRecord = {
488
- id: binding.id,
489
- label: binding.label,
490
- color: binding.color,
491
- windowId: tabs[0]?.windowId ?? binding.windowId,
492
- groupId: tabs[0]?.groupId ?? binding.groupId,
493
- tabIds: tabs.map((candidate) => candidate.id),
494
- activeTabId: nextActiveTabId,
495
- primaryTabId: nextPrimaryTabId
496
- };
497
- await this.storage.save(nextState);
475
+ const refreshed = (await this.inspectBinding(normalizedBindingId)) ?? binding;
498
476
  return {
499
- binding: {
500
- ...nextState,
501
- tabs
502
- },
503
- closedTabId: resolvedTabId
477
+ ok: true,
478
+ binding:
479
+ refreshed.windowId !== null && targetTabId !== null
480
+ ? this.withFocusedTab(refreshed, targetTabId)
481
+ : refreshed
504
482
  };
505
483
  }
506
-
507
- async reset(options: SessionBindingEnsureOptions = {}): Promise<SessionBindingEnsureResult> {
508
- const bindingId = this.normalizeBindingId(options.bindingId);
509
- await this.close(bindingId);
484
+
485
+ async closeTab(bindingId: string, tabId?: number): Promise<{ binding: SessionBindingInfo | null; closedTabId: number }> {
486
+ const binding = await this.inspectBinding(bindingId);
487
+ if (!binding) {
488
+ throw new Error(`Binding ${bindingId} does not exist`);
489
+ }
490
+ const resolvedTabId =
491
+ typeof tabId === 'number'
492
+ ? tabId
493
+ : binding.activeTabId ?? binding.primaryTabId ?? binding.tabs[0]?.id;
494
+ if (typeof resolvedTabId !== 'number' || !binding.tabIds.includes(resolvedTabId)) {
495
+ throw new Error(`Tab ${tabId ?? 'active'} does not belong to binding ${bindingId}`);
496
+ }
497
+
498
+ await this.browser.closeTab(resolvedTabId);
499
+ const remainingTabIds = binding.tabIds.filter((candidate) => candidate !== resolvedTabId);
500
+
501
+ if (remainingTabIds.length === 0) {
502
+ await this.storage.delete(binding.id);
503
+ return {
504
+ binding: null,
505
+ closedTabId: resolvedTabId
506
+ };
507
+ }
508
+
509
+ const tabs = await this.readLooseTrackedTabs(remainingTabIds);
510
+ const nextPrimaryTabId =
511
+ binding.primaryTabId === resolvedTabId ? tabs[0]?.id ?? null : binding.primaryTabId;
512
+ const nextActiveTabId =
513
+ binding.activeTabId === resolvedTabId
514
+ ? tabs.find((candidate) => candidate.active)?.id ?? nextPrimaryTabId ?? tabs[0]?.id ?? null
515
+ : binding.activeTabId;
516
+ const nextState: SessionBindingRecord = {
517
+ id: binding.id,
518
+ label: binding.label,
519
+ color: binding.color,
520
+ windowId: tabs[0]?.windowId ?? binding.windowId,
521
+ groupId: tabs[0]?.groupId ?? binding.groupId,
522
+ tabIds: tabs.map((candidate) => candidate.id),
523
+ activeTabId: nextActiveTabId,
524
+ primaryTabId: nextPrimaryTabId
525
+ };
526
+ await this.storage.save(nextState);
527
+ return {
528
+ binding: {
529
+ ...nextState,
530
+ tabs
531
+ },
532
+ closedTabId: resolvedTabId
533
+ };
534
+ }
535
+
536
+ async reset(options: SessionBindingEnsureOptions = {}): Promise<SessionBindingEnsureResult> {
537
+ const bindingId = this.normalizeBindingId(options.bindingId);
538
+ await this.close(bindingId);
510
539
  return this.ensureBinding({
511
540
  ...options,
512
541
  bindingId
513
542
  });
514
543
  }
515
544
 
516
- async close(bindingId: string): Promise<{ ok: true }> {
517
- const state = await this.loadBindingRecord(bindingId);
518
- if (!state) {
519
- await this.storage.delete(bindingId);
520
- return { ok: true };
521
- }
522
- // Clear persisted state before closing the window so tab/window removal
523
- // listeners cannot race and resurrect an empty binding record.
524
- await this.storage.delete(bindingId);
525
- const trackedTabs = await this.collectBindingTabsForClose(state);
526
- for (const trackedTab of trackedTabs) {
527
- try {
528
- await this.browser.closeTab(trackedTab.id);
529
- } catch {
530
- // Ignore tabs that were already removed before explicit close.
531
- }
532
- }
533
- return { ok: true };
534
- }
545
+ async close(bindingId: string): Promise<{ ok: true }> {
546
+ const state = await this.loadBindingRecord(bindingId);
547
+ if (!state) {
548
+ await this.storage.delete(bindingId);
549
+ return { ok: true };
550
+ }
551
+ // Clear persisted state before closing the window so tab/window removal
552
+ // listeners cannot race and resurrect an empty binding record.
553
+ await this.storage.delete(bindingId);
554
+ const trackedTabs = await this.collectBindingTabsForClose(state);
555
+ for (const trackedTab of trackedTabs) {
556
+ try {
557
+ await this.browser.closeTab(trackedTab.id);
558
+ } catch {
559
+ // Ignore tabs that were already removed before explicit close.
560
+ }
561
+ }
562
+ return { ok: true };
563
+ }
535
564
 
536
565
  async resolveTarget(options: SessionBindingResolveTargetOptions = {}): Promise<SessionBindingTargetResolution> {
537
566
  if (typeof options.tabId === 'number') {
@@ -584,13 +613,13 @@ class SessionBindingManager {
584
613
  return candidate;
585
614
  }
586
615
 
587
- private normalizeState(state: SessionBindingRecord | null, bindingId: string, label?: string): SessionBindingRecord {
588
- return {
589
- id: bindingId,
590
- label: label?.trim() ? label.trim() : state?.label ?? DEFAULT_SESSION_BINDING_LABEL,
591
- color: state?.color ?? DEFAULT_SESSION_BINDING_COLOR,
592
- windowId: state?.windowId ?? null,
593
- groupId: state?.groupId ?? null,
616
+ private normalizeState(state: SessionBindingRecord | null, bindingId: string, label?: string): SessionBindingRecord {
617
+ return {
618
+ id: bindingId,
619
+ label: label?.trim() ? label.trim() : state?.label ?? DEFAULT_SESSION_BINDING_LABEL,
620
+ color: state?.color ?? DEFAULT_SESSION_BINDING_COLOR,
621
+ windowId: state?.windowId ?? null,
622
+ groupId: state?.groupId ?? null,
594
623
  tabIds: state?.tabIds ?? [],
595
624
  activeTabId: state?.activeTabId ?? null,
596
625
  primaryTabId: state?.primaryTabId ?? null
@@ -687,7 +716,7 @@ class SessionBindingManager {
687
716
  return [...new Set(state.tabIds.concat([state.activeTabId, state.primaryTabId].filter((value): value is number => typeof value === 'number')))];
688
717
  }
689
718
 
690
- private async rebindBindingWindow(state: SessionBindingRecord): Promise<{ window: SessionBindingWindow; tabs: SessionBindingTab[] } | null> {
719
+ private async rebindBindingWindow(state: SessionBindingRecord): Promise<{ window: SessionBindingWindow; tabs: SessionBindingTab[] } | null> {
691
720
  const candidateWindowIds: number[] = [];
692
721
  const pushWindowId = (windowId: number | null | undefined): void => {
693
722
  if (typeof windowId !== 'number') {
@@ -698,7 +727,7 @@ class SessionBindingManager {
698
727
  }
699
728
  };
700
729
 
701
- const group = state.groupId !== null ? await this.waitForGroup(state.groupId) : null;
730
+ const group = state.groupId !== null ? await this.waitForGroup(state.groupId) : null;
702
731
  pushWindowId(group?.windowId);
703
732
 
704
733
  const trackedTabs = await this.readLooseTrackedTabs(this.collectCandidateTabIds(state));
@@ -707,13 +736,13 @@ class SessionBindingManager {
707
736
  }
708
737
 
709
738
  for (const candidateWindowId of candidateWindowIds) {
710
- const window = await this.waitForWindow(candidateWindowId);
739
+ const window = await this.waitForWindow(candidateWindowId);
711
740
  if (!window) {
712
741
  continue;
713
742
  }
714
743
  let tabs = await this.readTrackedTabs(this.collectCandidateTabIds(state), candidateWindowId);
715
744
  if (tabs.length === 0 && group?.id !== null && group?.windowId === candidateWindowId) {
716
- const windowTabs = await this.waitForWindowTabs(candidateWindowId, WINDOW_TABS_LOOKUP_TIMEOUT_MS);
745
+ const windowTabs = await this.waitForWindowTabs(candidateWindowId, WINDOW_TABS_LOOKUP_TIMEOUT_MS);
717
746
  tabs = windowTabs.filter((tab) => tab.groupId === group.id);
718
747
  }
719
748
  if (tabs.length === 0) {
@@ -735,146 +764,21 @@ class SessionBindingManager {
735
764
  return null;
736
765
  }
737
766
 
738
- private async inspectBindingWindowOwnership(state: SessionBindingRecord, windowId: number): Promise<SessionBindingWindowOwnership> {
739
- const windowTabs = await this.waitForWindowTabs(windowId, 500);
740
- const bindingTabIds = new Set(this.collectCandidateTabIds(state));
741
- const peerBindings = (await this.storage.list()).filter((candidate) => candidate.id !== state.id);
742
- const peerTabIds = new Set<number>();
743
- const peerGroupIds = new Set<number>();
744
- for (const peer of peerBindings) {
745
- for (const tabId of this.collectCandidateTabIds(peer)) {
746
- peerTabIds.add(tabId);
747
- }
748
- if (peer.groupId !== null) {
749
- peerGroupIds.add(peer.groupId);
750
- }
751
- }
752
-
753
- const bindingTabs: SessionBindingTab[] = [];
754
- const sharedBindingTabs: SessionBindingTab[] = [];
755
- const foreignTabs: SessionBindingTab[] = [];
756
- for (const tab of windowTabs) {
757
- if (bindingTabIds.has(tab.id) || (state.groupId !== null && tab.groupId === state.groupId)) {
758
- bindingTabs.push(tab);
759
- continue;
760
- }
761
- if (peerTabIds.has(tab.id) || (tab.groupId !== null && peerGroupIds.has(tab.groupId))) {
762
- sharedBindingTabs.push(tab);
763
- continue;
764
- }
765
- foreignTabs.push(tab);
766
- }
767
-
768
- return {
769
- bindingTabs,
770
- sharedBindingTabs,
771
- foreignTabs
772
- };
773
- }
774
-
775
- private async moveBindingIntoBakWindow(
776
- state: SessionBindingRecord,
777
- ownership: SessionBindingWindowOwnership,
778
- initialUrl: string
779
- ): Promise<{ window: SessionBindingWindow; tabs: SessionBindingTab[] }> {
780
- const sourceTabs = this.orderSessionBindingTabsForMigration(state, ownership.bindingTabs);
781
- const seedUrl = sourceTabs[0]?.url ?? initialUrl;
782
- const sharedWindow = await this.findSharedBindingWindow(state.id, state.windowId === null ? [] : [state.windowId]);
783
- const window =
784
- sharedWindow ??
785
- (await this.browser.createWindow({
786
- url: seedUrl || DEFAULT_SESSION_BINDING_URL,
787
- focused: false
788
- }));
789
- const initialTab =
790
- sharedWindow || typeof window.initialTabId !== 'number' ? null : await this.waitForTrackedTab(window.initialTabId, window.id);
791
- const recreatedTabs: SessionBindingTab[] = [];
792
- const tabIdMap = new Map<number, number>();
793
-
794
- if (sourceTabs[0]) {
795
- const firstTab = initialTab
796
- ? await this.browser.updateTab(initialTab.id, {
797
- url: sourceTabs[0].url,
798
- active: false
799
- })
800
- : await this.createBindingTab({
801
- windowId: window.id,
802
- url: sourceTabs[0].url,
803
- active: false
804
- });
805
- recreatedTabs.push(firstTab);
806
- tabIdMap.set(sourceTabs[0].id, firstTab.id);
807
- }
808
-
809
- for (const sourceTab of sourceTabs.slice(1)) {
810
- const recreated = await this.createBindingTab({
811
- windowId: window.id,
812
- url: sourceTab.url,
813
- active: false
814
- });
815
- recreatedTabs.push(recreated);
816
- tabIdMap.set(sourceTab.id, recreated.id);
767
+ private async attachBindingToActiveWindow(state: SessionBindingRecord): Promise<SessionBindingWindow | null> {
768
+ const activeTab = await this.browser.getActiveTab();
769
+ if (!activeTab) {
770
+ return null;
817
771
  }
818
-
819
- const nextPrimaryTabId =
820
- (state.primaryTabId !== null ? tabIdMap.get(state.primaryTabId) : undefined) ??
821
- recreatedTabs[0]?.id ??
822
- null;
823
- const nextActiveTabId =
824
- (state.activeTabId !== null ? tabIdMap.get(state.activeTabId) : undefined) ?? nextPrimaryTabId ?? recreatedTabs[0]?.id ?? null;
825
- if (nextActiveTabId !== null) {
826
- await this.browser.updateTab(nextActiveTabId, { active: true });
772
+ const window = await this.waitForWindow(activeTab.windowId, 300);
773
+ if (!window) {
774
+ return null;
827
775
  }
828
-
829
776
  state.windowId = window.id;
830
777
  state.groupId = null;
831
- state.tabIds = recreatedTabs.map((tab) => tab.id);
832
- state.primaryTabId = nextPrimaryTabId;
833
- state.activeTabId = nextActiveTabId;
834
- await this.storage.save({
835
- ...state,
836
- tabIds: [...state.tabIds]
837
- });
838
-
839
- for (const bindingTab of ownership.bindingTabs) {
840
- try {
841
- await this.browser.closeTab(bindingTab.id);
842
- } catch {
843
- // Ignore tabs that were already removed while evacuating the binding.
844
- }
845
- }
846
-
847
- return {
848
- window,
849
- tabs: await this.readTrackedTabs(state.tabIds, state.windowId)
850
- };
851
- }
852
-
853
- private orderSessionBindingTabsForMigration(state: SessionBindingRecord, tabs: SessionBindingTab[]): SessionBindingTab[] {
854
- const ordered: SessionBindingTab[] = [];
855
- const seen = new Set<number>();
856
- const pushById = (tabId: number | null): void => {
857
- if (typeof tabId !== 'number') {
858
- return;
859
- }
860
- const tab = tabs.find((candidate) => candidate.id === tabId);
861
- if (!tab || seen.has(tab.id)) {
862
- return;
863
- }
864
- ordered.push(tab);
865
- seen.add(tab.id);
866
- };
867
-
868
- pushById(state.primaryTabId);
869
- pushById(state.activeTabId);
870
- for (const tab of tabs) {
871
- if (seen.has(tab.id)) {
872
- continue;
873
- }
874
- ordered.push(tab);
875
- seen.add(tab.id);
876
- }
877
- return ordered;
778
+ state.tabIds = [];
779
+ state.activeTabId = null;
780
+ state.primaryTabId = null;
781
+ return window;
878
782
  }
879
783
 
880
784
  private async findSharedBindingWindow(bindingId: string, excludedWindowIds: number[] = []): Promise<SessionBindingWindow | null> {
@@ -886,51 +790,47 @@ class SessionBindingManager {
886
790
  if (typeof windowId !== 'number') {
887
791
  return;
888
792
  }
889
- if (excludedWindowIds.includes(windowId) || candidateWindowIds.includes(windowId)) {
890
- return;
891
- }
892
- candidateWindowIds.push(windowId);
893
- };
793
+ if (excludedWindowIds.includes(windowId) || candidateWindowIds.includes(windowId)) {
794
+ return;
795
+ }
796
+ candidateWindowIds.push(windowId);
797
+ };
894
798
 
895
799
  for (const peer of peers) {
896
- pushWindowId(peer.windowId);
897
- if (peer.groupId !== null) {
898
- peerGroupIds.add(peer.groupId);
899
- const group = await this.waitForGroup(peer.groupId, 300);
900
- pushWindowId(group?.windowId);
901
- }
902
800
  const trackedTabIds = this.collectCandidateTabIds(peer);
801
+ pushWindowId(peer.windowId);
903
802
  for (const trackedTabId of trackedTabIds) {
904
803
  peerTabIds.add(trackedTabId);
905
804
  }
805
+ if (peer.groupId !== null) {
806
+ const group = await this.waitForGroup(peer.groupId, 300);
807
+ if (group) {
808
+ peerGroupIds.add(group.id);
809
+ pushWindowId(group.windowId);
810
+ }
811
+ }
906
812
  const trackedTabs = await this.readLooseTrackedTabs(trackedTabIds);
907
813
  for (const tab of trackedTabs) {
908
814
  pushWindowId(tab.windowId);
909
815
  }
910
816
  }
911
-
817
+
912
818
  for (const windowId of candidateWindowIds) {
913
819
  const window = await this.waitForWindow(windowId, 300);
914
820
  if (window) {
915
- const windowTabs = await this.waitForWindowTabs(window.id, 300);
821
+ const windowTabs = await this.readStableWindowTabs(window.id, WINDOW_TABS_LOOKUP_TIMEOUT_MS, 150);
916
822
  const ownedTabs = windowTabs.filter(
917
823
  (tab) => peerTabIds.has(tab.id) || (tab.groupId !== null && peerGroupIds.has(tab.groupId))
918
824
  );
919
825
  if (ownedTabs.length === 0) {
920
826
  continue;
921
827
  }
922
- const foreignTabs = windowTabs.filter(
923
- (tab) => !peerTabIds.has(tab.id) && (tab.groupId === null || !peerGroupIds.has(tab.groupId))
924
- );
925
- if (foreignTabs.length > 0) {
926
- continue;
927
- }
928
828
  return window;
929
829
  }
930
830
  }
931
-
932
- return null;
933
- }
831
+
832
+ return null;
833
+ }
934
834
 
935
835
  private async recoverBindingTabs(state: SessionBindingRecord, existingTabs: SessionBindingTab[]): Promise<SessionBindingTab[]> {
936
836
  if (state.windowId === null) {
@@ -964,19 +864,43 @@ class SessionBindingManager {
964
864
  return existingTabs;
965
865
  }
966
866
 
967
- private async collectBindingTabsForClose(state: SessionBindingRecord): Promise<SessionBindingTab[]> {
968
- const trackedTabs = await this.readLooseTrackedTabs(this.collectCandidateTabIds(state));
969
- if (state.windowId === null || state.groupId === null) {
970
- return trackedTabs;
971
- }
972
- const windowTabs = await this.waitForWindowTabs(state.windowId, 300);
973
- const groupedTabs = windowTabs.filter((tab) => tab.groupId === state.groupId);
974
- const merged = new Map<number, SessionBindingTab>();
975
- for (const tab of [...trackedTabs, ...groupedTabs]) {
976
- merged.set(tab.id, tab);
867
+ private resolveFocusTabId(binding: Pick<SessionBindingInfo, 'activeTabId' | 'primaryTabId' | 'tabs'>): number | null {
868
+ return binding.activeTabId ?? binding.primaryTabId ?? binding.tabs[0]?.id ?? null;
869
+ }
870
+
871
+ private withFocusedTab(binding: SessionBindingInfo, targetTabId: number): SessionBindingInfo {
872
+ return {
873
+ ...binding,
874
+ activeTabId: targetTabId,
875
+ tabs: binding.tabs.map((tab) => ({
876
+ ...tab,
877
+ active: tab.id === targetTabId
878
+ }))
879
+ };
880
+ }
881
+
882
+ private async focusBindingWindow(windowId: number, tabId: number): Promise<void> {
883
+ await this.browser.updateWindow(windowId, { focused: true });
884
+ await this.browser.updateTab(tabId, { active: true });
885
+ const focusedTab = await this.waitForTrackedTab(tabId, windowId, 500);
886
+ if (!focusedTab?.active) {
887
+ await this.browser.updateTab(tabId, { active: true });
977
888
  }
978
- return [...merged.values()];
979
889
  }
890
+
891
+ private async collectBindingTabsForClose(state: SessionBindingRecord): Promise<SessionBindingTab[]> {
892
+ const trackedTabs = await this.readLooseTrackedTabs(this.collectCandidateTabIds(state));
893
+ if (state.windowId === null || state.groupId === null) {
894
+ return trackedTabs;
895
+ }
896
+ const windowTabs = await this.waitForWindowTabs(state.windowId, 300);
897
+ const groupedTabs = windowTabs.filter((tab) => tab.groupId === state.groupId);
898
+ const merged = new Map<number, SessionBindingTab>();
899
+ for (const tab of [...trackedTabs, ...groupedTabs]) {
900
+ merged.set(tab.id, tab);
901
+ }
902
+ return [...merged.values()];
903
+ }
980
904
 
981
905
  private async createBindingTab(options: { windowId: number | null; url: string; active: boolean }): Promise<SessionBindingTab> {
982
906
  if (options.windowId === null) {
@@ -1005,18 +929,18 @@ class SessionBindingManager {
1005
929
  throw lastError ?? new Error(`No window with id: ${options.windowId}.`);
1006
930
  }
1007
931
 
1008
- private async inspectBinding(bindingId: string): Promise<SessionBindingInfo | null> {
1009
- const state = await this.loadBindingRecord(bindingId);
1010
- if (!state) {
1011
- return null;
1012
- }
1013
-
1014
- let tabs = await this.readTrackedTabs(state.tabIds, state.windowId);
1015
- tabs = await this.recoverBindingTabs(state, tabs);
1016
- const activeTab = state.activeTabId !== null ? await this.waitForTrackedTab(state.activeTabId, state.windowId, 300) : null;
1017
- if (activeTab && !tabs.some((tab) => tab.id === activeTab.id)) {
1018
- tabs = [...tabs, activeTab];
1019
- }
932
+ private async inspectBinding(bindingId: string): Promise<SessionBindingInfo | null> {
933
+ const state = await this.loadBindingRecord(bindingId);
934
+ if (!state) {
935
+ return null;
936
+ }
937
+
938
+ let tabs = await this.readTrackedTabs(state.tabIds, state.windowId);
939
+ tabs = await this.recoverBindingTabs(state, tabs);
940
+ const activeTab = state.activeTabId !== null ? await this.waitForTrackedTab(state.activeTabId, state.windowId, 300) : null;
941
+ if (activeTab && !tabs.some((tab) => tab.id === activeTab.id)) {
942
+ tabs = [...tabs, activeTab];
943
+ }
1020
944
  if (tabs.length === 0 && state.primaryTabId !== null) {
1021
945
  const primaryTab = await this.waitForTrackedTab(state.primaryTabId, state.windowId, 300);
1022
946
  if (primaryTab) {
@@ -1036,12 +960,12 @@ class SessionBindingManager {
1036
960
  return null;
1037
961
  }
1038
962
  if (binding.primaryTabId !== null) {
1039
- const trackedPrimary = binding.tabs.find((tab) => tab.id === binding.primaryTabId) ?? (await this.waitForTrackedTab(binding.primaryTabId, binding.windowId));
963
+ const trackedPrimary = binding.tabs.find((tab) => tab.id === binding.primaryTabId) ?? (await this.waitForTrackedTab(binding.primaryTabId, binding.windowId));
1040
964
  if (trackedPrimary && (allowReuse || this.isReusableBlankSessionBindingTab(trackedPrimary, binding))) {
1041
965
  return trackedPrimary;
1042
966
  }
1043
967
  }
1044
- const windowTabs = await this.waitForWindowTabs(binding.windowId, WINDOW_TABS_LOOKUP_TIMEOUT_MS);
968
+ const windowTabs = await this.waitForWindowTabs(binding.windowId, WINDOW_TABS_LOOKUP_TIMEOUT_MS);
1045
969
  if (windowTabs.length !== 1) {
1046
970
  return null;
1047
971
  }
@@ -1060,29 +984,29 @@ class SessionBindingManager {
1060
984
  return normalizedUrl === '' || normalizedUrl === DEFAULT_SESSION_BINDING_URL;
1061
985
  }
1062
986
 
1063
- private async waitForWindow(windowId: number, timeoutMs = WINDOW_LOOKUP_TIMEOUT_MS): Promise<SessionBindingWindow | null> {
1064
- const deadline = Date.now() + timeoutMs;
1065
- while (Date.now() < deadline) {
1066
- const window = await this.browser.getWindow(windowId);
1067
- if (window) {
1068
- return window;
987
+ private async waitForWindow(windowId: number, timeoutMs = WINDOW_LOOKUP_TIMEOUT_MS): Promise<SessionBindingWindow | null> {
988
+ const deadline = Date.now() + timeoutMs;
989
+ while (Date.now() < deadline) {
990
+ const window = await this.browser.getWindow(windowId);
991
+ if (window) {
992
+ return window;
1069
993
  }
1070
994
  await this.delay(50);
1071
995
  }
1072
- return null;
1073
- }
1074
-
1075
- private async waitForGroup(groupId: number, timeoutMs = GROUP_LOOKUP_TIMEOUT_MS): Promise<SessionBindingGroup | null> {
1076
- const deadline = Date.now() + timeoutMs;
1077
- while (Date.now() < deadline) {
1078
- const group = await this.browser.getGroup(groupId);
1079
- if (group) {
1080
- return group;
1081
- }
1082
- await this.delay(50);
1083
- }
1084
- return null;
1085
- }
996
+ return null;
997
+ }
998
+
999
+ private async waitForGroup(groupId: number, timeoutMs = GROUP_LOOKUP_TIMEOUT_MS): Promise<SessionBindingGroup | null> {
1000
+ const deadline = Date.now() + timeoutMs;
1001
+ while (Date.now() < deadline) {
1002
+ const group = await this.browser.getGroup(groupId);
1003
+ if (group) {
1004
+ return group;
1005
+ }
1006
+ await this.delay(50);
1007
+ }
1008
+ return null;
1009
+ }
1086
1010
 
1087
1011
  private async waitForTrackedTab(tabId: number, windowId: number | null, timeoutMs = 1_000): Promise<SessionBindingTab | null> {
1088
1012
  const deadline = Date.now() + timeoutMs;
@@ -1097,16 +1021,55 @@ class SessionBindingManager {
1097
1021
  }
1098
1022
 
1099
1023
  private async waitForWindowTabs(windowId: number, timeoutMs = WINDOW_TABS_LOOKUP_TIMEOUT_MS): Promise<SessionBindingTab[]> {
1100
- const deadline = Date.now() + timeoutMs;
1101
- while (Date.now() < deadline) {
1102
- const tabs = await this.browser.listTabs({ windowId });
1103
- if (tabs.length > 0) {
1024
+ const deadline = Date.now() + timeoutMs;
1025
+ while (Date.now() < deadline) {
1026
+ const tabs = await this.browser.listTabs({ windowId });
1027
+ if (tabs.length > 0) {
1104
1028
  return tabs;
1105
1029
  }
1106
1030
  await this.delay(50);
1107
- }
1108
- return [];
1109
- }
1031
+ }
1032
+ return [];
1033
+ }
1034
+
1035
+ private async readStableWindowTabs(
1036
+ windowId: number,
1037
+ timeoutMs = WINDOW_TABS_LOOKUP_TIMEOUT_MS,
1038
+ settleMs = 150
1039
+ ): Promise<SessionBindingTab[]> {
1040
+ const deadline = Date.now() + timeoutMs;
1041
+ let bestTabs: SessionBindingTab[] = [];
1042
+ let bestSignature = '';
1043
+ let lastSignature = '';
1044
+ let stableSince = 0;
1045
+
1046
+ while (Date.now() < deadline) {
1047
+ const tabs = await this.browser.listTabs({ windowId });
1048
+ if (tabs.length > 0) {
1049
+ const signature = this.windowTabSnapshotSignature(tabs);
1050
+ if (tabs.length > bestTabs.length || (tabs.length === bestTabs.length && signature !== bestSignature)) {
1051
+ bestTabs = tabs;
1052
+ bestSignature = signature;
1053
+ }
1054
+ if (signature !== lastSignature) {
1055
+ lastSignature = signature;
1056
+ stableSince = Date.now();
1057
+ } else if (Date.now() - stableSince >= settleMs) {
1058
+ return bestTabs;
1059
+ }
1060
+ }
1061
+ await this.delay(50);
1062
+ }
1063
+
1064
+ return bestTabs;
1065
+ }
1066
+
1067
+ private windowTabSnapshotSignature(tabs: SessionBindingTab[]): string {
1068
+ return [...tabs]
1069
+ .map((tab) => `${tab.id}:${tab.groupId ?? 'ungrouped'}`)
1070
+ .sort()
1071
+ .join('|');
1072
+ }
1110
1073
 
1111
1074
  private async delay(ms: number): Promise<void> {
1112
1075
  await new Promise((resolve) => setTimeout(resolve, ms));
@@ -1118,4 +1081,4 @@ class SessionBindingManager {
1118
1081
  }
1119
1082
  }
1120
1083
 
1121
- export { SessionBindingManager };
1084
+ export { SessionBindingManager };