Rietveld Code Review Tool
Help | Bug tracker | Discussion group | Source code

Side by Side Diff: ext/background.js

Issue 29452181: Noissue - Merge current tip to Edge bookmark (Closed)
Patch Set: Created May 30, 2017, 3:49 p.m.
Left:
Right:
Use n/p to move between diff chunks; N/P to move between comments.
Jump to:
View unified diff | Download patch
« no previous file with comments | « devtools.js ('k') | ext/common.js » ('j') | no next file with comments »
Toggle Intra-line Diffs ('i') | Expand Comments ('e') | Collapse Comments ('c') | Show Comments Hide Comments ('s')
OLDNEW
1 /* 1 /*
2 * This file is part of Adblock Plus <https://adblockplus.org/>, 2 * This file is part of Adblock Plus <https://adblockplus.org/>,
3 * Copyright (C) 2006-2016 Eyeo GmbH 3 * Copyright (C) 2006-2017 eyeo GmbH
4 * 4 *
5 * Adblock Plus is free software: you can redistribute it and/or modify 5 * Adblock Plus is free software: you can redistribute it and/or modify
6 * it under the terms of the GNU General Public License version 3 as 6 * it under the terms of the GNU General Public License version 3 as
7 * published by the Free Software Foundation. 7 * published by the Free Software Foundation.
8 * 8 *
9 * Adblock Plus is distributed in the hope that it will be useful, 9 * Adblock Plus is distributed in the hope that it will be useful,
10 * but WITHOUT ANY WARRANTY; without even the implied warranty of 10 * but WITHOUT ANY WARRANTY; without even the implied warranty of
11 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 * GNU General Public License for more details. 12 * GNU General Public License for more details.
13 * 13 *
14 * You should have received a copy of the GNU General Public License 14 * You should have received a copy of the GNU General Public License
15 * along with Adblock Plus. If not, see <http://www.gnu.org/licenses/>. 15 * along with Adblock Plus. If not, see <http://www.gnu.org/licenses/>.
16 */ 16 */
17 17
18 "use strict"; 18 "use strict";
19 19
20 (function()
20 { 21 {
21 let nonEmptyPageMaps = Object.create(null); 22 let nonEmptyPageMaps = new Set();
22 let pageMapCounter = 0;
23 23
24 let PageMap = ext.PageMap = function() 24 let PageMap = ext.PageMap = function()
25 { 25 {
26 this._map = Object.create(null); 26 this._map = new Map();
27 this._id = ++pageMapCounter;
28 }; 27 };
29 PageMap.prototype = { 28 PageMap.prototype = {
30 _delete(id) 29 _delete(id)
31 { 30 {
32 delete this._map[id]; 31 this._map.delete(id);
33 32
34 if (Object.keys(this._map).length == 0) 33 if (this._map.size == 0)
35 delete nonEmptyPageMaps[this._id]; 34 nonEmptyPageMaps.delete(this);
36 }, 35 },
37 keys() 36 keys()
38 { 37 {
39 return Object.keys(this._map).map(ext.getPage); 38 return Array.from(this._map.keys()).map(ext.getPage);
40 }, 39 },
41 get(page) 40 get(page)
42 { 41 {
43 return this._map[page.id]; 42 return this._map.get(page.id);
44 }, 43 },
45 set(page, value) 44 set(page, value)
46 { 45 {
47 this._map[page.id] = value; 46 this._map.set(page.id, value);
48 nonEmptyPageMaps[this._id] = this; 47 nonEmptyPageMaps.add(this);
49 }, 48 },
50 has(page) 49 has(page)
51 { 50 {
52 return page.id in this._map; 51 return this._map.has(page.id);
53 }, 52 },
54 clear() 53 clear()
55 { 54 {
56 for (let id in this._map) 55 this._map.clear();
57 this._delete(id); 56 nonEmptyPageMaps.delete(this);
58 }, 57 },
59 delete(page) 58 delete(page)
60 { 59 {
61 this._delete(page.id); 60 this._delete(page.id);
62 } 61 }
63 }; 62 };
64 63
65 ext._removeFromAllPageMaps = pageId => 64 ext._removeFromAllPageMaps = pageId =>
66 { 65 {
67 for (let pageMapId in nonEmptyPageMaps) 66 for (let pageMap of nonEmptyPageMaps)
68 nonEmptyPageMaps[pageMapId]._delete(pageId); 67 pageMap._delete(pageId);
69 }; 68 };
70 } 69
70 /* Pages */
71
72 let Page = ext.Page = function(tab)
73 {
74 this.id = tab.id;
75 this._url = tab.url && new URL(tab.url);
76
77 this.browserAction = new BrowserAction(tab.id);
78 this.contextMenus = new ContextMenus(this);
79 };
80 Page.prototype = {
81 get url()
82 {
83 // usually our Page objects are created from Chrome's Tab objects, which
84 // provide the url. So we can return the url given in the constructor.
85 if (this._url)
86 return this._url;
87
88 // but sometimes we only have the tab id when we create a Page object.
89 // In that case we get the url from top frame of the tab, recorded by
90 // the onBeforeRequest handler.
91 let frames = framesOfTabs.get(this.id);
92 if (frames)
93 {
94 let frame = frames.get(0);
95 if (frame)
96 return frame.url;
97 }
98 },
99 sendMessage(message, responseCallback)
100 {
101 chrome.tabs.sendMessage(this.id, message, responseCallback);
102 }
103 };
104
105 ext.getPage = id => new Page({id: parseInt(id, 10)});
106
107 function afterTabLoaded(callback)
108 {
109 return openedTab =>
110 {
111 let onUpdated = (tabId, changeInfo, tab) =>
112 {
113 if (tabId == openedTab.id && changeInfo.status == "complete")
114 {
115 chrome.tabs.onUpdated.removeListener(onUpdated);
116 callback(new Page(openedTab));
117 }
118 };
119 chrome.tabs.onUpdated.addListener(onUpdated);
120 };
121 }
122
123 ext.pages = {
124 open(url, callback)
125 {
126 if (typeof callback !== "undefined" && typeof callback !== "null")
127 {
128 chrome.tabs.create({url: url}, callback && afterTabLoaded(callback));
129 }
130 else
131 {
132 chrome.tabs.create({url: url});
133 }
134 },
135 query(info, callback)
136 {
137 let rawInfo = {};
138 for (let property in info)
139 {
140 switch (property)
141 {
142 case "active":
143 case "lastFocusedWindow":
144 rawInfo[property] = info[property];
145 }
146 }
147
148 chrome.tabs.query(rawInfo, tabs =>
149 {
150 callback(tabs.map(tab => new Page(tab)));
151 });
152 },
153 onLoading: new ext._EventTarget(),
154 onActivated: new ext._EventTarget(),
155 onRemoved: new ext._EventTarget()
156 };
157
158 chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) =>
159 {
160 if (changeInfo.status == "loading")
161 ext.pages.onLoading._dispatch(new Page(tab));
162 });
163
164 function createFrame(tabId, frameId)
165 {
166 let frames = framesOfTabs.get(tabId);
167 if (!frames)
168 {
169 frames = new Map();
170 framesOfTabs.set(tabId, frames);
171 }
172
173 let frame = frames.get(frameId);
174 if (!frame)
175 {
176 frame = {};
177 frames.set(frameId, frame);
178 }
179
180 return frame;
181 }
182
183 function updatePageFrameStructure(frameId, tabId, url, parentFrameId)
184 {
185 if (frameId == 0)
186 {
187 let page = new Page({id: tabId, url});
188
189 ext._removeFromAllPageMaps(tabId);
190
191 chrome.tabs.get(tabId, () =>
192 {
193 // If the tab is prerendered, chrome.tabs.get() sets
194 // chrome.runtime.lastError and we have to dispatch the onLoading event,
195 // since the onUpdated event isn't dispatched for prerendered tabs.
196 // However, we have to keep relying on the unUpdated event for tabs that
197 // are already visible. Otherwise browser action changes get overridden
198 // when Chrome automatically resets them on navigation.
199 if (chrome.runtime.lastError)
200 ext.pages.onLoading._dispatch(page);
201 });
202 }
203
204 // Update frame URL and parent in frame structure
205 let frame = createFrame(tabId, frameId);
206 frame.url = new URL(url);
207
208 let parentFrame = framesOfTabs.get(tabId).get(parentFrameId);
209 if (parentFrame)
210 frame.parent = parentFrame;
211 }
212
213 chrome.webRequest.onHeadersReceived.addListener(details =>
214 {
215 // We have to update the frame structure when switching to a new
216 // document, so that we process any further requests made by that
217 // document in the right context. Unfortunately, we cannot rely
218 // on webNavigation.onCommitted since it isn't guaranteed to fire
219 // before any subresources start downloading[1]. As an
220 // alternative we use webRequest.onHeadersReceived for HTTP(S)
221 // URLs, being careful to ignore any responses that won't cause
222 // the document to be replaced.
223 // [1] - https://bugs.chromium.org/p/chromium/issues/detail?id=665843
224
225 // The request has been processed without replacing the document.
226 // https://chromium.googlesource.com/chromium/src/+/02d3f50b/content/browser /frame_host/navigation_request.cc#473
227 if (details.statusCode == 204 || details.statusCode == 205)
228 return;
229
230 for (let header of details.responseHeaders)
231 {
232 let headerName = header.name.toLowerCase();
233
234 // For redirects we must wait for the next response in order
235 // to know if the document will be replaced. Note: Chrome
236 // performs a redirect only if there is a "Location" header with
237 // a non-empty value and a known redirect status code.
238 // https://chromium.googlesource.com/chromium/src/+/39a7d96/net/http/http_ response_headers.cc#929
239 if (headerName == "location" && header.value &&
240 (details.statusCode == 301 || details.statusCode == 302 ||
241 details.statusCode == 303 || details.statusCode == 307 ||
242 details.statusCode == 308))
243 return;
244
245 // If the response initiates a download the document won't be
246 // replaced. Chrome initiates a download if there is a
247 // "Content-Disposition" with a valid and non-empty value other
248 // than "inline".
249 // https://chromium.googlesource.com/chromium/src/+/02d3f50b/content/brows er/loader/mime_sniffing_resource_handler.cc#534
250 // https://chromium.googlesource.com/chromium/src/+/02d3f50b/net/http/http _content_disposition.cc#374
251 // https://chromium.googlesource.com/chromium/src/+/16e2688e/net/http/http _util.cc#431
252 if (headerName == "content-disposition")
253 {
254 let disposition = header.value.split(";")[0].replace(/[ \t]+$/, "");
255 if (disposition.toLowerCase() != "inline" &&
256 /^[\x21-\x7E]+$/.test(disposition) &&
257 !/[()<>@,;:\\"/[\]?={}]/.test(disposition))
258 return;
259 }
260
261 // The value of the "Content-Type" header also determines if Chrome will
262 // initiate a download, or otherwise how the response will be rendered.
263 // We only need to consider responses which will result in a navigation
264 // and be rendered as HTML or similar.
265 // Note: Chrome might render the response as HTML if the "Content-Type"
266 // header is missing, invalid or unknown.
267 // https://chromium.googlesource.com/chromium/src/+/99f41af9/net/http/http _util.cc#66
268 // https://chromium.googlesource.com/chromium/src/+/3130418a/net/base/mime _sniffer.cc#667
269 if (headerName == "content-type")
270 {
271 let mediaType = header.value.split(/[ \t;(]/)[0].toLowerCase();
272 if (mediaType.includes("/") &&
273 mediaType != "*/*" &&
274 mediaType != "application/unknown" &&
275 mediaType != "unknown/unknown" &&
276 mediaType != "text/html" &&
277 mediaType != "text/xml" &&
278 mediaType != "application/xml" &&
279 mediaType != "application/xhtml+xml" &&
280 mediaType != "image/svg+xml")
281 return;
282 }
283 }
284
285 updatePageFrameStructure(details.frameId, details.tabId, details.url,
286 details.parentFrameId);
287 },
288 {types: ["main_frame", "sub_frame"], urls: ["http://*/*", "https://*/*"]},
289 ["responseHeaders"]);
290
291 chrome.webNavigation.onBeforeNavigate.addListener(details =>
292 {
293 // Since we can only listen for HTTP(S) responses using
294 // webRequest.onHeadersReceived we must update the page structure here for
295 // other navigations.
296 let url = new URL(details.url);
297 if (url.protocol != "http:" && url.protocol != "https:")
298 {
299 updatePageFrameStructure(details.frameId, details.tabId, details.url,
300 details.parentFrameId);
301 }
302 });
303
304 function forgetTab(tabId)
305 {
306 ext.pages.onRemoved._dispatch(tabId);
307
308 ext._removeFromAllPageMaps(tabId);
309 framesOfTabs.delete(tabId);
310 }
311
312 chrome.tabs.onReplaced.addListener((addedTabId, removedTabId) =>
313 {
314 forgetTab(removedTabId);
315 });
316
317 chrome.tabs.onRemoved.addListener(forgetTab);
318
319 chrome.tabs.onActivated.addListener(details =>
320 {
321 ext.pages.onActivated._dispatch(new Page({id: details.tabId}));
322 });
323
324
325 /* Browser actions */
326
327 let BrowserAction = function(tabId)
328 {
329 this._tabId = tabId;
330 this._changes = null;
331 };
332 BrowserAction.prototype = {
333 _applyChanges()
334 {
335 if ("iconPath" in this._changes)
336 {
337 chrome.browserAction.setIcon({
338 tabId: this._tabId,
339 path: {
340 16: this._changes.iconPath.replace("$size", "16"),
341 19: this._changes.iconPath.replace("$size", "19"),
342 20: this._changes.iconPath.replace("$size", "20"),
343 32: this._changes.iconPath.replace("$size", "32"),
344 38: this._changes.iconPath.replace("$size", "38"),
345 40: this._changes.iconPath.replace("$size", "40")
346 }
347 });
348 }
349
350 if ("badgeText" in this._changes)
351 {
352 chrome.browserAction.setBadgeText({
353 tabId: this._tabId,
354 text: this._changes.badgeText
355 });
356 }
357
358 if ("badgeColor" in this._changes)
359 {
360 chrome.browserAction.setBadgeBackgroundColor({
361 tabId: this._tabId,
362 color: this._changes.badgeColor
363 });
364 }
365
366 this._changes = null;
367 },
368 _queueChanges()
369 {
370 chrome.tabs.get(this._tabId, () =>
371 {
372 // If the tab is prerendered, chrome.tabs.get() sets
373 // chrome.runtime.lastError and we have to delay our changes
374 // until the currently visible tab is replaced with the
375 // prerendered tab. Otherwise chrome.browserAction.set* fails.
376 if (chrome.runtime.lastError)
377 {
378 let onReplaced = (addedTabId, removedTabId) =>
379 {
380 if (addedTabId == this._tabId)
381 {
382 chrome.tabs.onReplaced.removeListener(onReplaced);
383 this._applyChanges();
384 }
385 };
386 chrome.tabs.onReplaced.addListener(onReplaced);
387 }
388 else
389 {
390 this._applyChanges();
391 }
392 });
393 },
394 _addChange(name, value)
395 {
396 if (!this._changes)
397 {
398 this._changes = {};
399 this._queueChanges();
400 }
401
402 this._changes[name] = value;
403 },
404 setIcon(path)
405 {
406 this._addChange("iconPath", path);
407 },
408 setBadge(badge)
409 {
410 if (!badge)
411 {
412 this._addChange("badgeText", "");
413 }
414 else
415 {
416 if ("number" in badge)
417 this._addChange("badgeText", badge.number.toString());
418
419 if ("color" in badge)
420 this._addChange("badgeColor", badge.color);
421 }
422 }
423 };
424
425
426 /* Context menus */
427
428 let contextMenuItems = new ext.PageMap();
429 let contextMenuUpdating = false;
430
431 let updateContextMenu = () =>
432 {
433 if (contextMenuUpdating)
434 return;
435
436 contextMenuUpdating = true;
437
438 chrome.tabs.query({active: true, lastFocusedWindow: true}, tabs =>
439 {
440 chrome.contextMenus.removeAll(() =>
441 {
442 contextMenuUpdating = false;
443
444 if (tabs.length == 0)
445 return;
446
447 let items = contextMenuItems.get({id: tabs[0].id});
448
449 if (!items)
450 return;
451
452 items.forEach(item =>
453 {
454 chrome.contextMenus.create({
455 title: item.title,
456 contexts: item.contexts,
457 onclick(info, tab)
458 {
459 item.onclick(new Page(tab));
460 }
461 });
462 });
463 });
464 });
465 };
466
467 let ContextMenus = function(page)
468 {
469 this._page = page;
470 };
471 ContextMenus.prototype = {
472 create(item)
473 {
474 let items = contextMenuItems.get(this._page);
475 if (!items)
476 contextMenuItems.set(this._page, items = []);
477
478 items.push(item);
479 updateContextMenu();
480 },
481 remove(item)
482 {
483 let items = contextMenuItems.get(this._page);
484 if (items)
485 {
486 let index = items.indexOf(item);
487 if (index != -1)
488 {
489 items.splice(index, 1);
490 updateContextMenu();
491 }
492 }
493 }
494 };
495
496 chrome.tabs.onActivated.addListener(updateContextMenu);
497
498 chrome.windows.onFocusChanged.addListener(windowId =>
499 {
500 if (windowId != chrome.windows.WINDOW_ID_NONE)
501 updateContextMenu();
502 });
503
504
505 /* Web requests */
506
507 let framesOfTabs = new Map();
508
509 ext.getFrame = (tabId, frameId) =>
510 {
511 let frames = framesOfTabs.get(tabId);
512 return frames && frames.get(frameId);
513 };
514
515 let handlerBehaviorChangedQuota =
516 chrome.webRequest.MAX_HANDLER_BEHAVIOR_CHANGED_CALLS_PER_10_MINUTES;
517
518 function propagateHandlerBehaviorChange()
519 {
520 // Make sure to not call handlerBehaviorChanged() more often than allowed
521 // by chrome.webRequest.MAX_HANDLER_BEHAVIOR_CHANGED_CALLS_PER_10_MINUTES.
522 // Otherwise Chrome notifies the user that this extension is causing issues.
523 if (handlerBehaviorChangedQuota > 0)
524 {
525 chrome.webNavigation.onBeforeNavigate.removeListener(
526 propagateHandlerBehaviorChange
527 );
528 chrome.webRequest.handlerBehaviorChanged();
529
530 handlerBehaviorChangedQuota--;
531 setTimeout(() => { handlerBehaviorChangedQuota++; }, 600000);
532 }
533 }
534
535 ext.webRequest = {
536 onBeforeRequest: new ext._EventTarget(),
537 handlerBehaviorChanged()
538 {
539 // Defer handlerBehaviorChanged() until navigation occurs.
540 // There wouldn't be any visible effect when calling it earlier,
541 // but it's an expensive operation and that way we avoid to call
542 // it multiple times, if multiple filters are added/removed.
543 let {onBeforeNavigate} = chrome.webNavigation;
544 if (!onBeforeNavigate.hasListener(propagateHandlerBehaviorChange))
545 onBeforeNavigate.addListener(propagateHandlerBehaviorChange);
546 }
547 };
548
549 chrome.tabs.query({}, tabs =>
550 {
551 tabs.forEach(tab =>
552 {
553 chrome.webNavigation.getAllFrames({tabId: tab.id}, details =>
554 {
555 if (details && details.length > 0)
556 {
557 let frames = new Map();
558 framesOfTabs.set(tab.id, frames);
559
560 for (let detail of details)
561 {
562 let frame = {url: new URL(detail.url)};
563 frames.set(detail.frameId, frame);
564
565 if (detail.parentFrameId != -1)
566 frame.parent = frames.get(detail.parentFrameId);
567 }
568 }
569 });
570 });
571 });
572
573 chrome.webRequest.onBeforeRequest.addListener(details =>
574 {
575 // The high-level code isn't interested in requests that aren't
576 // related to a tab or requests loading a top-level document,
577 // those should never be blocked.
578 if (details.tabId == -1 || details.type == "main_frame")
579 return;
580
581 // Filter out requests from non web protocols. Ideally, we'd explicitly
582 // specify the protocols we are interested in (i.e. http://, https://,
583 // ws:// and wss://) with the url patterns, given below, when adding this
584 // listener. But unfortunately, Chrome <=57 doesn't support the WebSocket
585 // protocol and is causing an error if it is given.
586 let url = new URL(details.url);
587 if (url.protocol != "http:" && url.protocol != "https:" &&
588 url.protocol != "ws:" && url.protocol != "wss:")
589 return;
590
591 // We are looking for the frame that contains the element which
592 // has triggered this request. For most requests (e.g. images) we
593 // can just use the request's frame ID, but for subdocument requests
594 // (e.g. iframes) we must instead use the request's parent frame ID.
595 let {frameId, type} = details;
596 if (type == "sub_frame")
597 frameId = details.parentFrameId;
598
599 let frame = ext.getFrame(details.tabId, frameId);
600 if (frame)
601 {
602 let results = ext.webRequest.onBeforeRequest._dispatch(
603 url, type, new Page({id: details.tabId}), frame
604 );
605
606 if (results.indexOf(false) != -1)
607 return {cancel: true};
608 }
609 }, {urls: ["<all_urls>"]}, ["blocking"]);
610
611
612 /* Message passing */
613
614 chrome.runtime.onMessage.addListener((message, rawSender, sendResponse) =>
615 {
616 let sender = {};
617
618 // Add "page" and "frame" if the message was sent by a content script.
619 // If sent by popup or the background page itself, there is no "tab".
620 if ("tab" in rawSender)
621 {
622 sender.page = new Page(rawSender.tab);
623 sender.frame = {
624 // In Edge requests from internal extension pages
625 // (protocol ms-browser-extension://) do no have a sender URL.
626 url: rawSender.url ? new URL(rawSender.url) : null,
627 get parent()
628 {
629 let frames = framesOfTabs.get(rawSender.tab.id);
630
631 if (!frames)
632 return null;
633
634 let frame = frames.get(rawSender.frameId);
635 if (frame)
636 return frame.parent || null;
637
638 return frames.get(0) || null;
639 }
640 };
641 }
642
643 return ext.onMessage._dispatch(
644 message, sender, sendResponse
645 ).indexOf(true) != -1;
646 });
647
648
649 /* Storage */
650
651 ext.storage = {
652 get(keys, callback)
653 {
654 chrome.storage.local.get(keys, callback);
655 },
656 set(key, value, callback)
657 {
658 let items = {};
659 items[key] = value;
660 chrome.storage.local.set(items, callback);
661 },
662 remove(key, callback)
663 {
664 chrome.storage.local.remove(key, callback);
665 },
666 onChanged: chrome.storage.onChanged
667 };
668
669 /* Options */
670
671 if ("openOptionsPage" in chrome.runtime)
672 {
673 ext.showOptions = callback =>
674 {
675 if (!callback)
676 {
677 chrome.runtime.openOptionsPage();
678 }
679 else
680 {
681 chrome.runtime.openOptionsPage(() =>
682 {
683 if (chrome.runtime.lastError)
684 return;
685
686 chrome.tabs.query({active: true, lastFocusedWindow: true}, tabs =>
687 {
688 if (tabs.length > 0)
689 {
690 if (tabs[0].status == "complete")
691 callback(new Page(tabs[0]));
692 else
693 afterTabLoaded(callback)(tabs[0]);
694 }
695 });
696 });
697 }
698 };
699 }
700 else
701 {
702 // Edge does not yet support runtime.openOptionsPage (tested version 38)
703 // and so this workaround needs to stay for now.
704 ext.showOptions = callback =>
705 {
706 chrome.windows.getLastFocused(win =>
707 {
708 let optionsUrl = chrome.extension.getURL("options.html");
709 let queryInfo = {url: optionsUrl};
710
711 // extension pages can't be accessed in incognito windows. In order to
712 // correctly mimic the way in which Chrome opens extension options,
713 // we have to focus the options page in any other window.
714 if (!win.incognito)
715 queryInfo.windowId = win.id;
716
717 chrome.tabs.query(queryInfo, tabs =>
718 {
719 if (tabs.length > 0)
720 {
721 let tab = tabs[0];
722
723 chrome.windows.update(tab.windowId, {focused: true});
724 chrome.tabs.update(tab.id, {active: true});
725
726 if (callback)
727 callback(new Page(tab));
728 }
729 else
730 {
731 ext.pages.open(optionsUrl, callback);
732 }
733 });
734 });
735 };
736 }
737
738 /* Windows */
739 ext.windows = {
740 create(createData, callback)
741 {
742 chrome.windows.create(createData, createdWindow =>
743 {
744 afterTabLoaded(callback)(createdWindow.tabs[0]);
745 });
746 }
747 };
748 }());
OLDNEW
« no previous file with comments | « devtools.js ('k') | ext/common.js » ('j') | no next file with comments »

Powered by Google App Engine
This is Rietveld