backdraw 0.0.0-stage → 2.1.1

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 +635 -2
  3. package/README.tr.md +653 -0
  4. package/bin/backdraw.js +60 -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 +52 -4
  56. package/platform/index.js +14 -0
  57. package/platform/win32.js +268 -0
  58. package/setup.js +213 -0
  59. package/shared.js +118 -0
@@ -0,0 +1,45 @@
1
+ <!doctype html>
2
+ <html>
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <title>Backdraw</title>
6
+ <link rel="stylesheet" href="theme.css">
7
+ <style>
8
+ * { box-sizing: border-box; }
9
+ body { margin: 0; background: var(--bg); color: var(--fg); font: 13px/1.45 "Segoe UI", system-ui, sans-serif; }
10
+ form { display: flex; flex-direction: column; gap: 10px; padding: 14px; }
11
+ h1 { display: flex; align-items: center; gap: 8px; margin: 0; font-size: 15px; }
12
+ h1 img { width: 24px; height: 24px; border-radius: 7px; }
13
+ .thumb { display: grid; place-items: center; height: 190px; border: 1px solid var(--line); border-radius: 10px; background: var(--surface); color: var(--muted); overflow: hidden; }
14
+ .thumb img { max-width: 100%; max-height: 100%; }
15
+ #size { color: var(--muted); font-size: 11px; text-align: center; margin-top: -6px; }
16
+ label { color: var(--muted); }
17
+ input { padding: 8px 10px; border: 1px solid var(--line); border-radius: 8px; background: var(--field); color: var(--fg); font: inherit; }
18
+ input:focus { outline: 2px solid var(--accent); outline-offset: -1px; }
19
+ #msg { margin: 0; min-height: 18px; color: var(--muted); font-size: 12px; overflow-wrap: anywhere; }
20
+ #msg.bad { color: var(--bad); font-weight: 600; }
21
+ .row { display: flex; gap: 8px; justify-content: flex-end; }
22
+ button { padding: 8px 14px; border: 1px solid var(--line); border-radius: 8px; background: transparent; color: var(--fg); font: inherit; cursor: pointer; }
23
+ button[type=submit] { border-color: var(--accent); background: var(--accent); color: var(--accent-fg); font-weight: 600; }
24
+ button[type=submit]:hover:not(:disabled) { background: var(--accent-hover); }
25
+ button:disabled { opacity: .5; cursor: default; }
26
+ </style>
27
+ <script src="i18n.js"></script>
28
+ <script src="settings.js"></script>
29
+ </head>
30
+ <body>
31
+ <form id="form">
32
+ <h1><img src="icons/mascot-96.png" alt=""><span data-i18n="newTitle">New collection</span></h1>
33
+ <div id="thumb" class="thumb"><span data-i18n="preparing">Preparing the image…</span></div>
34
+ <div id="size"></div>
35
+ <label for="name" data-i18n="nameLabel">Collection name</label>
36
+ <input id="name" autocomplete="off" spellcheck="false" data-i18n-placeholder="namePlaceholder">
37
+ <p id="msg" role="status"></p>
38
+ <div class="row">
39
+ <button type="button" id="cancel" data-i18n="cancel">Cancel</button>
40
+ <button type="submit" id="add" data-i18n="add">Add</button>
41
+ </div>
42
+ </form>
43
+ <script src="collection.js"></script>
44
+ </body>
45
+ </html>
@@ -0,0 +1,62 @@
1
+ // "New collection…" window: shows the image the user right-clicked and asks for the collection name.
2
+ // The service worker already downloads the image meanwhile and saves it when the name is given.
3
+ const $ = id => document.getElementById(id);
4
+ const key = location.hash.slice(1);
5
+ const { t } = i18n;
6
+ let names = [];
7
+
8
+ function show(text, bad) {
9
+ $('msg').textContent = text || '';
10
+ $('msg').className = bad ? 'bad' : '';
11
+ }
12
+
13
+ async function ask(type, extra = {}) {
14
+ const r = await chrome.runtime.sendMessage({ type, key, ...extra }).catch(e => ({ ok: false, error: e.message }));
15
+ if (!r || !r.ok) throw new Error((r && r.error) || t('errNotConnected'));
16
+ return r;
17
+ }
18
+
19
+ function cancel() {
20
+ ask('collection:cancel').catch(() => {});
21
+ window.close();
22
+ }
23
+
24
+ async function load() {
25
+ i18n.apply();
26
+ $('name').focus();
27
+ try {
28
+ const r = await ask('collection:pending');
29
+ names = r.names || [];
30
+ $('thumb').replaceChildren(Object.assign(document.createElement('img'), { src: `data:image/jpeg;base64,${r.thumb}`, alt: '' }));
31
+ $('size').textContent = `${r.width} × ${r.height}`;
32
+ } catch (e) {
33
+ $('thumb').replaceChildren();
34
+ $('add').disabled = true;
35
+ show(e.message, true);
36
+ }
37
+ }
38
+
39
+ $('name').addEventListener('input', () => {
40
+ const wanted = $('name').value.replace(/\s+/g, ' ').trim().toLowerCase();
41
+ show(wanted && names.some(n => n.toLowerCase() === wanted) ? t('nameExists') : '');
42
+ });
43
+
44
+ $('form').addEventListener('submit', async ev => {
45
+ ev.preventDefault();
46
+ const name = $('name').value.replace(/\s+/g, ' ').trim();
47
+ if (!name || Array.from(name).length > 64) return show(t('nameInvalid'), true);
48
+ $('add').disabled = true;
49
+ show(t('saving'));
50
+ try {
51
+ await ask('collection:create', { name });
52
+ window.close(); // the page shows the result
53
+ } catch (e) {
54
+ show(e.message, true);
55
+ $('add').disabled = false;
56
+ }
57
+ });
58
+
59
+ $('cancel').addEventListener('click', cancel);
60
+ document.addEventListener('keydown', e => { if (e.key === 'Escape') cancel(); });
61
+ i18n.watch(() => i18n.apply());
62
+ backdrawSettings.ready.then(load); // in the language picked in the popup
@@ -0,0 +1,409 @@
1
+ // Reference collections: right-click an image on any page, "Add to Backdraw". Loaded by background.js
2
+ // (importScripts) and shares its globals (hostReady, post, connect, log, setBadge, b64ToBytes,
3
+ // bytesToB64). The host owns the collections folder; this side only downloads the picture the user
4
+ // clicked, makes sure it is an image, adds a thumbnail and sends the bytes over the native port.
5
+ const MAX_IMAGE_BYTES = 25 * 1024 * 1024; // lib/limits.js MAX_COLLECTION_BYTES; the host checks it again
6
+ const CALL_CHUNK = 512 * 1024; // base64 chars per call_chunk message
7
+ const THUMB_PX = 256;
8
+ const PENDING_TTL = 10 * 60 * 1000;
9
+ const DOWNLOAD_MS = 90 * 1000; // per download of the clicked image (in the worker, then in the page)
10
+ // What the popup may ask the host through the worker (the host has its own allow-list too).
11
+ const PAGE_HOST_METHODS = new Set(['list_collections', 'rename_collection', 'delete_collection', 'delete_collection_image', 'collection_thumbs']);
12
+
13
+ let collections = []; // [{ name, count }] as the host last sent them
14
+ let shownMenu = null; // the menu Chrome shows now, so the same one is not rebuilt
15
+ let latestMenu = null; // the menu asked for last
16
+ let menuChain = Promise.resolve();
17
+ let callSeq = 0;
18
+ let badgeTimer = null;
19
+ const hostCalls = new Map(); // reqId -> { resolve, reject, timer }
20
+ const readyWaiters = new Set();
21
+ const pendingNew = new Map(); // key -> { source, at, image, windowId } while a "New collection" window is open
22
+
23
+ const tr = (key, vars) => i18n.t(key, vars);
24
+ const userError = (key, vars) => new Error(tr(key, vars));
25
+ const tooLargeError = () => Object.assign(userError('errTooLarge', { mb: MAX_IMAGE_BYTES / 1048576 }), { tooLarge: true });
26
+
27
+ // ---------------------------------------------------------------- calls to the host
28
+
29
+ // A click can wake the worker before the native port is up: wait for the host's welcome a moment.
30
+ function whenHostReady(ms = 8000) {
31
+ if (hostReady) return Promise.resolve();
32
+ connect();
33
+ return new Promise((resolve, reject) => {
34
+ const w = { resolve, timer: setTimeout(() => { readyWaiters.delete(w); reject(userError('errNotConnected')); }, ms) };
35
+ readyWaiters.add(w);
36
+ });
37
+ }
38
+
39
+ // data (base64) goes first in call_chunk pieces, then the call itself; the host puts them together.
40
+ async function callHost(method, params = {}, data = null, timeoutMs = 60000) {
41
+ await whenHostReady();
42
+ const reqId = `c${++callSeq}-${Date.now().toString(36)}`;
43
+ return new Promise((resolve, reject) => {
44
+ const timer = setTimeout(() => { hostCalls.delete(reqId); reject(userError('errTimeout')); }, timeoutMs);
45
+ hostCalls.set(reqId, { resolve, reject, timer });
46
+ const total = data ? Math.max(1, Math.ceil(data.length / CALL_CHUNK)) : 0;
47
+ let sent = true;
48
+ for (let seq = 0; seq < total && sent; seq++) sent = post({ type: 'call_chunk', reqId, seq, total, data: data.slice(seq * CALL_CHUNK, (seq + 1) * CALL_CHUNK) });
49
+ if (!sent || !post({ type: 'call', reqId, method, params })) settleHostCall({ reqId, ok: false, error: tr('errNotConnected') });
50
+ });
51
+ }
52
+
53
+ function settleHostCall(msg) {
54
+ const c = hostCalls.get(msg.reqId);
55
+ if (!c) return;
56
+ hostCalls.delete(msg.reqId);
57
+ clearTimeout(c.timer);
58
+ if (msg.ok) c.resolve(msg.result);
59
+ else c.reject(Object.assign(new Error(msg.error || 'Backdraw reported an error.'), { code: msg.code }));
60
+ }
61
+
62
+ // background.js calls these when the host says welcome, sends a new list, or goes away.
63
+ function collectionsOnline(list) {
64
+ for (const w of readyWaiters) { clearTimeout(w.timer); w.resolve(); }
65
+ readyWaiters.clear();
66
+ if (Array.isArray(list)) collections = list;
67
+ rebuildMenu();
68
+ }
69
+
70
+ function collectionsChanged(list) {
71
+ collections = Array.isArray(list) ? list : [];
72
+ rebuildMenu();
73
+ }
74
+
75
+ function collectionsOffline() {
76
+ for (const reqId of [...hostCalls.keys()]) settleHostCall({ reqId, ok: false, error: tr('errNotConnected') });
77
+ rebuildMenu();
78
+ }
79
+
80
+ // ---------------------------------------------------------------- right-click menu
81
+
82
+ // Chrome has no "replace the menu": remove everything and create it again, one rebuild at a time.
83
+ // A rebuild that a newer one has overtaken is skipped, so the last call always wins.
84
+ function rebuildMenu() {
85
+ const items = backdrawMenus.menuItems(hostReady ? collections : null, key => i18n.t(key));
86
+ items.push(...backdrawMenus.actionItems(key => i18n.t(key))); // removeAll takes them too
87
+ const sig = latestMenu = JSON.stringify(items);
88
+ menuChain = menuChain.then(async () => {
89
+ if (sig !== latestMenu || sig === shownMenu) return;
90
+ shownMenu = null;
91
+ await new Promise(r => chrome.contextMenus.removeAll(() => r(chrome.runtime.lastError)));
92
+ for (const item of items) await new Promise(r => chrome.contextMenus.create(item, () => r(chrome.runtime.lastError)));
93
+ shownMenu = sig;
94
+ }).catch(e => log('warn', 'context menu update failed', String(e)));
95
+ }
96
+
97
+ chrome.runtime.onInstalled.addListener(() => rebuildMenu());
98
+ chrome.runtime.onStartup.addListener(() => rebuildMenu());
99
+ // The titles follow the language picked in the popup. A menu asked for before the choice was read
100
+ // is built again (unchanged titles make that a no-op).
101
+ i18n.watch(() => rebuildMenu());
102
+ i18n.load().then(() => { if (latestMenu !== null) rebuildMenu(); });
103
+
104
+ chrome.contextMenus.onClicked.addListener((info, tab) => {
105
+ const item = backdrawMenus.parseMenuId(info.menuItemId);
106
+ if (!item) return;
107
+ const tabId = tab && tab.id >= 0 ? tab.id : -1;
108
+ // Chrome leaves srcUrl out when the address is too long to pass on (a data: image over 2 MB).
109
+ if (!info.srcUrl) { notifyUser(tabId, tr('errNoSource'), true); return; }
110
+ const source = { srcUrl: info.srcUrl, pageUrl: info.pageUrl || (tab && tab.url) || '', frameId: info.frameId || 0, tabId };
111
+ if (item.kind === 'new') openNewCollection(source).catch(e => log('warn', 'new collection window failed', String(e)));
112
+ else addToCollection(source, item.name, false).catch(() => {}); // the user already saw the error
113
+ });
114
+
115
+ // ---------------------------------------------------------------- the clicked image
116
+
117
+ function sniffImageMime(b) {
118
+ if (b[0] === 0x89 && b[1] === 0x50 && b[2] === 0x4e && b[3] === 0x47) return 'image/png';
119
+ if (b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff) return 'image/jpeg';
120
+ if (String.fromCharCode(...b.subarray(0, 4)) === 'RIFF' && String.fromCharCode(...b.subarray(8, 12)) === 'WEBP') return 'image/webp';
121
+ if (String.fromCharCode(...b.subarray(0, 4)) === 'GIF8') return 'image/gif';
122
+ return null;
123
+ }
124
+
125
+ // Runs inside the frame that shows the image, where its blob: URLs and the site's own rules work.
126
+ // An image that is already shown can be copied through a canvas when fetching is refused (or, with
127
+ // canvasOnly, when only the page can decode it, like SVG). svg: the SVG's bytes (base64) when the
128
+ // worker has them already.
129
+ async function readImageInPage(src, max, timeoutMs, canvasOnly, svg) {
130
+ const asResult = async blob => {
131
+ if (blob.size > max) return { tooLarge: true };
132
+ const bytes = new Uint8Array(await blob.arrayBuffer());
133
+ let bin = '';
134
+ for (let i = 0; i < bytes.length; i += 0x8000) bin += String.fromCharCode.apply(null, bytes.subarray(i, i + 0x8000));
135
+ return { data: btoa(bin), mime: blob.type || '' };
136
+ };
137
+ let error = '';
138
+ if (!canvasOnly) {
139
+ const ac = new AbortController();
140
+ const timer = setTimeout(() => ac.abort(new Error(`no answer in ${timeoutMs / 1000} s`)), timeoutMs);
141
+ try {
142
+ const res = await fetch(src, { credentials: 'include', signal: ac.signal });
143
+ if (res.ok) {
144
+ // Counted while it arrives (like readCapped in the worker), so it stops at the limit.
145
+ const chunks = [];
146
+ let size = 0;
147
+ const reader = res.body ? res.body.getReader() : null;
148
+ for (let r; reader && !(r = await reader.read()).done;) {
149
+ if ((size += r.value.length) > max) { ac.abort(); return { tooLarge: true }; }
150
+ chunks.push(r.value);
151
+ }
152
+ return asResult(new Blob(chunks, { type: res.headers.get('content-type') || '' }));
153
+ }
154
+ error = `HTTP ${res.status}`;
155
+ } catch (e) {
156
+ error = String((e && e.message) || e);
157
+ } finally {
158
+ clearTimeout(timer);
159
+ }
160
+ }
161
+ const shown = Array.from(document.images).find(i => (i.currentSrc || i.src) === src);
162
+ let img = shown && shown.naturalWidth ? shown : null;
163
+ let url = null;
164
+ if (svg) {
165
+ // As this page's own blob: URL the SVG never taints the canvas, also when the page shows it from
166
+ // another site. A strict img-src policy refuses it: then the shown <img> is copied instead.
167
+ url = URL.createObjectURL(new Blob([Uint8Array.from(atob(svg), ch => ch.charCodeAt(0))], { type: 'image/svg+xml' }));
168
+ const own = new Image();
169
+ own.src = url;
170
+ if (await own.decode().then(() => true, () => false)) img = own;
171
+ }
172
+ try {
173
+ if (!img) return { error: error || 'image not found in the page' };
174
+ const canvas = document.createElement('canvas');
175
+ // An SVG without a size of its own is drawn at the size the page shows it.
176
+ canvas.width = img.naturalWidth || (shown && shown.width) || 1024;
177
+ canvas.height = img.naturalHeight || (shown && shown.height) || 1024;
178
+ canvas.getContext('2d').drawImage(img, 0, 0, canvas.width, canvas.height);
179
+ const blob = await new Promise(resolve => canvas.toBlob(resolve, 'image/png'));
180
+ return blob ? asResult(blob) : { error: `${error}; canvas export failed` };
181
+ } catch (e) {
182
+ return { error: `${error}; ${String((e && e.message) || e)}` }; // a cross-site image taints the canvas
183
+ } finally {
184
+ if (url) URL.revokeObjectURL(url);
185
+ }
186
+ }
187
+
188
+ function readInPage({ srcUrl, tabId, frameId }, canvasOnly = false, svg = null) {
189
+ return chrome.scripting.executeScript({
190
+ target: { tabId, frameIds: [frameId] }, func: readImageInPage, args: [srcUrl, MAX_IMAGE_BYTES, DOWNLOAD_MS, canvasOnly, svg],
191
+ }).then(([res]) => (res && res.result) || { error: 'no answer from the page' }, e => ({ error: String((e && e.message) || e) }));
192
+ }
193
+
194
+ // Counted while it arrives: a body without (or with a wrong) Content-Length, or one that never ends,
195
+ // stops at the limit instead of filling memory.
196
+ async function readCapped(res, ac) {
197
+ const chunks = [];
198
+ let size = 0;
199
+ const reader = res.body ? res.body.getReader() : null;
200
+ for (let r; reader && !(r = await reader.read()).done;) {
201
+ if ((size += r.value.length) > MAX_IMAGE_BYTES) { ac.abort(); throw tooLargeError(); }
202
+ chunks.push(r.value);
203
+ }
204
+ return new Blob(chunks, { type: res.headers.get('content-type') || '' });
205
+ }
206
+
207
+ async function downloadClicked(source) {
208
+ const { srcUrl, tabId } = source;
209
+ let why = '';
210
+ if (!/^blob:/i.test(srcUrl)) { // a page's blob: URL can only be read inside that page
211
+ const ac = new AbortController();
212
+ const timer = setTimeout(() => ac.abort(new Error(`no answer in ${DOWNLOAD_MS / 1000} s`)), DOWNLOAD_MS);
213
+ try {
214
+ const res = await fetch(srcUrl, { credentials: 'include', signal: ac.signal });
215
+ if (Number(res.headers.get('content-length')) > MAX_IMAGE_BYTES) { ac.abort(); throw tooLargeError(); }
216
+ if (res.ok) return await readCapped(res, ac);
217
+ why = `HTTP ${res.status}`;
218
+ } catch (e) {
219
+ if (e.tooLarge) throw e;
220
+ why = String((e && e.message) || e);
221
+ } finally {
222
+ clearTimeout(timer);
223
+ }
224
+ }
225
+ if (tabId >= 0) {
226
+ const r = await readInPage(source);
227
+ if (r.tooLarge) throw tooLargeError();
228
+ if (r.data) return new Blob([b64ToBytes(r.data)], { type: r.mime });
229
+ if (r.error) why = why ? `${why}; ${r.error}` : r.error;
230
+ }
231
+ throw userError('errDownload', { reason: why || '?' });
232
+ }
233
+
234
+ async function collectionThumb(bmp) {
235
+ const scale = Math.min(1, THUMB_PX / Math.max(bmp.width, bmp.height));
236
+ const w = Math.max(1, Math.round(bmp.width * scale));
237
+ const h = Math.max(1, Math.round(bmp.height * scale));
238
+ const canvas = new OffscreenCanvas(w, h);
239
+ const g = canvas.getContext('2d');
240
+ g.fillStyle = '#ffffff';
241
+ g.fillRect(0, 0, w, h);
242
+ g.drawImage(bmp, 0, 0, w, h);
243
+ const blob = await canvas.convertToBlob({ type: 'image/jpeg', quality: 0.8 });
244
+ return bytesToB64(new Uint8Array(await blob.arrayBuffer()));
245
+ }
246
+
247
+ const looksLikeSvg = (blob, bytes) => /svg/i.test(blob.type) || /<svg[\s>]/i.test(new TextDecoder().decode(bytes.subarray(0, 4096)));
248
+
249
+ // png/jpeg/webp/gif are kept as they are; anything else Chrome can decode (avif, bmp, ico…, or SVG
250
+ // through the page) is saved as PNG, so every viewer and ChatGPT can open it.
251
+ async function prepareImage(source) {
252
+ const blob = await downloadClicked(source);
253
+ if (blob.size > MAX_IMAGE_BYTES) throw tooLargeError();
254
+ let bytes = new Uint8Array(await blob.arrayBuffer());
255
+ let mime = sniffImageMime(bytes);
256
+ let bmp = await createImageBitmap(new Blob([bytes], { type: mime || blob.type })).catch(() => null);
257
+ if (!bmp && source.tabId >= 0) {
258
+ // Only a page can draw SVG; it gets the bytes, since the <img> it shows may be from another site.
259
+ const r = await readInPage(source, true, looksLikeSvg(blob, bytes) ? bytesToB64(bytes) : null);
260
+ if (r.tooLarge) throw tooLargeError();
261
+ if (r.data) {
262
+ bytes = b64ToBytes(r.data);
263
+ mime = sniffImageMime(bytes);
264
+ bmp = await createImageBitmap(new Blob([bytes], { type: mime || '' })).catch(() => null);
265
+ }
266
+ }
267
+ if (!bmp) throw userError('errNotImage');
268
+ try {
269
+ if (!mime) {
270
+ const canvas = new OffscreenCanvas(bmp.width, bmp.height);
271
+ canvas.getContext('2d').drawImage(bmp, 0, 0);
272
+ const png = await canvas.convertToBlob({ type: 'image/png' });
273
+ if (png.size > MAX_IMAGE_BYTES) throw tooLargeError();
274
+ bytes = new Uint8Array(await png.arrayBuffer());
275
+ mime = 'image/png';
276
+ }
277
+ return { data: bytesToB64(bytes), mime, width: bmp.width, height: bmp.height, thumb: await collectionThumb(bmp) };
278
+ } finally {
279
+ bmp.close();
280
+ }
281
+ }
282
+
283
+ // data: URLs can be megabytes long; the host keeps only their type anyway.
284
+ const shortUrl = u => (/^data:/i.test(u || '') ? u.slice(0, Math.min(u.indexOf(',') + 1 || 64, 64)) : String(u || '').slice(0, 2048));
285
+
286
+ async function addToCollection(source, name, create, prepared) {
287
+ if (source.tabId >= 0) await showPageToast(source.tabId, tr('toastAdding', { name }), 'busy');
288
+ try {
289
+ const img = await (prepared || prepareImage(source));
290
+ const r = await callHost('collection_add', {
291
+ collection: name, create, mime: img.mime, width: img.width, height: img.height, thumb: img.thumb,
292
+ sourceUrl: shortUrl(source.srcUrl), pageUrl: shortUrl(source.pageUrl),
293
+ }, img.data, 120000);
294
+ await notifyUser(source.tabId, tr(r.duplicate ? 'toastDuplicate' : 'toastAdded', { name: r.name, images: i18n.images(r.count) }), false);
295
+ return r;
296
+ } catch (e) {
297
+ await notifyUser(source.tabId, String((e && e.message) || e), true);
298
+ throw e;
299
+ }
300
+ }
301
+
302
+ // ---------------------------------------------------------------- the in-page note
303
+
304
+ // Injected only right after the user's own click: a small note in the page corner that removes itself.
305
+ function pageToast(text, kind) {
306
+ const old = document.getElementById('backdraw-toast');
307
+ if (old) old.remove();
308
+ const host = document.createElement('div');
309
+ host.id = 'backdraw-toast';
310
+ host.style.cssText = 'all:initial;position:fixed;right:16px;bottom:16px;z-index:2147483647;';
311
+ const box = document.createElement('div');
312
+ box.textContent = text;
313
+ box.style.cssText = 'font:13px/1.4 "Segoe UI",system-ui,sans-serif;color:#fff;padding:10px 14px;border-radius:10px;max-width:340px;' +
314
+ `box-shadow:0 6px 24px rgba(0,0,0,.25);transition:opacity .3s;background:${kind === 'error' ? '#c62828' : '#1d1d1f'};`;
315
+ host.attachShadow({ mode: 'closed' }).append(box);
316
+ document.documentElement.append(host);
317
+ setTimeout(() => { box.style.opacity = '0'; setTimeout(() => host.remove(), 300); }, kind === 'busy' ? 30000 : kind === 'error' ? 4000 : 2500);
318
+ }
319
+
320
+ function showPageToast(tabId, text, kind) {
321
+ return chrome.scripting.executeScript({ target: { tabId }, func: pageToast, args: [text, kind] }).then(() => true, () => false);
322
+ }
323
+
324
+ // Pages that can't be scripted (chrome://, the PDF viewer, the Web Store) get the toolbar badge.
325
+ function flashBadge(text, error) {
326
+ clearTimeout(badgeTimer);
327
+ chrome.action.setBadgeText({ text: error ? '!' : '✓' }).catch(() => {});
328
+ chrome.action.setBadgeBackgroundColor({ color: error ? '#d93025' : '#1a7f37' }).catch(() => {});
329
+ chrome.action.setTitle({ title: text }).catch(() => {});
330
+ badgeTimer = setTimeout(() => { setBadge(); chrome.action.setTitle({ title: 'Backdraw' }).catch(() => {}); }, error ? 4000 : 2500);
331
+ }
332
+
333
+ async function notifyUser(tabId, message, error) {
334
+ const text = i18n.shortPath(message, hostHome); // an error from the host may name a file in the user's folder
335
+ if (tabId >= 0 && await showPageToast(tabId, text, error ? 'error' : 'ok')) return;
336
+ flashBadge(text, error);
337
+ }
338
+
339
+ // ---------------------------------------------------------------- "New collection…" window
340
+
341
+ function forgetPending(key) {
342
+ pendingNew.delete(key);
343
+ chrome.storage.session.remove(`pending:${key}`).catch(() => {});
344
+ }
345
+
346
+ // The image is prepared while the user types the name. The click is kept in session storage too:
347
+ // the worker may be stopped while the window is open, and then downloads it again.
348
+ async function openNewCollection(source) {
349
+ for (const [key, e] of pendingNew) if (Date.now() - e.at > PENDING_TTL) forgetPending(key);
350
+ const key = `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`;
351
+ const entry = { source, at: Date.now(), image: prepareImage(source) };
352
+ entry.image.catch(() => {}); // shown in the window when it asks
353
+ pendingNew.set(key, entry);
354
+ // Centred over the browser window of the click; Chrome would put it in the screen's corner.
355
+ const over = await chrome.tabs.get(source.tabId).then(tab => chrome.windows.get(tab.windowId)).catch(() => null);
356
+ const win = await chrome.windows.create({
357
+ url: chrome.runtime.getURL(`collection.html#${key}`), type: 'popup', width: 380, height: 470, focused: true,
358
+ ...backdrawMenus.centreOver(over, 380, 470),
359
+ });
360
+ entry.windowId = win.id;
361
+ if (source.srcUrl.length < 1000000) await chrome.storage.session.set({ [`pending:${key}`]: { source, at: entry.at, windowId: win.id } }).catch(() => {});
362
+ }
363
+
364
+ // A window closed with its own × (not Cancel or Escape) lets go of the prepared image too.
365
+ chrome.windows.onRemoved.addListener(windowId => {
366
+ for (const [key, e] of pendingNew) if (e.windowId === windowId) forgetPending(key);
367
+ });
368
+
369
+ async function pendingEntry(key) {
370
+ let entry = pendingNew.get(key);
371
+ if (!entry) {
372
+ const stored = (await chrome.storage.session.get(`pending:${key}`))[`pending:${key}`];
373
+ if (!stored || Date.now() - stored.at > PENDING_TTL) throw userError('errExpired');
374
+ entry = { ...stored, image: prepareImage(stored.source) };
375
+ entry.image.catch(() => {});
376
+ pendingNew.set(key, entry);
377
+ }
378
+ return entry;
379
+ }
380
+
381
+ // ---------------------------------------------------------------- requests from the extension's pages
382
+
383
+ const pageRequests = new Map([
384
+ ['collection:pending', async msg => {
385
+ const img = await (await pendingEntry(msg.key)).image;
386
+ return { thumb: img.thumb, width: img.width, height: img.height, names: collections.map(c => c.name) };
387
+ }],
388
+ ['collection:create', async msg => {
389
+ const name = String(msg.name || '').replace(/\s+/g, ' ').trim();
390
+ if (!name || Array.from(name).length > 64) throw userError('nameInvalid');
391
+ const entry = await pendingEntry(msg.key);
392
+ const r = await addToCollection(entry.source, name, true, entry.image);
393
+ forgetPending(msg.key);
394
+ return r;
395
+ }],
396
+ ['collection:cancel', async msg => { forgetPending(msg.key); return {}; }],
397
+ ['host:call', async msg => {
398
+ if (!PAGE_HOST_METHODS.has(msg.method)) throw new Error(`Not allowed: ${msg.method}`);
399
+ return { result: await callHost(msg.method, msg.params || {}) };
400
+ }],
401
+ ]);
402
+
403
+ chrome.runtime.onMessage.addListener((msg, sender, reply) => {
404
+ const handler = msg && pageRequests.get(msg.type);
405
+ if (!handler || sender.id !== chrome.runtime.id || !String(sender.url || '').startsWith(chrome.runtime.getURL(''))) return;
406
+ // An error from the host may name a file in the user's folder; the pages show it as "~".
407
+ handler(msg).then(r => reply({ ok: true, ...r }), e => reply({ ok: false, error: i18n.shortPath(String((e && e.message) || e), hostHome) }));
408
+ return true;
409
+ });