diff --git a/packages/common/src/mdl.types.ts b/packages/common/src/mdl.types.ts index c46c6b3cad..d5f7a3a18c 100644 --- a/packages/common/src/mdl.types.ts +++ b/packages/common/src/mdl.types.ts @@ -18,5 +18,5 @@ export interface MDLTableData { owner: string; scriptBlocking: string; highlighted?: boolean; - highlightedClass?: string; + highlightedClass?: (selected: boolean) => string; } diff --git a/packages/common/src/prtToken.types.ts b/packages/common/src/prtToken.types.ts index aa94d69229..493d23184d 100644 --- a/packages/common/src/prtToken.types.ts +++ b/packages/common/src/prtToken.types.ts @@ -29,7 +29,6 @@ interface DecryptedToken { interface PlaintTextToken { uint8Signal: Uint8Array; - humanReadableSignal: string; ordinal: Uint8Array; version: number; hmacValid: boolean; @@ -37,9 +36,10 @@ interface PlaintTextToken { export interface PRTMetadata { origin: string; - humanReadableSignal: string; decryptionKeyAvailable: boolean; prtHeader: string; + nonZeroUint8Signal: boolean; + owner: string; } export type UniquePlainTextToken = PlaintTextToken & { diff --git a/packages/design-system/src/components/circlePieChart/emptyCirclePieChart.tsx b/packages/design-system/src/components/circlePieChart/emptyCirclePieChart.tsx index b09f63dbbd..7548cbb95c 100644 --- a/packages/design-system/src/components/circlePieChart/emptyCirclePieChart.tsx +++ b/packages/design-system/src/components/circlePieChart/emptyCirclePieChart.tsx @@ -22,19 +22,27 @@ import { VictoryPie } from 'victory-pie'; * Internal dependencies */ import { COLOR_MAP } from '../../theme/colors'; +import Tooltip from './tooltip'; -const EmptyCirclePieChart = () => { +const EmptyCirclePieChart = ({ + tooltipText = '', + showTooltip = false, +}: { + tooltipText?: string; + showTooltip?: boolean; +}) => { return (
-

+

0

+ {tooltipText && showTooltip && }
); }; diff --git a/packages/design-system/src/components/circlePieChart/index.tsx b/packages/design-system/src/components/circlePieChart/index.tsx index e89bbefcd0..13958ff5b3 100644 --- a/packages/design-system/src/components/circlePieChart/index.tsx +++ b/packages/design-system/src/components/circlePieChart/index.tsx @@ -16,7 +16,7 @@ /** * External dependencies. */ -import React from 'react'; +import React, { useState } from 'react'; import { VictoryPie } from 'victory-pie'; import classNames from 'classnames'; @@ -24,6 +24,7 @@ import classNames from 'classnames'; * Internal dependencies. */ import EmptyCirclePieChart from './emptyCirclePieChart'; +import Tooltip from './tooltip'; interface CirclePieChartProps { centerCount: number; @@ -32,7 +33,9 @@ interface CirclePieChartProps { fallbackText?: string; infoIconClassName?: string; centerTitleExtraClasses?: string; + bottomTitleExtraClasses?: string; pieChartExtraClasses?: string; + tooltipText?: string; } export const MAX_COUNT = 999; @@ -42,15 +45,25 @@ const CirclePieChart = ({ data, title, centerTitleExtraClasses = '', + bottomTitleExtraClasses = '', pieChartExtraClasses = '', + tooltipText = '', }: CirclePieChartProps) => { + const [showTooltip, setShowTooltip] = useState(false); const centerTitleClasses = centerCount <= MAX_COUNT ? 'text-2xl' : 'text-l'; return ( -
-
+
setShowTooltip(true)} + onMouseLeave={() => setShowTooltip(false)} + > +
{centerCount <= 0 ? ( - + ) : (
{centerCount <= MAX_COUNT ? centerCount : MAX_COUNT + '+'}

+ {tooltipText && showTooltip && ( + + )}
)}
{title && ( -
+

{title}

diff --git a/packages/design-system/src/components/circlePieChart/stories/circlePieChart.stories.tsx b/packages/design-system/src/components/circlePieChart/stories/circlePieChart.stories.tsx index 52ddf868dc..53be77eb71 100644 --- a/packages/design-system/src/components/circlePieChart/stories/circlePieChart.stories.tsx +++ b/packages/design-system/src/components/circlePieChart/stories/circlePieChart.stories.tsx @@ -55,6 +55,7 @@ export const Primary: StoryObj = { color: COLOR_MAP.uncategorized.color, }, ], + tooltipText: 'This chart shows the distribution of counts.', }, render: (args) => (
diff --git a/packages/design-system/src/components/circlePieChart/tooltip.tsx b/packages/design-system/src/components/circlePieChart/tooltip.tsx new file mode 100644 index 0000000000..f0e3ae7765 --- /dev/null +++ b/packages/design-system/src/components/circlePieChart/tooltip.tsx @@ -0,0 +1,29 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +/** + * External dependencies. + */ +import React from 'react'; + +const Tooltip = ({ tooltipText }: { tooltipText?: string }) => { + return ( +
+ {tooltipText} +
+ ); +}; + +export default Tooltip; diff --git a/packages/design-system/src/components/table/components/tableBody/bodyRow.tsx b/packages/design-system/src/components/table/components/tableBody/bodyRow.tsx index fa8e0c9fe8..34c55cdfe7 100644 --- a/packages/design-system/src/components/table/components/tableBody/bodyRow.tsx +++ b/packages/design-system/src/components/table/components/tableBody/bodyRow.tsx @@ -68,24 +68,26 @@ const BodyRow = ({ const rowKey = getRowObjectKey(row); const isHighlighted = row.originalData?.highlighted; const scrollToHighlighted = row.originalData?.scrollToHighlighted; - const isHighlightedClass = - row.originalData?.highlightedClass || 'bg-dirty-pink dark:text-black'; + const highlightedClass = (selected: boolean) => + row.originalData?.highlightedClass?.(selected) || + 'bg-dirty-pink dark:text-black'; + const classes = classnames( rowKey !== selectedKey && (index % 2 ? isHighlighted - ? isHighlightedClass + ? highlightedClass(false) : 'bg-anti-flash-white dark:bg-charleston-green' : isHighlighted - ? isHighlightedClass + ? highlightedClass(false) : 'bg-white dark:bg-raisin-black'), rowKey === selectedKey && (isRowFocused ? isHighlighted - ? isHighlightedClass + ? `${highlightedClass(true)}` : 'bg-lavender-sky text-black dark:bg-midnight-slate dark:text-chinese-silver' : isHighlighted - ? isHighlightedClass + ? `${highlightedClass(true)}` : 'bg-silver-mist text-black dark:bg-dark-graphite dark:text-chinese-silver') ); const extraClasses = getExtraClasses(); diff --git a/packages/design-system/src/components/table/useTable/types.ts b/packages/design-system/src/components/table/useTable/types.ts index ba6873a02f..19cbcc21d1 100644 --- a/packages/design-system/src/components/table/useTable/types.ts +++ b/packages/design-system/src/components/table/useTable/types.ts @@ -82,7 +82,7 @@ export type TableData = ( | PRTMetadata ) & { highlighted?: boolean; - highlightedClass?: string; // Optional class for highlighting rows + highlightedClass?: () => string; // Optional class for highlighting rows scrollToHighlighted?: boolean; // Optional flag to scroll to highlighted row }; diff --git a/packages/design-system/src/components/tabs/index.tsx b/packages/design-system/src/components/tabs/index.tsx index ad6c767acc..665336f645 100644 --- a/packages/design-system/src/components/tabs/index.tsx +++ b/packages/design-system/src/components/tabs/index.tsx @@ -35,17 +35,18 @@ const Tabs = ({ showBottomBorder = true, fontSizeClass }: TabsProps) => { activeTab, activeGroup, setActiveTab, + setActiveGroup, groupedTitles, titles, isTabHighlighted, shouldAddSpacer, getTabGroup, isGroup, - loading, } = useTabs(({ state, actions }) => ({ activeTab: state.activeTab, activeGroup: state.activeGroup, setActiveTab: actions.setActiveTab, + setActiveGroup: actions.setActiveGroup, groupedTitles: state.groupedTitles, titles: state.titles, isTabHighlighted: actions.isTabHighlighted, @@ -57,18 +58,6 @@ const Tabs = ({ showBottomBorder = true, fontSizeClass }: TabsProps) => { const timeoutRef = useRef(null); - const [expandedGroup, setExpandedGroup] = useState(null); - - useEffect(() => { - if (loading) { - return; - } - - if (activeGroup && expandedGroup === null) { - setExpandedGroup(activeGroup); - } - }, [activeGroup, expandedGroup, loading]); - useEffect(() => { return () => { if (timeoutRef.current) { @@ -87,17 +76,15 @@ const Tabs = ({ showBottomBorder = true, fontSizeClass }: TabsProps) => { setIsAnimating(true); - if (expandedGroup === group) { - setExpandedGroup(null); - } else { - setExpandedGroup(group); + if (activeGroup !== group) { + setActiveGroup(group); } timeoutRef.current = setTimeout(() => { setIsAnimating(false); }, 300); }, - [expandedGroup, isAnimating] + [activeGroup, isAnimating, setActiveGroup] ); const handleKeyDown = useCallback( @@ -108,37 +95,36 @@ const Tabs = ({ showBottomBorder = true, fontSizeClass }: TabsProps) => { if (event.shiftKey) { const previousIndex = activeTab - 1; if (previousIndex >= 0) { - setActiveTab(previousIndex); - const group = getTabGroup(previousIndex); - if (expandedGroup !== group) { - console.log(group, expandedGroup); + if (activeGroup !== group) { handleGroupClick(group); } - } else { - setActiveTab(titles.length - 1); + setActiveTab(previousIndex); + } else { const group = getTabGroup(titles.length - 1); - if (expandedGroup !== group) { + if (activeGroup !== group) { handleGroupClick(group); } + + setActiveTab(titles.length - 1); } } else { const nextIndex = activeTab + 1; if (nextIndex < titles.length) { - setActiveTab(nextIndex); - const group = getTabGroup(nextIndex); - if (expandedGroup !== group) { + if (activeGroup !== group) { handleGroupClick(group); } - } else { - setActiveTab(0); + setActiveTab(nextIndex); + } else { const group = getTabGroup(0); - if (expandedGroup !== group) { + if (activeGroup !== group) { handleGroupClick(group); } + + setActiveTab(0); } } } @@ -148,7 +134,7 @@ const Tabs = ({ showBottomBorder = true, fontSizeClass }: TabsProps) => { titles.length, setActiveTab, getTabGroup, - expandedGroup, + activeGroup, handleGroupClick, ] ); @@ -171,7 +157,7 @@ const Tabs = ({ showBottomBorder = true, fontSizeClass }: TabsProps) => { )} > {Object.entries(groupedTitles).map(([group, data]) => { - const isExpanded = expandedGroup === group; + const isExpanded = activeGroup === group; return (
{ )} > @@ -214,6 +220,9 @@ describe('useTabs', () => { // Check that the active group is 'group-1' expect(screen.getByText('group-1')).toHaveClass('active-group-button'); + fireEvent.click(screen.getByText('group-2')); + expect(title3).toHaveClass('active'); + fireEvent.click(screen.getByText('title4')); expect(title4).toHaveClass('active'); diff --git a/packages/design-system/src/theme.css b/packages/design-system/src/theme.css index 81170fa5ab..2cd67bf178 100644 --- a/packages/design-system/src/theme.css +++ b/packages/design-system/src/theme.css @@ -1383,3 +1383,12 @@ button { cursor: pointer; } + +@keyframes fadeIn { + from { opacity: 0; transform: translateY(-10px);} + to { opacity: 1; transform: translateY(0);} +} +.animate-fadeIn { + opacity: 1 !important; + animation: fadeIn 0.3s ease; +} diff --git a/packages/extension/package.json b/packages/extension/package.json index f7283ea51f..b7ccaec37f 100644 --- a/packages/extension/package.json +++ b/packages/extension/package.json @@ -9,8 +9,8 @@ "license": "Apache-2.0", "scripts": { "prebuild": "rimraf ../../dist/extension", - "dev": "cross-env NODE_ENV=development npm run build", - "build": "cross-env VITE_CJS_IGNORE_WARNING=true NODE_NO_WARNINGS=1 node --loader ts-node/esm ../../vite.extension.config.mts" + "dev": "cross-env NODE_ENV=development npm run build -- --watch", + "build": "cross-env VITE_CJS_IGNORE_WARNING=true NODE_NO_WARNINGS=1 NODE_OPTIONS=--max-old-space-size=4096 node --loader ts-node/esm ../../vite.extension.config.mts" }, "bugs": { "url": "https://github.com/GoogleChromeLabs/ps-analysis-tool/issues" diff --git a/packages/extension/src/manifest.json b/packages/extension/src/manifest.json index df64a8650f..3538276672 100644 --- a/packages/extension/src/manifest.json +++ b/packages/extension/src/manifest.json @@ -10,7 +10,7 @@ "48": "icons/icon-48.png", "128": "icons/icon-128.png" }, - "incognito": "spanning", + "incognito": "split", "permissions": [ "storage", "tabs", diff --git a/packages/extension/src/serviceWorker/chromeListeners/runtimeOnInstalledListener.ts b/packages/extension/src/serviceWorker/chromeListeners/runtimeOnInstalledListener.ts index 9b5a77770c..87da4d6267 100644 --- a/packages/extension/src/serviceWorker/chromeListeners/runtimeOnInstalledListener.ts +++ b/packages/extension/src/serviceWorker/chromeListeners/runtimeOnInstalledListener.ts @@ -37,6 +37,7 @@ export const runtimeOnInstalledListener = async ( if (details.reason === 'update') { const preSetSettings = await chrome.storage.sync.get(); + const objectToInstantiate: { [key: string]: any } = { ...preSetSettings }; await updateGlobalVariableAndAttachCDP(); @@ -44,9 +45,11 @@ export const runtimeOnInstalledListener = async ( return; } + if (!Object.keys(preSetSettings).includes('isUsingCDP')) { + objectToInstantiate['isUsingCDP'] = false; + } + await chrome.storage.sync.clear(); - await chrome.storage.sync.set({ - isUsingCDP: false, - }); + await chrome.storage.sync.set({ ...objectToInstantiate }); } }; diff --git a/packages/extension/src/serviceWorker/chromeListeners/runtimeStartUpListener.ts b/packages/extension/src/serviceWorker/chromeListeners/runtimeStartUpListener.ts index 9ce61d9779..f013fb4afd 100644 --- a/packages/extension/src/serviceWorker/chromeListeners/runtimeStartUpListener.ts +++ b/packages/extension/src/serviceWorker/chromeListeners/runtimeStartUpListener.ts @@ -17,13 +17,27 @@ * Internal dependencies */ import { DataStore } from '../../store/dataStore'; +import PRTStore from '../../store/PRTStore'; import { setupIntervals } from './utils'; export const onStartUpListener = async () => { const storage = await chrome.storage.sync.get(); + const sessionStorage = await chrome.storage.session.get(); setupIntervals(); if (Object.keys(storage).includes('isUsingCDP')) { DataStore.globalIsUsingCDP = storage.isUsingCDP; } + + if (Object.keys(sessionStorage).includes('prtStatistics')) { + PRTStore.statistics.prtStatistics.globalView = { + ...sessionStorage?.prtStatistics, + }; + } + + if (Object.keys(sessionStorage).includes('scriptBlocking')) { + PRTStore.statistics.scriptBlocking.globalView = { + ...sessionStorage?.scriptBlocking, + }; + } }; diff --git a/packages/extension/src/serviceWorker/chromeListeners/tests/tabOnCreatedListener.ts b/packages/extension/src/serviceWorker/chromeListeners/tests/tabOnCreatedListener.ts index 5739aade77..86203ed8e6 100644 --- a/packages/extension/src/serviceWorker/chromeListeners/tests/tabOnCreatedListener.ts +++ b/packages/extension/src/serviceWorker/chromeListeners/tests/tabOnCreatedListener.ts @@ -30,7 +30,6 @@ import { DataStore } from '../../../store/dataStore'; describe('chrome.tabs.onCreated.addListener', () => { beforeAll(() => { globalThis.chrome = SinonChrome as unknown as typeof chrome; - SinonChrome.tabs.onCreated.addListener(onTabCreatedListener); globalThis.fetch = function () { return Promise.resolve({ json: () => @@ -40,6 +39,7 @@ describe('chrome.tabs.onCreated.addListener', () => { text: () => Promise.resolve({}), }); } as unknown as typeof fetch; + SinonChrome.tabs.onCreated.addListener(onTabCreatedListener); }); test('Openeing new tab and if tabId is missing it should not create new tab.', async () => { diff --git a/packages/extension/src/serviceWorker/chromeListeners/utils/setupIntervals.ts b/packages/extension/src/serviceWorker/chromeListeners/utils/setupIntervals.ts index a881ebe4c3..4ecdc3d7d9 100644 --- a/packages/extension/src/serviceWorker/chromeListeners/utils/setupIntervals.ts +++ b/packages/extension/src/serviceWorker/chromeListeners/utils/setupIntervals.ts @@ -25,7 +25,11 @@ const setupIntervals = () => { // @see https://developer.chrome.com/blog/longer-esw-lifetimes#whats_changed // Doing this to keep the service worker alive so that we dont loose any data and introduce any bug. setInterval(async () => { - await chrome.storage.session.get(); + try { + await chrome.storage.session.get(); + } catch (error) { + //fail silently + } }, 20000); // @todo Send tab data of the active tab only, also if sending only the difference would make it any faster. diff --git a/packages/extension/src/serviceWorker/index.ts b/packages/extension/src/serviceWorker/index.ts index 727cf2f671..b2f2b6f729 100644 --- a/packages/extension/src/serviceWorker/index.ts +++ b/packages/extension/src/serviceWorker/index.ts @@ -33,6 +33,7 @@ import attachCDP from './attachCDP'; import readHeaderAndRegister from './readHeaderAndRegister'; import PRTStore from '../store/PRTStore'; import { createURL, extractHeader } from '../utils/headerFunctions'; +import updateStatistics from '../store/utils/updateStatistics'; const ALLOWED_EVENTS = [ 'Network.responseReceived', @@ -361,18 +362,35 @@ chrome.debugger.onEvent.addListener((source, method, params) => { 'Sec-Probabilistic-Reveal-Token', headers ); - const origin = - extractHeader('origin', headers) ?? isValidURL(createURL(headers)) - ? new URL(createURL(headers)).origin - : ''; - if (!prtHeader) { + let origin = ''; + + if ( + DataStore.requestIdToCDPURLMapping[tabId]?.[requestId]?.url && + isValidURL( + DataStore.requestIdToCDPURLMapping[tabId]?.[requestId]?.url + ) + ) { + origin = new URL( + DataStore.requestIdToCDPURLMapping[tabId]?.[requestId]?.url + ).origin; + } else if ( + createURL(headers) && + isValidURL(createURL(headers) ?? '') + ) { + origin = new URL(createURL(headers) ?? '').origin; + } + + if (!prtHeader || !origin) { return; } const prt = PRTStore.getTokenFromHeaderString(prtHeader); const decodedToken = await PRTStore.decryptTokenHeader(prtHeader); const plainTextToken = await PRTStore.getPlaintextToken(decodedToken); + const nonZeroUint8Signal = plainTextToken?.uint8Signal + ? !plainTextToken.uint8Signal.every((bit) => bit === 0) + : false; if ( prt && @@ -405,14 +423,30 @@ chrome.debugger.onEvent.addListener((source, method, params) => { ...plainTextToken, prtHeader, }); + + updateStatistics(origin, nonZeroUint8Signal); + + await chrome.storage.session.set({ + prtStatistics: { + ...PRTStore.statistics.prtStatistics.globalView, + }, + }); } if (!PRTStore.tabTokens[tabId]?.perTokenMetadata?.[prtHeader]) { + const hostname = isValidURL(origin) ? new URL(origin).hostname : ''; + const formedOrigin = hostname.startsWith('www.') + ? hostname.slice(4) + : hostname; + PRTStore.tabTokens[tabId].perTokenMetadata[prtHeader] = { prtHeader, - humanReadableSignal: plainTextToken?.humanReadableSignal ?? '', origin: isValidURL(origin) ? origin : '', decryptionKeyAvailable: Boolean(decodedToken), + nonZeroUint8Signal, + owner: PRTStore.mdlData[formedOrigin]?.owner + ? PRTStore.mdlData[formedOrigin]?.owner + : '', }; DataStore.tabs[tabId].newUpdatesPRT++; } @@ -519,7 +553,7 @@ chrome.debugger.onEvent.addListener((source, method, params) => { }; } - dataStore.updateUniqueResponseDomains(tabId, requestId); + PRTStore.updateUniqueResponseDomains(tabId, requestId); if (cookieStore.getUnParsedResponseHeadersForCA(tabId)?.[requestId]) { cookieStore.parseResponseHeadersForCA( diff --git a/packages/extension/src/store/PRTStore.ts b/packages/extension/src/store/PRTStore.ts index b5247443ef..6036807c23 100644 --- a/packages/extension/src/store/PRTStore.ts +++ b/packages/extension/src/store/PRTStore.ts @@ -16,11 +16,12 @@ /** * External dependencies. */ -import type { - UniqueDecryptedToken, - UniquePlainTextToken, - ProbablisticRevealToken, - PRTMetadata, +import { + type UniqueDecryptedToken, + type UniquePlainTextToken, + type ProbablisticRevealToken, + type PRTMetadata, + isValidURL, } from '@google-psat/common'; import { ec as ellipticEc } from 'elliptic'; @@ -28,7 +29,7 @@ import { ec as ellipticEc } from 'elliptic'; * Internal dependencies. */ import { DataStore } from './dataStore'; -import { TAB_TOKEN_DATA } from '../constants'; +import { EXTRA_DATA, TAB_TOKEN_DATA } from '../constants'; const PRT_SIZE = 79; const PRT_POINT_SIZE = 33; @@ -62,8 +63,123 @@ type SingleTabTokens = { class PRTStore extends DataStore { tabTokens: TabToken = {}; + mdlData: { + [domain: string]: { + scriptBlockingScope: 'NONE' | 'PARTIAL' | 'COMPLETE'; + domain: string; + owner: string; + }; + } = {}; + + statistics: { + prtStatistics: { + globalView: { + [domain: string]: { + totalTokens: number; + nonZeroSignal: number; + }; + }; + localView: { + [domain: string]: { + totalTokens: number; + nonZeroSignal: number; + }; + }; + }; + scriptBlocking: { + globalView: { + partiallyBlockedDomains: number; + completelyBlockedDomains: number; + domains: number; + }; + localView: { + partiallyBlockedDomains: number; + completelyBlockedDomains: number; + domains: number; + }; + }; + } = { + prtStatistics: { + globalView: {}, + localView: {}, + }, + scriptBlocking: { + globalView: { + partiallyBlockedDomains: 0, + completelyBlockedDomains: 0, + domains: 0, + }, + localView: { + partiallyBlockedDomains: 0, + completelyBlockedDomains: 0, + domains: 0, + }, + }, + }; + + uniqueResponseDomains: { + [tabId: string]: string[]; + } = {}; + constructor() { super(); + + (async () => { + const data = await fetch( + 'https://raw.githubusercontent.com/GoogleChrome/ip-protection/refs/heads/main/Masked-Domain-List.md' + ); + + if (!data.ok) { + throw new Error(`HTTP error! status: ${data.status}`); + } + + const text = await data.text(); + + const lines = text + .split('\n') + .filter((line) => line.includes('|')) + .slice(2); + + const mdlData = lines.map((line) => + line.split('|').map((item) => item.trim()) + ); + + const _data = mdlData.reduce((acc, item: string[]) => { + let owner = item[1]; + + if (item[1].includes('PSL Domain')) { + owner = 'PSL Domain'; + } + + let scriptBlocking = ''; + + switch (item[2]) { + case 'Not Impacted By Script Blocking': + scriptBlocking = 'NONE'; + break; + case 'Some URLs are Blocked': + scriptBlocking = 'PARTIAL'; + break; + case 'Entire Domain Blocked': + scriptBlocking = 'COMPLETE'; + break; + default: + break; + } + + if (!acc[item[0]]) { + acc[item[0]] = { + domain: item[0], + owner, + scriptBlockingScope: scriptBlocking, + }; + } + + return acc; + }, {} as { [key: string]: any }); + + this.mdlData = _data; + })(); } clear(): void { @@ -73,6 +189,55 @@ class PRTStore extends DataStore { }); } + updateUniqueResponseDomains(tabId: string, requestId: string) { + if (!DataStore.requestIdToCDPURLMapping[tabId]) { + return; + } + + const request = DataStore.requestIdToCDPURLMapping[tabId][requestId]; + + if ( + !request || + !isValidURL(request.url) || + request.url.startsWith('chrome://') || + request.url.startsWith('chrome-extension://') || + request.url.startsWith('file://') + ) { + return; + } + + let hostname = new URL(request.url).hostname; + hostname = hostname.startsWith('www.') ? hostname.slice(4) : hostname; + + if ( + hostname !== 'null' && + !this.uniqueResponseDomains[tabId].includes(hostname) + ) { + this.uniqueResponseDomains[tabId].push(hostname); + DataStore.tabs[tabId].newUpdatesScriptBlocking++; + + this.statistics.scriptBlocking.globalView.completelyBlockedDomains += + this.mdlData[hostname]?.scriptBlockingScope === 'COMPLETE' ? 1 : 0; + this.statistics.scriptBlocking.globalView.partiallyBlockedDomains += + this.mdlData[hostname]?.scriptBlockingScope === 'PARTIAL' ? 1 : 0; + + this.statistics.scriptBlocking.globalView.domains += 1; + + this.statistics.scriptBlocking.localView.completelyBlockedDomains += + this.mdlData[hostname]?.scriptBlockingScope === 'COMPLETE' ? 1 : 0; + this.statistics.scriptBlocking.localView.partiallyBlockedDomains += + this.mdlData[hostname]?.scriptBlockingScope === 'PARTIAL' ? 1 : 0; + + this.statistics.scriptBlocking.localView.domains += 1; + + chrome.storage.session.set({ + scriptBlocking: { + ...this.statistics.scriptBlocking.globalView, + }, + }); + } + } + getTabsData(tabId = ''): SingleTabTokens | typeof this.tabTokens { if (tabId) { return this.tabTokens[tabId]; @@ -83,6 +248,7 @@ class PRTStore extends DataStore { deinitialiseVariablesForTab(tabId: string): void { super.deinitialiseVariablesForTab(tabId); delete this.tabTokens[tabId]; + delete this.uniqueResponseDomains[parseInt(tabId)]; } initialiseVariablesForNewTab(tabId: string): void { @@ -93,11 +259,19 @@ class PRTStore extends DataStore { prtTokens: [], perTokenMetadata: {}, }; + this.statistics.prtStatistics.localView = {}; + this.statistics.scriptBlocking.localView = { + partiallyBlockedDomains: 0, + completelyBlockedDomains: 0, + domains: 0, + }; + this.uniqueResponseDomains[parseInt(tabId)] = []; //@ts-ignore globalThis.PSAT = { //@ts-ignore ...globalThis.PSAT, tabTokens: this.tabTokens, + statistics: this.statistics, }; } @@ -110,8 +284,13 @@ class PRTStore extends DataStore { tabId: string, overrideForInitialSync: boolean ) { + await this.processPRTData(tabId, overrideForInitialSync); + await this.processScriptBlockingData(tabId, overrideForInitialSync); + } + + async processPRTData(tabId: string, overrideForInitialSync: boolean) { try { - if (DataStore.tabs[tabId].newUpdatesCA <= 0 && !overrideForInitialSync) { + if (DataStore.tabs[tabId].newUpdatesPRT <= 0 && !overrideForInitialSync) { return; } @@ -120,11 +299,54 @@ class PRTStore extends DataStore { perTokenMetadata: Object.values(this.tabTokens[tabId].perTokenMetadata), }; - await chrome.runtime.sendMessage({ + //@ts-ignore + const { prtStatistics = {} } = await chrome.storage.session.get( + 'prtStatistics' + ); + + const localStats = Object.keys( + this.statistics.prtStatistics.localView + ).reduce( + (acc, origin) => { + acc.totalTokens += + this.statistics.prtStatistics.localView[origin].totalTokens; + acc.nonZeroSignal += + this.statistics.prtStatistics.localView[origin].nonZeroSignal; + + return acc; + }, + { totalTokens: 0, nonZeroSignal: 0 } + ); + + const globalStats = Object.keys(prtStatistics).reduce( + (acc, key) => { + if (prtStatistics[key]) { + acc.totalTokens = prtStatistics[key].totalTokens + acc.totalTokens; + acc.nonZeroSignal = + prtStatistics[key].nonZeroSignal + acc.nonZeroSignal; + let hostname = isValidURL(key) ? new URL(key).hostname : ''; + + hostname = hostname.startsWith('www.') + ? hostname.slice(4) + : hostname; + acc.mdl += hostname && this.mdlData[hostname] ? 1 : 0; + } + return acc; + }, + { totalTokens: 0, nonZeroSignal: 0, domains: 0, mdl: 0 } + ); + + globalStats.domains = Object.keys(prtStatistics).length; + + globalStats.mdl = await chrome.runtime.sendMessage({ type: TAB_TOKEN_DATA, payload: { tabId, tokens: tokenData, + stats: { + globalView: globalStats, + localView: localStats, + }, }, }); @@ -133,6 +355,38 @@ class PRTStore extends DataStore { // Fail silently } } + + async processScriptBlockingData( + tabId: string, + overrideForInitialSync: boolean + ) { + if ( + overrideForInitialSync || + ((DataStore.tabs[tabId].devToolsOpenState || + DataStore.tabs[tabId].popupOpenState) && + DataStore.tabs[tabId].newUpdatesScriptBlocking > 0) + ) { + //@ts-ignore + const { scriptBlocking = {} } = await chrome.storage.session.get( + 'scriptBlocking' + ); + + const globalView = scriptBlocking; + + await chrome.runtime.sendMessage({ + type: EXTRA_DATA, + payload: { + uniqueResponseDomains: this.uniqueResponseDomains[tabId], + stats: { + globalView, + localView: this.statistics.scriptBlocking.localView, + }, + tabId: Number(tabId), + }, + }); + DataStore.tabs[tabId].newUpdatesScriptBlocking = 0; + } + } /** * Deserializes a raw byte buffer into a PRT object, performing structural validation. * @param {ArrayBuffer} serializedPrt The raw byte array representing the serialized PRT. @@ -348,7 +602,6 @@ class PRTStore extends DataStore { version, ordinal, uint8Signal: signal, - humanReadableSignal: btoa(String.fromCharCode.apply(null, plaintext)), hmacValid, } as UniquePlainTextToken; } catch (e) { diff --git a/packages/extension/src/store/dataStore.ts b/packages/extension/src/store/dataStore.ts index 00edd860d1..fa89d03086 100644 --- a/packages/extension/src/store/dataStore.ts +++ b/packages/extension/src/store/dataStore.ts @@ -23,7 +23,6 @@ import { isValidURL } from '@google-psat/common'; * Internal dependencies. */ import { doesFrameExist } from '../utils/doesFrameExist'; -import { EXTRA_DATA } from '../constants'; export class DataStore { /** @@ -74,63 +73,14 @@ export class DataStore { }; } = {}; - updateUniqueResponseDomains(tabId: string, requestId: string) { - if (!DataStore.requestIdToCDPURLMapping[tabId]) { - return; - } - - const request = DataStore.requestIdToCDPURLMapping[tabId][requestId]; - - if ( - !request || - !isValidURL(request.url) || - request.url.startsWith('chrome://') || - request.url.startsWith('chrome-extension://') || - request.url.startsWith('file://') - ) { - return; - } - - const hostname = new URL(request.url).hostname; - - if ( - hostname !== 'null' && - !DataStore.tabs[tabId].uniqueResponseDomains.includes(hostname) - ) { - DataStore.tabs[tabId].uniqueResponseDomains.push(hostname); - DataStore.tabs[tabId].newUpdatesScriptBlocking++; - } - } - - async sendUpdatedDataToPopupAndDevTools( + sendUpdatedDataToPopupAndDevTools( tabId: string, - overrideForInitialSync = false + // eslint-disable-next-line @typescript-eslint/no-unused-vars + _overrideForInitialSync = false ) { if (!DataStore.tabs[tabId]) { return; } - - try { - if ( - overrideForInitialSync || - ((DataStore.tabs[tabId].devToolsOpenState || - DataStore.tabs[tabId].popupOpenState) && - DataStore.tabs[tabId].newUpdatesScriptBlocking > 0) - ) { - await chrome.runtime.sendMessage({ - type: EXTRA_DATA, // For sending extra data. - payload: { - uniqueResponseDomains: DataStore.tabs[tabId].uniqueResponseDomains, - tabId: Number(tabId), - }, - }); - DataStore.tabs[tabId].newUpdatesScriptBlocking = 0; - } - } catch (error) { - // eslint-disable-next-line no-console - console.warn(error); - //Fail silently. Ignoring the console.warn here because the only error this will throw is of "Error: Could not establish connection". - } } /** diff --git a/packages/extension/src/store/utils/updateStatistics.ts b/packages/extension/src/store/utils/updateStatistics.ts new file mode 100644 index 0000000000..f005f758cf --- /dev/null +++ b/packages/extension/src/store/utils/updateStatistics.ts @@ -0,0 +1,61 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +/** + * Internal dependencies + */ +import PRTStore from '../PRTStore'; + +const updateStatistics = (origin: string, nonZeroUint8Signal: boolean) => { + if (PRTStore.statistics.prtStatistics.localView[origin]) { + PRTStore.statistics.prtStatistics.localView[origin] = { + totalTokens: + (PRTStore.statistics.prtStatistics.localView[origin]?.totalTokens ?? + 0) + 1, + nonZeroSignal: + (PRTStore.statistics.prtStatistics.localView[origin]?.nonZeroSignal ?? + 0) + (nonZeroUint8Signal ? 1 : 0), + }; + } else { + PRTStore.statistics.prtStatistics.localView = { + ...PRTStore.statistics.prtStatistics.localView, + [origin]: { + totalTokens: 1, + nonZeroSignal: nonZeroUint8Signal ? 1 : 0, + }, + }; + } + + if (PRTStore.statistics.prtStatistics.globalView[origin]) { + PRTStore.statistics.prtStatistics.globalView[origin] = { + totalTokens: + (PRTStore.statistics.prtStatistics.globalView[origin]?.totalTokens ?? + 0) + 1, + nonZeroSignal: + (PRTStore.statistics.prtStatistics.globalView[origin]?.nonZeroSignal ?? + 0) + (nonZeroUint8Signal ? 1 : 0), + }; + } else { + PRTStore.statistics.prtStatistics.globalView = { + ...PRTStore.statistics.prtStatistics.globalView, + [origin]: { + totalTokens: 1, + nonZeroSignal: nonZeroUint8Signal ? 1 : 0, + }, + }; + } +}; + +export default updateStatistics; diff --git a/packages/extension/src/utils/getSignal.ts b/packages/extension/src/utils/getSignal.ts new file mode 100644 index 0000000000..8950837da7 --- /dev/null +++ b/packages/extension/src/utils/getSignal.ts @@ -0,0 +1,58 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Converts number array to IPv6 address format. + * @param {number[]} buffer The signal from which data needs to be decoded + * @returns IPv6 address + */ +function formatIPv6(buffer: number[]) { + const parts = []; + for (let i = 0; i < 16; i += 2) { + parts.push(((buffer[i] << 8) | buffer[i + 1]).toString(16)); + } + // Basic zero compression + return parts.join(':').replace(/:(0:)+/, '::'); +} + +/** + * Converts array buffer to an IP Address + * @param {number[]} signal The signal from which data needs to be decoded + * @returns IPv6 address + */ +function getSignal(signal: number[]) { + if (signal.every((byte) => byte === 0)) { + return 'No Signal'; + } + + const ipv4MappedPrefix = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xff, 0xff]; + const prefixMatch = signal + .slice(0, 12) + .every((val, i) => val === ipv4MappedPrefix[i]); + + if (signal.length === 16 && prefixMatch) { + const ipv4Bytes = signal.slice(12); + return Array.from(ipv4Bytes).join('.'); + } + + if (signal.length === 16) { + return formatIPv6(signal); + } + + return btoa(String.fromCharCode.apply(null, signal)); +} + +export default getSignal; diff --git a/packages/extension/src/utils/headerFunctions.ts b/packages/extension/src/utils/headerFunctions.ts index 466223a1f2..1c8962aea8 100644 --- a/packages/extension/src/utils/headerFunctions.ts +++ b/packages/extension/src/utils/headerFunctions.ts @@ -41,5 +41,9 @@ export const createURL = (headers: Protocol.Network.Headers) => { const path = extractHeader(':path', headers); const port = extractHeader(':port', headers); + if (!authority || !scheme || !path) { + return null; + } + return `${scheme}://${authority}${port ? `:${port}` : ''}${path}`; }; diff --git a/packages/extension/src/view/devtools/index.tsx b/packages/extension/src/view/devtools/index.tsx index ad775db459..2059303982 100644 --- a/packages/extension/src/view/devtools/index.tsx +++ b/packages/extension/src/view/devtools/index.tsx @@ -60,11 +60,11 @@ if (root) { - - + + - - + + diff --git a/packages/extension/src/view/devtools/pages/layout.tsx b/packages/extension/src/view/devtools/pages/layout.tsx index 59a8c01491..304bd14151 100644 --- a/packages/extension/src/view/devtools/pages/layout.tsx +++ b/packages/extension/src/view/devtools/pages/layout.tsx @@ -198,15 +198,31 @@ const Layout = ({ setSidebarData }: LayoutProps) => { } } - (async () => { - const currentTab = await getCurrentTab(); - const isFirstVisit = - (await chrome.storage.sync.get('isFirstTime'))?.isFirstTime ?? false; + return data; + }); + }, [ + selectedAdUnit, + canStartInspecting, + frameHasCookies, + isInspecting, + isKeySelected, + isSidebarFocused, + setIsInspecting, + setSidebarData, + tabFrames, + ]); + useEffect(() => { + (async () => { + const currentTab = await getCurrentTab(); + const isFirstVisit = + (await chrome.storage.sync.get('isFirstTime'))?.isFirstTime ?? false; + setSidebarData((prev) => { + const data = { ...prev }; if (currentTab?.incognito) { delete data[SIDEBAR_ITEMS_KEYS.OPEN_INCOGNITO_TAB]; data[SIDEBAR_ITEMS_KEYS.SETTINGS].addDivider = false; - return; + return data; } data[SIDEBAR_ITEMS_KEYS.OPEN_INCOGNITO_TAB].popupTitle = incognitoAccess @@ -246,23 +262,10 @@ const Layout = ({ setSidebarData }: LayoutProps) => { }, }; } - })(); - - return data; - }); - }, [ - selectedAdUnit, - canStartInspecting, - frameHasCookies, - isInspecting, - isKeySelected, - isSidebarFocused, - setIsInspecting, - setSidebarData, - tabFrames, - incognitoAccess, - openIncognitoTab, - ]); + return data; + }); + })(); + }, [incognitoAccess, isSidebarFocused, openIncognitoTab, setSidebarData]); const buttonReloadActionCompnent = useMemo(() => { return ( diff --git a/packages/extension/src/view/devtools/pages/privacyProtection/index.ts b/packages/extension/src/view/devtools/pages/privacyProtection/index.ts index 3b47230dc3..d120e0f06b 100644 --- a/packages/extension/src/view/devtools/pages/privacyProtection/index.ts +++ b/packages/extension/src/view/devtools/pages/privacyProtection/index.ts @@ -16,6 +16,5 @@ export { default as BounceTracking } from './bounceTracking'; export { default as UserAgentReduction } from './userAgentReduction'; export { default as IPProtection } from './ipProtection'; -export { default as ScriptBlocking } from './scriptBlocking'; export { default as PrivateStateTokens } from './privateStateTokens'; export { default as PrivacyProtection } from './privacyProtection'; diff --git a/packages/extension/src/view/devtools/pages/privacyProtection/ipProtection/index.tsx b/packages/extension/src/view/devtools/pages/privacyProtection/ipProtection/index.tsx index 8c0db60d73..4e07f8f394 100644 --- a/packages/extension/src/view/devtools/pages/privacyProtection/ipProtection/index.tsx +++ b/packages/extension/src/view/devtools/pages/privacyProtection/ipProtection/index.tsx @@ -27,8 +27,9 @@ import { * Internal dependencies. */ import Panel from './panel'; -import MDLTable from './mdlTable'; import ProbabilisticRevealTokens from './probabilisticRevealTokens'; +import SessionInsights from './probabilisticRevealTokens/sessionInsights'; +import MDLTable from './mdlTable'; const IPProtection = () => { const tabItems = useMemo( @@ -58,13 +59,21 @@ const IPProtection = () => { ], Observability: [ { - title: 'Probabilistic Reveal Tokens', + title: 'Site', content: { Element: ProbabilisticRevealTokens, className: 'overflow-auto h-full', containerClassName: 'h-full', }, }, + { + title: 'Session', + content: { + Element: SessionInsights, + className: 'overflow-auto h-full', + containerClassName: 'h-full', + }, + }, ], }), [] diff --git a/packages/extension/src/view/devtools/pages/privacyProtection/ipProtection/mdlTable/index.tsx b/packages/extension/src/view/devtools/pages/privacyProtection/ipProtection/mdlTable/index.tsx index cfb5d3c39b..e29d9eaa4d 100644 --- a/packages/extension/src/view/devtools/pages/privacyProtection/ipProtection/mdlTable/index.tsx +++ b/packages/extension/src/view/devtools/pages/privacyProtection/ipProtection/mdlTable/index.tsx @@ -28,13 +28,13 @@ import { Link, ResizableTray, } from '@google-psat/design-system'; -import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; /** * Internal dependencies */ import Legend from './legend'; -import { getCurrentTab } from '../../../../../../utils/getCurrentTab'; +import { useScriptBlocking } from '../../../../stateProviders'; const MDLTable = () => { const [selectedKey, setSelectedKey] = useState(null); @@ -45,69 +45,13 @@ const MDLTable = () => { domain: string; owner: string; highlighted: boolean; - highlightedClass: string; + highlightedClass?: () => string; }[] >([]); - const [showOnlyHighlighted, setShowOnlyHighlighted] = useState(true); - - const [tab, setTab] = useState(null); - - useEffect(() => { - const currentTab = async () => { - const _tab = await getCurrentTab(); - if (_tab) { - setTab(_tab); - } - }; - - currentTab(); - }, []); - - useEffect(() => { - const fetchTab = async ({ - frameId, - frameType, - tabId, - }: chrome.webNavigation.WebNavigationFramedCallbackDetails) => { - if ( - !( - chrome.devtools.inspectedWindow.tabId === tabId && - frameType === 'outermost_frame' && - frameId === 0 - ) - ) { - return; - } - - const currentTab = await getCurrentTab(); - if (!currentTab) { - return; - } - - setTab(currentTab); - }; - - chrome.webNavigation.onCommitted.addListener(fetchTab); - - return () => { - chrome.webNavigation.onCommitted.removeListener(fetchTab); - }; - }, []); - - const checkbox = useCallback(() => { - return ( - - ); - }, []); + const { uniqueResponseDomains } = useScriptBlocking(({ state }) => ({ + uniqueResponseDomains: state.uniqueResponseDomains, + })); useEffect(() => { (async () => { @@ -133,6 +77,7 @@ const MDLTable = () => { setTableData(() => { const _data = mdlData .map((item: string[]) => { + let available = false; let owner = item[1]; if (item[1].includes('PSL Domain')) { @@ -141,25 +86,26 @@ const MDLTable = () => { const scriptBlocking = item[2]; - const hostname = tab?.url ? new URL(tab.url).hostname : ''; + if (uniqueResponseDomains.includes(item[0])) { + available = true; + } return { domain: item[0], owner, scriptBlocking, - highlighted: hostname.includes(item[0]), - highlightedClass: hostname.includes(item[0]) - ? 'bg-amber-100' - : '', + highlighted: available, + highlightedClass: available + ? (selected: boolean) => { + if (selected) { + return 'bg-amber-200/80 dark:bg-amber-200/70'; + } + + return 'bg-amber-100/60 dark:bg-amber-200/90'; + } + : undefined, }; }) - .filter((item) => { - if (showOnlyHighlighted) { - return item.highlighted; - } - - return true; - }) .sort((a, b) => { return Number(b.highlighted) - Number(a.highlighted); }); @@ -169,7 +115,7 @@ const MDLTable = () => { return _data; }); })(); - }, [showOnlyHighlighted, tab?.url]); + }, [uniqueResponseDomains]); const tableColumns = useMemo( () => [ @@ -250,7 +196,7 @@ const MDLTable = () => { className="h-full flex" trayId="mdl-table-bottom-tray" > - +
diff --git a/packages/extension/src/view/devtools/pages/privacyProtection/ipProtection/probabilisticRevealTokens/index.tsx b/packages/extension/src/view/devtools/pages/privacyProtection/ipProtection/probabilisticRevealTokens/index.tsx index a6a172dd2f..551eac9815 100644 --- a/packages/extension/src/view/devtools/pages/privacyProtection/ipProtection/probabilisticRevealTokens/index.tsx +++ b/packages/extension/src/view/devtools/pages/privacyProtection/ipProtection/probabilisticRevealTokens/index.tsx @@ -18,62 +18,113 @@ * External dependencies */ import { - Table, - TableProvider, - type TableColumn, - type TableRow, - ResizableTray, + DraggableTray, JsonView, - noop, + TabsProvider, + type InfoType, + type TabItems, + type TableColumn, + type TableFilter, } from '@google-psat/design-system'; -import React, { useMemo, useRef, useState } from 'react'; -import type { PRTMetadata } from '@google-psat/common'; -import { I18n } from '@google-psat/i18n'; +import React, { useCallback, useMemo, useRef, useState } from 'react'; +import { isValidURL, type PRTMetadata } from '@google-psat/common'; + /** * Internal dependencies */ -import { useProbabilisticRevealTokens } from '../../../../stateProviders'; -import RowContextMenuForPRT from './rowContextMenu'; +import { + useProbabilisticRevealTokens, + useScriptBlocking, +} from '../../../../stateProviders'; +import MdlCommonPanel from '../../mdlCommon'; +import getSignal from '../../../../../../utils/getSignal'; +import Glossary from '../../mdlCommon/glossary'; const ProbabilisticRevealTokens = () => { const [selectedJSON, setSelectedJSON] = useState(null); + const [preSetFilters, setPresetFilters] = useState<{ + [key: string]: Record; + }>({ filter: {} }); + const [isCollapsed, setIsCollapsed] = useState(false); + const draggableTrayRef = useRef({ + isCollapsed, + setIsCollapsed, + }); const { perTokenMetadata, decryptedTokensData, prtTokensData, plainTextTokensData, + statistics, } = useProbabilisticRevealTokens(({ state }) => ({ perTokenMetadata: state.perTokenMetadata, decryptedTokensData: state.decryptedTokens, prtTokensData: state.prtTokens, plainTextTokensData: state.plainTextTokens, + statistics: state.statistics, })); - const rowContextMenuRef = useRef | null>(null); + const { scriptBlockingData, isLoading } = useScriptBlocking(({ state }) => ({ + scriptBlockingData: state.scriptBlockingData, + isLoading: state.isLoading, + })); - const tableColumns = useMemo( + const stats = useMemo( () => [ { - header: 'PRT', - accessorKey: 'prtHeader', - cell: (info) => info, - minWidth: 500, + title: 'Domains', + centerCount: perTokenMetadata.length, + color: '#F3AE4E', + glossaryText: 'Unique domains on page', }, { - header: 'Origin', - accessorKey: 'origin', - cell: (info) => info, + title: 'MDL', + centerCount: perTokenMetadata.filter(({ origin }) => { + let hostname = isValidURL(origin) ? new URL(origin).hostname : ''; + + hostname = hostname.startsWith('www.') ? hostname.slice(4) : hostname; + + if (!hostname) { + return false; + } + + return ( + scriptBlockingData.filter((_data) => _data.domain === hostname) + .length > 0 + ); + }).length, + onClick: () => + setPresetFilters((prev) => ({ + ...prev, + filter: { + mdl: ['True'], + }, + })), + color: '#4C79F4', + glossaryText: 'Page domains in MDL', }, { - header: 'Decryption key available', - accessorKey: 'decryptionKeyAvailable', - cell: (info) => info.toString(), + title: 'PRT', + centerCount: statistics.localView.totalTokens, + color: '#EC7159', + glossaryText: 'Unique tokens sent in requests', + }, + { + title: 'Signals', + centerCount: statistics.localView.nonZeroSignal, + color: '#5CC971', + glossaryText: 'PRTs that decode to IP address', + onClick: () => + setPresetFilters((prev) => ({ + ...prev, + filter: { + nonZeroUint8Signal: ['PRTs with signal'], + }, + })), }, ], - [] + [perTokenMetadata, scriptBlockingData, statistics] ); const formedJson = useMemo(() => { @@ -117,19 +168,19 @@ const ProbabilisticRevealTokens = () => { if (!_decryptedToken || !_plainTextToken) { return { - prtHeader, - prtToken: _prtToken, + ...prtHeader, + ..._prtToken, }; } delete _decryptedToken.prtHeader; delete _plainTextToken.prtHeader; + const { uint8Signal, ...rest } = _plainTextToken; + return { - prtHeader, - decryptedTokens: _decryptedToken, - prtToken: _prtToken, - plainTextToken: _plainTextToken, + ...rest, + ip: getSignal((Object.values(uint8Signal) as unknown as number[]) ?? []), }; }, [ decryptedTokensData, @@ -139,57 +190,206 @@ const ProbabilisticRevealTokens = () => { selectedJSON, ]); + const tabItems = useMemo( + () => [ + { + title: 'Glossary', + content: { + Element: Glossary, + className: 'p-4', + props: { + statItems: stats, + }, + }, + }, + { + title: 'JSON View', + content: { + //@ts-expect-error -- the component is lazy loaded and memoised thats why the error is being shown. + Element: JsonView, + className: 'p-4', + props: { + src: formedJson ?? {}, + }, + }, + }, + ], + [formedJson, stats] + ); + + const tableColumns = useMemo( + () => [ + { + header: 'Domain', + accessorKey: 'origin', + cell: (info) => info, + initialWidth: 120, + }, + { + header: 'Owner', + accessorKey: 'owner', + cell: (info) => info, + initialWidth: 100, + }, + { + header: 'Decrypted', + accessorKey: 'decryptionKeyAvailable', + cell: (info) => { + return info ? ✓ : ''; + }, + initialWidth: 60, + }, + { + header: 'Signal', + accessorKey: 'nonZeroUint8Signal', + cell: (info) => { + return info ? ✓ : ''; + }, + initialWidth: 60, + }, + { + header: 'PRT Prefix', + accessorKey: 'prtHeader', + cell: (info) => (info as string).slice(0, 10), + isHiddenByDefault: true, + }, + ], + [] + ); + + const mdlComparator = useCallback( + (value: InfoType, filterValue: string) => { + let hostname = isValidURL(value as string) + ? new URL(value as string).hostname + : ''; + + hostname = hostname.startsWith('www.') ? hostname.slice(4) : hostname; + + if (!hostname || isLoading) { + return false; + } + + switch (filterValue) { + case 'True': + return ( + scriptBlockingData.filter( + (_data) => value && hostname === _data.domain + ).length > 0 + ); + case 'False': + return ( + scriptBlockingData.filter( + (_data) => value && hostname === _data.domain + ).length === 0 + ); + default: + return true; + } + }, + [isLoading, scriptBlockingData] + ); + + const filters = useMemo( + () => ({ + nonZeroUint8Signal: { + title: 'Signal', + hasStaticFilterValues: true, + hasPrecalculatedFilterValues: true, + filterValues: { + 'PRTs with signal': { + selected: ( + preSetFilters?.filter?.nonZeroUint8Signal ?? [] + ).includes('PRTs with signal'), + description: "PRT's that reveal IP address", + }, + 'PRTs without signal': { + selected: ( + preSetFilters?.filter?.nonZeroUint8Signal ?? [] + ).includes('PRTs without signal'), + description: "PRT's that do not reveal IP address", + }, + }, + comparator: (value: InfoType, filterValue: string) => { + switch (filterValue) { + case 'PRTs without signal': + return !value as boolean; + case 'PRTs with signal': + return value as boolean; + default: + return true; + } + }, + }, + decryptionKeyAvailable: { + title: 'Decrypted', + hasStaticFilterValues: true, + hasPrecalculatedFilterValues: true, + filterValues: { + True: { + selected: false, + description: "PRT's that have been decrypted", + }, + False: { + selected: false, + description: "PRT's that have not been decrypted", + }, + }, + comparator: (value: InfoType, filterValue: string) => { + switch (filterValue) { + case 'True': + return value as boolean; + case 'False': + return !value as boolean; + default: + return true; + } + }, + }, + origin: { + title: 'MDL', + hasStaticFilterValues: true, + hasPrecalculatedFilterValues: true, + filterValues: { + True: { + selected: (preSetFilters?.filter?.mdl ?? []).includes('True'), + description: 'Domains that are in MDL', + }, + False: { + selected: (preSetFilters?.filter?.mdl ?? []).includes('False'), + description: 'Domains that are not in MDL', + }, + }, + comparator: (value: InfoType, filterValue: string) => + mdlComparator(value, filterValue), + }, + }), + [ + preSetFilters?.filter?.mdl, + preSetFilters?.filter?.nonZeroUint8Signal, + mdlComparator, + ] + ); + + const bottomPanel = ( + + + + ); + return ( -
- -
- setSelectedJSON(row as PRTMetadata)} - getRowObjectKey={(row: TableRow) => - (row.originalData as PRTMetadata).prtHeader.toString() - } - onRowContextMenu={ - rowContextMenuRef.current?.onRowContextMenu ?? noop - } - > -
- - - - -
- {formedJson ? ( -
- -
- ) : ( -
-

- {I18n.getMessage('selectRowToPreview')} -

-
- )} -
- + setSelectedJSON(row as PRTMetadata)} + stats={stats} + showJson={false} + tab="PRT" + /> ); }; diff --git a/packages/extension/src/view/devtools/pages/privacyProtection/ipProtection/probabilisticRevealTokens/sessionInsights.tsx b/packages/extension/src/view/devtools/pages/privacyProtection/ipProtection/probabilisticRevealTokens/sessionInsights.tsx new file mode 100644 index 0000000000..a912cdada2 --- /dev/null +++ b/packages/extension/src/view/devtools/pages/privacyProtection/ipProtection/probabilisticRevealTokens/sessionInsights.tsx @@ -0,0 +1,105 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +/** + * External dependencies. + */ +import React, { useMemo } from 'react'; + +/** + * Internal dependencies. + */ +import { useProbabilisticRevealTokens } from '../../../../stateProviders'; +import InsightsStats from '../../mdlCommon/insightsStats'; + +const SessionInsights = () => { + const { statistics } = useProbabilisticRevealTokens(({ state }) => ({ + statistics: state.statistics, + })); + + const { stats, matrixData } = useMemo( + () => ({ + stats: [ + { + title: 'Domains', + count: statistics.globalView.domains, + color: '#F3AE4E', + data: [ + { + color: '#F3AE4E', + count: statistics.globalView.domains, + }, + { + color: '#4C79F4', + count: statistics.globalView.mdl, + }, + ], + countClassName: 'text-emerald', + }, + { + title: 'PRT', + count: statistics.globalView.totalTokens, + color: '#EC7159', + data: [ + { + color: '#EC7159', + count: statistics.localView.totalTokens, + }, + { + color: '#5CC971', + count: statistics.localView.nonZeroSignal, + }, + ], + countClassName: 'text-emerald', + }, + ], + matrixData: [ + { + color: '#F3AE4E', + title: 'Domains', + count: statistics.globalView.domains, + description: 'Unique domains', + countClassName: 'text-emerald', + }, + { + color: '#4C79F4', + title: 'MDL', + count: statistics.globalView.mdl, + description: 'Domains in the MDL', + countClassName: 'text-indigo', + }, + { + color: '#EC7159', + title: 'PRT', + count: statistics.globalView.totalTokens, + description: 'Unique PRT tokens sent in requests', + countClassName: 'text-emerald', + }, + { + color: '#5CC971', + title: 'Signals', + count: statistics.globalView.nonZeroSignal, + description: 'PRTs that decode to IP address', + countClassName: 'text-emerald', + }, + ], + }), + [statistics] + ); + + return ; +}; + +export default SessionInsights; diff --git a/packages/extension/src/view/devtools/pages/privacyProtection/mdlCommon/glossary.tsx b/packages/extension/src/view/devtools/pages/privacyProtection/mdlCommon/glossary.tsx new file mode 100644 index 0000000000..e14d7d9068 --- /dev/null +++ b/packages/extension/src/view/devtools/pages/privacyProtection/mdlCommon/glossary.tsx @@ -0,0 +1,45 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Internal dependencies + */ +import type { StatItem } from './types'; + +type GlossaryProps = { + statItems: StatItem[]; +}; + +const Glossary = ({ statItems }: GlossaryProps) => { + return ( +
+
+ {statItems.map((item) => ( +
+
+ {item.title}: + {item.glossaryText} +
+ ))} +
+
+ ); +}; + +export default Glossary; diff --git a/packages/extension/src/view/devtools/pages/privacyProtection/mdlCommon/index.tsx b/packages/extension/src/view/devtools/pages/privacyProtection/mdlCommon/index.tsx new file mode 100644 index 0000000000..708d0d6a47 --- /dev/null +++ b/packages/extension/src/view/devtools/pages/privacyProtection/mdlCommon/index.tsx @@ -0,0 +1,151 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * External dependencies + */ +import React, { useRef } from 'react'; +import { + Table, + TableProvider, + type TableRow, + type TableFilter, + ResizableTray, + JsonView, + noop, + type TableData, + type TableColumn, +} from '@google-psat/design-system'; +import { + type MDLTableData, + type ProbablisticRevealToken, + type PRTMetadata, +} from '@google-psat/common'; +import { I18n } from '@google-psat/i18n'; + +/** + * Internal dependencies + */ +import RowContextMenuForMDLTable from './rowContextMenu'; +import StatsHeader from './statsHeader'; +import { type StatItem } from './types'; + +type NonDecryptedJson = PRTMetadata & ProbablisticRevealToken; +type DecryptedJson = { + ordinal: Uint8Array; + version: number; + hmacValid: boolean; + ip: string; +}; + +type FormedJson = NonDecryptedJson | DecryptedJson; +interface MdlCommonPanelProps { + formedJson: FormedJson | null; + tableColumns: TableColumn[]; + tableData: PRTMetadata[] | MDLTableData[]; + selectedKey?: string; + onRowClick: (row: TableData | null) => void; + extraInterfaceToTopBar?: () => React.JSX.Element; + filters?: TableFilter; + stats: StatItem[] | null; + tableSearchKeys: string[]; + bottomPanel?: React.JSX.Element; + showJson?: boolean; + tab: string; +} + +const MdlCommonPanel = ({ + formedJson, + tableColumns, + tableData, + selectedKey, + onRowClick, + tableSearchKeys = [], + extraInterfaceToTopBar, + filters, + stats, + bottomPanel, + showJson = true, + tab = '', +}: MdlCommonPanelProps) => { + const rowContextMenuRef = useRef | null>(null); + + return ( +
+ {stats && } + +
+ + (row.originalData as PRTMetadata).origin?.toString() ?? + (row.originalData as MDLTableData).domain?.toString() + } + onRowContextMenu={ + rowContextMenuRef.current?.onRowContextMenu ?? noop + } + > +
+ + + + +
+ {showJson ? ( + formedJson ? ( +
+ +
+ ) : ( +
+

+ {I18n.getMessage('selectRowToPreview')} +

+
+ ) + ) : bottomPanel ? ( + bottomPanel + ) : ( + <> + )} +
+ + ); +}; + +export default MdlCommonPanel; diff --git a/packages/extension/src/view/devtools/pages/privacyProtection/mdlCommon/insightsStats.tsx b/packages/extension/src/view/devtools/pages/privacyProtection/mdlCommon/insightsStats.tsx new file mode 100644 index 0000000000..9f7e4220ec --- /dev/null +++ b/packages/extension/src/view/devtools/pages/privacyProtection/mdlCommon/insightsStats.tsx @@ -0,0 +1,46 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +/** + * External dependencies. + */ +import React from 'react'; +import { + CookiesLandingWrapper, + MatrixContainer, + type MatrixComponentProps, +} from '@google-psat/design-system'; +import type { DataMapping } from '@google-psat/common'; + +interface InsightsStatsProps { + stats: DataMapping[]; + matrixData: MatrixComponentProps[]; +} + +const InsightsStats = ({ stats, matrixData }: InsightsStatsProps) => { + return ( + + {matrixData.length > 0 && ( + + )} + + ); +}; + +export default InsightsStats; diff --git a/packages/extension/src/view/devtools/pages/privacyProtection/ipProtection/probabilisticRevealTokens/rowContextMenu.tsx b/packages/extension/src/view/devtools/pages/privacyProtection/mdlCommon/rowContextMenu.tsx similarity index 74% rename from packages/extension/src/view/devtools/pages/privacyProtection/ipProtection/probabilisticRevealTokens/rowContextMenu.tsx rename to packages/extension/src/view/devtools/pages/privacyProtection/mdlCommon/rowContextMenu.tsx index 3255138215..9a68f9d17e 100644 --- a/packages/extension/src/view/devtools/pages/privacyProtection/ipProtection/probabilisticRevealTokens/rowContextMenu.tsx +++ b/packages/extension/src/view/devtools/pages/privacyProtection/mdlCommon/rowContextMenu.tsx @@ -24,27 +24,52 @@ import React, { useState, } from 'react'; import { createPortal } from 'react-dom'; -import { isValidURL, type PRTMetadata } from '@google-psat/common'; +import { + isValidURL, + type MDLTableData, + type PRTMetadata, +} from '@google-psat/common'; import type { TableRow } from '@google-psat/design-system'; import { I18n } from '@google-psat/i18n'; -const RowContextMenuForPRT = forwardRef<{ - onRowContextMenu: (e: React.MouseEvent, row: TableRow) => void; -}>(function RowContextMenu(_, ref) { +type ContextMenuProp = { + tab: string; +}; + +const RowContextMenuForPRT = forwardRef< + { + onRowContextMenu: (e: React.MouseEvent, row: TableRow) => void; + }, + ContextMenuProp +>(function RowContextMenu({ tab }: ContextMenuProp, ref) { const [contextMenuOpen, setContextMenuOpen] = useState(false); const [columnPosition, setColumnPosition] = useState({ x: 0, y: 0, }); - const [prtMetaData, setMetadata] = useState(); + const [data, setMetadata] = useState(null); + const domain = useMemo(() => { + if (!data) { + return null; + } - const domain = useMemo( - () => - prtMetaData?.origin && isValidURL(prtMetaData?.origin) - ? new URL(prtMetaData?.origin).hostname - : '', - [prtMetaData] - ); + if (tab === 'PRT') { + if (isValidURL((data as PRTMetadata)?.origin)) { + return new URL((data as PRTMetadata)?.origin).hostname; + } else { + return ''; + } + } + + if (tab === 'scriptBlocking') { + if ((data as MDLTableData)?.domain) { + return (data as MDLTableData)?.highlighted + ? (data as MDLTableData)?.domain + : ''; + } + } + return ''; + }, [data, tab]); const handleRightClick = useCallback( (e: React.MouseEvent, { originalData }: TableRow) => { @@ -66,7 +91,9 @@ const RowContextMenuForPRT = forwardRef<{ })); const handleFilterClick = useCallback(() => { - const filter = `has-request-header:sec-probabilistic-reveal-token domain:${domain}`; + const filter = `${ + tab === 'PRT' ? 'has-request-header:sec-probabilistic-reveal-token ' : '' + }domain:${domain}`; // @ts-ignore if (chrome.devtools.panels?.network?.show) { @@ -89,7 +116,7 @@ const RowContextMenuForPRT = forwardRef<{ } catch (error) { //Fail silently } - }, [domain]); + }, [domain, tab]); return ( <> @@ -113,7 +140,9 @@ const RowContextMenuForPRT = forwardRef<{ { // @ts-ignore chrome.devtools.panels?.network?.show - ? 'Show requests with this token.' + ? tab === 'scriptBlocking' + ? 'Show requests with this domain.' + : 'Show requests with this token.' : I18n.getMessage('copyNetworkFilter') } diff --git a/packages/extension/src/view/devtools/pages/privacyProtection/mdlCommon/statsHeader.tsx b/packages/extension/src/view/devtools/pages/privacyProtection/mdlCommon/statsHeader.tsx new file mode 100644 index 0000000000..f6f1e09bc9 --- /dev/null +++ b/packages/extension/src/view/devtools/pages/privacyProtection/mdlCommon/statsHeader.tsx @@ -0,0 +1,79 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +/** + * External dependencies. + */ +import React from 'react'; +import { CirclePieChart } from '@google-psat/design-system'; +import classnames from 'classnames'; + +/** + * Internal dependencies. + */ +import type { StatItem } from './types'; + +interface StatsHeaderProps { + stats: StatItem[]; +} + +const StatsHeader = ({ stats }: StatsHeaderProps) => { + return ( +
+
+
+ {stats.map( + ( + { title, centerCount, color, onClick, data, tooltipText }, + index + ) => ( + + ) + )} +
+
+
+ ); +}; + +export default StatsHeader; diff --git a/packages/extension/src/view/devtools/pages/privacyProtection/mdlCommon/types.ts b/packages/extension/src/view/devtools/pages/privacyProtection/mdlCommon/types.ts new file mode 100644 index 0000000000..b983563f23 --- /dev/null +++ b/packages/extension/src/view/devtools/pages/privacyProtection/mdlCommon/types.ts @@ -0,0 +1,25 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export type StatItem = { + title: string; + centerCount: number; + color: string; + onClick?: () => void; + data?: { count: number; color: string }[]; + glossaryText?: string; + countClassName?: string; +}; diff --git a/packages/extension/src/view/devtools/pages/privacyProtection/scriptBlocking/index.tsx b/packages/extension/src/view/devtools/pages/privacyProtection/scriptBlocking/index.tsx index 61641a4152..327066a94c 100644 --- a/packages/extension/src/view/devtools/pages/privacyProtection/scriptBlocking/index.tsx +++ b/packages/extension/src/view/devtools/pages/privacyProtection/scriptBlocking/index.tsx @@ -23,8 +23,13 @@ import { TabsProvider, type TabItems, } from '@google-psat/design-system'; + +/** + * Internal dependencies. + */ import Panel from './panel'; import MDLTable from './mdlTable'; +import SessionInsights from './mdlTable/sessionInsights'; const ScriptBlocking = () => { const tabItems = useMemo( @@ -43,7 +48,20 @@ const ScriptBlocking = () => { }, }, { - title: 'Blocked Domain List', + title: 'Blocked List', + content: { + Element: MDLTable, + props: { + type: 'Learning', + }, + className: 'overflow-auto h-full', + containerClassName: 'h-full', + }, + }, + ], + Observability: [ + { + title: 'Site', content: { Element: MDLTable, props: {}, @@ -51,6 +69,15 @@ const ScriptBlocking = () => { containerClassName: 'h-full', }, }, + { + title: 'Session', + content: { + Element: SessionInsights, + props: {}, + className: 'overflow-auto h-full', + containerClassName: 'h-full', + }, + }, ], }), [] diff --git a/packages/extension/src/view/devtools/pages/privacyProtection/scriptBlocking/mdlTable/index.tsx b/packages/extension/src/view/devtools/pages/privacyProtection/scriptBlocking/mdlTable/index.tsx index 3716b9d1c3..1283a825c8 100644 --- a/packages/extension/src/view/devtools/pages/privacyProtection/scriptBlocking/mdlTable/index.tsx +++ b/packages/extension/src/view/devtools/pages/privacyProtection/scriptBlocking/mdlTable/index.tsx @@ -18,145 +18,89 @@ * External dependencies */ import { - noop, ProgressBar, - Table, - TableProvider, type TableFilter, type TableColumn, - type TableRow, Link, - ResizableTray, + type InfoType, + TabsProvider, + type TabItems, + DraggableTray, } from '@google-psat/design-system'; -import React, { - useEffect, - useMemo, - useRef, - useState, - useCallback, -} from 'react'; +import React, { useMemo, useState, useCallback, useRef } from 'react'; import type { MDLTableData } from '@google-psat/common'; + /** * Internal dependencies */ +import { + useScriptBlocking, + IMPACTED_BY_SCRIPT_BLOCKING, +} from '../../../../stateProviders'; +import MdlCommonPanel from '../../mdlCommon'; import Legend from './legend'; -import { useScriptBlocking } from '../../../../stateProviders'; -import RowContextMenuForScriptBlocking from './rowContextMenu'; +import Glossary from '../../mdlCommon/glossary'; -export const IMPACTED_BY_SCRIPT_BLOCKING = { - NONE: 'Not Impacted By Script Blocking', - PARTIAL: 'Some URLs are Blocked', - ENTIRE: 'Entire Domain Blocked', +const titleMap = { + 'Entire Domain Blocked': 'Scope Complete', + 'Some URLs are Blocked': 'Scope Partial', }; -const DATA_URL = - 'https://raw.githubusercontent.com/GoogleChrome/ip-protection/refs/heads/main/Masked-Domain-List.md'; +type MDLTableProps = { + type?: 'Observability' | 'Learning'; +}; -const MDLTable = () => { +const MDLTable = ({ type = 'Observability' }: MDLTableProps) => { const [selectedKey, setSelectedKey] = useState(null); - const [showOnlyHighlighted, setShowOnlyHighlighted] = useState(true); - const [isLoading, setIsLoading] = useState(true); - const { uniqueResponseDomains } = useScriptBlocking(({ state }) => ({ - uniqueResponseDomains: state.uniqueResponseDomains, - })); - - const rowContextMenuRef = useRef | null>(null); - - const [initialTableData, setinitialTableData] = useState< - { domain: string; owner: string; scriptBlocking: string }[] - >([]); - - useEffect(() => { - (async () => { - setIsLoading(true); - const response = await fetch(DATA_URL); - - if (!response.ok) { - setIsLoading(false); - throw new Error(`HTTP error! status: ${response.status}`); - } - - const text = await response.text(); - - const lines = text - .split('\n') - .filter((line) => line.includes('|')) - .slice(2); - - const mdlData = lines - .map((line) => line.split('|').map((item) => item.trim())) - .filter((item) => item[2] !== IMPACTED_BY_SCRIPT_BLOCKING.NONE); - - setinitialTableData(() => { - const data = mdlData.map((item: string[]) => { - let owner = item[1]; - - if (item[1].includes('PSL Domain')) { - owner = 'PSL Domain'; - } - - const scriptBlocking = item[2]; - - return { - domain: item[0], - owner, - scriptBlocking, - }; - }); - - setIsLoading(false); - - return data; - }); - })(); - }, []); - - const checkbox = useCallback( - () => ( - - ), - [] - ); + const [preSetFilters, setPresetFilters] = useState<{ + [key: string]: Record; + }>({ filter: {} }); + const [isCollapsed, setIsCollapsed] = useState(false); + const draggableTrayRef = useRef({ + isCollapsed, + setIsCollapsed, + }); + const { uniqueResponseDomains, statistics, scriptBlockingData, isLoading } = + useScriptBlocking(({ state }) => ({ + uniqueResponseDomains: state.uniqueResponseDomains, + statistics: state.statistics, + scriptBlockingData: state.scriptBlockingData, + isLoading: state.isLoading, + })); const tableData: MDLTableData[] = useMemo(() => { - if (initialTableData.length === 0) { + if (scriptBlockingData.length === 0) { return []; } const data: MDLTableData[] = []; - initialTableData.forEach((item) => { - let available = false; - if (uniqueResponseDomains.includes(item.domain)) { - available = true; - } + scriptBlockingData + .filter( + (item) => item.scriptBlocking !== IMPACTED_BY_SCRIPT_BLOCKING.NONE + ) + .forEach((item) => { + if (type === 'Learning') { + data.push({ + ...item, + } as MDLTableData); + return; + } - const canPush = showOnlyHighlighted ? available : true; + let available = false; + if (uniqueResponseDomains.includes(item.domain)) { + available = true; + } - if (canPush) { - data.push({ - ...item, - highlighted: available, - highlightedClass: - available && item.scriptBlocking.startsWith('Some URLs are Blocked') - ? 'bg-amber-100' - : '', - } as MDLTableData); - } - }); + if (available) { + data.push({ + ...item, + } as MDLTableData); + } + }); - return data.sort((a, b) => Number(b.highlighted) - Number(a.highlighted)); - }, [uniqueResponseDomains, initialTableData, showOnlyHighlighted]); + return data; + }, [uniqueResponseDomains, scriptBlockingData, type]); const tableColumns = useMemo( () => [ @@ -187,35 +131,143 @@ const MDLTable = () => { initialWidth: 100, }, { - header: 'Impacted by Script Blocking', + header: 'Scope', accessorKey: 'scriptBlocking', - cell: (info) => info, + cell: (info) => titleMap[info as keyof typeof titleMap].slice(6), }, ], [] ); + const calculateFilters = useCallback( + (data: MDLTableData[]) => { + const _filters: { + [key: string]: { + selected: boolean; + description: string; + }; + } = {}; + + data.forEach((singleData) => { + _filters[ + titleMap[singleData.scriptBlocking as keyof typeof titleMap].slice(6) + ] = { + selected: preSetFilters?.filter?.scriptBlocking?.includes( + titleMap[singleData.scriptBlocking as keyof typeof titleMap] + ), + description: IMPACTED_BY_SCRIPT_BLOCKING[ + singleData.scriptBlocking as keyof typeof IMPACTED_BY_SCRIPT_BLOCKING + ] as string, + }; + }); + + return _filters; + }, + [preSetFilters?.filter?.scriptBlocking] + ); + const filters = useMemo( () => ({ owner: { title: 'Owner', }, scriptBlocking: { - title: 'Impacted by Script Blocking', + title: 'Scope', hasStaticFilterValues: true, - filterValues: { - [IMPACTED_BY_SCRIPT_BLOCKING.PARTIAL]: { - selected: false, - description: IMPACTED_BY_SCRIPT_BLOCKING.PARTIAL, - }, - [IMPACTED_BY_SCRIPT_BLOCKING.ENTIRE]: { - selected: false, - description: IMPACTED_BY_SCRIPT_BLOCKING.ENTIRE, - }, + hasPrecalculatedFilterValues: true, + filterValues: calculateFilters(tableData), + comparator: (value: InfoType, filterValue: string) => { + switch (filterValue) { + case 'Complete': + return value === 'Entire Domain Blocked'; + case 'Partial': + return value === 'Some URLs are Blocked'; + default: + return false; + } }, }, }), - [] + [calculateFilters, tableData] + ); + + const stats = useMemo( + () => [ + { + title: 'Domains', + centerCount: statistics.localView.domains, + color: '#25ACAD', + glossaryText: 'All page domains', + }, + { + title: 'BDL', + centerCount: + statistics.localView.partiallyBlockedDomains + + statistics.localView.completelyBlockedDomains, + color: '#7D8471', + glossaryText: 'Page domains in block list', + }, + { + title: 'Complete', + centerCount: statistics.localView.completelyBlockedDomains, + color: '#F3AE4E', + glossaryText: 'Completely blocked domains', + onClick: () => + setPresetFilters((prev) => ({ + ...prev, + filter: { + scriptBlocking: ['Complete'], + }, + })), + }, + { + title: 'Partial', + centerCount: statistics.localView.partiallyBlockedDomains, + color: '#4C79F4', + glossaryText: 'Partially blocked domains', + onClick: () => + setPresetFilters((prev) => ({ + ...prev, + filter: { + scriptBlocking: ['Partial'], + }, + })), + }, + ], + [ + statistics.localView.completelyBlockedDomains, + statistics.localView.domains, + statistics.localView.partiallyBlockedDomains, + ] + ); + + const tabItems = useMemo( + () => [ + { + title: 'Glossary', + content: { + Element: Glossary, + className: 'p-4', + props: { + statItems: stats, + }, + }, + }, + { + title: 'Legend', + content: { + Element: Legend, + className: 'p-4', + }, + }, + ], + [stats] + ); + + const bottomPanel = ( + + + ); if (isLoading) { @@ -227,47 +279,21 @@ const MDLTable = () => { } return ( -
- { - setSelectedKey((rowData as MDLTableData)?.domain || null); - }} - onRowContextMenu={ - rowContextMenuRef.current - ? rowContextMenuRef.current?.onRowContextMenu - : noop - } - getRowObjectKey={(row: TableRow) => - (row.originalData as MDLTableData).domain || '' - } - tablePersistentSettingsKey="mdlTable" - > - -
- - - - - + + setSelectedKey((row as MDLTableData)?.domain || null) + } + filters={filters} + stats={type === 'Learning' ? null : stats} + showJson={false} + bottomPanel={bottomPanel} + tab="scriptBlocking" + /> ); }; diff --git a/packages/extension/src/view/devtools/pages/privacyProtection/scriptBlocking/mdlTable/legend.tsx b/packages/extension/src/view/devtools/pages/privacyProtection/scriptBlocking/mdlTable/legend.tsx index dce9fa1a43..0650972e1b 100644 --- a/packages/extension/src/view/devtools/pages/privacyProtection/scriptBlocking/mdlTable/legend.tsx +++ b/packages/extension/src/view/devtools/pages/privacyProtection/scriptBlocking/mdlTable/legend.tsx @@ -22,7 +22,7 @@ import React from 'react'; const Legend = () => { return ( -
+

The Blocked Domain List is a subset of the{' '} @@ -32,7 +32,7 @@ const Legend = () => { IP Protection {' '} - initiative. Domains on this list are either entirely or patially + initiative. Domains on this list are either entirely or partially blocked.

diff --git a/packages/extension/src/view/devtools/pages/privacyProtection/scriptBlocking/mdlTable/rowContextMenu.tsx b/packages/extension/src/view/devtools/pages/privacyProtection/scriptBlocking/mdlTable/rowContextMenu.tsx deleted file mode 100644 index 155c71f889..0000000000 --- a/packages/extension/src/view/devtools/pages/privacyProtection/scriptBlocking/mdlTable/rowContextMenu.tsx +++ /dev/null @@ -1,126 +0,0 @@ -/* - * Copyright 2025 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -/** - * External dependencies - */ -import React, { - forwardRef, - useCallback, - useImperativeHandle, - useState, -} from 'react'; -import { createPortal } from 'react-dom'; -import { type MDLTableData } from '@google-psat/common'; -import type { TableRow } from '@google-psat/design-system'; -import { I18n } from '@google-psat/i18n'; - -const RowContextMenuForScriptBlocking = forwardRef<{ - onRowContextMenu: (e: React.MouseEvent, row: TableRow) => void; -}>(function RowContextMenu(_, ref) { - const [contextMenuOpen, setContextMenuOpen] = useState(false); - const [columnPosition, setColumnPosition] = useState({ - x: 0, - y: 0, - }); - const [rowData, setRowData] = useState(); - - const handleRightClick = useCallback( - (e: React.MouseEvent, { originalData }: TableRow) => { - e.preventDefault(); - const x = e.clientX, - y = e.clientY; - setColumnPosition({ x, y }); - document.body.style.overflow = contextMenuOpen ? 'auto' : 'hidden'; - setContextMenuOpen(!contextMenuOpen); - setRowData(originalData as MDLTableData); - }, - [contextMenuOpen] - ); - - useImperativeHandle(ref, () => ({ - onRowContextMenu(e, row) { - handleRightClick(e, row); - }, - })); - - const handleFilterClick = useCallback(() => { - const filter = `domain:${rowData?.domain}`; - - // @ts-ignore - if (chrome.devtools.panels?.network?.show && rowData?.domain) { - // @ts-ignore - chrome.devtools.panels.network.show({ filter }); - setContextMenuOpen(false); - return; - } - - try { - // Need to do this since chrome doesnt allow the clipboard access in extension. - const copyFrom = document.createElement('textarea'); - copyFrom.textContent = filter; - document.body.appendChild(copyFrom); - copyFrom.select(); - document.execCommand('copy'); - copyFrom.blur(); - document.body.removeChild(copyFrom); - setContextMenuOpen(false); - } catch (error) { - //Fail silently - } - }, [rowData]); - - return ( - <> - {rowData?.domain && - rowData?.highlighted && - contextMenuOpen && - createPortal( -
-
- -
-
setContextMenuOpen(false)} - className="absolute w-screen h-screen z-10 top-0 left-0" - /> -
, - document.body - )} - - ); -}); - -export default RowContextMenuForScriptBlocking; diff --git a/packages/extension/src/view/devtools/pages/privacyProtection/scriptBlocking/mdlTable/sessionInsights.tsx b/packages/extension/src/view/devtools/pages/privacyProtection/scriptBlocking/mdlTable/sessionInsights.tsx new file mode 100644 index 0000000000..e8f6de052a --- /dev/null +++ b/packages/extension/src/view/devtools/pages/privacyProtection/scriptBlocking/mdlTable/sessionInsights.tsx @@ -0,0 +1,124 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +/** + * External dependencies. + */ +import React, { useMemo } from 'react'; + +/** + * Internal dependencies. + */ +import { useScriptBlocking } from '../../../../stateProviders'; +import InsightsStats from '../../mdlCommon/insightsStats'; + +const SessionInsights = () => { + const { statistics } = useScriptBlocking(({ state }) => ({ + statistics: state.statistics, + })); + + const { stats, matrixData } = useMemo( + () => ({ + stats: [ + { + title: 'Domains', + count: statistics.globalView.domains, + color: '#25ACAD', + data: [ + { + count: statistics.globalView.domains, + color: '#25ACAD', + }, + { + count: + statistics.globalView.partiallyBlockedDomains + + statistics.globalView.completelyBlockedDomains, + color: '#4C79F4', + }, + ], + countClassName: 'text-emerald', + }, + { + title: 'Blockings', + count: + statistics.globalView.partiallyBlockedDomains + + statistics.globalView.completelyBlockedDomains, + color: '#EC7159', + data: [ + { + count: statistics.globalView.completelyBlockedDomains, + color: '#F3AE4E', + }, + { + count: statistics.globalView.partiallyBlockedDomains, + color: '#4C79F4', + }, + ], + countClassName: 'text-blue-berry', + }, + ], + matrixData: [ + { + title: 'Domains', + count: statistics.globalView.domains, + color: '#25ACAD', + description: 'Total browsing session domains', + countClassName: 'text-emerald', + }, + { + title: 'BDL', + count: + statistics.globalView.partiallyBlockedDomains + + statistics.globalView.completelyBlockedDomains, + color: '#7D8471', + description: 'Total domains in the block list', + countClassName: 'text-max-yellow-red', + }, + { + title: 'Blockings', + count: + statistics.globalView.partiallyBlockedDomains + + statistics.globalView.completelyBlockedDomains, + color: '#EC7159', + description: 'Total blocked domains', + countClassName: 'text-blue-berry', + }, + { + title: 'Complete', + count: statistics.globalView.completelyBlockedDomains, + color: '#F3AE4E', + description: 'Completely blocked domains', + countClassName: 'text-blue-berry', + }, + { + title: 'Partial', + count: statistics.globalView.partiallyBlockedDomains, + color: '#4C79F4', + description: 'Partially blocked domains', + countClassName: 'text-blue-berry', + }, + ], + }), + [ + statistics.globalView.completelyBlockedDomains, + statistics.globalView.domains, + statistics.globalView.partiallyBlockedDomains, + ] + ); + + return ; +}; + +export default SessionInsights; diff --git a/packages/extension/src/view/devtools/pages/privacyProtection/scriptBlocking/panel.tsx b/packages/extension/src/view/devtools/pages/privacyProtection/scriptBlocking/panel/index.tsx similarity index 100% rename from packages/extension/src/view/devtools/pages/privacyProtection/scriptBlocking/panel.tsx rename to packages/extension/src/view/devtools/pages/privacyProtection/scriptBlocking/panel/index.tsx diff --git a/packages/extension/src/view/devtools/pages/privateAdvertising/protectedAudience/explorableExplanation/panel.tsx b/packages/extension/src/view/devtools/pages/privateAdvertising/protectedAudience/explorableExplanation/panel.tsx index b9b03ee30f..e685f68bbc 100644 --- a/packages/extension/src/view/devtools/pages/privateAdvertising/protectedAudience/explorableExplanation/panel.tsx +++ b/packages/extension/src/view/devtools/pages/privateAdvertising/protectedAudience/explorableExplanation/panel.tsx @@ -23,6 +23,7 @@ import React, { useEffect, useRef, useMemo, + Suspense, } from 'react'; import { app, @@ -32,8 +33,11 @@ import { config, // @ts-ignore package does not have types } from '@google-psat/explorable-explanations'; -import { ReactP5Wrapper } from '@p5-wrapper/react'; -import { DraggableTray, useTabs } from '@google-psat/design-system'; +import { + DraggableTray, + ProgressBar, + useTabs, +} from '@google-psat/design-system'; import { getSessionStorage, updateSessionStorage } from '@google-psat/common'; import classNames from 'classnames'; @@ -44,6 +48,13 @@ import Header from '../../../explorableExplanation/header'; import type { CurrentSiteData, StepType } from './auctionEventTransformers'; import { useSettings } from '../../../../stateProviders'; +const ReactP5Wrapper = React.lazy(() => + //@ts-expect-error -- this is because the component is being exported as a different type than what lazy expects + import('@p5-wrapper/react').then((module) => ({ + default: module.ReactP5Wrapper, + })) +); + const STORAGE_KEY = 'paExplorableExplanation'; const DEFAULT_SETTINGS = { isAutoScroll: true, @@ -446,28 +457,52 @@ const Panel = ({
{/* Main Canvas */} - + + +
+ } + > + + {/* Interest Group Canvas */} - - + + + + } + > + + + + + + } + > + + ); diff --git a/packages/extension/src/view/devtools/stateProviders/allowedList/utils/setDomainsInAllowList.ts b/packages/extension/src/view/devtools/stateProviders/allowedList/utils/setDomainsInAllowList.ts index 6230584d5e..ded505488e 100644 --- a/packages/extension/src/view/devtools/stateProviders/allowedList/utils/setDomainsInAllowList.ts +++ b/packages/extension/src/view/devtools/stateProviders/allowedList/utils/setDomainsInAllowList.ts @@ -44,6 +44,10 @@ const setDomainsInAllowList = async ( return; } + if (isIncognito) { + return; + } + // @ts-ignore - The chrome-type definition is outdated, and the return type is a promise. const details = (await chrome.contentSettings.cookies.get({ primaryUrl: primaryUrl, diff --git a/packages/extension/src/view/devtools/stateProviders/probabilisticRevealTokens/context.ts b/packages/extension/src/view/devtools/stateProviders/probabilisticRevealTokens/context.ts index 00cf66f81e..02e0fddacd 100644 --- a/packages/extension/src/view/devtools/stateProviders/probabilisticRevealTokens/context.ts +++ b/packages/extension/src/view/devtools/stateProviders/probabilisticRevealTokens/context.ts @@ -24,21 +24,47 @@ import { type PRTMetadata, } from '@google-psat/common'; +type PRTStatistics = { + globalView: { + totalTokens: number; + nonZeroSignal: number; + mdl: number; + domains: number; + }; + localView: { + totalTokens: number; + nonZeroSignal: number; + }; +}; + export interface ProbabilisticRevealTokensContextType { state: { plainTextTokens: UniquePlainTextToken[]; decryptedTokens: UniqueDecryptedToken[]; prtTokens: ProbablisticRevealToken[]; perTokenMetadata: PRTMetadata[]; + statistics: PRTStatistics; }; } -const initialState: ProbabilisticRevealTokensContextType = { +export const initialState: ProbabilisticRevealTokensContextType = { state: { plainTextTokens: [], decryptedTokens: [], prtTokens: [], perTokenMetadata: [], + statistics: { + localView: { + totalTokens: 0, + nonZeroSignal: 0, + }, + globalView: { + totalTokens: 0, + nonZeroSignal: 0, + mdl: 0, + domains: 0, + }, + }, }, }; diff --git a/packages/extension/src/view/devtools/stateProviders/probabilisticRevealTokens/index.ts b/packages/extension/src/view/devtools/stateProviders/probabilisticRevealTokens/index.ts index ff27962cdd..9bca1004f1 100644 --- a/packages/extension/src/view/devtools/stateProviders/probabilisticRevealTokens/index.ts +++ b/packages/extension/src/view/devtools/stateProviders/probabilisticRevealTokens/index.ts @@ -15,7 +15,7 @@ */ export { default as ProbabilisticRevealTokensProvider } from './probabilisticRevealTokensProvider'; export { - default as ProbabilisticRevealTokensContext, + default as ProbabilisticRevealTokens, type ProbabilisticRevealTokensContextType, } from './context'; export { default as useProbabilisticRevealTokens } from './useProbabilisticRevealTokens'; diff --git a/packages/extension/src/view/devtools/stateProviders/probabilisticRevealTokens/probabilisticRevealTokensProvider.tsx b/packages/extension/src/view/devtools/stateProviders/probabilisticRevealTokens/probabilisticRevealTokensProvider.tsx index d7c912f6b0..563c238b9a 100644 --- a/packages/extension/src/view/devtools/stateProviders/probabilisticRevealTokens/probabilisticRevealTokensProvider.tsx +++ b/packages/extension/src/view/devtools/stateProviders/probabilisticRevealTokens/probabilisticRevealTokensProvider.tsx @@ -28,14 +28,23 @@ import { isEqual } from 'lodash-es'; /** * Internal dependencies. */ -import Context, { type ProbabilisticRevealTokensContextType } from './context'; +import Context, { + initialState, + type ProbabilisticRevealTokensContextType, +} from './context'; import { TAB_TOKEN_DATA } from '../../../../constants'; +import { useScriptBlocking } from '../scriptBlocking'; +import { isValidURL } from '@google-psat/common'; const Provider = ({ children }: PropsWithChildren) => { const [decryptedTokens, setDecryptedTokens] = useState< ProbabilisticRevealTokensContextType['state']['decryptedTokens'] >([]); + const [statistics, setStatistics] = useState< + ProbabilisticRevealTokensContextType['state']['statistics'] + >(initialState.state.statistics); + const [prtTokens, setPrtTokens] = useState< ProbabilisticRevealTokensContextType['state']['prtTokens'] >([]); @@ -48,12 +57,17 @@ const Provider = ({ children }: PropsWithChildren) => { ProbabilisticRevealTokensContextType['state']['perTokenMetadata'] >([]); + const { scriptBlockingData } = useScriptBlocking(({ state }) => ({ + scriptBlockingData: state.scriptBlockingData, + })); + const messagePassingListener = useCallback( (message: { type: string; payload: { tabId: string; tokens: ProbabilisticRevealTokensContextType['state']; + stats: ProbabilisticRevealTokensContextType['state']['statistics']; }; }) => { if (![TAB_TOKEN_DATA].includes(message.type)) { @@ -65,7 +79,7 @@ const Provider = ({ children }: PropsWithChildren) => { } if ( - message.payload.tabId !== + message.payload.tabId.toString() !== chrome.devtools.inspectedWindow.tabId.toString() ) { return; @@ -100,6 +114,15 @@ const Provider = ({ children }: PropsWithChildren) => { return message.payload.tokens.perTokenMetadata; }); } + + if (message.payload.stats) { + setStatistics((prev) => { + if (isEqual(prev, message.payload.stats)) { + return prev; + } + return message.payload.stats; + }); + } }, [] ); @@ -128,19 +151,73 @@ const Provider = ({ children }: PropsWithChildren) => { [] ); + const sessionStorageListener = useCallback( + async (changes: { [key: string]: chrome.storage.StorageChange }) => { + const hasChangesForPrtStatisticsData = + Object.keys(changes).includes('prtStatistics') && + Object.keys(changes.prtStatistics).includes('newValue'); + const { prtStatistics = {} } = await chrome.storage.session.get( + 'prtStatistics' + ); + + const globalStats = Object.keys(prtStatistics).reduce( + (acc, key) => { + if (prtStatistics[key]) { + acc.totalTokens = prtStatistics[key].totalTokens + acc.totalTokens; + acc.nonZeroSignal = + prtStatistics[key].nonZeroSignal + acc.nonZeroSignal; + + let hostname = isValidURL(origin) ? new URL(origin).hostname : ''; + + hostname = hostname.startsWith('www.') + ? hostname.slice(4) + : hostname; + + acc.mdl += + hostname && + scriptBlockingData.filter((_data) => _data.domain === hostname) + .length > 0 + ? 1 + : 0; + } + return acc; + }, + { totalTokens: 0, nonZeroSignal: 0, domains: 0, mdl: 0 } + ); + + globalStats.domains = Object.keys(prtStatistics).length; + + if (hasChangesForPrtStatisticsData) { + setStatistics((prev) => { + return { + ...prev, + globalView: globalStats, + }; + }); + } + }, + [scriptBlockingData] + ); + useEffect(() => { chrome.runtime?.onMessage?.addListener(messagePassingListener); chrome.webNavigation?.onCommitted?.addListener( onCommittedNavigationListener ); + chrome.storage.session.onChanged.addListener(sessionStorageListener); return () => { chrome.runtime?.onMessage?.removeListener(messagePassingListener); chrome.webNavigation?.onCommitted?.removeListener( onCommittedNavigationListener ); + chrome.storage.session.onChanged.removeListener(sessionStorageListener); }; - }, [messagePassingListener, onCommittedNavigationListener]); + }, [ + messagePassingListener, + onCommittedNavigationListener, + sessionStorageListener, + ]); const memoisedValue: ProbabilisticRevealTokensContextType = useMemo(() => { return { @@ -149,9 +226,16 @@ const Provider = ({ children }: PropsWithChildren) => { decryptedTokens, prtTokens, perTokenMetadata, + statistics, }, }; - }, [plainTextTokens, prtTokens, perTokenMetadata, decryptedTokens]); + }, [ + plainTextTokens, + decryptedTokens, + prtTokens, + perTokenMetadata, + statistics, + ]); return {children}; }; diff --git a/packages/extension/src/view/devtools/stateProviders/probabilisticRevealTokens/useProbabilisticRevealTokens.ts b/packages/extension/src/view/devtools/stateProviders/probabilisticRevealTokens/useProbabilisticRevealTokens.ts index ba12a58729..3a1f69d4c9 100644 --- a/packages/extension/src/view/devtools/stateProviders/probabilisticRevealTokens/useProbabilisticRevealTokens.ts +++ b/packages/extension/src/view/devtools/stateProviders/probabilisticRevealTokens/useProbabilisticRevealTokens.ts @@ -23,8 +23,8 @@ import { useContextSelector } from '@google-psat/common'; */ import Context, { type ProbabilisticRevealTokensContextType } from './context'; -export function useAttributionReporting(): ProbabilisticRevealTokensContextType; -export function useAttributionReporting( +export function useProbabilisticRevealTokens(): ProbabilisticRevealTokensContextType; +export function useProbabilisticRevealTokens( selector: (state: ProbabilisticRevealTokensContextType) => T ): T; @@ -33,7 +33,7 @@ export function useAttributionReporting( * @param selector Selector function to partially select state. * @returns selected part of the state */ -export function useAttributionReporting( +export function useProbabilisticRevealTokens( selector: ( state: ProbabilisticRevealTokensContextType ) => T | ProbabilisticRevealTokensContextType = (state) => state @@ -41,4 +41,4 @@ export function useAttributionReporting( return useContextSelector(Context, selector); } -export default useAttributionReporting; +export default useProbabilisticRevealTokens; diff --git a/packages/extension/src/view/devtools/stateProviders/scriptBlocking/context.ts b/packages/extension/src/view/devtools/stateProviders/scriptBlocking/context.ts index fd48020487..8e51b5567c 100644 --- a/packages/extension/src/view/devtools/stateProviders/scriptBlocking/context.ts +++ b/packages/extension/src/view/devtools/stateProviders/scriptBlocking/context.ts @@ -18,18 +18,54 @@ */ import { noop, createContext } from '@google-psat/common'; +type ScriptBlockingStatistics = { + globalView: { + partiallyBlockedDomains: number; + completelyBlockedDomains: number; + domains: number; + }; + localView: { + partiallyBlockedDomains: number; + completelyBlockedDomains: number; + domains: number; + }; +}; + +type ScriptBlockingData = { + domain: string; + owner: string; + scriptBlocking: string; +}[]; + export interface ScriptBlockingStoreContext { state: { uniqueResponseDomains: string[]; + statistics: ScriptBlockingStatistics; + scriptBlockingData: ScriptBlockingData; + isLoading: boolean; }; actions: { setUniqueResponseDomains: (newValue: string[]) => void; }; } -const initialState: ScriptBlockingStoreContext = { +export const initialState: ScriptBlockingStoreContext = { state: { uniqueResponseDomains: [], + statistics: { + localView: { + partiallyBlockedDomains: 0, + completelyBlockedDomains: 0, + domains: 0, + }, + globalView: { + partiallyBlockedDomains: 0, + completelyBlockedDomains: 0, + domains: 0, + }, + }, + scriptBlockingData: [], + isLoading: false, }, actions: { setUniqueResponseDomains: noop, diff --git a/packages/extension/src/view/devtools/stateProviders/scriptBlocking/index.ts b/packages/extension/src/view/devtools/stateProviders/scriptBlocking/index.ts index 5b73391fc0..ea26de29c0 100644 --- a/packages/extension/src/view/devtools/stateProviders/scriptBlocking/index.ts +++ b/packages/extension/src/view/devtools/stateProviders/scriptBlocking/index.ts @@ -13,6 +13,9 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -export { default as ScriptBlockingProvider } from './scriptBlocking'; +export { + default as ScriptBlockingProvider, + IMPACTED_BY_SCRIPT_BLOCKING, +} from './scriptBlockingProvider'; export { default as ScriptBlockingContext } from './context'; export { default as useScriptBlocking } from './useScriptBlocking'; diff --git a/packages/extension/src/view/devtools/stateProviders/scriptBlocking/scriptBlocking.tsx b/packages/extension/src/view/devtools/stateProviders/scriptBlocking/scriptBlocking.tsx deleted file mode 100644 index 50507d5033..0000000000 --- a/packages/extension/src/view/devtools/stateProviders/scriptBlocking/scriptBlocking.tsx +++ /dev/null @@ -1,87 +0,0 @@ -/* - * Copyright 2025 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -/** - * External dependencies. - */ -import React, { - type PropsWithChildren, - useState, - useEffect, - useCallback, -} from 'react'; -import { isEqual } from 'lodash-es'; - -/** - * Internal dependencies. - */ -import Context from './context'; -import { EXTRA_DATA } from '../../../../constants'; - -const ScriptBlockingProvider = ({ children }: PropsWithChildren) => { - const [uniqueResponseDomains, setUniqueResponseDomains] = useState( - [] - ); - - const messagePassingListener = useCallback( - (message: { - type: string; - payload: { - uniqueResponseDomains?: string[]; - tabId: string; - }; - }) => { - if ( - message.type !== EXTRA_DATA || - String(message.payload.tabId) !== - String(chrome.devtools.inspectedWindow.tabId) - ) { - return; - } - - setUniqueResponseDomains((prev) => { - return isEqual(message.payload.uniqueResponseDomains, prev) - ? prev - : message.payload.uniqueResponseDomains || []; - }); - }, - [] - ); - - useEffect(() => { - chrome.runtime?.onMessage?.addListener(messagePassingListener); - - return () => { - chrome.runtime?.onMessage?.removeListener(messagePassingListener); - }; - }, [messagePassingListener]); - - return ( - - {children} - - ); -}; - -export default ScriptBlockingProvider; diff --git a/packages/extension/src/view/devtools/stateProviders/scriptBlocking/scriptBlockingProvider.tsx b/packages/extension/src/view/devtools/stateProviders/scriptBlocking/scriptBlockingProvider.tsx new file mode 100644 index 0000000000..ba8fe4de49 --- /dev/null +++ b/packages/extension/src/view/devtools/stateProviders/scriptBlocking/scriptBlockingProvider.tsx @@ -0,0 +1,188 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +/** + * External dependencies. + */ +import React, { + type PropsWithChildren, + useState, + useEffect, + useCallback, +} from 'react'; +import { isEqual } from 'lodash-es'; + +/** + * Internal dependencies. + */ +import Context, { + initialState, + type ScriptBlockingStoreContext, +} from './context'; +import { EXTRA_DATA } from '../../../../constants'; + +export const IMPACTED_BY_SCRIPT_BLOCKING = { + NONE: 'Not Impacted By Script Blocking', + PARTIAL: 'Some URLs are Blocked', + ENTIRE: 'Entire Domain Blocked', +}; + +const DATA_URL = + 'https://raw.githubusercontent.com/GoogleChrome/ip-protection/refs/heads/main/Masked-Domain-List.md'; + +const ScriptBlockingProvider = ({ children }: PropsWithChildren) => { + const [uniqueResponseDomains, setUniqueResponseDomains] = useState( + [] + ); + const [isLoading, setIsLoading] = useState(true); + + const [statistics, setStatistics] = useState< + ScriptBlockingStoreContext['state']['statistics'] + >(initialState.state.statistics); + + const [initialTableData, setinitialTableData] = useState< + { domain: string; owner: string; scriptBlocking: string }[] + >([]); + + useEffect(() => { + (async () => { + setIsLoading(true); + const response = await fetch(DATA_URL); + + if (!response.ok) { + setIsLoading(false); + throw new Error(`HTTP error! status: ${response.status}`); + } + + const text = await response.text(); + + const lines = text + .split('\n') + .filter((line) => line.includes('|')) + .slice(2); + + const mdlData = lines.map((line) => + line.split('|').map((item) => item.trim()) + ); + + setinitialTableData(() => { + const data = mdlData.map((item: string[]) => { + let owner = item[1]; + + if (item[1].includes('PSL Domain')) { + owner = 'PSL Domain'; + } + + const scriptBlocking = item[2]; + + return { + domain: item[0], + owner, + scriptBlocking, + }; + }); + + setIsLoading(false); + + return data; + }); + })(); + }, []); + + const messagePassingListener = useCallback( + (message: { + type: string; + payload: { + uniqueResponseDomains?: string[]; + stats: ScriptBlockingStoreContext['state']['statistics']; + tabId: string; + }; + }) => { + if ( + message.type !== EXTRA_DATA || + String(message.payload.tabId) !== + String(chrome.devtools.inspectedWindow.tabId) + ) { + return; + } + + setUniqueResponseDomains((prev) => { + return isEqual(message.payload.uniqueResponseDomains, prev) + ? prev + : message.payload.uniqueResponseDomains || []; + }); + + if (message.payload.stats) { + setStatistics((prev) => { + if (isEqual(prev, message.payload.stats)) { + return prev; + } + return message.payload.stats; + }); + } + }, + [] + ); + + const syncStorageListener = useCallback( + (changes: { [key: string]: chrome.storage.StorageChange }) => { + const hasChangesForScriptBlockingData = + Object.keys(changes).includes('scriptBlocking') && + Object.keys(changes.scriptBlocking).includes('newValue'); + + if (hasChangesForScriptBlockingData) { + setStatistics((prev) => { + return { + ...prev, + globalView: { + ...changes.scriptBlocking.newValue, + }, + }; + }); + } + }, + [] + ); + + useEffect(() => { + chrome.runtime?.onMessage?.addListener(messagePassingListener); + chrome.storage.session.onChanged.addListener(syncStorageListener); + + return () => { + chrome.runtime?.onMessage?.removeListener(messagePassingListener); + chrome.storage.session.onChanged.removeListener(syncStorageListener); + }; + }, [messagePassingListener, syncStorageListener]); + + return ( + + {children} + + ); +}; + +export default ScriptBlockingProvider; diff --git a/packages/extension/src/view/devtools/tabs.ts b/packages/extension/src/view/devtools/tabs.ts index 87ec213dd9..07c59e69f6 100644 --- a/packages/extension/src/view/devtools/tabs.ts +++ b/packages/extension/src/view/devtools/tabs.ts @@ -49,9 +49,9 @@ import { ProtectionIcon, SiteBoundariesIconWhite, DemosIcon, + IncognitoIcon, BlockIcon, BlockIconWhite, - IncognitoIcon, } from '@google-psat/design-system'; import { I18n } from '@google-psat/i18n'; import { addUTMParams } from '@google-psat/common'; @@ -85,11 +85,11 @@ import { FederatedCredential, IPProtection, PrivateStateTokens, - ScriptBlocking, } from './pages'; import HelpCenter from './pages/learning/helpCenter'; import Demos from './pages/learning/demos'; import Incognito from './pages/incognito'; +import ScriptBlocking from './pages/privacyProtection/scriptBlocking'; const TABS: SidebarItems = { [SIDEBAR_ITEMS_KEYS.PRIVACY_SANDBOX]: { diff --git a/tests/jest.setup.cjs b/tests/jest.setup.cjs index 466112050e..45ac0e4645 100644 --- a/tests/jest.setup.cjs +++ b/tests/jest.setup.cjs @@ -18,7 +18,9 @@ */ const React = require('react'); const { TextEncoder, TextDecoder } = require('node:util'); +const fetch = require('node-fetch'); global.React = React; global.TextDecoder = TextDecoder; global.TextEncoder = TextEncoder; +global.fetch = fetch; diff --git a/vite.extension.config.mts b/vite.extension.config.mts index b20e7a30f1..8c4c9b31f5 100644 --- a/vite.extension.config.mts +++ b/vite.extension.config.mts @@ -13,200 +13,154 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -/** - * External dependencies: - */ import { build, defineConfig, mergeConfig } from 'vite'; import { viteSingleFile } from 'vite-plugin-singlefile'; import { viteStaticCopy } from 'vite-plugin-static-copy'; import path from 'path'; +import { fileURLToPath } from 'url'; -/** - * Internal dependencies: - */ -import baseConfig, { __dirname } from './vite.shared.config.mjs'; +import baseConfig from './vite.shared.config.mjs'; -const isDev = process.env.NODE_ENV === 'development'; +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); -type Script = { - name: string; - path: string; - // all content scripts must be in iife format - format: 'es' | 'iife'; -}; - -// content scripts and worker defined in the manifest.json -const scripts: Script[] = [ - { - name: 'service-worker', - path: 'src/serviceWorker/index.ts', - format: 'es', // defined as module in manifest.json - }, - { - name: 'content-script', - path: 'src/contentScript/index.ts', - format: 'iife', - }, - { - name: 'js-cookie-content-script', - path: 'src/contentScript/jsCookie.ts', - format: 'iife', - }, - { - name: 'prebid-content-script', - path: 'src/contentScript/prebid/prebidContentScript.ts', - format: 'iife', - }, - { - name: 'prebid-interface', - path: 'src/contentScript/prebid/prebidInterface.tsx', - format: 'iife', - }, -] as const; - -const createScriptConfig = (script: (typeof scripts)[number]) => { - let minifier: boolean | 'terser' = 'terser'; - if (isDev) { - minifier = false; - } else { - minifier = 'terser'; - } +const isDev = process.env.NODE_ENV === 'development'; +const distDir = path.resolve(__dirname, 'dist/extension'); - return defineConfig({ - build: { - watch: isDev ? {} : null, - emptyOutDir: false, - target: 'esnext', - outDir: `../../dist/extension`, - minify: minifier, - rollupOptions: { - input: { - [script.name]: script.path, - }, - output: { - format: script.format || 'iife', - chunkFileNames: '[name].js', - entryFileNames: '[name].js', - }, - }, +const runBuilds = async () => { + const scripts = [ + { + name: 'service-worker', + path: 'src/serviceWorker/index.ts', + format: 'es', }, - }); -}; - -const createExtensionConfig = (target: string) => { - const commonConfig = mergeConfig(baseConfig, { - base: '', // important to make sure the paths are correct in output index.html - sourcemap: isDev ? 'inline' : false, - build: { - emptyOutDir: false, // don't empty the output directory, output folder is re-used - outDir: `../../../../../dist/extension/${target}`, - minify: !isDev, + { + name: 'content-script', + path: 'src/contentScript/index.ts', + format: 'iife', + }, + { + name: 'js-cookie-content-script', + path: 'src/contentScript/jsCookie.ts', + format: 'iife', }, - }); + { + name: 'prebid-content-script', + path: 'src/contentScript/prebid/prebidContentScript.ts', + format: 'iife', + }, + { + name: 'prebid-interface', + path: 'src/contentScript/prebid/prebidInterface.tsx', + format: 'iife', + }, + ]; - if (target === 'popup') { - return mergeConfig(commonConfig, { - root: path.resolve(__dirname, 'packages/extension/src/view/popup'), - build: { - rollupOptions: { - input: { - popup: 'src/view/popup/index.html', + for (const script of scripts) { + // eslint-disable-next-line no-await-in-loop + await build( + mergeConfig(baseConfig, { + build: { + watch: isDev ? {} : null, + emptyOutDir: false, + target: 'esnext', + outDir: distDir, + minify: !isDev ? 'terser' : false, + rollupOptions: { + input: { + [script.name]: path.resolve( + __dirname, + 'packages/extension', + script.path + ), + }, + output: { + format: script.format, + entryFileNames: '[name].js', + }, }, }, - }, - }); + }) + ); } - if (target === 'report') { - return mergeConfig(commonConfig, { - root: path.resolve(__dirname, 'packages/extension/src/view/report'), - build: { - outDir: `../../../../../dist/extension/devtools`, - rollupOptions: { - input: { - dashboard: './src/view/report/dashboard.html', - }, - }, - }, + // 2. Build UI Components (Popup, DevTools, Report, etc.) + const uiTargets = [ + // Popup Page + { + root: 'packages/extension/src/view/popup', + outDir: path.join(distDir, 'popup'), + input: { popup: 'src/view/popup/index.html' }, + watch: isDev ? {} : null, + }, + // DevTools Panel UI + { + root: 'packages/extension/src/view/devtools', + outDir: path.join(distDir, 'devtools'), + input: { index: './src/view/devtools/index.html' }, + watch: isDev ? {} : null, + }, + // Report Page (inlined into a single file) + { + root: 'packages/extension/src/view/report', + outDir: path.join(distDir, 'devtools'), + input: { dashboard: './src/view/report/dashboard.html' }, plugins: [viteSingleFile()], - }); - } - - if (target === 'assets') { - return mergeConfig(commonConfig, { - root: path.resolve(__dirname, 'packages/extension/src/view/devtools'), - build: { - outDir: `../../../../../dist/extension/devtools`, - rollupOptions: { - input: { - devtools: './src/view/devtools/devtools.html', - }, - }, - }, + }, + // DevTools setup page and static asset copying + { + root: 'packages/extension/src/view/devtools', + outDir: path.join(distDir, 'devtools'), + input: { devtools: './src/view/devtools/devtools.html' }, + watch: isDev ? {} : null, plugins: [ viteStaticCopy({ targets: [ - { - src: '../../manifest.json', - dest: '../', - }, - { - src: '../../../icons', - dest: '../', - }, - { - src: '../../../../../assets', - dest: '../', - }, - { - src: '../../../../../data', - dest: '../', - }, + { src: '../../manifest.json', dest: '../' }, + { src: '../../../icons', dest: '../' }, + { src: '../../../../../assets', dest: '../' }, + { src: '../../../../../data', dest: '../' }, { src: '../../../../i18n/_locales/messages/*', dest: '../_locales/', }, - { - src: '../../../../../node_modules/p5/lib/p5.min.js', - dest: './', - }, + { src: '../../../../../node_modules/p5/lib/p5.min.js', dest: './' }, ], }), ], - }); - } + }, + ]; - if (target === 'devtools') { - return mergeConfig(commonConfig, { - root: path.resolve(__dirname, 'packages/extension/src/view/devtools'), - build: { - watch: isDev ? {} : null, - rollupOptions: { - input: { - index: './src/view/devtools/index.html', + for (const target of uiTargets) { + // eslint-disable-next-line no-await-in-loop + await build( + mergeConfig(baseConfig, { + root: path.resolve(__dirname, target.root), + base: '', // Ensures correct asset paths in HTML + build: { + watch: target.watch || null, + emptyOutDir: false, + sourcemap: isDev ? true : false, + outDir: target.outDir, + minify: !isDev, + rollupOptions: { + input: target.input, }, }, - }, - }); - } - - return commonConfig; -}; - -const start = async () => { - // vite/rollup does not support multiple input files when using inlineDynamicImports(forced with iife) - // so we need to create a config for each script - for (const script of scripts) { - // eslint-disable-next-line no-await-in-loop - await build(createScriptConfig(script)); // run each build in queue, when `watch: true` promise will be resolved anyway after first build - } - - for (const target of ['popup', 'report', 'assets', 'devtools']) { - // eslint-disable-next-line no-await-in-loop - await build(createExtensionConfig(target)); + plugins: target.plugins || [], + }) + ); } }; (async () => { - await start(); + try { + await runBuilds(); + } catch (error) { + // eslint-disable-next-line no-console + console.error('Build failed:', error); + process.exit(1); + } })(); + +export default defineConfig({});