Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,4 @@ dist/*.zip
.vscode
CLAUDE.local.md
CLAUDE.md
.codegraph/
111 changes: 111 additions & 0 deletions extension/background-notify.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
'use strict';

const test = require('node:test');
const assert = require('node:assert/strict');
const path = require('node:path');

const BACKGROUND_PATH = path.join(__dirname, 'background.js');

// background.js registers listeners at top level and caches its module, so we
// flush the cache and re-evaluate it for each scenario.
function loadBackground(chrome) {
global.chrome = chrome;
delete require.cache[require.resolve(BACKGROUND_PATH)];
require(BACKGROUND_PATH);
}

function buildChrome() {
const captured = {};
const state = {
sendMessageCalls: [],
warnCalls: [],
};

const chrome = {
runtime: {
id: 'test-ext-id',
getURL: (p) => `chrome-extension://test-ext-id/${p}`,
onInstalled: { addListener: () => {} },
onStartup: { addListener: () => {} },
sendMessage: async () => {},
},
action: {
setBadgeText: async () => {},
},
tabs: {
query: async () => [],
remove: async () => {},
onCreated: { addListener: (cb) => { captured.onCreated = cb; } },
onRemoved: { addListener: (cb) => { captured.onRemoved = cb; } },
onUpdated: { addListener: (cb) => { captured.onUpdated = cb; } },
},
storage: {
local: { get: async () => ({}) },
},
};

return { chrome, captured, state };
}

test('tab events broadcast via chrome.runtime.sendMessage, not content-script channel', async () => {
const { chrome, captured, state } = buildChrome();
chrome.runtime.sendMessage = async (msg) => {
state.sendMessageCalls.push(msg);
};
loadBackground(chrome);
assert.ok(captured.onCreated, 'background should register a tabs.onCreated listener');

await captured.onCreated({ id: 99 });

assert.strictEqual(state.sendMessageCalls.length, 1);
assert.deepStrictEqual(state.sendMessageCalls[0], {
action: 'tabs-changed',
source: 'tabs.onCreated',
triggerTabId: 99,
});

await captured.onRemoved(42);
const last = state.sendMessageCalls[state.sendMessageCalls.length - 1];
assert.deepStrictEqual(last, {
action: 'tabs-changed',
source: 'tabs.onRemoved',
triggerTabId: 42,
});
});

test('"Receiving end does not exist" rejection is swallowed silently', async () => {
const { chrome, captured, state } = buildChrome();
chrome.runtime.sendMessage = async () => {
throw new Error('Could not establish connection. Receiving end does not exist.');
};

const originalWarn = console.warn;
console.warn = (...args) => { state.warnCalls.push(args); };
try {
loadBackground(chrome);
await captured.onCreated({ id: 7 });
} finally {
console.warn = originalWarn;
}

assert.strictEqual(state.warnCalls.length, 0);
});

test('non-connection errors from sendMessage are surfaced via console.warn', async () => {
const { chrome, captured, state } = buildChrome();
chrome.runtime.sendMessage = async () => {
throw new Error('unexpected broadcast failure');
};

const originalWarn = console.warn;
console.warn = (...args) => { state.warnCalls.push(args); };
try {
loadBackground(chrome);
await captured.onCreated({ id: 7 });
} finally {
console.warn = originalWarn;
}

assert.strictEqual(state.warnCalls.length, 1);
assert.match(state.warnCalls[0].join(' '), /unexpected broadcast failure/);
});
111 changes: 47 additions & 64 deletions extension/background.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,114 +6,97 @@
*/

const TAB_HARBOR_BG_DEBUG = false;
if (TAB_HARBOR_BG_DEBUG) console.log('[tab-harbor bg] Service worker loaded, registering event listeners...');
if (TAB_HARBOR_BG_DEBUG)
console.log(
"[tab-harbor bg] Service worker loaded, registering event listeners...",
);

// ─── Auto-close duplicate new tabs ───────────────────────────────────────────

function getNewTabUrls() {
return new Set([
chrome.runtime.getURL('index.html'),
chrome.runtime.getURL('extension/index.html'),
chrome.runtime.getURL("index.html"),
chrome.runtime.getURL("extension/index.html"),
]);
}

