rentgen/components/toolbar/toolbar.tsx
am0 ffb37f9d61 feat/refactor: Chrome MV3 Support + Refaktoryzacja architektury dla kompatybilności obu przeglądarek
## 🎯 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
2025-10-18 13:08:14 +02:00

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&nbsp;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'));