diff options
Diffstat (limited to 'services/sync/modules/engines/tabs.js')
-rw-r--r-- | services/sync/modules/engines/tabs.js | 395 |
1 files changed, 395 insertions, 0 deletions
diff --git a/services/sync/modules/engines/tabs.js b/services/sync/modules/engines/tabs.js new file mode 100644 index 0000000000..99ff59ca8b --- /dev/null +++ b/services/sync/modules/engines/tabs.js @@ -0,0 +1,395 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +var EXPORTED_SYMBOLS = ["TabEngine", "TabSetRecord"]; + +const TABS_TTL = 31622400; // 366 days (1 leap year). +const TAB_ENTRIES_LIMIT = 5; // How many URLs to include in tab history. + +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); +const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); +const { Log } = ChromeUtils.import("resource://gre/modules/Log.jsm"); +const { Store, SyncEngine, Tracker } = ChromeUtils.import( + "resource://services-sync/engines.js" +); +const { CryptoWrapper } = ChromeUtils.import( + "resource://services-sync/record.js" +); +const { Svc, Utils } = ChromeUtils.import("resource://services-sync/util.js"); +const { SCORE_INCREMENT_SMALL, URI_LENGTH_MAX } = ChromeUtils.import( + "resource://services-sync/constants.js" +); + +ChromeUtils.defineModuleGetter( + this, + "PrivateBrowsingUtils", + "resource://gre/modules/PrivateBrowsingUtils.jsm" +); +ChromeUtils.defineModuleGetter( + this, + "SessionStore", + "resource:///modules/sessionstore/SessionStore.jsm" +); + +XPCOMUtils.defineLazyModuleGetters(this, { + PlacesUtils: "resource://gre/modules/PlacesUtils.jsm", +}); + +function TabSetRecord(collection, id) { + CryptoWrapper.call(this, collection, id); +} +TabSetRecord.prototype = { + __proto__: CryptoWrapper.prototype, + _logName: "Sync.Record.Tabs", + ttl: TABS_TTL, +}; + +Utils.deferGetSet(TabSetRecord, "cleartext", ["clientName", "tabs"]); + +function TabEngine(service) { + SyncEngine.call(this, "Tabs", service); +} +TabEngine.prototype = { + __proto__: SyncEngine.prototype, + _storeObj: TabStore, + _trackerObj: TabTracker, + _recordObj: TabSetRecord, + + syncPriority: 3, + + async initialize() { + await SyncEngine.prototype.initialize.call(this); + + // Reset the client on every startup so that we fetch recent tabs. + await this._resetClient(); + }, + + async getChangedIDs() { + // No need for a proper timestamp (no conflict resolution needed). + let changedIDs = {}; + if (this._tracker.modified) { + changedIDs[this.service.clientsEngine.localID] = 0; + } + return changedIDs; + }, + + // API for use by Sync UI code to give user choices of tabs to open. + getAllClients() { + return this._store._remoteClients; + }, + + getClientById(id) { + return this._store._remoteClients[id]; + }, + + async _resetClient() { + await SyncEngine.prototype._resetClient.call(this); + await this._store.wipe(); + this._tracker.modified = true; + }, + + async removeClientData() { + let url = this.engineURL + "/" + this.service.clientsEngine.localID; + await this.service.resource(url).delete(); + }, + + async _reconcile(item) { + // Skip our own record. + // TabStore.itemExists tests only against our local client ID. + if (await this._store.itemExists(item.id)) { + this._log.trace( + "Ignoring incoming tab item because of its id: " + item.id + ); + return false; + } + + return SyncEngine.prototype._reconcile.call(this, item); + }, + + async trackRemainingChanges() { + if (this._modified.count() > 0) { + this._tracker.modified = true; + } + }, +}; + +function TabStore(name, engine) { + Store.call(this, name, engine); +} +TabStore.prototype = { + __proto__: Store.prototype, + + async itemExists(id) { + return id == this.engine.service.clientsEngine.localID; + }, + + getWindowEnumerator() { + return Services.wm.getEnumerator("navigator:browser"); + }, + + shouldSkipWindow(win) { + return win.closed || PrivateBrowsingUtils.isWindowPrivate(win); + }, + + getTabState(tab) { + return JSON.parse(SessionStore.getTabState(tab)); + }, + + async getAllTabs(filter) { + let filteredUrls = new RegExp( + Svc.Prefs.get("engine.tabs.filteredUrls"), + "i" + ); + + let allTabs = []; + + for (let win of this.getWindowEnumerator()) { + if (this.shouldSkipWindow(win)) { + continue; + } + + for (let tab of win.gBrowser.tabs) { + let tabState = this.getTabState(tab); + + // Make sure there are history entries to look at. + if (!tabState || !tabState.entries.length) { + continue; + } + + let acceptable = !filter + ? url => url + : url => url && !filteredUrls.test(url); + + let entries = tabState.entries; + let index = tabState.index; + let current = entries[index - 1]; + + // We ignore the tab completely if the current entry url is + // not acceptable (we need something accurate to open). + if (!acceptable(current.url)) { + continue; + } + + if (current.url.length > URI_LENGTH_MAX) { + this._log.trace("Skipping over-long URL."); + continue; + } + + // The element at `index` is the current page. Previous URLs were + // previously visited URLs; subsequent URLs are in the 'forward' stack, + // which we can't represent in Sync, so we truncate here. + let candidates = + entries.length == index ? entries : entries.slice(0, index); + + let urls = candidates + .map(entry => entry.url) + .filter(acceptable) + .reverse(); // Because Sync puts current at index 0, and history after. + + // Truncate if necessary. + if (urls.length > TAB_ENTRIES_LIMIT) { + urls.length = TAB_ENTRIES_LIMIT; + } + + // tabState has .image, but it's a large data: url. So we ask the favicon service for the url. + let icon = ""; + try { + let iconData = await PlacesUtils.promiseFaviconData(urls[0]); + icon = iconData.uri.spec; + } catch (ex) { + this._log.trace(`Failed to fetch favicon for ${urls[0]}`, ex); + } + allTabs.push({ + title: current.title || "", + urlHistory: urls, + icon, + lastUsed: Math.floor((tabState.lastAccessed || 0) / 1000), + }); + } + } + + return allTabs; + }, + + async createRecord(id, collection) { + let record = new TabSetRecord(collection, id); + record.clientName = this.engine.service.clientsEngine.localName; + + // Sort tabs in descending-used order to grab the most recently used + let tabs = (await this.getAllTabs(true)).sort(function(a, b) { + return b.lastUsed - a.lastUsed; + }); + const maxPayloadSize = this.engine.service.getMemcacheMaxRecordPayloadSize(); + let records = Utils.tryFitItems(tabs, maxPayloadSize); + + if (records.length != tabs.length) { + this._log.warn( + `Can't fit all tabs in sync payload: have ${tabs.length}, but can only fit ${records.length}.` + ); + } + + if (this._log.level <= Log.Level.Trace) { + records.forEach(tab => { + this._log.trace("Wrapping tab: ", tab); + }); + } + + record.tabs = records; + return record; + }, + + async getAllIDs() { + // Don't report any tabs if all windows are in private browsing for + // first syncs. + let ids = {}; + let allWindowsArePrivate = false; + for (let win of Services.wm.getEnumerator("navigator:browser")) { + if (PrivateBrowsingUtils.isWindowPrivate(win)) { + // Ensure that at least there is a private window. + allWindowsArePrivate = true; + } else { + // If there is a not private windown then finish and continue. + allWindowsArePrivate = false; + break; + } + } + + if ( + allWindowsArePrivate && + !PrivateBrowsingUtils.permanentPrivateBrowsing + ) { + return ids; + } + + ids[this.engine.service.clientsEngine.localID] = true; + return ids; + }, + + async wipe() { + this._remoteClients = {}; + }, + + async create(record) { + this._log.debug("Adding remote tabs from " + record.id); + this._remoteClients[record.id] = Object.assign({}, record.cleartext, { + lastModified: record.modified, + }); + }, + + async update(record) { + this._log.trace("Ignoring tab updates as local ones win"); + }, +}; + +function TabTracker(name, engine) { + Tracker.call(this, name, engine); + + // Make sure "this" pointer is always set correctly for event listeners. + this.onTab = Utils.bind2(this, this.onTab); + this._unregisterListeners = Utils.bind2(this, this._unregisterListeners); +} +TabTracker.prototype = { + __proto__: Tracker.prototype, + + QueryInterface: ChromeUtils.generateQI(["nsIObserver"]), + + clearChangedIDs() { + this.modified = false; + }, + + _topics: ["pageshow", "TabOpen", "TabClose", "TabSelect"], + + _registerListenersForWindow(window) { + this._log.trace("Registering tab listeners in window"); + for (let topic of this._topics) { + window.addEventListener(topic, this.onTab); + } + window.addEventListener("unload", this._unregisterListeners); + // If it's got a tab browser we can listen for things like navigation. + if (window.gBrowser) { + window.gBrowser.addProgressListener(this); + } + }, + + _unregisterListeners(event) { + this._unregisterListenersForWindow(event.target); + }, + + _unregisterListenersForWindow(window) { + this._log.trace("Removing tab listeners in window"); + window.removeEventListener("unload", this._unregisterListeners); + for (let topic of this._topics) { + window.removeEventListener(topic, this.onTab); + } + if (window.gBrowser) { + window.gBrowser.removeProgressListener(this); + } + }, + + onStart() { + Svc.Obs.add("domwindowopened", this.asyncObserver); + for (let win of Services.wm.getEnumerator("navigator:browser")) { + this._registerListenersForWindow(win); + } + }, + + onStop() { + Svc.Obs.remove("domwindowopened", this.asyncObserver); + for (let win of Services.wm.getEnumerator("navigator:browser")) { + this._unregisterListenersForWindow(win); + } + }, + + async observe(subject, topic, data) { + switch (topic) { + case "domwindowopened": + let onLoad = () => { + subject.removeEventListener("load", onLoad); + // Only register after the window is done loading to avoid unloads. + this._registerListenersForWindow(subject); + }; + + // Add tab listeners now that a window has opened. + subject.addEventListener("load", onLoad); + break; + } + }, + + onTab(event) { + if (event.originalTarget.linkedBrowser) { + let browser = event.originalTarget.linkedBrowser; + if ( + PrivateBrowsingUtils.isBrowserPrivate(browser) && + !PrivateBrowsingUtils.permanentPrivateBrowsing + ) { + this._log.trace("Ignoring tab event from private browsing."); + return; + } + } + + this._log.trace("onTab event: " + event.type); + this.modified = true; + + // For page shows, bump the score 10% of the time, emulating a partial + // score. We don't want to sync too frequently. For all other page + // events, always bump the score. + if (event.type != "pageshow" || Math.random() < 0.1) { + this.score += SCORE_INCREMENT_SMALL; + } + }, + + // web progress listeners. + onLocationChange(webProgress, request, location, flags) { + // We only care about top-level location changes which are not in the same + // document. + if ( + webProgress.isTopLevel && + (flags & Ci.nsIWebProgressListener.LOCATION_CHANGE_SAME_DOCUMENT) == 0 + ) { + this.modified = true; + } + }, +}; |