backdraw 0.0.0-stage → 2.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.
Files changed (59) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +606 -2
  3. package/README.tr.md +624 -0
  4. package/bin/backdraw.js +56 -0
  5. package/cli.js +105 -0
  6. package/doctor.js +174 -0
  7. package/extension/_locales/en/messages.json +3 -0
  8. package/extension/_locales/tr/messages.json +3 -0
  9. package/extension/aspect.js +25 -0
  10. package/extension/background.js +1015 -0
  11. package/extension/collection.html +45 -0
  12. package/extension/collection.js +62 -0
  13. package/extension/collections.js +409 -0
  14. package/extension/i18n.js +359 -0
  15. package/extension/icons/icon128.png +0 -0
  16. package/extension/icons/icon16.png +0 -0
  17. package/extension/icons/icon32.png +0 -0
  18. package/extension/icons/icon48.png +0 -0
  19. package/extension/icons/mascot-192.png +0 -0
  20. package/extension/icons/mascot-96.png +0 -0
  21. package/extension/inpage.js +624 -0
  22. package/extension/manifest.json +20 -0
  23. package/extension/menus.js +50 -0
  24. package/extension/popup.css +90 -0
  25. package/extension/popup.html +63 -0
  26. package/extension/popup.js +376 -0
  27. package/extension/settings.js +35 -0
  28. package/extension/setup.js +57 -0
  29. package/extension/sidepanel.css +113 -0
  30. package/extension/sidepanel.html +31 -0
  31. package/extension/sidepanel.js +433 -0
  32. package/extension/theme.css +22 -0
  33. package/host.js +664 -0
  34. package/identity.js +21 -0
  35. package/install.js +427 -0
  36. package/lib/args.js +30 -0
  37. package/lib/aspect.js +85 -0
  38. package/lib/browsers.js +74 -0
  39. package/lib/collection-store.js +288 -0
  40. package/lib/collections.js +192 -0
  41. package/lib/doctor.js +143 -0
  42. package/lib/extension-calls.js +59 -0
  43. package/lib/gallery.js +63 -0
  44. package/lib/limits.js +18 -0
  45. package/lib/mcp-clients.js +178 -0
  46. package/lib/naming.js +25 -0
  47. package/lib/png.js +24 -0
  48. package/lib/projects.js +56 -0
  49. package/lib/prompt.js +25 -0
  50. package/lib/protocol.js +50 -0
  51. package/lib/results.js +170 -0
  52. package/lib/term.js +22 -0
  53. package/lib/toml.js +248 -0
  54. package/mcp-server.js +437 -0
  55. package/package.json +54 -4
  56. package/platform/index.js +14 -0
  57. package/platform/win32.js +268 -0
  58. package/setup.js +212 -0
  59. package/shared.js +118 -0
