// 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) : `
${JSON.stringify(analysis, null, 2)}
`; // 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 30–90s.'); 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 30–90s.'); 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 30–90s.'); 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 ). */ 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'); }