feat: add opt-in browser domain activity tracking

This commit is contained in:
mirivlad 2026-07-12 17:37:25 +08:00
parent e67a7e4ff7
commit 7461790934
17 changed files with 718 additions and 24 deletions

View File

@ -5,7 +5,7 @@
"description": "Send pages, selections, links, and files to the local Verstak browser inbox.", "description": "Send pages, selections, links, and files to the local Verstak browser inbox.",
"author": "Verstak", "author": "Verstak",
"homepage_url": "https://git.mirv.top/verstak/verstak-browser-extension", "homepage_url": "https://git.mirv.top/verstak/verstak-browser-extension",
"permissions": ["contextMenus", "storage", "tabs"], "permissions": ["alarms", "contextMenus", "idle", "storage", "tabs", "windows"],
"host_permissions": ["http://127.0.0.1/*", "http://localhost/*"], "host_permissions": ["http://127.0.0.1/*", "http://localhost/*"],
"background": { "background": {
"service_worker": "background.js" "service_worker": "background.js"

View File

@ -15,9 +15,9 @@
} }
} }
}, },
"permissions": ["contextMenus", "storage", "tabs", "http://127.0.0.1/*", "http://localhost/*"], "permissions": ["alarms", "contextMenus", "idle", "storage", "tabs", "windows", "http://127.0.0.1/*", "http://localhost/*"],
"background": { "background": {
"scripts": ["hostname.js", "protocol.js", "api.js", "queue.js", "i18n.js", "background.js"] "scripts": ["hostname.js", "activity-tracker.js", "protocol.js", "api.js", "queue.js", "i18n.js", "background.js"]
}, },
"browser_action": { "browser_action": {
"default_popup": "popup/popup.html", "default_popup": "popup/popup.html",

View File

@ -5,7 +5,7 @@
"description": "Verstak browser capture extension for Chromium and Firefox", "description": "Verstak browser capture extension for Chromium and Firefox",
"scripts": { "scripts": {
"build": "node scripts/build-extension.js", "build": "node scripts/build-extension.js",
"test": "node scripts/test-hostname.js && node scripts/test-protocol.js && node scripts/test-i18n.js && node scripts/test-popup-settings.js && node scripts/test-popup-catalog-fallback.js && node scripts/test-background-i18n.js", "test": "node scripts/test-hostname.js && node scripts/test-activity-tracker.js && node scripts/test-protocol.js && node scripts/test-i18n.js && node scripts/test-popup-settings.js && node scripts/test-popup-catalog-fallback.js && node scripts/test-background-i18n.js",
"sign:firefox": "./scripts/sign-firefox-xpi.sh", "sign:firefox": "./scripts/sign-firefox-xpi.sh",
"release:firefox": "./scripts/release-firefox-xpi.sh" "release:firefox": "./scripts/release-firefox-xpi.sh"
}, },

View File

@ -52,6 +52,7 @@ mkdir(chromiumDist);
copy(path.join(root, 'chromium', 'manifest.json'), path.join(chromiumDist, 'manifest.json')); copy(path.join(root, 'chromium', 'manifest.json'), path.join(chromiumDist, 'manifest.json'));
concat([ concat([
path.join(shared, 'hostname.js'), path.join(shared, 'hostname.js'),
path.join(shared, 'activity-tracker.js'),
path.join(shared, 'protocol.js'), path.join(shared, 'protocol.js'),
path.join(shared, 'api.js'), path.join(shared, 'api.js'),
path.join(shared, 'queue.js'), path.join(shared, 'queue.js'),
@ -65,7 +66,7 @@ copyLocalization(chromiumDist);
const firefoxDist = path.join(dist, 'firefox'); const firefoxDist = path.join(dist, 'firefox');
mkdir(firefoxDist); mkdir(firefoxDist);
copy(path.join(root, 'firefox', 'manifest.json'), path.join(firefoxDist, 'manifest.json')); copy(path.join(root, 'firefox', 'manifest.json'), path.join(firefoxDist, 'manifest.json'));
for (const name of ['hostname.js', 'protocol.js', 'api.js', 'queue.js', 'i18n.js', 'background.js']) { for (const name of ['hostname.js', 'activity-tracker.js', 'protocol.js', 'api.js', 'queue.js', 'i18n.js', 'background.js']) {
copy(path.join(shared, name), path.join(firefoxDist, name)); copy(path.join(shared, name), path.join(firefoxDist, name));
} }
copyPopup(firefoxDist); copyPopup(firefoxDist);

View File

@ -0,0 +1,70 @@
#!/usr/bin/env node
const assert = require('assert');
const hostname = require('../shared/hostname');
const trackerApi = require('../shared/activity-tracker');
async function main() {
const storage = trackerApi.createMemoryActivityStorage();
const sent = [];
let failSends = true;
const tracker = new trackerApi.DomainActivityTracker(storage, async (batch) => {
sent.push(JSON.parse(JSON.stringify(batch)));
if (failSends) throw new Error('offline');
return { status: 'accepted', batchId: batch.batchId };
});
await tracker.initialize();
await tracker.setEnabled(true);
await tracker.setActiveHostname('example.com', true, 0);
await tracker.checkpoint(600000);
await tracker.flush(600000);
let state = await tracker.getState();
assert.equal(state.pendingBatches.length, 1);
assert.equal(state.pendingBatches[0].entries[0].durationSeconds, 600);
const immutableFirstBatch = JSON.stringify(sent[0]);
await tracker.setActiveHostname('example.com', true, 900000);
await tracker.flush(900000);
state = await tracker.getState();
assert.equal(state.pendingBatches.length, 2, 'new activity must not mutate sent batch A');
assert.equal(state.pendingBatches[1].entries[0].durationSeconds, 300);
failSends = false;
await tracker.retryPending();
state = await tracker.getState();
assert.equal(state.pendingBatches.length, 0);
assert.ok(state.acknowledgedIds.length >= 2);
assert.equal(JSON.stringify(sent[1]), immutableFirstBatch, 'retry must resend an immutable batch A payload');
const clockTracker = new trackerApi.DomainActivityTracker(trackerApi.createMemoryActivityStorage(), async () => ({ status: 'accepted' }));
await clockTracker.initialize();
await clockTracker.setEnabled(true);
await clockTracker.setActiveHostname('example.com', true, 0);
await clockTracker.checkpoint(8 * 60 * 60 * 1000);
state = await clockTracker.getState();
assert.equal(state.activeAccumulator['example.com'].durationMs, 0, 'sleep-sized gap must be discarded');
await clockTracker.checkpoint(8 * 60 * 60 * 1000 + 5 * 60 * 1000);
state = await clockTracker.getState();
assert.equal(state.activeAccumulator['example.com'].durationMs, 300000);
await clockTracker.checkpoint(1000000);
state = await clockTracker.getState();
assert.equal(state.activeAccumulator['example.com'].durationMs, 0, 'clock rollback must reset the ambiguous interval');
assert.equal(trackerApi.isExcludedHostname('video.youtube.com', ['youtube.com']), true);
assert.equal(trackerApi.isExcludedHostname('youtube.com.evil.test', ['youtube.com']), false);
assert.equal(trackerApi.isExcludedHostname(hostname.normalizeHostnameV1('пример.рф'), ['пример.рф']), true);
await tracker.setEnabled(false);
state = await tracker.getState();
assert.deepEqual(state.activeAccumulator, {});
assert.deepEqual(state.pendingBatches, []);
assert.equal(JSON.stringify(state).includes('https://'), false);
console.log('browser activity tracker tests passed');
}
main().catch((error) => {
console.error(error);
process.exit(1);
});

View File

@ -49,7 +49,13 @@ const browser = {
onMessage: { addListener(listener) { messageListener = listener; } }, onMessage: { addListener(listener) { messageListener = listener; } },
}, },
i18n: { getUILanguage() { return 'en-US'; } }, i18n: { getUILanguage() { return 'en-US'; } },
tabs: { query() { return Promise.resolve([]); } }, tabs: {
query() { return Promise.resolve([{ id: 7, windowId: 1, url: 'https://example.com/private-path', title: 'Private title' }]); },
get() { return Promise.resolve({ id: 7, windowId: 1, url: 'https://example.com/private-path', title: 'Private title' }); },
onActivated: { addListener() {} },
onUpdated: { addListener() {} },
onRemoved: { addListener() {} },
},
}; };
function fetchCatalog(url) { function fetchCatalog(url) {
@ -69,7 +75,7 @@ const context = vm.createContext({
clearTimeout, clearTimeout,
}); });
context.globalThis = context; context.globalThis = context;
for (const file of ['protocol.js', 'api.js', 'queue.js', 'i18n.js', 'background.js']) { for (const file of ['hostname.js', 'activity-tracker.js', 'protocol.js', 'api.js', 'queue.js', 'i18n.js', 'background.js']) {
vm.runInContext(fs.readFileSync(path.join(root, 'shared', file), 'utf8'), context, { filename: file }); vm.runInContext(fs.readFileSync(path.join(root, 'shared', file), 'utf8'), context, { filename: file });
} }
@ -92,6 +98,22 @@ function sendMessage(message) {
'Отправить ссылку в Верстак', 'Отправить ссылку в Верстак',
]); ]);
const trackingState = await sendMessage({
type: 'verstak.capture',
action: 'saveSettings',
settings: {
receiverUrl: state.settings.receiverUrl,
receiverToken: state.settings.receiverToken,
language: 'en',
passiveActivityEnabled: true,
passiveActivityExcludedDomains: [],
},
});
assert.strictEqual(trackingState.settings.passiveActivityEnabled, true);
assert.ok(trackingState.activityState.activeAccumulator['example.com']);
assert.equal(JSON.stringify(trackingState.activityState).includes('private-path'), false);
assert.equal(JSON.stringify(trackingState.activityState).includes('Private title'), false);
const nextState = await sendMessage({ const nextState = await sendMessage({
type: 'verstak.capture', type: 'verstak.capture',
action: 'saveSettings', action: 'saveSettings',

View File

@ -35,10 +35,12 @@ const elements = {};
'receiver-token-input', 'receiver-token-input',
'file-input', 'file-input',
'pending-count', 'pending-count',
'activity-pending-count',
'status-dot', 'status-dot',
'subtitle', 'subtitle',
'receiver-label', 'receiver-label',
'pending-label', 'pending-label',
'activity-pending-label',
'url-label', 'url-label',
'capture-page', 'capture-page',
'capture-file', 'capture-file',
@ -53,6 +55,11 @@ const elements = {};
'language-ru-option', 'language-ru-option',
'save-settings', 'save-settings',
'context-menu-hint', 'context-menu-hint',
'passive-activity-enabled',
'passive-activity-label',
'passive-activity-disclosure',
'passive-activity-exclusions-label',
'passive-activity-exclusions',
].forEach((id) => { ].forEach((id) => {
elements[id] = new Element(); elements[id] = new Element();
}); });
@ -63,6 +70,8 @@ const initialState = {
receiverUrl: 'http://127.0.0.1:47731/api/browser-inbox/v1/captures', receiverUrl: 'http://127.0.0.1:47731/api/browser-inbox/v1/captures',
receiverToken: 'persisted-token', receiverToken: 'persisted-token',
language: 'system', language: 'system',
passiveActivityEnabled: false,
passiveActivityExcludedDomains: ['youtube.com'],
}, },
pendingCount: 0, pendingCount: 0,
status: {}, status: {},
@ -112,6 +121,8 @@ async function flush() {
assert.strictEqual(elements['receiver-input'].value, initialState.settings.receiverUrl); assert.strictEqual(elements['receiver-input'].value, initialState.settings.receiverUrl);
assert.strictEqual(elements['receiver-token-input'].value, initialState.settings.receiverToken); assert.strictEqual(elements['receiver-token-input'].value, initialState.settings.receiverToken);
assert.strictEqual(elements['language-select'].value, 'system'); assert.strictEqual(elements['language-select'].value, 'system');
assert.strictEqual(elements['passive-activity-enabled'].checked, false);
assert.strictEqual(elements['passive-activity-exclusions'].value, 'youtube.com');
assert.strictEqual(elements['capture-page'].textContent, 'Отправить страницу'); assert.strictEqual(elements['capture-page'].textContent, 'Отправить страницу');
assert.strictEqual(elements['receiver-state'].textContent, 'Неизвестно'); assert.strictEqual(elements['receiver-state'].textContent, 'Неизвестно');
assert.strictEqual(document.documentElement.lang, 'ru'); assert.strictEqual(document.documentElement.lang, 'ru');
@ -129,9 +140,13 @@ async function flush() {
assert.strictEqual(savedSettings.language, 'en'); assert.strictEqual(savedSettings.language, 'en');
assert.strictEqual(savedSettings.receiverUrl, initialState.settings.receiverUrl); assert.strictEqual(savedSettings.receiverUrl, initialState.settings.receiverUrl);
assert.strictEqual(savedSettings.receiverToken, initialState.settings.receiverToken); assert.strictEqual(savedSettings.receiverToken, initialState.settings.receiverToken);
assert.strictEqual(savedSettings.passiveActivityEnabled, false);
assert.deepStrictEqual(Array.from(savedSettings.passiveActivityExcludedDomains), ['youtube.com']);
elements['receiver-input'].value = 'http://127.0.0.1:47731/api/browser-inbox/v1/captures'; elements['receiver-input'].value = 'http://127.0.0.1:47731/api/browser-inbox/v1/captures';
elements['receiver-token-input'].value = 'new-token'; elements['receiver-token-input'].value = 'new-token';
elements['passive-activity-enabled'].checked = true;
elements['passive-activity-exclusions'].value = 'youtube.com\nx.com';
elements['save-settings'].click(); elements['save-settings'].click();
await flush(); await flush();
@ -139,6 +154,8 @@ async function flush() {
assert.strictEqual(savedSettings.receiverUrl, 'http://127.0.0.1:47731/api/browser-inbox/v1/captures'); assert.strictEqual(savedSettings.receiverUrl, 'http://127.0.0.1:47731/api/browser-inbox/v1/captures');
assert.strictEqual(savedSettings.receiverToken, 'new-token'); assert.strictEqual(savedSettings.receiverToken, 'new-token');
assert.strictEqual(savedSettings.language, 'en'); assert.strictEqual(savedSettings.language, 'en');
assert.strictEqual(savedSettings.passiveActivityEnabled, true);
assert.deepStrictEqual(Array.from(savedSettings.passiveActivityExcludedDomains), ['youtube.com', 'x.com']);
console.log('browser extension popup localization/settings tests passed'); console.log('browser extension popup localization/settings tests passed');
})().catch((error) => { })().catch((error) => {
console.error(error); console.error(error);

View File

@ -52,6 +52,20 @@ const fetchOk = (url, options) => {
return Promise.resolve({ status: 202, json: () => Promise.resolve({ status: 'accepted' }) }); return Promise.resolve({ status: 202, json: () => Promise.resolve({ status: 'accepted' }) });
}; };
const activityBatch = {
schemaVersion: 1,
batchId: 'activity-batch-id',
createdAt: '2026-07-12T10:05:00.000Z',
source: 'verstak-browser-extension',
entries: [{
hostname: 'example.com',
startedAt: '2026-07-12T10:00:00.000Z',
endedAt: '2026-07-12T10:05:00.000Z',
durationSeconds: 300,
url: 'https://must-not-be-sent.example'
}]
};
globalThis.VerstakBrowser.sendCapture('http://127.0.0.1:47731/api/browser-inbox/v1/captures', 'token', page, fetchOk) globalThis.VerstakBrowser.sendCapture('http://127.0.0.1:47731/api/browser-inbox/v1/captures', 'token', page, fetchOk)
.then((result) => { .then((result) => {
assert.equal(result.status, 'accepted'); assert.equal(result.status, 'accepted');
@ -59,6 +73,17 @@ globalThis.VerstakBrowser.sendCapture('http://127.0.0.1:47731/api/browser-inbox/
assert.equal(request.options.headers['X-Verstak-Receiver-Token'], 'token'); assert.equal(request.options.headers['X-Verstak-Receiver-Token'], 'token');
assert.equal(JSON.parse(request.options.body).captureId, 'test-capture-id'); assert.equal(JSON.parse(request.options.body).captureId, 'test-capture-id');
}) })
.then(() => {
return globalThis.VerstakBrowser.sendActivityBatch('http://127.0.0.1:47731/api/browser-inbox/v1/captures', 'token', activityBatch, fetchOk)
.then((result) => {
assert.equal(result.status, 'accepted');
assert.equal(request.url, 'http://127.0.0.1:47731/api/browser-activity/v1/batches');
const body = JSON.parse(request.options.body);
assert.equal(body.batchId, 'activity-batch-id');
assert.equal(body.entries[0].hostname, 'example.com');
assert.equal(Object.prototype.hasOwnProperty.call(body.entries[0], 'url'), false);
});
})
.then(() => { .then(() => {
const queue = new queueApi.CaptureQueue(queueApi.createMemoryStorage()); const queue = new queueApi.CaptureQueue(queueApi.createMemoryStorage());
return queue.enqueue(page) return queue.enqueue(page)

283
shared/activity-tracker.js Normal file
View File

@ -0,0 +1,283 @@
(function (root) {
'use strict';
var ACTIVITY_STATE_KEY = 'verstak.domainActivity.v1';
var MAX_CHECKPOINT_GAP_MS = 10 * 60 * 1000;
var ACK_RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
var MAX_ACKNOWLEDGED_IDS = 1000;
function createMemoryActivityStorage(seed) {
var state = Object.assign({}, seed || {});
return {
get: function (key) {
return Promise.resolve(state[key]);
},
set: function (key, value) {
state[key] = value;
return Promise.resolve();
}
};
}
function browserActivityStorageAdapter(browserApi) {
var storage = browserApi && browserApi.storage && browserApi.storage.local;
if (!storage) return createMemoryActivityStorage();
return {
get: function (key) {
return storage.get(key).then(function (result) { return result && result[key]; });
},
set: function (key, value) {
var patch = {};
patch[key] = value;
return storage.set(patch);
}
};
}
function clone(value) {
return JSON.parse(JSON.stringify(value));
}
function isFiniteNumber(value) {
return typeof value === 'number' && Number.isFinite(value);
}
function initialState() {
return {
activeAccumulator: {},
pendingBatches: [],
acknowledgedIds: []
};
}
function normalizedState(value) {
var state = value && typeof value === 'object' ? value : {};
return {
activeAccumulator: state.activeAccumulator && typeof state.activeAccumulator === 'object' ? state.activeAccumulator : {},
pendingBatches: Array.isArray(state.pendingBatches) ? state.pendingBatches : [],
acknowledgedIds: Array.isArray(state.acknowledgedIds) ? state.acknowledgedIds : []
};
}
function batchId(now) {
var cryptoObj = root.crypto;
if (cryptoObj && typeof cryptoObj.randomUUID === 'function') return cryptoObj.randomUUID();
return 'activity-' + String(now) + '-' + Math.random().toString(36).slice(2);
}
function toISOString(time) {
return new Date(time).toISOString();
}
function isExcludedHostname(hostname, excludedDomains) {
var normalizer = root.VerstakBrowser && root.VerstakBrowser.normalizeHostnameV1;
var canonicalHostname = typeof normalizer === 'function' ? normalizer(hostname) : '';
if (!canonicalHostname) return true;
return (Array.isArray(excludedDomains) ? excludedDomains : []).some(function (value) {
var excluded = typeof normalizer === 'function' ? normalizer(value) : '';
return excluded && (canonicalHostname === excluded || canonicalHostname.slice(-(excluded.length + 1)) === '.' + excluded);
});
}
function DomainActivityTracker(storage, sender, options) {
options = options || {};
this.storage = storage || createMemoryActivityStorage();
this.sender = typeof sender === 'function' ? sender : function () { return Promise.reject(new Error('activity sender unavailable')); };
this.maxCheckpointGapMs = isFiniteNumber(options.maxCheckpointGapMs) ? options.maxCheckpointGapMs : MAX_CHECKPOINT_GAP_MS;
this.now = typeof options.now === 'function' ? options.now : function () { return Date.now(); };
this.state = initialState();
this.enabled = false;
this.activeHostname = '';
this.serial = Promise.resolve();
}
DomainActivityTracker.prototype.enqueue = function (operation) {
var next = this.serial.then(operation, operation);
this.serial = next.catch(function () {});
return next;
};
DomainActivityTracker.prototype.initialize = function () {
var self = this;
return this.enqueue(function () {
return self.storage.get(ACTIVITY_STATE_KEY).then(function (value) {
self.state = normalizedState(value);
self.pruneAcknowledged(self.now());
return self.persist().then(function () { return self.getStateUnsafe(); });
});
});
};
DomainActivityTracker.prototype.getState = function () {
var self = this;
return this.enqueue(function () { return self.getStateUnsafe(); });
};
DomainActivityTracker.prototype.getStateUnsafe = function () {
return clone(this.state);
};
DomainActivityTracker.prototype.persist = function () {
return this.storage.set(ACTIVITY_STATE_KEY, clone(this.state));
};
DomainActivityTracker.prototype.setEnabled = function (enabled) {
var self = this;
return this.enqueue(function () {
self.enabled = enabled === true;
if (!self.enabled) {
self.activeHostname = '';
self.state.activeAccumulator = {};
self.state.pendingBatches = [];
}
return self.persist().then(function () { return self.getStateUnsafe(); });
});
};
DomainActivityTracker.prototype.setActiveHostname = function (hostname, active, observedAt) {
var self = this;
var now = isFiniteNumber(observedAt) ? observedAt : this.now();
return this.enqueue(function () {
if (!self.enabled || !active || !hostname) {
self.checkpointUnsafe(now);
self.activeHostname = '';
return self.persist().then(function () { return self.getStateUnsafe(); });
}
self.checkpointUnsafe(now);
self.activeHostname = hostname;
self.ensureAccumulator(hostname, now);
return self.persist().then(function () { return self.getStateUnsafe(); });
});
};
DomainActivityTracker.prototype.checkpoint = function (observedAt) {
var self = this;
var now = isFiniteNumber(observedAt) ? observedAt : this.now();
return this.enqueue(function () {
self.checkpointUnsafe(now);
return self.persist().then(function () { return self.getStateUnsafe(); });
});
};
DomainActivityTracker.prototype.ensureAccumulator = function (hostname, now) {
if (!this.state.activeAccumulator[hostname]) {
this.state.activeAccumulator[hostname] = {
hostname: hostname,
startedAt: now,
lastCheckpointAt: now,
durationMs: 0
};
}
return this.state.activeAccumulator[hostname];
};
DomainActivityTracker.prototype.checkpointUnsafe = function (now) {
if (!this.enabled || !this.activeHostname) return;
var accumulator = this.ensureAccumulator(this.activeHostname, now);
var previous = accumulator.lastCheckpointAt;
var delta = now - previous;
if (!isFiniteNumber(previous) || delta < 0 || delta > this.maxCheckpointGapMs) {
accumulator.startedAt = now;
accumulator.lastCheckpointAt = now;
accumulator.durationMs = 0;
return;
}
if (delta === 0) return;
accumulator.durationMs += delta;
accumulator.lastCheckpointAt = now;
};
DomainActivityTracker.prototype.flush = function (observedAt) {
var self = this;
var now = isFiniteNumber(observedAt) ? observedAt : this.now();
return this.enqueue(function () {
if (!self.enabled) return self.getStateUnsafe();
self.checkpointUnsafe(now);
var entries = [];
Object.keys(self.state.activeAccumulator).forEach(function (hostname) {
var accumulator = self.state.activeAccumulator[hostname];
var durationSeconds = Math.floor(Number(accumulator.durationMs || 0) / 1000);
if (durationSeconds < 1) return;
entries.push({
hostname: accumulator.hostname,
startedAt: toISOString(accumulator.startedAt),
endedAt: toISOString(accumulator.lastCheckpointAt),
durationSeconds: durationSeconds
});
});
if (entries.length) {
self.state.pendingBatches.push({
schemaVersion: 1,
batchId: batchId(now),
createdAt: toISOString(now),
source: 'verstak-browser-extension',
entries: entries
});
self.state.activeAccumulator = {};
if (self.activeHostname) self.ensureAccumulator(self.activeHostname, now);
}
return self.persist().then(function () {
return self.deliverPendingUnsafe(now);
});
});
};
DomainActivityTracker.prototype.retryPending = function () {
var self = this;
return this.enqueue(function () {
if (!self.enabled) return self.getStateUnsafe();
return self.deliverPendingUnsafe(self.now());
});
};
DomainActivityTracker.prototype.deliverPendingUnsafe = function (now) {
var self = this;
var pending = this.state.pendingBatches.slice();
return pending.reduce(function (chain, batch) {
return chain.then(function () {
if (!self.hasPendingBatch(batch.batchId)) return undefined;
var immutableBatch = clone(batch);
return self.sender(immutableBatch).then(function (result) {
if (result && result.batchId && result.batchId !== batch.batchId) {
throw new Error('receiver acknowledged a different activity batch');
}
self.state.pendingBatches = self.state.pendingBatches.filter(function (item) { return item.batchId !== batch.batchId; });
self.state.acknowledgedIds.push({ id: batch.batchId, acknowledgedAt: toISOString(now) });
self.pruneAcknowledged(now);
return self.persist();
});
});
}, Promise.resolve()).then(function () {
return self.getStateUnsafe();
}).catch(function () {
return self.persist().then(function () { return self.getStateUnsafe(); });
});
};
DomainActivityTracker.prototype.hasPendingBatch = function (batchID) {
return this.state.pendingBatches.some(function (batch) { return batch.batchId === batchID; });
};
DomainActivityTracker.prototype.pruneAcknowledged = function (now) {
var cutoff = now - ACK_RETENTION_MS;
var seen = {};
this.state.acknowledgedIds = this.state.acknowledgedIds.filter(function (entry) {
var timestamp = Date.parse(entry && entry.acknowledgedAt);
if (!entry || !entry.id || !Number.isFinite(timestamp) || timestamp < cutoff || seen[entry.id]) return false;
seen[entry.id] = true;
return true;
}).slice(-MAX_ACKNOWLEDGED_IDS);
};
var api = {
ACTIVITY_STATE_KEY: ACTIVITY_STATE_KEY,
MAX_CHECKPOINT_GAP_MS: MAX_CHECKPOINT_GAP_MS,
createMemoryActivityStorage: createMemoryActivityStorage,
browserActivityStorageAdapter: browserActivityStorageAdapter,
DomainActivityTracker: DomainActivityTracker,
isExcludedHostname: isExcludedHostname
};
root.VerstakBrowser = Object.assign(root.VerstakBrowser || {}, api);
if (typeof module !== 'undefined') module.exports = api;
})(typeof globalThis !== 'undefined' ? globalThis : this);

View File

@ -20,7 +20,63 @@
}); });
} }
var api = { sendCapture: sendCapture }; function activityReceiverURL(captureReceiverURL) {
var protocol = root.VerstakBrowser || {};
var receiverURL = String(captureReceiverURL || protocol.DEFAULT_RECEIVER_URL || '').trim();
var capturePath = '/api/browser-inbox/v1/captures';
if (receiverURL.slice(-capturePath.length) === capturePath) {
return receiverURL.slice(0, -capturePath.length) + '/api/browser-activity/v1/batches';
}
return protocol.DEFAULT_ACTIVITY_URL;
}
function safeActivityBatch(payload) {
if (!payload || payload.schemaVersion !== 1 || !payload.batchId || !payload.createdAt || !payload.source || !Array.isArray(payload.entries) || payload.entries.length === 0) {
throw new Error('invalid activity batch');
}
var entries = payload.entries.map(function (entry) {
if (!entry || !entry.hostname || !entry.startedAt || !entry.endedAt || !Number.isFinite(Number(entry.durationSeconds)) || Number(entry.durationSeconds) <= 0) {
throw new Error('invalid activity batch entry');
}
return {
hostname: String(entry.hostname),
startedAt: String(entry.startedAt),
endedAt: String(entry.endedAt),
durationSeconds: Math.floor(Number(entry.durationSeconds))
};
});
return {
schemaVersion: 1,
batchId: String(payload.batchId),
createdAt: String(payload.createdAt),
source: String(payload.source),
entries: entries
};
}
function sendActivityBatch(captureReceiverURL, token, payload, fetchImpl) {
var batch = safeActivityBatch(payload);
var fetchFn = fetchImpl || root.fetch;
if (typeof fetchFn !== 'function') return Promise.reject(new Error('fetch unavailable'));
return fetchFn(activityReceiverURL(captureReceiverURL), {
method: 'POST',
headers: Object.assign({
'Content-Type': 'application/json'
}, token ? { 'X-Verstak-Receiver-Token': token } : {}),
body: JSON.stringify(batch)
}).then(function (response) {
if (!response || response.status < 200 || response.status >= 300) {
throw new Error('receiver rejected activity batch: HTTP ' + (response && response.status));
}
return response.json ? response.json() : { status: 'accepted', batchId: batch.batchId };
});
}
var api = {
sendCapture: sendCapture,
sendActivityBatch: sendActivityBatch,
activityReceiverURL: activityReceiverURL
};
root.VerstakBrowser = Object.assign(root.VerstakBrowser || {}, api); root.VerstakBrowser = Object.assign(root.VerstakBrowser || {}, api);
if (typeof module !== 'undefined') module.exports = api; if (typeof module !== 'undefined') module.exports = api;
})(typeof globalThis !== 'undefined' ? globalThis : this); })(typeof globalThis !== 'undefined' ? globalThis : this);

