import { listSecrets, revealSecret, createSecret, getMe, getCategories } from "./api.js";
import "./pack-shared.js";
const CACHE_TTL_MS = 5 * 60 * 1000;
async function getSettings() {
return chrome.storage.local.get({ token: "", baseUrl: "https://creds.gnexus.space" });
}
async function setSettings(settings) {
return chrome.storage.local.set(settings);
}
async function getSecretsCached(force = false) {
const now = Date.now();
if (!force) {
try {
const cached = await chrome.storage.session.get(["secretsCache", "cacheTimestamp"]);
if (cached.secretsCache && cached.cacheTimestamp && now - cached.cacheTimestamp < CACHE_TTL_MS) {
return cached.secretsCache;
}
} catch {
// session storage may be unavailable in some contexts
}
}
const { token, baseUrl } = await getSettings();
if (!token) throw new Error("No API token configured");
const data = await listSecrets(token, baseUrl);
try {
await chrome.storage.session.set({ secretsCache: data, cacheTimestamp: now });
} catch {
// ignore session storage errors
}
return data;
}
async function invalidateCache() {
try {
await chrome.storage.session.remove(["secretsCache", "cacheTimestamp"]);
} catch {
// ignore
}
}
function secretsForDomain(secrets, domain) {
if (!secrets?.items) return [];
return secrets.items.filter((s) => {
const src = (s.source || "").toLowerCase();
const title = (s.title || "").toLowerCase();
const d = domain.toLowerCase();
return src.includes(d) || title.includes(d);
});
}
chrome.alarms.create("refresh-secrets-cache", { periodInMinutes: 15 });
// --- Design-resource pack sync ----------------------------------------------
// The pack (CSS + data tables) is fetched by the background worker only —
// unauthenticated (design data is not secret, and content scripts hold no
// token) — validated against pack.json sha256es and stored once in
// chrome.storage.local. Content script/popup only READ stored pack state;
// a page load never blocks on the network here.
const PACK_REFRESH_ALARM = "refresh-design-pack";
chrome.alarms.create(PACK_REFRESH_ALARM, { periodInMinutes: 720 });
const PACK_KNOWN_FILES = ["content.css", "popup.css", "tables.json", "config.json"];
const PACK_FILE_LIMIT = 262144; // per-file cap echoed by pack-shared.js
const OWN_VERSION = chrome.runtime.getManifest().version;
async function setPackStatus(fields) {
try {
await chrome.storage.local.set({
designPackStatus: { ...fields, lastAttemptAt: Date.now() },
});
} catch {
// ignore
}
}
function isPackSchemaValid(pack) {
if (!pack || typeof pack !== "object") return false;
if (!Number.isInteger(pack.packVersion) || pack.packVersion < 1) return false;
if (typeof pack.minExtensionVersion !== "string") return false;
if (pack.maxExtensionVersion != null && typeof pack.maxExtensionVersion !== "string") return false;
if (!pack.files || typeof pack.files !== "object") return false;
for (const [name, meta] of Object.entries(pack.files)) {
if (!PACK_KNOWN_FILES.includes(name)) return false;
if (!meta || typeof meta.sha256 !== "string" || !/^[0-9a-f]{64}$/.test(meta.sha256)) return false;
if (!Number.isInteger(meta.size) || meta.size < 1 || meta.size > PACK_FILE_LIMIT) return false;
}
return Object.keys(pack.files).length > 0;
}
async function refreshDesignPack(reason) {
const { baseUrl } = await getSettings();
const base = String(baseUrl || "").replace(/\/+$/, "");
if (!base) return;
try {
const response = await fetch(`${base}/api/v1/extension/pack`, { cache: "no-store" });
if (!response.ok) throw new Error(`pack.json ${response.status}`);
const pack = await response.json();
if (!isPackSchemaValid(pack)) throw new Error("bad_pack_schema");
const stored = (await chrome.storage.local.get("designPack")).designPack;
if (stored && pack.packVersion < stored.packVersion) {
await setPackStatus({ lastError: "", lastReason: "stale_pack_version" }); // rollback ignored
return;
}
// A pack declaring markup newer than this build's markup stays unadopted.
if (!self.GnPack.isCompatible(pack.minExtensionVersion, OWN_VERSION)) {
await setPackStatus({ lastError: "", lastReason: "extension_too_old" });
return;
}
const texts = {};
for (const name of Object.keys(pack.files)) {
const fileResponse = await fetch(
`${base}/api/v1/extension/pack/file/${pack.packVersion}/${name}`,
{ cache: "no-store" }
);
if (!fileResponse.ok) throw new Error(`${name} ${fileResponse.status}`);
const text = await fileResponse.text();
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text));
const hex = [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, "0")).join("");
if (hex !== pack.files[name].sha256) throw new Error(`sha_mismatch:${name}`);
texts[name] = text;
}
await chrome.storage.local.set({
designPack: {
packVersion: pack.packVersion,
minExtensionVersion: pack.minExtensionVersion,
maxExtensionVersion: pack.maxExtensionVersion ?? null,
publishedAt: pack.publishedAt ?? null,
adoptedAt: Date.now(),
files: pack.files,
texts,
},
designPackStatus: { lastAttemptAt: Date.now(), lastError: "", lastReason: reason },
});
} catch (err) {
await setPackStatus({ lastError: String(err.message || err), lastReason: reason });
}
}
// These top-level registrations survive the MV3 service worker sleeping.
chrome.runtime.onInstalled.addListener((details) => {
refreshDesignPack(details.reason === "update" ? "update" : "install");
});
chrome.runtime.onStartup.addListener(() => {
refreshDesignPack("startup");
});
chrome.alarms.onAlarm.addListener(async (alarm) => {
if (alarm.name !== "refresh-secrets-cache") return;
try {
await getSecretsCached(true);
} catch {
// ignore — no token or network error
}
});
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
(async () => {
try {
const { token, baseUrl } = await getSettings();
switch (message.type) {
case "GET_SETTINGS":
sendResponse({ ok: true, data: await getSettings() });
break;
case "SAVE_SETTINGS":
await setSettings(message.payload);
invalidateCache();
sendResponse({ ok: true });
break;
case "VERIFY_TOKEN":
{
const me = await getMe(message.payload.token, message.payload.baseUrl);
sendResponse({ ok: true, data: me });
}
break;
case "LIST_SECRETS":
{
const data = await getSecretsCached(message.payload?.force);
sendResponse({ ok: true, data });
}
break;
case "SEARCH_SECRETS":
{
const data = await listSecrets(token, baseUrl, { q: message.payload.q });
sendResponse({ ok: true, data });
}
break;
case "REVEAL_SECRET":
{
const data = await revealSecret(token, baseUrl, message.payload.id);
sendResponse({ ok: true, data });
}
break;
case "CREATE_SECRET":
{
const data = await createSecret(token, baseUrl, message.payload);
invalidateCache();
try {
await chrome.storage.session.remove("pendingSave");
await chrome.action.setBadgeText({ text: "" });
} catch {
// ignore
}
sendResponse({ ok: true, data });
}
break;
case "GET_SECRETS_FOR_DOMAIN":
{
const all = await getSecretsCached();
const matched = secretsForDomain(all, message.payload.domain);
sendResponse({ ok: true, data: matched });
}
break;
case "GET_CATEGORIES":
{
const data = await getCategories(token, baseUrl);
sendResponse({ ok: true, data });
}
break;
case "PENDING_SAVE":
{
const pending = {
...message.payload,
timestamp: Date.now(),
};
await chrome.storage.session.set({ pendingSave: pending });
try {
await chrome.action.setBadgeText({ text: "1" });
await chrome.action.setBadgeBackgroundColor({ color: "#238636" });
} catch {
// ignore
}
sendResponse({ ok: true });
}
break;
case "GET_PENDING_SAVE":
{
const stored = await chrome.storage.session.get("pendingSave");
sendResponse({ ok: true, data: stored.pendingSave || null });
}
break;
case "CLEAR_PENDING_SAVE":
{
await chrome.storage.session.remove("pendingSave");
try {
await chrome.action.setBadgeText({ text: "" });
} catch {
// ignore
}
sendResponse({ ok: true });
}
break;
case "REFRESH_DESIGN_PACK":
{
await refreshDesignPack("manual");
const { designPack } = await chrome.storage.local.get("designPack");
sendResponse({ ok: true, data: { packVersion: designPack ? designPack.packVersion : null } });
}
break;
case "PACK_STATUS":
{
const { designPack, designPackStatus } = await chrome.storage.local.get(["designPack", "designPackStatus"]);
sendResponse({
ok: true,
data: {
packVersion: designPack ? designPack.packVersion : null,
publishedAt: designPack ? designPack.publishedAt : null,
adoptedAt: designPack ? designPack.adoptedAt : null,
status: designPackStatus || null,
},
});
}
break;
default:
sendResponse({ ok: false, error: "Unknown message type" });
}
} catch (err) {
sendResponse({ ok: false, error: err.message });
}
})();
return true;
});