Files
tubestation/waterfox/browser/components/sidebar/background/background-cache.js
2025-11-06 14:13:52 +00:00

615 lines
21 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 { SequenceMatcher } from '/extlib/diff.js';
import {
log as internalLogger,
dumpTab,
wait,
mapAndFilter,
configs
} from '/common/common.js';
import * as ApiTabs from '/common/api-tabs.js';
import * as CacheStorage from '/common/cache-storage.js';
import * as Constants from '/common/constants.js';
import * as TabsInternalOperation from '/common/tabs-internal-operation.js';
import * as TabsStore from '/common/tabs-store.js';
import * as TabsUpdate from '/common/tabs-update.js';
import * as UniqueId from '/common/unique-id.js';
import MetricsData from '/common/MetricsData.js';
import { Tab } from '/common/TreeItem.js';
import * as Tree from './tree.js';
function log(...args) {
internalLogger('background/background-cache', ...args);
}
const kCONTENTS_VERSION = 5;
let mActivated = false;
const mCaches = {};
export function activate() {
mActivated = true;
configs.$addObserver(onConfigChange);
if (!configs.persistCachedTree) {
// clear obsolete cache
browser.windows.getAll().then(windows => {
for (const win of windows) {
browser.sessions.removeWindowValue(win.id, Constants.kWINDOW_STATE_CACHED_TABS).catch(ApiTabs.createErrorSuppressor());
browser.sessions.removeWindowValue(win.id, Constants.kWINDOW_STATE_CACHED_SIDEBAR_TABS_DIRTY).catch(ApiTabs.createErrorSuppressor());
}
});
}
}
// ===================================================================
// restoring tabs from cache
// ===================================================================
export async function restoreWindowFromEffectiveWindowCache(windowId, options = {}) {
MetricsData.add('restoreWindowFromEffectiveWindowCache: start');
log(`restoreWindowFromEffectiveWindowCache for ${windowId} start`);
const owner = options.owner || getWindowCacheOwner(windowId);
if (!owner) {
log(`restoreWindowFromEffectiveWindowCache for ${windowId} fail: no owner`);
return false;
}
cancelReservedCacheTree(windowId); // prevent to break cache before loading
const tabs = options.tabs || await browser.tabs.query({ windowId }).catch(ApiTabs.createErrorHandler());
if (configs.debug)
log(`restoreWindowFromEffectiveWindowCache for ${windowId} tabs: `, () => tabs.map(dumpTab));
const actualSignature = getWindowSignature(tabs);
let cache = options.caches?.get(`window-${owner.windowId}`) || await MetricsData.addAsync('restoreWindowFromEffectiveWindowCache: window cache', getWindowCache(owner, Constants.kWINDOW_STATE_CACHED_TABS));
if (!cache) {
log(`restoreWindowFromEffectiveWindowCache for ${windowId} fail: no cache`);
return false;
}
const promisedPermanentStates = Promise.all(tabs.map(tab => Tab.get(tab.id).$TST.getPermanentStates())); // don't await at here for better performance
MetricsData.add('restoreWindowFromEffectiveWindowCache: validity check: start');
let cachedSignature = cache?.signature;
log(`restoreWindowFromEffectiveWindowCache for ${windowId}: got from the owner `, {
owner, cachedSignature, cache
});
const signatureGeneratedFromCache = getWindowSignature(cache.tabs).join('\n');
if (cache &&
cache.tabs &&
cachedSignature &&
cachedSignature.join('\n') != signatureGeneratedFromCache) {
log(`restoreWindowFromEffectiveWindowCache for ${windowId}: cache is broken.`, {
cachedSignature: cachedSignature.join('\n'),
signatureGeneratedFromCache
});
cache = cachedSignature = null;
TabsInternalOperation.clearCache(owner);
MetricsData.add('restoreWindowFromEffectiveWindowCache: validity check: signature failed.');
}
else {
MetricsData.add('restoreWindowFromEffectiveWindowCache: validity check: signature passed.');
}
if (options.ignorePinnedTabs &&
cache &&
cache.tabs &&
cachedSignature) {
cache.tabs = trimTabsCache(cache.tabs, cache.pinnedTabsCount);
cachedSignature = trimSignature(cachedSignature, cache.pinnedTabsCount);
}
MetricsData.add('restoreWindowFromEffectiveWindowCache: validity check: matching actual signature of got cache');
const signatureMatchResult = matcheSignatures({
actual: actualSignature,
cached: cachedSignature
});
log(`restoreWindowFromEffectiveWindowCache for ${windowId}: verify cache`, {
cache, actualSignature, cachedSignature,
...signatureMatchResult,
});
if (!cache ||
cache.version != kCONTENTS_VERSION ||
!signatureMatchResult.matched) {
log(`restoreWindowFromEffectiveWindowCache for ${windowId}: no effective cache`);
TabsInternalOperation.clearCache(owner);
MetricsData.add('restoreWindowFromEffectiveWindowCache: validity check: actual signature failed.');
return false;
}
MetricsData.add('restoreWindowFromEffectiveWindowCache: validity check: actual signature passed.');
cache.offset = signatureMatchResult.offset;
log(`restoreWindowFromEffectiveWindowCache for ${windowId}: restore from cache`);
const permanentStates = await MetricsData.addAsync('restoreWindowFromEffectiveWindowCache: permanentStatus', promisedPermanentStates); // await at here for better performance
const restored = await MetricsData.addAsync('restoreWindowFromEffectiveWindowCache: restoreTabsFromCache', restoreTabsFromCache(windowId, { cache, tabs, permanentStates }));
if (restored) {
MetricsData.add(`restoreWindowFromEffectiveWindowCache: window ${windowId} succeeded`);
// Now we reload the sidebar if it is opened, because it is the easiest way
// to synchronize state of tabs completely.
log('reload sidebar for a tree restored from cache');
browser.runtime.sendMessage({
type: Constants.kCOMMAND_RELOAD,
windowId,
}).catch(ApiTabs.createErrorSuppressor());
}
else {
MetricsData.add(`restoreWindowFromEffectiveWindowCache: window ${windowId} failed`);
}
log(`restoreWindowFromEffectiveWindowCache for ${windowId}: restored = ${restored}`);
return restored;
}
function getWindowSignature(tabs) {
return tabs.map(tab => `${tab.cookieStoreId},${tab.incognito},${tab.pinned},${tab.url}`);
}
function trimSignature(signature, ignoreCount) {
if (!ignoreCount || ignoreCount < 0)
return signature;
return signature.slice(ignoreCount);
}
function trimTabsCache(cache, ignoreCount) {
if (!ignoreCount || ignoreCount < 0)
return cache;
return cache.slice(ignoreCount);
}
function matcheSignatures(signatures) {
const operations = (new SequenceMatcher(signatures.cached, signatures.actual)).operations();
log('matcheSignatures: operations ', operations);
let matched = false;
let offset = 0;
for (const operation of operations) {
const [tag, fromStart, fromEnd, toStart, toEnd] = operation;
if (tag == 'equal' &&
fromEnd - fromStart == signatures.cached.length) {
matched = true;
break;
}
offset += toEnd - toStart;
}
log('matcheSignatures: ', { matched, offset });
return { matched, offset };
}
async function restoreTabsFromCache(windowId, params = {}) {
if (!params.cache ||
params.cache.version != kCONTENTS_VERSION)
return false;
return (await restoreTabsFromCacheInternal({
windowId,
tabs: params.tabs,
permanentStates: params.permanentStates,
offset: params.cache.offset || 0,
cache: params.cache.tabs
})).length > 0;
}
async function restoreTabsFromCacheInternal(params) {
MetricsData.add('restoreTabsFromCacheInternal: start');
log(`restoreTabsFromCacheInternal: restore tabs for ${params.windowId} from cache`);
const offset = params.offset || 0;
const win = TabsStore.windows.get(params.windowId);
const tabs = params.tabs.slice(offset).map(tab => Tab.get(tab.id));
if (offset > 0 &&
tabs.length <= offset) {
log('restoreTabsFromCacheInternal: missing window');
return [];
}
log(`restoreTabsFromCacheInternal: there is ${win.tabs.size} tabs`);
if (params.cache.length != tabs.length) {
log('restoreTabsFromCacheInternal: Mismatched number of restored tabs?');
return [];
}
try {
await MetricsData.addAsync('rebuildAll: fixupTabsRestoredFromCache', fixupTabsRestoredFromCache(tabs, params.permanentStates, params.cache));
}
catch(e) {
log(String(e), e.stack);
throw e;
}
log('restoreTabsFromCacheInternal: done');
if (configs.debug)
Tab.dumpAll();
return tabs;
}
async function fixupTabsRestoredFromCache(tabs, permanentStates, cachedTabs) {
MetricsData.add('fixupTabsRestoredFromCache: start');
if (tabs.length != cachedTabs.length)
throw new Error(`fixupTabsRestoredFromCache: Mismatched number of tabs restored from cache, tabs=${tabs.length}, cachedTabs=${cachedTabs.length}`);
log('fixupTabsRestoredFromCache start ', () => ({ tabs: tabs.map(dumpTab), cachedTabs }));
const idMap = new Map();
let remappedCount = 0;
// step 1: build a map from old id to new id
tabs = tabs.map((tab, index) => {
const cachedTab = cachedTabs[index];
const oldId = cachedTab.id;
tab = Tab.get(tab.id);
log(`fixupTabsRestoredFromCache: remap ${oldId} => ${tab.id}`);
idMap.set(oldId, tab);
if (oldId != tab.id)
remappedCount++;
return tab;
});
if (remappedCount && remappedCount < tabs.length)
throw new Error(`fixupTabsRestoredFromCache: not a window restoration, only ${remappedCount} tab(s) are restored (maybe restoration of closed tabs)`);
MetricsData.add('fixupTabsRestoredFromCache: step 1 done.');
// step 2: restore information of tabs
// Do this from bottom to top, to reduce post operations for modified trees.
// (Attaching a tab to an existing tree will trigger "update" task for
// existing ancestors, but attaching existing subtree to a solo tab won't
// trigger such tasks.)
// See also: https://github.com/piroor/treestyletab/issues/2278#issuecomment-519387792
for (let i = tabs.length - 1; i > -1; i--) {
fixupTabRestoredFromCache(tabs[i], permanentStates[i], cachedTabs[i], idMap);
}
// step 3: restore collapsed/expanded state of tabs and finalize the
// restoration process
// Do this from top to bottom, because a tab can be placed under an
// expanded parent but the parent can be placed under a collapsed parent.
for (const tab of tabs) {
fixupTabRestoredFromCachePostProcess(tab);
}
MetricsData.add('fixupTabsRestoredFromCache: step 2 done.');
}
function fixupTabRestoredFromCache(tab, permanentStates, cachedTab, idMap) {
tab.$TST.clear();
const tabStates = new Set([...cachedTab.$TST.states, ...permanentStates]);
for (const state of Constants.kTAB_TEMPORARY_STATES) {
tabStates.delete(state);
}
tab.$TST.states = tabStates;
tab.$TST.attributes = cachedTab.$TST.attributes;
log('fixupTabRestoredFromCache children: ', cachedTab.$TST.childIds);
const childIds = mapAndFilter(cachedTab.$TST.childIds, oldId => {
const tab = idMap.get(oldId);
return tab?.id || undefined;
});
tab.$TST.children = childIds;
if (childIds.length > 0)
tab.$TST.setAttribute(Constants.kCHILDREN, `|${childIds.join('|')}|`);
else
tab.$TST.removeAttribute(Constants.kCHILDREN);
log('fixupTabRestoredFromCache children: => ', tab.$TST.childIds);
log('fixupTabRestoredFromCache parent: ', cachedTab.$TST.parentId);
const parentTab = idMap.get(cachedTab.$TST.parentId) || null;
tab.$TST.parent = parentTab;
if (parentTab)
tab.$TST.setAttribute(Constants.kPARENT, parentTab.id);
else
tab.$TST.removeAttribute(Constants.kPARENT);
log('fixupTabRestoredFromCache parent: => ', tab.$TST.parentId);
if (tab.discarded) {
tab.$TST.addState(Constants.kTAB_STATE_PENDING);
}
tab.$TST.temporaryMetadata.set('treeStructureAlreadyRestoredFromSessionData', true);
}
function fixupTabRestoredFromCachePostProcess(tab) {
const parentTab = tab.$TST.parent;
if (parentTab &&
(parentTab.$TST.collapsed ||
parentTab.$TST.subtreeCollapsed)) {
tab.$TST.addState(Constants.kTAB_STATE_COLLAPSED);
tab.$TST.addState(Constants.kTAB_STATE_COLLAPSED_DONE);
}
else {
tab.$TST.removeState(Constants.kTAB_STATE_COLLAPSED);
tab.$TST.removeState(Constants.kTAB_STATE_COLLAPSED_DONE);
}
TabsStore.updateIndexesForTab(tab);
TabsUpdate.updateTab(tab, tab, { forceApply: true, onlyApply: true });
}
// ===================================================================
// updating cache
// ===================================================================
async function updateWindowCache(owner, key, value) {
if (!owner)
return;
if (configs.persistCachedTree) {
try {
if (value)
await CacheStorage.setValue({
windowId: owner.windowId,
key,
value,
store: CacheStorage.BACKGROUND,
});
else
await CacheStorage.deleteValue({
windowId: owner.windowId,
key,
store: CacheStorage.BACKGROUND,
});
return;
}
catch(error) {
console.log(`BackgroundCache.updateWindowCache for ${owner.windowId}/${key} failed: `, error.message, error.stack, error);
}
}
const storageKey = `backgroundCache-${await UniqueId.ensureWindowId(owner.windowId)}-${key}`;
if (value)
mCaches[storageKey] = value;
else
delete mCaches[storageKey];
}
export function markWindowCacheDirtyFromTab(tab, akey) {
const win = TabsStore.windows.get(tab.windowId);
if (!win) // the window may be closed
return;
if (win.markWindowCacheDirtyFromTabTimeout)
clearTimeout(win.markWindowCacheDirtyFromTabTimeout);
win.markWindowCacheDirtyFromTabTimeout = setTimeout(() => {
win.markWindowCacheDirtyFromTabTimeout = null;
updateWindowCache(win.lastWindowCacheOwner, akey, true);
}, 100);
}
async function getWindowCache(owner, key) {
if (configs.persistCachedTree) {
try {
const value = await CacheStorage.getValue({
windowId: owner.windowId,
key,
store: CacheStorage.BACKGROUND,
});
return value;
}
catch(error) {
console.log(`BackgroundCache.getWindowCache for ${owner.windowId}/${key} failed: `, error.message, error.stack, error);
}
}
const storageKey = `backgroundCache-${await UniqueId.ensureWindowId(owner.windowId)}-${key}`;
return mCaches[storageKey];
}
function getWindowCacheOwner(windowId) {
const tab = Tab.getFirstTab(windowId);
if (!tab)
return null;
return {
id: tab.id,
windowId: tab.windowId
};
}
export async function reserveToCacheTree(windowId, trigger) {
if (!mActivated ||
!configs.useCachedTree)
return;
const win = TabsStore.windows.get(windowId);
if (!win)
return;
// If there is any opening (but not resolved its unique id yet) tab,
// we are possibly restoring tabs. To avoid cache breakage before
// restoration, we must wait until we know whether there is any other
// restoring tab or not.
if (Tab.needToWaitTracked(windowId))
await Tab.waitUntilTrackedAll(windowId);
if (win.promisedAllTabsRestored) // not restored yet
return;
if (!trigger && configs.debug)
trigger = new Error().stack;
log('reserveToCacheTree for window ', windowId, trigger);
TabsInternalOperation.clearCache(win.lastWindowCacheOwner);
if (trigger)
reserveToCacheTree.triggers.add(trigger);
if (win.waitingToCacheTree)
clearTimeout(win.waitingToCacheTree);
win.waitingToCacheTree = setTimeout(() => {
const triggers = [...reserveToCacheTree.triggers];
reserveToCacheTree.triggers.clear();
cacheTree(windowId, triggers);
}, 500);
}
reserveToCacheTree.triggers = new Set();
function cancelReservedCacheTree(windowId) {
const win = TabsStore.windows.get(windowId);
if (win?.waitingToCacheTree) {
clearTimeout(win.waitingToCacheTree);
delete win.waitingToCacheTree;
}
}
async function cacheTree(windowId, triggers) {
if (Tab.needToWaitTracked(windowId))
await Tab.waitUntilTrackedAll(windowId);
const win = TabsStore.windows.get(windowId);
if (!win ||
!configs.useCachedTree)
return;
const signature = getWindowSignature(Tab.getAllTabs(windowId));
if (win.promisedAllTabsRestored) // not restored yet
return;
//log('save cache for ', windowId);
win.lastWindowCacheOwner = getWindowCacheOwner(windowId);
if (!win.lastWindowCacheOwner)
return;
const firstTab = Tab.getFirstTab(windowId);
if (firstTab.incognito) { // never save cache for incognito windows
updateWindowCache(win.lastWindowCacheOwner, Constants.kWINDOW_STATE_CACHED_TABS, null);
return;
}
log('cacheTree for window ', windowId, triggers/*{ stack: configs.debug && new Error().stack }*/);
updateWindowCache(win.lastWindowCacheOwner, Constants.kWINDOW_STATE_CACHED_TABS, {
version: kCONTENTS_VERSION,
tabs: TabsStore.windows.get(windowId).export(true).tabs,
pinnedTabsCount: Tab.getPinnedTabs(windowId).length,
signature
});
}
// update cache on events
Tab.onCreated.addListener((tab, _info = {}) => {
if (!tab.$TST.previousTab) { // it is a new cache owner
const win = TabsStore.windows.get(tab.windowId);
if (win.lastWindowCacheOwner)
TabsInternalOperation.clearCache(win.lastWindowCacheOwner);
}
reserveToCacheTree(tab.windowId, 'tab created');
});
// Tree restoration for "Restore Previous Session"
Tab.onWindowRestoring.addListener(async ({ windowId, restoredCount }) => {
if (!configs.useCachedTree)
return;
log('Tabs.onWindowRestoring ', { windowId, restoredCount });
if (restoredCount == 1) {
log('Tabs.onWindowRestoring: single tab restored');
return;
}
log('Tabs.onWindowRestoring: continue ', windowId);
MetricsData.add('Tabs.onWindowRestoring restore start');
const tabs = await browser.tabs.query({ windowId }).catch(ApiTabs.createErrorHandler());
try {
await restoreWindowFromEffectiveWindowCache(windowId, {
ignorePinnedTabs: true,
owner: tabs[tabs.length - 1],
tabs
});
MetricsData.add('Tabs.onWindowRestoring restore end');
}
catch(e) {
log('Tabs.onWindowRestoring: FATAL ERROR while restoring tree from cache', String(e), e.stack);
}
});
Tab.onRemoved.addListener((tab, info) => {
if (!tab.$TST.previousTab) // the tab was the cache owner
TabsInternalOperation.clearCache(tab);
wait(0).then(() => {
// "Restore Previous Session" closes some tabs at first, so we should not clear the old cache yet.
// See also: https://dxr.mozilla.org/mozilla-central/rev/5be384bcf00191f97d32b4ac3ecd1b85ec7b18e1/browser/components/sessionstore/SessionStore.jsm#3053
reserveToCacheTree(info.windowId, 'tab removed');
});
});
Tab.onMoved.addListener((tab, info) => {
if (info.fromIndex == 0) // the tab is not the cache owner anymore
TabsInternalOperation.clearCache(tab);
reserveToCacheTree(info.windowId, 'tab moved');
});
Tab.onUpdated.addListener((tab, info) => {
markWindowCacheDirtyFromTab(tab, Constants.kWINDOW_STATE_CACHED_SIDEBAR_TABS_DIRTY);
if ('url' in info)
reserveToCacheTree(tab.windowId, 'tab updated');
});
Tab.onStateChanged.addListener((tab, state, _has) => {
if (state == Constants.kTAB_STATE_STICKY)
markWindowCacheDirtyFromTab(tab, Constants.kWINDOW_STATE_CACHED_SIDEBAR_TABS_DIRTY);
});
Tree.onSubtreeCollapsedStateChanging.addListener(tab => {
reserveToCacheTree(tab.windowId, 'subtree collapsed/expanded');
});
Tree.onAttached.addListener((tab, _info) => {
wait(0).then(() => {
// "Restore Previous Session" closes some tabs at first and it causes tree changes, so we should not clear the old cache yet.
// See also: https://dxr.mozilla.org/mozilla-central/rev/5be384bcf00191f97d32b4ac3ecd1b85ec7b18e1/browser/components/sessionstore/SessionStore.jsm#3053
reserveToCacheTree(tab.windowId, 'tab attached to tree');
});
});
Tree.onDetached.addListener((tab, _info) => {
TabsInternalOperation.clearCache(tab);
wait(0).then(() => {
// "Restore Previous Session" closes some tabs at first and it causes tree changes, so we should not clear the old cache yet.
// See also: https://dxr.mozilla.org/mozilla-central/rev/5be384bcf00191f97d32b4ac3ecd1b85ec7b18e1/browser/components/sessionstore/SessionStore.jsm#3053
reserveToCacheTree(tab.windowId, 'tab detached from tree');
});
});
Tab.onPinned.addListener(tab => {
reserveToCacheTree(tab.windowId, 'tab pinned');
});
Tab.onUnpinned.addListener(tab => {
if (tab.$TST.previousTab) // the tab was the cache owner
TabsInternalOperation.clearCache(tab);
reserveToCacheTree(tab.windowId, 'tab unpinned');
});
Tab.onShown.addListener(tab => {
reserveToCacheTree(tab.windowId, 'tab shown');
});
Tab.onHidden.addListener(tab => {
reserveToCacheTree(tab.windowId, 'tab hidden');
});
browser.windows.onRemoved.addListener(async windowId => {
try {
CacheStorage.clearForWindow(windowId);
}
catch(_error) {
}
const storageKeyPart = `Cache-${await UniqueId.ensureWindowId(windowId)}-`;
for (const key in mCaches) {
if (key.includes(storageKeyPart))
delete mCaches[key];
}
});
function onConfigChange(key) {
switch (key) {
case 'useCachedTree':
case 'persistCachedTree':
browser.windows.getAll({
populate: true,
windowTypes: ['normal']
}).then(windows => {
for (const win of windows) {
const owner = win.tabs[win.tabs.length - 1];
if (configs[key]) {
reserveToCacheTree(win.id, 'config change');
}
else {
TabsInternalOperation.clearCache(owner);
location.reload();
}
}
}).catch(ApiTabs.createErrorSuppressor());
break;
}
}