From daa3d332c5ed6977c03d2fd92dd364720c22ccb2 Mon Sep 17 00:00:00 2001 From: harsha balakrishnan Date: Tue, 6 Oct 2026 13:46:59 +0530 Subject: [PATCH 1/5] refactor(ui): share stored-theme application across pages Move the duplicated auto/light/dark class logic into theme.js, convert history to a module so it can import it, and drop an unused newtab helper. --- analytics.js | 26 ++++----------------- diagnostics.js | 22 +----------------- history.html | 2 +- history.js | 36 +++++----------------------- newtab.js | 35 +--------------------------- popup.js | 21 +---------------- scripts/package-release.mjs | 1 + tests/history-retention.test.mjs | 2 +- tests/manifest-runtime.test.mjs | 1 + tests/static-smoke.test.mjs | 40 +++++++++++++++++++++----------- theme.js | 22 ++++++++++++++++++ 11 files changed, 65 insertions(+), 143 deletions(-) create mode 100644 theme.js diff --git a/analytics.js b/analytics.js index 0440379..89b46bf 100644 --- a/analytics.js +++ b/analytics.js @@ -1,3 +1,7 @@ +import './theme.js'; +import { summarizeWeek, PRIVACY_COPY } from './session-metrics.js'; +import { sanitizeSessionHistory } from './privacy-utils.js'; + let privacyRevision = 0; chrome.runtime.onMessage.addListener(message => { if (message?.type === 'DATA_DELETION_STARTED' || message?.type === 'DATA_DELETED') { @@ -6,28 +10,6 @@ chrome.runtime.onMessage.addListener(message => { if (summary) summary.textContent = ''; } }); -import { summarizeWeek, PRIVACY_COPY } from './session-metrics.js'; -import { sanitizeSessionHistory } from './privacy-utils.js'; - -chrome.storage.local.get(['theme'], (result) => { - const theme = result.theme || 'auto'; - const root = document.documentElement; - if (theme === 'dark') { - root.classList.remove('theme-light'); - root.classList.add('theme-dark'); - } else if (theme === 'light') { - root.classList.remove('theme-dark'); - root.classList.add('theme-light'); - } else { - root.classList.remove('theme-light'); - const colorSchemeMedia = window.matchMedia('(prefers-color-scheme: dark)'); - const syncAutoTheme = () => { - root.classList.toggle('theme-dark', colorSchemeMedia.matches); - }; - syncAutoTheme(); - colorSchemeMedia.addEventListener('change', syncAutoTheme); - } -}); function createGlanceRow(label, value) { const row = document.createElement('div'); diff --git a/diagnostics.js b/diagnostics.js index 0c6ae65..f531314 100644 --- a/diagnostics.js +++ b/diagnostics.js @@ -1,25 +1,5 @@ +import './theme.js'; import { initializeStorageClient } from './storage-client.js'; -// Load and apply theme override as early as possible -chrome.storage.local.get(['theme'], (result) => { - const theme = result.theme || 'auto'; - const root = document.documentElement; - if (theme === 'dark') { - root.classList.remove('theme-light'); - root.classList.add('theme-dark'); - } else if (theme === 'light') { - root.classList.remove('theme-dark'); - root.classList.add('theme-light'); - } else { - root.classList.remove('theme-light'); - const colorSchemeMedia = window.matchMedia('(prefers-color-scheme: dark)'); - const syncAutoTheme = () => { - root.classList.toggle('theme-dark', colorSchemeMedia.matches); - }; - syncAutoTheme(); - colorSchemeMedia.addEventListener('change', syncAutoTheme); - } -}); - import { getErrorLog, clearErrorLog, diff --git a/history.html b/history.html index 2fcbe34..c0b18d5 100644 --- a/history.html +++ b/history.html @@ -34,6 +34,6 @@

Session history

- + diff --git a/history.js b/history.js index eec9a14..e34f18a 100644 --- a/history.js +++ b/history.js @@ -1,23 +1,5 @@ -// Load and apply theme override as early as possible -chrome.storage.local.get(['theme'], (result) => { - const theme = result.theme || 'auto'; - const root = document.documentElement; - if (theme === 'dark') { - root.classList.remove('theme-light'); - root.classList.add('theme-dark'); - } else if (theme === 'light') { - root.classList.remove('theme-dark'); - root.classList.add('theme-light'); - } else { - root.classList.remove('theme-light'); - const colorSchemeMedia = window.matchMedia('(prefers-color-scheme: dark)'); - const syncAutoTheme = () => { - root.classList.toggle('theme-dark', colorSchemeMedia.matches); - }; - syncAutoTheme(); - colorSchemeMedia.addEventListener('change', syncAutoTheme); - } -}); +import './theme.js'; +import { sanitizeSessionHistory } from './privacy-utils.js'; let dataDeletionInProgress = false; let privacyRevision = 0; @@ -45,16 +27,10 @@ document.addEventListener('DOMContentLoaded', () => { function loadSessionHistory(callback) { const revision = privacyRevision; - import('./privacy-utils.js').then(({ sanitizeSessionHistory }) => { - chrome.storage.local.get(['sessionHistory', 'privacyMutationState'], (result) => { - if (dataDeletionInProgress || revision !== privacyRevision || result.privacyMutationState?.deleting) { callback([]); return; } - const rawHistory = Array.isArray(result.sessionHistory) ? result.sessionHistory : []; - const sanitizedHistory = sanitizeSessionHistory(rawHistory); - callback(sanitizedHistory); - }); - }).catch((error) => { - console.error('Unable to sanitize session history.', error); - callback([]); + chrome.storage.local.get(['sessionHistory', 'privacyMutationState'], (result) => { + if (dataDeletionInProgress || revision !== privacyRevision || result.privacyMutationState?.deleting) { callback([]); return; } + const rawHistory = Array.isArray(result.sessionHistory) ? result.sessionHistory : []; + callback(sanitizeSessionHistory(rawHistory)); }); } diff --git a/newtab.js b/newtab.js index 59f4fe8..db1ea0a 100644 --- a/newtab.js +++ b/newtab.js @@ -1,24 +1,4 @@ -// Load and apply theme override as early as possible -chrome.storage.local.get(['theme'], (result) => { - const theme = result.theme || 'auto'; - const root = document.documentElement; - if (theme === 'dark') { - root.classList.remove('theme-light'); - root.classList.add('theme-dark'); - } else if (theme === 'light') { - root.classList.remove('theme-dark'); - root.classList.add('theme-light'); - } else { - root.classList.remove('theme-light'); - const colorSchemeMedia = window.matchMedia('(prefers-color-scheme: dark)'); - const syncAutoTheme = () => { - root.classList.toggle('theme-dark', colorSchemeMedia.matches); - }; - syncAutoTheme(); - colorSchemeMedia.addEventListener('change', syncAutoTheme); - } -}); - +import './theme.js'; import { mergePolicyWithIntent, INTENT_CATEGORIES } from './heuristic-policy.js'; import { getProvider, providerRequiresApiKey } from './providers.js'; import { logError, ERROR_TYPES } from './error-log.js'; @@ -337,19 +317,6 @@ document.addEventListener('DOMContentLoaded', async () => { return timerEl; } - function createHistoryEntry(session) { - const events = Array.isArray(session.events) ? session.events : []; - return { - id: session.id, - intent: session.intent, - startTime: session.startTime, - endTime: session.endTime, - timeBudget: session.timeBudget, - driftCount: events.filter(e => e?.actionType === 'OVERRIDE').length, - totalEvents: events.length - }; - } - // ── Active session state ──────────────────────────────────────────── function showActiveState(session) { diff --git a/popup.js b/popup.js index 6328351..139557d 100644 --- a/popup.js +++ b/popup.js @@ -1,3 +1,4 @@ +import './theme.js'; import { initializeStorageClient, captureStorageEpoch, sendStorageAction, guardStorageContinuation } from './storage-client.js'; import { sanitizeSessionHistory } from './privacy-utils.js'; import { activeElapsedMs, isSessionPaused } from './session-metrics.js'; @@ -25,26 +26,6 @@ function loadSessionHistory(callback) { }); } -chrome.storage.local.get(['theme'], (result) => { - const theme = result.theme || 'auto'; - const root = document.documentElement; - if (theme === 'dark') { - root.classList.remove('theme-light'); - root.classList.add('theme-dark'); - } else if (theme === 'light') { - root.classList.remove('theme-dark'); - root.classList.add('theme-light'); - } else { - root.classList.remove('theme-light'); - const colorSchemeMedia = window.matchMedia('(prefers-color-scheme: dark)'); - const syncAutoTheme = () => { - root.classList.toggle('theme-dark', colorSchemeMedia.matches); - }; - syncAutoTheme(); - colorSchemeMedia.addEventListener('change', syncAutoTheme); - } -}); - function formatSessionMinutes(session) { const start = session.startTime || 0; const end = session.endTime || Date.now(); diff --git a/scripts/package-release.mjs b/scripts/package-release.mjs index 1475da7..059abe9 100644 --- a/scripts/package-release.mjs +++ b/scripts/package-release.mjs @@ -66,6 +66,7 @@ export const RUNTIME_FILES = Object.freeze([ 'storage-queue.js', 'storage-client.js', 'storage-authority.js', + 'theme.js', ]); async function readManifest(repositoryRoot) { diff --git a/tests/history-retention.test.mjs b/tests/history-retention.test.mjs index 1b0b237..91a04e0 100644 --- a/tests/history-retention.test.mjs +++ b/tests/history-retention.test.mjs @@ -14,7 +14,7 @@ test('history and module UIs sanitize and persist retention-pruned session histo const analytics = await source('analytics.js'); const newtab = await source('newtab.js'); - assert.match(history, /import\('\.\/privacy-utils\.js'\)/); + assert.match(history, /import \{ sanitizeSessionHistory \} from '\.\/privacy-utils\.js';/); assert.match(history, /sanitizeSessionHistory\(rawHistory\)/); assert.doesNotMatch(history, /chrome\.storage\.local\.set\(\{ sessionHistory: sanitizedHistory \}/); diff --git a/tests/manifest-runtime.test.mjs b/tests/manifest-runtime.test.mjs index f8b50fb..751a91a 100644 --- a/tests/manifest-runtime.test.mjs +++ b/tests/manifest-runtime.test.mjs @@ -54,6 +54,7 @@ const expectedRuntimeFiles = [ 'storage-queue.js', 'storage-client.js', 'storage-authority.js', + 'theme.js', ]; async function readRoot(relativePath) { diff --git a/tests/static-smoke.test.mjs b/tests/static-smoke.test.mjs index 7987fd9..132de00 100644 --- a/tests/static-smoke.test.mjs +++ b/tests/static-smoke.test.mjs @@ -108,6 +108,7 @@ test('all extension javascript files parse', async () => { 'popup.js', 'analytics.js', 'storage-queue.js', + 'theme.js', ]) { const result = spawnSync(process.execPath, ['--check', file], { cwd: root, @@ -509,21 +510,32 @@ test('lock reflection textarea sets aria-invalid only after empty Continue', asy }); test('auto theme subscribes to prefers-color-scheme change', async () => { - const files = ['newtab.js', 'options.js', 'popup.js', 'analytics.js', 'history.js', 'diagnostics.js']; - for (const file of files) { + for (const file of ['newtab.js', 'popup.js', 'analytics.js', 'history.js', 'diagnostics.js']) { const src = await text(file); - assert.match( - src, - /matchMedia\(\s*['"]\(prefers-color-scheme:\s*dark\)['"]\s*\)/, - `${file} must query prefers-color-scheme`, - ); - assert.match( - src, - /addEventListener\(\s*['"]change['"]/, - `${file} must listen for color-scheme change`, - ); + assert.match(src, /import ['"]\.\/theme\.js['"]/, `${file} must load the shared theme module`); } + const theme = await text('theme.js'); + assert.match( + theme, + /matchMedia\(\s*['"]\(prefers-color-scheme:\s*dark\)['"]\s*\)/, + 'theme.js must query prefers-color-scheme', + ); + assert.match( + theme, + /addEventListener\(\s*['"]change['"]/, + 'theme.js must listen for color-scheme change', + ); const options = await text('options.js'); + assert.match( + options, + /matchMedia\(\s*['"]\(prefers-color-scheme:\s*dark\)['"]\s*\)/, + 'options.js must query prefers-color-scheme', + ); + assert.match( + options, + /addEventListener\(\s*['"]change['"]/, + 'options.js must listen for color-scheme change', + ); assert.match( options, /removeEventListener\(\s*['"]change['"]/, @@ -741,8 +753,8 @@ test('history-meta diagnostics-meta tabular-nums and h1 intent-statement text-wr test('theme auto applies theme-dark when matchMedia prefers dark', async () => { const options = await text('options.js'); - const newtab = await text('newtab.js'); - for (const src of [options, newtab]) { + const theme = await text('theme.js'); + for (const src of [options, theme]) { assert.match(src, /matchMedia\(\s*['"]\(prefers-color-scheme:\s*dark\)['"]\s*\)/); assert.match(src, /theme-dark/); } diff --git a/theme.js b/theme.js new file mode 100644 index 0000000..2448550 --- /dev/null +++ b/theme.js @@ -0,0 +1,22 @@ +// Apply the stored theme as soon as a page module loads. +export function applyStoredTheme(theme = 'auto', root = document.documentElement) { + if (theme === 'dark') { + root.classList.remove('theme-light'); + root.classList.add('theme-dark'); + } else if (theme === 'light') { + root.classList.remove('theme-dark'); + root.classList.add('theme-light'); + } else { + root.classList.remove('theme-light'); + const colorSchemeMedia = window.matchMedia('(prefers-color-scheme: dark)'); + const syncAutoTheme = () => { + root.classList.toggle('theme-dark', colorSchemeMedia.matches); + }; + syncAutoTheme(); + colorSchemeMedia.addEventListener('change', syncAutoTheme); + } +} + +chrome.storage.local.get(['theme'], (result) => { + applyStoredTheme(result.theme || 'auto'); +}); From 4b3b2111af38aad291eca91ebc9218b6fdd6be8e Mon Sep 17 00:00:00 2001 From: harsha balakrishnan Date: Tue, 6 Oct 2026 13:47:02 +0530 Subject: [PATCH 2/5] refactor(worker): dispatch messages and reuse cooldown helpers Replace the long onMessage if-else with a handler map, share hostname and cooldown matching, and collapse repeated local-storage epoch guards. --- background.js | 449 ++++++++++++++++++++++++-------------------------- 1 file changed, 218 insertions(+), 231 deletions(-) diff --git a/background.js b/background.js index 010fbbd..536a92c 100644 --- a/background.js +++ b/background.js @@ -26,6 +26,7 @@ import { import { sanitizeSessionHistory, sanitizeUrl, + hostnameFromUrl, pruneByRetention, redactSecrets, SESSION_RETENTION_MS, @@ -229,19 +230,22 @@ function transitionKey(sessionId, nonce, transition) { return `${sessionId}:${nonce}:${transition}`; } +function pruneOldestEntries(record, max, timeKey) { + const keys = Object.keys(record); + if (keys.length <= max) return record; + keys + .sort((a, b) => (record[a][timeKey] || 0) - (record[b][timeKey] || 0)) + .slice(0, keys.length - max) + .forEach((oldKey) => delete record[oldKey]); + return record; +} + function rememberCompletedTransition(completed, key, tabId, closeTab = false) { const next = { ...completed, [key]: { tabId, closeTab, completedAt: Date.now() }, }; - const keys = Object.keys(next); - if (keys.length > MAX_COMPLETED_TRANSITIONS) { - keys - .sort((a, b) => (next[a].completedAt || 0) - (next[b].completedAt || 0)) - .slice(0, keys.length - MAX_COMPLETED_TRANSITIONS) - .forEach((oldKey) => delete next[oldKey]); - } - return next; + return pruneOldestEntries(next, MAX_COMPLETED_TRANSITIONS, 'completedAt'); } function relatedHostnamesList() { @@ -307,8 +311,7 @@ function broadcastIdleState(isIdle) { chrome.idle.onStateChanged.addListener((newState) => { const epoch = getStorageGeneration(); const isIdle = (newState === 'idle' || newState === 'locked'); - chrome.storage.local.get(['trackingEnabled'], (result) => { - if (epoch !== getStorageGeneration() || isStorageDeletionActive()) return; + getLocalIfCurrent(['trackingEnabled'], epoch, (result) => { if (result.trackingEnabled === false) { isCurrentlyIdle = false; lastIdleTime = 0; @@ -332,18 +335,44 @@ chrome.idle.onStateChanged.addListener((newState) => { }); }); -// Helper for trackable URLs function isTrackableUrl(url) { return sanitizeUrl(url) != null; } -// Helper to extract bare hostname from a URL -function extractDomain(url) { - try { - return new URL(url).hostname.replace(/^www\./, '').toLowerCase(); - } catch { - return null; +const extractDomain = hostnameFromUrl; + +function domainsShareCooldown(evaluatedDomain, cooldownDomain) { + return evaluatedDomain === cooldownDomain || + evaluatedDomain.endsWith(`.${cooldownDomain}`) || + cooldownDomain.endsWith(`.${evaluatedDomain}`); +} + +function isDomainOnCooldown(evaluatedDomain, cooldowns, now = Date.now()) { + if (!evaluatedDomain) return false; + for (const [cooldownDomain, expiresAt] of cooldowns.entries()) { + if (now < expiresAt && domainsShareCooldown(evaluatedDomain, cooldownDomain)) { + return true; + } + } + return false; +} + +function pruneExpiredCooldowns(now = Date.now()) { + let mapChanged = false; + for (const [cooldownDomain, expiresAt] of overrideCooldowns.entries()) { + if (now >= expiresAt) { + overrideCooldowns.delete(cooldownDomain); + mapChanged = true; + } } + return mapChanged; +} + +function getLocalIfCurrent(keys, epoch, onResult) { + chrome.storage.local.get(keys, (result) => { + if (epoch !== getStorageGeneration() || isStorageDeletionActive()) return; + onResult(result); + }); } function openSessionReportTab() { @@ -605,13 +634,7 @@ async function handleInterventionTransition(message, sender) { const marks = { ...(result.relatedDomainMarks || relatedDomainMarks || {}) }; const previous = marks[host] || { count: 0, lastMarkedAt: 0 }; marks[host] = { count: (previous.count || 0) + 1, lastMarkedAt: Date.now() }; - const keys = Object.keys(marks); - if (keys.length > 200) { - keys - .sort((a, b) => (marks[a].lastMarkedAt || 0) - (marks[b].lastMarkedAt || 0)) - .slice(0, keys.length - 200) - .forEach((key) => delete marks[key]); - } + pruneOldestEntries(marks, 200, 'lastMarkedAt'); relatedDomainMarks = marks; values.relatedDomainMarks = marks; } @@ -668,8 +691,7 @@ function handleSessionCleared(sendResponse) { chrome.commands.onCommand.addListener((command) => { const epoch = getStorageGeneration(); if (command === 'toggle-session') { - chrome.storage.local.get(['activeSession'], (result) => { - if (epoch !== getStorageGeneration() || isStorageDeletionActive()) return; + getLocalIfCurrent(['activeSession'], epoch, (result) => { const session = result.activeSession; if (session && session.isActive) { endActiveSession(null, (ended, error) => { @@ -684,27 +706,159 @@ chrome.commands.onCommand.addListener((command) => { // ── Message handling ─────────────────────────────────────────────────── +const HANDLED_MESSAGES = new Set([ + 'SESSION_STARTED', 'OVERRIDE_INTERVENTION', 'GET_SESSION', + 'CONFIG_UPDATED', 'SESSION_CLEARED', 'DELETE_ALL_DATA', 'END_ACTIVE_SESSION', 'LOG_ERROR', + 'CONTENT_EVENT', 'GET_INTERVENTION_STATE', 'INTERVENTION_TRANSITION', + 'TEST_INTERVENTION', 'REPORT_VIEWED', 'STORAGE_MUTATION', 'PAUSE_SESSION', +]); +const CONTENT_MESSAGES = new Set(['CONTENT_EVENT', 'GET_INTERVENTION_STATE', 'INTERVENTION_TRANSITION', 'LOG_ERROR']); +const MUTATION_MESSAGES = new Set([ + 'STORAGE_MUTATION', 'SESSION_STARTED', 'END_ACTIVE_SESSION', 'SESSION_CLEARED', 'OVERRIDE_INTERVENTION', 'PAUSE_SESSION', +]); + +function replyOk(sendResponse, extra = {}) { + sendResponse({ status: 'ok', ...extra }); +} + +function replyError(sendResponse, error, fallback) { + sendResponse({ status: 'error', message: error?.message || fallback }); +} + +const messageHandlers = { + STORAGE_MUTATION(message, _sender, sendResponse, epoch) { + applyStorageCommand(message.command, message.payload, epoch).then( + value => sendResponse({ status: 'ok', value }), + error => sendResponse({ status: 'error', message: error.message }), + ); + }, + SESSION_STARTED(message, _sender, sendResponse, epoch) { + handleSessionStart(message.session, epoch).then( + () => replyOk(sendResponse), + (error) => replyError(sendResponse, error, 'Unable to start the session.'), + ); + }, + OVERRIDE_INTERVENTION(message, _sender, sendResponse, epoch) { + handleOverride(message.sessionData, epoch).then( + () => replyOk(sendResponse), + (error) => replyError(sendResponse, error, 'Unable to apply the override.'), + ); + }, + GET_SESSION(_message, _sender, sendResponse, epoch) { + if (currentSession) { + sendResponse({ session: currentSession }); + return; + } + chrome.storage.local.get(['activeSession'], (result) => { + if (epoch !== getStorageGeneration() || isStorageDeletionActive()) { + sendResponse({ status: 'error', session: null, message: 'Data changed while reading the session.' }); + return; + } + sendResponse({ session: result.activeSession || null }); + }); + }, + GET_INTERVENTION_STATE(message, sender, sendResponse, epoch) { + const requestedTabId = Number.isInteger(sender.tab?.id) ? sender.tab.id : message.tabId; + getInterventionStateForTab(requestedTabId, epoch).then((state) => { + assertStorageEpoch(epoch); + sendResponse({ ok: true, state }); + }).catch((error) => { + sendResponse({ ok: false, error: error.message || 'Unable to read intervention state.' }); + }); + }, + CONFIG_UPDATED(_message, _sender, sendResponse) { + reloadConfig() + .then(() => replyOk(sendResponse)) + .catch((err) => { + console.error('CONFIG_UPDATED reload failed:', err); + sendResponse({ status: 'error', message: err?.message || 'reload failed' }); + }); + }, + SESSION_CLEARED(_message, _sender, sendResponse) { + enqueueSessionMutation(async () => { + ungroupTabs(); + currentSession = null; + clearDriftCache(); + clearLlmBackoff(); + overrideCooldowns.clear(); + await storageRemove([ + INTERVENTION_STATE_KEY, + 'interventionState', + 'overrideCooldowns', + COMPLETED_TRANSITION_KEY, + 'sessionTabGroupId', + ]); + configPromise = null; + await loadConfig(); + return { status: 'ok' }; + }).then(sendResponse, () => { + sendResponse({ status: 'error', message: 'Unable to clear the completed session state.' }); + }); + }, + END_ACTIVE_SESSION(message, _sender, sendResponse, epoch) { + endActiveSession(message.reflection, (endedSession, error) => { + sendResponse(error ? { status: 'error', message: error.message } : { status: 'ok', session: endedSession }); + }, message.sessionId || null, epoch); + }, + PAUSE_SESSION(message, _sender, sendResponse, epoch) { + handleSessionPause(message, epoch).then( + session => sendResponse({ status: 'ok', session }), + (error) => replyError(sendResponse, error, 'Unable to change the session timer.'), + ); + }, + REPORT_VIEWED(message, _sender, sendResponse) { + handleReportViewed(message.sessionId, sendResponse); + }, + LOG_ERROR(message, _sender, sendResponse) { + logError(message.payload || {}).then(() => replyOk(sendResponse)); + }, + CONTENT_EVENT(message, sender, sendResponse) { + handleContentEvent(message.payload, sender.tab?.id); + replyOk(sendResponse); + }, + INTERVENTION_TRANSITION(message, sender, sendResponse) { + handleInterventionTransition(message, sender).then(sendResponse, (error) => { + sendResponse({ ok: false, error: error.message || 'Intervention transition failed.' }); + }); + }, + TEST_INTERVENTION(_message, _sender, sendResponse) { + chrome.storage.local.get(['activeSession', 'trackingEnabled'], (result) => { + if (result.trackingEnabled === false) { + sendResponse({ ok: false, error: 'Tracking is disabled.' }); + return; + } + const session = result.activeSession; + if (!session?.isActive) { + sendResponse({ ok: false, error: 'Start a session first (Lock in on the new tab).' }); + return; + } + chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => { + const activeTab = tabs.find((tab) => tab.id && tab.url && isTrackableUrl(tab.url)); + if (!activeTab) { + sendResponse({ ok: false, error: 'Open a website first, then try the lock.' }); + return; + } + triggerIntervention('Test intervention — drift detection is working.', activeTab.id); + sendResponse({ ok: true }); + }); + }); + }, +}; + chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { - const handledMessages = [ - 'SESSION_STARTED', 'OVERRIDE_INTERVENTION', 'GET_SESSION', - 'CONFIG_UPDATED', 'SESSION_CLEARED', 'DELETE_ALL_DATA', 'END_ACTIVE_SESSION', 'LOG_ERROR', - 'CONTENT_EVENT', 'GET_INTERVENTION_STATE', 'INTERVENTION_TRANSITION', - 'TEST_INTERVENTION', 'REPORT_VIEWED', 'STORAGE_MUTATION', 'PAUSE_SESSION' - ]; - if (!message || typeof message !== 'object' || !handledMessages.includes(message.type)) { + if (!message || typeof message !== 'object' || !HANDLED_MESSAGES.has(message.type)) { return false; } const page = isExtensionPage(sender); - const contentMessages = ['CONTENT_EVENT', 'GET_INTERVENTION_STATE', 'INTERVENTION_TRANSITION', 'LOG_ERROR']; - if ((sender?.id && sender.id !== chrome.runtime.id) || (!page && !(sender?.tab && contentMessages.includes(message.type)))) { + if ((sender?.id && sender.id !== chrome.runtime.id) || (!page && !(sender?.tab && CONTENT_MESSAGES.has(message.type)))) { sendResponse({ status: 'error', message: 'This operation requires an extension page.' }); return false; } // Capture on receipt, before config reads or other callbacks. Page payloads // carry their original epoch; classic content events are bound on receipt. const epoch = message.epoch === undefined ? getStorageGeneration() : message.epoch; - if (['STORAGE_MUTATION', 'SESSION_STARTED', 'END_ACTIVE_SESSION', 'SESSION_CLEARED', 'OVERRIDE_INTERVENTION', 'PAUSE_SESSION'].includes(message.type) && !Number.isSafeInteger(message.epoch)) { + if (MUTATION_MESSAGES.has(message.type) && !Number.isSafeInteger(message.epoch)) { sendResponse({ status: 'error', message: 'A mutation epoch is required.' }); return false; } @@ -714,121 +868,13 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { return true; } + const handler = messageHandlers[message.type]; authorityReady.then(() => { assertStorageEpoch(epoch); return loadConfig(); }).then(() => { assertStorageEpoch(epoch); - if (message.type === 'STORAGE_MUTATION') { - applyStorageCommand(message.command, message.payload, epoch).then( - value => sendResponse({ status: 'ok', value }), - error => sendResponse({ status: 'error', message: error.message }), - ); - } else if (message.type === 'SESSION_STARTED') { - handleSessionStart(message.session, epoch).then(() => { - sendResponse({ status: 'ok' }); - }, (error) => { - sendResponse({ status: 'error', message: error.message || 'Unable to start the session.' }); - }); - } else if (message.type === 'OVERRIDE_INTERVENTION') { - handleOverride(message.sessionData, epoch).then(() => { - sendResponse({ status: 'ok' }); - }, (error) => { - sendResponse({ status: 'error', message: error.message || 'Unable to apply the override.' }); - }); - } else if (message.type === 'GET_SESSION') { - // Return the latest from storage if in-memory is null - if (currentSession) { - sendResponse({ session: currentSession }); - } else { - chrome.storage.local.get(['activeSession'], (result) => { - if (epoch !== getStorageGeneration() || isStorageDeletionActive()) { - sendResponse({ status: 'error', session: null, message: 'Data changed while reading the session.' }); - return; - } - sendResponse({ session: result.activeSession || null }); - }); - } - } else if (message.type === 'GET_INTERVENTION_STATE') { - const requestedTabId = Number.isInteger(sender.tab?.id) ? sender.tab.id : message.tabId; - getInterventionStateForTab(requestedTabId, epoch).then((state) => { - assertStorageEpoch(epoch); - sendResponse({ ok: true, state }); - }).catch((error) => { - sendResponse({ ok: false, error: error.message || 'Unable to read intervention state.' }); - }); - } else if (message.type === 'CONFIG_UPDATED') { - reloadConfig() - .then(() => sendResponse({ status: 'ok' })) - .catch((err) => { - console.error('CONFIG_UPDATED reload failed:', err); - sendResponse({ status: 'error', message: err?.message || 'reload failed' }); - }); - } else if (message.type === 'SESSION_CLEARED') { - enqueueSessionMutation(async () => { - ungroupTabs(); - currentSession = null; - clearDriftCache(); - clearLlmBackoff(); - overrideCooldowns.clear(); - await storageRemove([ - INTERVENTION_STATE_KEY, - 'interventionState', - 'overrideCooldowns', - COMPLETED_TRANSITION_KEY, - 'sessionTabGroupId', - ]); - configPromise = null; - await loadConfig(); - return { status: 'ok' }; - }).then(sendResponse, () => { - sendResponse({ status: 'error', message: 'Unable to clear the completed session state.' }); - }); - } else if (message.type === 'END_ACTIVE_SESSION') { - endActiveSession(message.reflection, (endedSession, error) => { - sendResponse(error ? { status: 'error', message: error.message } : { status: 'ok', session: endedSession }); - }, message.sessionId || null, epoch); - } else if (message.type === 'PAUSE_SESSION') { - handleSessionPause(message, epoch).then(session => sendResponse({ status: 'ok', session }), (error) => { - sendResponse({ status: 'error', message: error.message || 'Unable to change the session timer.' }); - }); - } else if (message.type === 'REPORT_VIEWED') { - handleReportViewed(message.sessionId, sendResponse); - } else if (message.type === 'LOG_ERROR') { - logError(message.payload || {}).then(() => { - sendResponse({ status: 'ok' }); - }); - } else if (message.type === 'CONTENT_EVENT') { - handleContentEvent(message.payload, sender.tab?.id); - sendResponse({ status: 'ok' }); - } else if (message.type === 'INTERVENTION_TRANSITION') { - handleInterventionTransition(message, sender).then((result) => { - sendResponse(result); - }, (error) => { - sendResponse({ ok: false, error: error.message || 'Intervention transition failed.' }); - }); - } else if (message.type === 'TEST_INTERVENTION') { - chrome.storage.local.get(['activeSession', 'trackingEnabled'], (result) => { - if (result.trackingEnabled === false) { - sendResponse({ ok: false, error: 'Tracking is disabled.' }); - return; - } - const session = result.activeSession; - if (!session?.isActive) { - sendResponse({ ok: false, error: 'Start a session first (Lock in on the new tab).' }); - return; - } - chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => { - const activeTab = tabs.find((tab) => tab.id && tab.url && isTrackableUrl(tab.url)); - if (!activeTab) { - sendResponse({ ok: false, error: 'Open a website first, then try the lock.' }); - return; - } - triggerIntervention('Test intervention — drift detection is working.', activeTab.id); - sendResponse({ ok: true }); - }); - }); - } + handler?.(message, sender, sendResponse, epoch); }).catch(error => sendResponse({ status: 'error', message: error.message })); return true; // Keep channel open for async response }); @@ -871,11 +917,7 @@ function loadConfig() { } else { currentSession = null; } - if (data.trackingEnabled !== undefined) { - trackingEnabled = data.trackingEnabled; - } else { - trackingEnabled = true; - } + trackingEnabled = data.trackingEnabled !== undefined ? data.trackingEnabled : true; customDistractionSites = getEffectiveDistractionSites(data.customDistractionSites); if (data.heuristicPolicy && data.heuristicPolicy.version === 1) { heuristicPolicy = data.heuristicPolicy; @@ -888,21 +930,9 @@ function loadConfig() { } else { heuristicPolicy = buildDefaultPolicy('deep_work', 'balanced'); } - if (data.sessionTabGroupId !== undefined) { - sessionTabGroupId = data.sessionTabGroupId; - } else { - sessionTabGroupId = null; - } - if (data.isCurrentlyIdle !== undefined) { - isCurrentlyIdle = data.isCurrentlyIdle; - } else { - isCurrentlyIdle = false; - } - if (data.lastIdleTime !== undefined) { - lastIdleTime = data.lastIdleTime; - } else { - lastIdleTime = 0; - } + sessionTabGroupId = data.sessionTabGroupId !== undefined ? data.sessionTabGroupId : null; + isCurrentlyIdle = data.isCurrentlyIdle !== undefined ? data.isCurrentlyIdle : false; + lastIdleTime = data.lastIdleTime !== undefined ? data.lastIdleTime : 0; if (Array.isArray(data.overrideCooldowns)) { overrideCooldowns.clear(); data.overrideCooldowns.forEach((entry) => { @@ -916,11 +946,9 @@ function loadConfig() { if (data.llmBackoffUntil && data.llmBackoffUntil > Date.now()) { setQuotaBackoff({ retryAfterMs: data.llmBackoffUntil - Date.now() }); } - if (data.relatedDomainMarks && typeof data.relatedDomainMarks === 'object') { - relatedDomainMarks = data.relatedDomainMarks; - } else { - relatedDomainMarks = {}; - } + relatedDomainMarks = (data.relatedDomainMarks && typeof data.relatedDomainMarks === 'object') + ? data.relatedDomainMarks + : {}; resolve(); }); }); @@ -1102,8 +1130,7 @@ chrome.alarms.onAlarm.addListener((alarm) => { } if (alarm.name === timeBudgetAlarmName) { loadConfig().then(() => { - chrome.storage.local.get(['activeSession', 'trackingEnabled'], (result) => { - if (epoch !== getStorageGeneration() || isStorageDeletionActive()) return; + getLocalIfCurrent(['activeSession', 'trackingEnabled'], epoch, (result) => { if (result.trackingEnabled === false) return; const session = result.activeSession; // A paused timer cannot expire; resuming reschedules the alarm. @@ -1126,8 +1153,7 @@ chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => { const epoch = getStorageGeneration(); if (changeInfo.status === 'complete' && isTrackableUrl(tab.url)) { loadConfig().then(() => { - chrome.storage.local.get(['activeSession', 'trackingEnabled'], (result) => { - if (epoch !== getStorageGeneration() || isStorageDeletionActive()) return; + getLocalIfCurrent(['activeSession', 'trackingEnabled'], epoch, (result) => { if (result.trackingEnabled === false) return; const session = result.activeSession; if (session && session.isActive) { @@ -1143,8 +1169,7 @@ chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => { chrome.tabs.onActivated.addListener((activeInfo) => { const epoch = getStorageGeneration(); loadConfig().then(() => { - chrome.storage.local.get(['trackingEnabled', 'activeSession', 'isCurrentlyIdle', 'lastIdleTime'], (result) => { - if (epoch !== getStorageGeneration() || isStorageDeletionActive()) return; + getLocalIfCurrent(['trackingEnabled', 'activeSession', 'isCurrentlyIdle', 'lastIdleTime'], epoch, (result) => { if (result.trackingEnabled === false) return; const session = result.activeSession; if (session && session.isActive) { @@ -1299,8 +1324,7 @@ const DRIFT_DEBOUNCE_MS = 5000; function evaluateDrift(url, tabId, { dwellCheck = false } = {}) { const epoch = getStorageGeneration(); - chrome.storage.local.get(['activeSession', 'customDistractionSites', 'trackingEnabled'], (result) => { - if (epoch !== getStorageGeneration() || isStorageDeletionActive()) return; + getLocalIfCurrent(['activeSession', 'customDistractionSites', 'trackingEnabled'], epoch, (result) => { if (result.trackingEnabled === false) return; const session = result.activeSession; if (!session || !session.isActive) return; @@ -1321,32 +1345,14 @@ function evaluateDrift(url, tabId, { dwellCheck = false } = {}) { lastEvaluatedTime = now; } - // Check per-domain override cooldown const evaluatedDomain = extractDomain(url); if (evaluatedDomain) { - let hasCooldown = false; - let mapChanged = false; - const expiredDomains = []; - for (const [cooldownDomain, expiresAt] of overrideCooldowns.entries()) { - if (now < expiresAt) { - if (evaluatedDomain === cooldownDomain || - evaluatedDomain.endsWith(`.${cooldownDomain}`) || - cooldownDomain.endsWith(`.${evaluatedDomain}`)) { - hasCooldown = true; - } - } else { - expiredDomains.push(cooldownDomain); - mapChanged = true; - } - } - if (expiredDomains.length > 0) { - expiredDomains.forEach(domain => overrideCooldowns.delete(domain)); - } + const mapChanged = pruneExpiredCooldowns(now); if (mapChanged) { void enqueueSessionMutation(() => storageSet({ overrideCooldowns: Array.from(overrideCooldowns.entries()) }), epoch); } - if (hasCooldown) { - return; // Still in cooldown — skip intervention + if (isDomainOnCooldown(evaluatedDomain, overrideCooldowns, now)) { + return; } } @@ -1378,41 +1384,22 @@ function evaluateDrift(url, tabId, { dwellCheck = false } = {}) { checkDriftLLM(session.intent, url, sessionEvents(session)).then(res => { if (epoch !== getStorageGeneration() || isStorageDeletionActive()) return; if (!res.isAligned && res.confidence >= DRIFT_CONFIDENCE_THRESHOLD) { - chrome.storage.local.get(['activeSession', 'overrideCooldowns'], (storageResult) => { - if (epoch !== getStorageGeneration() || isStorageDeletionActive()) return; + getLocalIfCurrent(['activeSession', 'overrideCooldowns'], epoch, (storageResult) => { const current = storageResult.activeSession; if (current && current.isActive && current.id === session.id) { - // Check if domain is currently on cooldown - const evaluatedDomain = extractDomain(url); - if (evaluatedDomain) { - const cooldowns = new Map(storageResult.overrideCooldowns || []); - const now = Date.now(); - let hasCooldown = false; - for (const [cooldownDomain, expiresAt] of cooldowns.entries()) { - if (now < expiresAt) { - if (evaluatedDomain === cooldownDomain || - evaluatedDomain.endsWith(`.${cooldownDomain}`) || - cooldownDomain.endsWith(`.${evaluatedDomain}`)) { - hasCooldown = true; - break; - } - } - } - if (hasCooldown) return; // Skip intervention due to active cooldown - } + const cooldowns = new Map(storageResult.overrideCooldowns || []); + if (isDomainOnCooldown(extractDomain(url), cooldowns)) return; - // Verify the tab is still on the evaluated URL - if (tabId) { - chrome.tabs.get(tabId, (tab) => { - if (epoch !== getStorageGeneration() || isStorageDeletionActive()) return; - if (chrome.runtime.lastError || !tab) return; - if (tab.url === url) { - triggerIntervention('Your recent browsing no longer matches your declared intent.', tabId); - } - }); - } else { - triggerIntervention('Your recent browsing no longer matches your declared intent.', tabId); + const fire = () => triggerIntervention('Your recent browsing no longer matches your declared intent.', tabId); + if (!tabId) { + fire(); + return; } + chrome.tabs.get(tabId, (tab) => { + if (epoch !== getStorageGeneration() || isStorageDeletionActive()) return; + if (chrome.runtime.lastError || !tab) return; + if (tab.url === url) fire(); + }); } }); } From 7a139760090581a1d71c5acf66f451875a52937b Mon Sep 17 00:00:00 2001 From: harsha balakrishnan Date: Tue, 6 Oct 2026 13:47:12 +0530 Subject: [PATCH 3/5] refactor: collapse repeated provider and overlay request paths Share provider POST + tracking checks, overlay transition sends, and the trackingIsDisabled helper. No change to lock or matching rules. --- content.js | 69 ++++++++++++--------------------- error-log.js | 1 - intervention-overlay.js | 20 +++++----- llm.js | 8 +--- providers.js | 85 +++++++++++------------------------------ 5 files changed, 58 insertions(+), 125 deletions(-) diff --git a/content.js b/content.js index 97ccf13..8b5a641 100644 --- a/content.js +++ b/content.js @@ -52,53 +52,34 @@ function showTransitionError(message) { if (overlay) overlay.setError(message); } +function sendInterventionTransition(transition, extra = {}, fallbackError) { + return async (arg) => { + const state = extra.stateFrom ? extra.stateFrom(arg) : arg; + const result = await sendRuntimeMessage({ + type: 'INTERVENTION_TRANSITION', + transition, + sessionId: state?.sessionId, + nonce: state?.nonce, + ...(extra.payloadFrom ? extra.payloadFrom(arg) : {}), + }); + if (result.response?.ok) { + pendingIntervention = null; + overlay.hide(); + } else { + showTransitionError(result.response?.error || result.error?.message || fallbackError); + } + }; +} + function ensureOverlay() { if (!overlay) { overlay = createInterventionOverlay({ - onOverride: async ({ reflection, markRelated, state }) => { - const result = await sendRuntimeMessage({ - type: 'INTERVENTION_TRANSITION', - transition: 'override', - sessionId: state?.sessionId, - nonce: state?.nonce, - reflection, - markRelated, - }); - if (result.response?.ok) { - pendingIntervention = null; - overlay.hide(); - } else { - showTransitionError(result.response?.error || result.error?.message || 'Unable to continue.'); - } - }, - onCloseTab: async (state) => { - const result = await sendRuntimeMessage({ - type: 'INTERVENTION_TRANSITION', - transition: 'close-tab', - sessionId: state?.sessionId, - nonce: state?.nonce, - }); - if (result.response?.ok) { - pendingIntervention = null; - overlay.hide(); - } else { - showTransitionError(result.response?.error || result.error?.message || 'Unable to close this lock.'); - } - }, - onEndSession: async (state) => { - const result = await sendRuntimeMessage({ - type: 'INTERVENTION_TRANSITION', - transition: 'end-session', - sessionId: state?.sessionId, - nonce: state?.nonce, - }); - if (result.response?.ok) { - pendingIntervention = null; - overlay.hide(); - } else { - showTransitionError(result.response?.error || result.error?.message || 'Unable to end the session.'); - } - }, + onOverride: sendInterventionTransition('override', { + stateFrom: (arg) => arg.state, + payloadFrom: ({ reflection, markRelated }) => ({ reflection, markRelated }), + }, 'Unable to continue.'), + onCloseTab: sendInterventionTransition('close-tab', {}, 'Unable to close this lock.'), + onEndSession: sendInterventionTransition('end-session', {}, 'Unable to end the session.'), }); } return overlay; diff --git a/error-log.js b/error-log.js index 105650b..d4aa7c1 100644 --- a/error-log.js +++ b/error-log.js @@ -57,7 +57,6 @@ export function classifyApiError(status, bodyText = '', providerId = 'unknown') try { const parsed = JSON.parse(body); providerMessage = parsed?.error?.message - || parsed?.error?.message || parsed?.message || parsed?.[0]?.error?.message || null; diff --git a/intervention-overlay.js b/intervention-overlay.js index c7d1ec0..abdb19f 100644 --- a/intervention-overlay.js +++ b/intervention-overlay.js @@ -405,17 +405,15 @@ function enforceHostVisibility() { if (!host || !lockedVisible) return; - if (host.style.getPropertyValue('display') !== 'block') { - host.style.setProperty('display', 'block', 'important'); - } - if (host.style.getPropertyValue('visibility') !== 'visible') { - host.style.setProperty('visibility', 'visible', 'important'); - } - if (host.style.getPropertyValue('opacity') !== '1') { - host.style.setProperty('opacity', '1', 'important'); - } - if (host.style.getPropertyValue('pointer-events') !== 'auto') { - host.style.setProperty('pointer-events', 'auto', 'important'); + for (const [prop, value] of Object.entries({ + display: 'block', + visibility: 'visible', + opacity: '1', + 'pointer-events': 'auto', + })) { + if (host.style.getPropertyValue(prop) !== value) { + host.style.setProperty(prop, value, 'important'); + } } host.hidden = false; host.removeAttribute('aria-hidden'); diff --git a/llm.js b/llm.js index 70be7f9..85a66c7 100644 --- a/llm.js +++ b/llm.js @@ -5,6 +5,7 @@ import { cleanJsonString, getLlmConfig, isLlmConfigured, + trackingIsDisabled, } from './providers.js'; import { logError, ERROR_TYPES, captureErrorEpoch, isErrorEpochCurrent } from './error-log.js'; import { sanitizeUrl } from './privacy-utils.js'; @@ -191,10 +192,3 @@ async function generateIntentPlan(intent) { } export { checkDriftLLM, generateIntentPlan, cleanJsonString }; - -async function trackingIsDisabled() { - if (typeof chrome === 'undefined' || !chrome.storage?.local) return false; - return new Promise((resolve) => { - chrome.storage.local.get(['trackingEnabled'], (result) => resolve(result?.trackingEnabled === false)); - }); -} diff --git a/providers.js b/providers.js index a975261..ec14fe9 100644 --- a/providers.js +++ b/providers.js @@ -359,8 +359,26 @@ export async function getLlmConfig() { }); } +async function postProviderJson(url, { providerId, body, headers = {} }) { + const controller = createProviderController(); + try { + await assertTrackingEnabled(providerId); + } catch (error) { + if (controller) activeProviderControllers.delete(controller); + throw error; + } + const response = await fetchWithTimeout(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...headers }, + body: JSON.stringify(body), + controller, + }); + if (!response.ok) await throwApiFailure(response, providerId); + return response.json(); +} + async function callOpenAiCompatible({ baseUrl, apiKey, model, prompt, jsonMode, maxTokens, temperature, authType, providerId }) { - const headers = { 'Content-Type': 'application/json' }; + const headers = {}; let url = baseUrl; const effectiveAuthType = providerId === 'custom' ? authType @@ -385,32 +403,13 @@ async function callOpenAiCompatible({ baseUrl, apiKey, model, prompt, jsonMode, body.response_format = { type: 'json_object' }; } - const controller = createProviderController(); - try { - await assertTrackingEnabled(providerId); - } catch (error) { - if (controller) activeProviderControllers.delete(controller); - throw error; - } - const response = await fetchWithTimeout(url, { - method: 'POST', - headers, - body: JSON.stringify(body), - controller, - }); - - if (!response.ok) { - await throwApiFailure(response, providerId); - } - - const data = await response.json(); + const data = await postProviderJson(url, { providerId, body, headers }); return data.choices?.[0]?.message?.content ?? null; } async function callGemini({ baseUrl, apiKey, model, prompt, jsonMode, maxTokens, temperature, providerId }) { const root = baseUrl.replace(/\/$/, ''); const url = `${root}/${model}:generateContent?key=${encodeURIComponent(apiKey)}`; - const body = { contents: [{ parts: [{ text: prompt }] }], generationConfig: { @@ -421,26 +420,7 @@ async function callGemini({ baseUrl, apiKey, model, prompt, jsonMode, maxTokens, if (jsonMode) { body.generationConfig.responseMimeType = 'application/json'; } - - const controller = createProviderController(); - try { - await assertTrackingEnabled(providerId); - } catch (error) { - if (controller) activeProviderControllers.delete(controller); - throw error; - } - const response = await fetchWithTimeout(url, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - controller, - }); - - if (!response.ok) { - await throwApiFailure(response, providerId); - } - - const data = await response.json(); + const data = await postProviderJson(url, { providerId, body }); return data.candidates?.[0]?.content?.parts?.[0]?.text ?? null; } @@ -454,26 +434,7 @@ async function callOllama({ baseUrl, model, prompt, jsonMode, maxTokens, tempera if (jsonMode) { body.format = 'json'; } - - const controller = createProviderController(); - try { - await assertTrackingEnabled(providerId); - } catch (error) { - if (controller) activeProviderControllers.delete(controller); - throw error; - } - const response = await fetchWithTimeout(baseUrl, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - controller, - }); - - if (!response.ok) { - await throwApiFailure(response, providerId); - } - - const data = await response.json(); + const data = await postProviderJson(baseUrl, { providerId, body }); return data.message?.content ?? null; } @@ -644,7 +605,7 @@ export async function chatCompletion(prompt, options = {}) { } } -async function trackingIsDisabled() { +export async function trackingIsDisabled() { if (typeof chrome === 'undefined' || !chrome.storage?.local) return false; return new Promise((resolve) => { chrome.storage.local.get(['trackingEnabled'], (result) => resolve(result?.trackingEnabled === false)); From 63b620335abfef1708f6dbea51d4cfa166b41d13 Mon Sep 17 00:00:00 2001 From: harsha balakrishnan Date: Tue, 6 Oct 2026 14:43:54 +0530 Subject: [PATCH 4/5] test: Pin extractDomain vs hostnameFromUrl on degenerate URLs about:blank and file:/// hostnames are null (legacy empty string). Callers treat both as missing except one stored hostname field. --- .../extract-domain-characterization.test.mjs | 73 +++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 tests/extract-domain-characterization.test.mjs diff --git a/tests/extract-domain-characterization.test.mjs b/tests/extract-domain-characterization.test.mjs new file mode 100644 index 0000000..64dd9da --- /dev/null +++ b/tests/extract-domain-characterization.test.mjs @@ -0,0 +1,73 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import test from 'node:test'; +import { hostnameFromUrl } from '../privacy-utils.js'; + +/** Pre-refactor extractDomain from origin/main background.js. */ +function extractDomainLegacy(url) { + try { + return new URL(url).hostname.replace(/^www\./, '').toLowerCase(); + } catch { + return null; + } +} + +const extractDomain = hostnameFromUrl; + +const CASES = [ + ['empty string', ''], + ['chrome://settings', 'chrome://settings'], + ['about:blank', 'about:blank'], + ['file:///tmp/x', 'file:///tmp/x'], + ['userinfo', 'https://user:pass@example.com/path'], + ['ipv4', 'https://127.0.0.1/x'], + ['trailing-dot host', 'https://example.com./a'], + ['www strip', 'https://www.Example.com/a'], + ['nullish', null], + ['undefined', undefined], +]; + +test('extractDomain aliases hostnameFromUrl and pins degenerate URLs', () => { + for (const [label, url] of CASES) { + const got = extractDomain(url); + const legacy = extractDomainLegacy(url); + if (legacy === '' && got === null) { + continue; + } + assert.equal(got, legacy, `${label}: new=${JSON.stringify(got)} legacy=${JSON.stringify(legacy)}`); + } + assert.equal(extractDomain(''), null); + assert.equal(extractDomain('chrome://settings'), 'settings'); + assert.equal(extractDomainLegacy('about:blank'), ''); + assert.equal(extractDomain('about:blank'), null); + assert.equal(extractDomainLegacy('file:///tmp/x'), ''); + assert.equal(extractDomain('file:///tmp/x'), null); + assert.equal(extractDomain('https://user:pass@example.com/path'), 'example.com'); + assert.equal(extractDomain('https://127.0.0.1/x'), '127.0.0.1'); + assert.equal(extractDomain('https://example.com./a'), 'example.com.'); +}); + +test('every extractDomain caller treats null and empty string the same (truthiness or || null)', async () => { + const bg = await readFile(new URL('../background.js', import.meta.url), 'utf8'); + const lines = bg.split('\n'); + const hits = []; + lines.forEach((line, i) => { + if (line.includes('extractDomain(')) hits.push({ line: i + 1, text: line.trim() }); + }); + assert.ok(hits.length >= 6, `expected several callers, got ${hits.length}`); + const unsafe = hits.filter(({ text }) => { + if (/if\s*\(\s*(host|domain|evaluatedDomain|hostname)\s*\)/.test(text)) return false; + if (/\|\|\s*null/.test(text)) return false; + if (/isDomainOnCooldown\(extractDomain\(/.test(text)) return false; + if (/const (host|domain|hostname|evaluatedDomain) = extractDomain\(/.test(text)) return false; + if (/hostname:\s*(e\.hostname\s*\|\|\s*)?extractDomain\(/.test(text)) return false; + return true; + }); + const assigned = hits.filter(({ text }) => /hostname:\s*extractDomain\(/.test(text) && !/\|\|\s*null/.test(text)); + assert.equal( + assigned.length, + 1, + 'override event stores extractDomain() directly; empty-host now null vs legacy ""', + ); + assert.deepEqual(unsafe, [], `callers that may distinguish null vs "": ${JSON.stringify(unsafe)}`); +}); From f014ac23d5fdcf3687f30807e446d188cd485760 Mon Sep 17 00:00:00 2001 From: harsha balakrishnan Date: Thu, 8 Oct 2026 11:20:19 +0530 Subject: [PATCH 5/5] fix: Restore legacy extractDomain empty-host as empty string about:blank and file URLs stored "" in override events. Alias to hostnameFromUrl returned null. Restore the original function body. --- background.js | 8 ++++- .../extract-domain-characterization.test.mjs | 34 ++++++++++--------- 2 files changed, 25 insertions(+), 17 deletions(-) diff --git a/background.js b/background.js index 536a92c..9819bb3 100644 --- a/background.js +++ b/background.js @@ -339,7 +339,13 @@ function isTrackableUrl(url) { return sanitizeUrl(url) != null; } -const extractDomain = hostnameFromUrl; +function extractDomain(url) { + try { + return new URL(url).hostname.replace(/^www\./, '').toLowerCase(); + } catch { + return null; + } +} function domainsShareCooldown(evaluatedDomain, cooldownDomain) { return evaluatedDomain === cooldownDomain || diff --git a/tests/extract-domain-characterization.test.mjs b/tests/extract-domain-characterization.test.mjs index 64dd9da..916900c 100644 --- a/tests/extract-domain-characterization.test.mjs +++ b/tests/extract-domain-characterization.test.mjs @@ -3,8 +3,8 @@ import { readFile } from 'node:fs/promises'; import test from 'node:test'; import { hostnameFromUrl } from '../privacy-utils.js'; -/** Pre-refactor extractDomain from origin/main background.js. */ -function extractDomainLegacy(url) { +/** Restored extractDomain (same body as origin/main background.js). */ +function extractDomain(url) { try { return new URL(url).hostname.replace(/^www\./, '').toLowerCase(); } catch { @@ -12,8 +12,6 @@ function extractDomainLegacy(url) { } } -const extractDomain = hostnameFromUrl; - const CASES = [ ['empty string', ''], ['chrome://settings', 'chrome://settings'], @@ -27,21 +25,23 @@ const CASES = [ ['undefined', undefined], ]; -test('extractDomain aliases hostnameFromUrl and pins degenerate URLs', () => { +test('background.js extractDomain is the legacy function, not hostnameFromUrl', async () => { + const bg = await readFile(new URL('../background.js', import.meta.url), 'utf8'); + assert.match(bg, /function extractDomain\(url\) \{/); + assert.doesNotMatch(bg, /const extractDomain = hostnameFromUrl/); +}); + +test('extractDomain pins legacy empty-host as empty string', () => { for (const [label, url] of CASES) { const got = extractDomain(url); - const legacy = extractDomainLegacy(url); - if (legacy === '' && got === null) { - continue; - } - assert.equal(got, legacy, `${label}: new=${JSON.stringify(got)} legacy=${JSON.stringify(legacy)}`); + const privacy = hostnameFromUrl(url); + if (got === '' && privacy === null) continue; + assert.equal(got, privacy, `${label}: extractDomain=${JSON.stringify(got)} hostnameFromUrl=${JSON.stringify(privacy)}`); } assert.equal(extractDomain(''), null); assert.equal(extractDomain('chrome://settings'), 'settings'); - assert.equal(extractDomainLegacy('about:blank'), ''); - assert.equal(extractDomain('about:blank'), null); - assert.equal(extractDomainLegacy('file:///tmp/x'), ''); - assert.equal(extractDomain('file:///tmp/x'), null); + assert.equal(extractDomain('about:blank'), ''); + assert.equal(extractDomain('file:///tmp/x'), ''); assert.equal(extractDomain('https://user:pass@example.com/path'), 'example.com'); assert.equal(extractDomain('https://127.0.0.1/x'), '127.0.0.1'); assert.equal(extractDomain('https://example.com./a'), 'example.com.'); @@ -52,7 +52,9 @@ test('every extractDomain caller treats null and empty string the same (truthine const lines = bg.split('\n'); const hits = []; lines.forEach((line, i) => { - if (line.includes('extractDomain(')) hits.push({ line: i + 1, text: line.trim() }); + if (line.includes('extractDomain(') && !line.includes('function extractDomain')) { + hits.push({ line: i + 1, text: line.trim() }); + } }); assert.ok(hits.length >= 6, `expected several callers, got ${hits.length}`); const unsafe = hits.filter(({ text }) => { @@ -67,7 +69,7 @@ test('every extractDomain caller treats null and empty string the same (truthine assert.equal( assigned.length, 1, - 'override event stores extractDomain() directly; empty-host now null vs legacy ""', + 'override event stores extractDomain() directly (legacy empty-host is "")', ); assert.deepEqual(unsafe, [], `callers that may distinguish null vs "": ${JSON.stringify(unsafe)}`); });