From af421961993b040e5e4b4893e7b8ec51d96d76d2 Mon Sep 17 00:00:00 2001 From: Kirill Lebedenko Date: Thu, 2 Apr 2026 21:34:51 +0300 Subject: [PATCH] feat show badge count based on headers applied to current page When URL filters are configured the extension icon badge now shows 0 empty if the current page doesn't match any filter and the actual header count only when headers are actively being injected - Add countActiveHeadersForUrl utility with doesUrlMatchFilter - Update setBrowserHeaders to pass current tab URL to badge logic - Add tabsonUpdated listener to refresh badge on navigation - Add 'tabs' permission to all manifests --- manifest.chromium.json | 3 +- manifest.dev.json | 3 +- manifest.firefox.json | 3 +- src/background.ts | 102 +++++-------- .../countActiveHeadersForUrl.spec.ts | 143 ++++++++++++++++++ src/shared/utils/countActiveHeadersForUrl.ts | 50 ++++++ src/shared/utils/createUrlCondition.ts | 2 +- src/shared/utils/setBrowserHeaders.ts | 6 +- 8 files changed, 246 insertions(+), 66 deletions(-) create mode 100644 src/shared/utils/__tests__/countActiveHeadersForUrl.spec.ts create mode 100644 src/shared/utils/countActiveHeadersForUrl.ts diff --git a/manifest.chromium.json b/manifest.chromium.json index 8a3fe7b..4533c7a 100644 --- a/manifest.chromium.json +++ b/manifest.chromium.json @@ -23,7 +23,8 @@ "permissions": [ "storage", "declarativeNetRequest", - "declarativeNetRequestFeedback" + "declarativeNetRequestFeedback", + "tabs" ], "background": { "service_worker": "background.bundle.js" diff --git a/manifest.dev.json b/manifest.dev.json index 51646ce..0b4924c 100644 --- a/manifest.dev.json +++ b/manifest.dev.json @@ -21,7 +21,8 @@ "permissions": [ "storage", "declarativeNetRequest", - "declarativeNetRequestFeedback" + "declarativeNetRequestFeedback", + "tabs" ], "background": { "service_worker": "background.bundle.js" diff --git a/manifest.firefox.json b/manifest.firefox.json index f56fefe..473485e 100644 --- a/manifest.firefox.json +++ b/manifest.firefox.json @@ -20,7 +20,8 @@ "storage", "declarativeNetRequest", "declarativeNetRequestFeedback", - "activeTab" + "activeTab", + "tabs" ], "host_permissions": [ "" diff --git a/src/background.ts b/src/background.ts index 6a6a8f0..bc4c115 100644 --- a/src/background.ts +++ b/src/background.ts @@ -1,12 +1,9 @@ import browser from 'webextension-polyfill'; -import type { Profile, RequestHeader } from '#entities/request-profile/types'; - import { BrowserStorageKey, ServiceWorkerEvent } from './shared/constants'; import { browserAction } from './shared/utils/browserAPI'; import { logger, LogLevel } from './shared/utils/logger'; import { setBrowserHeaders } from './shared/utils/setBrowserHeaders'; -import { setIconBadge } from './shared/utils/setIconBadge'; import { enableExtensionReload } from './utils/extension-reload'; logger.configure({ @@ -21,6 +18,15 @@ logger.info('🎯 Background script loaded successfully!'); logger.debug('🎯 Background script loaded successfully! (debug)'); logger.info('🔍 About to check storage contents...'); +async function getCurrentTabUrl(): Promise { + try { + const tabs = await browser.tabs.query({ active: true, currentWindow: true }); + return tabs[0]?.url; + } catch { + return undefined; + } +} + // Check storage immediately on background script load (async () => { try { @@ -34,35 +40,13 @@ logger.info('🔍 About to check storage contents...'); logger.info(' - Profiles:', result[BrowserStorageKey.Profiles] ? 'Present' : 'Missing'); logger.info(' - Selected Profile:', result[BrowserStorageKey.SelectedProfile] || 'None'); logger.info(' - Is Paused:', result[BrowserStorageKey.IsPaused] || false); - - // 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.groupEnd(); logger.debug('Background script load storage data:', JSON.stringify(result, null, 2)); - logger.groupEnd(); - // Set the badge based on storage data - const isPaused = (result[BrowserStorageKey.IsPaused] as boolean) || false; - await setIconBadge({ isPaused, activeRulesCount: activeHeadersCount }); - logger.info(`🏷️ Badge set: paused=${isPaused}, activeRules=${activeHeadersCount}`); + const currentTabUrl = await getCurrentTabUrl(); + await setBrowserHeaders(result, currentTabUrl); + logger.info(`🏷️ Initial badge set for URL: ${currentTabUrl}`); } catch (error) { logger.error('Failed to check storage on background script load:', error); } @@ -89,7 +73,7 @@ async function notify(message: ServiceWorkerEvent) { ]); logger.info('📦 Storage data for reload:', result); - await setBrowserHeaders(result); + await setBrowserHeaders(result, await getCurrentTabUrl()); } return undefined; } @@ -110,25 +94,12 @@ 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 setBrowserHeaders(result); + await setBrowserHeaders(result, await getCurrentTabUrl()); } catch (error) { logger.error('Failed to set browser headers on startup:', error); } @@ -156,7 +127,7 @@ browser.storage.onChanged.addListener(async (changes, areaName) => { ]); logger.debug('Storage changes data:', result); try { - await setBrowserHeaders(result); + await setBrowserHeaders(result, await getCurrentTabUrl()); } catch (error) { logger.error('Failed to set browser headers on storage change:', error); } @@ -181,25 +152,12 @@ 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 setBrowserHeaders(result); + await setBrowserHeaders(result, await getCurrentTabUrl()); } catch (error) { logger.error('Failed to set browser headers on install/update:', error); } @@ -222,7 +180,8 @@ browser.tabs.onActivated.addListener(async activeInfo => { if (Object.keys(result).length) { logger.info('📱 Tab activated, updating headers'); try { - await setBrowserHeaders(result); + const tab = await browser.tabs.get(activeInfo.tabId); + await setBrowserHeaders(result, tab.url); } catch (error) { logger.error('Failed to set browser headers on tab activation:', error); } @@ -231,6 +190,29 @@ browser.tabs.onActivated.addListener(async activeInfo => { } }); +browser.tabs.onUpdated.addListener(async (tabId, changeInfo, tab) => { + if (changeInfo.status !== 'complete') return; + + const activeTabs = await browser.tabs.query({ active: true, currentWindow: true }); + if (activeTabs[0]?.id !== tabId) return; + + logger.debug('Active tab URL updated:', tab.url); + + const result = await browser.storage.local.get([ + BrowserStorageKey.Profiles, + BrowserStorageKey.SelectedProfile, + BrowserStorageKey.IsPaused, + ]); + + if (Object.keys(result).length) { + try { + await setBrowserHeaders(result, tab.url); + } catch (error) { + logger.error('Failed to set browser headers on tab URL update:', error); + } + } +}); + browserAction.setBadgeBackgroundColor({ color: BADGE_COLOR }); browser.runtime.onMessage.addListener((message: unknown) => { diff --git a/src/shared/utils/__tests__/countActiveHeadersForUrl.spec.ts b/src/shared/utils/__tests__/countActiveHeadersForUrl.spec.ts new file mode 100644 index 0000000..21b3c3e --- /dev/null +++ b/src/shared/utils/__tests__/countActiveHeadersForUrl.spec.ts @@ -0,0 +1,143 @@ +import { describe, expect, it } from 'vitest'; + +import { countActiveHeadersForUrl, doesUrlMatchFilter } from '../countActiveHeadersForUrl'; + +const makeHeaders = (count: number) => + Array.from({ length: count }, (_, i) => ({ + id: i + 1, + name: `X-Header-${i + 1}`, + value: `value-${i + 1}`, + disabled: false, + })); + +describe('doesUrlMatchFilter', () => { + describe('regex path (*:// patterns)', () => { + it('matches *://example.com/* against https://example.com/path', () => { + expect(doesUrlMatchFilter('https://example.com/path', '*://example.com/*')).toBe(true); + }); + + it('does not match *://example.com/* against https://other.com/path', () => { + expect(doesUrlMatchFilter('https://other.com/path', '*://example.com/*')).toBe(false); + }); + + it('matches *://api-test*/* against https://api-test.example.org/v1/resource', () => { + expect(doesUrlMatchFilter('https://api-test.example.org/v1/resource', '*://api-test*/*')).toBe(true); + }); + + it('does not match *://api*/* when host does not start with api', () => { + expect(doesUrlMatchFilter('https://service.example.com/api/test', '*://api*/*')).toBe(false); + }); + + it('matches *://api*/* when host starts with api', () => { + expect(doesUrlMatchFilter('https://api-test.example.org/api/test', '*://api*/*')).toBe(true); + }); + }); + + describe('urlFilter path (non-*:// patterns)', () => { + it('matches a simple domain substring', () => { + expect(doesUrlMatchFilter('https://example.com/path', 'example.com')).toBe(true); + }); + + it('does not match a simple domain against an unrelated URL', () => { + expect(doesUrlMatchFilter('https://other.com/path', 'example.com')).toBe(false); + }); + + it('matches https:// prefix filter with wildcard', () => { + expect(doesUrlMatchFilter('https://example.com/api', 'https://example.com/*')).toBe(true); + }); + + it('does not match https:// filter against http://', () => { + expect(doesUrlMatchFilter('http://example.com/api', 'https://example.com/*')).toBe(false); + }); + + it('matches a wildcard pattern for a path prefix', () => { + expect(doesUrlMatchFilter('https://example.com/api/v2', 'example.com/api/*')).toBe(true); + }); + + it('is case-insensitive for urlFilter path', () => { + expect(doesUrlMatchFilter('https://EXAMPLE.COM/path', 'example.com')).toBe(true); + }); + + it('matches a keyword filter as substring', () => { + expect(doesUrlMatchFilter('https://api-test.internal/v1', 'api-test')).toBe(true); + }); + + it('does not match keyword filter when not present in URL', () => { + expect(doesUrlMatchFilter('https://service.internal/v1', 'api-test')).toBe(false); + }); + }); + + describe('edge cases', () => { + it('returns false for empty url', () => { + expect(doesUrlMatchFilter('', 'example.com')).toBe(false); + }); + + it('returns false for empty filter', () => { + expect(doesUrlMatchFilter('https://example.com', '')).toBe(false); + }); + }); +}); + +describe('countActiveHeadersForUrl', () => { + const twoHeaders = makeHeaders(2); + + describe('no URL filters configured', () => { + it('returns activeHeaders.length when activeUrlFilters is empty', () => { + expect(countActiveHeadersForUrl(twoHeaders, [], 'https://example.com')).toBe(2); + }); + + it('returns activeHeaders.length even when currentUrl is undefined', () => { + expect(countActiveHeadersForUrl(twoHeaders, [], undefined)).toBe(2); + }); + }); + + describe('URL filters configured, URL matches', () => { + it('returns activeHeaders.length when URL matches a filter', () => { + expect(countActiveHeadersForUrl(twoHeaders, ['*://example.com/*'], 'https://example.com/page')).toBe(2); + }); + + it('returns activeHeaders.length when any one of multiple filters matches', () => { + const filters = ['*://other.com/*', '*://example.com/*']; + expect(countActiveHeadersForUrl(twoHeaders, filters, 'https://example.com/page')).toBe(2); + }); + + it('returns activeHeaders.length for simple domain filter match', () => { + expect(countActiveHeadersForUrl(twoHeaders, ['example.com'], 'https://example.com/page')).toBe(2); + }); + }); + + describe('URL filters configured, URL does not match', () => { + it('returns 0 when URL does not match the filter', () => { + expect(countActiveHeadersForUrl(twoHeaders, ['*://example.com/*'], 'https://other.com/page')).toBe(0); + }); + + it('returns 0 when none of multiple filters match', () => { + const filters = ['*://other.com/*', '*://third.com/*']; + expect(countActiveHeadersForUrl(twoHeaders, filters, 'https://example.com/page')).toBe(0); + }); + }); + + describe('unknown currentUrl fallback', () => { + it('falls back to activeHeaders.length when currentUrl is undefined (safe default)', () => { + expect(countActiveHeadersForUrl(twoHeaders, ['*://example.com/*'], undefined)).toBe(2); + }); + + it('falls back to activeHeaders.length when currentUrl is empty string', () => { + expect(countActiveHeadersForUrl(twoHeaders, ['*://example.com/*'], '')).toBe(2); + }); + }); + + describe('zero active headers', () => { + it('returns 0 when there are no active headers (no filters)', () => { + expect(countActiveHeadersForUrl([], [], 'https://example.com')).toBe(0); + }); + + it('returns 0 when there are no active headers (matching filter)', () => { + expect(countActiveHeadersForUrl([], ['*://example.com/*'], 'https://example.com/page')).toBe(0); + }); + + it('returns 0 when there are no active headers (non-matching filter)', () => { + expect(countActiveHeadersForUrl([], ['*://example.com/*'], 'https://other.com/page')).toBe(0); + }); + }); +}); diff --git a/src/shared/utils/countActiveHeadersForUrl.ts b/src/shared/utils/countActiveHeadersForUrl.ts new file mode 100644 index 0000000..8eca3f1 --- /dev/null +++ b/src/shared/utils/countActiveHeadersForUrl.ts @@ -0,0 +1,50 @@ +import type { RequestHeader } from '#entities/request-profile/types'; + +import { convertToRegexFilter } from './createUrlCondition'; + +/** + * Tests whether a URL matches a single URL filter string, using the same + * matching semantics as declarativeNetRequest: + * - Filters containing *:// are treated as regex patterns (via convertToRegexFilter) + * - All other filters are treated as urlFilter substring/wildcard patterns + */ +export function doesUrlMatchFilter(url: string, filter: string): boolean { + if (!url || !filter) return false; + + if (filter.includes('*://')) { + try { + return new RegExp(convertToRegexFilter(filter)).test(url); + } catch { + return false; + } + } + + // urlFilter path: escape regex metacharacters, convert * → .* + // Chrome urlFilter matching is case-insensitive substring/wildcard match + try { + const pattern = filter.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*'); + return new RegExp(pattern, 'i').test(url); + } catch { + return false; + } +} + +/** + * Returns the count of active headers that apply to the given URL. + * + * - No URL filters → headers apply everywhere → return activeHeaders.length + * - Unknown URL (chrome:// pages, about:blank, race conditions) → safe fallback → return activeHeaders.length + * - At least one filter matches currentUrl → return activeHeaders.length + * - No filters match → return 0 + */ +export function countActiveHeadersForUrl( + activeHeaders: RequestHeader[], + activeUrlFilters: string[], + currentUrl: string | undefined, +): number { + if (activeUrlFilters.length === 0) return activeHeaders.length; + if (!currentUrl) return activeHeaders.length; + + const matches = activeUrlFilters.some(f => doesUrlMatchFilter(currentUrl, f)); + return matches ? activeHeaders.length : 0; +} diff --git a/src/shared/utils/createUrlCondition.ts b/src/shared/utils/createUrlCondition.ts index 15c84e4..bd34f50 100644 --- a/src/shared/utils/createUrlCondition.ts +++ b/src/shared/utils/createUrlCondition.ts @@ -17,7 +17,7 @@ /** * Converts a wildcard pattern into a valid RE2 regular expression */ -function convertToRegexFilter(pattern: string): string { +export function convertToRegexFilter(pattern: string): string { // Escape RE2 special characters except * and . const regex = pattern .replace(/[+^${}()|[\]\\]/g, '\\$&') // Escape special characters (excluding . and *) diff --git a/src/shared/utils/setBrowserHeaders.ts b/src/shared/utils/setBrowserHeaders.ts index 4546d92..3185789 100644 --- a/src/shared/utils/setBrowserHeaders.ts +++ b/src/shared/utils/setBrowserHeaders.ts @@ -3,6 +3,7 @@ import browser from 'webextension-polyfill'; import type { Profile, RequestHeader } from '#entities/request-profile/types'; import { BrowserStorageKey } from '#shared/constants'; +import { countActiveHeadersForUrl } from './countActiveHeadersForUrl'; import { createUrlCondition } from './createUrlCondition'; import { validateHeader } from './headers'; import { logger } from './logger'; @@ -57,7 +58,7 @@ function getRulesForHeader(header: RequestHeader, urlFilters: string[]): browser }); } -export async function setBrowserHeaders(result: Record) { +export async function setBrowserHeaders(result: Record, currentTabUrl?: string) { const isPaused = result[BrowserStorageKey.IsPaused] as boolean; // Validate data from storage @@ -161,7 +162,8 @@ export async function setBrowserHeaders(result: Record) { logger.info('Rules updated successfully'); logger.groupEnd(); - await setIconBadge({ isPaused, activeRulesCount: activeHeaders.length }); + const activeRulesCount = countActiveHeadersForUrl(activeHeaders, activeUrlFilters, currentTabUrl); + await setIconBadge({ isPaused, activeRulesCount }); } catch (err) { logger.error('Failed to update dynamic rules:', err); }