software/hoardy-web/./extension/background/state-tab.js

Passively capture, archive, and hoard your web browsing history, including the contents of the pages you visit, for later offline viewing, replay, mirroring, data scraping, and/or indexing. Your own personal private Wayback Machine that can also archive HTTP POST requests and responses, as well as most other HTTP-level data.

Files

Raw Source

Contents

/*
 * Copyright (c) 2023-2026 Jan Malakhovski <oxij@oxij.org>
 *
 * This file is a part of `hoardy-web` project.
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program. If not, see <http://www.gnu.org/licenses/>.
 */

/*
 * Per-tab/origin config and stats.
 */

"use strict";

// per-window config
let windowConfig = new Map();

function getWindowConfig(windowId) {
    if (windowId === WINDOW_ID_NONE)
        return config.root;
    return cacheSingleton(windowConfig, windowId, () => assignRec({}, config.root));
}

function setWindowConfig(windowId, wincfg, oldWincfg, dontBroadcast) {
    fixSourceConfig(wincfg, config.root);
    windowConfig.set(windowId, wincfg);

    if (dontBroadcast)
        return;

    broadcastToPopup("updateWindowConfig", windowId, wincfg);

    scheduleUpdateDisplay(false);
}

// per-window state
let windowStateDefaults = assignRec({}, dynamicStateDefaults, commonStateDefaults);

let windowState = new Map();

function getWindowState(windowId) {
    return cacheSingleton(windowState, windowId, () => assignRec({}, windowStateDefaults));
}

// per-tab config
let tabConfig = new Map();

// per-tab state
let tabStateDefaults = assignRec({
    windowId: WINDOW_ID_NONE,
    emitTimeStamp: 0,
}, windowStateDefaults);

// per-tab state
let tabState = new Map();

function getTabStateInternal(tabId) {
    return cacheSingleton(tabState, tabId, () => assignRec({}, tabStateDefaults));
}

let tabStateProxyFuncs = {
    set(obj, name, value) {
        let old = obj[name];
        obj[name] = value;

        if (!windowStateDefaults.hasOwnProperty(name))
            return true;

        let diff = value - old;

        let winstate = getWindowState(obj.windowId);
        winstate[name] += diff;

        state[name] += diff;
        wantSaveState = true;

        return true;
    }
}

function getTabState(tabId, fromExtension) {
    // NB: not tracking extensions separately here, unlike with configs
    if (fromExtension)
        tabId = TAB_ID_NONE;
    return new Proxy(getTabStateInternal(tabId), tabStateProxyFuncs);
}

function getWindowId(tabId) {
    let tabstate = tabState.get(tabId);
    if (tabstate === undefined)
        return WINDOW_ID_NONE;
    return tabstate.windowId;
}

function prefillChildren(data) {
    return assignRec({
        children: assignRec({}, data),
    }, data);
}

function getTabConfig(tabId, fromExtension) {
    if (fromExtension)
        return prefillChildren(config.extension);
    if (tabId === TAB_ID_NONE)
        return prefillChildren(config.background);
    console.assert(tabId !== undefined, "tabId !== undefined");
    return cacheSingleton(tabConfig, tabId,
                          (tabId) => prefillChildren(getWindowConfig(getWindowId(tabId))));
}

function fixTabConfig(tabId, url, cfg, oldCfg) {
    fixSourceConfig(cfg, () => getWindowConfig(getWindowId(tabId)));
    fixSourceConfig(cfg.children, cfg);

    if (url !== undefined) {
        // force some settings based on tab's URL
        if ((cfg.autoReplay || cfg.children.autoReplay) && config.autoReplayOffInReplay && isServerURL(url))
            cfg.autoReplay = cfg.children.autoReplay = false;
        if ((!cfg.workOffline || !cfg.children.workOffline) && (
               config.workOfflineFile && url.startsWith("file:") ||
               config.workOfflineData && url.startsWith("data:")) ||
               config.workOfflineReplay && isServerURL(url)
           )
            cfg.workOffline = cfg.children.workOffline = true;
    }

    // the most common case
    if (cfg === oldCfg)
        return;

    // propagate any changes to `.children`
    for (let field of Object.keys(cfg)) {
        if (field === "children")
            continue;

        if (cfg[field] !== oldCfg[field])
            cfg.children[field] = cfg[field];
    }
}

