From 799141376af0b44252a53c6cff055d0e776602de Mon Sep 17 00:00:00 2001 From: Bob Silverberg Date: Tue, 25 Oct 2016 16:09:28 -0400 Subject: [PATCH] Bug 1308058 - Implement sessions.getRecentlyClosed WebExtensions API, r=aswan MozReview-Commit-ID: 7AKfMil3Dr4 --HG-- extra : rebase_source : a8a6c35e329c699eea523f1dad794e1106681719 --- browser/components/extensions/ext-sessions.js | 44 ++++++ browser/components/extensions/ext-utils.js | 64 ++++++-- .../extensions/extensions-browser.manifest | 2 + browser/components/extensions/jar.mn | 1 + browser/components/extensions/schemas/jar.mn | 1 + .../extensions/schemas/sessions.json | 147 ++++++++++++++++++ .../extensions/schemas/windows.json | 1 - .../extensions/test/browser/browser.ini | 3 + .../browser_ext_sessions_getRecentlyClosed.js | 97 ++++++++++++ ..._ext_sessions_getRecentlyClosed_private.js | 61 ++++++++ .../extensions/test/browser/head_sessions.js | 47 ++++++ 11 files changed, 456 insertions(+), 12 deletions(-) create mode 100644 browser/components/extensions/ext-sessions.js create mode 100644 browser/components/extensions/schemas/sessions.json create mode 100644 browser/components/extensions/test/browser/browser_ext_sessions_getRecentlyClosed.js create mode 100644 browser/components/extensions/test/browser/browser_ext_sessions_getRecentlyClosed_private.js create mode 100644 browser/components/extensions/test/browser/head_sessions.js diff --git a/browser/components/extensions/ext-sessions.js b/browser/components/extensions/ext-sessions.js new file mode 100644 index 000000000000..5745df17cbf8 --- /dev/null +++ b/browser/components/extensions/ext-sessions.js @@ -0,0 +1,44 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +XPCOMUtils.defineLazyModuleGetter(this, "SessionStore", + "resource:///modules/sessionstore/SessionStore.jsm"); + +function getRecentlyClosed(maxResults, extension) { + let recentlyClosed = []; + + // Get closed windows + let closedWindowData = SessionStore.getClosedWindowData(false); + for (let window of closedWindowData) { + recentlyClosed.push({ + lastModified: window.closedAt, + window: WindowManager.convertFromSessionStoreClosedData(window, extension)}); + } + + // Get closed tabs + for (let window of WindowListManager.browserWindows()) { + let closedTabData = SessionStore.getClosedTabData(window, false); + for (let tab of closedTabData) { + recentlyClosed.push({ + lastModified: tab.closedAt, + tab: TabManager.for(extension).convertFromSessionStoreClosedData(tab, window)}); + } + } + + // Sort windows and tabs + recentlyClosed.sort((a, b) => b.lastModified - a.lastModified); + return recentlyClosed.slice(0, maxResults); +} + +extensions.registerSchemaAPI("sessions", "addon_parent", context => { + let {extension} = context; + return { + sessions: { + getRecentlyClosed: function(filter) { + let maxResults = filter.maxResults == undefined ? this.MAX_SESSION_RESULTS : filter.maxResults; + return Promise.resolve(getRecentlyClosed(maxResults, extension)); + }, + }, + }; +}); diff --git a/browser/components/extensions/ext-utils.js b/browser/components/extensions/ext-utils.js index eacbc9b14acb..4fafdf2cf7a9 100644 --- a/browser/components/extensions/ext-utils.js +++ b/browser/components/extensions/ext-utils.js @@ -665,6 +665,23 @@ ExtensionTabManager.prototype = { return result; }, + // Converts tabs returned from SessionStore.getClosedTabData and + // SessionStore.getClosedWindowData into API tab objects + convertFromSessionStoreClosedData(tab, window) { + let result = { + sessionId: String(tab.closedId), + index: tab.pos ? tab.pos : 0, + windowId: WindowManager.getId(window), + selected: false, + highlighted: false, + active: false, + pinned: false, + incognito: Boolean(tab.state && tab.state.isPrivate), + }; + + return result; + }, + getTabs(window) { return Array.from(window.gBrowser.tabs) .filter(tab => !tab.closing) @@ -912,6 +929,19 @@ global.WindowManager = { return null; }, + getState(window) { + const STATES = { + [window.STATE_MAXIMIZED]: "maximized", + [window.STATE_MINIMIZED]: "minimized", + [window.STATE_NORMAL]: "normal", + }; + let state = STATES[window.windowState]; + if (window.fullScreen) { + state = "fullscreen"; + } + return state; + }, + setState(window, state) { if (state != "fullscreen" && window.fullScreen) { window.fullScreen = false; @@ -952,16 +982,6 @@ global.WindowManager = { }, convert(extension, window, getInfo) { - const STATES = { - [window.STATE_MAXIMIZED]: "maximized", - [window.STATE_MINIMIZED]: "minimized", - [window.STATE_NORMAL]: "normal", - }; - let state = STATES[window.windowState]; - if (window.fullScreen) { - state = "fullscreen"; - } - let xulWindow = window.QueryInterface(Ci.nsIInterfaceRequestor) .getInterface(Ci.nsIDocShell) .treeOwner.QueryInterface(Ci.nsIInterfaceRequestor) @@ -976,7 +996,7 @@ global.WindowManager = { height: window.outerHeight, incognito: PrivateBrowsingUtils.isWindowPrivate(window), type: this.windowType(window), - state, + state: this.getState(window), alwaysOnTop: xulWindow.zLevel >= Ci.nsIXULWindow.raisedZ, }; @@ -986,6 +1006,28 @@ global.WindowManager = { return result; }, + + // Converts windows returned from SessionStore.getClosedWindowData + // into API window objects + convertFromSessionStoreClosedData(window, extension) { + let result = { + sessionId: String(window.closedId), + focused: false, + incognito: false, + type: "normal", // this is always "normal" for a closed window + state: this.getState(window), + alwaysOnTop: false, + }; + + if (window.tabs.length) { + result.tabs = []; + window.tabs.forEach((tab, index) => { + result.tabs.push(TabManager.for(extension).convertFromSessionStoreClosedData(tab, window, index)); + }); + } + + return result; + }, }; // Manages listeners for window opening and closing. A window is diff --git a/browser/components/extensions/extensions-browser.manifest b/browser/components/extensions/extensions-browser.manifest index 358b6d2d2ebe..ef1f2cecfe2d 100644 --- a/browser/components/extensions/extensions-browser.manifest +++ b/browser/components/extensions/extensions-browser.manifest @@ -6,6 +6,7 @@ category webextension-scripts contextMenus chrome://browser/content/ext-contextM category webextension-scripts desktop-runtime chrome://browser/content/ext-desktop-runtime.js category webextension-scripts history chrome://browser/content/ext-history.js category webextension-scripts pageAction chrome://browser/content/ext-pageAction.js +category webextension-scripts sessions chrome://browser/content/ext-sessions.js category webextension-scripts tabs chrome://browser/content/ext-tabs.js category webextension-scripts utils chrome://browser/content/ext-utils.js category webextension-scripts windows chrome://browser/content/ext-windows.js @@ -24,5 +25,6 @@ category webextension-schemas context_menus chrome://browser/content/schemas/con category webextension-schemas context_menus_internal chrome://browser/content/schemas/context_menus_internal.json category webextension-schemas history chrome://browser/content/schemas/history.json category webextension-schemas page_action chrome://browser/content/schemas/page_action.json +category webextension-schemas sessions chrome://browser/content/schemas/sessions.json category webextension-schemas tabs chrome://browser/content/schemas/tabs.json category webextension-schemas windows chrome://browser/content/schemas/windows.json diff --git a/browser/components/extensions/jar.mn b/browser/components/extensions/jar.mn index a8451a9c4ade..ef8a2a967b80 100644 --- a/browser/components/extensions/jar.mn +++ b/browser/components/extensions/jar.mn @@ -19,6 +19,7 @@ browser.jar: content/browser/ext-desktop-runtime.js content/browser/ext-history.js content/browser/ext-pageAction.js + content/browser/ext-sessions.js content/browser/ext-tabs.js content/browser/ext-utils.js content/browser/ext-windows.js diff --git a/browser/components/extensions/schemas/jar.mn b/browser/components/extensions/schemas/jar.mn index 264ce37ea771..77b2102b1ec0 100644 --- a/browser/components/extensions/schemas/jar.mn +++ b/browser/components/extensions/schemas/jar.mn @@ -10,5 +10,6 @@ browser.jar: content/browser/schemas/context_menus_internal.json content/browser/schemas/history.json content/browser/schemas/page_action.json + content/browser/schemas/sessions.json content/browser/schemas/tabs.json content/browser/schemas/windows.json diff --git a/browser/components/extensions/schemas/sessions.json b/browser/components/extensions/schemas/sessions.json new file mode 100644 index 000000000000..ba81f46c645b --- /dev/null +++ b/browser/components/extensions/schemas/sessions.json @@ -0,0 +1,147 @@ +// Copyright 2013 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +[ + { + "namespace": "manifest", + "types": [ + { + "$extend": "Permission", + "choices": [{ + "type": "string", + "enum": [ + "sessions" + ] + }] + } + ] + }, + { + "namespace": "sessions", + "description": "Use the chrome.sessions API to query and restore tabs and windows from a browsing session.", + "permissions": ["sessions"], + "types": [ + { + "id": "Filter", + "type": "object", + "properties": { + "maxResults": { + "type": "integer", + "minimum": 0, + "maximum": 25, + "optional": true, + "description": "The maximum number of entries to be fetched in the requested list. Omit this parameter to fetch the maximum number of entries ($(ref:sessions.MAX_SESSION_RESULTS))." + } + } + }, + { + "id": "Session", + "type": "object", + "properties": { + "lastModified": {"type": "integer", "description": "The time when the window or tab was closed or modified, represented in milliseconds since the epoch."}, + "tab": {"$ref": "tabs.Tab", "optional": true, "description": "The $(ref:tabs.Tab), if this entry describes a tab. Either this or $(ref:sessions.Session.window) will be set."}, + "window": {"$ref": "windows.Window", "optional": true, "description": "The $(ref:windows.Window), if this entry describes a window. Either this or $(ref:sessions.Session.tab) will be set."} + } + }, + { + "id": "Device", + "type": "object", + "properties": { + "info": {"type": "string"}, + "deviceName": {"type": "string", "description": "The name of the foreign device."}, + "sessions": {"type": "array", "items": {"$ref": "Session"}, "description": "A list of open window sessions for the foreign device, sorted from most recently to least recently modified session."} + } + } + ], + "functions": [ + { + "name": "getRecentlyClosed", + "type": "function", + "description": "Gets the list of recently closed tabs and/or windows.", + "async": "callback", + "parameters": [ + { + "$ref": "Filter", + "name": "filter", + "optional": true, + "default": {} + }, + { + "type": "function", + "name": "callback", + "parameters": [ + { + "name": "sessions", "type": "array", "items": { "$ref": "Session" }, "description": "The list of closed entries in reverse order that they were closed (the most recently closed tab or window will be at index 0). The entries may contain either tabs or windows." + } + ] + } + ] + }, + { + "name": "getDevices", + "unsupported": true, + "type": "function", + "description": "Retrieves all devices with synced sessions.", + "async": "callback", + "parameters": [ + { + "$ref": "Filter", + "name": "filter", + "optional": true + }, + { + "type": "function", + "name": "callback", + "parameters": [ + { + "name": "devices", "type": "array", "items": { "$ref": "Device" }, "description": "The list of $(ref:sessions.Device) objects for each synced session, sorted in order from device with most recently modified session to device with least recently modified session. $(ref:tabs.Tab) objects are sorted by recency in the $(ref:windows.Window) of the $(ref:sessions.Session) objects." + } + ] + } + ] + }, + { + "name": "restore", + "unsupported": true, + "type": "function", + "description": "Reopens a $(ref:windows.Window) or $(ref:tabs.Tab), with an optional callback to run when the entry has been restored.", + "async": "callback", + "parameters": [ + { + "type": "string", + "name": "sessionId", + "optional": true, + "description": "The $(ref:windows.Window.sessionId), or $(ref:tabs.Tab.sessionId) to restore. If this parameter is not specified, the most recently closed session is restored." + }, + { + "type": "function", + "name": "callback", + "optional": true, + "parameters": [ + { + "$ref": "Session", + "name": "restoredSession", + "description": "A $(ref:sessions.Session) containing the restored $(ref:windows.Window) or $(ref:tabs.Tab) object." + } + ] + } + ] + } + ], + "events": [ + { + "name": "onChanged", + "unsupported": true, + "description": "Fired when recently closed tabs and/or windows are changed. This event does not monitor synced sessions changes.", + "type": "function" + } + ], + "properties": { + "MAX_SESSION_RESULTS": { + "value": 25, + "description": "The maximum number of $(ref:sessions.Session) that will be included in a requested list." + } + } + } +] diff --git a/browser/components/extensions/schemas/windows.json b/browser/components/extensions/schemas/windows.json index f74cded680bc..f4b2563abd0f 100644 --- a/browser/components/extensions/schemas/windows.json +++ b/browser/components/extensions/schemas/windows.json @@ -78,7 +78,6 @@ "description": "Whether the window is set to be always on top." }, "sessionId": { - "unsupported": true, "type": "string", "optional": true, "description": "The session ID used to uniquely identify a Window obtained from the $(ref:sessions) API." diff --git a/browser/components/extensions/test/browser/browser.ini b/browser/components/extensions/test/browser/browser.ini index 5c3dccd3a7c9..9b2032aedd0b 100644 --- a/browser/components/extensions/test/browser/browser.ini +++ b/browser/components/extensions/test/browser/browser.ini @@ -2,6 +2,7 @@ support-files = head.js head_pageAction.js + head_sessions.js context.html ctxmenu-image.png context_tabs_onUpdated_page.html @@ -58,6 +59,8 @@ tags = webextensions [browser_ext_runtime_openOptionsPage.js] [browser_ext_runtime_openOptionsPage_uninstall.js] [browser_ext_runtime_setUninstallURL.js] +[browser_ext_sessions_getRecentlyClosed.js] +[browser_ext_sessions_getRecentlyClosed_private.js] [browser_ext_simple.js] [browser_ext_tab_runtimeConnect.js] [browser_ext_tabs_audio.js] diff --git a/browser/components/extensions/test/browser/browser_ext_sessions_getRecentlyClosed.js b/browser/components/extensions/test/browser/browser_ext_sessions_getRecentlyClosed.js new file mode 100644 index 000000000000..230dcf450452 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_sessions_getRecentlyClosed.js @@ -0,0 +1,97 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +/* globals recordInitialTimestamps, onlyNewItemsFilter, checkRecentlyClosed */ + +SimpleTest.requestCompleteLog(); + +Services.scriptloader.loadSubScript(new URL("head_sessions.js", gTestPath).href, + this); + +add_task(function* test_sessions_get_recently_closed() { + function* openAndCloseWindow(url = "http://example.com", tabUrls) { + let win = yield BrowserTestUtils.openNewBrowserWindow(); + yield BrowserTestUtils.loadURI(win.gBrowser.selectedBrowser, url); + yield BrowserTestUtils.browserLoaded(win.gBrowser.selectedBrowser); + if (tabUrls) { + for (let url of tabUrls) { + yield BrowserTestUtils.openNewForegroundTab(win.gBrowser, url); + } + } + yield BrowserTestUtils.closeWindow(win); + } + + function background() { + Promise.all([ + browser.sessions.getRecentlyClosed(), + browser.tabs.query({active: true, currentWindow: true}), + ]).then(([recentlyClosed, tabs]) => { + browser.test.sendMessage("initialData", {recentlyClosed, currentWindowId: tabs[0].windowId}); + }); + + browser.test.onMessage.addListener((msg, filter) => { + if (msg == "check-sessions") { + browser.sessions.getRecentlyClosed(filter).then(recentlyClosed => { + browser.test.sendMessage("recentlyClosed", recentlyClosed); + }); + } + }); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["sessions", "tabs"], + }, + background, + }); + + // Open and close a window that will be ignored, to prove that we are removing previous entries + yield openAndCloseWindow(); + + yield extension.startup(); + + let {recentlyClosed, currentWindowId} = yield extension.awaitMessage("initialData"); + recordInitialTimestamps(recentlyClosed.map(item => item.lastModified)); + + yield openAndCloseWindow(); + extension.sendMessage("check-sessions"); + recentlyClosed = yield extension.awaitMessage("recentlyClosed"); + checkRecentlyClosed(recentlyClosed.filter(onlyNewItemsFilter), 1, currentWindowId); + + yield openAndCloseWindow("about:config", ["about:robots", "about:mozilla"]); + extension.sendMessage("check-sessions"); + recentlyClosed = yield extension.awaitMessage("recentlyClosed"); + // Check for multiple tabs in most recently closed window + is(recentlyClosed[0].window.tabs.length, 3, "most recently closed window has the expected number of tabs"); + + let tab = yield BrowserTestUtils.openNewForegroundTab(gBrowser, "http://example.com"); + yield BrowserTestUtils.removeTab(tab); + + tab = yield BrowserTestUtils.openNewForegroundTab(gBrowser, "http://example.com"); + yield BrowserTestUtils.removeTab(tab); + + yield openAndCloseWindow(); + extension.sendMessage("check-sessions"); + recentlyClosed = yield extension.awaitMessage("recentlyClosed"); + let finalResult = recentlyClosed.filter(onlyNewItemsFilter); + checkRecentlyClosed(finalResult, 5, currentWindowId); + + isnot(finalResult[0].window, undefined, "first item is a window"); + is(finalResult[0].tab, undefined, "first item is not a tab"); + isnot(finalResult[1].tab, undefined, "second item is a tab"); + is(finalResult[1].window, undefined, "second item is not a window"); + isnot(finalResult[2].tab, undefined, "third item is a tab"); + is(finalResult[2].window, undefined, "third item is not a window"); + isnot(finalResult[3].window, undefined, "fourth item is a window"); + is(finalResult[3].tab, undefined, "fourth item is not a tab"); + isnot(finalResult[4].window, undefined, "fifth item is a window"); + is(finalResult[4].tab, undefined, "fifth item is not a tab"); + + // test with filter + extension.sendMessage("check-sessions", {maxResults: 2}); + recentlyClosed = yield extension.awaitMessage("recentlyClosed"); + checkRecentlyClosed(recentlyClosed.filter(onlyNewItemsFilter), 2, currentWindowId); + + yield extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_sessions_getRecentlyClosed_private.js b/browser/components/extensions/test/browser/browser_ext_sessions_getRecentlyClosed_private.js new file mode 100644 index 000000000000..217c8e130bc4 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_sessions_getRecentlyClosed_private.js @@ -0,0 +1,61 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +/* globals recordInitialTimestamps, onlyNewItemsFilter, checkRecentlyClosed */ + +SimpleTest.requestCompleteLog(); + +Services.scriptloader.loadSubScript(new URL("head_sessions.js", gTestPath).href, + this); + +add_task(function* test_sessions_get_recently_closed_private() { + function background() { + browser.test.onMessage.addListener((msg, filter) => { + if (msg == "check-sessions") { + browser.sessions.getRecentlyClosed(filter).then(recentlyClosed => { + browser.test.sendMessage("recentlyClosed", recentlyClosed); + }); + } + }); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["sessions", "tabs"], + }, + background, + }); + + // Open a private browsing window. + let privateWin = yield BrowserTestUtils.openNewBrowserWindow({private: true}); + + yield extension.startup(); + + let {Management: {global: {WindowManager}}} = Cu.import("resource://gre/modules/Extension.jsm", {}); + let privateWinId = WindowManager.getId(privateWin); + + extension.sendMessage("check-sessions"); + let recentlyClosed = yield extension.awaitMessage("recentlyClosed"); + recordInitialTimestamps(recentlyClosed.map(item => item.lastModified)); + + // Open and close two tabs in the private window + let tab = yield BrowserTestUtils.openNewForegroundTab(privateWin.gBrowser, "http://example.com"); + yield BrowserTestUtils.removeTab(tab); + + tab = yield BrowserTestUtils.openNewForegroundTab(privateWin.gBrowser, "http://example.com"); + yield BrowserTestUtils.removeTab(tab); + + extension.sendMessage("check-sessions"); + recentlyClosed = yield extension.awaitMessage("recentlyClosed"); + checkRecentlyClosed(recentlyClosed.filter(onlyNewItemsFilter), 2, privateWinId, true); + + // Close the private window. + yield BrowserTestUtils.closeWindow(privateWin); + + extension.sendMessage("check-sessions"); + recentlyClosed = yield extension.awaitMessage("recentlyClosed"); + is(recentlyClosed.filter(onlyNewItemsFilter).length, 0, "the closed private window info was not found in recently closed data"); + + yield extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/head_sessions.js b/browser/components/extensions/test/browser/head_sessions.js new file mode 100644 index 000000000000..ca3a86c24d55 --- /dev/null +++ b/browser/components/extensions/test/browser/head_sessions.js @@ -0,0 +1,47 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +/* exported recordInitialTimestamps onlyNewItemsFilter checkRecentlyClosed */ + +let initialTimestamps = []; + +function recordInitialTimestamps(timestamps) { + initialTimestamps = timestamps; +} + +function onlyNewItemsFilter(item) { + return !initialTimestamps.includes(item.lastModified); +} + +function checkWindow(window) { + for (let prop of ["focused", "incognito", "alwaysOnTop"]) { + is(window[prop], false, `closed window has the expected value for ${prop}`); + } + for (let prop of ["state", "type"]) { + is(window[prop], "normal", `closed window has the expected value for ${prop}`); + } +} + +function checkTab(tab, windowId, incognito) { + for (let prop of ["selected", "highlighted", "active", "pinned"]) { + is(tab[prop], false, `closed tab has the expected value for ${prop}`); + } + is(tab.windowId, windowId, "closed tab has the expected value for windowId"); + is(tab.incognito, incognito, "closed tab has the expected value for incognito"); +} + +function checkRecentlyClosed(recentlyClosed, expectedCount, windowId, incognito = false) { + let sessionIds = new Set(); + is(recentlyClosed.length, expectedCount, "the expected number of closed tabs/windows was found"); + for (let item of recentlyClosed) { + if (item.window) { + sessionIds.add(item.window.sessionId); + checkWindow(item.window); + } else if (item.tab) { + sessionIds.add(item.tab.sessionId); + checkTab(item.tab, windowId, incognito); + } + } + is(sessionIds.size, expectedCount, "each item has a unique sessionId"); +}