software/hoardy-web/./extension/background/state-global.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/>.
 */

/*
 * Global config and state.
 */

"use strict";

// default config
let sourceConfigDefaults = {
    autoReplay: false,
    // are in work offline mode?
    workOffline: false,
    // are we collecting new data?
    collecting: true,
    // are we also collecting work offline requests?
    collectingWorkOffline: true,
    problematicNotify: true,
    limbo: false,
    negLimbo: false,
    stashLimbo: true,
    bucket: "default",
};

let configVersion = 9;

let configDefaults = {
    version: configVersion,

    // behavior
    history: 1024,
    autoReloadOnUpdates: false,

    // user interface
    sparse: null,
    colors: null,
    colorblind: false,
    seasonal: true,
    season: {
        halloween: null,
        winter: null,
    },
    pureText: false,
    animateIcon: 800,
    spawnNewTabs: !isMobile,

    // notifications
    verbose: true,
    escapeNotifications: false,
    hintNotify: true,
    invisibleUINotify: true,

    // Firefox workarounds
    workaroundFirefoxFirstRequest: true,

    // Chromium workarounds
    workaroundChromiumResetRootTab: true,
    workaroundChromiumResetRootTabURL: "about:blank",
    workaroundChromiumDebugTimeout: 3,

    // persistence

    // should we stash unarchived reqres to local storage?
    stash: true,
    // are we archiving? or temporarily paused
    archive: true,

    // prefer `IndexedDB` to `storage.local` for stashing and saving?
    preferIndexedDB: useDebugger,
    // GZip dumps written to local storage
    gzipLSDumps: true,

    // export via exportAs
    archiveExportAs: false,
    rearchiveExportAs: !isMobile,
    exportAsHumanReadable: true,
    exportAsBundle: true,
    exportAsMaxSize: 64,
    exportAsTimeout: 3,
    exportAsInFlightTimeout: 60,
    gzipExportAs: true,

    // submission and replay via HTTP
    archiveSubmitHTTP: false,
    rearchiveSubmitHTTP: false,
    replaySubmitHTTP: null,
    submitHTTPURLBase: "http://127.0.0.1:3210/",
    submitHTTPParanoid: true,

    // saving to local storage
    archiveSaveLS: true,
    persistLSParanoid: true,

    // archiving notifications
    archiveDoneNotify: true,
    persistFailedNotify: true,
    archiveStuckNotify: true,

    // problematic marking options
    markProblematicPartialRequest: false,
    markProblematicBuggy: true,
    markProblematicCanceled: false,
    markProblematicNoResponse: true,
    markProblematicIncomplete: true,
    markProblematicIncompleteFC: false,
    markProblematicTransientCodes: true,
    markProblematicPermanentCodes: false,
    markProblematicWithImportantErrors: true,
    markProblematicPickedWithErrors: true,
    markProblematicDroppedWithErrors: false,

    // problematic unmarking options
    autoUnmarkProblematic: false,
    autoUnmarkProblematicSimilar: true,
    autoUnmarkProblematicSimilarAcrossTabs: true,
    autoUnmarkProblematicSimilarAcrossLimbo: true,

    // problematic notifications
    problematicNotify: null,
    problematicNotifyNumber: 3,

    // picking options
    archivePartialRequest: true,
    archiveBuggy: true,
    archiveCanceled: false,
    archiveNoResponse: false,
    archiveIncompleteResponse: false,
    archive1xxCodes: true,
    archive3xxCodes: true,
    archiveTransientCodes: true,
    archivePermanentCodes: true,
    archiveWithErrors: true,

    // limbo notifications
    limboMaxNumber: 1024,
    limboMaxSize: 128,
    limboNotifyInterval: 300,
    limboNotify: true,

    // automatic actions
    autoPopInLimboCollect: false,
    autoPopInLimboDiscard: false,
    // automatic actions notifications
    autoTimeout: 0,
    autoNotify: true,

    // work offline settings
    workOfflineFile: true,
    workOfflineData: false,
    workOfflineReplay: true,
    autoReplayOffInReplay: true,

    root: assignRec(
        {
            snapshottable: true,
            replayable: true,
            settleDelay: 5,
            settleRetries: 5,
        },
        sourceConfigDefaults,
    ),

    background: assignRec({}, sourceConfigDefaults, {
        bucket: "background",
    }),

    extension: assignRec({}, sourceConfigDefaults, {
        bucket: "extension",
    }),

    // debugging options
    ephemeral: false, // stop the config from being saved to disk
    debugConfig: false, // allow unsafe config
    debugRuntime: false, // runtime debugging
    logRuntime: false, // log runtime events
    debugPersisence: false, // log stashes and archivals
    debugCaptures: false, // log capture events
    dumpCaptures: false, // log CBOR dumps
    snapshotAny: false, // snapshot isBoringOrServerURL
    discardAll: false, // drop all reqres on archival

    // meta
    lastSeenVersion: manifest.version,
    seenChangelog: true,
    seenHelp: false,
};

