// ============================================================
// PhishGuard AI - popup.js
// Popup logic: displays verdict, feedback buttons, retraining
// status, and session stats.
// ============================================================
(function() {
"use strict";
// ── DOM Elements ──────────────────────────────────────────────
const $id = id => document.getElementById(id);
const loadingState = $id("loadingState");
const resultPanel = $id("resultPanel");
const feedbackSection = $id("feedbackSection");
const retrainSection = $id("retrainSection");
const statsRow = $id("statsRow");
const blockedOverlay = $id("blockedOverlay");
const offlineBanner = $id("offlineBanner");
let currentResult = null;
let currentUrlHash = null;
let feedbackGiven = false;
let feedbackTimeout = null;
let countdownInterval = null;
// ── Init ──────────────────────────────────────────────────────
async function init() {
// Check if this is a blocked page redirect
const params = new URLSearchParams(window.location.search);
if (params.get("blocked") === "1") {
showBlockedPage(params);
return;
}
// Get active tab
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
if (!tab?.url || !tab.url.startsWith("http")) {
showResult({
status: "safe", tier: 0, method: "internal",
confidence: 0, url: tab?.url || "N/A"
});
return;
}
$id("currentUrl").textContent = tab.url;
// Try per-tab cache first (instant)
chrome.runtime.sendMessage(
{ type: "get_tab_result", tabId: tab.id },
response => {
if (response?.result) {
showResult(response.result);
} else {
// Fallback to chrome.storage
chrome.storage.local.get("lastResult", data => {
if (data.lastResult && data.lastResult.url === tab.url) {
showResult(data.lastResult);
} else {
loadingState.style.display = "flex";
}
});
}
}
);
// Load status
loadStatus();
}
// ── Show Result ───────────────────────────────────────────────
function showResult(result) {
currentResult = result;
loadingState.style.display = "none";
resultPanel.style.display = "block";
feedbackSection.style.display = "block";
retrainSection.style.display = "block";
statsRow.style.display = "flex";
const isBlocked = result.status === "blocked" || result.is_phishing;
const isWarn = !isBlocked && (result.confidence || 0) >= 0.4;
const confidence = Math.round((result.confidence || 0) * 100);
const tier = result.tier || 0;
// Score ring
const ring = $id("scoreRing");
const circumference = 2 * Math.PI * 34; // r=34
const offset = circumference - (confidence / 100) * circumference;
ring.style.strokeDasharray = circumference;
setTimeout(() => {
ring.style.strokeDashoffset = offset;
ring.style.stroke = isBlocked ? "var(--danger)" :
isWarn ? "var(--warning)" : "var(--safe)";
}, 100);
$id("scorePct").textContent = confidence + "%";
$id("scorePct").className = `score-pct ${isBlocked ? "status-danger" : isWarn ? "status-warn" : "status-safe"}`;
$id("scoreSub").textContent = isBlocked ? "THREAT" : "RISK";
// Shield
$id("shieldIcon").textContent = isBlocked ? "🚨" : isWarn ? "⚠️" : "✅";
// Verdict text
$id("verdictLabel").textContent = isBlocked ? "PHISHING DETECTED" :
isWarn ? "SUSPICIOUS" : "SAFE";
$id("verdictLabel").className = `verdict-label ${isBlocked ? "status-danger" : isWarn ? "status-warn" : "status-safe"}`;
const methodNames = {
"whitelist": "Whitelist (Tier 1)",
"heuristic": "Heuristic Engine (Tier 2)",
"heuristic-fallback": "Heuristic Fallback",
"bert_gnn_ensemble": "BERT + GNN Ensemble (Tier 3)",
"full_ensemble_bert_gnn_cnn": "Full ML Ensemble (Tier 4)",
"ensemble_with_visual": "Ensemble + Visual (Tier 4)",
"user-override": "User Override",
};
const methodText = methodNames[result.method] || result.method || "Unknown";
$id("verdictDetail").textContent = `${methodText} · Confidence: ${confidence}%`;
// Tier dots and scores
updateTierRow(1, tier >= 1 ? "checked" : "pending", tier === 1 ? "SAFE ✓" : "Miss →");
updateTierRow(2, tier >= 2 ? (isBlocked && tier === 2 ? "blocked" : "checked") : "pending",
result.heuristic_score != null ? `${result.heuristic_score}/100` : "—");
updateTierRow(3, tier >= 3 ? (isBlocked && tier === 3 ? "blocked" : "checked") : "pending",
result.details?.tier3_score != null ? (result.details.tier3_score * 100).toFixed(0) + "%" : "—");
updateTierRow(4, tier >= 4 ? (isBlocked && tier === 4 ? "blocked" : "checked") : "pending",
result.details?.tier4_score != null ? (result.details.tier4_score * 100).toFixed(0) + "%" : "—");
// Tier 2 details — show triggered signals
if (result.signals && result.signals.length > 0) {
const badges = result.signals.map(s => `${s}`).join(" ");
$id("t2Detail").innerHTML = `Signals triggered:
${badges}`;
}
// Compute URL hash for feedback
computeUrlHash(result.url);
// Check if we already gave feedback
checkExistingFeedback();
}
function updateTierRow(tier, status, scoreText) {
const dot = $id(`t${tier}Dot`);
const score = $id(`t${tier}Score`);
const colors = {
checked: "var(--safe)",
blocked: "var(--danger)",
pending: "var(--text-muted)",
};
dot.style.background = colors[status] || colors.pending;
score.textContent = scoreText;
}
// ── Blocked Page ──────────────────────────────────────────────
function showBlockedPage(params) {
loadingState.style.display = "none";
blockedOverlay.classList.add("show");
const url = decodeURIComponent(params.get("url") || "");
const score = params.get("score") || "0";
const method = decodeURIComponent(params.get("method") || "");
$id("blockedUrl").textContent = url;
$id("blockedMethod").textContent = `Detection: ${method} · Risk: ${score}%`;
$id("currentUrl").textContent = url;
currentResult = { url, status: "blocked", confidence: parseInt(score) / 100, method };
computeUrlHash(url);
// Show feedback for blocked pages too
feedbackSection.style.display = "block";
retrainSection.style.display = "block";
statsRow.style.display = "flex";
loadStatus();
$id("proceedBtn").onclick = () => {
chrome.runtime.sendMessage({ type: "whitelist_url", url }, () => {
chrome.tabs.update({ url });
});
};
}
// ── Feedback ──────────────────────────────────────────────────
async function computeUrlHash(url) {
if (!url) return;
const encoded = new TextEncoder().encode(url);
const hash = await crypto.subtle.digest("SHA-256", encoded);
currentUrlHash = Array.from(new Uint8Array(hash))
.map(b => b.toString(16).padStart(2, "0")).join("");
}
function submitFeedback(feedback) {
if (feedbackGiven || !currentUrlHash) return;
feedbackGiven = true;
chrome.runtime.sendMessage({
type: "submit_feedback",
url_hash: currentUrlHash,
feedback: feedback,
}, response => {
if (response?.success) {
// Highlight selected, dim other
const correctBtn = $id("btnCorrect");
const wrongBtn = $id("btnWrong");
if (feedback === "correct") {
correctBtn.classList.add("selected");
wrongBtn.classList.add("dimmed");
} else {
wrongBtn.classList.add("selected");
correctBtn.classList.add("dimmed");
}
$id("thankYou").classList.add("show");
// Allow changing within 5 minutes
feedbackTimeout = setTimeout(() => {
feedbackGiven = false;
correctBtn.classList.remove("selected", "dimmed");
wrongBtn.classList.remove("selected", "dimmed");
}, 5 * 60 * 1000);
}
});
}
function checkExistingFeedback() {
// Check if feedback was already given for this URL
chrome.storage.local.get("phishguard_feedback_queue", data => {
const queue = data.phishguard_feedback_queue || [];
const record = queue.find(r => r.url_hash === currentUrlHash);
if (record?.user_feedback) {
feedbackGiven = true;
const correctBtn = $id("btnCorrect");
const wrongBtn = $id("btnWrong");
if (record.user_feedback === "correct") {
correctBtn.classList.add("selected");
wrongBtn.classList.add("dimmed");
} else {
wrongBtn.classList.add("selected");
correctBtn.classList.add("dimmed");
}
}
});
}
// ── Retraining Status ─────────────────────────────────────────
function loadStatus() {
chrome.runtime.sendMessage({ type: "get_status" }, status => {
if (!status) return;
// Stats row
$id("statScanned").textContent = `📊 ${status.scan_count} scanned`;
$id("statFeedback").textContent = `💬 ${status.labeled_count || 0} labeled`;
$id("statVersion").textContent = `🏷️ v${status.model_version}`;
$id("versionBadge").textContent = `v${status.model_version || "3.0"}`;
// Retrain progress
const urlsRemaining = status.next_retrain_urls_remaining || 50;
const progress = Math.round(((50 - urlsRemaining) / 50) * 100);
$id("retrainProgressBar").style.width = `${progress}%`;
// Retrain status text
const timeMs = status.next_retrain_time_remaining_ms || 0;
const hours = Math.floor(timeMs / 3600000);
const mins = Math.floor((timeMs % 3600000) / 60000);
const labeledNeeded = status.min_labeled_needed || 0;
if (labeledNeeded > 0) {
$id("retrainStatus").textContent =
`Need ${labeledNeeded} more feedback to retrain`;
} else {
$id("retrainStatus").textContent =
`Next retrain: ${urlsRemaining} URLs or ${hours}h ${mins}m`;
}
// Last retrain info
if (status.last_retrain_ts) {
const ago = timeSince(new Date(status.last_retrain_ts));
$id("retrainLast").textContent = `Last retrain: ${ago} ago`;
}
// Start countdown
startCountdown(timeMs);
});
}
function startCountdown(initialMs) {
if (countdownInterval) clearInterval(countdownInterval);
let remaining = initialMs;
countdownInterval = setInterval(() => {
remaining -= 1000;
if (remaining <= 0) {
clearInterval(countdownInterval);
$id("retrainStatus").textContent = "Retrain pending...";
return;
}
const h = Math.floor(remaining / 3600000);
const m = Math.floor((remaining % 3600000) / 60000);
const s = Math.floor((remaining % 60000) / 1000);
// Only update the time portion if it's the time display
const el = $id("retrainStatus");
if (el.textContent.includes("URLs or")) {
const parts = el.textContent.split(" or ");
el.textContent = `${parts[0]} or ${h}h ${m}m ${s}s`;
}
}, 1000);
}
function timeSince(date) {
const seconds = Math.floor((Date.now() - date.getTime()) / 1000);
if (seconds < 60) return `${seconds}s`;
if (seconds < 3600) return `${Math.floor(seconds / 60)}m`;
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h`;
return `${Math.floor(seconds / 86400)}d`;
}
// ── Tier Row Toggle ───────────────────────────────────────────
window.toggleTier = function(header) {
const row = header.parentElement;
row.classList.toggle("open");
};
// ── Event Listeners ───────────────────────────────────────────
$id("btnCorrect").addEventListener("click", () => submitFeedback("correct"));
$id("btnWrong").addEventListener("click", () => submitFeedback("incorrect"));
// ── Start ─────────────────────────────────────────────────────
init();
})();