@hraness/sponge 0.6.8 → 0.6.9

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.
@@ -24,6 +24,7 @@ import { createDigest, undigestible } from '../digest/pipeline.js';
24
24
  import { createQueryEmbedder, createTextEmbedder } from '../search/query.js';
25
25
  import { runtimeGcAction, runtimeGcText, setupRuntimeGc } from './runtimes.js';
26
26
  import { drainLibrary } from '../worker/basic.js';
27
+ import { browserStatus, removeBrowser, setupBrowser } from '../browser/setup.js';
27
28
  import { minAge, publishSite } from '../publish/index.js';
28
29
  import { SpongeError, codeOf, publicCode, exitFor, isSpongeCode } from '../errors.js';
29
30
  const abs = (v) => typeof v === 'string' && isAbsolute(v);
@@ -33,6 +34,21 @@ function archivable(kind, locator, connection) {
33
34
  if (kind === 'media' || connection || !/^https?:/.test(locator))
34
35
  throw new SpongeError('unsupported');
35
36
  }
37
+ /** The extension captures web articles; PDFs and media keep their own fetchers. */
38
+ function browsable(kind, locator, connection) {
39
+ if (kind !== 'article' || connection || !/^https?:/.test(locator))
40
+ throw new SpongeError('unsupported');
41
+ }
42
+ /** `--archive` or `--browser`: at most one route. */
43
+ function route(flags, kind, locator, connection) {
44
+ if (flags.archive && flags.browser)
45
+ throw new SpongeError('invalid-input');
46
+ if (flags.archive)
47
+ return (archivable(kind, locator, connection), 'archive');
48
+ if (flags.browser)
49
+ return (browsable(kind, locator, connection), 'browser');
50
+ return undefined;
51
+ }
36
52
  const lines = (items) => items.map((item) => `${item.itemId}\t${item.title}`);
37
53
  /** The failure of a job that did not run here: its state, or its recorded reason. */