// archiving/replay server config
let serverConfigDefaults = {
    baseURL: configDefaults.submitHTTPURLBase,
    alive: false,
    info: {
        version: 0,
        dump_wrr: "/pwebarc/dump",
    },
    canDump: false,
    canReplay: false,
};

function upgradeConfig(config) {
    function rename(from, to, ...args) {
        if (args.length === 0) {
            args = [config];
        }
        for (let cfg of args) {
            let old = cfg[from];
            if (old === undefined) {
                return;
            }
            delete cfg[from];
            cfg[to] = old;
        }
    }

    switch (config.version) {
        case 1:
            rename("collectPartialRequests", "archivePartialRequest");
            rename("collectNoResponse", "archiveNoResponse");
            rename("collectIncompleteResponses", "archiveIncompleteResponse");
        // fallthrough
        case 2:
            // because it got updated lots
            config.seenHelp = false;
        // fallthrough
        case 3:
            // making them disjoint
            if (config.markProblematicWithErrors) {
                config.markProblematicPickedWithErrors = true;
            }
            rename("markProblematicWithErrors", "markProblematicDroppedWithErrors");
        // fallthrough
        case 4:
            // because that, essentially, was the default before, even though it is not now
            config.archiveSubmitHTTP = true;
            config.archiveSaveLS = false;

            rename("archiving", "archive");
            rename("archiveURLBase", "submitHTTPURLBase");
            rename("archiveNotifyOK", "archiveDoneNotify");
            rename("archiveNotifyFailed", "archiveFailedNotify");
            rename("archiveNotifyDisabled", "archiveStuckNotify");

            rename("profile", "bucket", config.root, config.background, config.extension);
        // fallthrough
        case 5:
            if (config.exportAsMaxSize === 0) {
                config.exportAsBundle = false;
                config.exportAsMaxSize = configDefaults.exportAsMaxSize;
            }
            // because it got updated lots
            config.seenHelp = false;
        // fallthrough
        case 6:
            // its semantics changed
            config.problematicNotify = config.autoNotify ? true : null;
            rename("debugging", "debugRuntime");
        // fallthrough
        case 7:
            // its semantics changed
            config.collectingWorkOffline = true;
        // fallthrough
        case 8:
            rename("archiveFailedNotify", "persistFailedNotify");
            rename("saveLSParanoid", "persistLSParanoid");

            config.root.workOffline = config.root.workOffline && config.workOffline;
            config.root.collecting = config.root.collecting && config.collecting;
            config.root.collectingWorkOffline = config.collectingWorkOffline;
            config.background.workOffline = config.background.workOffline && config.workOffline;
            config.background.collecting = config.background.collecting && config.collecting;
            config.background.collectingWorkOffline = config.collectingWorkOffline;
            config.extension.workOffline = config.extension.workOffline && config.workOffline;
            config.extension.collecting = config.extension.collecting && config.collecting;
            config.extension.collectingWorkOffline = config.collectingWorkOffline;
            delete config["workOffline"];
            delete config["collecting"];
            delete config["collectingWorkOffline"];
        // fallthrough
        case 9:
            // epilog, do NOT move or copy-paste this `break` into the above
            break;
        default:
            console.warn(
                `Bad old config version ${config.version}, reusing values as-is without updates`,
            );
        // the following updateFromRec will do its best
    }

    config.version = configVersion;

    return config;
}

tests.upgradeConfig = () => {
    let value = {
        version: 1,
        debugging: false,
        history: 1000,

        archiving: true,
        archiveURLBase: "http://127.0.0.1:3210/pwebarc/dump",

        collecting: true,
        collectPartialRequests: true,
        collectNoResponse: false,
        collectIncompleteResponses: false,

        root: {
            collecting: true,
            profile: "default",
        },

        background: {
            collecting: true,
            profile: "background",
        },

        extension: {
            collecting: false,
            profile: "extension",
        },
    };

    let up = upgradeConfig(assignRec({}, value));

    // sanity checks
    if (up.version !== configVersion) {
        throw new Error("upgradeConfig version");
    }
    try {
        updateFromRec(assignRec({}, configDefaults), up);
    } catch (err) {
        console.trace();
        throw new Error("upgradeConfig updateFromRec", { cause: err });
    }

    let issues = [];
    let res = equivalentRec(
        (a, b, prefix) => {
            if (a === undefined && b !== undefined) {
                console.error("upgradeConfig", prefix, a, b);
                issues.push(prefix);
                return false;
            }
            return true;
        },
        configDefaults,
        up,
        false,
    );

    if (!res) {
        throw new Error("upgradeConfig fields");
    }
};