function setTabConfig(tabId, tabUrl, tabcfg, oldTabcfg, dontBroadcast) {
    fixTabConfig(tabId, tabUrl, tabcfg, oldTabcfg);
    tabConfig.set(tabId, tabcfg);

    if (dontBroadcast)
        return;

    broadcastToPopup("updateTabConfig", tabId, tabcfg);

    if (useDebugger) {
        // Chromium does not provide `browser.menus.onShown` event
        updateMenu(tabcfg);
        syncDebuggersState();
    }

    scheduleUpdateDisplay(false, tabId);
}

// collect all tabs referenced in not yet archived reqres
// NB: ignores rrfilter.limit
function getUsedTabs(rrfilter, unqueued, problematic, limbo, log, dumping) {
    let rrpredicate = compileReqresFilter(rrfilter)[1];

    let set = new Set();

    function collect(reqres, c) {
        if (c && rrpredicate(reqres))
            set.add(reqres.tabId);
    }
    applyToReqres13(
        collect, true,
        unqueued, unqueued, unqueued, unqueued, unqueued, // inFlight
        problematic, // problematic
        limbo, log, dumping, // limbo, log, queue
        dumping, // bundled
        dumping, dumping, true // unstashed, unarchived, bugged out
    );

    return set;
}

async function smartSwitchTabs(highlight, direction, roundRobin) {
    let usedTabs = getUsedTabs({sessionId}, true, true, true, false, false);
    let isInteresting = (tabId) => usedTabs.has(tabId);

    let tabs = await browser.tabs.query({currentWindow: true});

    if (highlight)
        tabs = tabs.filter((tab) => isInteresting(tab.id));

    if (tabs.length === 0)
        return;

    tabs.sort((a, b) => {
        let aid = a.id;
        let bid = b.id;

        if (!highlight) {
            // interesing ones go first
            let ai = isInteresting(aid);
            let bi = isInteresting(bid);
            if (ai !== bi)
                return ai ? -1 : 1;
        }

        // otherwise, sort in order of their last `emitTimeStamp`s
        return getTabState(bid).emitTimeStamp - getTabState(aid).emitTimeStamp;
    });
    if (direction)
        tabs.reverse();

    let act = roundRobin ? mapRoundRobinTabsPerWindow : mapTabsPerWindow;

    await Promise.all(
        act((windowId, tabs) => {
            if (config.debugRuntime)
                console.log("smartSwitchTabs", windowId, tabs.map((tab) => tab.id));

            if (highlight)
                return browser.tabs.highlight(assignRec(
                    { windowId, tabs: tabs.map((e) => e.index) },
                    useDebugger ? undefined : { populate: false }
                )).catch(logError);
            else
                return browser.tabs.highlight(assignRec(
                    { windowId, tabs: [tabs[0].index] },
                    useDebugger ? undefined : { populate: false }
                )).catch(logError);
                // NB: not doing
                //   return browser.tabs.update(tabs[0].id, {active: true}).catch(logError);
                // instead because that keeps old highlight if `tabs[0]` was highlighted too
        }, tabs)
    );
}

// Free unused `tabConfig` and `tabState` structures.
function cleanupTabs() {
    let usedTabs = getUsedTabs(null, true, true, true, true, true);

    // delete configs of closed and unused tabs
    for (let tabId of Array.from(tabConfig.keys())) {
        if(tabId === TAB_ID_NONE || openTabs.has(tabId) || usedTabs.has(tabId))
            continue;
        if (config.debugRuntime)
            console.log("removing config of tab", tabId);
        tabConfig.delete(tabId);
        tabState.delete(tabId);
    }

    // delete any stale leftovers from tabState
    for (let tabId of Array.from(tabState.keys())) {
        if(tabId === TAB_ID_NONE || openTabs.has(tabId) || usedTabs.has(tabId))
            continue;
        console.warn("removing stale tab state", tabId);
        tabState.delete(tabId);
    }
}