@@ -0,0 +1,1015 @@
1
+ // Service worker. Keeps a native-messaging connection to the local host and runs ChatGPT jobs in a
2
+ // tab it opens in the background: the tab is never activated and no window is ever focused.
3
+ // Only prompts, reference images, progress, chat links and image bytes cross the bridge; cookies,
4
+ // tokens and passwords are never read.
5
+ importScripts('aspect.js', 'i18n.js', 'menus.js', 'collections.js'); // crop maths; right-click reference collections
6
+
7
+ const { cropRect } = self.BackdrawAspect;
8
+ const HOST = 'io.github.muhammedtalha04.backdraw'; // identity.js HOST_NAME
9
+ const VERSION = chrome.runtime.getManifest().version;
10
+ const HOME = 'https://chatgpt.com/';
11
+ const HUMAN_WAIT_MS = 10 * 60 * 1000;
12
+
13
+ // ChatGPT asking for the image to be (re)uploaded because it can't reach the one it should edit.
14
+ const REUPLOAD = /(tekrar|yeniden)\s+(yükle|ekle|gönder|paylaş)|sohbete ekle|erişilebilir (olarak )?görmüyor|erişemiyorum|göremiyorum|re-?upload|upload (it|the image|the picture|that image|this image) again|please (re-?)?(upload|attach|share|provide|send) (the|your|that|this)?\s*(image|picture|photo|sprite)|can.?t (access|see|find) (the|that|this) (image|picture|file)|(don.?t|do not) have access to (the|that|this) (image|picture)/i;
15
+
16
+ let port = null;
17
+ let hostReady = false;
18
+ let lastError = null;
19
+ let project = null;
20
+ let reconnectTimer = null;
21
+ let reconnectDelay = 2000;
22
+ let current = null;
23
+ let workerTabId = null;
24
+ let closeTimer = null;
25
+ const recent = [];
26
+ const refParts = new Map(); // jobId -> [{ name, mime, parts[] }] while reference images arrive in chunks
27
+
28
+ const sleep = ms => new Promise(r => setTimeout(r, ms));
29
+
30
+ class Cancelled extends Error {
31
+ constructor() { super('Cancelled'); this.name = 'Cancelled'; }
32
+ }
33
+
34
+ function fail(reason, message) {
35
+ const e = new Error(message);
36
+ e.reason = reason;
37
+ return e;
38
+ }
39
+
40
+ function check(ctx) { if (ctx.cancelled) throw new Cancelled(); }
41
+
42
+ function post(msg) {
43
+ if (!port) return false;
44
+ try { port.postMessage(msg); return true; } catch { return false; }
45
+ }
46
+
47
+ function log(level, msg, data) {
48
+ (level === 'error' ? console.error : console.log)(`[bridge] ${msg}`, data === undefined ? '' : data);
49
+ post({ type: 'log', level, msg, data });
50
+ }
51
+
52
+ // ---------------------------------------------------------------- native host connection
53
+
54
+ function connect() {
55
+ if (port) return;
56
+ clearTimeout(reconnectTimer);
57
+ let p;
58
+ try {
59
+ p = chrome.runtime.connectNative(HOST);
60
+ } catch (e) {
61
+ onDisconnected(null, String((e && e.message) || e));
62
+ return;
63
+ }
64
+ port = p;
65
+ p.onMessage.addListener(msg => {
66
+ onHostMessage(msg).catch(e => log('error', 'host message failed', String((e && e.stack) || e)));
67
+ });
68
+ p.onDisconnect.addListener(() => onDisconnected(p, (chrome.runtime.lastError && chrome.runtime.lastError.message) || 'disconnected'));
69
+ p.postMessage({ type: 'hello', version: VERSION });
70
+ }
71
+
72
+ function onDisconnected(p, error) {
73
+ if (p && port !== p) return;
74
+ port = null;
75
+ hostReady = false;
76
+ lastError = error;
77
+ refParts.clear();
78
+ if (current) current.cancelled = true; // the host that owned the job is gone
79
+ setBadge();
80
+ collectionsOffline();
81
+ clearTimeout(reconnectTimer);
82
+ reconnectTimer = setTimeout(connect, reconnectDelay);
83
+ reconnectDelay = Math.min(reconnectDelay * 2, 60000);
84
+ }
85
+
86
+ async function onHostMessage(msg) {
87
+ switch (msg.type) {
88
+ case 'welcome':
89
+ hostReady = true;
90
+ lastError = null;
91
+ reconnectDelay = 2000;
92
+ project = msg.project || null;
93
+ hostHome = msg.home || null;
94
+ setBadge();
95
+ collectionsOnline(msg.collections);
96
+ break;
97
+ case 'config':
98
+ project = msg.project || null;
99
+ break;
100
+ case 'ref_chunk': {
101
+ // Native messages from the host are limited to 1 MB, so reference images come in pieces
102
+ // just before the job that uses them.
103
+ const files = refParts.get(msg.jobId) || [];
104
+ const f = files[msg.index] || (files[msg.index] = { name: msg.name, mime: msg.mime, parts: new Array(msg.total) });
105
+ f.parts[msg.seq] = msg.data;
106
+ refParts.set(msg.jobId, files);
107
+ break;
108
+ }
109
+ case 'run': {
110
+ const files = refParts.get(msg.job.id) || [];
111
+ refParts.delete(msg.job.id);
112
+ msg.job.refs = files.map(f => {
113
+ let complete = true;
114
+ for (let i = 0; i < f.parts.length; i++) if (typeof f.parts[i] !== 'string') complete = false;
115
+ return { name: f.name, mime: f.mime, data: complete ? f.parts.join('') : '', complete };
116
+ });
117
+ runJob(msg.job);
118
+ break;
119
+ }
120
+ case 'cancel':
121
+ if (current && current.job.id === msg.jobId) current.cancelled = true;
122
+ break;
123
+ case 'request':
124
+ handleRequest(msg);
125
+ break;
126
+ case 'reply':
127
+ settleHostCall(msg);
128
+ break;
129
+ case 'collections':
130
+ collectionsChanged(msg.collections);
131
+ break;
132
+ case 'reload':
133
+ setTimeout(() => chrome.runtime.reload(), 300);
134
+ break;
135
+ }
136
+ }
137
+
138
+ // ---------------------------------------------------------------- tabs
139
+
140
+ async function createBackgroundTab(url) {
141
+ let win = null;
142
+ try { win = await chrome.windows.getLastFocused({ windowTypes: ['normal'] }); } catch {}
143
+ let tab;
144
+ if (win && win.type === 'normal') {
145
+ tab = await chrome.tabs.create({ windowId: win.id, url, active: false });
146
+ } else {
147
+ // No browser window is open: use a minimized one that never takes focus.
148
+ const w = await chrome.windows.create({ url, focused: false, state: 'minimized' });
149
+ tab = w.tabs[0];
150
+ }
151
+ try { await chrome.tabs.update(tab.id, { autoDiscardable: false, muted: true }); } catch {}
152
+ return tab;
153
+ }
154
+
155
+ async function openWorker(url) {
156
+ clearTimeout(closeTimer);
157
+ let tab = null;
158
+ if (workerTabId !== null) tab = await chrome.tabs.get(workerTabId).catch(() => null);
159
+ if (tab) {
160
+ await chrome.tabs.update(tab.id, { url });
161
+ } else {
162
+ tab = await createBackgroundTab(url);
163
+ workerTabId = tab.id;
164
+ await chrome.storage.session.set({ workerTabId });
165
+ }
166
+ return tab.id;
167
+ }
168
+
169
+ function forgetWorker() {
170
+ workerTabId = null;
171
+ chrome.storage.session.remove('workerTabId').catch(() => {});
172
+ }
173
+
174
+ async function closeTabQuietly(id) {
175
+ try {
176
+ const t = await chrome.tabs.get(id);
177
+ if (t.active) return; // the user is looking at it: leave it alone
178
+ await chrome.tabs.remove(id);
179
+ } catch {}
180
+ }
181
+
182
+ function scheduleWorkerClose(keep) {
183
+ clearTimeout(closeTimer);
184
+ if (keep) { forgetWorker(); return; } // left open for the user (e.g. a CAPTCHA)
185
+ closeTimer = setTimeout(() => {
186
+ if (current || workerTabId === null) return;
187
+ const id = workerTabId;
188
+ forgetWorker();
189
+ closeTabQuietly(id);
190
+ }, 5000);
191
+ }
192
+
193
+ async function withHelperTab(url, fn) {
194
+ const tab = await createBackgroundTab(url);
195
+ try { return await fn(tab.id); } finally { closeTabQuietly(tab.id); }
196
+ }
197
+
198
+ // Runs one helper from inpage.js inside the tab and returns its (JSON) result.
199
+ async function pageCall(tabId, fn, ...args) {
200
+ await chrome.scripting.executeScript({ target: { tabId }, files: ['inpage.js'] });
201
+ const [res] = await chrome.scripting.executeScript({
202
+ target: { tabId },
203
+ func: (name, params) => {
204
+ const lib = globalThis.__cgb;
205
+ if (!lib || typeof lib[name] !== 'function') return { __error: `page helper missing: ${name}` };
206
+ try {
207
+ return Promise.resolve(lib[name](...params)).catch(e => ({ __error: String((e && e.stack) || e) }));
208
+ } catch (e) {
209
+ return { __error: String((e && e.stack) || e) };
210
+ }
211
+ },
212
+ args: [fn, args],
213
+ });
214
+ const value = res ? res.result : undefined;
215
+ if (value && value.__error) throw new Error(`page.${fn}: ${value.__error}`);
216
+ if (fn === 'probe') noteChatgpt(value);
217
+ return value;
218
+ }
219
+
220
+ async function probeTab(ctx, baseline) {
221
+ try {
222
+ return await pageCall(ctx.tabId, 'probe', baseline);
223
+ } catch (e) {
224
+ const msg = String((e && e.message) || e);
225
+ if (/no tab with id|tab was closed|closed tab/i.test(msg)) throw fail('tab_closed', 'The background ChatGPT tab was closed.');
226
+ ctx.lastProbeError = msg; // usually: the page is still loading
227
+ return null;
228
+ }
229
+ }
230
+
231
+ const isChatUrl = url => /\/c\/[0-9a-f-]{8,}/i.test(url || '');
232
+ const cleanUrl = url => { const u = new URL(url); return u.origin + u.pathname; };
233
+ const comparable = s => String(s || '').replace(/[^\p{L}\p{N}]+/gu, '').toLowerCase();
234
+
235
+ // Is the newest user message in the chat the one we sent? (Same opening text.)
236
+ function isOurMessage(shown, sent) {
237
+ const a = comparable(shown);
238
+ const b = comparable(sent);
239
+ const n = Math.min(150, a.length, b.length);
240
+ return n >= 20 && a.slice(0, n) === b.slice(0, n);
241
+ }
242
+
243
+ function describe(st) {
244
+ if (!st) return 'page not loaded';
245
+ return `url=${st.url} title="${st.title}" composer=${st.composer} login=${st.login} challenge=${st.challenge} dialogs=${(st.dialogs || []).length}`;
246
+ }
247
+
248
+ function compact(st) {
249
+ const c = { ...st };
250
+ if (c.images) c.images = c.images.map(i => ({ ...i, src: i.src.slice(0, 120) }));
251
+ if (c.text) c.text = c.text.slice(0, 300);
252
+ return c;
253
+ }
254
+
255
+ // ---------------------------------------------------------------- jobs
256
+
257
+ async function runJob(job) {
258
+ if (current) {
259
+ post({ type: 'result', jobId: job.id, ok: false, status: 'failed', reason: 'busy', error: 'The extension is busy with another job.' });
260
+ return;
261
+ }
262
+ const ctx = current = {
263
+ job, cancelled: false, tabId: null, conversationUrl: null, lastText: '', sent: false, keepTab: false,
264
+ step: 'starting', message: '', deadline: Date.now() + (job.timeoutSec || 600) * 1000, refs: job.refs || [],
265
+ startedAt: Date.now(),
266
+ };
267
+ ctx.progress = (step, message, extra = {}) => {
268
+ ctx.step = step;
269
+ ctx.message = message;
270
+ post({ type: 'progress', jobId: job.id, step, message, ...extra });
271
+ };
272
+ setBadge();
273
+ remember(job.id, 'running');
274
+ try {
275
+ if (ctx.refs.some(r => !r.complete)) throw fail('ref_transfer', 'A reference image did not arrive completely; please try again.');
276
+ ctx.progress('opening', 'Opening ChatGPT in a background tab');
277
+ ctx.tabId = await openWorker(job.startUrl);
278
+ const ready = await waitUntilReady(ctx);
279
+ let outcome;
280
+ let attached = ctx.refs;
281
+ if (job.mode === 'collect') {
282
+ // Only read an existing conversation: nothing is typed or sent.
283
+ ctx.collect = true;
284
+ if (isChatUrl(ready.url)) ctx.conversationUrl = cleanUrl(ready.url);
285
+ outcome = await waitForReply(ctx, { users: 0, images: 0 });
286
+ } else {
287
+ outcome = await sendAndWait(ctx, ready, job.text, attached);
288
+ // In long chats ChatGPT can lose track of earlier images and asks for them to be uploaded
289
+ // again instead of editing. With auto_reattach: attach it (or the given references) once.
290
+ if (outcome.kind === 'needs_reference' && job.autoReattach) {
291
+ attached = ctx.refs.length ? ctx.refs : await latestImageAsFile(ctx);
292
+ if (attached.length) {
293
+ ctx.progress('reattaching', 'ChatGPT asked for the image again: attaching it and asking once more', { conversationUrl: ctx.conversationUrl });
294
+ const now = (await probeTab(ctx, null)) || { counts: { users: 0, images: 0 } };
295
+ outcome = await sendAndWait(ctx, now, `I attached the image again.\n\n${job.text}`, attached);
296
+ outcome.reattached = true;
297
+ }
298
+ }
299
+ }
300
+ if (outcome.kind !== 'images') {
301
+ post({
302
+ type: 'result', jobId: job.id, ok: false, status: outcome.kind, reason: outcome.kind,
303
+ error: outcome.kind === 'needs_reference'
304
+ ? 'ChatGPT could not access the image it should edit and asked for it to be uploaded again.'
305
+ : 'ChatGPT replied with text and did not create an image.',
306
+ conversationUrl: ctx.conversationUrl, assistantText: outcome.text || null, reattached: !!outcome.reattached,
307
+ });
308
+ remember(job.id, outcome.kind);
309
+ return;
310
+ }
311
+ let editCheck = null;
312
+ if (!ctx.collect && (attached.length || isChatUrl(job.startUrl))) {
313
+ // Best effort: never let the comparison hold up the result.
314
+ editCheck = await Promise.race([
315
+ pageCall(ctx.tabId, 'editCheck', attached.map(r => ({ name: r.name, mime: r.mime, data: r.data }))),
316
+ sleep(20000).then(() => { throw new Error('timed out'); }),
317
+ ]).catch(e => { log('warn', 'edit check skipped', String(e)); return null; });
318
+ }
319
+ ctx.progress('downloading', 'Downloading the image', { conversationUrl: ctx.conversationUrl });
320
+ const images = await downloadImages(ctx, outcome.images);
321
+ post({
322
+ type: 'result', jobId: job.id, ok: true, status: 'succeeded', conversationUrl: ctx.conversationUrl,
323
+ assistantText: outcome.text, title: outcome.title, images, editCheck, reattached: !!outcome.reattached,
324
+ references: attached.length,
325
+ });
326
+ remember(job.id, 'succeeded');
327
+ } catch (e) {
328
+ const cancelled = e instanceof Cancelled || ctx.cancelled;
329
+ if (cancelled && ctx.tabId !== null && !ctx.collect) {
330
+ try {
331
+ await pageCall(ctx.tabId, ctx.sent ? 'clickStop' : 'clearComposer');
332
+ if (!ctx.sent) await pageCall(ctx.tabId, 'removeAttachments');
333
+ await sleep(1500);
334
+ if (ctx.job.debug) log('debug', `cancelled (${ctx.sent ? 'pressed Stop' : 'before sending'})`, await pageCall(ctx.tabId, 'composerState'));
335
+ } catch {}
336
+ }
337
+ if (!cancelled) log('warn', `job ${job.id} failed: ${e.message}`, { reason: e.reason });
338
+ post({
339
+ type: 'result', jobId: job.id, ok: false, status: cancelled ? 'cancelled' : 'failed',
340
+ reason: cancelled ? 'cancelled' : e.reason || 'error', error: cancelled ? 'Cancelled.' : e.message,
341
+ conversationUrl: ctx.conversationUrl, assistantText: ctx.lastText || null,
342
+ });
343
+ remember(job.id, cancelled ? 'cancelled' : 'failed');
344
+ } finally {
345
+ current = null;
346
+ setBadge();
347
+ scheduleWorkerClose(ctx.keepTab);
348
+ }
349
+ }
350
+
351
+ async function sendAndWait(ctx, before, text, files) {
352
+ if (files.length) await attachRefs(ctx, files);
353
+ ctx.progress('typing', 'Writing the prompt');
354
+ await typePrompt(ctx, text);
355
+ ctx.progress('sending', 'Sending the prompt');
356
+ ctx.sentText = text;
357
+ await sendPrompt(ctx, before.counts, files.length > 0);
358
+ ctx.progress('generating', 'ChatGPT is generating the image', { conversationUrl: ctx.conversationUrl });
359
+ return waitForReply(ctx, before.counts);
360
+ }
361
+
362
+ // Waits (without giving up) while ChatGPT shows a login or human-verification page. It never
363
+ // interacts with that page; the user has to complete it.
364
+ async function humanGate(ctx, st) {
365
+ const kind = st.challenge ? 'verification' : st.login ? 'login' : null;
366
+ if (!kind) return false;
367
+ const message = kind === 'login'
368
+ ? 'ChatGPT is not logged in in Chrome. The user must log in to chatgpt.com in Chrome (they can use the background ChatGPT tab).'
369
+ : 'ChatGPT is showing a human verification (CAPTCHA) page. The user must open the background ChatGPT tab in Chrome and complete it.';
370
+ ctx.progress('needs_human', message, { needsHuman: true });
371
+ log('warn', `waiting for the user: ${kind}`, { url: st.url, title: st.title });
372
+ ctx.deadline += HUMAN_WAIT_MS;
373
+ const until = Date.now() + HUMAN_WAIT_MS;
374
+ while (Date.now() < until) {
375
+ check(ctx);
376
+ await sleep(3000);
377
+ const now = await probeTab(ctx, null);
378
+ if (now && !now.challenge && !now.login && now.composer) {
379
+ ctx.progress('resuming', 'The page is available again, continuing');
380
+ return true;
381
+ }
382
+ }
383
+ ctx.keepTab = true;
384
+ throw fail('needs_human', `${message} (Stopped waiting after 10 minutes.)`);
385
+ }
386
+
387
+ async function waitUntilReady(ctx) {
388
+ const projectId = (ctx.job.startUrl.match(/\/g\/(g-p-[0-9a-f]+)/i) || [])[1];
389
+ const until = Date.now() + 60000;
390
+ let readySince = 0;
391
+ let offTargetSince = 0;
392
+ let last = null;
393
+ await sleep(1500);
394
+ for (;;) {
395
+ check(ctx);
396
+ const st = await probeTab(ctx, null);
397
+ if (st) {
398
+ last = st;
399
+ if (await humanGate(ctx, st)) {
400
+ await chrome.tabs.update(ctx.tabId, { url: ctx.job.startUrl });
401
+ await sleep(2000);
402
+ continue;
403
+ }
404
+ const onTarget = !projectId || st.url.includes(projectId);
405
+ if (st.composer && onTarget) {
406
+ if (!readySince) readySince = Date.now();
407
+ else if (Date.now() - readySince >= 1500) return st;
408
+ } else {
409
+ readySince = 0;
410
+ }
411
+ if (st.composer && !onTarget) {
412
+ if (!offTargetSince) offTargetSince = Date.now();
413
+ else if (Date.now() - offTargetSince > 6000) {
414
+ throw fail('project_unavailable', `ChatGPT did not open the project (it went to ${st.url}). Was it deleted or renamed? Select it again with select_project.`);
415
+ }
416
+ } else {
417
+ offTargetSince = 0;
418
+ }
419
+ }
420
+ if (Date.now() > until) {
421
+ throw fail('page_not_ready', `ChatGPT did not finish loading (no message box). ${describe(last)}${ctx.lastProbeError ? ` | ${ctx.lastProbeError}` : ''}`);
422
+ }
423
+ await sleep(1000);
424
+ }
425
+ }
426
+
427
+ // Puts the reference images on the composer and waits until ChatGPT has uploaded all of them.
428
+ // Right after a page load the file input may not react yet, so the chips are checked and the
429
+ // files attached again if they don't show up.
430
+ async function attachRefs(ctx, files) {
431
+ ctx.progress('attaching', `Attaching ${files.length} reference image(s)`);
432
+ const payload = files.map(f => ({ name: f.name, mime: f.mime, data: f.data }));
433
+ let s = null;
434
+ await sleep(2000);
435
+ for (let attempt = 1; attempt <= 4; attempt++) {
436
+ check(ctx);
437
+ const r = await pageCall(ctx.tabId, 'attachFiles', payload);
438
+ if (r.ok) {
439
+ const shownBy = Date.now() + 8000;
440
+ do {
441
+ await sleep(800);
442
+ s = await pageCall(ctx.tabId, 'attachmentState');
443
+ } while (s.count < files.length && Date.now() < shownBy);
444
+ if (s.count >= files.length) break;
445
+ }
446
+ if (ctx.job.debug) log('debug', `attach attempt ${attempt} did not show up`, { result: r, state: s });
447
+ if (s && s.count) await pageCall(ctx.tabId, 'removeAttachments'); // start clean, no duplicates
448
+ await sleep(1500 * attempt);
449
+ }
450
+ if (!s || s.count < files.length) throw fail('attach_failed', `ChatGPT did not accept the reference image(s) (${s ? s.count : 0}/${files.length} shown).`);
451
+ const until = Date.now() + 120000;
452
+ while (s.uploading) {
453
+ check(ctx);
454
+ if (s.failed.length) throw fail('attach_failed', `ChatGPT could not upload: ${s.failed.join(', ')}`);
455
+ if (Date.now() > until) throw fail('attach_failed', 'The reference image(s) did not finish uploading.');
456
+ await sleep(1000);
457
+ s = await pageCall(ctx.tabId, 'attachmentState');
458
+ }
459
+ if (s.failed.length) throw fail('attach_failed', `ChatGPT could not upload: ${s.failed.join(', ')}`);
460
+ if (ctx.job.debug) log('debug', 'reference images attached', s);
461
+ }
462
+
463
+ // The newest image the chat already has, as a file to attach again.
464
+ async function latestImageAsFile(ctx) {
465
+ const info = await pageCall(ctx.tabId, 'latestEarlierImage').catch(() => null);
466
+ if (!info) return [];
467
+ const r = await pageCall(ctx.tabId, 'fetchImage', info.src).catch(e => ({ error: e.message }));
468
+ if (!r || r.error) { log('warn', 'could not read the previous image to re-attach', r && r.error); return []; }
469
+ const ext = /png/.test(r.mime) ? 'png' : /webp/.test(r.mime) ? 'webp' : 'jpg';
470
+ return [{ name: `previous-image.${ext}`, mime: r.mime || 'image/png', data: r.data }];
471
+ }
472
+
473
+ async function typePrompt(ctx, text) {
474
+ const want = comparable(text);
475
+ for (const method of ['insertText', 'paste', 'dom']) {
476
+ check(ctx);
477
+ await pageCall(ctx.tabId, 'clearComposer');
478
+ await sleep(250);
479
+ await pageCall(ctx.tabId, 'setPrompt', text, method);
480
+ await sleep(600);
481
+ const st = await pageCall(ctx.tabId, 'composerState');
482
+ const got = comparable(st.text);
483
+ if (got === want || (got.includes(want.slice(0, 40)) && Math.abs(got.length - want.length) <= Math.max(5, want.length * 0.03))) {
484
+ if (ctx.job.debug) log('debug', `prompt written via ${method}`);
485
+ return method;
486
+ }
487
+ log('warn', `writing the prompt via ${method} did not stick`, { got: String(st.text).slice(0, 160), dialogs: st.dialogs });
488
+ await pageCall(ctx.tabId, 'dismissDialogs').catch(() => {});
489
+ await sleep(500);
490
+ }
491
+ throw fail('type_failed', "Couldn't write the prompt into ChatGPT's message box.");
492
+ }
493
+
494
+ async function sendPrompt(ctx, baseline, withFiles) {
495
+ const startPath = new URL(ctx.job.startUrl).pathname;
496
+ for (const how of ['click', 'enter']) {
497
+ check(ctx);
498
+ if (how === 'click') {
499
+ // With attachments the send button stays disabled until the upload has finished.
500
+ for (let i = 0, n = withFiles ? 240 : 20; i < n; i++) {
501
+ const s = await pageCall(ctx.tabId, 'composerState');
502
+ if (s.stop || (s.send && !s.send.disabled)) break;
503
+ await sleep(250);
504
+ }
505
+ const r = await pageCall(ctx.tabId, 'clickSend');
506
+ if (!r.ok) log('warn', `send button: ${r.error}`);
507
+ } else {
508
+ await pageCall(ctx.tabId, 'pressEnter');
509
+ }
510
+ const t0 = Date.now();
511
+ let emptyCount = 0;
512
+ while (Date.now() - t0 < 12000) {
513
+ await sleep(600);
514
+ check(ctx);
515
+ const st = await probeTab(ctx, baseline);
516
+ if (!st) continue;
517
+ const moved = isChatUrl(st.url) && new URL(st.url).pathname !== startPath;
518
+ if (st.userTurns > 0 || st.stop || moved) {
519
+ ctx.sent = true;
520
+ ctx.sentAt = Date.now();
521
+ if (isChatUrl(st.url)) ctx.conversationUrl = cleanUrl(st.url);
522
+ return st;
523
+ }
524
+ if (st.composer && !st.composerText) emptyCount++;
525
+ }
526
+ if (emptyCount >= 3) {
527
+ // The box was emptied, so the message most likely went out; never risk sending it twice.
528
+ log('warn', 'message box emptied without a visible reply yet; assuming it was sent');
529
+ ctx.sent = true;
530
+ ctx.sentAt = Date.now();
531
+ return null;
532
+ }
533
+ }
534
+ throw fail('send_failed', "ChatGPT didn't accept the message (the send button had no effect).");
535
+ }
536
+
537
+ async function reloadWorker(ctx) {
538
+ if (ctx.conversationUrl) await chrome.tabs.update(ctx.tabId, { url: ctx.conversationUrl });
539
+ else await chrome.tabs.reload(ctx.tabId);
540
+ await sleep(3000);
541
+ for (let i = 0; i < 25; i++) {
542
+ check(ctx);
543
+ const st = await probeTab(ctx, null);
544
+ if (st && st.composer && st.counts.users + st.counts.images > 0) return;
545
+ await sleep(1000);
546
+ }
547
+ }
548
+
549
+ // Waits for ChatGPT's answer to our message. Returns {kind: 'images', images, text, title} or, when
550
+ // ChatGPT answered in text only, {kind: 'needs_reference' | 'text_reply', text}.
551
+ async function waitForReply(ctx, baseline) {
552
+ // Temporary chats are not stored, so re-opening one would lose it: no confirmation reloads there.
553
+ const canReload = !/temporary-chat=true/i.test(ctx.job.startUrl);
554
+ const verdict = text => ({ kind: REUPLOAD.test(text) ? 'needs_reference' : 'text_reply', text });
555
+ let lastProgress = null;
556
+ let lastSig = '';
557
+ let lastChange = Date.now();
558
+ let lastReload = 0;
559
+ let textSeen = null; // the text-only reply seen before a confirmation reload
560
+ let verified = !!ctx.collect || !canReload; // a chat that was just opened already shows the server's state
561
+ for (;;) {
562
+ check(ctx);
563
+ if (Date.now() > ctx.deadline) {
564
+ throw fail('timeout', `ChatGPT did not finish in time.${ctx.conversationUrl ? ` It may still finish in the chat: ${ctx.conversationUrl}` : ''}`);
565
+ }
566
+ const st = await probeTab(ctx, baseline);
567
+ if (!st) { await sleep(1500); continue; }
568
+ if (st.challenge || st.login) {
569
+ await humanGate(ctx, st);
570
+ await reloadWorker(ctx);
571
+ continue;
572
+ }
573
+ if (isChatUrl(st.url) && cleanUrl(st.url) !== ctx.conversationUrl) {
574
+ ctx.conversationUrl = cleanUrl(st.url);
575
+ ctx.progress(ctx.step, ctx.message, { conversationUrl: ctx.conversationUrl });
576
+ }
577
+ // Everything below the newest user message is the reply, so that message must be ours (long
578
+ // chats only render their latest turns, so turns are never counted from the top).
579
+ const ours = ctx.collect || isOurMessage(st.lastUserText, ctx.sentText) || Date.now() - (ctx.sentAt || 0) > 90000;
580
+ if (ours && st.text) ctx.lastText = st.text;
581
+ const pct = st.progress && (st.progress.match(/\d{1,3}/) || [])[0];
582
+ if (pct && pct !== lastProgress) {
583
+ lastProgress = pct;
584
+ ctx.progress('generating', `ChatGPT is generating the image (${pct}%)`, { conversationUrl: ctx.conversationUrl });
585
+ }
586
+ const sig = `${ours}#${st.sig}`;
587
+ if (sig !== lastSig) {
588
+ lastSig = sig;
589
+ lastChange = Date.now();
590
+ if (ctx.job.debug) {
591
+ const tab = await chrome.tabs.get(ctx.tabId).catch(() => ({}));
592
+ log('debug', 'reply changed', { tabActive: tab.active, ours, ...compact(st) });
593
+ }
594
+ }
595
+ const quiet = Date.now() - lastChange;
596
+ const idle = ours && !st.stop && !st.busy;
597
+ const images = ours ? st.images || [] : [];
598
+ let finals = images.filter(i => !i.blurred);
599
+ // After a fresh load the server only has final images; a hidden tab may never un-blur them.
600
+ if (!finals.length && verified && quiet >= 15000) finals = images;
601
+
602
+ if (idle && finals.length && quiet >= (verified ? 3000 : 5000)) {
603
+ if (verified) return { kind: 'images', images: finals, text: st.text, title: st.title };
604
+ // Re-open the chat once, so the image is taken from the server's final state and never
605
+ // from an in-progress preview.
606
+ ctx.progress('verifying', 'Image ready, checking the final version', { conversationUrl: ctx.conversationUrl });
607
+ await reloadWorker(ctx);
608
+ verified = true;
609
+ lastSig = '';
610
+ lastReload = Date.now();
611
+ continue;
612
+ }
613
+
614
+ // A finished reply without an image (its action bar is shown, or a "failed" line): confirm
615
+ // once with a fresh load, then report the text so the caller can see what ChatGPT said.
616
+ if (idle && !images.length && st.replyCount > 0 && quiet >= 5000 && (st.done || st.failed || quiet >= 20000)) {
617
+ if (textSeen === null && canReload) {
618
+ textSeen = st.text || st.failed || '';
619
+ await reloadWorker(ctx);
620
+ lastSig = '';
621
+ lastReload = Date.now();
622
+ continue;
623
+ }
624
+ return verdict(st.text || st.failed || '');
625
+ }
626
+ // The reload came back without the conversation: go with the reply seen before it.
627
+ if (textSeen !== null && st.counts.users === 0 && !st.text && Date.now() - lastReload > 15000) return verdict(textSeen);
628
+
629
+ // A hidden tab can miss live updates; reload if nothing changed for a long time.
630
+ if (canReload && quiet > 120000 && Date.now() - lastReload > 120000) {
631
+ if (ctx.job.debug) log('debug', 'no change for 2 minutes, reloading', compact(st));
632
+ await reloadWorker(ctx);
633
+ lastSig = '';
634
+ lastReload = Date.now();
635
+ continue;
636
+ }
637
+ await sleep(2000);
638
+ }
639
+ }
640
+
641
+ // ---------------------------------------------------------------- image download + preview
642
+
643
+ function b64ToBytes(b64) {
644
+ const bin = atob(b64);
645
+ const out = new Uint8Array(bin.length);
646
+ for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
647
+ return out;
648
+ }
649
+
650
+ function bytesToB64(bytes) {
651
+ let bin = '';
652
+ for (let i = 0; i < bytes.length; i += 0x8000) bin += String.fromCharCode.apply(null, bytes.subarray(i, i + 0x8000));
653
+ return btoa(bin);
654
+ }
655
+
656
+ const blobToB64 = async blob => bytesToB64(new Uint8Array(await blob.arrayBuffer()));
657
+
658
+ // The part r of the image on a canvas of its own size.
659
+ function drawPart(bmp, r) {
660
+ const canvas = new OffscreenCanvas(r.width, r.height);
661
+ canvas.getContext('2d', { willReadFrequently: true }).drawImage(bmp, r.x, r.y, r.width, r.height, 0, 0, r.width, r.height);
662
+ return canvas;
663
+ }
664
+
665
+ // Share of the pixels that are see-through (alpha < 250) and fully opaque, from every 4th pixel.
666
+ function measureAlpha(canvas) {
667
+ const { data } = canvas.getContext('2d', { willReadFrequently: true }).getImageData(0, 0, canvas.width, canvas.height);
668
+ let n = 0;
669
+ let clear = 0;
670
+ let opaque = 0;
671
+ for (let i = 3; i < data.length; i += 16) {
672
+ n++;
673
+ if (data[i] < 250) clear++;
674
+ else if (data[i] === 255) opaque++;
675
+ }
676
+ const share = x => (n ? Math.round((x / n) * 10000) / 10000 : 0);
677
+ return { transparentShare: share(clear), opaqueShare: share(opaque) };
678
+ }
679
+
680
+ // A JPEG of at most 1024 px. Transparent parts show over a light checkerboard, as image editors
681
+ // show them, so a see-through background can be told apart from a white one.
682
+ async function makePreview(bmp, r, checker) {
683
+ const scale = Math.min(1, 1024 / Math.max(r.width, r.height));
684
+ const w = Math.max(1, Math.round(r.width * scale));
685
+ const h = Math.max(1, Math.round(r.height * scale));
686
+ const canvas = new OffscreenCanvas(w, h);
687
+ const g = canvas.getContext('2d');
688
+ g.fillStyle = '#ffffff';
689
+ g.fillRect(0, 0, w, h);
690
+ if (checker) {
691
+ const size = 16;
692
+ g.fillStyle = '#e2e2e2';
693
+ for (let y = 0; y < h; y += size) for (let x = (y / size) % 2 ? size : 0; x < w; x += 2 * size) g.fillRect(x, y, size, size);
694
+ }
695
+ g.drawImage(bmp, r.x, r.y, r.width, r.height, 0, 0, w, h);
696
+ return blobToB64(await canvas.convertToBlob({ type: 'image/jpeg', quality: 0.86 }));
697
+ }
698
+
699
+ // Decodes a downloaded image once. With an aspect ratio it is cropped from the centre (exported as
700
+ // PNG, which keeps any alpha); a transparent job measures the alpha of the (cropped) image; the
701
+ // preview shows the cropped version.
702
+ async function processImage(b64, mime, job) {
703
+ const bmp = await createImageBitmap(new Blob([b64ToBytes(b64)], { type: mime || 'image/png' }));
704
+ try {
705
+ const out = { width: bmp.width, height: bmp.height, crop: null, alpha: null, preview: null };
706
+ const rect = job.aspect ? cropRect(bmp.width, bmp.height, job.aspect.w, job.aspect.h) : null;
707
+ const r = rect || { x: 0, y: 0, width: bmp.width, height: bmp.height };
708
+ let canvas = null;
709
+ if (rect) {
710
+ canvas = drawPart(bmp, rect);
711
+ out.crop = { data: await blobToB64(await canvas.convertToBlob({ type: 'image/png' })), mime: 'image/png', width: rect.width, height: rect.height };
712
+ }
713
+ if (job.transparent) out.alpha = measureAlpha(canvas || drawPart(bmp, r));
714
+ out.preview = await makePreview(bmp, r, !!out.alpha && out.alpha.transparentShare > 0)
715
+ .catch(e => { log('warn', 'preview failed', String(e)); return null; });
716
+ return out;
717
+ } finally {
718
+ bmp.close();
719
+ }
720
+ }
721
+
722
+ async function fetchFromWorker(src) {
723
+ const res = await fetch(src, { credentials: 'include' });
724
+ if (!res.ok) return { error: `HTTP ${res.status}` };
725
+ const blob = await res.blob();
726
+ return { data: bytesToB64(new Uint8Array(await blob.arrayBuffer())), mime: blob.type };
727
+ }
728
+
729
+ async function downloadImages(ctx, images) {
730
+ const out = [];
731
+ for (const im of images.slice(0, 4)) {
732
+ check(ctx);
733
+ let r = await pageCall(ctx.tabId, 'fetchImage', im.src).catch(e => ({ error: e.message }));
734
+ if (!r || r.error) {
735
+ const pageError = r && r.error;
736
+ r = await fetchFromWorker(im.src).catch(e => ({ error: e.message }));
737
+ if (r.error) { log('warn', 'image download failed', { page: pageError, worker: r.error }); continue; }
738
+ }
739
+ if (ctx.job.debug) log('debug', `image downloaded via ${r.via || 'worker'}`, { bytes: r.size, mime: r.mime });
740
+ let meta = {};
741
+ try { meta = await processImage(r.data, r.mime, ctx.job); } catch (e) { log('warn', 'image processing failed', String(e)); }
742
+ out.push({
743
+ data: r.data, mime: r.mime, width: meta.width || im.w || null, height: meta.height || im.h || null, preview: meta.preview || null,
744
+ crop: meta.crop || null, alpha: meta.alpha || null,
745
+ });
746
+ }
747
+ if (!out.length) throw fail('download_failed', `The image was generated but could not be downloaded. Open the chat: ${ctx.conversationUrl}`);
748
+ return out;
749
+ }
750
+
751
+ // ---------------------------------------------------------------- requests from the host
752
+
753
+ async function handleRequest(msg) {
754
+ try {
755
+ let data;
756
+ if (msg.action === 'list_projects') data = await listProjects();
757
+ else if (msg.action === 'resolve_project') data = await resolveProject((msg.params || {}).name);
758
+ else if (msg.action === 'diagnose') data = await diagnose(msg.params || {});
759
+ else if (msg.action === 'chatgpt_state') data = chatgptState; // what the last look at chatgpt.com showed (backdraw doctor)
760
+ else throw new Error(`Unknown action ${msg.action}`);
761
+ post({ type: 'response', reqId: msg.reqId, ok: true, data });
762
+ } catch (e) {
763
+ post({ type: 'response', reqId: msg.reqId, ok: false, error: String((e && e.message) || e), code: e && e.reason });
764
+ }
765
+ }
766
+
767
+ // The sidebar fills in lazily in a background tab, so poll for the project rows. If the user keeps
768
+ // the sidebar closed it is opened here; the caller closes it again (sidebarOpened).
769
+ async function projectRows(tabId) {
770
+ const until = Date.now() + 45000;
771
+ let sidebarOpened = false;
772
+ let last = null;
773
+ await sleep(2500);
774
+ while (Date.now() < until) {
775
+ const st = await pageCall(tabId, 'probe', null).catch(() => null);
776
+ if (st) {
777
+ last = st;
778
+ if (st.login) throw fail('needs_human', 'ChatGPT is not logged in in Chrome. Log in at chatgpt.com first.');
779
+ if (st.challenge) throw fail('needs_human', 'ChatGPT is showing a human verification page in Chrome; complete it first.');
780
+ if (st.composer) {
781
+ const rows = await pageCall(tabId, 'listProjects').catch(() => []);
782
+ if (rows.length) return { rows, sidebarOpened };
783
+ if (!sidebarOpened && st.sidebarOpen === false && Date.now() > until - 25000) {
784
+ const r = await pageCall(tabId, 'sidebar', true).catch(() => ({}));
785
+ sidebarOpened = !!r.clicked;
786
+ }
787
+ }
788
+ }
789
+ await sleep(1500);
790
+ }
791
+ if (sidebarOpened) await pageCall(tabId, 'sidebar', false).catch(() => {});
792
+ throw fail('not_found', `No projects were found in ChatGPT's sidebar (${describe(last)}).`);
793
+ }
794
+
795
+ async function listProjects() {
796
+ return withHelperTab(HOME, async tabId => {
797
+ const { rows, sidebarOpened } = await projectRows(tabId);
798
+ if (sidebarOpened) await pageCall(tabId, 'sidebar', false).catch(() => {});
799
+ return rows;
800
+ });
801
+ }
802
+
803
+ // Projects without chats show no link in the sidebar: open the project (in the helper tab) and
804
+ // read its address.
805
+ async function resolveProject(name) {
806
+ return withHelperTab(HOME, async tabId => {
807
+ const { rows, sidebarOpened } = await projectRows(tabId);
808
+ try {
809
+ const row = rows.find(r => r.name === name);
810
+ if (!row) throw fail('not_found', `Project "${name}" is not in the ChatGPT sidebar.`);
811
+ if (row.url) return row;
812
+ const r = await pageCall(tabId, 'openProject', name);
813
+ if (!r.ok) throw fail('not_found', `Could not open project "${name}": ${r.error}`);
814
+ for (let i = 0; i < 20; i++) {
815
+ await sleep(1000);
816
+ const st = await pageCall(tabId, 'probe', null).catch(() => null);
817
+ const m = st && st.url.match(/\/g\/(g-p-[0-9a-f]+)/i);
818
+ if (m) return { name, id: m[1], url: `https://chatgpt.com/g/${m[1]}/project` };
819
+ }
820
+ throw fail('not_found', `Opened project "${name}" but could not read its link.`);
821
+ } finally {
822
+ if (sidebarOpened) await pageCall(tabId, 'sidebar', false).catch(() => {});
823
+ }
824
+ });
825
+ }
826
+
827
+ async function diagnose(p) {
828
+ if (p.target === 'worker') {
829
+ if (workerTabId === null) throw new Error('No ChatGPT job tab is open right now.');
830
+ return { worker: true, ...(await pageCall(workerTabId, 'snapshot', p.selector || null, p.max || 400)) };
831
+ }
832
+ return withHelperTab(p.url || (project && project.url) || HOME, async tabId => {
833
+ await sleep(p.waitMs || 7000);
834
+ const clicks = [];
835
+ for (const label of p.clicks || []) {
836
+ clicks.push({ label, ...(await pageCall(tabId, 'clickLabel', label)) });
837
+ await sleep(2500);
838
+ }
839
+ // Optionally attach files and/or write (never send) a text, to see the composer in that state.
840
+ if (p.attach) {
841
+ clicks.push({ attached: await pageCall(tabId, 'attachFiles', p.attach) });
842
+ await sleep(p.attachWaitMs || 6000);
843
+ clicks.push({ attachments: await pageCall(tabId, 'attachmentState') });
844
+ }
845
+ if (p.type) {
846
+ await pageCall(tabId, 'clearComposer');
847
+ await sleep(300);
848
+ clicks.push({ typed: await pageCall(tabId, 'setPrompt', p.type, 'insertText') });
849
+ await sleep(1500);
850
+ clicks.push({ state: await pageCall(tabId, 'composerState') });
851
+ }
852
+ if (p.editCheck) clicks.push({ editCheck: await pageCall(tabId, 'editCheck', p.editCheck === true ? [] : p.editCheck) });
853
+ const snap = await pageCall(tabId, 'snapshot', p.selector || null, p.max || 400);
854
+ if (p.attach) {
855
+ clicks.push({ removed: await pageCall(tabId, 'removeAttachments').catch(e => String(e)) });
856
+ await sleep(1000);
857
+ }
858
+ if (p.type || p.attach) await pageCall(tabId, 'clearComposer').catch(() => {});
859
+ return { clicks, ...snap };
860
+ });
861
+ }
862
+
863
+ // ---------------------------------------------------------------- toolbar badge + popup
864
+
865
+ function setBadge() {
866
+ chrome.action.setBadgeText({ text: !hostReady ? '!' : current ? '…' : '' }).catch(() => {});
867
+ chrome.action.setBadgeBackgroundColor({ color: hostReady ? '#7c3aed' : '#d93025' }).catch(() => {});
868
+ }
869
+
870
+ function remember(id, status) {
871
+ const r = recent.find(x => x.id === id);
872
+ if (r) r.status = status;
873
+ else recent.unshift({ id, status, at: Date.now() });
874
+ recent.length = Math.min(recent.length, 5);
875
+ }
876
+
877
+ chrome.runtime.onMessage.addListener((msg, sender, reply) => {
878
+ if (sender.id !== chrome.runtime.id || !msg) return;
879
+ if (msg.type === 'popup:status') {
880
+ reply({
881
+ version: VERSION, connected: hostReady, lastError, project, recent,
882
+ current: current ? { id: current.job.id, step: current.step, message: current.message } : null,
883
+ chatgpt: { ...chatgptState, checking: !!chatgptCheck }, home: hostHome,
884
+ });
885
+ } else if (msg.type === 'popup:select') {
886
+ if (!hostReady) { reply({ ok: false, error: i18n.t('errNotConnected') }); return; }
887
+ post({ type: 'set_project', project: msg.project });
888
+ project = msg.project;
889
+ reply({ ok: true });
890
+ }
891
+ });
892
+
893
+ // ---------------------------------------------------------------- setup checklist (popup)
894
+
895
+ // Whether ChatGPT is signed in, as the last look at a chatgpt.com page showed it: every probe of a
896
+ // job or a project list updates it, and the popup can ask for a fresh look. Only the page is looked
897
+ // at (login wall, verification page, message box); never cookies, tokens or the session endpoint.
898
+ // Kept in chrome.storage.local, so the popup still knows it after a browser restart.
899
+ let chatgptState = { loggedIn: null, challenge: null, checkedAt: null };
900
+ let chatgptCheck = null;
901
+ let hostHome = null; // the user's home folder (from the host): the extension's pages show it as "~"
902
+
903
+ function noteChatgpt(st) {
904
+ if (!st || !(st.login || st.challenge || st.composer)) return; // still loading
905
+ const next = st.challenge ? { loggedIn: chatgptState.loggedIn, challenge: st.challenge } : { loggedIn: !st.login, challenge: null };
906
+ const changed = next.loggedIn !== chatgptState.loggedIn || next.challenge !== chatgptState.challenge;
907
+ chatgptState = { ...next, checkedAt: Date.now() };
908
+ if (changed) chrome.storage.local.set({ chatgptState }).catch(() => {});
909
+ }
910
+
911
+ // Opens chatgpt.com in a background tab until it shows a login wall, a verification page or the
912
+ // message box, then closes it. "Signed in" waits a moment: the "Log in" button can come after the box.
913
+ function checkChatgpt() {
914
+ if (!chatgptCheck) {
915
+ chatgptCheck = withHelperTab(HOME, async tabId => {
916
+ const until = Date.now() + 45000;
917
+ let composerSince = 0;
918
+ await sleep(1500);
919
+ while (Date.now() < until) {
920
+ const st = await pageCall(tabId, 'probe', null).catch(() => null);
921
+ if (st && (st.login || st.challenge)) return chatgptState;
922
+ composerSince = st && st.composer ? composerSince || Date.now() : 0;
923
+ if (composerSince && Date.now() - composerSince >= 2500) return chatgptState;
924
+ await sleep(1000);
925
+ }
926
+ throw new Error(i18n.t('errChatgptCheck'));
927
+ }).finally(() => { chatgptCheck = null; });
928
+ }
929
+ return chatgptCheck;
930
+ }
931
+
932
+ // The popup shows these in its own language; anything else as it is.
933
+ function setupError(e, fallbackKey) {
934
+ if (e && e.reason === 'needs_human') return i18n.t(chatgptState.challenge ? 'chatgptChallenge' : 'chatgptOut');
935
+ if (e && e.reason === 'not_found' && fallbackKey) return i18n.t(fallbackKey);
936
+ return String((e && e.message) || e);
937
+ }
938
+
939
+ const setupRequests = new Map([
940
+ ['popup:check-chatgpt', async () => ({ chatgpt: await checkChatgpt() })],
941
+ ['popup:projects', async () => {
942
+ try { return { projects: await listProjects() }; } catch (e) { if (e.reason === 'not_found') return { projects: [] }; throw e; }
943
+ }],
944
+ // A project row without a link: open it once to read the link (popup:select then saves it).
945
+ ['popup:resolve-project', async msg => {
946
+ try { return { project: await resolveProject(String(msg.name || '')) }; } catch (e) { throw new Error(setupError(e, 'selectFailed')); }
947
+ }],
948
+ // "Try again" after running install.js: connect now instead of after the back-off.
949
+ ['popup:reconnect', async () => {
950
+ if (!hostReady) {
951
+ reconnectDelay = 2000;
952
+ connect();
953
+ for (let i = 0; i < 20 && port && !hostReady; i++) await sleep(150);
954
+ }
955
+ return { connected: hostReady, lastError };
956
+ }],
957
+ ]);
958
+
959
+ chrome.runtime.onMessage.addListener((msg, sender, reply) => {
960
+ const handler = msg && setupRequests.get(msg.type);
961
+ if (!handler || sender.id !== chrome.runtime.id || !String(sender.url || '').startsWith(chrome.runtime.getURL(''))) return;
962
+ handler(msg).then(r => reply({ ok: true, ...r }), e => reply({ ok: false, error: setupError(e) }));
963
+ return true;
964
+ });
965
+
966
+ chrome.storage.local.get('chatgptState').then(r => {
967
+ if (r.chatgptState && !chatgptState.checkedAt) chatgptState = { ...chatgptState, ...r.chatgptState };
968
+ }).catch(() => {});
969
+
970
+ // ---------------------------------------------------------------- gallery side panel
971
+
972
+ // What the side panel may ask the host through the worker (the host has its own allow-list too).
973
+ const PANEL_HOST_METHODS = new Set(['gallery_list', 'gallery_thumb', 'reveal_image', 'collection_add_job_image', 'list_collections']);
974
+
975
+ chrome.runtime.onMessage.addListener((msg, sender, reply) => {
976
+ if (!msg || sender.id !== chrome.runtime.id || !String(sender.url || '').startsWith(chrome.runtime.getURL(''))) return;
977
+ if (msg.type === 'panel:status') {
978
+ reply({
979
+ connected: hostReady, lastError,
980
+ current: current ? { id: current.job.id, step: current.step, message: current.message, startedAt: current.startedAt } : null,
981
+ });
982
+ } else if (msg.type === 'panel:call') {
983
+ if (!PANEL_HOST_METHODS.has(msg.method)) { reply({ ok: false, error: `Not allowed: ${msg.method}` }); return; }
984
+ callHost(msg.method, msg.params || {}).then(result => reply({ ok: true, result }), e => reply({ ok: false, error: String((e && e.message) || e) }));
985
+ return true;
986
+ }
987
+ });
988
+
989
+ // A menu click counts as the user gesture sidePanel.open needs, so nothing is awaited before it.
990
+ chrome.contextMenus.onClicked.addListener((info, tab) => {
991
+ if (info.menuItemId !== backdrawMenus.GALLERY_ID || !tab || !(tab.windowId >= 0)) return;
992
+ try {
993
+ chrome.sidePanel.open({ windowId: tab.windowId }).catch(e => log('warn', 'the side panel did not open', String(e)));
994
+ } catch (e) {
995
+ log('warn', 'the side panel did not open', String(e));
996
+ }
997
+ });
998
+
999
+ // ---------------------------------------------------------------- start
1000
+
1001
+ async function closeStaleWorker() {
1002
+ const { workerTabId: stale } = await chrome.storage.session.get('workerTabId');
1003
+ if (stale !== undefined && stale !== null && !current && workerTabId === null) {
1004
+ await chrome.storage.session.remove('workerTabId');
1005
+ closeTabQuietly(stale);
1006
+ }
1007
+ }
1008
+
1009
+ chrome.runtime.onStartup.addListener(connect);
1010
+ chrome.runtime.onInstalled.addListener(connect);
1011
+ chrome.alarms.create('bridge-reconnect', { periodInMinutes: 1 });
1012
+ chrome.alarms.onAlarm.addListener(a => { if (a.name === 'bridge-reconnect' && !port) connect(); });
1013
+ setBadge();
1014
+ connect();
1015
+ closeStaleWorker().catch(() => {});