@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.
- package/README.md +26 -0
- package/dist/capture/worker.js +154 -152
- package/dist/contracts/index.d.ts +2 -0
- package/dist/src/acquisition/capture.d.ts +4 -0
- package/dist/src/acquisition/capture.js +8 -2
- package/dist/src/acquisition/child-request.d.ts +5 -0
- package/dist/src/acquisition/normalize.js +3 -0
- package/dist/src/app/article.d.ts +2 -0
- package/dist/src/app/article.js +14 -3
- package/dist/src/browser/bridge.d.ts +48 -0
- package/dist/src/browser/bridge.js +90 -0
- package/dist/src/browser/host.d.ts +10 -0
- package/dist/src/browser/host.js +147 -0
- package/dist/src/browser/setup.d.ts +26 -0
- package/dist/src/browser/setup.js +103 -0
- package/dist/src/capture/types.d.ts +1 -1
- package/dist/src/cli/commands.js +84 -12
- package/dist/src/errors.d.ts +1 -0
- package/dist/src/errors.js +2 -0
- package/dist/src/pdf/handler.js +1 -3
- package/dist/src/store/queue.d.ts +2 -2
- package/dist/src/store/queue.js +7 -4
- package/dist/src/store/types.d.ts +5 -2
- package/dist/src/worker/basic.js +15 -0
- package/extension/background.js +180 -0
- package/extension/manifest.json +25 -0
- package/extension/popup.html +85 -0
- package/extension/popup.js +39 -0
- package/native/runtime-manifest.json +5 -5
- package/package.json +2 -1
package/dist/src/cli/commands.js
CHANGED
|
@@ -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
|
|
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
|
-
...(
|
|
198
|
-
idempotencyKey:
|
|
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 {
|
|
368
|
-
|
|
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:
|
|
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',
|
package/dist/src/errors.d.ts
CHANGED
|
@@ -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;
|
package/dist/src/errors.js
CHANGED
|
@@ -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. */
|
package/dist/src/pdf/handler.js
CHANGED
|
@@ -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. `
|
|
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
|
|
57
|
+
via?: 'archive' | 'browser';
|
|
58
58
|
} | null;
|
|
59
59
|
claimNext(token: OwnerToken): AttemptToken | null;
|
|
60
60
|
claim(token: OwnerToken, jobId: string): AttemptToken;
|
package/dist/src/store/queue.js
CHANGED
|
@@ -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. `
|
|
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,
|
|
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
|
-
|
|
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
|
-
/**
|
|
31
|
-
|
|
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;
|
package/dist/src/worker/basic.js
CHANGED
|
@@ -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();
|