// Closed tab auto-cleanup.

function cleanupProblematicAfterTab(tabId) {
    if (config.debugRuntime)
        console.log("MAIN: cleaning up reqresProblematic after tab", tabId);

    let unprob = unmarkProblematic({tabId})[1];

    if (config.problematicNotify === true && unprob > 0)
        browser.notifications.create(`cleanedProblematic-${tabId}`, {
            title: "Hoardy-Web: AUTO",
            message: `Auto-unmarked ${unprob} problematic reqres from tab #${tabId}.`,
            iconUrl: iconURL("problematic", 128),
            type: "basic",
        }).catch(logError);

    cleanupTabs();

    return unprob > 0 ? tabId : undefined;
}

function cleanupLimboAfterTab(tabId) {
    if (config.debugRuntime)
        console.log("MAIN: cleaning up reqresLimbo after tab", tabId);

    let what;
    let unlimbo = 0;
    if (config.autoPopInLimboCollect) {
        what = "collected";
        unlimbo = popInLimbo(true, {tabId})[1];
    } else if (config.autoPopInLimboDiscard) {
        what = "discarded";
        unlimbo = popInLimbo(false, {tabId})[1];
    }

    if (config.autoNotify && unlimbo > 0)
        browser.notifications.create(`cleanedLimbo-${tabId}`, {
            title: "Hoardy-Web: AUTO",
            message: `Auto-${what} ${unlimbo} in-limbo reqres from tab #${tabId}.`,
            iconUrl: iconURL("limbo", 128),
            type: "basic",
        }).catch(logError);

    cleanupTabs();

    return unlimbo > 0 ? tabId : undefined;
}

function cleanupAfterTab(tabId) {
    let tabstate = tabState.get(tabId);
    if (tabstate === undefined)
        return;

    let updatedTabId;

    if (config.autoUnmarkProblematic && tabstate.problematicTotal > 0) {
        cleanupProblematicAfterTab(tabId);
        updatedTabId = tabId;
    }

    if ((config.autoPopInLimboCollect || config.autoPopInLimboDiscard) && tabstate.inLimboTotal > 0) {
        if (config.autoTimeout === 0) {
            cleanupLimboAfterTab(tabId);
            updatedTabId = tabId;
        } else {
            let name = `cleanup-tab#${tabId}`;
            resetSingletonTimeout(scheduledDelayed, name, config.autoTimeout * 1000, () => {
                runSynchronouslyB(name, cleanupLimboAfterTab, tabId);
                scheduleEndgame(tabId);
            });
        }
    }

    return updatedTabId;
}

function closeTabThenDiscardInLimbo(tabId) {
    runSynchronouslyB("closeTabThenDiscardInLimbo", async () => {
        try {
            await browser.tabs.remove(tabId);
        } catch (err) {
            logError(err);
            return;
        }
        // drop some stuff immediately
        popInLimbo(false, {tabId});
        // drop the rest when it finishes
        runSynchronouslyWhenNoInFlight(tabId, `discardTab#${tabId}`, () => syncPopInLimbo(false, {tabId}));
    });
}

// New tab spawning.

function spawnChildTab(url, newWindow, tab) {
    let windowId =  tab.windowId;
    let tabId = tab.id;

    // See (openerIdsWorkaround).
    openerIds.push([windowId, tabId]);

    if (newWindow && browser.windows !== undefined)
        return browser.windows.create({
            url,
            incognito: tab.incognito,
        }).catch(logError);

    return browser.tabs.create({
        url,
        windowId,
        openerTabId: tabId,
    }).catch(logError);
}

// Tracking open tabs and generating their configs.

let openTabs = new Set();
let negateConfigFor = new Set();
let openerIds = [];

