// 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