• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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