Bug 1153504 - Add per-origin push quotas. r=nsm,mt,markh

--HG--
extra : commitid : 8CPpVl9sAYo
extra : rebase_source : d4e6330e3717c89f39686c620ae0aeb90e2156c8
This commit is contained in:
Kit Cambridge 2015-06-25 14:52:57 -07:00
Родитель 74e29ff0f6
Коммит 97018538cd
47 изменённых файлов: 1003 добавлений и 348 удалений

Просмотреть файл

@ -23,10 +23,11 @@ const prefs = new Preferences("dom.push.");
this.EXPORTED_SYMBOLS = ["PushDB"];
this.PushDB = function PushDB(dbName, dbVersion, dbStoreName, schemaFunction) {
this.PushDB = function PushDB(dbName, dbVersion, dbStoreName, keyPath, model) {
debug("PushDB()");
this._dbStoreName = dbStoreName;
this._schemaFunction = schemaFunction;
this._keyPath = keyPath;
this._model = model;
// set the indexeddb database
this.initDBHelper(dbName, dbVersion,
@ -38,9 +39,48 @@ this.PushDB = function PushDB(dbName, dbVersion, dbStoreName, schemaFunction) {
this.PushDB.prototype = {
__proto__: IndexedDBHelper.prototype,
toPushRecord: function(record) {
if (!record) {
return;
}
return new this._model(record);
},
isValidRecord: function(record) {
return record && typeof record.scope == "string" &&
typeof record.originAttributes == "string" &&
record.quota >= 0 &&
typeof record[this._keyPath] == "string";
},
upgradeSchema: function(aTransaction, aDb, aOldVersion, aNewVersion) {
if (this._schemaFunction) {
this._schemaFunction(aTransaction, aDb, aOldVersion, aNewVersion, this);
if (aOldVersion <= 3) {
//XXXnsm We haven't shipped Push during this upgrade, so I'm just going to throw old
//registrations away without even informing the app.
if (aDb.objectStoreNames.contains(this._dbStoreName)) {
aDb.deleteObjectStore(this._dbStoreName);
}
let objectStore = aDb.createObjectStore(this._dbStoreName,
{ keyPath: this._keyPath });
// index to fetch records based on endpoints. used by unregister
objectStore.createIndex("pushEndpoint", "pushEndpoint", { unique: true });
// index to fetch records by identifiers.
// In the current security model, the originAttributes distinguish between
// different 'apps' on the same origin. Since ServiceWorkers are
// same-origin to the scope they are registered for, the attributes and
// scope are enough to reconstruct a valid principal.
objectStore.createIndex("identifiers", ["scope", "originAttributes"], { unique: true });
objectStore.createIndex("originAttributes", "originAttributes", { unique: false });
}
if (aOldVersion < 4) {
let objectStore = aTransaction.objectStore(this._dbStoreName);
// index to fetch active and expired registrations.
objectStore.createIndex("quota", "quota", { unique: false });
}
},
@ -51,15 +91,25 @@ this.PushDB.prototype = {
put: function(aRecord) {
debug("put()" + JSON.stringify(aRecord));
if (!this.isValidRecord(aRecord)) {
return Promise.reject(new TypeError(
"Scope, originAttributes, and quota are required! " +
JSON.stringify(aRecord)
)
);
}
return new Promise((resolve, reject) =>
this.newTxn(
"readwrite",
this._dbStoreName,
function txnCb(aTxn, aStore) {
aStore.put(aRecord).onsuccess = function setTxnResult(aEvent) {
(aTxn, aStore) => {
aTxn.result = undefined;
aStore.put(aRecord).onsuccess = aEvent => {
debug("Request successful. Updated record ID: " +
aEvent.target.result);
aTxn.result = this.toPushRecord(aRecord);
};
},
resolve,
@ -111,12 +161,12 @@ this.PushDB.prototype = {
this.newTxn(
"readonly",
this._dbStoreName,
function txnCb(aTxn, aStore) {
(aTxn, aStore) => {
aTxn.result = undefined;
let index = aStore.index("pushEndpoint");
index.get(aPushEndpoint).onsuccess = function setTxnResult(aEvent) {
aTxn.result = aEvent.target.result;
index.get(aPushEndpoint).onsuccess = aEvent => {
aTxn.result = this.toPushRecord(aEvent.target.result);
debug("Fetch successful " + aEvent.target.result);
};
},
@ -133,11 +183,11 @@ this.PushDB.prototype = {
this.newTxn(
"readonly",
this._dbStoreName,
function txnCb(aTxn, aStore) {
(aTxn, aStore) => {
aTxn.result = undefined;
aStore.get(aKeyID).onsuccess = function setTxnResult(aEvent) {
aTxn.result = aEvent.target.result;
aStore.get(aKeyID).onsuccess = aEvent => {
aTxn.result = this.toPushRecord(aEvent.target.result);
debug("Fetch successful " + aEvent.target.result);
};
},
@ -161,14 +211,14 @@ this.PushDB.prototype = {
this.newTxn(
"readonly",
this._dbStoreName,
function txnCb(aTxn, aStore) {
(aTxn, aStore) => {
aTxn.result = undefined;
let index = aStore.index("identifiers");
let request = index.get(IDBKeyRange.only([aPageRecord.scope, aPageRecord.originAttributes]));
request.onsuccess = function setTxnResult(aEvent) {
aTxn.result = aEvent.target.result;
}
request.onsuccess = aEvent => {
aTxn.result = this.toPushRecord(aEvent.target.result);
};
},
resolve,
reject
@ -181,7 +231,7 @@ this.PushDB.prototype = {
this.newTxn(
"readonly",
this._dbStoreName,
function txnCb(aTxn, aStore) {
(aTxn, aStore) => {
aTxn.result = undefined;
let index = aStore.index(aKeyName);
@ -189,9 +239,10 @@ this.PushDB.prototype = {
// high storage APIs, we don't expect more than a handful of
// registrations per domain, and usually only one.
let getAllReq = index.mozGetAll(aKeyValue);
getAllReq.onsuccess = function setTxnResult(aEvent) {
aTxn.result = aEvent.target.result;
}
getAllReq.onsuccess = aEvent => {
aTxn.result = aEvent.target.result.map(
record => this.toPushRecord(record));
};
},
resolve,
reject
@ -214,10 +265,88 @@ this.PushDB.prototype = {
this.newTxn(
"readonly",
this._dbStoreName,
function txnCb(aTxn, aStore) {
(aTxn, aStore) => {
aTxn.result = undefined;
aStore.mozGetAll().onsuccess = function(event) {
aTxn.result = event.target.result;
aStore.mozGetAll().onsuccess = event => {
aTxn.result = event.target.result.map(
record => this.toPushRecord(record));
};
},
resolve,
reject
)
);
},
_getAllByPushQuota: function(range) {
debug("getAllByPushQuota()");
return new Promise((resolve, reject) =>
this.newTxn(
"readonly",
this._dbStoreName,
(aTxn, aStore) => {
aTxn.result = [];
let index = aStore.index("quota");
index.openCursor(range).onsuccess = event => {
let cursor = event.target.result;
if (cursor) {
aTxn.result.push(this.toPushRecord(cursor.value));
cursor.continue();
}
};
},
resolve,
reject
)
);
},
getAllUnexpired: function() {
debug("getAllUnexpired()");
return this._getAllByPushQuota(IDBKeyRange.lowerBound(1));
},
getAllExpired: function() {
debug("getAllExpired()");
return this._getAllByPushQuota(IDBKeyRange.only(0));
},
/**
* Updates an existing push registration.
*
* @param {String} aKeyID The registration ID.
* @param {Function} aUpdateFunc A function that receives the existing
* registration record as its argument, and returns a new record. If the
* function returns `null` or `undefined`, the record will not be updated.
* If the record does not exist, the function will not be called.
* @returns {Promise} A promise resolved with either the updated record, or
* `undefined` if the record was not updated.
*/
update: function(aKeyID, aUpdateFunc) {
return new Promise((resolve, reject) =>
this.newTxn(
"readwrite",
this._dbStoreName,
(aTxn, aStore) => {
aStore.get(aKeyID).onsuccess = aEvent => {
aTxn.result = undefined;
let record = aEvent.target.result;
if (!record) {
debug("update: Key ID " + aKeyID + " does not exist");
return;
}
let newRecord = aUpdateFunc(this.toPushRecord(record));
if (!this.isValidRecord(newRecord)) {
debug("update: Ignoring invalid update for key ID " + aKeyID);
return;
}
aStore.put(newRecord).onsuccess = aEvent => {
debug("update: Update successful for key ID " + aKeyID);
aTxn.result = newRecord;
};
};
},
resolve,

Просмотреть файл

@ -39,7 +39,11 @@ PushNotificationService.prototype = {
Ci.nsIPushNotificationService]),
register: function register(scope, originAttributes) {
return PushService._register({scope, originAttributes});
return PushService._register({
scope: scope,
originAttributes: originAttributes,
maxQuota: Infinity,
});
},
unregister: function unregister(scope, originAttributes) {

183
dom/push/PushRecord.jsm Normal file
Просмотреть файл

@ -0,0 +1,183 @@
/* 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/. */
"use strict";
const Cc = Components.classes;
const Ci = Components.interfaces;
const Cu = Components.utils;
const Cr = Components.results;
Cu.import("resource://gre/modules/Preferences.jsm");
Cu.import("resource://gre/modules/Services.jsm");
Cu.import("resource://gre/modules/XPCOMUtils.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "PlacesUtils",
"resource://gre/modules/PlacesUtils.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "PrivateBrowsingUtils",
"resource://gre/modules/PrivateBrowsingUtils.jsm");
this.EXPORTED_SYMBOLS = ["PushRecord"];
const prefs = new Preferences("dom.push.");
// History transition types that can fire an `pushsubscriptionchange` event
// when the user visits a site with expired push registrations. Visits only
// count if the user sees the origin in the address bar. This excludes embedded
// resources, downloads, and framed links.
const QUOTA_REFRESH_TRANSITIONS_SQL = [
Ci.nsINavHistoryService.TRANSITION_LINK,
Ci.nsINavHistoryService.TRANSITION_TYPED,
Ci.nsINavHistoryService.TRANSITION_BOOKMARK,
Ci.nsINavHistoryService.TRANSITION_REDIRECT_PERMANENT,
Ci.nsINavHistoryService.TRANSITION_REDIRECT_TEMPORARY
].join(",");
function PushRecord(props) {
this.pushEndpoint = props.pushEndpoint;
this.scope = props.scope;
this.origin = Services.io.newURI(this.scope, null, null).prePath;
this.originAttributes = props.originAttributes;
this.pushCount = props.pushCount || 0;
this.lastPush = props.lastPush || 0;
this.setQuota(props.quota);
}
PushRecord.prototype = {
setQuota(suggestedQuota) {
this.quota = (!isNaN(suggestedQuota) && suggestedQuota >= 0) ?
suggestedQuota : prefs.get("maxQuotaPerSubscription");
},
updateQuota(lastVisit) {
if (this.isExpired() || !this.quotaApplies()) {
// Ignore updates if the registration is already expired, or isn't
// subject to quota.
return;
}
if (lastVisit < 0) {
// If the user cleared their history, but retained the push permission,
// mark the registration as expired.
this.quota = 0;
return;
}
let currentQuota;
if (lastVisit > this.lastPush) {
// If the user visited the site since the last time we received a
// notification, reset the quota.
let daysElapsed = (Date.now() - lastVisit) / 24 / 60 / 60 / 1000;
currentQuota = Math.min(
Math.round(8 * Math.pow(daysElapsed, -0.8)),
prefs.get("maxQuotaPerSubscription")
);
} else {
// The user hasn't visited the site since the last notification.
currentQuota = this.quota;
}
this.quota = Math.max(currentQuota - 1, 0);
},
receivedPush(lastVisit) {
this.updateQuota(lastVisit);
this.pushCount++;
this.lastPush = Date.now();
},
/**
* Queries the Places database for the last time a user visited the site
* associated with a push registration.
*
* @returns {Promise} A promise resolved with either the last time the user
* visited the site, or `-Infinity` if the site is not in the user's history.
* The time is expressed in milliseconds since Epoch.
*/
getLastVisit() {
if (!this.quotaApplies() || this.isTabOpen()) {
// If the registration isn't subject to quota, or the user already
// has the site open, skip the Places query.
return Promise.resolve(Date.now());
}
return PlacesUtils.withConnectionWrapper("PushRecord.getLastVisit", db => {
// We're using a custom query instead of `nsINavHistoryQueryOptions`
// because the latter doesn't expose a way to filter by transition type:
// `setTransitions` performs a logical "and," but we want an "or." We
// also avoid an unneeded left join on `moz_favicons`, and an `ORDER BY`
// clause that emits a suboptimal index warning.
return db.executeCached(
`SELECT MAX(p.last_visit_date)
FROM moz_places p
INNER JOIN moz_historyvisits h ON p.id = h.place_id
WHERE (
p.url >= :urlLowerBound AND p.url <= :urlUpperBound AND
h.visit_type IN (${QUOTA_REFRESH_TRANSITIONS_SQL})
)
`,
{
// Restrict the query to all pages for this origin.
urlLowerBound: this.origin,
urlUpperBound: this.origin + "\x7f"
}
);
}).then(rows => {
if (!rows.length) {
return -Infinity;
}
// Places records times in microseconds.
let lastVisit = rows[0].getResultByIndex(0);
return lastVisit / 1000;
});
},
isTabOpen() {
let windows = Services.wm.getEnumerator("navigator:browser");
while (windows.hasMoreElements()) {
let window = windows.getNext();
if (window.closed || PrivateBrowsingUtils.isWindowPrivate(window)) {
continue;
}
// `gBrowser` on Desktop; `BrowserApp` on Fennec.
let tabs = window.gBrowser ? window.gBrowser.tabContainer.children :
window.BrowserApp.tabs;
for (let tab of tabs) {
// `linkedBrowser` on Desktop; `browser` on Fennec.
let tabURI = (tab.linkedBrowser || tab.browser).currentURI;
if (tabURI.prePath == this.origin) {
return true;
}
}
}
return false;
},
quotaApplies() {
return Number.isFinite(this.quota);
},
isExpired() {
return this.quota === 0;
},
toRegistration() {
return {
pushEndpoint: this.pushEndpoint,
lastPush: this.lastPush,
pushCount: this.pushCount,
};
},
toRegister() {
return {
pushEndpoint: this.pushEndpoint,
};
},
};
// Mark the `origin` property as non-enumerable to avoid storing the
// registration origin in IndexedDB.
Object.defineProperty(PushRecord.prototype, "origin", {
configurable: true,
enumerable: false,
writable: true,
});

Просмотреть файл

@ -83,6 +83,7 @@ this.PushService = {
_state: PUSH_SERVICE_UNINIT,
_db: null,
_options: null,
_alarmID: null,
// When serverURI changes (this is used for testing), db is cleaned up and a
// a new db is started. This events must be sequential.
@ -181,11 +182,11 @@ this.PushService = {
// Disconnect first.
this._service.disconnect();
}
this._db.getAllKeyIDs()
.then(keyIDs => {
if (keyIDs.length > 0) {
this._db.getAllUnexpired()
.then(records => {
if (records.length > 0) {
// if there are request waiting
this._service.connect(keyIDs);
this._service.connect(records);
}
});
this._setState(PUSH_SERVICE_RUNNING);
@ -242,6 +243,10 @@ this.PushService = {
}
break;
case "idle-daily":
this._dropExpiredRegistrations();
break;
case "webapps-clear-data":
debug("webapps-clear-data");
@ -259,26 +264,26 @@ this.PushService = {
this._db.getAllByOriginAttributes(originAttributes)
.then(records => {
records.forEach(record => {
this._db.delete(this._service.getKeyFromRecord(record))
this._db.delete(record.keyID)
.then(_ => {
// courtesy, but don't establish a connection
// just for it
if (this._ws) {
debug("Had a connection, so telling the server");
this._sendRequest("unregister", {channelID: records.channelID})
this._sendRequest("unregister", {channelID: record.channelID})
.catch(function(e) {
debug("Unregister errored " + e);
});
}
}, err => {
debug("webapps-clear-data: " + scope +
" Could not delete entry " + records.channelID);
debug("webapps-clear-data: " + record.scope +
" Could not delete entry " + record.channelID);
// courtesy, but don't establish a connection
// just for it
if (this._ws) {
debug("Had a connection, so telling the server");
this._sendRequest("unregister", {channelID: records.channelID})
this._sendRequest("unregister", {channelID: record.channelID})
.catch(function(e) {
debug("Unregister errored " + e);
});
@ -437,10 +442,11 @@ this.PushService = {
}
// Start service.
this._startService(service, uri, false, options);
// Before completing the activation check prefs. This will first check
// connection.enabled pref and then check offline state.
this._changeStateConnectionEnabledEvent(prefs.get("connection.enabled"));
this._startService(service, uri, false, options).then(_ => {
// Before completing the activation check prefs. This will first check
// connection.enabled pref and then check offline state.
this._changeStateConnectionEnabledEvent(prefs.get("connection.enabled"));
});
} else {
// This is only used for testing. Different tests require connecting to
@ -459,8 +465,6 @@ this.PushService = {
return;
}
this._alarmID = null;
Services.obs.addObserver(this, "webapps-clear-data", false);
// On B2G the NetworkManager interface fires a network-active-changed
@ -486,6 +490,9 @@ this.PushService = {
// Used to monitor if the user wishes to disable Push.
prefs.observe("connection.enabled", this);
// Used to prune expired registrations and notify dormant service workers.
Services.obs.addObserver(this, "idle-daily", false);
},
_startService: function(service, serverURI, event, options = {}) {
@ -582,7 +589,8 @@ this.PushService = {
prefs.ignore("connection.enabled", this);
Services.obs.removeObserver(this, this._networkStateChangeEventName);
Services.obs.removeObserver(this, "webapps-clear-data", false);
Services.obs.removeObserver(this, "webapps-clear-data");
Services.obs.removeObserver(this, "idle-daily");
},
uninit: function() {
@ -595,7 +603,7 @@ this.PushService = {
this._setState(PUSH_SERVICE_UNINIT);
prefs.ignore("serverURL", this);
Services.obs.removeObserver(this, "xpcom-shutdown", false);
Services.obs.removeObserver(this, "xpcom-shutdown");
this._serverURIProcessEnqueue(_ =>
this._changeServerURL("", UNINIT_EVENT));
@ -653,78 +661,108 @@ this.PushService = {
.then(_ => this._db.drop());
},
_notifySubscriptionChangeObservers: function(record) {
let globalMM = Cc['@mozilla.org/globalmessagemanager;1']
.getService(Ci.nsIMessageListenerManager);
// Notify XPCOM observers.
Services.obs.notifyObservers(
null,
"push-subscription-change",
record.scope
);
let data = {
originAttributes: record.originAttributes,
scope: record.scope
};
globalMM.broadcastAsyncMessage('pushsubscriptionchange', data);
},
// Fires a push-register system message to all applications that have
// registration.
_notifyAllAppsRegister: function() {
debug("notifyAllAppsRegister()");
// records are objects describing the registration as stored in IndexedDB.
return this._db.getAllKeyIDs()
.then(records => {
let globalMM = Cc['@mozilla.org/globalmessagemanager;1']
.getService(Ci.nsIMessageListenerManager);
for (let record of records) {
// Notify XPCOM observers.
Services.obs.notifyObservers(
null,
"push-subscription-change",
scope
);
let data = {
originAttributes: record.originAttributes,
scope: record.scope
};
globalMM.broadcastAsyncMessage('pushsubscriptionchange', data);
}
});
return this._db.getAllUnexpired().then(records =>
records.forEach(record =>
this._notifySubscriptionChangeObservers(record)
)
);
},
dropRegistrationAndNotifyApp: function(aKeyId) {
return this._db.getByKeyID(aKeyId)
.then(record => {
let globalMM = Cc['@mozilla.org/globalmessagemanager;1']
.getService(Ci.nsIMessageListenerManager);
Services.obs.notifyObservers(
null,
"push-subscription-change",
record.scope
);
let data = {
originAttributes: record.originAttributes,
scope: record.scope
};
globalMM.broadcastAsyncMessage('pushsubscriptionchange', data);
})
.then(_ => this._db.delete(aKeyId));
return this._db.getByKeyID(aKeyId).then(record => {
this._notifySubscriptionChangeObservers(record);
return this._db.delete(aKeyId);
});
},
updateRegistrationAndNotifyApp: function(aOldKey, aRecord) {
return this._db.delete(aOldKey)
.then(_ => this._db.put(aRecord)
.then(record => {
let globalMM = Cc['@mozilla.org/globalmessagemanager;1']
.getService(Ci.nsIMessageListenerManager);
Services.obs.notifyObservers(
null,
"push-subscription-change",
record.scope
);
let data = {
originAttributes: record.originAttributes,
scope: record.scope
};
globalMM.broadcastAsyncMessage('pushsubscriptionchange', data);
}));
.then(_ => this._db.put(aRecord))
.then(record => this._notifySubscriptionChangeObservers(record));
},
receivedPushMessage: function(aPushRecord, message) {
this._db.put(aPushRecord)
.then(_ => this._notifyApp(aPushRecord, message));
/**
* Dispatches an incoming message to a service worker, recalculating the
* quota for the associated push registration. If the quota is exceeded,
* the registration and message will be dropped, and the worker will not
* be notified.
*
* @param {String} keyID The push registration ID.
* @param {String} message The message contents.
* @param {Function} updateFunc A function that receives the existing
* registration record as its argument, and returns a new record. If the
* function returns `null` or `undefined`, the record will not be updated.
* `PushServiceWebSocket` uses this to drop incoming updates with older
* versions.
*/
receivedPushMessage: function(keyID, message, updateFunc) {
debug("receivedPushMessage()");
let shouldNotify = false;
this.getByKeyID(keyID).then(record => {
if (!record) {
throw new Error("No record for key ID " + keyID);
}
return record.getLastVisit();
}).then(lastVisit => {
// As a special case, don't notify the service worker if the user
// cleared their history.
shouldNotify = isFinite(lastVisit);
return this._db.update(keyID, record => {
let newRecord = updateFunc(record);
if (!newRecord) {
return null;
}
if (newRecord.isExpired()) {
// Because `unregister` is advisory only, we can still receive messages
// for stale registrations from the server.
debug("receivedPushMessage: Ignoring update for expired key ID " + keyID);
return null;
}
newRecord.receivedPush(lastVisit);
return newRecord;
});
}).then(record => {
if (!record) {
return null;
}
if (shouldNotify) {
this._notifyApp(record, message);
}
if (record.isExpired()) {
// Drop the registration in the background. If the user returns to the
// site, the service worker will be notified on the next `idle-daily`
// event.
this._sendRequest("unregister", record).catch(error => {
debug("receivedPushMessage: Unregister error: " + error);
});
}
}).catch(error => {
debug("receivedPushMessage: Error notifying app: " + error);
});
},
_notifyApp: function(aPushRecord, message) {
@ -775,8 +813,8 @@ this.PushService = {
return this._db.getByKeyID(aKeyID);
},
getAllKeyIDs: function() {
return this._db.getAllKeyIDs();
getAllUnexpired: function() {
return this._db.getAllUnexpired();
},
_sendRequest: function(action, aRecord) {
@ -796,11 +834,11 @@ this.PushService = {
debug("registerWithServer()" + JSON.stringify(aPageRecord));
return this._sendRequest("register", aPageRecord)
.then(pushRecord => this._onRegisterSuccess(pushRecord),
.then(record => this._onRegisterSuccess(record),
err => this._onRegisterError(err))
.then(pushRecord => {
.then(record => {
this._deletePendingRequest(aPageRecord);
return pushRecord;
return record;
}, err => {
this._deletePendingRequest(aPageRecord);
throw err;
@ -815,11 +853,23 @@ this.PushService = {
return this._checkActivated()
.then(_ => this._db.getByIdentifiers(aPageRecord))
.then(pushRecord => {
if (pushRecord === undefined) {
.then(record => {
if (!record) {
return this._lookupOrPutPendingRequest(aPageRecord);
}
return pushRecord;
if (record.isExpired()) {
return record.getLastVisit().then(lastVisit => {
if (lastVisit > record.lastPush) {
// If the user revisited the site, drop the expired push
// registration and re-register.
return this._db.delete(record.keyID).then(_ => {
return this._lookupOrPutPendingRequest(aPageRecord);
});
}
throw {state: 0, error: "NotFoundError"};
});
}
return record;
}, error => {
debug("getByIdentifiers failed");
throw error;
@ -834,7 +884,7 @@ this.PushService = {
debug("_onRegisterSuccess()");
return this._db.put(aRecord)
.then(_ => aRecord, error => {
.catch(error => {
// Unable to save. Destroy the subscription in the background.
this._sendRequest("unregister", aRecord).catch(err => {
debug("_onRegisterSuccess: Error unregistering stale subscription" +
@ -877,7 +927,7 @@ this.PushService = {
if (!principal) {
debug("No principal passed!");
let message = {
requestID: aPageRecord.requestID,
requestID: pageRecord.requestID,
error: "SecurityError"
};
mm.sendAsyncMessage("PushService:Register:KO", message);
@ -890,7 +940,7 @@ this.PushService = {
if (!pageRecord.scope || pageRecord.originAttributes === undefined) {
debug("Incorrect identifier values set! " + JSON.stringify(pageRecord));
let message = {
requestID: aPageRecord.requestID,
requestID: pageRecord.requestID,
error: "SecurityError"
};
mm.sendAsyncMessage("PushService:Register:KO", message);
@ -904,8 +954,8 @@ this.PushService = {
debug("register(): " + JSON.stringify(aPageRecord));
this._register(aPageRecord)
.then(pushRecord => {
let message = this._service.prepareRegister(pushRecord);
.then(record => {
let message = record.toRegister();
message.requestID = aPageRecord.requestID;
aMessageManager.sendAsyncMessage("PushService:Register:OK", message);
}, error => {
@ -958,7 +1008,7 @@ this.PushService = {
return Promise.all([
this._sendRequest("unregister", record),
this._db.delete(this._service.getKeyFromRecord(record))
this._db.delete(record.keyID),
]);
});
},
@ -998,11 +1048,19 @@ this.PushService = {
return this._checkActivated()
.then(_ => this._db.getByIdentifiers(aPageRecord))
.then(pushRecord => {
if (!pushRecord) {
.then(record => {
if (!record) {
return null;
}
return this._service.prepareRegistration(pushRecord);
if (record.isExpired()) {
return record.getLastVisit().then(lastVisit => {
if (lastVisit > record.lastPush) {
return this._db.delete(record.keyID).then(_ => null);
}
throw {state: 0, error: "NotFoundError"};
});
}
return record.toRegistration();
});
},
@ -1020,5 +1078,29 @@ this.PushService = {
error
})
);
}
},
_dropExpiredRegistrations: function() {
debug("dropExpiredRegistrations()");
this._db.getAllExpired().then(records => {
return Promise.all(records.map(record => {
return record.getLastVisit().then(lastVisit => {
if (lastVisit > record.lastPush) {
// If the user revisited the site, drop the expired push
// registration and notify the associated service worker.
return this._db.delete(record.keyID).then(() => {
this._notifySubscriptionChangeObservers(record);
});
}
}).catch(error => {
debug("dropExpiredRegistrations: Error dropping registration " +
record.keyID + ": " + error);
});
}));
}).catch(error => {
debug("dropExpiredRegistrations: Error dropping registrations: " +
error);
});
},
};

Просмотреть файл

@ -11,6 +11,7 @@ const Cu = Components.utils;
const Cr = Components.results;
const {PushDB} = Cu.import("resource://gre/modules/PushDB.jsm");
const {PushRecord} = Cu.import("resource://gre/modules/PushRecord.jsm");
Cu.import("resource://gre/modules/XPCOMUtils.jsm");
Cu.import("resource://gre/modules/Services.jsm");
Cu.import("resource://gre/modules/IndexedDBHelper.jsm");
@ -33,7 +34,7 @@ function debug(s) {
}
const kPUSHHTTP2DB_DB_NAME = "pushHttp2";
const kPUSHHTTP2DB_DB_VERSION = 3; // Change this if the IndexedDB format changes
const kPUSHHTTP2DB_DB_VERSION = 4; // Change this if the IndexedDB format changes
const kPUSHHTTP2DB_STORE_NAME = "pushHttp2";
/**
@ -285,16 +286,15 @@ SubscriptionListener.prototype = {
return;
}
var reply = {
let reply = new PushRecordHttp2({
subscriptionUri: subscriptionUri,
pushEndpoint: linkParserResult.pushEndpoint,
pushReceiptEndpoint: linkParserResult.pushReceiptEndpoint,
pageURL: this._subInfo.record.pageURL,
scope: this._subInfo.record.scope,
originAttributes: this._subInfo.record.originAttributes,
pushCount: 0,
lastPush: 0
};
quota: this._subInfo.record.maxQuota,
});
this._subInfo.resolve(reply);
},
};
@ -379,51 +379,12 @@ this.PushServiceHttp2 = {
_conns: {},
_started: false,
upgradeSchema: function(aTransaction,
aDb,
aOldVersion,
aNewVersion,
aDbInstance) {
debug("upgradeSchemaHttp2()");
//XXXnsm We haven't shipped Push during this upgrade, so I'm just going to throw old
//registrations away without even informing the app.
if (aNewVersion != aOldVersion) {
try {
aDb.deleteObjectStore(aDbInstance._dbStoreName);
} catch (e) {
if (e.name === "NotFoundError") {
debug("No existing object store found");
} else {
throw e;
}
}
}
let objectStore = aDb.createObjectStore(aDbInstance._dbStoreName,
{ keyPath: "subscriptionUri" });
// index to fetch records based on endpoints. used by unregister
objectStore.createIndex("pushEndpoint", "pushEndpoint", { unique: true });
// index to fetch records by identifiers.
// In the current security model, the originAttributes distinguish between
// different 'apps' on the same origin. Since ServiceWorkers are
// same-origin to the scope they are registered for, the attributes and
// scope are enough to reconstruct a valid principal.
objectStore.createIndex("identifiers", ["scope", "originAttributes"], { unique: true });
objectStore.createIndex("originAttributes", "originAttributes", { unique: false });
},
getKeyFromRecord: function(aRecord) {
return aRecord.subscriptionUri;
},
newPushDB: function() {
return new PushDB(kPUSHHTTP2DB_DB_NAME,
kPUSHHTTP2DB_DB_VERSION,
kPUSHHTTP2DB_STORE_NAME,
this.upgradeSchema);
"subscriptionUri",
PushRecordHttp2);
},
hasmainPushService: function() {
@ -798,20 +759,11 @@ this.PushServiceHttp2 = {
_pushChannelOnStop: function(aUri, aAckUri, aMessage) {
debug("pushChannelOnStop() ");
let sendNotification = function(aAckUri, aPushRecord, self) {
aPushRecord.pushCount = aPushRecord.pushCount + 1;
aPushRecord.lastPush = new Date().getTime();
self._mainPushService.receivedPushMessage(aPushRecord, aMessage);
self._ackMsgRecv(aAckUri);
};
let recoverNoSuchEndpoint = function() {
debug("Could not get push endpoint " + aUri + " from DB");
};
this._mainPushService.getByKeyID(aUri)
.then(pushRecord => sendNotification(aAckUri, pushRecord, this),
recoverNoSuchEndpoint);
this._mainPushService.receivedPushMessage(aUri, aMessage, record => {
// Always update the stored record.
return record;
});
this._ackMsgRecv(aAckUri);
},
onAlarmFired: function() {
@ -819,21 +771,30 @@ this.PushServiceHttp2 = {
// i.e. when _waitingForPong is true, other conditions are also true.
this._startConnectionsWaitingForAlarm();
},
prepareRegistration: function(aPushRecord) {
return {
pushEndpoint: aPushRecord.pushEndpoint,
pushReceiptEndpoint: aPushRecord.pushReceiptEndpoint,
version: aPushRecord.version,
lastPush: aPushRecord.lastPush,
pushCount: aPushRecord.pushCount
};
},
prepareRegister: function(aPushRecord) {
return {
pushEndpoint: aPushRecord.pushEndpoint,
pushReceiptEndpoint: aPushRecord.pushReceiptEndpoint
};
}
};
function PushRecordHttp2(record) {
PushRecord.call(this, record);
this.subscriptionUri = record.subscriptionUri;
this.pushReceiptEndpoint = record.pushReceiptEndpoint;
}
PushRecordHttp2.prototype = Object.create(PushRecord.prototype, {
keyID: {
get() {
return this.subscriptionUri;
},
},
});
PushRecordHttp2.prototype.toRegistration = function() {
let registration = PushRecord.prototype.toRegistration.call(this);
registration.pushReceiptEndpoint = this.pushReceiptEndpoint;
return registration;
};
PushRecordHttp2.prototype.toRegister = function() {
let register = PushRecord.prototype.toRegister.call(this);
register.pushReceiptEndpoint = this.pushReceiptEndpoint;
return register;
};

Просмотреть файл

@ -11,6 +11,7 @@ const Cu = Components.utils;
const Cr = Components.results;
const {PushDB} = Cu.import("resource://gre/modules/PushDB.jsm");
const {PushRecord} = Cu.import("resource://gre/modules/PushRecord.jsm");
Cu.import("resource://gre/modules/Preferences.jsm");
Cu.import("resource://gre/modules/Timer.jsm");
Cu.import("resource://gre/modules/Promise.jsm");
@ -32,7 +33,7 @@ var threadManager = Cc["@mozilla.org/thread-manager;1"]
.getService(Ci.nsIThreadManager);
const kPUSHWSDB_DB_NAME = "pushapi";
const kPUSHWSDB_DB_VERSION = 3; // Change this if the IndexedDB format changes
const kPUSHWSDB_DB_VERSION = 4; // Change this if the IndexedDB format changes
const kPUSHWSDB_STORE_NAME = "pushapi";
const kUDP_WAKEUP_WS_STATUS_CODE = 4774; // WebSocket Close status code sent
@ -125,51 +126,12 @@ this.PushServiceWebSocket = {
_mainPushService: null,
_serverURI: null,
upgradeSchema: function(aTransaction,
aDb,
aOldVersion,
aNewVersion,
aDbInstance) {
debug("upgradeSchemaWS()");
//XXXnsm We haven't shipped Push during this upgrade, so I'm just going to throw old
//registrations away without even informing the app.
if (aNewVersion != aOldVersion) {
try {
aDb.deleteObjectStore(aDbInstance._dbStoreName);
} catch (e) {
if (e.name === "NotFoundError") {
debug("No existing object store found");
} else {
throw e;
}
}
}
let objectStore = aDb.createObjectStore(aDbInstance._dbStoreName,
{ keyPath: "channelID" });
// index to fetch records based on endpoints. used by unregister
objectStore.createIndex("pushEndpoint", "pushEndpoint", { unique: true });
// index to fetch records by identifiers.
// In the current security model, the originAttributes distinguish between
// different 'apps' on the same origin. Since ServiceWorkers are
// same-origin to the scope they are registered for, the attributes and
// scope are enough to reconstruct a valid principal.
objectStore.createIndex("identifiers", ["scope", "originAttributes"], { unique: true });
objectStore.createIndex("originAttributes", "originAttributes", { unique: false });
},
getKeyFromRecord: function(aRecord) {
return aRecord.channelID;
},
newPushDB: function() {
return new PushDB(kPUSHWSDB_DB_NAME,
kPUSHWSDB_DB_VERSION,
kPUSHWSDB_STORE_NAME,
this.upgradeSchema);
"channelID",
PushRecordWebSocket);
},
disconnect: function() {
@ -682,10 +644,10 @@ this.PushServiceWebSocket = {
}
},
connect: function(channelIDs) {
connect: function(records) {
debug("connect");
// Check to see if we need to do anything.
if (channelIDs.length > 0) {
if (records.length > 0) {
this._beginWSSetup();
}
},
@ -883,16 +845,14 @@ this.PushServiceWebSocket = {
return;
}
let record = {
let record = new PushRecordWebSocket({
channelID: reply.channelID,
pushEndpoint: reply.pushEndpoint,
pageURL: tmp.record.pageURL,
scope: tmp.record.scope,
originAttributes: tmp.record.originAttributes,
pushCount: 0,
lastPush: 0,
version: null
};
version: null,
quota: tmp.record.maxQuota,
});
dump("PushWebSocket " + JSON.stringify(record));
tmp.resolve(record);
} else {
@ -1016,11 +976,10 @@ this.PushServiceWebSocket = {
_queueRequest(data) {
if (this._currentState != STATE_READY) {
if (!this._notifyRequestQueue) {
this._enqueue(_ => {
return new Promise((resolve, reject) => {
this._notifyRequestQueue = resolve;
});
let promise = new Promise((resolve, reject) => {
this._notifyRequestQueue = resolve;
});
this._enqueue(_ => promise);
}
}
@ -1041,36 +1000,17 @@ this.PushServiceWebSocket = {
_receivedUpdate: function(aChannelID, aLatestVersion) {
debug("Updating: " + aChannelID + " -> " + aLatestVersion);
let compareRecordVersionAndNotify = function(aPushRecord) {
debug("compareRecordVersionAndNotify()");
if (!aPushRecord) {
debug("No record for channel ID " + aChannelID);
return;
this._mainPushService.receivedPushMessage(aChannelID, "", record => {
if (record.version === null ||
record.version < aLatestVersion) {
debug("Version changed for " + aChannelID + ": " + aLatestVersion);
record.version = aLatestVersion;
return record;
}
if (aPushRecord.version === null ||
aPushRecord.version < aLatestVersion) {
debug("Version changed, notifying app and updating DB");
aPushRecord.version = aLatestVersion;
aPushRecord.pushCount = aPushRecord.pushCount + 1;
aPushRecord.lastPush = new Date().getTime();
this._mainPushService.receivedPushMessage(aPushRecord,
"Short as life is, we make " +
"it still shorter by the " +
"careless waste of time.");
}
else {
debug("No significant version change: " + aLatestVersion);
}
};
let recoverNoSuchChannelID = function(aChannelIDFromServer) {
debug("Could not get channelID " + aChannelIDFromServer + " from DB");
};
this._mainPushService.getByKeyID(aChannelID)
.then(compareRecordVersionAndNotify.bind(this),
err => recoverNoSuchChannelID(err));
debug("No significant version change for " + aChannelID + ": " +
aLatestVersion);
return null;
});
},
// begin Push protocol handshake
@ -1121,7 +1061,7 @@ this.PushServiceWebSocket = {
};
}
this._mainPushService.getAllKeyIDs()
this._mainPushService.getAllUnexpired()
.then(sendHelloMessage.bind(this),
sendHelloMessage.bind(this));
});
@ -1299,21 +1239,6 @@ this.PushServiceWebSocket = {
this._udpServer = undefined;
this._beginWSSetup();
},
prepareRegistration: function(aPushRecord) {
return {
pushEndpoint: aPushRecord.pushEndpoint,
version: aPushRecord.version,
lastPush: aPushRecord.lastPush,
pushCount: aPushRecord.pushCount
};
},
prepareRegister: function(aPushRecord) {
return {
pushEndpoint: aPushRecord.pushEndpoint
};
}
};
let PushNetworkInfo = {
@ -1439,3 +1364,23 @@ let PushNetworkInfo = {
queryDNSForDomain(netidAddress, callback);
}
};
function PushRecordWebSocket(record) {
PushRecord.call(this, record);
this.channelID = record.channelID;
this.version = record.version;
}
PushRecordWebSocket.prototype = Object.create(PushRecord.prototype, {
keyID: {
get() {
return this.channelID;
},
},
});
PushRecordWebSocket.prototype.toRegistration = function() {
let registration = PushRecord.prototype.toRegistration.call(this);
registration.version = this.version;
return registration;
};

Просмотреть файл

@ -15,6 +15,7 @@ EXTRA_PP_JS_MODULES += [
EXTRA_JS_MODULES += [
'PushDB.jsm',
'PushRecord.jsm',
'PushService.jsm',
'PushServiceHttp2.jsm',
]

Просмотреть файл

@ -7,8 +7,7 @@ function handlePush(event) {
self.clients.matchAll().then(function(result) {
if (event instanceof PushEvent &&
event.data instanceof PushMessageData &&
event.data.text().length > 0) {
event.data instanceof PushMessageData) {
result[0].postMessage({type: "finished", okay: "yes"});
return;

Просмотреть файл

@ -10,6 +10,7 @@ Cu.import('resource://gre/modules/Services.jsm');
Cu.import('resource://gre/modules/Timer.jsm');
Cu.import('resource://gre/modules/Promise.jsm');
Cu.import('resource://gre/modules/Preferences.jsm');
Cu.import('resource://gre/modules/PlacesUtils.jsm');
const serviceExports = Cu.import('resource://gre/modules/PushService.jsm', {});
const servicePrefs = new Preferences('dom.push.');
@ -61,6 +62,25 @@ function after(times, func) {
};
}
/**
* Updates the places database.
*
* @param {mozIPlaceInfo} place A place record to insert.
* @returns {Promise} A promise that fulfills when the database is updated.
*/
function addVisit(place) {
return new Promise((resolve, reject) => {
if (typeof place.uri == 'string') {
place.uri = Services.io.newURI(place.uri, null, null);
}
PlacesUtils.asyncHistory.updatePlaces(place, {
handleCompletion: resolve,
handleError: reject,
handleResult() {},
});
});
}
/**
* Defers one or more callbacks until the next turn of the event loop. Multiple
* callbacks are executed in order.
@ -199,7 +219,8 @@ function setPrefs(prefs = {}) {
'adaptive.mobile': '',
'http2.maxRetries': 2,
'http2.retryInterval': 500,
'http2.reset_retry_count_after_ms': 60000
'http2.reset_retry_count_after_ms': 60000,
maxQuotaPerSubscription: 16,
}, prefs);
for (let pref in defaultPrefs) {
servicePrefs.set(pref, defaultPrefs[pref]);

Просмотреть файл

@ -20,7 +20,9 @@ add_task(function* test_unregister_success() {
channelID,
pushEndpoint: 'https://example.org/update/unregister-success',
scope: 'https://example.com/page/unregister-success',
version: 1
version: 1,
originAttributes: '',
quota: Infinity,
});
let unregisterDefer = Promise.defer();

Просмотреть файл

@ -26,19 +26,22 @@ add_task(function* test_notification_ack() {
pushEndpoint: 'https://example.com/update/1',
scope: 'https://example.org/1',
originAttributes: '',
version: 1
version: 1,
quota: Infinity,
}, {
channelID: '9a5ff87f-47c9-4215-b2b8-0bdd38b4b305',
pushEndpoint: 'https://example.com/update/2',
scope: 'https://example.org/2',
originAttributes: '',
version: 2
version: 2,
quota: Infinity,
}, {
channelID: '5477bfda-22db-45d4-9614-fee369630260',
pushEndpoint: 'https://example.com/update/3',
scope: 'https://example.org/3',
originAttributes: '',
version: 3
version: 3,
quota: Infinity,
}];
for (let record of records) {
yield db.put(record);

Просмотреть файл

@ -24,13 +24,15 @@ add_task(function* test_notification_duplicate() {
pushEndpoint: 'https://example.org/update/1',
scope: 'https://example.com/1',
originAttributes: "",
version: 2
version: 2,
quota: Infinity,
}, {
channelID: '27d1e393-03ef-4c72-a5e6-9e890dfccad0',
pushEndpoint: 'https://example.org/update/2',
scope: 'https://example.com/2',
originAttributes: "",
version: 2
version: 2,
quota: Infinity,
}];
for (let record of records) {
yield db.put(record);

Просмотреть файл

@ -26,19 +26,22 @@ add_task(function* test_notification_error() {
pushEndpoint: 'https://example.org/update/success-1',
scope: 'https://example.com/a',
originAttributes: originAttributes,
version: 1
version: 1,
quota: Infinity,
}, {
channelID: '3c3930ba-44de-40dc-a7ca-8a133ec1a866',
pushEndpoint: 'https://example.org/update/error',
scope: 'https://example.com/b',
originAttributes: originAttributes,
version: 2
version: 2,
quota: Infinity,
}, {
channelID: 'b63f7bef-0a0d-4236-b41e-086a69dfd316',
pushEndpoint: 'https://example.org/update/success-2',
scope: 'https://example.com/c',
originAttributes: originAttributes,
version: 3
version: 3,
quota: Infinity,
}];
for (let record of records) {
yield db.put(record);

Просмотреть файл

@ -58,19 +58,22 @@ add_task(function* test_pushNotifications() {
pushEndpoint: serverURL + '/pushEndpoint1',
pushReceiptEndpoint: serverURL + '/pushReceiptEndpoint1',
scope: 'https://example.com/page/1',
originAttributes: ChromeUtils.originAttributesToSuffix({ appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inBrowser: false })
originAttributes: ChromeUtils.originAttributesToSuffix({ appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inBrowser: false }),
quota: Infinity,
}, {
subscriptionUri: serverURL + '/pushNotifications/subscription2',
pushEndpoint: serverURL + '/pushEndpoint2',
pushReceiptEndpoint: serverURL + '/pushReceiptEndpoint2',
scope: 'https://example.com/page/2',
originAttributes: ChromeUtils.originAttributesToSuffix({ appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inBrowser: false })
originAttributes: ChromeUtils.originAttributesToSuffix({ appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inBrowser: false }),
quota: Infinity,
}, {
subscriptionUri: serverURL + '/pushNotifications/subscription3',
pushEndpoint: serverURL + '/pushEndpoint3',
pushReceiptEndpoint: serverURL + '/pushReceiptEndpoint3',
scope: 'https://example.com/page/3',
originAttributes: ChromeUtils.originAttributesToSuffix({ appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inBrowser: false })
originAttributes: ChromeUtils.originAttributesToSuffix({ appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inBrowser: false }),
quota: Infinity,
}];
for (let record of records) {

Просмотреть файл

@ -24,25 +24,33 @@ add_task(function* test_notification_incomplete() {
channelID: '123',
pushEndpoint: 'https://example.org/update/1',
scope: 'https://example.com/page/1',
version: 1
version: 1,
originAttributes: '',
quota: Infinity,
}, {
channelID: '3ad1ed95-d37a-4d88-950f-22cbe2e240d7',
pushEndpoint: 'https://example.org/update/2',
scope: 'https://example.com/page/2',
version: 1
version: 1,
originAttributes: '',
quota: Infinity,
}, {
channelID: 'd239498b-1c85-4486-b99b-205866e82d1f',
pushEndpoint: 'https://example.org/update/3',
scope: 'https://example.com/page/3',
version: 3
version: 3,
originAttributes: '',
quota: Infinity,
}, {
channelID: 'a50de97d-b496-43ce-8b53-05522feb78db',
pushEndpoint: 'https://example.org/update/4',
scope: 'https://example.com/page/4',
version: 10
version: 10,
originAttributes: '',
quota: Infinity,
}];
for (let record of records) {
db.put(record);
yield db.put(record);
}
Services.obs.addObserver(function observe(subject, topic, data) {
@ -51,7 +59,7 @@ add_task(function* test_notification_incomplete() {
let notificationDefer = Promise.defer();
let notificationDone = after(2, notificationDefer.resolve);
let prevHandler = PushService._handleNotificationReply;
let prevHandler = PushServiceWebSocket._handleNotificationReply;
PushServiceWebSocket._handleNotificationReply = function _handleNotificationReply() {
notificationDone();
return prevHandler.apply(this, arguments);

Просмотреть файл

@ -22,7 +22,8 @@ add_task(function* test_notification_version_string() {
pushEndpoint: 'https://example.org/updates/1',
scope: 'https://example.com/page/1',
originAttributes: '',
version: 2
version: 2,
quota: Infinity,
});
let notifyPromise = promiseObserverNotification('push-notification');
@ -70,4 +71,5 @@ add_task(function* test_notification_version_string() {
let storeRecord = yield db.getByKeyID(
'6ff97d56-d0c0-43bc-8f5b-61b855e1d93b');
strictEqual(storeRecord.version, 4, 'Wrong record version');
equal(storeRecord.quota, Infinity, 'Wrong quota');
});

Просмотреть файл

@ -0,0 +1,147 @@
/* Any copyright is dedicated to the Public Domain.
http://creativecommons.org/publicdomain/zero/1.0/ */
'use strict';
const {PushDB, PushService, PushServiceWebSocket} = serviceExports;
Cu.import("resource://gre/modules/Task.jsm");
const userAgentID = '7eb873f9-8d47-4218-804b-fff78dc04e88';
function run_test() {
do_get_profile();
setPrefs({
userAgentID,
});
run_next_test();
}
add_task(function* test_expiration_origin_threshold() {
let db = PushServiceWebSocket.newPushDB();
do_register_cleanup(() => db.drop().then(_ => db.close()));
yield db.put({
channelID: 'eb33fc90-c883-4267-b5cb-613969e8e349',
pushEndpoint: 'https://example.org/push/1',
scope: 'https://example.com/auctions',
pushCount: 0,
lastPush: 0,
version: null,
originAttributes: '',
quota: 16,
});
yield db.put({
channelID: '46cc6f6a-c106-4ffa-bb7c-55c60bd50c41',
pushEndpoint: 'https://example.org/push/2',
scope: 'https://example.com/deals',
pushCount: 0,
lastPush: 0,
version: null,
originAttributes: '',
quota: 16,
});
// The notification threshold is per-origin, even with multiple service
// workers for different scopes.
yield addVisit({
uri: 'https://example.com/login',
title: 'Sign in to see your auctions',
visits: [{
visitDate: (Date.now() - 7 * 24 * 60 * 60 * 1000) * 1000,
transitionType: Ci.nsINavHistoryService.TRANSITION_LINK,
}],
});
// We'll always use your most recent visit to an origin.
yield addVisit({
uri: 'https://example.com/auctions',
title: 'Your auctions',
visits: [{
visitDate: (Date.now() - 2 * 24 * 60 * 60 * 1000) * 1000,
transitionType: Ci.nsINavHistoryService.TRANSITION_LINK,
}],
});
// ...But we won't count downloads or embeds.
yield addVisit({
uri: 'https://example.com/invoices/invoice.pdf',
title: 'Invoice #123',
visits: [{
visitDate: (Date.now() - 1 * 24 * 60 * 60 * 1000) * 1000,
transitionType: Ci.nsINavHistoryService.TRANSITION_EMBED,
}, {
visitDate: Date.now() * 1000,
transitionType: Ci.nsINavHistoryService.TRANSITION_DOWNLOAD,
}],
});
// We expect to receive 6 notifications: 5 on the `auctions` channel,
// and 1 on the `deals` channel. They're from the same origin, but
// different scopes, so each can send 5 notifications before we remove
// their subscription.
let updates = 0;
let notifyPromise = promiseObserverNotification('push-notification', (subject, data) => {
updates++;
return updates == 6;
});
let unregisterDefer = Promise.defer();
PushService.init({
serverURI: 'wss://push.example.org/',
networkInfo: new MockDesktopNetworkInfo(),
db,
makeWebSocket(uri) {
return new MockWebSocket(uri, {
onHello(request) {
deepEqual(request.channelIDs.sort(), [
'46cc6f6a-c106-4ffa-bb7c-55c60bd50c41',
'eb33fc90-c883-4267-b5cb-613969e8e349',
], 'Wrong active registrations in handshake');
this.serverSendMsg(JSON.stringify({
messageType: 'hello',
status: 200,
uaid: userAgentID,
}));
// We last visited the site 2 days ago, so we can send 5
// notifications without throttling. Sending a 6th should
// drop the registration.
for (let version = 1; version <= 6; version++) {
this.serverSendMsg(JSON.stringify({
messageType: 'notification',
updates: [{
channelID: 'eb33fc90-c883-4267-b5cb-613969e8e349',
version,
}],
}));
}
// But the limits are per-channel, so we can send 5 more
// notifications on a different channel.
this.serverSendMsg(JSON.stringify({
messageType: 'notification',
updates: [{
channelID: '46cc6f6a-c106-4ffa-bb7c-55c60bd50c41',
version: 1,
}],
}));
},
onUnregister(request) {
equal(request.channelID, 'eb33fc90-c883-4267-b5cb-613969e8e349', 'Unregistered wrong channel ID');
unregisterDefer.resolve();
},
// We expect to receive acks, but don't care about their
// contents.
onACK(request) {},
});
},
});
yield waitForPromise(unregisterDefer.promise, DEFAULT_TIMEOUT,
'Timed out waiting for unregister request');
yield waitForPromise(notifyPromise, DEFAULT_TIMEOUT,
'Timed out waiting for notifications');
let expiredRecord = yield db.getByKeyID('eb33fc90-c883-4267-b5cb-613969e8e349');
strictEqual(expiredRecord.quota, 0, 'Expired record not updated');
});

Просмотреть файл

@ -0,0 +1,125 @@
/* Any copyright is dedicated to the Public Domain.
http://creativecommons.org/publicdomain/zero/1.0/ */
'use strict';
const {PushDB, PushService, PushServiceWebSocket} = serviceExports;
const userAgentID = '28cd09e2-7506-42d8-9e50-b02785adc7ef';
function run_test() {
do_get_profile();
setPrefs({
userAgentID,
});
run_next_test();
}
add_task(function* test_expiration_history_observer() {
let db = PushServiceWebSocket.newPushDB();
do_register_cleanup(() => db.drop().then(_ => db.close()));
// A registration that we'll expire...
yield db.put({
channelID: '379c0668-8323-44d2-a315-4ee83f1a9ee9',
pushEndpoint: 'https://example.org/push/1',
scope: 'https://example.com/deals',
pushCount: 0,
lastPush: 0,
version: null,
originAttributes: '',
quota: 16,
});
// ...And an expired registration that we'll revive later.
yield db.put({
channelID: 'eb33fc90-c883-4267-b5cb-613969e8e349',
pushEndpoint: 'https://example.org/push/2',
scope: 'https://example.com/auctions',
pushCount: 0,
lastPush: 0,
version: null,
originAttributes: '',
quota: 0,
});
yield addVisit({
uri: 'https://example.com/infrequent',
title: 'Infrequently-visited page',
visits: [{
visitDate: (Date.now() - 14 * 24 * 60 * 60 * 1000) * 1000,
transitionType: Ci.nsINavHistoryService.TRANSITION_LINK,
}],
});
let unregisterDefer = Promise.defer();
PushService.init({
serverURI: 'wss://push.example.org/',
networkInfo: new MockDesktopNetworkInfo(),
db,
makeWebSocket(uri) {
return new MockWebSocket(uri, {
onHello(request) {
deepEqual(request.channelIDs, [
'379c0668-8323-44d2-a315-4ee83f1a9ee9',
], 'Should not include expired channel IDs');
this.serverSendMsg(JSON.stringify({
messageType: 'hello',
status: 200,
uaid: userAgentID,
}));
this.serverSendMsg(JSON.stringify({
messageType: 'notification',
updates: [{
channelID: '379c0668-8323-44d2-a315-4ee83f1a9ee9',
version: 2,
}],
}));
},
onUnregister(request) {
equal(request.channelID, '379c0668-8323-44d2-a315-4ee83f1a9ee9', 'Dropped wrong channel ID');
unregisterDefer.resolve();
},
onACK(request) {},
});
}
});
yield waitForPromise(unregisterDefer.promise, DEFAULT_TIMEOUT,
'Timed out waiting for unregister request');
let expiredRecord = yield db.getByKeyID('379c0668-8323-44d2-a315-4ee83f1a9ee9');
strictEqual(expiredRecord.quota, 0, 'Expired record not updated');
let notifiedScopes = [];
let subChangePromise = promiseObserverNotification('push-subscription-change', (subject, data) => {
notifiedScopes.push(data);
return notifiedScopes.length == 2;
});
// Now visit the site...
yield addVisit({
uri: 'https://example.com/another-page',
title: 'Infrequently-visited page',
visits: [{
visitDate: Date.now() * 1000,
transitionType: Ci.nsINavHistoryService.TRANSITION_LINK,
}],
});
Services.obs.notifyObservers(null, 'idle-daily', '');
// And we should receive notifications for both scopes.
yield waitForPromise(subChangePromise, DEFAULT_TIMEOUT,
'Timed out waiting for subscription change events');
deepEqual(notifiedScopes.sort(), [
'https://example.com/auctions',
'https://example.com/deals'
], 'Wrong scopes for subscription changes');
let aRecord = yield db.getByKeyID('379c0668-8323-44d2-a315-4ee83f1a9ee9');
ok(!aRecord, 'Should drop expired record');
let bRecord = yield db.getByKeyID('eb33fc90-c883-4267-b5cb-613969e8e349');
ok(!bRecord, 'Should drop evicted record');
});

Просмотреть файл

@ -85,7 +85,7 @@ add_task(function* test1() {
let newRecord = yield PushNotificationService.register(
'https://example.com/retry5xxCode',
{ appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inBrowser: false }
ChromeUtils.originAttributesToSuffix({ appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inBrowser: false })
);
var subscriptionUri = serverURL + '/subscription';

Просмотреть файл

@ -48,7 +48,7 @@ add_task(function* test_register_case() {
let newRecord = yield waitForPromise(
PushNotificationService.register('https://example.net/case',
{ appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inBrowser: false }),
ChromeUtils.originAttributesToSuffix({ appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inBrowser: false })),
DEFAULT_TIMEOUT,
'Mixed-case register response timed out'
);

Просмотреть файл

@ -30,7 +30,8 @@ add_task(function* test_register_flush() {
pushEndpoint: 'https://example.org/update/1',
scope: 'https://example.com/page/1',
originAttributes: '',
version: 2
version: 2,
quota: Infinity,
};
yield db.put(record);

Просмотреть файл

@ -49,7 +49,7 @@ add_task(function* test_register_invalid_channel() {
yield rejects(
PushNotificationService.register('https://example.com/invalid-channel',
{ appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inBrowser: false }),
ChromeUtils.originAttributesToSuffix({ appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inBrowser: false })),
function(error) {
return error == 'Invalid channel ID';
},

Просмотреть файл

@ -51,7 +51,7 @@ add_task(function* test_register_invalid_endpoint() {
yield rejects(
PushNotificationService.register(
'https://example.net/page/invalid-endpoint',
{ appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inBrowser: false }),
ChromeUtils.originAttributesToSuffix({ appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inBrowser: false })),
function(error) {
return error && error.includes('Invalid pushEndpoint');
},

Просмотреть файл

@ -50,7 +50,7 @@ add_task(function* test_register_invalid_json() {
yield rejects(
PushNotificationService.register('https://example.net/page/invalid-json',
{ appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inBrowser: false }),
ChromeUtils.originAttributesToSuffix({ appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inBrowser: false })),
function(error) {
return error == 'TimeoutError';
},

Просмотреть файл

@ -54,7 +54,7 @@ add_task(function* test_register_no_id() {
yield rejects(
PushNotificationService.register('https://example.com/incomplete',
{ appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inBrowser: false }),
ChromeUtils.originAttributesToSuffix({ appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inBrowser: false })),
function(error) {
return error == 'TimeoutError';
},

Просмотреть файл

@ -46,11 +46,11 @@ add_task(function* test_register_request_queue() {
let firstRegister = PushNotificationService.register(
'https://example.com/page/1',
{ appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inBrowser: false }
ChromeUtils.originAttributesToSuffix({ appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inBrowser: false })
);
let secondRegister = PushNotificationService.register(
'https://example.com/page/1',
{ appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inBrowser: false }
ChromeUtils.originAttributesToSuffix({ appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inBrowser: false })
);
yield waitForPromise(Promise.all([

Просмотреть файл

@ -75,7 +75,7 @@ add_task(function* test_register_rollback() {
// Should return a rejected promise if storage fails.
yield rejects(
PushNotificationService.register('https://example.com/storage-error',
{ appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inBrowser: false }),
ChromeUtils.originAttributesToSuffix({ appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inBrowser: false })),
function(error) {
return error == 'universe has imploded';
},

Просмотреть файл

@ -58,7 +58,7 @@ add_task(function* test_register_success() {
let newRecord = yield PushNotificationService.register(
'https://example.org/1',
{ appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inBrowser: false }
ChromeUtils.originAttributesToSuffix({ appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inBrowser: false })
);
equal(newRecord.channelID, channelID,
'Wrong channel ID in registration record');
@ -66,6 +66,8 @@ add_task(function* test_register_success() {
'Wrong push endpoint in registration record');
equal(newRecord.scope, 'https://example.org/1',
'Wrong scope in registration record');
equal(newRecord.quota, Infinity,
'Wrong quota in registration record');
let record = yield db.getByKeyID(channelID);
equal(record.channelID, channelID,
@ -74,4 +76,6 @@ add_task(function* test_register_success() {
'Wrong push endpoint in database record');
equal(record.scope, 'https://example.org/1',
'Wrong scope in database record');
equal(record.quota, Infinity,
'Wrong quota in database record');
});

Просмотреть файл

@ -84,7 +84,7 @@ add_task(function* test_register_timeout() {
yield rejects(
PushNotificationService.register('https://example.net/page/timeout',
{ appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inBrowser: false }),
ChromeUtils.originAttributesToSuffix({ appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inBrowser: false })),
function(error) {
return error == 'TimeoutError';
},

Просмотреть файл

@ -60,7 +60,7 @@ add_task(function* test_register_wrong_id() {
yield rejects(
PushNotificationService.register('https://example.com/mismatched',
{ appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inBrowser: false }),
ChromeUtils.originAttributesToSuffix({ appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inBrowser: false })),
function(error) {
return error == 'TimeoutError';
},

Просмотреть файл

@ -56,7 +56,7 @@ add_task(function* test_register_wrong_type() {
yield rejects(
PushNotificationService.register('https://example.com/mistyped',
{ appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inBrowser: false }),
ChromeUtils.originAttributesToSuffix({ appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inBrowser: false })),
function(error) {
return error == 'TimeoutError';
},

Просмотреть файл

@ -32,7 +32,7 @@ add_task(function* test_registrations_error() {
yield rejects(
PushNotificationService.registration('https://example.net/1',
{ appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inBrowser: false }),
ChromeUtils.originAttributesToSuffix({ appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inBrowser: false })),
function(error) {
return error == 'Database error';
},

Просмотреть файл

@ -25,6 +25,6 @@ add_task(function* test_registration_none() {
let registration = yield PushNotificationService.registration(
'https://example.net/1',
{ appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inBrowser: false });
ChromeUtils.originAttributesToSuffix({ appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inBrowser: false }));
ok(!registration, 'Should not open a connection without registration');
});

Просмотреть файл

@ -21,24 +21,28 @@ add_task(function* test_registration_success() {
pushEndpoint: 'https://example.com/update/same-manifest/1',
scope: 'https://example.net/a',
originAttributes: '',
version: 5
version: 5,
quota: Infinity,
}, {
channelID: 'f6edfbcd-79d6-49b8-9766-48b9dcfeff0f',
pushEndpoint: 'https://example.com/update/same-manifest/2',
scope: 'https://example.net/b',
originAttributes: ChromeUtils.originAttributesToSuffix({ appId: 42 }),
version: 10
version: 10,
quota: Infinity,
}, {
channelID: 'b1cf38c9-6836-4d29-8a30-a3e98d59b728',
pushEndpoint: 'https://example.org/update/different-manifest',
scope: 'https://example.org/c',
originAttributes: ChromeUtils.originAttributesToSuffix({ appId: 42, inBrowser: true }),
version: 15
version: 15,
quota: Infinity,
}];
for (let record of records) {
yield db.put(record);
}
let handshakeDefer = Promise.defer();
PushService.init({
serverURI: "wss://push.example.org/",
networkInfo: new MockDesktopNetworkInfo(),
@ -56,11 +60,18 @@ add_task(function* test_registration_success() {
status: 200,
uaid: userAgentID
}));
handshakeDefer.resolve();
}
});
}
});
yield waitForPromise(
handshakeDefer.promise,
DEFAULT_TIMEOUT,
'Timed out waiting for handshake'
);
let registration = yield PushNotificationService.registration(
'https://example.net/a', '');
equal(

Просмотреть файл

@ -42,19 +42,22 @@ add_task(function* test_pushNotifications() {
pushEndpoint: serverURL + '/pushEndpointA',
pushReceiptEndpoint: serverURL + '/pushReceiptEndpointA',
scope: 'https://example.net/a',
originAttributes: ChromeUtils.originAttributesToSuffix({ appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inBrowser: false })
originAttributes: ChromeUtils.originAttributesToSuffix({ appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inBrowser: false }),
quota: Infinity,
}, {
subscriptionUri: serverURL + '/subscriptionB',
pushEndpoint: serverURL + '/pushEndpointB',
pushReceiptEndpoint: serverURL + '/pushReceiptEndpointB',
scope: 'https://example.net/b',
originAttributes: ChromeUtils.originAttributesToSuffix({ appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inBrowser: false })
originAttributes: ChromeUtils.originAttributesToSuffix({ appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inBrowser: false }),
quota: Infinity,
}, {
subscriptionUri: serverURL + '/subscriptionC',
pushEndpoint: serverURL + '/pushEndpointC',
pushReceiptEndpoint: serverURL + '/pushReceiptEndpointC',
scope: 'https://example.net/c',
originAttributes: ChromeUtils.originAttributesToSuffix({ appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inBrowser: false })
originAttributes: ChromeUtils.originAttributesToSuffix({ appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inBrowser: false }),
quota: Infinity,
}];
for (let record of records) {

Просмотреть файл

@ -72,7 +72,9 @@ add_task(function* test1() {
subscriptionUri: serverURL + '/subscription4xxCode',
pushEndpoint: serverURL + '/pushEndpoint',
pushReceiptEndpoint: serverURL + '/pushReceiptEndpoint',
scope: 'https://example.com/page'
scope: 'https://example.com/page',
originAttributes: '',
quota: Infinity,
}];
for (let record of records) {

Просмотреть файл

@ -82,7 +82,9 @@ add_task(function* test1() {
subscriptionUri: serverURL + '/subscription5xxCode',
pushEndpoint: serverURL + '/pushEndpoint',
pushReceiptEndpoint: serverURL + '/pushReceiptEndpoint',
scope: 'https://example.com/page'
scope: 'https://example.com/page',
originAttributes: '',
quota: Infinity,
}];
for (let record of records) {

Просмотреть файл

@ -67,7 +67,9 @@ add_task(function* test1() {
subscriptionUri: 'http://localhost/subscriptionNotExist',
pushEndpoint: serverURL + '/pushEndpoint',
pushReceiptEndpoint: serverURL + '/pushReceiptEndpoint',
scope: 'https://example.com/page'
scope: 'https://example.com/page',
originAttributes: '',
quota: Infinity,
}];
for (let record of records) {

Просмотреть файл

@ -30,7 +30,7 @@ add_task(function* test_unregister_empty_scope() {
yield rejects(
PushNotificationService.unregister('',
{ appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inBrowser: false }),
ChromeUtils.originAttributesToSuffix({ appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inBrowser: false })),
function(error) {
return error.error == 'NotFoundError';
},

Просмотреть файл

@ -21,7 +21,8 @@ add_task(function* test_unregister_error() {
pushEndpoint: 'https://example.org/update/failure',
scope: 'https://example.net/page/failure',
originAttributes: '',
version: 1
version: 1,
quota: Infinity,
});
let unregisterDefer = Promise.defer();

Просмотреть файл

@ -25,13 +25,15 @@ add_task(function* test_unregister_invalid_json() {
pushEndpoint: 'https://example.org/update/1',
scope: 'https://example.edu/page/1',
originAttributes: '',
version: 1
version: 1,
quota: Infinity,
}, {
channelID: '057caa8f-9b99-47ff-891c-adad18ce603e',
pushEndpoint: 'https://example.com/update/2',
scope: 'https://example.net/page/1',
originAttributes: '',
version: 1
version: 1,
quota: Infinity,
}];
for (let record of records) {
yield db.put(record);

Просмотреть файл

@ -30,7 +30,7 @@ add_task(function* test_unregister_not_found() {
let promise = PushNotificationService.unregister(
'https://example.net/nonexistent',
{ appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inBrowser: false });
ChromeUtils.originAttributesToSuffix({ appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inBrowser: false }));
yield rejects(promise, function(error) {
return error == 'NotFoundError';
}, 'Wrong error for nonexistent scope');

Просмотреть файл

@ -21,7 +21,8 @@ add_task(function* test_unregister_success() {
pushEndpoint: 'https://example.org/update/unregister-success',
scope: 'https://example.com/page/unregister-success',
originAttributes: '',
version: 1
version: 1,
quota: Infinity,
});
let unregisterDefer = Promise.defer();

Просмотреть файл

@ -55,6 +55,7 @@ add_task(function* test_pushUnsubscriptionSuccess() {
pushReceiptEndpoint: serverURL + '/receiptPushEndpointUnsubscriptionSuccess',
scope: 'https://example.com/page/unregister-success',
originAttributes: ChromeUtils.originAttributesToSuffix({ appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inBrowser: false }),
quota: Infinity,
});
PushService.init({

Просмотреть файл

@ -26,7 +26,6 @@ add_task(function* test_webapps_cleardata() {
let db = PushServiceWebSocket.newPushDB();
do_register_cleanup(() => {return db.drop().then(_ => db.close());});
PushService._generateID = () => channelID;
PushService.init({
serverURI: "wss://push.example.org",
networkInfo: new MockDesktopNetworkInfo(),

Просмотреть файл

@ -9,6 +9,8 @@ skip-if = toolkit == 'android'
[test_notification_error.js]
[test_notification_incomplete.js]
[test_notification_version_string.js]
[test_quota_exceeded.js]
[test_quota_observer.js]
[test_register_case.js]
[test_register_flush.js]
[test_register_invalid_channel.js]

Просмотреть файл

@ -4409,6 +4409,10 @@ pref("dom.push.debug", false);
pref("dom.push.serverURL", "wss://push.services.mozilla.com/");
pref("dom.push.userAgentID", "");
// The maximum number of notifications that a service worker can receive
// without user interaction.
pref("dom.push.maxQuotaPerSubscription", 16);
// Is the network connection allowed to be up?
// This preference should be used in UX to enable/disable push.
pref("dom.push.connection.enabled", true);