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 3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 5/* eslint-env mozilla/frame-script */ 6 7const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); 8const { XPCOMUtils } = ChromeUtils.import( 9 "resource://gre/modules/XPCOMUtils.jsm" 10); 11 12var EXPORTED_SYMBOLS = ["ViewSourcePageChild"]; 13 14XPCOMUtils.defineLazyGlobalGetters(this, ["NodeFilter"]); 15 16const BUNDLE_URL = "chrome://global/locale/viewSource.properties"; 17 18// These are markers used to delimit the selection during processing. They 19// are removed from the final rendering. 20// We use noncharacter Unicode codepoints to minimize the risk of clashing 21// with anything that might legitimately be present in the document. 22// U+FDD0..FDEF <noncharacters> 23const MARK_SELECTION_START = "\uFDD0"; 24const MARK_SELECTION_END = "\uFDEF"; 25 26/** 27 * When showing selection source, chrome will construct a page fragment to 28 * show, and then instruct content to draw a selection after load. This is 29 * set true when there is a pending request to draw selection. 30 */ 31let gNeedsDrawSelection = false; 32 33/** 34 * Start at a specific line number. 35 */ 36let gInitialLineNumber = -1; 37 38class ViewSourcePageChild extends JSWindowActorChild { 39 constructor() { 40 super(); 41 42 XPCOMUtils.defineLazyGetter(this, "bundle", function() { 43 return Services.strings.createBundle(BUNDLE_URL); 44 }); 45 } 46 47 static setNeedsDrawSelection(value) { 48 gNeedsDrawSelection = value; 49 } 50 51 static setInitialLineNumber(value) { 52 gInitialLineNumber = value; 53 } 54 55 receiveMessage(msg) { 56 switch (msg.name) { 57 case "ViewSource:GoToLine": 58 this.goToLine(msg.data.lineNumber); 59 break; 60 case "ViewSource:IsWrapping": 61 return this.isWrapping; 62 case "ViewSource:IsSyntaxHighlighting": 63 return this.isSyntaxHighlighting; 64 case "ViewSource:ToggleWrapping": 65 this.toggleWrapping(); 66 break; 67 case "ViewSource:ToggleSyntaxHighlighting": 68 this.toggleSyntaxHighlighting(); 69 break; 70 } 71 return undefined; 72 } 73 74 /** 75 * Any events should get handled here, and should get dispatched to 76 * a specific function for the event type. 77 */ 78 handleEvent(event) { 79 switch (event.type) { 80 case "pageshow": 81 this.onPageShow(event); 82 break; 83 case "click": 84 this.onClick(event); 85 break; 86 } 87 } 88 89 /** 90 * A shortcut to the nsISelectionController for the content. 91 */ 92 get selectionController() { 93 return this.docShell 94 .QueryInterface(Ci.nsIInterfaceRequestor) 95 .getInterface(Ci.nsISelectionDisplay) 96 .QueryInterface(Ci.nsISelectionController); 97 } 98 99 /** 100 * A shortcut to the nsIWebBrowserFind for the content. 101 */ 102 get webBrowserFind() { 103 return this.docShell 104 .QueryInterface(Ci.nsIInterfaceRequestor) 105 .getInterface(Ci.nsIWebBrowserFind); 106 } 107 108 /** 109 * This handler is for click events from: 110 * * error page content, which can show up if the user attempts to view the 111 * source of an attack page. 112 */ 113 onClick(event) { 114 let target = event.originalTarget; 115 116 // Don't trust synthetic events 117 if (!event.isTrusted || event.target.localName != "button") { 118 return; 119 } 120 121 let errorDoc = target.ownerDocument; 122 123 if (/^about:blocked/.test(errorDoc.documentURI)) { 124 // The event came from a button on a malware/phishing block page 125 126 if (target == errorDoc.getElementById("goBackButton")) { 127 // Instead of loading some safe page, just close the window 128 this.sendAsyncMessage("ViewSource:Close"); 129 } 130 } 131 } 132 133 /** 134 * Handler for the pageshow event. 135 * 136 * @param event 137 * The pageshow event being handled. 138 */ 139 onPageShow(event) { 140 // If we need to draw the selection, wait until an actual view source page 141 // has loaded, instead of about:blank. 142 if ( 143 gNeedsDrawSelection && 144 this.document.documentURI.startsWith("view-source:") 145 ) { 146 gNeedsDrawSelection = false; 147 this.drawSelection(); 148 } 149 150 if (gInitialLineNumber >= 0) { 151 this.goToLine(gInitialLineNumber); 152 gInitialLineNumber = -1; 153 } 154 } 155 156 /** 157 * Attempts to go to a particular line in the source code being 158 * shown. If it succeeds in finding the line, it will fire a 159 * "ViewSource:GoToLine:Success" message, passing up an object 160 * with the lineNumber we just went to. If it cannot find the line, 161 * it will fire a "ViewSource:GoToLine:Failed" message. 162 * 163 * @param lineNumber 164 * The line number to attempt to go to. 165 */ 166 goToLine(lineNumber) { 167 let body = this.document.body; 168 169 // The source document is made up of a number of pre elements with 170 // id attributes in the format <pre id="line123">, meaning that 171 // the first line in the pre element is number 123. 172 // Do binary search to find the pre element containing the line. 173 // However, in the plain text case, we have only one pre without an 174 // attribute, so assume it begins on line 1. 175 let pre; 176 for (let lbound = 0, ubound = body.childNodes.length; ; ) { 177 let middle = (lbound + ubound) >> 1; 178 pre = body.childNodes[middle]; 179 180 let firstLine = pre.id ? parseInt(pre.id.substring(4)) : 1; 181 182 if (lbound == ubound - 1) { 183 break; 184 } 185 186 if (lineNumber >= firstLine) { 187 lbound = middle; 188 } else { 189 ubound = middle; 190 } 191 } 192 193 let result = {}; 194 let found = this.findLocation(pre, lineNumber, null, -1, false, result); 195 196 if (!found) { 197 this.sendAsyncMessage("ViewSource:GoToLine:Failed"); 198 return; 199 } 200 201 let selection = this.document.defaultView.getSelection(); 202 selection.removeAllRanges(); 203 204 // In our case, the range's startOffset is after "\n" on the previous line. 205 // Tune the selection at the beginning of the next line and do some tweaking 206 // to position the focusNode and the caret at the beginning of the line. 207 selection.interlinePosition = true; 208 209 selection.addRange(result.range); 210 211 if (!selection.isCollapsed) { 212 selection.collapseToEnd(); 213 214 let offset = result.range.startOffset; 215 let node = result.range.startContainer; 216 if (offset < node.data.length) { 217 // The same text node spans across the "\n", just focus where we were. 218 selection.extend(node, offset); 219 } else { 220 // There is another tag just after the "\n", hook there. We need 221 // to focus a safe point because there are edgy cases such as 222 // <span>...\n</span><span>...</span> vs. 223 // <span>...\n<span>...</span></span><span>...</span> 224 node = node.nextSibling 225 ? node.nextSibling 226 : node.parentNode.nextSibling; 227 selection.extend(node, 0); 228 } 229 } 230 231 let selCon = this.selectionController; 232 selCon.setDisplaySelection(Ci.nsISelectionController.SELECTION_ON); 233 selCon.setCaretVisibilityDuringSelection(true); 234 235 // Scroll the beginning of the line into view. 236 selCon.scrollSelectionIntoView( 237 Ci.nsISelectionController.SELECTION_NORMAL, 238 Ci.nsISelectionController.SELECTION_FOCUS_REGION, 239 true 240 ); 241 242 this.sendAsyncMessage("ViewSource:GoToLine:Success", { lineNumber }); 243 } 244 245 /** 246 * Some old code from the original view source implementation. Original 247 * documentation follows: 248 * 249 * "Loops through the text lines in the pre element. The arguments are either 250 * (pre, line) or (node, offset, interlinePosition). result is an out 251 * argument. If (pre, line) are specified (and node == null), result.range is 252 * a range spanning the specified line. If the (node, offset, 253 * interlinePosition) are specified, result.line and result.col are the line 254 * and column number of the specified offset in the specified node relative to 255 * the whole file." 256 */ 257 findLocation(pre, lineNumber, node, offset, interlinePosition, result) { 258 if (node && !pre) { 259 // Look upwards to find the current pre element. 260 // eslint-disable-next-line no-empty 261 for (pre = node; pre.nodeName != "PRE"; pre = pre.parentNode) {} 262 } 263 264 // The source document is made up of a number of pre elements with 265 // id attributes in the format <pre id="line123">, meaning that 266 // the first line in the pre element is number 123. 267 // However, in the plain text case, there is only one <pre> without an id, 268 // so assume line 1. 269 let curLine = pre.id ? parseInt(pre.id.substring(4)) : 1; 270 271 // Walk through each of the text nodes and count newlines. 272 let treewalker = this.document.createTreeWalker( 273 pre, 274 NodeFilter.SHOW_TEXT, 275 null 276 ); 277 278 // The column number of the first character in the current text node. 279 let firstCol = 1; 280 281 let found = false; 282 for ( 283 let textNode = treewalker.firstChild(); 284 textNode && !found; 285 textNode = treewalker.nextNode() 286 ) { 287 // \r is not a valid character in the DOM, so we only check for \n. 288 let lineArray = textNode.data.split(/\n/); 289 let lastLineInNode = curLine + lineArray.length - 1; 290 291 // Check if we can skip the text node without further inspection. 292 if (node ? textNode != node : lastLineInNode < lineNumber) { 293 if (lineArray.length > 1) { 294 firstCol = 1; 295 } 296 firstCol += lineArray[lineArray.length - 1].length; 297 curLine = lastLineInNode; 298 continue; 299 } 300 301 // curPos is the offset within the current text node of the first 302 // character in the current line. 303 for ( 304 var i = 0, curPos = 0; 305 i < lineArray.length; 306 curPos += lineArray[i++].length + 1 307 ) { 308 if (i > 0) { 309 curLine++; 310 } 311 312 if (node) { 313 if (offset >= curPos && offset <= curPos + lineArray[i].length) { 314 // If we are right after the \n of a line and interlinePosition is 315 // false, the caret looks as if it were at the end of the previous 316 // line, so we display that line and column instead. 317 318 if (i > 0 && offset == curPos && !interlinePosition) { 319 result.line = curLine - 1; 320 var prevPos = curPos - lineArray[i - 1].length; 321 result.col = (i == 1 ? firstCol : 1) + offset - prevPos; 322 } else { 323 result.line = curLine; 324 result.col = (i == 0 ? firstCol : 1) + offset - curPos; 325 } 326 found = true; 327 328 break; 329 } 330 } else if (curLine == lineNumber && !("range" in result)) { 331 result.range = this.document.createRange(); 332 result.range.setStart(textNode, curPos); 333 334 // This will always be overridden later, except when we look for 335 // the very last line in the file (this is the only line that does 336 // not end with \n). 337 result.range.setEndAfter(pre.lastChild); 338 } else if (curLine == lineNumber + 1) { 339 result.range.setEnd(textNode, curPos - 1); 340 found = true; 341 break; 342 } 343 } 344 } 345 346 return found || "range" in result; 347 } 348 349 /** 350 * @return {boolean} whether the "wrap" class exists on the document body. 351 */ 352 get isWrapping() { 353 return this.document.body.classList.contains("wrap"); 354 } 355 356 /** 357 * @return {boolean} whether the "highlight" class exists on the document body. 358 */ 359 get isSyntaxHighlighting() { 360 return this.document.body.classList.contains("highlight"); 361 } 362 363 /** 364 * Toggles the "wrap" class on the document body, which sets whether 365 * or not long lines are wrapped. Notifies parent to update the pref. 366 */ 367 toggleWrapping() { 368 let body = this.document.body; 369 let state = body.classList.toggle("wrap"); 370 this.sendAsyncMessage("ViewSource:StoreWrapping", { state }); 371 } 372 373 /** 374 * Toggles the "highlight" class on the document body, which sets whether 375 * or not syntax highlighting is displayed. Notifies parent to update the 376 * pref. 377 */ 378 toggleSyntaxHighlighting() { 379 let body = this.document.body; 380 let state = body.classList.toggle("highlight"); 381 this.sendAsyncMessage("ViewSource:StoreSyntaxHighlighting", { state }); 382 } 383 384 /** 385 * Using special markers left in the serialized source, this helper makes the 386 * underlying markup of the selected fragment to automatically appear as 387 * selected on the inflated view-source DOM. 388 */ 389 drawSelection() { 390 this.document.title = this.bundle.GetStringFromName( 391 "viewSelectionSourceTitle" 392 ); 393 394 // find the special selection markers that we added earlier, and 395 // draw the selection between the two... 396 var findService = null; 397 try { 398 // get the find service which stores the global find state 399 findService = Cc["@mozilla.org/find/find_service;1"].getService( 400 Ci.nsIFindService 401 ); 402 } catch (e) {} 403 if (!findService) { 404 return; 405 } 406 407 // cache the current global find state 408 var matchCase = findService.matchCase; 409 var entireWord = findService.entireWord; 410 var wrapFind = findService.wrapFind; 411 var findBackwards = findService.findBackwards; 412 var searchString = findService.searchString; 413 var replaceString = findService.replaceString; 414 415 // setup our find instance 416 var findInst = this.webBrowserFind; 417 findInst.matchCase = true; 418 findInst.entireWord = false; 419 findInst.wrapFind = true; 420 findInst.findBackwards = false; 421 422 // ...lookup the start mark 423 findInst.searchString = MARK_SELECTION_START; 424 var startLength = MARK_SELECTION_START.length; 425 findInst.findNext(); 426 427 var selection = this.document.defaultView.getSelection(); 428 if (!selection.rangeCount) { 429 return; 430 } 431 432 var range = selection.getRangeAt(0); 433 434 var startContainer = range.startContainer; 435 var startOffset = range.startOffset; 436 437 // ...lookup the end mark 438 findInst.searchString = MARK_SELECTION_END; 439 var endLength = MARK_SELECTION_END.length; 440 findInst.findNext(); 441 442 var endContainer = selection.anchorNode; 443 var endOffset = selection.anchorOffset; 444 445 // reset the selection that find has left 446 selection.removeAllRanges(); 447 448 // delete the special markers now... 449 endContainer.deleteData(endOffset, endLength); 450 startContainer.deleteData(startOffset, startLength); 451 if (startContainer == endContainer) { 452 endOffset -= startLength; 453 } // has shrunk if on same text node... 454 range.setEnd(endContainer, endOffset); 455 456 // show the selection and scroll it into view 457 selection.addRange(range); 458 // the default behavior of the selection is to scroll at the end of 459 // the selection, whereas in this situation, it is more user-friendly 460 // to scroll at the beginning. So we override the default behavior here 461 try { 462 this.selectionController.scrollSelectionIntoView( 463 Ci.nsISelectionController.SELECTION_NORMAL, 464 Ci.nsISelectionController.SELECTION_ANCHOR_REGION, 465 true 466 ); 467 } catch (e) {} 468 469 // restore the current find state 470 findService.matchCase = matchCase; 471 findService.entireWord = entireWord; 472 findService.wrapFind = wrapFind; 473 findService.findBackwards = findBackwards; 474 findService.searchString = searchString; 475 findService.replaceString = replaceString; 476 477 findInst.matchCase = matchCase; 478 findInst.entireWord = entireWord; 479 findInst.wrapFind = wrapFind; 480 findInst.findBackwards = findBackwards; 481 findInst.searchString = searchString; 482 } 483} 484