function setServer(config) {
    let res = assignRec({}, serverConfigDefaults);

    let serverURL;
    try {
        serverURL = new URL(config.submitHTTPURLBase);
    } catch (err) {
        logHandledError(err);
        browser.notifications
            .create("error-server-url", {
                title: "Hoardy-Web: ERROR",
                message: escapeNotification(
                    config,
                    `Malformed \`Server URL\` \`${config.submitHTTPURLBase}\`:\n${errorMessageOf(err)}`,
                ),
                iconUrl: iconURL("error", 128),
                type: "basic",
            })
            .catch(logError);
        return;
    }

    // clear stale
    browser.notifications.clear("error-server-url").catch(logError);

    if (serverURL.pathname === "/pwebarc/dump") {
        // handle old-style URLs
        serverURL.pathname = "/";
    }
    // just in case
    serverURL.search = "";
    serverURL.hash = "";

    res.baseURL = serverURL.href;

    return res;
}

function fixSourceConfig(cfg, defaults, noTab) {
    // if unset, reset to default
    if (!cfg.bucket) {
        if (typeof defaults === "function") {
            defaults = defaults();
        }
        cfg.bucket = defaults.bucket;
    }

    if (noTab) {
        return;
    }

    cfg.settleDelay = clamp(0, 60, toNumber(cfg.settleDelay));
    cfg.settleRetries = clamp(0, 100, toNumber(cfg.settleRetries));
}

