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 };