didi-lot3-frontend/extension/background.js
Top Clossers cec967f953 Livrare Lot 3 (Frontend): aplicație web, aplicație mobilă Android, extensie browser
- Surse complete web (React/Vite) + mobil (React Native/Expo) + extensie (MV3)
- Documentație de livrare: ghid utilizare, matrice trasabilitate cerințe, raport testare furnizor
- Artefacte binare: imagine Docker didi-frontend:lot3-1.0, APK, extensie v3.2.6 + SHA256SUMS
- Configurare adresă platformă externalizată (build args / .env / config.js)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 12:40:40 +03:00

492 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// DIDI Analysis Tool - Background Service Worker (V3 API prin Kong, auth OIDC)
// BUILD: 2026-07-15 — OIDC/PKCE (Keycloak) + Bearer prin Kong; context menus; URL/selecție
console.log('[DIDI BUILD] background.js loaded — build 2026-07-15 (OIDC)');
// config.js FIRST (defines self.DidiConfig used by auth.js), then the renderer
// and the auth module. render.js is needed in the SW to build the standalone
// result page (popup is closed by the time async polling finishes).
importScripts('config.js', 'render.js', 'auth.js');
// Same JWT-protected routes the web app uses (nginx → Kong → agent-v3).
const DIDI_API_BASE = self.DidiConfig.API_BASE;
const DEFAULT_PLAN_TYPE = 1;
// === Result delivery + history (centralized) ===
// Push the analysis to the originator tab (content modal) when applicable, and
// open a new tab with a rendered standalone page for popup-origin requests
// (popup is closed by the time async polling completes, so its callback dies).
async function deliverAnalysis({ analysis, filename, originatorTabId, originatorContext }) {
// 1) Save to local history (used by history.html). Ignore errors.
try {
const data = await chrome.storage.local.get(['didiHistory']);
const history = data.didiHistory || [];
history.unshift({
analysis, filename,
timestamp: new Date().toISOString(),
url: originatorContext === 'popup' ? 'popup' : 'tab',
});
if (history.length > 50) history.length = 50;
await chrome.storage.local.set({ didiHistory: history });
} catch (e) {
console.warn('[BACKGROUND] history save failed:', e.message);
}
// 2) Content-script origin (snip + video + context menu): push the modal back
// into the page; if the tab is gone, fall back to a standalone result tab.
if (originatorTabId != null && originatorContext === 'content') {
try {
await chrome.tabs.sendMessage(originatorTabId, { action: 'showAnalysis', analysis, filename });
} catch (e) {
console.warn('[BACKGROUND] tabs.sendMessage failed (tab closed?), opening result tab:', e.message);
originatorContext = 'popup';
}
}
// 3) Popup origin (text/url): popup is closed, open a new tab with the rendered page.
if (originatorContext === 'popup') {
try {
const html = self.DidiRender
? self.DidiRender.renderStandalonePage(analysis)
: `<pre>${JSON.stringify(analysis, null, 2)}</pre>`;
// Service workers can't use URL.createObjectURL — build a data URL instead.
const dataUrl = 'data:text/html;charset=utf-8,' + encodeURIComponent(html);
await chrome.tabs.create({ url: dataUrl });
} catch (e) {
console.error('[BACKGROUND] failed to open result tab:', e.message);
}
}
}
// ========== AUTH (OIDC/PKCE via auth.js) ==========
/** Authorization header for API calls; refreshes the access token when needed. */
async function authHeaders() {
const token = await self.DidiAuth.getValidToken();
return { 'Authorization': `Bearer ${token}` };
}
// ========== UPLOAD CONSENT (cerință caiet: consimțământ explicit fișiere/capturi) ==========
async function hasUploadConsent() {
const data = await chrome.storage.local.get(['didiUploadConsent']);
return data.didiUploadConsent === true;
}
/** Ask the page (content script) to show the consent modal; resolves true if accepted. */
async function ensureUploadConsent(tabId) {
if (await hasUploadConsent()) return true;
if (tabId == null) return false;
try {
const resp = await chrome.tabs.sendMessage(tabId, { action: 'requestUploadConsent' });
return resp?.accepted === true;
} catch {
return false;
}
}
// ========== MESSAGE LISTENER ==========
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
console.log('[BACKGROUND] Message received:', message.action);
if (message.action === 'login') {
self.DidiAuth.login()
.then((tokens) => sendResponse({ success: true, email: tokens.email, name: tokens.name }))
.catch((error) => sendResponse({ success: false, error: error.message }));
return true;
}
if (message.action === 'getAuthStatus') {
self.DidiAuth.getAuthState()
.then((state) => sendResponse(state))
.catch(() => sendResponse({ authenticated: false }));
return true;
}
if (message.action === 'logout') {
self.DidiAuth.logout()
.then(() => sendResponse({ success: true }))
.catch(() => sendResponse({ success: true })); // local session is cleared regardless
return true;
}
if (message.action === 'snipCaptured') {
console.log('[BACKGROUND] Snip captured:', message.rect);
const tabId = sender.tab?.id ?? null;
// Ack the originator immediately so it doesn't sit on a stale callback for ~90s.
// The actual result is delivered via chrome.tabs.sendMessage when polling completes.
sendResponse({ success: true, accepted: true });
chrome.tabs.captureVisibleTab(null, { format: 'png' }, async (screenshotUrl) => {
if (chrome.runtime.lastError) {
console.error('[BACKGROUND] Screenshot error:', chrome.runtime.lastError);
if (tabId != null) chrome.tabs.sendMessage(tabId, { action: 'analysisFailed', error: chrome.runtime.lastError.message }).catch(() => {});
return;
}
try {
const croppedDataUrl = await cropScreenshot(screenshotUrl, message.rect, message.devicePixelRatio || 1);
const timestamp = new Date().toISOString().replace(/:/g, '-').split('.')[0];
const filename = `didi-snip-${timestamp}.png`;
console.log('[BACKGROUND] Sending screenshot to DIDI API…');
const analysis = await analyzeScreenshot(croppedDataUrl, filename);
console.log('[BACKGROUND] Analysis complete:', analysis);
await deliverAnalysis({ analysis, filename, originatorTabId: tabId, originatorContext: 'content' });
} catch (error) {
console.error('[BACKGROUND] Analysis failed:', error);
if (tabId != null) chrome.tabs.sendMessage(tabId, { action: 'analysisFailed', error: error.message }).catch(() => {});
}
});
return true;
}
if (message.action === 'analyzeText') {
console.log('[BACKGROUND] Analyzing text, length:', message.text?.length);
sendResponse({ success: true, accepted: true }); // popup will likely close — result delivered via new tab
analyzeTextContent(message.text)
.then((analysis) => {
const filename = `text-analysis-${new Date().toISOString().replace(/:/g, '-').split('.')[0]}.txt`;
return deliverAnalysis({ analysis, filename, originatorTabId: null, originatorContext: 'popup' });
})
.catch((error) => console.error('[BACKGROUND] Text analysis failed:', error));
return true;
}
if (message.action === 'analyzeVideo') {
console.log('[BACKGROUND] Analyzing video, filename:', message.filename);
const tabId = sender.tab?.id ?? null;
sendResponse({ success: true, accepted: true });
analyzeVideo(message.dataUrl, message.filename)
.then((analysis) => deliverAnalysis({ analysis, filename: message.filename, originatorTabId: tabId, originatorContext: 'content' }))
.catch((error) => {
console.error('[BACKGROUND] Video analysis failed:', error);
if (tabId != null) chrome.tabs.sendMessage(tabId, { action: 'analysisFailed', error: error.message }).catch(() => {});
});
return true;
}
// Social post extracted in-page (Facebook/Twitter extractors) → analyze it.
if (message.action === 'contentExtracted') {
const tabId = sender.tab?.id ?? null;
const content = message.data || {};
sendResponse({ success: true, accepted: true });
(async () => {
const text = (content.text || '').trim();
const externalLink = (content.links || []).find((l) => typeof l === 'string' && /^https?:/i.test(l));
let analysis;
if (text) {
analysis = await analyzeTextContent(text);
} else if (externalLink) {
analysis = await analyzeUrl(externalLink);
} else {
throw new Error('Postarea selectată nu conține text sau link analizabil');
}
await deliverAnalysis({ analysis, filename: `${message.platform || 'social'}-post`, originatorTabId: tabId, originatorContext: 'content' });
})().catch((error) => {
console.error('[BACKGROUND] Social post analysis failed:', error);
notifyTab(tabId, 'Analiza postării a eșuat: ' + error.message, 'error');
});
return true;
}
if (message.action === 'analyzeUrl') {
console.log('[BACKGROUND] Analyzing URL:', message.url);
sendResponse({ success: true, accepted: true });
analyzeUrl(message.url)
.then((analysis) => {
const filename = `url-analysis-${new Date().toISOString().replace(/:/g, '-').split('.')[0]}.txt`;
return deliverAnalysis({ analysis, filename, originatorTabId: null, originatorContext: 'popup' });
})
.catch((error) => console.error('[BACKGROUND] URL analysis failed:', error));
return true;
}
return true;
});
// ========== INITIALIZATION + CONTEXT MENUS ==========
chrome.runtime.onInstalled.addListener(() => {
console.log('[DIDI] Extension V3 installed');
chrome.contextMenus.removeAll(() => {
chrome.contextMenus.create({
id: 'didi-analyze-image',
title: 'Analizează imaginea cu DiDi',
contexts: ['image'],
});
chrome.contextMenus.create({
id: 'didi-analyze-selection',
title: 'Analizează textul selectat cu DiDi',
contexts: ['selection'],
});
chrome.contextMenus.create({
id: 'didi-analyze-page',
title: 'Analizează pagina curentă cu DiDi',
contexts: ['page'],
});
});
});
/** Toast helper for context-menu flows (best-effort; page may block injection). */
function notifyTab(tabId, message, type = 'info') {
if (tabId == null) return;
chrome.tabs.sendMessage(tabId, { action: 'showToast', message, type }).catch(() => {});
}
chrome.contextMenus.onClicked.addListener(async (info, tab) => {
const tabId = tab?.id ?? null;
try {
// Make sure the content script is present (tabs opened before install/reload).
if (tabId != null) {
try {
await chrome.scripting.executeScript({ target: { tabId }, files: ['render.js', 'content.js'] });
} catch { /* injection blocked (chrome:// etc.) — fall back to result tab */ }
}
if (info.menuItemId === 'didi-analyze-image') {
if (!info.srcUrl) return;
if (!(await ensureUploadConsent(tabId))) {
notifyTab(tabId, 'Analiza a fost anulată — este necesar consimțământul pentru trimiterea imaginii.', 'error');
return;
}
notifyTab(tabId, 'DiDi analizează imaginea… rezultatul apare aici în 3090s.');
const analysis = await analyzeImageFromSrc(info.srcUrl);
await deliverAnalysis({ analysis, filename: 'context-image', originatorTabId: tabId, originatorContext: 'content' });
return;
}
if (info.menuItemId === 'didi-analyze-selection') {
const text = (info.selectionText || '').trim();
if (!text) return;
notifyTab(tabId, 'DiDi analizează textul selectat… rezultatul apare aici în 3090s.');
const analysis = await analyzeTextContent(text);
await deliverAnalysis({ analysis, filename: 'context-selection', originatorTabId: tabId, originatorContext: 'content' });
return;
}
if (info.menuItemId === 'didi-analyze-page') {
const pageUrl = info.pageUrl || tab?.url;
if (!pageUrl || !/^https?:/i.test(pageUrl)) return;
notifyTab(tabId, 'DiDi analizează pagina curentă… rezultatul apare aici în 3090s.');
const analysis = await analyzeUrl(pageUrl);
await deliverAnalysis({ analysis, filename: 'context-url', originatorTabId: tabId, originatorContext: 'content' });
return;
}
} catch (error) {
console.error('[BACKGROUND] Context menu analysis failed:', error);
notifyTab(tabId, 'Analiza a eșuat: ' + error.message, 'error');
}
});
// ========== SCREENSHOT CROP ==========
async function cropScreenshot(dataUrl, rect, devicePixelRatio = 1) {
return new Promise((resolve, reject) => {
fetch(dataUrl)
.then(res => res.blob())
.then(blob => createImageBitmap(blob))
.then(imageBitmap => {
const scaledLeft = rect.left * devicePixelRatio;
const scaledTop = rect.top * devicePixelRatio;
const scaledWidth = rect.width * devicePixelRatio;
const scaledHeight = rect.height * devicePixelRatio;
const canvas = new OffscreenCanvas(rect.width, rect.height);
const ctx = canvas.getContext('2d');
ctx.drawImage(imageBitmap, scaledLeft, scaledTop, scaledWidth, scaledHeight, 0, 0, rect.width, rect.height);
return canvas.convertToBlob({ type: 'image/png' });
})
.then(blob => {
const reader = new FileReader();
reader.onloadend = () => resolve(reader.result);
reader.onerror = (error) => reject(error);
reader.readAsDataURL(blob);
})
.catch(error => reject(error));
});
}
// Manual data URL → Blob decoder.
// fetch(dataUrl) breaks in the MV3 service worker when MIME contains an
// unquoted comma (e.g. MediaRecorder's `video/webm;codecs=vp9,opus`): the URL
// parser splits on that first comma and treats the rest as the body, so the
// "blob" ends up containing the literal text `opus;base64,...` instead of
// decoded bytes. Splitting on the unambiguous `;base64,` marker avoids it.
async function dataUrlToBlob(dataUrl) {
const marker = ';base64,';
const b64Idx = dataUrl.indexOf(marker);
if (b64Idx >= 0) {
const mime = dataUrl.substring(5, b64Idx); // strip leading "data:"
const b64 = dataUrl.substring(b64Idx + marker.length);
const binary = atob(b64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
return new Blob([bytes], { type: mime });
}
const comma = dataUrl.indexOf(',');
if (comma < 0) throw new Error('Invalid data URL');
const mime = dataUrl.substring(5, comma);
return new Blob([decodeURIComponent(dataUrl.substring(comma + 1))], { type: mime });
}
// ========== V3 API: ANALYZE (async + polling, Bearer prin Kong) ==========
// Same contract as the web app: POST /pipeline/analyze-async returns 202 +
// session_id, poll /pipeline/:id/queue-status every 2s, then GET /:id/result.
const POLL_INTERVAL_MS = 2000;
const POLL_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes hard cap (video upper bound)
/** Submit an analyze-async request and poll until completion. */
async function submitAndPoll(payload, kind) {
const claims = await self.DidiAuth.getUserClaims();
const body = { ...payload, ...claims, plan_type: DEFAULT_PLAN_TYPE };
console.log(`[DIDI API] ${kind} async submit`, JSON.stringify(body).substring(0, 200));
const submitResp = await fetch(`${DIDI_API_BASE}/pipeline/analyze-async`, {
method: 'POST',
headers: { 'Content-Type': 'application/json; charset=utf-8', ...(await authHeaders()) },
body: JSON.stringify(body),
});
if (!submitResp.ok) {
const errText = await submitResp.text();
throw new Error(`${kind} submit failed: ${submitResp.status} - ${errText}`);
}
const submit = await submitResp.json();
// Sync fallback: backend already returned a complete analysis (queue was unavailable)
if (submit.async === false && submit.data) {
console.log(`[DIDI API] ${kind} sync fallback (queue down) — returning result directly`);
return submit.data;
}
const sessionId = submit.data?.session_id;
if (!sessionId) throw new Error(`${kind} submit response missing session_id: ${JSON.stringify(submit)}`);
console.log(`[DIDI API] ${kind} dispatched, session_id=${sessionId}, polling…`);
const startedAt = Date.now();
while (Date.now() - startedAt < POLL_TIMEOUT_MS) {
await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
let data;
try {
const statusResp = await fetch(`${DIDI_API_BASE}/pipeline/${sessionId}/queue-status`, {
method: 'GET',
headers: await authHeaders(),
});
if (!statusResp.ok) {
console.warn(`[DIDI API] poll ${sessionId}: HTTP ${statusResp.status}`);
continue;
}
const statusJson = await statusResp.json();
data = statusJson.data || {};
} catch (e) {
// Network blip — log and try again; do NOT swallow a server-side 'failed' status
console.warn(`[DIDI API] poll network error (will retry): ${e.message}`);
continue;
}
console.log(`[DIDI API] poll ${sessionId}: status=${data.status} progress=${data._queue?.progress ?? '?'}%`);
if (data.status === 'completed') {
// queue-status may omit heavy result fields — fetch the full session
try {
const resultResp = await fetch(`${DIDI_API_BASE}/pipeline/${sessionId}/result`, {
method: 'GET',
headers: await authHeaders(),
});
if (resultResp.ok) {
const resultJson = await resultResp.json();
if (resultJson.data) return resultJson.data;
}
} catch (e) {
console.warn(`[DIDI API] result fetch failed, falling back to queue-status payload: ${e.message}`);
}
return data;
}
if (data.status === 'failed') {
throw new Error(`Analysis failed server-side: ${data.reason || 'no result returned'}`);
}
}
throw new Error(`Analysis timed out after ${POLL_TIMEOUT_MS / 1000}s (session ${sessionId})`);
}
/** Upload a media blob via /media/upload (Bearer) → public_url. */
async function uploadMediaBlob(blob, filename) {
const claims = await self.DidiAuth.getUserClaims();
const formData = new FormData();
formData.append('file', blob, filename);
formData.append('user_id', claims.user_id);
const resp = await fetch(`${DIDI_API_BASE}/media/upload`, {
method: 'POST',
headers: await authHeaders(),
body: formData,
});
if (!resp.ok) {
const errText = await resp.text();
throw new Error(`Upload failed: ${resp.status} - ${errText}`);
}
const json = await resp.json();
const d = json.data || {};
const url = d.public_url || d.download_url || d.url;
if (!url) {
throw new Error(`Upload succeeded but no URL in response: ${JSON.stringify(json).substring(0, 300)}`);
}
console.log('[DIDI API] Upload OK:', url);
return url;
}
async function analyzeTextContent(text) {
return submitAndPoll({ text, media_type: 'text' }, 'text');
}
async function analyzeUrl(url) {
// Normalize www. prefix like the web app does
const normalized = /^www\./i.test(url) ? `https://${url}` : url;
return submitAndPoll({ url: normalized, media_type: 'url' }, 'url');
}
async function analyzeScreenshot(dataUrl, filename) {
console.log('[DIDI API] Screenshot upload + analyze…');
const blob = await dataUrlToBlob(dataUrl);
const imageUrl = await uploadMediaBlob(blob, filename);
return submitAndPoll({ media_url: imageUrl, media_type: 'image' }, 'image');
}
/** Analyze an image already hosted somewhere (context menu on <img>). */
async function analyzeImageFromSrc(srcUrl) {
console.log('[DIDI API] Image fetch + upload + analyze…', srcUrl.substring(0, 120));
let blob;
if (srcUrl.startsWith('data:')) {
blob = await dataUrlToBlob(srcUrl);
} else {
const resp = await fetch(srcUrl);
if (!resp.ok) throw new Error(`Nu am putut prelua imaginea (HTTP ${resp.status})`);
blob = await resp.blob();
}
const ext = (blob.type.split('/')[1] || 'png').split(';')[0];
const timestamp = new Date().toISOString().replace(/:/g, '-').split('.')[0];
const imageUrl = await uploadMediaBlob(blob, `didi-image-${timestamp}.${ext}`);
return submitAndPoll({ media_url: imageUrl, media_type: 'image' }, 'image');
}
async function analyzeVideo(dataUrl, filename) {
console.log('[DIDI API] Video upload + analyze…');
const blob = await dataUrlToBlob(dataUrl);
console.log('[DIDI API] Video blob size:', blob.size, 'bytes, type:', blob.type);
const videoUrl = await uploadMediaBlob(blob, filename);
return submitAndPoll({ media_url: videoUrl, media_type: 'video' }, 'video');
}