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
package/host.js ADDED
@@ -0,0 +1,664 @@
1
+ 'use strict';
2
+ // Native messaging host. Chrome starts it when the extension connects and ends it when Chrome
3
+ // closes. It owns the job queue, hands jobs to the extension one at a time, saves the images and
4
+ // answers the MCP server / CLI over a per-user named pipe. It never sees cookies or passwords:
5
+ // the extension only sends prompts, progress, chat links and image bytes.
6
+ const crypto = require('crypto');
7
+ const fs = require('fs');
8
+ const net = require('net');
9
+ const os = require('os');
10
+ const path = require('path');
11
+ const identity = require('./identity');
12
+ const shared = require('./shared');
13
+ const { stamp, slugify, sniffExt } = require('./lib/naming');
14
+ const { normName, parseProjectUrl, pickProject } = require('./lib/projects');
15
+ const { buildText } = require('./lib/prompt');
16
+ const { aspectOption } = require('./lib/aspect');
17
+ const { pngInfo } = require('./lib/png');
18
+ const { MAX_REFS, MAX_REF_BYTES, MAX_REFS_TOTAL_BYTES, MAX_COLLECTION_BYTES } = require('./lib/limits');
19
+ const { withCollection } = require('./lib/collections');
20
+ const store = require('./lib/collection-store');
21
+ const { callAssembler, answerCall } = require('./lib/extension-calls');
22
+ const gallery = require('./lib/gallery');
23
+ const platform = require('./platform');
24
+
25
+ const VERSION = shared.VERSION;
26
+ const LOG_FILE = path.join(shared.LOG_DIR, 'host.log');
27
+ const MAX_JOBS = 100;
28
+ // needs_reference: ChatGPT asked for the image to be uploaded again; text_reply: it answered in text.
29
+ const TERMINAL = new Set(['succeeded', 'failed', 'cancelled', 'needs_reference', 'text_reply']);
30
+ const CHUNK = 600000; // base64 chars per message; Chrome caps host->extension messages at 1 MB
31
+
32
+ let config = loadConfig();
33
+ let jobs = shared.readJson(shared.JOBS_FILE, []);
34
+ let extension = null;
35
+ let runningId = null;
36
+ let watchdog = null;
37
+ let reqSeq = 0;
38
+ let saveTimer = null;
39
+ const pending = new Map();
40
+ const refStore = new Map(); // jobId -> reference images (kept in memory only, until the job ends)
41
+
42
+ function now() { return new Date().toISOString(); }
43
+
44
+ function log(level, msg, data) {
45
+ try {
46
+ fs.mkdirSync(shared.LOG_DIR, { recursive: true });
47
+ try { if (fs.statSync(LOG_FILE).size > 4 * 1024 * 1024) fs.renameSync(LOG_FILE, `${LOG_FILE}.1`); } catch {}
48
+ let extra = '';
49
+ if (data !== undefined) {
50
+ extra = ' ' + (typeof data === 'string' ? data : JSON.stringify(data));
51
+ if (extra.length > 40000) extra = extra.slice(0, 40000) + '…';
52
+ }
53
+ fs.appendFileSync(LOG_FILE, `${now()} [${level}] ${msg}${extra}\n`);
54
+ } catch {}
55
+ }
56
+
57
+ function loadConfig() {
58
+ const c = shared.readJson(shared.CONFIG_FILE, {});
59
+ return {
60
+ project: c.project || null,
61
+ outputDir: c.outputDir || path.join(os.homedir(), 'Pictures', identity.IMAGES_DIR_NAME),
62
+ collectionsDir: c.collectionsDir || path.join(os.homedir(), 'Pictures', identity.IMAGES_DIR_NAME, identity.COLLECTIONS_DIR_NAME),
63
+ debug: !!c.debug,
64
+ imagesCentralized: !!c.imagesCentralized,
65
+ };
66
+ }
67
+
68
+ function saveConfig() { shared.writeJson(shared.CONFIG_FILE, config); }
69
+
70
+ function saveJobs() {
71
+ clearTimeout(saveTimer);
72
+ saveTimer = null;
73
+ try { shared.writeJson(shared.JOBS_FILE, jobs.slice(-MAX_JOBS)); } catch (e) { log('error', 'saving jobs failed', String(e)); }
74
+ }
75
+
76
+ function saveJobsSoon() { if (!saveTimer) saveTimer = setTimeout(saveJobs, 1000); }
77
+
78
+ // ---------------------------------------------------------------- Chrome native messaging (stdio)
79
+
80
+ let inChunks = [];
81
+ let inLen = 0;
82
+
83
+ function drainInput() {
84
+ while (inLen >= 4) {
85
+ if (inChunks[0].length < 4) inChunks = [Buffer.concat(inChunks, inLen)];
86
+ const size = inChunks[0].readUInt32LE(0);
87
+ if (inLen < 4 + size) return;
88
+ const all = inChunks.length === 1 ? inChunks[0] : Buffer.concat(inChunks, inLen);
89
+ const body = all.subarray(4, 4 + size);
90
+ const rest = all.subarray(4 + size);
91
+ inChunks = rest.length ? [rest] : [];
92
+ inLen = rest.length;
93
+ let msg;
94
+ try { msg = JSON.parse(body.toString('utf8')); } catch (e) { log('error', 'unreadable message from extension', String(e)); continue; }
95
+ try { onExtensionMessage(msg); } catch (e) { log('error', 'extension message handler failed', e.stack); }
96
+ }
97
+ }
98
+
99
+ function toExtension(msg) {
100
+ const body = Buffer.from(JSON.stringify(msg), 'utf8');
101
+ const head = Buffer.alloc(4);
102
+ head.writeUInt32LE(body.length, 0);
103
+ process.stdout.write(Buffer.concat([head, body]));
104
+ }
105
+
106
+ function onExtensionMessage(msg) {
107
+ switch (msg.type) {
108
+ case 'hello':
109
+ extension = { version: msg.version || '?', connectedAt: now() };
110
+ log('info', `extension connected (v${extension.version})`);
111
+ // home: the extension shows paths with the home folder (it holds the user's name) as "~".
112
+ toExtension({ type: 'welcome', hostVersion: VERSION, project: config.project, collections: collectionSummaries(), home: os.homedir() });
113
+ pump();
114
+ break;
115
+ case 'progress': onProgress(msg); break;
116
+ case 'result': onResult(msg); break;
117
+ case 'response': {
118
+ const p = pending.get(msg.reqId);
119
+ if (!p) break;
120
+ pending.delete(msg.reqId);
121
+ clearTimeout(p.timer);
122
+ if (msg.ok) p.resolve(msg.data);
123
+ else p.reject(new shared.BridgeError(msg.error || 'The extension reported an error.', msg.code || 'EXTENSION'));
124
+ break;
125
+ }
126
+ case 'set_project':
127
+ try { setProject(msg.project); } catch (e) { log('warn', 'project from popup rejected', e.message); }
128
+ break;
129
+ case 'log': log(msg.level || 'ext', `[ext] ${msg.msg}`, msg.data); break;
130
+ case 'call_chunk': extensionCalls.chunk(msg); break;
131
+ case 'call':
132
+ answerCall(msg, handlers, extensionCalls.take(msg.reqId), MAX_COLLECTION_BYTES / 1024 / 1024).then(toExtension);
133
+ break;
134
+ default: log('warn', 'unknown message from extension', msg.type);
135
+ }
136
+ }
137
+
138
+ function askExtension(action, params = {}, timeoutMs = 120000) {
139
+ if (!extension) return Promise.reject(new shared.BridgeError('The Chrome extension has not connected yet.', 'NO_EXTENSION'));
140
+ const reqId = `r${++reqSeq}`;
141
+ return new Promise((resolve, reject) => {
142
+ const timer = setTimeout(() => {
143
+ pending.delete(reqId);
144
+ reject(new shared.BridgeError(`The extension did not finish "${action}" in time.`, 'TIMEOUT'));
145
+ }, timeoutMs);
146
+ pending.set(reqId, { resolve, reject, timer });
147
+ toExtension({ type: 'request', reqId, action, params });
148
+ });
149
+ }
150
+
151
+ // Calls from the extension itself (collections); lib/extension-calls.js keeps them to its allow-list.
152
+ const extensionCalls = callAssembler(Math.ceil(MAX_COLLECTION_BYTES / 3) * 4 + 4);
153
+
154
+ // ---------------------------------------------------------------- jobs
155
+
156
+ function clamp(n, lo, hi) { return Math.min(hi, Math.max(lo, n)); }
157
+
158
+ function findJob(id) {
159
+ const job = jobs.find(j => j.id === id);
160
+ if (!job) throw new shared.BridgeError(`Unknown job id "${id}".`, 'NOT_FOUND');
161
+ return job;
162
+ }
163
+
164
+ function createJob(p) {
165
+ // mode "collect" only downloads the images of an existing conversation; nothing is sent.
166
+ const mode = p.mode === 'collect' ? 'collect' : 'generate';
167
+ const prompt = String(p.prompt || '').trim();
168
+ if (!prompt && mode === 'generate') throw new shared.BridgeError('The prompt is empty.', 'BAD_REQUEST');
169
+ const startUrl = p.startUrl ? String(p.startUrl).trim() : null;
170
+ if (startUrl && !/^https:\/\/chatgpt\.com\//i.test(startUrl)) {
171
+ throw new shared.BridgeError('conversation_url must be a https://chatgpt.com/ link.', 'BAD_REQUEST');
172
+ }
173
+ if (mode === 'collect' && !/\/c\/[0-9a-f-]{8,}/i.test(startUrl || '')) {
174
+ throw new shared.BridgeError('A ChatGPT conversation link (https://chatgpt.com/…/c/…) is required.', 'BAD_REQUEST');
175
+ }
176
+ if (!startUrl && !config.project) {
177
+ throw new shared.BridgeError('No ChatGPT project is selected yet. Call list_projects, then select_project ' +
178
+ "with the user's images project.", 'NO_PROJECT');
179
+ }
180
+ for (const key of ['outputDir', 'copyTo']) {
181
+ if (p[key] && !path.isAbsolute(p[key])) throw new shared.BridgeError('output_dir must be an absolute folder path.', 'BAD_REQUEST');
182
+ }
183
+ const refs = Array.isArray(p.references) ? p.references : [];
184
+ if (refs.length > MAX_REFS) throw new shared.BridgeError(`At most ${MAX_REFS} reference images per request.`, 'BAD_REQUEST');
185
+ for (const r of refs) {
186
+ if (!r || !r.data || !/^image\//.test(r.mime || '')) throw new shared.BridgeError('Each reference must be an image file.', 'BAD_REQUEST');
187
+ if (r.data.length * 0.75 > MAX_REF_BYTES) throw new shared.BridgeError(`Reference "${r.name}" is larger than ${MAX_REF_BYTES / 1048576} MB.`, 'BAD_REQUEST');
188
+ }
189
+ if (refs.reduce((sum, r) => sum + r.data.length * 0.75, 0) > MAX_REFS_TOTAL_BYTES) {
190
+ throw new shared.BridgeError(`The reference images together are larger than ${MAX_REFS_TOTAL_BYTES / 1048576} MB.`, 'BAD_REQUEST');
191
+ }
192
+ if (mode === 'collect' && refs.length) throw new shared.BridgeError('Reference images cannot be used when only fetching images.', 'BAD_REQUEST');
193
+ let aspect;
194
+ try { aspect = aspectOption(p.aspect); } catch (e) { throw new shared.BridgeError(e.message, 'BAD_REQUEST'); }
195
+ const job = {
196
+ id: `img_${crypto.randomBytes(4).toString('hex')}`,
197
+ mode, status: 'queued', step: 'queued', message: 'Waiting for its turn',
198
+ prompt, sendAsIs: !!p.sendAsIs, aspect, transparent: !!p.transparent, startUrl, project: startUrl ? null : config.project,
199
+ // outputDir: legacy clients, the host writes a copy there. copyTo: the MCP server copies itself
200
+ // (it runs in the caller's context, so the copy lands where the caller can see it).
201
+ outputDir: p.outputDir || null, copyTo: p.copyTo || null, copies: [],
202
+ references: refs.map(r => ({ name: r.name, bytes: Math.round(r.data.length * 0.75) })), autoReattach: !!p.autoReattach,
203
+ timeoutSec: clamp(Number(p.timeoutSec) || 600, 60, 1800),
204
+ createdAt: now(), startedAt: null, finishedAt: null,
205
+ conversationUrl: null, images: [], assistantText: null, error: null, reason: null, editCheck: null, reattached: false,
206
+ };
207
+ if (refs.length) refStore.set(job.id, refs.map(r => ({ name: String(r.name || 'reference.png'), mime: r.mime, data: r.data })));
208
+ jobs.push(job);
209
+ if (jobs.length > MAX_JOBS) jobs = jobs.filter((j, i) => i >= jobs.length - MAX_JOBS || !TERMINAL.has(j.status));
210
+ saveJobs();
211
+ log('info', `job ${job.id} queued`, { prompt: prompt.slice(0, 300), start: startUrl || job.project.url });
212
+ pump();
213
+ return job;
214
+ }
215
+
216
+ function pump() {
217
+ if (runningId || !extension) return;
218
+ const job = jobs.find(j => j.status === 'queued');
219
+ if (!job) return;
220
+ runningId = job.id;
221
+ Object.assign(job, { status: 'running', step: 'starting', message: 'Starting', startedAt: now() });
222
+ saveJobs();
223
+ // Reference images go first, in pieces; the extension puts them together when "run" arrives.
224
+ (refStore.get(job.id) || []).forEach((r, index) => {
225
+ const total = Math.max(1, Math.ceil(r.data.length / CHUNK));
226
+ for (let seq = 0; seq < total; seq++) {
227
+ toExtension({ type: 'ref_chunk', jobId: job.id, index, name: r.name, mime: r.mime, seq, total, data: r.data.slice(seq * CHUNK, (seq + 1) * CHUNK) });
228
+ }
229
+ });
230
+ toExtension({
231
+ type: 'run',
232
+ job: {
233
+ id: job.id, mode: job.mode, text: job.mode === 'collect' ? '' : buildText(job), startUrl: job.startUrl || job.project.url,
234
+ timeoutSec: job.timeoutSec, debug: config.debug, autoReattach: job.autoReattach,
235
+ aspect: job.aspect ? job.aspect.target : null, transparent: !!job.transparent,
236
+ },
237
+ });
238
+ clearTimeout(watchdog);
239
+ // Generous: a login/CAPTCHA pause may add up to 10 minutes on top of the job timeout.
240
+ watchdog = setTimeout(() => {
241
+ if (runningId !== job.id) return;
242
+ toExtension({ type: 'cancel', jobId: job.id });
243
+ finishJob(job, { status: 'failed', reason: 'timeout', error: 'The extension stopped reporting on this job.' });
244
+ }, (job.timeoutSec + 900) * 1000);
245
+ }
246
+
247
+ function finishJob(job, fields) {
248
+ if (TERMINAL.has(job.status)) return;
249
+ Object.assign(job, fields, { finishedAt: now() });
250
+ job.step = job.status;
251
+ refStore.delete(job.id);
252
+ if (runningId === job.id) { runningId = null; clearTimeout(watchdog); }
253
+ saveJobs();
254
+ log('info', `job ${job.id} ${job.status}`, { error: job.error, conversation: job.conversationUrl, files: job.images.map(i => i.path) });
255
+ setImmediate(pump);
256
+ }
257
+
258
+ function onProgress(msg) {
259
+ const job = jobs.find(j => j.id === msg.jobId);
260
+ if (!job || TERMINAL.has(job.status)) return;
261
+ job.status = msg.needsHuman ? 'needs_human' : 'running';
262
+ if (msg.step) job.step = msg.step;
263
+ if (msg.message) job.message = msg.message;
264
+ if (msg.conversationUrl) job.conversationUrl = msg.conversationUrl;
265
+ job.updatedAt = now();
266
+ saveJobsSoon();
267
+ }
268
+
269
+ function onResult(msg) {
270
+ const job = jobs.find(j => j.id === msg.jobId);
271
+ if (job && !TERMINAL.has(job.status)) {
272
+ if (msg.conversationUrl) job.conversationUrl = msg.conversationUrl;
273
+ if (msg.assistantText) job.assistantText = String(msg.assistantText).slice(0, 4000);
274
+ job.reattached = !!msg.reattached;
275
+ if (msg.ok && Array.isArray(msg.images) && msg.images.length) {
276
+ try {
277
+ const images = saveImages(job, msg.images, msg.title);
278
+ finishJob(job, { status: 'succeeded', images, editCheck: msg.editCheck || null, message: `${images.length} image(s) saved` });
279
+ } catch (e) {
280
+ finishJob(job, { status: 'failed', reason: 'save_failed', error: `The image was generated but could not be saved: ${e.message}` });
281
+ }
282
+ } else if (msg.status === 'needs_reference' || msg.status === 'text_reply') {
283
+ finishJob(job, { status: msg.status, reason: msg.status, error: msg.error, message: msg.error });
284
+ } else if (msg.status === 'cancelled') {
285
+ finishJob(job, { status: 'cancelled', error: 'Cancelled.', message: 'Cancelled' });
286
+ } else {
287
+ const error = msg.error || 'ChatGPT did not return an image.';
288
+ finishJob(job, { status: 'failed', reason: msg.reason || null, error, message: error });
289
+ }
290
+ }
291
+ if (runningId === msg.jobId) {
292
+ runningId = null;
293
+ clearTimeout(watchdog);
294
+ setImmediate(pump);
295
+ }
296
+ }
297
+
298
+ function cancelJob(id) {
299
+ const job = findJob(id);
300
+ if (TERMINAL.has(job.status)) return job;
301
+ if (job.status === 'queued') {
302
+ finishJob(job, { status: 'cancelled', error: 'Cancelled before it started.', message: 'Cancelled' });
303
+ return job;
304
+ }
305
+ job.message = 'Cancelling…';
306
+ toExtension({ type: 'cancel', jobId: job.id });
307
+ // If the extension never answers, don't leave the job hanging.
308
+ setTimeout(() => finishJob(job, { status: 'cancelled', error: 'Cancelled.', message: 'Cancelled' }), 20000);
309
+ return job;
310
+ }
311
+
312
+ // ---------------------------------------------------------------- saving images
313
+
314
+ function uniquePath(dir, base, ext) {
315
+ let file = path.join(dir, `${base}.${ext}`);
316
+ for (let n = 2; fs.existsSync(file); n++) file = path.join(dir, `${base}_${n}.${ext}`);
317
+ return file;
318
+ }
319
+
320
+ // Every image is saved in the central images folder. A legacy outputDir request gets an extra copy.
321
+ // When the extension cropped an image to the asked aspect ratio, the crop is the main file and
322
+ // ChatGPT's original is kept next to it as <name>_full.
323
+ function saveImages(job, images, title) {
324
+ fs.mkdirSync(config.outputDir, { recursive: true });
325
+ fs.mkdirSync(shared.PREVIEW_DIR, { recursive: true });
326
+ // Named after the prompt; when only collecting (no prompt) ChatGPT's chat title is used instead.
327
+ const usefulTitle = title && !/^(chatgpt\b|generate an image|update the image)/i.test(title.trim()) ? title : '';
328
+ const base = `${stamp()}_${slugify(job.prompt || usefulTitle)}`;
329
+ return images.map((img, i) => {
330
+ const buf = Buffer.from(img.data || '', 'base64');
331
+ if (buf.length < 100) throw new Error('empty image data');
332
+ const mimeExt = { 'image/png': 'png', 'image/jpeg': 'jpg', 'image/webp': 'webp', 'image/gif': 'gif' }[String(img.mime).split(';')[0]];
333
+ const name = images.length > 1 ? `${base}-${i + 1}` : base;
334
+ const ext = sniffExt(buf) || mimeExt || 'png';
335
+ const cropped = img.crop ? Buffer.from(img.crop.data || '', 'base64') : null;
336
+ const crop = cropped && cropped.length >= 100 ? cropped : null;
337
+ const main = crop || buf;
338
+ const mainExt = crop ? 'png' : ext;
339
+ const file = uniquePath(config.outputDir, name, mainExt);
340
+ fs.writeFileSync(file, main);
341
+ let full = null;
342
+ if (crop) {
343
+ const fullFile = uniquePath(config.outputDir, `${path.basename(file, `.${mainExt}`)}_full`, ext);
344
+ fs.writeFileSync(fullFile, buf);
345
+ full = { path: fullFile, width: img.width || null, height: img.height || null, bytes: buf.length };
346
+ }
347
+ if (job.outputDir && path.resolve(job.outputDir) !== path.resolve(config.outputDir)) {
348
+ try {
349
+ fs.mkdirSync(job.outputDir, { recursive: true });
350
+ const copy = uniquePath(job.outputDir, name, mainExt);
351
+ fs.writeFileSync(copy, main);
352
+ job.copies.push(copy);
353
+ } catch (e) { log('warn', 'copy to output_dir failed', e.message); }
354
+ }
355
+ let preview = null;
356
+ if (img.preview) {
357
+ preview = path.join(shared.PREVIEW_DIR, `${job.id}-${i + 1}.jpg`);
358
+ fs.writeFileSync(preview, Buffer.from(img.preview, 'base64'));
359
+ }
360
+ // From ChatGPT's own bytes: a cropped copy is always RGBA, so only the original tells whether
361
+ // ChatGPT sent an alpha channel.
362
+ const info = pngInfo(buf);
363
+ return {
364
+ path: file, width: (crop ? img.crop.width : img.width) || null, height: (crop ? img.crop.height : img.height) || null,
365
+ bytes: main.length, preview, full, aspect: job.aspect ? job.aspect.target.label : null,
366
+ png: info && { colorType: info.colorType, alphaChannel: info.alphaChannel, trns: info.trns }, alpha: img.alpha || null,
367
+ };
368
+ });
369
+ }
370
+
371
+ // Images this bridge already saved from the same ChatGPT conversation, newest first.
372
+ function chatId(url) { const m = String(url || '').match(/\/c\/([0-9a-f-]{8,})/i); return m ? m[1].toLowerCase() : null; }
373
+ function imagesFromChat(job) {
374
+ const id = chatId(job.conversationUrl || job.startUrl);
375
+ if (!id) return [];
376
+ return jobs.filter(j => j !== job && j.status === 'succeeded' && chatId(j.conversationUrl) === id)
377
+ .flatMap(j => j.images.map(im => ({ job: j.id, path: im.path, createdAt: j.createdAt, prompt: j.prompt.slice(0, 100) })))
378
+ .reverse().slice(0, 8);
379
+ }
380
+
381
+ // v1.0 saved images straight into the caller's output_dir only. Keep a copy of every image in the
382
+ // central folder too, and point the job there (callers may rename or move their own copies). This
383
+ // runs once: changing the images folder later must not copy the whole history into the new one.
384
+ function centralizeLegacyImages() {
385
+ if (config.imagesCentralized) return;
386
+ const central = path.resolve(config.outputDir).toLowerCase();
387
+ let changed = false;
388
+ for (const j of jobs) {
389
+ if (j.status !== 'succeeded' || !Array.isArray(j.images)) continue;
390
+ for (const im of j.images) {
391
+ if (!im.path || path.dirname(path.resolve(im.path)).toLowerCase() === central) continue;
392
+ if (!fs.existsSync(im.path)) {
393
+ // Moved away by its caller, but a copy with the same name may already be in the folder.
394
+ const same = path.join(config.outputDir, path.basename(im.path));
395
+ if (fs.existsSync(same)) {
396
+ j.copies = Array.from(new Set([...(j.copies || []), im.path]));
397
+ im.path = same;
398
+ changed = true;
399
+ }
400
+ continue;
401
+ }
402
+ try {
403
+ fs.mkdirSync(config.outputDir, { recursive: true });
404
+ const ext = path.extname(im.path);
405
+ const dest = uniquePath(config.outputDir, path.basename(im.path, ext), ext.slice(1) || 'png');
406
+ fs.copyFileSync(im.path, dest);
407
+ j.copies = Array.from(new Set([...(j.copies || []), im.path]));
408
+ im.path = dest;
409
+ changed = true;
410
+ log('info', `copied ${path.basename(dest)} into the images folder`);
411
+ } catch (e) {
412
+ log('warn', 'could not copy an older image into the images folder', { path: im.path, error: e.message });
413
+ }
414
+ }
415
+ }
416
+ if (changed) saveJobs();
417
+ config.imagesCentralized = true;
418
+ try { saveConfig(); } catch (e) { log('warn', 'saving the config failed', String(e)); }
419
+ }
420
+
421
+ function cleanupPreviews() {
422
+ try {
423
+ const cutoff = Date.now() - 7 * 24 * 3600 * 1000;
424
+ for (const f of fs.readdirSync(shared.PREVIEW_DIR)) {
425
+ const full = path.join(shared.PREVIEW_DIR, f);
426
+ if (fs.statSync(full).mtimeMs < cutoff) fs.unlinkSync(full);
427
+ }
428
+ } catch {}
429
+ }
430
+
431
+ // ---------------------------------------------------------------- projects
432
+
433
+ function setProject(p) {
434
+ const parsed = parseProjectUrl(p && p.url);
435
+ if (!parsed) throw new shared.BridgeError('That is not a ChatGPT project link (expected https://chatgpt.com/g/g-p-…/project).', 'BAD_REQUEST');
436
+ config.project = { id: parsed.id, name: String(p.name || parsed.slug || parsed.id).trim(), url: parsed.url, selectedAt: now() };
437
+ saveConfig();
438
+ if (extension) toExtension({ type: 'config', project: config.project });
439
+ log('info', 'project selected', config.project);
440
+ return config.project;
441
+ }
442
+
443
+ async function selectProject(p) {
444
+ if (p.url) return { project: setProject({ url: p.url, name: p.name }) };
445
+ if (!normName(p.name)) throw new shared.BridgeError('Give the project name (or its URL).', 'BAD_REQUEST');
446
+ const projects = await askExtension('list_projects', {}, 120000);
447
+ const { project: best, error, ties } = pickProject(projects, p.name);
448
+ const names = projects.map(x => x.name).join(', ') || '(none)';
449
+ if (error === 'NOT_FOUND') throw new shared.BridgeError(`No ChatGPT project matches "${p.name}". Available projects: ${names}`, 'NOT_FOUND');
450
+ if (error === 'AMBIGUOUS') throw new shared.BridgeError(`"${p.name}" matches several projects: ${ties.map(x => x.name).join(', ')}. Use the exact name or URL.`, 'AMBIGUOUS');
451
+ const chosen = best.url ? best : await askExtension('resolve_project', { name: best.name }, 120000);
452
+ return { project: setProject(chosen), available: projects.map(x => x.name) };
453
+ }
454
+
455
+ // ---------------------------------------------------------------- reference collections
456
+
457
+ function collectionSummaries() {
458
+ try {
459
+ return store.list(config.collectionsDir).map(x => ({ name: x.name, count: x.count }));
460
+ } catch (e) {
461
+ log('warn', 'could not read the collections folder', { dir: config.collectionsDir, error: e.message });
462
+ return [];
463
+ }
464
+ }
465
+
466
+ // The extension rebuilds its right-click menu from the list it gets after every change.
467
+ function collectionsChanged(what, result) {
468
+ log('info', `collections: ${what}`, result);
469
+ if (extension) toExtension({ type: 'collections', collections: collectionSummaries() });
470
+ return result;
471
+ }
472
+
473
+ // reference_collection: the host reads the chosen images itself (it owns the folder) and attaches
474
+ // them after the caller's own reference images (lib/collections.js decides which).
475
+ function createWithCollection(p) {
476
+ const dir = config.collectionsDir;
477
+ const { params, collection } = withCollection(p, { find: name => store.find(dir, name), read: (col, im) => store.readImage(dir, col, im) });
478
+ const job = createJob(params);
479
+ if (collection) {
480
+ job.collection = collection;
481
+ saveJobs();
482
+ log('info', `job ${job.id} uses collection "${collection.name}"`, { used: collection.used, skipped: collection.skipped });
483
+ }
484
+ return job;
485
+ }
486
+
487
+ // ---------------------------------------------------------------- gallery (the extension's side panel)
488
+
489
+ // The panel names an image by job id and number only; the file is always the one the job saved.
490
+ function galleryImage(p, needFile) {
491
+ const found = gallery.findJobImage(jobs, p.jobId, p.n);
492
+ if (!found) throw new shared.BridgeError('That image is not in the gallery any more.', 'NOT_FOUND');
493
+ if (needFile && !gallery.fileExists(found.image.path)) {
494
+ throw new shared.BridgeError(`The image file was moved or deleted: ${found.image.path}`, 'NOT_FOUND');
495
+ }
496
+ return found;
497
+ }
498
+
499
+ // Like a right-click add, with the chat as the source; the size limit applies before the file is read.
500
+ // The panel's small JPEG (checked by the store like any thumbnail) or else the saved preview becomes
501
+ // the collection's thumbnail; the store drops a preview over its thumbnail limit (256 KB), so then
502
+ // there is none.
503
+ function addJobImage(p) {
504
+ const { job, image } = galleryImage(p, true);
505
+ if (fs.statSync(image.path).size > MAX_COLLECTION_BYTES) {
506
+ throw new shared.BridgeError(`The image is larger than ${MAX_COLLECTION_BYTES / 1024 / 1024} MB.`, 'TOO_LARGE');
507
+ }
508
+ const thumb = typeof p.thumb === 'string' && p.thumb ? p.thumb : gallery.readPreview(shared.PREVIEW_DIR, image).data;
509
+ const params = {
510
+ collection: p.collection, create: !!p.create, data: fs.readFileSync(image.path).toString('base64'), thumb,
511
+ sourceUrl: job.conversationUrl, pageUrl: job.conversationUrl, width: image.width, height: image.height,
512
+ };
513
+ const fileName = path.basename(image.path, path.extname(image.path));
514
+ return collectionsChanged('add from the gallery', store.add(config.collectionsDir, params, new Date(), { fileName }));
515
+ }
516
+
517
+ // ---------------------------------------------------------------- local API (named pipe)
518
+
519
+ function publicJob(j) {
520
+ if (!j) return null;
521
+ return {
522
+ id: j.id, mode: j.mode, status: j.status, step: j.step, message: j.message,
523
+ prompt: j.prompt.length > 300 ? `${j.prompt.slice(0, 300)}…` : j.prompt,
524
+ conversationUrl: j.conversationUrl, startUrl: j.startUrl, images: j.images, assistantText: j.assistantText,
525
+ error: j.error, reason: j.reason, createdAt: j.createdAt, startedAt: j.startedAt, finishedAt: j.finishedAt,
526
+ references: j.references || [], reattached: !!j.reattached, editCheck: j.editCheck || null,
527
+ aspect: j.aspect ? j.aspect.target.label : null, transparent: !!j.transparent,
528
+ copyTo: j.copyTo || null, copies: j.copies || [],
529
+ collection: j.collection || null,
530
+ chatImages: j.status === 'needs_reference' || j.status === 'text_reply' ? imagesFromChat(j) : undefined,
531
+ queuePosition: j.status === 'queued' ? jobs.filter(x => x.status === 'queued').indexOf(j) + 1 : undefined,
532
+ };
533
+ }
534
+
535
+ function statusInfo() {
536
+ return {
537
+ hostVersion: VERSION,
538
+ extension,
539
+ project: config.project,
540
+ outputDir: config.outputDir,
541
+ collectionsDir: config.collectionsDir,
542
+ debug: config.debug,
543
+ running: publicJob(jobs.find(j => j.id === runningId)),
544
+ queued: jobs.filter(j => j.status === 'queued').map(j => j.id),
545
+ recent: jobs.slice(-5).reverse().map(j => ({
546
+ id: j.id, status: j.status, createdAt: j.createdAt, prompt: j.prompt.slice(0, 80), conversationUrl: j.conversationUrl,
547
+ })),
548
+ };
549
+ }
550
+
551
+ const handlers = {
552
+ status: () => statusInfo(),
553
+ generate: p => publicJob(createWithCollection(p)),
554
+ job: p => publicJob(findJob(p.jobId)),
555
+ // The MCP server copies finished images into the caller's output_dir and records it here once.
556
+ mark_copied: p => {
557
+ const job = findJob(p.jobId);
558
+ job.copies = Array.from(new Set([...(job.copies || []), ...(p.paths || [])]));
559
+ saveJobs();
560
+ return publicJob(job);
561
+ },
562
+ cancel: p => publicJob(cancelJob(p.jobId)),
563
+ list_projects: async () => ({ projects: await askExtension('list_projects', {}, 120000), selected: config.project }),
564
+ select_project: p => selectProject(p),
565
+ set_config: p => {
566
+ if (p.outputDir !== undefined) {
567
+ if (!path.isAbsolute(String(p.outputDir))) throw new shared.BridgeError('outputDir must be absolute.', 'BAD_REQUEST');
568
+ config.outputDir = String(p.outputDir);
569
+ }
570
+ if (p.collectionsDir !== undefined) {
571
+ if (!path.isAbsolute(String(p.collectionsDir))) throw new shared.BridgeError('collectionsDir must be absolute.', 'BAD_REQUEST');
572
+ config.collectionsDir = String(p.collectionsDir);
573
+ }
574
+ if (p.debug !== undefined) config.debug = !!p.debug;
575
+ saveConfig();
576
+ if (p.collectionsDir !== undefined) collectionsChanged('folder set', { dir: config.collectionsDir });
577
+ return config;
578
+ },
579
+ diagnose: p => askExtension('diagnose', p, 180000),
580
+ // ChatGPT sign-in as the extension last saw it ({ loggedIn, challenge, checkedAt }); opens no tab.
581
+ chatgpt_state: () => askExtension('chatgpt_state', {}, 10000),
582
+ reload_extension: () => {
583
+ if (!extension) throw new shared.BridgeError('The extension is not connected.', 'NO_EXTENSION');
584
+ toExtension({ type: 'reload' });
585
+ return { ok: true };
586
+ },
587
+ // summary: without the image lists (the popup only needs names and counts).
588
+ list_collections: p => {
589
+ const list = store.list(config.collectionsDir);
590
+ return p.summary ? list.map(({ images, ...rest }) => rest) : list;
591
+ },
592
+ collection_add: p => collectionsChanged('add', store.add(config.collectionsDir, p)),
593
+ rename_collection: p => collectionsChanged('rename', store.rename(config.collectionsDir, p.name, p.newName)),
594
+ delete_collection: p => collectionsChanged('delete', store.remove(config.collectionsDir, p.name)),
595
+ delete_collection_image: p => collectionsChanged('delete image', store.removeImage(config.collectionsDir, p.name, p.file)),
596
+ collection_thumbs: p => store.thumbs(config.collectionsDir, p.name, p.offset),
597
+ gallery_list: p => gallery.listGallery(jobs, p.offset, p.limit, gallery.fileExists),
598
+ gallery_thumb: p => gallery.readPreview(shared.PREVIEW_DIR, galleryImage(p).image),
599
+ reveal_image: p => {
600
+ platform.revealInFolder(galleryImage(p, true).image.path);
601
+ return { ok: true };
602
+ },
603
+ collection_add_job_image: p => addJobImage(p),
604
+ };
605
+
606
+ async function answer(sock, line) {
607
+ let req;
608
+ try { req = JSON.parse(line); } catch { return; }
609
+ let reply;
610
+ try {
611
+ const fn = handlers[req.method];
612
+ if (!fn) throw new shared.BridgeError(`Unknown method "${req.method}".`, 'BAD_REQUEST');
613
+ reply = { id: req.id, result: await fn(req.params || {}) };
614
+ } catch (e) {
615
+ reply = { id: req.id, error: { message: e.message, code: e.code || 'ERROR' } };
616
+ }
617
+ if (!sock.destroyed) sock.write(JSON.stringify(reply) + '\n');
618
+ }
619
+
620
+ const server = net.createServer(sock => {
621
+ let buf = '';
622
+ sock.setEncoding('utf8');
623
+ sock.on('data', chunk => {
624
+ buf += chunk;
625
+ let nl;
626
+ while ((nl = buf.indexOf('\n')) >= 0) {
627
+ const line = buf.slice(0, nl);
628
+ buf = buf.slice(nl + 1);
629
+ if (line.trim()) answer(sock, line);
630
+ }
631
+ });
632
+ sock.on('error', () => {});
633
+ });
634
+
635
+ function shutdown(reason) {
636
+ log('info', `host stopping: ${reason}`);
637
+ for (const j of jobs) {
638
+ if (!TERMINAL.has(j.status)) Object.assign(j, { status: 'failed', step: 'failed', error: `Interrupted: ${reason}.`, finishedAt: now() });
639
+ }
640
+ saveJobs();
641
+ try { server.close(); } catch {}
642
+ process.exit(0);
643
+ }
644
+
645
+ // ---------------------------------------------------------------- start
646
+
647
+ for (const j of jobs) {
648
+ if (!TERMINAL.has(j.status)) {
649
+ Object.assign(j, { status: 'failed', step: 'failed', finishedAt: now(),
650
+ error: 'Interrupted: Chrome or the bridge restarted while this job was in progress.' });
651
+ }
652
+ }
653
+ saveJobs();
654
+ centralizeLegacyImages();
655
+ cleanupPreviews();
656
+
657
+ process.on('uncaughtException', e => log('error', 'uncaught exception', e.stack));
658
+ process.on('unhandledRejection', e => log('error', 'unhandled rejection', String((e && e.stack) || e)));
659
+ process.stdin.on('data', chunk => { inChunks.push(chunk); inLen += chunk.length; drainInput(); });
660
+ process.stdin.on('end', () => shutdown('Chrome closed the connection'));
661
+ process.stdout.on('error', () => shutdown('Chrome stopped reading'));
662
+
663
+ server.on('error', e => log('error', `named pipe unavailable (${e.code || e.message}) — is the bridge already running in another Chrome profile?`));
664
+ server.listen(shared.PIPE_PATH, () => log('info', `host v${VERSION} listening (pid ${process.pid})`));