From de4dff2d00a493b9d0d58a5bfc5a1d4d76744959 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 2 Nov 2026 16:04:22 +0000 Subject: [PATCH 1/3] feat: open debug log download from settings next to GitHub --- scripts/firefox-screenshots.mjs | 46 +- src/background.ts | 574 ++++++++++++++++-- src/entities/app-view/__tests__/model.spec.ts | 24 + src/entities/app-view/index.ts | 2 + src/entities/app-view/model.ts | 12 + src/entities/dnr-health/model.ts | 29 + src/entities/dnr-health/utils/load.ts | 21 + src/entities/dnr-health/utils/parse.ts | 12 + src/entities/is-paused/model.ts | 3 + src/entities/is-paused/utils/save.ts | 7 +- .../request-profile/model/request-profiles.ts | 16 +- .../model/selected-request-profile.ts | 22 +- src/entities/request-profile/utils/save.ts | 19 +- .../export-debug-logs/__tests__/model.spec.ts | 120 ++++ src/features/export-debug-logs/index.ts | 1 + src/features/export-debug-logs/model.ts | 82 +++ .../utils/__tests__/downloadDebugLogs.spec.ts | 17 + .../utils/downloadDebugLogs.ts | 29 + src/features/export-debug-logs/utils/index.ts | 1 + src/pages/main/Main.tsx | 33 +- src/pages/main/styled.ts | 16 + src/shared/constants.ts | 8 +- .../utils/__tests__/debugLogsExport.spec.ts | 84 +++ .../setBrowserHeaders.recovery.spec.ts | 435 +++++++++++++ src/shared/utils/debugLogsExport.ts | 157 +++++ src/shared/utils/headersConfigMeta.ts | 61 ++ src/shared/utils/logger.ts | 37 ++ src/shared/utils/setBrowserHeaders.ts | 375 +++++++++++- src/shared/utils/setIconBadge.ts | 9 +- src/shared/utils/updateOverrideHeaders.ts | 7 - .../request-headers/RequestHeaders.tsx | 13 +- .../RequestHeaderRow/RequestHeaderRow.tsx | 17 +- src/widgets/settings/Settings.tsx | 127 ++++ src/widgets/settings/index.ts | 1 + src/widgets/settings/styled.ts | 47 ++ src/widgets/sidebar/Sidebar.tsx | 26 +- tests/e2e/dnr-warning.spec.ts | 172 ++++++ tests/e2e/header-toggle-dnr.spec.ts | 278 +++++++++ .../config/screenshot.config.ts | 3 + .../page-objects/popup.page.ts | 7 + .../specs/settings.screenshots.ts | 10 + .../cookies-all-disabled-dark.png | Bin 30661 -> 31475 bytes .../cookies-all-disabled-light.png | Bin 31312 -> 32189 bytes .../cookies-cookie-menu-open-dark.png | Bin 30737 -> 31550 bytes .../cookies-cookie-menu-open-light.png | Bin 32785 -> 33622 bytes .../cookies-disabled-cookie-dark.png | Bin 27165 -> 27950 bytes .../cookies-disabled-cookie-light.png | Bin 27810 -> 28577 bytes .../cookies-empty-state-dark.png | Bin 22516 -> 23340 bytes .../cookies-empty-state-light.png | Bin 23060 -> 23874 bytes .../cookies-multiple-cookies-dark.png | Bin 30705 -> 31523 bytes .../cookies-multiple-cookies-light.png | Bin 31010 -> 31872 bytes .../cookies-name-validation-error-dark.png | Bin 26477 -> 27262 bytes .../cookies-name-validation-error-light.png | Bin 27006 -> 27798 bytes .../cookies-single-cookie-dark.png | Bin 27768 -> 28568 bytes .../cookies-single-cookie-light.png | Bin 28135 -> 28919 bytes .../cookies-value-validation-error-dark.png | Bin 30726 -> 31544 bytes .../cookies-value-validation-error-light.png | Bin 31263 -> 32148 bytes .../general-empty-popup-dark.png | Bin 20951 -> 21782 bytes .../general-empty-popup-light.png | Bin 21510 -> 22322 bytes .../general-paused-state-dark.png | Bin 20045 -> 20902 bytes .../general-paused-state-light.png | Bin 20658 -> 21503 bytes .../general-theme-menu-open-dark.png | Bin 20924 -> 21759 bytes .../general-theme-menu-open-light.png | Bin 21492 -> 22307 bytes .../headers-all-disabled-dark.png | Bin 27700 -> 28499 bytes .../headers-all-disabled-light.png | Bin 28347 -> 29124 bytes .../headers-disabled-header-dark.png | Bin 27202 -> 27993 bytes .../headers-disabled-header-light.png | Bin 27860 -> 28634 bytes .../headers-empty-state-dark.png | Bin 20951 -> 21782 bytes .../headers-empty-state-light.png | Bin 21510 -> 22322 bytes .../headers-header-menu-open-dark.png | Bin 28867 -> 29629 bytes .../headers-header-menu-open-light.png | Bin 30659 -> 31519 bytes .../headers-multiple-headers-dark.png | Bin 31379 -> 32252 bytes .../headers-multiple-headers-light.png | Bin 31775 -> 32632 bytes .../headers-single-header-dark.png | Bin 27795 -> 28593 bytes .../headers-single-header-light.png | Bin 28198 -> 28972 bytes .../headers-validation-error-dark.png | Bin 26085 -> 26864 bytes .../headers-validation-error-light.png | Bin 26640 -> 27417 bytes ...odals-cookies-delete-confirmation-dark.png | Bin 37675 -> 38426 bytes ...dals-cookies-delete-confirmation-light.png | Bin 38617 -> 39358 bytes .../modals-delete-confirmation-dark.png | Bin 33870 -> 34635 bytes .../modals-delete-confirmation-light.png | Bin 34293 -> 35057 bytes .../modals-export-modal-dark.png | Bin 38326 -> 39097 bytes .../modals-export-modal-light.png | Bin 40007 -> 40775 bytes .../modals-import-from-extension-dark.png | Bin 23890 -> 24685 bytes .../modals-import-from-extension-light.png | Bin 24341 -> 25132 bytes .../modals-import-modal-dark.png | Bin 21255 -> 22024 bytes .../modals-import-modal-light.png | Bin 21787 -> 22545 bytes .../profiles-multiple-profiles-dark.png | Bin 23159 -> 23980 bytes .../profiles-multiple-profiles-light.png | Bin 23198 -> 24007 bytes .../profiles-profile-actions-menu-dark.png | Bin 29470 -> 30272 bytes .../profiles-profile-actions-menu-light.png | Bin 31895 -> 32799 bytes .../profiles-profile-editing-name-dark.png | Bin 20740 -> 21573 bytes .../profiles-profile-editing-name-light.png | Bin 21280 -> 22091 bytes .../profiles-profile-selected-dark.png | Bin 23159 -> 23980 bytes .../profiles-profile-selected-light.png | Bin 23198 -> 24007 bytes .../profiles-single-profile-dark.png | Bin 20951 -> 21782 bytes .../profiles-single-profile-light.png | Bin 21510 -> 22322 bytes .../settings-settings-page-dark.png | Bin 0 -> 38375 bytes .../settings-settings-page-light.png | Bin 0 -> 39040 bytes .../url-filters-disabled-filter-dark.png | Bin 23273 -> 24088 bytes .../url-filters-disabled-filter-light.png | Bin 23869 -> 24688 bytes .../url-filters-empty-state-dark.png | Bin 20460 -> 21288 bytes .../url-filters-empty-state-light.png | Bin 20979 -> 21794 bytes .../url-filters-filter-menu-open-dark.png | Bin 28681 -> 29435 bytes .../url-filters-filter-menu-open-light.png | Bin 30361 -> 31178 bytes .../url-filters-multiple-filters-dark.png | Bin 29034 -> 29801 bytes .../url-filters-multiple-filters-light.png | Bin 29548 -> 30368 bytes .../url-filters-single-filter-dark.png | Bin 22970 -> 23786 bytes .../url-filters-single-filter-light.png | Bin 23073 -> 23888 bytes tests/e2e/screenshots.spec.ts | 1 + .../general-theme-menu-open-dark.png | Bin 20618 -> 21365 bytes .../general-theme-menu-open-light.png | Bin 20261 -> 21016 bytes .../settings-settings-page-dark.png | Bin 0 -> 26569 bytes .../settings-settings-page-light.png | Bin 0 -> 25381 bytes tests/e2e/settings.spec.ts | 91 +++ tests/e2e/sw-restart.spec.ts | 240 ++++++++ 116 files changed, 3169 insertions(+), 123 deletions(-) create mode 100644 src/entities/app-view/__tests__/model.spec.ts create mode 100644 src/entities/app-view/index.ts create mode 100644 src/entities/app-view/model.ts create mode 100644 src/entities/dnr-health/model.ts create mode 100644 src/entities/dnr-health/utils/load.ts create mode 100644 src/entities/dnr-health/utils/parse.ts create mode 100644 src/features/export-debug-logs/__tests__/model.spec.ts create mode 100644 src/features/export-debug-logs/index.ts create mode 100644 src/features/export-debug-logs/model.ts create mode 100644 src/features/export-debug-logs/utils/__tests__/downloadDebugLogs.spec.ts create mode 100644 src/features/export-debug-logs/utils/downloadDebugLogs.ts create mode 100644 src/features/export-debug-logs/utils/index.ts create mode 100644 src/shared/utils/__tests__/debugLogsExport.spec.ts create mode 100644 src/shared/utils/__tests__/setBrowserHeaders.recovery.spec.ts create mode 100644 src/shared/utils/debugLogsExport.ts create mode 100644 src/shared/utils/headersConfigMeta.ts delete mode 100644 src/shared/utils/updateOverrideHeaders.ts create mode 100644 src/widgets/settings/Settings.tsx create mode 100644 src/widgets/settings/index.ts create mode 100644 src/widgets/settings/styled.ts create mode 100644 tests/e2e/dnr-warning.spec.ts create mode 100644 tests/e2e/header-toggle-dnr.spec.ts create mode 100644 tests/e2e/screenshot-tests/specs/settings.screenshots.ts create mode 100644 tests/e2e/screenshots.firefox.spec.ts-snapshots/settings-settings-page-dark.png create mode 100644 tests/e2e/screenshots.firefox.spec.ts-snapshots/settings-settings-page-light.png create mode 100644 tests/e2e/screenshots.spec.ts-snapshots/settings-settings-page-dark.png create mode 100644 tests/e2e/screenshots.spec.ts-snapshots/settings-settings-page-light.png create mode 100644 tests/e2e/settings.spec.ts create mode 100644 tests/e2e/sw-restart.spec.ts diff --git a/scripts/firefox-screenshots.mjs b/scripts/firefox-screenshots.mjs index 7c4b71fa..fc7f5b55 100644 --- a/scripts/firefox-screenshots.mjs +++ b/scripts/firefox-screenshots.mjs @@ -1,7 +1,7 @@ import { existsSync, mkdirSync, writeFileSync } from 'node:fs'; import { readFile } from 'node:fs/promises'; import { dirname, resolve } from 'node:path'; -import { By, Key } from 'selenium-webdriver'; +import { By, Key, Origin } from 'selenium-webdriver'; import pixelmatch from 'pixelmatch'; import { PNG } from 'pngjs'; @@ -38,6 +38,8 @@ const selectors = { urlFilterMenuButton: '[data-test-id="url-filter-menu-button"]', pauseButton: '[data-test-id="pause-button"]', themeToggle: '[data-test-id="theme-toggle-button"]', + settingsButton: '[data-test-id="settings-button"]', + settingsPage: '[data-test-id="settings-page"]', profileActionsButton: '[data-test-id="profile-actions-menu-button"]', addProfileButton: '[data-test-id="add-profile-button"]', profileSelect: '[data-test-id="profile-select"]', @@ -56,6 +58,11 @@ const scenarios = [ scenario('general', 'theme-menu-open', async ({ click }) => { await click(selectors.themeToggle); }), + scenario('settings', 'settings-page', async ({ click, waitText, dismissHover }) => { + await click(selectors.settingsButton); + await waitText('Download extension logs'); + await dismissHover(); + }), scenario('headers', 'empty-state', async ({ activateHeaders }) => { await activateHeaders(); }), @@ -150,12 +157,16 @@ const scenarios = [ await removeAllUrlFilters(); await waitText('Remove all URL filters'); }), - scenario('modals', 'cookies-delete-confirmation', async ({ activateCookies, addCookie, removeAllCookies, waitText }) => { - await activateCookies(); - await addCookie('session_id', 'abc123'); - await removeAllCookies(); - await waitText('Remove all request cookies'); - }), + scenario( + 'modals', + 'cookies-delete-confirmation', + async ({ activateCookies, addCookie, removeAllCookies, waitText }) => { + await activateCookies(); + await addCookie('session_id', 'abc123'); + await removeAllCookies(); + await waitText('Remove all request cookies'); + }, + ), scenario('profiles', 'single-profile', async ({ activateHeaders }) => { await activateHeaders(); }), @@ -213,7 +224,7 @@ async function main() { try { const browser = createBrowserHelpers(driver, popupUrl); const selectedScenarios = scenarios - .flatMap((item) => ['light', 'dark'].map((theme) => ({ ...item, theme }))) + .flatMap(item => ['light', 'dark'].map(theme => ({ ...item, theme }))) .filter(({ area, name, theme }) => !FILTER || `${area}-${name}-${theme}`.includes(FILTER)); if (selectedScenarios.length === 0) { @@ -236,7 +247,7 @@ async function main() { } function createBrowserHelpers(driver, popupUrl) { - const elements = async (selector) => driver.findElements(By.css(selector)); + const elements = async selector => driver.findElements(By.css(selector)); const element = async (selector, index = 0) => { const matches = await elements(selector); if (!matches[index]) { @@ -266,21 +277,21 @@ function createBrowserHelpers(driver, popupUrl) { await waitUntil(() => visible(selector, index), `${selector} to become visible`); await (await element(selector, index)).sendKeys(Key.TAB); }; - const waitText = async (text) => { + const waitText = async text => { await waitUntil(async () => { const body = await driver.findElement(By.css('body')); return (await body.getText()).includes(text); }, `text "${text}"`); }; - const clickMenuItem = async (text) => { + const clickMenuItem = async text => { const xpath = `//*[@role="menuitem" and contains(normalize-space(.), "${text}")]`; await waitUntil(async () => { const matches = await driver.findElements(By.xpath(xpath)); - return matches.length > 0 && await matches[0].isDisplayed(); + return matches.length > 0 && (await matches[0].isDisplayed()); }, `menu item "${text}"`); await (await driver.findElements(By.xpath(xpath)))[0].click(); }; - const clickTab = async (text) => { + const clickTab = async text => { const xpath = `//*[@role="tab" and contains(normalize-space(.), "${text}")]`; await waitUntil(async () => (await driver.findElements(By.xpath(xpath))).length > 0, `tab "${text}"`); await (await driver.findElements(By.xpath(xpath)))[0].click(); @@ -311,6 +322,11 @@ function createBrowserHelpers(driver, popupUrl) { blur, click, clickMenuItem, + dismissHover: async () => { + await driver.actions().move({ origin: Origin.VIEWPORT, x: 8, y: 8 }).perform(); + const active = await driver.switchTo().activeElement(); + await driver.executeScript('arguments[0].blur()', active); + }, fill, removeAllCookies: async () => { await click(selectors.removeAllCookiesButton); @@ -333,7 +349,7 @@ function createBrowserHelpers(driver, popupUrl) { await driver.navigate().refresh(); await waitUntil(() => visible(selectors.headerNameInput), 'popup to reload'); }, - setTheme: async (theme) => { + setTheme: async theme => { await click(selectors.themeToggle); await clickMenuItem(theme === 'light' ? 'Light' : 'Dark'); await waitUntil( @@ -382,7 +398,7 @@ async function assertScreenshot(driver, snapshotName) { } } -main().catch((error) => { +main().catch(error => { console.error(error); process.exitCode = 1; }); diff --git a/src/background.ts b/src/background.ts index a999ab8b..1fab912d 100644 --- a/src/background.ts +++ b/src/background.ts @@ -1,10 +1,20 @@ import browser from 'webextension-polyfill'; -import { BrowserStorageKey, ServiceWorkerEvent } from './shared/constants'; -import { browserAction } from './shared/utils/browserAPI'; +import type { Profile, RequestHeader } from '#entities/request-profile/types'; + +import { BrowserStorageKey, RuntimeMessageType } from './shared/constants'; +import { validateCookie } from './shared/utils/cookies'; +import { countActiveHeadersForUrl, doesUrlMatchFilter } from './shared/utils/countActiveHeadersForUrl'; +import { + filterDebugLogsForProfile, + parseProfileFromUnknown, + parseProfilesFromStorage, +} from './shared/utils/debugLogsExport'; +import { validateHeader } from './shared/utils/headers'; import { logger, LogLevel } from './shared/utils/logger'; import { setBrowserCookies } from './shared/utils/setBrowserCookies'; import { setBrowserHeaders } from './shared/utils/setBrowserHeaders'; +import { setIconBadge } from './shared/utils/setIconBadge'; import { enableExtensionReload } from './utils/extension-reload'; logger.configure({ @@ -41,13 +51,35 @@ async function getCurrentTabUrl(): Promise { logger.info(' - Profiles:', result[BrowserStorageKey.Profiles] ? 'Present' : 'Missing'); logger.info(' - Selected Profile:', result[BrowserStorageKey.SelectedProfile] || 'None'); logger.info(' - Is Paused:', result[BrowserStorageKey.IsPaused] || false); - logger.groupEnd(); + + // Log profile count if present + let activeHeadersCount = 0; + if (result[BrowserStorageKey.Profiles]) { + try { + const profiles = JSON.parse(result[BrowserStorageKey.Profiles] as string); + logger.info(` - Profiles count: ${profiles.length}`); + if (profiles.length > 0) { + logger.info(' - Profile names:', profiles.map((p: Profile) => p.name || p.id).join(', ')); + + // Count active headers for the badge + const selectedProfile = profiles.find((p: Profile) => p.id === result[BrowserStorageKey.SelectedProfile]); + if (selectedProfile) { + activeHeadersCount = selectedProfile.requestHeaders?.filter((h: RequestHeader) => !h.disabled).length || 0; + logger.info(` - Active headers count: ${activeHeadersCount}`); + } + } + } catch (error) { + logger.warn(' - Failed to parse profiles:', error); + } + } logger.debug('Background script load storage data:', JSON.stringify(result, null, 2)); + logger.groupEnd(); - const currentTabUrl = await getCurrentTabUrl(); - await setBrowserHeaders(result, currentTabUrl); - logger.info(`🏷️ Initial badge set for URL: ${currentTabUrl}`); + // DNR mutations are deliberately performed only by applyHeadersFromStorageQueue. + // sw-init below owns the queue during worker startup, so a direct update here + // could race with its stale-rule cleanup on slower machines (notably Windows). + logger.info('🏷️ Initial DNR sync is scheduled by sw-init queue'); } catch (error) { logger.error('Failed to check storage on background script load:', error); } @@ -59,24 +91,353 @@ if (process.env.NODE_ENV === 'development') { logger.debug('Extension auto-reload enabled for development mode'); } -const BADGE_COLOR = '#ffffff'; +const MAX_DEBUG_LOGS = 3000; + +const workerBootId = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; +const workerBootAt = Date.now(); +let debugLogSeq = 0; +const debugLogsBuffer: Array<{ seq: number; timestamp: number; level: string; message: string; args: string[] }> = []; + +let applyInProgress = false; +let applyPending = false; +let applyCounter = 0; +let lastRequestedReason = 'unknown'; +let lastAppliedStorageFingerprint: string | null = null; +let lastAppliedMeta: { seq: number; updatedAt: number } = { seq: 0, updatedAt: 0 }; + +function safeStringify(value: unknown): string { + const seen = new WeakSet(); + return JSON.stringify( + value, + (_, currentValue) => { + if (currentValue instanceof Error) { + return { + name: currentValue.name, + message: currentValue.message, + stack: currentValue.stack, + }; + } + if (currentValue && typeof currentValue === 'object') { + if (seen.has(currentValue as object)) { + return '[Circular]'; + } + seen.add(currentValue as object); + } + return currentValue; + }, + 2, + ); +} -async function notify(message: ServiceWorkerEvent) { - logger.debug('Received message:', message); +function appendDebugLog(entry: { timestamp: number; level: string; message: string; args: string[] }): void { + debugLogSeq += 1; + debugLogsBuffer.push({ seq: debugLogSeq, ...entry }); + if (debugLogsBuffer.length > MAX_DEBUG_LOGS) { + debugLogsBuffer.splice(0, debugLogsBuffer.length - MAX_DEBUG_LOGS); + } +} - if (message === ServiceWorkerEvent.Reload) { - logger.info('πŸ”„ Reloading headers configuration'); +logger.setExternalSink(({ level, message, args, timestamp }) => { + const serializedArgs = args.map(item => safeStringify(item)); + appendDebugLog({ timestamp, level, message, args: serializedArgs }); +}); - const result = await browser.storage.local.get([ - BrowserStorageKey.Profiles, - BrowserStorageKey.SelectedProfile, - BrowserStorageKey.IsPaused, - ]); +function getApplyHealthSnapshot() { + const now = Date.now(); + return { + workerBootId, + workerUptimeMs: now - workerBootAt, + applyInProgress, + applyPending, + applyCounter, + lastRequestedReason, + lastAppliedStorageFingerprint, + lastAppliedMeta, + }; +} + +async function buildDebugLogsExportPayload(options?: { profileId?: string; profile?: unknown }) { + const now = Date.now(); + const storage = await browser.storage.local.get([ + BrowserStorageKey.Profiles, + BrowserStorageKey.SelectedProfile, + BrowserStorageKey.IsPaused, + BrowserStorageKey.HeadersConfigMeta, + ]); + + let dynamicRules: unknown[] = []; + let sessionRules: unknown[] = []; + try { + dynamicRules = await browser.declarativeNetRequest.getDynamicRules(); + } catch { + dynamicRules = []; + } + try { + sessionRules = await browser.declarativeNetRequest.getSessionRules(); + } catch { + sessionRules = []; + } + + const payload = { + exportedAt: new Date(now).toISOString(), + scope: 'general' as const, + worker: { + bootId: workerBootId, + bootedAt: new Date(workerBootAt).toISOString(), + uptimeMs: now - workerBootAt, + }, + health: getApplyHealthSnapshot(), + storage, + dnr: { + dynamicRulesCount: dynamicRules.length, + sessionRulesCount: sessionRules.length, + dynamicRules, + sessionRules, + }, + logs: debugLogsBuffer, + }; + + if (!options) { + return payload; + } + + const profileId = options.profileId; + const profile = + parseProfileFromUnknown(options.profile) ?? parseProfilesFromStorage(storage).find(item => item.id === profileId); + + if (!profile) { + throw new Error(`Profile "${profileId ?? ''}" not found`); + } + + return filterDebugLogsForProfile(payload, profile); +} + +function storageFingerprint(result: Record): string { + const profiles = result[BrowserStorageKey.Profiles]; + const selected = result[BrowserStorageKey.SelectedProfile]; + const paused = result[BrowserStorageKey.IsPaused]; + + // Keep it cheap and stable: correlate across logs without huge payloads. + // If profiles is a big JSON string, we don't want to log it fully. + let profilesStr = ''; + if (typeof profiles === 'string') { + profilesStr = profiles; + } else if (profiles !== undefined) { + profilesStr = JSON.stringify(profiles); + } + const selectedStr = typeof selected === 'string' ? selected : String(selected ?? ''); + const pausedStr = paused === undefined ? '' : String(Boolean(paused)); + + // Simple FNV-1a 32-bit hash for correlation (no deps). + const input = `${selectedStr}|${pausedStr}|${profilesStr}`; + let hash = 0x811c9dc5; + for (let i = 0; i < input.length; i++) { + hash ^= input.charCodeAt(i); + + hash = (hash * 0x01000193) >>> 0; + } + return `fnv1a32:${hash.toString(16)}:len:${input.length}`; +} + +function normalizeHeadersConfigMeta(value: unknown): { seq: number; updatedAt: number } { + if (value && typeof value === 'object') { + const obj = value as Record; + const seq = typeof obj.seq === 'number' && Number.isFinite(obj.seq) ? obj.seq : 0; + const updatedAt = typeof obj.updatedAt === 'number' && Number.isFinite(obj.updatedAt) ? obj.updatedAt : 0; + return { seq, updatedAt }; + } + if (typeof value === 'string') { + try { + return normalizeHeadersConfigMeta(JSON.parse(value) as unknown); + } catch { + return { seq: 0, updatedAt: 0 }; + } + } + return { seq: 0, updatedAt: 0 }; +} + +function isNewerMeta(next: { seq: number; updatedAt: number }, prev: { seq: number; updatedAt: number }) { + if (next.seq !== prev.seq) return next.seq > prev.seq; + return next.updatedAt > prev.updatedAt; +} + +/** + * Refreshes the badge for the active tab without touching DNR rules. + * + * Tab activation/navigation used to call setBrowserHeaders directly just to refresh + * the badge. That bypassed the apply queue and could overlap with a storage-driven + * DNR update or the sw-init stale-rule cleanup. + */ +async function refreshBadgeFromStorage(result: Record, currentTabUrl: string | undefined) { + const isPaused = result[BrowserStorageKey.IsPaused] === true; + let profiles: Profile[] = []; + + try { + const profilesData = result[BrowserStorageKey.Profiles]; + if (typeof profilesData === 'string') { + profiles = JSON.parse(profilesData) as Profile[]; + } else if (Array.isArray(profilesData)) { + profiles = profilesData as Profile[]; + } + } catch (error) { + logger.warn('Failed to parse profiles while refreshing badge:', error); + } - logger.info('πŸ“¦ Storage data for reload:', result); - await Promise.all([setBrowserHeaders(result, await getCurrentTabUrl()), setBrowserCookies(result)]); + const selectedProfileId = result[BrowserStorageKey.SelectedProfile]; + const selectedProfile = + profiles.find(profile => profile.id === selectedProfileId) ?? (profiles.length > 0 ? profiles[0] : undefined); + const activeHeaders = (selectedProfile?.requestHeaders ?? []).filter( + ({ disabled, name, value }) => !disabled && validateHeader(name, value), + ); + const activeUrlFilters = (selectedProfile?.urlFilters ?? []) + .filter(({ disabled, value }) => !disabled && value.trim()) + .map(({ value }) => value.trim()); + const activeCookies = (selectedProfile?.requestCookies ?? []).filter( + ({ disabled, name, value }) => !disabled && validateCookie(name, value), + ); + const urlMatchesCurrentTab = + activeUrlFilters.length === 0 || + !currentTabUrl || + activeUrlFilters.some(filter => doesUrlMatchFilter(currentTabUrl, filter)); + const activeRulesCount = + countActiveHeadersForUrl(activeHeaders, activeUrlFilters, currentTabUrl) + + (urlMatchesCurrentTab ? activeCookies.length : 0); + + await setIconBadge({ isPaused, activeRulesCount }); +} + +async function applyHeadersFromStorageQueue(reason: string) { + lastRequestedReason = reason; + applyPending = true; + + logger.debug('πŸ“₯ applyHeadersFromStorageQueue called:', { + reason, + applyInProgress, + applyPending, + lastAppliedStorageFingerprint, + lastAppliedMeta, + }); + + if (applyInProgress) { + logger.debug('⏳ Apply already in progress, queued for later'); + return; + } + applyInProgress = true; + logger.debug('πŸ”’ Apply lock acquired'); + + try { + while (applyPending) { + applyPending = false; + const applyId = ++applyCounter; + + const startedAt = Date.now(); + const result = await browser.storage.local.get([ + BrowserStorageKey.Profiles, + BrowserStorageKey.SelectedProfile, + BrowserStorageKey.IsPaused, + BrowserStorageKey.HeadersConfigMeta, + ]); + const fp = storageFingerprint(result); + const meta = normalizeHeadersConfigMeta(result[BrowserStorageKey.HeadersConfigMeta]); + + logger.group('🧡 Headers apply (queued)', true); + logger.info('Apply request:', { + applyId, + reason: lastRequestedReason, + startedAt, + elapsedMsBeforeApply: Date.now() - startedAt, + storageFingerprint: fp, + headersConfigMeta: meta, + lastAppliedMeta, + }); + + try { + const isNewer = isNewerMeta(meta, lastAppliedMeta); + const isSameFingerprint = lastAppliedStorageFingerprint === fp; + + logger.debug('πŸ” Apply decision check:', { + applyId, + isNewerMeta: isNewer, + isSameFingerprint, + meta, + lastAppliedMeta, + fp, + lastAppliedStorageFingerprint, + }); + + // On the first worker run old installations may not yet have config meta. + // The fingerprint still proves there is an unapplied configuration, so do + // not leave its valid headers absent just because meta is { seq: 0 }. + if (!isNewer && lastAppliedStorageFingerprint !== null) { + logger.warn('⏭️ Apply skipped (stale meta):', { + applyId, + reason: `meta.seq=${meta.seq} <= lastApplied.seq=${lastAppliedMeta.seq}, meta.updatedAt=${meta.updatedAt} <= lastApplied.updatedAt=${lastAppliedMeta.updatedAt}`, + headersConfigMeta: meta, + lastAppliedMeta, + }); + continue; + } + if (isSameFingerprint) { + // Meta changed but effective config didn't. Still advance meta to avoid replaying. + const prevMeta = { ...lastAppliedMeta }; + lastAppliedMeta = meta; + logger.info('⏭️ Apply skipped (no effective changes):', { + applyId, + storageFingerprint: fp, + headersConfigMeta: meta, + prevMeta, + note: 'Meta advanced to prevent replay', + }); + } else { + const prevFp = lastAppliedStorageFingerprint; + const prevMeta = { ...lastAppliedMeta }; + const currentTabUrl = await getCurrentTabUrl(); + + const [{ stuckRuleIds }] = await Promise.all([ + setBrowserHeaders(result, { + applyId, + reason: lastRequestedReason, + storageFingerprint: fp, + currentTabUrl, + }), + setBrowserCookies(result), + ]); + await browser.storage.local.set({ + [BrowserStorageKey.DnrHealth]: { ok: stuckRuleIds.length === 0, stuckRuleIds, updatedAt: Date.now() }, + }); + + lastAppliedStorageFingerprint = fp; + lastAppliedMeta = meta; + + logger.info('βœ… Apply done:', { + applyId, + elapsedMsTotal: Date.now() - startedAt, + fingerprintChange: `${prevFp} β†’ ${fp}`, + metaChange: `seq:${prevMeta.seq}β†’${meta.seq}, updatedAt:${prevMeta.updatedAt}β†’${meta.updatedAt}`, + }); + } + } catch (error) { + logger.error('❌ Apply failed (state NOT updated, will retry on next change):', { + applyId, + error, + stateRemains: { + lastAppliedStorageFingerprint, + lastAppliedMeta, + }, + attemptedFingerprint: fp, + attemptedMeta: meta, + }); + } finally { + logger.groupEnd(); + } + } + } finally { + applyInProgress = false; + logger.debug('πŸ”“ Apply lock released:', { + lastAppliedStorageFingerprint, + lastAppliedMeta, + }); } - return undefined; } browser.runtime.onStartup.addListener(async function () { @@ -86,6 +447,7 @@ browser.runtime.onStartup.addListener(async function () { BrowserStorageKey.Profiles, BrowserStorageKey.SelectedProfile, BrowserStorageKey.IsPaused, + BrowserStorageKey.HeadersConfigMeta, ]); // Detailed logging of storage contents on startup @@ -95,43 +457,76 @@ browser.runtime.onStartup.addListener(async function () { logger.info(' - Is Paused:', result[BrowserStorageKey.IsPaused] || false); logger.debug('Startup storage data:', JSON.stringify(result, null, 2)); + // Log profile count if present + if (result[BrowserStorageKey.Profiles]) { + try { + const profiles = JSON.parse(result[BrowserStorageKey.Profiles] as string); + logger.info(` - Profiles count: ${profiles.length}`); + if (profiles.length > 0) { + logger.info(' - Profile names:', profiles.map((p: Profile) => p.name || p.id).join(', ')); + } + } catch (error) { + logger.warn(' - Failed to parse profiles:', error); + } + } + logger.debug('Startup storage data:', result); if (Object.keys(result).length) { logger.info('πŸš€ Storage data found, setting browser headers on startup'); - try { - await Promise.all([setBrowserHeaders(result, await getCurrentTabUrl()), setBrowserCookies(result)]); - } catch (error) { - logger.error('Failed to set browser headers on startup:', error); - } + await applyHeadersFromStorageQueue('runtime.onStartup'); } else { logger.info('πŸ“­ No storage data found on startup - extension will start with default settings'); } }); +browser.runtime.onMessage.addListener((message: unknown) => { + if (!message || typeof message !== 'object') return undefined; + const payload = message as { type?: string; scope?: unknown; profileId?: unknown; profile?: unknown }; + if (payload.type !== RuntimeMessageType.ExportDebugLogs) return undefined; + const isProfileScope = payload.scope === 'profile'; + const profileId = typeof payload.profileId === 'string' ? payload.profileId : undefined; + return buildDebugLogsExportPayload(isProfileScope ? { profileId, profile: payload.profile } : undefined) + .then(result => ({ ok: true, result })) + .catch(error => ({ ok: false, error: safeStringify(error) })); +}); + browser.storage.onChanged.addListener(async (changes, areaName) => { - logger.debug('Storage changes detected in area:', areaName, changes); + logger.debug('Storage changes detected in area:', areaName); if (areaName === 'local') { - const relevantChanges = [ + const relevantKeys = [ BrowserStorageKey.Profiles, BrowserStorageKey.SelectedProfile, BrowserStorageKey.IsPaused, - ].some(key => Object.keys(changes).includes(key)); - - if (relevantChanges) { - logger.info('πŸ“ Relevant storage changes detected, updating headers'); - const result = await browser.storage.local.get([ - BrowserStorageKey.Profiles, - BrowserStorageKey.SelectedProfile, - BrowserStorageKey.IsPaused, - ]); - logger.debug('Storage changes data:', result); - try { - await Promise.all([setBrowserHeaders(result, await getCurrentTabUrl()), setBrowserCookies(result)]); - } catch (error) { - logger.error('Failed to set browser headers on storage change:', error); + BrowserStorageKey.HeadersConfigMeta, + ]; + const changedKeys = Object.keys(changes); + const relevantChangedKeys = relevantKeys.filter(key => changedKeys.includes(key)); + + if (relevantChangedKeys.length > 0) { + // Log details about what changed + const changeDetails: Record = {}; + for (const key of relevantChangedKeys) { + const change = changes[key]; + changeDetails[key] = { + hadOldValue: change?.oldValue !== undefined, + hasNewValue: change?.newValue !== undefined, + }; } + + logger.info('πŸ“ Relevant storage changes detected:', { + changedKeys: relevantChangedKeys, + changeDetails, + currentQueueState: { + lastAppliedStorageFingerprint, + lastAppliedMeta, + applyInProgress, + applyPending, + }, + }); + + await applyHeadersFromStorageQueue('storage.onChanged'); } } }); @@ -143,6 +538,7 @@ browser.runtime.onInstalled.addListener(async details => { BrowserStorageKey.Profiles, BrowserStorageKey.SelectedProfile, BrowserStorageKey.IsPaused, + BrowserStorageKey.HeadersConfigMeta, ]); // Detailed logging of storage contents on install/update @@ -153,20 +549,30 @@ browser.runtime.onInstalled.addListener(async details => { logger.debug('Install/update storage data:', JSON.stringify(result, null, 2)); logger.groupEnd(); + // Log profile count if present + if (result[BrowserStorageKey.Profiles]) { + try { + const profiles = JSON.parse(result[BrowserStorageKey.Profiles] as string); + logger.info(` - Profiles count: ${profiles.length}`); + if (profiles.length > 0) { + logger.info(' - Profile names:', profiles.map((p: Profile) => p.name || p.id).join(', ')); + } + } catch (error) { + logger.warn(' - Failed to parse profiles:', error); + } + } + logger.debug('Install/update storage data:', result); if (Object.keys(result).length) { logger.info('πŸ”§ Storage data found, initializing browser headers on install/update'); - try { - await Promise.all([setBrowserHeaders(result, await getCurrentTabUrl()), setBrowserCookies(result)]); - } catch (error) { - logger.error('Failed to set browser headers on install/update:', error); - } + await applyHeadersFromStorageQueue(`runtime.onInstalled:${details.reason}`); } else { logger.info('πŸ“­ No storage data found on install/update - extension will start with default settings'); } }); +// Badge depends on the active tab URL when URL filters are configured. browser.tabs.onActivated.addListener(async activeInfo => { logger.debug('Tab activated:', activeInfo); @@ -179,12 +585,12 @@ browser.tabs.onActivated.addListener(async activeInfo => { logger.debug('Tab activation storage data:', result); if (Object.keys(result).length) { - logger.info('πŸ“± Tab activated, updating headers'); + logger.info('πŸ“± Tab activated, updating badge'); try { const tab = await browser.tabs.get(activeInfo.tabId); - await setBrowserHeaders(result, tab.url); + await refreshBadgeFromStorage(result, tab.url); } catch (error) { - logger.error('Failed to set browser headers on tab activation:', error); + logger.error('Failed to refresh badge on tab activation:', error); } } else { logger.debug('No storage data found on tab activation'); @@ -207,18 +613,76 @@ browser.tabs.onUpdated.addListener(async (tabId, changeInfo, tab) => { if (Object.keys(result).length) { try { - await setBrowserHeaders(result, tab.url); + await refreshBadgeFromStorage(result, tab.url); } catch (error) { - logger.error('Failed to set browser headers on tab URL update:', error); + logger.error('Failed to refresh badge on tab URL update:', error); } } }); -browserAction.setBadgeBackgroundColor({ color: BADGE_COLOR }); +// Sync DNR rules on every service worker startup. +// +// MV3 service workers are killed by Chrome after ~30s of inactivity and restarted on demand. +// onStartup only fires on browser start; onInstalled only fires on extension install/update. +// Neither fires on a plain SW restart, so stale dynamic rules from the previous session +// (which persist across SW restarts) would never be cleaned up until storage changes. +// +// By triggering an apply here, we reconcile DNR state with storage on every SW start. +// The meta/fingerprint deduplication in applyHeadersFromStorageQueue prevents redundant applies. +// +// IMPORTANT: Before starting the full apply (which reads storage first, ~200ms total), +// we eagerly remove all current dynamic rules. This eliminates the window where a disabled +// header from a previous SW session is still active as a stale DNR rule (e.g. after computer +// unlock). The apply that follows will re-add only the currently enabled headers. +const SW_INIT_MAX_RETRIES = 3; +const SW_INIT_RETRY_DELAY_MS = 200; + +async function clearDynamicRulesOnSwInit(): Promise { + try { + const staleRules = await browser.declarativeNetRequest.getDynamicRules(); + if (staleRules.length === 0) { + logger.debug('🧹 sw-init: no stale dynamic rules'); + return; + } + logger.info('🧹 sw-init: clearing stale dynamic rules:', { + count: staleRules.length, + ids: staleRules.map(r => r.id), + }); + + let lastError: unknown = null; + for (let attempt = 1; attempt <= SW_INIT_MAX_RETRIES; attempt++) { + try { + await browser.declarativeNetRequest.updateDynamicRules({ + removeRuleIds: staleRules.map(r => r.id), + }); + logger.info('🧹 sw-init: stale dynamic rules cleared'); + return; + } catch (err) { + lastError = err; + logger.warn( + `⚠️ sw-init: failed to clear stale dynamic rules (attempt ${attempt}/${SW_INIT_MAX_RETRIES}):`, + err, + ); + if (attempt < SW_INIT_MAX_RETRIES) { + const delay = SW_INIT_RETRY_DELAY_MS * attempt; + await new Promise(resolve => setTimeout(resolve, delay)); + } + } + } + logger.warn('⚠️ sw-init: failed to clear stale dynamic rules after retries (apply will handle it):', lastError); + } catch (err) { + logger.warn('⚠️ sw-init: failed to clear stale dynamic rules (apply will handle it):', err); + } +} -browser.runtime.onMessage.addListener((message: unknown) => { - notify(message as ServiceWorkerEvent).catch(err => { - logger.error('Error handling message:', err); +// Hold the apply lock during sw-init clear so any concurrent storage.onChanged events +// queue up (applyPending=true) instead of racing with the DNR clear call. +// Concurrent updateDynamicRules calls cause Chrome to throw "Internal error while updating +// dynamic rules" and leave the DNR API broken for the rest of the session. +applyInProgress = true; +clearDynamicRulesOnSwInit().finally(() => { + applyInProgress = false; + applyHeadersFromStorageQueue('sw-init').catch(err => { + logger.error('❌ Failed to apply headers on SW init:', err); }); - return undefined; }); diff --git a/src/entities/app-view/__tests__/model.spec.ts b/src/entities/app-view/__tests__/model.spec.ts new file mode 100644 index 00000000..0a44aa79 --- /dev/null +++ b/src/entities/app-view/__tests__/model.spec.ts @@ -0,0 +1,24 @@ +import { allSettled, fork } from 'effector'; +import { describe, expect, it } from 'vitest'; + +import { $appView, settingsClosed, settingsOpened, settingsToggled } from '../model'; + +describe('app-view', () => { + it('opens, closes and toggles the settings view', async () => { + const scope = fork(); + + expect(scope.getState($appView)).toBe('main'); + + await allSettled(settingsOpened, { scope }); + expect(scope.getState($appView)).toBe('settings'); + + await allSettled(settingsClosed, { scope }); + expect(scope.getState($appView)).toBe('main'); + + await allSettled(settingsToggled, { scope }); + expect(scope.getState($appView)).toBe('settings'); + + await allSettled(settingsToggled, { scope }); + expect(scope.getState($appView)).toBe('main'); + }); +}); diff --git a/src/entities/app-view/index.ts b/src/entities/app-view/index.ts new file mode 100644 index 00000000..129fcd2a --- /dev/null +++ b/src/entities/app-view/index.ts @@ -0,0 +1,2 @@ +export { $appView, settingsClosed, settingsOpened, settingsToggled } from './model'; +export type { AppView } from './model'; diff --git a/src/entities/app-view/model.ts b/src/entities/app-view/model.ts new file mode 100644 index 00000000..8afe07a9 --- /dev/null +++ b/src/entities/app-view/model.ts @@ -0,0 +1,12 @@ +import { createEvent, createStore } from 'effector'; + +export type AppView = 'main' | 'settings'; + +export const settingsOpened = createEvent(); +export const settingsClosed = createEvent(); +export const settingsToggled = createEvent(); + +export const $appView = createStore('main') + .on(settingsOpened, () => 'settings') + .on(settingsClosed, () => 'main') + .on(settingsToggled, view => (view === 'settings' ? 'main' : 'settings')); diff --git a/src/entities/dnr-health/model.ts b/src/entities/dnr-health/model.ts new file mode 100644 index 00000000..14b248b5 --- /dev/null +++ b/src/entities/dnr-health/model.ts @@ -0,0 +1,29 @@ +import { createEffect, createEvent, createStore, sample } from 'effector'; +import browser from 'webextension-polyfill'; + +import { BrowserStorageKey } from '#shared/constants'; +import { initApp } from '#shared/model'; + +import { type DnrHealth, loadDnrHealthFromStorageApi } from './utils/load'; +import { parseDnrHealth } from './utils/parse'; + +export type { DnrHealth }; + +const loadDnrHealthFx = createEffect({ handler: loadDnrHealthFromStorageApi }); + +export const dnrHealthUpdated = createEvent(); + +export const $dnrHealth = createStore(null) + .on(loadDnrHealthFx.doneData, (_, health) => health) + .on(dnrHealthUpdated, (_, health) => health); + +sample({ source: initApp, target: loadDnrHealthFx }); + +// Keep $dnrHealth in sync when background writes DnrHealth to storage. +browser.storage.onChanged.addListener((changes, areaName) => { + if (areaName !== 'local') return; + if (!(BrowserStorageKey.DnrHealth in changes)) return; + + const newValue = changes[BrowserStorageKey.DnrHealth]?.newValue; + dnrHealthUpdated(parseDnrHealth(newValue)); +}); diff --git a/src/entities/dnr-health/utils/load.ts b/src/entities/dnr-health/utils/load.ts new file mode 100644 index 00000000..abd7fffa --- /dev/null +++ b/src/entities/dnr-health/utils/load.ts @@ -0,0 +1,21 @@ +import browser from 'webextension-polyfill'; + +import { BrowserStorageKey } from '#shared/constants'; + +import { parseDnrHealth } from './parse'; + +export type DnrHealth = { + ok: boolean; + stuckRuleIds: number[]; + updatedAt: number; +}; + +export async function loadDnrHealthFromStorageApi(): Promise { + try { + const response = await browser.storage.local.get([BrowserStorageKey.DnrHealth]); + const value = response[BrowserStorageKey.DnrHealth]; + return parseDnrHealth(value); + } catch { + return null; + } +} diff --git a/src/entities/dnr-health/utils/parse.ts b/src/entities/dnr-health/utils/parse.ts new file mode 100644 index 00000000..410c2cfc --- /dev/null +++ b/src/entities/dnr-health/utils/parse.ts @@ -0,0 +1,12 @@ +import type { DnrHealth } from './load'; + +export function parseDnrHealth(value: unknown): DnrHealth | null { + if (!value || typeof value !== 'object') return null; + + const raw = value as Record; + return { + ok: Boolean(raw.ok), + stuckRuleIds: Array.isArray(raw.stuckRuleIds) ? (raw.stuckRuleIds as number[]) : [], + updatedAt: typeof raw.updatedAt === 'number' ? raw.updatedAt : 0, + }; +} diff --git a/src/entities/is-paused/model.ts b/src/entities/is-paused/model.ts index 6182234b..a776ef4a 100644 --- a/src/entities/is-paused/model.ts +++ b/src/entities/is-paused/model.ts @@ -19,7 +19,10 @@ export const $isPaused = createStore(false) .on(toggleIsPaused, state => !state) .on(loadIsPausedFromStorageFx.doneData, (_, isPaused) => Boolean(isPaused)); +// Save only on explicit user toggle, not on initial load from storage. +// Same race-condition fix as in selected-request-profile.ts. sample({ + clock: toggleIsPaused, source: $isPaused, target: saveIsPausedToStorageFx, }); diff --git a/src/entities/is-paused/utils/save.ts b/src/entities/is-paused/utils/save.ts index 7bd5733a..0e0fe88b 100644 --- a/src/entities/is-paused/utils/save.ts +++ b/src/entities/is-paused/utils/save.ts @@ -1,11 +1,8 @@ -import browser from 'webextension-polyfill'; - import { BrowserStorageKey } from '#shared/constants'; -import { updateOverrideHeaders } from '#shared/utils/updateOverrideHeaders'; +import { setWithBumpedHeadersConfigMeta } from '#shared/utils/headersConfigMeta'; export async function saveIsPausedToBrowserApi(isPaused: boolean) { - await browser.storage.local.set({ + await setWithBumpedHeadersConfigMeta({ [BrowserStorageKey.IsPaused]: isPaused, }); - await updateOverrideHeaders(); } diff --git a/src/entities/request-profile/model/request-profiles.ts b/src/entities/request-profile/model/request-profiles.ts index 3180b56d..3731a30e 100644 --- a/src/entities/request-profile/model/request-profiles.ts +++ b/src/entities/request-profile/model/request-profiles.ts @@ -54,8 +54,6 @@ sample({ target: selectedRequestProfileIdChanged, }); -sample({ source: $requestProfiles, target: profilesSavedToBrowserFx }); - const profileAddedFx = attach({ source: $requestProfiles, effect: profiles => { @@ -133,6 +131,20 @@ const profileMultiRemovedFx = attach({ sample({ clock: profileMultiRemoved, target: profileMultiRemovedFx }); sample({ clock: profileMultiRemovedFx.doneData, target: $requestProfiles }); +// Save profiles only on explicit user actions, not on initial load from storage. +// This prevents a saveβ†’onChangedβ†’apply cycle when loading from storage. +sample({ + clock: [ + profileAddedFx.doneData, + profileMultiAddedFx.doneData, + profileUpdatedFx.doneData, + profileRemovedFx.doneData, + profileMultiRemovedFx.doneData, + ], + source: $requestProfiles, + target: profilesSavedToBrowserFx, +}); + // loading from browser cache sample({ clock: initApp, target: profilesLoadedFromStorageFx }); sample({ clock: profilesLoadedFromStorageFx.doneData, target: $requestProfiles }); diff --git a/src/entities/request-profile/model/selected-request-profile.ts b/src/entities/request-profile/model/selected-request-profile.ts index 8970b1e8..ac345ab5 100644 --- a/src/entities/request-profile/model/selected-request-profile.ts +++ b/src/entities/request-profile/model/selected-request-profile.ts @@ -1,12 +1,19 @@ import { createEffect, createEvent, createStore, sample } from 'effector'; import { Profile } from '../types'; -import { loadSelectedProfileFromStorageApi, saveSelectedProfileToBrowserApi } from '../utils'; +import { + ensureSelectedProfileInStorage, + loadSelectedProfileFromStorageApi, + saveSelectedProfileToBrowserApi, +} from '../utils'; export const selectedRequestProfileIdChanged = createEvent(); export const loadSelectedProfileFromStorage = createEvent(); const saveSelectedProfileToBrowserFx = createEffect(saveSelectedProfileToBrowserApi); +// Writes SelectedProfile to storage WITHOUT bumping meta β€” safe to call on load. +const ensureSelectedProfileInStorageFx = createEffect(ensureSelectedProfileInStorage); + export const $selectedRequestProfile = createStore('').on( selectedRequestProfileIdChanged, (_, profileId) => profileId, @@ -14,6 +21,17 @@ export const $selectedRequestProfile = createStore('').on( const loadSelectedProfileFromStorageFx = createEffect(loadSelectedProfileFromStorageApi); -sample({ source: $selectedRequestProfile, target: saveSelectedProfileToBrowserFx }); +// Save WITH meta bump only on explicit user actions (profile switch, add, delete). +// This prevents the meta-bump race condition with concurrent user-initiated saves. +sample({ + clock: selectedRequestProfileIdChanged, + source: $selectedRequestProfile, + target: saveSelectedProfileToBrowserFx, +}); + sample({ clock: loadSelectedProfileFromStorage, target: loadSelectedProfileFromStorageFx }); sample({ clock: loadSelectedProfileFromStorageFx.doneData, target: $selectedRequestProfile }); + +// Ensure SelectedProfile key exists in storage so the background script can always resolve +// the active profile. This does NOT bump HeadersConfigMeta, so it cannot race with user saves. +sample({ clock: loadSelectedProfileFromStorageFx.doneData, target: ensureSelectedProfileInStorageFx }); diff --git a/src/entities/request-profile/utils/save.ts b/src/entities/request-profile/utils/save.ts index 389c6684..7b969616 100644 --- a/src/entities/request-profile/utils/save.ts +++ b/src/entities/request-profile/utils/save.ts @@ -1,20 +1,29 @@ import browser from 'webextension-polyfill'; import { BrowserStorageKey } from '#shared/constants'; -import { updateOverrideHeaders } from '#shared/utils/updateOverrideHeaders'; +import { setWithBumpedHeadersConfigMeta } from '#shared/utils/headersConfigMeta'; import { Profile } from '../types'; export async function saveProfilesToBrowserApi(profiles: Profile[]) { - await browser.storage.local.set({ + await setWithBumpedHeadersConfigMeta({ [BrowserStorageKey.Profiles]: JSON.stringify(profiles), }); - await updateOverrideHeaders(); } export async function saveSelectedProfileToBrowserApi(requestHeader: string) { - await browser.storage.local.set({ + await setWithBumpedHeadersConfigMeta({ [BrowserStorageKey.SelectedProfile]: requestHeader, }); - await updateOverrideHeaders(); +} + +/** + * Write SelectedProfile to storage WITHOUT bumping HeadersConfigMeta. + * Used on initial load to ensure the background script can always find the selected profile, + * without creating a meta bump that would race with user-initiated saves. + */ +export async function ensureSelectedProfileInStorage(profileId: string) { + await browser.storage.local.set({ + [BrowserStorageKey.SelectedProfile]: profileId, + }); } diff --git a/src/features/export-debug-logs/__tests__/model.spec.ts b/src/features/export-debug-logs/__tests__/model.spec.ts new file mode 100644 index 00000000..fae440f2 --- /dev/null +++ b/src/features/export-debug-logs/__tests__/model.spec.ts @@ -0,0 +1,120 @@ +import { allSettled, fork } from 'effector'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import browser from 'webextension-polyfill'; + +import { $requestProfiles, $selectedRequestProfile } from '#entities/request-profile/model'; + +import { generalDebugLogsExported, profileDebugLogsExported } from '../model'; +import { downloadDebugLogs } from '../utils'; + +vi.mock('webextension-polyfill', () => ({ + default: { + runtime: { + sendMessage: vi.fn(), + }, + storage: { + local: { + get: vi.fn().mockResolvedValue({}), + set: vi.fn().mockResolvedValue(undefined), + }, + onChanged: { + addListener: vi.fn(), + }, + }, + }, +})); + +vi.mock('#entities/notification/utils', () => ({ + showToast: vi.fn(), +})); + +vi.mock('../utils', () => ({ + downloadDebugLogs: vi.fn(), +})); + +const sendMessage = browser.runtime.sendMessage as unknown as ReturnType; + +describe('export-debug-logs', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('downloads general debug logs', async () => { + const payload = { scope: 'general', logs: [{ message: 'hello' }] }; + sendMessage.mockResolvedValue({ ok: true, result: payload }); + + const scope = fork(); + await allSettled(generalDebugLogsExported, { scope }); + + expect(sendMessage).toHaveBeenCalledWith({ + type: 'export-debug-logs', + scope: 'general', + profileId: undefined, + profile: undefined, + }); + expect(downloadDebugLogs).toHaveBeenCalledWith(payload); + }); + + it('downloads profile debug logs for the selected profile', async () => { + const selectedProfile = { + id: 'profile-1', + name: 'Work', + requestHeaders: [], + requestCookies: [], + urlFilters: [], + }; + const payload = { scope: 'profile', profileId: 'profile-1' }; + sendMessage.mockResolvedValue({ ok: true, result: payload }); + + const scope = fork({ + values: [ + [$selectedRequestProfile, 'profile-1'], + [$requestProfiles, [selectedProfile]], + ], + }); + await allSettled(profileDebugLogsExported, { scope }); + + expect(sendMessage).toHaveBeenCalledWith({ + type: 'export-debug-logs', + scope: 'profile', + profileId: 'profile-1', + profile: selectedProfile, + }); + expect(downloadDebugLogs).toHaveBeenCalledWith(payload); + }); + + it('does not download logs when the background response is unsuccessful', async () => { + sendMessage.mockResolvedValue({ ok: false, error: 'boom' }); + + const scope = fork(); + await allSettled(generalDebugLogsExported, { scope }); + + expect(downloadDebugLogs).not.toHaveBeenCalled(); + }); + + it('falls back to the first profile when none is selected', async () => { + const fallbackProfile = { + id: 'profile-2', + name: 'Fallback', + requestHeaders: [], + requestCookies: [], + urlFilters: [], + }; + sendMessage.mockResolvedValue({ ok: true, result: { scope: 'profile' } }); + + const scope = fork({ + values: [ + [$selectedRequestProfile, ''], + [$requestProfiles, [fallbackProfile]], + ], + }); + await allSettled(profileDebugLogsExported, { scope }); + + expect(sendMessage).toHaveBeenCalledWith({ + type: 'export-debug-logs', + scope: 'profile', + profileId: 'profile-2', + profile: fallbackProfile, + }); + }); +}); diff --git a/src/features/export-debug-logs/index.ts b/src/features/export-debug-logs/index.ts new file mode 100644 index 00000000..cf5b8265 --- /dev/null +++ b/src/features/export-debug-logs/index.ts @@ -0,0 +1 @@ +export { $isExportingDebugLogs, generalDebugLogsExported, profileDebugLogsExported } from './model'; diff --git a/src/features/export-debug-logs/model.ts b/src/features/export-debug-logs/model.ts new file mode 100644 index 00000000..3b312cd7 --- /dev/null +++ b/src/features/export-debug-logs/model.ts @@ -0,0 +1,82 @@ +import { createEffect, createEvent, sample } from 'effector'; +import browser from 'webextension-polyfill'; + +import { notificationAdded } from '#entities/notification/model'; +import { NotificationInfo, NotificationVariant } from '#entities/notification/types'; +import { $requestProfiles, $selectedRequestProfile } from '#entities/request-profile/model'; +import { RuntimeMessageType } from '#shared/constants'; + +import { downloadDebugLogs } from './utils'; + +export const generalDebugLogsExported = createEvent(); +export const profileDebugLogsExported = createEvent(); + +type ExportDebugLogsResponse = { + ok?: boolean; + result?: unknown; + error?: string; +}; + +type ExportDebugLogsParams = { + scope: 'general' | 'profile'; + profileId?: string; + profile?: unknown; +}; + +const exportDebugLogsFx = createEffect(async ({ scope, profileId, profile }: ExportDebugLogsParams) => { + const response = (await browser.runtime.sendMessage({ + type: RuntimeMessageType.ExportDebugLogs, + scope, + profileId, + profile, + })) as ExportDebugLogsResponse | undefined; + + if (!response?.ok || response.result == null) { + throw new Error(response?.error || 'Failed to export debug logs'); + } + + downloadDebugLogs(response.result); +}); + +export const $isExportingDebugLogs = exportDebugLogsFx.pending; + +sample({ + clock: generalDebugLogsExported, + fn: (): ExportDebugLogsParams => ({ scope: 'general' }), + target: exportDebugLogsFx, +}); + +sample({ + clock: profileDebugLogsExported, + source: { + profileId: $selectedRequestProfile, + profiles: $requestProfiles, + }, + fn: ({ profileId, profiles }): ExportDebugLogsParams => { + const profile = profiles.find(item => item.id === profileId) ?? profiles[0]; + return { + scope: 'profile', + profileId: profile?.id, + profile, + }; + }, + target: exportDebugLogsFx, +}); + +sample({ + clock: exportDebugLogsFx.done, + fn: (): NotificationInfo => ({ + variant: NotificationVariant.Default, + message: 'Debug logs downloaded', + }), + target: notificationAdded, +}); + +sample({ + clock: exportDebugLogsFx.failData, + fn: (): NotificationInfo => ({ + variant: NotificationVariant.Default, + message: 'Failed to export debug logs', + }), + target: notificationAdded, +}); diff --git a/src/features/export-debug-logs/utils/__tests__/downloadDebugLogs.spec.ts b/src/features/export-debug-logs/utils/__tests__/downloadDebugLogs.spec.ts new file mode 100644 index 00000000..33623e2f --- /dev/null +++ b/src/features/export-debug-logs/utils/__tests__/downloadDebugLogs.spec.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest'; + +import { buildDebugLogsFilename } from '../downloadDebugLogs'; + +describe('buildDebugLogsFilename', () => { + const now = new Date(2026, 8, 9, 7, 5, 4); + + it('builds a timestamped debug logs filename', () => { + expect(buildDebugLogsFilename(now)).toBe('Cloudhood_debug_logs_2026-9-9_7-5-4.txt'); + }); + + it('includes a sanitized profile name for profile logs', () => { + expect(buildDebugLogsFilename(now, 'profile', 'Work / QA')).toBe( + 'Cloudhood_debug_logs_profile_Work_QA_2026-9-9_7-5-4.txt', + ); + }); +}); diff --git a/src/features/export-debug-logs/utils/downloadDebugLogs.ts b/src/features/export-debug-logs/utils/downloadDebugLogs.ts new file mode 100644 index 00000000..f996d3a9 --- /dev/null +++ b/src/features/export-debug-logs/utils/downloadDebugLogs.ts @@ -0,0 +1,29 @@ +export function buildDebugLogsFilename( + now: Date, + scope: 'general' | 'profile' = 'general', + profileName?: string, +): string { + const timestamp = `${now.getFullYear()}-${now.getMonth() + 1}-${now.getDate()}_${now.getHours()}-${now.getMinutes()}-${now.getSeconds()}`; + + if (scope === 'profile') { + const safeName = (profileName || 'profile').replace(/[^a-zA-Z0-9_-]+/g, '_').slice(0, 40); + return `Cloudhood_debug_logs_profile_${safeName}_${timestamp}.txt`; + } + + return `Cloudhood_debug_logs_${timestamp}.txt`; +} + +export function downloadDebugLogs(payload: unknown, now: Date = new Date()): void { + const scopedPayload = payload as { scope?: 'general' | 'profile'; profileName?: string; profile?: { name?: string } }; + const content = JSON.stringify(payload, null, 2); + const blob = new Blob([content], { type: 'text/plain;charset=utf-8' }); + const a = document.createElement('a'); + a.href = window.URL.createObjectURL(blob); + a.download = buildDebugLogsFilename( + now, + scopedPayload.scope, + scopedPayload.profileName ?? scopedPayload.profile?.name, + ); + a.click(); + window.URL.revokeObjectURL(a.href); +} diff --git a/src/features/export-debug-logs/utils/index.ts b/src/features/export-debug-logs/utils/index.ts new file mode 100644 index 00000000..566a48cd --- /dev/null +++ b/src/features/export-debug-logs/utils/index.ts @@ -0,0 +1 @@ +export { buildDebugLogsFilename, downloadDebugLogs } from './downloadDebugLogs'; diff --git a/src/pages/main/Main.tsx b/src/pages/main/Main.tsx index dc7c5a37..f41f7af1 100644 --- a/src/pages/main/Main.tsx +++ b/src/pages/main/Main.tsx @@ -2,18 +2,24 @@ import { useUnit } from 'effector-react'; import { useMemo } from 'react'; import { Divider } from '@cloud-ru/ds-divider'; +import { WarningSVG } from '@cloud-ru/ds-icons/interface/system'; +import { $appView } from '#entities/app-view'; +import { $dnrHealth } from '#entities/dnr-health/model'; import { $selectedProfileIndex } from '#entities/request-profile/model'; import { profileColorList } from '#shared/assets/colors'; import { Header } from '#widgets/header'; import { LazyModals } from '#widgets/modals'; +import { Settings } from '#widgets/settings'; import { Sidebar } from '#widgets/sidebar'; import { ProfileActions } from './components/ProfileActions'; import * as S from './styled'; export function MainPage() { - const [selectedProfileIndex] = useUnit([$selectedProfileIndex]); + const [selectedProfileIndex, dnrHealth, appView] = useUnit([$selectedProfileIndex, $dnrHealth, $appView]); + const hasDnrMismatch = dnrHealth !== null && !dnrHealth.ok; + const isSettingsView = appView === 'settings'; const colorMap = useMemo( () => profileColorList[selectedProfileIndex % profileColorList.length], @@ -29,11 +35,26 @@ export function MainPage() { -
- - - - + {isSettingsView ? ( + + ) : ( + <> +
+ + + + {hasDnrMismatch && ( + + + + Some headers couldn't be applied by Chrome. Try toggling them or restarting the browser. + + + )} + + + + )} diff --git a/src/pages/main/styled.ts b/src/pages/main/styled.ts index b7b4f1a5..baee9e46 100644 --- a/src/pages/main/styled.ts +++ b/src/pages/main/styled.ts @@ -29,3 +29,19 @@ export const ColorLine = styled.div<{ color: string }>` width: 4px; background: ${({ color }) => color}; `; + +export const DnrWarningBanner = styled.div` + display: flex; + align-items: flex-start; + gap: 8px; + padding: 8px 12px; + background: ${themeVars.sn.theme.color.orange.background}; + color: ${themeVars.sn.theme.color.orange.text}; + font-size: 12px; + line-height: 16px; + + svg { + color: ${themeVars.sn.theme.color.orange.accent}; + flex-shrink: 0; + } +`; diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 0decf3a0..b10e029f 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -2,11 +2,15 @@ export enum BrowserStorageKey { Profiles = 'requestHeaderProfilesV1', SelectedProfile = 'selectedHeaderProfileV1', IsPaused = 'isPausedV1', + /** Monotonic version meta for any header-config change */ + HeadersConfigMeta = 'headersConfigMetaV1', ThemeMode = 'themeMode', + /** DNR health state written by background after each apply */ + DnrHealth = 'dnrHealthV1', } -export enum ServiceWorkerEvent { - Reload = 'reload', +export enum RuntimeMessageType { + ExportDebugLogs = 'export-debug-logs', } export enum Extensions { diff --git a/src/shared/utils/__tests__/debugLogsExport.spec.ts b/src/shared/utils/__tests__/debugLogsExport.spec.ts new file mode 100644 index 00000000..f4f5b257 --- /dev/null +++ b/src/shared/utils/__tests__/debugLogsExport.spec.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from 'vitest'; + +import { BrowserStorageKey } from '#shared/constants'; + +import { + COUNTERACT_BASE_ID, + type DebugLogsExportPayload, + type DebugLogsProfile, + filterDebugLogsForProfile, + getProfileRuleIds, + parseProfileFromUnknown, + parseProfilesFromStorage, +} from '../debugLogsExport'; + +const profile: DebugLogsProfile = { + id: 'profile-1', + name: 'Work', + requestHeaders: [{ id: 42, name: 'X-Test', value: 'abc' }], + requestCookies: [{ name: 'session', value: 'tok' }], + urlFilters: [], +}; + +function createPayload(overrides: Partial = {}): DebugLogsExportPayload { + return { + exportedAt: '2026-09-09T00:00:00.000Z', + scope: 'general', + worker: { bootId: 'w1' }, + health: { applyCounter: 1 }, + storage: { + [BrowserStorageKey.Profiles]: JSON.stringify([profile]), + [BrowserStorageKey.SelectedProfile]: 'profile-1', + [BrowserStorageKey.IsPaused]: false, + }, + dnr: { + dynamicRulesCount: 2, + sessionRulesCount: 1, + dynamicRules: [{ id: 42 }, { id: 99 }], + sessionRules: [ + { id: COUNTERACT_BASE_ID, action: { requestHeaders: [{ header: 'X-Test' }] } }, + { id: COUNTERACT_BASE_ID + 1, action: { requestHeaders: [{ header: 'Other' }] } }, + ], + }, + logs: [ + { seq: 1, timestamp: 1, level: 'INFO', message: 'Apply profile-1', args: [] }, + { seq: 2, timestamp: 2, level: 'INFO', message: 'Unrelated', args: ['other'] }, + { seq: 3, timestamp: 3, level: 'INFO', message: 'Header', args: ['X-Test'] }, + ], + ...overrides, + }; +} + +describe('debugLogsExport', () => { + it('parses profiles from a JSON storage string', () => { + expect(parseProfilesFromStorage({ [BrowserStorageKey.Profiles]: JSON.stringify([profile]) })).toEqual([profile]); + }); + + it('collects header and counteracting rule ids for a profile', () => { + expect(getProfileRuleIds(profile)).toEqual(new Set([42])); + }); + + it('keeps only profile-related rules and logs', () => { + const filtered = filterDebugLogsForProfile(createPayload(), profile); + + expect(filtered.scope).toBe('profile'); + expect(filtered.profileId).toBe('profile-1'); + expect(filtered.dnr.dynamicRules).toEqual([{ id: 42 }]); + expect(filtered.dnr.sessionRules).toEqual([ + { id: COUNTERACT_BASE_ID, action: { requestHeaders: [{ header: 'X-Test' }] } }, + ]); + expect(filtered.logs.map(entry => entry.seq)).toEqual([1, 3]); + expect(filtered.profile).toEqual(profile); + }); + + it('parses a profile payload from an untrusted message', () => { + expect(parseProfileFromUnknown({ id: 'profile-1', name: 'Work' })).toEqual({ + id: 'profile-1', + name: 'Work', + requestHeaders: undefined, + requestCookies: undefined, + urlFilters: undefined, + }); + expect(parseProfileFromUnknown({ name: 'missing-id' })).toBeUndefined(); + }); +}); diff --git a/src/shared/utils/__tests__/setBrowserHeaders.recovery.spec.ts b/src/shared/utils/__tests__/setBrowserHeaders.recovery.spec.ts new file mode 100644 index 00000000..a50aab63 --- /dev/null +++ b/src/shared/utils/__tests__/setBrowserHeaders.recovery.spec.ts @@ -0,0 +1,435 @@ +/** + * Unit tests for setBrowserHeaders DNR recovery behavior. + * + * Covers the bug where recoveryUpdateDynamicRules() always returned { success: true } + * even when the final DNR rule count didn't match the expected count. This caused + * disabled headers to keep being injected into requests because the stale DNR rule + * was never actually removed from Chrome's internal state. + * + * Reproduction scenario (from real bug report): + * - Profile has a header (cp-front-billing) marked as disabled: true + * - Chrome DNR still has an active rule for that header (id: 178804364) + * - apply() tries to remove the rule β†’ Chrome returns "Internal error while updating dynamic rules" + * - Recovery strategy also fails β†’ rule count still 1, expected 0 + * - Bug: recovery claimed success anyway β†’ "βœ… Rules updated successfully" logged + * - Fix: recovery now returns { success: false } on count mismatch β†’ session fallback is tried + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { BrowserStorageKey } from '#shared/constants'; + +vi.mock('webextension-polyfill', () => ({ + default: { + declarativeNetRequest: { + getDynamicRules: vi.fn(), + updateDynamicRules: vi.fn(), + getSessionRules: vi.fn(), + updateSessionRules: vi.fn(), + }, + runtime: { + id: 'test-extension-id', + getURL: vi.fn(() => ''), + }, + }, +})); + +vi.mock('../setIconBadge', () => ({ + setIconBadge: vi.fn(), +})); + +// These imports must come after vi.mock() calls +import browser from 'webextension-polyfill'; + +import { setBrowserHeaders } from '../setBrowserHeaders'; + +// Cast to access vi.fn() methods β€” these ARE vi.fn() instances because of vi.mock above +const mockDnr = browser.declarativeNetRequest as unknown as { + getDynamicRules: ReturnType; + updateDynamicRules: ReturnType; + getSessionRules: ReturnType; + updateSessionRules: ReturnType; +}; + +// Matches the header id from the real bug report +const STALE_RULE_ID = 178804364; + +function makeStaleRule() { + return { + id: STALE_RULE_ID, + action: { + type: 'modifyHeaders', + requestHeaders: [{ header: 'cp-front-billing', operation: 'set', value: 'RM-3967' }], + }, + condition: { resourceTypes: ['main_frame'] }, + }; +} + +/** Storage snapshot with one disabled header β€” addRules should be [] on apply */ +function makeStorageWithDisabledHeader() { + const profile = { + id: 'profile-1', + requestHeaders: [{ id: STALE_RULE_ID, name: 'cp-front-billing', value: 'RM-3945', disabled: true }], + urlFilters: [], + }; + return { + [BrowserStorageKey.IsPaused]: false, + [BrowserStorageKey.Profiles]: JSON.stringify([profile]), + [BrowserStorageKey.SelectedProfile]: 'profile-1', + [BrowserStorageKey.HeadersConfigMeta]: { seq: 10, updatedAt: Date.now() }, + }; +} + +const CHROME_INTERNAL_ERROR = new Error('Internal error while updating dynamic rules.'); + +describe('setBrowserHeaders – DNR recovery', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + // Sensible defaults: no session rules, session updates succeed + mockDnr.getSessionRules.mockResolvedValue([]); + mockDnr.updateSessionRules.mockResolvedValue(undefined); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + describe('happy path – disabled header rule is removed', () => { + it('calls updateDynamicRules to remove the stale rule when Chrome API succeeds', async () => { + mockDnr.getDynamicRules.mockResolvedValue([makeStaleRule()]); + mockDnr.updateDynamicRules.mockResolvedValue(undefined); + + const promise = setBrowserHeaders(makeStorageWithDisabledHeader()); + await vi.runAllTimersAsync(); + await promise; + + expect(mockDnr.updateDynamicRules).toHaveBeenCalledWith( + expect.objectContaining({ removeRuleIds: [STALE_RULE_ID], addRules: [] }), + ); + }); + + it('does not add any DNR rules when all headers are disabled', async () => { + mockDnr.getDynamicRules + .mockResolvedValueOnce([makeStaleRule()]) // initial state: stale rule present + .mockResolvedValue([]); // after removal: cleared + + mockDnr.updateDynamicRules.mockResolvedValue(undefined); + + const promise = setBrowserHeaders(makeStorageWithDisabledHeader()); + await vi.runAllTimersAsync(); + await promise; + + // Every updateDynamicRules call must have addRules: [] β€” no new rules added + for (const call of mockDnr.updateDynamicRules.mock.calls) { + const args = call[0] as { addRules: unknown[] }; + expect(args.addRules).toHaveLength(0); + } + }); + }); + + describe('Chrome "Internal error while updating dynamic rules" recovery', () => { + it('triggers session rules fallback when all dynamic rule removals fail and recovery count still mismatches', async () => { + // Chrome refuses to clear the stale rule β€” updateDynamicRules always throws, + // getDynamicRules always returns the stale rule (it was never removed) + mockDnr.getDynamicRules.mockResolvedValue([makeStaleRule()]); + mockDnr.updateDynamicRules.mockRejectedValue(CHROME_INTERNAL_ERROR); + + const promise = setBrowserHeaders(makeStorageWithDisabledHeader()); + await vi.runAllTimersAsync(); + // Should NOT throw β€” session fallback handles it gracefully + await promise; + + // Session rules fallback must have been tried as last resort + expect(mockDnr.updateSessionRules).toHaveBeenCalled(); + }); + + it('tries one-by-one rule removal in recovery when batch removal fails', async () => { + // Batch removal in recovery fails, but individual removal succeeds + mockDnr.getDynamicRules + .mockResolvedValueOnce([makeStaleRule()]) // initial state + .mockResolvedValueOnce([makeStaleRule()]) // recovery: before clear + .mockResolvedValueOnce([]) // recovery: after clear (one-by-one worked) + .mockResolvedValueOnce([]) // recovery: final check (0 === 0 β†’ success) + .mockResolvedValue([]); // final state logging + + mockDnr.updateDynamicRules + .mockRejectedValueOnce(CHROME_INTERNAL_ERROR) // main retry 1 + .mockRejectedValueOnce(CHROME_INTERNAL_ERROR) // main retry 2 + .mockRejectedValueOnce(CHROME_INTERNAL_ERROR) // main retry 3 + .mockRejectedValueOnce(CHROME_INTERNAL_ERROR) // recovery: batch removal + .mockResolvedValue(undefined); // recovery: one-by-one removal succeeds + + const promise = setBrowserHeaders(makeStorageWithDisabledHeader()); + await vi.runAllTimersAsync(); + await promise; + + // There must be a single-rule removal call (removeRuleIds has exactly 1 entry) + const singleRuleCall = mockDnr.updateDynamicRules.mock.calls.find(call => { + const args = call[0] as { removeRuleIds: number[]; addRules: unknown[] }; + return ( + args.removeRuleIds?.length === 1 && args.removeRuleIds[0] === STALE_RULE_ID && args.addRules?.length === 0 + ); + }); + expect(singleRuleCall).toBeDefined(); + + // Recovery succeeded without session fallback + expect(mockDnr.updateSessionRules).not.toHaveBeenCalled(); + }); + + it('throws when both dynamic rules and session rules APIs are broken', async () => { + mockDnr.getDynamicRules.mockResolvedValue([makeStaleRule()]); + mockDnr.updateDynamicRules.mockRejectedValue(CHROME_INTERNAL_ERROR); + mockDnr.updateSessionRules.mockRejectedValue(new Error('Session rules API also broken')); + + const promise = setBrowserHeaders(makeStorageWithDisabledHeader()); + // Suppress the unhandled rejection that fires during timer advancement + // (the rejection is properly asserted below via .rejects.toThrow()) + promise.catch(() => {}); + await vi.runAllTimersAsync(); + + await expect(promise).rejects.toThrow(); + }); + }); + + describe('stuck rule detection – only dynamic rules count', () => { + it('does not report session rule IDs as stuck even if they are not in addRules', async () => { + // A leftover counteracting session rule (id >= 1_000_000_000) exists from a previous apply. + // Dynamic rules are clean, so the apply should succeed and clean it up β€” stuckRuleIds must be []. + const counteractRule = { + id: 1_000_000_000, + action: { + type: 'modifyHeaders', + requestHeaders: [{ header: 'cp-front-service-modals', operation: 'remove' }], + }, + condition: { resourceTypes: ['main_frame'] }, + }; + + mockDnr.getDynamicRules.mockResolvedValue([]); // no stuck dynamic rules + mockDnr.updateDynamicRules.mockResolvedValue(undefined); + mockDnr.getSessionRules + .mockResolvedValueOnce([counteractRule]) // initial read β€” will be cleaned up + .mockResolvedValue([]); // after cleanup + mockDnr.updateSessionRules.mockResolvedValue(undefined); + + const promise = setBrowserHeaders(makeStorageWithDisabledHeader()); + await vi.runAllTimersAsync(); + const result = await promise; + + expect(result.stuckRuleIds).toHaveLength(0); + expect(result.stuckRuleIds).not.toContain(1_000_000_000); + }); + + it('returns stuckRuleIds only for dynamic rules that were not removed', async () => { + mockDnr.getDynamicRules.mockResolvedValue([makeStaleRule()]); + mockDnr.updateDynamicRules.mockRejectedValue(CHROME_INTERNAL_ERROR); + // Session rules: empty before and after (fallback applies []) + mockDnr.getSessionRules.mockResolvedValue([]); + mockDnr.updateSessionRules.mockResolvedValue(undefined); + + const promise = setBrowserHeaders(makeStorageWithDisabledHeader()); + await vi.runAllTimersAsync(); + const result = await promise; + + expect(result.stuckRuleIds).toEqual([STALE_RULE_ID]); + }); + }); + + describe('counteracting session rules for stuck disabled headers', () => { + it('adds a session remove rule to suppress a stuck disabled dynamic rule', async () => { + // All dynamic rule operations fail β†’ stuck rule stays. + // Since the header is disabled (addRules=[]), the stuck rule has no session counterpart β†’ + // a counteracting session rule with operation:"remove" must be added. + mockDnr.getDynamicRules.mockResolvedValue([makeStaleRule()]); + mockDnr.updateDynamicRules.mockRejectedValue(CHROME_INTERNAL_ERROR); + mockDnr.getSessionRules.mockResolvedValue([]); + mockDnr.updateSessionRules.mockResolvedValue(undefined); + + const promise = setBrowserHeaders(makeStorageWithDisabledHeader()); + await vi.runAllTimersAsync(); + await promise; + + const counteractCall = mockDnr.updateSessionRules.mock.calls.find((call: unknown[]) => { + const arg = call[0] as { + addRules?: Array<{ + id: number; + priority?: number; + action: { requestHeaders?: Array<{ operation: string; header: string }> }; + }>; + }; + const { addRules } = arg; + return addRules?.some( + rule => + rule.id >= 1_000_000_000 && + rule.priority === 2 && + rule.action.requestHeaders?.some(h => h.operation === 'remove' && h.header === 'cp-front-billing'), + ); + }); + expect(counteractCall).toBeDefined(); + }); + + it('does not remove an existing counteracting session rule when session fallback is invoked', async () => { + // Regression: sessionRulesFallback used to receive ALL removeSessionRuleIds, including + // counteracting rules (id >= 1_000_000_000). It would delete them while the stuck + // dynamic rule remained, opening a window where the disabled header was transmitted. + // Fix: counteracting rules are excluded from fallbackRemoveSessionRuleIds. + // The fallback is the one that receives addRules: [] β€” it must not remove the counteracting rule. + const COUNTERACT_RULE_ID = 1_000_000_000; + const existingCounteractRule = { + id: COUNTERACT_RULE_ID, + action: { + type: 'modifyHeaders', + requestHeaders: [{ header: 'cp-front-billing', operation: 'remove' }], + }, + condition: { resourceTypes: ['main_frame'] }, + }; + + mockDnr.getDynamicRules.mockResolvedValue([makeStaleRule()]); + mockDnr.updateDynamicRules.mockRejectedValue(CHROME_INTERNAL_ERROR); + // Counteracting rule already exists from a previous apply + mockDnr.getSessionRules.mockResolvedValue([existingCounteractRule]); + mockDnr.updateSessionRules.mockResolvedValue(undefined); + + const promise = setBrowserHeaders(makeStorageWithDisabledHeader()); + await vi.runAllTimersAsync(); + await promise; + + // The fallback calls updateSessionRules with addRules: [] β€” that call must NOT remove the counteracting rule + const fallbackCall = mockDnr.updateSessionRules.mock.calls.find((call: unknown[]) => { + const arg = call[0] as { removeRuleIds?: number[]; addRules?: unknown[] }; + return (arg.addRules?.length ?? 0) === 0; + }); + expect(fallbackCall).toBeDefined(); + const fallbackArg = (fallbackCall as [unknown])[0] as { removeRuleIds?: number[] }; + expect(fallbackArg.removeRuleIds).not.toContain(COUNTERACT_RULE_ID); + }); + + it('removes stale counteracting rule when the previously-disabled header becomes enabled', async () => { + // Scenario: header was disabled (counteracting rule added), user re-enables it. + // Dynamic rules succeed this time, so stuckRuleIds=[]. We must remove the old + // counteracting rule since the header is now enabled and no longer needs counteracting. + const COUNTERACT_RULE_ID = 1_000_000_000; + const existingCounteractRule = { + id: COUNTERACT_RULE_ID, + action: { + type: 'modifyHeaders', + requestHeaders: [{ header: 'cp-front-billing', operation: 'remove' }], + }, + condition: { resourceTypes: ['main_frame'] }, + }; + + const profile = { + id: 'profile-1', + requestHeaders: [{ id: STALE_RULE_ID, name: 'cp-front-billing', value: 'RM-3945', disabled: false }], + urlFilters: [], + }; + const storage = { + [BrowserStorageKey.IsPaused]: false, + [BrowserStorageKey.Profiles]: JSON.stringify([profile]), + [BrowserStorageKey.SelectedProfile]: 'profile-1', + [BrowserStorageKey.HeadersConfigMeta]: { seq: 11, updatedAt: Date.now() }, + }; + + mockDnr.getDynamicRules.mockResolvedValue([]); + mockDnr.updateDynamicRules.mockResolvedValue(undefined); + mockDnr.getSessionRules.mockResolvedValueOnce([existingCounteractRule]).mockResolvedValue([]); + mockDnr.updateSessionRules.mockResolvedValue(undefined); + + const promise = setBrowserHeaders(storage); + await vi.runAllTimersAsync(); + await promise; + + const cleanupCall = mockDnr.updateSessionRules.mock.calls.find((call: unknown[]) => { + const arg = call[0] as { removeRuleIds?: number[]; addRules?: unknown[] }; + return arg.removeRuleIds?.includes(COUNTERACT_RULE_ID) && (arg.addRules?.length ?? 0) === 0; + }); + expect(cleanupCall).toBeDefined(); + }); + + it('does not add a counteracting rule when the stuck header is already covered by the session fallback', async () => { + // Scenario: the user deleted a header (id=100, name="x-service") and added a new one + // with the same name but a fresh id=200. The old dynamic rule (id=100) is stuck. + // The session fallback applies the new enabled rule (id=200). Because "x-service" is + // already present in session rules, no additional counteract rule should be added. + const OLD_RULE_ID = 100; + const NEW_RULE_ID = 200; + const HEADER_NAME = 'x-service'; + + const stuckDynamicRule = { + id: OLD_RULE_ID, + action: { + type: 'modifyHeaders', + requestHeaders: [{ header: HEADER_NAME, operation: 'set', value: 'old-value' }], + }, + condition: { resourceTypes: ['main_frame'] }, + }; + + const profile = { + id: 'profile-1', + requestHeaders: [{ id: NEW_RULE_ID, name: HEADER_NAME, value: 'new-value', disabled: false }], + urlFilters: [], + }; + const storage = { + [BrowserStorageKey.IsPaused]: false, + [BrowserStorageKey.Profiles]: JSON.stringify([profile]), + [BrowserStorageKey.SelectedProfile]: 'profile-1', + [BrowserStorageKey.HeadersConfigMeta]: { seq: 10, updatedAt: Date.now() }, + }; + + const sessionFallbackRule = { + id: NEW_RULE_ID, + action: { + type: 'modifyHeaders', + requestHeaders: [{ header: HEADER_NAME, operation: 'set', value: 'new-value' }], + }, + condition: { resourceTypes: ['main_frame'] }, + }; + + mockDnr.getDynamicRules.mockResolvedValue([stuckDynamicRule]); + mockDnr.updateDynamicRules.mockRejectedValue(CHROME_INTERNAL_ERROR); + mockDnr.getSessionRules + .mockResolvedValueOnce([]) // initial read + .mockResolvedValue([sessionFallbackRule]); // after session fallback applied new-value rule + mockDnr.updateSessionRules.mockResolvedValue(undefined); + + const promise = setBrowserHeaders(storage); + await vi.runAllTimersAsync(); + await promise; + + // No counteracting rule (id >= 1_000_000_000) should have been added + const counteractCall = mockDnr.updateSessionRules.mock.calls.find((call: unknown[]) => + (call[0] as { addRules?: Array<{ id: number }> })?.addRules?.some(rule => rule.id >= 1_000_000_000), + ); + expect(counteractCall).toBeUndefined(); + }); + + it('removes stale counteracting rules when stuckRuleIds is empty (recovered from previous broken session)', async () => { + // Scenario: previous apply left a counteracting rule, but dynamic rules have since been + // cleared (e.g. browser restart). Now stuckRuleIds=[], we must remove the stale counteracting rule. + const COUNTERACT_RULE_ID = 1_000_000_000; + const existingCounteractRule = { + id: COUNTERACT_RULE_ID, + action: { + type: 'modifyHeaders', + requestHeaders: [{ header: 'cp-front-billing', operation: 'remove' }], + }, + condition: { resourceTypes: ['main_frame'] }, + }; + + mockDnr.getDynamicRules.mockResolvedValue([]); + mockDnr.updateDynamicRules.mockResolvedValue(undefined); + mockDnr.getSessionRules.mockResolvedValueOnce([existingCounteractRule]).mockResolvedValue([]); + mockDnr.updateSessionRules.mockResolvedValue(undefined); + + const promise = setBrowserHeaders(makeStorageWithDisabledHeader()); + await vi.runAllTimersAsync(); + await promise; + + const cleanupCall = mockDnr.updateSessionRules.mock.calls.find((call: unknown[]) => { + const arg = call[0] as { removeRuleIds?: number[] }; + return arg.removeRuleIds?.includes(COUNTERACT_RULE_ID); + }); + expect(cleanupCall).toBeDefined(); + }); + }); +}); diff --git a/src/shared/utils/debugLogsExport.ts b/src/shared/utils/debugLogsExport.ts new file mode 100644 index 00000000..15fdefa9 --- /dev/null +++ b/src/shared/utils/debugLogsExport.ts @@ -0,0 +1,157 @@ +import { BrowserStorageKey } from '#shared/constants'; + +export const COUNTERACT_BASE_ID = 1_000_000_000; + +export type DebugLogsScope = 'general' | 'profile'; + +export type DebugLogsProfile = { + id: string; + name?: string; + requestHeaders?: Array<{ id: number; name: string; value: string }>; + requestCookies?: Array<{ name: string; value: string }>; + urlFilters?: Array<{ value: string }>; +}; + +export type DebugLogEntry = { + seq: number; + timestamp: number; + level: string; + message: string; + args: string[]; +}; + +export type DebugLogsDnrSnapshot = { + dynamicRulesCount: number; + sessionRulesCount: number; + dynamicRules: unknown[]; + sessionRules: unknown[]; +}; + +export type DebugLogsExportPayload = { + exportedAt: string; + scope: DebugLogsScope; + profileId?: string; + profileName?: string; + profile?: DebugLogsProfile; + worker: unknown; + health: unknown; + storage: Record; + dnr: DebugLogsDnrSnapshot; + logs: DebugLogEntry[]; +}; + +export function parseProfilesFromStorage(storage: Record): DebugLogsProfile[] { + const raw = storage[BrowserStorageKey.Profiles]; + + if (typeof raw === 'string') { + try { + const parsed = JSON.parse(raw) as unknown; + return Array.isArray(parsed) ? (parsed as DebugLogsProfile[]) : []; + } catch { + return []; + } + } + + return Array.isArray(raw) ? (raw as DebugLogsProfile[]) : []; +} + +export function parseProfileFromUnknown(value: unknown): DebugLogsProfile | undefined { + if (!value || typeof value !== 'object') { + return undefined; + } + + const profile = value as Partial; + if (typeof profile.id !== 'string' || profile.id.length === 0) { + return undefined; + } + + return { + id: profile.id, + name: typeof profile.name === 'string' ? profile.name : undefined, + requestHeaders: Array.isArray(profile.requestHeaders) ? profile.requestHeaders : undefined, + requestCookies: Array.isArray(profile.requestCookies) ? profile.requestCookies : undefined, + urlFilters: Array.isArray(profile.urlFilters) ? profile.urlFilters : undefined, + }; +} + +export function getProfileRuleIds(profile: DebugLogsProfile): Set { + const ids = new Set(); + const urlFilterCount = Math.max(profile.urlFilters?.length ?? 0, 1); + + for (const header of profile.requestHeaders ?? []) { + for (let index = 0; index < urlFilterCount; index += 1) { + ids.add(header.id + index * 10000); + } + } + + return ids; +} + +function isRuleWithId(rule: unknown): rule is { id: number } { + return typeof rule === 'object' && rule !== null && typeof (rule as { id?: unknown }).id === 'number'; +} + +function getRuleHeaderNames(rule: unknown): string[] { + if (typeof rule !== 'object' || rule === null) { + return []; + } + + const headers = (rule as { action?: { requestHeaders?: Array<{ header?: unknown }> } }).action?.requestHeaders ?? []; + return headers + .map(header => header.header) + .filter((name): name is string => typeof name === 'string' && name.length > 0); +} + +function isProfileRelatedRule(rule: unknown, ruleIds: Set, headerNames: Set): boolean { + if (isRuleWithId(rule) && ruleIds.has(rule.id)) { + return true; + } + + return ( + isRuleWithId(rule) && rule.id >= COUNTERACT_BASE_ID && getRuleHeaderNames(rule).some(name => headerNames.has(name)) + ); +} + +function collectProfileNeedles(profile: DebugLogsProfile): string[] { + return [ + profile.id, + profile.name, + ...(profile.requestHeaders ?? []).flatMap(header => [String(header.id), header.name, header.value]), + ...(profile.requestCookies ?? []).flatMap(cookie => [cookie.name, cookie.value]), + ...(profile.urlFilters ?? []).map(filter => filter.value), + ].filter((value): value is string => Boolean(value && value.length > 0)); +} + +export function filterDebugLogsForProfile( + payload: DebugLogsExportPayload, + profile: DebugLogsProfile, +): DebugLogsExportPayload { + const ruleIds = getProfileRuleIds(profile); + const headerNames = new Set((profile.requestHeaders ?? []).map(header => header.name).filter(Boolean)); + const needles = collectProfileNeedles(profile); + const dynamicRules = payload.dnr.dynamicRules.filter(rule => isProfileRelatedRule(rule, ruleIds, headerNames)); + const sessionRules = payload.dnr.sessionRules.filter(rule => isProfileRelatedRule(rule, ruleIds, headerNames)); + + return { + ...payload, + scope: 'profile', + profileId: profile.id, + profileName: profile.name, + profile, + storage: { + [BrowserStorageKey.SelectedProfile]: profile.id, + [BrowserStorageKey.IsPaused]: payload.storage[BrowserStorageKey.IsPaused], + [BrowserStorageKey.HeadersConfigMeta]: payload.storage[BrowserStorageKey.HeadersConfigMeta], + }, + dnr: { + dynamicRulesCount: dynamicRules.length, + sessionRulesCount: sessionRules.length, + dynamicRules, + sessionRules, + }, + logs: payload.logs.filter(entry => { + const haystack = `${entry.message} ${entry.args.join(' ')}`; + return needles.some(needle => haystack.includes(needle)); + }), + }; +} diff --git a/src/shared/utils/headersConfigMeta.ts b/src/shared/utils/headersConfigMeta.ts new file mode 100644 index 00000000..11c84005 --- /dev/null +++ b/src/shared/utils/headersConfigMeta.ts @@ -0,0 +1,61 @@ +import browser from 'webextension-polyfill'; + +import { BrowserStorageKey } from '#shared/constants'; + +export type HeadersConfigMeta = { + /** Monotonic counter (best-effort) */ + seq: number; + /** Monotonic timestamp in ms (enforced even if clock moves backwards) */ + updatedAt: number; +}; + +function normalizeMeta(value: unknown): HeadersConfigMeta { + if (value && typeof value === 'object') { + const obj = value as Record; + const seq = typeof obj.seq === 'number' && Number.isFinite(obj.seq) ? obj.seq : 0; + const updatedAt = typeof obj.updatedAt === 'number' && Number.isFinite(obj.updatedAt) ? obj.updatedAt : 0; + return { seq, updatedAt }; + } + + if (typeof value === 'string') { + try { + const parsed = JSON.parse(value) as unknown; + return normalizeMeta(parsed); + } catch { + return { seq: 0, updatedAt: 0 }; + } + } + + return { seq: 0, updatedAt: 0 }; +} + +/** + * Serialised meta chain. + * + * Each call to setWithBumpedHeadersConfigMeta chains onto the previous promise so that + * the next seq/updatedAt is always derived from the most recently committed value β€” + * even when multiple saves are launched concurrently (e.g. rapid keystrokes or + * concurrent Effector effect chains). Without this serialisation two concurrent callers + * both read the same prev.seq from storage, compute the same next.seq, and the second + * write silently overwrites the first; the background script then sees the same meta on + * both storage.onChanged events and skips the second apply, leaving headers stuck. + */ +let _metaChain: Promise = browser.storage.local + .get([BrowserStorageKey.HeadersConfigMeta]) + .then(current => normalizeMeta(current[BrowserStorageKey.HeadersConfigMeta])); + +export async function setWithBumpedHeadersConfigMeta(patch: Record) { + _metaChain = _metaChain.then(async prev => { + const now = Date.now(); + const meta: HeadersConfigMeta = { + seq: prev.seq + 1, + updatedAt: Math.max(now, prev.updatedAt + 1), + }; + await browser.storage.local.set({ + ...patch, + [BrowserStorageKey.HeadersConfigMeta]: meta, + }); + return meta; + }); + return _metaChain; +} diff --git a/src/shared/utils/logger.ts b/src/shared/utils/logger.ts index 1a8dc971..c0787bb4 100644 --- a/src/shared/utils/logger.ts +++ b/src/shared/utils/logger.ts @@ -48,28 +48,64 @@ function shouldLog(level: LogLevel): boolean { return levels.indexOf(level) >= levels.indexOf(currentConfig.minLevel); } +type LogSinkEntry = { + level: LogLevel; + message: string; + args: unknown[]; + timestamp: number; +}; + +type LogSink = (entry: LogSinkEntry) => void | Promise; + +let externalLogSink: LogSink | null = null; + +export function setExternalLogSink(sink: LogSink | null): void { + externalLogSink = sink; +} + +function emitToExternalSink(level: LogLevel, message: string, args: unknown[]): void { + if (!externalLogSink) return; + try { + const maybePromise = externalLogSink({ + level, + message, + args, + timestamp: Date.now(), + }); + if (maybePromise && typeof (maybePromise as Promise).then === 'function') { + (maybePromise as Promise).catch(() => {}); + } + } catch { + // ignore sink errors + } +} + export function logDebug(message: string, ...args: unknown[]): void { if (shouldLog(LogLevel.DEBUG)) { // eslint-disable-next-line no-console console.log(formatLogMessage(LogLevel.DEBUG, message), ...args); + emitToExternalSink(LogLevel.DEBUG, message, args); } } export function logInfo(message: string, ...args: unknown[]): void { if (shouldLog(LogLevel.INFO)) { console.info(formatLogMessage(LogLevel.INFO, message), ...args); + emitToExternalSink(LogLevel.INFO, message, args); } } export function logWarn(message: string, ...args: unknown[]): void { if (shouldLog(LogLevel.WARN)) { console.warn(formatLogMessage(LogLevel.WARN, message), ...args); + emitToExternalSink(LogLevel.WARN, message, args); } } export function logError(message: string, ...args: unknown[]): void { if (shouldLog(LogLevel.ERROR)) { console.error(formatLogMessage(LogLevel.ERROR, message), ...args); + emitToExternalSink(LogLevel.ERROR, message, args); } } @@ -102,4 +138,5 @@ export const logger = { configure: configureLogger, enable: enableLogger, disable: disableLogger, + setExternalSink: setExternalLogSink, }; diff --git a/src/shared/utils/setBrowserHeaders.ts b/src/shared/utils/setBrowserHeaders.ts index 463a5930..7bed9f21 100644 --- a/src/shared/utils/setBrowserHeaders.ts +++ b/src/shared/utils/setBrowserHeaders.ts @@ -10,6 +10,140 @@ import { validateHeader } from './headers'; import { logger } from './logger'; import { setIconBadge } from './setIconBadge'; +const MAX_RETRIES = 3; +const RETRY_DELAY_MS = 200; +const RECOVERY_DELAY_MS = 500; + +// IDs >= COUNTERACT_BASE_ID are reserved for counteracting stuck dynamic rules. +// Header IDs are generated in range [0, 999_999_999) β€” see generateId.ts. +const COUNTERACT_BASE_ID = 1_000_000_000; + +async function sleep(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +/** + * Attempts to recover from DNR API errors by clearing all rules first, + * then applying new rules in a separate call. + * This handles cases where Chrome has corrupted internal state or "ghost" rules. + */ +async function recoveryUpdateDynamicRules( + removeRuleIds: number[], + addRules: browser.DeclarativeNetRequest.Rule[], +): Promise<{ success: boolean; error?: unknown }> { + logger.warn('πŸ”§ DNR Recovery: Attempting recovery strategy...'); + + try { + // Step 1: Get current rules (for diagnostics) + const currentRules = await browser.declarativeNetRequest.getDynamicRules(); + logger.info('πŸ”§ DNR Recovery: Current rules before clear:', { + count: currentRules.length, + ids: currentRules.map(r => r.id), + }); + + // Step 2: Try to remove ALL rules we know about (both from getDynamicRules and our removeRuleIds) + const allIdsToRemove = [...new Set([...currentRules.map(r => r.id), ...removeRuleIds])]; + + if (allIdsToRemove.length > 0) { + logger.info('πŸ”§ DNR Recovery: Removing all known rules:', { ids: allIdsToRemove }); + try { + await browser.declarativeNetRequest.updateDynamicRules({ + removeRuleIds: allIdsToRemove, + addRules: [], + }); + logger.info('πŸ”§ DNR Recovery: Rules removed successfully'); + } catch (removeErr) { + logger.warn('πŸ”§ DNR Recovery: Batch removal failed, trying one-by-one:', removeErr); + for (const id of allIdsToRemove) { + try { + await browser.declarativeNetRequest.updateDynamicRules({ removeRuleIds: [id], addRules: [] }); + logger.info('πŸ”§ DNR Recovery: Removed rule:', id); + } catch (singleErr) { + logger.warn('πŸ”§ DNR Recovery: Failed to remove rule:', { id, error: singleErr }); + } + } + } + } + + // Step 3: Wait for Chrome to stabilize + await sleep(RECOVERY_DELAY_MS); + + // Step 4: Verify rules are cleared + const rulesAfterClear = await browser.declarativeNetRequest.getDynamicRules(); + logger.info('πŸ”§ DNR Recovery: Rules after clear:', { + count: rulesAfterClear.length, + ids: rulesAfterClear.map(r => r.id), + }); + + // Step 5: Add new rules in a separate call + if (addRules.length > 0) { + logger.info('πŸ”§ DNR Recovery: Adding new rules:', { + count: addRules.length, + ids: addRules.map(r => r.id), + }); + await browser.declarativeNetRequest.updateDynamicRules({ + removeRuleIds: [], + addRules, + }); + logger.info('πŸ”§ DNR Recovery: New rules added successfully'); + } + + // Step 6: Verify final state actually matches expected + const finalRules = await browser.declarativeNetRequest.getDynamicRules(); + const expectedCount = addRules.length; + const actualCount = finalRules.length; + logger.info('πŸ”§ DNR Recovery: Final rules state:', { + count: actualCount, + ids: finalRules.map(r => r.id), + expected: expectedCount, + }); + + if (actualCount !== expectedCount) { + return { + success: false, + error: new Error( + `DNR Recovery: rules count mismatch after recovery: got ${actualCount}, expected ${expectedCount}`, + ), + }; + } + + return { success: true }; + } catch (error) { + logger.error('πŸ”§ DNR Recovery: Recovery failed:', error); + return { success: false, error }; + } +} + +/** + * Last-resort fallback: use session rules when dynamic rules API is completely broken. + * Session rules are ephemeral (lost on browser restart / SW termination) but use a + * different internal Chrome storage path, so they may work when dynamic rules don't. + */ +async function sessionRulesFallback( + removeSessionRuleIds: number[], + addRules: browser.DeclarativeNetRequest.Rule[], +): Promise<{ success: boolean; error?: unknown }> { + logger.warn('πŸ”„ Session rules fallback: dynamic rules API broken, trying session rules...'); + + try { + await browser.declarativeNetRequest.updateSessionRules({ + removeRuleIds: removeSessionRuleIds, + addRules, + }); + + const finalRules = await browser.declarativeNetRequest.getSessionRules(); + logger.info('βœ… Session rules fallback succeeded:', { + activeRulesCount: finalRules.length, + ids: finalRules.map(r => r.id), + }); + + return { success: true }; + } catch (error) { + logger.error('❌ Session rules fallback also failed:', error); + return { success: false, error }; + } +} + const allResourceTypes = [ 'main_frame', 'sub_frame', @@ -59,8 +193,28 @@ function getRulesForHeader(header: RequestHeader, urlFilters: string[]): browser }); } -export async function setBrowserHeaders(result: Record, currentTabUrl?: string) { +type SetBrowserHeadersMeta = { + /** Monotonic apply id from background queue */ + applyId?: number; + /** Why this apply happened (storage.onChanged, onActivated, reload, startup, etc) */ + reason?: string; + /** Optional storage fingerprint computed by caller for correlation */ + storageFingerprint?: string; + /** Active tab URL β€” used for badge count when URL filters are set */ + currentTabUrl?: string; +}; + +export type SetBrowserHeadersResult = { + /** Rule IDs that are still active in Chrome DNR but should have been removed (disabled headers) */ + stuckRuleIds: number[]; +}; + +export async function setBrowserHeaders( + result: Record, + meta: SetBrowserHeadersMeta = {}, +): Promise { const isPaused = result[BrowserStorageKey.IsPaused] as boolean; + const currentTabUrl = meta.currentTabUrl; // Validate data from storage let profiles: Profile[] = []; @@ -84,6 +238,10 @@ export async function setBrowserHeaders(result: Record, current } logger.debug('Storage data validation:', { + applyId: meta.applyId, + reason: meta.reason, + storageFingerprint: meta.storageFingerprint, + currentTabUrl, profilesCount: profiles.length, selectedProfile, isPaused, @@ -92,13 +250,25 @@ export async function setBrowserHeaders(result: Record, current hasIsPausedData: result[BrowserStorageKey.IsPaused] !== undefined, }); - const currentRules = await browser.declarativeNetRequest.getDynamicRules(); + const currentDynamicRules = await browser.declarativeNetRequest.getDynamicRules(); + + // Also read session rules β€” we may have leftover session rules from a previous fallback + let currentSessionRules: browser.DeclarativeNetRequest.Rule[] = []; + try { + currentSessionRules = await browser.declarativeNetRequest.getSessionRules(); + } catch { + // getSessionRules may be unavailable in older browsers; ignore + } - const profile = profiles.find(p => p.id === selectedProfile); + let profile = profiles.find(p => p.id === selectedProfile); - if (!profile && selectedProfile) { + // Fallback to the first profile when selectedProfile is missing or doesn't match. + // This happens when SelectedProfile hasn't been written to storage yet (e.g. first run) + // or when it points to a deleted profile. + if (!profile && profiles.length > 0) { + profile = profiles[0]; logger.warn( - `Profile with id "${selectedProfile}" not found in storage. Available profiles:`, + `Profile with id "${selectedProfile}" not found in storage, falling back to first profile "${profile.id}". Available profiles:`, profiles.map(p => ({ id: p.id, name: p.name })), ); } @@ -164,25 +334,144 @@ export async function setBrowserHeaders(result: Record, current const addRules = [...headerRules, ...cookieRules]; - const removeRuleIds = currentRules.map(item => item.id); + const removeDynamicRuleIds = currentDynamicRules.map(item => item.id); + const removeSessionRuleIds = currentSessionRules.map(item => item.id); try { logger.group('Updating dynamic rules', true); - logger.info('Remove rule IDs:', removeRuleIds); + logger.info('Apply meta:', meta); + logger.info('Remove dynamic rule IDs:', removeDynamicRuleIds); + if (removeSessionRuleIds.length > 0) { + logger.info('Remove session rule IDs (leftover from previous fallback):', removeSessionRuleIds); + } logger.info('Add rules:', addRules); - if (removeRuleIds.length > 0 || addRules.length > 0) { - await browser.declarativeNetRequest.updateDynamicRules({ - removeRuleIds, - addRules, + // IMPORTANT: + // Apply rules atomically in a single updateDynamicRules call. + // Two-step removeβ†’add is prone to race conditions when multiple triggers fire concurrently + // (often more noticeable on Windows due to timing/latency differences). + let usedSessionFallback = false; + + if (removeDynamicRuleIds.length > 0 || removeSessionRuleIds.length > 0 || addRules.length > 0) { + // Enhanced diagnostics: log full rule details before update + logger.info('πŸ“Š DNR Update diagnostics:', { + removeDynamicRuleIds, + addRulesCount: addRules.length, + addRulesDetails: addRules.map(r => ({ + id: r.id, + actionType: r.action.type, + hasUrlFilter: 'urlFilter' in (r.condition || {}), + hasRegexFilter: 'regexFilter' in (r.condition || {}), + condition: r.condition, + })), }); - logger.debug('Rules updated'); + + // Retry logic for transient DNR API errors (e.g., "Internal error while updating dynamic rules") + let lastError: unknown = null; + for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) { + try { + await browser.declarativeNetRequest.updateDynamicRules({ + removeRuleIds: removeDynamicRuleIds, + addRules, + }); + logger.debug(`Dynamic rules updated (atomic, attempt ${attempt})`); + lastError = null; + break; + } catch (err) { + lastError = err; + logger.warn(`DNR updateDynamicRules failed (attempt ${attempt}/${MAX_RETRIES}):`, err); + if (attempt < MAX_RETRIES) { + await sleep(RETRY_DELAY_MS * attempt); // Exponential backoff + } + } + } + + // If atomic update failed, try recovery strategy + if (lastError) { + logger.warn('⚠️ Atomic DNR update failed after retries, attempting recovery...'); + const recoveryResult = await recoveryUpdateDynamicRules(removeDynamicRuleIds, addRules); + + if (recoveryResult.success) { + logger.info('βœ… DNR Recovery successful!'); + lastError = null; + } else { + logger.error('❌ DNR Recovery also failed:', recoveryResult.error); + } + } + + // Last resort: session rules fallback when Chrome's dynamic rules DB is corrupted + if (lastError) { + // Exclude counteracting rules (id >= COUNTERACT_BASE_ID) from the fallback removal. + // Counteracting rules cancel stuck dynamic rules that couldn't be removed β€” if we pass + // them to sessionRulesFallback it will delete them while the stuck dynamic rules remain, + // opening a window where the disabled header is transmitted. They are cleaned up later + // (in the counteracting-rules section below) once we know the full stuck-rule picture. + const fallbackRemoveSessionRuleIds = removeSessionRuleIds.filter(id => id < COUNTERACT_BASE_ID); + const sessionResult = await sessionRulesFallback(fallbackRemoveSessionRuleIds, addRules); + + if (sessionResult.success) { + usedSessionFallback = true; + lastError = null; + } + } + + if (lastError) { + throw lastError; + } + } else { + logger.debug('No dynamic rules to update'); + } + + // If dynamic rules succeeded, clean up any leftover session rules from a previous fallback + if (!usedSessionFallback && removeSessionRuleIds.length > 0) { + try { + await browser.declarativeNetRequest.updateSessionRules({ + removeRuleIds: removeSessionRuleIds, + addRules: [], + }); + logger.info('🧹 Cleaned up leftover session rules'); + } catch { + // Non-critical: ignore cleanup failures + } } - const updatedRules = await browser.declarativeNetRequest.getDynamicRules(); - logger.debug('Current active rules after update:', updatedRules); + // Verify final state (check both dynamic and session rules) + const updatedDynamicRules = await browser.declarativeNetRequest.getDynamicRules(); + let updatedSessionRules: browser.DeclarativeNetRequest.Rule[] = []; + try { + updatedSessionRules = await browser.declarativeNetRequest.getSessionRules(); + } catch { + // ignore + } + const allActiveRules = [...updatedDynamicRules, ...updatedSessionRules]; + + // Rules that are still active in Chrome but should have been removed (disabled headers stuck due to DNR API errors). + // Only check dynamic rules β€” session rules are entirely under our control (fallback + counteracting). + const expectedRuleIds = new Set(addRules.map(r => r.id)); + const stuckRuleIds = updatedDynamicRules.filter(r => !expectedRuleIds.has(r.id)).map(r => r.id); + + logger.info('πŸ“Š Final DNR state:', { + dynamicRulesCount: updatedDynamicRules.length, + sessionRulesCount: updatedSessionRules.length, + totalActiveRulesCount: allActiveRules.length, + expectedRulesCount: addRules.length, + match: allActiveRules.length === addRules.length, + stuckRuleIds, + usedSessionFallback, + activeRuleIds: allActiveRules.map(r => r.id), + activeRulesDetails: allActiveRules.map(r => ({ + id: r.id, + actionType: r.action.type, + condition: r.condition, + requestHeaders: r.action.requestHeaders, + })), + }); - logger.info('Rules updated successfully'); + logger.info( + usedSessionFallback + ? '⚠️ Rules applied via session fallback (will be lost on browser restart β€” restart Chrome to fix dynamic rules)' + : 'βœ… Rules updated successfully', + ); logger.groupEnd(); const tabUrl = currentTabUrl; @@ -191,8 +480,62 @@ export async function setBrowserHeaders(result: Record, current const activeCookiesCount = urlMatchesCurrentTab ? activeCookies.length : 0; const activeRulesCount = countActiveHeadersForUrl(activeHeaders, activeUrlFilters, currentTabUrl) + activeCookiesCount; - await setIconBadge({ isPaused, activeRulesCount }); + await setIconBadge({ isPaused, activeRulesCount, hasDnrMismatch: stuckRuleIds.length > 0 }); + + // Counteract stuck dynamic rules that are injecting headers which should be disabled. + // On the next successful apply these session rules are cleaned up automatically because + // removeSessionRuleIds (computed from getSessionRules at the top) includes them. + const prevCounteractIds = updatedSessionRules.filter(r => r.id >= COUNTERACT_BASE_ID).map(r => r.id); + const counteractingRules: browser.DeclarativeNetRequest.Rule[] = []; + + if (stuckRuleIds.length > 0) { + const sessionRuleHeaderNames = new Set( + updatedSessionRules.flatMap(r => r.action.requestHeaders?.map(h => h.header) ?? []), + ); + for (let i = 0; i < stuckRuleIds.length; i++) { + const stuckRule = updatedDynamicRules.find(r => r.id === stuckRuleIds[i]); + if (!stuckRule?.action.requestHeaders) continue; + const headersToRemove = stuckRule.action.requestHeaders.filter( + h => h.operation === 'set' && !sessionRuleHeaderNames.has(h.header), + ); + if (headersToRemove.length === 0) continue; + counteractingRules.push({ + id: COUNTERACT_BASE_ID + i, + priority: 2, + action: { + type: 'modifyHeaders' as const, + requestHeaders: headersToRemove.map(h => ({ header: h.header, operation: 'remove' as const })), + }, + condition: stuckRule.condition, + }); + } + } + + if (prevCounteractIds.length > 0 || counteractingRules.length > 0) { + try { + await browser.declarativeNetRequest.updateSessionRules({ + removeRuleIds: prevCounteractIds, + addRules: counteractingRules, + }); + if (counteractingRules.length > 0) { + logger.info('πŸ›‘οΈ Added counteracting session rules for stuck dynamic rules:', { + count: counteractingRules.length, + headers: counteractingRules.flatMap(r => r.action.requestHeaders?.map(h => h.header) ?? []), + }); + } else if (prevCounteractIds.length > 0) { + logger.info('🧹 Cleaned up stale counteracting session rules'); + } + } catch (err) { + logger.warn('⚠️ Failed to update counteracting session rules:', err); + } + } + + return { stuckRuleIds }; } catch (err) { logger.error('Failed to update dynamic rules:', err); + logger.groupEnd(); + // CRITICAL: Re-throw to prevent caller from updating lastAppliedStorageFingerprint/lastAppliedMeta + // If we swallow the error, the queue state becomes desynchronized with actual DNR rules + throw err; } } diff --git a/src/shared/utils/setIconBadge.ts b/src/shared/utils/setIconBadge.ts index 34a38b0c..38dbdce8 100644 --- a/src/shared/utils/setIconBadge.ts +++ b/src/shared/utils/setIconBadge.ts @@ -6,11 +6,14 @@ import { logger } from './logger'; type SetIconBadgeParams = { isPaused: boolean; activeRulesCount: number; + /** When true, badge background turns orange to warn that some DNR rules could not be applied */ + hasDnrMismatch?: boolean; }; -export async function setIconBadge({ isPaused, activeRulesCount }: SetIconBadgeParams) { +export async function setIconBadge({ isPaused, activeRulesCount, hasDnrMismatch = false }: SetIconBadgeParams) { const iconPath = isPaused ? 'img/paused-icon-38.png' : 'img/main-icon-38.png'; const badgeText = !isPaused && activeRulesCount > 0 ? activeRulesCount.toString() : ''; + const badgeColor = hasDnrMismatch ? '#f59e0b' : '#ffffff'; // Log extension info logger.debug('Extension info:', { @@ -20,9 +23,10 @@ export async function setIconBadge({ isPaused, activeRulesCount }: SetIconBadgeP badgeText, isPaused, activeRulesCount, + hasDnrMismatch, }); - logger.debug('Setting icon badge:', { isPaused, activeRulesCount, iconPath, badgeText }); + logger.debug('Setting icon badge:', { isPaused, activeRulesCount, iconPath, badgeText, hasDnrMismatch }); try { // In Chrome Manifest V3, use an object with icon sizes @@ -36,6 +40,7 @@ export async function setIconBadge({ isPaused, activeRulesCount }: SetIconBadgeP await browserAction.setIcon(iconDetails); await browserAction.setBadgeText({ text: badgeText }); + await browserAction.setBadgeBackgroundColor({ color: badgeColor }); logger.debug('Icon badge set successfully'); } catch (err) { logger.error('Error setting icon badge:', err); diff --git a/src/shared/utils/updateOverrideHeaders.ts b/src/shared/utils/updateOverrideHeaders.ts deleted file mode 100644 index 3ad71026..00000000 --- a/src/shared/utils/updateOverrideHeaders.ts +++ /dev/null @@ -1,7 +0,0 @@ -import browser from 'webextension-polyfill'; - -import { ServiceWorkerEvent } from '../constants'; - -export async function updateOverrideHeaders() { - await browser.runtime.sendMessage(ServiceWorkerEvent.Reload); -} diff --git a/src/widgets/request-headers/RequestHeaders.tsx b/src/widgets/request-headers/RequestHeaders.tsx index 712b947d..a874b5b1 100644 --- a/src/widgets/request-headers/RequestHeaders.tsx +++ b/src/widgets/request-headers/RequestHeaders.tsx @@ -1,5 +1,6 @@ import { useUnit } from 'effector-react'; +import { $dnrHealth } from '#entities/dnr-health/model'; import { $isPaused } from '#entities/is-paused/model'; import { $selectedProfileRequestHeaders } from '#entities/request-profile/model/selected-request-headers'; import { useSortableList } from '#entities/sortable-list'; @@ -9,18 +10,26 @@ import { RequestHeaderRow } from './components/RequestHeaderRow'; import * as S from './styled'; export function RequestHeaders() { - const { isPaused, requestHeaders, onReorder } = useUnit({ + const { isPaused, requestHeaders, dnrHealth, onReorder } = useUnit({ isPaused: $isPaused, requestHeaders: $selectedProfileRequestHeaders, + dnrHealth: $dnrHealth, onReorder: requestHeadersReordered, }); + const stuckRuleIds = new Set(dnrHealth?.stuckRuleIds ?? []); + const { listRef, moveByKeyboard } = useSortableList({ disabled: isPaused, items: requestHeaders, onReorder }); return ( {requestHeaders.map(header => ( - moveByKeyboard(header.id, direction)} /> + moveByKeyboard(header.id, direction)} + /> ))} ); diff --git a/src/widgets/request-headers/components/RequestHeaderRow/RequestHeaderRow.tsx b/src/widgets/request-headers/components/RequestHeaderRow/RequestHeaderRow.tsx index 24d98b82..2d78bade 100644 --- a/src/widgets/request-headers/components/RequestHeaderRow/RequestHeaderRow.tsx +++ b/src/widgets/request-headers/components/RequestHeaderRow/RequestHeaderRow.tsx @@ -2,7 +2,8 @@ import { useUnit } from 'effector-react/effector-react.mjs'; import { type ClipboardEvent, type KeyboardEvent, useState } from 'react'; import { Button } from '@cloud-ru/ds-button'; -import { CrossSVG } from '@cloud-ru/ds-icons/interface/system'; +import { themeVars } from '@cloud-ru/ds-figma-variables'; +import { CrossSVG, WarningSVG } from '@cloud-ru/ds-icons/interface/system'; import { Checkbox, CheckboxProps } from '@cloud-ru/ds-toggles'; import { Tooltip } from '@cloud-ru/ds-tooltip'; @@ -20,9 +21,10 @@ import * as S from './styled'; type RequestHeaderRowProps = RequestHeader & { onMove: (direction: -1 | 1) => void; + isStuck?: boolean; }; -export function RequestHeaderRow({ onMove, ...props }: RequestHeaderRowProps) { +export function RequestHeaderRow({ onMove, isStuck, ...props }: RequestHeaderRowProps) { const { disabled, name, value, id } = props; const { isPaused, onRequestHeadersPasted, onRequestHeadersUpdated, onRequestHeadersRemoved } = useUnit({ isPaused: $isPaused, @@ -78,6 +80,17 @@ export function RequestHeaderRow({ onMove, ...props }: RequestHeaderRowProps) { /> + {isStuck && ( + + + + + + )} + 0 && !isNameFormatVerified} diff --git a/src/widgets/settings/Settings.tsx b/src/widgets/settings/Settings.tsx new file mode 100644 index 00000000..6deac66b --- /dev/null +++ b/src/widgets/settings/Settings.tsx @@ -0,0 +1,127 @@ +import { useUnit } from 'effector-react'; + +import { Button } from '@cloud-ru/ds-button'; +import { Divider } from '@cloud-ru/ds-divider'; +import { FieldSelect } from '@cloud-ru/ds-fields'; +import { ChevronLeftSVG, DaySVG, DownloadSVG, LaptopPhoneSVG, NightSVG } from '@cloud-ru/ds-icons/interface/system'; +import { ItemId } from '@cloud-ru/ds-list'; +import { Typography } from '@cloud-ru/ds-typography'; + +import { settingsClosed } from '#entities/app-view'; +import { $currentTheme, currentThemeChanged } from '#entities/themeMode/model'; +import { $isExportingDebugLogs, generalDebugLogsExported, profileDebugLogsExported } from '#features/export-debug-logs'; +import { ThemeMode } from '#shared/constants'; + +import packageJson from '../../../package.json'; +import * as S from './styled'; + +const THEME_OPTIONS = [ + { id: ThemeMode.Light, content: { label: 'Light' }, beforeContent: }, + { id: ThemeMode.Dark, content: { label: 'Dark' }, beforeContent: }, + { id: ThemeMode.System, content: { label: 'System' }, beforeContent: }, +]; + +export function Settings() { + const [ + currentTheme, + isExportingDebugLogs, + onSettingsClosed, + onThemeChanged, + onGeneralDebugLogsExported, + onProfileDebugLogsExported, + ] = useUnit([ + $currentTheme, + $isExportingDebugLogs, + settingsClosed, + currentThemeChanged, + generalDebugLogsExported, + profileDebugLogsExported, + ]); + + const handleThemeChange = (value: ItemId | undefined) => { + if (value === ThemeMode.Light || value === ThemeMode.Dark || value === ThemeMode.System) { + onThemeChanged(value); + } + }; + + return ( + + +