/* # 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: `
${sanitizeForHTMLText(browser.i18n.getMessage(messageKey || 'warnOnCloseTabs_message', [count]))}
${listing} `.trim(), buttons: [ browser.i18n.getMessage('warnOnCloseTabs_close'), browser.i18n.getMessage('warnOnCloseTabs_cancel') ], checkMessage: browser.i18n.getMessage('warnOnCloseTabs_warnAgain'), checked: true, modal: !configs.debug, // for popup type: 'common-dialog', // for popup url: ((await Permissions.isGranted(Permissions.ALL_URLS)) ? null : '/resources/blank.html'), // for popup title: browser.i18n.getMessage(titleKey || 'warnOnCloseTabs_title'), // for popup onShownInPopup(container) { setTimeout(() => { // because window.requestAnimationFrame is decelerate for an invisible document. // this need to be done on the next tick, to use the height of // the box for calculation of dialog size const style = container.querySelector('ul').style; style.height = '0px'; // this makes the box shrinkable style.maxHeight = 'none'; style.minHeight = '0px'; }, 0); } }); log('confirmToCloseTabs: result = ', result); switch (result.buttonIndex) { case 0: if (!result.checked) configs[configKey] = false; configs.grantedRemovingTabIds = Array.from(new Set((configs.grantedRemovingTabIds || []).concat(tabIds))); log('confirmToCloseTabs: granted ', configs.grantedRemovingTabIds); reserveToClearGrantedRemovingTabs(); return true; default: return false; } } Commands.onTabsClosing.addListener((tabIds, options = {}) => { return confirmToCloseTabs(tabIds.map(Tab.get), options); }); function reserveToClearGrantedRemovingTabs() { const lastGranted = configs.grantedRemovingTabIds.join(','); setTimeout(() => { if (configs.grantedRemovingTabIds.join(',') == lastGranted) configs.grantedRemovingTabIds = []; }, 1000); } Tab.onCreated.addListener((tab, info = {}) => { if (!info.duplicated) return; // Duplicated tab has its own tree structure information inherited // from the original tab, but they must be cleared. reserveToUpdateAncestors(tab); reserveToUpdateChildren(tab); reserveToUpdateInsertionPosition([ tab, tab.hidden ? tab.$TST.unsafePreviousTab : tab.$TST.previousTab, tab.hidden ? tab.$TST.unsafeNextTab : tab.$TST.nextTab ]); }); Tab.onUpdated.addListener((tab, changeInfo) => { if (!mInitialized) return; // Loading of "about:(unknown type)" won't report new URL via tabs.onUpdated, // so we need to see the complete tab object. const status = changeInfo.status || tab?.status; const url = changeInfo.url ? changeInfo.url : status == 'complete' && tab ? tab.url : ''; if (tab && Constants.kSHORTHAND_ABOUT_URI.test(url)) { const shorthand = RegExp.$1; const oldUrl = tab.url; wait(100).then(() => { // redirect with delay to avoid infinite loop of recursive redirections. if (tab.url != oldUrl) return; browser.tabs.update(tab.id, { url: url.replace(Constants.kSHORTHAND_ABOUT_URI, Constants.kSHORTHAND_URIS[shorthand] || 'about:blank') }).catch(ApiTabs.createErrorSuppressor(ApiTabs.handleMissingTabError)); if (shorthand == 'group') tab.$TST.addState(Constants.kTAB_STATE_GROUP_TAB, { permanently: true }); }); } if (changeInfo.status || changeInfo.url) tryStartHandleAccelKeyOnTab(tab); }); Tab.onShown.addListener(tab => { if (!mInitialized) return; if (configs.fixupTreeOnTabVisibilityChanged) { reserveToUpdateAncestors(tab); reserveToUpdateChildren(tab); } reserveToUpdateInsertionPosition([ tab, tab.hidden ? tab.$TST.unsafePreviousTab : tab.$TST.previousTab, tab.hidden ? tab.$TST.unsafeNextTab : tab.$TST.nextTab ]); }); Tab.onMutedStateChanged.addListener((root, toBeMuted) => { if (!mInitialized) return; // Spread muted state of a parent tab to its collapsed descendants if (!root.$TST.subtreeCollapsed || // We don't need to spread muted state to descendants of multiselected // tabs here, because tabs.update() was called with all multiselected tabs. root.$TST.multiselected || // We should not spread muted state to descendants of collapsed tab // recursively, because they were already controlled from a visible // ancestor. root.$TST.collapsed) return; const tabs = root.$TST.descendants; for (const tab of tabs) { const playing = tab.$TST.soundPlaying; const muted = tab.$TST.muted; log(`tab ${tab.id}: playing=${playing}, muted=${muted}`); if (configs.spreadMutedStateOnlyToSoundPlayingTabs && !playing && playing != toBeMuted) continue; log(` => set muted=${toBeMuted}`); browser.tabs.update(tab.id, { muted: toBeMuted }).catch(ApiTabs.createErrorHandler(ApiTabs.handleMissingTabError)); const add = []; const remove = []; if (toBeMuted) { add.push(Constants.kTAB_STATE_MUTED); tab.$TST.addState(Constants.kTAB_STATE_MUTED); } else { remove.push(Constants.kTAB_STATE_MUTED); tab.$TST.removeState(Constants.kTAB_STATE_MUTED); } if (tab.audible && !toBeMuted) { add.push(Constants.kTAB_STATE_SOUND_PLAYING); tab.$TST.addState(Constants.kTAB_STATE_SOUND_PLAYING); } else { remove.push(Constants.kTAB_STATE_SOUND_PLAYING); tab.$TST.removeState(Constants.kTAB_STATE_SOUND_PLAYING); } // tabs.onUpdated is too slow, so users will be confused // from still-not-updated tabs (in other words, they tabs // are unresponsive for quick-clicks). Tab.broadcastState(tab, { add, remove }); } }); Tab.onTabInternallyMoved.addListener((tab, info = {}) => { reserveToUpdateInsertionPosition([ tab, tab.hidden ? tab.$TST.unsafePreviousTab : tab.$TST.previousTab, tab.hidden ? tab.$TST.unsafeNextTab : tab.$TST.nextTab, info.oldPreviousTab, info.oldNextTab ]); }); Tab.onMoved.addListener((tab, moveInfo) => { if (moveInfo.movedInBulk) return; reserveToUpdateInsertionPosition([ tab, moveInfo.oldPreviousTab, moveInfo.oldNextTab, tab.hidden ? tab.$TST.unsafePreviousTab : tab.$TST.previousTab, tab.hidden ? tab.$TST.unsafeNextTab : tab.$TST.nextTab ]); }); Tree.onAttached.addListener(async (tab, attachInfo) => { await tab.$TST.opened; if (!TabsStore.ensureLivingItem(tab) || // not removed while waiting tab.$TST.parent != attachInfo.parent) // not detached while waiting return; if (attachInfo.newlyAttached) reserveToUpdateAncestors([tab].concat(tab.$TST.descendants)); reserveToUpdateChildren(tab.$TST.parent); reserveToUpdateInsertionPosition([ tab, tab.$TST.nextTab, tab.$TST.previousTab ]); }); Tree.onDetached.addListener((tab, detachInfo) => { reserveToUpdateAncestors([tab].concat(tab.$TST.descendants)); reserveToUpdateChildren(detachInfo.oldParentTab); }); Tree.onSubtreeCollapsedStateChanging.addListener((tab, _info) => { reserveToUpdateSubtreeCollapsed(tab); }); const BASE_ICONS = { '16': '/resources/16x16.svg', '20': '/resources/20x20.svg', '24': '/resources/24x24.svg', '32': '/resources/32x32.svg', }; async function updateIconForBrowserTheme(theme) { // generate icons with theme specific color const toolbarIcons = {}; const menuIcons = {}; const sidebarIcons = {}; if (!theme) { const win = await browser.windows.getLastFocused(); theme = await browser.theme.getCurrent(win.id); } log('updateIconForBrowserTheme: ', theme); if (theme.colors) { const toolbarIconColor = theme.colors.icons || theme.colors.toolbar_text || theme.colors.tab_text || theme.colors.tab_background_text || theme.colors.bookmark_text || theme.colors.textcolor; const menuIconColor = theme.colors.popup_text || toolbarIconColor; const sidebarIconColor = theme.colors.sidebar_text || toolbarIconColor; log(' => ', { toolbarIconColor, menuIconColor, sidebarIconColor }, theme.colors); await Promise.all(Array.from(Object.entries(BASE_ICONS), async ([size, url]) => { const response = await fetch(url); const body = await response.text(); const toolbarIconSource = body.replace(/transparent\s*\/\*\s*TO BE REPLACED WITH THEME COLOR\s*\*\//g, toolbarIconColor); toolbarIcons[size] = `data:image/svg+xml,${escape(toolbarIconSource)}#toolbar-theme`; const menuIconSource = body.replace(/transparent\s*\/\*\s*TO BE REPLACED WITH THEME COLOR\s*\*\//g, menuIconColor); menuIcons[size] = `data:image/svg+xml,${escape(menuIconSource)}#default-theme`; const sidebarIconSource = body.replace(/transparent\s*\/\*\s*TO BE REPLACED WITH THEME COLOR\s*\*\//g, sidebarIconColor); sidebarIcons[size] = `data:image/svg+xml,${escape(sidebarIconSource)}#default-theme`; })); } else { for (const [size, url] of Object.entries(BASE_ICONS)) { toolbarIcons[size] = `${url}#toolbar`; menuIcons[size] = sidebarIcons[size] = `${url}#default`; } } log('updateIconForBrowserTheme: applying icons: ', { toolbarIcons, menuIcons, sidebarIcons, }); await Promise.all([ ...ContextMenu.getItemIdsWithIcon().map(id => browser.menus.update(id, { icons: menuIcons })), browser.menus.refresh().catch(ApiTabs.createErrorSuppressor()), browser.action?.setIcon({ path: toolbarIcons }), // Manifest v2 browser.browserAction?.setIcon({ path: toolbarIcons }), // Manifest v3 browser.sidebarAction?.setIcon({ path: sidebarIcons }), ]); } browser.theme.onUpdated.addListener(updateInfo => { updateIconForBrowserTheme(updateInfo.theme); }); mDarkModeMatchMedia.addListener(async _event => { updateIconForBrowserTheme(); }); configs.$addObserver(key => { switch (key) { case 'style': updatePanelUrl(); break; case 'debug': EventListenerManager.debug = configs.debug; break; case 'testKey': // for tests/utils.js browser.runtime.sendMessage({ type: Constants.kCOMMAND_NOTIFY_TEST_KEY_CHANGED, value: configs.testKey }); break; } });