View File

@ -4,19 +4,34 @@
var ext = typeof browser !== 'undefined' ? browser : chrome; var ext = typeof browser !== 'undefined' ? browser : chrome;
var protocol = globalThis.VerstakBrowser; var protocol = globalThis.VerstakBrowser;
var queue = new protocol.CaptureQueue(protocol.browserStorageAdapter(ext)); var queue = new protocol.CaptureQueue(protocol.browserStorageAdapter(ext));
var activityTracker = new protocol.DomainActivityTracker(
protocol.browserActivityStorageAdapter(ext),
function (batch) {
return getSettings().then(function (settings) {
return protocol.sendActivityBatch(settings.receiverUrl, settings.receiverToken, batch);
});
}
);
var i18n = globalThis.VerstakBrowserI18n; var i18n = globalThis.VerstakBrowserI18n;
var DEFAULT_SETTINGS = { var DEFAULT_SETTINGS = {
receiverUrl: protocol.DEFAULT_RECEIVER_URL, receiverUrl: protocol.DEFAULT_RECEIVER_URL,
receiverToken: '', receiverToken: '',
language: 'system' language: 'system',
passiveActivityEnabled: false,
passiveActivityExcludedDomains: []
}; };
var STATUS_KEY = 'verstak.status'; var STATUS_KEY = 'verstak.status';
var ACTIVITY_FLUSH_ALARM = 'verstak-domain-activity-flush';
var localeCatalogs = null; var localeCatalogs = null;
var focusedWindowID = null;
var activeTabID = null;
function getSettings() { function getSettings() {
return ext.storage.local.get('settings').then(function (result) { return ext.storage.local.get('settings').then(function (result) {
var settings = Object.assign({}, DEFAULT_SETTINGS, result && result.settings || {}); var settings = Object.assign({}, DEFAULT_SETTINGS, result && result.settings || {});
settings.language = i18n.normalizePreference(settings.language); settings.language = i18n.normalizePreference(settings.language);
settings.passiveActivityEnabled = settings.passiveActivityEnabled === true;
settings.passiveActivityExcludedDomains = normalizeExcludedDomains(settings.passiveActivityExcludedDomains);
return settings; return settings;
}); });
} }
@ -24,9 +39,23 @@
function saveSettings(settings) { function saveSettings(settings) {
settings = Object.assign({}, DEFAULT_SETTINGS, settings || {}); settings = Object.assign({}, DEFAULT_SETTINGS, settings || {});
settings.language = i18n.normalizePreference(settings.language); settings.language = i18n.normalizePreference(settings.language);
settings.passiveActivityEnabled = settings.passiveActivityEnabled === true;
settings.passiveActivityExcludedDomains = normalizeExcludedDomains(settings.passiveActivityExcludedDomains);
return ext.storage.local.set({ settings: settings }); return ext.storage.local.set({ settings: settings });
} }
function normalizeExcludedDomains(value) {
var values = Array.isArray(value) ? value : String(value || '').split(/[\n,]/);
var seen = {};
return values.map(function (item) {
return protocol.normalizeHostnameV1(String(item || '').trim());
}).filter(function (item) {
if (!item || seen[item]) return false;
seen[item] = true;
return true;
});
}
function loadLocaleCatalogs() { function loadLocaleCatalogs() {
if (localeCatalogs) return localeCatalogs; if (localeCatalogs) return localeCatalogs;
localeCatalogs = i18n.loadCatalogs(function (locale) { localeCatalogs = i18n.loadCatalogs(function (locale) {
@ -68,6 +97,58 @@
}); });
} }
function isFocusedWindow(windowID) {
return focusedWindowID == null || focusedWindowID === windowID;
}
function trackTab(tab, settings) {
if (!settings.passiveActivityEnabled || !tab || !isFocusedWindow(tab.windowId)) {
return activityTracker.setActiveHostname('', false);
}
activeTabID = tab.id == null ? activeTabID : tab.id;
var hostname = protocol.normalizeURLHostnameV1(tab.url || '');
if (!hostname || protocol.isExcludedHostname(hostname, settings.passiveActivityExcludedDomains)) {
return activityTracker.setActiveHostname('', false);
}
return activityTracker.setActiveHostname(hostname, true);
}
function refreshFocusedTab(settings) {
return activeTab().then(function (tab) {
return trackTab(tab, settings);
}).catch(function () {
return activityTracker.setActiveHostname('', false);
});
}
function configurePassiveActivity(settings) {
return activityTracker.setEnabled(settings.passiveActivityEnabled).then(function () {
if (!settings.passiveActivityEnabled) return undefined;
return refreshFocusedTab(settings);
});
}
function flushPassiveActivity() {
return getSettings().then(function (settings) {
if (!settings.passiveActivityEnabled) return activityTracker.setEnabled(false);
return activityTracker.setEnabled(true).then(function () {
return refreshFocusedTab(settings);
}).then(function () {
return activityTracker.flush();
});
});
}
function setupPassiveActivity() {
if (ext.alarms && ext.alarms.create) {
ext.alarms.create(ACTIVITY_FLUSH_ALARM, { periodInMinutes: 5 });
}
if (ext.idle && ext.idle.setDetectionInterval) {
ext.idle.setDetectionInterval(60);
}
return getSettings().then(configurePassiveActivity);
}
function captureFromInfo(kind, info, tab) { function captureFromInfo(kind, info, tab) {
return protocol.buildCapture({ return protocol.buildCapture({
kind: kind, kind: kind,
@ -123,11 +204,14 @@
return Promise.all([ return Promise.all([
getSettings(), getSettings(),
queue.list(), queue.list(),
ext.storage.local.get(STATUS_KEY) ext.storage.local.get(STATUS_KEY),
activityTracker.getState()
]).then(function (results) { ]).then(function (results) {
return { return {
settings: results[0], settings: results[0],
pendingCount: results[1].length, pendingCount: results[1].length,
pendingActivityCount: results[3].pendingBatches.length,
activityState: results[3],
status: results[2] && results[2][STATUS_KEY] || {} status: results[2] && results[2][STATUS_KEY] || {}
}; };
}); });
@ -170,19 +254,24 @@
} }
function handleMessage(message) { function handleMessage(message) {
if (!message || message.type !== 'verstak.capture') return Promise.resolve(undefined); return ready.then(function () {
if (!message || message.type !== 'verstak.capture') return undefined;
if (message.action === 'getState') return getState(); if (message.action === 'getState') return getState();
if (message.action === 'saveSettings') { if (message.action === 'saveSettings') {
return saveSettings(message.settings).then(function () { return saveSettings(message.settings).then(function () {
return setupContextMenus(); return setupContextMenus();
}).then(function () {
return getSettings().then(configurePassiveActivity);
}).then(function () { }).then(function () {
return setStatus({ receiverReachable: null, lastResult: 'settings-saved', lastError: '' }); return setStatus({ receiverReachable: null, lastResult: 'settings-saved', lastError: '' });
}).then(getState); }).then(getState);
} }
if (message.action === 'retryPending') return retryPending().then(getState); if (message.action === 'retryPending') return retryPending().then(getState);
if (message.action === 'retryPendingActivity') return activityTracker.retryPending().then(getState);
return activeTab().then(function (tab) { return activeTab().then(function (tab) {
return sendOrQueue(captureFromInfo(message.kind || 'page', message, tab)); return sendOrQueue(captureFromInfo(message.kind || 'page', message, tab));
}).then(getState); }).then(getState);
});
} }
ext.runtime.onMessage.addListener(function (message, sender, sendResponse) { ext.runtime.onMessage.addListener(function (message, sender, sendResponse) {
@ -191,4 +280,63 @@
}); });
return true; return true;
}); });
if (ext.tabs && ext.tabs.onActivated) {
ext.tabs.onActivated.addListener(function (info) {
if (!isFocusedWindow(info.windowId) || !ext.tabs.get) return;
activeTabID = info.tabId;
ready.then(function () { return getSettings(); }).then(function (settings) {
return ext.tabs.get(info.tabId).then(function (tab) { return trackTab(tab, settings); });
}).catch(function () {});
});
}
if (ext.tabs && ext.tabs.onUpdated) {
ext.tabs.onUpdated.addListener(function (tabID, changeInfo, tab) {
if (tabID !== activeTabID || (!changeInfo.url && !(tab && tab.url))) return;
ready.then(function () { return getSettings(); }).then(function (settings) {
return trackTab(tab || { id: tabID, url: changeInfo.url }, settings);
}).catch(function () {});
});
}
if (ext.windows && ext.windows.onFocusChanged) {
ext.windows.onFocusChanged.addListener(function (windowID) {
focusedWindowID = windowID;
if (windowID === (ext.windows.WINDOW_ID_NONE == null ? -1 : ext.windows.WINDOW_ID_NONE)) {
activityTracker.setActiveHostname('', false).catch(function () {});
return;
}
ready.then(function () { return getSettings(); }).then(refreshFocusedTab).catch(function () {});
});
}
if (ext.idle && ext.idle.onStateChanged) {
ext.idle.onStateChanged.addListener(function (state) {
if (state === 'active') {
ready.then(function () { return getSettings(); }).then(refreshFocusedTab).catch(function () {});
return;
}
activityTracker.setActiveHostname('', false).catch(function () {});
});
}
if (ext.alarms && ext.alarms.onAlarm) {
ext.alarms.onAlarm.addListener(function (alarm) {
if (alarm && alarm.name === ACTIVITY_FLUSH_ALARM) flushPassiveActivity().catch(function () {});
});
}
if (ext.tabs && ext.tabs.onRemoved) {
ext.tabs.onRemoved.addListener(function (tabID) {
if (tabID === activeTabID) {
activeTabID = null;
activityTracker.setActiveHostname('', false).catch(function () {});
}
});
}
var ready = activityTracker.initialize().then(setupPassiveActivity).catch(function (error) {
console.warn('[verstak] passive activity initialization failed:', error);
});
})(); })();