function processNewTab(tabId, windowId, openerTabId) {
    openTabs.add(tabId);

    let openerWindowId = windowId;

    if (openerIds.length > 0)
        // (openerIdsWorkaround):
        //
        // Work around the fact that `browser.windows.create` has no `openerTabId` argument.
        //
        // Also, on Chromium, `browser.tabs.create` with `openerTabId` specified does not pass it
        // into `openerTabId` of `handleTabCreated` (it's a bug).
        [openerWindowId, openerTabId] = openerIds.shift();

    let wincfg;

    if (openerWindowId !== windowId) {
        // this is a new tab spawned into a new window, copy old window's config
        wincfg = assignRec({}, getWindowConfig(openerWindowId));
        setWindowConfig(windowId, wincfg, wincfg);
    } else
        wincfg = getWindowConfig(windowId);

    let tabstate = getTabStateInternal(tabId);
    // force it, in case an async function created it via `getTabState`
    tabstate.windowId = windowId;
    tabstate.emitTimeStamp = Date.now();

    let oldTabcfg;
    let tabcfg = oldTabcfg = prefillChildren(openerTabId !== undefined ? getTabConfig(openerTabId).children : wincfg);

    if (openerTabId !== undefined && negateConfigFor.delete(openerTabId)) {
        // Negate `tabcfg.collecting` when `openerTabId` is in `negateConfigFor`.
        tabcfg = assignRec({}, tabcfg);
        tabcfg.collecting = !tabcfg.collecting;
    }

    setTabConfig(tabId, undefined, tabcfg, oldTabcfg);

    return tabcfg;
}

function processUpdateTab(tabId, windowId) {
    let tabstate = tabState.get(tabId);
    if (tabstate === undefined)
        return false;

    let oldWindowId = tabstate.windowId;
    if (oldWindowId === windowId)
        return false;

    // so, this tab was moved from one window to another

    // if `windowId` is a completely new window, clone `oldWindowId`'s per-window config instead of
    // using `config.root`
    let wincfg = windowConfig.get(windowId);
    if (wincfg === undefined) {
        wincfg = assignRec({}, getWindowConfig(oldWindowId));
        setWindowConfig(windowId, wincfg, wincfg);
    }

    // update its state
    tabstate.windowId = windowId;

    // substract its stats from the first and add them to the second
    let oldWinstate = getWindowState(oldWindowId);
    let winstate = getWindowState(windowId);

    for (let k of Object.keys(windowStateDefaults)) {
        let v = tabstate[k];
        oldWinstate[k] -= v;
        winstate[k] += v;
    }

    // apply this change to all the reqres
    function rewrite(v) {
        if (v.tabId === tabId)
            v.windowId = windowId;
    }
    applyToReqres13(rewrite, true);

    // reset everything
    broadcastToState(tabId, "resetInFlight", () => getInFlight(null));
    broadcastToState(tabId, "resetProblematic", getProblematic);
    broadcastToState(tabId, "resetInLimbo", getInLimbo);
    broadcastToState(tabId, "resetLog", reqresLog);
    broadcastToState(tabId, "resetQueued", getQueued);
    broadcastToState(tabId, "resetUnarchived", getUnarchived);
    broadcastToState(tabId, "resetBuggedOut", getBuggedOut);

    return true;
}

function processRemoveTab(tabId) {
    openTabs.delete(tabId);

    let updatedTabId;
    if (useDebugger)
        updatedTabId = stopInFlight({tabId}, "capture::EMIT_FORCED::BY_CLOSED_TAB");
    updatedTabId = mergeUpdatedTabIds(updatedTabId, cleanupAfterTab(tabId));

    scheduleEndgame(updatedTabId);
}

function processReplaceTab(addedTabId, removedTabId) {
    openTabs.delete(removedTabId);
    openTabs.add(addedTabId);

    // NB: Not calling `.delete`s below because some async stuff might still be using the old
    // values, so we have to make `addedTabId` and `removedTabId` share their states.
    //
    // Unused keys will be freed in `cleanupTabs`.

    let tabcfg = tabConfig.get(removedTabId);
    if (tabcfg !== undefined)
        // tabConfig.delete(removedTabId);
        tabConfig.set(addedTabId);

    let tabstate = tabState.get(removedTabId);
    if (tabstate !== undefined)
        // tabState.delete(removedTabId);
        tabState.set(addedTabId, tabState);

    scheduleUpdateDisplay(false, addedTabId);
}