Skip to content
Open
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: "" });
}
Comment thread
KanoCifer marked this conversation as resolved.
}

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);
}
Comment thread
KanoCifer marked this conversation as resolved.
}

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
1 change: 0 additions & 1 deletion extension/dashboard-runtime.js
Original file line number Diff line number Diff line change
Expand Up @@ -3234,7 +3234,6 @@ async function buildDomainGroups(realTabs = getRealTabs()) {
}

if (landingTabs.length > 0) {
groupMap.__landing_pages__ = undefined;
groupMap['__landing-pages__'] = { domain: '__landing-pages__', tabs: landingTabs };
}

Expand Down
11 changes: 9 additions & 2 deletions extension/manifest.json
Original file line number Diff line number Diff line change
@@ -1,9 +1,16 @@
{
"manifest_version": 3,
"name": "Tab Harbor",
"version": "1.1.1",
"version": "1.1.2",
"description": "A new tab dashboard for organizing open tabs, quick links, todos, and saved tab sessions in one calm workspace.",
"permissions": ["tabs", "storage", "search", "clipboardRead", "tabGroups", "favicon"],
"permissions": [
"tabs",
"storage",
"search",
"clipboardRead",
"tabGroups",
"favicon"
],
"chrome_url_overrides": { "newtab": "index.html" },
"background": { "service_worker": "background.js" },
"action": {
Expand Down
15 changes: 9 additions & 6 deletions extension/ui-regression.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -345,7 +345,7 @@ test('optional local config is loaded safely before app mount', () => {
});

test('background keeps the toolbar badge empty', () => {
assert.match(backgroundJs, /await chrome\.action\.setBadgeText\(\{\s*text:\s*''\s*\}\)/);
assert.match(backgroundJs, /await chrome\.action\.setBadgeText\(\{\s*text:\s*["']{2}\s*\}\)/);
assert.doesNotMatch(backgroundJs, /String\(count\)/);
});

Expand Down Expand Up @@ -1056,13 +1056,16 @@ test('dynamic animation styles are generated by JavaScript instead of hardcoded
test('dashboard auto-refreshes when tabs change via background message', () => {
const bgJs = fs.readFileSync(path.join(__dirname, 'background.js'), 'utf8');

// Background should notify Tab Harbor pages
// Background should broadcast tab changes via chrome.runtime.sendMessage
// (the dashboard is an extension page, NOT a content script — sendMessage
// to a content script would always fail with "Receiving end does not exist").
assert.match(bgJs, /notifyTabHarborPages/);
assert.match(bgJs, /chrome\.tabs\.sendMessage/);
assert.match(bgJs, /action:\s*'tabs-changed'/);
assert.match(bgJs, /chrome\.runtime\.sendMessage/);
assert.doesNotMatch(bgJs, /chrome\.tabs\.sendMessage/);
assert.match(bgJs, /action:\s*["']tabs-changed["']/);
assert.match(bgJs, /triggerTabId/);
assert.match(bgJs, /source:\s*'tabs\.onCreated'/);
assert.match(bgJs, /source:\s*'tabs\.onUpdated'/);
assert.match(bgJs, /source:\s*["']tabs\.onCreated["']/);
assert.match(bgJs, /source:\s*["']tabs\.onUpdated["']/);
assert.match(bgJs, /chrome\.tabs\.onCreated\.addListener/);
assert.match(bgJs, /chrome\.tabs\.onRemoved\.addListener/);

Expand Down