View File

@ -2,16 +2,20 @@
"popup.subtitle": "Browser Inbox", "popup.subtitle": "Browser Inbox",
"label.receiver": "Receiver", "label.receiver": "Receiver",
"label.pending": "Pending", "label.pending": "Pending",
"label.activityPending": "Activity batches",
"label.url": "URL", "label.url": "URL",
"label.file": "File", "label.file": "File",
"label.receiverUrl": "Receiver URL", "label.receiverUrl": "Receiver URL",
"label.pairingToken": "Pairing token", "label.pairingToken": "Pairing token",
"label.language": "Language", "label.language": "Language",
"label.passiveActivity": "Track active domain time",
"label.passiveActivityExclusions": "Excluded domains",
"action.sendPage": "Send Page", "action.sendPage": "Send Page",
"action.sendFile": "Send File", "action.sendFile": "Send File",
"action.retryPending": "Retry Pending", "action.retryPending": "Retry Pending",
"action.save": "Save", "action.save": "Save",
"hint.contextMenu": "Selection and link captures are available from the page context menu.", "hint.contextMenu": "Selection and link captures are available from the page context menu.",
"hint.passiveActivityDisclosure": "Optional. Collects only the active domain and time spent there. It never sends page URLs, titles, text, keystrokes, or page contents.",
"receiver.online": "Online", "receiver.online": "Online",
"receiver.offline": "Offline", "receiver.offline": "Offline",
"receiver.unknown": "Unknown", "receiver.unknown": "Unknown",