function fixConfig(config, oldConfig, serverConfig) {
    config.history = clamp(64, 10240, toNumber(config.history));

    // when more than one season is forced, reset the old ones to `null`s
    let season = config.season;
    let numSeasons = Array.from(Object.keys(season)).filter((k) => season[k]).length;
    if (numSeasons > 1) {
        for (let key of Object.keys(season)) {
            if (season[key] === oldConfig.season[key]) {
                season[key] = null;
            }
        }
    }

    config.animateIcon = clamp(100, 5000, toNumber(config.animateIcon));

    config.workaroundChromiumDebugTimeout = clamp(
        1,
        120,
        toNumber(config.workaroundChromiumDebugTimeout),
    );

    if (!config.debugConfig && !isMobile && !config.spawnNewTabs) {
        // unavailable
        config.spawnNewTabs = true;

        if (config.hintNotify) {
            browser.notifications
                .create("hint-configNotSupported-spawnNewTabs", {
                    title: "Hoardy-Web: HINT",
                    message: escapeNotification(
                        config,
                        `"Spawn internal pages in new tabs" can not be disabled on a desktop browser. See the description of that option for more info.` +
                            annoyingNotification(
                                config,
                                "Generate notifications about > ... UI hints",
                            ),
                    ),
                    iconUrl: iconURL("main", 128),
                    type: "basic",
                })
                .catch(logError);
        }
    }

    if (reqresIDB === undefined) {
        config.preferIndexedDB = false;
    } else if (!config.debugConfig && useDebugger && !config.preferIndexedDB) {
        // can not be disabled on Chromium ATM, since serialization of
        // Uint8Array to `storage.local` won't work there
        config.preferIndexedDB = true;

        if (config.hintNotify) {
            browser.notifications
                .create("hint-configNotSupported-preferIndexedDB", {
                    title: "Hoardy-Web: HINT",
                    message: escapeNotification(
                        config,
                        `"Prefer \`IndexedDB\` API" can not be disabled on a Chromium-based browser. See the description of that option for more info.` +
                            annoyingNotification(
                                config,
                                "Generate notifications about > ... UI hints",
                            ),
                    ),
                    iconUrl: iconURL("main", 128),
                    type: "basic",
                })
                .catch(logError);
        }
    }

    let noArchiveExportAs = isMobile && isFirefox;
    if (
        !config.debugConfig &&
        noArchiveExportAs &&
        (config.archiveExportAs || config.rearchiveExportAs)
    ) {
        // Firefox on Android crashes with this set see "Quirks and Bugs" in ../page/help.org
        config.archiveExportAs = false;
        config.rearchiveExportAs = false;

        if (config.hintNotify) {
            browser.notifications
                .create("hint-configNotSupported-archiveExportAs", {
                    title: "Hoardy-Web: HINT",
                    message: escapeNotification(
                        config,
                        `"Export via \`saveAs\` is not supported on Firefox-based mobile browsers. See the "Help" page for more info.` +
                            annoyingNotification(
                                config,
                                "Generate notifications about > ... UI hints",
                            ),
                    ),
                    iconUrl: iconURL("main", 128),
                    type: "basic",
                })
                .catch(logError);
        }
    }

    // at lest one of these must be set
    if (!(config.archiveExportAs || config.archiveSubmitHTTP || config.archiveSaveLS)) {
        if (config.archiveSaveLS !== oldConfig.archiveSaveLS && !noArchiveExportAs) {
            config.archiveExportAs = true;
        } else {
            config.archiveSaveLS = true;
        }
    }

    // to prevent surprises
    if (
        config.archive &&
        (reqresQueue.length > 0 || reqresUnarchivedIssueAcc[0].size > 0) &&
        (config.archiveExportAs !== oldConfig.archiveExportAs ||
            config.archiveSubmitHTTP !== oldConfig.archiveSubmitHTTP ||
            config.archiveSaveLS !== oldConfig.archiveSaveLS)
    ) {
        config.archive = false;

        if (config.hintNotify) {
            browser.notifications
                .create("hint-notArchivingNow", {
                    title: "Hoardy-Web: HINT",
                    message: escapeNotification(
                        config,
                        `"Archive \`collected\` reqres" option was disabled because the archival queue and/or the list of failed reqres are non-empty.` +
                            annoyingNotification(
                                config,
                                "Generate notifications about > ... UI hints",
                            ),
                    ),
                    iconUrl: iconURL("off", 128),
                    type: "basic",
                })
                .catch(logError);
        }
    }

    config.exportAsMaxSize = clamp(1, 512, toNumber(config.exportAsMaxSize));
    config.exportAsTimeout = clamp(1, 900, toNumber(config.exportAsTimeout));
    config.exportAsInFlightTimeout = clamp(
        config.exportAsTimeout,
        1200,
        toNumber(config.exportAsInFlightTimeout),
    );

    // if unset, reset to default
    if (!config.submitHTTPURLBase) {
        config.submitHTTPURLBase = configDefaults.submitHTTPURLBase;
    }

    if (config.submitHTTPURLBase !== oldConfig.submitHTTPURLBase) {
        serverConfig = setServer(config);
    }

    config.problematicNotifyNumber = clamp(1, 32, toNumber(config.problematicNotifyNumber));
    config.limboMaxNumber = clamp(1, 10240, toNumber(config.limboMaxNumber));
    config.limboMaxSize = clamp(1, 512, toNumber(config.limboMaxSize));
    config.limboNotifyInterval = clamp(1, 3600, toNumber(config.limboNotifyInterval));

    // these are mutually exclusive
    if (config.autoPopInLimboCollect && config.autoPopInLimboDiscard) {
        if (config.autoPopInLimboCollect !== oldConfig.autoPopInLimboCollect) {
            config.autoPopInLimboDiscard = !config.autoPopInLimboCollect;
        } else {
            config.autoPopInLimboCollect = !config.autoPopInLimboDiscard;
        }
    }
    config.autoTimeout = clamp(0, 3600, toNumber(config.autoTimeout));

    // fix per-source ones
    fixSourceConfig(config.root, configDefaults.root);
    fixSourceConfig(config.background, configDefaults.background, true);
    fixSourceConfig(config.extension, configDefaults.extension, true);

    DEBUG_WEBEXT_RPC = DEBUG_CAYDARSC = config.logRuntime;

    return [config, serverConfig];
}

// these are to be computed dynamically from `reqresProblematic` and `reqresLimbo`
let dynamicStateDefaults = {
    problematicTotal: 0,
    problematicSize: 0,
    inLimboTotal: 0,
    inLimboSize: 0,
};

// common
let commonStateDefaults = {
    pickedTotal: 0,
    pickedSize: 0,
    droppedTotal: 0,
    droppedSize: 0,
    collectedTotal: 0,
    collectedSize: 0,
    discardedTotal: 0,
    discardedSize: 0,
};

// these can be reset by `resetStats`
let resettableStateDefaults = assignRec(
    {
        exportedAsTotal: 0,
        exportedAsSize: 0,
        submittedHTTPTotal: 0,
        submittedHTTPSize: 0,
        dumpedTotal: 0,
        dumpedUndo: 0,
        dumpedSize: 0,
        dumpedReal: 0,
        stashedTotal: 0,
        stashedRedo: 0,
        stashedUndo: 0,
        savedTotal: 0,
        savedRedo: 0,
        savedUndo: 0,
    },
    commonStateDefaults,
);

