/* # This Source Code Form is subject to the terms of the Mozilla Public # License, v. 2.0. If a copy of the MPL was not distributed with this # file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 'use strict'; import EventListenerManager from '/extlib/EventListenerManager.js'; import { log as internalLogger, wait, configs, sanitizeForHTMLText, waitUntilStartupOperationsUnblocked, } from '/common/common.js'; import * as ApiTabs from '/common/api-tabs.js'; import * as Constants from '/common/constants.js'; import * as ContextualIdentities from '/common/contextual-identities.js'; import * as Dialog from '/common/dialog.js'; import * as Permissions from '/common/permissions.js'; import * as SidebarConnection from '/common/sidebar-connection.js'; import * as Sync from '/common/sync.js'; import * as TabsStore from '/common/tabs-store.js'; import * as TabsUpdate from '/common/tabs-update.js'; import * as TSTAPI from '/common/tst-api.js'; import * as UniqueId from '/common/unique-id.js'; import '/common/bookmark.js'; // we need to load this once in the background page to register the global listener import MetricsData from '/common/MetricsData.js'; import { Tab, TabGroup } from '/common/TreeItem.js'; import Window from '/common/Window.js'; import * as ApiTabsListener from './api-tabs-listener.js'; import * as BackgroundCache from './background-cache.js'; import * as Commands from './commands.js'; import * as ContextMenu from './context-menu.js'; import * as Migration from './migration.js'; import * as NativeTabGroups from './native-tab-groups.js'; import * as TabContextMenu from './tab-context-menu.js'; import * as Tree from './tree.js'; import * as TreeStructure from './tree-structure.js'; import './browser-action-menu.js'; import './duplicated-tab-detection.js'; import './successor-tab.js'; function log(...args) { internalLogger('background/background', ...args); } // This needs to be large enough for bulk updates on multiple tabs. const DELAY_TO_PROCESS_RESERVED_UPDATE_TASKS = 250; export const onInit = new EventListenerManager(); export const onBuilt = new EventListenerManager(); export const onReady = new EventListenerManager(); export const onDestroy = new EventListenerManager(); export const onTreeCompletelyAttached = new EventListenerManager(); export const instanceId = `${Date.now()}-${parseInt(Math.random() * 65000)}`; const mDarkModeMatchMedia = window.matchMedia('(prefers-color-scheme: dark)'); let mInitialized = false; const mPreloadedCaches = new Map(); async function getAllWindows() { const [windows, tabGroups] = await Promise.all([ browser.windows.getAll({ populate: true, // We need to track all type windows because // popup windows can be destination of tabs.move(). // See also: https://github.com/piroor/treestyletab/issues/3311 windowTypes: ['normal', 'panel', 'popup'], }).catch(ApiTabs.createErrorHandler()), browser.tabGroups.query({}), ]); const groupsByWindow = new Map(); for (const group of tabGroups) { const groupsInWindow = groupsByWindow.get(group.windowId) || []; groupsInWindow.push(group); groupsByWindow.set(group.windowId, groupsInWindow) } for (const win of windows) { win.tabGroups = groupsByWindow.get(win.id) || []; } return windows; } log('init: Start queuing of messages notified via WE APIs'); ApiTabsListener.init(); const promisedRestored = waitUntilCompletelyRestored(); // this must be called synchronosly export async function init() { log('init: start'); MetricsData.add('init: start'); window.addEventListener('pagehide', destroy, { once: true }); onInit.dispatch(); SidebarConnection.init(); // Read caches from existing tabs at first, for better performance. // Those promises will be resolved while waiting for waitUntilCompletelyRestored(). getAllWindows() .then(windows => { for (const win of windows) { browser.sessions.getWindowValue(win.id, Constants.kWINDOW_STATE_CACHED_TABS) .catch(ApiTabs.createErrorSuppressor()) .then(cache => mPreloadedCaches.set(`window-${win.id}`, cache)); const tab = win.tabs[0]; browser.sessions.getTabValue(tab.id, Constants.kWINDOW_STATE_CACHED_TABS) .catch(ApiTabs.createErrorSuppressor()) .then(cache => mPreloadedCaches.set(`tab-${tab.id}`, cache)); } }); let promisedWindows; log('init: Getting existing windows and tabs'); await MetricsData.addAsync('init: waiting for waitUntilCompletelyRestored, ContextualIdentities.init and configs.$loaded', Promise.all([ promisedRestored.then(() => { // don't wait at here for better performance promisedWindows = getAllWindows(); }), ContextualIdentities.init(), configs.$loaded.then(waitUntilStartupOperationsUnblocked), ])); MetricsData.add('init: prepare'); EventListenerManager.debug = configs.debug; Migration.migrateConfigs(); Migration.migrateBookmarkUrls(); configs.grantedRemovingTabIds = []; // clear! MetricsData.add('init: Migration.migrateConfigs'); updatePanelUrl(); const windows = await MetricsData.addAsync('init: getting all tabs across windows', promisedWindows); // wait at here for better performance const restoredFromCache = await MetricsData.addAsync('init: rebuildAll', rebuildAll(windows)); mPreloadedCaches.clear(); await MetricsData.addAsync('init: TreeStructure.loadTreeStructure', TreeStructure.loadTreeStructure(windows, restoredFromCache)); log('init: Start to process messages including queued ones'); ApiTabsListener.start(); Migration.tryNotifyNewFeatures(); ContextualIdentities.startObserve(); onBuilt.dispatch(); // after this line, this master process may receive "kCOMMAND_PING_TO_BACKGROUND" requests from sidebars. MetricsData.add('init: started listening'); TabContextMenu.init(); ContextMenu.init().then(() => updateIconForBrowserTheme()); MetricsData.add('init: started initializing of context menu'); Permissions.clearRequest(); for (const windowId of restoredFromCache.keys()) { if (!restoredFromCache[windowId]) BackgroundCache.reserveToCacheTree(windowId, 'initialize'); TabsUpdate.completeLoadingTabs(windowId); } for (const tab of Tab.getAllTabs(null, { iterator: true })) { updateSubtreeCollapsed(tab); } for (const tab of Tab.getActiveTabs()) { for (const ancestor of tab.$TST.ancestors) { Tree.collapseExpandTabAndSubtree(ancestor, { collapsed: false, justNow: true }); } } // we don't need to await that for the initialization of TST itself. MetricsData.addAsync('init: initializing API for other addons', TSTAPI.initAsBackend()); mInitialized = true; UniqueId.readyToDetectDuplicatedTab(); Tab.broadcastState.enabled = true; onReady.dispatch(); BackgroundCache.activate(); TreeStructure.startTracking(); Sync.init(); await NativeTabGroups.startToMaintainTree(); await MetricsData.addAsync('init: exporting tabs to sidebars', notifyReadyToSidebars()); log(`Startup metrics for ${TabsStore.tabs.size} tabs: `, MetricsData.toString()); } async function notifyReadyToSidebars() { log('notifyReadyToSidebars: start'); const promisedResults = []; for (const win of TabsStore.windows.values()) { // Send PING to all windows whether they are detected as opened or not, because // the connection may be established before this background page starts listening // of messages from sidebar pages. // See also: https://github.com/piroor/treestyletab/issues/2200 TabsUpdate.completeLoadingTabs(win.id); // failsafe log(`notifyReadyToSidebars: to ${win.id}`); promisedResults.push(browser.runtime.sendMessage({ type: Constants.kCOMMAND_NOTIFY_BACKGROUND_READY, windowId: win.id, exported: win.export(true), // send tabs together to optimizie further initialization tasks in the sidebar }).catch(ApiTabs.createErrorSuppressor())); } return Promise.all(promisedResults); } async function updatePanelUrl(theme) { const url = new URL(Constants.kSHORTHAND_URIS.tabbar); url.searchParams.set('style', configs.style); url.searchParams.set('reloadMaskImage', !!configs.enableWorkaroundForBug1763420_reloadMaskImage); if (!theme) theme = await browser.theme.getCurrent(); if (browser.sidebarAction) browser.sidebarAction.setPanel({ panel: url.href }); /* const url = new URL(Constants.kSHORTHAND_URIS.tabbar); url.searchParams.set('style', configs.style); if (browser.sidebarAction) browser.sidebarAction.setPanel({ panel: url.href }); */ } async function waitUntilCompletelyRestored() { log('waitUntilCompletelyRestored'); const initialTabs = await browser.tabs.query({}); await Promise.all([ MetricsData.addAsync('waitUntilCompletelyRestored: existing tabs ', Promise.all( initialTabs.map(tab => waitUntilPersistentIdBecomeAvailable(tab.id).catch(_error => {})) )), MetricsData.addAsync('waitUntilCompletelyRestored: opening tabs ', new Promise((resolve, _reject) => { let promises = []; let timeout; let resolver; let onNewTabRestored = async (tab, _info = {}) => { clearTimeout(timeout); log('new restored tab is detected.'); promises.push(waitUntilPersistentIdBecomeAvailable(tab.id).catch(_error => {})); // Read caches from restored tabs while waiting, for better performance. browser.sessions.getWindowValue(tab.windowId, Constants.kWINDOW_STATE_CACHED_TABS) .catch(ApiTabs.createErrorSuppressor()) .then(cache => mPreloadedCaches.set(`window-${tab.windowId}`, cache)); browser.sessions.getTabValue(tab.id, Constants.kWINDOW_STATE_CACHED_TABS) .catch(ApiTabs.createErrorSuppressor()) .then(cache => mPreloadedCaches.set(`tab-${tab.id}`, cache)); //uniqueId = uniqueId?.id || '?'; // not used timeout = setTimeout(resolver, 100); }; browser.tabs.onCreated.addListener(onNewTabRestored); resolver = (async () => { log(`timeout: all ${promises.length} tabs are restored. `, promises); browser.tabs.onCreated.removeListener(onNewTabRestored); timeout = resolver = onNewTabRestored = undefined; await Promise.all(promises); promises = undefined; resolve(); }); timeout = setTimeout(resolver, 500); })), ]); } async function waitUntilPersistentIdBecomeAvailable(tabId, retryCount = 0) { if (retryCount > 10) { console.log(`could not get persistent ID for ${tabId}`); return false; } const uniqueId = await browser.sessions.getTabValue(tabId, Constants.kPERSISTENT_ID); if (!uniqueId) return wait(100).then(() => waitUntilPersistentIdBecomeAvailable(tabId, retryCount + 1)); return true; } function destroy() { browser.runtime.sendMessage({ type: TSTAPI.kUNREGISTER_SELF }).catch(ApiTabs.createErrorSuppressor()); // This API doesn't work as expected because it is not notified to // other addons actually when browser.runtime.sendMessage() is called // on pagehide or something unloading event. TSTAPI.broadcastMessage({ type: TSTAPI.kNOTIFY_SHUTDOWN }).catch(ApiTabs.createErrorSuppressor()); onDestroy.dispatch(); ApiTabsListener.destroy(); ContextualIdentities.endObserve(); } async function rebuildAll(windows) { if (!windows) windows = await getAllWindows(); const restoredFromCache = new Map(); await Promise.all(windows.map(async win => { await MetricsData.addAsync(`rebuildAll: tabs in window ${win.id}`, async () => { let trackedWindow = TabsStore.windows.get(win.id); if (!trackedWindow) trackedWindow = Window.init(win.id, win.tabGroups.map(TabGroup.init)); for (const tab of win.tabs) { Tab.track(tab); Tab.init(tab, { existing: true }); tryStartHandleAccelKeyOnTab(tab); } try { if (configs.useCachedTree) { log(`trying to restore window ${win.id} from cache`); const restored = await MetricsData.addAsync(`rebuildAll: restore tabs in window ${win.id} from cache`, BackgroundCache.restoreWindowFromEffectiveWindowCache(win.id, { owner: win.tabs[win.tabs.length - 1], tabs: win.tabs, caches: mPreloadedCaches })); restoredFromCache.set(win.id, restored); log(`window ${win.id}: restored from cache?: `, restored); if (restored) return; } } catch(e) { log(`failed to restore tabs for ${win.id} from cache `, e); } try { log(`build tabs for ${win.id} from scratch`); Window.init(win.id, win.tabGroups.map(TabGroup.init)); const promises = []; for (let tab of win.tabs) { tab = Tab.get(tab.id); tab.$TST.clear(); // clear dirty restored states promises.push( tab.$TST.getPermanentStates() .then(states => { tab.$TST.states = new Set(states); tab.$TST.addState(Constants.kTAB_STATE_PENDING); }) .catch(console.error) .then(() => { TabsUpdate.updateTab(tab, tab, { forceApply: true }); }) ); tryStartHandleAccelKeyOnTab(tab); } await Promise.all(promises); } catch(e) { log(`failed to build tabs for ${win.id}`, e); } restoredFromCache.set(win.id, false); }); for (const tab of Tab.getGroupTabs(win.id, { iterator: true })) { if (!tab.discarded) tab.$TST.temporaryMetadata.set('shouldReloadOnSelect', true); } })); return restoredFromCache; } export async function reload(options = {}) { mPreloadedCaches.clear(); for (const win of TabsStore.windows.values()) { win.clear(); } TabsStore.clear(); const windows = await getAllWindows(); await MetricsData.addAsync('reload: rebuildAll', rebuildAll(windows)); await MetricsData.addAsync('reload: TreeStructure.loadTreeStructure', TreeStructure.loadTreeStructure(windows)); if (!options.all) return; for (const win of TabsStore.windows.values()) { if (!SidebarConnection.isOpen(win.id)) continue; log('reload all sidebars: ', new Error().stack); browser.runtime.sendMessage({ type: Constants.kCOMMAND_RELOAD }).catch(ApiTabs.createErrorSuppressor()); } } export async function tryStartHandleAccelKeyOnTab(tab) { if (!TabsStore.ensureLivingItem(tab)) return; const granted = await Permissions.isGranted(Permissions.ALL_URLS); if (!granted || /^(about|chrome|resource):/.test(tab.url)) return; try { //log(`tryStartHandleAccelKeyOnTab: initialize tab ${tab.id}`); if (browser.scripting) // Manifest V3 browser.scripting.executeScript({ target: { tabId: tab.id, allFrames: true, }, files: ['/common/handle-accel-key.js'], }).catch(ApiTabs.createErrorSuppressor(ApiTabs.handleMissingTabError, ApiTabs.handleMissingHostPermissionError)); else browser.tabs.executeScript(tab.id, { file: '/common/handle-accel-key.js', allFrames: true, matchAboutBlank: true, runAt: 'document_start' }).catch(ApiTabs.createErrorSuppressor(ApiTabs.handleMissingTabError, ApiTabs.handleMissingHostPermissionError)); } catch(error) { console.log(error); } } export function reserveToUpdateInsertionPosition(tabOrTabs) { const tabs = Array.isArray(tabOrTabs) ? tabOrTabs : [tabOrTabs] ; for (const tab of tabs) { if (!TabsStore.ensureLivingItem(tab)) continue; const reserved = reserveToUpdateInsertionPosition.reserved.get(tab.windowId) || { timer: null, tabs: new Set() }; if (reserved.timer) clearTimeout(reserved.timer); reserved.tabs.add(tab); reserved.timer = setTimeout(() => { reserveToUpdateInsertionPosition.reserved.delete(tab.windowId); for (const tab of reserved.tabs) { if (!tab.$TST) continue; updateInsertionPosition(tab); } }, DELAY_TO_PROCESS_RESERVED_UPDATE_TASKS); reserveToUpdateInsertionPosition.reserved.set(tab.windowId, reserved); } } reserveToUpdateInsertionPosition.reserved = new Map(); async function updateInsertionPosition(tab) { if (!TabsStore.ensureLivingItem(tab)) return; const prev = tab.hidden ? tab.$TST.unsafePreviousTab : tab.$TST.previousTab; if (prev) browser.sessions.setTabValue( tab.id, Constants.kPERSISTENT_INSERT_AFTER, prev.$TST.uniqueId.id ).catch(ApiTabs.createErrorSuppressor( ApiTabs.handleMissingTabError // The tab can be closed while waiting. )); else browser.sessions.removeTabValue( tab.id, Constants.kPERSISTENT_INSERT_AFTER ).catch(ApiTabs.createErrorSuppressor( ApiTabs.handleMissingTabError // The tab can be closed while waiting. )); // This code should be removed after legacy data are cleared enough, maybe after Firefox 128 is released. browser.sessions.removeTabValue( tab.id, Constants.kPERSISTENT_INSERT_AFTER_LEGACY ).catch(ApiTabs.createErrorSuppressor( ApiTabs.handleMissingTabError // The tab can be closed while waiting. )); const next = tab.hidden ? tab.$TST.unsafeNextTab : tab.$TST.nextTab; if (next) browser.sessions.setTabValue( tab.id, Constants.kPERSISTENT_INSERT_BEFORE, next.$TST.uniqueId.id ).catch(ApiTabs.createErrorSuppressor( ApiTabs.handleMissingTabError // The tab can be closed while waiting. )); else browser.sessions.removeTabValue( tab.id, Constants.kPERSISTENT_INSERT_BEFORE ).catch(ApiTabs.createErrorSuppressor( ApiTabs.handleMissingTabError // The tab can be closed while waiting. )); } export function reserveToUpdateAncestors(tabOrTabs) { const tabs = Array.isArray(tabOrTabs) ? tabOrTabs : [tabOrTabs] ; for (const tab of tabs) { if (!TabsStore.ensureLivingItem(tab)) continue; const reserved = reserveToUpdateAncestors.reserved.get(tab.windowId) || { timer: null, tabs: new Set() }; if (reserved.timer) clearTimeout(reserved.timer); reserved.tabs.add(tab); reserved.timer = setTimeout(() => { reserveToUpdateAncestors.reserved.delete(tab.windowId); for (const tab of reserved.tabs) { if (!tab.$TST) continue; updateAncestors(tab); } }, DELAY_TO_PROCESS_RESERVED_UPDATE_TASKS); reserveToUpdateAncestors.reserved.set(tab.windowId, reserved); } } reserveToUpdateAncestors.reserved = new Map(); async function updateAncestors(tab) { if (!TabsStore.ensureLivingItem(tab)) return; const ancestors = tab.$TST.ancestors.map(ancestor => ancestor.$TST.uniqueId.id); log(`updateAncestors: save persistent ancestors for ${tab.id}: `, ancestors); browser.sessions.setTabValue( tab.id, Constants.kPERSISTENT_ANCESTORS, ancestors ).catch(ApiTabs.createErrorSuppressor( ApiTabs.handleMissingTabError // The tab can be closed while waiting. )); } export function reserveToUpdateChildren(tabOrTabs) { const tabs = Array.isArray(tabOrTabs) ? tabOrTabs : [tabOrTabs] ; for (const tab of tabs) { if (!TabsStore.ensureLivingItem(tab)) continue; const reserved = reserveToUpdateChildren.reserved.get(tab.windowId) || { timer: null, tabs: new Set() }; if (reserved.timer) clearTimeout(reserved.timer); reserved.tabs.add(tab); reserved.timer = setTimeout(() => { reserveToUpdateChildren.reserved.delete(tab.windowId); for (const tab of reserved.tabs) { if (!tab.$TST) continue; updateChildren(tab); } }, DELAY_TO_PROCESS_RESERVED_UPDATE_TASKS); reserveToUpdateChildren.reserved.set(tab.windowId, reserved); } } reserveToUpdateChildren.reserved = new Map(); async function updateChildren(tab) { if (!TabsStore.ensureLivingItem(tab)) return; const children = tab.$TST.children.map(child => child.$TST.uniqueId.id); log(`updateChildren: save persistent children for ${tab.id}: `, children); browser.sessions.setTabValue( tab.id, Constants.kPERSISTENT_CHILDREN, children ).catch(ApiTabs.createErrorSuppressor( ApiTabs.handleMissingTabError // The tab can be closed while waiting. )); } function reserveToUpdateSubtreeCollapsed(tab) { if (!mInitialized || !TabsStore.ensureLivingItem(tab)) return; const reserved = reserveToUpdateSubtreeCollapsed.reserved.get(tab.windowId) || { timer: null, tabs: new Set() }; if (reserved.timer) clearTimeout(reserved.timer); reserved.tabs.add(tab); reserved.timer = setTimeout(() => { reserveToUpdateSubtreeCollapsed.reserved.delete(tab.windowId); for (const tab of reserved.tabs) { if (!tab.$TST) continue; updateSubtreeCollapsed(tab); } }, DELAY_TO_PROCESS_RESERVED_UPDATE_TASKS); reserveToUpdateSubtreeCollapsed.reserved.set(tab.windowId, reserved); } reserveToUpdateSubtreeCollapsed.reserved = new Map(); async function updateSubtreeCollapsed(tab) { if (!TabsStore.ensureLivingItem(tab)) return; tab.$TST.toggleState(Constants.kTAB_STATE_SUBTREE_COLLAPSED, tab.$TST.subtreeCollapsed, { permanently: true }); } export async function confirmToCloseTabs(tabs, { windowId, configKey, messageKey, titleKey, minConfirmCount } = {}) { if (!windowId) windowId = tabs[0].windowId; const grantedIds = new Set(configs.grantedRemovingTabIds); let count = 0; const tabIds = []; tabs = tabs.map(tab => Tab.get(tab?.id)).filter(tab => { if (tab && !grantedIds.has(tab.id)) { count++; tabIds.push(tab.id); return true; } return false; }); if (!configKey) configKey = 'warnOnCloseTabs'; const shouldConfirm = configs[configKey]; const deltaFromLastConfirmation = Date.now() - configs.lastConfirmedToCloseTabs; log('confirmToCloseTabs ', { tabIds, count, windowId, configKey, grantedIds, shouldConfirm, deltaFromLastConfirmation, minConfirmCount }); if (count <= (typeof minConfirmCount == 'number' ? minConfirmCount : 1) || !shouldConfirm || deltaFromLastConfirmation < 500) { log('confirmToCloseTabs: skip confirmation and treated as granted'); return true; } const win = await browser.windows.get(windowId); const listing = configs.warnOnCloseTabsWithListing ? Dialog.tabsToHTMLList(tabs, { maxHeight: Math.round(win.height * 0.8), maxWidth: Math.round(win.width * 0.75) }) : ''; const result = await Dialog.show(win, { content: `