1// Copyright (c) 2011 The Chromium Authors. All rights reserved. 2// Use of this source code is governed by a BSD-style license that can be 3// found in the LICENSE file. 4 5/** 6 * @fileoverview This file implements the ProxyFormController class, which 7 * wraps a form element with logic that enables implementation of proxy 8 * settings. 9 * 10 * @author mkwst@google.com (Mike West) 11 */ 12 13/** 14 * Wraps the proxy configuration form, binding proper handlers to its various 15 * `change`, `click`, etc. events in order to take appropriate action in 16 * response to user events. 17 * 18 * @param {string} id The form's DOM ID. 19 * @constructor 20 */ 21var ProxyFormController = function(id) { 22 /** 23 * The wrapped form element 24 * @type {Node} 25 * @private 26 */ 27 this.form_ = document.getElementById(id); 28 29 // Throw an error if the element either doesn't exist, or isn't a form. 30 if (!this.form_) 31 throw chrome.i18n.getMessage('errorIdNotFound', id); 32 else if (this.form_.nodeName !== 'FORM') 33 throw chrome.i18n.getMessage('errorIdNotForm', id); 34 35 /** 36 * Cached references to the `fieldset` groups that define the configuration 37 * options presented to the user. 38 * 39 * @type {NodeList} 40 * @private 41 */ 42 this.configGroups_ = document.querySelectorAll('#' + id + ' > fieldset'); 43 44 this.bindEventHandlers_(); 45 this.readCurrentState_(); 46 47 // Handle errors 48 this.handleProxyErrors_(); 49}; 50 51/////////////////////////////////////////////////////////////////////////////// 52 53/** 54 * The proxy types we're capable of handling. 55 * @enum {string} 56 */ 57ProxyFormController.ProxyTypes = { 58 AUTO: 'auto_detect', 59 PAC: 'pac_script', 60 DIRECT: 'direct', 61 FIXED: 'fixed_servers', 62 SYSTEM: 'system' 63}; 64 65/** 66 * The window types we're capable of handling. 67 * @enum {int} 68 */ 69ProxyFormController.WindowTypes = { 70 REGULAR: 1, 71 INCOGNITO: 2 72}; 73 74/** 75 * The extension's level of control of Chrome's roxy setting 76 * @enum {string} 77 */ 78ProxyFormController.LevelOfControl = { 79 NOT_CONTROLLABLE: 'not_controllable', 80 OTHER_EXTENSION: 'controlled_by_other_extension', 81 AVAILABLE: 'controllable_by_this_extension', 82 CONTROLLING: 'controlled_by_this_extension' 83}; 84 85/** 86 * The response type from 'proxy.settings.get' 87 * 88 * @typedef {{value: ProxyConfig, 89 * levelOfControl: ProxyFormController.LevelOfControl}} 90 */ 91ProxyFormController.WrappedProxyConfig; 92 93/////////////////////////////////////////////////////////////////////////////// 94 95/** 96 * Retrieves proxy settings that have been persisted across restarts. 97 * 98 * @return {?ProxyConfig} The persisted proxy configuration, or null if no 99 * value has been persisted. 100 * @static 101 */ 102ProxyFormController.getPersistedSettings = function() { 103 var result = null; 104 if (window.localStorage['proxyConfig'] !== undefined) 105 result = JSON.parse(window.localStorage['proxyConfig']); 106 return result ? result : null; 107}; 108 109 110/** 111 * Persists proxy settings across restarts. 112 * 113 * @param {!ProxyConfig} config The proxy config to persist. 114 * @static 115 */ 116ProxyFormController.setPersistedSettings = function(config) { 117 window.localStorage['proxyConfig'] = JSON.stringify(config); 118}; 119 120/////////////////////////////////////////////////////////////////////////////// 121 122ProxyFormController.prototype = { 123 /** 124 * The form's current state. 125 * @type {regular: ?ProxyConfig, incognito: ?ProxyConfig} 126 * @private 127 */ 128 config_: {regular: null, incognito: null}, 129 130 /** 131 * Do we have access to incognito mode? 132 * @type {boolean} 133 * @private 134 */ 135 isAllowedIncognitoAccess_: false, 136 137 /** 138 * @return {string} The PAC file URL (or an empty string). 139 */ 140 get pacURL() { 141 return document.getElementById('autoconfigURL').value; 142 }, 143 144 145 /** 146 * @param {!string} value The PAC file URL. 147 */ 148 set pacURL(value) { 149 document.getElementById('autoconfigURL').value = value; 150 }, 151 152 153 /** 154 * @return {string} The PAC file data (or an empty string). 155 */ 156 get manualPac() { 157 return document.getElementById('autoconfigData').value; 158 }, 159 160 161 /** 162 * @param {!string} value The PAC file data. 163 */ 164 set manualPac(value) { 165 document.getElementById('autoconfigData').value = value; 166 }, 167 168 169 /** 170 * @return {Array.<string>} A list of hostnames that should bypass the proxy. 171 */ 172 get bypassList() { 173 return document.getElementById('bypassList').value.split(/\s*(?:,|^)\s*/m); 174 }, 175 176 177 /** 178 * @param {?Array.<string>} data A list of hostnames that should bypass 179 * the proxy. If empty, the bypass list is emptied. 180 */ 181 set bypassList(data) { 182 if (!data) 183 data = []; 184 document.getElementById('bypassList').value = data.join(', '); 185 }, 186 187 188 /** 189 * @see http://code.google.com/chrome/extensions/trunk/proxy.html 190 * @return {?ProxyServer} An object containing the proxy server host, port, 191 * and scheme. If null, there is no single proxy. 192 */ 193 get singleProxy() { 194 var checkbox = document.getElementById('singleProxyForEverything'); 195 return checkbox.checked ? this.httpProxy : null; 196 }, 197 198 199 /** 200 * @see http://code.google.com/chrome/extensions/trunk/proxy.html 201 * @param {?ProxyServer} data An object containing the proxy server host, 202 * port, and scheme. If null, the single proxy checkbox will be unchecked. 203 */ 204 set singleProxy(data) { 205 var checkbox = document.getElementById('singleProxyForEverything'); 206 checkbox.checked = !!data; 207 208 if (data) 209 this.httpProxy = data; 210 211 if (checkbox.checked) 212 checkbox.parentNode.parentNode.classList.add('single'); 213 else 214 checkbox.parentNode.parentNode.classList.remove('single'); 215 }, 216 217 /** 218 * @return {?ProxyServer} An object containing the proxy server host, port 219 * and scheme. 220 */ 221 get httpProxy() { 222 return this.getProxyImpl_('Http'); 223 }, 224 225 226 /** 227 * @param {?ProxyServer} data An object containing the proxy server host, 228 * port, and scheme. If empty, empties the proxy setting. 229 */ 230 set httpProxy(data) { 231 this.setProxyImpl_('Http', data); 232 }, 233 234 235 /** 236 * @return {?ProxyServer} An object containing the proxy server host, port 237 * and scheme. 238 */ 239 get httpsProxy() { 240 return this.getProxyImpl_('Https'); 241 }, 242 243 244 /** 245 * @param {?ProxyServer} data An object containing the proxy server host, 246 * port, and scheme. If empty, empties the proxy setting. 247 */ 248 set httpsProxy(data) { 249 this.setProxyImpl_('Https', data); 250 }, 251 252 253 /** 254 * @return {?ProxyServer} An object containing the proxy server host, port 255 * and scheme. 256 */ 257 get ftpProxy() { 258 return this.getProxyImpl_('Ftp'); 259 }, 260 261 262 /** 263 * @param {?ProxyServer} data An object containing the proxy server host, 264 * port, and scheme. If empty, empties the proxy setting. 265 */ 266 set ftpProxy(data) { 267 this.setProxyImpl_('Ftp', data); 268 }, 269 270 271 /** 272 * @return {?ProxyServer} An object containing the proxy server host, port 273 * and scheme. 274 */ 275 get fallbackProxy() { 276 return this.getProxyImpl_('Fallback'); 277 }, 278 279 280 /** 281 * @param {?ProxyServer} data An object containing the proxy server host, 282 * port, and scheme. If empty, empties the proxy setting. 283 */ 284 set fallbackProxy(data) { 285 this.setProxyImpl_('Fallback', data); 286 }, 287 288 289 /** 290 * @param {string} type The type of proxy that's being set ("Http", 291 * "Https", etc.). 292 * @return {?ProxyServer} An object containing the proxy server host, 293 * port, and scheme. 294 * @private 295 */ 296 getProxyImpl_: function(type) { 297 var result = { 298 scheme: document.getElementById('proxyScheme' + type).value, 299 host: document.getElementById('proxyHost' + type).value, 300 port: parseInt(document.getElementById('proxyPort' + type).value, 10) 301 }; 302 return (result.scheme && result.host && result.port) ? result : undefined; 303 }, 304 305 306 /** 307 * A generic mechanism for setting proxy data. 308 * 309 * @see http://code.google.com/chrome/extensions/trunk/proxy.html 310 * @param {string} type The type of proxy that's being set ("Http", 311 * "Https", etc.). 312 * @param {?ProxyServer} data An object containing the proxy server host, 313 * port, and scheme. If empty, empties the proxy setting. 314 * @private 315 */ 316 setProxyImpl_: function(type, data) { 317 if (!data) 318 data = {scheme: 'http', host: '', port: ''}; 319 320 document.getElementById('proxyScheme' + type).value = data.scheme; 321 document.getElementById('proxyHost' + type).value = data.host; 322 document.getElementById('proxyPort' + type).value = data.port; 323 }, 324 325/////////////////////////////////////////////////////////////////////////////// 326 327 /** 328 * Calls the proxy API to read the current settings, and populates the form 329 * accordingly. 330 * 331 * @private 332 */ 333 readCurrentState_: function() { 334 chrome.extension.isAllowedIncognitoAccess( 335 this.handleIncognitoAccessResponse_.bind(this)); 336 }, 337 338 /** 339 * Handles the respnse from `chrome.extension.isAllowedIncognitoAccess` 340 * We can't render the form until we know what our access level is, so 341 * we wait until we have confirmed incognito access levels before 342 * asking for the proxy state. 343 * 344 * @param {boolean} state The state of incognito access. 345 * @private 346 */ 347 handleIncognitoAccessResponse_: function(state) { 348 this.isAllowedIncognitoAccess_ = state; 349 chrome.proxy.settings.get({incognito: false}, 350 this.handleRegularState_.bind(this)); 351 if (this.isAllowedIncognitoAccess_) { 352 chrome.proxy.settings.get({incognito: true}, 353 this.handleIncognitoState_.bind(this)); 354 } 355 }, 356 357 /** 358 * Handles the response from 'proxy.settings.get' for regular 359 * settings. 360 * 361 * @param {ProxyFormController.WrappedProxyConfig} c The proxy data and 362 * extension's level of control thereof. 363 * @private 364 */ 365 handleRegularState_: function(c) { 366 if (c.levelOfControl === ProxyFormController.LevelOfControl.AVAILABLE || 367 c.levelOfControl === ProxyFormController.LevelOfControl.CONTROLLING) { 368 this.recalcFormValues_(c.value); 369 this.config_.regular = c.value; 370 } else { 371 this.handleLackOfControl_(c.levelOfControl); 372 } 373 }, 374 375 /** 376 * Handles the response from 'proxy.settings.get' for incognito 377 * settings. 378 * 379 * @param {ProxyFormController.WrappedProxyConfig} c The proxy data and 380 * extension's level of control thereof. 381 * @private 382 */ 383 handleIncognitoState_: function(c) { 384 if (c.levelOfControl === ProxyFormController.LevelOfControl.AVAILABLE || 385 c.levelOfControl === ProxyFormController.LevelOfControl.CONTROLLING) { 386 if (this.isIncognitoMode_()) 387 this.recalcFormValues_(c.value); 388 389 this.config_.incognito = c.value; 390 } else { 391 this.handleLackOfControl_(c.levelOfControl); 392 } 393 }, 394 395 /** 396 * Binds event handlers for the various bits and pieces of the form that 397 * are interesting to the controller. 398 * 399 * @private 400 */ 401 bindEventHandlers_: function() { 402 this.form_.addEventListener('click', this.dispatchFormClick_.bind(this)); 403 }, 404 405 406 /** 407 * When a `click` event is triggered on the form, this function handles it by 408 * analyzing the context, and dispatching the click to the correct handler. 409 * 410 * @param {Event} e The event to be handled. 411 * @private 412 * @return {boolean} True if the event should bubble, false otherwise. 413 */ 414 dispatchFormClick_: function(e) { 415 var t = e.target; 416 417 // Case 1: "Apply" 418 if (t.nodeName === 'INPUT' && t.getAttribute('type') === 'submit') { 419 return this.applyChanges_(e); 420 421 // Case 2: "Use the same proxy for all protocols" in an active section 422 } else if (t.nodeName === 'INPUT' && 423 t.getAttribute('type') === 'checkbox' && 424 t.parentNode.parentNode.parentNode.classList.contains('active') 425 ) { 426 return this.toggleSingleProxyConfig_(e); 427 428 // Case 3: "Flip to incognito mode." 429 } else if (t.nodeName === 'BUTTON') { 430 return this.toggleIncognitoMode_(e); 431 432 // Case 4: Click on something random: maybe changing active config group? 433 } else { 434 // Walk up the tree until we hit `form > fieldset` or fall off the top 435 while (t && (t.nodeName !== 'FIELDSET' || 436 t.parentNode.nodeName !== 'FORM')) { 437 t = t.parentNode; 438 } 439 if (t) { 440 this.changeActive_(t); 441 return false; 442 } 443 } 444 return true; 445 }, 446 447 448 /** 449 * Sets the form's active config group. 450 * 451 * @param {DOMElement} fieldset The configuration group to activate. 452 * @private 453 */ 454 changeActive_: function(fieldset) { 455 for (var i = 0; i < this.configGroups_.length; i++) { 456 var el = this.configGroups_[i]; 457 var radio = el.querySelector("input[type='radio']"); 458 if (el === fieldset) { 459 el.classList.add('active'); 460 radio.checked = true; 461 } else { 462 el.classList.remove('active'); 463 } 464 } 465 this.recalcDisabledInputs_(); 466 }, 467 468 469 /** 470 * Recalculates the `disabled` state of the form's input elements, based 471 * on the currently active group, and that group's contents. 472 * 473 * @private 474 */ 475 recalcDisabledInputs_: function() { 476 var i, j; 477 for (i = 0; i < this.configGroups_.length; i++) { 478 var el = this.configGroups_[i]; 479 var inputs = el.querySelectorAll( 480 "input:not([type='radio']), select, textarea"); 481 if (el.classList.contains('active')) { 482 for (j = 0; j < inputs.length; j++) { 483 inputs[j].removeAttribute('disabled'); 484 } 485 } else { 486 for (j = 0; j < inputs.length; j++) { 487 inputs[j].setAttribute('disabled', 'disabled'); 488 } 489 } 490 } 491 }, 492 493 494 /** 495 * Handler called in response to click on form's submission button. Generates 496 * the proxy configuration and passes it to `useCustomProxySettings`, or 497 * handles errors in user input. 498 * 499 * Proxy errors (and the browser action's badge) are cleared upon setting new 500 * values. 501 * 502 * @param {Event} e DOM event generated by the user's click. 503 * @private 504 */ 505 applyChanges_: function(e) { 506 e.preventDefault(); 507 e.stopPropagation(); 508 509 if (this.isIncognitoMode_()) 510 this.config_.incognito = this.generateProxyConfig_(); 511 else 512 this.config_.regular = this.generateProxyConfig_(); 513 514 chrome.proxy.settings.set( 515 {value: this.config_.regular, scope: 'regular'}, 516 this.callbackForRegularSettings_.bind(this)); 517 chrome.extension.sendRequest({type: 'clearError'}); 518 }, 519 520 /** 521 * Called in response to setting a regular window's proxy settings: checks 522 * for `lastError`, and then sets incognito settings (if they exist). 523 * 524 * @private 525 */ 526 callbackForRegularSettings_: function() { 527 if (chrome.runtime.lastError) { 528 this.generateAlert_(chrome.i18n.getMessage('errorSettingRegularProxy')); 529 return; 530 } 531 if (this.config_.incognito) { 532 chrome.proxy.settings.set( 533 {value: this.config_.incognito, scope: 'incognito_persistent'}, 534 this.callbackForIncognitoSettings_.bind(this)); 535 } else { 536 ProxyFormController.setPersistedSettings(this.config_); 537 this.generateAlert_(chrome.i18n.getMessage('successfullySetProxy')); 538 } 539 }, 540 541 /** 542 * Called in response to setting an incognito window's proxy settings: checks 543 * for `lastError` and sets a success message. 544 * 545 * @private 546 */ 547 callbackForIncognitoSettings_: function() { 548 if (chrome.runtime.lastError) { 549 this.generateAlert_(chrome.i18n.getMessage('errorSettingIncognitoProxy')); 550 return; 551 } 552 ProxyFormController.setPersistedSettings(this.config_); 553 this.generateAlert_( 554 chrome.i18n.getMessage('successfullySetProxy')); 555 }, 556 557 /** 558 * Generates an alert overlay inside the proxy's popup, then closes the popup 559 * after a short delay. 560 * 561 * @param {string} msg The message to be displayed in the overlay. 562 * @param {?boolean} close Should the window be closed? Defaults to true. 563 * @private 564 */ 565 generateAlert_: function(msg, close) { 566 var success = document.createElement('div'); 567 success.classList.add('overlay'); 568 success.setAttribute('role', 'alert'); 569 success.textContent = msg; 570 document.body.appendChild(success); 571 572 setTimeout(function() { success.classList.add('visible'); }, 10); 573 setTimeout(function() { 574 if (close === false) 575 success.classList.remove('visible'); 576 else 577 window.close(); 578 }, 4000); 579 }, 580 581 582 /** 583 * Parses the proxy configuration form, and generates a ProxyConfig object 584 * that can be passed to `useCustomProxyConfig`. 585 * 586 * @see http://code.google.com/chrome/extensions/trunk/proxy.html 587 * @return {ProxyConfig} The proxy configuration represented by the form. 588 * @private 589 */ 590 generateProxyConfig_: function() { 591 var active = document.getElementsByClassName('active')[0]; 592 switch (active.id) { 593 case ProxyFormController.ProxyTypes.SYSTEM: 594 return {mode: 'system'}; 595 case ProxyFormController.ProxyTypes.DIRECT: 596 return {mode: 'direct'}; 597 case ProxyFormController.ProxyTypes.PAC: 598 var pacScriptURL = this.pacURL; 599 var pacManual = this.manualPac; 600 if (pacScriptURL) 601 return {mode: 'pac_script', 602 pacScript: {url: pacScriptURL, mandatory: true}}; 603 else if (pacManual) 604 return {mode: 'pac_script', 605 pacScript: {data: pacManual, mandatory: true}}; 606 else 607 return {mode: 'auto_detect'}; 608 case ProxyFormController.ProxyTypes.FIXED: 609 var config = {mode: 'fixed_servers'}; 610 if (this.singleProxy) { 611 config.rules = { 612 singleProxy: this.singleProxy, 613 bypassList: this.bypassList 614 }; 615 } else { 616 config.rules = { 617 proxyForHttp: this.httpProxy, 618 proxyForHttps: this.httpsProxy, 619 proxyForFtp: this.ftpProxy, 620 fallbackProxy: this.fallbackProxy, 621 bypassList: this.bypassList 622 }; 623 } 624 return config; 625 } 626 }, 627 628 629 /** 630 * Sets the proper display classes based on the "Use the same proxy server 631 * for all protocols" checkbox. Expects to be called as an event handler 632 * when that field is clicked. 633 * 634 * @param {Event} e The `click` event to respond to. 635 * @private 636 */ 637 toggleSingleProxyConfig_: function(e) { 638 var checkbox = e.target; 639 if (checkbox.nodeName === 'INPUT' && 640 checkbox.getAttribute('type') === 'checkbox') { 641 if (checkbox.checked) 642 checkbox.parentNode.parentNode.classList.add('single'); 643 else 644 checkbox.parentNode.parentNode.classList.remove('single'); 645 } 646 }, 647 648 649 /** 650 * Returns the form's current incognito status. 651 * 652 * @return {boolean} True if the form is in incognito mode, false otherwise. 653 * @private 654 */ 655 isIncognitoMode_: function(e) { 656 return this.form_.parentNode.classList.contains('incognito'); 657 }, 658 659 660 /** 661 * Toggles the form's incognito mode. Saves the current state to an object 662 * property for later use, clears the form, and toggles the appropriate state. 663 * 664 * @param {Event} e The `click` event to respond to. 665 * @private 666 */ 667 toggleIncognitoMode_: function(e) { 668 var div = this.form_.parentNode; 669 var button = document.getElementsByTagName('button')[0]; 670 671 // Cancel the button click. 672 e.preventDefault(); 673 e.stopPropagation(); 674 675 // If we can't access Incognito settings, throw a message and return. 676 if (!this.isAllowedIncognitoAccess_) { 677 var msg = "I'm sorry, Dave, I'm afraid I can't do that. Give me access " + 678 "to Incognito settings by checking the checkbox labeled " + 679 "'Allow in Incognito mode', which is visible at " + 680 "chrome://extensions."; 681 this.generateAlert_(msg, false); 682 return; 683 } 684 685 if (this.isIncognitoMode_()) { 686 // In incognito mode, switching to cognito. 687 this.config_.incognito = this.generateProxyConfig_(); 688 div.classList.remove('incognito'); 689 this.recalcFormValues_(this.config_.regular); 690 button.innerText = 'Configure incognito window settings.'; 691 } else { 692 // In cognito mode, switching to incognito. 693 this.config_.regular = this.generateProxyConfig_(); 694 div.classList.add('incognito'); 695 this.recalcFormValues_(this.config_.incognito); 696 button.innerText = 'Configure regular window settings.'; 697 } 698 }, 699 700 701 /** 702 * Sets the form's values based on a ProxyConfig. 703 * 704 * @param {!ProxyConfig} c The ProxyConfig object. 705 * @private 706 */ 707 recalcFormValues_: function(c) { 708 // Normalize `auto_detect` 709 if (c.mode === 'auto_detect') 710 c.mode = 'pac_script'; 711 // Activate one of the groups, based on `mode`. 712 this.changeActive_(document.getElementById(c.mode)); 713 // Populate the PAC script 714 if (c.pacScript) { 715 if (c.pacScript.url) 716 this.pacURL = c.pacScript.url; 717 } else { 718 this.pacURL = ''; 719 } 720 // Evaluate the `rules` 721 if (c.rules) { 722 var rules = c.rules; 723 if (rules.singleProxy) { 724 this.singleProxy = rules.singleProxy; 725 } else { 726 this.singleProxy = null; 727 this.httpProxy = rules.proxyForHttp; 728 this.httpsProxy = rules.proxyForHttps; 729 this.ftpProxy = rules.proxyForFtp; 730 this.fallbackProxy = rules.fallbackProxy; 731 } 732 this.bypassList = rules.bypassList; 733 } else { 734 this.singleProxy = null; 735 this.httpProxy = null; 736 this.httpsProxy = null; 737 this.ftpProxy = null; 738 this.fallbackProxy = null; 739 this.bypassList = ''; 740 } 741 }, 742 743 744 /** 745 * Handles the case in which this extension doesn't have the ability to 746 * control the Proxy settings, either because of an overriding policy 747 * or an extension with higher priority. 748 * 749 * @param {ProxyFormController.LevelOfControl} l The level of control this 750 * extension has over the proxy settings. 751 * @private 752 */ 753 handleLackOfControl_: function(l) { 754 var msg; 755 if (l === ProxyFormController.LevelOfControl.NO_ACCESS) 756 msg = chrome.i18n.getMessage('errorNoExtensionAccess'); 757 else if (l === ProxyFormController.LevelOfControl.OTHER_EXTENSION) 758 msg = chrome.i18n.getMessage('errorOtherExtensionControls'); 759 this.generateAlert_(msg); 760 }, 761 762 763 /** 764 * Handle the case in which errors have been generated outside the context 765 * of this popup. 766 * 767 * @private 768 */ 769 handleProxyErrors_: function() { 770 chrome.extension.sendRequest( 771 {type: 'getError'}, 772 this.handleProxyErrorHandlerResponse_.bind(this)); 773 }, 774 775 /** 776 * Handles response from ProxyErrorHandler 777 * 778 * @param {{result: !string}} response The message sent in response to this 779 * popup's request. 780 */ 781 handleProxyErrorHandlerResponse_: function(response) { 782 if (response.result !== null) { 783 var error = JSON.parse(response.result); 784 console.error(error); 785 // TODO(mkwst): Do something more interesting 786 this.generateAlert_( 787 chrome.i18n.getMessage( 788 error.details ? 'errorProxyDetailedError' : 'errorProxyError', 789 [error.error, error.details]), 790 false); 791 } 792 } 793}; 794