View File

@ -2,16 +2,20 @@
"popup.subtitle": "Входящие из браузера", "popup.subtitle": "Входящие из браузера",
"label.receiver": "Приёмник", "label.receiver": "Приёмник",
"label.pending": "В очереди", "label.pending": "В очереди",
"label.activityPending": "Пакеты активности",
"label.url": "URL", "label.url": "URL",
"label.file": "Файл", "label.file": "Файл",
"label.receiverUrl": "URL приёмника", "label.receiverUrl": "URL приёмника",
"label.pairingToken": "Токен сопряжения", "label.pairingToken": "Токен сопряжения",
"label.language": "Язык", "label.language": "Язык",
"label.passiveActivity": "Учитывать время на активном домене",
"label.passiveActivityExclusions": "Исключённые домены",
"action.sendPage": "Отправить страницу", "action.sendPage": "Отправить страницу",
"action.sendFile": "Отправить файл", "action.sendFile": "Отправить файл",
"action.retryPending": "Повторить отправку", "action.retryPending": "Повторить отправку",
"action.save": "Сохранить", "action.save": "Сохранить",
"hint.contextMenu": "Выделение и ссылки можно отправить через контекстное меню страницы.", "hint.contextMenu": "Выделение и ссылки можно отправить через контекстное меню страницы.",
"hint.passiveActivityDisclosure": "Необязательно. Собираются только активный домен и время на нём. URL страниц, заголовки, текст, нажатия клавиш и содержимое страниц не отправляются.",
"receiver.online": "Доступен", "receiver.online": "Доступен",
"receiver.offline": "Недоступен", "receiver.offline": "Недоступен",
"receiver.unknown": "Неизвестно", "receiver.unknown": "Неизвестно",

