## 🎯 Cel Dodanie pełnego wsparcia dla Chrome Manifest V3 przy zachowaniu kompatybilności z Firefox. Główne wyzwanie: Chrome MV3 używa service workerów zamiast persistent background pages, co wymaga nowej architektury zarządzania pamięcią. --- ## 🏗️ Architektura ### Nowa warstwa abstrakcji: lib/browser-api/ **lib/browser-api/index.ts** - Główny punkt wejścia do zunifikowanego API - Wybiera właściwą implementację na podstawie zmiennej TARGET (build-time) - Eksportuje jeden interfejs dla całej aplikacji **lib/browser-api/types.ts** - Wspólne definicje typów dla obu przeglądarek - Interfejs BrowserAPI definiujący wszystkie potrzebne metody - Typy dla tabs, badge, webRequest, cookies, extension, windows **lib/browser-api/firefox.ts** - Adapter dla Firefox browser.* API - Lazy access do globalnego obiektu browser (bezpieczne dla środowisk bez Firefox API) - Wszystkie metody zwracają Promise lub są no-op jeśli API niedostępne **lib/browser-api/chrome.ts** - Adapter dla Chrome chrome.* API - Mapowanie chrome.action → badge (różnica nazewnictwa) - Ochrona przed błędami gdy karta zostaje zamknięta (try-catch w operacjach badge) --- ## 🔧 Build System ### esbuild.config.js - **Dodano**: Rozpoznawanie zmiennej środowiskowej TARGET (firefox/chrome) - **Dodano**: Różne katalogi wyjściowe (dist-firefox, dist-chrome) - **Dodano**: Kopiowanie odpowiedniego manifestu na podstawie TARGET - **Dodano**: Plugin do konwersji ikon SVG → PNG dla Chrome (wymaga PNG w MV3) - **Zmieniono**: define zawiera teraz process.env.TARGET dostępny w runtime ### package.json - **Dodano**: Skrypty build:firefox, build:chrome, watch:firefox, watch:chrome - **Dodano**: Skrypty build-addon:firefox, build-addon:chrome do tworzenia paczek - **Dodano**: Skrypt convert-icons do generowania PNG z SVG - **Dodano**: Zależność sharp do konwersji obrazów ### scripts/convert-icons.js (NOWY PLIK) - Konwertuje assets/icon-addon.svg → PNG w rozmiarach 16, 32, 48, 128px - Wymagane dla Chrome (MV3 nie akceptuje SVG w manifestach) ### manifest-chrome.json (NOWY PLIK) - Manifest V3 dla Chrome - background.service_worker zamiast background.scripts - action zamiast browser_action - Ikony PNG zamiast SVG - host_permissions zamiast embedowanych w permissions --- ## 🧠 Pamięć i Stan (NAJWIĘKSZA ZMIANA) ### memory.ts - Kompletna refaktoryzacja **Problem Chrome MV3:** - Service worker może być wyładowany w dowolnym momencie - Brak dostępu do window.memory z popup/sidebar - chrome.extension.getBackgroundPage() zwraca null w MV3 **Rozwiązanie:** 1. **Service Worker**: Trzyma pełne dane, synchronizuje do chrome.storage.session 2. **Popup/Sidebar**: Tworzy własną instancję Memory czytając z storage 3. **Throttled sync**: Maksymalnie co 500ms zapisy do storage (wydajność) **Nowa klasa: CachedRequestCluster** - Dziedziczy po RequestCluster ale NIE ma rzeczywistych requestów - Przechowuje tylko metadane: hasCookies, exposesOrigin, hasMarks - Implementuje wszystkie wymagane metody zwracając cached state - Używana TYLKO w popup/report window w Chrome **Zmiany w Memory klasie:** - **Dodano**: isReady flag i readyPromise dla async inicjalizacji (Chrome) - **Dodano**: waitUntilReady() - popup musi poczekać na załadowanie danych - **Dodano**: tabUrls: Map<number, string> - tracking pełnych URL dla Chrome (service worker nie dostaje documentUrl) - **Dodano**: scheduleSyncToStorage() - throttled sync do storage - **Dodano**: syncToStorage() - serializacja clustrów do JSON - **Dodano**: loadFromStorage() - deserializacja przy starcie popup - **Zmieniono**: register() śledzi main_frame URL i synuje po każdej zmianie - **Zmieniono**: Badge operacje w try-catch (karta może być zamknięta) **Funkcja getMemory():** - **Firefox**: browserAPI.extension.getBackgroundPage().memory (tradycyjnie) - **Chrome Service Worker**: self.memory (jesteśmy W service workerze) - **Chrome Popup**: Tworzy NOWĄ instancję czytając z storage (cachowana jako popupMemoryInstance) --- ## 🔒 Bezpieczeństwo i Obsługa Błędów ### util.ts - getshorthost() - **Dodano**: Walidacja wejścia (null, undefined, pusty string) - **Dodano**: Czyszczenie URL (protokół, ścieżka, port) - **Dodano**: Obsługa edge cases (localhost, single word domains) - **Dodano**: Bezpieczne fallbacki zamiast crashowania - **Dodano**: Console.warn zamiast milczących błędów ### extended-request.ts - **MASYWNE POPRAWKI** parsowania URL w konstruktorze - **Dodano**: isValidHttpUrl() helper - sprawdza czy URL zaczyna się od http(s) - **Dodano**: safeParseUrl() helper - try-catch wokół new URL() - **Dodano**: Próba parsowania wielu URL w kolejności priorytetów - **Dodano**: Obsługa Chrome MV3 initiator property - **Dodano**: Bezpieczne defaulty gdy parsowanie się nie uda (unknown://unknown) - **Zmieniono**: isThirdParty() pomija requesty z unparseable URLs - **Dodano**: uint8ArrayToString() - chunked konwersja dużych arrayów (zapobiega stack overflow) - **Zmieniono**: Request body processing używa chunked konwersji --- ## 🎨 UI Components ### toolbar.tsx (popup) - **Dodano**: getCurrentTab() z retry mechanism (Chrome czasem nie zwraca karty od razu) - **Dodano**: Sprawdzanie memoryReady przed renderowaniem danych - **Dodano**: Wywołanie waitUntilReady() w useEffect - **Dodano**: Opóźnienie 200ms dla Chrome przy inicjalizacji (service worker + storage delay) - **Dodano**: Graceful handling gdy popup otwarty bez active tab ### sidebar.tsx - **Dodano**: Stan memoryReady i loading screen dla Chrome - **Dodano**: Wywołanie waitUntilReady() przed dostępem do danych - **Dodano**: Conditional rendering - pokazuje Ładowanie... gdy pamięć nie gotowa ### report-window.tsx - **Dodano**: Stan memoryReady i loading message - **Dodano**: Wywołanie waitUntilReady() przed generowaniem raportu - **Dodano**: Fallback konstruowania URL z origin gdy brak visited_url - **Zmieniono**: Filtr clustrów używa hasMarks() zamiast getMarkedRequests().length ### stolen-data-cluster.tsx - **Bez znaczących zmian** - działa z abstrakcją RequestCluster --- ## 🐛 Poprawki Bugów ### background.ts - **Dodano**: Diagnostic logging do debugowania inicjalizacji - **Dodano**: Try-catch wokół init() z error logging - **Dodano**: Różne logi dla Firefox vs Chrome ### memory.ts - badge operations - **Dodano**: Try-catch wokół wszystkich operacji badge - Zapobiega crashowaniu gdy użytkownik zamknie kartę podczas operacji ### chrome.ts - badge adapter - **Dodano**: Try-catch w setBadgeText, setTitle, setBadgeBackgroundColor - Chrome rzuca błędy gdy operujemy na zamkniętych kartach --- ## 📝 Workflow Użytkownika (Chrome) 1. **Użytkownik odwiedza stronę** → Service worker rejestruje requesty → Auto-mark podejrzanych → Sync do storage 2. **Użytkownik otwiera popup** → Tworzy Memory → Czyta ze storage → Pokazuje dane z flagami 3. **Użytkownik (od)zaznacza domeny** → Zmienia flagi → Sync do storage 4. **Użytkownik generuje raport** → Otwiera report-window → Czyta ze storage → Filtruje według hasMarks() --- ## ✅ Rezultat - ✅ **Firefox**: Działa jak wcześniej (background page + window.memory) - ✅ **Chrome**: Pełne wsparcie MV3 (service worker + storage.session) - ✅ **Wspólny kod**: 95% kodu jest shared, tylko warstwa dostępu do API różni się - ✅ **Bezpieczeństwo**: Obsługa wszystkich edge cases w parsowaniu URL - ✅ **Wydajność**: Throttled sync do storage (max co 500ms) - ✅ **UX**: Loading states w popup/sidebar dla Chrome - ✅ **Build**: npm run build-addon:firefox lub npm build-addon dla firefox / npm run build:chrome dla chrome
345 lines
14 KiB
TypeScript
345 lines
14 KiB
TypeScript
import React, { Fragment } from 'react';
|
|
import ReactDOM from 'react-dom';
|
|
import { getMemory } from '../../memory';
|
|
import { useEmitter, getshorthost } from '../../util';
|
|
import browserAPI from '../../lib/browser-api';
|
|
|
|
// Niezawodne pobieranie zakładki z ponawianiem prób
|
|
async function getCurrentTab(retries = 3, delay = 100): Promise<any> {
|
|
for (let i = 0; i < retries; i++) {
|
|
try {
|
|
// Metoda 1: Zapytanie o aktywną zakładkę
|
|
const tabs = await browserAPI.tabs.query({
|
|
active: true,
|
|
currentWindow: true,
|
|
});
|
|
|
|
if (tabs && tabs[0] && tabs[0].url) {
|
|
return tabs[0];
|
|
}
|
|
|
|
// Metoda 2: Użycie lastFocusedWindow
|
|
const tabsLastFocused = await browserAPI.tabs.query({
|
|
active: true,
|
|
lastFocusedWindow: true,
|
|
});
|
|
|
|
if (tabsLastFocused && tabsLastFocused[0] && tabsLastFocused[0].url) {
|
|
return tabsLastFocused[0];
|
|
}
|
|
} catch (error) {
|
|
console.warn(`Próba ${i + 1} zapytania o zakładkę nie powiodła się:`, error);
|
|
}
|
|
|
|
// Czekanie przed ponowieniem próby
|
|
if (i < retries - 1) {
|
|
await new Promise(resolve => setTimeout(resolve, delay));
|
|
}
|
|
}
|
|
|
|
return undefined;
|
|
}
|
|
|
|
function isDomainHighlySuspicious(domain: string) {
|
|
return (
|
|
domain.includes('facebook') ||
|
|
domain.includes('twitter') ||
|
|
domain.includes('linkedin') ||
|
|
false
|
|
);
|
|
}
|
|
|
|
const Toolbar = () => {
|
|
const [origin, setOrigin] = React.useState<string | null>(null);
|
|
const [memoryReady, setMemoryReady] = React.useState(process.env.TARGET !== 'chrome');
|
|
const [eventCounts] = useEmitter(getMemory());
|
|
const [cookieDomainCopy, setCookieDomainCopy] = React.useState<string | null>(null);
|
|
const [_, setMarksOccurrence] = React.useState(false);
|
|
const [exposedOriginDomainCopy, setExposedOriginDomainCopy] = React.useState<string | null>(
|
|
null
|
|
);
|
|
|
|
const first_sentence_cookie =
|
|
'Strona dokonała zapisu i odczytu plików Cookie dla domen ';
|
|
const first_sentence_history =
|
|
'Część informacji o Twojej historii przeglądania została wysłana do ';
|
|
|
|
// Oczekiwanie na gotowość pamięci Chrome
|
|
React.useEffect(() => {
|
|
if (process.env.TARGET === 'chrome') {
|
|
const memory = getMemory();
|
|
if (typeof (memory as any).waitUntilReady === 'function') {
|
|
(memory as any).waitUntilReady().then(() => {
|
|
setMemoryReady(true);
|
|
console.log('✅ Memory gotowa, popup może wyświetlać dane');
|
|
});
|
|
} else {
|
|
setMemoryReady(true);
|
|
}
|
|
}
|
|
}, []);
|
|
|
|
React.useEffect(() => {
|
|
let isMounted = true;
|
|
|
|
const listener = async () => {
|
|
if (!isMounted) return;
|
|
|
|
const tab = await getCurrentTab();
|
|
|
|
if (!isMounted) return;
|
|
|
|
if (tab && tab.url) {
|
|
try {
|
|
const url = new URL(tab.url);
|
|
|
|
// Pomijanie stron rozszerzenia
|
|
if (url.origin.startsWith('moz-extension') ||
|
|
url.origin.startsWith('chrome-extension') ||
|
|
url.protocol === 'chrome:' ||
|
|
url.protocol === 'about:') {
|
|
return;
|
|
}
|
|
|
|
setOrigin(url.origin);
|
|
} catch (error) {
|
|
console.warn('Nie udało się sparsować URL zakładki:', tab.url, error);
|
|
}
|
|
} else {
|
|
// Tylko ostrzeżenie w trybie debug, nie błąd
|
|
if (process.env.NODE_ENV === 'development') {
|
|
console.debug('Popup otwarty bez kontekstu aktywnej zakładki');
|
|
}
|
|
}
|
|
};
|
|
|
|
browserAPI.tabs.onUpdated.addListener(listener);
|
|
|
|
// Początkowe wczytywanie z odpowiednim opóźnieniem
|
|
if (process.env.TARGET === 'chrome') {
|
|
// Chrome potrzebuje więcej czasu dla service worker + storage
|
|
setTimeout(listener, 200);
|
|
} else {
|
|
// Firefox jest gotowy natychmiast
|
|
listener();
|
|
}
|
|
|
|
return () => {
|
|
isMounted = false;
|
|
browserAPI.tabs.onUpdated.removeListener(listener);
|
|
};
|
|
}, []);
|
|
|
|
React.useEffect(() => {
|
|
if (!origin || !memoryReady) return;
|
|
|
|
const exposedOriginDomains = Object.values(getMemory().getClustersForOrigin(origin))
|
|
.filter((cluster) => cluster.exposesOrigin())
|
|
.sort((cluster1, cluster2) =>
|
|
isDomainHighlySuspicious(cluster1.id)
|
|
? -1
|
|
: isDomainHighlySuspicious(cluster2.id)
|
|
? 1
|
|
: 0
|
|
)
|
|
.map((cluster) => cluster.id);
|
|
|
|
setExposedOriginDomainCopy('');
|
|
|
|
switch (exposedOriginDomains.length) {
|
|
case 0:
|
|
break;
|
|
case 1:
|
|
setExposedOriginDomainCopy(`${exposedOriginDomains[0]}.`);
|
|
break;
|
|
case 2:
|
|
setExposedOriginDomainCopy(
|
|
`${exposedOriginDomains[0]} oraz ${exposedOriginDomains[1]}.`
|
|
);
|
|
break;
|
|
case 3:
|
|
setExposedOriginDomainCopy(
|
|
`${exposedOriginDomains[0]}, ${exposedOriginDomains[1]} oraz ${exposedOriginDomains[2]}.`
|
|
);
|
|
break;
|
|
default:
|
|
setExposedOriginDomainCopy(
|
|
`${exposedOriginDomains[0]}, ${exposedOriginDomains[1]} (i ${
|
|
exposedOriginDomains.length - 2 < 2 ? 2 : exposedOriginDomains.length - 2
|
|
} innych).`
|
|
);
|
|
break;
|
|
}
|
|
}, [eventCounts['*'], origin, memoryReady]);
|
|
|
|
React.useEffect(() => {
|
|
if (!origin || !memoryReady) return;
|
|
|
|
const cookieDomains = Object.values(getMemory().getClustersForOrigin(origin))
|
|
.filter((cluster) => cluster.hasCookies())
|
|
.sort((cluster1, cluster2) =>
|
|
isDomainHighlySuspicious(cluster1.id)
|
|
? -1
|
|
: isDomainHighlySuspicious(cluster2.id)
|
|
? 1
|
|
: 0
|
|
)
|
|
.map((cluster) => cluster.id);
|
|
|
|
setCookieDomainCopy('');
|
|
|
|
switch (cookieDomains.length) {
|
|
case 0:
|
|
break;
|
|
case 1:
|
|
setCookieDomainCopy(`${cookieDomains[0]}.`);
|
|
break;
|
|
case 2:
|
|
setCookieDomainCopy(`${cookieDomains[0]} oraz ${cookieDomains[1]}.`);
|
|
break;
|
|
case 3:
|
|
setCookieDomainCopy(
|
|
`${cookieDomains[0]}, ${cookieDomains[1]} oraz ${cookieDomains[2]}.`
|
|
);
|
|
break;
|
|
default:
|
|
setCookieDomainCopy(
|
|
`${cookieDomains[0]}, ${cookieDomains[1]} (i ${
|
|
cookieDomains.length - 2 < 2 ? 2 : cookieDomains.length - 2
|
|
} innych).`
|
|
);
|
|
break;
|
|
}
|
|
}, [eventCounts['*'], origin, memoryReady]);
|
|
|
|
const autoMark = () => {
|
|
Object.values(getMemory().getClustersForOrigin(origin || '')).forEach((cluster) =>
|
|
cluster.autoMark()
|
|
);
|
|
setMarksOccurrence(true);
|
|
};
|
|
|
|
return (
|
|
<div className="toolbar">
|
|
<header className={origin ? 'header' : 'header header--no-page'}>
|
|
<img src="../../assets/icon-addon.svg" height="24" />
|
|
<div className="webpage-metadata">
|
|
{origin ? (
|
|
<div className="webpage-metadata--hyperlink">{origin}</div>
|
|
) : (
|
|
<div>Rentgen - wtyczka do przeglądania</div>
|
|
)}
|
|
</div>
|
|
{origin ? (
|
|
<button
|
|
onClick={() => {
|
|
window.close();
|
|
}}
|
|
>
|
|
<img src="../../assets/icons/x_thick.svg" width="12" height="12" />
|
|
</button>
|
|
) : (
|
|
<a href="https://internet-czas-dzialac.pl">
|
|
<img src="/assets/icons/info_circle_outline.svg" width="20" height="20" />
|
|
</a>
|
|
)}
|
|
</header>
|
|
|
|
{origin ? (
|
|
<Fragment>
|
|
{' '}
|
|
<section className="summary">
|
|
<div className="counters-wrapper">
|
|
<div className="counters">
|
|
<div className="counter counter--cookies">
|
|
<img src="/assets/icons/cookie.svg#color" width="24" height="24" />
|
|
<span data-event={`${eventCounts['*']}`}>
|
|
{
|
|
Object.values(getMemory().getClustersForOrigin(origin)).filter(
|
|
(cluster) => cluster.hasCookies()
|
|
).length
|
|
}
|
|
</span>
|
|
</div>
|
|
<div className="counter counter--browser-history">
|
|
<img src="/assets/icons/warning.svg#color" width="24" height="24" />
|
|
<span data-event={`${eventCounts['*']}`}>
|
|
{
|
|
Object.values(getMemory().getClustersForOrigin(origin)).filter(
|
|
(cluster) => cluster.exposesOrigin()
|
|
).length
|
|
}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<div className="big-counter" data-event={`${eventCounts['*']}`}>
|
|
{Object.values(getMemory().getClustersForOrigin(origin)).length}
|
|
</div>
|
|
</div>
|
|
<span className="notice">Liczba wykrytych domen podmiotów trzecich</span>
|
|
</section>
|
|
<section className="details">
|
|
{cookieDomainCopy ? (
|
|
<p
|
|
data-event={`${eventCounts['*']}`}
|
|
title={Object.values(getMemory().getClustersForOrigin(origin))
|
|
.filter((cluster) => cluster.hasCookies())
|
|
.map((domain) => domain.id)
|
|
.join(', ')}
|
|
>
|
|
{first_sentence_cookie}
|
|
<strong>{cookieDomainCopy}</strong>
|
|
</p>
|
|
) : null}
|
|
{exposedOriginDomainCopy ? (
|
|
<p
|
|
data-event={`${eventCounts['*']}`}
|
|
title={Object.values(getMemory().getClustersForOrigin(origin))
|
|
.filter((cluster) => cluster.exposesOrigin())
|
|
.map((domain) => domain.id)
|
|
.join(', ')}
|
|
>
|
|
{first_sentence_history}
|
|
<strong>{exposedOriginDomainCopy}</strong>
|
|
</p>
|
|
) : null}
|
|
</section>
|
|
{exposedOriginDomainCopy || cookieDomainCopy ? (
|
|
<Fragment>
|
|
<section className="about">
|
|
<p>
|
|
Takie przetwarzanie danych może być niezgodne z prawem.
|
|
Przejdź do analizy aby pomóc ustalić, czy ta strona nie narusza
|
|
RODO lub ustawy Prawo Komunikacji Elektronicznej.
|
|
</p>
|
|
</section>
|
|
<section className="actions">
|
|
<button
|
|
className="button button--report"
|
|
onClick={() => {
|
|
autoMark();
|
|
window.open(
|
|
`/components/sidebar/sidebar.html?origin=${origin}`,
|
|
'new_tab'
|
|
);
|
|
window.close();
|
|
}}
|
|
>
|
|
Przejdź do analizy
|
|
</button>
|
|
</section>
|
|
</Fragment>
|
|
) : (
|
|
<Fragment>
|
|
<section className="about about__no-errors">
|
|
<p>Nie znaleziono problemów na tej stronie.</p>
|
|
</section>
|
|
</Fragment>
|
|
)}
|
|
</Fragment>
|
|
) : null}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
ReactDOM.render(<Toolbar />, document.getElementById('toolbar')); |