diff --git a/extensions/chromium/pdfHandler.html b/extensions/chromium/pdfHandler.html
index e412ca1ae..728a038c3 100644
--- a/extensions/chromium/pdfHandler.html
+++ b/extensions/chromium/pdfHandler.html
@@ -22,3 +22,4 @@ limitations under the License.
+
diff --git a/extensions/chromium/preferences_schema.json b/extensions/chromium/preferences_schema.json
index 921ef28df..c4ea94889 100644
--- a/extensions/chromium/preferences_schema.json
+++ b/extensions/chromium/preferences_schema.json
@@ -88,6 +88,12 @@
4
],
"default": 0
+ },
+ "disableTelemetry": {
+ "title": "Disable telemetry",
+ "type": "boolean",
+ "description": "Whether to prevent the extension from reporting the extension and browser version to the extension developers.",
+ "default": false
}
}
}
diff --git a/extensions/chromium/telemetry.js b/extensions/chromium/telemetry.js
new file mode 100644
index 000000000..04061681d
--- /dev/null
+++ b/extensions/chromium/telemetry.js
@@ -0,0 +1,162 @@
+/*
+Copyright 2016 Mozilla Foundation
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+/* globals chrome, crypto, Headers, Request */
+
+(function() {
+ 'use strict';
+ // This module sends the browser and extension version to a server, to
+ // determine whether it is safe to drop support for old Chrome versions in
+ // future extension updates.
+ //
+ // The source code for the server is available at:
+ // https://github.com/Rob--W/pdfjs-telemetry
+ var LOG_URL = 'https://pdfjs.robwu.nl/logpdfjs';
+
+ // The minimum time to wait before sending a ping, so that we don't send too
+ // many requests even if the user restarts their browser very often.
+ // We want one ping a day, so a minimum delay of 12 hours should be OK.
+ var MINIMUM_TIME_BETWEEN_PING = 12 * 36E5;
+
+ if (chrome.extension.inIncognitoContext) {
+ // The extension uses incognito split mode, so there are two background
+ // pages. Only send telemetry when not in incognito mode.
+ return;
+ }
+
+ if (chrome.runtime.id !== 'oemmndcbldboiebfnladdacbdfmadadm') {
+ // Only send telemetry for the official PDF.js extension.
+ console.warn('Disabled telemetry because this is not an official build.');
+ return;
+ }
+
+ maybeSendPing();
+ setInterval(maybeSendPing, 36E5);
+
+ function maybeSendPing() {
+ getLoggingPref(function(didOptOut) {
+ if (didOptOut) {
+ // Respect the user's decision to not send statistics.
+ return;
+ }
+ if (!navigator.onLine) {
+ // No network available; Wait until the next scheduled ping opportunity.
+ // Even if onLine is true, the server may still be unreachable. But
+ // because it is impossible to tell whether a request failed due to the
+ // inability to connect, or a deliberate connection termination by the
+ // server, we don't validate the response and assume that the request
+ // succeeded. This ensures that the server cannot ask the client to
+ // send more pings.
+ return;
+ }
+ var lastTime = parseInt(localStorage.telemetryLastTime) || 0;
+ var wasUpdated = didUpdateSinceLastCheck();
+ if (!wasUpdated && Date.now() - lastTime < MINIMUM_TIME_BETWEEN_PING) {
+ return;
+ }
+ localStorage.telemetryLastTime = Date.now();
+
+ var deduplication_id = getDeduplicationId(wasUpdated);
+ var extension_version = chrome.runtime.getManifest().version;
+ if (window.Request && 'mode' in Request.prototype) {
+ // fetch is supported in extensions since Chrome 42 (though the above
+ // feature-detection method detects selects Chrome 43+).
+ // Unlike XMLHttpRequest, fetch omits credentials such as cookies in the
+ // requests, which guarantees that the server cannot track the client
+ // via HTTP cookies.
+ fetch(LOG_URL, {
+ method: 'POST',
+ headers: new Headers({
+ 'Deduplication-Id': deduplication_id,
+ 'Extension-Version': extension_version,
+ }),
+ // Set mode=cors so that the above custom headers are included in the
+ // request.
+ mode: 'cors',
+ });
+ return;
+ }
+ var x = new XMLHttpRequest();
+ x.open('POST', LOG_URL);
+ x.setRequestHeader('Deduplication-Id', deduplication_id);
+ x.setRequestHeader('Extension-Version', extension_version);
+ x.send();
+ });
+ }
+
+ /**
+ * Generate a 40-bit hexadecimal string (=10 letters, 1.1E12 possibilities).
+ * This is used by the server to discard duplicate entries of the same browser
+ * version when the log data is aggregated.
+ */
+ function getDeduplicationId(wasUpdated) {
+ var id = localStorage.telemetryDeduplicationId;
+ // The ID is only used to deduplicate reports for the same browser version,
+ // so it is OK to change the ID if the browser is updated. By changing the
+ // ID, the server cannot track users for a long period even if it wants to.
+ if (!id || !/^[0-9a-f]{10}$/.test(id) || wasUpdated) {
+ id = '';
+ var buf = new Uint8Array(5);
+ crypto.getRandomValues(buf);
+ for (var i = 0; i < buf.length; ++i) {
+ var c = buf[i];
+ id += (c >>> 4).toString(16) + (c & 0xF).toString(16);
+ }
+ localStorage.telemetryDeduplicationId = id;
+ }
+ return id;
+ }
+
+ /**
+ * Returns whether the browser has received a major update since the last call
+ * to this function.
+ */
+ function didUpdateSinceLastCheck() {
+ var chromeVersion = /Chrome\/(\d+)\./.exec(navigator.userAgent);
+ chromeVersion = chromeVersion && chromeVersion[1];
+ if (!chromeVersion || localStorage.telemetryLastVersion === chromeVersion) {
+ return false;
+ }
+ localStorage.telemetryLastVersion = chromeVersion;
+ return true;
+ }
+
+ /**
+ * Get the value of the telemetry preference. The callback is invoked with a
+ * boolean if a preference is found, and with the undefined value otherwise.
+ */
+ function getLoggingPref(callback) {
+ // Try to look up the preference in the storage, in the following order:
+ var areas = ['sync', 'local', 'managed'];
+
+ next();
+ function next(result) {
+ var storageAreaName = areas.shift();
+ if (typeof result === 'boolean' || !storageAreaName) {
+ callback(result);
+ return;
+ }
+
+ if (!chrome.storage[storageAreaName]) {
+ next();
+ return;
+ }
+
+ chrome.storage[storageAreaName].get('disableTelemetry', function(items) {
+ next(items && items.disableTelemetry);
+ });
+ }
+ }
+})();
diff --git a/gulpfile.js b/gulpfile.js
index 0ac9dc1dc..b13865343 100644
--- a/gulpfile.js
+++ b/gulpfile.js
@@ -79,6 +79,13 @@ function checkChromePreferencesFile(chromePrefsPath, webPrefsPath) {
var webPrefs = JSON.parse(fs.readFileSync(webPrefsPath).toString());
var webPrefsKeys = Object.keys(webPrefs);
webPrefsKeys.sort();
+ var telemetryIndex = chromePrefsKeys.indexOf('disableTelemetry');
+ if (telemetryIndex >= 0) {
+ chromePrefsKeys.splice(telemetryIndex, 1);
+ } else {
+ console.log('Warning: disableTelemetry key not found in chrome prefs!');
+ return false;
+ }
if (webPrefsKeys.length !== chromePrefsKeys.length) {
return false;
}
diff --git a/test/chromium/test-telemetry.js b/test/chromium/test-telemetry.js
new file mode 100755
index 000000000..a290c0866
--- /dev/null
+++ b/test/chromium/test-telemetry.js
@@ -0,0 +1,411 @@
+#!/usr/bin/env node
+/* Copyright 2016 Mozilla Foundation
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+'use strict';
+
+var assert = require('assert');
+var fs = require('fs');
+var vm = require('vm');
+
+var SRC_DIR = __dirname + '/../../';
+var telemetryJsPath = 'extensions/chromium/telemetry.js';
+var telemetryJsSource = fs.readFileSync(SRC_DIR + telemetryJsPath);
+var telemetryScript = new vm.Script(telemetryJsSource, {
+ filename: telemetryJsPath,
+ displayErrors: true,
+});
+var LOG_URL = /LOG_URL = '([^']+)'/.exec(telemetryJsSource)[1];
+
+// Create a minimal extension global that mocks the extension environment that
+// is used by telemetry.js
+function createExtensionGlobal() {
+ var window = {};
+
+ // Whenever a "request" was made, the extra headers are appended to this list.
+ var test_requests = window.test_requests = [];
+
+ // Extension API mocks.
+ window.window = window;
+ window.chrome = {};
+ window.chrome.extension = {};
+ window.chrome.extension.inIncognitoContext = false;
+ window.chrome.runtime = {};
+ window.chrome.runtime.id = 'oemmndcbldboiebfnladdacbdfmadadm';
+ window.chrome.runtime.getManifest = function() {
+ return {version: '1.0.0'};
+ };
+
+ function createStorageAPI() {
+ var storageArea = {};
+ storageArea.get = function(key, callback) {
+ assert.equal(key, 'disableTelemetry');
+ // chrome.storage.*. is async, but we make it synchronous to ease testing.
+ callback(storageArea.mock_data);
+ };
+ return storageArea;
+ }
+ window.chrome.storage = {};
+ window.chrome.storage.managed = createStorageAPI();
+ window.chrome.storage.local = createStorageAPI();
+ window.chrome.storage.sync = createStorageAPI();
+
+ // Other DOM.
+ window.navigator = {};
+ window.navigator.onLine = true;
+ window.navigator.userAgent =
+ 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) ' +
+ 'Chrome/50.0.2661.94 Safari/537.36';
+ window.localStorage = {};
+
+ var getRandomValues_state = 0;
+ window.crypto = {};
+ window.crypto.getRandomValues = function(buf) {
+ var state = getRandomValues_state++;
+ for (var i = 0; i < buf.length; ++i) {
+ // Totally random byte ;)
+ buf[i] = 0x42 + state;
+ }
+ return buf;
+ };
+
+ // Network-related mocks.
+ window.Request = {};
+ window.Request.prototype = {
+ get mode() { throw new TypeError('Illegal invocation'); },
+ };
+ window.fetch = function(url, options) {
+ assert.equal(url, LOG_URL);
+ assert.equal(options.method, 'POST');
+ assert.equal(options.mode, 'cors');
+ assert.ok(!options.body);
+ test_requests.push(options.headers);
+ };
+ window.Headers = function(headers) {
+ headers = JSON.parse(JSON.stringify(headers)); // Clone.
+ Object.keys(headers).forEach(function(k) {
+ headers[k] = String(headers[k]);
+ });
+ return headers;
+ };
+ window.XMLHttpRequest = function() {
+ var invoked = {
+ open: false,
+ send: false,
+ };
+ var headers = {};
+ return {
+ open: function(method, url) {
+ assert.equal(invoked.open, false);
+ invoked.open = true;
+ assert.equal(method, 'POST');
+ assert.equal(url, LOG_URL);
+ },
+ setRequestHeader: function(k, v) {
+ assert.equal(invoked.open, true);
+ headers[k] = String(v);
+ },
+ send: function(body) {
+ assert.equal(invoked.open, true);
+ assert.equal(invoked.send, false);
+ invoked.send = true;
+ assert.ok(!body);
+ test_requests.push(headers);
+ },
+ };
+ };
+
+ // Time-related logic.
+ var timers = [];
+ window.setInterval = function(callback, ms) {
+ assert.equal(typeof callback, 'function');
+ timers.push(callback);
+ };
+ window.Date = {
+ test_now_value: Date.now(),
+ now: function() {
+ return window.Date.test_now_value;
+ },
+ };
+ window.test_fireTimers = function() {
+ assert.ok(timers.length);
+ timers.forEach(function(timer) {
+ timer();
+ });
+ };
+
+ return window;
+}
+
+// Simulate an update of the browser.
+function updateBrowser(window) {
+ window.navigator.userAgent =
+ window.navigator.userAgent.replace(/Chrome\/(\d+)/, function(_, v) {
+ return 'Chrome/' + (parseInt(v) + 1);
+ });
+}
+
+var tests = [
+ function test_first_run() {
+ // Default settings, run extension for the first time.
+ var window = createExtensionGlobal();
+ telemetryScript.runInNewContext(window);
+ assert.deepEqual(window.test_requests, [{
+ 'Deduplication-Id': '4242424242',
+ 'Extension-Version': '1.0.0',
+ }]);
+ },
+
+ function test_first_run_incognito() {
+ // The extension should not send any requests when in incognito mode.
+ var window = createExtensionGlobal();
+ window.chrome.extension.inIncognitoContext = true;
+ telemetryScript.runInNewContext(window);
+ assert.deepEqual(window.test_requests, []);
+ },
+
+ function test_storage_managed_unavailable() {
+ var window = createExtensionGlobal();
+ delete window.chrome.storage.managed;
+ telemetryScript.runInNewContext(window);
+ assert.deepEqual(window.test_requests, [{
+ 'Deduplication-Id': '4242424242',
+ 'Extension-Version': '1.0.0',
+ }]);
+ },
+
+ function test_managed_pref() {
+ var window = createExtensionGlobal();
+ window.chrome.storage.managed.mock_data = {
+ disableTelemetry: true,
+ };
+ telemetryScript.runInNewContext(window);
+ assert.deepEqual(window.test_requests, []);
+ },
+
+ function test_local_pref() {
+ var window = createExtensionGlobal();
+ window.chrome.storage.local.mock_data = {
+ disableTelemetry: true,
+ };
+ telemetryScript.runInNewContext(window);
+ assert.deepEqual(window.test_requests, []);
+ },
+
+ function test_managed_pref_is_overridden() {
+ var window = createExtensionGlobal();
+ window.chrome.storage.managed.mock_data = {
+ disableTelemetry: true,
+ };
+ window.chrome.storage.sync.mock_data = {
+ disableTelemetry: false,
+ };
+ telemetryScript.runInNewContext(window);
+ assert.deepEqual(window.test_requests, [{
+ 'Deduplication-Id': '4242424242',
+ 'Extension-Version': '1.0.0',
+ }]);
+ },
+
+ function test_run_extension_again() {
+ var window = createExtensionGlobal();
+ telemetryScript.runInNewContext(window);
+ telemetryScript.runInNewContext(window);
+ // Only one request should be sent because of rate-limiting.
+ assert.deepEqual(window.test_requests, [{
+ 'Deduplication-Id': '4242424242',
+ 'Extension-Version': '1.0.0',
+ }]);
+
+ // Simulate that quite some hours passed, but it's still rate-limited.
+ window.Date.test_now_value += 11 * 36E5;
+ telemetryScript.runInNewContext(window);
+ // Only one request should be sent because of rate-limiting.
+ assert.deepEqual(window.test_requests, [{
+ 'Deduplication-Id': '4242424242',
+ 'Extension-Version': '1.0.0',
+ }]);
+
+ // Another hour passes and the request should not be rate-limited any more.
+ window.Date.test_now_value += 1 * 36E5;
+ telemetryScript.runInNewContext(window);
+ assert.deepEqual(window.test_requests, [{
+ 'Deduplication-Id': '4242424242',
+ 'Extension-Version': '1.0.0',
+ }, {
+ 'Deduplication-Id': '4242424242',
+ 'Extension-Version': '1.0.0',
+ }]);
+ },
+
+ function test_running_for_a_while() {
+ var window = createExtensionGlobal();
+ telemetryScript.runInNewContext(window);
+ assert.deepEqual(window.test_requests, [{
+ 'Deduplication-Id': '4242424242',
+ 'Extension-Version': '1.0.0',
+ }]);
+
+ // Simulate that the timer fired 11 hours since the last ping. The request
+ // should still be rate-limited.
+ window.Date.test_now_value += 11 * 36E5;
+ window.test_fireTimers();
+ assert.deepEqual(window.test_requests, [{
+ 'Deduplication-Id': '4242424242',
+ 'Extension-Version': '1.0.0',
+ }]);
+
+ // Another hour passes and the request should not be rate-limited any more.
+ window.Date.test_now_value += 1 * 36E5;
+ window.test_fireTimers();
+ assert.deepEqual(window.test_requests, [{
+ 'Deduplication-Id': '4242424242',
+ 'Extension-Version': '1.0.0',
+ }, {
+ 'Deduplication-Id': '4242424242',
+ 'Extension-Version': '1.0.0',
+ }]);
+ },
+
+ function test_browser_update() {
+ var window = createExtensionGlobal();
+ telemetryScript.runInNewContext(window);
+ updateBrowser(window);
+ telemetryScript.runInNewContext(window);
+ assert.deepEqual(window.test_requests, [{
+ 'Deduplication-Id': '4242424242',
+ 'Extension-Version': '1.0.0',
+ }, {
+ // Generate a new ID for better privacy.
+ 'Deduplication-Id': '4343434343',
+ 'Extension-Version': '1.0.0',
+ }]);
+ },
+
+ function test_browser_update_between_pref_toggle() {
+ var window = createExtensionGlobal();
+ telemetryScript.runInNewContext(window);
+ window.chrome.storage.local.mock_data = {
+ disableTelemetry: true,
+ };
+ updateBrowser(window);
+ telemetryScript.runInNewContext(window);
+ window.chrome.storage.local.mock_data = {
+ disableTelemetry: false,
+ };
+ telemetryScript.runInNewContext(window);
+ assert.deepEqual(window.test_requests, [{
+ 'Deduplication-Id': '4242424242',
+ 'Extension-Version': '1.0.0',
+ }, {
+ // Generate a new ID for better privacy, even if the update happened
+ // while telemetry was disabled.
+ 'Deduplication-Id': '4343434343',
+ 'Extension-Version': '1.0.0',
+ }]);
+ },
+
+ function test_extension_update() {
+ var window = createExtensionGlobal();
+ telemetryScript.runInNewContext(window);
+ window.chrome.runtime.getManifest = function() {
+ return {version: '1.0.1'};
+ };
+ window.Date.test_now_value += 12 * 36E5;
+ telemetryScript.runInNewContext(window);
+ assert.deepEqual(window.test_requests, [{
+ 'Deduplication-Id': '4242424242',
+ 'Extension-Version': '1.0.0',
+ }, {
+ // The ID did not change because the browser version did not change.
+ 'Deduplication-Id': '4242424242',
+ 'Extension-Version': '1.0.1',
+ }]);
+ },
+
+ function test_unofficial_build() {
+ var window = createExtensionGlobal();
+ var didWarn = false;
+ window.console = {};
+ window.console.warn = function() { didWarn = true; };
+ window.chrome.runtime.id = 'abcdefghijklmnopabcdefghijklmnop';
+ telemetryScript.runInNewContext(window);
+ assert.deepEqual(window.test_requests, []);
+ assert.ok(didWarn);
+ },
+
+ function test_fetch_is_supported() {
+ var window = createExtensionGlobal();
+ // XMLHttpRequest should not be called when fetch is available. So removing
+ // the XMLHttpRequest API should not change behavior.
+ delete window.XMLHttpRequest;
+ telemetryScript.runInNewContext(window);
+ assert.deepEqual(window.test_requests, [{
+ 'Deduplication-Id': '4242424242',
+ 'Extension-Version': '1.0.0',
+ }]);
+ },
+
+ function test_fetch_not_supported() {
+ var window = createExtensionGlobal();
+ delete window.fetch;
+ delete window.Request;
+ delete window.Headers;
+ telemetryScript.runInNewContext(window);
+ assert.deepEqual(window.test_requests, [{
+ 'Deduplication-Id': '4242424242',
+ 'Extension-Version': '1.0.0',
+ }]);
+ },
+
+ function test_fetch_mode_not_supported() {
+ var window = createExtensionGlobal();
+ delete window.Request.prototype.mode;
+ window.fetch = function() { throw new Error('Unexpected call to fetch!'); };
+ telemetryScript.runInNewContext(window);
+ assert.deepEqual(window.test_requests, [{
+ 'Deduplication-Id': '4242424242',
+ 'Extension-Version': '1.0.0',
+ }]);
+ },
+
+ function test_network_offline() {
+ var window = createExtensionGlobal();
+ // Simulate that the network is down for sure.
+ window.navigator.onLine = false;
+ telemetryScript.runInNewContext(window);
+ assert.deepEqual(window.test_requests, []);
+
+ // Simulate that the network might be up.
+ window.navigator.onLine = true;
+ telemetryScript.runInNewContext(window);
+ assert.deepEqual(window.test_requests, [{
+ 'Deduplication-Id': '4242424242',
+ 'Extension-Version': '1.0.0',
+ }]);
+ },
+];
+var test_i = 0;
+
+(function next() {
+ var test = tests[test_i++];
+ if (!test) {
+ console.log('All tests completed.');
+ return;
+ }
+ console.log('Running test ' + test_i + '/' + tests.length + ': ' + test.name);
+ test();
+ process.nextTick(next);
+})();