summaryrefslogtreecommitdiffstats
path: root/browser/extensions/screenshots/background
diff options
context:
space:
mode:
Diffstat (limited to 'browser/extensions/screenshots/background')
-rw-r--r--browser/extensions/screenshots/background/analytics.js367
-rw-r--r--browser/extensions/screenshots/background/auth.js224
-rw-r--r--browser/extensions/screenshots/background/communication.js53
-rw-r--r--browser/extensions/screenshots/background/deviceInfo.js38
-rw-r--r--browser/extensions/screenshots/background/main.js291
-rw-r--r--browser/extensions/screenshots/background/selectorLoader.js134
-rw-r--r--browser/extensions/screenshots/background/senderror.js154
-rw-r--r--browser/extensions/screenshots/background/startBackground.js135
-rw-r--r--browser/extensions/screenshots/background/takeshot.js179
9 files changed, 1575 insertions, 0 deletions
diff --git a/browser/extensions/screenshots/background/analytics.js b/browser/extensions/screenshots/background/analytics.js
new file mode 100644
index 0000000000..37049e3b77
--- /dev/null
+++ b/browser/extensions/screenshots/background/analytics.js
@@ -0,0 +1,367 @@
+/* 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/. */
+
+/* globals main, auth, browser, catcher, deviceInfo, communication, log */
+
+"use strict";
+
+this.analytics = (function() {
+ const exports = {};
+
+ const GA_PORTION = 0.1; // 10% of users will send to the server/GA
+ // This is set from storage, or randomly; if it is less that GA_PORTION then we send analytics:
+ let myGaSegment = 1;
+ let telemetryPrefKnown = false;
+ let telemetryEnabled;
+ // If we ever get a 410 Gone response (or 404) from the server, we'll stop trying to send events for the rest
+ // of the session
+ let hasReturnedGone = false;
+ // If there's this many entirely failed responses (e.g., server can't be contacted), then stop sending events
+ // for the rest of the session:
+ let serverFailedResponses = 3;
+
+ const EVENT_BATCH_DURATION = 1000; // ms for setTimeout
+ let pendingEvents = [];
+ let pendingTimings = [];
+ let eventsTimeoutHandle, timingsTimeoutHandle;
+ const fetchOptions = {
+ method: "POST",
+ mode: "cors",
+ headers: { "content-type": "application/json" },
+ credentials: "include",
+ };
+
+ function shouldSendEvents() {
+ return !hasReturnedGone && serverFailedResponses > 0 && myGaSegment < GA_PORTION;
+ }
+
+ function flushEvents() {
+ if (pendingEvents.length === 0) {
+ return;
+ }
+
+ const eventsUrl = `${main.getBackend()}/event`;
+ const deviceId = auth.getDeviceId();
+ const sendTime = Date.now();
+
+ pendingEvents.forEach(event => {
+ event.queueTime = sendTime - event.eventTime;
+ log.info(`sendEvent ${event.event}/${event.action}/${event.label || "none"} ${JSON.stringify(event.options)}`);
+ });
+
+ const body = JSON.stringify({deviceId, events: pendingEvents});
+ const fetchRequest = fetch(eventsUrl, Object.assign({body}, fetchOptions));
+ fetchWatcher(fetchRequest);
+ pendingEvents = [];
+ }
+
+ function flushTimings() {
+ if (pendingTimings.length === 0) {
+ return;
+ }
+
+ const timingsUrl = `${main.getBackend()}/timing`;
+ const deviceId = auth.getDeviceId();
+ const body = JSON.stringify({deviceId, timings: pendingTimings});
+ const fetchRequest = fetch(timingsUrl, Object.assign({body}, fetchOptions));
+ fetchWatcher(fetchRequest);
+ pendingTimings.forEach(t => {
+ log.info(`sendTiming ${t.timingCategory}/${t.timingLabel}/${t.timingVar}: ${t.timingValue}`);
+ });
+ pendingTimings = [];
+ }
+
+ function sendTiming(timingLabel, timingVar, timingValue) {
+ // sendTiming is only called in response to sendEvent, so no need to check
+ // the telemetry pref again here.
+ if (!shouldSendEvents()) {
+ return;
+ }
+ const timingCategory = "addon";
+ pendingTimings.push({
+ timingCategory,
+ timingLabel,
+ timingVar,
+ timingValue,
+ });
+ if (!timingsTimeoutHandle) {
+ timingsTimeoutHandle = setTimeout(() => {
+ timingsTimeoutHandle = null;
+ flushTimings();
+ }, EVENT_BATCH_DURATION);
+ }
+ }
+
+ exports.sendEvent = function(action, label, options) {
+ const eventCategory = "addon";
+ if (!telemetryPrefKnown) {
+ log.warn("sendEvent called before we were able to refresh");
+ return Promise.resolve();
+ }
+ if (!telemetryEnabled) {
+ log.info(`Cancelled sendEvent ${eventCategory}/${action}/${label || "none"} ${JSON.stringify(options)}`);
+ return Promise.resolve();
+ }
+ measureTiming(action, label);
+ // Internal-only events are used for measuring time between events,
+ // but aren't submitted to GA.
+ if (action === "internal") {
+ return Promise.resolve();
+ }
+ if (typeof label === "object" && (!options)) {
+ options = label;
+ label = undefined;
+ }
+ options = options || {};
+
+ // Don't send events if in private browsing.
+ if (options.incognito) {
+ return Promise.resolve();
+ }
+
+ // Don't include in event data.
+ delete options.incognito;
+
+ const di = deviceInfo();
+ options.applicationName = di.appName;
+ options.applicationVersion = di.addonVersion;
+ const abTests = auth.getAbTests();
+ for (const [gaField, value] of Object.entries(abTests)) {
+ options[gaField] = value;
+ }
+ if (!shouldSendEvents()) {
+ // We don't want to save or send the events anymore
+ return Promise.resolve();
+ }
+ pendingEvents.push({
+ eventTime: Date.now(),
+ event: eventCategory,
+ action,
+ label,
+ options,
+ });
+ if (!eventsTimeoutHandle) {
+ eventsTimeoutHandle = setTimeout(() => {
+ eventsTimeoutHandle = null;
+ flushEvents();
+ }, EVENT_BATCH_DURATION);
+ }
+ // This function used to return a Promise that was not used at any of the
+ // call sites; doing this simply maintains that interface.
+ return Promise.resolve();
+ };
+
+ exports.incrementCount = function(scalar) {
+ const allowedScalars = ["download", "upload", "copy"];
+ if (!allowedScalars.includes(scalar)) {
+ const err = `incrementCount passed an unrecognized scalar ${scalar}`;
+ log.warn(err);
+ return Promise.resolve();
+ }
+ return browser.telemetry.scalarAdd(`screenshots.${scalar}`, 1).catch(err => {
+ log.warn(`incrementCount failed with error: ${err}`);
+ });
+ };
+
+ exports.refreshTelemetryPref = function() {
+ return browser.telemetry.canUpload().then((result) => {
+ telemetryPrefKnown = true;
+ telemetryEnabled = result;
+ }, (error) => {
+ // If there's an error reading the pref, we should assume that we shouldn't send data
+ telemetryPrefKnown = true;
+ telemetryEnabled = false;
+ throw error;
+ });
+ };
+
+ exports.isTelemetryEnabled = function() {
+ catcher.watchPromise(exports.refreshTelemetryPref());
+ return telemetryEnabled;
+ };
+
+ const timingData = new Map();
+
+ // Configuration for filtering the sendEvent stream on start/end events.
+ // When start or end events occur, the time is recorded.
+ // When end events occur, the elapsed time is calculated and submitted
+ // via `sendEvent`, where action = "perf-response-time", label = name of rule,
+ // and cd1 value is the elapsed time in milliseconds.
+ // If a cancel event happens between the start and end events, the start time
+ // is deleted.
+ const rules = [{
+ name: "page-action",
+ start: { action: "start-shot", label: "toolbar-button" },
+ end: { action: "internal", label: "unhide-preselection-frame" },
+ cancel: [
+ { action: "cancel-shot" },
+ { action: "internal", label: "document-hidden" },
+ { action: "internal", label: "unhide-onboarding-frame" },
+ ],
+ }, {
+ name: "context-menu",
+ start: { action: "start-shot", label: "context-menu" },
+ end: { action: "internal", label: "unhide-preselection-frame" },
+ cancel: [
+ { action: "cancel-shot" },
+ { action: "internal", label: "document-hidden" },
+ { action: "internal", label: "unhide-onboarding-frame" },
+ ],
+ }, {
+ name: "page-action-onboarding",
+ start: { action: "start-shot", label: "toolbar-button" },
+ end: { action: "internal", label: "unhide-onboarding-frame" },
+ cancel: [
+ { action: "cancel-shot" },
+ { action: "internal", label: "document-hidden" },
+ { action: "internal", label: "unhide-preselection-frame" },
+ ],
+ }, {
+ name: "context-menu-onboarding",
+ start: { action: "start-shot", label: "context-menu" },
+ end: { action: "internal", label: "unhide-onboarding-frame" },
+ cancel: [
+ { action: "cancel-shot" },
+ { action: "internal", label: "document-hidden" },
+ { action: "internal", label: "unhide-preselection-frame" },
+ ],
+ }, {
+ name: "capture-full-page",
+ start: { action: "capture-full-page" },
+ end: { action: "internal", label: "unhide-preview-frame" },
+ cancel: [
+ { action: "cancel-shot" },
+ { action: "internal", label: "document-hidden" },
+ ],
+ }, {
+ name: "capture-visible",
+ start: { action: "capture-visible" },
+ end: { action: "internal", label: "unhide-preview-frame" },
+ cancel: [
+ { action: "cancel-shot" },
+ { action: "internal", label: "document-hidden" },
+ ],
+ }, {
+ name: "make-selection",
+ start: { action: "make-selection" },
+ end: { action: "internal", label: "unhide-selection-frame" },
+ cancel: [
+ { action: "cancel-shot" },
+ { action: "internal", label: "document-hidden" },
+ ],
+ }, {
+ name: "save-shot",
+ start: { action: "save-shot" },
+ end: { action: "internal", label: "open-shot-tab" },
+ cancel: [{ action: "cancel-shot" }, { action: "upload-failed" }],
+ }, {
+ name: "save-visible",
+ start: { action: "save-visible" },
+ end: { action: "internal", label: "open-shot-tab" },
+ cancel: [{ action: "cancel-shot" }, { action: "upload-failed" }],
+ }, {
+ name: "save-full-page",
+ start: { action: "save-full-page" },
+ end: { action: "internal", label: "open-shot-tab" },
+ cancel: [{ action: "cancel-shot" }, { action: "upload-failed" }],
+ }, {
+ name: "save-full-page-truncated",
+ start: { action: "save-full-page-truncated" },
+ end: { action: "internal", label: "open-shot-tab" },
+ cancel: [{ action: "cancel-shot" }, { action: "upload-failed" }],
+ }, {
+ name: "download-shot",
+ start: { action: "download-shot" },
+ end: { action: "internal", label: "deactivate" },
+ cancel: [
+ { action: "cancel-shot" },
+ { action: "internal", label: "document-hidden" },
+ ],
+ }, {
+ name: "download-full-page",
+ start: { action: "download-full-page" },
+ end: { action: "internal", label: "deactivate" },
+ cancel: [
+ { action: "cancel-shot" },
+ { action: "internal", label: "document-hidden" },
+ ],
+ }, {
+ name: "download-full-page-truncated",
+ start: { action: "download-full-page-truncated" },
+ end: { action: "internal", label: "deactivate" },
+ cancel: [
+ { action: "cancel-shot" },
+ { action: "internal", label: "document-hidden" },
+ ],
+ }, {
+ name: "download-visible",
+ start: { action: "download-visible" },
+ end: { action: "internal", label: "deactivate" },
+ cancel: [
+ { action: "cancel-shot" },
+ { action: "internal", label: "document-hidden" },
+ ],
+ }];
+
+ // Match a filter (action and optional label) against an action and label.
+ function match(filter, action, label) {
+ return filter.label ?
+ filter.action === action && filter.label === label :
+ filter.action === action;
+ }
+
+ function anyMatches(filters, action, label) {
+ return filters.some(filter => match(filter, action, label));
+ }
+
+ function measureTiming(action, label) {
+ rules.forEach(r => {
+ if (anyMatches(r.cancel, action, label)) {
+ delete timingData[r.name];
+ } else if (match(r.start, action, label)) {
+ timingData[r.name] = Math.round(performance.now());
+ } else if (timingData[r.name] && match(r.end, action, label)) {
+ const endTime = Math.round(performance.now());
+ const elapsed = endTime - timingData[r.name];
+ sendTiming("perf-response-time", r.name, elapsed);
+ delete timingData[r.name];
+ }
+ });
+ }
+
+ function fetchWatcher(request) {
+ request.then(response => {
+ if (response.status === 410 || response.status === 404) { // Gone
+ hasReturnedGone = true;
+ pendingEvents = [];
+ pendingTimings = [];
+ }
+ if (!response.ok) {
+ log.debug(`Error code in event response: ${response.status} ${response.statusText}`);
+ }
+ }).catch(error => {
+ serverFailedResponses--;
+ if (serverFailedResponses <= 0) {
+ log.info(`Server is not responding, no more events will be sent`);
+ pendingEvents = [];
+ pendingTimings = [];
+ }
+ log.debug(`Error event in response: ${error}`);
+ });
+ }
+
+ async function init() {
+ const result = await browser.storage.local.get(["myGaSegment"]);
+ if (!result.myGaSegment) {
+ myGaSegment = Math.random();
+ await browser.storage.local.set({myGaSegment});
+ } else {
+ myGaSegment = result.myGaSegment;
+ }
+ }
+
+ init();
+
+ return exports;
+})();
diff --git a/browser/extensions/screenshots/background/auth.js b/browser/extensions/screenshots/background/auth.js
new file mode 100644
index 0000000000..f6cfd0f9aa
--- /dev/null
+++ b/browser/extensions/screenshots/background/auth.js
@@ -0,0 +1,224 @@
+/* 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/. */
+
+/* globals log */
+/* globals main, makeUuid, deviceInfo, analytics, catcher, buildSettings, communication */
+
+"use strict";
+
+this.auth = (function() {
+ const exports = {};
+
+ let registrationInfo;
+ let initialized = false;
+ let authHeader = null;
+ let sentryPublicDSN = null;
+ let abTests = {};
+ let accountId = null;
+
+ const fetchStoredInfo = catcher.watchPromise(
+ browser.storage.local.get(["registrationInfo", "abTests"]).then((result) => {
+ if (result.abTests) {
+ abTests = result.abTests;
+ }
+ if (result.registrationInfo) {
+ registrationInfo = result.registrationInfo;
+ }
+ }));
+
+ function getRegistrationInfo() {
+ if (!registrationInfo) {
+ registrationInfo = generateRegistrationInfo();
+ log.info("Generating new device authentication ID", registrationInfo);
+ browser.storage.local.set({registrationInfo});
+ }
+ return registrationInfo;
+ }
+
+ exports.getDeviceId = function() {
+ return registrationInfo && registrationInfo.deviceId;
+ };
+
+ function generateRegistrationInfo() {
+ const info = {
+ deviceId: `anon${makeUuid()}`,
+ secret: makeUuid(),
+ registered: false,
+ };
+ return info;
+ }
+
+ function register() {
+ return new Promise((resolve, reject) => {
+ const registerUrl = main.getBackend() + "/api/register";
+ // TODO: replace xhr with Fetch #2261
+ const req = new XMLHttpRequest();
+ req.open("POST", registerUrl);
+ req.setRequestHeader("content-type", "application/json");
+ req.onload = catcher.watchFunction(() => {
+ if (req.status === 200) {
+ log.info("Registered login");
+ initialized = true;
+ saveAuthInfo(JSON.parse(req.responseText));
+ resolve(true);
+ analytics.sendEvent("registered");
+ } else {
+ analytics.sendEvent("register-failed", `bad-response-${req.status}`);
+ log.warn("Error in response:", req.responseText);
+ const exc = new Error("Bad response: " + req.status);
+ exc.popupMessage = "LOGIN_ERROR";
+ reject(exc);
+ }
+ });
+ req.onerror = catcher.watchFunction(() => {
+ analytics.sendEvent("register-failed", "connection-error");
+ const exc = new Error("Error contacting server");
+ exc.popupMessage = "LOGIN_CONNECTION_ERROR";
+ reject(exc);
+ });
+ req.send(JSON.stringify({
+ deviceId: registrationInfo.deviceId,
+ secret: registrationInfo.secret,
+ deviceInfo: JSON.stringify(deviceInfo()),
+ }));
+ });
+ }
+
+ function login(options) {
+ const { ownershipCheck, noRegister } = options || {};
+ return new Promise((resolve, reject) => {
+ return fetchStoredInfo.then(() => {
+ const registrationInfo = getRegistrationInfo();
+ const loginUrl = main.getBackend() + "/api/login";
+ // TODO: replace xhr with Fetch #2261
+ const req = new XMLHttpRequest();
+ req.open("POST", loginUrl);
+ req.onload = catcher.watchFunction(() => {
+ if (req.status === 404) {
+ if (noRegister) {
+ resolve(false);
+ } else {
+ resolve(register());
+ }
+ } else if (req.status >= 300) {
+ log.warn("Error in response:", req.responseText);
+ const exc = new Error("Could not log in: " + req.status);
+ exc.popupMessage = "LOGIN_ERROR";
+ analytics.sendEvent("login-failed", `bad-response-${req.status}`);
+ reject(exc);
+ } else if (req.status === 0) {
+ const error = new Error("Could not log in, server unavailable");
+ error.popupMessage = "LOGIN_CONNECTION_ERROR";
+ analytics.sendEvent("login-failed", "connection-error");
+ reject(error);
+ } else {
+ initialized = true;
+ const jsonResponse = JSON.parse(req.responseText);
+ log.info("Screenshots logged in");
+ analytics.sendEvent("login");
+ saveAuthInfo(jsonResponse);
+ if (ownershipCheck) {
+ resolve({isOwner: jsonResponse.isOwner});
+ } else {
+ resolve(true);
+ }
+ }
+ });
+ req.onerror = catcher.watchFunction(() => {
+ analytics.sendEvent("login-failed", "connection-error");
+ const exc = new Error("Connection failed");
+ exc.url = loginUrl;
+ exc.popupMessage = "CONNECTION_ERROR";
+ reject(exc);
+ });
+ req.setRequestHeader("content-type", "application/json");
+ req.send(JSON.stringify({
+ deviceId: registrationInfo.deviceId,
+ secret: registrationInfo.secret,
+ deviceInfo: JSON.stringify(deviceInfo()),
+ ownershipCheck,
+ }));
+ });
+ });
+ }
+
+ function saveAuthInfo(responseJson) {
+ accountId = responseJson.accountId;
+ if (responseJson.sentryPublicDSN) {
+ sentryPublicDSN = responseJson.sentryPublicDSN;
+ }
+ if (responseJson.authHeader) {
+ authHeader = responseJson.authHeader;
+ if (!registrationInfo.registered) {
+ registrationInfo.registered = true;
+ catcher.watchPromise(browser.storage.local.set({registrationInfo}));
+ }
+ }
+ if (responseJson.abTests) {
+ abTests = responseJson.abTests;
+ catcher.watchPromise(browser.storage.local.set({abTests}));
+ }
+ }
+
+ exports.maybeLogin = function() {
+ if (!registrationInfo) {
+ return Promise.resolve();
+ }
+
+ return exports.authHeaders();
+ };
+
+ exports.authHeaders = function() {
+ let initPromise = Promise.resolve();
+ if (!initialized) {
+ initPromise = login();
+ }
+ return initPromise.then(() => {
+ if (authHeader) {
+ return {"x-screenshots-auth": authHeader};
+ }
+ log.warn("No auth header available");
+ return {};
+ });
+ };
+
+ exports.getSentryPublicDSN = function() {
+ return sentryPublicDSN || buildSettings.defaultSentryDsn;
+ };
+
+ exports.getAbTests = function() {
+ return abTests;
+ };
+
+ exports.isRegistered = function() {
+ return registrationInfo && registrationInfo.registered;
+ };
+
+ communication.register("getAuthInfo", (sender, ownershipCheck) => {
+ return fetchStoredInfo.then(() => {
+ // If a device id was never generated, report back accordingly.
+ if (!registrationInfo) {
+ return null;
+ }
+
+ return exports.authHeaders().then((authHeaders) => {
+ let info = registrationInfo;
+ if (info.registered) {
+ return login({ownershipCheck}).then((result) => {
+ return {
+ isOwner: result && result.isOwner,
+ deviceId: registrationInfo.deviceId,
+ accountId,
+ authHeaders,
+ };
+ });
+ }
+ info = Object.assign({authHeaders}, info);
+ return info;
+ });
+ });
+});
+
+ return exports;
+})();
diff --git a/browser/extensions/screenshots/background/communication.js b/browser/extensions/screenshots/background/communication.js
new file mode 100644
index 0000000000..7db2b188f2
--- /dev/null
+++ b/browser/extensions/screenshots/background/communication.js
@@ -0,0 +1,53 @@
+/* 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/. */
+
+/* globals catcher, log */
+
+"use strict";
+
+this.communication = (function() {
+ const exports = {};
+
+ const registeredFunctions = {};
+
+ exports.onMessage = catcher.watchFunction((req, sender, sendResponse) => {
+ if (!(req.funcName in registeredFunctions)) {
+ log.error(`Received unknown internal message type ${req.funcName}`);
+ sendResponse({type: "error", name: "Unknown message type"});
+ return;
+ }
+ if (!Array.isArray(req.args)) {
+ log.error("Received message with no .args list");
+ sendResponse({type: "error", name: "No .args"});
+ return;
+ }
+ const func = registeredFunctions[req.funcName];
+ let result;
+ try {
+ req.args.unshift(sender);
+ result = func.apply(null, req.args);
+ } catch (e) {
+ log.error(`Error in ${req.funcName}:`, e, e.stack);
+ // FIXME: should consider using makeError from catcher here:
+ sendResponse({type: "error", message: e + "", errorCode: e.errorCode, popupMessage: e.popupMessage});
+ return;
+ }
+ if (result && result.then) {
+ result.then((concreteResult) => {
+ sendResponse({type: "success", value: concreteResult});
+ }).catch((errorResult) => {
+ log.error(`Promise error in ${req.funcName}:`, errorResult, errorResult && errorResult.stack);
+ sendResponse({type: "error", message: errorResult + "", errorCode: errorResult.errorCode, popupMessage: errorResult.popupMessage});
+ });
+ return;
+ }
+ sendResponse({type: "success", value: result});
+ });
+
+ exports.register = function(name, func) {
+ registeredFunctions[name] = func;
+ };
+
+ return exports;
+})();
diff --git a/browser/extensions/screenshots/background/deviceInfo.js b/browser/extensions/screenshots/background/deviceInfo.js
new file mode 100644
index 0000000000..a16580a8e4
--- /dev/null
+++ b/browser/extensions/screenshots/background/deviceInfo.js
@@ -0,0 +1,38 @@
+/* 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/. */
+
+/* globals catcher */
+
+"use strict";
+
+this.deviceInfo = (function() {
+ const manifest = browser.runtime.getManifest();
+
+ let platformInfo = {};
+ catcher.watchPromise(browser.runtime.getPlatformInfo().then((info) => {
+ platformInfo = info;
+ }));
+
+ return function deviceInfo() {
+ let match = navigator.userAgent.match(/Chrom(?:e|ium)\/([0-9.]{1,1000})/);
+ const chromeVersion = match ? match[1] : null;
+ match = navigator.userAgent.match(/Firefox\/([0-9.]{1,1000})/);
+ const firefoxVersion = match ? match[1] : null;
+ const appName = chromeVersion ? "chrome" : "firefox";
+
+ return {
+ addonVersion: manifest.version,
+ platform: platformInfo.os,
+ architecture: platformInfo.arch,
+ version: firefoxVersion || chromeVersion,
+ // These don't seem to apply to Chrome:
+ // build: system.build,
+ // platformVersion: system.platformVersion,
+ userAgent: navigator.userAgent,
+ appVendor: appName,
+ appName,
+ };
+ };
+
+})();
diff --git a/browser/extensions/screenshots/background/main.js b/browser/extensions/screenshots/background/main.js
new file mode 100644
index 0000000000..25f1cd6979
--- /dev/null
+++ b/browser/extensions/screenshots/background/main.js
@@ -0,0 +1,291 @@
+/* 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/. */
+
+/* globals selectorLoader, analytics, communication, catcher, log, makeUuid, auth, senderror, startBackground, blobConverters buildSettings */
+
+"use strict";
+
+this.main = (function() {
+ const exports = {};
+
+ const pasteSymbol = (window.navigator.platform.match(/Mac/i)) ? "\u2318" : "Ctrl";
+ const { sendEvent, incrementCount } = analytics;
+
+ const manifest = browser.runtime.getManifest();
+ let backend;
+
+ exports.hasAnyShots = function() {
+ return false;
+ };
+
+ exports.setBackend = function(newBackend) {
+ backend = newBackend;
+ backend = backend.replace(/\/*$/, "");
+ };
+
+ exports.getBackend = function() {
+ return backend;
+ };
+
+ communication.register("getBackend", () => {
+ return backend;
+ });
+
+ for (const permission of manifest.permissions) {
+ if (/^https?:\/\//.test(permission)) {
+ exports.setBackend(permission);
+ break;
+ }
+ }
+
+ function setIconActive(active, tabId) {
+ const path = active ? "icons/icon-highlight-32-v2.svg" : "icons/icon-v2.svg";
+ browser.pageAction.setIcon({tabId, path});
+ }
+
+ function toggleSelector(tab) {
+ return analytics.refreshTelemetryPref()
+ .then(() => selectorLoader.toggle(tab.id))
+ .then(active => {
+ setIconActive(active, tab.id);
+ return active;
+ })
+ .catch((error) => {
+ if (error.message && /Missing host permission for the tab/.test(error.message)) {
+ error.noReport = true;
+ }
+ error.popupMessage = "UNSHOOTABLE_PAGE";
+ throw error;
+ });
+ }
+
+ function shouldOpenMyShots(url) {
+ return /^about:(?:newtab|blank|home)/i.test(url) || /^resource:\/\/activity-streams\//i.test(url);
+ }
+
+ // This is called by startBackground.js, where is registered as a click
+ // handler for the webextension page action.
+ exports.onClicked = catcher.watchFunction((tab) => {
+ _startShotFlow(tab, "toolbar-button");
+ });
+
+ exports.onClickedContextMenu = catcher.watchFunction((info, tab) => {
+ _startShotFlow(tab, "context-menu");
+ });
+
+ exports.onCommand = catcher.watchFunction((tab) => {
+ _startShotFlow(tab, "keyboard-shortcut");
+ });
+
+ const _openMyShots = (tab, inputType) => {
+ catcher.watchPromise(analytics.refreshTelemetryPref().then(() => {
+ sendEvent("goto-myshots", inputType, {incognito: tab.incognito});
+ }));
+ catcher.watchPromise(
+ auth.maybeLogin()
+ .then(() => browser.tabs.update({url: backend + "/shots"})));
+ };
+
+ const _startShotFlow = (tab, inputType) => {
+ if (!tab) {
+ // Not in a page/tab context, ignore
+ return;
+ }
+ if (!urlEnabled(tab.url)) {
+ senderror.showError({
+ popupMessage: "UNSHOOTABLE_PAGE",
+ });
+ return;
+ } else if (shouldOpenMyShots(tab.url)) {
+ _openMyShots(tab, inputType);
+ return;
+ }
+
+ catcher.watchPromise(toggleSelector(tab)
+ .then(active => {
+ let event = "start-shot";
+ if (inputType !== "context-menu") {
+ event = active ? "start-shot" : "cancel-shot";
+ }
+ sendEvent(event, inputType, {incognito: tab.incognito});
+ }).catch((error) => {
+ throw error;
+ }));
+ };
+
+ function urlEnabled(url) {
+ if (shouldOpenMyShots(url)) {
+ return true;
+ }
+ // Allow screenshots on urls related to web pages in reader mode.
+ if (url && url.startsWith("about:reader?url=")) {
+ return true;
+ }
+ if (isShotOrMyShotPage(url) || /^(?:about|data|moz-extension):/i.test(url) || isBlacklistedUrl(url)) {
+ return false;
+ }
+ return true;
+ }
+
+ function isShotOrMyShotPage(url) {
+ // It's okay to take a shot of any pages except shot pages and My Shots
+ if (!url.startsWith(backend)) {
+ return false;
+ }
+ const path = url.substr(backend.length).replace(/^\/*/, "").replace(/[?#].*/, "");
+ if (path === "shots") {
+ return true;
+ }
+ if (/^[^/]{1,4000}\/[^/]{1,4000}$/.test(path)) {
+ // Blocks {:id}/{:domain}, but not /, /privacy, etc
+ return true;
+ }
+ return false;
+ }
+
+ function isBlacklistedUrl(url) {
+ // These specific domains are not allowed for general WebExtension permission reasons
+ // Discussion: https://bugzilla.mozilla.org/show_bug.cgi?id=1310082
+ // List of domains copied from: https://searchfox.org/mozilla-central/source/browser/app/permissions#18-19
+ // Note we disable it here to be informative, the security check is done in WebExtension code
+ const badDomains = ["testpilot.firefox.com"];
+ let domain = url.replace(/^https?:\/\//i, "");
+ domain = domain.replace(/\/.*/, "").replace(/:.*/, "");
+ domain = domain.toLowerCase();
+ return badDomains.includes(domain);
+ }
+
+ communication.register("getStrings", (sender, ids) => {
+ return getStrings(ids.map(id => ({id})));
+ });
+
+ communication.register("sendEvent", (sender, ...args) => {
+ catcher.watchPromise(sendEvent(...args));
+ // We don't wait for it to complete:
+ return null;
+ });
+
+ communication.register("openMyShots", (sender) => {
+ return catcher.watchPromise(
+ auth.maybeLogin()
+ .then(() => browser.tabs.create({url: backend + "/shots"})));
+ });
+
+ communication.register("openShot", async (sender, {url, copied}) => {
+ if (copied) {
+ const id = makeUuid();
+ const [ title, message ] = await getStrings([
+ { id: "screenshots-notification-link-copied-title" },
+ { id: "screenshots-notification-link-copied-details" },
+ ]);
+ return browser.notifications.create(id, {
+ type: "basic",
+ iconUrl: "../icons/copied-notification.svg",
+ title,
+ message,
+ });
+ }
+ return null;
+ });
+
+ // This is used for truncated full page downloads and copy to clipboards.
+ // Those longer operations need to display an animated spinner/loader, so
+ // it's preferable to perform toDataURL() in the background.
+ communication.register("canvasToDataURL", (sender, imageData) => {
+ const canvas = document.createElement("canvas");
+ canvas.width = imageData.width;
+ canvas.height = imageData.height;
+ canvas.getContext("2d").putImageData(imageData, 0, 0);
+ let dataUrl = canvas.toDataURL();
+ if (buildSettings.pngToJpegCutoff && dataUrl.length > buildSettings.pngToJpegCutoff) {
+ const jpegDataUrl = canvas.toDataURL("image/jpeg");
+ if (jpegDataUrl.length < dataUrl.length) {
+ // Only use the JPEG if it is actually smaller
+ dataUrl = jpegDataUrl;
+ }
+ }
+ return dataUrl;
+ });
+
+ communication.register("copyShotToClipboard", async (sender, blob) => {
+ let buffer = await blobConverters.blobToArray(blob);
+ await browser.clipboard.setImageData(buffer, blob.type.split("/", 2)[1]);
+
+ const [title, message] = await getStrings([
+ { id: "screenshots-notification-image-copied-title" },
+ { id: "screenshots-notification-image-copied-details" },
+ ]);
+
+ catcher.watchPromise(incrementCount("copy"));
+ return browser.notifications.create({
+ type: "basic",
+ iconUrl: "../icons/copied-notification.svg",
+ title,
+ message,
+ });
+ });
+
+ communication.register("downloadShot", (sender, info) => {
+ // 'data:' urls don't work directly, let's use a Blob
+ // see http://stackoverflow.com/questions/40269862/save-data-uri-as-file-using-downloads-download-api
+ const blob = blobConverters.dataUrlToBlob(info.url);
+ const url = URL.createObjectURL(blob);
+ let downloadId;
+ const onChangedCallback = catcher.watchFunction(function(change) {
+ if (!downloadId || downloadId !== change.id) {
+ return;
+ }
+ if (change.state && change.state.current !== "in_progress") {
+ URL.revokeObjectURL(url);
+ browser.downloads.onChanged.removeListener(onChangedCallback);
+ }
+ });
+ browser.downloads.onChanged.addListener(onChangedCallback);
+ catcher.watchPromise(incrementCount("download"));
+ return browser.windows.getLastFocused().then(windowInfo => {
+ return browser.downloads.download({
+ url,
+ incognito: windowInfo.incognito,
+ filename: info.filename,
+ }).catch((error) => {
+ // We are not logging error message when user cancels download
+ if (error && error.message && !error.message.includes("canceled")) {
+ log.error(error.message);
+ }
+ }).then((id) => {
+ downloadId = id;
+ });
+ });
+ });
+
+ communication.register("closeSelector", (sender) => {
+ setIconActive(false, sender.tab.id);
+ });
+
+ communication.register("abortStartShot", () => {
+ // Note, we only show the error but don't report it, as we know that we can't
+ // take shots of these pages:
+ senderror.showError({
+ popupMessage: "UNSHOOTABLE_PAGE",
+ });
+ });
+
+ // A Screenshots page wants us to start/force onboarding
+ communication.register("requestOnboarding", (sender) => {
+ return startSelectionWithOnboarding(sender.tab);
+ });
+
+ communication.register("getPlatformOs", () => {
+ return catcher.watchPromise(browser.runtime.getPlatformInfo().then(platformInfo => {
+ return platformInfo.os;
+ }));
+ });
+
+ // This allows the web site show notifications through sitehelper.js
+ communication.register("showNotification", (sender, notification) => {
+ return browser.notifications.create(notification);
+ });
+
+ return exports;
+})();
diff --git a/browser/extensions/screenshots/background/selectorLoader.js b/browser/extensions/screenshots/background/selectorLoader.js
new file mode 100644
index 0000000000..a6dbd69b26
--- /dev/null
+++ b/browser/extensions/screenshots/background/selectorLoader.js
@@ -0,0 +1,134 @@
+/* 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/. */
+
+/* globals catcher, communication, log, main */
+
+"use strict";
+
+// eslint-disable-next-line no-var
+var global = this;
+
+this.selectorLoader = (function() {
+ const exports = {};
+
+ // These modules are loaded in order, first standardScripts and then selectorScripts
+ // The order is important due to dependencies
+ const standardScripts = [
+ "build/buildSettings.js",
+ "log.js",
+ "catcher.js",
+ "assertIsTrusted.js",
+ "assertIsBlankDocument.js",
+ "blobConverters.js",
+ "background/selectorLoader.js",
+ "selector/callBackground.js",
+ "selector/util.js",
+ ];
+
+ const selectorScripts = [
+ "clipboard.js",
+ "makeUuid.js",
+ "build/selection.js",
+ "build/shot.js",
+ "randomString.js",
+ "domainFromUrl.js",
+ "build/inlineSelectionCss.js",
+ "selector/documentMetadata.js",
+ "selector/ui.js",
+ "selector/shooter.js",
+ "selector/uicontrol.js",
+ ];
+
+ exports.unloadIfLoaded = function(tabId) {
+ return browser.tabs.executeScript(tabId, {
+ code: "this.selectorLoader && this.selectorLoader.unloadModules()",
+ runAt: "document_start",
+ }).then(result => {
+ return result && result[0];
+ });
+ };
+
+ exports.testIfLoaded = function(tabId) {
+ if (loadingTabs.has(tabId)) {
+ return true;
+ }
+ return browser.tabs.executeScript(tabId, {
+ code: "!!this.selectorLoader",
+ runAt: "document_start",
+ }).then(result => {
+ return result && result[0];
+ });
+ };
+
+ const loadingTabs = new Set();
+
+ exports.loadModules = function(tabId) {
+ loadingTabs.add(tabId);
+ catcher.watchPromise(browser.tabs.executeScript(tabId, {
+ code: `window.hasAnyShots = ${!!main.hasAnyShots()};`,
+ runAt: "document_start",
+ }).then(() => {
+ return executeModules(tabId, standardScripts.concat(selectorScripts));
+ }).finally(() => {
+ loadingTabs.delete(tabId);
+ }));
+ };
+
+ function executeModules(tabId, scripts) {
+ let lastPromise = Promise.resolve(null);
+ scripts.forEach((file) => {
+ lastPromise = lastPromise.then(() => {
+ return browser.tabs.executeScript(tabId, {
+ file,
+ runAt: "document_start",
+ }).catch((error) => {
+ log.error("error in script:", file, error);
+ error.scriptName = file;
+ throw error;
+ });
+ });
+ });
+ return lastPromise.then(() => {
+ log.debug("finished loading scripts:", scripts.join(" "));
+ },
+ (error) => {
+ exports.unloadIfLoaded(tabId);
+ catcher.unhandled(error);
+ throw error;
+ });
+ }
+
+ exports.unloadModules = function() {
+ const watchFunction = catcher.watchFunction;
+ const allScripts = standardScripts.concat(selectorScripts);
+ const moduleNames = allScripts.map((filename) =>
+ filename.replace(/^.*\//, "").replace(/\.js$/, ""));
+ moduleNames.reverse();
+ for (const moduleName of moduleNames) {
+ const moduleObj = global[moduleName];
+ if (moduleObj && moduleObj.unload) {
+ try {
+ watchFunction(moduleObj.unload)();
+ } catch (e) {
+ // ignore (watchFunction handles it)
+ }
+ }
+ delete global[moduleName];
+ }
+ return true;
+ };
+
+ exports.toggle = function(tabId) {
+ return exports.unloadIfLoaded(tabId)
+ .then(wasLoaded => {
+ if (!wasLoaded) {
+ exports.loadModules(tabId);
+ }
+ return !wasLoaded;
+ });
+ };
+
+ return exports;
+})();
+null;
diff --git a/browser/extensions/screenshots/background/senderror.js b/browser/extensions/screenshots/background/senderror.js
new file mode 100644
index 0000000000..f136e265be
--- /dev/null
+++ b/browser/extensions/screenshots/background/senderror.js
@@ -0,0 +1,154 @@
+/* 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/. */
+
+/* globals startBackground, analytics, communication, makeUuid, Raven, catcher, auth, log */
+
+"use strict";
+
+this.senderror = (function() {
+ const exports = {};
+
+ const manifest = browser.runtime.getManifest();
+
+ // Do not show an error more than every ERROR_TIME_LIMIT milliseconds:
+ const ERROR_TIME_LIMIT = 3000;
+
+ const messages = {
+ REQUEST_ERROR: {
+ titleKey: "screenshots-request-error-title",
+ infoKey: "screenshots-request-error-details",
+ },
+ CONNECTION_ERROR: {
+ titleKey: "screenshots-connection-error-title",
+ infoKey: "screenshots-connection-error-details",
+ },
+ LOGIN_ERROR: {
+ titleKey: "screenshots-request-error-title",
+ infoKey: "screenshots-login-error-details",
+ },
+ LOGIN_CONNECTION_ERROR: {
+ titleKey: "screenshots-connection-error-title",
+ infoKey: "screenshots-connection-error-details",
+ },
+ UNSHOOTABLE_PAGE: {
+ titleKey: "screenshots-unshootable-page-error-title",
+ infoKey: "screenshots-unshootable-page-error-details",
+ },
+ SHOT_PAGE: {
+ titleKey: "screenshots-self-screenshot-error-title",
+ },
+ MY_SHOTS: {
+ titleKey: "screenshots-self-screenshot-error-title",
+ },
+ EMPTY_SELECTION: {
+ titleKey: "screenshots-empty-selection-error-title",
+ },
+ PRIVATE_WINDOW: {
+ titleKey: "screenshots-private-window-error-title",
+ infoKey: "screenshots-private-window-error-details",
+ },
+ generic: {
+ titleKey: "screenshots-generic-error-title",
+ infoKey: "screenshots-generic-error-details",
+ showMessage: true,
+ },
+ };
+
+ communication.register("reportError", (sender, error) => {
+ catcher.unhandled(error);
+ });
+
+ let lastErrorTime;
+
+ exports.showError = async function(error) {
+ if (lastErrorTime && (Date.now() - lastErrorTime) < ERROR_TIME_LIMIT) {
+ return;
+ }
+ lastErrorTime = Date.now();
+ const id = makeUuid();
+ let popupMessage = error.popupMessage || "generic";
+ if (!messages[popupMessage]) {
+ popupMessage = "generic";
+ }
+
+ let item = messages[popupMessage];
+ if (!("title" in item)) {
+ let keys = [{id: item.titleKey}];
+ if ("infoKey" in item) {
+ keys.push({id: item.infoKey});
+ }
+
+ [item.title, item.info] = await getStrings(keys);
+ }
+
+ let title = item.title;
+ let message = item.info || "";
+ const showMessage = item.showMessage;
+ if (error.message && showMessage) {
+ if (message) {
+ message += "\n" + error.message;
+ } else {
+ message = error.message;
+ }
+ }
+ if (Date.now() - startBackground.startTime > 5 * 1000) {
+ browser.notifications.create(id, {
+ type: "basic",
+ // FIXME: need iconUrl for an image, see #2239
+ title,
+ message,
+ });
+ }
+ };
+
+ exports.reportError = function(e) {
+ if (!analytics.isTelemetryEnabled()) {
+ log.error("Telemetry disabled. Not sending critical error:", e);
+ return;
+ }
+ const dsn = auth.getSentryPublicDSN();
+ if (!dsn) {
+ return;
+ }
+ if (!Raven.isSetup()) {
+ Raven.config(dsn, {allowSecretKey: true}).install();
+ }
+ const exception = new Error(e.message);
+ exception.stack = e.multilineStack || e.stack || undefined;
+
+ // To improve Sentry reporting & grouping, replace the
+ // moz-extension://$uuid base URL with a generic resource:// URL.
+ if (exception.stack) {
+ exception.stack = exception.stack.replace(
+ /moz-extension:\/\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/g,
+ "resource://screenshots-addon"
+ );
+ }
+ const rest = {};
+ for (const attr in e) {
+ if (!["name", "message", "stack", "multilineStack", "popupMessage", "version", "sentryPublicDSN", "help", "fromMakeError"].includes(attr)) {
+ rest[attr] = e[attr];
+ }
+ }
+ rest.stack = exception.stack;
+ Raven.captureException(exception, {
+ logger: "addon",
+ tags: {category: e.popupMessage},
+ release: manifest.version,
+ message: exception.message,
+ extra: rest,
+ });
+ };
+
+ catcher.registerHandler((errorObj) => {
+ if (!errorObj.noPopup) {
+ exports.showError(errorObj);
+ }
+ if (!errorObj.noReport) {
+ exports.reportError(errorObj);
+ }
+ });
+
+ return exports;
+})();
diff --git a/browser/extensions/screenshots/background/startBackground.js b/browser/extensions/screenshots/background/startBackground.js
new file mode 100644
index 0000000000..b32dc3d6f7
--- /dev/null
+++ b/browser/extensions/screenshots/background/startBackground.js
@@ -0,0 +1,135 @@
+/* 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/. */
+
+/* globals browser, main, communication, manifest */
+/* This file handles:
+ clicks on the WebExtension page action
+ browser.contextMenus.onClicked
+ browser.runtime.onMessage
+ and loads the rest of the background page in response to those events, forwarding
+ the events to main.onClicked, main.onClickedContextMenu, or communication.onMessage
+*/
+const startTime = Date.now();
+
+// Set up to be able to use fluent:
+(function() {
+ let link = document.createElement("link");
+ link.setAttribute("rel", "localization");
+ link.setAttribute("href", "browser/screenshots.ftl");
+ document.head.appendChild(link);
+
+ link = document.createElement("link");
+ link.setAttribute("rel", "localization");
+ link.setAttribute("href", "browser/branding/brandings.ftl");
+ document.head.appendChild(link);
+})();
+
+this.getStrings = async function(ids) {
+ if (document.readyState != "complete") {
+ await new Promise(resolve => window.addEventListener("load", resolve, {once: true}));
+ }
+ await document.l10n.ready;
+ return document.l10n.formatValues(ids);
+}
+
+this.startBackground = (function() {
+ const exports = {startTime};
+
+ const backgroundScripts = [
+ "log.js",
+ "makeUuid.js",
+ "catcher.js",
+ "blobConverters.js",
+ "background/selectorLoader.js",
+ "background/communication.js",
+ "background/auth.js",
+ "background/senderror.js",
+ "build/raven.js",
+ "build/shot.js",
+ "build/thumbnailGenerator.js",
+ "background/analytics.js",
+ "background/deviceInfo.js",
+ "background/takeshot.js",
+ "background/main.js",
+ ];
+
+ browser.pageAction.onClicked.addListener(tab => {
+ loadIfNecessary().then(() => {
+ main.onClicked(tab);
+ }).catch(error => {
+ console.error("Error loading Screenshots:", error);
+ });
+ });
+
+ this.getStrings([{id: "screenshots-context-menu"}]).then(msgs => {
+ browser.contextMenus.create({
+ id: "create-screenshot",
+ title: msgs[0],
+ contexts: ["page", "selection"],
+ documentUrlPatterns: ["<all_urls>", "about:reader*"],
+ });
+ });
+
+ browser.contextMenus.onClicked.addListener((info, tab) => {
+ loadIfNecessary().then(() => {
+ main.onClickedContextMenu(info, tab);
+ }).catch((error) => {
+ console.error("Error loading Screenshots:", error);
+ });
+ });
+
+ browser.commands.onCommand.addListener((cmd) => {
+ if (cmd !== "take-screenshot") {
+ return;
+ }
+ loadIfNecessary().then(() => {
+ browser.tabs.query({currentWindow: true, active: true}).then((tabs) => {
+ const activeTab = tabs[0];
+ main.onCommand(activeTab);
+ }).catch((error) => {
+ throw error;
+ });
+ }).catch((error) => {
+ console.error("Error toggling Screenshots via keyboard shortcut: ", error);
+ });
+ });
+
+ browser.runtime.onMessage.addListener((req, sender, sendResponse) => {
+ loadIfNecessary().then(() => {
+ return communication.onMessage(req, sender, sendResponse);
+ }).catch((error) => {
+ console.error("Error loading Screenshots:", error);
+ });
+ return true;
+ });
+
+ let loadedPromise;
+
+ function loadIfNecessary() {
+ if (loadedPromise) {
+ return loadedPromise;
+ }
+ loadedPromise = Promise.resolve();
+ backgroundScripts.forEach((script) => {
+ loadedPromise = loadedPromise.then(() => {
+ return new Promise((resolve, reject) => {
+ const tag = document.createElement("script");
+ tag.src = browser.extension.getURL(script);
+ tag.onload = () => {
+ resolve();
+ };
+ tag.onerror = (error) => {
+ const exc = new Error(`Error loading script: ${error.message}`);
+ exc.scriptName = script;
+ reject(exc);
+ };
+ document.head.appendChild(tag);
+ });
+ });
+ });
+ return loadedPromise;
+ }
+
+ return exports;
+})();
diff --git a/browser/extensions/screenshots/background/takeshot.js b/browser/extensions/screenshots/background/takeshot.js
new file mode 100644
index 0000000000..b25e984fc8
--- /dev/null
+++ b/browser/extensions/screenshots/background/takeshot.js
@@ -0,0 +1,179 @@
+/* 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/. */
+
+/* globals communication, shot, main, auth, catcher, analytics, buildSettings, blobConverters, thumbnailGenerator */
+
+"use strict";
+
+this.takeshot = (function() {
+ const exports = {};
+ const Shot = shot.AbstractShot;
+ const { sendEvent, incrementCount } = analytics;
+ const MAX_CANVAS_DIMENSION = 32767;
+
+ communication.register("screenshotPage", (sender, selectedPos, isFullPage, devicePixelRatio) => {
+ return screenshotPage(selectedPos, isFullPage, devicePixelRatio);
+ });
+
+ function screenshotPage(pos, isFullPage, devicePixelRatio) {
+ pos.width = Math.min(pos.right - pos.left, MAX_CANVAS_DIMENSION);
+ pos.height = Math.min(pos.bottom - pos.top, MAX_CANVAS_DIMENSION);
+
+ // If we are printing the full page or a truncated full page,
+ // we must pass in this rectangle to preview the entire image
+ let options = {format: "png"};
+ if (isFullPage) {
+ let rectangle = {
+ x: 0,
+ y: 0,
+ width: pos.width,
+ height: pos.height,
+ }
+ options.rect = rectangle;
+
+ // To avoid creating extremely large images (which causes
+ // performance problems), we set the scale to 1.
+ devicePixelRatio = options.scale = 1;
+ } else {
+ let rectangle = {
+ x: pos.left,
+ y: pos.top,
+ width: pos.width,
+ height: pos.height,
+ }
+ options.rect = rectangle
+ }
+
+ return catcher.watchPromise(browser.tabs.captureTab(
+ null,
+ options,
+ ).then((dataUrl) => {
+ const image = new Image();
+ image.src = dataUrl;
+ return new Promise((resolve, reject) => {
+ image.onload = catcher.watchFunction(() => {
+ const xScale = devicePixelRatio;
+ const yScale = devicePixelRatio;
+ const canvas = document.createElement("canvas");
+ canvas.height = pos.height * yScale;
+ canvas.width = pos.width * xScale;
+ const context = canvas.getContext("2d");
+ context.drawImage(
+ image,
+ 0, 0,
+ pos.width * xScale, pos.height * yScale,
+ 0, 0,
+ pos.width * xScale, pos.height * yScale
+ );
+ const result = canvas.toDataURL();
+ resolve(result);
+ });
+ });
+ }));
+ }
+
+ /** Combines two buffers or Uint8Array's */
+ function concatBuffers(buffer1, buffer2) {
+ const tmp = new Uint8Array(buffer1.byteLength + buffer2.byteLength);
+ tmp.set(new Uint8Array(buffer1), 0);
+ tmp.set(new Uint8Array(buffer2), buffer1.byteLength);
+ return tmp.buffer;
+ }
+
+ /** Creates a multipart TypedArray, given {name: value} fields
+ and a files array in the format of
+ [{fieldName: "NAME", filename: "NAME.png", blob: fileBlob}, {...}, ...]
+
+ Returns {body, "content-type"}
+ */
+ function createMultipart(fields, files) {
+ const boundary = "---------------------------ScreenshotBoundary" + Date.now();
+ let body = [];
+ for (const name in fields) {
+ body.push("--" + boundary);
+ body.push(`Content-Disposition: form-data; name="${name}"`);
+ body.push("");
+ body.push(fields[name]);
+ }
+ body.push("");
+ body = body.join("\r\n");
+ const enc = new TextEncoder("utf-8");
+ body = enc.encode(body).buffer;
+
+ const blobToArrayPromises = files.map(f => {
+ return blobConverters.blobToArray(f.blob);
+ });
+
+ return Promise.all(blobToArrayPromises).then(buffers => {
+ for (let i = 0; i < buffers.length; i++) {
+ let filePart = [];
+ filePart.push("--" + boundary);
+ filePart.push(`Content-Disposition: form-data; name="${files[i].fieldName}"; filename="${files[i].filename}"`);
+ filePart.push(`Content-Type: ${files[i].blob.type}`);
+ filePart.push("");
+ filePart.push("");
+ filePart = filePart.join("\r\n");
+ filePart = concatBuffers(enc.encode(filePart).buffer, buffers[i]);
+ body = concatBuffers(body, filePart);
+ body = concatBuffers(body, enc.encode("\r\n").buffer);
+ }
+
+ let tail = `\r\n--${boundary}--`;
+ tail = enc.encode(tail);
+ body = concatBuffers(body, tail.buffer);
+ return {
+ "content-type": `multipart/form-data; boundary=${boundary}`,
+ body,
+ };
+ });
+ }
+
+ function uploadShot(shot, blob, thumbnail) {
+ let headers;
+ return auth.authHeaders().then((_headers) => {
+ headers = _headers;
+ if (blob) {
+ const files = [ {fieldName: "blob", filename: "screenshot.png", blob} ];
+ if (thumbnail) {
+ files.push({fieldName: "thumbnail", filename: "thumbnail.png", blob: thumbnail});
+ }
+ return createMultipart(
+ {shot: JSON.stringify(shot)},
+
+ files
+ );
+ }
+ return {
+ "content-type": "application/json",
+ body: JSON.stringify(shot),
+ };
+
+ }).then((submission) => {
+ headers["content-type"] = submission["content-type"];
+ sendEvent("upload", "started", {eventValue: Math.floor(submission.body.length / 1000)});
+ return fetch(shot.jsonUrl, {
+ method: "PUT",
+ mode: "cors",
+ headers,
+ body: submission.body,
+ });
+ }).then((resp) => {
+ if (!resp.ok) {
+ sendEvent("upload-failed", `status-${resp.status}`);
+ const exc = new Error(`Response failed with status ${resp.status}`);
+ exc.popupMessage = "REQUEST_ERROR";
+ throw exc;
+ } else {
+ sendEvent("upload", "success");
+ }
+ }, (error) => {
+ // FIXME: I'm not sure what exceptions we can expect
+ sendEvent("upload-failed", "connection");
+ error.popupMessage = "CONNECTION_ERROR";
+ throw error;
+ });
+ }
+
+ return exports;
+})();