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