View File

@ -126,6 +126,19 @@ input {
color: #e5e7eb; color: #e5e7eb;
} }
textarea {
box-sizing: border-box;
width: 100%;
margin-top: 5px;
border: 1px solid #374151;
border-radius: 4px;
padding: 7px 8px;
resize: vertical;
background: #0f172a;
color: #e5e7eb;
font: inherit;
}
select { select {
box-sizing: border-box; box-sizing: border-box;
width: 100%; width: 100%;
@ -142,6 +155,23 @@ input[type="file"] {
font-size: 12px; font-size: 12px;
} }
.tracking-toggle {
display: flex;
align-items: center;
gap: 7px;
color: #e8ecf3 !important;
cursor: pointer;
}
.tracking-toggle input {
width: auto;
margin: 0;
}
.tracking-settings .hint {
margin: 8px 0 4px;
}
.hint { .hint {
margin: 0; margin: 0;
font-size: 12px; font-size: 12px;

View File

@ -24,6 +24,10 @@
<span id="pending-label">Pending</span> <span id="pending-label">Pending</span>
<strong id="pending-count">0</strong> <strong id="pending-count">0</strong>
</div> </div>
<div class="row">
<span id="activity-pending-label">Activity batches</span>
<strong id="activity-pending-count">0</strong>
</div>
<div class="row url-row"> <div class="row url-row">
<span id="url-label">URL</span> <span id="url-label">URL</span>
<code id="receiver-url"></code> <code id="receiver-url"></code>
@ -55,6 +59,16 @@
<button id="save-settings" class="secondary">Save</button> <button id="save-settings" class="secondary">Save</button>
</section> </section>
<section class="settings tracking-settings">
<label class="tracking-toggle" for="passive-activity-enabled">
<input id="passive-activity-enabled" type="checkbox">
<span id="passive-activity-label">Track active domain time</span>
</label>
<p id="passive-activity-disclosure" class="hint">Optional. Collects only the active domain and time spent there. It never sends page URLs, titles, text, keystrokes, or page contents.</p>
<label id="passive-activity-exclusions-label" for="passive-activity-exclusions">Excluded domains</label>
<textarea id="passive-activity-exclusions" rows="3" spellcheck="false" placeholder="youtube.com"></textarea>
</section>
<p id="context-menu-hint" class="hint">Selection and link captures are available from the page context menu.</p> <p id="context-menu-hint" class="hint">Selection and link captures are available from the page context menu.</p>
<p id="status"></p> <p id="status"></p>
</main> </main>

View File

@ -11,7 +11,10 @@
var languageSelectEl = document.getElementById('language-select'); var languageSelectEl = document.getElementById('language-select');
var fileInputEl = document.getElementById('file-input'); var fileInputEl = document.getElementById('file-input');
var pendingCountEl = document.getElementById('pending-count'); var pendingCountEl = document.getElementById('pending-count');
var pendingActivityCountEl = document.getElementById('activity-pending-count');
var statusDotEl = document.getElementById('status-dot'); var statusDotEl = document.getElementById('status-dot');
var passiveActivityEnabledEl = document.getElementById('passive-activity-enabled');
var passiveActivityExclusionsEl = document.getElementById('passive-activity-exclusions');
var MAX_FILE_TEXT_LENGTH = 2 * 1024 * 1024; var MAX_FILE_TEXT_LENGTH = 2 * 1024 * 1024;
var MAX_FILE_BYTES = 8 * 1024 * 1024; var MAX_FILE_BYTES = 8 * 1024 * 1024;
var catalogs = { en: {}, ru: {} }; var catalogs = { en: {}, ru: {} };
@ -23,6 +26,7 @@
subtitle: 'popup.subtitle', subtitle: 'popup.subtitle',
'receiver-label': 'label.receiver', 'receiver-label': 'label.receiver',
'pending-label': 'label.pending', 'pending-label': 'label.pending',
'activity-pending-label': 'label.activityPending',
'url-label': 'label.url', 'url-label': 'label.url',
'file-label': 'label.file', 'file-label': 'label.file',
'receiver-url-label': 'label.receiverUrl', 'receiver-url-label': 'label.receiverUrl',
@ -35,7 +39,10 @@
'context-menu-hint': 'hint.contextMenu', 'context-menu-hint': 'hint.contextMenu',
'language-system-option': 'language.system', 'language-system-option': 'language.system',
'language-en-option': 'language.en', 'language-en-option': 'language.en',
'language-ru-option': 'language.ru' 'language-ru-option': 'language.ru',
'passive-activity-label': 'label.passiveActivity',
'passive-activity-disclosure': 'hint.passiveActivityDisclosure',
'passive-activity-exclusions-label': 'label.passiveActivityExclusions'
}; };
function browserLocale() { function browserLocale() {
@ -122,9 +129,16 @@
currentState = state || {}; currentState = state || {};
var settings = currentState.settings || {}; var settings = currentState.settings || {};
pendingCountEl.textContent = String(currentState.pendingCount || 0); pendingCountEl.textContent = String(currentState.pendingCount || 0);
pendingActivityCountEl.textContent = String(currentState.pendingActivityCount || 0);
receiverUrlEl.textContent = settings.receiverUrl || ''; receiverUrlEl.textContent = settings.receiverUrl || '';
if (document.activeElement !== receiverInputEl) receiverInputEl.value = settings.receiverUrl || ''; if (document.activeElement !== receiverInputEl) receiverInputEl.value = settings.receiverUrl || '';
if (document.activeElement !== receiverTokenInputEl) receiverTokenInputEl.value = settings.receiverToken || ''; if (document.activeElement !== receiverTokenInputEl) receiverTokenInputEl.value = settings.receiverToken || '';
passiveActivityEnabledEl.checked = settings.passiveActivityEnabled === true;
if (document.activeElement !== passiveActivityExclusionsEl) {
passiveActivityExclusionsEl.value = Array.isArray(settings.passiveActivityExcludedDomains)
? settings.passiveActivityExcludedDomains.join('\n')
: '';
}
applyReceiverState(currentState); applyReceiverState(currentState);
} }
@ -142,7 +156,11 @@
return { return {
receiverUrl: receiverInputEl.value.trim(), receiverUrl: receiverInputEl.value.trim(),
receiverToken: receiverTokenInputEl.value.trim(), receiverToken: receiverTokenInputEl.value.trim(),
language: currentPreference language: currentPreference,
passiveActivityEnabled: passiveActivityEnabledEl.checked === true,
passiveActivityExcludedDomains: passiveActivityExclusionsEl.value.split(/[\n,]/).map(function (value) {
return value.trim();
}).filter(Boolean)
}; };
} }

