diff --git a/chromium/manifest.json b/chromium/manifest.json index d2c1949..ddbf931 100644 --- a/chromium/manifest.json +++ b/chromium/manifest.json @@ -5,7 +5,7 @@ "description": "Send pages, selections, links, and files to the local Verstak browser inbox.", "author": "Verstak", "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/*"], "background": { "service_worker": "background.js" diff --git a/firefox/manifest.json b/firefox/manifest.json index 0ef91fc..cd47e23 100644 --- a/firefox/manifest.json +++ b/firefox/manifest.json @@ -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": { - "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": { "default_popup": "popup/popup.html", diff --git a/package.json b/package.json index 66ff076..2474a85 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "description": "Verstak browser capture extension for Chromium and Firefox", "scripts": { "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", "release:firefox": "./scripts/release-firefox-xpi.sh" }, diff --git a/scripts/build-extension.js b/scripts/build-extension.js index 11b73bb..87bf596 100755 --- a/scripts/build-extension.js +++ b/scripts/build-extension.js @@ -52,6 +52,7 @@ mkdir(chromiumDist); copy(path.join(root, 'chromium', 'manifest.json'), path.join(chromiumDist, 'manifest.json')); concat([ path.join(shared, 'hostname.js'), + path.join(shared, 'activity-tracker.js'), path.join(shared, 'protocol.js'), path.join(shared, 'api.js'), path.join(shared, 'queue.js'), @@ -65,7 +66,7 @@ copyLocalization(chromiumDist); const firefoxDist = path.join(dist, 'firefox'); mkdir(firefoxDist); 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)); } copyPopup(firefoxDist); diff --git a/scripts/test-activity-tracker.js b/scripts/test-activity-tracker.js new file mode 100644 index 0000000..7579586 --- /dev/null +++ b/scripts/test-activity-tracker.js @@ -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); +}); diff --git a/scripts/test-background-i18n.js b/scripts/test-background-i18n.js index e37f277..0fa2b01 100644 --- a/scripts/test-background-i18n.js +++ b/scripts/test-background-i18n.js @@ -49,7 +49,13 @@ const browser = { onMessage: { addListener(listener) { messageListener = listener; } }, }, 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) { @@ -69,7 +75,7 @@ const context = vm.createContext({ clearTimeout, }); 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 }); } @@ -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({ type: 'verstak.capture', action: 'saveSettings', diff --git a/scripts/test-popup-settings.js b/scripts/test-popup-settings.js index 062f4ad..57f034f 100644 --- a/scripts/test-popup-settings.js +++ b/scripts/test-popup-settings.js @@ -35,10 +35,12 @@ const elements = {}; 'receiver-token-input', 'file-input', 'pending-count', + 'activity-pending-count', 'status-dot', 'subtitle', 'receiver-label', 'pending-label', + 'activity-pending-label', 'url-label', 'capture-page', 'capture-file', @@ -53,6 +55,11 @@ const elements = {}; 'language-ru-option', 'save-settings', 'context-menu-hint', + 'passive-activity-enabled', + 'passive-activity-label', + 'passive-activity-disclosure', + 'passive-activity-exclusions-label', + 'passive-activity-exclusions', ].forEach((id) => { elements[id] = new Element(); }); @@ -63,6 +70,8 @@ const initialState = { receiverUrl: 'http://127.0.0.1:47731/api/browser-inbox/v1/captures', receiverToken: 'persisted-token', language: 'system', + passiveActivityEnabled: false, + passiveActivityExcludedDomains: ['youtube.com'], }, pendingCount: 0, status: {}, @@ -112,6 +121,8 @@ async function flush() { assert.strictEqual(elements['receiver-input'].value, initialState.settings.receiverUrl); assert.strictEqual(elements['receiver-token-input'].value, initialState.settings.receiverToken); 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['receiver-state'].textContent, 'Неизвестно'); assert.strictEqual(document.documentElement.lang, 'ru'); @@ -129,9 +140,13 @@ async function flush() { assert.strictEqual(savedSettings.language, 'en'); assert.strictEqual(savedSettings.receiverUrl, initialState.settings.receiverUrl); 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-token-input'].value = 'new-token'; + elements['passive-activity-enabled'].checked = true; + elements['passive-activity-exclusions'].value = 'youtube.com\nx.com'; elements['save-settings'].click(); 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.receiverToken, 'new-token'); 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'); })().catch((error) => { console.error(error); diff --git a/scripts/test-protocol.js b/scripts/test-protocol.js index 5f78042..9df6c51 100755 --- a/scripts/test-protocol.js +++ b/scripts/test-protocol.js @@ -52,6 +52,20 @@ const fetchOk = (url, options) => { 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) .then((result) => { 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(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(() => { const queue = new queueApi.CaptureQueue(queueApi.createMemoryStorage()); return queue.enqueue(page) diff --git a/shared/activity-tracker.js b/shared/activity-tracker.js new file mode 100644 index 0000000..ac51aa1 --- /dev/null +++ b/shared/activity-tracker.js @@ -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); diff --git a/shared/api.js b/shared/api.js index 54c53dc..6ac2bb8 100644 --- a/shared/api.js +++ b/shared/api.js @@ -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); if (typeof module !== 'undefined') module.exports = api; })(typeof globalThis !== 'undefined' ? globalThis : this); diff --git a/shared/background.js b/shared/background.js index dfdbf41..32f3028 100644 --- a/shared/background.js +++ b/shared/background.js @@ -4,19 +4,34 @@ var ext = typeof browser !== 'undefined' ? browser : chrome; var protocol = globalThis.VerstakBrowser; 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 DEFAULT_SETTINGS = { receiverUrl: protocol.DEFAULT_RECEIVER_URL, receiverToken: '', - language: 'system' + language: 'system', + passiveActivityEnabled: false, + passiveActivityExcludedDomains: [] }; var STATUS_KEY = 'verstak.status'; + var ACTIVITY_FLUSH_ALARM = 'verstak-domain-activity-flush'; var localeCatalogs = null; + var focusedWindowID = null; + var activeTabID = null; function getSettings() { return ext.storage.local.get('settings').then(function (result) { var settings = Object.assign({}, DEFAULT_SETTINGS, result && result.settings || {}); settings.language = i18n.normalizePreference(settings.language); + settings.passiveActivityEnabled = settings.passiveActivityEnabled === true; + settings.passiveActivityExcludedDomains = normalizeExcludedDomains(settings.passiveActivityExcludedDomains); return settings; }); } @@ -24,9 +39,23 @@ function saveSettings(settings) { settings = Object.assign({}, DEFAULT_SETTINGS, settings || {}); settings.language = i18n.normalizePreference(settings.language); + settings.passiveActivityEnabled = settings.passiveActivityEnabled === true; + settings.passiveActivityExcludedDomains = normalizeExcludedDomains(settings.passiveActivityExcludedDomains); 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() { if (localeCatalogs) return localeCatalogs; 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) { return protocol.buildCapture({ kind: kind, @@ -123,11 +204,14 @@ return Promise.all([ getSettings(), queue.list(), - ext.storage.local.get(STATUS_KEY) + ext.storage.local.get(STATUS_KEY), + activityTracker.getState() ]).then(function (results) { return { settings: results[0], pendingCount: results[1].length, + pendingActivityCount: results[3].pendingBatches.length, + activityState: results[3], status: results[2] && results[2][STATUS_KEY] || {} }; }); @@ -170,19 +254,24 @@ } function handleMessage(message) { - if (!message || message.type !== 'verstak.capture') return Promise.resolve(undefined); - if (message.action === 'getState') return getState(); - if (message.action === 'saveSettings') { - return saveSettings(message.settings).then(function () { - return setupContextMenus(); - }).then(function () { - return setStatus({ receiverReachable: null, lastResult: 'settings-saved', lastError: '' }); + return ready.then(function () { + if (!message || message.type !== 'verstak.capture') return undefined; + if (message.action === 'getState') return getState(); + if (message.action === 'saveSettings') { + return saveSettings(message.settings).then(function () { + return setupContextMenus(); + }).then(function () { + return getSettings().then(configurePassiveActivity); + }).then(function () { + return setStatus({ receiverReachable: null, lastResult: 'settings-saved', lastError: '' }); + }).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 sendOrQueue(captureFromInfo(message.kind || 'page', message, tab)); }).then(getState); - } - if (message.action === 'retryPending') return retryPending().then(getState); - return activeTab().then(function (tab) { - return sendOrQueue(captureFromInfo(message.kind || 'page', message, tab)); - }).then(getState); + }); } ext.runtime.onMessage.addListener(function (message, sender, sendResponse) { @@ -191,4 +280,63 @@ }); 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); + }); })(); diff --git a/shared/locales/en.json b/shared/locales/en.json index 5785e83..d5b6ee7 100644 --- a/shared/locales/en.json +++ b/shared/locales/en.json @@ -2,16 +2,20 @@ "popup.subtitle": "Browser Inbox", "label.receiver": "Receiver", "label.pending": "Pending", + "label.activityPending": "Activity batches", "label.url": "URL", "label.file": "File", "label.receiverUrl": "Receiver URL", "label.pairingToken": "Pairing token", "label.language": "Language", + "label.passiveActivity": "Track active domain time", + "label.passiveActivityExclusions": "Excluded domains", "action.sendPage": "Send Page", "action.sendFile": "Send File", "action.retryPending": "Retry Pending", "action.save": "Save", "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.offline": "Offline", "receiver.unknown": "Unknown", diff --git a/shared/locales/ru.json b/shared/locales/ru.json index 059ef08..9f010a1 100644 --- a/shared/locales/ru.json +++ b/shared/locales/ru.json @@ -2,16 +2,20 @@ "popup.subtitle": "Входящие из браузера", "label.receiver": "Приёмник", "label.pending": "В очереди", + "label.activityPending": "Пакеты активности", "label.url": "URL", "label.file": "Файл", "label.receiverUrl": "URL приёмника", "label.pairingToken": "Токен сопряжения", "label.language": "Язык", + "label.passiveActivity": "Учитывать время на активном домене", + "label.passiveActivityExclusions": "Исключённые домены", "action.sendPage": "Отправить страницу", "action.sendFile": "Отправить файл", "action.retryPending": "Повторить отправку", "action.save": "Сохранить", "hint.contextMenu": "Выделение и ссылки можно отправить через контекстное меню страницы.", + "hint.passiveActivityDisclosure": "Необязательно. Собираются только активный домен и время на нём. URL страниц, заголовки, текст, нажатия клавиш и содержимое страниц не отправляются.", "receiver.online": "Доступен", "receiver.offline": "Недоступен", "receiver.unknown": "Неизвестно", diff --git a/shared/popup/popup.css b/shared/popup/popup.css index daa6b3b..20254ef 100644 --- a/shared/popup/popup.css +++ b/shared/popup/popup.css @@ -126,6 +126,19 @@ input { 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 { box-sizing: border-box; width: 100%; @@ -142,6 +155,23 @@ input[type="file"] { 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 { margin: 0; font-size: 12px; diff --git a/shared/popup/popup.html b/shared/popup/popup.html index 9818ab2..338c304 100644 --- a/shared/popup/popup.html +++ b/shared/popup/popup.html @@ -24,6 +24,10 @@ Pending 0 +
@@ -55,6 +59,16 @@
+ Optional. Collects only the active domain and time spent there. It never sends page URLs, titles, text, keystrokes, or page contents.
+ + +