// background.js — ATS Resume Generator service worker // No ATS or model-inference logic here. Only: read storage, call API, trigger download. // R20: bundled default resume (self.DEFAULT_RESUME_LATEX) so a brand-new user can // Run before configuring anything. importScripts works in a classic MV3 worker. try { importScripts('default_resume.js'); } catch (_) { /* default optional */ } function safeRespond(sendResponse, data) { try { sendResponse(data); } catch (_) { /* popup closed before reply */ } } // ─── Storage source-of-truth (resilient Run; survives popup close + worker suspend) ── // The background service worker OWNS generation. It persists the run lifecycle to // chrome.storage.local keyed by the normalized job URL, so a reopened popup (or a // recycled MV3 worker) can always surface the running marker or the final result. // Mirrors the popup's `ats_results` map + `normalizeUrl` contract exactly. const RESULTS_KEY = 'ats_results'; // map: { [urlKey]: {status, result, job_title, company, startedAt, savedAt} } const MAX_SAVED = 40; // retained history entries (R19); quota-guarded below const BYTE_BUDGET = 4500000; // ~4.5MB headroom under the ~5MB local quota function normalizeUrl(u) { // Drop the #fragment so an in-page refresh still matches; keep the query. return (u || '').split('#')[0]; } // Approximate serialized size of the results map (bytes ~= JSON length). function approxSize(map) { try { return JSON.stringify(map).length; } catch (_) { return 0; } } // Strip base64 artifacts from the OLDEST entries (keeping the newest `keepNewest` // full) so the history row still renders (metadata + scores) but the heavy // docx/pdf/tex payloads are dropped. Flags pruned entries as metadata-only. function stripOldestArtifacts(map, keepNewest) { const ks = Object.keys(map).sort((a, b) => (map[a].savedAt || 0) - (map[b].savedAt || 0)); for (const k of ks.slice(0, Math.max(0, ks.length - keepNewest))) { const r = map[k] && map[k].result; if (r && (r.docx_b64 || r.pdf_b64 || r.tex_b64)) { delete r.docx_b64; delete r.pdf_b64; delete r.tex_b64; r.artifacts_pruned = true; } } } async function resolveActiveTabUrl() { try { const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); return tab ? tab.url : ''; } catch (_) { return ''; } } // Shallow-merge a patch into the entry for `urlKey`, preserving existing fields, // stamping savedAt, and pruning to the most-recent MAX_SAVED entries. async function writeEntry(urlKey, patch) { if (!urlKey) return; try { const store = await new Promise(res => chrome.storage.local.get([RESULTS_KEY], res)); const map = store[RESULTS_KEY] || {}; map[urlKey] = { ...(map[urlKey] || {}), ...patch, savedAt: Date.now() }; // 1. Prune to the MAX_SAVED most-recent entries by recency. let ks = Object.keys(map); if (ks.length > MAX_SAVED) { ks.sort((a, b) => (map[a].savedAt || 0) - (map[b].savedAt || 0)); for (const k of ks.slice(0, ks.length - MAX_SAVED)) delete map[k]; } // 2. Byte-budget guard: progressively strip artifacts from the oldest // entries (keeping fewer full each pass), then drop whole oldest entries, // until under budget. The just-written (newest) entry stays full. let keep = MAX_SAVED; while (approxSize(map) > BYTE_BUDGET && keep > 1) { keep = Math.max(1, Math.floor(keep / 2)); stripOldestArtifacts(map, keep); } while (approxSize(map) > BYTE_BUDGET && Object.keys(map).length > 1) { const oldest = Object.keys(map) .sort((a, b) => (map[a].savedAt || 0) - (map[b].savedAt || 0))[0]; delete map[oldest]; } // 3. Write; on a QUOTA_BYTES error, degrade gracefully (strip oldest // artifacts, else drop oldest entry) and retry — never lose the newest. let attempts = 0; while (attempts++ < MAX_SAVED + 2) { try { await new Promise((res, rej) => chrome.storage.local.set({ [RESULTS_KEY]: map }, () => { const e = chrome.runtime.lastError; if (e) rej(new Error(e.message)); else res(); })); return; // success } catch (_) { const ksOld = Object.keys(map) .sort((a, b) => (map[a].savedAt || 0) - (map[b].savedAt || 0)); if (ksOld.length <= 1) return; // can't reduce further without losing newest const oldest = ksOld[0]; const r = map[oldest] && map[oldest].result; if (r && (r.docx_b64 || r.pdf_b64 || r.tex_b64)) { delete r.docx_b64; delete r.pdf_b64; delete r.tex_b64; r.artifacts_pruned = true; } else { delete map[oldest]; } } } } catch (_) { /* quota or transient storage error — non-fatal */ } } chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { if (msg.type === 'GENERATE') { handleGenerate(msg) .then((data) => safeRespond(sendResponse, data)) .catch((err) => safeRespond(sendResponse, { error: 'runtime_error', detail: err.message })); return true; } if (msg.type === 'REPAIR') { handleRepair(msg) .then((data) => safeRespond(sendResponse, data)) .catch((err) => safeRespond(sendResponse, { error: 'runtime_error', detail: err.message })); return true; } if (msg.type === 'DOWNLOAD') { handleDownload(msg).catch(console.error); return false; } }); // ─── REPAIR (External ATS Feedback Repair Mode) ─────────────────────────────── async function handleRepair({ jd_text, job_title, company, feedback, maximum_ats_mode, confirmed_terms, url }) { const data = await new Promise(res => chrome.storage.local.get( ['resume_b64', 'resume_latex', 'api_url', 'api_token'], res )); if (!data.api_url || !data.api_token) { return { error: 'not_configured', detail: 'Please set API URL and token in Options.' }; } // R20: fall back to the bundled default resume when nothing is stored. let resumeLatex = data.resume_latex; if ((!resumeLatex || !resumeLatex.trim()) && !data.resume_b64 && self.DEFAULT_RESUME_LATEX) { resumeLatex = self.DEFAULT_RESUME_LATEX; } const hasLatex = !!(resumeLatex && resumeLatex.trim()); if (!hasLatex && !data.resume_b64) { return { error: 'no_resume', detail: 'Please paste your resume LaTeX or upload a PDF in Options first.' }; } const formData = new FormData(); formData.append('jd_text', jd_text); formData.append('job_title', job_title || ''); formData.append('company', company || ''); formData.append('feedback', feedback || ''); formData.append('maximum_ats_mode', maximum_ats_mode ? '1' : '0'); if (confirmed_terms) formData.append('confirmed_terms', confirmed_terms); // LaTeX takes priority; only attach the PDF when no LaTeX is saved. if (hasLatex) { formData.append('resume_latex', resumeLatex); } else { const binaryStr = atob(data.resume_b64); const bytes = new Uint8Array(binaryStr.length); for (let i = 0; i < binaryStr.length; i++) bytes[i] = binaryStr.charCodeAt(i); const resumeBlob = new Blob([bytes], { type: 'application/pdf' }); formData.append('resume', resumeBlob, 'resume.pdf'); } let resp; try { resp = await fetch(`${data.api_url.replace(/\/$/, '')}/api/repair-with-feedback`, { method: 'POST', headers: { 'X-Api-Token': data.api_token }, body: formData, }); } catch (networkErr) { return { error: 'network_error', detail: `Cannot reach API: ${networkErr.message}` }; } let result; try { result = await resp.json(); } catch { return { error: 'parse_error', detail: `API returned non-JSON (status ${resp.status})` }; } if (!resp.ok) { return { error: result.error || 'api_error', detail: result.detail || `HTTP ${resp.status}` }; } // An improved result is also persisted so reopening surfaces it (source of truth). const urlKey = normalizeUrl(url || await resolveActiveTabUrl()); await writeEntry(urlKey, { status: 'done', result, job_title: job_title || '', company: company || '' }); return result; } // ─── GENERATE ──────────────────────────────────────────────────────────────── async function handleGenerate({ jd_text, job_title, company, maximum_ats_mode, confirmed_terms, url }) { // 1. Read settings from storage const data = await new Promise(res => chrome.storage.local.get( ['resume_b64', 'resume_latex', 'api_url', 'api_token'], res )); if (!data.api_url || !data.api_token) { return { error: 'not_configured', detail: 'Please set API URL and token in Options.' }; } // R20: fall back to the bundled default resume when nothing is stored. let resumeLatex = data.resume_latex; if ((!resumeLatex || !resumeLatex.trim()) && !data.resume_b64 && self.DEFAULT_RESUME_LATEX) { resumeLatex = self.DEFAULT_RESUME_LATEX; } const hasLatex = !!(resumeLatex && resumeLatex.trim()); if (!hasLatex && !data.resume_b64) { return { error: 'no_resume', detail: 'Please paste your resume LaTeX or upload a PDF in Options first.' }; } // The popup sends the job URL so persistence is keyed consistently; if it's // missing (e.g. a recycled worker handling a queued message), fall back to the // active tab. Storage — not in-memory worker state — is the source of truth. const urlKey = normalizeUrl(url || await resolveActiveTabUrl()); // 2. Write the running marker BEFORE awaiting the API so a closed/reopened // popup restores a spinner instead of resetting to idle "Run". await writeEntry(urlKey, { status: 'running', startedAt: Date.now(), job_title: job_title || '', company: company || '', }); // 3. Perform the network round-trip (no popup dependency). const result = await runGenerate({ data, hasLatex, resumeLatex, jd_text, job_title, company, maximum_ats_mode, confirmed_terms }); // 4. Overwrite the marker with the terminal state — done OR error — so the // popup never shows a false/forever spinner. Done regardless of popup state. if (result && result.error) { await writeEntry(urlKey, { status: 'error', result }); } else { await writeEntry(urlKey, { status: 'done', result, job_title: job_title || '', company: company || '' }); } return result; // still returned to the popup so an open popup updates immediately } // Network/parse layer for GENERATE. Returns a result object (success or {error}). async function runGenerate({ data, hasLatex, resumeLatex, jd_text, job_title, company, maximum_ats_mode, confirmed_terms }) { // Build multipart/form-data. LaTeX takes priority over the PDF. const formData = new FormData(); formData.append('jd_text', jd_text); formData.append('job_title', job_title || ''); formData.append('company', company || ''); formData.append('maximum_ats_mode', maximum_ats_mode ? '1' : '0'); if (confirmed_terms) formData.append('confirmed_terms', confirmed_terms); if (hasLatex) { formData.append('resume_latex', resumeLatex || data.resume_latex); } else { const binaryStr = atob(data.resume_b64); const bytes = new Uint8Array(binaryStr.length); for (let i = 0; i < binaryStr.length; i++) { bytes[i] = binaryStr.charCodeAt(i); } const resumeBlob = new Blob([bytes], { type: 'application/pdf' }); formData.append('resume', resumeBlob, 'resume.pdf'); } let resp; try { resp = await fetch(`${data.api_url.replace(/\/$/, '')}/api/generate`, { method: 'POST', headers: { 'X-Api-Token': data.api_token }, body: formData, }); } catch (networkErr) { return { error: 'network_error', detail: `Cannot reach API: ${networkErr.message}` }; } let result; try { result = await resp.json(); } catch { return { error: 'parse_error', detail: `API returned non-JSON (status ${resp.status})` }; } if (!resp.ok) { return { error: result.error || 'api_error', detail: result.detail || `HTTP ${resp.status}` }; } return result; } // ─── DOWNLOAD ──────────────────────────────────────────────────────────────── async function handleDownload({ format, b64, filename }) { if (!b64) return; // MV3 service workers do NOT support URL.createObjectURL — it's undefined here, // which silently broke the download. Use a data: URL instead (chrome.downloads // accepts data: URLs directly from the service worker). let mimeType; if (format === 'pdf') { mimeType = 'application/pdf'; } else if (format === 'tex') { mimeType = 'application/x-tex'; } else { mimeType = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'; } const url = `data:${mimeType};base64,${b64}`; try { await chrome.downloads.download({ url, filename: filename || `resume.${format}`, saveAs: false, }); } catch (err) { console.error('[download] failed:', err); } }