-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathbackground.js
More file actions
437 lines (389 loc) · 17 KB
/
Copy pathbackground.js
File metadata and controls
437 lines (389 loc) · 17 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
import {
getSnippets, saveSnippets, getSnippetById,
matchUrl, isInitialized, setInitialized, getExportSettings
} from './shared/storage.js';
import { renderExport, markdownStyleUsesIcon } from './shared/tabExportFormat.js';
// Shared by the popup's dropdown (message-based) and the toolbar-icon
// right-click submenu (context-menu-based) so both offer the same 4 choices.
// Grouped by action rather than spelling it out on every item ("Download as
// Markdown File", etc.) -- the group says the verb once, each item is just
// the format, for a much shorter read.
const TAB_URL_MENU_ITEMS = {
'tcs-download-markdown': { title: 'Markdown', action: 'download', format: 'markdown', group: 'download' },
'tcs-download-text': { title: 'Text', action: 'download', format: 'text', group: 'download' },
'tcs-copy-markdown': { title: 'Markdown', action: 'copy', format: 'markdown', group: 'copy' },
'tcs-copy-text': { title: 'Text', action: 'copy', format: 'text', group: 'copy' }
};
// --- First-run: load default snippets ---
chrome.runtime.onInstalled.addListener(async (details) => {
if (details.reason === 'install') {
const alreadyInit = await isInitialized();
if (!alreadyInit) {
try {
const resp = await fetch(chrome.runtime.getURL('defaults/default-snippets.json'));
const defaults = await resp.json();
const snippets = defaults.map((s, i) => ({
id: crypto.randomUUID(),
name: s.name,
code: s.code,
allowedUrls: s.allowedUrls,
position: i
}));
await saveSnippets(snippets);
await setInitialized();
} catch (err) {
console.error('Failed to load default snippets:', err);
}
}
}
await rebuildContextMenus();
});
// --- Context menu management ---
let contextMenuRebuildTimer = null;
async function rebuildContextMenus() {
await chrome.contextMenus.removeAll();
// Toolbar-icon (action) menu: right-click the extension icon for the same
// copy/download choices as the popup's dropdown. Recreated on every rebuild
// so it survives the storage-change removeAll() below.
chrome.contextMenus.create({
id: 'tcs-tab-urls-parent',
title: 'Export All Open Tabs (URLs)',
contexts: ['action']
});
chrome.contextMenus.create({
id: 'tcs-tab-urls-download',
parentId: 'tcs-tab-urls-parent',
title: 'Download',
contexts: ['action']
});
chrome.contextMenus.create({
id: 'tcs-tab-urls-copy',
parentId: 'tcs-tab-urls-parent',
title: 'Copy',
contexts: ['action']
});
for (const [id, { title, group }] of Object.entries(TAB_URL_MENU_ITEMS)) {
chrome.contextMenus.create({
id,
parentId: group === 'download' ? 'tcs-tab-urls-download' : 'tcs-tab-urls-copy',
title,
contexts: ['action']
});
}
chrome.contextMenus.create({
id: 'tcs-parent',
title: 'Trigger Code Snippets',
contexts: ['page']
});
const snippets = await getSnippets();
for (const snippet of snippets) {
const shortcutHint = snippet.position < 9
? ` [Alt+Shift+${snippet.position + 1}]`
: '';
chrome.contextMenus.create({
id: `tcs-snippet-${snippet.id}`,
parentId: 'tcs-parent',
title: snippet.name + shortcutHint,
contexts: ['page'],
enabled: false // disabled by default, updated per tab
});
}
// Update states for the current active tab
try {
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
if (tab) await updateContextMenuStates(tab.id);
} catch { /* ignore if no active tab */ }
}
async function updateContextMenuStates(tabId) {
try {
const tab = await chrome.tabs.get(tabId);
if (!tab.url) return; // chrome:// pages etc.
const snippets = await getSnippets();
for (const snippet of snippets) {
try {
const enabled = matchUrl(snippet.allowedUrls, tab.url);
chrome.contextMenus.update(`tcs-snippet-${snippet.id}`, { enabled });
} catch { /* menu item may not exist yet */ }
}
} catch { /* tab may no longer exist */ }
}
// Update context menu states when the active tab changes or navigates
chrome.tabs.onActivated.addListener(({ tabId }) => updateContextMenuStates(tabId));
chrome.tabs.onUpdated.addListener((tabId, changeInfo) => {
if (changeInfo.url || changeInfo.status === 'complete') {
updateContextMenuStates(tabId);
}
});
// --- Context menu click handler ---
chrome.contextMenus.onClicked.addListener(async (info, tab) => {
console.log('[TCS] Context menu clicked:', info.menuItemId);
const tabUrlItem = TAB_URL_MENU_ITEMS[info.menuItemId];
if (tabUrlItem) {
await handleTabUrlsAction(tabUrlItem.action, tabUrlItem.format);
return;
}
if (!info.menuItemId.toString().startsWith('tcs-snippet-')) return;
const snippetId = info.menuItemId.toString().replace('tcs-snippet-', '');
const snippet = await getSnippetById(snippetId);
if (!snippet) { console.log('[TCS] BAIL: context menu snippet not found'); return; }
if (!matchUrl(snippet.allowedUrls, tab.url)) { console.log('[TCS] BAIL: context menu URL mismatch'); return; }
executeSnippet(snippet, tab.id);
});
// --- Snippet execution ---
async function executeSnippet(snippet, tabId) {
console.log('[TCS] executeSnippet called:', snippet.name, 'on tab:', tabId, 'code length:', snippet.code.length);
const target = { tabId };
try {
await chrome.debugger.attach(target, '1.3');
await chrome.debugger.sendCommand(target, 'Runtime.evaluate', {
expression: snippet.code,
userGesture: true
});
await chrome.debugger.detach(target);
console.log('[TCS] executeScript SUCCESS for:', snippet.name);
} catch (err) {
try { await chrome.debugger.detach(target); } catch { /* already detached */ }
console.error('[TCS] executeScript FAILED for:', snippet.name, err);
}
}
// --- Tab URL export: gather, format (text or markdown), copy or download ---
async function getTabGroupsByWindow() {
// Spanning mode (no "incognito":"split" in the manifest) means query({})
// returns tabs from every window, including incognito. Raw tab objects are
// kept (not just title/url) so the full-field markdown dump can read them.
const tabs = await chrome.tabs.query({});
const byWindow = new Map();
for (const tab of tabs) {
if (!tab.url) continue;
if (!byWindow.has(tab.windowId)) byWindow.set(tab.windowId, []);
byWindow.get(tab.windowId).push(tab);
}
return [...byWindow.values()];
}
// Stand-in emojis for the Icon column, for cases where a real favicon image
// either can't apply or shouldn't be fetched.
const EXTENSION_ICON_EMOJI = '🧩'; // puzzle piece -- Chrome's own extensions icon
const FILE_ICON_EMOJI = '💾'; // floppy disk -- 📄 looks too close to a browser's own "image failed to load" icon
const IP_ADDRESS_EMOJI = '🖥️'; // desktop computer
const BROWSER_PAGE_EMOJI = '⚙️'; // gear -- chrome://newtab, chrome://extensions, etc.
const NO_FAVICON_EMOJI = '🌐'; // globe -- a URL was tried, nothing loaded
function isIpAddressHost(hostname) {
if (hostname.startsWith('[') && hostname.endsWith(']')) return true; // IPv6 literal, e.g. "[::1]"
return /^\d{1,3}(\.\d{1,3}){3}$/.test(hostname); // IPv4, e.g. "192.168.1.1"
}
// Chrome doesn't report a favicon for some tabs (its own PDF viewer, e.g.) --
// guess the *registrable* domain's favicon.ico (e.g. "docs.nvidia.com" ->
// "nvidia.com") since a subdomain rarely hosts its own icon but the main
// site almost always does. A simple last-two-labels heuristic: wrong for
// two-part TLDs like .co.uk, but that just means the guess fails the same
// way a genuinely missing icon does -- verified below either way.
function guessFavicon(url) {
try {
const { protocol, hostname } = new URL(url);
const mainDomain = hostname.split('.').slice(-2).join('.');
return `${protocol}//${mainDomain}/favicon.ico`;
} catch {
return '';
}
}
// HEAD-checks a candidate favicon URL so a dead link doesn't ship as a
// broken image in the export. Cached per download since many tabs on the
// same site share one favicon URL; a timeout keeps one slow/unreachable
// server from stalling the whole export.
async function faviconExists(url, cache) {
if (cache.has(url)) return cache.get(url);
const promise = (async () => {
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 3000);
const resp = await fetch(url, { method: 'HEAD', signal: controller.signal });
clearTimeout(timeout);
const contentType = resp.headers.get('content-type') || '';
return resp.ok && contentType.startsWith('image');
} catch {
return false;
}
})();
cache.set(url, promise);
return promise;
}
// Resolves one tab's Icon cell: an emoji for cases where a real favicon
// either doesn't apply (extension/local pages) or shouldn't be guessed at
// (an IP-address host -- guessing there would mean this extension probing
// an arbitrary network address on its own); otherwise { imageUrl } for a
// verified favicon (the caller turns this into markdown, deduplicated --
// some sites' favicon is a multi-KB base64 image reused across every tab);
// otherwise a generic "nothing loaded" emoji.
async function resolveTabIcon(tab, cache) {
if (tab.url.startsWith('chrome-extension://')) return EXTENSION_ICON_EMOJI;
if (tab.url.startsWith('file://')) return FILE_ICON_EMOJI;
if (tab.favIconUrl) {
// fetch() handles http(s) and data: fine, but a favIconUrl can
// occasionally be an internal chrome://favicon2/... resource instead --
// fetch()-ing THAT throws AND gets logged to the extension's own Errors
// page regardless of this code catching it, so it's never attempted.
if (!/^(https?:|data:)/.test(tab.favIconUrl)) return NO_FAVICON_EMOJI;
return (await faviconExists(tab.favIconUrl, cache))
? { imageUrl: tab.favIconUrl }
: NO_FAVICON_EMOJI;
}
let hostname = '';
let scheme = '';
try {
({ hostname, protocol: scheme } = new URL(tab.url));
} catch { /* falls through to the emoji below */ }
// Same reasoning as above: chrome://newtab, chrome://extensions, etc.
// have no real favicon.ico to guess at, and fetch() can't reach them.
if (scheme && scheme !== 'http:' && scheme !== 'https:') return BROWSER_PAGE_EMOJI;
if (isIpAddressHost(hostname)) return IP_ADDRESS_EMOJI;
const guessedUrl = guessFavicon(tab.url);
return (guessedUrl && await faviconExists(guessedUrl, cache))
? { imageUrl: guessedUrl }
: NO_FAVICON_EMOJI;
}
// Resolves and attaches tab.icon across every tab in every window, up front
// and in parallel, so an identical favicon (the same site open in several
// tabs) gets deduplicated by the shared module into one reference link
// instead of once per tab -- same picture in every row, far smaller file.
async function attachResolvedIcons(groups) {
const cache = new Map();
const flatTabs = groups.flat();
const icons = await Promise.all(flatTabs.map(tab => resolveTabIcon(tab, cache)));
let cursor = 0;
return groups.map(tabs => tabs.map(tab => ({ ...tab, icon: icons[cursor++] })));
}
async function buildTabUrlText(format) {
const settings = await getExportSettings();
const formatSettings = settings[format];
let groups = await getTabGroupsByWindow();
// Only pay for the favicon network round-trips when Icon is actually
// configured to show -- a real win for any export that leaves it out
// (every Text export, since Icon is Markdown-only; a Markdown export with
// Icon unchecked; or a Markdown style that ignores Icon entirely, like
// Numbered Links).
if (format === 'markdown' && formatSettings.fields.includes('icon') && markdownStyleUsesIcon(formatSettings.style)) {
groups = await attachResolvedIcons(groups);
}
return renderExport(groups, format, formatSettings);
}
async function copyTabUrls(format) {
const text = await buildTabUrlText(format);
console.log(`[TCS] Copying tab URLs (${format}):\n${text}`);
return copyToClipboard(text);
}
async function downloadTabUrls(format) {
const text = await buildTabUrlText(format);
const isMarkdown = format === 'markdown';
const filename = `tab-urls-${new Date().toISOString().slice(0, 10)}.${isMarkdown ? 'md' : 'txt'}`;
const mime = isMarkdown ? 'text/markdown' : 'text/plain';
// A data: URL avoids a known issue where blob: URLs created in a service
// worker aren't reliably fetchable by chrome.downloads.download(); no extra
// permission needed since "downloads" is already declared in the manifest.
const dataUrl = `data:${mime};charset=utf-8,${encodeURIComponent(text)}`;
try {
// saveAs omitted deliberately: follows the user's own Chrome
// "ask where to save" setting rather than forcing a dialog either way.
await chrome.downloads.download({ url: dataUrl, filename });
console.log('[TCS] Download started:', filename);
return true;
} catch (err) {
console.error('[TCS] Download failed:', err);
return false;
}
}
async function handleTabUrlsAction(action, format) {
return action === 'download' ? downloadTabUrls(format) : copyTabUrls(format);
}
// --- Offscreen clipboard (service workers have no clipboard access) ---
let creatingOffscreen = null; // in-flight createDocument promise, serializes callers
async function ensureOffscreenDocument() {
const offscreenUrl = chrome.runtime.getURL('offscreen.html');
const existing = await chrome.runtime.getContexts({
contextTypes: ['OFFSCREEN_DOCUMENT'],
documentUrls: [offscreenUrl]
});
if (existing.length > 0) return;
// getContexts alone can't prevent a double-create race (two rapid clicks both
// see zero contexts); the shared `creating` promise makes the second caller
// await the first createDocument instead of firing its own.
if (creatingOffscreen) {
await creatingOffscreen;
} else {
creatingOffscreen = chrome.offscreen.createDocument({
url: 'offscreen.html',
reasons: ['CLIPBOARD'],
justification: 'Copy the list of open tab URLs to the clipboard.'
});
try {
await creatingOffscreen;
} finally {
creatingOffscreen = null;
}
}
}
async function copyToClipboard(text) {
try {
await ensureOffscreenDocument();
// Await the offscreen ack so the write finishes before we close the doc.
const resp = await chrome.runtime.sendMessage({
target: 'tcs-offscreen',
type: 'copy-to-clipboard',
text
});
if (!resp || !resp.ok) console.error('[TCS] Clipboard write did not confirm success');
return !!(resp && resp.ok);
} catch (err) {
console.error('[TCS] Clipboard copy failed:', err);
return false;
} finally {
// CLIPBOARD offscreen docs have no auto-close; free the single-doc slot.
try { await chrome.offscreen.closeDocument(); } catch { /* already closed */ }
}
}
// --- Message listener ---
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
console.log('[TCS] Message received:', message.type, message);
if (message.type === 'execute-by-position') {
handleExecuteByPosition(message.position, sender.tab).then(() => sendResponse({ ok: true }));
return true;
} else if (message.type === 'execute-by-id') {
handleExecuteById(message.snippetId, message.tabId).then(() => sendResponse({ ok: true }));
return true;
} else if (message.type === 'tab-urls-action') {
handleTabUrlsAction(message.action, message.format).then((ok) => sendResponse({ ok }));
return true;
}
});
async function handleExecuteByPosition(position, tab) {
console.log('[TCS] handleExecuteByPosition:', position, 'tab:', tab?.url);
if (!tab || !tab.url) { console.log('[TCS] BAIL: no tab or url'); return; }
const snippets = await getSnippets();
const snippet = snippets.find(s => s.position === position);
if (!snippet) { console.log('[TCS] BAIL: no snippet at position', position); return; }
console.log('[TCS] Found snippet:', snippet.name, 'allowedUrls:', snippet.allowedUrls);
const urlMatch = matchUrl(snippet.allowedUrls, tab.url);
console.log('[TCS] URL match result:', urlMatch, 'tab.url:', tab.url);
if (!urlMatch) return;
executeSnippet(snippet, tab.id);
}
async function handleExecuteById(snippetId, tabId) {
console.log('[TCS] handleExecuteById:', snippetId, 'tabId:', tabId);
const snippet = await getSnippetById(snippetId);
if (!snippet) { console.log('[TCS] BAIL: snippet not found for id:', snippetId); return; }
console.log('[TCS] Found snippet:', snippet.name, 'allowedUrls:', snippet.allowedUrls);
try {
const tab = await chrome.tabs.get(tabId);
console.log('[TCS] Tab url:', tab.url);
const urlMatch = matchUrl(snippet.allowedUrls, tab.url);
console.log('[TCS] URL match result:', urlMatch);
if (!urlMatch) { console.log('[TCS] BAIL: URL does not match'); return; }
executeSnippet(snippet, tabId);
} catch (e) { console.log('[TCS] BAIL: tab error:', e.message); }
}
// --- Rebuild context menus when snippets change ---
chrome.storage.onChanged.addListener((changes, area) => {
if (area === 'local' && changes.snippets) {
clearTimeout(contextMenuRebuildTimer);
contextMenuRebuildTimer = setTimeout(() => rebuildContextMenus(), 500);
}
});