diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1f925b0..d176eb7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,11 +36,18 @@ jobs: - name: Run Server unit tests run: cd Server && npm test + - name: Install Extension dependencies + run: cd Extension && npm ci + + - name: Run Extension unit tests + run: cd Extension && npm test + - name: Run full-system scan (E2E) env: USE_SCORING_TEST_DATA: "1" PORT: "3000" run: | + E2E_START=$(date +%s) node Server/server.js & SERVER_PID=$! sleep 6 @@ -59,3 +66,6 @@ jobs: } console.log('Full-system scan OK, safetyScore:', d.safetyScore); " + E2E_END=$(date +%s) + E2E_DURATION=$((E2E_END - E2E_START)) + echo "E2E test duration: ${E2E_DURATION}s" diff --git a/Docs/bill-of-materials.md b/Docs/bill-of-materials.md new file mode 100644 index 0000000..f3979d7 --- /dev/null +++ b/Docs/bill-of-materials.md @@ -0,0 +1,75 @@ +# Bill of Materials + +| Field | Value | +|-------|-------| +| Artifact ID | ART-021 | +| Artifact Title | Project Management | +| Revision | 01 | +| Revision Date | 25 FEB 2026 | +| Prepared by | Tate McCauley | +| Checked by | Cam Gedris | +| Purpose | Preview of future work plans from our Jira instance | + +--- + +## Bill of Materials + +### Deliverables + +| Item # | Part / Description | Quantity | Notes | +|--------|--------------------|----------|-------| +| 1 | Chrome Extension (netstar-extension) | 1 | React 19, MV3; popup, background, content script | +| 2 | Node.js Server (Express API) | 1 | `/scan` endpoint; validates targets, spawns scoring engine | +| 3 | Python Scoring Engine | 1 | scoring_main.py, score_engine.py, data_fetch.py, scoring_logic.py | + +### Runtime / Prerequisites + +| Item # | Part / Description | Quantity | Notes | +|--------|--------------------|----------|-------| +| 4 | Node.js | 1 | v18+ required (Extension + Server) | +| 5 | Python | 1 | 3.x required (Scoring Engine) | +| 6 | Chrome / Chromium browser | 1 | Edge, Brave, etc. for Extension | + +### Extension — Production Dependencies + +| Item # | Part / Description | Quantity | Notes | +|--------|--------------------|----------|-------| +| 7 | react | 19.1.0 | UI framework | +| 8 | react-dom | 19.1.0 | React DOM renderer | +| 9 | @radix-ui/react-progress, react-slot, react-switch | 3 | Accessible UI primitives | +| 10 | lucide-react | ^0.552.0 | Icons | +| 11 | tailwind-merge, tailwind-scrollbar, tailwindcss-animate | 3 | Styling utilities | +| 12 | class-variance-authority, clsx | 2 | Component styling (cva/clsx) | + +### Extension — Build / Dev Dependencies + +| Item # | Part / Description | Quantity | Notes | +|--------|--------------------|----------|-------| +| 13 | Vite | ^5.4.21 | Build tool | +| 14 | @crxjs/vite-plugin | ^2.2.0 | Chrome extension build | +| 15 | @vitejs/plugin-react | ^4.3.1 | React HMR/build | +| 16 | Tailwind CSS, PostCSS, Autoprefixer | 3 | Styles (Tailwind ^4.1.17) | +| 17 | Jest | ^30.2.0 | Unit tests | +| 18 | JSDoc | ^4.0.5 | Documentation | + +### Server — Dependencies + +| Item # | Part / Description | Quantity | Notes | +|--------|--------------------|----------|-------| +| 19 | express | ^5.1.0 | HTTP API | +| 20 | jest, supertest | 2 | Testing (dev) | + +### Scoring Engine — Dependencies + +| Item # | Part / Description | Quantity | Notes | +|--------|--------------------|----------|-------| +| 21 | Python standard library + curl | — | Telemetry (cert, DNS, RDAP, etc.) via curl | +| 22 | pytest, pytest-cov, pytest-mock | 3 | Testing (requirements.txt) | + +### Documentation + +| Item # | Part / Description | Quantity | Notes | +|--------|--------------------|----------|-------| +| 23 | Docs (deployment, testing, url-sanitization-policy, extension-server-flow) | 4+ | NetSTAR-Shield/Docs | +| 24 | Extension docs (architecture, development, theme, content, caching) | 5+ | Extension/docs | +| 25 | READMEs (project, Extension, Server, Scoring Engine) | 4 | Top-level and per-component | diff --git a/Extension/__tests__/scan.test.js b/Extension/__tests__/scan.test.js new file mode 100644 index 0000000..8483bde --- /dev/null +++ b/Extension/__tests__/scan.test.js @@ -0,0 +1,222 @@ +import { jest, describe, it, expect, beforeEach, afterEach } from "@jest/globals"; + +// ─── Mocks ───────────────────────────────────────────────────────────── +// We use jest.unstable_mockModule for ESM. Constants are mocked with a +// short timeout so the abort test completes quickly. + +const TEST_TIMEOUT_MS = 200; + +jest.unstable_mockModule("../src/background/constants.js", () => ({ + SCAN_API_BASE: "http://test-server:3000", + CACHE_DURATION_MS: 300_000, + SCAN_FETCH_TIMEOUT_MS: TEST_TIMEOUT_MS, +})); + +// The urlNormalize module is real (no mock needed). + +// Global chrome mock — getCachedOrScan uses chrome.storage.local +const storageBacking = {}; +globalThis.chrome = { + storage: { + local: { + get: jest.fn((key) => { + const k = typeof key === "string" ? key : Object.keys(key)[0]; + return Promise.resolve(k in storageBacking ? { [k]: storageBacking[k] } : {}); + }), + set: jest.fn((obj) => { + Object.assign(storageBacking, obj); + return Promise.resolve(); + }), + remove: jest.fn((key) => { + delete storageBacking[key]; + return Promise.resolve(); + }), + }, + }, +}; + +// Import after mocks are registered +const { performSecurityScan, getCachedOrScan } = await import( + "../src/background/scan.js" +); + +// ─── Helpers ─────────────────────────────────────────────────────────── + +function mockFetchResponse(body, { ok = true, status = 200 } = {}) { + globalThis.fetch = jest.fn(() => + Promise.resolve({ + ok, + status, + json: () => Promise.resolve(body), + }) + ); +} + +function clearStorageBacking() { + for (const key of Object.keys(storageBacking)) delete storageBacking[key]; +} + +// ─── Tests ───────────────────────────────────────────────────────────── + +beforeEach(() => { + jest.clearAllMocks(); + clearStorageBacking(); +}); + +// ═══ performSecurityScan ═══════════════════════════════════════════════ + +describe("performSecurityScan", () => { + it("returns { safetyScore, indicators, timestamp } on success", async () => { + mockFetchResponse({ safetyScore: 85, indicators: [{ id: "cert" }] }); + + const result = await performSecurityScan("https://www.example.com/path"); + + expect(result).toEqual( + expect.objectContaining({ + safetyScore: 85, + indicators: [{ id: "cert" }], + }) + ); + expect(typeof result.timestamp).toBe("number"); + }); + + it("uses the normalized domain in the fetch URL", async () => { + mockFetchResponse({ safetyScore: 90, indicators: [] }); + + await performSecurityScan("https://www.Example.COM/page?q=1"); + + expect(globalThis.fetch).toHaveBeenCalledTimes(1); + const url = globalThis.fetch.mock.calls[0][0]; + expect(url).toContain("domain=example.com"); + expect(url).not.toContain("www."); + }); + + it("falls back to aggregatedScore when safetyScore is missing", async () => { + mockFetchResponse({ aggregatedScore: 70, indicators: [] }); + + const result = await performSecurityScan("example.com"); + + expect(result.safetyScore).toBe(70); + }); + + it("rejects with a timeout error when fetch never resolves", async () => { + globalThis.fetch = jest.fn( + () => new Promise((_, reject) => { + // Listen for the abort signal to reject, like a real fetch would. + const opts = globalThis.fetch.mock.calls[0]?.[1]; + if (opts?.signal) { + opts.signal.addEventListener("abort", () => + reject(Object.assign(new Error("The operation was aborted"), { name: "AbortError" })) + ); + } + }) + ); + + await expect(performSecurityScan("example.com")).rejects.toThrow( + /timed out/i + ); + }, TEST_TIMEOUT_MS + 5000); + + it("rejects with the original error on network failure", async () => { + globalThis.fetch = jest.fn(() => Promise.reject(new Error("Network down"))); + + await expect(performSecurityScan("example.com")).rejects.toThrow( + "Network down" + ); + }); + + it("passes an AbortSignal to fetch", async () => { + mockFetchResponse({ safetyScore: 50, indicators: [] }); + + await performSecurityScan("example.com"); + + const opts = globalThis.fetch.mock.calls[0][1]; + expect(opts).toBeDefined(); + expect(opts.signal).toBeInstanceOf(AbortSignal); + }); +}); + +// ═══ getCachedOrScan ═══════════════════════════════════════════════════ + +describe("getCachedOrScan", () => { + it("returns cached data on cache hit (no fetch)", async () => { + const cached = { + safetyScore: 92, + indicators: [], + timestamp: Date.now(), + }; + const cacheKey = "scan_example.com"; + storageBacking[cacheKey] = cached; + + globalThis.fetch = jest.fn(); + + const result = await getCachedOrScan("https://www.example.com"); + + expect(result).toEqual(cached); + expect(globalThis.fetch).not.toHaveBeenCalled(); + expect(chrome.storage.local.set).not.toHaveBeenCalled(); + }); + + it("fetches and caches on cache miss", async () => { + mockFetchResponse({ safetyScore: 77, indicators: [{ id: "dns" }] }); + + const result = await getCachedOrScan("https://example.com"); + + expect(result.safetyScore).toBe(77); + expect(result.indicators).toEqual([{ id: "dns" }]); + expect(globalThis.fetch).toHaveBeenCalledTimes(1); + expect(chrome.storage.local.set).toHaveBeenCalledTimes(1); + + const setArg = chrome.storage.local.set.mock.calls[0][0]; + expect(Object.keys(setArg)[0]).toBe("scan_example.com"); + }); + + it("removes expired cache and fetches fresh data", async () => { + const expired = { + safetyScore: 50, + indicators: [], + timestamp: Date.now() - 600_000, // 10 min ago, well past 5 min TTL + }; + storageBacking["scan_example.com"] = expired; + + mockFetchResponse({ safetyScore: 88, indicators: [] }); + + const result = await getCachedOrScan("https://example.com"); + + expect(chrome.storage.local.remove).toHaveBeenCalledWith("scan_example.com"); + expect(result.safetyScore).toBe(88); + expect(globalThis.fetch).toHaveBeenCalledTimes(1); + }); + + it("deduplicates concurrent in-flight requests", async () => { + let resolveFetch; + globalThis.fetch = jest.fn( + () => + new Promise((resolve) => { + resolveFetch = resolve; + }) + ); + + // Launch both before awaiting either — storage.get is async so we need + // to let microtasks drain so both calls reach the in-flight check. + const p1 = getCachedOrScan("https://example.com"); + const p2 = getCachedOrScan("https://example.com"); + + // Wait a tick so the async storage calls resolve and fetch is invoked + await new Promise((r) => setTimeout(r, 50)); + + expect(globalThis.fetch).toHaveBeenCalledTimes(1); + + resolveFetch({ + ok: true, + status: 200, + json: () => Promise.resolve({ safetyScore: 60, indicators: [] }), + }); + + const [r1, r2] = await Promise.all([p1, p2]); + + expect(r1.safetyScore).toBe(60); + expect(r2.safetyScore).toBe(60); + expect(r1).toBe(r2); + }); +}); diff --git a/Extension/src/background/constants.js b/Extension/src/background/constants.js index 88f837e..a5540d0 100644 --- a/Extension/src/background/constants.js +++ b/Extension/src/background/constants.js @@ -20,3 +20,6 @@ export const CACHE_DURATION_MS = 1000 * 5 * 60; // 5 minutes // Score threshold to trigger a warning notification export const ALERT_THRESHOLD = 60; +// Max time to wait for the scan API before aborting the fetch +export const SCAN_FETCH_TIMEOUT_MS = 10_000; // 10 seconds + diff --git a/Extension/src/background/messages.js b/Extension/src/background/messages.js index 7da9857..a41a71d 100644 --- a/Extension/src/background/messages.js +++ b/Extension/src/background/messages.js @@ -22,17 +22,9 @@ export function registerMessageListeners() { if (request.action === "scanUrl") { (async () => { try { - // ── Manual-scan validation ──────────────────────────────────── - // Block inputs that don't contain a letter-based TLD (e.g. - // ".com", ".org", ".co.uk"). This rejects: - // • Single words with no dot ("hello") - // • Plain IP addresses ("192.168.1.1", "::1") - // • Strings with only numeric TLDs or no TLD at all - // - // Plain IPs are intentionally blocked for now; this may change - // in a future version. See Docs/url-sanitization-policy.md. - // The server also validates and will return 400 for anything - // that slips past this check. + // Block inputs that don't contain a letter-based TLD (e.g. ".com", ".org"). + // Rejects single words, plain IPs, and strings with no TLD. + // See Docs/url-sanitization-policy.md. if (!/\.[a-z]{2,}/i.test(request.url)) { console.log("Invalid URL entered:", request.url); sendResponse({ @@ -44,146 +36,88 @@ export function registerMessageListeners() { } const result = await getCachedOrScan(request.url); - - const tabs = await chrome.tabs.query({ active: true, currentWindow: true }); - if (tabs[0]) { - updateIcon(tabs[0].id, result.safetyScore); - updateRecentScans(request.url, result.safetyScore); - await maybeShowRiskNotification(request.url, result.safetyScore); - } - sendResponse(result); + + // Fire-and-forget side-effects so the popup is unblocked immediately + void (async () => { + try { + const tabs = await chrome.tabs.query({ active: true, currentWindow: true }); + if (tabs[0]) { + updateIcon(tabs[0].id, result.safetyScore); + updateRecentScans(request.url, result.safetyScore); + await maybeShowRiskNotification(request.url, result.safetyScore); + } + } catch (e) { + console.error("[NetSTAR] scanUrl side-effects error:", e); + } + })(); } catch (error) { console.error("Error in scanUrl:", error); sendResponse({ error: true, message: error.message }); } })(); - // Return true to indicate we will send a response asynchronously return true; } if (request.action === "getCurrentTab") { - if (request.requestId) { - (async () => { - try { - const activeTabs = await chrome.tabs.query({ active: true, currentWindow: true }); - let targetTab = activeTabs[0]; - - if ( - !targetTab || - !targetTab.url || - !/^https?:\/\//i.test(targetTab.url) || - targetTab.url.startsWith("chrome-extension://") || - targetTab.url.startsWith("chrome://") || - targetTab.url.startsWith("edge://") || - targetTab.url.startsWith("about:") - ) { - const allTabs = await chrome.tabs.query({ currentWindow: true }); - for (const t of allTabs) { - if ( - t.url && - /^https?:\/\//i.test(t.url) && - !t.url.startsWith("chrome-extension://") && - !t.url.startsWith("chrome://") && - !t.url.startsWith("edge://") && - !t.url.startsWith("about:") - ) { - targetTab = t; - break; - } - } - } + (async () => { + const t0 = Date.now(); + try { + const activeTabs = await chrome.tabs.query({ active: true, currentWindow: true }); + let targetTab = activeTabs[0]; - let response; - if (targetTab && targetTab.url) { - const url = targetTab.url; - if (!/^https?:\/\//i.test(url)) { - response = { url, title: targetTab.title, securityData: null }; - } else { - const result = await getCachedOrScan(url); - response = { - url, - title: targetTab.title, - securityData: result || null, - }; + if ( + !targetTab || + !targetTab.url || + !/^https?:\/\//i.test(targetTab.url) || + targetTab.url.startsWith("chrome-extension://") || + targetTab.url.startsWith("chrome://") || + targetTab.url.startsWith("edge://") || + targetTab.url.startsWith("about:") + ) { + const allTabs = await chrome.tabs.query({ currentWindow: true }); + for (const t of allTabs) { + if ( + t.url && + /^https?:\/\//i.test(t.url) && + !t.url.startsWith("chrome-extension://") && + !t.url.startsWith("chrome://") && + !t.url.startsWith("edge://") && + !t.url.startsWith("about:") + ) { + targetTab = t; + break; } - } else { - response = { url: null, title: null, securityData: null }; } - - chrome.runtime - .sendMessage({ - action: "getCurrentTabResponse", - requestId: request.requestId, - data: response, - }) - .catch(() => {}); - } catch (error) { - console.error("Error in getCurrentTab handler:", error); - chrome.runtime - .sendMessage({ - action: "getCurrentTabResponse", - requestId: request.requestId, - data: { url: null, title: null, securityData: null, error: error.message }, - }) - .catch(() => {}); } - })(); - return false; - } else { - (async () => { - try { - const activeTabs = await chrome.tabs.query({ active: true, currentWindow: true }); - let targetTab = activeTabs[0]; - if ( - !targetTab || - !targetTab.url || - !/^https?:\/\//i.test(targetTab.url) || - targetTab.url.startsWith("chrome-extension://") || - targetTab.url.startsWith("chrome://") || - targetTab.url.startsWith("edge://") || - targetTab.url.startsWith("about:") - ) { - const allTabs = await chrome.tabs.query({ currentWindow: true }); - for (const t of allTabs) { - if ( - t.url && - /^https?:\/\//i.test(t.url) && - !t.url.startsWith("chrome-extension://") && - !t.url.startsWith("chrome://") && - !t.url.startsWith("edge://") && - !t.url.startsWith("about:") - ) { - targetTab = t; - break; - } - } - } - - if (targetTab && targetTab.url) { - const url = targetTab.url; - if (!/^https?:\/\//i.test(url)) { - sendResponse({ url, title: targetTab.title, securityData: null }); - return; - } + let response; + if (targetTab && targetTab.url) { + const url = targetTab.url; + if (!/^https?:\/\//i.test(url)) { + response = { url, title: targetTab.title, securityData: null }; + } else { const result = await getCachedOrScan(url); - sendResponse({ + response = { url, title: targetTab.title, securityData: result || null, - }); - } else { - sendResponse({ url: null, title: null, securityData: null }); + }; } - } catch (error) { - console.error("Error in getCurrentTab handler:", error); - sendResponse({ url: null, title: null, securityData: null, error: error.message }); + } else { + response = { url: null, title: null, securityData: null }; } - })(); - return true; - } + + console.log("[NetSTAR][timing] getCurrentTab: done", Date.now() - t0, "ms"); + sendResponse(response); + } catch (error) { + console.error("[NetSTAR] getCurrentTab error:", Date.now() - t0, "ms", error); + sendResponse({ url: null, title: null, securityData: null, error: error.message }); + } + })(); + + return true; } return false; diff --git a/Extension/src/background/scan.js b/Extension/src/background/scan.js index 1eab987..fe2cd5f 100644 --- a/Extension/src/background/scan.js +++ b/Extension/src/background/scan.js @@ -1,27 +1,54 @@ -import { CACHE_DURATION_MS, SCAN_API_BASE } from "./constants.js"; +import { CACHE_DURATION_MS, SCAN_API_BASE, SCAN_FETCH_TIMEOUT_MS } from "./constants.js"; import { normalizeScanDomain } from "./urlNormalize.js"; +/** In-flight scan promises by cache key so concurrent callers for the same domain share one request. */ +const inFlightByKey = new Map(); + /** * Cache and scan entry points */ export async function getCachedOrScan(url) { + const t0 = Date.now(); const domain = normalizeScanDomain(url); const cacheKey = `scan_${encodeURIComponent(domain || url)}`; const data = await chrome.storage.local.get(cacheKey); - const now = Date.now(); + console.log("[NetSTAR][timing] getCachedOrScan: after storage.get", Date.now() - t0, "ms"); + const now = Date.now(); if (data[cacheKey]) { const cached = data[cacheKey]; if (now - cached.timestamp < CACHE_DURATION_MS) { + console.log("[NetSTAR][timing] getCachedOrScan: cache HIT, returning", Date.now() - t0, "ms"); return cached; } else { await chrome.storage.local.remove(cacheKey); + console.log("[NetSTAR][timing] getCachedOrScan: cache expired, removed", Date.now() - t0, "ms"); } + } else { + console.log("[NetSTAR][timing] getCachedOrScan: cache MISS", Date.now() - t0, "ms"); + } + + // Reuse in-flight request for the same domain so we don't double-scan (e.g. React Strict Mode / double mount). + if (inFlightByKey.has(cacheKey)) { + console.log("[NetSTAR][timing] getCachedOrScan: reusing in-flight request for", cacheKey); + return inFlightByKey.get(cacheKey); } - const result = await performSecurityScan(url); - await chrome.storage.local.set({ [cacheKey]: result }); - return result; + const promise = (async () => { + try { + const beforeScan = Date.now(); + const result = await performSecurityScan(url); + console.log("[NetSTAR][timing] getCachedOrScan: after performSecurityScan", Date.now() - beforeScan, "ms (total", Date.now() - t0, "ms)"); + + await chrome.storage.local.set({ [cacheKey]: result }); + console.log("[NetSTAR][timing] getCachedOrScan: after storage.set, done", Date.now() - t0, "ms"); + return result; + } finally { + inFlightByKey.delete(cacheKey); + } + })(); + inFlightByKey.set(cacheKey, promise); + return promise; } /** @@ -36,11 +63,16 @@ export async function performSecurityScan(url) { ? `${SCAN_API_BASE}/scan?domain=${encodeURIComponent(domain)}` : `${SCAN_API_BASE}/scan?url=${encodeURIComponent(url)}`; const startedAt = Date.now(); - console.log("[NetSTAR][scan] Requesting:", endpoint); + console.log("[NetSTAR][timing] performSecurityScan: fetch start", endpoint); + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), SCAN_FETCH_TIMEOUT_MS); try { - const response = await fetch(endpoint); + const response = await fetch(endpoint, { signal: controller.signal }); + clearTimeout(timeoutId); const elapsedMs = Date.now() - startedAt; + console.log("[NetSTAR][timing] performSecurityScan: fetch done", elapsedMs, "ms"); console.log("[NetSTAR][scan] Response:", { endpoint, @@ -66,7 +98,12 @@ export async function performSecurityScan(url) { timestamp: Date.now(), }; } catch (error) { + clearTimeout(timeoutId); const elapsedMs = Date.now() - startedAt; + if (error.name === "AbortError") { + console.error("[NetSTAR][scan] Timed out:", { endpoint, elapsedMs }); + throw new Error(`Scan request timed out after ${SCAN_FETCH_TIMEOUT_MS}ms`); + } console.error("[NetSTAR][scan] Failed:", { endpoint, elapsedMs, error }); throw error; } diff --git a/Extension/src/components/tabs/HomeTab.jsx b/Extension/src/components/tabs/HomeTab.jsx index 771c3af..fff4044 100644 --- a/Extension/src/components/tabs/HomeTab.jsx +++ b/Extension/src/components/tabs/HomeTab.jsx @@ -64,15 +64,26 @@ const INDICATORS_OPEN_KEY = "indicatorsOpen"; * /> * ``` */ +function hostnameFromUrl(urlString) { + try { + const u = new URL(urlString.includes("://") ? urlString : `https://${urlString}`); + return u.hostname; + } catch { + return "this site"; + } +} + export function HomeTab({ mode, onNavigate, forceShowIndicators, overrideUrl, overrideSecurityData }) { - /** Current website URL hostname */ - const [currentUrl, setCurrentUrl] = useState(""); - - /** Security safety score (0-100), default is 87 */ - const [safetyScore, setSafetyScore] = useState(87); + /** Current website URL hostname. Initialize from override when landing after a manual scan. */ + const [currentUrl, setCurrentUrl] = useState(() => (overrideUrl ? hostnameFromUrl(overrideUrl) : "")); + + /** Security safety score (0-100). Initialize from override when landing after a manual scan so the score shows immediately. */ + const [safetyScore, setSafetyScore] = useState(() => + overrideSecurityData?.safetyScore !== undefined ? overrideSecurityData.safetyScore : 87 + ); - /** Complete security scan data including indicators and metadata, or null if not loaded */ - const [securityData, setSecurityData] = useState(null); + /** Complete security scan data including indicators and metadata. Initialize from override so we don't show loading after a manual scan. */ + const [securityData, setSecurityData] = useState(() => overrideSecurityData ?? null); /** * State for whether the "What We Checked" indicators section is expanded @@ -123,21 +134,11 @@ export function HomeTab({ mode, onNavigate, forceShowIndicators, overrideUrl, ov useEffect(() => { let isMounted = true; - // Helper: display a URL string as hostname for the UI. - const setHostnameFromUrl = (urlString) => { - try { - const u = new URL(urlString.includes("://") ? urlString : `https://${urlString}`); - setCurrentUrl(u.hostname); - } catch { - setCurrentUrl("this site"); - } - }; - const run = async () => { // If the popup is showing a manual scan target, prefer that over "current tab". if (overrideUrl) { - setHostnameFromUrl(overrideUrl); + setCurrentUrl(hostnameFromUrl(overrideUrl)); if (overrideSecurityData?.safetyScore !== undefined) { setSafetyScore(overrideSecurityData.safetyScore); @@ -169,87 +170,30 @@ export function HomeTab({ mode, onNavigate, forceShowIndicators, overrideUrl, ov // Default behavior: Get current tab URL and security data. if (typeof chrome !== "undefined" && chrome.runtime) { try { - const response = await new Promise((resolve, reject) => { - let resolved = false; - const requestId = `getCurrentTab_${Date.now()}_${Math.random()}`; - - // Set up a one-time message listener for the response - const messageListener = (message) => { - if (message.action === "getCurrentTabResponse" && message.requestId === requestId) { - chrome.runtime.onMessage.removeListener(messageListener); - if (!resolved) { - resolved = true; - resolve(message.data); - } - return true; - } - }; - - chrome.runtime.onMessage.addListener(messageListener); - - // Send the request - chrome.runtime.sendMessage( - { - action: "getCurrentTab", - requestId: requestId, - }, - (response) => { - const callbackError = chrome.runtime.lastError; - - // If we got a response synchronously, use it - if (response && typeof response === "object" && response.url !== undefined) { - chrome.runtime.onMessage.removeListener(messageListener); - if (!resolved) { - resolved = true; - resolve(response); - } - return; - } - - // Check for port closed error - expected in Manifest V3 with async handlers - if (callbackError) { - const errorMsg = callbackError.message || String(callbackError); - if ( - errorMsg.includes("message port closed") || - errorMsg.includes("The message port closed before a response was received") - ) { - // Wait for message listener to receive the response - return; - } - - // Other fatal errors - if ( - errorMsg.includes("Receiving end does not exist") || - errorMsg.includes("Could not establish connection") || - errorMsg.includes("Extension context invalidated") - ) { - chrome.runtime.onMessage.removeListener(messageListener); - if (!resolved) { - resolved = true; - reject(callbackError); - } - return; - } - } - } - ); - - // Timeout fallback - setTimeout(() => { - if (!resolved) { - chrome.runtime.onMessage.removeListener(messageListener); - resolved = true; + const t0 = Date.now(); + const TIMEOUT_MS = 12_000; + const response = await new Promise((resolve) => { + const timer = setTimeout(() => resolve(null), TIMEOUT_MS); + + chrome.runtime.sendMessage({ action: "getCurrentTab" }, (resp) => { + clearTimeout(timer); + if (chrome.runtime.lastError) { + console.error("[NetSTAR] getCurrentTab error:", chrome.runtime.lastError.message); resolve(null); + return; } - }, 3000); + resolve(resp ?? null); + }); }); if (!isMounted || !response) return; if (response.url) { - setHostnameFromUrl(response.url); + const elapsedMs = Date.now() - t0; + console.log("[NetSTAR][timing] popup: getCurrentTab end-to-end", elapsedMs, "ms"); + + setCurrentUrl(hostnameFromUrl(response.url)); - // Update safety score from background script if available if (response.securityData?.safetyScore !== undefined) { setSafetyScore(response.securityData.safetyScore); setSecurityData(response.securityData);