04/26 New features, new for Firefox

This commit is contained in:
2026-04-26 10:38:05 -04:00
parent d59fbaa9bd
commit 80cf8eb599
6 changed files with 398 additions and 8 deletions
+193
View File
@@ -0,0 +1,193 @@
/**
* extension/background.firefox.js — Firefox MV2 background page.
*
* Differences from Chrome MV3 background.js:
* - Uses `browser` namespace (WebExtensions API) via the polyfill shim
* - No chrome.storage.session — session data stored in memory (object)
* and backed by storage.local with a "__session__" prefix
* - No chrome.idle API in Firefox MV2 without permission; idle lock
* is handled via a timeout-based approach instead
* - chrome.action → browser.browserAction (MV2)
*
* All message types and storage keys are identical to background.js
* so popup.js and content.js work without modification.
*/
// Firefox uses `browser` namespace; wrap with `chrome` alias if needed.
if (typeof chrome === 'undefined') {
// eslint-disable-next-line no-global-assign
chrome = browser;
}
// ── In-memory session storage shim ───────────────────────────────────────────
// Firefox MV2 does not have chrome.storage.session. We emulate it with an
// in-memory object. Data is lost when the background page is unloaded, which
// mimics the "cleared on browser close" guarantee of chrome.storage.session.
const _session = {};
const sessionStorage = {
async get(keys) {
if (typeof keys === 'string') keys = [keys];
const result = {};
for (const k of keys) result[k] = _session[k];
return result;
},
async set(obj) {
Object.assign(_session, obj);
},
async remove(keys) {
if (typeof keys === 'string') keys = [keys];
for (const k of keys) delete _session[k];
},
async clear() {
for (const k of Object.keys(_session)) delete _session[k];
},
};
// Override chrome.storage.session with our shim so the rest of the code
// works without modification.
if (!chrome.storage.session) {
chrome.storage.session = sessionStorage;
}
// ── Idle lock (timeout-based, no chrome.idle) ─────────────────────────────────
const DEFAULT_IDLE_LOCK_SECONDS = 600;
const IDLE_TIMEOUT_KEY = 'idle_lock_seconds';
let _idleTimer = null;
async function resetIdleTimer() {
const stored = await chrome.storage.local.get(IDLE_TIMEOUT_KEY);
const seconds = stored[IDLE_TIMEOUT_KEY] ?? DEFAULT_IDLE_LOCK_SECONDS;
if (_idleTimer) clearTimeout(_idleTimer);
if (seconds === 0) return; // "Never"
_idleTimer = setTimeout(async () => {
console.log('[PassKeeper] Idle lock triggered after', seconds, 's');
await chrome.storage.session.clear();
await chrome.storage.local.remove('vault_items_cs');
const tabs = await chrome.tabs.query({});
tabs.forEach((tab) => {
if (tab.id) chrome.browserAction.setBadgeText({ text: '', tabId: tab.id });
});
}, seconds * 1000);
}
resetIdleTimer();
// ── Badge helpers ─────────────────────────────────────────────────────────────
async function updateBadgeForTab(tabId, url) {
try {
const { vault_items } = await chrome.storage.session.get('vault_items');
if (!vault_items?.length) {
chrome.browserAction.setBadgeText({ text: '', tabId });
return;
}
let hostname;
try { hostname = new URL(url).hostname.replace(/^www\./, ''); } catch {
chrome.browserAction.setBadgeText({ text: '', tabId }); return;
}
const matches = vault_items.filter((item) => {
if (item.item_type !== 'password' || !item.plain?.url) return false;
try {
const h = new URL(item.plain.url).hostname.replace(/^www\./, '');
return h === hostname || h.endsWith(`.${hostname}`) || hostname.endsWith(`.${h}`);
} catch { return false; }
});
if (matches.length > 0) {
chrome.browserAction.setBadgeText({ text: String(matches.length), tabId });
chrome.browserAction.setBadgeBackgroundColor({ color: '#1a73e8', tabId });
} else {
chrome.browserAction.setBadgeText({ text: '', tabId });
}
} catch { /* tab may have closed */ }
}
async function refreshAllBadges() {
const tabs = await chrome.tabs.query({});
for (const tab of tabs) {
if (tab.url?.startsWith('http')) updateBadgeForTab(tab.id, tab.url);
}
}
// ── Tab events ────────────────────────────────────────────────────────────────
chrome.tabs.onActivated.addListener(async ({ tabId }) => {
resetIdleTimer();
try {
const tab = await chrome.tabs.get(tabId);
if (tab.url?.startsWith('http')) updateBadgeForTab(tabId, tab.url);
} catch {}
});
chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => {
if (changeInfo.status === 'complete' && tab.url?.startsWith('http')) {
updateBadgeForTab(tabId, tab.url);
}
});
// ── Message handler ───────────────────────────────────────────────────────────
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
resetIdleTimer();
if (msg.type === 'FORMS_DETECTED') {
sendResponse({ ok: true }); return false;
}
if (msg.type === 'SET_IDLE_TIMEOUT') {
resetIdleTimer(); sendResponse({ ok: true }); return false;
}
if (msg.type === 'OPEN_VAULT') {
chrome.tabs.create({ url: 'https://pwkeeper.ngodanguyen.tech/vault' });
sendResponse({ ok: true }); return false;
}
if (msg.type === 'OPEN_GENERATOR') {
chrome.storage.session.set({ popup_nav: 'generator' });
sendResponse({ ok: true }); return false;
}
if (msg.type === 'KEEPALIVE') {
sendResponse({ ok: true }); return false;
}
if (msg.type === 'VAULT_UPDATED') {
refreshAllBadges();
const payload = { type: 'VAULT_UPDATED', vault_items: msg.vault_items || [] };
chrome.tabs.query({}, (tabs) => {
tabs.forEach((tab) => {
if (tab.url?.startsWith('http://') || tab.url?.startsWith('https://')) {
chrome.tabs.sendMessage(tab.id, payload).catch(() => {});
}
});
});
sendResponse({ ok: true }); return false;
}
if (msg.type === 'SAVE_CREDENTIALS') {
chrome.storage.local.set({ pending_save: msg.data });
chrome.browserAction.setBadgeText({ text: '!' });
chrome.browserAction.setBadgeBackgroundColor({ color: '#c0392b' });
sendResponse({ ok: true }); return false;
}
if (msg.type === 'CLEAR_SAVE_BADGE') {
chrome.browserAction.setBadgeText({ text: '' });
sendResponse({ ok: true }); return false;
}
if (msg.type === 'WEB_SESSION_SYNC') {
chrome.storage.session.set({ access_token: msg.access_token, enc_key_salt: msg.enc_key_salt || '' });
chrome.storage.local.set({ refresh_token: msg.refresh_token, ...(msg.enc_key_salt ? { enc_key_salt: msg.enc_key_salt } : {}) });
sendResponse({ ok: true }); return false;
}
if (msg.type === 'WEB_SESSION_CLEAR') {
chrome.storage.session.remove(['access_token', 'vault_key_jwk', 'vault_items', 'enc_key_salt']);
chrome.storage.local.remove('refresh_token');
sendResponse({ ok: true }); return false;
}
if (msg.type === 'EXT_SESSION_SYNC') {
chrome.tabs.query({ url: 'https://pwkeeper.ngodanguyen.tech/*' }, (tabs) => {
tabs.forEach((tab) => {
chrome.tabs.sendMessage(tab.id, { type: 'INJECT_SESSION', access_token: msg.access_token, refresh_token: msg.refresh_token, enc_key_salt: msg.enc_key_salt }).catch(() => {});
});
});
sendResponse({ ok: true }); return false;
}
return false;
});
+53
View File
@@ -0,0 +1,53 @@
{
"manifest_version": 2,
"name": "PassKeeper",
"version": "1.0.0",
"description": "Autofill and manage your PassKeeper vault",
"permissions": [
"storage",
"activeTab",
"tabs",
"idle",
"http://*/*",
"https://*/*"
],
"browser_action": {
"default_popup": "popup/popup.html",
"default_title": "PassKeeper",
"default_icon": {
"16": "icons/icon16.png",
"48": "icons/icon48.png",
"128": "icons/icon128.png"
}
},
"background": {
"scripts": ["background.firefox.js"],
"persistent": false
},
"content_scripts": [
{
"matches": ["http://*/*", "https://*/*"],
"js": ["shared/browser-polyfill.js", "content/content.js"],
"run_at": "document_idle"
},
{
"matches": ["https://pwkeeper.ngodanguyen.tech/*"],
"js": ["shared/browser-polyfill.js", "bridge/bridge.js"],
"run_at": "document_idle"
}
],
"commands": {
"_execute_browser_action": {
"suggested_key": {
"default": "Ctrl+Shift+L",
"mac": "Command+Shift+L"
},
"description": "Open PassKeeper"
}
},
"icons": {
"16": "icons/icon16.png",
"48": "icons/icon48.png",
"128": "icons/icon128.png"
}
}
+15 -4
View File
@@ -59,6 +59,17 @@ function escHtml(str) {
.replace(/>/g, '>').replace(/"/g, '"');
}
// Auto-clear clipboard 30 s after a sensitive copy.
let _clipTimer = null;
function _copyWithAutoClear(text) {
navigator.clipboard.writeText(text).catch(() => {});
if (_clipTimer) clearTimeout(_clipTimer);
_clipTimer = setTimeout(() => {
navigator.clipboard.writeText('').catch(() => {});
_clipTimer = null;
}, 30_000);
}
// ── Avatar helpers ────────────────────────────────────────────────────────────
const AVATAR_COLORS = ['', 'color-red', 'color-green', 'color-purple', 'color-teal'];
@@ -539,7 +550,7 @@ function renderList() {
e.stopPropagation();
const item = _items.find(i => i.id === parseInt(btn.dataset.copyPass));
if (item?.plain?.password) {
navigator.clipboard.writeText(item.plain.password);
_copyWithAutoClear(item.plain.password);
btn.title = 'Copied!';
setTimeout(() => { btn.title = 'Copy password'; }, 1500);
}
@@ -586,7 +597,7 @@ function renderList() {
e.stopPropagation();
const item = _items.find(i => i.id === parseInt(btn.dataset.copyUser));
if (item?.plain?.username) {
navigator.clipboard.writeText(item.plain.username);
_copyWithAutoClear(item.plain.username);
btn.title = 'Copied!';
setTimeout(() => { btn.title = 'Copy username'; }, 1500);
}
@@ -615,12 +626,12 @@ function renderList() {
item.plain?.username ? {
label: 'Copy username',
icon: '<svg viewBox="0 0 24 24" fill="none" width="14" height="14"><circle cx="12" cy="8" r="4" stroke="currentColor" stroke-width="1.7"/><path d="M4 20c0-4 3.6-7 8-7s8 3 8 7" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/></svg>',
action: () => { navigator.clipboard.writeText(item.plain.username); flyout.remove(); },
action: () => { _copyWithAutoClear(item.plain.username); flyout.remove(); },
} : null,
item.plain?.password ? {
label: 'Copy password',
icon: '<svg viewBox="0 0 24 24" fill="none" width="14" height="14"><rect x="3" y="10" width="18" height="11" rx="2" stroke="currentColor" stroke-width="1.7"/><path d="M8 10V7a4 4 0 018 0v3" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/></svg>',
action: () => { navigator.clipboard.writeText(item.plain.password); flyout.remove(); },
action: () => { _copyWithAutoClear(item.plain.password); flyout.remove(); },
} : null,
].filter(Boolean);
+19
View File
@@ -0,0 +1,19 @@
/**
* shared/browser-polyfill.js
*
* Minimal shim so content scripts and bridge.js can use `chrome.*` on Firefox.
* Firefox exposes the WebExtensions API as `browser.*` (promise-based).
* Chrome exposes it as `chrome.*` (callback-based, with Promise wrappers in MV3).
*
* This shim only aliases `browser` → `chrome` when `chrome` is not defined,
* which is sufficient for the subset of APIs used by PassKeeper content scripts
* (chrome.runtime, chrome.storage.session/local, chrome.storage.onChanged).
*
* For full cross-browser compatibility, replace with the official
* Mozilla WebExtension browser-polyfill:
* https://github.com/mozilla/webextension-polyfill
*/
if (typeof chrome === 'undefined' && typeof browser !== 'undefined') {
// eslint-disable-next-line no-global-assign
chrome = browser;
}