/* ***** BEGIN LICENSE BLOCK ***** * Version: MPL 1.1 * * The contents of this file are subject to the Mozilla Public License Version * 1.1 (the "License"); you may not use this file except in compliance with * the License. You may obtain a copy of the License at * http://www.mozilla.org/MPL/ * * Software distributed under the License is distributed on an "AS IS" basis, * WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License * for the specific language governing rights and limitations under the * License. * * The Original Code is the Tree Style Tab. * * The Initial Developer of the Original Code is YUKI "Piro" Hiroshi. * Portions created by the Initial Developer are Copyright (C) 2011-2024 * the Initial Developer. All Rights Reserved. * * Contributor(s): YUKI "Piro" Hiroshi * wanabe * Tetsuharu OHZEKI * Xidorn Quan (Firefox 40+ support) * lv7777 (https://github.com/lv7777) * * ***** END LICENSE BLOCK ******/ 'use strict'; import EventListenerManager from '/extlib/EventListenerManager.js'; import { log as internalLogger, wait, configs, isLinux, } from './common.js'; import * as ApiTabs from '/common/api-tabs.js'; import * as Constants from './constants.js'; import * as SidebarConnection from './sidebar-connection.js'; import * as TabsStore from './tabs-store.js'; import { Tab, kPERMISSION_INCOGNITO, kPERMISSIONS_ALL, } from './TreeItem.js'; function log(...args) { internalLogger('common/tst-api', ...args); } export const onInitialized = new EventListenerManager(); export const onRegistered = new EventListenerManager(); export const onUnregistered = new EventListenerManager(); export const onMessageExternal = { $listeners: new Set(), addListener(listener) { this.$listeners.add(listener); }, removeListener(listener) { this.$listeners.delete(listener); }, dispatch(...args) { return Array.from(this.$listeners, listener => listener(...args)); } }; export const kREGISTER_SELF = 'register-self'; export const kUNREGISTER_SELF = 'unregister-self'; export const kGET_VERSION = 'get-version'; export const kWAIT_FOR_SHUTDOWN = 'wait-for-shutdown'; export const kPING = 'ping'; export const kNOTIFY_READY = 'ready'; export const kNOTIFY_SHUTDOWN = 'shutdown'; // defined but not notified for now. export const kNOTIFY_SIDEBAR_SHOW = 'sidebar-show'; export const kNOTIFY_SIDEBAR_HIDE = 'sidebar-hide'; export const kNOTIFY_TABS_RENDERED = 'tabs-rendered'; export const kNOTIFY_TABS_UNRENDERED = 'tabs-unrendered'; export const kNOTIFY_TAB_STICKY_STATE_CHANGED = 'tab-sticky-state-changed'; export const kNOTIFY_TAB_CLICKED = 'tab-clicked'; // for backward compatibility export const kNOTIFY_TAB_DBLCLICKED = 'tab-dblclicked'; export const kNOTIFY_TAB_MOUSEDOWN = 'tab-mousedown'; export const kNOTIFY_TAB_MOUSEUP = 'tab-mouseup'; export const kNOTIFY_TABBAR_CLICKED = 'tabbar-clicked'; // for backward compatibility export const kNOTIFY_TABBAR_MOUSEDOWN = 'tabbar-mousedown'; export const kNOTIFY_TABBAR_MOUSEUP = 'tabbar-mouseup'; export const kNOTIFY_EXTRA_CONTENTS_CLICKED = 'extra-contents-clicked'; export const kNOTIFY_EXTRA_CONTENTS_DBLCLICKED = 'extra-contents-dblclicked'; export const kNOTIFY_EXTRA_CONTENTS_MOUSEDOWN = 'extra-contents-mousedown'; export const kNOTIFY_EXTRA_CONTENTS_MOUSEUP = 'extra-contents-mouseup'; export const kNOTIFY_EXTRA_CONTENTS_KEYDOWN = 'extra-contents-keydown'; export const kNOTIFY_EXTRA_CONTENTS_KEYUP = 'extra-contents-keyup'; export const kNOTIFY_EXTRA_CONTENTS_INPUT = 'extra-contents-input'; export const kNOTIFY_EXTRA_CONTENTS_CHANGE = 'extra-contents-change'; export const kNOTIFY_EXTRA_CONTENTS_COMPOSITIONSTART = 'extra-contents-compositionstart'; export const kNOTIFY_EXTRA_CONTENTS_COMPOSITIONUPDATE = 'extra-contents-compositionupdate'; export const kNOTIFY_EXTRA_CONTENTS_COMPOSITIONEND = 'extra-contents-compositionend'; export const kNOTIFY_EXTRA_CONTENTS_FOCUS = 'extra-contents-focus'; export const kNOTIFY_EXTRA_CONTENTS_BLUR = 'extra-contents-blur'; export const kNOTIFY_TABBAR_OVERFLOW = 'tabbar-overflow'; export const kNOTIFY_TABBAR_UNDERFLOW = 'tabbar-underflow'; export const kNOTIFY_NEW_TAB_BUTTON_CLICKED = 'new-tab-button-clicked'; export const kNOTIFY_NEW_TAB_BUTTON_MOUSEDOWN = 'new-tab-button-mousedown'; export const kNOTIFY_NEW_TAB_BUTTON_MOUSEUP = 'new-tab-button-mouseup'; export const kNOTIFY_TAB_MOUSEMOVE = 'tab-mousemove'; export const kNOTIFY_TAB_MOUSEOVER = 'tab-mouseover'; export const kNOTIFY_TAB_MOUSEOUT = 'tab-mouseout'; export const kNOTIFY_TAB_DRAGREADY = 'tab-dragready'; export const kNOTIFY_TAB_DRAGCANCEL = 'tab-dragcancel'; export const kNOTIFY_TAB_DRAGSTART = 'tab-dragstart'; export const kNOTIFY_TAB_DRAGENTER = 'tab-dragenter'; export const kNOTIFY_TAB_DRAGEXIT = 'tab-dragexit'; export const kNOTIFY_TAB_DRAGEND = 'tab-dragend'; export const kNOTIFY_TREE_ATTACHED = 'tree-attached'; export const kNOTIFY_TREE_DETACHED = 'tree-detached'; export const kNOTIFY_TREE_COLLAPSED_STATE_CHANGED = 'tree-collapsed-state-changed'; export const kNOTIFY_NATIVE_TAB_DRAGSTART = 'native-tab-dragstart'; export const kNOTIFY_NATIVE_TAB_DRAGEND = 'native-tab-dragend'; export const kNOTIFY_PERMISSIONS_CHANGED = 'permissions-changed'; export const kNOTIFY_NEW_TAB_PROCESSED = 'new-tab-processed'; export const kSTART_CUSTOM_DRAG = 'start-custom-drag'; export const kNOTIFY_TRY_MOVE_FOCUS_FROM_COLLAPSING_TREE = 'try-move-focus-from-collapsing-tree'; export const kNOTIFY_TRY_REDIRECT_FOCUS_FROM_COLLAPSED_TAB = 'try-redirect-focus-from-collaped-tab'; export const kNOTIFY_TRY_EXPAND_TREE_FROM_FOCUSED_PARENT = 'try-expand-tree-from-focused-parent'; export const kNOTIFY_TRY_EXPAND_TREE_FROM_FOCUSED_BUNDLED_PARENT = 'try-expand-tree-from-focused-bundled-parent'; export const kNOTIFY_TRY_EXPAND_TREE_FROM_ATTACHED_CHILD = 'try-expand-tree-from-attached-child'; export const kNOTIFY_TRY_EXPAND_TREE_FROM_FOCUSED_COLLAPSED_TAB = 'try-expand-tree-from-focused-collapsed-tab'; export const kNOTIFY_TRY_EXPAND_TREE_FROM_LONG_PRESS_CTRL_KEY = 'try-expand-tree-from-long-press-ctrl-key'; export const kNOTIFY_TRY_EXPAND_TREE_FROM_END_TAB_SWITCH = 'try-expand-tree-from-end-tab-switch'; export const kNOTIFY_TRY_EXPAND_TREE_FROM_EXPAND_COMMAND = 'try-expand-tree-from-expand-command'; export const kNOTIFY_TRY_EXPAND_TREE_FROM_EXPAND_ALL_COMMAND = 'try-expand-tree-from-expand-all-command'; export const kNOTIFY_TRY_COLLAPSE_TREE_FROM_OTHER_EXPANSION = 'try-collapse-tree-from-other-expansion'; export const kNOTIFY_TRY_COLLAPSE_TREE_FROM_COLLAPSE_COMMAND = 'try-collapse-tree-from-collapse-command'; export const kNOTIFY_TRY_COLLAPSE_TREE_FROM_COLLAPSE_ALL_COMMAND = 'try-collapse-tree-from-collapse-all-command'; export const kNOTIFY_TRY_FIXUP_TREE_ON_TAB_MOVED = 'try-fixup-tree-on-tab-moved'; export const kNOTIFY_TRY_HANDLE_NEWTAB = 'try-handle-newtab'; export const kNOTIFY_TRY_SCROLL_TO_ACTIVATED_TAB = 'try-scroll-to-activated-tab'; export const kGET_TREE = 'get-tree'; export const kGET_LIGHT_TREE = 'get-light-tree'; export const kATTACH = 'attach'; export const kDETACH = 'detach'; export const kINDENT = 'indent'; export const kDEMOTE = 'demote'; export const kOUTDENT = 'outdent'; export const kPROMOTE = 'promote'; export const kMOVE_UP = 'move-up'; export const kMOVE_TO_START = 'move-to-start'; export const kMOVE_DOWN = 'move-down'; export const kMOVE_TO_END = 'move-to-end'; export const kMOVE_BEFORE = 'move-before'; export const kMOVE_AFTER = 'move-after'; export const kFOCUS = 'focus'; export const kCREATE = 'create'; export const kDUPLICATE = 'duplicate'; export const kGROUP_TABS = 'group-tabs'; export const kOPEN_IN_NEW_WINDOW = 'open-in-new-window'; export const kREOPEN_IN_CONTAINER = 'reopen-in-container'; export const kGET_TREE_STRUCTURE = 'get-tree-structure'; export const kSET_TREE_STRUCTURE = 'set-tree-structure'; export const kSTICK_TAB = 'stick-tab'; export const kUNSTICK_TAB = 'unstick-tab'; export const kTOGGLE_STICKY_STATE = 'toggle-sticky-state'; export const kREGISTER_AUTO_STICKY_STATES = 'register-auto-sticky-states'; export const kUNREGISTER_AUTO_STICKY_STATES = 'unregister-auto-sticky-states'; export const kCOLLAPSE_TREE = 'collapse-tree'; export const kEXPAND_TREE = 'expand-tree'; export const kTOGGLE_TREE_COLLAPSED = 'toggle-tree-collapsed'; export const kADD_TAB_STATE = 'add-tab-state'; export const kREMOVE_TAB_STATE = 'remove-tab-state'; export const kSCROLL = 'scroll'; export const kSTOP_SCROLL = 'stop-scroll'; export const kSCROLL_LOCK = 'scroll-lock'; export const kSCROLL_UNLOCK = 'scroll-unlock'; export const kNOTIFY_SCROLLED = 'scrolled'; export const kBLOCK_GROUPING = 'block-grouping'; export const kUNBLOCK_GROUPING = 'unblock-grouping'; export const kSET_TOOLTIP_TEXT = 'set-tooltip-text'; export const kCLEAR_TOOLTIP_TEXT = 'clear-tooltip-text'; export const kGRANT_TO_REMOVE_TABS = 'grant-to-remove-tabs'; export const kOPEN_ALL_BOOKMARKS_WITH_STRUCTURE = 'open-all-bookmarks-with-structure'; export const kSET_EXTRA_CONTENTS = 'set-extra-contents'; export const kCLEAR_EXTRA_CONTENTS = 'clear-extra-contents'; export const kCLEAR_ALL_EXTRA_CONTENTS = 'clear-all-extra-contents'; export const kSET_EXTRA_TAB_CONTENTS = 'set-extra-tab-contents'; // for backward compatibility export const kCLEAR_EXTRA_TAB_CONTENTS = 'clear-extra-tab-contents'; // for backward compatibility export const kCLEAR_ALL_EXTRA_TAB_CONTENTS = 'clear-all-extra-tab-contents'; // for backward compatibility export const kSET_EXTRA_NEW_TAB_BUTTON_CONTENTS = 'set-extra-new-tab-button-contents'; // for backward compatibility export const kCLEAR_EXTRA_NEW_TAB_BUTTON_CONTENTS = 'clear-extra-new-tab-button-contents'; // for backward compatibility export const kSET_EXTRA_CONTENTS_PROPERTIES = 'set-extra-contents-properties'; export const kFOCUS_TO_EXTRA_CONTENTS = 'focus-to-extra-contents'; export const kGET_DRAG_DATA = 'get-drag-data'; const BULK_MESSAGING_TYPES = new Set([ kNOTIFY_SIDEBAR_SHOW, kNOTIFY_SIDEBAR_HIDE, kNOTIFY_TABS_RENDERED, kNOTIFY_TABS_UNRENDERED, kNOTIFY_TAB_STICKY_STATE_CHANGED, kNOTIFY_EXTRA_CONTENTS_FOCUS, kNOTIFY_EXTRA_CONTENTS_BLUR, kNOTIFY_TABBAR_OVERFLOW, kNOTIFY_TABBAR_UNDERFLOW, kNOTIFY_TAB_MOUSEMOVE, kNOTIFY_TAB_MOUSEOVER, kNOTIFY_TAB_MOUSEOUT, kNOTIFY_TAB_DRAGREADY, kNOTIFY_TAB_DRAGCANCEL, kNOTIFY_TAB_DRAGSTART, kNOTIFY_TAB_DRAGENTER, kNOTIFY_TAB_DRAGEXIT, kNOTIFY_TAB_DRAGEND, kNOTIFY_TREE_ATTACHED, kNOTIFY_TREE_DETACHED, kNOTIFY_TREE_COLLAPSED_STATE_CHANGED, kNOTIFY_NATIVE_TAB_DRAGSTART, kNOTIFY_NATIVE_TAB_DRAGEND, kNOTIFY_PERMISSIONS_CHANGED, ]); export const kCONTEXT_MENU_OPEN = 'contextMenu-open'; export const kCONTEXT_MENU_CREATE = 'contextMenu-create'; export const kCONTEXT_MENU_UPDATE = 'contextMenu-update'; export const kCONTEXT_MENU_REMOVE = 'contextMenu-remove'; export const kCONTEXT_MENU_REMOVE_ALL = 'contextMenu-remove-all'; export const kCONTEXT_MENU_CLICK = 'contextMenu-click'; export const kCONTEXT_MENU_SHOWN = 'contextMenu-shown'; export const kCONTEXT_MENU_HIDDEN = 'contextMenu-hidden'; export const kFAKE_CONTEXT_MENU_OPEN = 'fake-contextMenu-open'; export const kFAKE_CONTEXT_MENU_CREATE = 'fake-contextMenu-create'; export const kFAKE_CONTEXT_MENU_UPDATE = 'fake-contextMenu-update'; export const kFAKE_CONTEXT_MENU_REMOVE = 'fake-contextMenu-remove'; export const kFAKE_CONTEXT_MENU_REMOVE_ALL = 'fake-contextMenu-remove-all'; export const kFAKE_CONTEXT_MENU_CLICK = 'fake-contextMenu-click'; export const kFAKE_CONTEXT_MENU_SHOWN = 'fake-contextMenu-shown'; export const kFAKE_CONTEXT_MENU_HIDDEN = 'fake-contextMenu-hidden'; export const kOVERRIDE_CONTEXT = 'override-context'; export const kCOMMAND_BROADCAST_API_REGISTERED = 'ws:broadcast-registered'; export const kCOMMAND_BROADCAST_API_UNREGISTERED = 'ws:broadcast-unregistered'; export const kCOMMAND_BROADCAST_API_PERMISSION_CHANGED = 'ws:permission-changed'; export const kCOMMAND_REQUEST_INITIALIZE = 'ws:request-initialize'; export const kCOMMAND_REQUEST_CONTROL_STATE = 'ws:request-control-state'; export const kCOMMAND_GET_ADDONS = 'ws:get-addons'; export const kCOMMAND_SET_API_PERMISSION = 'ws:set-api-permisssion'; export const kCOMMAND_NOTIFY_PERMISSION_CHANGED = 'ws:notify-api-permisssion-changed'; export const kCOMMAND_UNREGISTER_ADDON = 'ws:unregister-addon'; export const INTERNAL_CALL_PREFIX = 'ws:api:'; export const INTERNAL_CALL_PREFIX_MATCHER = new RegExp(`^${INTERNAL_CALL_PREFIX}`); export const kNEWTAB_CONTEXT_NEWTAB_COMMAND = 'newtab-command'; export const kNEWTAB_CONTEXT_WITH_OPENER = 'with-opener'; export const kNEWTAB_CONTEXT_DUPLICATED = 'duplicated'; export const kNEWTAB_CONTEXT_FROM_PINNED = 'from-pinned'; export const kNEWTAB_CONTEXT_FROM_EXTERNAL = 'from-external'; export const kNEWTAB_CONTEXT_WEBSITE_SAME_TO_ACTIVE_TAB = 'website-same-to-active-tab'; export const kNEWTAB_CONTEXT_FROM_ABOUT_ADDONS = 'from-about-addons'; export const kNEWTAB_CONTEXT_UNKNOWN = 'unknown'; const mAddons = new Map(); let mScrollLockedBy = {}; let mGroupingBlockedBy = {}; const mPendingMessagesFor = new Map(); const mMessagesPendedAt = new Map(); // you should use this to reduce memory usage around effective favicons export function clearCache(cache) { cache.effectiveFavIconUrls = {}; } // This function is complex a little, but we should not make a custom class for this purpose, // bacause instances of the class will be very short-life and increases RAM usage on // massive tabs case. export async function exportTab(sourceTab, { addonId, light, isContextTab, interval, permissions, cache, cacheKey } = {}) { const normalizedSourceTab = Tab.get(sourceTab); if (!normalizedSourceTab) throw new Error(`Fatal error: tried to export not a tab. ${sourceTab}`); sourceTab = normalizedSourceTab; if (!interval) interval = 0; if (!cache) cache = {}; if (!cache.tabs) cache.tabs = {}; if (!cache.effectiveFavIconUrls) cache.effectiveFavIconUrls = {}; if (!permissions) { permissions = (!addonId || addonId == browser.runtime.id) ? kPERMISSIONS_ALL : new Set(getGrantedPermissionsForAddon(addonId)); if (addonId && configs.incognitoAllowedExternalAddons.includes(addonId)) permissions.add(kPERMISSION_INCOGNITO); } if (!cacheKey) cacheKey = `${sourceTab.id}:${Array.from(permissions).sort().join(',')}`; if (!sourceTab || (sourceTab.incognito && !permissions.has(kPERMISSION_INCOGNITO))) return null; // The promise is cached here instead of the result, // to avoid cache miss caused by concurrent call. if (!(cacheKey in cache.tabs)) { cache.tabs[cacheKey] = sourceTab.$TST.exportForAPI({ addonId, light, isContextTab, interval, permissions, cache, cacheKey }); } return cache.tabs[cacheKey]; } export function getAddon(id) { return mAddons.get(id); } export function getGrantedPermissionsForAddon(id) { const addon = getAddon(id); return addon?.grantedPermissions || new Set(); } function registerAddon(id, addon) { log('addon is registered: ', id, addon); // inherit properties from last effective value const oldAddon = getAddon(id); if (oldAddon) { for (const param of [ 'name', 'icons', 'listeningTypes', 'allowBulkMessaging', 'lightTree', 'style', 'permissions', ]) { if (!(param in addon) && param in oldAddon) { addon[param] = oldAddon[param]; } } } if (!addon.listeningTypes) { // for backward compatibility, send all message types available on TST 2.4.16 by default. addon.listeningTypes = [ kNOTIFY_READY, kNOTIFY_SHUTDOWN, kNOTIFY_TAB_CLICKED, kNOTIFY_TAB_MOUSEDOWN, kNOTIFY_TAB_MOUSEUP, kNOTIFY_NEW_TAB_BUTTON_CLICKED, kNOTIFY_NEW_TAB_BUTTON_MOUSEDOWN, kNOTIFY_NEW_TAB_BUTTON_MOUSEUP, kNOTIFY_TABBAR_CLICKED, kNOTIFY_TABBAR_MOUSEDOWN, kNOTIFY_TABBAR_MOUSEUP ]; } let requestedPermissions = addon.permissions || []; if (!Array.isArray(requestedPermissions)) requestedPermissions = [requestedPermissions]; addon.requestedPermissions = new Set(requestedPermissions); const grantedPermissions = configs.grantedExternalAddonPermissions[id] || []; addon.grantedPermissions = new Set(grantedPermissions); if (Constants.IS_BACKGROUND && !addon.bypassPermissionCheck && addon.requestedPermissions.size > 0 && addon.grantedPermissions.size != addon.requestedPermissions.size) notifyPermissionRequest(addon, addon.requestedPermissions); addon.id = id; addon.lastRegistered = Date.now(); mAddons.set(id, addon); onRegistered.dispatch(addon); } const mPermissionNotificationForAddon = new Map(); async function notifyPermissionRequest(addon, requestedPermissions) { log('notifyPermissionRequest ', addon, requestedPermissions); if (mPermissionNotificationForAddon.has(addon.id)) return; mPermissionNotificationForAddon.set(addon.id, -1); const id = await browser.notifications.create({ type: 'basic', iconUrl: Constants.kNOTIFICATION_DEFAULT_ICON, title: browser.i18n.getMessage('api_requestedPermissions_title'), message: browser.i18n.getMessage(`api_requestedPermissions_message${isLinux() ? '_linux' : ''}`, [ addon.name || addon.title || addon.id, Array.from(requestedPermissions, permission => { if (permission == kPERMISSION_INCOGNITO) return null; try { return browser.i18n.getMessage(`api_requestedPermissions_type_${permission}`) || permission; } catch(_error) { return permission; } }).filter(permission => !!permission).join('\n') ]) }); mPermissionNotificationForAddon.set(addon.id, id); } function setPermissions(addon, permisssions) { addon.grantedPermissions = permisssions; const cachedPermissions = JSON.parse(JSON.stringify(configs.grantedExternalAddonPermissions)); cachedPermissions[addon.id] = Array.from(addon.grantedPermissions); configs.grantedExternalAddonPermissions = cachedPermissions; notifyPermissionChanged(addon); } function notifyPermissionChanged(addon) { const permissions = Array.from(addon.grantedPermissions); browser.runtime.sendMessage({ type: kCOMMAND_BROADCAST_API_PERMISSION_CHANGED, id: addon.id, permissions }); if (addon.id == browser.runtime.id) return; browser.runtime.sendMessage(addon.id, { type: kNOTIFY_PERMISSIONS_CHANGED, grantedPermissions: permissions.filter(permission => permission.startsWith('!')), privateWindowAllowed: configs.incognitoAllowedExternalAddons.includes(addon.id) }).catch(ApiTabs.createErrorHandler()); } function unregisterAddon(id) { const addon = getAddon(id); log('addon is unregistered: ', id, addon); onUnregistered.dispatch(addon); mAddons.delete(id); mPendingMessagesFor.delete(id); mMessagesPendedAt.delete(id); delete mScrollLockedBy[id]; delete mGroupingBlockedBy[id]; } export function getAddons() { return mAddons.entries(); } const mConnections = new Map(); function onCommonCommand(message, sender) { if (!message || typeof message.type != 'string') return; const addon = getAddon(sender.id); switch (message.type) { case kSCROLL_LOCK: mScrollLockedBy[sender.id] = true; if (!addon) registerAddon(sender.id, sender); return Promise.resolve(true); case kSCROLL_UNLOCK: delete mScrollLockedBy[sender.id]; return Promise.resolve(true); case kBLOCK_GROUPING: mGroupingBlockedBy[sender.id] = true; if (!addon) registerAddon(sender.id, sender); return Promise.resolve(true); case kUNBLOCK_GROUPING: delete mGroupingBlockedBy[sender.id]; return Promise.resolve(true); case kSET_EXTRA_TAB_CONTENTS: if (!addon) registerAddon(sender.id, sender); break; case kSET_EXTRA_NEW_TAB_BUTTON_CONTENTS: if (!addon) registerAddon(sender.id, sender); break; } } // ======================================================================= // for backend // ======================================================================= let mInitialized = false; let mPromisedInitialized = null; if (Constants.IS_BACKGROUND) { browser.runtime.onMessageExternal.addListener(onBackendCommand); browser.runtime.onConnectExternal.addListener(port => { if (!mInitialized || !configs.APIEnabled) return; const sender = port.sender; mConnections.set(sender.id, port); port.onMessage.addListener(message => { const messages = message.messages || [message]; for (const oneMessage of messages) { onMessageExternal.dispatch(oneMessage, sender); SidebarConnection.sendMessage({ type: 'external', oneMessage, sender }); } }); port.onDisconnect.addListener(_message => { mConnections.delete(sender.id); onBackendCommand({ type: kUNREGISTER_SELF, sender }).catch(ApiTabs.createErrorSuppressor()); }); }); } export async function initAsBackend() { // We must listen API messages from other addons here beacause: // * Before notification messages are sent to other addons. // * After configs are loaded and TST's background page is almost completely initialized. // (to prevent troubles like breakage of `configs.cachedExternalAddons`, see also: // https://github.com/piroor/treestyletab/issues/2300#issuecomment-498947370 ) mInitialized = true; let resolver; mPromisedInitialized = new Promise((resolve, _reject) => { resolver = resolve; }); const manifest = browser.runtime.getManifest(); registerAddon(browser.runtime.id, { id: browser.runtime.id, internalId: browser.runtime.getURL('').replace(/^moz-extension:\/\/([^\/]+)\/.*$/, '$1'), icons: manifest.icons, listeningTypes: [ kNOTIFY_EXTRA_CONTENTS_CLICKED, kNOTIFY_EXTRA_CONTENTS_DBLCLICKED, kNOTIFY_EXTRA_CONTENTS_MOUSEDOWN, kNOTIFY_EXTRA_CONTENTS_MOUSEUP, kNOTIFY_EXTRA_CONTENTS_KEYDOWN, kNOTIFY_EXTRA_CONTENTS_KEYUP, kNOTIFY_EXTRA_CONTENTS_INPUT, kNOTIFY_EXTRA_CONTENTS_CHANGE, kNOTIFY_EXTRA_CONTENTS_COMPOSITIONSTART, kNOTIFY_EXTRA_CONTENTS_COMPOSITIONUPDATE, kNOTIFY_EXTRA_CONTENTS_COMPOSITIONEND, kNOTIFY_EXTRA_CONTENTS_FOCUS, kNOTIFY_EXTRA_CONTENTS_BLUR, ], bypassPermissionCheck: true, allowBulkMessaging: true, lightTree: true, }); const respondedAddons = []; const notifiedAddons = {}; const notifyAddons = configs.knownExternalAddons.concat(configs.cachedExternalAddons); log('initAsBackend: notifyAddons = ', notifyAddons); await Promise.all(notifyAddons.map(async id => { if (id in notifiedAddons) return; notifiedAddons[id] = true; try { id = await new Promise((resolve, reject) => { let responded = false; browser.runtime.sendMessage(id, { type: kNOTIFY_READY }).then(() => { responded = true; resolve(id); }).catch(ApiTabs.createErrorHandler(reject)); setTimeout(() => { if (!responded) reject(new Error(`TSTAPI.initAsBackend: addon ${id} does not respond.`)); }, 3000); }); if (id) respondedAddons.push(id); } catch(e) { console.log(`TSTAPI.initAsBackend: failed to send "ready" message to "${id}":`, e); } })); log('initAsBackend: respondedAddons = ', respondedAddons); configs.cachedExternalAddons = respondedAddons; onInitialized.dispatch(); resolver(); } if (Constants.IS_BACKGROUND) { browser.notifications.onClicked.addListener(notificationId => { if (!mInitialized) return; for (const [addonId, id] of mPermissionNotificationForAddon.entries()) { if (id != notificationId) continue; mPermissionNotificationForAddon.delete(addonId); browser.tabs.create({ url: `moz-extension://${location.host}/options/options.html#externalAddonPermissionsGroup` }); break; } }); browser.notifications.onClosed.addListener((notificationId, _byUser) => { if (!mInitialized) return; for (const [addonId, id] of mPermissionNotificationForAddon.entries()) { if (id != notificationId) continue; mPermissionNotificationForAddon.delete(addonId); break; } }); SidebarConnection.onConnected.addListener((windowId, openCount) => { SidebarConnection.sendMessage({ type: Constants.kCOMMAND_NOTIFY_CONNECTION_READY, windowId, openCount, }); }); SidebarConnection.onDisconnected.addListener((windowId, openCount) => { broadcastMessage({ type: kNOTIFY_SIDEBAR_HIDE, window: windowId, windowId, openCount }); }); /* // This mechanism doesn't work actually. // See also: https://github.com/piroor/treestyletab/issues/2128#issuecomment-454650407 const mConnectionsForAddons = new Map(); browser.runtime.onConnectExternal.addListener(port => { if (!mInitialized) return; const sender = port.sender; log('Connected: ', sender.id); const connections = mConnectionsForAddons.get(sender.id) || new Set(); connections.add(port); const addon = getAddon(sender.id); if (!addon) { // treat as register-self const message = { id: sender.id, internalId: sender.url.replace(/^moz-extension:\/\/([^\/]+)\/.*$/, '$1'), newlyInstalled: !configs.cachedExternalAddons.includes(sender.id) }; registerAddon(sender.id, message); browser.runtime.sendMessage({ type: kCOMMAND_BROADCAST_API_REGISTERED, sender, message }).catch(ApiTabs.createErrorSuppressor()); if (message.newlyInstalled) configs.cachedExternalAddons = configs.cachedExternalAddons.concat([sender.id]); } const onMessage = message => { onBackendCommand(message, sender); }; port.onMessage.addListener(onMessage); const onDisconnected = _message => { log('Disconnected: ', sender.id); port.onMessage.removeListener(onMessage); port.onDisconnect.removeListener(onDisconnected); connections.delete(port); if (connections.size > 0) return; setTimeout(() => { // if it is not re-registered while 10sec, it may be uninstalled. if (getAddon(sender.id)) return; configs.cachedExternalAddons = configs.cachedExternalAddons.filter(id => id != sender.id); }, 10 * 1000); browser.runtime.sendMessage({ type: kCOMMAND_BROADCAST_API_UNREGISTERED, sender }).catch(ApiTabs.createErrorSuppressor()); unregisterAddon(sender.id); mConnectionsForAddons.delete(sender.id); } port.onDisconnect.addListener(onDisconnected); }); */ browser.runtime.onMessage.addListener((message, _sender) => { if (!mInitialized || !message || typeof message.type != 'string') return; switch (message.type) { case kCOMMAND_REQUEST_INITIALIZE: return Promise.resolve({ addons: exportAddons(), scrollLocked: mScrollLockedBy, groupingLocked: mGroupingBlockedBy }); case kCOMMAND_REQUEST_CONTROL_STATE: return Promise.resolve({ scrollLocked: mScrollLockedBy, groupingLocked: mGroupingBlockedBy }); case kCOMMAND_GET_ADDONS: return mPromisedInitialized.then(() => { const addons = []; for (const [id, addon] of mAddons.entries()) { addons.push({ id, label: addon.name || addon.title || addon.id, permissions: Array.from(addon.requestedPermissions), permissionsGranted: Array.from(addon.requestedPermissions).join(',') == Array.from(addon.grantedPermissions).join(',') }); } return addons; }); case kCOMMAND_SET_API_PERMISSION: setPermissions(getAddon(message.id), new Set(message.permissions)); break; case kCOMMAND_NOTIFY_PERMISSION_CHANGED: notifyPermissionChanged(getAddon(message.id)); break; case kCOMMAND_UNREGISTER_ADDON: unregisterAddon(message.id); break; } }); } const mPromisedOnBeforeUnload = new Promise((resolve, _reject) => { // If this promise doesn't do anything then there seems to be a timeout so it only works if TST is disabled within about 10 seconds after this promise is used as a response to a message. After that it will not throw an error for the waiting extension. // If we use the following then the returned promise will be rejected when TST is disabled even for longer times: window.addEventListener('beforeunload', () => resolve()); }); const mWaitingShutdownMessages = new Map(); function onBackendCommand(message, sender) { if (message?.messages) return Promise.all( message.messages.map(oneMessage => onBackendCommand(oneMessage, sender)) ); if (!mInitialized || !message || typeof message != 'object' || typeof message.type != 'string') return; const results = onMessageExternal.dispatch(message, sender); const firstPromise = results.find(result => result instanceof Promise); if (firstPromise) return firstPromise; switch (message.type) { case kPING: return Promise.resolve(true); case kREGISTER_SELF: return (async () => { message.internalId = sender.url.replace(/^moz-extension:\/\/([^\/]+)\/.*$/, '$1'); message.id = sender.id; message.subPanel = message.subPanel || message.subpanel || null; if (message.subPanel) { const url = typeof message.subPanel.url === 'string' && new URL(message.subPanel.url, new URL('/', sender.url)); if (!url || url.hostname !== message.internalId) { console.error(`"subPanel.url" must refer to a page packed in the registering extension.`); message.subPanel.url = 'about:blank?error=invalid-origin' } else message.subPanel.url = url.href; } message.newlyInstalled = !configs.cachedExternalAddons.includes(sender.id); registerAddon(sender.id, message); browser.runtime.sendMessage({ type: kCOMMAND_BROADCAST_API_REGISTERED, sender: sender, message: message }).catch(ApiTabs.createErrorSuppressor()); if (message.newlyInstalled) configs.cachedExternalAddons = configs.cachedExternalAddons.concat([sender.id]); if (message.listeningTypes && message.listeningTypes.includes(kWAIT_FOR_SHUTDOWN) && !mWaitingShutdownMessages.has(sender.id)) { const onShutdown = () => { const storedShutdown = mWaitingShutdownMessages.get(sender.id); // eslint-disable-next-line no-use-before-define if (storedShutdown && storedShutdown !== promisedShutdown) return; // it is obsolete const addon = getAddon(sender.id); const lastRegistered = addon?.lastRegistered; setTimeout(() => { // if it is re-registered immediately, it was updated or reloaded. const addon = getAddon(sender.id); if (addon && addon.lastRegistered != lastRegistered) return; // otherwise it is uninstalled. browser.runtime.sendMessage({ type: kCOMMAND_BROADCAST_API_UNREGISTERED, sender }).catch(ApiTabs.createErrorSuppressor()); unregisterAddon(sender.id); configs.cachedExternalAddons = configs.cachedExternalAddons.filter(id => id != sender.id); }, 350); }; const promisedShutdown = (async () => { try { const shouldUninit = await browser.runtime.sendMessage(sender.id, { type: kWAIT_FOR_SHUTDOWN }); if (!shouldUninit) return; } catch (_error) { // Extension was disabled. } finally { mWaitingShutdownMessages.delete(sender.id); } onShutdown(); })(); mWaitingShutdownMessages.set(sender.id, promisedShutdown); promisedShutdown.catch(onShutdown); } return { grantedPermissions: Array.from(getGrantedPermissionsForAddon(sender.id)).filter(permission => permission.startsWith('!')), privateWindowAllowed: configs.incognitoAllowedExternalAddons.includes(sender.id) }; })(); case kUNREGISTER_SELF: return (async () => { browser.runtime.sendMessage({ type: kCOMMAND_BROADCAST_API_UNREGISTERED, sender }).catch(ApiTabs.createErrorSuppressor()); unregisterAddon(sender.id); configs.cachedExternalAddons = configs.cachedExternalAddons.filter(id => id != sender.id); return true; })(); case kWAIT_FOR_SHUTDOWN: return mPromisedOnBeforeUnload; default: return onCommonCommand(message, sender); } } function exportAddons() { const exported = {}; for (const [id, addon] of getAddons()) { exported[id] = addon; } return exported; } export function isGroupingBlocked() { return Object.keys(mGroupingBlockedBy).length > 0; } // ======================================================================= // for frontend // ======================================================================= export async function initAsFrontend() { let resolver; mPromisedInitialized = new Promise((resolve, _reject) => { resolver = resolve; }); log('initAsFrontend: start'); let response; while (true) { response = await browser.runtime.sendMessage({ type: kCOMMAND_REQUEST_INITIALIZE }); if (response) break; await wait(10); } browser.runtime.onMessageExternal.addListener(onFrontendCommand); log('initAsFrontend: response = ', response); importAddons(response.addons); for (const [, addon] of getAddons()) { onRegistered.dispatch(addon); } mScrollLockedBy = response.scrollLocked; mGroupingBlockedBy = response.groupingLocked; onInitialized.dispatch(); log('initAsFrontend: finish'); resolver(); mPromisedInitialized = null; } function onFrontendCommand(message, sender) { //console.log('onFrontendCommand ', message, sender); if (!configs.APIEnabled) return; if (message?.messages) return Promise.all( message.messages.map(oneMessage => onFrontendCommand(oneMessage, sender)) ); if (message && typeof message == 'object' && typeof message.type == 'string') { const results = onMessageExternal.dispatch(message, sender); log('onMessageExternal: ', message, ' => ', results, 'sender: ', sender); const firstPromise = results.find(result => result instanceof Promise); if (firstPromise) return firstPromise; } if (configs.incognitoAllowedExternalAddons.includes(sender.id) || !document.documentElement.classList.contains('incognito')) return onCommonCommand(message, sender); } if (Constants.IS_SIDEBAR) { browser.runtime.onMessage.addListener((message, _sender) => { if (!message || typeof message != 'object' || typeof message.type != 'string') return; switch (message.type) { case kCOMMAND_BROADCAST_API_REGISTERED: registerAddon(message.sender.id, message.message); break; case kCOMMAND_BROADCAST_API_UNREGISTERED: unregisterAddon(message.sender.id); break; case kCOMMAND_BROADCAST_API_PERMISSION_CHANGED: { const addon = getAddon(message.id); addon.grantedPermissions = new Set(message.permissions); }; break; } }); } function importAddons(addons) { if (!addons) console.log(new Error('null import')); for (const id of Object.keys(mAddons)) { unregisterAddon(id); } for (const [id, addon] of Object.entries(addons)) { registerAddon(id, addon); } } export function isScrollLocked() { return Object.keys(mScrollLockedBy).length > 0; } export async function notifyScrolled(params = {}) { const lockers = Object.keys(mScrollLockedBy); const tab = params.tab; const windowId = TabsStore.getCurrentWindowId(); const tabs = Tab.getTabs(windowId); const cache = {}; const results = await broadcastMessage({ type: kNOTIFY_SCROLLED, tab: tab && tabs.find(another => another.id == tab.id), tabs, overflow: params.overflow, window: windowId, windowId, deltaX: params.event.deltaX, deltaY: params.event.deltaY, deltaZ: params.event.deltaZ, deltaMode: params.event.deltaMode, scrollTop: params.scrollContainer.scrollTop, scrollTopMax: params.scrollContainer.scrollTopMax, altKey: params.event.altKey, ctrlKey: params.event.ctrlKey, metaKey: params.event.metaKey, shiftKey: params.event.shiftKey, clientX: params.event.clientX, clientY: params.event.clientY, }, { targets: lockers, tabProperties: ['tab', 'tabs'], cache, }); for (const result of results) { if (!result || result.error || result.result === undefined) delete mScrollLockedBy[result.id]; } clearCache(cache); } // ======================================================================= // Common utilities to send notification messages to other addons // ======================================================================= export async function tryOperationAllowed(type, message = {}, { targets, except, tabProperties, cache } = {}) { if (mPromisedInitialized) await mPromisedInitialized; if (!hasListenerForMessageType(type, { targets, except })) { //log(`=> ${type}: no listener, always allowed`); return true; } cache = cache || {}; const results = await broadcastMessage({ ...message, type }, { targets, except, tabProperties, cache, }).catch(error => { if (configs.debug) console.error(error); return []; }); if (!results) { log(`=> ${type}: allowed because no one responded`); return true; } if (results.flat().some(result => result?.result)) { log(`=> ${type}: canceled by some helper addon`); return false; } log(`=> ${type}: allowed by all helper addons`); return true; } export function hasListenerForMessageType(type, { targets, except } = {}) { return getListenersForMessageType(type, { targets, except }).length > 0; } export function getListenersForMessageType(type, { targets, except } = {}) { targets = targets instanceof Set ? targets : new Set(Array.isArray(targets) ? targets : targets ? [targets] : []); except = except instanceof Set ? except : new Set(Array.isArray(except) ? except : except ? [except] : []); const finalTargets = new Set(); for (const [id, addon] of getAddons()) { if (addon.listeningTypes.includes(type) && (targets.size == 0 || targets.has(id)) && !except.has(id)) finalTargets.add(id); } //log('getListenersForMessageType ', { type, targets, except, finalTargets, all: mAddons }); return Array.from(finalTargets, getAddon); } export async function sendMessage(addonId, message, { tabProperties, cache, isContextTab } = {}) { if (mPromisedInitialized) await mPromisedInitialized; cache = cache || {}; const incognitoParams = { windowId: message.windowId || message.window }; for (const key of tabProperties) { if (!message[key]) continue; if (Array.isArray(message[key])) incognitoParams.tab = message[key][0].tab; else incognitoParams.tab = message[key].tab; break; } if (!isSafeAtIncognito(addonId, incognitoParams)) throw new Error(`Message from incognito source is not allowed for ${addonId}`); const safeMessage = await sanitizeMessage(message, { addonId, tabProperties, cache, isContextTab }); const result = directSendMessage(addonId, safeMessage); if (result.error) throw result.error; return result.result; } export async function broadcastMessage(message, { targets, except, tabProperties, cache, isContextTab } = {}) { if (!configs.APIEnabled) return []; if (mPromisedInitialized) await mPromisedInitialized; const listenerAddons = getListenersForMessageType(message.type, { targets, except }); tabProperties = tabProperties || []; cache = cache || {}; log(`broadcastMessage: sending message for ${message.type}: `, { message, listenerAddons, tabProperties }); const promisedResults = spawnMessages(new Set(listenerAddons.map(addon => addon.id)), { message, tabProperties, cache, isContextTab, }); return Promise.all(promisedResults).then(results => { log(`broadcastMessage: got responses for ${message.type}: `, results); return results; }).catch(ApiTabs.createErrorHandler()); } function* spawnMessages(targets, { message, tabProperties, cache, isContextTab }) { tabProperties = tabProperties || []; cache = cache || {}; const incognitoParams = { windowId: message.windowId || message.window }; for (const key of tabProperties) { if (!message[key]) continue; if (Array.isArray(message[key])) incognitoParams.tab = message[key][0].tab; else incognitoParams.tab = message[key].tab; break; } const send = async (id) => { if (!isSafeAtIncognito(id, incognitoParams)) return { id, result: undefined }; const allowedMessage = await sanitizeMessage(message, { addonId: id, tabProperties, cache, isContextTab }); const addon = getAddon(id) || {}; if (BULK_MESSAGING_TYPES.has(message.type) && addon.allowBulkMessaging) { const startAt = `${Date.now()}-${parseInt(Math.random() * 65000)}`; mMessagesPendedAt.set(id, startAt); const messages = mPendingMessagesFor.get(id) || []; messages.push(allowedMessage); mPendingMessagesFor.set(id, messages); (Constants.IS_BACKGROUND ? setTimeout : // because window.requestAnimationFrame is decelerate for an invisible document. window.requestAnimationFrame)(() => { if (mMessagesPendedAt.get(id) != startAt) return; const messages = mPendingMessagesFor.get(id); mPendingMessagesFor.delete(id); if (!messages || messages.length == 0) return; directSendMessage(id, messages.length == 1 ? messages[0] : { messages }); }, 0); return { id, result: null, }; } return directSendMessage(id, allowedMessage); }; for (const id of targets) { yield send(id); } } async function directSendMessage(id, message) { try { const result = await (id == browser.runtime.id ? browser.runtime.sendMessage( Array.isArray(message) ? message.map(message => ({ ...message, type: `${INTERNAL_CALL_PREFIX}${message.type}` })) : ({ ...message, type: `${INTERNAL_CALL_PREFIX}${message.type}` }) ) : browser.runtime.sendMessage(id, message)); return { id, result, }; } catch(error) { console.log(`Error on sending message to ${id}`, message, error); if (error && error.message == 'Could not establish connection. Receiving end does not exist.') { browser.runtime.sendMessage(id, { type: kNOTIFY_READY }).catch(_error => { console.log(`Unregistering missing helper addon ${id}...`); unregisterAddon(id); if (Constants.IS_SIDEBAR) browser.runtime.sendMessage({ type: kCOMMAND_UNREGISTER_ADDON, id }); }); } return { id, error, }; } } export function isSafeAtIncognito(addonId, { tab, windowId }) { if (addonId == browser.runtime.id) return true; const win = windowId && TabsStore.windows.get(windowId); const hasIncognitoInfo = win?.incognito || tab?.incognito; return !hasIncognitoInfo || configs.incognitoAllowedExternalAddons.includes(addonId); } async function sanitizeMessage(message, { addonId, tabProperties, cache, isContextTab }) { const addon = getAddon(addonId); if (!message || !tabProperties || tabProperties.length == 0 || addon.bypassPermissionCheck) return message; cache = cache || {}; const sanitizedProperties = {}; const tasks = []; if (tabProperties) { for (const name of tabProperties) { const treeItem = message[name]; if (!treeItem) continue; if (Array.isArray(treeItem)) tasks.push((async treeItems => { const tabs = await Promise.all(treeItems.map(treeItem => exportTab(treeItem, { addonId: addon.id, light: !!addon.lightTree, cache, isContextTab, }))); sanitizedProperties[name] = tabs.filter(tab => !!tab); })(treeItem)); else tasks.push((async () => { sanitizedProperties[name] = await exportTab(treeItem, { addonId: addon.id, light: !!addon.lightTree, cache, isContextTab, }); })()); } } await Promise.all(tasks); return { ...message, ...sanitizedProperties }; } // ======================================================================= // Common utilities for request-response type API call // ======================================================================= export async function getTargetTabs(message, sender) { const tabQuery = message.tabs || message.tabIds || message.tab || message.tabId; const windowId = message.window || message.windowId; if (Array.isArray(tabQuery)) await Promise.all(tabQuery.map(oneTabQuery => { if (typeof oneTabQuery == 'number') return Tab.waitUntilTracked(oneTabQuery) return true; })); else if (typeof tabQuery == 'number') await Tab.waitUntilTracked(tabQuery); if (windowId) await Tab.waitUntilTrackedAll(windowId); const queryOptions = {}; if (Array.isArray(queryOptions.states)) { queryOptions.states = queryOptions.states || []; queryOptions.states.push(...queryOptions.states.map(state => [state, true])); } if (Array.isArray(queryOptions.statesNot)) { queryOptions.states = queryOptions.statesNot || []; queryOptions.states.push(...queryOptions.statesNot.map(state => [state, false])); } if (Array.isArray(tabQuery)) return getTabsByQueries(tabQuery, { windowId, queryOptions, sender }); if (windowId) { if (tabQuery == '*') return Tab.getAllTabs(windowId, { ...queryOptions, iterator: true }); else if (!tabQuery) return Tab.getRootTabs(windowId, { ...queryOptions, iterator: true }); } if (tabQuery == '*') { const win = await browser.windows.getLastFocused({ windowTypes: ['normal'] }).catch(ApiTabs.createErrorHandler()); return Tab.getAllTabs(win.id, { ...queryOptions, iterator: true }); } if (tabQuery) { let tabs = await getTabsByQueries([tabQuery], { windowId, queryOptions, sender }); if (queryOptions.states) tabs = tabs.filter(tab => { const unified = new Set([...tab.$TST.states, ...queryOptions.states]); return unified.size == tab.$TST.states.size; }); if (queryOptions.statesNot) tabs = tabs.filter(tab => { const unified = new Set([...tab.$TST.states, ...queryOptions.statesNot]); return unified.size > tab.$TST.states.size; }); return tabs; } return []; } export async function getTargetRenderedTabs(message, sender) { // Don't touch to this "tabs" until they are finally returned. // Populating it to an array while operations will finishes // the iterator and returned tabs will become just blank. const tabs = await getTargetTabs(message, sender); if (!tabs) return tabs; const windowId = message.window || message.windowId || await browser.windows.getLastFocused({ windowTypes: ['normal'] }).catch(ApiTabs.createErrorHandler()).then(win => win?.id); const renderedTabIds = await browser.runtime.sendMessage({ type: Constants.kCOMMAND_GET_RENDERED_TAB_IDS, windowId, }); const renderedTabIdsSet = new Set(renderedTabIds); return Array.from(tabs).filter(tab => renderedTabIdsSet.has(tab.id)); } async function getTabsByQueries(queries, { windowId, queryOptions, sender }) { const win = !windowId && await browser.windows.getLastFocused({ populate: true }).catch(ApiTabs.createErrorHandler()); const activeWindow = TabsStore.windows.get(windowId || win.id) || win; const tabs = await Promise.all(queries.map(query => getTabsByQuery(query, { activeWindow, queryOptions, sender }).catch(error => { console.error(error); return null; }))); log('getTabsByQueries: ', queries, ' => ', tabs, 'sender: ', sender, windowId); return tabs.flat().filter(tab => !!tab); } async function getTabsByQuery(query, { activeWindow, queryOptions, sender }) { log('getTabsByQuery: ', { query, activeWindow, queryOptions, sender }); if (query && typeof query == 'object' && typeof query.id == 'number') // tabs.Tab query = query.id; let id = query; query = String(query).toLowerCase(); let baseTab = Tab.getActiveTab(activeWindow.id); // this sometimes happen when the active tab was detached from the window if (!baseTab) return null; const nonActiveTabMatched = query.match(/^([^-]+)-of-(.+)$/i); if (nonActiveTabMatched) { query = nonActiveTabMatched[1]; id = nonActiveTabMatched[2]; if (/^\d+$/.test(id)) id = parseInt(id); baseTab = Tab.get(id) || Tab.getByUniqueId(id); if (!baseTab) return null; } switch (query) { case 'active': case 'current': return baseTab; case 'parent': return baseTab.$TST.parent; case 'root': return baseTab.$TST.rootTab; case 'next': return baseTab.$TST.nextTab; case 'nextcyclic': return baseTab.$TST.nextTab || Tab.getFirstTab(baseTab.windowId, queryOptions || {}); case 'previous': case 'prev': return baseTab.$TST.previousTab; case 'previouscyclic': case 'prevcyclic': return baseTab.$TST.previousTab || Tab.getLastTab(baseTab.windowId, queryOptions || {}); case 'nextsibling': return baseTab.$TST.nextSiblingTab; case 'nextsiblingcyclic': { const nextSibling = baseTab.$TST.nextSiblingTab; if (nextSibling) return nextSibling; const parent = baseTab.$TST.parent; if (parent) return parent.$TST.firstChild; return Tab.getFirstTab(baseTab.windowId, queryOptions || {}); } case 'previoussibling': case 'prevsibling': return baseTab.$TST.previousSiblingTab; case 'previoussiblingcyclic': case 'prevsiblingcyclic': { const previousSiblingTab = baseTab.$TST.previousSiblingTab; if (previousSiblingTab) return previousSiblingTab; const parent = baseTab.$TST.parent; if (parent) return parent.$TST.lastChild; return Tab.getLastRootTab(baseTab.windowId, queryOptions || {}); } case 'nextvisible': return baseTab.$TST.nearestVisibleFollowingTab; case 'nextvisiblecyclic': return baseTab.$TST.nearestVisibleFollowingTab || Tab.getFirstVisibleTab(baseTab.windowId, queryOptions || {}); case 'previousvisible': case 'prevvisible': return baseTab.$TST.nearestVisiblePrecedingTab; case 'previousvisiblecyclic': case 'prevvisiblecyclic': return baseTab.$TST.nearestVisiblePrecedingTab || Tab.getLastVisibleTab(baseTab.windowId, queryOptions || {}); case 'lastdescendant': return baseTab.$TST.lastDescendant; case 'sendertab': return Tab.get(sender?.tab?.id) || null; case 'highlighted': case 'multiselected': return Tab.getHighlightedTabs(baseTab.windowId, queryOptions || {}); case 'allvisibles': return Tab.getVisibleTabs(baseTab.windowId, queryOptions || {}); case 'normalvisibles': return Tab.getVisibleTabs(baseTab.windowId, { ...(queryOptions || {}), normal: true }); default: return Tab.get(id) || Tab.getByUniqueId(id); } } export function formatResult(results, originalMessage) { if (Array.isArray(originalMessage.tabs) || originalMessage.tab == '*' || originalMessage.tabs == '*') return results; if (originalMessage.tab) return results[0]; return results; } const TABS_ARRAY_QUERY_MATCHER = /^(\*|allvisibles|normalvisibles)$/i; export async function formatTabResult(exportedTabs, originalMessage) { exportedTabs = await Promise.all(exportedTabs); if (Array.isArray(originalMessage.tabs) || TABS_ARRAY_QUERY_MATCHER.test(originalMessage.tab) || TABS_ARRAY_QUERY_MATCHER.test(originalMessage.tabs)) return exportedTabs.filter(tab => !!tab); return exportedTabs.length == 0 ? null : exportedTabs[0]; }