929 lines
32 KiB
JavaScript
929 lines
32 KiB
JavaScript
/*
|
|
# 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: `
|
|
<div>${sanitizeForHTMLText(browser.i18n.getMessage(messageKey || 'warnOnCloseTabs_message', [count]))}</div>${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;
|
|
}
|
|
});
|