479 lines
11 KiB
JavaScript
479 lines
11 KiB
JavaScript
const HOME_URL = browser.runtime.getURL("home.html");
|
|
const OPTIONS_URL = browser.runtime.getURL("options.html");
|
|
const DEFAULT_SETTINGS = {
|
|
bookmarks: [
|
|
{ title: "Mozilla", url: "https://www.mozilla.org/" },
|
|
{ title: "DuckDuckGo", url: "https://duckduckgo.com/" },
|
|
{ title: "Wikipedia", url: "https://www.wikipedia.org/" },
|
|
{ title: "Proton", url: "https://proton.me/" }
|
|
],
|
|
blocking: {
|
|
trackers: true,
|
|
ads: false,
|
|
analytics: false,
|
|
social: false
|
|
}
|
|
};
|
|
|
|
const BLOCKLISTS = {
|
|
trackers: [
|
|
"doubleclick.net",
|
|
"googlesyndication.com",
|
|
"googletagmanager.com",
|
|
"googleadservices.com",
|
|
"taboola.com",
|
|
"outbrain.com",
|
|
"scorecardresearch.com",
|
|
"zedo.com",
|
|
"criteo.com"
|
|
],
|
|
ads: [
|
|
"ads.",
|
|
"adservice.",
|
|
"adserver.",
|
|
"/ads/",
|
|
"amazon-adsystem.com",
|
|
"adnxs.com",
|
|
"rubiconproject.com",
|
|
"openx.net"
|
|
],
|
|
analytics: [
|
|
"google-analytics.com",
|
|
"analytics.",
|
|
"segment.com",
|
|
"mixpanel.com",
|
|
"hotjar.com",
|
|
"fullstory.com",
|
|
"amplitude.com",
|
|
"plausible.io"
|
|
],
|
|
social: [
|
|
"facebook.net",
|
|
"facebook.com/plugins",
|
|
"platform.twitter.com",
|
|
"connect.facebook.net",
|
|
"platform.linkedin.com",
|
|
"assets.pinterest.com",
|
|
"redditstatic.com"
|
|
]
|
|
};
|
|
|
|
let isCollapsingTabs = false;
|
|
let currentSettings = cloneDefaultSettings();
|
|
const pendingCollapseTimers = new Map();
|
|
const pendingCollapseStartedAt = new Map();
|
|
const NON_MEANINGFUL_COLLAPSE_GRACE_MS = 800;
|
|
|
|
function cloneDefaultSettings() {
|
|
return JSON.parse(JSON.stringify(DEFAULT_SETTINGS));
|
|
}
|
|
|
|
function normalizeUrl(rawUrl) {
|
|
if (!rawUrl) {
|
|
return "";
|
|
}
|
|
|
|
if (/^[a-zA-Z][a-zA-Z\d+\-.]*:/.test(rawUrl)) {
|
|
return rawUrl;
|
|
}
|
|
|
|
if (rawUrl.includes(" ") && !rawUrl.includes(".")) {
|
|
return `https://duckduckgo.com/?q=${encodeURIComponent(rawUrl)}`;
|
|
}
|
|
|
|
return `https://${rawUrl}`;
|
|
}
|
|
|
|
function sanitizeBookmarks(bookmarks) {
|
|
const fallback = cloneDefaultSettings().bookmarks;
|
|
const sanitized = Array.isArray(bookmarks) ? bookmarks.slice(0, 4).map((bookmark, index) => {
|
|
const safeFallback = fallback[index] || { title: "", url: "" };
|
|
|
|
return {
|
|
title: String(bookmark?.title || safeFallback.title).trim(),
|
|
url: String(bookmark?.url || safeFallback.url).trim()
|
|
};
|
|
}) : [];
|
|
|
|
while (sanitized.length < 4) {
|
|
sanitized.push(fallback[sanitized.length]);
|
|
}
|
|
|
|
return sanitized;
|
|
}
|
|
|
|
async function loadSettings() {
|
|
const stored = await browser.storage.local.get("settings");
|
|
const defaults = cloneDefaultSettings();
|
|
const settings = {
|
|
bookmarks: sanitizeBookmarks(stored.settings?.bookmarks || defaults.bookmarks),
|
|
blocking: {
|
|
trackers: stored.settings?.blocking?.trackers ?? defaults.blocking.trackers,
|
|
ads: stored.settings?.blocking?.ads ?? defaults.blocking.ads,
|
|
analytics: stored.settings?.blocking?.analytics ?? defaults.blocking.analytics,
|
|
social: stored.settings?.blocking?.social ?? defaults.blocking.social
|
|
}
|
|
};
|
|
|
|
currentSettings = settings;
|
|
return settings;
|
|
}
|
|
|
|
async function saveSettings(settings) {
|
|
currentSettings = {
|
|
bookmarks: sanitizeBookmarks(settings.bookmarks),
|
|
blocking: {
|
|
trackers: Boolean(settings.blocking?.trackers),
|
|
ads: Boolean(settings.blocking?.ads),
|
|
analytics: Boolean(settings.blocking?.analytics),
|
|
social: Boolean(settings.blocking?.social)
|
|
}
|
|
};
|
|
|
|
await browser.storage.local.set({ settings: currentSettings });
|
|
return currentSettings;
|
|
}
|
|
|
|
function isMeaningfulUrl(url) {
|
|
if (!url) {
|
|
return false;
|
|
}
|
|
|
|
return ![
|
|
"about:blank",
|
|
"about:newtab",
|
|
HOME_URL
|
|
].includes(url);
|
|
}
|
|
|
|
async function getAllNormalTabs() {
|
|
const windows = await browser.windows.getAll({ populate: true, windowTypes: ["normal"] });
|
|
return windows
|
|
.sort((left, right) => left.id - right.id)
|
|
.flatMap((windowInfo) => (windowInfo.tabs || []).map((tab) => ({ ...tab, __window: windowInfo })));
|
|
}
|
|
|
|
async function getKeeperTab(excludedTabId) {
|
|
const windows = await browser.windows.getAll({ populate: true, windowTypes: ["normal"] });
|
|
const sortedWindows = windows.sort((left, right) => {
|
|
if (left.focused && !right.focused) {
|
|
return -1;
|
|
}
|
|
|
|
if (!left.focused && right.focused) {
|
|
return 1;
|
|
}
|
|
|
|
return left.id - right.id;
|
|
});
|
|
|
|
for (const windowInfo of sortedWindows) {
|
|
const activeTab = (windowInfo.tabs || []).find((tab) => tab.active && tab.id !== excludedTabId);
|
|
if (activeTab) {
|
|
return activeTab;
|
|
}
|
|
|
|
const firstTab = (windowInfo.tabs || []).find((tab) => tab.id !== excludedTabId);
|
|
if (firstTab) {
|
|
return firstTab;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
async function focusTab(tabId, windowId) {
|
|
await browser.tabs.update(tabId, { active: true });
|
|
await browser.windows.update(windowId, { focused: true });
|
|
}
|
|
|
|
function clearPendingCollapse(tabId) {
|
|
const timerId = pendingCollapseTimers.get(tabId);
|
|
if (timerId) {
|
|
clearTimeout(timerId);
|
|
pendingCollapseTimers.delete(tabId);
|
|
}
|
|
|
|
pendingCollapseStartedAt.delete(tabId);
|
|
}
|
|
|
|
function queueCollapse(tabId, delay = 150) {
|
|
const existingTimerId = pendingCollapseTimers.get(tabId);
|
|
if (existingTimerId) {
|
|
clearTimeout(existingTimerId);
|
|
}
|
|
|
|
if (!pendingCollapseStartedAt.has(tabId)) {
|
|
pendingCollapseStartedAt.set(tabId, Date.now());
|
|
}
|
|
|
|
const timerId = setTimeout(() => {
|
|
pendingCollapseTimers.delete(tabId);
|
|
void collapseExtraTab(tabId);
|
|
}, delay);
|
|
|
|
pendingCollapseTimers.set(tabId, timerId);
|
|
}
|
|
|
|
async function collapseExtraTab(tabId) {
|
|
const timerId = pendingCollapseTimers.get(tabId);
|
|
if (timerId) {
|
|
clearTimeout(timerId);
|
|
pendingCollapseTimers.delete(tabId);
|
|
}
|
|
|
|
if (isCollapsingTabs) {
|
|
return;
|
|
}
|
|
|
|
const tab = await browser.tabs.get(tabId).catch(() => null);
|
|
if (!tab) {
|
|
pendingCollapseStartedAt.delete(tabId);
|
|
return;
|
|
}
|
|
|
|
if (!isMeaningfulUrl(tab.url) && (tab.pendingUrl || tab.openerTabId)) {
|
|
queueCollapse(tabId);
|
|
return;
|
|
}
|
|
|
|
if (!isMeaningfulUrl(tab.url)) {
|
|
const allTabs = await getAllNormalTabs();
|
|
if (allTabs.length <= 1) {
|
|
pendingCollapseStartedAt.delete(tabId);
|
|
return;
|
|
}
|
|
|
|
const startedAt = pendingCollapseStartedAt.get(tabId) || Date.now();
|
|
if (Date.now() - startedAt < NON_MEANINGFUL_COLLAPSE_GRACE_MS) {
|
|
queueCollapse(tabId);
|
|
return;
|
|
}
|
|
}
|
|
|
|
const allTabs = await getAllNormalTabs();
|
|
if (allTabs.length <= 1) {
|
|
pendingCollapseStartedAt.delete(tabId);
|
|
return;
|
|
}
|
|
|
|
const keeper = await getKeeperTab(tab.id);
|
|
if (!keeper) {
|
|
pendingCollapseStartedAt.delete(tabId);
|
|
return;
|
|
}
|
|
|
|
isCollapsingTabs = true;
|
|
|
|
try {
|
|
const nextUrl = isMeaningfulUrl(tab.url) ? tab.url : HOME_URL;
|
|
await browser.tabs.update(keeper.id, { url: nextUrl });
|
|
|
|
await browser.tabs.remove(tab.id).catch(() => {});
|
|
await focusTab(keeper.id, keeper.windowId);
|
|
|
|
if (tab.windowId !== keeper.windowId) {
|
|
await browser.windows.remove(tab.windowId).catch(() => {});
|
|
}
|
|
} finally {
|
|
pendingCollapseStartedAt.delete(tabId);
|
|
isCollapsingTabs = false;
|
|
}
|
|
}
|
|
|
|
async function enforceSingleTab() {
|
|
if (isCollapsingTabs) {
|
|
return;
|
|
}
|
|
|
|
const allTabs = await getAllNormalTabs();
|
|
if (allTabs.length <= 1) {
|
|
return;
|
|
}
|
|
|
|
const keeper = await getKeeperTab();
|
|
if (!keeper) {
|
|
return;
|
|
}
|
|
|
|
isCollapsingTabs = true;
|
|
|
|
try {
|
|
for (const tab of allTabs) {
|
|
if (tab.id === keeper.id) {
|
|
continue;
|
|
}
|
|
|
|
if (isMeaningfulUrl(tab.url)) {
|
|
await browser.tabs.update(keeper.id, { url: tab.url });
|
|
}
|
|
|
|
await browser.tabs.remove(tab.id).catch(() => {});
|
|
}
|
|
|
|
const windows = await browser.windows.getAll({ windowTypes: ["normal"] });
|
|
for (const windowInfo of windows) {
|
|
if (windowInfo.id !== keeper.windowId) {
|
|
await browser.windows.remove(windowInfo.id).catch(() => {});
|
|
}
|
|
}
|
|
|
|
await focusTab(keeper.id, keeper.windowId);
|
|
} finally {
|
|
isCollapsingTabs = false;
|
|
}
|
|
}
|
|
|
|
function shouldBlock(details) {
|
|
if (details.tabId < 0) {
|
|
return false;
|
|
}
|
|
|
|
const url = details.url || "";
|
|
let decodedUrl = url;
|
|
try {
|
|
decodedUrl = decodeURIComponent(url);
|
|
} catch (error) {
|
|
decodedUrl = url;
|
|
}
|
|
|
|
return Object.entries(currentSettings.blocking).some(([category, enabled]) => {
|
|
if (!enabled) {
|
|
return false;
|
|
}
|
|
|
|
return BLOCKLISTS[category].some((pattern) => decodedUrl.includes(pattern));
|
|
});
|
|
}
|
|
|
|
async function clearSession(options = {}) {
|
|
const settings = options.preserveSettings ? await loadSettings() : currentSettings;
|
|
const redirectHome = options.redirectHome ?? true;
|
|
|
|
await browser.browsingData.remove(
|
|
{},
|
|
{
|
|
cache: true,
|
|
cookies: true,
|
|
downloads: true,
|
|
formData: true,
|
|
history: true,
|
|
indexedDB: true,
|
|
localStorage: true,
|
|
serviceWorkers: true
|
|
}
|
|
);
|
|
|
|
if (options.preserveSettings) {
|
|
await browser.storage.local.clear();
|
|
await browser.storage.local.set({ settings });
|
|
currentSettings = settings;
|
|
}
|
|
|
|
const keeper = await getKeeperTab();
|
|
if (keeper && (redirectHome || !isMeaningfulUrl(keeper.url))) {
|
|
await browser.tabs.update(keeper.id, { url: HOME_URL, active: true });
|
|
await focusTab(keeper.id, keeper.windowId);
|
|
} else if (!keeper) {
|
|
await browser.tabs.create({ url: HOME_URL });
|
|
}
|
|
|
|
await enforceSingleTab();
|
|
}
|
|
|
|
async function openInKeeperTab(url) {
|
|
const normalized = normalizeUrl(url);
|
|
const keeper = await getKeeperTab();
|
|
|
|
if (keeper) {
|
|
await browser.tabs.update(keeper.id, { url: normalized, active: true });
|
|
await focusTab(keeper.id, keeper.windowId);
|
|
} else {
|
|
await browser.tabs.create({ url: normalized });
|
|
}
|
|
|
|
await enforceSingleTab();
|
|
}
|
|
|
|
browser.runtime.onInstalled.addListener(async () => {
|
|
await saveSettings(await loadSettings());
|
|
});
|
|
|
|
browser.runtime.onStartup.addListener(async () => {
|
|
await loadSettings();
|
|
await clearSession({ preserveSettings: true, redirectHome: false });
|
|
});
|
|
|
|
browser.storage.onChanged.addListener((changes, areaName) => {
|
|
if (areaName === "local" && changes.settings?.newValue) {
|
|
currentSettings = changes.settings.newValue;
|
|
}
|
|
});
|
|
|
|
browser.tabs.onCreated.addListener((tab) => {
|
|
if (tab.id) {
|
|
queueCollapse(tab.id);
|
|
}
|
|
});
|
|
|
|
browser.tabs.onUpdated.addListener((tabId, changeInfo) => {
|
|
if (changeInfo.url || changeInfo.status === "complete") {
|
|
clearPendingCollapse(tabId);
|
|
}
|
|
|
|
if (changeInfo.url) {
|
|
void collapseExtraTab(tabId);
|
|
} else if (changeInfo.status === "complete") {
|
|
queueCollapse(tabId, 0);
|
|
}
|
|
});
|
|
|
|
browser.tabs.onRemoved.addListener((tabId) => {
|
|
clearPendingCollapse(tabId);
|
|
});
|
|
|
|
browser.windows.onCreated.addListener(() => {
|
|
void enforceSingleTab();
|
|
});
|
|
|
|
browser.webNavigation.onCreatedNavigationTarget.addListener((details) => {
|
|
if (details.url) {
|
|
void openInKeeperTab(details.url);
|
|
}
|
|
});
|
|
|
|
browser.webRequest.onBeforeRequest.addListener(
|
|
(details) => ({ cancel: shouldBlock(details) }),
|
|
{ urls: ["<all_urls>"] },
|
|
["blocking"]
|
|
);
|
|
|
|
browser.runtime.onMessage.addListener((message) => {
|
|
if (message?.type === "get-settings") {
|
|
return loadSettings();
|
|
}
|
|
|
|
if (message?.type === "save-settings") {
|
|
return saveSettings(message.settings || currentSettings);
|
|
}
|
|
|
|
if (message?.type === "clear-session") {
|
|
return clearSession({ preserveSettings: true });
|
|
}
|
|
|
|
if (message?.type === "open-url") {
|
|
return openInKeeperTab(message.url || HOME_URL);
|
|
}
|
|
|
|
if (message?.type === "open-settings") {
|
|
return browser.tabs.create({ url: OPTIONS_URL });
|
|
}
|
|
|
|
if (message?.type === "go-home") {
|
|
return openInKeeperTab(HOME_URL);
|
|
}
|
|
|
|
return undefined;
|
|
});
|
|
|
|
void loadSettings();
|