38
54
  function jobFailure(store, jobId) {
@@ -83,6 +99,10 @@ async function drain(c, jobId, connections) {
83
99
  }
84
100
  const failed = results.find((result) => !result.ok);
85
101
  if (failed && !failed.ok) {
102
+ // A narrower recorded reason, such as browser-offline, prints with its own hint.
103
+ const reason = jobId && store.queue.list().find((j) => j.id === jobId)?.reason;
104
+ if (isSpongeCode(reason) && reason !== failed.code && publicCode(reason) === failed.code)
105
+ throw jobFailure(store, jobId);
86
106
  process.stderr.write(JSON.stringify(failed) + '\n');
87
107
  return exitFor(failed.code);
88
108
  }
@@ -177,9 +197,10 @@ export const commands = [
177
197
  usage: [
178
198
  ['add URL_OR_PDF [--kind article|pdf|media] [--connection ID] [--wait]', ''],
179
199
  ['add URL --archive [--wait]', 'Capture the newest Internet Archive copy of a dead or paywalled page'],
200
+ ['add URL --browser [--wait]', 'Capture through your browser and its sign-ins (sponge browser setup)'],
180
201
  ],
181
202
  positional: 1,
182
- flags: { connection: 'value', kind: 'value', wait: 'boolean', archive: 'boolean', ...media },
203
+ flags: { connection: 'value', kind: 'value', wait: 'boolean', archive: 'boolean', browser: 'boolean', ...media },
183
204
  library: 'open',
184
205
  handler: async (c) => {
185
206
  const { store, args } = c, connections = await readConnections(store.root);
@@ -188,14 +209,12 @@ export const commands = [
188
209
  throw new SpongeError('invalid-input');
189
210
  const input = args.positional[0], kind = (args.flags.kind ?? inferKind(input));
190
211
  const source = (kind === 'pdf' ? pdfSource : kind === 'media' ? mediaSource : articleSource)(input, connection?.realm ?? 'public');
191
- const archive = args.flags.archive === true;
192
- if (archive)
193
- archivable(kind, source.locator, connection);
212
+ const via = route(args.flags, kind, source.locator, connection);
194
213
  const submission = store.queue.submit({
195
214
  kind,
196
215
  ...source,
197
- ...(archive ? { intent: 'archive' } : {}),
198
- idempotencyKey: (archive ? 'archive:' : 'save:') + source.sourceKey,
216
+ ...(via ? { intent: via } : {}),
217
+ idempotencyKey: `${via ?? 'save'}:${source.sourceKey}`,
199
218
  }), { jobId, itemId } = submission;
200
219
  const job = store.queue.list().find((j) => j.id === jobId);
201
220
  if (!args.flags.wait) {
@@ -356,21 +375,21 @@ export const commands = [
356
375
  usage: [
357
376
  ['refresh ITEM [--wait]', 'Capture a new revision'],
358
377
  ['refresh ITEM --archive [--wait]', 'Capture the newest Internet Archive copy instead of the live page'],
378
+ ['refresh ITEM --browser [--wait]', 'Capture through your browser and its sign-ins'],
359
379
  ],
360
380
  positional: 1,
361
- flags: { wait: 'boolean', archive: 'boolean', ...media },
381
+ flags: { wait: 'boolean', archive: 'boolean', browser: 'boolean', ...media },
362
382
  library: 'open',
363
383
  handler: async (c) => {
364
384
  const { store, args } = c, connections = await readConnections(store.root), previous = store.queue.list().find((j) => j.item_id === args.positional[0]), source = previous && store.queue.source(String(previous.id));
365
385
  if (!source || !['article', 'pdf', 'media'].includes(source.kind))
366
386
  throw new SpongeError('unavailable');
367
- const { archive: _, ...rest } = source, kind = source.kind;
368
- if (args.flags.archive)
369
- archivable(kind, source.locator);
387
+ const { via: _, ...rest } = source, kind = source.kind;
388
+ const via = route(args.flags, kind, source.locator, source.realm.startsWith('connection:') || undefined);
370
389
  const submission = store.queue.submit({
371
390
  ...rest,
372
391
  kind,
373
- intent: args.flags.archive ? 'archive' : 'refresh',
392
+ intent: via ?? 'refresh',
374
393
  idempotencyKey: randomUUID(),
375
394
  });
376
395
  if (!args.flags.wait) {
@@ -463,10 +482,17 @@ export const commands = [
463
482
  catch (error) {
464
483
  digest = publicCode(error, 'runtime-missing');
465
484
  }
485
+ const browser = await browserStatus(store.root), browserState = browser.connected
486
+ ? browser.background
487
+ ? 'connected'
488
+ : 'connected, background captures off'
489
+ : browser.registered.length
490
+ ? 'not connected'
491
+ : 'not set up';
466
492
  const cleanup = store.queue.pendingCleanup(), worker = await workerDoctor(store, c.version), workerBroken = !['ready', 'not-installed', 'unsupported'].includes(worker.worker);
467
493
  // Each runtime that is not ready names the command that installs it.
468
494
  const line = (label, state, fix) => `${label}: ${state}${state === 'runtime-missing' ? ` (${fix})` : ''}`;
469
- c.emit({ library: 'ready', capture, pdf, ...states, digest, pendingCleanup: cleanup, worker }, [
495
+ c.emit({ library: 'ready', capture, pdf, ...states, digest, browser: browserState, pendingCleanup: cleanup, worker }, [
470
496
  'Library: ready',
471
497
  `Capture: ${capture}`,
472
498
  `PDF: ${pdf === 'ready' ? 'ready' : 'Poppler not found (brew install poppler)'}`,
@@ -474,6 +500,7 @@ export const commands = [
474
500
  line('Speech-to-text', states.asr, 'sponge setup --asr'),
475
501
  line('Embedding', states.embedding, 'sponge setup --embedding'),
476
502
  line('Digest model', digest, 'sponge setup --digest'),
503
+ `Browser: ${browserState}${browserState === 'not set up' ? ' (optional: sponge browser setup)' : ''}`,
477
504
  `Pending cleanup: ${cleanup}${cleanup ? ' (sponge queue recover)' : ''}`,
478
505
  `Worker: ${worker.worker}${Array.isArray(worker.drift) ? ` (${worker.drift.join(', ')})` : ''}${worker.repair ? `\n ${worker.repair}` : ''}`,
479
506
  ].join('\n'));
@@ -592,6 +619,51 @@ export const commands = [
592
619
  return 0;
593
620
  },
594
621
  },
622
+ {
623
+ name: 'browser',
624
+ summary: 'Capture through your own browser and its sign-ins',
625
+ usage: [
626
+ ['browser setup [--hosts-dir DIR]', 'Connect the Sponge extension in Chrome and other Chromium browsers'],
627
+ ['browser status', 'Show which browsers are set up and whether the extension is connected'],
628
+ ['browser remove', 'Disconnect Sponge from every browser'],
629
+ ],
630
+ positional: 1,
631
+ flags: { 'hosts-dir': 'value' },
632
+ library: 'open',
633
+ check: (p) => ['setup', 'status', 'remove'].includes(p[0]),
634
+ handler: async (c) => {
635
+ const { store, args } = c, hostsDir = args.flags['hosts-dir'];
636
+ if (hostsDir !== undefined && !isAbsolute(hostsDir))
637
+ throw new SpongeError('invalid-input');
638
+ const options = hostsDir ? { hostsDirs: [hostsDir] } : {};
639
+ if (args.positional[0] === 'setup') {
640
+ const result = await setupBrowser(store.root, options);
641
+ c.emit(result, [
642
+ result.registered.length
643
+ ? `Registered with: ${result.registered.join(', ')}`
644
+ : 'No Chromium browser found. Install Chrome, or pass --hosts-dir.',
645
+ 'Then load the extension once:',
646
+ ' 1. Open chrome://extensions and turn on Developer mode',
647
+ ` 2. Click "Load unpacked" and choose ${result.extension}`,
648
+ ' 3. Pin Sponge, then check `sponge browser status`',
649
+ ].join('\n'));
650
+ return result.registered.length ? 0 : 4;
651
+ }
652
+ if (args.positional[0] === 'remove') {
653
+ const result = await removeBrowser(options);
654
+ c.emit(result, result.removed.length ? `Removed from: ${result.removed.join(', ')}` : 'Nothing to remove');
655
+ return 0;
656
+ }
657
+ const status = await browserStatus(store.root, options);
658
+ c.emit(status, [
659
+ `Registered: ${status.registered.join(', ') || 'none (sponge browser setup)'}`,
660
+ `Library: ${status.bound ? 'this one' : 'another library (sponge browser setup to switch)'}`,
661
+ `Extension: ${status.connected ? `connected${status.extension ? ` (${status.extension})` : ''}` : 'not connected'}`,
662
+ ...(status.connected ? [`Background captures: ${status.background ? 'on' : 'off'}`] : []),
663
+ ].join('\n'));
664
+ return status.connected ? 0 : 4;
665
+ },
666
+ },
595
667
  {
596
668
  name: 'import',
597
669
  summary: 'Import existing captures',
@@ -23,6 +23,7 @@ declare const parents: {
23
23
  readonly 'parent-missing': "invalid-input";
24
24
  readonly 'target-exists': "invalid-input";
25
25
  readonly 'connection-retired': "unsupported";
26
+ readonly 'browser-offline': "unavailable";
26
27
  };
27
28
  export type DetailCode = keyof typeof parents;
28
29
  export type SpongeCode = ErrorCode | DetailCode;
@@ -37,6 +37,7 @@ const parents = {
37
37
  'parent-missing': 'invalid-input',
38
38
  'target-exists': 'invalid-input',
39
39
  'connection-retired': 'unsupported',
40
+ 'browser-offline': 'unavailable',
40
41
  };
41
42
  export const indexCodes = [
42
43
  'invalid-index',
@@ -88,6 +89,7 @@ export const detailHints = {
88
89
  'model-unavailable': 'The model server did not answer or returned an error. Check that it is still running and has free GPU memory (another loaded model can exhaust it), then retry.',
89
90
  'source-too-thin': 'The capture holds too little text to digest: under 20 words of prose, or only an archive’s metadata. Refresh the item when a fuller capture is possible.',
90
91
  'connection-retired': 'Saved GhostGet account connections were retired in Sponge 0.5.0. Attach a signed-in browser over CDP and add it to connections.json instead.',
92
+ 'browser-offline': 'No browser has the Sponge extension connected, or its background captures are off. Open Chrome with the extension on, check `sponge browser status`, then retry.',
91
93
  'model-busy': 'Another llama-server is running, and two loaded models can exhaust GPU memory. Wait for it to exit, then retry.',
92
94
  };
93
95
  /** The one error type. `message` is the code, so bare `String(error)` never carries detail, paths or causes. */
@@ -22,9 +22,7 @@ export function ingestPdf(store, token, options) {
22
22
  const source = store.queue.source(token.jobId);
23
23
  if (!source || source.kind !== 'pdf')
24
24
  throw new SpongeError('invalid-input');
25
- const snapshot = source.archive
26
- ? await (options.snapshot ?? waybackSnapshot)(source.locator, token.attemptId)
27
- : null;
25
+ const snapshot = source.via === 'archive' ? await (options.snapshot ?? waybackSnapshot)(source.locator, token.attemptId) : null;
28
26
  if (snapshot && 'ok' in snapshot)
29
27
  return snapshot;
30
28
  const fetched = snapshot?.url ?? source.locator;
@@ -46,7 +46,7 @@ export declare class Queue {
46
46
  list(): Row[];
47
47
  /**
48
48
  * Acquisition data for a capture job. A local file's locator is its path on this device (`local_sources`), never the
49
- * synced form. `archive` is set when any submission the job serves asked for the Internet Archive copy.
49
+ * synced form. `via` names the route the job's newest archive or browser submission asked for.
50
50
  */
51
51
  source(jobId: string): {
52
52
  itemId: string;
@@ -54,7 +54,7 @@ export declare class Queue {
54
54
  sourceKey: string;
55
55
  locator: string;
56
56
  realm: string;
57
- archive: boolean;
57
+ via?: 'archive' | 'browser';
58
58
  } | null;
59
59
  claimNext(token: OwnerToken): AttemptToken | null;
60
60
  claim(token: OwnerToken, jobId: string): AttemptToken;
@@ -163,7 +163,7 @@ export class Queue {
163
163
  const realm = input.realm ?? '';
164
164
  if (realm)
165
165
  text(realm, 256);
166
- if (input.intent !== undefined && !['save', 'refresh', 'archive'].includes(input.intent))
166
+ if (input.intent !== undefined && !['save', 'refresh', 'archive', 'browser'].includes(input.intent))
167
167
  throw new SpongeError('invalid-input');
168
168
  const db = this.c.db;
169
169
  return transaction(db, () => {
@@ -226,14 +226,17 @@ export class Queue {
226
226
  }
227
227
  /**
228
228
  * Acquisition data for a capture job. A local file's locator is its path on this device (`local_sources`), never the
229
- * synced form. `archive` is set when any submission the job serves asked for the Internet Archive copy.
229
+ * synced form. `via` names the route the job's newest archive or browser submission asked for.
230
230
  */
231
231
  source(jobId) {
232
232
  text(jobId, 256);
233
233
  const row = this.c.db
234
- .prepare("SELECT i.id itemId,i.kind,r.source_key sourceKey,COALESCE(l.locator,r.locator) locator,r.realm_key realm,EXISTS(SELECT 1 FROM submissions s WHERE s.job_id=j.id AND s.intent='archive') archive FROM jobs j JOIN items i ON i.id=j.item_id JOIN source_refs r ON r.item_id=i.id LEFT JOIN local_sources l ON l.item_id=i.id WHERE j.id=? AND i.deleted_at IS NULL ORDER BY r.id LIMIT 1")
234
+ .prepare("SELECT i.id itemId,i.kind,r.source_key sourceKey,COALESCE(l.locator,r.locator) locator,r.realm_key realm,(SELECT s.intent FROM submissions s WHERE s.job_id=j.id AND s.intent IN ('archive','browser') ORDER BY s.created_at DESC,s.rowid DESC LIMIT 1) via FROM jobs j JOIN items i ON i.id=j.item_id JOIN source_refs r ON r.item_id=i.id LEFT JOIN local_sources l ON l.item_id=i.id WHERE j.id=? AND i.deleted_at IS NULL ORDER BY r.id LIMIT 1")
235
235
  .get(jobId);
236
- return row ? { ...row, archive: row.archive === 1 } : null;
236
+ if (!row)
237
+ return null;
238
+ const { via, ...source } = row;
239
+ return via ? { ...source, via } : source;
237
240
  }
238
241
  claimNext(token) {
239
242
  checkOwner(this.c.db, token);
@@ -27,8 +27,11 @@ export interface SourceSubmission {
27
27
  realm?: string;
28
28
  kind: ItemKind;
29
29
  idempotencyKey: string;
30
- /** `archive` captures the source's newest Internet Archive copy instead of the live page. */
31
- intent?: 'save' | 'refresh' | 'archive';
30
+ /**
31
+ * `archive` captures the source's newest Internet Archive copy instead of the live page; `browser` captures it
32
+ * through the reader's own browser and sign-ins.
33
+ */
34
+ intent?: 'save' | 'refresh' | 'archive' | 'browser';
32
35
  }
33
36
  export interface Submission {
34
37
  itemId: string;
@@ -4,8 +4,10 @@ import { ingestArticle } from '../app/article.js';
4
4
  import { ingestPdf } from '../pdf/index.js';
5
5
  import { ingestMedia } from '../media/handler.js';
6
6
  import { drainQueue } from './engine.js';
7
+ import { askBridge, bridgeSocket } from '../browser/bridge.js';
7
8
  /** Content handlers over the durable sequential worker and its process supervisor. */
8
9
  export async function drainLibrary(store, options) {
10
+ const browser = options.browser ?? bridgeSocket(store.root);
9
11
  const handler = async (context) => {
10
12
  const source = store.queue.source(context.attempt.jobId);
11
13
  let connection;
@@ -18,6 +20,18 @@ export async function drainLibrary(store, options) {
18
20
  store.queue.settle(context.attempt);
19
21
  return { ...failure(context.attempt.attemptId, publicCode(code), 'acquisition'), cleanup: 'settled' };
20
22
  }
23
+ if (source?.via === 'browser') {
24
+ // Asked once up front, so an absent browser is a clear reason rather than an empty capture.
25
+ const status = await askBridge(browser, { op: 'status' }, { timeoutMs: 5000, maxBytes: 4096 });
26
+ if (!status.ok || !('background' in status) || !status.background) {
27
+ store.queue.fail(context.attempt, 'browser-offline', { retryable: true });
28
+ store.queue.settle(context.attempt);
29
+ return {
30
+ ...failure(context.attempt.attemptId, 'unavailable', 'acquisition', true),
31
+ cleanup: 'settled',
32
+ };
33
+ }
34
+ }
21
35
  if (source?.kind === 'media') {
22
36
  const { capture: _articleCapture, ...mediaOptions } = options;
23
37
  return ingestMedia(store, context.attempt, {
@@ -30,6 +44,7 @@ export async function drainLibrary(store, options) {
30
44
  }
31
45
  return (source?.kind === 'pdf' ? ingestPdf : ingestArticle)(store, context.attempt, {
32
46
  ...options,
47
+ browser,
33
48
  connection,
34
49
  signal: context.signal,
35
50
  runProcess: context.runProcess,
@@ -0,0 +1,180 @@
1
+ // The Sponge extension. Sponge asks over native messaging for a page; this worker loads it with the reader's own
2
+ // sign-ins and answers with the page alone. Cookies and passwords never leave the browser.
3
+ const host = 'com.hraness.sponge';
4
+ const version = chrome.runtime.getManifest().version;
5
+ /** Sponge holds a page to 16 MiB of HTML. */
6
+ const maxHtml = 16 * 1024 * 1024;
7
+ let port = null;
8
+ let connectedAt = 0;
9
+ let retryMs = 1000;
10
+ const saves = new Map();
11
+
12
+ async function backgroundAllowed() {
13
+ const { background } = await chrome.storage.local.get('background');
14
+ return background !== false;
15
+ }
16
+
17
+ async function connect() {
18
+ if (port) return;
19
+ try {
20
+ port = chrome.runtime.connectNative(host);
21
+ } catch {
22
+ port = null;
23
+ return reconnectLater();
24
+ }
25
+ connectedAt = Date.now();
26
+ port.onMessage.addListener(onHostMessage);
27
+ port.onDisconnect.addListener(() => {
28
+ void chrome.runtime.lastError;
29
+ port = null;
30
+ for (const resolve of saves.values()) resolve({ ok: false, code: 'offline' });
31
+ saves.clear();
32
+ // A host that ran for a while left normally; one that never started (not set up) backs off to a minute.
33
+ if (Date.now() - connectedAt > 10_000) retryMs = 1000;
34
+ reconnectLater();
35
+ });
36
+ port.postMessage({ type: 'hello', version, background: await backgroundAllowed() });
37
+ }
38
+
39
+ function reconnectLater() {
40
+ setTimeout(connect, retryMs);
41
+ retryMs = Math.min(retryMs * 2, 60_000);
42
+ }
43
+
44
+ function onHostMessage(message) {
45
+ if (message?.type === 'saved') {
46
+ saves.get(message.requestId)?.(message);
47
+ saves.delete(message.requestId);
48
+ return;
49
+ }
50
+ if (typeof message?.id !== 'string') return;
51
+ const run = message.op === 'fetch' ? fetchPage : message.op === 'render' ? renderPage : null;
52
+ if (!run) return port?.postMessage({ id: message.id, ok: false, code: 'failed' });
53
+ backgroundAllowed()
54
+ .then((allowed) => (allowed ? run(message.url, message.timeoutMs) : { ok: false, code: 'disabled' }))
55
+ .catch(() => ({ ok: false, code: 'failed' }))
56
+ .then((answer) => port?.postMessage({ id: message.id, ...answer }));
57
+ }
58
+
59
+ /** The page's HTML as the reader's browser would fetch it, with their cookies. */
60
+ async function fetchPage(url, timeoutMs) {
61
+ const controller = new AbortController();
62
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
63
+ try {
64
+ const response = await fetch(url, { credentials: 'include', cache: 'no-store', signal: controller.signal });
65
+ if (!response.ok) return { ok: false, code: 'failed', status: response.status };
66
+ if (!/^(text\/html|application\/xhtml\+xml)\b/i.test(response.headers.get('content-type') ?? ''))
67
+ return { ok: false, code: 'not-html' };
68
+ const html = await response.text();
69
+ if (html.length > maxHtml) return { ok: false, code: 'too-large' };
70
+ return { ok: true, html, finalUrl: response.url };
71
+ } catch {
72
+ return { ok: false, code: controller.signal.aborted ? 'timeout' : 'failed' };
73
+ } finally {
74
+ clearTimeout(timer);
75
+ }
76
+ }
77
+
78
+ /** The page after its scripts run, in a muted background tab that closes when done. */
79
+ async function renderPage(url, timeoutMs) {
80
+ const deadline = Date.now() + timeoutMs;
81
+ let tab;
82
+ try {
83
+ tab = await chrome.tabs.create({ url, active: false });
84
+ } catch {
85
+ // No window to open a tab in: a minimized one serves instead.
86
+ const window = await chrome.windows.create({ url, focused: false, state: 'minimized' });
87
+ tab = window.tabs[0];
88
+ }
89
+ try {
90
+ await chrome.tabs.update(tab.id, { muted: true });
91
+ if (!(await loaded(tab.id, deadline))) return { ok: false, code: 'timeout' };
92
+ const [result] = await chrome.scripting.executeScript({ target: { tabId: tab.id }, func: snapshot });
93
+ const page = result?.result;
94
+ if (!page || typeof page.html !== 'string') return { ok: false, code: 'failed' };
95
+ if (page.html.length > maxHtml) return { ok: false, code: 'too-large' };
96
+ return { ok: true, html: page.html, finalUrl: page.url, title: page.title };
97
+ } catch {
98
+ return { ok: false, code: 'failed' };
99
+ } finally {
100
+ await chrome.tabs.remove(tab.id).catch(() => undefined);
101
+ }
102
+ }
103
+
104
+ /** A new tab first reports its blank start page as complete; only the requested page counts. */
105
+ const navigated = (tab) => tab.status === 'complete' && !tab.pendingUrl && /^https?:/.test(tab.url ?? '');
106
+
107
+ function loaded(tabId, deadline) {
108
+ return new Promise((resolve) => {
109
+ const done = (value) => {
110
+ clearTimeout(timer);
111
+ clearInterval(poll);
112
+ chrome.tabs.onUpdated.removeListener(listen);
113
+ resolve(value);
114
+ };
115
+ const check = () =>
116
+ chrome.tabs.get(tabId).then(
117
+ (tab) => navigated(tab) && done(true),
118
+ () => done(false),
119
+ );
120
+ const listen = (id, _change, tab) => id === tabId && navigated(tab) && done(true);
121
+ const timer = setTimeout(() => done(false), Math.max(0, deadline - Date.now()));
122
+ // Update events can be missed while a fresh browser settles, so the tab is also checked every half second.
123
+ const poll = setInterval(check, 500);
124
+ chrome.tabs.onUpdated.addListener(listen);
125
+ check();
126
+ });
127
+ }
128
+
129
+ /** Runs in the page: scrolls once through it so lazy text loads, then returns the document. */
130
+ async function snapshot() {
131
+ const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
132
+ await pause(1000);
133
+ for (let step = 0; step < 10 && innerHeight + scrollY < document.body.scrollHeight; step++) {
134
+ scrollBy(0, innerHeight);
135
+ await pause(200);
136
+ }
137
+ scrollTo(0, 0);
138
+ return { html: '<!doctype html>\n' + document.documentElement.outerHTML, url: location.href, title: document.title };
139
+ }
140
+
141
+ /** Asks Sponge to save a page; the Sponge worker then captures it through this browser. */
142
+ function save(url) {
143
+ if (!port) return Promise.resolve({ ok: false, code: 'offline' });
144
+ const requestId = crypto.randomUUID();
145
+ return new Promise((resolve) => {
146
+ saves.set(requestId, resolve);
147
+ setTimeout(() => saves.delete(requestId) && resolve({ ok: false, code: 'timeout' }), 15_000);
148
+ port.postMessage({ type: 'save', requestId, url });
149
+ });
150
+ }
151
+
152
+ chrome.runtime.onMessage.addListener((message, _sender, reply) => {
153
+ if (message?.type === 'status') {
154
+ backgroundAllowed().then((background) => reply({ connected: port !== null, background }));
155
+ return true;
156
+ }
157
+ if (message?.type === 'set-background') {
158
+ chrome.storage.local
159
+ .set({ background: message.value === true })
160
+ .then(() => port?.postMessage({ type: 'settings', version, background: message.value === true }))
161
+ .then(() => reply({ ok: true }));
162
+ return true;
163
+ }
164
+ if (message?.type === 'save') {
165
+ save(message.url).then(reply);
166
+ return true;
167
+ }
168
+ return false;
169
+ });
170
+
171
+ chrome.runtime.onInstalled.addListener(() => {
172
+ chrome.contextMenus.create({ id: 'save-link', title: 'Save link to Sponge', contexts: ['link'] });
173
+ chrome.contextMenus.create({ id: 'save-page', title: 'Save page to Sponge', contexts: ['page'] });
174
+ });
175
+ chrome.contextMenus.onClicked.addListener((info, tab) => {
176
+ const url = info.menuItemId === 'save-link' ? info.linkUrl : tab?.url;
177
+ if (url) void save(url);
178
+ });
179
+ chrome.runtime.onStartup.addListener(connect);
180
+ void connect();
@@ -0,0 +1,25 @@
1
+ {
2
+ "manifest_version": 3,
3
+ "name": "Sponge",
4
+ "version": "0.6.9",
5
+ "description": "Save pages to your local Sponge library with the sign-ins you already have. Cookies never leave the browser.",
6
+ "key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAoSbZrBglr0h0193XOgavySnrG0DQif+WgYJ37CFSrbBwWP43rUz2ZzGRPvNveHgFZvFZlhGITJD0VC4NMQa2kxGAPP66RWEulIZDvGauxKonwcrlSneD+srPY8oMNX5sivTqw819uMwDA70oxBxKyTX4t4vwpwXuFX3Zf+5jwUOD3nz8XZFGT9uDh86MHyYYlURzsZQYBJSf2qAN17+OfhV7NHCWM5FdQodnOau6N/PcH/MstVGnbOaWeaTGged0PS2QGA5NDoOu5WZv1sUTHFqzM/Wsb/h1l8Kt5IfOZAXvhokhBBW7HTyYqgvX4wFHuj5fNioYshBUfMrKeZZFlQIDAQAB",
7
+ "minimum_chrome_version": "116",
8
+ "background": {
9
+ "service_worker": "background.js"
10
+ },
11
+ "action": {
12
+ "default_title": "Sponge",
13
+ "default_popup": "popup.html"
14
+ },
15
+ "permissions": [
16
+ "nativeMessaging",
17
+ "scripting",
18
+ "tabs",
19
+ "storage",
20
+ "contextMenus"
21
+ ],
22
+ "host_permissions": [
23
+ "<all_urls>"
24
+ ]
25
+ }
@@ -0,0 +1,85 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <title>Sponge</title>
6
+ <style>
7
+ :root {
8
+ color-scheme: light dark;
9
+ --ink: #1d1d1f;
10
+ --muted: #6e6e73;
11
+ --paper: #ffffff;
12
+ --line: #d2d2d7;
13
+ --accent: #0a7c5a;
14
+ }
15
+ @media (prefers-color-scheme: dark) {
16
+ :root {
17
+ --ink: #f5f5f7;
18
+ --muted: #a1a1a6;
19
+ --paper: #1c1c1e;
20
+ --line: #3a3a3c;
21
+ --accent: #30c48d;
22
+ }
23
+ }
24
+ body {
25
+ margin: 0;
26
+ width: 280px;
27
+ padding: 14px 16px;
28
+ font:
29
+ 13px/1.4 -apple-system,
30
+ BlinkMacSystemFont,
31
+ 'Helvetica Neue',
32
+ sans-serif;
33
+ color: var(--ink);
34
+ background: var(--paper);
35
+ }
36
+ h1 {
37
+ margin: 0 0 4px;
38
+ font-size: 15px;
39
+ }
40
+ #state {
41
+ margin: 0 0 12px;
42
+ color: var(--muted);
43
+ }
44
+ button {
45
+ width: 100%;
46
+ padding: 8px;
47
+ border: 0;
48
+ border-radius: 6px;
49
+ font: inherit;
50
+ font-weight: 600;
51
+ color: #fff;
52
+ background: var(--accent);
53
+ cursor: pointer;
54
+ }
55
+ button:disabled {
56
+ opacity: 0.5;
57
+ cursor: default;
58
+ }
59
+ label {
60
+ display: flex;
61
+ gap: 8px;
62
+ align-items: flex-start;
63
+ margin-top: 12px;
64
+ padding-top: 12px;
65
+ border-top: 1px solid var(--line);
66
+ color: var(--muted);
67
+ }
68
+ #result {
69
+ min-height: 1.4em;
70
+ margin: 8px 0 0;
71
+ }
72
+ </style>
73
+ </head>
74
+ <body>
75
+ <h1>Sponge</h1>
76
+ <p id="state">Checking…</p>
77
+ <button id="save" disabled>Save this page</button>
78
+ <p id="result" role="status"></p>
79
+ <label
80
+ ><input id="background" type="checkbox" /> Let Sponge load pages with this browser's sign-ins. Pages it loads stay
81
+ private in your library.</label
82
+ >
83
+ <script src="popup.js"></script>
84
+ </body>
85
+ </html>
@@ -0,0 +1,39 @@
1
+ const state = document.getElementById('state');
2
+ const save = document.getElementById('save');
3
+ const result = document.getElementById('result');
4
+ const background = document.getElementById('background');
5
+ const messages = {
6
+ offline: 'Sponge is not connected. Run `sponge browser setup`, then reopen this.',
7
+ unsupported: 'Sponge cannot save this kind of page.',
8
+ timeout: 'Sponge did not answer. Try again.',
9
+ failed: 'Sponge could not save this page.',
10
+ };
11
+
12
+ async function refresh() {
13
+ const status = await chrome.runtime.sendMessage({ type: 'status' });
14
+ background.checked = status.background;
15
+ state.textContent = status.connected
16
+ ? status.background
17
+ ? 'Connected to your library.'
18
+ : 'Connected. Turn on browser captures below to save pages.'
19
+ : messages.offline;
20
+ const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
21
+ save.disabled = !status.connected || !status.background || !/^https?:/.test(tab?.url ?? '');
22
+ save.dataset.url = tab?.url ?? '';
23
+ }
24
+
25
+ save.addEventListener('click', async () => {
26
+ save.disabled = true;
27
+ result.textContent = 'Saving…';
28
+ const answer = await chrome.runtime.sendMessage({ type: 'save', url: save.dataset.url });
29
+ result.textContent = answer.ok
30
+ ? 'Saved. Sponge captures it with your sign-ins.'
31
+ : (messages[answer.code] ?? messages.failed);
32
+ });
33
+
34
+ background.addEventListener('change', async () => {
35
+ await chrome.runtime.sendMessage({ type: 'set-background', value: background.checked });
36
+ await refresh();
37
+ });
38
+
39
+ void refresh();