@flrande/bak-extension 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,705 @@
1
+ import type { ConsoleEntry, Locator } from '@flrande/bak-protocol';
2
+ import { isSupportedAutomationUrl } from './url-policy.js';
3
+ import { computeReconnectDelayMs } from './reconnect.js';
4
+
5
+ interface CliRequest {
6
+ id: string;
7
+ method: string;
8
+ params?: Record<string, unknown>;
9
+ }
10
+
11
+ interface CliResponse {
12
+ id: string;
13
+ ok: boolean;
14
+ result?: unknown;
15
+ error?: {
16
+ code: string;
17
+ message: string;
18
+ data?: Record<string, unknown>;
19
+ };
20
+ }
21
+
22
+ interface ExtensionConfig {
23
+ token: string;
24
+ port: number;
25
+ debugRichText: boolean;
26
+ }
27
+
28
+ interface RuntimeErrorDetails {
29
+ message: string;
30
+ context: 'config' | 'socket' | 'request' | 'parse';
31
+ at: number;
32
+ }
33
+
34
+ const DEFAULT_PORT = 17373;
35
+ const STORAGE_KEY_TOKEN = 'pairToken';
36
+ const STORAGE_KEY_PORT = 'cliPort';
37
+ const STORAGE_KEY_DEBUG_RICH_TEXT = 'debugRichText';
38
+ const DEFAULT_TAB_LOAD_TIMEOUT_MS = 40_000;
39
+
40
+ let ws: WebSocket | null = null;
41
+ let reconnectTimer: number | null = null;
42
+ let nextReconnectInMs: number | null = null;
43
+ let reconnectAttempt = 0;
44
+ let lastError: RuntimeErrorDetails | null = null;
45
+ let manualDisconnect = false;
46
+
47
+ async function getConfig(): Promise<ExtensionConfig> {
48
+ const stored = await chrome.storage.local.get([STORAGE_KEY_TOKEN, STORAGE_KEY_PORT, STORAGE_KEY_DEBUG_RICH_TEXT]);
49
+ return {
50
+ token: typeof stored[STORAGE_KEY_TOKEN] === 'string' ? stored[STORAGE_KEY_TOKEN] : '',
51
+ port: typeof stored[STORAGE_KEY_PORT] === 'number' ? stored[STORAGE_KEY_PORT] : DEFAULT_PORT,
52
+ debugRichText: stored[STORAGE_KEY_DEBUG_RICH_TEXT] === true
53
+ };
54
+ }
55
+
56
+ async function setConfig(config: Partial<ExtensionConfig>): Promise<void> {
57
+ const payload: Record<string, unknown> = {};
58
+ if (typeof config.token === 'string') {
59
+ payload[STORAGE_KEY_TOKEN] = config.token;
60
+ }
61
+ if (typeof config.port === 'number') {
62
+ payload[STORAGE_KEY_PORT] = config.port;
63
+ }
64
+ if (typeof config.debugRichText === 'boolean') {
65
+ payload[STORAGE_KEY_DEBUG_RICH_TEXT] = config.debugRichText;
66
+ }
67
+ if (Object.keys(payload).length > 0) {
68
+ await chrome.storage.local.set(payload);
69
+ }
70
+ }
71
+
72
+ function setRuntimeError(message: string, context: RuntimeErrorDetails['context']): void {
73
+ lastError = {
74
+ message,
75
+ context,
76
+ at: Date.now()
77
+ };
78
+ }
79
+
80
+ function clearReconnectTimer(): void {
81
+ if (reconnectTimer !== null) {
82
+ clearTimeout(reconnectTimer);
83
+ reconnectTimer = null;
84
+ }
85
+ nextReconnectInMs = null;
86
+ }
87
+
88
+ function sendResponse(payload: CliResponse): void {
89
+ if (ws && ws.readyState === WebSocket.OPEN) {
90
+ ws.send(JSON.stringify(payload));
91
+ }
92
+ }
93
+
94
+ function toError(code: string, message: string, data?: Record<string, unknown>): CliResponse['error'] {
95
+ return { code, message, data };
96
+ }
97
+
98
+ function normalizeUnhandledError(error: unknown): CliResponse['error'] {
99
+ if (typeof error === 'object' && error !== null && 'code' in error) {
100
+ return error as CliResponse['error'];
101
+ }
102
+
103
+ const message = error instanceof Error ? error.message : String(error);
104
+ const lower = message.toLowerCase();
105
+
106
+ if (lower.includes('no tab with id') || lower.includes('no window with id')) {
107
+ return toError('E_NOT_FOUND', message);
108
+ }
109
+ if (lower.includes('invalid url') || lower.includes('url is invalid')) {
110
+ return toError('E_INVALID_PARAMS', message);
111
+ }
112
+ if (lower.includes('cannot access contents of url') || lower.includes('permission denied')) {
113
+ return toError('E_PERMISSION', message);
114
+ }
115
+
116
+ return toError('E_INTERNAL', message);
117
+ }
118
+
119
+ async function waitForTabComplete(tabId: number, timeoutMs = DEFAULT_TAB_LOAD_TIMEOUT_MS): Promise<void> {
120
+ try {
121
+ const current = await chrome.tabs.get(tabId);
122
+ if (current.status === 'complete') {
123
+ return;
124
+ }
125
+ } catch {
126
+ return;
127
+ }
128
+
129
+ await new Promise<void>((resolve, reject) => {
130
+ let done = false;
131
+ const probeStatus = (): void => {
132
+ void chrome.tabs
133
+ .get(tabId)
134
+ .then((tab) => {
135
+ if (tab.status === 'complete') {
136
+ finish();
137
+ }
138
+ })
139
+ .catch(() => {
140
+ finish(new Error(`tab removed before load complete: ${tabId}`));
141
+ });
142
+ };
143
+
144
+ const finish = (error?: Error): void => {
145
+ if (done) {
146
+ return;
147
+ }
148
+ done = true;
149
+ clearTimeout(timeoutTimer);
150
+ clearInterval(pollTimer);
151
+ chrome.tabs.onUpdated.removeListener(onUpdated);
152
+ chrome.tabs.onRemoved.removeListener(onRemoved);
153
+ if (error) {
154
+ reject(error);
155
+ return;
156
+ }
157
+ resolve();
158
+ };
159
+
160
+ const onUpdated = (updatedTabId: number, changeInfo: { status?: string }): void => {
161
+ if (updatedTabId !== tabId) {
162
+ return;
163
+ }
164
+ if (changeInfo.status === 'complete') {
165
+ finish();
166
+ }
167
+ };
168
+
169
+ const onRemoved = (removedTabId: number): void => {
170
+ if (removedTabId === tabId) {
171
+ finish(new Error(`tab removed before load complete: ${tabId}`));
172
+ }
173
+ };
174
+
175
+ const pollTimer = setInterval(probeStatus, 250);
176
+ const timeoutTimer = setTimeout(() => {
177
+ finish(new Error(`tab load timeout: ${tabId}`));
178
+ }, timeoutMs);
179
+
180
+ chrome.tabs.onUpdated.addListener(onUpdated);
181
+ chrome.tabs.onRemoved.addListener(onRemoved);
182
+ probeStatus();
183
+ });
184
+ }
185
+
186
+ interface WithTabOptions {
187
+ requireSupportedAutomationUrl?: boolean;
188
+ }
189
+
190
+ async function withTab(tabId?: number, options: WithTabOptions = {}): Promise<chrome.tabs.Tab> {
191
+ const requireSupportedAutomationUrl = options.requireSupportedAutomationUrl !== false;
192
+ const validate = (tab: chrome.tabs.Tab): chrome.tabs.Tab => {
193
+ if (!tab.id) {
194
+ throw toError('E_NOT_FOUND', 'Tab missing id');
195
+ }
196
+ if (requireSupportedAutomationUrl && !isSupportedAutomationUrl(tab.url)) {
197
+ throw toError('E_PERMISSION', 'Unsupported tab URL: only http/https pages can be automated', {
198
+ url: tab.url ?? ''
199
+ });
200
+ }
201
+ return tab;
202
+ };
203
+
204
+ if (typeof tabId === 'number') {
205
+ const tab = await chrome.tabs.get(tabId);
206
+ return validate(tab);
207
+ }
208
+ const tabs = await chrome.tabs.query({ active: true, lastFocusedWindow: true });
209
+ const tab = tabs[0];
210
+ if (!tab) {
211
+ throw toError('E_NOT_FOUND', 'No active tab');
212
+ }
213
+ return validate(tab);
214
+ }
215
+
216
+ async function captureAlignedTabScreenshot(tab: chrome.tabs.Tab): Promise<string> {
217
+ if (typeof tab.id !== 'number' || typeof tab.windowId !== 'number') {
218
+ throw toError('E_NOT_FOUND', 'Tab screenshot requires tab id and window id');
219
+ }
220
+
221
+ const activeTabs = await chrome.tabs.query({ windowId: tab.windowId, active: true });
222
+ const activeTab = activeTabs[0];
223
+ const shouldSwitch = activeTab?.id !== tab.id;
224
+
225
+ if (shouldSwitch) {
226
+ await chrome.tabs.update(tab.id, { active: true });
227
+ await new Promise((resolve) => setTimeout(resolve, 80));
228
+ }
229
+
230
+ try {
231
+ return await chrome.tabs.captureVisibleTab(tab.windowId, { format: 'png' });
232
+ } finally {
233
+ if (shouldSwitch && typeof activeTab?.id === 'number') {
234
+ try {
235
+ await chrome.tabs.update(activeTab.id, { active: true });
236
+ } catch {
237
+ // Ignore restore errors if the original tab no longer exists.
238
+ }
239
+ }
240
+ }
241
+ }
242
+
243
+ async function sendToContent<TResponse>(tabId: number, message: Record<string, unknown>): Promise<TResponse> {
244
+ const maxAttempts = 6;
245
+ for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
246
+ try {
247
+ const response = await chrome.tabs.sendMessage(tabId, message);
248
+ if (typeof response === 'undefined') {
249
+ throw new Error('Content script returned undefined response');
250
+ }
251
+ return response as TResponse;
252
+ } catch (error) {
253
+ const detail = error instanceof Error ? error.message : String(error);
254
+ const retriable =
255
+ detail.includes('Receiving end does not exist') ||
256
+ detail.includes('Could not establish connection') ||
257
+ detail.includes('No tab with id') ||
258
+ detail.includes('message port closed before a response was received') ||
259
+ detail.includes('Content script returned undefined response');
260
+ if (!retriable || attempt >= maxAttempts) {
261
+ throw toError('E_NOT_READY', 'Content script unavailable', { detail });
262
+ }
263
+ await new Promise((resolve) => setTimeout(resolve, 150 * attempt));
264
+ }
265
+ }
266
+
267
+ throw toError('E_NOT_READY', 'Content script unavailable');
268
+ }
269
+
270
+ function requireRpcEnvelope(
271
+ method: string,
272
+ value: unknown
273
+ ): { ok: boolean; result?: unknown; error?: CliResponse['error'] } {
274
+ if (typeof value !== 'object' || value === null || typeof (value as { ok?: unknown }).ok !== 'boolean') {
275
+ throw toError('E_NOT_READY', `Content script returned malformed response for ${method}`);
276
+ }
277
+ return value as { ok: boolean; result?: unknown; error?: CliResponse['error'] };
278
+ }
279
+
280
+ async function forwardContentRpc(
281
+ tabId: number,
282
+ method: string,
283
+ params: Record<string, unknown>
284
+ ): Promise<unknown> {
285
+ const raw = await sendToContent<unknown>(tabId, {
286
+ type: 'bak.rpc',
287
+ method,
288
+ params
289
+ });
290
+ const response = requireRpcEnvelope(method, raw);
291
+
292
+ if (!response.ok) {
293
+ throw response.error ?? toError('E_INTERNAL', `${method} failed`);
294
+ }
295
+
296
+ return response.result;
297
+ }
298
+
299
+ async function handleRequest(request: CliRequest): Promise<unknown> {
300
+ const params = request.params ?? {};
301
+
302
+ const rpcForwardMethods = new Set([
303
+ 'page.title',
304
+ 'page.url',
305
+ 'page.text',
306
+ 'page.dom',
307
+ 'page.accessibilityTree',
308
+ 'page.scrollTo',
309
+ 'page.metrics',
310
+ 'element.hover',
311
+ 'element.doubleClick',
312
+ 'element.rightClick',
313
+ 'element.dragDrop',
314
+ 'element.select',
315
+ 'element.check',
316
+ 'element.uncheck',
317
+ 'element.scrollIntoView',
318
+ 'element.focus',
319
+ 'element.blur',
320
+ 'element.get',
321
+ 'keyboard.press',
322
+ 'keyboard.type',
323
+ 'keyboard.hotkey',
324
+ 'mouse.move',
325
+ 'mouse.click',
326
+ 'mouse.wheel',
327
+ 'file.upload',
328
+ 'context.enterFrame',
329
+ 'context.exitFrame',
330
+ 'context.enterShadow',
331
+ 'context.exitShadow',
332
+ 'context.reset',
333
+ 'network.list',
334
+ 'network.get',
335
+ 'network.waitFor',
336
+ 'network.clear',
337
+ 'debug.dumpState'
338
+ ]);
339
+
340
+ switch (request.method) {
341
+ case 'session.ping': {
342
+ return { ok: true, ts: Date.now() };
343
+ }
344
+ case 'tabs.list': {
345
+ const tabs = await chrome.tabs.query({});
346
+ return {
347
+ tabs: tabs
348
+ .filter((tab) => typeof tab.id === 'number')
349
+ .map((tab) => ({
350
+ id: tab.id as number,
351
+ title: tab.title ?? '',
352
+ url: tab.url ?? '',
353
+ active: tab.active
354
+ }))
355
+ };
356
+ }
357
+ case 'tabs.getActive': {
358
+ const tabs = await chrome.tabs.query({ active: true, lastFocusedWindow: true });
359
+ const tab = tabs[0];
360
+ if (!tab || typeof tab.id !== 'number') {
361
+ return { tab: null };
362
+ }
363
+ return {
364
+ tab: {
365
+ id: tab.id,
366
+ title: tab.title ?? '',
367
+ url: tab.url ?? '',
368
+ active: Boolean(tab.active)
369
+ }
370
+ };
371
+ }
372
+ case 'tabs.get': {
373
+ const tabId = Number(params.tabId);
374
+ const tab = await chrome.tabs.get(tabId);
375
+ if (typeof tab.id !== 'number') {
376
+ throw toError('E_NOT_FOUND', 'Tab missing id');
377
+ }
378
+ return {
379
+ tab: {
380
+ id: tab.id,
381
+ title: tab.title ?? '',
382
+ url: tab.url ?? '',
383
+ active: Boolean(tab.active)
384
+ }
385
+ };
386
+ }
387
+ case 'tabs.focus': {
388
+ const tabId = Number(params.tabId);
389
+ await chrome.tabs.update(tabId, { active: true });
390
+ return { ok: true };
391
+ }
392
+ case 'tabs.new': {
393
+ const tab = await chrome.tabs.create({ url: (params.url as string | undefined) ?? 'about:blank' });
394
+ return { tabId: tab.id };
395
+ }
396
+ case 'tabs.close': {
397
+ const tabId = Number(params.tabId);
398
+ await chrome.tabs.remove(tabId);
399
+ return { ok: true };
400
+ }
401
+ case 'page.goto': {
402
+ const tab = await withTab(params.tabId as number | undefined, {
403
+ requireSupportedAutomationUrl: false
404
+ });
405
+ await chrome.tabs.update(tab.id!, { url: String(params.url ?? 'about:blank') });
406
+ await waitForTabComplete(tab.id!);
407
+ return { ok: true };
408
+ }
409
+ case 'page.back': {
410
+ const tab = await withTab(params.tabId as number | undefined);
411
+ await chrome.tabs.goBack(tab.id!);
412
+ await waitForTabComplete(tab.id!);
413
+ return { ok: true };
414
+ }
415
+ case 'page.forward': {
416
+ const tab = await withTab(params.tabId as number | undefined);
417
+ await chrome.tabs.goForward(tab.id!);
418
+ await waitForTabComplete(tab.id!);
419
+ return { ok: true };
420
+ }
421
+ case 'page.reload': {
422
+ const tab = await withTab(params.tabId as number | undefined);
423
+ await chrome.tabs.reload(tab.id!);
424
+ await waitForTabComplete(tab.id!);
425
+ return { ok: true };
426
+ }
427
+ case 'page.viewport': {
428
+ const tab = await withTab(params.tabId as number | undefined, {
429
+ requireSupportedAutomationUrl: false
430
+ });
431
+ if (typeof tab.windowId !== 'number') {
432
+ throw toError('E_NOT_FOUND', 'Tab window unavailable');
433
+ }
434
+
435
+ const width = typeof params.width === 'number' ? Math.max(320, Math.floor(params.width)) : undefined;
436
+ const height = typeof params.height === 'number' ? Math.max(320, Math.floor(params.height)) : undefined;
437
+ if (width || height) {
438
+ await chrome.windows.update(tab.windowId, {
439
+ width,
440
+ height
441
+ });
442
+ }
443
+
444
+ const viewport = (await forwardContentRpc(tab.id!, 'page.viewport', {})) as {
445
+ width: number;
446
+ height: number;
447
+ devicePixelRatio: number;
448
+ };
449
+ const viewWidth = typeof width === 'number' ? width : viewport.width ?? tab.width ?? 0;
450
+ const viewHeight = typeof height === 'number' ? height : viewport.height ?? tab.height ?? 0;
451
+ return {
452
+ width: viewWidth,
453
+ height: viewHeight,
454
+ devicePixelRatio: viewport.devicePixelRatio
455
+ };
456
+ }
457
+ case 'page.snapshot': {
458
+ const tab = await withTab(params.tabId as number | undefined);
459
+ if (typeof tab.id !== 'number' || typeof tab.windowId !== 'number') {
460
+ throw toError('E_NOT_FOUND', 'Tab missing id');
461
+ }
462
+ const includeBase64 = params.includeBase64 !== false;
463
+ const config = await getConfig();
464
+ const elements = await sendToContent<{ elements: unknown[] }>(tab.id, {
465
+ type: 'bak.collectElements',
466
+ debugRichText: config.debugRichText
467
+ });
468
+ const imageData = await captureAlignedTabScreenshot(tab);
469
+ return {
470
+ imageBase64: includeBase64 ? imageData.replace(/^data:image\/png;base64,/, '') : '',
471
+ elements: elements.elements,
472
+ tabId: tab.id,
473
+ url: tab.url ?? ''
474
+ };
475
+ }
476
+ case 'element.click': {
477
+ const tab = await withTab(params.tabId as number | undefined);
478
+ const response = await sendToContent<{ ok: boolean; error?: CliResponse['error'] }>(tab.id!, {
479
+ type: 'bak.performAction',
480
+ action: 'click',
481
+ locator: params.locator as Locator,
482
+ requiresConfirm: params.requiresConfirm === true
483
+ });
484
+ if (!response.ok) {
485
+ throw response.error ?? toError('E_INTERNAL', 'element.click failed');
486
+ }
487
+ return { ok: true };
488
+ }
489
+ case 'element.type': {
490
+ const tab = await withTab(params.tabId as number | undefined);
491
+ const response = await sendToContent<{ ok: boolean; error?: CliResponse['error'] }>(tab.id!, {
492
+ type: 'bak.performAction',
493
+ action: 'type',
494
+ locator: params.locator as Locator,
495
+ text: String(params.text ?? ''),
496
+ clear: Boolean(params.clear),
497
+ requiresConfirm: params.requiresConfirm === true
498
+ });
499
+ if (!response.ok) {
500
+ throw response.error ?? toError('E_INTERNAL', 'element.type failed');
501
+ }
502
+ return { ok: true };
503
+ }
504
+ case 'element.scroll': {
505
+ const tab = await withTab(params.tabId as number | undefined);
506
+ const response = await sendToContent<{ ok: boolean; error?: CliResponse['error'] }>(tab.id!, {
507
+ type: 'bak.performAction',
508
+ action: 'scroll',
509
+ locator: params.locator as Locator,
510
+ dx: Number(params.dx ?? 0),
511
+ dy: Number(params.dy ?? 320)
512
+ });
513
+ if (!response.ok) {
514
+ throw response.error ?? toError('E_INTERNAL', 'element.scroll failed');
515
+ }
516
+ return { ok: true };
517
+ }
518
+ case 'page.wait': {
519
+ const tab = await withTab(params.tabId as number | undefined);
520
+ const response = await sendToContent<{ ok: boolean; error?: CliResponse['error'] }>(tab.id!, {
521
+ type: 'bak.waitFor',
522
+ mode: String(params.mode ?? 'selector'),
523
+ value: String(params.value ?? ''),
524
+ timeoutMs: Number(params.timeoutMs ?? 5000)
525
+ });
526
+ if (!response.ok) {
527
+ throw response.error ?? toError('E_TIMEOUT', 'page.wait failed');
528
+ }
529
+ return { ok: true };
530
+ }
531
+ case 'debug.getConsole': {
532
+ const tab = await withTab(params.tabId as number | undefined);
533
+ const response = await sendToContent<{ entries: ConsoleEntry[] }>(tab.id!, {
534
+ type: 'bak.getConsole',
535
+ limit: Number(params.limit ?? 50)
536
+ });
537
+ return { entries: response.entries };
538
+ }
539
+ case 'ui.selectCandidate': {
540
+ const tab = await withTab(params.tabId as number | undefined);
541
+ const response = await sendToContent<{ ok: boolean; selectedEid?: string; error?: CliResponse['error'] }>(
542
+ tab.id!,
543
+ {
544
+ type: 'bak.selectCandidate',
545
+ candidates: params.candidates
546
+ }
547
+ );
548
+ if (!response.ok || !response.selectedEid) {
549
+ throw response.error ?? toError('E_NEED_USER_CONFIRM', 'User did not confirm candidate');
550
+ }
551
+ return { selectedEid: response.selectedEid };
552
+ }
553
+ default:
554
+ if (rpcForwardMethods.has(request.method)) {
555
+ const tab = await withTab(params.tabId as number | undefined);
556
+ return forwardContentRpc(tab.id!, request.method, params);
557
+ }
558
+ throw toError('E_NOT_FOUND', `Unsupported method from CLI bridge: ${request.method}`);
559
+ }
560
+ }
561
+
562
+ function scheduleReconnect(reason: string): void {
563
+ if (manualDisconnect) {
564
+ return;
565
+ }
566
+ if (reconnectTimer !== null) {
567
+ return;
568
+ }
569
+
570
+ const delayMs = computeReconnectDelayMs(reconnectAttempt);
571
+ reconnectAttempt += 1;
572
+ nextReconnectInMs = delayMs;
573
+ reconnectTimer = setTimeout(() => {
574
+ reconnectTimer = null;
575
+ nextReconnectInMs = null;
576
+ void connectWebSocket();
577
+ }, delayMs) as unknown as number;
578
+
579
+ if (!lastError) {
580
+ setRuntimeError(`Reconnect scheduled: ${reason}`, 'socket');
581
+ }
582
+ }
583
+
584
+ async function connectWebSocket(): Promise<void> {
585
+ clearReconnectTimer();
586
+ if (manualDisconnect) {
587
+ return;
588
+ }
589
+
590
+ if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) {
591
+ return;
592
+ }
593
+
594
+ const config = await getConfig();
595
+ if (!config.token) {
596
+ setRuntimeError('Pair token is empty', 'config');
597
+ return;
598
+ }
599
+
600
+ const url = `ws://127.0.0.1:${config.port}/extension?token=${encodeURIComponent(config.token)}`;
601
+ ws = new WebSocket(url);
602
+
603
+ ws.addEventListener('open', () => {
604
+ manualDisconnect = false;
605
+ reconnectAttempt = 0;
606
+ lastError = null;
607
+ ws?.send(JSON.stringify({
608
+ type: 'hello',
609
+ role: 'extension',
610
+ version: '0.1.0',
611
+ ts: Date.now()
612
+ }));
613
+ });
614
+
615
+ ws.addEventListener('message', (event) => {
616
+ try {
617
+ const request = JSON.parse(String(event.data)) as CliRequest;
618
+ if (!request.id || !request.method) {
619
+ return;
620
+ }
621
+ void handleRequest(request)
622
+ .then((result) => {
623
+ sendResponse({ id: request.id, ok: true, result });
624
+ })
625
+ .catch((error: unknown) => {
626
+ const normalized = normalizeUnhandledError(error);
627
+ sendResponse({ id: request.id, ok: false, error: normalized });
628
+ });
629
+ } catch (error) {
630
+ setRuntimeError(error instanceof Error ? error.message : String(error), 'parse');
631
+ sendResponse({
632
+ id: 'parse-error',
633
+ ok: false,
634
+ error: toError('E_INTERNAL', error instanceof Error ? error.message : String(error))
635
+ });
636
+ }
637
+ });
638
+
639
+ ws.addEventListener('close', () => {
640
+ ws = null;
641
+ scheduleReconnect('socket-closed');
642
+ });
643
+
644
+ ws.addEventListener('error', () => {
645
+ setRuntimeError('Cannot connect to bak cli', 'socket');
646
+ ws?.close();
647
+ });
648
+ }
649
+
650
+ chrome.runtime.onInstalled.addListener(() => {
651
+ void setConfig({ port: DEFAULT_PORT, debugRichText: false });
652
+ });
653
+
654
+ chrome.runtime.onStartup.addListener(() => {
655
+ void connectWebSocket();
656
+ });
657
+
658
+ void connectWebSocket();
659
+
660
+ chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
661
+ if (message?.type === 'bak.updateConfig') {
662
+ manualDisconnect = false;
663
+ void setConfig({
664
+ token: message.token,
665
+ port: Number(message.port ?? DEFAULT_PORT),
666
+ debugRichText: message.debugRichText === true
667
+ }).then(() => {
668
+ ws?.close();
669
+ void connectWebSocket().then(() => sendResponse({ ok: true }));
670
+ });
671
+ return true;
672
+ }
673
+
674
+ if (message?.type === 'bak.getState') {
675
+ void getConfig().then((config) => {
676
+ sendResponse({
677
+ ok: true,
678
+ connected: ws?.readyState === WebSocket.OPEN,
679
+ hasToken: Boolean(config.token),
680
+ port: config.port,
681
+ debugRichText: config.debugRichText,
682
+ lastError: lastError?.message ?? null,
683
+ lastErrorAt: lastError?.at ?? null,
684
+ lastErrorContext: lastError?.context ?? null,
685
+ reconnectAttempt,
686
+ nextReconnectInMs
687
+ });
688
+ });
689
+ return true;
690
+ }
691
+
692
+ if (message?.type === 'bak.disconnect') {
693
+ manualDisconnect = true;
694
+ clearReconnectTimer();
695
+ reconnectAttempt = 0;
696
+ ws?.close();
697
+ ws = null;
698
+ sendResponse({ ok: true });
699
+ return false;
700
+ }
701
+
702
+ return false;
703
+ });
704
+
705
+