View File

@ -3,6 +3,7 @@
var CAPTURE_SCHEMA_VERSION = 1; var CAPTURE_SCHEMA_VERSION = 1;
var DEFAULT_RECEIVER_URL = 'http://127.0.0.1:47731/api/browser-inbox/v1/captures'; var DEFAULT_RECEIVER_URL = 'http://127.0.0.1:47731/api/browser-inbox/v1/captures';
var DEFAULT_ACTIVITY_URL = 'http://127.0.0.1:47731/api/browser-activity/v1/batches';
var MAX_FILE_TEXT_LENGTH = 2 * 1024 * 1024; var MAX_FILE_TEXT_LENGTH = 2 * 1024 * 1024;
var MAX_FILE_BYTES = 8 * 1024 * 1024; var MAX_FILE_BYTES = 8 * 1024 * 1024;
@ -98,6 +99,7 @@
var api = { var api = {
CAPTURE_SCHEMA_VERSION: CAPTURE_SCHEMA_VERSION, CAPTURE_SCHEMA_VERSION: CAPTURE_SCHEMA_VERSION,
DEFAULT_RECEIVER_URL: DEFAULT_RECEIVER_URL, DEFAULT_RECEIVER_URL: DEFAULT_RECEIVER_URL,
DEFAULT_ACTIVITY_URL: DEFAULT_ACTIVITY_URL,
MAX_FILE_TEXT_LENGTH: MAX_FILE_TEXT_LENGTH, MAX_FILE_TEXT_LENGTH: MAX_FILE_TEXT_LENGTH,
MAX_FILE_BYTES: MAX_FILE_BYTES, MAX_FILE_BYTES: MAX_FILE_BYTES,
buildCapture: buildCapture, buildCapture: buildCapture,