diff options
Diffstat (limited to 'browser/extensions/screenshots/background')
-rw-r--r-- | browser/extensions/screenshots/background/analytics.js | 367 | ||||
-rw-r--r-- | browser/extensions/screenshots/background/auth.js | 224 | ||||
-rw-r--r-- | browser/extensions/screenshots/background/communication.js | 53 | ||||
-rw-r--r-- | browser/extensions/screenshots/background/deviceInfo.js | 38 | ||||
-rw-r--r-- | browser/extensions/screenshots/background/main.js | 291 | ||||
-rw-r--r-- | browser/extensions/screenshots/background/selectorLoader.js | 134 | ||||
-rw-r--r-- | browser/extensions/screenshots/background/senderror.js | 154 | ||||
-rw-r--r-- | browser/extensions/screenshots/background/startBackground.js | 135 | ||||
-rw-r--r-- | browser/extensions/screenshots/background/takeshot.js | 179 |
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; +})(); |