rentgen/components/sidebar/sidebar.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

270 lines
12 KiB
TypeScript

import React from 'react';
import ReactDOM from 'react-dom';
import { getMemory } from '../../memory';
import Options from '../../options';
import { useEmitter } from '../../util';
import './../../styles/global.scss';
import './sidebar.scss';
import { StolenData } from './stolen-data';
const Sidebar = () => {
const url = new URL(document.location.toString());
const origin = url.searchParams.get('origin');
// Chrome: Oczekiwanie na gotowość pamięci
const [memoryReady, setMemoryReady] = React.useState(process.env.TARGET !== 'chrome');
const [minValueLength, setMinValueLength] = React.useState<number | null>(
localStorage.getItem('minValueLength') === null
? 7
: (localStorage.getItem('minValueLength') as unknown as number)
);
const [cookiesOnly, setCookiesOnly] = React.useState<boolean>(false);
const [stolenDataView, setStolenDataView] = React.useState<boolean>(true);
const [cookiesOrOriginOnly, setCookiesOrOriginOnly] = React.useState<boolean>(false);
const [eventCounts] = useEmitter(getMemory());
const [_, setMarksOccurrence] = React.useState<boolean>(false);
const [infoDataDialogAck, setInfoDataDialogAck] = React.useState<boolean>(
localStorage.getItem('infoDataDialogAck') === null
? true
: localStorage.getItem('infoDataDialogAck') == 'true'
? true
: false
);
const [warningDataDialogAck, setWarningDataDialogAck] = React.useState<boolean>(
localStorage.getItem('warningDataDialogAck') === null
? true
: localStorage.getItem('warningDataDialogAck') == 'true'
? true
: false
);
const [detailsVisibility, setDetailsVisibility] = React.useState<boolean>(
localStorage.getItem('detailsVisibility') === null
? false
: localStorage.getItem('detailsVisibility') == 'true'
? true
: false
);
// 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 dla sidebara');
});
} else {
setMemoryReady(true);
}
}
}, []);
React.useEffect(() => {
if (!origin || !memoryReady) return;
for (const cluster of Object.values(getMemory().getClustersForOrigin(origin))) {
if (cluster.hasMarks()) {
return setMarksOccurrence(true);
}
}
return setMarksOccurrence(false);
}, [eventCounts['*'], memoryReady]);
if (!origin) return <div>Błąd: Brak parametru "origin"</div>;
// Wyświetlanie stanu wczytywania dla Chrome
if (!memoryReady) {
return (
<div className="sidebar">
<header className="header">
<img src="../../assets/icon-addon.svg" height={32}></img>
<div className="webpage-metadata">
<span>Ładowanie danych...</span>
</div>
</header>
</div>
);
}
return (
<div className="sidebar">
<header className="header">
<img src="../../assets/icon-addon.svg" height={32}></img>
<div className="webpage-metadata">
{origin ? (
<>
<span>Analiza strony</span>
<span className="webpage-metadata--hyperlink">{origin}</span>
</>
) : (
<span>Przejdź do wybranej strony internetowej</span>
)}
</div>
<button
className="button button--report"
onClick={() => {
window.open(
`/components/report-window/report-window.html?origin=${origin}`,
'new_tab'
);
}}
>
Generuj raport
</button>
</header>
{stolenDataView ? (
<nav>
<button
onClick={() => {
window.open(
`/components/report-window/report-window.html?origin=${origin}`,
'new_tab'
);
}}
>
<img src="/assets/icons/report.svg" width="20" height="20" />
<span>Generuj raport</span>
</button>
<button
onClick={() => {
setDetailsVisibility(!detailsVisibility);
}}
>
<img
src={
detailsVisibility
? '/assets/icons/file_minus.svg'
: '/assets/icons/file_find.svg'
}
width="20"
height="20"
/>
<span>
{detailsVisibility ? 'Ukryj szczegóły' : 'Wyświetlaj szczegóły'}
</span>
</button>
<button onClick={() => setStolenDataView(!stolenDataView)}>
<img src="/assets/icons/settings.svg" width="20" height="20" />
<span>Ustawienia</span>
</button>
{localStorage.getItem('blottingBrowser') ===
'nikttakniesplamitwojejprzeglądarki jakspidersweb' ? (
<button
onClick={() => {
if (
window.confirm(
'Czy chcesz wczytać wszystkie domeny w celu „splamienia" twojej przeglądarki? Uwaga przeglądarka może zablokować otwieranie nowych kart. (Ten krok jest opcjonalny)'
)
) {
let deep_copy = JSON.parse(
JSON.stringify(
Object.values(
getMemory().getClustersForOrigin(origin)
).map((domain) => domain.id)
)
);
for (const domain of deep_copy) {
window.open(`https://${domain}`);
}
}
}}
>
<img src="/assets/icons/bulb.svg" width="20" height="20" />
<span>Odwiedź wszystkie domeny</span>
</button>
) : null}
</nav>
) : null}
<section>
{stolenDataView ? (
<>
{infoDataDialogAck ? (
<section className="dialog-container dialog-container--info">
<span>
<strong>
Rentgen automatycznie zaznacza wybrane domeny na podstawie
zebranych danych.
</strong>{' '}
Możesz teraz przejść do generowania raportu lub dokonać korekty.
</span>
<button
onClick={() => {
setInfoDataDialogAck(false);
localStorage.setItem(
'infoDataDialogAck',
false as unknown as string
);
}}
>
<img src="/assets/icons/close_big.svg" width="16" height="16" />
</button>
</section>
) : null}
{warningDataDialogAck ? (
<section className="dialog-container dialog-container--warning">
<span>
<strong>Uwaga!</strong> Niekoniecznie każda przesłana poniżej
informacja jest daną osobową. Niektóre z podanych domen mogą
należeć do właściciela strony i nie reprezentować podmiotów
trzecich.
</span>
<button
onClick={() => {
setWarningDataDialogAck(false);
localStorage.setItem(
'warningDataDialogAck',
false as unknown as string
);
}}
>
<img src="/assets/icons/close_big.svg" width="16" height="16" />
</button>
</section>
) : null}
<StolenData
origin={origin}
eventCounts={eventCounts}
minValueLength={minValueLength === null ? 7 : minValueLength}
cookiesOnly={cookiesOnly}
cookiesOrOriginOnly={cookiesOrOriginOnly}
detailsVisibility={detailsVisibility}
/>
</>
) : (
<Options
minValueLength={minValueLength === null ? 7 : minValueLength}
setMinValueLength={setMinValueLength}
cookiesOnly={cookiesOnly}
setCookiesOnly={setCookiesOnly}
cookiesOrOriginOnly={cookiesOrOriginOnly}
setCookiesOrOriginOnly={setCookiesOrOriginOnly}
warningDataDialogAck={warningDataDialogAck}
setWarningDataDialogAck={setWarningDataDialogAck}
detailsVisibility={detailsVisibility}
setDetailsVisibility={setDetailsVisibility}
setStolenDataView={setStolenDataView}
removeCookies={() => {
getMemory().removeCookiesFor(origin);
getMemory().emit('change', origin);
setMarksOccurrence(false);
}}
removeRequests={() => {
getMemory().removeRequestsFor(origin);
getMemory().emit('change', origin);
setMarksOccurrence(false);
}}
/>
)}
</section>
</div>
);
};
ReactDOM.render(<Sidebar />, document.getElementById('app'));