tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

webcompat-tracker-debugger.js (12890B)


      1 /* This Source Code Form is subject to the terms of the Mozilla Public
      2 * License, v. 2.0. If a copy of the MPL was not distributed with this file,
      3 * You can obtain one at http://mozilla.org/MPL/2.0/. */
      4 
      5 "use strict";
      6 
      7 const { BrowserLoader } = ChromeUtils.importESModule(
      8  "resource://devtools/shared/loader/browser-loader.sys.mjs"
      9 );
     10 const require = BrowserLoader({
     11  baseURI: "resource://devtools/client/anti-tracking/",
     12  window,
     13 }).require;
     14 
     15 const {
     16  DebuggerFSMContext,
     17 } = require("resource://devtools/client/anti-tracking/debugger-fsm-context.js");
     18 
     19 /**
     20 * A devtool extension to help streamline the process of debugging tracker-related webcompat issues.
     21 * This extension supports:
     22 * - Viewing all blocked resources
     23 * - Select any of the blocked resources to unblock and test if the website is fixed
     24 * - An interactive debugger that will narrow down the exact resources that we need to add to the exceptions list
     25 */
     26 class WebcompatTrackerDebugger {
     27  constructor(commands) {
     28    this.selectedTrackers = new Set();
     29    this.unblockedChannels = new Set();
     30    this.allTrackers = {};
     31    this.commands = null;
     32    this.debuggerFSMContext = null;
     33    this.commands = commands;
     34    this.boundOnChannelBlocked = this.onChannelBlocked.bind(this);
     35 
     36    this.setupListeners();
     37    this.populateTrackerTable();
     38  }
     39 
     40  onChannelBlocked(subject) {
     41    const channelBlockedReasonToString = {
     42      [Ci.nsIUrlClassifierBlockedChannel.TRACKING_PROTECTION]:
     43        "Tracking Protection",
     44      [Ci.nsIUrlClassifierBlockedChannel.FINGERPRINTING_PROTECTION]:
     45        "Fingerprinting Protection",
     46      [Ci.nsIUrlClassifierBlockedChannel.CRYPTOMINING_PROTECTION]:
     47        "Cryptomining Protection",
     48      [Ci.nsIUrlClassifierBlockedChannel.SOCIAL_TRACKING_PROTECTION]:
     49        "Social Tracking Protection",
     50    };
     51 
     52    const hostname = new URL(subject.url).hostname;
     53    this.allTrackers[hostname] = {
     54      trackerType: channelBlockedReasonToString[subject.reason] || "unknown",
     55    };
     56    if (this.unblockedChannels.has(hostname)) {
     57      const channel = subject.QueryInterface(Ci.nsIUrlClassifierBlockedChannel);
     58      channel.allow();
     59    }
     60    this.populateTrackerTable();
     61  }
     62 
     63  /**
     64   * Set up UI and message listeners.
     65   */
     66  setupListeners() {
     67    // Add listener for UrlClassifierBlockedChannel
     68    const channelClassifier = Cc[
     69      "@mozilla.org/url-classifier/channel-classifier-service;1"
     70    ].getService(Ci.nsIChannelClassifierService);
     71    channelClassifier.addListener(this.boundOnChannelBlocked);
     72    this.addClickListener("reset", this.onResetClick.bind(this));
     73    this.addClickListener("block-selected", async () => {
     74      await this.blockOrUnblockSelected(true);
     75    });
     76    this.addClickListener("unblock-selected", async () => {
     77      await this.blockOrUnblockSelected(false);
     78    });
     79    this.addClickListener(
     80      "interactive-debugging",
     81      this.onInteractiveDebuggingClick.bind(this)
     82    );
     83    this.addClickListener("website-broke", async () => {
     84      await this.debuggerFSMContext?.onWebsiteBroke();
     85    });
     86    this.addClickListener("test-next-tracker", async () => {
     87      await this.debuggerFSMContext?.onTestNextTracker();
     88    });
     89    this.addClickListener("stop-debugging", async () => {
     90      await this.debuggerFSMContext.stop();
     91      this.debuggerFSMContext = undefined;
     92    });
     93  }
     94 
     95  /**
     96   * Handler for reset button click.
     97   */
     98  onResetClick() {
     99    this.selectedTrackers.clear();
    100    this.unblockedChannels.clear();
    101    this.populateTrackerTable();
    102  }
    103 
    104  /**
    105   * Handler for interactive debugging button click.
    106   */
    107  onInteractiveDebuggingClick() {
    108    const callbacks = {
    109      onPromptTextUpdate: this.onPromptTextUpdate.bind(this),
    110      onButtonStateUpdate: this.onButtonStateUpdate.bind(this),
    111      onTrackersBlockedStateUpdate:
    112        this.onTrackersBlockedStateUpdate.bind(this),
    113    };
    114    this.debuggerFSMContext = new DebuggerFSMContext(
    115      Object.keys(this.allTrackers),
    116      callbacks
    117    );
    118  }
    119 
    120  /**
    121   * Callback to update the text content of the interactive debugger prompt element.
    122   *
    123   * @param {number} count - The number of prompts left. If undefined, the count is omitted.
    124   * @param {string} text - The text to display in the prompt.
    125   * @param {boolean} completed - If true, indicates that the debugging session is completed.
    126   */
    127  onPromptTextUpdate(count, text, completed = false) {
    128    const el = document.getElementById("interactive-debugger-prompt");
    129    if (el) {
    130      el.textContent = (count !== undefined ? `[${count} left] ` : ``) + text;
    131      el.classList.toggle("completed", completed);
    132    }
    133  }
    134 
    135  /**
    136   * Callback to update the disabled state of a button element by its ID.
    137   *
    138   * @param {string} buttonName - The ID of the button element to update.
    139   * @param {boolean} isDisabled - Whether the button should be disabled.
    140   */
    141  onButtonStateUpdate(buttonName, isDisabled) {
    142    const el = document.getElementById(buttonName);
    143    el.disabled = isDisabled;
    144  }
    145 
    146  /**
    147   * Handles updates to the blocked state of trackers.
    148   *
    149   * @async
    150   * @param {boolean} blocked - Indicates whether trackers are currently blocked.
    151   * @param {Set<string>} trackers - A set of tracker identifiers to update.
    152   * @returns {Promise<void>} Resolves when the top-level target is reloaded and the tracker table is populated.
    153   */
    154  async onTrackersBlockedStateUpdate(blocked, trackers) {
    155    if (blocked) {
    156      trackers.forEach(tracker => this.unblockedChannels.delete(tracker));
    157    } else {
    158      trackers.forEach(tracker => this.unblockedChannels.add(tracker));
    159    }
    160    await this.commands.targetCommand.reloadTopLevelTarget(false);
    161    this.populateTrackerTable();
    162  }
    163 
    164  /**
    165   * Helper to add click event listeners safely.
    166   */
    167  addClickListener(id, handler) {
    168    const el = document.getElementById(id);
    169    if (el) {
    170      el.addEventListener("click", handler);
    171    }
    172  }
    173 
    174  /**
    175   * Block or unblock all selected trackers.
    176   *
    177   * @param {boolean} blocked - If true, block the selected trackers; if false, unblock them.
    178   */
    179  async blockOrUnblockSelected(blocked) {
    180    if (this.selectedTrackers.size === 0) {
    181      return;
    182    }
    183    this.selectedTrackers.forEach(tracker => {
    184      if (blocked) {
    185        this.unblockedChannels.delete(tracker);
    186      } else {
    187        this.unblockedChannels.add(tracker);
    188      }
    189    });
    190    this.populateTrackerTable();
    191    await this.commands.targetCommand.reloadTopLevelTarget(false);
    192  }
    193 
    194  /**
    195   * Render the tracker table.
    196   */
    197  populateTrackerTable() {
    198    const table = document.getElementById("tracker-table");
    199    if (!table) {
    200      return;
    201    }
    202    table.replaceChildren(this.createTableHead(), this.createTableBody());
    203 
    204    const tableContainer = document.getElementById("tracker-table-container");
    205    const existingMsg = tableContainer.querySelector(".no-content-message");
    206    if (existingMsg) {
    207      existingMsg.remove();
    208    }
    209 
    210    if (Object.keys(this.allTrackers).length === 0) {
    211      const noContentMessage = document.createElement("p");
    212      noContentMessage.textContent =
    213        "No blocked resources, try refreshing the page.";
    214      noContentMessage.classList.add("no-content-message");
    215      tableContainer.append(noContentMessage);
    216    }
    217  }
    218 
    219  /**
    220   * Create the table head for the tracker table.
    221   *
    222   * @return {HTMLTableSectionElement} The table head element containing the header row.
    223   */
    224  createTableHead() {
    225    const thead = document.createElement("thead");
    226    thead.id = "tracker-table-head";
    227    const headerRow = document.createElement("tr");
    228    headerRow.id = "tracker-table-header";
    229 
    230    // Select all checkbox
    231    const selectAllTh = document.createElement("th");
    232    const selectAllCheckbox = document.createElement("input");
    233    selectAllCheckbox.type = "checkbox";
    234    selectAllCheckbox.addEventListener(
    235      "change",
    236      this.onSelectAllChange.bind(this)
    237    );
    238    selectAllTh.appendChild(selectAllCheckbox);
    239    headerRow.appendChild(selectAllTh);
    240 
    241    ["State", "Hostname", "Type", "Action"].forEach(name => {
    242      const th = document.createElement("th");
    243      th.textContent = name;
    244      headerRow.appendChild(th);
    245    });
    246    thead.appendChild(headerRow);
    247    return thead;
    248  }
    249 
    250  /**
    251   * Handler for select all checkbox change event.
    252   *
    253   * @param {Event} e - The change event.
    254   */
    255  onSelectAllChange(e) {
    256    const checked = e.target.checked;
    257    this.selectedTrackers = new Set();
    258    document.querySelectorAll(".row-checkbox").forEach(cb => {
    259      cb.checked = checked;
    260      if (checked) {
    261        this.selectedTrackers.add(cb.dataset.tracker);
    262      }
    263    });
    264  }
    265 
    266  /**
    267   * Create the table body for the tracker table.
    268   *
    269   * @return {HTMLTableSectionElement} The table body element containing tracker rows.
    270   */
    271  createTableBody() {
    272    const tbody = document.createElement("tbody");
    273    Object.entries(this.allTrackers).forEach(([hostname, trackerData]) => {
    274      tbody.appendChild(this.createTrackerRow(hostname, trackerData));
    275    });
    276    return tbody;
    277  }
    278 
    279  /**
    280   * Create a row for a tracker.
    281   *
    282   * @param {string} hostname - The hostname of the tracker.
    283   * @param {object} trackerData - The data associated with the tracker.
    284   * @param {string} trackerData.trackerType - The type of tracker (e.g., "tracking", "fingerprinting").
    285   * @returns {HTMLTableRowElement} The tracker row element.
    286   */
    287  createTrackerRow(hostname, trackerData) {
    288    const isBlocked = !this.unblockedChannels.has(hostname);
    289    const row = document.createElement("tr");
    290    row.appendChild(this.createRowCheckboxCell(hostname));
    291 
    292    const isBlockedCell = document.createElement("td");
    293    isBlockedCell.textContent = isBlocked ? "Blocked 🛑" : "Not Blocked";
    294    isBlockedCell.classList.toggle("tracker-blocked", isBlocked);
    295    row.appendChild(isBlockedCell);
    296 
    297    const hostnameCell = document.createElement("td");
    298    hostnameCell.className = "hostname-cell";
    299    hostnameCell.textContent = hostname;
    300    hostnameCell.title = hostname;
    301    hostnameCell.classList.toggle("tracker-blocked", isBlocked);
    302    row.appendChild(hostnameCell);
    303 
    304    const trackerTypeCell = document.createElement("td");
    305    trackerTypeCell.textContent = trackerData.trackerType || "N/A";
    306    trackerTypeCell.classList.toggle("tracker-blocked", isBlocked);
    307    row.appendChild(trackerTypeCell);
    308 
    309    row.appendChild(this.createActionCell(hostname, isBlocked));
    310    return row;
    311  }
    312 
    313  /**
    314   * Create a checkbox cell for a tracker row.
    315   *
    316   * @param {string} tracker - The tracker identifier (hostname).
    317   * @returns {HTMLTableCellElement} The checkbox cell element.
    318   */
    319  createRowCheckboxCell(tracker) {
    320    const checkboxCell = document.createElement("td");
    321    const checkbox = document.createElement("input");
    322    checkbox.type = "checkbox";
    323    checkbox.className = "row-checkbox";
    324    checkbox.dataset.tracker = tracker;
    325    checkbox.checked = this.selectedTrackers.has(tracker);
    326    checkbox.addEventListener("change", this.onRowCheckboxChange.bind(this));
    327    checkboxCell.appendChild(checkbox);
    328    return checkboxCell;
    329  }
    330 
    331  /**
    332   * Handler for individual row checkbox change event.
    333   *
    334   * @param {Event} e - The change event.
    335   */
    336  onRowCheckboxChange(e) {
    337    const tracker = e.target.dataset.tracker;
    338    if (e.target.checked) {
    339      this.selectedTrackers.add(tracker);
    340    } else {
    341      this.selectedTrackers.delete(tracker);
    342    }
    343  }
    344 
    345  /**
    346   * Create an action cell (block/unblock button) for a tracker row.
    347   *
    348   * @param {string} tracker - The tracker identifier (hostname).
    349   * @param {boolean} isBlocked - Whether the tracker is currently blocked.
    350   * @returns {HTMLTableCellElement} The action cell element containing the button.
    351   */
    352  createActionCell(tracker, isBlocked) {
    353    const actionCell = document.createElement("td");
    354    const button = document.createElement("button");
    355    button.textContent = isBlocked ? "Unblock" : "Block";
    356    button.addEventListener(
    357      "click",
    358      this.onActionButtonClick.bind(this, tracker, isBlocked)
    359    );
    360    actionCell.appendChild(button);
    361    return actionCell;
    362  }
    363 
    364  /**
    365   * Handler for individual action button click event.
    366   *
    367   * @param {string} tracker - The tracker identifier (hostname).
    368   * @param {boolean} isBlocked - Whether the tracker is currently blocked.
    369   */
    370  async onActionButtonClick(tracker, isBlocked) {
    371    if (isBlocked) {
    372      this.unblockedChannels.add(tracker);
    373    } else {
    374      this.unblockedChannels.delete(tracker);
    375    }
    376    this.populateTrackerTable();
    377    await this.commands.targetCommand.reloadTopLevelTarget(false);
    378  }
    379 
    380  async destroy() {
    381    const channelClassifierService = Cc[
    382      "@mozilla.org/url-classifier/channel-classifier-service;1"
    383    ].getService(Ci.nsIChannelClassifierService);
    384    channelClassifierService.removeListener(this.boundOnChannelBlocked);
    385  }
    386 }
    387 
    388 module.exports = { WebcompatTrackerDebugger };