function isNewTabBlank(tab, newTabUrls) {
const knownNewTabUrls = newTabUrls instanceof Set
? newTabUrls
: new Set(Array.isArray(newTabUrls) ? newTabUrls : [newTabUrls]);
const url = tab?.url || '';
const pendingUrl = tab?.pendingUrl || '';
if (pendingUrl && !knownNewTabUrls.has(pendingUrl) && pendingUrl !== 'chrome://newtab/') {
const knownNewTabUrls =
newTabUrls instanceof Set
? newTabUrls
: new Set(Array.isArray(newTabUrls) ? newTabUrls : [newTabUrls]);
const url = tab?.url || "";
const pendingUrl = tab?.pendingUrl || "";
if (
pendingUrl &&
!knownNewTabUrls.has(pendingUrl) &&
pendingUrl !== "chrome://newtab/"
) {
return false;
}
return (
url === 'chrome://newtab/' ||
url === "chrome://newtab/" ||
knownNewTabUrls.has(url) ||
pendingUrl === 'chrome://newtab/' ||
pendingUrl === "chrome://newtab/" ||
knownNewTabUrls.has(pendingUrl) ||
url === '' ||
(tab.status === 'loading' && !url)
url === "" ||
(tab.status === "loading" && !url)
);
}

async function closeDuplicateNewTabs() {
try {
const stored = await chrome.storage.local.get('themePreferences');
const stored = await chrome.storage.local.get("themePreferences");
const prefs = stored.themePreferences || {};
if (prefs.closeDuplicateNewTabsEnabled !== true) return;

const newTabUrls = getNewTabUrls();
const allTabs = await chrome.tabs.query({});
const blankTabs = allTabs.filter(tab => isNewTabBlank(tab, newTabUrls));
const blankTabs = allTabs.filter((tab) => isNewTabBlank(tab, newTabUrls));

if (blankTabs.length <= 1) return;

// Keep the active tab; if none is active, keep the one with the largest id (newest)
const activeTab = blankTabs.find(tab => tab.active);
const toKeep = activeTab || blankTabs.reduce((a, b) => (a.id > b.id ? a : b));
const toClose = blankTabs.filter(tab => tab.id !== toKeep.id).map(tab => tab.id);
const activeTab = blankTabs.find((tab) => tab.active);
const toKeep =
activeTab || blankTabs.reduce((a, b) => (a.id > b.id ? a : b));
const toClose = blankTabs
.filter((tab) => tab.id !== toKeep.id)
.map((tab) => tab.id);

if (toClose.length > 0) await chrome.tabs.remove(toClose);
} catch (err) {
console.warn('[tab-harbor bg] closeDuplicateNewTabs error:', err.message);
console.warn("[tab-harbor bg] closeDuplicateNewTabs error:", err.message);
}
}

async function updateBadge() {
try {
await chrome.action.setBadgeText({ text: '' });
await chrome.action.setBadgeText({ text: "" });
} catch {
chrome.action.setBadgeText({ text: '' });
chrome.action.setBadgeText({ text: "" });
}
}

function getTabHarborDashboardUrls() {
const extensionId = chrome.runtime.id;
return new Set([
`chrome-extension://${extensionId}/index.html`,
`chrome-extension://${extensionId}/extension/index.html`,
]);
}

// ─── Event listeners ──────────────────────────────────────────────────────────

// Notify Tab Harbor pages when tabs change so they can refresh
async function notifyTabHarborPages(eventMeta = {}) {
try {
// Find all Tab Harbor dashboard pages
const dashboardUrls = getTabHarborDashboardUrls();
const message = {
action: "tabs-changed",
source: eventMeta.source || "tabs.changed",
triggerTabId: eventMeta.triggerTabId ?? null,
};

// Query all tabs and filter manually for more reliable matching
const allTabs = await chrome.tabs.query({});

const dashboardTabs = allTabs.filter(tab => {
if (!tab.url) return false;
// Tab Harbor can appear as either:
// 1. chrome-extension://EXTENSION_ID/index.html (direct access)
// 2. chrome-extension://EXTENSION_ID/extension/index.html (root manifest entry)
// 3. chrome://newtab/ with title "Tab Harbor" (new tab override)
return (
dashboardUrls.has(tab.url) ||
(tab.url === 'chrome://newtab/' && tab.title === 'Tab Harbor')
try {
await chrome.runtime.sendMessage(message);
} catch (err) {
if (err?.message && !err.message.includes("Receiving end does not exist")) {
console.warn(
"[tab-harbor bg] Error notifying Tab Harbor pages:",
err.message,
);
});

if (dashboardTabs.length === 0) return;

for (const tab of dashboardTabs) {
try {
await chrome.tabs.sendMessage(tab.id, {
action: 'tabs-changed',
source: eventMeta.source || 'tabs.changed',
triggerTabId: eventMeta.triggerTabId ?? null,
});
} catch (err) {
// Tab might be closed or not ready, ignore
console.warn(`[tab-harbor bg] Failed to notify tab ${tab.id}:`, err.message);
}
}
} catch (err) {
console.warn('[tab-harbor bg] Error in notifyTabHarborPages:', err);
}
}

Expand All @@ -130,20 +113,20 @@ chrome.runtime.onStartup.addListener(() => {
// Update badge and notify Tab Harbor pages whenever a tab is opened
chrome.tabs.onCreated.addListener((tab) => {
updateBadge();
notifyTabHarborPages({ source: 'tabs.onCreated', triggerTabId: tab?.id });
notifyTabHarborPages({ source: "tabs.onCreated", triggerTabId: tab?.id });
closeDuplicateNewTabs();
});

// Update badge and notify Tab Harbor pages whenever a tab is closed
chrome.tabs.onRemoved.addListener((tabId) => {
updateBadge();
notifyTabHarborPages({ source: 'tabs.onRemoved', triggerTabId: tabId });
notifyTabHarborPages({ source: "tabs.onRemoved", triggerTabId: tabId });
});

// Update badge and notify Tab Harbor pages when a tab's URL changes (e.g. navigating to/from chrome://)
chrome.tabs.onUpdated.addListener((tabId) => {
updateBadge();
notifyTabHarborPages({ source: 'tabs.onUpdated', triggerTabId: tabId });
notifyTabHarborPages({ source: "tabs.onUpdated", triggerTabId: tabId });
});

// ─── Initial run ─────────────────────────────────────────────────────────────
Expand Down
Loading