import { track, trackError } from "@/features/analytics"; import { collectionCount, getCollections, saveCollections, thumbnailCaptureEnabled } from "@/features/collectionStorage"; import { collectionStorage } from "@/features/collectionStorage/utils/collectionStorage"; import getCollectionsFromCloud from "@/features/collectionStorage/utils/getCollectionsFromCloud"; import getCollectionsFromLocal from "@/features/collectionStorage/utils/getCollectionsFromLocal"; import { migrateStorage } from "@/features/migration"; import { setSettingsReviewNeeded } from "@/features/settingsReview/utils"; import { showWelcomeDialog } from "@/features/v3welcome/utils/showWelcomeDialog"; import { SettingsValue } from "@/hooks/useSettings"; import { CollectionItem, GraphicsStorage } from "@/models/CollectionModels"; import { closeTabsAsync } from "@/utils/closeTabsAsync"; import { createCollectionFromTabs } from "@/utils/createCollectionFromTabs"; import getLogger from "@/utils/getLogger"; import { getTabsToSaveAsync } from "@/utils/getTabsToSaveAsync"; import { onMessage, sendMessage } from "@/utils/messaging"; import sendNotification from "@/utils/sendNotification"; import sendPartialSaveNotification from "@/utils/sendPartialSaveNotification"; import { settings } from "@/utils/settings"; import watchTabSelection from "@/utils/watchTabSelection"; import { RemoveListenerCallback } from "@webext-core/messaging"; import { Unwatch } from "wxt/utils/storage"; import { openCollection, openGroup } from "./sidepanel/utils/opener"; export default defineBackground(() => { try { const logger = getLogger("background"); let graphicsCache: GraphicsStorage = {}; let listLocation: SettingsValue<"listLocation"> = "sidebar"; logger("Background script started"); // Little workaround for opening side panel // See: https://stackoverflow.com/questions/77213045/error-sidepanel-open-may-only-be-called-in-response-to-a-user-gesture-re settings.listLocation.getValue().then(location => listLocation = location); settings.listLocation.watch(newLocation => listLocation = newLocation); browser.runtime.onInstalled.addListener(async ({ reason, previousVersion }) => { logger("onInstalled", reason, previousVersion); track("extension_installed", { reason, previousVersion: previousVersion ?? "none" }); const [major, minor, patch] = (previousVersion ?? "0.0.0").split(".").map(parseInt); const cumulative: number = major * 10000 + minor * 100 + patch; await setSettingsReviewNeeded(reason, previousVersion); if (reason === "update" && cumulative < 30000) // < 3.0.0 { await migrateStorage(); await showWelcomeDialog.setValue(true); browser.runtime.reload(); } if (reason === "update" && cumulative >= 30000 && cumulative < 30200) // >= 3.0.0 && < 3.2.0 { // Merge cloud and local storage if they are out of sync const localTimestamp: number = await collectionStorage.localLastUpdated.getValue(); const syncTimestamp: number = await collectionStorage.syncLastUpdated.getValue(); if (localTimestamp === syncTimestamp) return; try { const localCollections: CollectionItem[] = await getCollectionsFromLocal(); const cloudCollections: CollectionItem[] = await getCollectionsFromCloud(); const mergedCollections: CollectionItem[] = [...cloudCollections, ...localCollections]; await saveCollections(mergedCollections, true, graphicsCache); } catch (ex) { logger("Failed to merge cloud and local storage during update"); trackError("cloud_sync_merge_error", ex as Error); console.error(ex); } } }); browser.commands.onCommand.addListener( (command, tab) => performContextAction(command, tab!.windowId!) ); onMessage("getGraphicsCache", () => graphicsCache); onMessage("refreshCollections", () => { }); if (import.meta.env.FIREFOX) { onMessage("openCollection", ({ data }) => openCollection(data.collection, data.targetWindow)); onMessage("openGroup", ({ data }) => openGroup(data.group, data.newWindow)); } setupTabCaputre(); async function setupTabCaputre(): Promise { let unwatchAddThumbnail: RemoveListenerCallback | null = null; let captureInterval: NodeJS.Timeout | null = null; const captureFavicon = (_: any, __: any, tab: Browser.tabs.Tab): void => { if (!tab.url) return; graphicsCache[tab.url] = { preview: graphicsCache[tab.url]?.preview, capture: graphicsCache[tab.url]?.capture, icon: tab.favIconUrl ?? graphicsCache[tab.url]?.icon }; }; const tryCaptureTab = async (tab: Browser.tabs.Tab): Promise => { if (!tab.url || tab.status !== "complete" || !tab.active) return; if (graphicsCache[tab.url]?.capture || graphicsCache[tab.url]?.capture === null) return; try { // We use chrome here because polyfill throws uncatchable errors for some reason // It's a compatible API anyway const capture: string = await browser.tabs.captureVisibleTab(tab.windowId!, { format: "jpeg", quality: 1 }); if (capture) { graphicsCache[tab.url] = { capture, preview: graphicsCache[tab.url]?.preview, icon: graphicsCache[tab.url]?.icon }; } } catch { graphicsCache[tab.url] = { capture: null!, preview: graphicsCache[tab.url]?.preview, icon: graphicsCache[tab.url]?.icon }; } }; const updateCapture = async (captureThumbnails: boolean): Promise => { const scriptingGranted: boolean = await browser.permissions.contains({ permissions: ["scripting"] }); if (captureThumbnails) { if (scriptingGranted) await browser.scripting.registerContentScripts([ { id: "capture-script", matches: [""], runAt: "document_idle", js: ["capture.js"] } ]); unwatchAddThumbnail = onMessage("addThumbnail", ({ data }) => { graphicsCache[data.url] = { preview: data.thumbnail, capture: graphicsCache[data.url]?.capture, icon: graphicsCache[data.url]?.icon }; }); captureInterval = setInterval(() => { browser.tabs.query({ active: true }) .then(tabs => tabs.forEach(tab => tryCaptureTab(tab))); }, 1000); browser.tabs.onUpdated.addListener(captureFavicon); } else { if (scriptingGranted) await browser.scripting.unregisterContentScripts({ ids: ["capture-script"] }); unwatchAddThumbnail?.(); if (captureInterval) clearInterval(captureInterval); browser.tabs.onUpdated.removeListener(captureFavicon); graphicsCache = {}; } }; if (await thumbnailCaptureEnabled.getValue()) updateCapture(true); thumbnailCaptureEnabled.watch(updateCapture); } setupContextMenu(); async function setupContextMenu(): Promise { await browser.contextMenus.removeAll(); const items: Record = { "show_collections": i18n.t("actions.show_collections"), "set_aside": i18n.t("actions.set_aside.all"), "save": i18n.t("actions.save.all") }; Object.entries(items).forEach(([id, title]) => browser.contextMenus.create({ id, title, visible: true, contexts: ["action"] })); watchTabSelection(async selection => { await browser.contextMenus.update("set_aside", { title: i18n.t(`actions.set_aside.${selection}`) }); await browser.contextMenus.update("save", { title: i18n.t(`actions.save.${selection}`) }); }); browser.contextMenus.onClicked.addListener( ({ menuItemId }, tab) => performContextAction((menuItemId as string), tab!.windowId!) ); } setupBadge(); async function setupBadge(): Promise { let unwatchBadge: Unwatch | null = null; const updateBadge = async (count: number | null) => await browser.action.setBadgeText({ text: count && count > 0 ? count.toString() : "" }); if (await settings.showBadge.getValue()) { updateBadge(await collectionCount.getValue()); unwatchBadge = collectionCount.watch(updateBadge); } if (import.meta.env.FIREFOX) { await browser.action.setBadgeBackgroundColor({ color: "#0f6cbd" }); await browser.action.setBadgeTextColor({ color: "white" }); } settings.showBadge.watch(async showBadge => { if (showBadge) { updateBadge(await collectionCount.getValue()); unwatchBadge = collectionCount.watch(updateBadge); } else { unwatchBadge?.(); await browser.action.setBadgeText({ text: "" }); } }); } setupActionButton(); async function setupActionButton(): Promise { let unwatchActionTitle: Unwatch | null = null; const onClickAction = async (): Promise => { logger("action.onClicked"); const defaultAction = await settings.defaultSaveAction.getValue(); await saveTabs(defaultAction === "set_aside"); }; const updateTitle = async (selection: "all" | "selected"): Promise => { const defaultAction = await settings.defaultSaveAction.getValue(); await browser.action.setTitle({ title: i18n.t(`actions.${defaultAction}.${selection}`) }); }; const toggleSidebarFirefox = async (): Promise => // @ts-expect-error Firefox-only API await browser.sidebarAction.toggle(); const updateButton = async (action: SettingsValue<"contextAction">): Promise => { logger("updateButton", action); // Cleanup any existing behavior browser.action.onClicked.removeListener(onClickAction); browser.action.onClicked.removeListener(toggleSidebarFirefox); browser.action.onClicked.removeListener(openCollectionsInTab); await browser.action.disable(); await browser.action.setTitle({ title: i18n.t("manifest.name") }); unwatchActionTitle?.(); if (!import.meta.env.FIREFOX) await browser.sidePanel.setPanelBehavior({ openPanelOnActionClick: false }); // Setup new behavior if (action === "action") { browser.action.onClicked.addListener(onClickAction); unwatchActionTitle = watchTabSelection(updateTitle); await browser.action.enable(); } else if (action === "open") { await browser.action.enable(); const location = await settings.listLocation.getValue(); if (location === "sidebar") { if (import.meta.env.FIREFOX) browser.action.onClicked.addListener(toggleSidebarFirefox); else browser.sidePanel.setPanelBehavior({ openPanelOnActionClick: true }); } else if (location !== "popup") browser.action.onClicked.addListener(openCollectionsInTab); } }; updateButton(await settings.contextAction.getValue()); settings.contextAction.watch(updateButton); settings.listLocation.watch(async () => updateButton(await settings.contextAction.getValue())); } setupCollectionView(); async function setupCollectionView(): Promise { const enforcePinnedTab = async (): Promise => { logger("enforcePinnedTab"); const openWindows: Browser.windows.Window[] = await browser.windows.getAll({ populate: true }); for (const openWindow of openWindows) { if (openWindow.incognito || openWindow.type !== "normal") continue; const activeTabs: Browser.tabs.Tab[] = openWindow.tabs!.filter(tab => tab.url === browser.runtime.getURL("/sidepanel.html")); const targetTab: Browser.tabs.Tab | undefined = activeTabs.find(tab => tab.pinned); if (!targetTab) await browser.tabs.create({ url: browser.runtime.getURL("/sidepanel.html"), windowId: openWindow.id, active: false, pinned: true }); const tabsToClose: Browser.tabs.Tab[] = activeTabs.filter(tab => tab.id !== targetTab?.id); if (tabsToClose.length > 0) await browser.tabs.remove(tabsToClose.map(tab => tab.id!)); } }; const updateView = async (viewLocation: SettingsValue<"listLocation">): Promise => { logger("updateView", viewLocation); browser.tabs.onHighlighted.removeListener(enforcePinnedTab); const tabs: Browser.tabs.Tab[] = await browser.tabs.query({ url: browser.runtime.getURL("/sidepanel.html") }); await browser.tabs.remove(tabs.map(tab => tab.id!)); await browser.action.setPopup({ popup: viewLocation === "popup" ? browser.runtime.getURL("/popup.html") : "" }); if (import.meta.env.FIREFOX) // @ts-expect-error Firefox-only API await browser.sidebarAction.setPanel({ panel: viewLocation === "sidebar" ? browser.runtime.getURL("/sidepanel.html") : "" }); else await browser.sidePanel.setOptions({ enabled: viewLocation === "sidebar" }); if (viewLocation === "pinned") { enforcePinnedTab(); browser.tabs.onHighlighted.addListener(enforcePinnedTab); } }; updateView(await settings.listLocation.getValue()); settings.listLocation.watch(updateView); } function performContextAction(action: string, windowId: number): void { if (action === "show_collections") { if (listLocation === "sidebar" || listLocation === "popup") openCollectionsInView(listLocation, windowId); else openCollectionsInTab(); } else saveTabs(action === "set_aside"); } function openCollectionsInView(view: "sidebar" | "popup", windowId: number): void { if (view === "sidebar") { if (import.meta.env.FIREFOX) // @ts-expect-error Firefox-only API browser.sidebarAction.open(); else browser.sidePanel.open({ windowId }); } else browser.action.openPopup(); } async function openCollectionsInTab(): Promise { logger("openCollectionsInTab"); const currentWindow: Browser.windows.Window = await browser.windows.getCurrent({ populate: true }); if (currentWindow.incognito) { let availableWindows: Browser.windows.Window[] = await browser.windows.getAll({ populate: true }); availableWindows = availableWindows.filter(window => !window.incognito && window.tabs?.some(i => i.url === browser.runtime.getURL("/sidepanel.html")) ); if (availableWindows.length > 0) { const availableTab: Browser.tabs.Tab = availableWindows[0].tabs!.find( tab => tab.url === browser.runtime.getURL("/sidepanel.html") )!; await browser.tabs.update(availableTab.id, { active: true }); await browser.windows.update(availableWindows[0].id!, { focused: true }); return; } await browser.windows.create({ url: browser.runtime.getURL("/sidepanel.html"), focused: true }); } else { const collectionTab: Browser.tabs.Tab | undefined = currentWindow.tabs!.find( tab => tab.url === browser.runtime.getURL("/sidepanel.html") ); if (collectionTab) await browser.tabs.update(collectionTab.id, { active: true }); else await browser.tabs.create({ url: browser.runtime.getURL("/sidepanel.html"), active: true, windowId: currentWindow.id }); } } async function saveTabs(closeAfterSave: boolean): Promise { logger("saveTabs", closeAfterSave); const [tabs, skipCount] = await getTabsToSaveAsync(); if (tabs.length < 1) { await sendPartialSaveNotification(); return; } const collection: CollectionItem = await createCollectionFromTabs(tabs); const [savedCollections, cloudIssue] = await getCollections(); const newList = [collection, ...savedCollections]; await saveCollections(newList, cloudIssue === null, graphicsCache); track(closeAfterSave ? "set_aside" : "save"); sendMessage("refreshCollections", undefined); if (skipCount > 0) await sendPartialSaveNotification(); if (closeAfterSave) await closeTabsAsync(tabs); if (await settings.notifyOnSave.getValue()) await sendNotification({ title: i18n.t("notifications.tabs_saved.title"), message: i18n.t("notifications.tabs_saved.message"), icon: "/notification_icons/cloud_checkmark.png" }); } } catch (ex) { console.error(ex); trackError("background_error", ex as Error); } });