rentgen/memory.ts
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

425 lines
15 KiB
TypeScript

import ExtendedRequest from './extended-request';
import { getshorthost } from './util';
import { RequestCluster } from './request-cluster';
import { SaferEmitter } from './safer-emitter';
import browserAPI from './lib/browser-api';
// Deklaracja Chrome API dla TypeScript
declare const chrome: any;
function setDomainsCount(counter: number, tabId: number) {
// Ochrona przed próbą ustawienia badge dla zamkniętej zakładki
try {
browserAPI.badge.setBadgeText({ text: counter < 0 ? '0' : counter.toString(), tabId });
browserAPI.badge.setTitle({
title: 'Rentgen',
tabId,
});
} catch (e) {
// Zakładka została zamknięta - ignorujemy błąd
console.debug(`Zakładka ${tabId} już nie istnieje, pomijanie aktualizacji badge`);
}
}
// Cachowany RequestCluster dla popupu Chrome (ma metody, ale używa danych z cache)
class CachedRequestCluster extends RequestCluster {
private _hasCookies: boolean = false;
private _exposesOrigin: boolean = false;
private _hasMarks: boolean = false;
public lastFullUrl: string | null = null;
public lastModified: number = 0;
constructor(id: string, cached: any) {
super(id);
this._hasCookies = cached.hasCookies || false;
this._exposesOrigin = cached.exposesOrigin || false;
this._hasMarks = cached.hasMarks || false;
this.lastFullUrl = cached.lastFullUrl || null;
this.lastModified = cached.lastModified || 0;
this.requests = []; // Pusta tablica zapobiegająca błędom
}
hasCookies(): boolean {
return this._hasCookies;
}
exposesOrigin(): boolean {
return this._exposesOrigin;
}
hasMarks(): boolean {
return this._hasMarks;
}
// Automatyczne zaznaczenie dla cachowanego clustra
autoMark(): void {
this._hasMarks = true;
// ✅ Trigger storage sync
if (process.env.TARGET === 'chrome') {
const memory = getMemory();
if (memory instanceof Memory) {
memory.scheduleSyncToStorage?.();
}
}
}
// Cofnięcie zaznaczenia dla cachowanego clustra
undoMark(): void {
this._hasMarks = false;
// ✅ Trigger storage sync
if (process.env.TARGET === 'chrome') {
const memory = getMemory();
if (memory instanceof Memory) {
memory.scheduleSyncToStorage?.();
}
}
}
// Nadpisanie metody - zwraca pustą tablicę
calculateRepresentativeStolenData(): any[] {
return [];
}
// Nadpisanie metody - zwraca tylko główną domenę
getFullHosts(): string[] {
return [this.id];
}
// Metody wymagane przez report-window
hasMarkedCookies(): boolean {
return this._hasCookies && this._hasMarks;
}
getMarkedRequests(): any[] {
return [];
}
getMarkedEntries(): any[] {
return [];
}
exposesOriginWhere(): any[] {
return this._exposesOrigin ? [{ path: '', source: 'cached', key: '' }] : [];
}
getDataTypeDescription(noun = 'Twojej'): string {
let types_of_data: string[] = [];
if (this.exposesOrigin()) {
types_of_data.push(`część ${noun} historii przeglądania`);
}
if (this.hasMarkedCookies()) {
types_of_data.push('unikalne ID z cookies');
}
if (types_of_data.length > 1) {
types_of_data[types_of_data.length - 1] = 'oraz ' + types_of_data[types_of_data.length - 1];
}
return types_of_data.join(', ');
}
}
export default class Memory extends SaferEmitter {
origin_to_history = {} as Record<string, Record<string, RequestCluster>>;
isReady: boolean = true; // Firefox jest zawsze gotowy
private readyPromise: Promise<void> | null = null;
private syncScheduled: boolean = false;
private lastSyncTime: number = 0;
// Chrome: Śledzenie pełnych URLi dla zakładek (do generowania screenshotów)
private tabUrls: Map<number, string> = new Map();
async register(request: ExtendedRequest) {
await request.init();
if (!request.isThirdParty()) {
return;
}
if (!this.origin_to_history[request.origin]) {
this.origin_to_history[request.origin] = {};
}
const shorthost = getshorthost(new URL(request.url).host);
let isNewCluster = false;
if (!this.origin_to_history[request.origin][shorthost]) {
const cluster = new RequestCluster(shorthost);
this.origin_to_history[request.origin][shorthost] = cluster;
isNewCluster = true;
}
this.origin_to_history[request.origin][shorthost].add(request);
// Chrome: Automatyczne zaznaczanie podejrzanych domen w service workerze
if (process.env.TARGET === 'chrome' && isNewCluster) {
// Zaznacz od razu po utworzeniu clustra
this.origin_to_history[request.origin][shorthost].autoMark();
}
this.emit('change', shorthost);
// Owinięcie operacji badge w try-catch
try {
Object.values(this.getClustersForOrigin(request.origin)).some((cluster) =>
cluster.hasCookies()
)
? browserAPI.badge.setBadgeBackgroundColor({ color: '#ff726b' })
: browserAPI.badge.setBadgeBackgroundColor({ color: '#ffb900' });
if (request.tabId >= 0) {
setDomainsCount(
Object.values(this.getClustersForOrigin(request.origin)).length,
request.tabId
);
}
} catch (e) {
// Zakładka zamknięta - ignorujemy błędy badge
console.debug('Aktualizacja badge nie powiodła się - zakładka prawdopodobnie zamknięta');
}
// Chrome: Throttlowana synchronizacja do storage (max co 500ms)
if (process.env.TARGET === 'chrome') {
this.scheduleSyncToStorage();
}
}
constructor() {
super();
browserAPI.webRequest.onBeforeRequest.addListener(
async (request) => {
// Chrome: Śledzenie nawigacji main_frame dla pełnego URL
if (process.env.TARGET === 'chrome' && request.type === 'main_frame' && request.tabId >= 0) {
this.tabUrls.set(request.tabId, request.url);
console.log(`📍 Zapamiętano URL zakładki ${request.tabId}:`, request.url);
}
const extReq = new ExtendedRequest(request);
// Chrome: Wstrzyknięcie pełnego URL ze śledzonych zakładek
if (process.env.TARGET === 'chrome' && request.tabId >= 0) {
const fullUrl = this.tabUrls.get(request.tabId);
if (fullUrl && !extReq.originalURL) {
extReq.originalURL = fullUrl;
}
}
},
{ urls: ['<all_urls>'] },
['requestBody']
);
browserAPI.webRequest.onBeforeSendHeaders.addListener(
async (request) => {
const extendedRequest = ExtendedRequest.by_id[request.requestId].addHeaders(
request.requestHeaders || []
);
this.register(extendedRequest);
},
{ urls: ['<all_urls>'] },
['requestHeaders']
);
// Chrome: Czyszczenie URLi zakładek po zamknięciu
if (process.env.TARGET === 'chrome') {
browserAPI.tabs.onRemoved?.addListener?.((tabId: number) => {
this.tabUrls.delete(tabId);
});
}
// Chrome: Wczytywanie z storage przy starcie (dla popupu)
if (process.env.TARGET === 'chrome') {
this.isReady = false;
this.readyPromise = this.loadFromStorage().then(() => {
this.isReady = true;
this.emit('ready');
console.log('🔵 Memory gotowa do użycia przez popup');
});
}
}
emit(eventName: string, data = 'any'): boolean {
setTimeout(() => {
super.emit(eventName, data);
// ✅ Sync to storage when marks change (Chrome only)
if (process.env.TARGET === 'chrome' && eventName === 'change') {
this.scheduleSyncToStorage();
}
}, 0);
return true;
}
getClustersForOrigin(origin: string): Record<string, RequestCluster> {
return this.origin_to_history[origin] || {};
}
// Chrome: Oczekiwanie na zakończenie wczytywania ze storage
async waitUntilReady(): Promise<void> {
if (this.isReady) return;
if (this.readyPromise) {
await this.readyPromise;
}
}
async removeCookiesFor(origin: string, shorthost?: string): Promise<void> {
if (shorthost) {
const cookies = await browserAPI.cookies.getAll({ domain: shorthost });
for (const cookie of cookies) {
await browserAPI.cookies.remove({
name: cookie.name,
url: `https://${cookie.domain}`,
});
}
} else {
const clusters = this.getClustersForOrigin(origin);
await Promise.all(
Object.values(clusters)
.filter((cluster) => !shorthost || cluster.id === shorthost)
.map((cluster) => this.removeCookiesFor(origin, cluster.id))
);
}
// Chrome: Throttlowana synchronizacja do storage
if (process.env.TARGET === 'chrome') {
this.scheduleSyncToStorage();
}
}
async removeRequestsFor(origin: string) {
this.origin_to_history[origin] = {};
// Chrome: Throttlowana synchronizacja do storage
if (process.env.TARGET === 'chrome') {
this.scheduleSyncToStorage();
}
}
// ========================================
// CHROME: Metody synchronizacji ze storage (THROTTLED)
// ========================================
public scheduleSyncToStorage(): void {
if (this.syncScheduled) return;
this.syncScheduled = true;
setTimeout(() => {
this.syncToStorage();
this.syncScheduled = false;
}, 500); // Synchronizacja max co 500ms
}
private syncToStorage(): void {
if (typeof chrome !== 'undefined' && chrome.storage?.session) {
const now = Date.now();
// Pomijamy jeśli synchronizowano niedawno (w ciągu 300ms)
if (now - this.lastSyncTime < 300) {
return;
}
this.lastSyncTime = now;
const serializable: Record<string, any> = {};
for (const [origin, clusters] of Object.entries(this.origin_to_history)) {
serializable[origin] = {};
for (const [shorthost, cluster] of Object.entries(clusters)) {
// Zapisujemy tylko niezbędne dane dla UI
serializable[origin][shorthost] = {
id: cluster.id,
hasCookies: cluster.hasCookies(),
exposesOrigin: cluster.exposesOrigin(),
hasMarks: cluster.hasMarks(),
requestCount: cluster.requests?.length || 0,
lastFullUrl: cluster.lastFullUrl || null,
lastModified: cluster.lastModified || 0,
};
}
}
chrome.storage.session.set({ rentgen_memory: serializable }).catch((err: any) => {
console.error('Nie udało się zsynchronizować pamięci do storage:', err);
});
}
}
private async loadFromStorage(): Promise<void> {
if (typeof chrome !== 'undefined' && chrome.storage?.session) {
try {
const result = await chrome.storage.session.get('rentgen_memory');
if (result.rentgen_memory) {
const serialized = result.rentgen_memory;
for (const [origin, clusters] of Object.entries(serialized)) {
this.origin_to_history[origin] = {};
for (const [shorthost, cached] of Object.entries(clusters as Record<string, any>)) {
// Tworzenie CachedRequestCluster ze wszystkimi potrzebnymi danymi
const clusterData = cached as {
id: string;
hasCookies: boolean;
exposesOrigin: boolean;
hasMarks: boolean;
requestCount: number;
lastFullUrl: string | null;
lastModified: number;
};
this.origin_to_history[origin][shorthost] = new CachedRequestCluster(
clusterData.id,
clusterData
);
}
}
console.log('🔵 Wczytano pamięć z chrome.storage.session:', Object.keys(this.origin_to_history).length, 'origins');
}
} catch (err) {
console.error('Nie udało się wczytać pamięci ze storage:', err);
}
}
}
}
// ========================================
// INICJALIZACJA: Firefox vs Chrome
// ========================================
export function init() {
const memory = new Memory();
if (process.env.TARGET === 'chrome') {
// Chrome MV3: Service worker używa 'self' zamiast 'window'
(self as any).memory = memory;
console.log('🔵 Memory zainicjalizowana w service workerze Chrome (self.memory + chrome.storage.session)');
} else {
// Firefox: Standardowa strona tła z 'window'
(window as any).memory = memory;
console.log('🦊 Memory zainicjalizowana w stronie tła Firefox (window.memory)');
}
}
// ========================================
// DOSTĘP DO MEMORY: Firefox vs Chrome
// ========================================
// Cachowana instancja memory dla popupu (tylko Chrome)
let popupMemoryInstance: Memory | null = null;
export function getMemory(): Memory {
if (process.env.TARGET === 'chrome') {
// Chrome: Najpierw próba pobrania z service workera
if (typeof self !== 'undefined' && (self as any).memory) {
// Jesteśmy W service workerze - bezpośredni dostęp
return (self as any).memory as Memory;
} else {
// Jesteśmy w popupie/contencie - tworzymy RAZ i cachujemy
if (!popupMemoryInstance) {
console.log('🔵 Tworzenie instancji Chrome memory dla popupu (czytanie z chrome.storage.session)');
popupMemoryInstance = new Memory();
}
return popupMemoryInstance;
}
} else {
// Firefox: Używamy tradycyjnego getBackgroundPage()
const backgroundPage = browserAPI.extension.getBackgroundPage();
if (!backgroundPage) {
throw new Error('Strona tła nie jest dostępna');
}
return (backgroundPage.window as any).memory as Memory;
}
}