let dbStateDefaults = { number: 0, size: 0 };

let stateVersion = 1;
let stateDefaults = assignRec(
    {
        version: stateVersion,
        stashedLS: assignRec({}, dbStateDefaults),
        stashedIDB: assignRec({}, dbStateDefaults),
        savedLS: assignRec({}, dbStateDefaults),
        savedIDB: assignRec({}, dbStateDefaults),
    },
    dynamicStateDefaults,
    resettableStateDefaults,
);

function upgradeState(state) {
    if (state.version === undefined) {
        state.version = 1;
    }

    return state;
}

// Global state, its predicates, and persistence.

// current session ID, to prevent old reqres from being counted as belonging
// to current tabs, etc
let sessionId = Date.now();

// current config
let config = assignRec({}, configDefaults);
// last config saved in storage, will be set in `main`
let savedConfig;
// current server config
let serverConfig = assignRec({}, serverConfigDefaults);
// current global stats
let state = assignRec({}, stateDefaults);
// last global stats saved in storage, will be set in `main`
let savedState;

async function saveConfig(force) {
    if (!force && equalRec(savedConfig, config)) {
        return;
    }

    savedConfig = assignRec({}, config);
    if (config.logRuntime) {
        console.warn("SAVE: writing config", savedConfig);
    }
    await browser.storage.local.set({ config: savedConfig }).catch(logError);
}

function scheduleSaveConfig(timeout, force) {
    if (!force && equalRecWarnNeq(savedConfig, config, "SAVE:")) {
        return;
    }

    scheduleAction(scheduledSaveState, "saveConfig", timeout, () => {
        saveConfig(force);
        // return undefined;
    });
    // NB: needs scheduleUpdateDisplay afterwards
}

async function saveState(force) {
    if (!force && equalRec(savedState, state)) {
        return;
    }

    savedState = assignRec({}, state);
    if (config.logRuntime) {
        console.warn("SAVE: writing state", savedState);
    }
    await browser.storage.local.set({ state: savedState }).catch(logError);
    await browser.storage.local.remove("globals").catch(noop);
    await browser.storage.local.remove("persistentStats").catch(noop);
    await browser.storage.local.remove("globalStats").catch(noop);
}

function scheduleSaveState(timeout, force) {
    if (!force && equalRecWarnNeq(savedState, state, "SAVE:")) {
        return;
    }

    scheduleAction(scheduledSaveState, "saveState", timeout, () => {
        saveState(force);
        // return undefined;
    });
    // NB: needs scheduleUpdateDisplay afterwards
}

function resetStats() {
    state = updateFromRec(state, resettableStateDefaults);
    scheduleSaveState(0, true);
    scheduleUpdateDisplay(true);
}

function setConfig(newConfig) {
    let oldConfig = config;
    config = updateFromRec(assignRec({}, oldConfig), newConfig);

    [config, serverConfig] = fixConfig(config, oldConfig, serverConfig);

    if (config.stash && config.stash !== oldConfig.stash) {
        syncStashAll(false);
    }

    if (
        config.archive &&
        config.archiveSubmitHTTP &&
        (config.archive !== oldConfig.archive ||
            config.archiveSubmitHTTP !== oldConfig.archiveSubmitHTTP ||
            config.submitHTTPURLBase !== oldConfig.submitHTTPURLBase)
    ) {
        syncRetryUnarchived(true, {});
        wantArchiveDoneNotify = true;
    }

    if (
        (config.rearchiveSubmitHTTP &&
            (config.rearchiveSubmitHTTP !== oldConfig.rearchiveSubmitHTTP ||
                config.submitHTTPURLBase !== oldConfig.submitHTTPURLBase)) ||
        (config.replaySubmitHTTP !== false &&
            (config.replaySubmitHTTP !== oldConfig.replaySubmitHTTP ||
                config.submitHTTPURLBase !== oldConfig.submitHTTPURLBase))
    ) {
        wantCheckServer = true;
    }

    if (!config.ephemeral && !equalRec(config, oldConfig)) {
        // save config after a little pause to give the user time to click
        // the same toggle again without torturing the SSD
        scheduleSaveConfig(1000, true);
    }

    if (useDebugger) {
        syncDebuggersState();
    }

    broadcast(false, "updateConfig", config);
}

function isServerURL(url) {
    return url.startsWith(serverConfig.baseURL);
}

function isBoringOrServerURL(url) {
    return isBoringURL(url) || isServerURL(url);
}