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