diff options
Diffstat (limited to 'browser/extensions/screenshots')
55 files changed, 10484 insertions, 0 deletions
diff --git a/browser/extensions/screenshots/assertIsBlankDocument.js b/browser/extensions/screenshots/assertIsBlankDocument.js new file mode 100644 index 0000000000..2f39374215 --- /dev/null +++ b/browser/extensions/screenshots/assertIsBlankDocument.js @@ -0,0 +1,16 @@ +/* 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/. */ + +/** For use inside an iframe onload function, throws an Error if iframe src is not blank.html + + Should be applied *inside* catcher.watchFunction +*/ +this.assertIsBlankDocument = function assertIsBlankDocument(doc) { + if (doc.documentURI !== browser.extension.getURL("blank.html")) { + const exc = new Error("iframe URL does not match expected blank.html"); + exc.foundURL = doc.documentURI; + throw exc; + } +}; +null; diff --git a/browser/extensions/screenshots/assertIsTrusted.js b/browser/extensions/screenshots/assertIsTrusted.js new file mode 100644 index 0000000000..9065d8603b --- /dev/null +++ b/browser/extensions/screenshots/assertIsTrusted.js @@ -0,0 +1,24 @@ +/* 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/. */ + +/** For use with addEventListener, assures that any events have event.isTrusted set to true + https://developer.mozilla.org/en-US/docs/Web/API/Event/isTrusted + Should be applied *inside* catcher.watchFunction +*/ +this.assertIsTrusted = function assertIsTrusted(handlerFunction) { + return function(event) { + if (!event) { + const exc = new Error("assertIsTrusted did not get an event"); + exc.noPopup = true; + throw exc; + } + if (!event.isTrusted) { + const exc = new Error(`Received untrusted event (type: ${event.type})`); + exc.noPopup = true; + throw exc; + } + return handlerFunction.call(this, event); + }; +}; +null; 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; +})(); diff --git a/browser/extensions/screenshots/blank.html b/browser/extensions/screenshots/blank.html new file mode 100644 index 0000000000..dacff8e72b --- /dev/null +++ b/browser/extensions/screenshots/blank.html @@ -0,0 +1,5 @@ +<!-- 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/. --> + +<html></html> diff --git a/browser/extensions/screenshots/blobConverters.js b/browser/extensions/screenshots/blobConverters.js new file mode 100644 index 0000000000..f2312251d7 --- /dev/null +++ b/browser/extensions/screenshots/blobConverters.js @@ -0,0 +1,48 @@ +/* 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/. */ + +this.blobConverters = (function() { + const exports = {}; + + exports.dataUrlToBlob = function(url) { + const binary = atob(url.split(",", 2)[1]); + let contentType = exports.getTypeFromDataUrl(url); + if (contentType !== "image/png" && contentType !== "image/jpeg") { + contentType = "image/png"; + } + const data = Uint8Array.from(binary, char => char.charCodeAt(0)); + const blob = new Blob([data], {type: contentType}); + return blob; + }; + + exports.getTypeFromDataUrl = function(url) { + let contentType = url.split(",", 1)[0]; + contentType = contentType.split(";", 1)[0]; + contentType = contentType.split(":", 2)[1]; + return contentType; + }; + + exports.blobToArray = function(blob) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.addEventListener("loadend", function() { + resolve(reader.result); + }); + reader.readAsArrayBuffer(blob); + }); + }; + + exports.blobToDataUrl = function(blob) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.addEventListener("loadend", function() { + resolve(reader.result); + }); + reader.readAsDataURL(blob); + }); + }; + + return exports; +})(); +null; diff --git a/browser/extensions/screenshots/build/buildSettings.js b/browser/extensions/screenshots/build/buildSettings.js new file mode 100644 index 0000000000..cc79976c8d --- /dev/null +++ b/browser/extensions/screenshots/build/buildSettings.js @@ -0,0 +1,15 @@ +/* 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/. */ + +window.buildSettings = { + defaultSentryDsn: "", + logLevel: "" || "warn", + captureText: ("" === "true"), + uploadBinary: ("" === "true"), + pngToJpegCutoff: parseInt("" || 2500000, 10), + maxImageHeight: parseInt("" || 10000, 10), + maxImageWidth: parseInt("" || 10000, 10) +}; +null; + diff --git a/browser/extensions/screenshots/build/inlineSelectionCss.js b/browser/extensions/screenshots/build/inlineSelectionCss.js new file mode 100644 index 0000000000..c59b8fa89a --- /dev/null +++ b/browser/extensions/screenshots/build/inlineSelectionCss.js @@ -0,0 +1,666 @@ +/* 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/. */ + +/* Created from build/server/static/css/inline-selection.css */ +window.inlineSelectionCss = ` +.button, .highlight-button-cancel, .highlight-button-download, .highlight-button-copy { + display: flex; + align-items: center; + justify-content: center; + border: 0; + border-radius: 3px; + cursor: pointer; + font-size: 16px; + font-weight: 400; + height: 40px; + min-width: 40px; + outline: none; + padding: 0 10px; + position: relative; + text-align: center; + text-decoration: none; + transition: background 150ms cubic-bezier(0.07, 0.95, 0, 1), border 150ms cubic-bezier(0.07, 0.95, 0, 1); + user-select: none; + white-space: nowrap; } + .button.hidden, .hidden.highlight-button-cancel, .hidden.highlight-button-download, .hidden.highlight-button-copy { + display: none; } + .button.small, .small.highlight-button-cancel, .small.highlight-button-download, .small.highlight-button-copy { + height: 32px; + line-height: 32px; + padding: 0 8px; } + .button.active, .active.highlight-button-cancel, .active.highlight-button-download, .active.highlight-button-copy { + background-color: #dedede; } + .button.tiny, .tiny.highlight-button-cancel, .tiny.highlight-button-download, .tiny.highlight-button-copy { + font-size: 14px; + height: 26px; + border: 1px solid #c7c7c7; } + .button.tiny:hover, .tiny.highlight-button-cancel:hover, .tiny.highlight-button-download:hover, .tiny.highlight-button-copy:hover, .button.tiny:focus, .tiny.highlight-button-cancel:focus, .tiny.highlight-button-download:focus, .tiny.highlight-button-copy:focus { + background: #ededf0; + border-color: #989898; } + .button.tiny:active, .tiny.highlight-button-cancel:active, .tiny.highlight-button-download:active, .tiny.highlight-button-copy:active { + background: #dedede; + border-color: #989898; } + .button.block-button, .block-button.highlight-button-cancel, .block-button.highlight-button-download, .block-button.highlight-button-copy { + display: flex; + align-items: center; + justify-content: center; + box-sizing: border-box; + border: 0; + border-inline-end: 1px solid #c7c7c7; + box-shadow: 0; + border-radius: 0; + flex-shrink: 0; + font-size: 20px; + height: 100px; + line-height: 100%; + overflow: hidden; } + @media (max-width: 719px) { + .button.block-button, .block-button.highlight-button-cancel, .block-button.highlight-button-download, .block-button.highlight-button-copy { + justify-content: flex-start; + font-size: 16px; + height: 72px; + margin-inline-end: 10px; + padding: 0 5px; } } + .button.block-button:hover, .block-button.highlight-button-cancel:hover, .block-button.highlight-button-download:hover, .block-button.highlight-button-copy:hover { + background: #ededf0; } + .button.block-button:active, .block-button.highlight-button-cancel:active, .block-button.highlight-button-download:active, .block-button.highlight-button-copy:active { + background: #dedede; } + .button.download, .download.highlight-button-cancel, .download.highlight-button-download, .download.highlight-button-copy, .button.edit, .edit.highlight-button-cancel, .edit.highlight-button-download, .edit.highlight-button-copy, .button.trash, .trash.highlight-button-cancel, .trash.highlight-button-download, .trash.highlight-button-copy, .button.share, .share.highlight-button-cancel, .share.highlight-button-download, .share.highlight-button-copy, .button.flag, .flag.highlight-button-cancel, .flag.highlight-button-download, .flag.highlight-button-copy { + background-repeat: no-repeat; + background-size: 50%; + background-position: center; + margin-inline-end: 10px; + transition: background-color 150ms cubic-bezier(0.07, 0.95, 0, 1); } + .button.download, .download.highlight-button-cancel, .download.highlight-button-download, .download.highlight-button-copy { + background-image: url("../img/icon-download.svg"); } + .button.download:hover, .download.highlight-button-cancel:hover, .download.highlight-button-download:hover, .download.highlight-button-copy:hover { + background-color: #ededf0; } + .button.download:active, .download.highlight-button-cancel:active, .download.highlight-button-download:active, .download.highlight-button-copy:active { + background-color: #dedede; } + .button.share, .share.highlight-button-cancel, .share.highlight-button-download, .share.highlight-button-copy { + background-image: url("../img/icon-share.svg"); } + .button.share:hover, .share.highlight-button-cancel:hover, .share.highlight-button-download:hover, .share.highlight-button-copy:hover { + background-color: #ededf0; } + .button.share.active, .share.active.highlight-button-cancel, .share.active.highlight-button-download, .share.active.highlight-button-copy, .button.share:active, .share.highlight-button-cancel:active, .share.highlight-button-download:active, .share.highlight-button-copy:active { + background-color: #dedede; } + .button.share.newicon, .share.newicon.highlight-button-cancel, .share.newicon.highlight-button-download, .share.newicon.highlight-button-copy { + background-image: url("../img/icon-share-alternate.svg"); } + .button.trash, .trash.highlight-button-cancel, .trash.highlight-button-download, .trash.highlight-button-copy { + background-image: url("../img/icon-trash.svg"); } + .button.trash:hover, .trash.highlight-button-cancel:hover, .trash.highlight-button-download:hover, .trash.highlight-button-copy:hover { + background-color: #ededf0; } + .button.trash:active, .trash.highlight-button-cancel:active, .trash.highlight-button-download:active, .trash.highlight-button-copy:active { + background-color: #dedede; } + .button.edit, .edit.highlight-button-cancel, .edit.highlight-button-download, .edit.highlight-button-copy { + background-image: url("../img/icon-edit.svg"); } + .button.edit:hover, .edit.highlight-button-cancel:hover, .edit.highlight-button-download:hover, .edit.highlight-button-copy:hover { + background-color: #ededf0; } + .button.edit:active, .edit.highlight-button-cancel:active, .edit.highlight-button-download:active, .edit.highlight-button-copy:active { + background-color: #dedede; } + +.app-body { + background: #f9f9fa; + color: #38383d; } + .app-body a { + color: #0a84ff; } + +.highlight-color-scheme { + background: #0a84ff; + color: #fff; } + .highlight-color-scheme a { + color: #fff; + text-decoration: underline; } + +.alt-color-scheme { + background: #38383d; + color: #f9f9fa; } + .alt-color-scheme h1 { + color: #6f7fb6; } + .alt-color-scheme a { + color: #e1e1e6; + text-decoration: underline; } + +.button.primary, .primary.highlight-button-cancel, .highlight-button-download, .primary.highlight-button-copy { + background-color: #0a84ff; + color: #fff; } + .button.primary:hover, .primary.highlight-button-cancel:hover, .highlight-button-download:hover, .primary.highlight-button-copy:hover, .button.primary:focus, .primary.highlight-button-cancel:focus, .highlight-button-download:focus, .primary.highlight-button-copy:focus { + background-color: #0072e5; } + .button.primary:active, .primary.highlight-button-cancel:active, .highlight-button-download:active, .primary.highlight-button-copy:active { + background-color: #0065cc; } + +.button.secondary, .highlight-button-cancel, .secondary.highlight-button-download, .highlight-button-copy { + background-color: #f9f9fa; + color: #38383d; } + .button.secondary:hover, .highlight-button-cancel:hover, .secondary.highlight-button-download:hover, .highlight-button-copy:hover { + background-color: #ededf0; } + .button.secondary:active, .highlight-button-cancel:active, .secondary.highlight-button-download:active, .highlight-button-copy:active { + background-color: #dedede; } + +.button.transparent, .transparent.highlight-button-cancel, .transparent.highlight-button-download, .transparent.highlight-button-copy { + background-color: transparent; + color: #38383d; } + .button.transparent:hover, .transparent.highlight-button-cancel:hover, .transparent.highlight-button-download:hover, .transparent.highlight-button-copy:hover { + background-color: #ededf0; } + .button.transparent:focus, .transparent.highlight-button-cancel:focus, .transparent.highlight-button-download:focus, .transparent.highlight-button-copy:focus, .button.transparent:active, .transparent.highlight-button-cancel:active, .transparent.highlight-button-download:active, .transparent.highlight-button-copy:active { + background-color: #dedede; } + +.button.warning, .warning.highlight-button-cancel, .warning.highlight-button-download, .warning.highlight-button-copy { + color: #fff; + background: #d92215; } + .button.warning:hover, .warning.highlight-button-cancel:hover, .warning.highlight-button-download:hover, .warning.highlight-button-copy:hover, .button.warning:focus, .warning.highlight-button-cancel:focus, .warning.highlight-button-download:focus, .warning.highlight-button-copy:focus { + background: #b81d12; } + .button.warning:active, .warning.highlight-button-cancel:active, .warning.highlight-button-download:active, .warning.highlight-button-copy:active { + background: #a11910; } + +.subtitle-link { + color: #0a84ff; } + +.loader { + background: rgba(12, 12, 13, 0.2); + border-radius: 2px; + height: 4px; + overflow: hidden; + position: relative; + width: 200px; } + +.loader-inner { + animation: bounce infinite alternate 1250ms cubic-bezier(0.7, 0, 0.3, 1); + background: #45a1ff; + border-radius: 2px; + height: 4px; + transform: translateX(-40px); + width: 50px; } + +@keyframes bounce { + 0% { + transform: translateX(-40px); } + 100% { + transform: translate(190px); } } + +@keyframes fade-in { + 0% { + opacity: 0; } + 100% { + opacity: 1; } } + +@keyframes pop { + 0% { + transform: scale(1); } + 97% { + transform: scale(1.04); } + 100% { + transform: scale(1); } } + +@keyframes pulse { + 0% { + opacity: 0.3; + transform: scale(1); } + 70% { + opacity: 0.25; + transform: scale(1.04); } + 100% { + opacity: 0.3; + transform: scale(1); } } + +@keyframes slide-left { + 0% { + opacity: 0; + transform: translate3d(160px, 0, 0); } + 100% { + opacity: 1; + transform: translate3d(0, 0, 0); } } + +@keyframes bounce-in { + 0% { + opacity: 0; + transform: scale(1); } + 60% { + opacity: 1; + transform: scale(1.02); } + 100% { + transform: scale(1); } } + +.mover-target { + display: flex; + align-items: center; + justify-content: center; + pointer-events: auto; + position: absolute; + z-index: 5; } + +.highlight, +.mover-target { + background-color: transparent; + background-image: none; } + +.mover-target, +.bghighlight { + border: 0; } + +.hover-highlight { + animation: fade-in 125ms forwards cubic-bezier(0.07, 0.95, 0, 1); + background: rgba(255, 255, 255, 0.2); + border-radius: 1px; + pointer-events: none; + position: absolute; + z-index: 10000000000; } + .hover-highlight::before { + border: 2px dashed rgba(255, 255, 255, 0.4); + bottom: 0; + content: ""; + inset-inline-start: 0; + position: absolute; + inset-inline-end: 0; + top: 0; } + body.hcm .hover-highlight { + background-color: white; + opacity: 0.2; } + +.mover-target.direction-topLeft { + cursor: nwse-resize; + height: 60px; + left: -30px; + top: -30px; + width: 60px; } + +.mover-target.direction-top { + cursor: ns-resize; + height: 60px; + inset-inline-start: 0; + top: -30px; + width: 100%; + z-index: 4; } + +.mover-target.direction-topRight { + cursor: nesw-resize; + height: 60px; + right: -30px; + top: -30px; + width: 60px; } + +.mover-target.direction-left { + cursor: ew-resize; + height: 100%; + left: -30px; + top: 0; + width: 60px; + z-index: 4; } + +.mover-target.direction-right { + cursor: ew-resize; + height: 100%; + right: -30px; + top: 0; + width: 60px; + z-index: 4; } + +.mover-target.direction-bottomLeft { + bottom: -30px; + cursor: nesw-resize; + height: 60px; + left: -30px; + width: 60px; } + +.mover-target.direction-bottom { + bottom: -30px; + cursor: ns-resize; + height: 60px; + inset-inline-start: 0; + width: 100%; + z-index: 4; } + +.mover-target.direction-bottomRight { + bottom: -30px; + cursor: nwse-resize; + height: 60px; + right: -30px; + width: 60px; } + +.mover-target:hover .mover { + transform: scale(1.05); } + +.mover { + background-color: #fff; + border-radius: 50%; + box-shadow: 0 0 4px rgba(0, 0, 0, 0.5); + height: 16px; + opacity: 1; + position: relative; + transition: transform 125ms cubic-bezier(0.07, 0.95, 0, 1); + width: 16px; } + .small-selection .mover { + height: 10px; + width: 10px; } + +.direction-topLeft .mover, +.direction-left .mover, +.direction-bottomLeft .mover { + left: -1px; } + +.direction-topLeft .mover, +.direction-top .mover, +.direction-topRight .mover { + top: -1px; } + +.direction-topRight .mover, +.direction-right .mover, +.direction-bottomRight .mover { + right: -1px; } + +.direction-bottomRight .mover, +.direction-bottom .mover, +.direction-bottomLeft .mover { + bottom: -1px; } + +.bghighlight { + background-color: rgba(0, 0, 0, 0.7); + position: absolute; + z-index: 9999999999; } + body.hcm .bghighlight { + background-color: black; + opacity: 0.7; } + +.preview-overlay { + align-items: center; + background-color: rgba(0, 0, 0, 0.7); + display: flex; + height: 100%; + justify-content: center; + inset-inline-start: 0; + margin: 0; + padding: 0; + position: fixed; + top: 0; + width: 100%; + z-index: 9999999999; } + body.hcm .preview-overlay { + background-color: black; + opacity: 0.7; } + +.precision-cursor { + cursor: crosshair; } + +.highlight { + border-radius: 1px; + border: 2px dashed rgba(255, 255, 255, 0.8); + box-sizing: border-box; + cursor: move; + position: absolute; + z-index: 9999999999; } + body.hcm .highlight { + border: 2px dashed white; + opacity: 1.0; } + +.highlight-buttons { + display: flex; + align-items: center; + justify-content: center; + bottom: -58px; + position: absolute; + inset-inline-end: 5px; + z-index: 6; } + .bottom-selection .highlight-buttons { + bottom: 5px; } + .left-selection .highlight-buttons { + inset-inline-end: auto; + inset-inline-start: 5px; } + .highlight-buttons > button { + box-shadow: 0 0 0 1px rgba(12, 12, 13, 0.1), 0 2px 8px rgba(12, 12, 13, 0.1); } + +.highlight-button-cancel { + margin: 5px; + width: 40px; } + +.highlight-button-download { + margin: 5px; + width: auto; + font-size: 18px; } + +.highlight-button-download img { + height: 16px; + width: 16px; +} + +.highlight-button-download:-moz-locale-dir(rtl) { + flex-direction: reverse; +} + +.highlight-button-download img:-moz-locale-dir(ltr) { + padding-inline-end: 8px; +} + +.highlight-button-download img:-moz-locale-dir(rtl) { + padding-inline-start: 8px; +} + +.highlight-button-copy { + margin: 5px; + width: auto; } + +.highlight-button-copy img { + height: 16px; + width: 16px; +} + +.highlight-button-copy:-moz-locale-dir(rtl) { + flex-direction: reverse; +} + +.highlight-button-copy img:-moz-locale-dir(ltr) { + padding-inline-end: 8px; +} + +.highlight-button-copy img:-moz-locale-dir(rtl) { + padding-inline-start: 8px; +} + +.pixel-dimensions { + position: absolute; + pointer-events: none; + font-weight: bold; + font-family: -apple-system, BlinkMacSystemFont, "segoe ui", "helvetica neue", helvetica, ubuntu, roboto, noto, arial, sans-serif; + font-size: 70%; + color: #000; + text-shadow: -1px -1px 0 #fff, 1px -1px 0 #fff, -1px 1px 0 #fff, 1px 1px 0 #fff; } + +.preview-buttons { + display: flex; + align-items: center; + justify-content: flex-end; + padding-inline-end: 4px; + inset-inline-end: 0; + width: 100%; + position: absolute; + height: 60px; + border-radius: 4px 4px 0 0; + background: rgba(249, 249, 250, 0.8); + top: 0; + border: 1px solid rgba(249, 249, 250, 0.2); + border-bottom: 0; + box-sizing: border-box; } + +.preview-image { + display: flex; + align-items: center; + flex-direction: column; + justify-content: center; + margin: 24px auto; + position: relative; + max-width: 80%; + max-height: 95%; + text-align: center; + animation-delay: 50ms; + display: flex; } + +.preview-image-wrapper { + background: rgba(249, 249, 250, 0.8); + border-radius: 0 0 4px 4px; + display: block; + height: auto; + max-width: 100%; + min-width: 320px; + overflow-y: scroll; + padding: 0 60px; + margin-top: 60px; + border: 1px solid rgba(249, 249, 250, 0.2); + border-top: 0; } + +.preview-image-wrapper > img { + box-shadow: 0 0 0 1px rgba(12, 12, 13, 0.1), 0 2px 8px rgba(12, 12, 13, 0.1); + height: auto; + margin-bottom: 60px; + max-width: 100%; + width: 100%; } + +.fixed-container { + align-items: center; + display: flex; + flex-direction: column; + height: 100vh; + justify-content: center; + inset-inline-start: 0; + margin: 0; + padding: 0; + pointer-events: none; + position: fixed; + top: 0; + width: 100%; } + +.face-container { + position: relative; + width: 64px; + height: 64px; } + +.face { + width: 62.4px; + height: 62.4px; + display: block; + background-image: url("MOZ_EXTENSION/icons/icon-welcome-face-without-eyes.svg"); } + +.eye { + background-color: #fff; + width: 10.8px; + height: 14.6px; + position: absolute; + border-radius: 100%; + overflow: hidden; + inset-inline-start: 16.4px; + top: 19.8px; } + +.eyeball { + position: absolute; + width: 6px; + height: 6px; + background-color: #000; + border-radius: 50%; + inset-inline-start: 2.4px; + top: 4.3px; + z-index: 10; } + +.left { + margin-inline-start: 0; } + +.right { + margin-inline-start: 20px; } + +.preview-instructions { + display: flex; + align-items: center; + justify-content: center; + animation: pulse 125mm cubic-bezier(0.07, 0.95, 0, 1); + color: #fff; + font-family: -apple-system, BlinkMacSystemFont, "segoe ui", "helvetica neue", helvetica, ubuntu, roboto, noto, arial, sans-serif; + font-size: 24px; + line-height: 32px; + text-align: center; + padding-top: 20px; + width: 400px; + user-select: none; } + +.cancel-shot { + background-color: transparent; + cursor: pointer; + outline: none; + border-radius: 3px; + border: 1px #9b9b9b solid; + color: #fff; + cursor: pointer; + font-family: -apple-system, BlinkMacSystemFont, "segoe ui", "helvetica neue", helvetica, ubuntu, roboto, noto, arial, sans-serif; + font-size: 16px; + margin-top: 40px; + padding: 10px 25px; + pointer-events: all; } + +.myshots-all-buttons-container { + display: flex; + flex-direction: row-reverse; + background: #f5f5f5; + border-radius: 2px; + box-sizing: border-box; + height: 80px; + padding: 8px; + position: absolute; + inset-inline-end: 8px; + top: 8px; + box-shadow: 0 0 0 1px rgba(12, 12, 13, 0.1), 0 2px 8px rgba(12, 12, 13, 0.1); } + .myshots-all-buttons-container .spacer { + background-color: #c9c9c9; + flex: 0 0 1px; + height: 80px; + margin: 0 10px; + position: relative; + top: -8px; } + .myshots-all-buttons-container button { + display: flex; + align-items: center; + flex-direction: column; + justify-content: flex-end; + color: #3e3d40; + background-color: #f5f5f5; + background-position: center top; + background-repeat: no-repeat; + background-size: 46px 46px; + border: 1px solid transparent; + cursor: pointer; + height: 100%; + min-width: 90px; + padding: 46px 5px 5px; + pointer-events: all; + transition: border 150ms cubic-bezier(0.07, 0.95, 0, 1), background-color 150ms cubic-bezier(0.07, 0.95, 0, 1); + white-space: nowrap; } + .myshots-all-buttons-container button:hover { + background-color: #ebebeb; + border: 1px solid #c7c7c7; } + .myshots-all-buttons-container button:active { + background-color: #dedede; + border: 1px solid #989898; } + .myshots-all-buttons-container .myshots-button { + background-image: url("MOZ_EXTENSION/icons/menu-myshot.svg"); } + .myshots-all-buttons-container .full-page { + background-image: url("MOZ_EXTENSION/icons/menu-fullpage.svg"); } + .myshots-all-buttons-container .visible { + background-image: url("MOZ_EXTENSION/icons/menu-visible.svg"); } + +.myshots-button-container { + display: flex; + align-items: center; + justify-content: center; } + +@keyframes pulse { + 0% { + transform: scale(1); } + 50% { + transform: scale(1.06); } + 100% { + transform: scale(1); } } + +@keyframes fade-in { + 0% { + opacity: 0; } + 100% { + opacity: 1; } } + +`; +null; + diff --git a/browser/extensions/screenshots/build/raven.js b/browser/extensions/screenshots/build/raven.js new file mode 100644 index 0000000000..86eaa50368 --- /dev/null +++ b/browser/extensions/screenshots/build/raven.js @@ -0,0 +1,4115 @@ +/*! Raven.js 3.27.0 (200cffcc) | github.com/getsentry/raven-js */ + +/* + * Includes TraceKit + * https://github.com/getsentry/TraceKit + * + * Copyright (c) 2018 Sentry (https://sentry.io) and individual contributors. + * All rights reserved. + * https://github.com/getsentry/sentry-javascript/blob/master/packages/raven-js/LICENSE + * + */ + +(function(f){if(typeof exports==="object"&&typeof module!=="undefined"){module.exports=f()}else if(typeof define==="function"&&define.amd){define([],f)}else{var g;if(typeof window!=="undefined"){g=window}else if(typeof global!=="undefined"){g=global}else if(typeof self!=="undefined"){g=self}else{g=this}g.Raven = f()}})(function(){var define,module,exports;return (function e(t,n,r){function s(o,u){if(!n[o]){if(!t[o]){var a=typeof require=="function"&&require;if(!u&&a)return a(o,!0);if(i)return i(o,!0);var f=new Error("Cannot find module '"+o+"'");throw f.code="MODULE_NOT_FOUND",f}var l=n[o]={exports:{}};t[o][0].call(l.exports,function(e){var n=t[o][1][e];return s(n?n:e)},l,l.exports,e,t,n,r)}return n[o].exports}var i=typeof require=="function"&&require;for(var o=0;o<r.length;o++)s(r[o]);return s})({1:[function(_dereq_,module,exports){ +function RavenConfigError(message) { + this.name = 'RavenConfigError'; + this.message = message; +} +RavenConfigError.prototype = new Error(); +RavenConfigError.prototype.constructor = RavenConfigError; + +module.exports = RavenConfigError; + +},{}],2:[function(_dereq_,module,exports){ +var utils = _dereq_(5); + +var wrapMethod = function(console, level, callback) { + var originalConsoleLevel = console[level]; + var originalConsole = console; + + if (!(level in console)) { + return; + } + + var sentryLevel = level === 'warn' ? 'warning' : level; + + console[level] = function() { + var args = [].slice.call(arguments); + + var msg = utils.safeJoin(args, ' '); + var data = {level: sentryLevel, logger: 'console', extra: {arguments: args}}; + + if (level === 'assert') { + if (args[0] === false) { + // Default browsers message + msg = + 'Assertion failed: ' + (utils.safeJoin(args.slice(1), ' ') || 'console.assert'); + data.extra.arguments = args.slice(1); + callback && callback(msg, data); + } + } else { + callback && callback(msg, data); + } + + // this fails for some browsers. :( + if (originalConsoleLevel) { + // IE9 doesn't allow calling apply on console functions directly + // See: https://stackoverflow.com/questions/5472938/does-ie9-support-console-log-and-is-it-a-real-function#answer-5473193 + Function.prototype.apply.call(originalConsoleLevel, originalConsole, args); + } + }; +}; + +module.exports = { + wrapMethod: wrapMethod +}; + +},{"5":5}],3:[function(_dereq_,module,exports){ +(function (global){ +/*global XDomainRequest:false */ + +var TraceKit = _dereq_(6); +var stringify = _dereq_(7); +var md5 = _dereq_(8); +var RavenConfigError = _dereq_(1); + +var utils = _dereq_(5); +var isErrorEvent = utils.isErrorEvent; +var isDOMError = utils.isDOMError; +var isDOMException = utils.isDOMException; +var isError = utils.isError; +var isObject = utils.isObject; +var isPlainObject = utils.isPlainObject; +var isUndefined = utils.isUndefined; +var isFunction = utils.isFunction; +var isString = utils.isString; +var isArray = utils.isArray; +var isEmptyObject = utils.isEmptyObject; +var each = utils.each; +var objectMerge = utils.objectMerge; +var truncate = utils.truncate; +var objectFrozen = utils.objectFrozen; +var hasKey = utils.hasKey; +var joinRegExp = utils.joinRegExp; +var urlencode = utils.urlencode; +var uuid4 = utils.uuid4; +var htmlTreeAsString = utils.htmlTreeAsString; +var isSameException = utils.isSameException; +var isSameStacktrace = utils.isSameStacktrace; +var parseUrl = utils.parseUrl; +var fill = utils.fill; +var supportsFetch = utils.supportsFetch; +var supportsReferrerPolicy = utils.supportsReferrerPolicy; +var serializeKeysForMessage = utils.serializeKeysForMessage; +var serializeException = utils.serializeException; +var sanitize = utils.sanitize; + +var wrapConsoleMethod = _dereq_(2).wrapMethod; + +var dsnKeys = 'source protocol user pass host port path'.split(' '), + dsnPattern = /^(?:(\w+):)?\/\/(?:(\w+)(:\w+)?@)?([\w\.-]+)(?::(\d+))?(\/.*)/; + +function now() { + return +new Date(); +} + +// This is to be defensive in environments where window does not exist (see https://github.com/getsentry/raven-js/pull/785) +var _window = + typeof window !== 'undefined' + ? window + : typeof global !== 'undefined' ? global : typeof self !== 'undefined' ? self : {}; +var _document = _window.document; +var _navigator = _window.navigator; + +function keepOriginalCallback(original, callback) { + return isFunction(callback) + ? function(data) { + return callback(data, original); + } + : callback; +} + +// First, check for JSON support +// If there is no JSON, we no-op the core features of Raven +// since JSON is required to encode the payload +function Raven() { + this._hasJSON = !!(typeof JSON === 'object' && JSON.stringify); + // Raven can run in contexts where there's no document (react-native) + this._hasDocument = !isUndefined(_document); + this._hasNavigator = !isUndefined(_navigator); + this._lastCapturedException = null; + this._lastData = null; + this._lastEventId = null; + this._globalServer = null; + this._globalKey = null; + this._globalProject = null; + this._globalContext = {}; + this._globalOptions = { + // SENTRY_RELEASE can be injected by https://github.com/getsentry/sentry-webpack-plugin + release: _window.SENTRY_RELEASE && _window.SENTRY_RELEASE.id, + logger: 'javascript', + ignoreErrors: [], + ignoreUrls: [], + whitelistUrls: [], + includePaths: [], + headers: null, + collectWindowErrors: true, + captureUnhandledRejections: true, + maxMessageLength: 0, + // By default, truncates URL values to 250 chars + maxUrlLength: 250, + stackTraceLimit: 50, + autoBreadcrumbs: true, + instrument: true, + sampleRate: 1, + sanitizeKeys: [] + }; + this._fetchDefaults = { + method: 'POST', + // Despite all stars in the sky saying that Edge supports old draft syntax, aka 'never', 'always', 'origin' and 'default + // https://caniuse.com/#feat=referrer-policy + // It doesn't. And it throw exception instead of ignoring this parameter... + // REF: https://github.com/getsentry/raven-js/issues/1233 + referrerPolicy: supportsReferrerPolicy() ? 'origin' : '' + }; + this._ignoreOnError = 0; + this._isRavenInstalled = false; + this._originalErrorStackTraceLimit = Error.stackTraceLimit; + // capture references to window.console *and* all its methods first + // before the console plugin has a chance to monkey patch + this._originalConsole = _window.console || {}; + this._originalConsoleMethods = {}; + this._plugins = []; + this._startTime = now(); + this._wrappedBuiltIns = []; + this._breadcrumbs = []; + this._lastCapturedEvent = null; + this._keypressTimeout; + this._location = _window.location; + this._lastHref = this._location && this._location.href; + this._resetBackoff(); + + // eslint-disable-next-line guard-for-in + for (var method in this._originalConsole) { + this._originalConsoleMethods[method] = this._originalConsole[method]; + } +} + +/* + * The core Raven singleton + * + * @this {Raven} + */ + +Raven.prototype = { + // Hardcode version string so that raven source can be loaded directly via + // webpack (using a build step causes webpack #1617). Grunt verifies that + // this value matches package.json during build. + // See: https://github.com/getsentry/raven-js/issues/465 + VERSION: '3.27.0', + + debug: false, + + TraceKit: TraceKit, // alias to TraceKit + + /* + * Configure Raven with a DSN and extra options + * + * @param {string} dsn The public Sentry DSN + * @param {object} options Set of global options [optional] + * @return {Raven} + */ + config: function(dsn, options) { + var self = this; + + if (self._globalServer) { + this._logDebug('error', 'Error: Raven has already been configured'); + return self; + } + if (!dsn) return self; + + var globalOptions = self._globalOptions; + + // merge in options + if (options) { + each(options, function(key, value) { + // tags and extra are special and need to be put into context + if (key === 'tags' || key === 'extra' || key === 'user') { + self._globalContext[key] = value; + } else { + globalOptions[key] = value; + } + }); + } + + self.setDSN(dsn); + + // "Script error." is hard coded into browsers for errors that it can't read. + // this is the result of a script being pulled in from an external domain and CORS. + globalOptions.ignoreErrors.push(/^Script error\.?$/); + globalOptions.ignoreErrors.push(/^Javascript error: Script error\.? on line 0$/); + + // join regexp rules into one big rule + globalOptions.ignoreErrors = joinRegExp(globalOptions.ignoreErrors); + globalOptions.ignoreUrls = globalOptions.ignoreUrls.length + ? joinRegExp(globalOptions.ignoreUrls) + : false; + globalOptions.whitelistUrls = globalOptions.whitelistUrls.length + ? joinRegExp(globalOptions.whitelistUrls) + : false; + globalOptions.includePaths = joinRegExp(globalOptions.includePaths); + globalOptions.maxBreadcrumbs = Math.max( + 0, + Math.min(globalOptions.maxBreadcrumbs || 100, 100) + ); // default and hard limit is 100 + + var autoBreadcrumbDefaults = { + xhr: true, + console: true, + dom: true, + location: true, + sentry: true + }; + + var autoBreadcrumbs = globalOptions.autoBreadcrumbs; + if ({}.toString.call(autoBreadcrumbs) === '[object Object]') { + autoBreadcrumbs = objectMerge(autoBreadcrumbDefaults, autoBreadcrumbs); + } else if (autoBreadcrumbs !== false) { + autoBreadcrumbs = autoBreadcrumbDefaults; + } + globalOptions.autoBreadcrumbs = autoBreadcrumbs; + + var instrumentDefaults = { + tryCatch: true + }; + + var instrument = globalOptions.instrument; + if ({}.toString.call(instrument) === '[object Object]') { + instrument = objectMerge(instrumentDefaults, instrument); + } else if (instrument !== false) { + instrument = instrumentDefaults; + } + globalOptions.instrument = instrument; + + TraceKit.collectWindowErrors = !!globalOptions.collectWindowErrors; + + // return for chaining + return self; + }, + + /* + * Installs a global window.onerror error handler + * to capture and report uncaught exceptions. + * At this point, install() is required to be called due + * to the way TraceKit is set up. + * + * @return {Raven} + */ + install: function() { + var self = this; + if (self.isSetup() && !self._isRavenInstalled) { + TraceKit.report.subscribe(function() { + self._handleOnErrorStackInfo.apply(self, arguments); + }); + + if (self._globalOptions.captureUnhandledRejections) { + self._attachPromiseRejectionHandler(); + } + + self._patchFunctionToString(); + + if (self._globalOptions.instrument && self._globalOptions.instrument.tryCatch) { + self._instrumentTryCatch(); + } + + if (self._globalOptions.autoBreadcrumbs) self._instrumentBreadcrumbs(); + + // Install all of the plugins + self._drainPlugins(); + + self._isRavenInstalled = true; + } + + Error.stackTraceLimit = self._globalOptions.stackTraceLimit; + return this; + }, + + /* + * Set the DSN (can be called multiple time unlike config) + * + * @param {string} dsn The public Sentry DSN + */ + setDSN: function(dsn) { + var self = this, + uri = self._parseDSN(dsn), + lastSlash = uri.path.lastIndexOf('/'), + path = uri.path.substr(1, lastSlash); + + self._dsn = dsn; + self._globalKey = uri.user; + self._globalSecret = uri.pass && uri.pass.substr(1); + self._globalProject = uri.path.substr(lastSlash + 1); + + self._globalServer = self._getGlobalServer(uri); + + self._globalEndpoint = + self._globalServer + '/' + path + 'api/' + self._globalProject + '/store/'; + + // Reset backoff state since we may be pointing at a + // new project/server + this._resetBackoff(); + }, + + /* + * Wrap code within a context so Raven can capture errors + * reliably across domains that is executed immediately. + * + * @param {object} options A specific set of options for this context [optional] + * @param {function} func The callback to be immediately executed within the context + * @param {array} args An array of arguments to be called with the callback [optional] + */ + context: function(options, func, args) { + if (isFunction(options)) { + args = func || []; + func = options; + options = {}; + } + + return this.wrap(options, func).apply(this, args); + }, + + /* + * Wrap code within a context and returns back a new function to be executed + * + * @param {object} options A specific set of options for this context [optional] + * @param {function} func The function to be wrapped in a new context + * @param {function} _before A function to call before the try/catch wrapper [optional, private] + * @return {function} The newly wrapped functions with a context + */ + wrap: function(options, func, _before) { + var self = this; + // 1 argument has been passed, and it's not a function + // so just return it + if (isUndefined(func) && !isFunction(options)) { + return options; + } + + // options is optional + if (isFunction(options)) { + func = options; + options = undefined; + } + + // At this point, we've passed along 2 arguments, and the second one + // is not a function either, so we'll just return the second argument. + if (!isFunction(func)) { + return func; + } + + // We don't wanna wrap it twice! + try { + if (func.__raven__) { + return func; + } + + // If this has already been wrapped in the past, return that + if (func.__raven_wrapper__) { + return func.__raven_wrapper__; + } + } catch (e) { + // Just accessing custom props in some Selenium environments + // can cause a "Permission denied" exception (see raven-js#495). + // Bail on wrapping and return the function as-is (defers to window.onerror). + return func; + } + + function wrapped() { + var args = [], + i = arguments.length, + deep = !options || (options && options.deep !== false); + + if (_before && isFunction(_before)) { + _before.apply(this, arguments); + } + + // Recursively wrap all of a function's arguments that are + // functions themselves. + while (i--) args[i] = deep ? self.wrap(options, arguments[i]) : arguments[i]; + + try { + // Attempt to invoke user-land function + // NOTE: If you are a Sentry user, and you are seeing this stack frame, it + // means Raven caught an error invoking your application code. This is + // expected behavior and NOT indicative of a bug with Raven.js. + return func.apply(this, args); + } catch (e) { + self._ignoreNextOnError(); + self.captureException(e, options); + throw e; + } + } + + // copy over properties of the old function + for (var property in func) { + if (hasKey(func, property)) { + wrapped[property] = func[property]; + } + } + wrapped.prototype = func.prototype; + + func.__raven_wrapper__ = wrapped; + // Signal that this function has been wrapped/filled already + // for both debugging and to prevent it to being wrapped/filled twice + wrapped.__raven__ = true; + wrapped.__orig__ = func; + + return wrapped; + }, + + /** + * Uninstalls the global error handler. + * + * @return {Raven} + */ + uninstall: function() { + TraceKit.report.uninstall(); + + this._detachPromiseRejectionHandler(); + this._unpatchFunctionToString(); + this._restoreBuiltIns(); + this._restoreConsole(); + + Error.stackTraceLimit = this._originalErrorStackTraceLimit; + this._isRavenInstalled = false; + + return this; + }, + + /** + * Callback used for `unhandledrejection` event + * + * @param {PromiseRejectionEvent} event An object containing + * promise: the Promise that was rejected + * reason: the value with which the Promise was rejected + * @return void + */ + _promiseRejectionHandler: function(event) { + this._logDebug('debug', 'Raven caught unhandled promise rejection:', event); + this.captureException(event.reason, { + mechanism: { + type: 'onunhandledrejection', + handled: false + } + }); + }, + + /** + * Installs the global promise rejection handler. + * + * @return {raven} + */ + _attachPromiseRejectionHandler: function() { + this._promiseRejectionHandler = this._promiseRejectionHandler.bind(this); + _window.addEventListener && + _window.addEventListener('unhandledrejection', this._promiseRejectionHandler); + return this; + }, + + /** + * Uninstalls the global promise rejection handler. + * + * @return {raven} + */ + _detachPromiseRejectionHandler: function() { + _window.removeEventListener && + _window.removeEventListener('unhandledrejection', this._promiseRejectionHandler); + return this; + }, + + /** + * Manually capture an exception and send it over to Sentry + * + * @param {error} ex An exception to be logged + * @param {object} options A specific set of options for this error [optional] + * @return {Raven} + */ + captureException: function(ex, options) { + options = objectMerge({trimHeadFrames: 0}, options ? options : {}); + + if (isErrorEvent(ex) && ex.error) { + // If it is an ErrorEvent with `error` property, extract it to get actual Error + ex = ex.error; + } else if (isDOMError(ex) || isDOMException(ex)) { + // If it is a DOMError or DOMException (which are legacy APIs, but still supported in some browsers) + // then we just extract the name and message, as they don't provide anything else + // https://developer.mozilla.org/en-US/docs/Web/API/DOMError + // https://developer.mozilla.org/en-US/docs/Web/API/DOMException + var name = ex.name || (isDOMError(ex) ? 'DOMError' : 'DOMException'); + var message = ex.message ? name + ': ' + ex.message : name; + + return this.captureMessage( + message, + objectMerge(options, { + // neither DOMError or DOMException provide stack trace and we most likely wont get it this way as well + // but it's barely any overhead so we may at least try + stacktrace: true, + trimHeadFrames: options.trimHeadFrames + 1 + }) + ); + } else if (isError(ex)) { + // we have a real Error object + ex = ex; + } else if (isPlainObject(ex)) { + // If it is plain Object, serialize it manually and extract options + // This will allow us to group events based on top-level keys + // which is much better than creating new group when any key/value change + options = this._getCaptureExceptionOptionsFromPlainObject(options, ex); + ex = new Error(options.message); + } else { + // If none of previous checks were valid, then it means that + // it's not a DOMError/DOMException + // it's not a plain Object + // it's not a valid ErrorEvent (one with an error property) + // it's not an Error + // So bail out and capture it as a simple message: + return this.captureMessage( + ex, + objectMerge(options, { + stacktrace: true, // if we fall back to captureMessage, default to attempting a new trace + trimHeadFrames: options.trimHeadFrames + 1 + }) + ); + } + + // Store the raw exception object for potential debugging and introspection + this._lastCapturedException = ex; + + // TraceKit.report will re-raise any exception passed to it, + // which means you have to wrap it in try/catch. Instead, we + // can wrap it here and only re-raise if TraceKit.report + // raises an exception different from the one we asked to + // report on. + try { + var stack = TraceKit.computeStackTrace(ex); + this._handleStackInfo(stack, options); + } catch (ex1) { + if (ex !== ex1) { + throw ex1; + } + } + + return this; + }, + + _getCaptureExceptionOptionsFromPlainObject: function(currentOptions, ex) { + var exKeys = Object.keys(ex).sort(); + var options = objectMerge(currentOptions, { + message: + 'Non-Error exception captured with keys: ' + serializeKeysForMessage(exKeys), + fingerprint: [md5(exKeys)], + extra: currentOptions.extra || {} + }); + options.extra.__serialized__ = serializeException(ex); + + return options; + }, + + /* + * Manually send a message to Sentry + * + * @param {string} msg A plain message to be captured in Sentry + * @param {object} options A specific set of options for this message [optional] + * @return {Raven} + */ + captureMessage: function(msg, options) { + // config() automagically converts ignoreErrors from a list to a RegExp so we need to test for an + // early call; we'll error on the side of logging anything called before configuration since it's + // probably something you should see: + if ( + !!this._globalOptions.ignoreErrors.test && + this._globalOptions.ignoreErrors.test(msg) + ) { + return; + } + + options = options || {}; + msg = msg + ''; // Make sure it's actually a string + + var data = objectMerge( + { + message: msg + }, + options + ); + + var ex; + // Generate a "synthetic" stack trace from this point. + // NOTE: If you are a Sentry user, and you are seeing this stack frame, it is NOT indicative + // of a bug with Raven.js. Sentry generates synthetic traces either by configuration, + // or if it catches a thrown object without a "stack" property. + try { + throw new Error(msg); + } catch (ex1) { + ex = ex1; + } + + // null exception name so `Error` isn't prefixed to msg + ex.name = null; + var stack = TraceKit.computeStackTrace(ex); + + // stack[0] is `throw new Error(msg)` call itself, we are interested in the frame that was just before that, stack[1] + var initialCall = isArray(stack.stack) && stack.stack[1]; + + // if stack[1] is `Raven.captureException`, it means that someone passed a string to it and we redirected that call + // to be handled by `captureMessage`, thus `initialCall` is the 3rd one, not 2nd + // initialCall => captureException(string) => captureMessage(string) + if (initialCall && initialCall.func === 'Raven.captureException') { + initialCall = stack.stack[2]; + } + + var fileurl = (initialCall && initialCall.url) || ''; + + if ( + !!this._globalOptions.ignoreUrls.test && + this._globalOptions.ignoreUrls.test(fileurl) + ) { + return; + } + + if ( + !!this._globalOptions.whitelistUrls.test && + !this._globalOptions.whitelistUrls.test(fileurl) + ) { + return; + } + + // Always attempt to get stacktrace if message is empty. + // It's the only way to provide any helpful information to the user. + if (this._globalOptions.stacktrace || options.stacktrace || data.message === '') { + // fingerprint on msg, not stack trace (legacy behavior, could be revisited) + data.fingerprint = data.fingerprint == null ? msg : data.fingerprint; + + options = objectMerge( + { + trimHeadFrames: 0 + }, + options + ); + // Since we know this is a synthetic trace, the top frame (this function call) + // MUST be from Raven.js, so mark it for trimming + // We add to the trim counter so that callers can choose to trim extra frames, such + // as utility functions. + options.trimHeadFrames += 1; + + var frames = this._prepareFrames(stack, options); + data.stacktrace = { + // Sentry expects frames oldest to newest + frames: frames.reverse() + }; + } + + // Make sure that fingerprint is always wrapped in an array + if (data.fingerprint) { + data.fingerprint = isArray(data.fingerprint) + ? data.fingerprint + : [data.fingerprint]; + } + + // Fire away! + this._send(data); + + return this; + }, + + captureBreadcrumb: function(obj) { + var crumb = objectMerge( + { + timestamp: now() / 1000 + }, + obj + ); + + if (isFunction(this._globalOptions.breadcrumbCallback)) { + var result = this._globalOptions.breadcrumbCallback(crumb); + + if (isObject(result) && !isEmptyObject(result)) { + crumb = result; + } else if (result === false) { + return this; + } + } + + this._breadcrumbs.push(crumb); + if (this._breadcrumbs.length > this._globalOptions.maxBreadcrumbs) { + this._breadcrumbs.shift(); + } + return this; + }, + + addPlugin: function(plugin /*arg1, arg2, ... argN*/) { + var pluginArgs = [].slice.call(arguments, 1); + + this._plugins.push([plugin, pluginArgs]); + if (this._isRavenInstalled) { + this._drainPlugins(); + } + + return this; + }, + + /* + * Set/clear a user to be sent along with the payload. + * + * @param {object} user An object representing user data [optional] + * @return {Raven} + */ + setUserContext: function(user) { + // Intentionally do not merge here since that's an unexpected behavior. + this._globalContext.user = user; + + return this; + }, + + /* + * Merge extra attributes to be sent along with the payload. + * + * @param {object} extra An object representing extra data [optional] + * @return {Raven} + */ + setExtraContext: function(extra) { + this._mergeContext('extra', extra); + + return this; + }, + + /* + * Merge tags to be sent along with the payload. + * + * @param {object} tags An object representing tags [optional] + * @return {Raven} + */ + setTagsContext: function(tags) { + this._mergeContext('tags', tags); + + return this; + }, + + /* + * Clear all of the context. + * + * @return {Raven} + */ + clearContext: function() { + this._globalContext = {}; + + return this; + }, + + /* + * Get a copy of the current context. This cannot be mutated. + * + * @return {object} copy of context + */ + getContext: function() { + // lol javascript + return JSON.parse(stringify(this._globalContext)); + }, + + /* + * Set environment of application + * + * @param {string} environment Typically something like 'production'. + * @return {Raven} + */ + setEnvironment: function(environment) { + this._globalOptions.environment = environment; + + return this; + }, + + /* + * Set release version of application + * + * @param {string} release Typically something like a git SHA to identify version + * @return {Raven} + */ + setRelease: function(release) { + this._globalOptions.release = release; + + return this; + }, + + /* + * Set the dataCallback option + * + * @param {function} callback The callback to run which allows the + * data blob to be mutated before sending + * @return {Raven} + */ + setDataCallback: function(callback) { + var original = this._globalOptions.dataCallback; + this._globalOptions.dataCallback = keepOriginalCallback(original, callback); + return this; + }, + + /* + * Set the breadcrumbCallback option + * + * @param {function} callback The callback to run which allows filtering + * or mutating breadcrumbs + * @return {Raven} + */ + setBreadcrumbCallback: function(callback) { + var original = this._globalOptions.breadcrumbCallback; + this._globalOptions.breadcrumbCallback = keepOriginalCallback(original, callback); + return this; + }, + + /* + * Set the shouldSendCallback option + * + * @param {function} callback The callback to run which allows + * introspecting the blob before sending + * @return {Raven} + */ + setShouldSendCallback: function(callback) { + var original = this._globalOptions.shouldSendCallback; + this._globalOptions.shouldSendCallback = keepOriginalCallback(original, callback); + return this; + }, + + /** + * Override the default HTTP transport mechanism that transmits data + * to the Sentry server. + * + * @param {function} transport Function invoked instead of the default + * `makeRequest` handler. + * + * @return {Raven} + */ + setTransport: function(transport) { + this._globalOptions.transport = transport; + + return this; + }, + + /* + * Get the latest raw exception that was captured by Raven. + * + * @return {error} + */ + lastException: function() { + return this._lastCapturedException; + }, + + /* + * Get the last event id + * + * @return {string} + */ + lastEventId: function() { + return this._lastEventId; + }, + + /* + * Determine if Raven is setup and ready to go. + * + * @return {boolean} + */ + isSetup: function() { + if (!this._hasJSON) return false; // needs JSON support + if (!this._globalServer) { + if (!this.ravenNotConfiguredError) { + this.ravenNotConfiguredError = true; + this._logDebug('error', 'Error: Raven has not been configured.'); + } + return false; + } + return true; + }, + + afterLoad: function() { + // TODO: remove window dependence? + + // Attempt to initialize Raven on load + var RavenConfig = _window.RavenConfig; + if (RavenConfig) { + this.config(RavenConfig.dsn, RavenConfig.config).install(); + } + }, + + showReportDialog: function(options) { + if ( + !_document // doesn't work without a document (React native) + ) + return; + + options = objectMerge( + { + eventId: this.lastEventId(), + dsn: this._dsn, + user: this._globalContext.user || {} + }, + options + ); + + if (!options.eventId) { + throw new RavenConfigError('Missing eventId'); + } + + if (!options.dsn) { + throw new RavenConfigError('Missing DSN'); + } + + var encode = encodeURIComponent; + var encodedOptions = []; + + for (var key in options) { + if (key === 'user') { + var user = options.user; + if (user.name) encodedOptions.push('name=' + encode(user.name)); + if (user.email) encodedOptions.push('email=' + encode(user.email)); + } else { + encodedOptions.push(encode(key) + '=' + encode(options[key])); + } + } + var globalServer = this._getGlobalServer(this._parseDSN(options.dsn)); + + var script = _document.createElement('script'); + script.async = true; + script.src = globalServer + '/api/embed/error-page/?' + encodedOptions.join('&'); + (_document.head || _document.body).appendChild(script); + }, + + /**** Private functions ****/ + _ignoreNextOnError: function() { + var self = this; + this._ignoreOnError += 1; + setTimeout(function() { + // onerror should trigger before setTimeout + self._ignoreOnError -= 1; + }); + }, + + _triggerEvent: function(eventType, options) { + // NOTE: `event` is a native browser thing, so let's avoid conflicting wiht it + var evt, key; + + if (!this._hasDocument) return; + + options = options || {}; + + eventType = 'raven' + eventType.substr(0, 1).toUpperCase() + eventType.substr(1); + + if (_document.createEvent) { + evt = _document.createEvent('HTMLEvents'); + evt.initEvent(eventType, true, true); + } else { + evt = _document.createEventObject(); + evt.eventType = eventType; + } + + for (key in options) + if (hasKey(options, key)) { + evt[key] = options[key]; + } + + if (_document.createEvent) { + // IE9 if standards + _document.dispatchEvent(evt); + } else { + // IE8 regardless of Quirks or Standards + // IE9 if quirks + try { + _document.fireEvent('on' + evt.eventType.toLowerCase(), evt); + } catch (e) { + // Do nothing + } + } + }, + + /** + * Wraps addEventListener to capture UI breadcrumbs + * @param evtName the event name (e.g. "click") + * @returns {Function} + * @private + */ + _breadcrumbEventHandler: function(evtName) { + var self = this; + return function(evt) { + // reset keypress timeout; e.g. triggering a 'click' after + // a 'keypress' will reset the keypress debounce so that a new + // set of keypresses can be recorded + self._keypressTimeout = null; + + // It's possible this handler might trigger multiple times for the same + // event (e.g. event propagation through node ancestors). Ignore if we've + // already captured the event. + if (self._lastCapturedEvent === evt) return; + + self._lastCapturedEvent = evt; + + // try/catch both: + // - accessing evt.target (see getsentry/raven-js#838, #768) + // - `htmlTreeAsString` because it's complex, and just accessing the DOM incorrectly + // can throw an exception in some circumstances. + var target; + try { + target = htmlTreeAsString(evt.target); + } catch (e) { + target = '<unknown>'; + } + + self.captureBreadcrumb({ + category: 'ui.' + evtName, // e.g. ui.click, ui.input + message: target + }); + }; + }, + + /** + * Wraps addEventListener to capture keypress UI events + * @returns {Function} + * @private + */ + _keypressEventHandler: function() { + var self = this, + debounceDuration = 1000; // milliseconds + + // TODO: if somehow user switches keypress target before + // debounce timeout is triggered, we will only capture + // a single breadcrumb from the FIRST target (acceptable?) + return function(evt) { + var target; + try { + target = evt.target; + } catch (e) { + // just accessing event properties can throw an exception in some rare circumstances + // see: https://github.com/getsentry/raven-js/issues/838 + return; + } + var tagName = target && target.tagName; + + // only consider keypress events on actual input elements + // this will disregard keypresses targeting body (e.g. tabbing + // through elements, hotkeys, etc) + if ( + !tagName || + (tagName !== 'INPUT' && tagName !== 'TEXTAREA' && !target.isContentEditable) + ) + return; + + // record first keypress in a series, but ignore subsequent + // keypresses until debounce clears + var timeout = self._keypressTimeout; + if (!timeout) { + self._breadcrumbEventHandler('input')(evt); + } + clearTimeout(timeout); + self._keypressTimeout = setTimeout(function() { + self._keypressTimeout = null; + }, debounceDuration); + }; + }, + + /** + * Captures a breadcrumb of type "navigation", normalizing input URLs + * @param to the originating URL + * @param from the target URL + * @private + */ + _captureUrlChange: function(from, to) { + var parsedLoc = parseUrl(this._location.href); + var parsedTo = parseUrl(to); + var parsedFrom = parseUrl(from); + + // because onpopstate only tells you the "new" (to) value of location.href, and + // not the previous (from) value, we need to track the value of the current URL + // state ourselves + this._lastHref = to; + + // Use only the path component of the URL if the URL matches the current + // document (almost all the time when using pushState) + if (parsedLoc.protocol === parsedTo.protocol && parsedLoc.host === parsedTo.host) + to = parsedTo.relative; + if (parsedLoc.protocol === parsedFrom.protocol && parsedLoc.host === parsedFrom.host) + from = parsedFrom.relative; + + this.captureBreadcrumb({ + category: 'navigation', + data: { + to: to, + from: from + } + }); + }, + + _patchFunctionToString: function() { + var self = this; + self._originalFunctionToString = Function.prototype.toString; + // eslint-disable-next-line no-extend-native + Function.prototype.toString = function() { + if (typeof this === 'function' && this.__raven__) { + return self._originalFunctionToString.apply(this.__orig__, arguments); + } + return self._originalFunctionToString.apply(this, arguments); + }; + }, + + _unpatchFunctionToString: function() { + if (this._originalFunctionToString) { + // eslint-disable-next-line no-extend-native + Function.prototype.toString = this._originalFunctionToString; + } + }, + + /** + * Wrap timer functions and event targets to catch errors and provide + * better metadata. + */ + _instrumentTryCatch: function() { + var self = this; + + var wrappedBuiltIns = self._wrappedBuiltIns; + + function wrapTimeFn(orig) { + return function(fn, t) { + // preserve arity + // Make a copy of the arguments to prevent deoptimization + // https://github.com/petkaantonov/bluebird/wiki/Optimization-killers#32-leaking-arguments + var args = new Array(arguments.length); + for (var i = 0; i < args.length; ++i) { + args[i] = arguments[i]; + } + var originalCallback = args[0]; + if (isFunction(originalCallback)) { + args[0] = self.wrap( + { + mechanism: { + type: 'instrument', + data: {function: orig.name || '<anonymous>'} + } + }, + originalCallback + ); + } + + // IE < 9 doesn't support .call/.apply on setInterval/setTimeout, but it + // also supports only two arguments and doesn't care what this is, so we + // can just call the original function directly. + if (orig.apply) { + return orig.apply(this, args); + } else { + return orig(args[0], args[1]); + } + }; + } + + var autoBreadcrumbs = this._globalOptions.autoBreadcrumbs; + + function wrapEventTarget(global) { + var proto = _window[global] && _window[global].prototype; + if (proto && proto.hasOwnProperty && proto.hasOwnProperty('addEventListener')) { + fill( + proto, + 'addEventListener', + function(orig) { + return function(evtName, fn, capture, secure) { + // preserve arity + try { + if (fn && fn.handleEvent) { + fn.handleEvent = self.wrap( + { + mechanism: { + type: 'instrument', + data: { + target: global, + function: 'handleEvent', + handler: (fn && fn.name) || '<anonymous>' + } + } + }, + fn.handleEvent + ); + } + } catch (err) { + // can sometimes get 'Permission denied to access property "handle Event' + } + + // More breadcrumb DOM capture ... done here and not in `_instrumentBreadcrumbs` + // so that we don't have more than one wrapper function + var before, clickHandler, keypressHandler; + + if ( + autoBreadcrumbs && + autoBreadcrumbs.dom && + (global === 'EventTarget' || global === 'Node') + ) { + // NOTE: generating multiple handlers per addEventListener invocation, should + // revisit and verify we can just use one (almost certainly) + clickHandler = self._breadcrumbEventHandler('click'); + keypressHandler = self._keypressEventHandler(); + before = function(evt) { + // need to intercept every DOM event in `before` argument, in case that + // same wrapped method is re-used for different events (e.g. mousemove THEN click) + // see #724 + if (!evt) return; + + var eventType; + try { + eventType = evt.type; + } catch (e) { + // just accessing event properties can throw an exception in some rare circumstances + // see: https://github.com/getsentry/raven-js/issues/838 + return; + } + if (eventType === 'click') return clickHandler(evt); + else if (eventType === 'keypress') return keypressHandler(evt); + }; + } + return orig.call( + this, + evtName, + self.wrap( + { + mechanism: { + type: 'instrument', + data: { + target: global, + function: 'addEventListener', + handler: (fn && fn.name) || '<anonymous>' + } + } + }, + fn, + before + ), + capture, + secure + ); + }; + }, + wrappedBuiltIns + ); + fill( + proto, + 'removeEventListener', + function(orig) { + return function(evt, fn, capture, secure) { + try { + fn = fn && (fn.__raven_wrapper__ ? fn.__raven_wrapper__ : fn); + } catch (e) { + // ignore, accessing __raven_wrapper__ will throw in some Selenium environments + } + return orig.call(this, evt, fn, capture, secure); + }; + }, + wrappedBuiltIns + ); + } + } + + fill(_window, 'setTimeout', wrapTimeFn, wrappedBuiltIns); + fill(_window, 'setInterval', wrapTimeFn, wrappedBuiltIns); + if (_window.requestAnimationFrame) { + fill( + _window, + 'requestAnimationFrame', + function(orig) { + return function(cb) { + return orig( + self.wrap( + { + mechanism: { + type: 'instrument', + data: { + function: 'requestAnimationFrame', + handler: (orig && orig.name) || '<anonymous>' + } + } + }, + cb + ) + ); + }; + }, + wrappedBuiltIns + ); + } + + // event targets borrowed from bugsnag-js: + // https://github.com/bugsnag/bugsnag-js/blob/master/src/bugsnag.js#L666 + var eventTargets = [ + 'EventTarget', + 'Window', + 'Node', + 'ApplicationCache', + 'AudioTrackList', + 'ChannelMergerNode', + 'CryptoOperation', + 'EventSource', + 'FileReader', + 'HTMLUnknownElement', + 'IDBDatabase', + 'IDBRequest', + 'IDBTransaction', + 'KeyOperation', + 'MediaController', + 'MessagePort', + 'ModalWindow', + 'Notification', + 'SVGElementInstance', + 'Screen', + 'TextTrack', + 'TextTrackCue', + 'TextTrackList', + 'WebSocket', + 'WebSocketWorker', + 'Worker', + 'XMLHttpRequest', + 'XMLHttpRequestEventTarget', + 'XMLHttpRequestUpload' + ]; + for (var i = 0; i < eventTargets.length; i++) { + wrapEventTarget(eventTargets[i]); + } + }, + + /** + * Instrument browser built-ins w/ breadcrumb capturing + * - XMLHttpRequests + * - DOM interactions (click/typing) + * - window.location changes + * - console + * + * Can be disabled or individually configured via the `autoBreadcrumbs` config option + */ + _instrumentBreadcrumbs: function() { + var self = this; + var autoBreadcrumbs = this._globalOptions.autoBreadcrumbs; + + var wrappedBuiltIns = self._wrappedBuiltIns; + + function wrapProp(prop, xhr) { + if (prop in xhr && isFunction(xhr[prop])) { + fill(xhr, prop, function(orig) { + return self.wrap( + { + mechanism: { + type: 'instrument', + data: {function: prop, handler: (orig && orig.name) || '<anonymous>'} + } + }, + orig + ); + }); // intentionally don't track filled methods on XHR instances + } + } + + if (autoBreadcrumbs.xhr && 'XMLHttpRequest' in _window) { + var xhrproto = _window.XMLHttpRequest && _window.XMLHttpRequest.prototype; + fill( + xhrproto, + 'open', + function(origOpen) { + return function(method, url) { + // preserve arity + + // if Sentry key appears in URL, don't capture + if (isString(url) && url.indexOf(self._globalKey) === -1) { + this.__raven_xhr = { + method: method, + url: url, + status_code: null + }; + } + + return origOpen.apply(this, arguments); + }; + }, + wrappedBuiltIns + ); + + fill( + xhrproto, + 'send', + function(origSend) { + return function() { + // preserve arity + var xhr = this; + + function onreadystatechangeHandler() { + if (xhr.__raven_xhr && xhr.readyState === 4) { + try { + // touching statusCode in some platforms throws + // an exception + xhr.__raven_xhr.status_code = xhr.status; + } catch (e) { + /* do nothing */ + } + + self.captureBreadcrumb({ + type: 'http', + category: 'xhr', + data: xhr.__raven_xhr + }); + } + } + + var props = ['onload', 'onerror', 'onprogress']; + for (var j = 0; j < props.length; j++) { + wrapProp(props[j], xhr); + } + + if ('onreadystatechange' in xhr && isFunction(xhr.onreadystatechange)) { + fill( + xhr, + 'onreadystatechange', + function(orig) { + return self.wrap( + { + mechanism: { + type: 'instrument', + data: { + function: 'onreadystatechange', + handler: (orig && orig.name) || '<anonymous>' + } + } + }, + orig, + onreadystatechangeHandler + ); + } /* intentionally don't track this instrumentation */ + ); + } else { + // if onreadystatechange wasn't actually set by the page on this xhr, we + // are free to set our own and capture the breadcrumb + xhr.onreadystatechange = onreadystatechangeHandler; + } + + return origSend.apply(this, arguments); + }; + }, + wrappedBuiltIns + ); + } + + if (autoBreadcrumbs.xhr && supportsFetch()) { + fill( + _window, + 'fetch', + function(origFetch) { + return function() { + // preserve arity + // Make a copy of the arguments to prevent deoptimization + // https://github.com/petkaantonov/bluebird/wiki/Optimization-killers#32-leaking-arguments + var args = new Array(arguments.length); + for (var i = 0; i < args.length; ++i) { + args[i] = arguments[i]; + } + + var fetchInput = args[0]; + var method = 'GET'; + var url; + + if (typeof fetchInput === 'string') { + url = fetchInput; + } else if ('Request' in _window && fetchInput instanceof _window.Request) { + url = fetchInput.url; + if (fetchInput.method) { + method = fetchInput.method; + } + } else { + url = '' + fetchInput; + } + + // if Sentry key appears in URL, don't capture, as it's our own request + if (url.indexOf(self._globalKey) !== -1) { + return origFetch.apply(this, args); + } + + if (args[1] && args[1].method) { + method = args[1].method; + } + + var fetchData = { + method: method, + url: url, + status_code: null + }; + + return origFetch + .apply(this, args) + .then(function(response) { + fetchData.status_code = response.status; + + self.captureBreadcrumb({ + type: 'http', + category: 'fetch', + data: fetchData + }); + + return response; + }) + ['catch'](function(err) { + // if there is an error performing the request + self.captureBreadcrumb({ + type: 'http', + category: 'fetch', + data: fetchData, + level: 'error' + }); + + throw err; + }); + }; + }, + wrappedBuiltIns + ); + } + + // Capture breadcrumbs from any click that is unhandled / bubbled up all the way + // to the document. Do this before we instrument addEventListener. + if (autoBreadcrumbs.dom && this._hasDocument) { + if (_document.addEventListener) { + _document.addEventListener('click', self._breadcrumbEventHandler('click'), false); + _document.addEventListener('keypress', self._keypressEventHandler(), false); + } else if (_document.attachEvent) { + // IE8 Compatibility + _document.attachEvent('onclick', self._breadcrumbEventHandler('click')); + _document.attachEvent('onkeypress', self._keypressEventHandler()); + } + } + + // record navigation (URL) changes + // NOTE: in Chrome App environment, touching history.pushState, *even inside + // a try/catch block*, will cause Chrome to output an error to console.error + // borrowed from: https://github.com/angular/angular.js/pull/13945/files + var chrome = _window.chrome; + var isChromePackagedApp = chrome && chrome.app && chrome.app.runtime; + var hasPushAndReplaceState = + !isChromePackagedApp && + _window.history && + _window.history.pushState && + _window.history.replaceState; + if (autoBreadcrumbs.location && hasPushAndReplaceState) { + // TODO: remove onpopstate handler on uninstall() + var oldOnPopState = _window.onpopstate; + _window.onpopstate = function() { + var currentHref = self._location.href; + self._captureUrlChange(self._lastHref, currentHref); + + if (oldOnPopState) { + return oldOnPopState.apply(this, arguments); + } + }; + + var historyReplacementFunction = function(origHistFunction) { + // note history.pushState.length is 0; intentionally not declaring + // params to preserve 0 arity + return function(/* state, title, url */) { + var url = arguments.length > 2 ? arguments[2] : undefined; + + // url argument is optional + if (url) { + // coerce to string (this is what pushState does) + self._captureUrlChange(self._lastHref, url + ''); + } + + return origHistFunction.apply(this, arguments); + }; + }; + + fill(_window.history, 'pushState', historyReplacementFunction, wrappedBuiltIns); + fill(_window.history, 'replaceState', historyReplacementFunction, wrappedBuiltIns); + } + + if (autoBreadcrumbs.console && 'console' in _window && console.log) { + // console + var consoleMethodCallback = function(msg, data) { + self.captureBreadcrumb({ + message: msg, + level: data.level, + category: 'console' + }); + }; + + each(['debug', 'info', 'warn', 'error', 'log'], function(_, level) { + wrapConsoleMethod(console, level, consoleMethodCallback); + }); + } + }, + + _restoreBuiltIns: function() { + // restore any wrapped builtins + var builtin; + while (this._wrappedBuiltIns.length) { + builtin = this._wrappedBuiltIns.shift(); + + var obj = builtin[0], + name = builtin[1], + orig = builtin[2]; + + obj[name] = orig; + } + }, + + _restoreConsole: function() { + // eslint-disable-next-line guard-for-in + for (var method in this._originalConsoleMethods) { + this._originalConsole[method] = this._originalConsoleMethods[method]; + } + }, + + _drainPlugins: function() { + var self = this; + + // FIX ME TODO + each(this._plugins, function(_, plugin) { + var installer = plugin[0]; + var args = plugin[1]; + installer.apply(self, [self].concat(args)); + }); + }, + + _parseDSN: function(str) { + var m = dsnPattern.exec(str), + dsn = {}, + i = 7; + + try { + while (i--) dsn[dsnKeys[i]] = m[i] || ''; + } catch (e) { + throw new RavenConfigError('Invalid DSN: ' + str); + } + + if (dsn.pass && !this._globalOptions.allowSecretKey) { + throw new RavenConfigError( + 'Do not specify your secret key in the DSN. See: http://bit.ly/raven-secret-key' + ); + } + + return dsn; + }, + + _getGlobalServer: function(uri) { + // assemble the endpoint from the uri pieces + var globalServer = '//' + uri.host + (uri.port ? ':' + uri.port : ''); + + if (uri.protocol) { + globalServer = uri.protocol + ':' + globalServer; + } + return globalServer; + }, + + _handleOnErrorStackInfo: function(stackInfo, options) { + options = options || {}; + options.mechanism = options.mechanism || { + type: 'onerror', + handled: false + }; + + // if we are intentionally ignoring errors via onerror, bail out + if (!this._ignoreOnError) { + this._handleStackInfo(stackInfo, options); + } + }, + + _handleStackInfo: function(stackInfo, options) { + var frames = this._prepareFrames(stackInfo, options); + + this._triggerEvent('handle', { + stackInfo: stackInfo, + options: options + }); + + this._processException( + stackInfo.name, + stackInfo.message, + stackInfo.url, + stackInfo.lineno, + frames, + options + ); + }, + + _prepareFrames: function(stackInfo, options) { + var self = this; + var frames = []; + if (stackInfo.stack && stackInfo.stack.length) { + each(stackInfo.stack, function(i, stack) { + var frame = self._normalizeFrame(stack, stackInfo.url); + if (frame) { + frames.push(frame); + } + }); + + // e.g. frames captured via captureMessage throw + if (options && options.trimHeadFrames) { + for (var j = 0; j < options.trimHeadFrames && j < frames.length; j++) { + frames[j].in_app = false; + } + } + } + frames = frames.slice(0, this._globalOptions.stackTraceLimit); + return frames; + }, + + _normalizeFrame: function(frame, stackInfoUrl) { + // normalize the frames data + var normalized = { + filename: frame.url, + lineno: frame.line, + colno: frame.column, + function: frame.func || '?' + }; + + // Case when we don't have any information about the error + // E.g. throwing a string or raw object, instead of an `Error` in Firefox + // Generating synthetic error doesn't add any value here + // + // We should probably somehow let a user know that they should fix their code + if (!frame.url) { + normalized.filename = stackInfoUrl; // fallback to whole stacks url from onerror handler + } + + normalized.in_app = !// determine if an exception came from outside of our app + // first we check the global includePaths list. + ( + (!!this._globalOptions.includePaths.test && + !this._globalOptions.includePaths.test(normalized.filename)) || + // Now we check for fun, if the function name is Raven or TraceKit + /(Raven|TraceKit)\./.test(normalized['function']) || + // finally, we do a last ditch effort and check for raven.min.js + /raven\.(min\.)?js$/.test(normalized.filename) + ); + + return normalized; + }, + + _processException: function(type, message, fileurl, lineno, frames, options) { + var prefixedMessage = (type ? type + ': ' : '') + (message || ''); + if ( + !!this._globalOptions.ignoreErrors.test && + (this._globalOptions.ignoreErrors.test(message) || + this._globalOptions.ignoreErrors.test(prefixedMessage)) + ) { + return; + } + + var stacktrace; + + if (frames && frames.length) { + fileurl = frames[0].filename || fileurl; + // Sentry expects frames oldest to newest + // and JS sends them as newest to oldest + frames.reverse(); + stacktrace = {frames: frames}; + } else if (fileurl) { + stacktrace = { + frames: [ + { + filename: fileurl, + lineno: lineno, + in_app: true + } + ] + }; + } + + if ( + !!this._globalOptions.ignoreUrls.test && + this._globalOptions.ignoreUrls.test(fileurl) + ) { + return; + } + + if ( + !!this._globalOptions.whitelistUrls.test && + !this._globalOptions.whitelistUrls.test(fileurl) + ) { + return; + } + + var data = objectMerge( + { + // sentry.interfaces.Exception + exception: { + values: [ + { + type: type, + value: message, + stacktrace: stacktrace + } + ] + }, + transaction: fileurl + }, + options + ); + + var ex = data.exception.values[0]; + if (ex.type == null && ex.value === '') { + ex.value = 'Unrecoverable error caught'; + } + + // Move mechanism from options to exception interface + // We do this, as requiring user to pass `{exception:{mechanism:{ ... }}}` would be + // too much + if (!data.exception.mechanism && data.mechanism) { + data.exception.mechanism = data.mechanism; + delete data.mechanism; + } + + data.exception.mechanism = objectMerge( + { + type: 'generic', + handled: true + }, + data.exception.mechanism || {} + ); + + // Fire away! + this._send(data); + }, + + _trimPacket: function(data) { + // For now, we only want to truncate the two different messages + // but this could/should be expanded to just trim everything + var max = this._globalOptions.maxMessageLength; + if (data.message) { + data.message = truncate(data.message, max); + } + if (data.exception) { + var exception = data.exception.values[0]; + exception.value = truncate(exception.value, max); + } + + var request = data.request; + if (request) { + if (request.url) { + request.url = truncate(request.url, this._globalOptions.maxUrlLength); + } + if (request.Referer) { + request.Referer = truncate(request.Referer, this._globalOptions.maxUrlLength); + } + } + + if (data.breadcrumbs && data.breadcrumbs.values) + this._trimBreadcrumbs(data.breadcrumbs); + + return data; + }, + + /** + * Truncate breadcrumb values (right now just URLs) + */ + _trimBreadcrumbs: function(breadcrumbs) { + // known breadcrumb properties with urls + // TODO: also consider arbitrary prop values that start with (https?)?:// + var urlProps = ['to', 'from', 'url'], + urlProp, + crumb, + data; + + for (var i = 0; i < breadcrumbs.values.length; ++i) { + crumb = breadcrumbs.values[i]; + if ( + !crumb.hasOwnProperty('data') || + !isObject(crumb.data) || + objectFrozen(crumb.data) + ) + continue; + + data = objectMerge({}, crumb.data); + for (var j = 0; j < urlProps.length; ++j) { + urlProp = urlProps[j]; + if (data.hasOwnProperty(urlProp) && data[urlProp]) { + data[urlProp] = truncate(data[urlProp], this._globalOptions.maxUrlLength); + } + } + breadcrumbs.values[i].data = data; + } + }, + + _getHttpData: function() { + if (!this._hasNavigator && !this._hasDocument) return; + var httpData = {}; + + if (this._hasNavigator && _navigator.userAgent) { + httpData.headers = { + 'User-Agent': _navigator.userAgent + }; + } + + // Check in `window` instead of `document`, as we may be in ServiceWorker environment + if (_window.location && _window.location.href) { + httpData.url = _window.location.href; + } + + if (this._hasDocument && _document.referrer) { + if (!httpData.headers) httpData.headers = {}; + httpData.headers.Referer = _document.referrer; + } + + return httpData; + }, + + _resetBackoff: function() { + this._backoffDuration = 0; + this._backoffStart = null; + }, + + _shouldBackoff: function() { + return this._backoffDuration && now() - this._backoffStart < this._backoffDuration; + }, + + /** + * Returns true if the in-process data payload matches the signature + * of the previously-sent data + * + * NOTE: This has to be done at this level because TraceKit can generate + * data from window.onerror WITHOUT an exception object (IE8, IE9, + * other old browsers). This can take the form of an "exception" + * data object with a single frame (derived from the onerror args). + */ + _isRepeatData: function(current) { + var last = this._lastData; + + if ( + !last || + current.message !== last.message || // defined for captureMessage + current.transaction !== last.transaction // defined for captureException/onerror + ) + return false; + + // Stacktrace interface (i.e. from captureMessage) + if (current.stacktrace || last.stacktrace) { + return isSameStacktrace(current.stacktrace, last.stacktrace); + } else if (current.exception || last.exception) { + // Exception interface (i.e. from captureException/onerror) + return isSameException(current.exception, last.exception); + } + + return true; + }, + + _setBackoffState: function(request) { + // If we are already in a backoff state, don't change anything + if (this._shouldBackoff()) { + return; + } + + var status = request.status; + + // 400 - project_id doesn't exist or some other fatal + // 401 - invalid/revoked dsn + // 429 - too many requests + if (!(status === 400 || status === 401 || status === 429)) return; + + var retry; + try { + // If Retry-After is not in Access-Control-Expose-Headers, most + // browsers will throw an exception trying to access it + if (supportsFetch()) { + retry = request.headers.get('Retry-After'); + } else { + retry = request.getResponseHeader('Retry-After'); + } + + // Retry-After is returned in seconds + retry = parseInt(retry, 10) * 1000; + } catch (e) { + /* eslint no-empty:0 */ + } + + this._backoffDuration = retry + ? // If Sentry server returned a Retry-After value, use it + retry + : // Otherwise, double the last backoff duration (starts at 1 sec) + this._backoffDuration * 2 || 1000; + + this._backoffStart = now(); + }, + + _send: function(data) { + var globalOptions = this._globalOptions; + + var baseData = { + project: this._globalProject, + logger: globalOptions.logger, + platform: 'javascript' + }, + httpData = this._getHttpData(); + + if (httpData) { + baseData.request = httpData; + } + + // HACK: delete `trimHeadFrames` to prevent from appearing in outbound payload + if (data.trimHeadFrames) delete data.trimHeadFrames; + + data = objectMerge(baseData, data); + + // Merge in the tags and extra separately since objectMerge doesn't handle a deep merge + data.tags = objectMerge(objectMerge({}, this._globalContext.tags), data.tags); + data.extra = objectMerge(objectMerge({}, this._globalContext.extra), data.extra); + + // Send along our own collected metadata with extra + data.extra['session:duration'] = now() - this._startTime; + + if (this._breadcrumbs && this._breadcrumbs.length > 0) { + // intentionally make shallow copy so that additions + // to breadcrumbs aren't accidentally sent in this request + data.breadcrumbs = { + values: [].slice.call(this._breadcrumbs, 0) + }; + } + + if (this._globalContext.user) { + // sentry.interfaces.User + data.user = this._globalContext.user; + } + + // Include the environment if it's defined in globalOptions + if (globalOptions.environment) data.environment = globalOptions.environment; + + // Include the release if it's defined in globalOptions + if (globalOptions.release) data.release = globalOptions.release; + + // Include server_name if it's defined in globalOptions + if (globalOptions.serverName) data.server_name = globalOptions.serverName; + + data = this._sanitizeData(data); + + // Cleanup empty properties before sending them to the server + Object.keys(data).forEach(function(key) { + if (data[key] == null || data[key] === '' || isEmptyObject(data[key])) { + delete data[key]; + } + }); + + if (isFunction(globalOptions.dataCallback)) { + data = globalOptions.dataCallback(data) || data; + } + + // Why?????????? + if (!data || isEmptyObject(data)) { + return; + } + + // Check if the request should be filtered or not + if ( + isFunction(globalOptions.shouldSendCallback) && + !globalOptions.shouldSendCallback(data) + ) { + return; + } + + // Backoff state: Sentry server previously responded w/ an error (e.g. 429 - too many requests), + // so drop requests until "cool-off" period has elapsed. + if (this._shouldBackoff()) { + this._logDebug('warn', 'Raven dropped error due to backoff: ', data); + return; + } + + if (typeof globalOptions.sampleRate === 'number') { + if (Math.random() < globalOptions.sampleRate) { + this._sendProcessedPayload(data); + } + } else { + this._sendProcessedPayload(data); + } + }, + + _sanitizeData: function(data) { + return sanitize(data, this._globalOptions.sanitizeKeys); + }, + + _getUuid: function() { + return uuid4(); + }, + + _sendProcessedPayload: function(data, callback) { + var self = this; + var globalOptions = this._globalOptions; + + if (!this.isSetup()) return; + + // Try and clean up the packet before sending by truncating long values + data = this._trimPacket(data); + + // ideally duplicate error testing should occur *before* dataCallback/shouldSendCallback, + // but this would require copying an un-truncated copy of the data packet, which can be + // arbitrarily deep (extra_data) -- could be worthwhile? will revisit + if (!this._globalOptions.allowDuplicates && this._isRepeatData(data)) { + this._logDebug('warn', 'Raven dropped repeat event: ', data); + return; + } + + // Send along an event_id if not explicitly passed. + // This event_id can be used to reference the error within Sentry itself. + // Set lastEventId after we know the error should actually be sent + this._lastEventId = data.event_id || (data.event_id = this._getUuid()); + + // Store outbound payload after trim + this._lastData = data; + + this._logDebug('debug', 'Raven about to send:', data); + + var auth = { + sentry_version: '7', + sentry_client: 'raven-js/' + this.VERSION, + sentry_key: this._globalKey + }; + + if (this._globalSecret) { + auth.sentry_secret = this._globalSecret; + } + + var exception = data.exception && data.exception.values[0]; + + // only capture 'sentry' breadcrumb is autoBreadcrumbs is truthy + if ( + this._globalOptions.autoBreadcrumbs && + this._globalOptions.autoBreadcrumbs.sentry + ) { + this.captureBreadcrumb({ + category: 'sentry', + message: exception + ? (exception.type ? exception.type + ': ' : '') + exception.value + : data.message, + event_id: data.event_id, + level: data.level || 'error' // presume error unless specified + }); + } + + var url = this._globalEndpoint; + (globalOptions.transport || this._makeRequest).call(this, { + url: url, + auth: auth, + data: data, + options: globalOptions, + onSuccess: function success() { + self._resetBackoff(); + + self._triggerEvent('success', { + data: data, + src: url + }); + callback && callback(); + }, + onError: function failure(error) { + self._logDebug('error', 'Raven transport failed to send: ', error); + + if (error.request) { + self._setBackoffState(error.request); + } + + self._triggerEvent('failure', { + data: data, + src: url + }); + error = error || new Error('Raven send failed (no additional details provided)'); + callback && callback(error); + } + }); + }, + + _makeRequest: function(opts) { + // Auth is intentionally sent as part of query string (NOT as custom HTTP header) to avoid preflight CORS requests + var url = opts.url + '?' + urlencode(opts.auth); + + var evaluatedHeaders = null; + var evaluatedFetchParameters = {}; + + if (opts.options.headers) { + evaluatedHeaders = this._evaluateHash(opts.options.headers); + } + + if (opts.options.fetchParameters) { + evaluatedFetchParameters = this._evaluateHash(opts.options.fetchParameters); + } + + if (supportsFetch()) { + evaluatedFetchParameters.body = stringify(opts.data); + + var defaultFetchOptions = objectMerge({}, this._fetchDefaults); + var fetchOptions = objectMerge(defaultFetchOptions, evaluatedFetchParameters); + + if (evaluatedHeaders) { + fetchOptions.headers = evaluatedHeaders; + } + + return _window + .fetch(url, fetchOptions) + .then(function(response) { + if (response.ok) { + opts.onSuccess && opts.onSuccess(); + } else { + var error = new Error('Sentry error code: ' + response.status); + // It's called request only to keep compatibility with XHR interface + // and not add more redundant checks in setBackoffState method + error.request = response; + opts.onError && opts.onError(error); + } + }) + ['catch'](function() { + opts.onError && + opts.onError(new Error('Sentry error code: network unavailable')); + }); + } + + var request = _window.XMLHttpRequest && new _window.XMLHttpRequest(); + if (!request) return; + + // if browser doesn't support CORS (e.g. IE7), we are out of luck + var hasCORS = 'withCredentials' in request || typeof XDomainRequest !== 'undefined'; + + if (!hasCORS) return; + + if ('withCredentials' in request) { + request.onreadystatechange = function() { + if (request.readyState !== 4) { + return; + } else if (request.status === 200) { + opts.onSuccess && opts.onSuccess(); + } else if (opts.onError) { + var err = new Error('Sentry error code: ' + request.status); + err.request = request; + opts.onError(err); + } + }; + } else { + request = new XDomainRequest(); + // xdomainrequest cannot go http -> https (or vice versa), + // so always use protocol relative + url = url.replace(/^https?:/, ''); + + // onreadystatechange not supported by XDomainRequest + if (opts.onSuccess) { + request.onload = opts.onSuccess; + } + if (opts.onError) { + request.onerror = function() { + var err = new Error('Sentry error code: XDomainRequest'); + err.request = request; + opts.onError(err); + }; + } + } + + request.open('POST', url); + + if (evaluatedHeaders) { + each(evaluatedHeaders, function(key, value) { + request.setRequestHeader(key, value); + }); + } + + request.send(stringify(opts.data)); + }, + + _evaluateHash: function(hash) { + var evaluated = {}; + + for (var key in hash) { + if (hash.hasOwnProperty(key)) { + var value = hash[key]; + evaluated[key] = typeof value === 'function' ? value() : value; + } + } + + return evaluated; + }, + + _logDebug: function(level) { + // We allow `Raven.debug` and `Raven.config(DSN, { debug: true })` to not make backward incompatible API change + if ( + this._originalConsoleMethods[level] && + (this.debug || this._globalOptions.debug) + ) { + // In IE<10 console methods do not have their own 'apply' method + Function.prototype.apply.call( + this._originalConsoleMethods[level], + this._originalConsole, + [].slice.call(arguments, 1) + ); + } + }, + + _mergeContext: function(key, context) { + if (isUndefined(context)) { + delete this._globalContext[key]; + } else { + this._globalContext[key] = objectMerge(this._globalContext[key] || {}, context); + } + } +}; + +// Deprecations +Raven.prototype.setUser = Raven.prototype.setUserContext; +Raven.prototype.setReleaseContext = Raven.prototype.setRelease; + +module.exports = Raven; + +}).call(this,typeof global !== "undefined" ? global : typeof self !== "undefined" ? self : typeof window !== "undefined" ? window : {}) +},{"1":1,"2":2,"5":5,"6":6,"7":7,"8":8}],4:[function(_dereq_,module,exports){ +(function (global){ +/** + * Enforces a single instance of the Raven client, and the + * main entry point for Raven. If you are a consumer of the + * Raven library, you SHOULD load this file (vs raven.js). + **/ + +var RavenConstructor = _dereq_(3); + +// This is to be defensive in environments where window does not exist (see https://github.com/getsentry/raven-js/pull/785) +var _window = + typeof window !== 'undefined' + ? window + : typeof global !== 'undefined' ? global : typeof self !== 'undefined' ? self : {}; +var _Raven = _window.Raven; + +var Raven = new RavenConstructor(); + +/* + * Allow multiple versions of Raven to be installed. + * Strip Raven from the global context and returns the instance. + * + * @return {Raven} + */ +Raven.noConflict = function() { + _window.Raven = _Raven; + return Raven; +}; + +Raven.afterLoad(); + +module.exports = Raven; + +/** + * DISCLAIMER: + * + * Expose `Client` constructor for cases where user want to track multiple "sub-applications" in one larger app. + * It's not meant to be used by a wide audience, so pleaaase make sure that you know what you're doing before using it. + * Accidentally calling `install` multiple times, may result in an unexpected behavior that's very hard to debug. + * + * It's called `Client' to be in-line with Raven Node implementation. + * + * HOWTO: + * + * import Raven from 'raven-js'; + * + * const someAppReporter = new Raven.Client(); + * const someOtherAppReporter = new Raven.Client(); + * + * someAppReporter.config('__DSN__', { + * ...config goes here + * }); + * + * someOtherAppReporter.config('__OTHER_DSN__', { + * ...config goes here + * }); + * + * someAppReporter.captureMessage(...); + * someAppReporter.captureException(...); + * someAppReporter.captureBreadcrumb(...); + * + * someOtherAppReporter.captureMessage(...); + * someOtherAppReporter.captureException(...); + * someOtherAppReporter.captureBreadcrumb(...); + * + * It should "just work". + */ +module.exports.Client = RavenConstructor; + +}).call(this,typeof global !== "undefined" ? global : typeof self !== "undefined" ? self : typeof window !== "undefined" ? window : {}) +},{"3":3}],5:[function(_dereq_,module,exports){ +(function (global){ +var stringify = _dereq_(7); + +var _window = + typeof window !== 'undefined' + ? window + : typeof global !== 'undefined' + ? global + : typeof self !== 'undefined' + ? self + : {}; + +function isObject(what) { + return typeof what === 'object' && what !== null; +} + +// Yanked from https://git.io/vS8DV re-used under CC0 +// with some tiny modifications +function isError(value) { + switch (Object.prototype.toString.call(value)) { + case '[object Error]': + return true; + case '[object Exception]': + return true; + case '[object DOMException]': + return true; + default: + return value instanceof Error; + } +} + +function isErrorEvent(value) { + return Object.prototype.toString.call(value) === '[object ErrorEvent]'; +} + +function isDOMError(value) { + return Object.prototype.toString.call(value) === '[object DOMError]'; +} + +function isDOMException(value) { + return Object.prototype.toString.call(value) === '[object DOMException]'; +} + +function isUndefined(what) { + return what === void 0; +} + +function isFunction(what) { + return typeof what === 'function'; +} + +function isPlainObject(what) { + return Object.prototype.toString.call(what) === '[object Object]'; +} + +function isString(what) { + return Object.prototype.toString.call(what) === '[object String]'; +} + +function isArray(what) { + return Object.prototype.toString.call(what) === '[object Array]'; +} + +function isEmptyObject(what) { + if (!isPlainObject(what)) return false; + + for (var _ in what) { + if (what.hasOwnProperty(_)) { + return false; + } + } + return true; +} + +function supportsErrorEvent() { + try { + new ErrorEvent(''); // eslint-disable-line no-new + return true; + } catch (e) { + return false; + } +} + +function supportsDOMError() { + try { + new DOMError(''); // eslint-disable-line no-new + return true; + } catch (e) { + return false; + } +} + +function supportsDOMException() { + try { + new DOMException(''); // eslint-disable-line no-new + return true; + } catch (e) { + return false; + } +} + +function supportsFetch() { + if (!('fetch' in _window)) return false; + + try { + new Headers(); // eslint-disable-line no-new + new Request(''); // eslint-disable-line no-new + new Response(); // eslint-disable-line no-new + return true; + } catch (e) { + return false; + } +} + +// Despite all stars in the sky saying that Edge supports old draft syntax, aka 'never', 'always', 'origin' and 'default +// https://caniuse.com/#feat=referrer-policy +// It doesn't. And it throw exception instead of ignoring this parameter... +// REF: https://github.com/getsentry/raven-js/issues/1233 +function supportsReferrerPolicy() { + if (!supportsFetch()) return false; + + try { + // eslint-disable-next-line no-new + new Request('pickleRick', { + referrerPolicy: 'origin' + }); + return true; + } catch (e) { + return false; + } +} + +function supportsPromiseRejectionEvent() { + return typeof PromiseRejectionEvent === 'function'; +} + +function wrappedCallback(callback) { + function dataCallback(data, original) { + var normalizedData = callback(data) || data; + if (original) { + return original(normalizedData) || normalizedData; + } + return normalizedData; + } + + return dataCallback; +} + +function each(obj, callback) { + var i, j; + + if (isUndefined(obj.length)) { + for (i in obj) { + if (hasKey(obj, i)) { + callback.call(null, i, obj[i]); + } + } + } else { + j = obj.length; + if (j) { + for (i = 0; i < j; i++) { + callback.call(null, i, obj[i]); + } + } + } +} + +function objectMerge(obj1, obj2) { + if (!obj2) { + return obj1; + } + each(obj2, function(key, value) { + obj1[key] = value; + }); + return obj1; +} + +/** + * This function is only used for react-native. + * react-native freezes object that have already been sent over the + * js bridge. We need this function in order to check if the object is frozen. + * So it's ok that objectFrozen returns false if Object.isFrozen is not + * supported because it's not relevant for other "platforms". See related issue: + * https://github.com/getsentry/react-native-sentry/issues/57 + */ +function objectFrozen(obj) { + if (!Object.isFrozen) { + return false; + } + return Object.isFrozen(obj); +} + +function truncate(str, max) { + if (typeof max !== 'number') { + throw new Error('2nd argument to `truncate` function should be a number'); + } + if (typeof str !== 'string' || max === 0) { + return str; + } + return str.length <= max ? str : str.substr(0, max) + '\u2026'; +} + +/** + * hasKey, a better form of hasOwnProperty + * Example: hasKey(MainHostObject, property) === true/false + * + * @param {Object} host object to check property + * @param {string} key to check + */ +function hasKey(object, key) { + return Object.prototype.hasOwnProperty.call(object, key); +} + +function joinRegExp(patterns) { + // Combine an array of regular expressions and strings into one large regexp + // Be mad. + var sources = [], + i = 0, + len = patterns.length, + pattern; + + for (; i < len; i++) { + pattern = patterns[i]; + if (isString(pattern)) { + // If it's a string, we need to escape it + // Taken from: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions + sources.push(pattern.replace(/([.*+?^=!:${}()|\[\]\/\\])/g, '\\$1')); + } else if (pattern && pattern.source) { + // If it's a regexp already, we want to extract the source + sources.push(pattern.source); + } + // Intentionally skip other cases + } + return new RegExp(sources.join('|'), 'i'); +} + +function urlencode(o) { + var pairs = []; + each(o, function(key, value) { + pairs.push(encodeURIComponent(key) + '=' + encodeURIComponent(value)); + }); + return pairs.join('&'); +} + +// borrowed from https://tools.ietf.org/html/rfc3986#appendix-B +// intentionally using regex and not <a/> href parsing trick because React Native and other +// environments where DOM might not be available +function parseUrl(url) { + if (typeof url !== 'string') return {}; + var match = url.match(/^(([^:\/?#]+):)?(\/\/([^\/?#]*))?([^?#]*)(\?([^#]*))?(#(.*))?$/); + + // coerce to undefined values to empty string so we don't get 'undefined' + var query = match[6] || ''; + var fragment = match[8] || ''; + return { + protocol: match[2], + host: match[4], + path: match[5], + relative: match[5] + query + fragment // everything minus origin + }; +} +function uuid4() { + var crypto = _window.crypto || _window.msCrypto; + + if (!isUndefined(crypto) && crypto.getRandomValues) { + // Use window.crypto API if available + // eslint-disable-next-line no-undef + var arr = new Uint16Array(8); + crypto.getRandomValues(arr); + + // set 4 in byte 7 + arr[3] = (arr[3] & 0xfff) | 0x4000; + // set 2 most significant bits of byte 9 to '10' + arr[4] = (arr[4] & 0x3fff) | 0x8000; + + var pad = function(num) { + var v = num.toString(16); + while (v.length < 4) { + v = '0' + v; + } + return v; + }; + + return ( + pad(arr[0]) + + pad(arr[1]) + + pad(arr[2]) + + pad(arr[3]) + + pad(arr[4]) + + pad(arr[5]) + + pad(arr[6]) + + pad(arr[7]) + ); + } else { + // http://stackoverflow.com/questions/105034/how-to-create-a-guid-uuid-in-javascript/2117523#2117523 + return 'xxxxxxxxxxxx4xxxyxxxxxxxxxxxxxxx'.replace(/[xy]/g, function(c) { + var r = (Math.random() * 16) | 0, + v = c === 'x' ? r : (r & 0x3) | 0x8; + return v.toString(16); + }); + } +} + +/** + * Given a child DOM element, returns a query-selector statement describing that + * and its ancestors + * e.g. [HTMLElement] => body > div > input#foo.btn[name=baz] + * @param elem + * @returns {string} + */ +function htmlTreeAsString(elem) { + /* eslint no-extra-parens:0*/ + var MAX_TRAVERSE_HEIGHT = 5, + MAX_OUTPUT_LEN = 80, + out = [], + height = 0, + len = 0, + separator = ' > ', + sepLength = separator.length, + nextStr; + + while (elem && height++ < MAX_TRAVERSE_HEIGHT) { + nextStr = htmlElementAsString(elem); + // bail out if + // - nextStr is the 'html' element + // - the length of the string that would be created exceeds MAX_OUTPUT_LEN + // (ignore this limit if we are on the first iteration) + if ( + nextStr === 'html' || + (height > 1 && len + out.length * sepLength + nextStr.length >= MAX_OUTPUT_LEN) + ) { + break; + } + + out.push(nextStr); + + len += nextStr.length; + elem = elem.parentNode; + } + + return out.reverse().join(separator); +} + +/** + * Returns a simple, query-selector representation of a DOM element + * e.g. [HTMLElement] => input#foo.btn[name=baz] + * @param HTMLElement + * @returns {string} + */ +function htmlElementAsString(elem) { + var out = [], + className, + classes, + key, + attr, + i; + + if (!elem || !elem.tagName) { + return ''; + } + + out.push(elem.tagName.toLowerCase()); + if (elem.id) { + out.push('#' + elem.id); + } + + className = elem.className; + if (className && isString(className)) { + classes = className.split(/\s+/); + for (i = 0; i < classes.length; i++) { + out.push('.' + classes[i]); + } + } + var attrWhitelist = ['type', 'name', 'title', 'alt']; + for (i = 0; i < attrWhitelist.length; i++) { + key = attrWhitelist[i]; + attr = elem.getAttribute(key); + if (attr) { + out.push('[' + key + '="' + attr + '"]'); + } + } + return out.join(''); +} + +/** + * Returns true if either a OR b is truthy, but not both + */ +function isOnlyOneTruthy(a, b) { + return !!(!!a ^ !!b); +} + +/** + * Returns true if both parameters are undefined + */ +function isBothUndefined(a, b) { + return isUndefined(a) && isUndefined(b); +} + +/** + * Returns true if the two input exception interfaces have the same content + */ +function isSameException(ex1, ex2) { + if (isOnlyOneTruthy(ex1, ex2)) return false; + + ex1 = ex1.values[0]; + ex2 = ex2.values[0]; + + if (ex1.type !== ex2.type || ex1.value !== ex2.value) return false; + + // in case both stacktraces are undefined, we can't decide so default to false + if (isBothUndefined(ex1.stacktrace, ex2.stacktrace)) return false; + + return isSameStacktrace(ex1.stacktrace, ex2.stacktrace); +} + +/** + * Returns true if the two input stack trace interfaces have the same content + */ +function isSameStacktrace(stack1, stack2) { + if (isOnlyOneTruthy(stack1, stack2)) return false; + + var frames1 = stack1.frames; + var frames2 = stack2.frames; + + // Exit early if stacktrace is malformed + if (frames1 === undefined || frames2 === undefined) return false; + + // Exit early if frame count differs + if (frames1.length !== frames2.length) return false; + + // Iterate through every frame; bail out if anything differs + var a, b; + for (var i = 0; i < frames1.length; i++) { + a = frames1[i]; + b = frames2[i]; + if ( + a.filename !== b.filename || + a.lineno !== b.lineno || + a.colno !== b.colno || + a['function'] !== b['function'] + ) + return false; + } + return true; +} + +/** + * Polyfill a method + * @param obj object e.g. `document` + * @param name method name present on object e.g. `addEventListener` + * @param replacement replacement function + * @param track {optional} record instrumentation to an array + */ +function fill(obj, name, replacement, track) { + if (obj == null) return; + var orig = obj[name]; + obj[name] = replacement(orig); + obj[name].__raven__ = true; + obj[name].__orig__ = orig; + if (track) { + track.push([obj, name, orig]); + } +} + +/** + * Join values in array + * @param input array of values to be joined together + * @param delimiter string to be placed in-between values + * @returns {string} + */ +function safeJoin(input, delimiter) { + if (!isArray(input)) return ''; + + var output = []; + + for (var i = 0; i < input.length; i++) { + try { + output.push(String(input[i])); + } catch (e) { + output.push('[value cannot be serialized]'); + } + } + + return output.join(delimiter); +} + +// Default Node.js REPL depth +var MAX_SERIALIZE_EXCEPTION_DEPTH = 3; +// 50kB, as 100kB is max payload size, so half sounds reasonable +var MAX_SERIALIZE_EXCEPTION_SIZE = 50 * 1024; +var MAX_SERIALIZE_KEYS_LENGTH = 40; + +function utf8Length(value) { + return ~-encodeURI(value).split(/%..|./).length; +} + +function jsonSize(value) { + return utf8Length(JSON.stringify(value)); +} + +function serializeValue(value) { + if (typeof value === 'string') { + var maxLength = 40; + return truncate(value, maxLength); + } else if ( + typeof value === 'number' || + typeof value === 'boolean' || + typeof value === 'undefined' + ) { + return value; + } + + var type = Object.prototype.toString.call(value); + + // Node.js REPL notation + if (type === '[object Object]') return '[Object]'; + if (type === '[object Array]') return '[Array]'; + if (type === '[object Function]') + return value.name ? '[Function: ' + value.name + ']' : '[Function]'; + + return value; +} + +function serializeObject(value, depth) { + if (depth === 0) return serializeValue(value); + + if (isPlainObject(value)) { + return Object.keys(value).reduce(function(acc, key) { + acc[key] = serializeObject(value[key], depth - 1); + return acc; + }, {}); + } else if (Array.isArray(value)) { + return value.map(function(val) { + return serializeObject(val, depth - 1); + }); + } + + return serializeValue(value); +} + +function serializeException(ex, depth, maxSize) { + if (!isPlainObject(ex)) return ex; + + depth = typeof depth !== 'number' ? MAX_SERIALIZE_EXCEPTION_DEPTH : depth; + maxSize = typeof depth !== 'number' ? MAX_SERIALIZE_EXCEPTION_SIZE : maxSize; + + var serialized = serializeObject(ex, depth); + + if (jsonSize(stringify(serialized)) > maxSize) { + return serializeException(ex, depth - 1); + } + + return serialized; +} + +function serializeKeysForMessage(keys, maxLength) { + if (typeof keys === 'number' || typeof keys === 'string') return keys.toString(); + if (!Array.isArray(keys)) return ''; + + keys = keys.filter(function(key) { + return typeof key === 'string'; + }); + if (keys.length === 0) return '[object has no keys]'; + + maxLength = typeof maxLength !== 'number' ? MAX_SERIALIZE_KEYS_LENGTH : maxLength; + if (keys[0].length >= maxLength) return keys[0]; + + for (var usedKeys = keys.length; usedKeys > 0; usedKeys--) { + var serialized = keys.slice(0, usedKeys).join(', '); + if (serialized.length > maxLength) continue; + if (usedKeys === keys.length) return serialized; + return serialized + '\u2026'; + } + + return ''; +} + +function sanitize(input, sanitizeKeys) { + if (!isArray(sanitizeKeys) || (isArray(sanitizeKeys) && sanitizeKeys.length === 0)) + return input; + + var sanitizeRegExp = joinRegExp(sanitizeKeys); + var sanitizeMask = '********'; + var safeInput; + + try { + safeInput = JSON.parse(stringify(input)); + } catch (o_O) { + return input; + } + + function sanitizeWorker(workerInput) { + if (isArray(workerInput)) { + return workerInput.map(function(val) { + return sanitizeWorker(val); + }); + } + + if (isPlainObject(workerInput)) { + return Object.keys(workerInput).reduce(function(acc, k) { + if (sanitizeRegExp.test(k)) { + acc[k] = sanitizeMask; + } else { + acc[k] = sanitizeWorker(workerInput[k]); + } + return acc; + }, {}); + } + + return workerInput; + } + + return sanitizeWorker(safeInput); +} + +module.exports = { + isObject: isObject, + isError: isError, + isErrorEvent: isErrorEvent, + isDOMError: isDOMError, + isDOMException: isDOMException, + isUndefined: isUndefined, + isFunction: isFunction, + isPlainObject: isPlainObject, + isString: isString, + isArray: isArray, + isEmptyObject: isEmptyObject, + supportsErrorEvent: supportsErrorEvent, + supportsDOMError: supportsDOMError, + supportsDOMException: supportsDOMException, + supportsFetch: supportsFetch, + supportsReferrerPolicy: supportsReferrerPolicy, + supportsPromiseRejectionEvent: supportsPromiseRejectionEvent, + wrappedCallback: wrappedCallback, + each: each, + objectMerge: objectMerge, + truncate: truncate, + objectFrozen: objectFrozen, + hasKey: hasKey, + joinRegExp: joinRegExp, + urlencode: urlencode, + uuid4: uuid4, + htmlTreeAsString: htmlTreeAsString, + htmlElementAsString: htmlElementAsString, + isSameException: isSameException, + isSameStacktrace: isSameStacktrace, + parseUrl: parseUrl, + fill: fill, + safeJoin: safeJoin, + serializeException: serializeException, + serializeKeysForMessage: serializeKeysForMessage, + sanitize: sanitize +}; + +}).call(this,typeof global !== "undefined" ? global : typeof self !== "undefined" ? self : typeof window !== "undefined" ? window : {}) +},{"7":7}],6:[function(_dereq_,module,exports){ +(function (global){ +var utils = _dereq_(5); + +/* + TraceKit - Cross brower stack traces + + This was originally forked from github.com/occ/TraceKit, but has since been + largely re-written and is now maintained as part of raven-js. Tests for + this are in test/vendor. + + MIT license +*/ + +var TraceKit = { + collectWindowErrors: true, + debug: false +}; + +// This is to be defensive in environments where window does not exist (see https://github.com/getsentry/raven-js/pull/785) +var _window = + typeof window !== 'undefined' + ? window + : typeof global !== 'undefined' ? global : typeof self !== 'undefined' ? self : {}; + +// global reference to slice +var _slice = [].slice; +var UNKNOWN_FUNCTION = '?'; + +// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error#Error_types +var ERROR_TYPES_RE = /^(?:[Uu]ncaught (?:exception: )?)?(?:((?:Eval|Internal|Range|Reference|Syntax|Type|URI|)Error): )?(.*)$/; + +function getLocationHref() { + if (typeof document === 'undefined' || document.location == null) return ''; + return document.location.href; +} + +function getLocationOrigin() { + if (typeof document === 'undefined' || document.location == null) return ''; + + // Oh dear IE10... + if (!document.location.origin) { + return ( + document.location.protocol + + '//' + + document.location.hostname + + (document.location.port ? ':' + document.location.port : '') + ); + } + + return document.location.origin; +} + +/** + * TraceKit.report: cross-browser processing of unhandled exceptions + * + * Syntax: + * TraceKit.report.subscribe(function(stackInfo) { ... }) + * TraceKit.report.unsubscribe(function(stackInfo) { ... }) + * TraceKit.report(exception) + * try { ...code... } catch(ex) { TraceKit.report(ex); } + * + * Supports: + * - Firefox: full stack trace with line numbers, plus column number + * on top frame; column number is not guaranteed + * - Opera: full stack trace with line and column numbers + * - Chrome: full stack trace with line and column numbers + * - Safari: line and column number for the top frame only; some frames + * may be missing, and column number is not guaranteed + * - IE: line and column number for the top frame only; some frames + * may be missing, and column number is not guaranteed + * + * In theory, TraceKit should work on all of the following versions: + * - IE5.5+ (only 8.0 tested) + * - Firefox 0.9+ (only 3.5+ tested) + * - Opera 7+ (only 10.50 tested; versions 9 and earlier may require + * Exceptions Have Stacktrace to be enabled in opera:config) + * - Safari 3+ (only 4+ tested) + * - Chrome 1+ (only 5+ tested) + * - Konqueror 3.5+ (untested) + * + * Requires TraceKit.computeStackTrace. + * + * Tries to catch all unhandled exceptions and report them to the + * subscribed handlers. Please note that TraceKit.report will rethrow the + * exception. This is REQUIRED in order to get a useful stack trace in IE. + * If the exception does not reach the top of the browser, you will only + * get a stack trace from the point where TraceKit.report was called. + * + * Handlers receive a stackInfo object as described in the + * TraceKit.computeStackTrace docs. + */ +TraceKit.report = (function reportModuleWrapper() { + var handlers = [], + lastArgs = null, + lastException = null, + lastExceptionStack = null; + + /** + * Add a crash handler. + * @param {Function} handler + */ + function subscribe(handler) { + installGlobalHandler(); + handlers.push(handler); + } + + /** + * Remove a crash handler. + * @param {Function} handler + */ + function unsubscribe(handler) { + for (var i = handlers.length - 1; i >= 0; --i) { + if (handlers[i] === handler) { + handlers.splice(i, 1); + } + } + } + + /** + * Remove all crash handlers. + */ + function unsubscribeAll() { + uninstallGlobalHandler(); + handlers = []; + } + + /** + * Dispatch stack information to all handlers. + * @param {Object.<string, *>} stack + */ + function notifyHandlers(stack, isWindowError) { + var exception = null; + if (isWindowError && !TraceKit.collectWindowErrors) { + return; + } + for (var i in handlers) { + if (handlers.hasOwnProperty(i)) { + try { + handlers[i].apply(null, [stack].concat(_slice.call(arguments, 2))); + } catch (inner) { + exception = inner; + } + } + } + + if (exception) { + throw exception; + } + } + + var _oldOnerrorHandler, _onErrorHandlerInstalled; + + /** + * Ensures all global unhandled exceptions are recorded. + * Supported by Gecko and IE. + * @param {string} msg Error message. + * @param {string} url URL of script that generated the exception. + * @param {(number|string)} lineNo The line number at which the error + * occurred. + * @param {?(number|string)} colNo The column number at which the error + * occurred. + * @param {?Error} ex The actual Error object. + */ + function traceKitWindowOnError(msg, url, lineNo, colNo, ex) { + var stack = null; + // If 'ex' is ErrorEvent, get real Error from inside + var exception = utils.isErrorEvent(ex) ? ex.error : ex; + // If 'msg' is ErrorEvent, get real message from inside + var message = utils.isErrorEvent(msg) ? msg.message : msg; + + if (lastExceptionStack) { + TraceKit.computeStackTrace.augmentStackTraceWithInitialElement( + lastExceptionStack, + url, + lineNo, + message + ); + processLastException(); + } else if (exception && utils.isError(exception)) { + // non-string `exception` arg; attempt to extract stack trace + + // New chrome and blink send along a real error object + // Let's just report that like a normal error. + // See: https://mikewest.org/2013/08/debugging-runtime-errors-with-window-onerror + stack = TraceKit.computeStackTrace(exception); + notifyHandlers(stack, true); + } else { + var location = { + url: url, + line: lineNo, + column: colNo + }; + + var name = undefined; + var groups; + + if ({}.toString.call(message) === '[object String]') { + var groups = message.match(ERROR_TYPES_RE); + if (groups) { + name = groups[1]; + message = groups[2]; + } + } + + location.func = UNKNOWN_FUNCTION; + + stack = { + name: name, + message: message, + url: getLocationHref(), + stack: [location] + }; + notifyHandlers(stack, true); + } + + if (_oldOnerrorHandler) { + return _oldOnerrorHandler.apply(this, arguments); + } + + return false; + } + + function installGlobalHandler() { + if (_onErrorHandlerInstalled) { + return; + } + _oldOnerrorHandler = _window.onerror; + _window.onerror = traceKitWindowOnError; + _onErrorHandlerInstalled = true; + } + + function uninstallGlobalHandler() { + if (!_onErrorHandlerInstalled) { + return; + } + _window.onerror = _oldOnerrorHandler; + _onErrorHandlerInstalled = false; + _oldOnerrorHandler = undefined; + } + + function processLastException() { + var _lastExceptionStack = lastExceptionStack, + _lastArgs = lastArgs; + lastArgs = null; + lastExceptionStack = null; + lastException = null; + notifyHandlers.apply(null, [_lastExceptionStack, false].concat(_lastArgs)); + } + + /** + * Reports an unhandled Error to TraceKit. + * @param {Error} ex + * @param {?boolean} rethrow If false, do not re-throw the exception. + * Only used for window.onerror to not cause an infinite loop of + * rethrowing. + */ + function report(ex, rethrow) { + var args = _slice.call(arguments, 1); + if (lastExceptionStack) { + if (lastException === ex) { + return; // already caught by an inner catch block, ignore + } else { + processLastException(); + } + } + + var stack = TraceKit.computeStackTrace(ex); + lastExceptionStack = stack; + lastException = ex; + lastArgs = args; + + // If the stack trace is incomplete, wait for 2 seconds for + // slow slow IE to see if onerror occurs or not before reporting + // this exception; otherwise, we will end up with an incomplete + // stack trace + setTimeout(function() { + if (lastException === ex) { + processLastException(); + } + }, stack.incomplete ? 2000 : 0); + + if (rethrow !== false) { + throw ex; // re-throw to propagate to the top level (and cause window.onerror) + } + } + + report.subscribe = subscribe; + report.unsubscribe = unsubscribe; + report.uninstall = unsubscribeAll; + return report; +})(); + +/** + * TraceKit.computeStackTrace: cross-browser stack traces in JavaScript + * + * Syntax: + * s = TraceKit.computeStackTrace(exception) // consider using TraceKit.report instead (see below) + * Returns: + * s.name - exception name + * s.message - exception message + * s.stack[i].url - JavaScript or HTML file URL + * s.stack[i].func - function name, or empty for anonymous functions (if guessing did not work) + * s.stack[i].args - arguments passed to the function, if known + * s.stack[i].line - line number, if known + * s.stack[i].column - column number, if known + * + * Supports: + * - Firefox: full stack trace with line numbers and unreliable column + * number on top frame + * - Opera 10: full stack trace with line and column numbers + * - Opera 9-: full stack trace with line numbers + * - Chrome: full stack trace with line and column numbers + * - Safari: line and column number for the topmost stacktrace element + * only + * - IE: no line numbers whatsoever + * + * Tries to guess names of anonymous functions by looking for assignments + * in the source code. In IE and Safari, we have to guess source file names + * by searching for function bodies inside all page scripts. This will not + * work for scripts that are loaded cross-domain. + * Here be dragons: some function names may be guessed incorrectly, and + * duplicate functions may be mismatched. + * + * TraceKit.computeStackTrace should only be used for tracing purposes. + * Logging of unhandled exceptions should be done with TraceKit.report, + * which builds on top of TraceKit.computeStackTrace and provides better + * IE support by utilizing the window.onerror event to retrieve information + * about the top of the stack. + * + * Note: In IE and Safari, no stack trace is recorded on the Error object, + * so computeStackTrace instead walks its *own* chain of callers. + * This means that: + * * in Safari, some methods may be missing from the stack trace; + * * in IE, the topmost function in the stack trace will always be the + * caller of computeStackTrace. + * + * This is okay for tracing (because you are likely to be calling + * computeStackTrace from the function you want to be the topmost element + * of the stack trace anyway), but not okay for logging unhandled + * exceptions (because your catch block will likely be far away from the + * inner function that actually caused the exception). + * + */ +TraceKit.computeStackTrace = (function computeStackTraceWrapper() { + // Contents of Exception in various browsers. + // + // SAFARI: + // ex.message = Can't find variable: qq + // ex.line = 59 + // ex.sourceId = 580238192 + // ex.sourceURL = http://... + // ex.expressionBeginOffset = 96 + // ex.expressionCaretOffset = 98 + // ex.expressionEndOffset = 98 + // ex.name = ReferenceError + // + // FIREFOX: + // ex.message = qq is not defined + // ex.fileName = http://... + // ex.lineNumber = 59 + // ex.columnNumber = 69 + // ex.stack = ...stack trace... (see the example below) + // ex.name = ReferenceError + // + // CHROME: + // ex.message = qq is not defined + // ex.name = ReferenceError + // ex.type = not_defined + // ex.arguments = ['aa'] + // ex.stack = ...stack trace... + // + // INTERNET EXPLORER: + // ex.message = ... + // ex.name = ReferenceError + // + // OPERA: + // ex.message = ...message... (see the example below) + // ex.name = ReferenceError + // ex.opera#sourceloc = 11 (pretty much useless, duplicates the info in ex.message) + // ex.stacktrace = n/a; see 'opera:config#UserPrefs|Exceptions Have Stacktrace' + + /** + * Computes stack trace information from the stack property. + * Chrome and Gecko use this property. + * @param {Error} ex + * @return {?Object.<string, *>} Stack trace information. + */ + function computeStackTraceFromStackProp(ex) { + if (typeof ex.stack === 'undefined' || !ex.stack) return; + + var chrome = /^\s*at (?:(.*?) ?\()?((?:file|https?|blob|chrome-extension|native|eval|webpack|<anonymous>|[a-z]:|\/).*?)(?::(\d+))?(?::(\d+))?\)?\s*$/i; + var winjs = /^\s*at (?:((?:\[object object\])?.+) )?\(?((?:file|ms-appx(?:-web)|https?|webpack|blob):.*?):(\d+)(?::(\d+))?\)?\s*$/i; + // NOTE: blob urls are now supposed to always have an origin, therefore it's format + // which is `blob:http://url/path/with-some-uuid`, is matched by `blob.*?:\/` as well + var gecko = /^\s*(.*?)(?:\((.*?)\))?(?:^|@)((?:file|https?|blob|chrome|webpack|resource|moz-extension).*?:\/.*?|\[native code\]|[^@]*bundle)(?::(\d+))?(?::(\d+))?\s*$/i; + // Used to additionally parse URL/line/column from eval frames + var geckoEval = /(\S+) line (\d+)(?: > eval line \d+)* > eval/i; + var chromeEval = /\((\S*)(?::(\d+))(?::(\d+))\)/; + var lines = ex.stack.split('\n'); + var stack = []; + var submatch; + var parts; + var element; + var reference = /^(.*) is undefined$/.exec(ex.message); + + for (var i = 0, j = lines.length; i < j; ++i) { + if ((parts = chrome.exec(lines[i]))) { + var isNative = parts[2] && parts[2].indexOf('native') === 0; // start of line + var isEval = parts[2] && parts[2].indexOf('eval') === 0; // start of line + if (isEval && (submatch = chromeEval.exec(parts[2]))) { + // throw out eval line/column and use top-most line/column number + parts[2] = submatch[1]; // url + parts[3] = submatch[2]; // line + parts[4] = submatch[3]; // column + } + element = { + url: !isNative ? parts[2] : null, + func: parts[1] || UNKNOWN_FUNCTION, + args: isNative ? [parts[2]] : [], + line: parts[3] ? +parts[3] : null, + column: parts[4] ? +parts[4] : null + }; + } else if ((parts = winjs.exec(lines[i]))) { + element = { + url: parts[2], + func: parts[1] || UNKNOWN_FUNCTION, + args: [], + line: +parts[3], + column: parts[4] ? +parts[4] : null + }; + } else if ((parts = gecko.exec(lines[i]))) { + var isEval = parts[3] && parts[3].indexOf(' > eval') > -1; + if (isEval && (submatch = geckoEval.exec(parts[3]))) { + // throw out eval line/column and use top-most line number + parts[3] = submatch[1]; + parts[4] = submatch[2]; + parts[5] = null; // no column when eval + } else if (i === 0 && !parts[5] && typeof ex.columnNumber !== 'undefined') { + // FireFox uses this awesome columnNumber property for its top frame + // Also note, Firefox's column number is 0-based and everything else expects 1-based, + // so adding 1 + // NOTE: this hack doesn't work if top-most frame is eval + stack[0].column = ex.columnNumber + 1; + } + element = { + url: parts[3], + func: parts[1] || UNKNOWN_FUNCTION, + args: parts[2] ? parts[2].split(',') : [], + line: parts[4] ? +parts[4] : null, + column: parts[5] ? +parts[5] : null + }; + } else { + continue; + } + + if (!element.func && element.line) { + element.func = UNKNOWN_FUNCTION; + } + + if (element.url && element.url.substr(0, 5) === 'blob:') { + // Special case for handling JavaScript loaded into a blob. + // We use a synchronous AJAX request here as a blob is already in + // memory - it's not making a network request. This will generate a warning + // in the browser console, but there has already been an error so that's not + // that much of an issue. + var xhr = new XMLHttpRequest(); + xhr.open('GET', element.url, false); + xhr.send(null); + + // If we failed to download the source, skip this patch + if (xhr.status === 200) { + var source = xhr.responseText || ''; + + // We trim the source down to the last 300 characters as sourceMappingURL is always at the end of the file. + // Why 300? To be in line with: https://github.com/getsentry/sentry/blob/4af29e8f2350e20c28a6933354e4f42437b4ba42/src/sentry/lang/javascript/processor.py#L164-L175 + source = source.slice(-300); + + // Now we dig out the source map URL + var sourceMaps = source.match(/\/\/# sourceMappingURL=(.*)$/); + + // If we don't find a source map comment or we find more than one, continue on to the next element. + if (sourceMaps) { + var sourceMapAddress = sourceMaps[1]; + + // Now we check to see if it's a relative URL. + // If it is, convert it to an absolute one. + if (sourceMapAddress.charAt(0) === '~') { + sourceMapAddress = getLocationOrigin() + sourceMapAddress.slice(1); + } + + // Now we strip the '.map' off of the end of the URL and update the + // element so that Sentry can match the map to the blob. + element.url = sourceMapAddress.slice(0, -4); + } + } + } + + stack.push(element); + } + + if (!stack.length) { + return null; + } + + return { + name: ex.name, + message: ex.message, + url: getLocationHref(), + stack: stack + }; + } + + /** + * Adds information about the first frame to incomplete stack traces. + * Safari and IE require this to get complete data on the first frame. + * @param {Object.<string, *>} stackInfo Stack trace information from + * one of the compute* methods. + * @param {string} url The URL of the script that caused an error. + * @param {(number|string)} lineNo The line number of the script that + * caused an error. + * @param {string=} message The error generated by the browser, which + * hopefully contains the name of the object that caused the error. + * @return {boolean} Whether or not the stack information was + * augmented. + */ + function augmentStackTraceWithInitialElement(stackInfo, url, lineNo, message) { + var initial = { + url: url, + line: lineNo + }; + + if (initial.url && initial.line) { + stackInfo.incomplete = false; + + if (!initial.func) { + initial.func = UNKNOWN_FUNCTION; + } + + if (stackInfo.stack.length > 0) { + if (stackInfo.stack[0].url === initial.url) { + if (stackInfo.stack[0].line === initial.line) { + return false; // already in stack trace + } else if ( + !stackInfo.stack[0].line && + stackInfo.stack[0].func === initial.func + ) { + stackInfo.stack[0].line = initial.line; + return false; + } + } + } + + stackInfo.stack.unshift(initial); + stackInfo.partial = true; + return true; + } else { + stackInfo.incomplete = true; + } + + return false; + } + + /** + * Computes stack trace information by walking the arguments.caller + * chain at the time the exception occurred. This will cause earlier + * frames to be missed but is the only way to get any stack trace in + * Safari and IE. The top frame is restored by + * {@link augmentStackTraceWithInitialElement}. + * @param {Error} ex + * @return {?Object.<string, *>} Stack trace information. + */ + function computeStackTraceByWalkingCallerChain(ex, depth) { + var functionName = /function\s+([_$a-zA-Z\xA0-\uFFFF][_$a-zA-Z0-9\xA0-\uFFFF]*)?\s*\(/i, + stack = [], + funcs = {}, + recursion = false, + parts, + item, + source; + + for ( + var curr = computeStackTraceByWalkingCallerChain.caller; + curr && !recursion; + curr = curr.caller + ) { + if (curr === computeStackTrace || curr === TraceKit.report) { + // console.log('skipping internal function'); + continue; + } + + item = { + url: null, + func: UNKNOWN_FUNCTION, + line: null, + column: null + }; + + if (curr.name) { + item.func = curr.name; + } else if ((parts = functionName.exec(curr.toString()))) { + item.func = parts[1]; + } + + if (typeof item.func === 'undefined') { + try { + item.func = parts.input.substring(0, parts.input.indexOf('{')); + } catch (e) {} + } + + if (funcs['' + curr]) { + recursion = true; + } else { + funcs['' + curr] = true; + } + + stack.push(item); + } + + if (depth) { + // console.log('depth is ' + depth); + // console.log('stack is ' + stack.length); + stack.splice(0, depth); + } + + var result = { + name: ex.name, + message: ex.message, + url: getLocationHref(), + stack: stack + }; + augmentStackTraceWithInitialElement( + result, + ex.sourceURL || ex.fileName, + ex.line || ex.lineNumber, + ex.message || ex.description + ); + return result; + } + + /** + * Computes a stack trace for an exception. + * @param {Error} ex + * @param {(string|number)=} depth + */ + function computeStackTrace(ex, depth) { + var stack = null; + depth = depth == null ? 0 : +depth; + + try { + stack = computeStackTraceFromStackProp(ex); + if (stack) { + return stack; + } + } catch (e) { + if (TraceKit.debug) { + throw e; + } + } + + try { + stack = computeStackTraceByWalkingCallerChain(ex, depth + 1); + if (stack) { + return stack; + } + } catch (e) { + if (TraceKit.debug) { + throw e; + } + } + return { + name: ex.name, + message: ex.message, + url: getLocationHref() + }; + } + + computeStackTrace.augmentStackTraceWithInitialElement = augmentStackTraceWithInitialElement; + computeStackTrace.computeStackTraceFromStackProp = computeStackTraceFromStackProp; + + return computeStackTrace; +})(); + +module.exports = TraceKit; + +}).call(this,typeof global !== "undefined" ? global : typeof self !== "undefined" ? self : typeof window !== "undefined" ? window : {}) +},{"5":5}],7:[function(_dereq_,module,exports){ +/* + json-stringify-safe + Like JSON.stringify, but doesn't throw on circular references. + + Originally forked from https://github.com/isaacs/json-stringify-safe + version 5.0.1 on 3/8/2017 and modified to handle Errors serialization + and IE8 compatibility. Tests for this are in test/vendor. + + ISC license: https://github.com/isaacs/json-stringify-safe/blob/master/LICENSE +*/ + +exports = module.exports = stringify; +exports.getSerialize = serializer; + +function indexOf(haystack, needle) { + for (var i = 0; i < haystack.length; ++i) { + if (haystack[i] === needle) return i; + } + return -1; +} + +function stringify(obj, replacer, spaces, cycleReplacer) { + return JSON.stringify(obj, serializer(replacer, cycleReplacer), spaces); +} + +// https://github.com/ftlabs/js-abbreviate/blob/fa709e5f139e7770a71827b1893f22418097fbda/index.js#L95-L106 +function stringifyError(value) { + var err = { + // These properties are implemented as magical getters and don't show up in for in + stack: value.stack, + message: value.message, + name: value.name + }; + + for (var i in value) { + if (Object.prototype.hasOwnProperty.call(value, i)) { + err[i] = value[i]; + } + } + + return err; +} + +function serializer(replacer, cycleReplacer) { + var stack = []; + var keys = []; + + if (cycleReplacer == null) { + cycleReplacer = function(key, value) { + if (stack[0] === value) { + return '[Circular ~]'; + } + return '[Circular ~.' + keys.slice(0, indexOf(stack, value)).join('.') + ']'; + }; + } + + return function(key, value) { + if (stack.length > 0) { + var thisPos = indexOf(stack, this); + ~thisPos ? stack.splice(thisPos + 1) : stack.push(this); + ~thisPos ? keys.splice(thisPos, Infinity, key) : keys.push(key); + + if (~indexOf(stack, value)) { + value = cycleReplacer.call(this, key, value); + } + } else { + stack.push(value); + } + + return replacer == null + ? value instanceof Error ? stringifyError(value) : value + : replacer.call(this, key, value); + }; +} + +},{}],8:[function(_dereq_,module,exports){ +/* + * JavaScript MD5 + * https://github.com/blueimp/JavaScript-MD5 + * + * Copyright 2011, Sebastian Tschan + * https://blueimp.net + * + * Licensed under the MIT license: + * https://opensource.org/licenses/MIT + * + * Based on + * A JavaScript implementation of the RSA Data Security, Inc. MD5 Message + * Digest Algorithm, as defined in RFC 1321. + * Version 2.2 Copyright (C) Paul Johnston 1999 - 2009 + * Other contributors: Greg Holt, Andrew Kepert, Ydnar, Lostinet + * Distributed under the BSD License + * See http://pajhome.org.uk/crypt/md5 for more info. + */ + +/* +* Add integers, wrapping at 2^32. This uses 16-bit operations internally +* to work around bugs in some JS interpreters. +*/ +function safeAdd(x, y) { + var lsw = (x & 0xffff) + (y & 0xffff); + var msw = (x >> 16) + (y >> 16) + (lsw >> 16); + return (msw << 16) | (lsw & 0xffff); +} + +/* +* Bitwise rotate a 32-bit number to the left. +*/ +function bitRotateLeft(num, cnt) { + return (num << cnt) | (num >>> (32 - cnt)); +} + +/* +* These functions implement the four basic operations the algorithm uses. +*/ +function md5cmn(q, a, b, x, s, t) { + return safeAdd(bitRotateLeft(safeAdd(safeAdd(a, q), safeAdd(x, t)), s), b); +} +function md5ff(a, b, c, d, x, s, t) { + return md5cmn((b & c) | (~b & d), a, b, x, s, t); +} +function md5gg(a, b, c, d, x, s, t) { + return md5cmn((b & d) | (c & ~d), a, b, x, s, t); +} +function md5hh(a, b, c, d, x, s, t) { + return md5cmn(b ^ c ^ d, a, b, x, s, t); +} +function md5ii(a, b, c, d, x, s, t) { + return md5cmn(c ^ (b | ~d), a, b, x, s, t); +} + +/* +* Calculate the MD5 of an array of little-endian words, and a bit length. +*/ +function binlMD5(x, len) { + /* append padding */ + x[len >> 5] |= 0x80 << (len % 32); + x[(((len + 64) >>> 9) << 4) + 14] = len; + + var i; + var olda; + var oldb; + var oldc; + var oldd; + var a = 1732584193; + var b = -271733879; + var c = -1732584194; + var d = 271733878; + + for (i = 0; i < x.length; i += 16) { + olda = a; + oldb = b; + oldc = c; + oldd = d; + + a = md5ff(a, b, c, d, x[i], 7, -680876936); + d = md5ff(d, a, b, c, x[i + 1], 12, -389564586); + c = md5ff(c, d, a, b, x[i + 2], 17, 606105819); + b = md5ff(b, c, d, a, x[i + 3], 22, -1044525330); + a = md5ff(a, b, c, d, x[i + 4], 7, -176418897); + d = md5ff(d, a, b, c, x[i + 5], 12, 1200080426); + c = md5ff(c, d, a, b, x[i + 6], 17, -1473231341); + b = md5ff(b, c, d, a, x[i + 7], 22, -45705983); + a = md5ff(a, b, c, d, x[i + 8], 7, 1770035416); + d = md5ff(d, a, b, c, x[i + 9], 12, -1958414417); + c = md5ff(c, d, a, b, x[i + 10], 17, -42063); + b = md5ff(b, c, d, a, x[i + 11], 22, -1990404162); + a = md5ff(a, b, c, d, x[i + 12], 7, 1804603682); + d = md5ff(d, a, b, c, x[i + 13], 12, -40341101); + c = md5ff(c, d, a, b, x[i + 14], 17, -1502002290); + b = md5ff(b, c, d, a, x[i + 15], 22, 1236535329); + + a = md5gg(a, b, c, d, x[i + 1], 5, -165796510); + d = md5gg(d, a, b, c, x[i + 6], 9, -1069501632); + c = md5gg(c, d, a, b, x[i + 11], 14, 643717713); + b = md5gg(b, c, d, a, x[i], 20, -373897302); + a = md5gg(a, b, c, d, x[i + 5], 5, -701558691); + d = md5gg(d, a, b, c, x[i + 10], 9, 38016083); + c = md5gg(c, d, a, b, x[i + 15], 14, -660478335); + b = md5gg(b, c, d, a, x[i + 4], 20, -405537848); + a = md5gg(a, b, c, d, x[i + 9], 5, 568446438); + d = md5gg(d, a, b, c, x[i + 14], 9, -1019803690); + c = md5gg(c, d, a, b, x[i + 3], 14, -187363961); + b = md5gg(b, c, d, a, x[i + 8], 20, 1163531501); + a = md5gg(a, b, c, d, x[i + 13], 5, -1444681467); + d = md5gg(d, a, b, c, x[i + 2], 9, -51403784); + c = md5gg(c, d, a, b, x[i + 7], 14, 1735328473); + b = md5gg(b, c, d, a, x[i + 12], 20, -1926607734); + + a = md5hh(a, b, c, d, x[i + 5], 4, -378558); + d = md5hh(d, a, b, c, x[i + 8], 11, -2022574463); + c = md5hh(c, d, a, b, x[i + 11], 16, 1839030562); + b = md5hh(b, c, d, a, x[i + 14], 23, -35309556); + a = md5hh(a, b, c, d, x[i + 1], 4, -1530992060); + d = md5hh(d, a, b, c, x[i + 4], 11, 1272893353); + c = md5hh(c, d, a, b, x[i + 7], 16, -155497632); + b = md5hh(b, c, d, a, x[i + 10], 23, -1094730640); + a = md5hh(a, b, c, d, x[i + 13], 4, 681279174); + d = md5hh(d, a, b, c, x[i], 11, -358537222); + c = md5hh(c, d, a, b, x[i + 3], 16, -722521979); + b = md5hh(b, c, d, a, x[i + 6], 23, 76029189); + a = md5hh(a, b, c, d, x[i + 9], 4, -640364487); + d = md5hh(d, a, b, c, x[i + 12], 11, -421815835); + c = md5hh(c, d, a, b, x[i + 15], 16, 530742520); + b = md5hh(b, c, d, a, x[i + 2], 23, -995338651); + + a = md5ii(a, b, c, d, x[i], 6, -198630844); + d = md5ii(d, a, b, c, x[i + 7], 10, 1126891415); + c = md5ii(c, d, a, b, x[i + 14], 15, -1416354905); + b = md5ii(b, c, d, a, x[i + 5], 21, -57434055); + a = md5ii(a, b, c, d, x[i + 12], 6, 1700485571); + d = md5ii(d, a, b, c, x[i + 3], 10, -1894986606); + c = md5ii(c, d, a, b, x[i + 10], 15, -1051523); + b = md5ii(b, c, d, a, x[i + 1], 21, -2054922799); + a = md5ii(a, b, c, d, x[i + 8], 6, 1873313359); + d = md5ii(d, a, b, c, x[i + 15], 10, -30611744); + c = md5ii(c, d, a, b, x[i + 6], 15, -1560198380); + b = md5ii(b, c, d, a, x[i + 13], 21, 1309151649); + a = md5ii(a, b, c, d, x[i + 4], 6, -145523070); + d = md5ii(d, a, b, c, x[i + 11], 10, -1120210379); + c = md5ii(c, d, a, b, x[i + 2], 15, 718787259); + b = md5ii(b, c, d, a, x[i + 9], 21, -343485551); + + a = safeAdd(a, olda); + b = safeAdd(b, oldb); + c = safeAdd(c, oldc); + d = safeAdd(d, oldd); + } + return [a, b, c, d]; +} + +/* +* Convert an array of little-endian words to a string +*/ +function binl2rstr(input) { + var i; + var output = ''; + var length32 = input.length * 32; + for (i = 0; i < length32; i += 8) { + output += String.fromCharCode((input[i >> 5] >>> (i % 32)) & 0xff); + } + return output; +} + +/* +* Convert a raw string to an array of little-endian words +* Characters >255 have their high-byte silently ignored. +*/ +function rstr2binl(input) { + var i; + var output = []; + output[(input.length >> 2) - 1] = undefined; + for (i = 0; i < output.length; i += 1) { + output[i] = 0; + } + var length8 = input.length * 8; + for (i = 0; i < length8; i += 8) { + output[i >> 5] |= (input.charCodeAt(i / 8) & 0xff) << (i % 32); + } + return output; +} + +/* +* Calculate the MD5 of a raw string +*/ +function rstrMD5(s) { + return binl2rstr(binlMD5(rstr2binl(s), s.length * 8)); +} + +/* +* Calculate the HMAC-MD5, of a key and some data (raw strings) +*/ +function rstrHMACMD5(key, data) { + var i; + var bkey = rstr2binl(key); + var ipad = []; + var opad = []; + var hash; + ipad[15] = opad[15] = undefined; + if (bkey.length > 16) { + bkey = binlMD5(bkey, key.length * 8); + } + for (i = 0; i < 16; i += 1) { + ipad[i] = bkey[i] ^ 0x36363636; + opad[i] = bkey[i] ^ 0x5c5c5c5c; + } + hash = binlMD5(ipad.concat(rstr2binl(data)), 512 + data.length * 8); + return binl2rstr(binlMD5(opad.concat(hash), 512 + 128)); +} + +/* +* Convert a raw string to a hex string +*/ +function rstr2hex(input) { + var hexTab = '0123456789abcdef'; + var output = ''; + var x; + var i; + for (i = 0; i < input.length; i += 1) { + x = input.charCodeAt(i); + output += hexTab.charAt((x >>> 4) & 0x0f) + hexTab.charAt(x & 0x0f); + } + return output; +} + +/* +* Encode a string as utf-8 +*/ +function str2rstrUTF8(input) { + return unescape(encodeURIComponent(input)); +} + +/* +* Take string arguments and return either raw or hex encoded strings +*/ +function rawMD5(s) { + return rstrMD5(str2rstrUTF8(s)); +} +function hexMD5(s) { + return rstr2hex(rawMD5(s)); +} +function rawHMACMD5(k, d) { + return rstrHMACMD5(str2rstrUTF8(k), str2rstrUTF8(d)); +} +function hexHMACMD5(k, d) { + return rstr2hex(rawHMACMD5(k, d)); +} + +function md5(string, key, raw) { + if (!key) { + if (!raw) { + return hexMD5(string); + } + return rawMD5(string); + } + if (!raw) { + return hexHMACMD5(key, string); + } + return rawHMACMD5(key, string); +} + +module.exports = md5; + +},{}]},{},[4])(4) +}); diff --git a/browser/extensions/screenshots/build/selection.js b/browser/extensions/screenshots/build/selection.js new file mode 100644 index 0000000000..cea347e212 --- /dev/null +++ b/browser/extensions/screenshots/build/selection.js @@ -0,0 +1,125 @@ +/* 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/. */ + +this.selection = (function () {let exports={}; class Selection { + constructor(x1, y1, x2, y2) { + this.x1 = x1; + this.y1 = y1; + this.x2 = x2; + this.y2 = y2; + } + + get top() { + return Math.min(this.y1, this.y2); + } + set top(val) { + if (this.y1 < this.y2) { + this.y1 = val; + } else { + this.y2 = val; + } + } + + get bottom() { + return Math.max(this.y1, this.y2); + } + set bottom(val) { + if (this.y1 > this.y2) { + this.y1 = val; + } else { + this.y2 = val; + } + } + + get left() { + return Math.min(this.x1, this.x2); + } + set left(val) { + if (this.x1 < this.x2) { + this.x1 = val; + } else { + this.x2 = val; + } + } + + get right() { + return Math.max(this.x1, this.x2); + } + set right(val) { + if (this.x1 > this.x2) { + this.x1 = val; + } else { + this.x2 = val; + } + } + + get width() { + return Math.abs(this.x2 - this.x1); + } + get height() { + return Math.abs(this.y2 - this.y1); + } + + rect() { + return { + top: Math.floor(this.top), + left: Math.floor(this.left), + bottom: Math.floor(this.bottom), + right: Math.floor(this.right), + }; + } + + union(other) { + return new Selection( + Math.min(this.left, other.left), + Math.min(this.top, other.top), + Math.max(this.right, other.right), + Math.max(this.bottom, other.bottom) + ); + } + + /** Sort x1/x2 and y1/y2 so x1<x2, y1<y2 */ + sortCoords() { + if (this.x1 > this.x2) { + [this.x1, this.x2] = [this.x2, this.x1]; + } + if (this.y1 > this.y2) { + [this.y1, this.y2] = [this.y2, this.y1]; + } + } + + clone() { + return new Selection(this.x1, this.y1, this.x2, this.y2); + } + + toJSON() { + return { + left: this.left, + right: this.right, + top: this.top, + bottom: this.bottom, + }; + } + + static getBoundingClientRect(el) { + if (!el.getBoundingClientRect) { + // Typically the <html> element or somesuch + return null; + } + const rect = el.getBoundingClientRect(); + if (!rect) { + return null; + } + return new Selection(rect.left, rect.top, rect.right, rect.bottom); + } +} + +if (typeof exports !== "undefined") { + exports.Selection = Selection; +} + +return exports; +})(); +null; + diff --git a/browser/extensions/screenshots/build/shot.js b/browser/extensions/screenshots/build/shot.js new file mode 100644 index 0000000000..57ccea5c38 --- /dev/null +++ b/browser/extensions/screenshots/build/shot.js @@ -0,0 +1,754 @@ +/* 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/. */ + +this.shot = (function () {let exports={}; // Note: in this library we can't use any "system" dependencies because this can be used from multiple +// environments + +const isNode = typeof process !== "undefined" && Object.prototype.toString.call(process) === "[object process]"; +const URL = (isNode && require("url").URL) || window.URL; + +/** Throws an error if the condition isn't true. Any extra arguments after the condition + are used as console.error() arguments. */ +function assert(condition, ...args) { + if (condition) { + return; + } + console.error("Failed assertion", ...args); + throw new Error(`Failed assertion: ${args.join(" ")}`); +} + +/** True if `url` is a valid URL */ +function isUrl(url) { + try { + const parsed = new URL(url); + + if (parsed.protocol === "view-source:") { + return isUrl(url.substr("view-source:".length)); + } + + return true; + } catch (e) { + return false; + } +} + +function isValidClipImageUrl(url) { + return isUrl(url) && !(url.indexOf(")") > -1); +} + +function assertUrl(url) { + if (!url) { + throw new Error("Empty value is not URL"); + } + if (!isUrl(url)) { + const exc = new Error("Not a URL"); + exc.scheme = url.split(":")[0]; + throw exc; + } +} + +function isSecureWebUri(url) { + return isUrl(url) && url.toLowerCase().startsWith("https"); +} + +function assertOrigin(url) { + assertUrl(url); + if (url.search(/^https?:/i) !== -1) { + const match = (/^https?:\/\/[^/:]{1,4000}\/?$/i).exec(url); + if (!match) { + throw new Error("Bad origin, might include path"); + } + } +} + +function originFromUrl(url) { + if (!url) { + return null; + } + if (url.search(/^https?:/i) === -1) { + // Non-HTTP URLs don't have an origin + return null; + } + const match = (/^https?:\/\/[^/:]{1,4000}/i).exec(url); + if (match) { + return match[0]; + } + return null; +} + +/** Check if the given object has all of the required attributes, and no extra + attributes exception those in optional */ +function checkObject(obj, required, optional) { + if (typeof obj !== "object" || obj === null) { + throw new Error("Cannot check non-object: " + (typeof obj) + " that is " + JSON.stringify(obj)); + } + required = required || []; + for (const attr of required) { + if (!(attr in obj)) { + return false; + } + } + optional = optional || []; + for (const attr in obj) { + if (!required.includes(attr) && !optional.includes(attr)) { + return false; + } + } + return true; +} + +/** Create a JSON object from a normal object, given the required and optional + attributes (filtering out any other attributes). Optional attributes are + only kept when they are truthy. */ +function jsonify(obj, required, optional) { + required = required || []; + const result = {}; + for (const attr of required) { + result[attr] = obj[attr]; + } + optional = optional || []; + for (const attr of optional) { + if (obj[attr]) { + result[attr] = obj[attr]; + } + } + return result; +} + +/** True if the two objects look alike. Null, undefined, and absent properties + are all treated as equivalent. Traverses objects and arrays */ +function deepEqual(a, b) { + if ((a === null || a === undefined) && (b === null || b === undefined)) { + return true; + } + if (typeof a !== "object" || typeof b !== "object") { + return a === b; + } + if (Array.isArray(a)) { + if (!Array.isArray(b)) { + return false; + } + if (a.length !== b.length) { + return false; + } + for (let i = 0; i < a.length; i++) { + if (!deepEqual(a[i], b[i])) { + return false; + } + } + } + if (Array.isArray(b)) { + return false; + } + const seen = new Set(); + for (const attr of Object.keys(a)) { + if (!deepEqual(a[attr], b[attr])) { + return false; + } + seen.add(attr); + } + for (const attr of Object.keys(b)) { + if (!seen.has(attr)) { + if (!deepEqual(a[attr], b[attr])) { + return false; + } + } + } + return true; +} + +function makeRandomId() { + // Note: this isn't for secure contexts, only for non-conflicting IDs + let id = ""; + while (id.length < 12) { + let num; + if (!id) { + num = Date.now() % Math.pow(36, 3); + } else { + num = Math.floor(Math.random() * Math.pow(36, 3)); + } + id += num.toString(36); + } + return id; +} + +class AbstractShot { + + constructor(backend, id, attrs) { + attrs = attrs || {}; + assert((/^[a-zA-Z0-9]{1,4000}\/[a-z0-9._-]{1,4000}$/).test(id), "Bad ID (should be alphanumeric):", JSON.stringify(id)); + this._backend = backend; + this._id = id; + this.origin = attrs.origin || null; + this.fullUrl = attrs.fullUrl || null; + if ((!attrs.fullUrl) && attrs.url) { + console.warn("Received deprecated attribute .url"); + this.fullUrl = attrs.url; + } + if (this.origin && !isSecureWebUri(this.origin)) { + this.origin = ""; + } + if (this.fullUrl && !isSecureWebUri(this.fullUrl)) { + this.fullUrl = ""; + } + this.docTitle = attrs.docTitle || null; + this.userTitle = attrs.userTitle || null; + this.createdDate = attrs.createdDate || Date.now(); + this.siteName = attrs.siteName || null; + this.images = []; + if (attrs.images) { + this.images = attrs.images.map( + (json) => new this.Image(json)); + } + this.openGraph = attrs.openGraph || null; + this.twitterCard = attrs.twitterCard || null; + this.documentSize = attrs.documentSize || null; + this.thumbnail = attrs.thumbnail || null; + this.abTests = attrs.abTests || null; + this.firefoxChannel = attrs.firefoxChannel || null; + this._clips = {}; + if (attrs.clips) { + for (const clipId in attrs.clips) { + const clip = attrs.clips[clipId]; + this._clips[clipId] = new this.Clip(this, clipId, clip); + } + } + + const isProd = typeof process !== "undefined" && process.env.NODE_ENV === "production"; + + for (const attr in attrs) { + if (attr !== "clips" && attr !== "id" && !this.REGULAR_ATTRS.includes(attr) && !this.DEPRECATED_ATTRS.includes(attr)) { + if (isProd) { + console.warn("Unexpected attribute: " + attr); + } else { + throw new Error("Unexpected attribute: " + attr); + } + } else if (attr === "id") { + console.warn("passing id in attrs in AbstractShot constructor"); + console.trace(); + assert(attrs.id === this.id); + } + } + } + + /** Update any and all attributes in the json object, with deep updating + of `json.clips` */ + update(json) { + const ALL_ATTRS = ["clips"].concat(this.REGULAR_ATTRS); + assert(checkObject(json, [], ALL_ATTRS), "Bad attr to new Shot():", Object.keys(json)); + for (const attr in json) { + if (attr === "clips") { + continue; + } + if (typeof json[attr] === "object" && typeof this[attr] === "object" && this[attr] !== null) { + let val = this[attr]; + if (val.toJSON) { + val = val.toJSON(); + } + if (!deepEqual(json[attr], val)) { + this[attr] = json[attr]; + } + } else if (json[attr] !== this[attr] && + (json[attr] || this[attr])) { + this[attr] = json[attr]; + } + } + if (json.clips) { + for (const clipId in json.clips) { + if (!json.clips[clipId]) { + this.delClip(clipId); + } else if (!this.getClip(clipId)) { + this.setClip(clipId, json.clips[clipId]); + } else if (!deepEqual(this.getClip(clipId).toJSON(), json.clips[clipId])) { + this.setClip(clipId, json.clips[clipId]); + } + } + } + + } + + /** Returns a JSON version of this shot */ + toJSON() { + const result = {}; + for (const attr of this.REGULAR_ATTRS) { + let val = this[attr]; + if (val && val.toJSON) { + val = val.toJSON(); + } + result[attr] = val; + } + result.clips = {}; + for (const attr in this._clips) { + result.clips[attr] = this._clips[attr].toJSON(); + } + return result; + } + + /** A more minimal JSON representation for creating indexes of shots */ + asRecallJson() { + const result = {clips: {}}; + for (const attr of this.RECALL_ATTRS) { + let val = this[attr]; + if (val && val.toJSON) { + val = val.toJSON(); + } + result[attr] = val; + } + for (const name of this.clipNames()) { + result.clips[name] = this.getClip(name).toJSON(); + } + return result; + } + + get backend() { + return this._backend; + } + + get id() { + return this._id; + } + + get url() { + return this.fullUrl || this.origin; + } + set url(val) { + throw new Error(".url is read-only"); + } + + get fullUrl() { + return this._fullUrl; + } + set fullUrl(val) { + if (val) { + assertUrl(val); + } + this._fullUrl = val || undefined; + } + + get origin() { + return this._origin; + } + set origin(val) { + if (val) { + assertOrigin(val); + } + this._origin = val || undefined; + } + + get isOwner() { + return this._isOwner; + } + + set isOwner(val) { + this._isOwner = val || undefined; + } + + get filename() { + let filenameTitle = this.title; + const date = new Date(this.createdDate); + // eslint-disable-next-line no-control-regex + filenameTitle = filenameTitle.replace(/[:\\<>/!@&?"*.|\x00-\x1F]/g, " "); + filenameTitle = filenameTitle.replace(/\s{1,4000}/g, " "); + const filenameDate = new Date(date.getTime() - date.getTimezoneOffset() * 60 * 1000).toISOString().substring(0, 10); + let clipFilename = `Screenshot_${filenameDate} ${filenameTitle}`; + const clipFilenameBytesSize = clipFilename.length * 2; // JS STrings are UTF-16 + if (clipFilenameBytesSize > 251) { // 255 bytes (Usual filesystems max) - 4 for the ".png" file extension string + const excedingchars = (clipFilenameBytesSize - 246) / 2; // 251 - 5 for ellipsis "[...]" + clipFilename = clipFilename.substring(0, clipFilename.length - excedingchars); + clipFilename = clipFilename + "[...]"; + } + const clip = this.getClip(this.clipNames()[0]); + let extension = ".png"; + if (clip && clip.image && clip.image.type) { + if (clip.image.type === "jpeg") { + extension = ".jpg"; + } + } + return clipFilename + extension; + } + + get urlDisplay() { + if (!this.url) { + return null; + } + if (/^https?:\/\//i.test(this.url)) { + let txt = this.url; + txt = txt.replace(/^[a-z]{1,4000}:\/\//i, ""); + txt = txt.replace(/\/.{0,4000}/, ""); + txt = txt.replace(/^www\./i, ""); + return txt; + } else if (this.url.startsWith("data:")) { + return "data:url"; + } + let txt = this.url; + txt = txt.replace(/\?.{0,4000}/, ""); + return txt; + } + + get viewUrl() { + const url = this.backend + "/" + this.id; + return url; + } + + get creatingUrl() { + let url = `${this.backend}/creating/${this.id}`; + url += `?title=${encodeURIComponent(this.title || "")}`; + url += `&url=${encodeURIComponent(this.url)}`; + return url; + } + + get jsonUrl() { + return this.backend + "/data/" + this.id; + } + + get oembedUrl() { + return this.backend + "/oembed?url=" + encodeURIComponent(this.viewUrl); + } + + get docTitle() { + return this._title; + } + set docTitle(val) { + assert(val === null || typeof val === "string", "Bad docTitle:", val); + this._title = val; + } + + get openGraph() { + return this._openGraph || null; + } + set openGraph(val) { + assert(val === null || typeof val === "object", "Bad openGraph:", val); + if (val) { + assert(checkObject(val, [], this._OPENGRAPH_PROPERTIES), "Bad attr to openGraph:", Object.keys(val)); + this._openGraph = val; + } else { + this._openGraph = null; + } + } + + get twitterCard() { + return this._twitterCard || null; + } + set twitterCard(val) { + assert(val === null || typeof val === "object", "Bad twitterCard:", val); + if (val) { + assert(checkObject(val, [], this._TWITTERCARD_PROPERTIES), "Bad attr to twitterCard:", Object.keys(val)); + this._twitterCard = val; + } else { + this._twitterCard = null; + } + } + + get userTitle() { + return this._userTitle; + } + set userTitle(val) { + assert(val === null || typeof val === "string", "Bad userTitle:", val); + this._userTitle = val; + } + + get title() { + // FIXME: we shouldn't support both openGraph.title and ogTitle + const ogTitle = this.openGraph && this.openGraph.title; + const twitterTitle = this.twitterCard && this.twitterCard.title; + let title = this.userTitle || ogTitle || twitterTitle || this.docTitle || this.url; + if (Array.isArray(title)) { + title = title[0]; + } + if (!title) { + title = "Screenshot"; + } + return title; + } + + get createdDate() { + return this._createdDate; + } + set createdDate(val) { + assert(val === null || typeof val === "number", "Bad createdDate:", val); + this._createdDate = val; + } + + clipNames() { + const names = Object.getOwnPropertyNames(this._clips); + names.sort(function(a, b) { + return a.sortOrder < b.sortOrder ? 1 : 0; + }); + return names; + } + getClip(name) { + return this._clips[name]; + } + addClip(val) { + const name = makeRandomId(); + this.setClip(name, val); + return name; + } + setClip(name, val) { + const clip = new this.Clip(this, name, val); + this._clips[name] = clip; + } + delClip(name) { + if (!this._clips[name]) { + throw new Error("No existing clip with id: " + name); + } + delete this._clips[name]; + } + delAllClips() { + this._clips = {}; + } + biggestClipSortOrder() { + let biggest = 0; + for (const clipId in this._clips) { + biggest = Math.max(biggest, this._clips[clipId].sortOrder); + } + return biggest; + } + updateClipUrl(clipId, clipUrl) { + const clip = this.getClip(clipId); + if ( clip && clip.image ) { + clip.image.url = clipUrl; + } else { + console.warn("Tried to update the url of a clip with no image:", clip); + } + } + + get siteName() { + return this._siteName || null; + } + set siteName(val) { + assert(typeof val === "string" || !val); + this._siteName = val; + } + + get documentSize() { + return this._documentSize; + } + set documentSize(val) { + assert(typeof val === "object" || !val); + if (val) { + assert(checkObject(val, ["height", "width"], "Bad attr to documentSize:", Object.keys(val))); + assert(typeof val.height === "number"); + assert(typeof val.width === "number"); + this._documentSize = val; + } else { + this._documentSize = null; + } + } + + get thumbnail() { + return this._thumbnail; + } + set thumbnail(val) { + assert(typeof val === "string" || !val); + if (val) { + assert(isUrl(val)); + this._thumbnail = val; + } else { + this._thumbnail = null; + } + } + + get abTests() { + return this._abTests; + } + set abTests(val) { + if (val === null || val === undefined) { + this._abTests = null; + return; + } + assert(typeof val === "object", "abTests should be an object, not:", typeof val); + assert(!Array.isArray(val), "abTests should not be an Array"); + for (const name in val) { + assert(val[name] && typeof val[name] === "string", `abTests.${name} should be a string:`, typeof val[name]); + } + this._abTests = val; + } + + get firefoxChannel() { + return this._firefoxChannel; + } + set firefoxChannel(val) { + if (val === null || val === undefined) { + this._firefoxChannel = null; + return; + } + assert(typeof val === "string", "firefoxChannel should be a string, not:", typeof val); + this._firefoxChannel = val; + } + +} + +AbstractShot.prototype.REGULAR_ATTRS = (` +origin fullUrl docTitle userTitle createdDate images +siteName openGraph twitterCard documentSize +thumbnail abTests firefoxChannel +`).split(/\s+/g); + +// Attributes that will be accepted in the constructor, but ignored/dropped +AbstractShot.prototype.DEPRECATED_ATTRS = (` +microdata history ogTitle createdDevice head body htmlAttrs bodyAttrs headAttrs +readable hashtags comments showPage isPublic resources deviceId url +fullScreenThumbnail favicon +`).split(/\s+/g); + +AbstractShot.prototype.RECALL_ATTRS = (` +url docTitle userTitle createdDate openGraph twitterCard images thumbnail +`).split(/\s+/g); + +AbstractShot.prototype._OPENGRAPH_PROPERTIES = (` +title type url image audio description determiner locale site_name video +image:secure_url image:type image:width image:height +video:secure_url video:type video:width image:height +audio:secure_url audio:type +article:published_time article:modified_time article:expiration_time article:author article:section article:tag +book:author book:isbn book:release_date book:tag +profile:first_name profile:last_name profile:username profile:gender +`).split(/\s+/g); + +AbstractShot.prototype._TWITTERCARD_PROPERTIES = (` +card site title description image +player player:width player:height player:stream player:stream:content_type +`).split(/\s+/g); + +/** Represents one found image in the document (not a clip) */ +class _Image { + // FIXME: either we have to notify the shot of updates, or make + // this read-only + constructor(json) { + assert(typeof json === "object", "Clip Image given a non-object", json); + assert(checkObject(json, ["url"], ["dimensions", "title", "alt"]), "Bad attrs for Image:", Object.keys(json)); + assert(isUrl(json.url), "Bad Image url:", json.url); + this.url = json.url; + assert((!json.dimensions) || + (typeof json.dimensions.x === "number" && typeof json.dimensions.y === "number"), + "Bad Image dimensions:", json.dimensions); + this.dimensions = json.dimensions; + assert(typeof json.title === "string" || !json.title, "Bad Image title:", json.title); + this.title = json.title; + assert(typeof json.alt === "string" || !json.alt, "Bad Image alt:", json.alt); + this.alt = json.alt; + } + + toJSON() { + return jsonify(this, ["url"], ["dimensions"]); + } +} + +AbstractShot.prototype.Image = _Image; + +/** Represents a clip, either a text or image clip */ +class _Clip { + constructor(shot, id, json) { + this._shot = shot; + assert(checkObject(json, ["createdDate", "image"], ["sortOrder"]), "Bad attrs for Clip:", Object.keys(json)); + assert(typeof id === "string" && id, "Bad Clip id:", id); + this._id = id; + this.createdDate = json.createdDate; + if ("sortOrder" in json) { + assert(typeof json.sortOrder === "number" || !json.sortOrder, "Bad Clip sortOrder:", json.sortOrder); + } + if ("sortOrder" in json) { + this.sortOrder = json.sortOrder; + } else { + const biggestOrder = shot.biggestClipSortOrder(); + this.sortOrder = biggestOrder + 100; + } + this.image = json.image; + } + + toString() { + return `[Shot Clip id=${this.id} sortOrder=${this.sortOrder} image ${this.image.dimensions.x}x${this.image.dimensions.y}]`; + } + + toJSON() { + return jsonify(this, ["createdDate"], ["sortOrder", "image"]); + } + + get id() { + return this._id; + } + + get createdDate() { + return this._createdDate; + } + set createdDate(val) { + assert(typeof val === "number" || !val, "Bad Clip createdDate:", val); + this._createdDate = val; + } + + get image() { + return this._image; + } + set image(image) { + if (!image) { + this._image = undefined; + return; + } + assert(checkObject(image, ["url"], ["dimensions", "text", "location", "captureType", "type"]), "Bad attrs for Clip Image:", Object.keys(image)); + assert(isValidClipImageUrl(image.url), "Bad Clip image URL:", image.url); + assert( + image.captureType === "madeSelection" || + image.captureType === "selection" || + image.captureType === "visible" || + image.captureType === "auto" || + image.captureType === "fullPage" || + image.captureType === "fullPageTruncated" || + !image.captureType, "Bad image.captureType:", image.captureType); + assert(typeof image.text === "string" || !image.text, "Bad Clip image text:", image.text); + if (image.dimensions) { + assert(typeof image.dimensions.x === "number" && typeof image.dimensions.y === "number", "Bad Clip image dimensions:", image.dimensions); + } + if (image.type) { + assert(image.type === "png" || image.type === "jpeg", "Unexpected image type:", image.type); + } + assert(image.location && + typeof image.location.left === "number" && + typeof image.location.right === "number" && + typeof image.location.top === "number" && + typeof image.location.bottom === "number", "Bad Clip image pixel location:", image.location); + if (image.location.topLeftElement || image.location.topLeftOffset || + image.location.bottomRightElement || image.location.bottomRightOffset) { + assert(typeof image.location.topLeftElement === "string" && + image.location.topLeftOffset && + typeof image.location.topLeftOffset.x === "number" && + typeof image.location.topLeftOffset.y === "number" && + typeof image.location.bottomRightElement === "string" && + image.location.bottomRightOffset && + typeof image.location.bottomRightOffset.x === "number" && + typeof image.location.bottomRightOffset.y === "number", + "Bad Clip image element location:", image.location); + } + this._image = image; + } + + isDataUrl() { + if (this.image) { + return this.image.url.startsWith("data:"); + } + return false; + } + + get sortOrder() { + return this._sortOrder || null; + } + set sortOrder(val) { + assert(typeof val === "number" || !val, "Bad Clip sortOrder:", val); + this._sortOrder = val; + } + +} + +AbstractShot.prototype.Clip = _Clip; + +if (typeof exports !== "undefined") { + exports.AbstractShot = AbstractShot; + exports.originFromUrl = originFromUrl; + exports.isValidClipImageUrl = isValidClipImageUrl; +} + +return exports; +})(); +null; + diff --git a/browser/extensions/screenshots/build/thumbnailGenerator.js b/browser/extensions/screenshots/build/thumbnailGenerator.js new file mode 100644 index 0000000000..e51105fe94 --- /dev/null +++ b/browser/extensions/screenshots/build/thumbnailGenerator.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/. */ + +this.thumbnailGenerator = (function () {let exports={}; // This is used in webextension/background/takeshot.js, +// server/src/pages/shot/controller.js, and +// server/scr/pages/shotindex/view.js. It is used in a browser +// environment. + +// Resize down 1/2 at a time produces better image quality. +// Not quite as good as using a third-party filter (which will be +// slower), but good enough. +const maxResizeScaleFactor = 0.5; + +// The shot will be scaled or cropped down to 210px on x, and cropped or +// scaled down to a maximum of 280px on y. +// x: 210 +// y: <= 280 +const maxThumbnailWidth = 210; +const maxThumbnailHeight = 280; + +/** + * @param {int} imageHeight Height in pixels of the original image. + * @param {int} imageWidth Width in pixels of the original image. + * @returns {width, height, scaledX, scaledY} + */ +function getThumbnailDimensions(imageWidth, imageHeight) { + const displayAspectRatio = 3 / 4; + const imageAspectRatio = imageWidth / imageHeight; + let thumbnailImageWidth, thumbnailImageHeight; + let scaledX, scaledY; + + if (imageAspectRatio > displayAspectRatio) { + // "Landscape" mode + // Scale on y, crop on x + const yScaleFactor = (imageHeight > maxThumbnailHeight) ? (maxThumbnailHeight / imageHeight) : 1.0; + thumbnailImageHeight = scaledY = Math.round(imageHeight * yScaleFactor); + scaledX = Math.round(imageWidth * yScaleFactor); + thumbnailImageWidth = Math.min(scaledX, maxThumbnailWidth); + } else { + // "Portrait" mode + // Scale on x, crop on y + const xScaleFactor = (imageWidth > maxThumbnailWidth) ? (maxThumbnailWidth / imageWidth) : 1.0; + thumbnailImageWidth = scaledX = Math.round(imageWidth * xScaleFactor); + scaledY = Math.round(imageHeight * xScaleFactor); + // The CSS could widen the image, in which case we crop more off of y. + thumbnailImageHeight = Math.min(scaledY, maxThumbnailHeight, + maxThumbnailHeight / (maxThumbnailWidth / imageWidth)); + } + + return { + width: thumbnailImageWidth, + height: thumbnailImageHeight, + scaledX, + scaledY, + }; +} + +/** + * @param {dataUrl} String Data URL of the original image. + * @param {int} imageHeight Height in pixels of the original image. + * @param {int} imageWidth Width in pixels of the original image. + * @param {String} urlOrBlob 'blob' for a blob, otherwise data url. + * @returns A promise that resolves to the data URL or blob of the thumbnail image, or null. + */ +function createThumbnail(dataUrl, imageWidth, imageHeight, urlOrBlob) { + // There's cost associated with generating, transmitting, and storing + // thumbnails, so we'll opt out if the image size is below a certain threshold + const thumbnailThresholdFactor = 1.20; + const thumbnailWidthThreshold = maxThumbnailWidth * thumbnailThresholdFactor; + const thumbnailHeightThreshold = maxThumbnailHeight * thumbnailThresholdFactor; + + if (imageWidth <= thumbnailWidthThreshold && + imageHeight <= thumbnailHeightThreshold) { + // Do not create a thumbnail. + return Promise.resolve(null); + } + + const thumbnailDimensions = getThumbnailDimensions(imageWidth, imageHeight); + + return new Promise((resolve, reject) => { + const thumbnailImage = new Image(); + let srcWidth = imageWidth; + let srcHeight = imageHeight; + let destWidth, destHeight; + + thumbnailImage.onload = function() { + destWidth = Math.round(srcWidth * maxResizeScaleFactor); + destHeight = Math.round(srcHeight * maxResizeScaleFactor); + if (destWidth <= thumbnailDimensions.scaledX || destHeight <= thumbnailDimensions.scaledY) { + srcWidth = Math.round(srcWidth * (thumbnailDimensions.width / thumbnailDimensions.scaledX)); + srcHeight = Math.round(srcHeight * (thumbnailDimensions.height / thumbnailDimensions.scaledY)); + destWidth = thumbnailDimensions.width; + destHeight = thumbnailDimensions.height; + } + + const thumbnailCanvas = document.createElement("canvas"); + thumbnailCanvas.width = destWidth; + thumbnailCanvas.height = destHeight; + const ctx = thumbnailCanvas.getContext("2d"); + ctx.imageSmoothingEnabled = false; + + ctx.drawImage( + thumbnailImage, + 0, 0, srcWidth, srcHeight, + 0, 0, destWidth, destHeight); + + if (thumbnailCanvas.width <= thumbnailDimensions.width || + thumbnailCanvas.height <= thumbnailDimensions.height) { + if (urlOrBlob === "blob") { + thumbnailCanvas.toBlob((blob) => { + resolve(blob); + }); + } else { + resolve(thumbnailCanvas.toDataURL("image/png")); + } + return; + } + + srcWidth = destWidth; + srcHeight = destHeight; + thumbnailImage.src = thumbnailCanvas.toDataURL(); + }; + thumbnailImage.src = dataUrl; + }); +} + +function createThumbnailUrl(shot) { + const image = shot.getClip(shot.clipNames()[0]).image; + if (!image.url) { + return Promise.resolve(null); + } + return createThumbnail( + image.url, image.dimensions.x, image.dimensions.y, "dataurl"); +} + +function createThumbnailBlobFromPromise(shot, blobToUrlPromise) { + return blobToUrlPromise.then(dataUrl => { + const image = shot.getClip(shot.clipNames()[0]).image; + return createThumbnail( + dataUrl, image.dimensions.x, image.dimensions.y, "blob"); + }); +} + +if (typeof exports !== "undefined") { + exports.getThumbnailDimensions = getThumbnailDimensions; + exports.createThumbnailUrl = createThumbnailUrl; + exports.createThumbnailBlobFromPromise = createThumbnailBlobFromPromise; +} + +return exports; +})(); +null; + diff --git a/browser/extensions/screenshots/catcher.js b/browser/extensions/screenshots/catcher.js new file mode 100644 index 0000000000..f16c6a0202 --- /dev/null +++ b/browser/extensions/screenshots/catcher.js @@ -0,0 +1,101 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +// eslint-disable-next-line no-var +var global = this; + +this.catcher = (function() { + const exports = {}; + + let handler; + + let queue = []; + + const log = global.log; + + exports.unhandled = function(error, info) { + if (!error.noReport) { + log.error("Unhandled error:", error, info); + } + const e = makeError(error, info); + if (!handler) { + queue.push(e); + } else { + handler(e); + } + }; + + /** Turn an exception into an error object */ + function makeError(exc, info) { + let result; + if (exc.fromMakeError) { + result = exc; + } else { + result = { + fromMakeError: true, + name: exc.name || "ERROR", + message: String(exc), + stack: exc.stack, + }; + for (const attr in exc) { + result[attr] = exc[attr]; + } + } + if (info) { + for (const attr of Object.keys(info)) { + result[attr] = info[attr]; + } + } + return result; + } + + /** Wrap the function, and if it raises any exceptions then call unhandled() */ + exports.watchFunction = function watchFunction(func, quiet) { + return function() { + try { + return func.apply(this, arguments); + } catch (e) { + if (!quiet) { + exports.unhandled(e); + } + throw e; + } + }; + }; + + exports.watchPromise = function watchPromise(promise, quiet) { + return promise.catch((e) => { + if (quiet) { + if (!e.noReport) { + log.debug("------Error in promise:", e); + log.debug(e.stack); + } + } else { + if (!e.noReport) { + log.error("------Error in promise:", e); + log.error(e.stack); + } + exports.unhandled(makeError(e)); + } + throw e; + }); + }; + + exports.registerHandler = function(h) { + if (handler) { + log.error("registerHandler called after handler was already registered"); + return; + } + handler = h; + for (const error of queue) { + handler(error); + } + queue = []; + }; + + return exports; +})(); +null; diff --git a/browser/extensions/screenshots/clipboard.js b/browser/extensions/screenshots/clipboard.js new file mode 100644 index 0000000000..313a3e6372 --- /dev/null +++ b/browser/extensions/screenshots/clipboard.js @@ -0,0 +1,58 @@ +/* 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, assertIsBlankDocument */ + +"use strict"; + +this.clipboard = (function() { + const exports = {}; + + exports.copy = function(text) { + return new Promise((resolve, reject) => { + const element = document.createElement("iframe"); + element.src = browser.extension.getURL("blank.html"); + // We can't actually hide the iframe while copying, but we can make + // it close to invisible: + element.style.opacity = "0"; + element.style.width = "1px"; + element.style.height = "1px"; + element.style.display = "block"; + element.addEventListener("load", catcher.watchFunction(() => { + try { + const doc = element.contentDocument; + assertIsBlankDocument(doc); + const el = doc.createElement("textarea"); + doc.body.appendChild(el); + el.value = text; + if (!text) { + const exc = new Error("Clipboard copy given empty text"); + exc.noPopup = true; + catcher.unhandled(exc); + } + el.select(); + if (doc.activeElement !== el) { + const unhandledTag = doc.activeElement ? doc.activeElement.tagName : "No active element"; + const exc = new Error("Clipboard el.select failed"); + exc.activeElement = unhandledTag; + exc.noPopup = true; + catcher.unhandled(exc); + } + const copied = doc.execCommand("copy"); + if (!copied) { + catcher.unhandled(new Error("Clipboard copy failed")); + } + el.remove(); + resolve(copied); + } finally { + element.remove(); + } + }), {once: true}); + document.body.appendChild(element); + }); + }; + + return exports; +})(); +null; diff --git a/browser/extensions/screenshots/domainFromUrl.js b/browser/extensions/screenshots/domainFromUrl.js new file mode 100644 index 0000000000..421e752508 --- /dev/null +++ b/browser/extensions/screenshots/domainFromUrl.js @@ -0,0 +1,33 @@ +/* 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/. */ + +/** Returns the domain of a URL, but safely and in ASCII; URLs without domains + (such as about:blank) return the scheme, Unicode domains get stripped down + to ASCII */ + +"use strict"; + +this.domainFromUrl = (function() { + + return function urlDomainForId(location) { // eslint-disable-line no-unused-vars + let domain = location.hostname; + if (!domain) { + domain = location.origin.split(":")[0]; + if (!domain) { + domain = "unknown"; + } + } + if (domain.search(/^[a-z0-9._-]{1,1000}$/i) === -1) { + // Probably a unicode domain; we could use punycode but it wouldn't decode + // well in the URL anyway. Instead we'll punt. + domain = domain.replace(/[^a-z0-9._-]/ig, ""); + if (!domain) { + domain = "site"; + } + } + return domain; + }; + +})(); +null; diff --git a/browser/extensions/screenshots/experiments/screenshots/api.js b/browser/extensions/screenshots/experiments/screenshots/api.js new file mode 100644 index 0000000000..946ff3a5c0 --- /dev/null +++ b/browser/extensions/screenshots/experiments/screenshots/api.js @@ -0,0 +1,47 @@ +/* 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, AppConstants, Services, ExtensionAPI */ + +"use strict"; + +ChromeUtils.defineModuleGetter(this, "AppConstants", + "resource://gre/modules/AppConstants.jsm"); +ChromeUtils.defineModuleGetter(this, "Services", + "resource://gre/modules/Services.jsm"); + +this.screenshots = class extends ExtensionAPI { + getAPI(context) { + const {extension} = context; + return { + experiments: { + screenshots: { + // If you are checking for 'nightly', also check for 'nightly-try'. + // + // Otherwise, just use the standard builds, but be aware of the many + // non-standard options that also exist (as of August 2018). + // + // Standard builds: + // 'esr' - ESR channel + // 'release' - release channel + // 'beta' - beta channel + // 'nightly' - nightly channel + // Non-standard / deprecated builds: + // 'aurora' - deprecated aurora channel (still observed in dxr) + // 'default' - local builds from source + // 'nightly-try' - nightly Try builds (QA may occasionally need to test with these) + getUpdateChannel() { + return AppConstants.MOZ_UPDATE_CHANNEL; + }, + isHistoryEnabled() { + return Services.prefs.getBoolPref("places.history.enabled", true); + }, + isUploadDisabled() { + return Services.prefs.getBoolPref("extensions.screenshots.upload-disabled", false); + }, + }, + }, + }; + } +}; diff --git a/browser/extensions/screenshots/experiments/screenshots/schema.json b/browser/extensions/screenshots/experiments/screenshots/schema.json new file mode 100644 index 0000000000..99cf39e7f1 --- /dev/null +++ b/browser/extensions/screenshots/experiments/screenshots/schema.json @@ -0,0 +1,29 @@ +[ + { + "namespace": "experiments.screenshots", + "description": "Firefox Screenshots internal API", + "functions": [ + { + "name": "getUpdateChannel", + "type": "function", + "description": "Returns the Firefox channel (AppConstants.MOZ_UPDATE_CHANNEL)", + "parameters": [], + "async": true + }, + { + "name": "isHistoryEnabled", + "type": "function", + "description": "Returns the value of the 'places.history.enabled' preference", + "parameters": [], + "async": true + }, + { + "name": "isUploadDisabled", + "type": "function", + "description": "Returns the value of the 'extensions.screenshots.upload-disabled' preference", + "parameters": [], + "async": true + } + ] + } +] diff --git a/browser/extensions/screenshots/icons/cancel.svg b/browser/extensions/screenshots/icons/cancel.svg new file mode 100644 index 0000000000..3da18aeab7 --- /dev/null +++ b/browser/extensions/screenshots/icons/cancel.svg @@ -0,0 +1,4 @@ +<!-- 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/. --> +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"><path d="M10.5 8.7L5.2 3.3c-.5-.5-1.3-.5-1.8 0s-.5 1.3 0 1.8l5.3 5.3-5.3 5.3c-.5.5-.5 1.3 0 1.8s1.3.5 1.8 0l5.3-5.3 5.3 5.3c.5.5 1.3.5 1.8 0s.5-1.3 0-1.8l-5.3-5.3 5.3-5.3c.5-.5.5-1.3 0-1.8s-1.3-.5-1.8 0l-5.3 5.4z" fill="#3e3d40"/></svg>
\ No newline at end of file diff --git a/browser/extensions/screenshots/icons/cloud.svg b/browser/extensions/screenshots/icons/cloud.svg new file mode 100644 index 0000000000..51eed6b6ac --- /dev/null +++ b/browser/extensions/screenshots/icons/cloud.svg @@ -0,0 +1,4 @@ +<!-- 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/. --> +<svg width="20" height="18" xmlns="http://www.w3.org/2000/svg"><g fill="#FFF" fill-rule="evenodd"><path d="M15 5.6h-.3C14.5 2.7 12 .5 9.2.5c-3 0-5.4 2.4-5.5 5.3C1.5 6.4 0 8.3 0 10.6c0 2.8 2.2 5 5 5a1 1 0 0 0 1-1v-.1a1 1 0 0 0-1-1c-1.7 0-3-1.3-3-3 0-1.3.8-2.5 2.2-2.9l1.4-.4.1-1.4c.1-1.9 1.6-3.3 3.5-3.3 1.8 0 3.4 1.4 3.5 3.2l.1 1.8h2.1c1.7 0 3 1.3 3 3s-1.3 3-3 3h-1.85a1.05 1.05 0 1 0 0 2.1H15c2.8 0 5-2.2 5-5s-2.2-5-5-5z" fill-rule="nonzero"/><path d="M10 11.414V17c0 .667-.333 1-1 1s-1-.333-1-1v-5.586l-.293.293a1 1 0 1 1-1.414-1.414L9 7.586l2.707 2.707a1 1 0 0 1-1.414 1.414L10 11.414z"/></g></svg>
\ No newline at end of file diff --git a/browser/extensions/screenshots/icons/copied-notification.svg b/browser/extensions/screenshots/icons/copied-notification.svg new file mode 100644 index 0000000000..2310b41aef --- /dev/null +++ b/browser/extensions/screenshots/icons/copied-notification.svg @@ -0,0 +1,4 @@ +<!-- 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/. --> +<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48"><path fill="context-fill" d="M44.121 24.879l-9-9A3 3 0 0 0 33 15h-3v-3a3 3 0 0 0-.879-2.121l-9-9A3 3 0 0 0 18 0H9a6 6 0 0 0-6 6v21a6 6 0 0 0 6 6h9v9a6 6 0 0 0 6 6h15a6 6 0 0 0 6-6V27a3 3 0 0 0-.879-2.121zM37.758 27H33v-4.758zm-15-15H18V7.242zM18 21v6H9V6h6v7.5a1.5 1.5 0 0 0 1.5 1.5H24a6 6 0 0 0-6 6zm6 21V21h6v7.5a1.5 1.5 0 0 0 1.5 1.5H39v12z"/></svg>
\ No newline at end of file diff --git a/browser/extensions/screenshots/icons/copy.svg b/browser/extensions/screenshots/icons/copy.svg new file mode 100644 index 0000000000..d601e73b21 --- /dev/null +++ b/browser/extensions/screenshots/icons/copy.svg @@ -0,0 +1,4 @@ +<!-- 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/. --> +<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"><path fill="#3e3d40" d="M14.707 8.293l-3-3A1 1 0 0 0 11 5h-1V4a1 1 0 0 0-.293-.707l-3-3A1 1 0 0 0 6 0H3a2 2 0 0 0-2 2v7a2 2 0 0 0 2 2h3v3a2 2 0 0 0 2 2h5a2 2 0 0 0 2-2V9a1 1 0 0 0-.293-.707zM12.586 9H11V7.414zm-5-5H6V2.414zM6 7v2H3V2h2v2.5a.5.5 0 0 0 .5.5H8a2 2 0 0 0-2 2zm2 7V7h2v2.5a.5.5 0 0 0 .5.5H13v4z"/></svg>
\ No newline at end of file diff --git a/browser/extensions/screenshots/icons/download-white.svg b/browser/extensions/screenshots/icons/download-white.svg new file mode 100644 index 0000000000..bb6a7de845 --- /dev/null +++ b/browser/extensions/screenshots/icons/download-white.svg @@ -0,0 +1,4 @@ +<!-- 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/. --> +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"><path d="M9.1 12L4.9 7.9c-.5-.5-1.3-.5-1.8 0s-.5 1.3 0 1.8l6.2 6.2c.5.5 1.3.5 1.8 0l6.2-6.2c.5-.5.5-1.3 0-1.8s-1.3-.5-1.8 0L11.6 12V1.2C11.6.6 11 0 10.3 0c-.7 0-1.2.6-1.2 1.2V12zM4 20c-.7 0-1.2-.6-1.2-1.2s.6-1.2 1.2-1.2h12.5c.7 0 1.2.6 1.2 1.2s-.5 1.2-1.2 1.2H4z" fill="#fff"/></svg>
\ No newline at end of file diff --git a/browser/extensions/screenshots/icons/download.svg b/browser/extensions/screenshots/icons/download.svg new file mode 100644 index 0000000000..e3075a5ccd --- /dev/null +++ b/browser/extensions/screenshots/icons/download.svg @@ -0,0 +1,4 @@ +<!-- 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/. --> +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"><path d="M9.1 12L4.9 7.9c-.5-.5-1.3-.5-1.8 0s-.5 1.3 0 1.8l6.2 6.2c.5.5 1.3.5 1.8 0l6.2-6.2c.5-.5.5-1.3 0-1.8s-1.3-.5-1.8 0L11.6 12V1.2C11.6.6 11 0 10.3 0c-.7 0-1.2.6-1.2 1.2V12zM4 20c-.7 0-1.2-.6-1.2-1.2s.6-1.2 1.2-1.2h12.5c.7 0 1.2.6 1.2 1.2s-.5 1.2-1.2 1.2H4z" fill="#3e3d40"/></svg>
\ No newline at end of file diff --git a/browser/extensions/screenshots/icons/help-16.svg b/browser/extensions/screenshots/icons/help-16.svg new file mode 100644 index 0000000000..614d024e2a --- /dev/null +++ b/browser/extensions/screenshots/icons/help-16.svg @@ -0,0 +1,4 @@ +<!-- 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/. --> +<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"><path fill="rgba(249, 249, 250, .8)" d="M8 1a7 7 0 1 0 7 7 7.008 7.008 0 0 0-7-7zm0 13a6 6 0 1 1 6-6 6.007 6.007 0 0 1-6 6zM8 3.125A2.7 2.7 0 0 0 5.125 6a.875.875 0 0 0 1.75 0c0-1 .6-1.125 1.125-1.125a1.105 1.105 0 0 1 1.13.744.894.894 0 0 1-.53 1.016A2.738 2.738 0 0 0 7.125 9v.337a.875.875 0 0 0 1.75 0v-.37a1.041 1.041 0 0 1 .609-.824A2.637 2.637 0 0 0 10.82 5.16 2.838 2.838 0 0 0 8 3.125zm0 7.625A1.25 1.25 0 1 0 9.25 12 1.25 1.25 0 0 0 8 10.75z"/></svg>
\ No newline at end of file diff --git a/browser/extensions/screenshots/icons/icon-highlight-32-v2.svg b/browser/extensions/screenshots/icons/icon-highlight-32-v2.svg new file mode 100644 index 0000000000..ef226edf27 --- /dev/null +++ b/browser/extensions/screenshots/icons/icon-highlight-32-v2.svg @@ -0,0 +1,4 @@ +<!-- 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/. --> +<svg width="32" height="32" xmlns="http://www.w3.org/2000/svg"><path d="M8 2a4 4 0 0 0-4 4h4V2zm12 0h-4v4h4V2zm8 0v4h4a4 4 0 0 0-4-4zM14 2h-4v4h4V2zm12 0h-4v4h4V2zm2 10h4V8h-4v4zm0 12a4 4 0 0 0 4-4h-4v4zm0-6h4v-4h-4v4zm-.882-4.334a4 4 0 0 0-5.57-.984l-7.67 5.662-3.936-2.76c.031-.193.05-.388.058-.584a4.976 4.976 0 0 0-2-3.978V8H4v2.1a5 5 0 1 0 3.916 8.948l2.484 1.738-2.8 1.964a4.988 4.988 0 1 0 2.3 3.266l17.218-12.35zM5 17.5a2.5 2.5 0 1 1 0-5 2.5 2.5 0 0 1 0 5zm0 12a2.5 2.5 0 1 1 0-5 2.5 2.5 0 0 1 0 5zm10.8-4.858l6.034 4.6a4 4 0 0 0 5.57-.984L19.28 22.2l-3.48 2.442z" fill="#989898"/></svg>
\ No newline at end of file diff --git a/browser/extensions/screenshots/icons/icon-v2.svg b/browser/extensions/screenshots/icons/icon-v2.svg new file mode 100644 index 0000000000..92b1473b61 --- /dev/null +++ b/browser/extensions/screenshots/icons/icon-v2.svg @@ -0,0 +1,4 @@ +<!-- 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/. --> +<svg viewBox="0 0 32 32" width="32" height="32" xmlns="http://www.w3.org/2000/svg" fill="context-fill" fill-opacity="context-fill-opacity"><path d="M8 2a4 4 0 0 0-4 4h4V2zm12 0h-4v4h4V2zm8 0v4h4a4 4 0 0 0-4-4zM14 2h-4v4h4V2zm12 0h-4v4h4V2zm2 10h4V8h-4v4zm0 12a4 4 0 0 0 4-4h-4v4zm0-6h4v-4h-4v4zm-.882-4.334a4 4 0 0 0-5.57-.984l-7.67 5.662-3.936-2.76c.031-.193.05-.388.058-.584a4.976 4.976 0 0 0-2-3.978V8H4v2.1a5 5 0 1 0 3.916 8.948l2.484 1.738-2.8 1.964a4.988 4.988 0 1 0 2.3 3.266l17.218-12.35zM5 17.5a2.5 2.5 0 1 1 0-5 2.5 2.5 0 0 1 0 5zm0 12a2.5 2.5 0 1 1 0-5 2.5 2.5 0 0 1 0 5zm10.8-4.858l6.034 4.6a4 4 0 0 0 5.57-.984L19.28 22.2l-3.48 2.442z"/></svg> diff --git a/browser/extensions/screenshots/icons/icon-welcome-face-without-eyes.svg b/browser/extensions/screenshots/icons/icon-welcome-face-without-eyes.svg new file mode 100644 index 0000000000..138308af57 --- /dev/null +++ b/browser/extensions/screenshots/icons/icon-welcome-face-without-eyes.svg @@ -0,0 +1,4 @@ +<!-- 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/. --> +<svg id="Layer_1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><style>.st0{fill:#fff}</style><g id="Visual-design"><g id="_x31_.2-Div-selection" transform="translate(-575 -503)"><g id="Introduction" transform="translate(250 503)"><g id="icon-welcomeface" transform="translate(325)"><g id="Layer_1_1_"><path id="Shape" class="st0" d="M11.4.9v2.9h-6c-.9 0-1.5.8-1.5 1.5v6H.8V3.8C.8 2.1 2.2.7 3.9.7h7.6v.2h-.1z"/><path id="Shape_1_" class="st0" d="M63.2 11.4h-3.1v-6c0-.8-.6-1.5-1.5-1.5h-6v-3h7.6c1.7 0 3.1 1.4 3.1 3.1l-.1 7.4z"/><path id="Shape_2_" class="st0" d="M52.6 63.2v-3.1h6c.9 0 1.5-.6 1.5-1.5v-6h3.1v7.6c0 1.7-1.4 3.1-3.1 3.1l-7.5-.1z"/><path id="Shape_3_" class="st0" d="M.8 52.7h3.1v6c0 .9.6 1.5 1.5 1.5h6v3.1H3.8c-1.7 0-3.1-1.4-3.1-3.1l.1-7.5z"/><path id="Shape_6_" class="st0" d="M33.3 49.2H33c-4.6-.1-7.8-3.6-7.9-3.8-.6-.8-.6-2 .1-2.7.8-.8 1.9-.6 2.6.1 0 0 2.3 2.6 5.2 2.6 1.8 0 3.6-.9 5.2-2.6.8-.8 1.9-.8 2.7 0s.8 1.9 0 2.7c-2.2 2.4-4.9 3.7-7.6 3.7z"/></g></g></g></g></g></svg>
\ No newline at end of file diff --git a/browser/extensions/screenshots/icons/menu-fullpage.svg b/browser/extensions/screenshots/icons/menu-fullpage.svg new file mode 100644 index 0000000000..14f5c843d7 --- /dev/null +++ b/browser/extensions/screenshots/icons/menu-fullpage.svg @@ -0,0 +1,4 @@ +<!-- 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/. --> +<svg id="Layer_1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 46 46"><style>.st2{fill:#004c66}</style><path id="bg" d="M7 42h32V5.1H7z" fill="#00fdff"/><g id="frame" transform="translate(0 6)"><path d="M40 5c.5 0 1 .4 1 1v24c0 .5-.5 1-1 1H6c-.6 0-1-.5-1-1V6c0-.6.4-1 1-1h34zM7 29h32V7H7v22z" fill="#3e3d40"/><path id="Fill-4" class="st2" d="M7 7h32V5H7z"/><path id="Fill-6" class="st2" d="M7 31h32v-2H7z"/></g><path id="dash" d="M38 11h1V9h-1v2zm0 3h1v-2h-1v2zm0 3h1v-2h-1v2zm0 3h1v-2h-1v2zm0 3h1v-2h-1v2zm0 3h1v-2h-1v2zm0 3h1v-2h-1v2zm0 3h1v-2h-1v2zm0 3h1v-2h-1v2zm0 3h1v-2h-1v2zm0 3h1v-2h-1v2zm-1 1h2v-1h-2v1zm-3 0h2v-1h-2v1zm-3 0h2v-1h-2v1zm-3 0h2v-1h-2v1zm-3 0h2v-1h-2v1zm-3 0h2v-1h-2v1zm-3 0h2v-1h-2v1zm-3 0h2v-1h-2v1zm-3 0h2v-1h-2v1zm-3 0h2v-1h-2v1zm-2-3H7v3h2v-1H8v-2zm-1-1h1v-2H7v2zm0-3h1v-2H7v2zm0-3h1v-2H7v2zm0-3h1v-2H7v2zm0-3h1v-2H7v2zm0-3h1v-2H7v2zm0-3h1v-2H7v2zm0-3h1v-2H7v2zm0-3h1v-2H7v2zm0-3h1V9H7v2zm2-6H7v3h1V6h1V5zm1 1h2V5h-2v1zm3 0h2V5h-2v1zm3 0h2V5h-2v1zm3 0h2V5h-2v1zm3 0h2V5h-2v1zm3 0h2V5h-2v1zm3 0h2V5h-2v1zm3 0h2V5h-2v1zm3 0h2V5h-2v1zm5-1h-2v1h1v2h1V5z" fill="#00d1e6"/></svg>
\ No newline at end of file diff --git a/browser/extensions/screenshots/icons/menu-myshot-white.svg b/browser/extensions/screenshots/icons/menu-myshot-white.svg new file mode 100644 index 0000000000..ffd61d8bfc --- /dev/null +++ b/browser/extensions/screenshots/icons/menu-myshot-white.svg @@ -0,0 +1,4 @@ +<!-- 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/. --> +<svg width="40" height="40" viewBox="0 0 46 46" xmlns="http://www.w3.org/2000/svg"><path d="M11 11.995a1 1 0 0 1 .995-.995h23.01a1 1 0 0 1 .995.995v23.01a1 1 0 0 1-.995.995h-23.01a1 1 0 0 1-.995-.995v-23.01zM11 25v-2h7v2h-7zm9-5h7v-2h-7v2zm9 5h7v-2h-7v2zm-9 4h7v-2h-7v2zm-2-18h2v25h-2V11zm9 0h2v25h-2V11z" fill="#FFF" fill-rule="evenodd"/></svg>
\ No newline at end of file diff --git a/browser/extensions/screenshots/icons/menu-myshot.svg b/browser/extensions/screenshots/icons/menu-myshot.svg new file mode 100644 index 0000000000..f442fdfb8d --- /dev/null +++ b/browser/extensions/screenshots/icons/menu-myshot.svg @@ -0,0 +1,4 @@ +<!-- 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/. --> +<svg width="46" height="46" xmlns="http://www.w3.org/2000/svg"><path d="M11 11.995a1 1 0 0 1 .995-.995h23.01a1 1 0 0 1 .995.995v23.01a1 1 0 0 1-.995.995h-23.01a1 1 0 0 1-.995-.995v-23.01zM11 25v-2h7v2h-7zm9-5h7v-2h-7v2zm9 5h7v-2h-7v2zm-9 4h7v-2h-7v2zm-2-18h2v25h-2V11zm9 0h2v25h-2V11z" fill="#3E3D40" fill-rule="evenodd"/></svg>
\ No newline at end of file diff --git a/browser/extensions/screenshots/icons/menu-visible.svg b/browser/extensions/screenshots/icons/menu-visible.svg new file mode 100644 index 0000000000..4322579943 --- /dev/null +++ b/browser/extensions/screenshots/icons/menu-visible.svg @@ -0,0 +1,4 @@ +<!-- 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/. --> +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 46 46"><path d="M5 12c0-.6.5-1 1-1h34c.6 0 1 .5 1 1v24c0 .6-.5 1-1 1H6c-.6 0-1-.5-1-1V12zm2 23V13h32v22H7z" fill="#3e3d40"/><path d="M7 35h32V13H7z" fill="#00fdff"/><path d="M38 19h1v-2h-1v2zm0 3h1v-2h-1v2zm0 3h1v-2h-1v2zm0 3h1v-2h-1v2zm0 3h1v-2h-1v2zm0 3h1v-2h-1v2zm-1 1h2v-1h-2v1zm-3 0h2v-1h-2v1zm-3 0h2v-1h-2v1zm-3 0h2v-1h-2v1zm-3 0h2v-1h-2v1zm-3 0h2v-1h-2v1zm-3 0h2v-1h-2v1zm-3 0h2v-1h-2v1zm-3 0h2v-1h-2v1zm-3 0h2v-1h-2v1zm-2-3H7v3h2v-1H8v-2zm-1-1h1v-2H7v2zm0-3h1v-2H7v2zm0-3h1v-2H7v2zm0-3h1v-2H7v2zm0-3h1v-2H7v2zm2-6H7v3h1v-2h1v-1zm1 1h2v-1h-2v1zm3 0h2v-1h-2v1zm3 0h2v-1h-2v1zm3 0h2v-1h-2v1zm3 0h2v-1h-2v1zm3 0h2v-1h-2v1zm3 0h2v-1h-2v1zm3 0h2v-1h-2v1zm3 0h2v-1h-2v1zm5-1h-2v1h1v2h1v-3z" fill="#00d1e6"/></svg>
\ No newline at end of file diff --git a/browser/extensions/screenshots/log.js b/browser/extensions/screenshots/log.js new file mode 100644 index 0000000000..96be7e57ed --- /dev/null +++ b/browser/extensions/screenshots/log.js @@ -0,0 +1,52 @@ +/* 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 buildSettings */ +/* eslint-disable no-console */ + +"use strict"; + +this.log = (function() { + const exports = {}; + + const levels = ["debug", "info", "warn", "error"]; + if (!levels.includes(buildSettings.logLevel)) { + console.warn("Invalid buildSettings.logLevel:", buildSettings.logLevel); + } + const shouldLog = {}; + + { + let startLogging = false; + for (const level of levels) { + if (buildSettings.logLevel === level) { + startLogging = true; + } + if (startLogging) { + shouldLog[level] = true; + } + } + } + + function stub() {} + exports.debug = exports.info = exports.warn = exports.error = stub; + + if (shouldLog.debug) { + exports.debug = console.debug; + } + + if (shouldLog.info) { + exports.info = console.info; + } + + if (shouldLog.warn) { + exports.warn = console.warn; + } + + if (shouldLog.error) { + exports.error = console.error; + } + + return exports; +})(); +null; diff --git a/browser/extensions/screenshots/makeUuid.js b/browser/extensions/screenshots/makeUuid.js new file mode 100644 index 0000000000..7ae77299e4 --- /dev/null +++ b/browser/extensions/screenshots/makeUuid.js @@ -0,0 +1,23 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +this.makeUuid = (function() { + + // generates a v4 UUID + return function makeUuid() { // eslint-disable-line no-unused-vars + // get sixteen unsigned 8 bit random values + const randomValues = window + .crypto + .getRandomValues(new Uint8Array(36)); + + return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, function(c) { + const i = Array.prototype.slice.call(arguments).slice(-2)[0]; // grab the `offset` parameter + const r = randomValues[i] % 16|0, v = c === "x" ? r : (r & 0x3 | 0x8); + return v.toString(16); + }); + }; +})(); +null; diff --git a/browser/extensions/screenshots/manifest.json b/browser/extensions/screenshots/manifest.json new file mode 100644 index 0000000000..782a244c46 --- /dev/null +++ b/browser/extensions/screenshots/manifest.json @@ -0,0 +1,91 @@ +{ + "manifest_version": 2, + "name": "Firefox Screenshots", + "version": "39.0.0", + "description": "Take clips and screenshots from the Web and save them temporarily or permanently.", + "author": "Mozilla <screenshots-feedback@mozilla.com>", + "homepage_url": "https://github.com/mozilla-services/screenshots", + "incognito": "spanning", + "applications": { + "gecko": { + "id": "screenshots@mozilla.org", + "strict_min_version": "57.0a1" + } + }, + "l10n_resources": [ "browser/screenshots.ftl" ], + "background": { + "scripts": [ + "build/buildSettings.js", + "background/startBackground.js" + ] + }, + "commands": { + "take-screenshot": { + "suggested_key": { + "default": "Ctrl+Shift+S" + }, + "description": "Open the Firefox Screenshots UI" + } + }, + "content_scripts": [ + { + "matches": ["https://screenshots.firefox.com/*"], + "js": [ + "build/buildSettings.js", + "log.js", + "catcher.js", + "selector/callBackground.js", + "sitehelper.js" + ] + } + ], + "page_action": { + "browser_style": true, + "default_icon" : { + "32": "icons/icon-v2.svg" + }, + "default_title": "__MSG_screenshots-context-menu__", + "show_matches": ["<all_urls>", "about:reader*"], + "pinned": false + }, + "icons": { + "32": "icons/icon-v2.svg" + }, + "web_accessible_resources": [ + "blank.html", + "icons/cancel.svg", + "icons/download.svg", + "icons/copy.svg", + "icons/icon-256.png", + "icons/help-16.svg", + "icons/menu-fullpage.svg", + "icons/menu-visible.svg", + "icons/menu-myshot.svg", + "icons/icon-welcome-face-without-eyes.svg" + ], + "permissions": [ + "activeTab", + "downloads", + "tabs", + "storage", + "notifications", + "clipboardWrite", + "contextMenus", + "mozillaAddons", + "telemetry", + "<all_urls>", + "https://screenshots.firefox.com/", + "resource://pdf.js/", + "about:reader*" + ], + "experiment_apis": { + "screenshots": { + "schema": "experiments/screenshots/schema.json", + "parent": { + "scopes": ["addon_parent"], + "script": "experiments/screenshots/api.js", + "paths": [["experiments", "screenshots"]] + } + } + } +} diff --git a/browser/extensions/screenshots/moz.build b/browser/extensions/screenshots/moz.build new file mode 100644 index 0000000000..3a1b161357 --- /dev/null +++ b/browser/extensions/screenshots/moz.build @@ -0,0 +1,81 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# 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/. + +with Files("**"): + BUG_COMPONENT = ("Firefox", "Screenshots") + +# This file list is automatically generated by Screenshots' export scripts. +# AUTOMATIC INSERTION START +FINAL_TARGET_FILES.features["screenshots@mozilla.org"] += [ + "assertIsBlankDocument.js", + "assertIsTrusted.js", + "blank.html", + "blobConverters.js", + "catcher.js", + "clipboard.js", + "domainFromUrl.js", + "log.js", + "makeUuid.js", + "manifest.json", + "moz.build", + "randomString.js", + "sitehelper.js", +] + +FINAL_TARGET_FILES.features["screenshots@mozilla.org"]["background"] += [ + "background/analytics.js", + "background/auth.js", + "background/communication.js", + "background/deviceInfo.js", + "background/main.js", + "background/selectorLoader.js", + "background/senderror.js", + "background/startBackground.js", + "background/takeshot.js", +] + +FINAL_TARGET_FILES.features["screenshots@mozilla.org"]["build"] += [ + "build/buildSettings.js", + "build/inlineSelectionCss.js", + "build/raven.js", + "build/selection.js", + "build/shot.js", + "build/thumbnailGenerator.js", +] + +FINAL_TARGET_FILES.features["screenshots@mozilla.org"]["experiments"][ + "screenshots" +] += ["experiments/screenshots/api.js", "experiments/screenshots/schema.json"] + +FINAL_TARGET_FILES.features["screenshots@mozilla.org"]["icons"] += [ + "icons/cancel.svg", + "icons/cloud.svg", + "icons/copied-notification.svg", + "icons/copy.svg", + "icons/download-white.svg", + "icons/download.svg", + "icons/help-16.svg", + "icons/icon-highlight-32-v2.svg", + "icons/icon-v2.svg", + "icons/icon-welcome-face-without-eyes.svg", + "icons/menu-fullpage.svg", + "icons/menu-myshot-white.svg", + "icons/menu-myshot.svg", + "icons/menu-visible.svg", +] + +FINAL_TARGET_FILES.features["screenshots@mozilla.org"]["selector"] += [ + "selector/callBackground.js", + "selector/documentMetadata.js", + "selector/shooter.js", + "selector/ui.js", + "selector/uicontrol.js", + "selector/util.js", +] + +# AUTOMATIC INSERTION END + +BROWSER_CHROME_MANIFESTS += ["test/browser/browser.ini"] diff --git a/browser/extensions/screenshots/randomString.js b/browser/extensions/screenshots/randomString.js new file mode 100644 index 0000000000..ee016573d5 --- /dev/null +++ b/browser/extensions/screenshots/randomString.js @@ -0,0 +1,18 @@ +/* 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/. */ + +/* exported randomString */ + +"use strict"; + +this.randomString = function randomString(length, chars) { + const randomStringChars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; + chars = chars || randomStringChars; + let result = ""; + for (let i = 0; i < length; i++) { + result += chars[Math.floor(Math.random() * chars.length)]; + } + return result; +}; +null; diff --git a/browser/extensions/screenshots/selector/callBackground.js b/browser/extensions/screenshots/selector/callBackground.js new file mode 100644 index 0000000000..3fb8734c38 --- /dev/null +++ b/browser/extensions/screenshots/selector/callBackground.js @@ -0,0 +1,31 @@ +/* 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 */ + +"use strict"; + +this.callBackground = function callBackground(funcName, ...args) { + return browser.runtime.sendMessage({funcName, args}).then((result) => { + if (result && result.type === "success") { + return result.value; + } else if (result && result.type === "error") { + const exc = new Error(result.message || "Unknown error"); + exc.name = "BackgroundError"; + if ("errorCode" in result) { + exc.errorCode = result.errorCode; + } + if ("popupMessage" in result) { + exc.popupMessage = result.popupMessage; + } + throw exc; + } else { + log.error("Unexpected background result:", result); + const exc = new Error(`Bad response type from background page: ${result && result.type || undefined}`); + exc.resultType = result ? (result.type || "undefined") : "undefined result"; + throw exc; + } + }); +}; +null; diff --git a/browser/extensions/screenshots/selector/documentMetadata.js b/browser/extensions/screenshots/selector/documentMetadata.js new file mode 100644 index 0000000000..ceb532634a --- /dev/null +++ b/browser/extensions/screenshots/selector/documentMetadata.js @@ -0,0 +1,91 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +this.documentMetadata = (function() { + + function findSiteName() { + let el = document.querySelector("meta[property~='og:site_name'][content]"); + if (el) { + return el.getAttribute("content"); + } + // nytimes.com uses this property: + el = document.querySelector("meta[name='cre'][content]"); + if (el) { + return el.getAttribute("content"); + } + return null; + } + + function getOpenGraph() { + const openGraph = {}; + // If you update this, also update _OPENGRAPH_PROPERTIES in shot.js: + const forceSingle = `title type url`.split(" "); + const openGraphProperties = ` + title type url image audio description determiner locale site_name video + image:secure_url image:type image:width image:height + video:secure_url video:type video:width image:height + audio:secure_url audio:type + article:published_time article:modified_time article:expiration_time article:author article:section article:tag + book:author book:isbn book:release_date book:tag + profile:first_name profile:last_name profile:username profile:gender + `.split(/\s+/g); + for (const prop of openGraphProperties) { + let elems = document.querySelectorAll(`meta[property~='og:${prop}'][content]`); + if (forceSingle.includes(prop) && elems.length > 1) { + elems = [elems[0]]; + } + let value; + if (elems.length > 1) { + value = []; + for (const elem of elems) { + const v = elem.getAttribute("content"); + if (v) { + value.push(v); + } + } + if (!value.length) { + value = null; + } + } else if (elems.length === 1) { + value = elems[0].getAttribute("content"); + } + if (value) { + openGraph[prop] = value; + } + } + return openGraph; + } + + function getTwitterCard() { + const twitterCard = {}; + // If you update this, also update _TWITTERCARD_PROPERTIES in shot.js: + const properties = ` + card site title description image + player player:width player:height player:stream player:stream:content_type + `.split(/\s+/g); + for (const prop of properties) { + const elem = document.querySelector(`meta[name='twitter:${prop}'][content]`); + if (elem) { + const value = elem.getAttribute("content"); + if (value) { + twitterCard[prop] = value; + } + } + } + return twitterCard; + } + + return function documentMetadata() { + const result = {}; + result.docTitle = document.title; + result.siteName = findSiteName(); + result.openGraph = getOpenGraph(); + result.twitterCard = getTwitterCard(); + return result; + }; + +})(); +null; diff --git a/browser/extensions/screenshots/selector/shooter.js b/browser/extensions/screenshots/selector/shooter.js new file mode 100644 index 0000000000..d1a4a34e9e --- /dev/null +++ b/browser/extensions/screenshots/selector/shooter.js @@ -0,0 +1,147 @@ +/* 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 global, documentMetadata, util, uicontrol, ui, catcher */ +/* globals buildSettings, domainFromUrl, randomString, shot, blobConverters */ + +"use strict"; + +this.shooter = (function() { // eslint-disable-line no-unused-vars + const exports = {}; + const { AbstractShot } = shot; + + const RANDOM_STRING_LENGTH = 16; + const MAX_CANVAS_DIMENSION = 32767; + let backend; + let shotObject; + const callBackground = global.callBackground; + const clipboard = global.clipboard; + + function regexpEscape(str) { + // http://stackoverflow.com/questions/3115150/how-to-escape-regular-expression-special-characters-using-javascript + return str.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, "\\$&"); + } + + function sanitizeError(data) { + const href = new RegExp(regexpEscape(window.location.href), "g"); + const origin = new RegExp(`${regexpEscape(window.location.origin)}[^ \t\n\r",>]*`, "g"); + const json = JSON.stringify(data) + .replace(href, "REDACTED_HREF") + .replace(origin, "REDACTED_URL"); + const result = JSON.parse(json); + return result; + } + + catcher.registerHandler((errorObj) => { + callBackground("reportError", sanitizeError(errorObj)); + }); + + function hideUIFrame() { + ui.iframe.hide(); + return Promise.resolve(null); + } + + function screenshotPage(dataUrl, selectedPos, type, screenshotTaskFn) { + let promise = Promise.resolve(dataUrl); + + if (!dataUrl) { + let isFullPage = type === "fullPage" || type == "fullPageTruncated"; + promise = callBackground( + "screenshotPage", + selectedPos.toJSON(), + isFullPage, + window.devicePixelRatio); + } + + catcher.watchPromise(promise.then((dataUrl) => { + screenshotTaskFn(dataUrl); + })); + } + + exports.downloadShot = function(selectedPos, previewDataUrl, type) { + const shotPromise = previewDataUrl ? Promise.resolve(previewDataUrl) : hideUIFrame(); + catcher.watchPromise(shotPromise.then(dataUrl => { + screenshotPage(dataUrl, selectedPos, type, url => { + let type = blobConverters.getTypeFromDataUrl(url); + type = type ? type.split("/", 2)[1] : null; + shotObject.delAllClips(); + shotObject.addClip({ + createdDate: Date.now(), + image: { + url, + type, + location: selectedPos, + }, + }); + ui.triggerDownload(url, shotObject.filename); + uicontrol.deactivate(); + }); + })); + }; + + exports.preview = function(selectedPos, type) { + catcher.watchPromise(hideUIFrame().then(dataUrl => { + screenshotPage(dataUrl, selectedPos, type, url => { + ui.iframe.usePreview(); + ui.Preview.display(url); + }); + })); + }; + + let copyInProgress = null; + exports.copyShot = function(selectedPos, previewDataUrl, type) { + // This is pretty slow. We'll ignore additional user triggered copy events + // while it is in progress. + if (copyInProgress) { + return; + } + // A max of five seconds in case some error occurs. + copyInProgress = setTimeout(() => { + copyInProgress = null; + }, 5000); + + const unsetCopyInProgress = () => { + if (copyInProgress) { + clearTimeout(copyInProgress); + copyInProgress = null; + } + }; + const shotPromise = previewDataUrl ? Promise.resolve(previewDataUrl) : hideUIFrame(); + catcher.watchPromise(shotPromise.then(dataUrl => { + screenshotPage(dataUrl, selectedPos, type, url => { + const blob = blobConverters.dataUrlToBlob(url); + catcher.watchPromise(callBackground("copyShotToClipboard", blob).then(() => { + uicontrol.deactivate(); + unsetCopyInProgress(); + }, unsetCopyInProgress)); + }); + })); + }; + + exports.sendEvent = function(...args) { + const maybeOptions = args[args.length - 1]; + + if (typeof maybeOptions === "object") { + maybeOptions.incognito = browser.extension.inIncognitoContext; + } else { + args.push({incognito: browser.extension.inIncognitoContext}); + } + + callBackground("sendEvent", ...args); + }; + + catcher.watchFunction(() => { + shotObject = new AbstractShot( + backend, + randomString(RANDOM_STRING_LENGTH) + "/" + domainFromUrl(location), + { + origin: shot.originFromUrl(location.href), + } + ); + shotObject.update(documentMetadata()); + })(); + + return exports; +})(); +null; diff --git a/browser/extensions/screenshots/selector/ui.js b/browser/extensions/screenshots/selector/ui.js new file mode 100644 index 0000000000..8a92c80470 --- /dev/null +++ b/browser/extensions/screenshots/selector/ui.js @@ -0,0 +1,828 @@ +/* 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, util, catcher, inlineSelectionCss, callBackground, assertIsTrusted, assertIsBlankDocument, buildSettings blobConverters */ + +"use strict"; + +this.ui = (function() { // eslint-disable-line no-unused-vars + const exports = {}; + const SAVE_BUTTON_HEIGHT = 50; + + const { watchFunction } = catcher; + + exports.isHeader = function(el) { + while (el) { + if (el.classList && + (el.classList.contains("myshots-button") || + el.classList.contains("visible") || + el.classList.contains("full-page") || + el.classList.contains("cancel-shot"))) { + return true; + } + el = el.parentNode; + } + return false; + }; + + const substitutedCss = inlineSelectionCss.replace(/MOZ_EXTENSION([^"]+)/g, (match, filename) => { + return browser.extension.getURL(filename); + }); + + function makeEl(tagName, className) { + if (!iframe.document()) { + throw new Error("Attempted makeEl before iframe was initialized"); + } + const el = iframe.document().createElement(tagName); + if (className) { + el.className = className; + } + return el; + } + + function onResize() { + if (this.sizeTracking.windowDelayer) { + clearTimeout(this.sizeTracking.windowDelayer); + } + this.sizeTracking.windowDelayer = setTimeout(watchFunction(() => { + this.updateElementSize(true); + }), 50); + } + + function highContrastCheck(win) { + const doc = win.document; + const el = doc.createElement("div"); + el.style.backgroundImage = "url('#')"; + el.style.display = "none"; + doc.body.appendChild(el); + const computed = win.getComputedStyle(el); + doc.body.removeChild(el); + // When Windows is in High Contrast mode, Firefox replaces background + // image URLs with the string "none". + return (computed && computed.backgroundImage === "none"); + } + + const showMyShots = exports.showMyShots = function() { + return window.hasAnyShots; + }; + + function initializeIframe() { + const el = document.createElement("iframe"); + el.src = browser.extension.getURL("blank.html"); + el.style.zIndex = "99999999999"; + el.style.border = "none"; + el.style.top = "0"; + el.style.left = "0"; + el.style.margin = "0"; + el.scrolling = "no"; + el.style.clip = "auto"; + return el; + } + + const iframeSelection = exports.iframeSelection = { + element: null, + addClassName: "", + sizeTracking: { + timer: null, + windowDelayer: null, + lastHeight: null, + lastWidth: null, + }, + document: null, + window: null, + display(installHandlerOnDocument) { + return new Promise((resolve, reject) => { + if (!this.element) { + this.element = initializeIframe(); + this.element.id = "firefox-screenshots-selection-iframe"; + this.element.style.display = "none"; + this.element.style.setProperty("position", "absolute", "important"); + this.element.style.setProperty("background-color", "transparent"); + this.element.setAttribute("role", "dialog"); + this.updateElementSize(); + this.element.addEventListener("load", watchFunction(() => { + this.document = this.element.contentDocument; + this.window = this.element.contentWindow; + assertIsBlankDocument(this.document); + // eslint-disable-next-line no-unsanitized/property + this.document.documentElement.innerHTML = ` + <head> + <style>${substitutedCss}</style> + <title></title> + </head> + <body></body>`; + installHandlerOnDocument(this.document); + if (this.addClassName) { + this.document.body.className = this.addClassName; + } + this.document.documentElement.dir = browser.i18n.getMessage("@@bidi_dir"); + this.document.documentElement.lang = browser.i18n.getMessage("@@ui_locale"); + resolve(); + }), {once: true}); + document.body.appendChild(this.element); + } else { + resolve(); + } + }); + }, + + hide() { + this.element.style.display = "none"; + this.stopSizeWatch(); + }, + + unhide() { + this.updateElementSize(); + this.element.style.display = "block"; + catcher.watchPromise(callBackground("sendEvent", "internal", "unhide-selection-frame")); + if (highContrastCheck(this.element.contentWindow)) { + this.element.contentDocument.body.classList.add("hcm"); + } + this.initSizeWatch(); + this.element.focus(); + }, + + updateElementSize(force) { + // Note: if someone sizes down the page, then the iframe will keep the + // document from naturally shrinking. We use force to temporarily hide + // the element so that we can tell if the document shrinks + const visible = this.element.style.display !== "none"; + if (force && visible) { + this.element.style.display = "none"; + } + const height = Math.max( + document.documentElement.clientHeight, + document.body.clientHeight, + document.documentElement.scrollHeight, + document.body.scrollHeight); + if (height !== this.sizeTracking.lastHeight) { + this.sizeTracking.lastHeight = height; + this.element.style.height = height + "px"; + } + // Do not use window.innerWidth since that includes the width of the + // scroll bar. + const width = Math.max( + document.documentElement.clientWidth, + document.body.clientWidth, + document.documentElement.scrollWidth, + document.body.scrollWidth); + if (width !== this.sizeTracking.lastWidth) { + this.sizeTracking.lastWidth = width; + this.element.style.width = width + "px"; + // Since this frame has an absolute position relative to the parent + // document, if the parent document's body has a relative position and + // left and/or top not at 0, then the left and/or top of the parent + // document's body is not at (0, 0) of the viewport. That makes the + // frame shifted relative to the viewport. These margins negates that. + if (window.getComputedStyle(document.body).position === "relative") { + const docBoundingRect = document.documentElement.getBoundingClientRect(); + const bodyBoundingRect = document.body.getBoundingClientRect(); + this.element.style.marginLeft = `-${bodyBoundingRect.left - docBoundingRect.left}px`; + this.element.style.marginTop = `-${bodyBoundingRect.top - docBoundingRect.top}px`; + } + } + if (force && visible) { + this.element.style.display = "block"; + } + }, + + initSizeWatch() { + this.stopSizeWatch(); + this.sizeTracking.timer = setInterval(watchFunction(this.updateElementSize.bind(this)), 2000); + window.addEventListener("resize", this.onResize, true); + }, + + stopSizeWatch() { + if (this.sizeTracking.timer) { + clearTimeout(this.sizeTracking.timer); + this.sizeTracking.timer = null; + } + if (this.sizeTracking.windowDelayer) { + clearTimeout(this.sizeTracking.windowDelayer); + this.sizeTracking.windowDelayer = null; + } + this.sizeTracking.lastHeight = this.sizeTracking.lastWidth = null; + window.removeEventListener("resize", this.onResize, true); + }, + + getElementFromPoint(x, y) { + this.element.style.pointerEvents = "none"; + let el; + try { + el = document.elementFromPoint(x, y); + } finally { + this.element.style.pointerEvents = ""; + } + return el; + }, + + remove() { + this.stopSizeWatch(); + util.removeNode(this.element); + this.element = this.document = this.window = null; + }, + }; + + iframeSelection.onResize = watchFunction(assertIsTrusted(onResize.bind(iframeSelection)), true); + + const iframePreSelection = exports.iframePreSelection = { + element: null, + document: null, + window: null, + display(installHandlerOnDocument, standardOverlayCallbacks) { + return new Promise((resolve, reject) => { + if (!this.element) { + this.element = initializeIframe(); + this.element.id = "firefox-screenshots-preselection-iframe"; + this.element.style.setProperty("position", "fixed", "important"); + this.element.style.setProperty("background-color", "transparent"); + this.element.style.width = "100%"; + this.element.style.height = "100%"; + this.element.setAttribute("role", "dialog"); + this.element.addEventListener("load", watchFunction(() => { + this.document = this.element.contentDocument; + this.window = this.element.contentWindow; + assertIsBlankDocument(this.document); + // eslint-disable-next-line no-unsanitized/property + this.document.documentElement.innerHTML = ` + <head> + <link rel="localization" href="browser/screenshots.ftl"> + <style>${substitutedCss}</style> + <title></title> + </head> + <body> + <div class="preview-overlay precision-cursor"> + <div class="fixed-container"> + <div class="face-container"> + <div class="eye left"><div class="eyeball"></div></div> + <div class="eye right"><div class="eyeball"></div></div> + <div class="face"></div> + </div> + <div class="preview-instructions" data-l10n-id="screenshots-instructions"></div> + <button class="cancel-shot" data-l10n-id="screenshots-cancel-button"></button> + <div class="myshots-all-buttons-container"> + ${showMyShots() ? ` + <button class="myshots-button" tabindex="3" data-l10n-id="screenshots-my-shots-button"></button> + <div class="spacer"></div> + ` : ""} + <button class="visible" tabindex="2" data-l10n-id="screenshots-save-visible-button"></button> + <button class="full-page" tabindex="1" data-l10n-id="screenshots-save-page-button"></button> + </div> + </div> + </div> + </body>`; + installHandlerOnDocument(this.document); + if (this.addClassName) { + this.document.body.className = this.addClassName; + } + this.document.documentElement.dir = browser.i18n.getMessage("@@bidi_dir"); + this.document.documentElement.lang = browser.i18n.getMessage("@@ui_locale"); + const overlay = this.document.querySelector(".preview-overlay"); + if (showMyShots()) { + overlay.querySelector(".myshots-button").addEventListener( + "click", watchFunction(assertIsTrusted(standardOverlayCallbacks.onOpenMyShots))); + } + overlay.querySelector(".visible").addEventListener( + "click", watchFunction(assertIsTrusted(standardOverlayCallbacks.onClickVisible))); + overlay.querySelector(".full-page").addEventListener( + "click", watchFunction(assertIsTrusted(standardOverlayCallbacks.onClickFullPage))); + overlay.querySelector(".cancel-shot").addEventListener( + "click", watchFunction(assertIsTrusted(standardOverlayCallbacks.onClickCancel))); + + resolve(); + }), {once: true}); + document.body.appendChild(this.element); + } else { + resolve(); + } + }); + }, + + hide() { + window.removeEventListener("scroll", watchFunction(assertIsTrusted(this.onScroll))); + window.removeEventListener("resize", this.onResize, true); + if (this.element) { + this.element.style.display = "none"; + } + }, + + unhide() { + window.addEventListener("scroll", watchFunction(assertIsTrusted(this.onScroll))); + window.addEventListener("resize", this.onResize, true); + this.element.style.display = "block"; + catcher.watchPromise(callBackground("sendEvent", "internal", "unhide-preselection-frame")); + if (highContrastCheck(this.element.contentWindow)) { + this.element.contentDocument.body.classList.add("hcm"); + } + this.element.focus(); + }, + + onScroll() { + exports.HoverBox.hide(); + }, + + getElementFromPoint(x, y) { + this.element.style.pointerEvents = "none"; + let el; + try { + el = document.elementFromPoint(x, y); + } finally { + this.element.style.pointerEvents = ""; + } + return el; + }, + + remove() { + this.hide(); + util.removeNode(this.element); + this.element = this.document = this.window = null; + }, + }; + + let msgsPromise = callBackground("getStrings", [ + "screenshots-cancel-button", + "screenshots-copy-button-tooltip", + "screenshots-download-button-tooltip", + "screenshots-copy-button", + "screenshots-download-button", + ]); + + const iframePreview = exports.iframePreview = { + element: null, + document: null, + window: null, + display(installHandlerOnDocument, standardOverlayCallbacks) { + return new Promise((resolve, reject) => { + if (!this.element) { + this.element = initializeIframe(); + this.element.id = "firefox-screenshots-preview-iframe"; + this.element.style.display = "none"; + this.element.style.setProperty("position", "fixed", "important"); + this.element.style.setProperty("background-color", "transparent"); + this.element.style.height = "100%"; + this.element.style.width = "100%"; + this.element.setAttribute("role", "dialog"); + this.element.onload = watchFunction(() => { + msgsPromise.then(([cancelTitle, copyTitle, downloadTitle]) => { + assertIsBlankDocument(this.element.contentDocument); + this.document = this.element.contentDocument; + this.window = this.element.contentWindow; + // eslint-disable-next-line no-unsanitized/property + this.document.documentElement.innerHTML = ` + <head> + <link rel="localization" href="browser/screenshots.ftl"> + <style>${substitutedCss}</style> + <title></title> + </head> + <body> + <div class="preview-overlay"> + <div class="preview-image"> + <div class="preview-buttons"> + <button class="highlight-button-cancel" title="${cancelTitle}"> + <img src="${browser.extension.getURL("icons/cancel.svg")}" /> + </button> + <button class="highlight-button-copy" title="${copyTitle}"> + <img src="${browser.extension.getURL("icons/copy.svg")}" /> + <span data-l10n-id="screenshots-copy-button"/> + </button> + <button class="highlight-button-download" title="${downloadTitle}"> + <img src="${browser.extension.getURL("icons/download-white.svg")}" /> + <span data-l10n-id="screenshots-download-button"/> + </button> + </div> + <div class="preview-image-wrapper"></div> + </div> + <div class="loader" style="display:none"> + <div class="loader-inner"></div> + </div> + </div> + </body>`; + + installHandlerOnDocument(this.document); + this.document.documentElement.dir = browser.i18n.getMessage("@@bidi_dir"); + this.document.documentElement.lang = browser.i18n.getMessage("@@ui_locale"); + + const overlay = this.document.querySelector(".preview-overlay"); + overlay.querySelector(".highlight-button-copy").addEventListener( + "click", watchFunction(assertIsTrusted(standardOverlayCallbacks.onCopyPreview))); + overlay.querySelector(".highlight-button-download").addEventListener( + "click", watchFunction(assertIsTrusted(standardOverlayCallbacks.onDownloadPreview))); + overlay.querySelector(".highlight-button-cancel").addEventListener( + "click", watchFunction(assertIsTrusted(standardOverlayCallbacks.cancel))); + resolve(); + }); + }); + document.body.appendChild(this.element); + } else { + resolve(); + } + }); + }, + + hide() { + if (this.element) { + this.element.style.display = "none"; + } + }, + + unhide() { + this.element.style.display = "block"; + catcher.watchPromise(callBackground("sendEvent", "internal", "unhide-preview-frame")); + this.element.focus(); + }, + + showLoader() { + this.document.body.querySelector(".preview-image").style.display = "none"; + this.document.body.querySelector(".loader").style.display = ""; + }, + + remove() { + this.hide(); + util.removeNode(this.element); + this.element = null; + this.document = null; + }, + }; + + iframePreSelection.onResize = watchFunction(onResize.bind(iframePreSelection), true); + + const iframe = exports.iframe = { + currentIframe: iframePreSelection, + display(installHandlerOnDocument, standardOverlayCallbacks) { + return iframeSelection.display(installHandlerOnDocument) + .then(() => iframePreSelection.display(installHandlerOnDocument, standardOverlayCallbacks)) + .then(() => iframePreview.display(installHandlerOnDocument, standardOverlayCallbacks)); + }, + + hide() { + this.currentIframe.hide(); + }, + + unhide() { + this.currentIframe.unhide(); + }, + + showLoader() { + if (this.currentIframe.showLoader) { + this.currentIframe.showLoader(); + this.currentIframe.unhide(); + } + }, + + getElementFromPoint(x, y) { + return this.currentIframe.getElementFromPoint(x, y); + }, + + remove() { + iframeSelection.remove(); + iframePreSelection.remove(); + iframePreview.remove(); + }, + + getContentWindow() { + return this.currentIframe.element.contentWindow; + }, + + document() { + return this.currentIframe.document; + }, + + useSelection() { + if (this.currentIframe === iframePreSelection || this.currentIframe === iframePreview) { + this.hide(); + } + this.currentIframe = iframeSelection; + this.unhide(); + }, + + usePreSelection() { + if (this.currentIframe === iframeSelection || this.currentIframe === iframePreview) { + this.hide(); + } + this.currentIframe = iframePreSelection; + this.unhide(); + }, + + usePreview() { + if (this.currentIframe === iframeSelection || this.currentIframe === iframePreSelection) { + this.hide(); + } + this.currentIframe = iframePreview; + this.unhide(); + }, + }; + + const movements = ["topLeft", "top", "topRight", "left", "right", "bottomLeft", "bottom", "bottomRight"]; + + /** Creates the selection box */ + exports.Box = { + + async display(pos, callbacks) { + await this._createEl(); + if (callbacks !== undefined && callbacks.cancel) { + // We use onclick here because we don't want addEventListener + // to add multiple event handlers to the same button + this.cancel.onclick = watchFunction(assertIsTrusted(callbacks.cancel)); + this.cancel.style.display = ""; + } else { + this.cancel.style.display = "none"; + } + if (callbacks !== undefined && callbacks.save && this.save) { + // We use onclick here because we don't want addEventListener + // to add multiple event handlers to the same button + this.save.removeAttribute("disabled"); + this.save.onclick = watchFunction(assertIsTrusted((e) => { + this.save.setAttribute("disabled", "true"); + callbacks.save(e); + })); + this.save.style.display = ""; + } else if (this.save) { + this.save.style.display = "none"; + } + if (callbacks !== undefined && callbacks.download) { + this.download.removeAttribute("disabled"); + this.download.onclick = watchFunction(assertIsTrusted((e) => { + this.download.setAttribute("disabled", true); + callbacks.download(e); + e.preventDefault(); + e.stopPropagation(); + return false; + })); + this.download.style.display = ""; + } else { + this.download.style.display = "none"; + } + if (callbacks !== undefined && callbacks.copy) { + this.copy.removeAttribute("disabled"); + this.copy.onclick = watchFunction(assertIsTrusted((e) => { + this.copy.setAttribute("disabled", true); + callbacks.copy(e); + e.preventDefault(); + e.stopPropagation(); + })); + this.copy.style.display = ""; + } else { + this.copy.style.display = "none"; + } + + const winBottom = window.innerHeight; + const pageYOffset = window.pageYOffset; + + if ((pos.right - pos.left) < 78 || (pos.bottom - pos.top) < 78) { + this.el.classList.add("small-selection"); + } else { + this.el.classList.remove("small-selection"); + } + + // if the selection bounding box is w/in SAVE_BUTTON_HEIGHT px of the bottom of + // the window, flip controls into the box + if (pos.bottom > ((winBottom + pageYOffset) - SAVE_BUTTON_HEIGHT)) { + this.el.classList.add("bottom-selection"); + } else { + this.el.classList.remove("bottom-selection"); + } + + if (pos.right < 200) { + this.el.classList.add("left-selection"); + } else { + this.el.classList.remove("left-selection"); + } + this.el.style.top = `${pos.top}px`; + this.el.style.left = `${pos.left}px`; + this.el.style.height = `${pos.bottom - pos.top}px`; + this.el.style.width = `${pos.right - pos.left}px`; + this.bgTop.style.top = "0px"; + this.bgTop.style.height = `${pos.top}px`; + this.bgTop.style.left = "0px"; + this.bgTop.style.width = "100%"; + this.bgBottom.style.top = `${pos.bottom}px`; + this.bgBottom.style.height = `calc(100vh - ${pos.bottom}px)`; + this.bgBottom.style.left = "0px"; + this.bgBottom.style.width = "100%"; + this.bgLeft.style.top = `${pos.top}px`; + this.bgLeft.style.height = `${pos.bottom - pos.top}px`; + this.bgLeft.style.left = "0px"; + this.bgLeft.style.width = `${pos.left}px`; + this.bgRight.style.top = `${pos.top}px`; + this.bgRight.style.height = `${pos.bottom - pos.top}px`; + this.bgRight.style.left = `${pos.right}px`; + this.bgRight.style.width = `calc(100% - ${pos.right}px)`; + }, + + // used to eventually move the download-only warning + // when a user ends scrolling or ends resizing a window + delayExecution(delay, cb) { + let timer; + return function() { + if (typeof timer !== "undefined") { + clearTimeout(timer); + } + timer = setTimeout(cb, delay); + }; + }, + + remove() { + for (const name of ["el", "bgTop", "bgLeft", "bgRight", "bgBottom"]) { + if (name in this) { + util.removeNode(this[name]); + this[name] = null; + } + } + }, + + async _createEl() { + let boxEl = this.el; + if (boxEl) { + return; + } + let [cancelTitle, copyTitle, downloadTitle, copyText, downloadText ] = await msgsPromise; + boxEl = makeEl("div", "highlight"); + const buttons = makeEl("div", "highlight-buttons"); + const cancel = makeEl("button", "highlight-button-cancel"); + const cancelImg = makeEl("img"); + cancelImg.src = browser.extension.getURL("icons/cancel.svg"); + cancel.title = cancelTitle; + cancel.appendChild(cancelImg); + buttons.appendChild(cancel); + + const copy = makeEl("button", "highlight-button-copy"); + copy.title = copyTitle; + const copyImg = makeEl("img"); + const copyString = makeEl("span"); + copyString.textContent = copyText; + copyImg.src = browser.extension.getURL("icons/copy.svg"); + copy.appendChild(copyImg); + copy.appendChild(copyString); + buttons.appendChild(copy); + + const download = makeEl("button", "highlight-button-download"); + const downloadImg = makeEl("img"); + downloadImg.src = browser.extension.getURL("icons/download-white.svg"); + download.appendChild(downloadImg); + download.append(downloadText); + download.title = downloadTitle; + buttons.appendChild(download); + this.cancel = cancel; + this.download = download; + this.copy = copy; + + boxEl.appendChild(buttons); + for (const name of movements) { + const elTarget = makeEl("div", "mover-target direction-" + name); + const elMover = makeEl("div", "mover"); + elTarget.appendChild(elMover); + boxEl.appendChild(elTarget); + } + this.bgTop = makeEl("div", "bghighlight"); + iframe.document().body.appendChild(this.bgTop); + this.bgLeft = makeEl("div", "bghighlight"); + iframe.document().body.appendChild(this.bgLeft); + this.bgRight = makeEl("div", "bghighlight"); + iframe.document().body.appendChild(this.bgRight); + this.bgBottom = makeEl("div", "bghighlight"); + iframe.document().body.appendChild(this.bgBottom); + iframe.document().body.appendChild(boxEl); + this.el = boxEl; + }, + + draggerDirection(target) { + while (target) { + if (target.nodeType === document.ELEMENT_NODE) { + if (target.classList.contains("mover-target")) { + for (const name of movements) { + if (target.classList.contains("direction-" + name)) { + return name; + } + } + catcher.unhandled(new Error("Surprising mover element"), {element: target.outerHTML}); + log.warn("Got mover-target that wasn't a specific direction"); + } + } + target = target.parentNode; + } + return null; + }, + + isSelection(target) { + while (target) { + if (target.tagName === "BUTTON") { + return false; + } + if (target.nodeType === document.ELEMENT_NODE && target.classList.contains("highlight")) { + return true; + } + target = target.parentNode; + } + return false; + }, + + isControl(target) { + while (target) { + if (target.nodeType === document.ELEMENT_NODE && target.classList.contains("highlight-buttons")) { + return true; + } + target = target.parentNode; + } + return false; + }, + + clearSaveDisabled() { + if (!this.save) { + // Happens if we try to remove the disabled status after the worker + // has been shut down + return; + } + this.save.removeAttribute("disabled"); + }, + + el: null, + boxTopEl: null, + boxLeftEl: null, + boxRightEl: null, + boxBottomEl: null, + }; + + exports.HoverBox = { + + el: null, + + display(rect) { + if (!this.el) { + this.el = makeEl("div", "hover-highlight"); + iframe.document().body.appendChild(this.el); + } + this.el.style.display = ""; + this.el.style.top = (rect.top - 1) + "px"; + this.el.style.left = (rect.left - 1) + "px"; + this.el.style.width = (rect.right - rect.left + 2) + "px"; + this.el.style.height = (rect.bottom - rect.top + 2) + "px"; + }, + + hide() { + if (this.el) { + this.el.style.display = "none"; + } + }, + + remove() { + util.removeNode(this.el); + this.el = null; + }, + }; + + exports.PixelDimensions = { + el: null, + xEl: null, + yEl: null, + display(xPos, yPos, x, y) { + if (!this.el) { + this.el = makeEl("div", "pixel-dimensions"); + this.xEl = makeEl("div"); + this.el.appendChild(this.xEl); + this.yEl = makeEl("div"); + this.el.appendChild(this.yEl); + iframe.document().body.appendChild(this.el); + } + this.xEl.textContent = Math.round(x); + this.yEl.textContent = Math.round(y); + this.el.style.top = (yPos + 12) + "px"; + this.el.style.left = (xPos + 12) + "px"; + }, + remove() { + util.removeNode(this.el); + this.el = this.xEl = this.yEl = null; + }, + }; + + exports.Preview = { + display(dataUrl) { + const img = makeEl("IMG"); + const imgBlob = blobConverters.dataUrlToBlob(dataUrl); + img.src = iframe.getContentWindow().URL.createObjectURL(imgBlob); + iframe.document().querySelector(".preview-image-wrapper").appendChild(img); + }, + }; + + /** Removes every UI this module creates */ + exports.remove = function() { + for (const name in exports) { + if (name.startsWith("iframe")) { + continue; + } + if (typeof exports[name] === "object" && exports[name].remove) { + exports[name].remove(); + } + } + exports.iframe.remove(); + }; + + exports.triggerDownload = function(url, filename) { + return catcher.watchPromise(callBackground("downloadShot", {url, filename})); + }; + + exports.unload = exports.remove; + + return exports; +})(); +null; diff --git a/browser/extensions/screenshots/selector/uicontrol.js b/browser/extensions/screenshots/selector/uicontrol.js new file mode 100644 index 0000000000..f3aff2c5c8 --- /dev/null +++ b/browser/extensions/screenshots/selector/uicontrol.js @@ -0,0 +1,901 @@ +/* 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, catcher, util, ui, slides */ +/* globals shooter, callBackground, selectorLoader, assertIsTrusted, buildSettings, selection */ + +"use strict"; + +this.uicontrol = (function() { + const exports = {}; + + /** ******************************************************** + * selection + */ + + /* States: + + "crosshairs": + Nothing has happened, and the crosshairs will follow the movement of the mouse + "draggingReady": + The user has pressed the mouse button, but hasn't moved enough to create a selection + "dragging": + The user has pressed down a mouse button, and is dragging out an area far enough to show a selection + "selected": + The user has selected an area + "resizing": + The user is resizing the selection + "cancelled": + Everything has been cancelled + "previewing": + The user is previewing the full-screen/visible image + + A mousedown goes from crosshairs to dragging. + A mouseup goes from dragging to selected + A click outside of the selection goes from selected to crosshairs + A click on one of the draggers goes from selected to resizing + + State variables: + + state (string, one of the above) + mousedownPos (object with x/y during draggingReady, shows where the selection started) + selectedPos (object with x/y/h/w during selected or dragging, gives the entire selection) + resizeDirection (string: top, topLeft, etc, during resizing) + resizeStartPos (x/y position where resizing started) + mouseupNoAutoselect (true if a mouseup in draggingReady should not trigger autoselect) + + */ + + const { watchFunction, watchPromise } = catcher; + + const MAX_PAGE_HEIGHT = buildSettings.maxImageHeight; + const MAX_PAGE_WIDTH = buildSettings.maxImageWidth; + // An autoselection smaller than these will be ignored entirely: + const MIN_DETECT_ABSOLUTE_HEIGHT = 10; + const MIN_DETECT_ABSOLUTE_WIDTH = 30; + // An autoselection smaller than these will not be preferred: + const MIN_DETECT_HEIGHT = 30; + const MIN_DETECT_WIDTH = 100; + // An autoselection bigger than either of these will be ignored: + const MAX_DETECT_HEIGHT = Math.max(window.innerHeight + 100, 700); + const MAX_DETECT_WIDTH = Math.max(window.innerWidth + 100, 1000); + // This is how close (in pixels) you can get to the edge of the window and then + // it will scroll: + const SCROLL_BY_EDGE = 20; + // This is how wide the inboard scrollbars are, generally 0 except on Mac + const SCROLLBAR_WIDTH = (window.navigator.platform.match(/Mac/i)) ? 17 : 0; + + + const { Selection } = selection; + const { sendEvent } = shooter; + const log = global.log; + + function round10(n) { + return Math.floor(n / 10) * 10; + } + + function eventOptionsForBox(box) { + return { + cd1: round10(Math.abs(box.bottom - box.top)), + cd2: round10(Math.abs(box.right - box.left)), + }; + } + + function eventOptionsForResize(boxStart, boxEnd) { + return { + cd1: round10( + (boxEnd.bottom - boxEnd.top) + - (boxStart.bottom - boxStart.top)), + cd2: round10( + (boxEnd.right - boxEnd.left) + - (boxStart.right - boxStart.left)), + }; + } + + function eventOptionsForMove(posStart, posEnd) { + return { + cd1: round10(posEnd.y - posStart.y), + cd2: round10(posEnd.x - posStart.x), + }; + } + + function downloadShot() { + const previewDataUrl = (captureType === "fullPageTruncated") ? null : dataUrl; + // Downloaded shots don't have dimension limits + removeDimensionLimitsOnFullPageShot(); + shooter.downloadShot(selectedPos, previewDataUrl, captureType); + } + + function copyShot() { + const previewDataUrl = (captureType === "fullPageTruncated") ? null : dataUrl; + // Copied shots don't have dimension limits + removeDimensionLimitsOnFullPageShot(); + shooter.copyShot(selectedPos, previewDataUrl, captureType); + } + + /** ********************************************* + * State and stateHandlers infrastructure + */ + + // This enumerates all the anchors on the selection, and what part of the + // selection they move: + const movements = { + topLeft: ["x1", "y1"], + top: [null, "y1"], + topRight: ["x2", "y1"], + left: ["x1", null], + right: ["x2", null], + bottomLeft: ["x1", "y2"], + bottom: [null, "y2"], + bottomRight: ["x2", "y2"], + move: ["*", "*"], + }; + + const doNotAutoselectTags = { + H1: true, + H2: true, + H3: true, + H4: true, + H5: true, + H6: true, + }; + + let captureType; + + function removeDimensionLimitsOnFullPageShot() { + if (captureType === "fullPageTruncated") { + captureType = "fullPage"; + selectedPos = new Selection( + 0, 0, + getDocumentWidth(), getDocumentHeight()); + } + } + + const standardDisplayCallbacks = { + cancel: () => { + sendEvent("cancel-shot", "overlay-cancel-button"); + exports.deactivate(); + }, download: () => { + sendEvent("download-shot", "overlay-download-button"); + downloadShot(); + }, copy: () => { + sendEvent("copy-shot", "overlay-copy-button"); + copyShot(); + }, + }; + + const standardOverlayCallbacks = { + cancel: () => { + sendEvent("cancel-shot", "cancel-preview-button"); + exports.deactivate(); + }, + onClickCancel: e => { + sendEvent("cancel-shot", "cancel-selection-button"); + e.preventDefault(); + e.stopPropagation(); + exports.deactivate(); + }, + onOpenMyShots: () => { + sendEvent("goto-myshots", "selection-button"); + callBackground("openMyShots") + .then(() => exports.deactivate()) + .catch(() => { + // Handled in communication.js + }); + }, + onClickVisible: () => { + sendEvent("capture-visible", "selection-button"); + selectedPos = new Selection( + window.scrollX, window.scrollY, + window.scrollX + document.documentElement.clientWidth, window.scrollY + window.innerHeight); + captureType = "visible"; + setState("previewing"); + }, + onClickFullPage: () => { + sendEvent("capture-full-page", "selection-button"); + captureType = "fullPage"; + const width = getDocumentWidth(); + if (width > MAX_PAGE_WIDTH) { + captureType = "fullPageTruncated"; + } + const height = getDocumentHeight(); + if (height > MAX_PAGE_HEIGHT) { + captureType = "fullPageTruncated"; + } + selectedPos = new Selection( + 0, 0, + width, height); + setState("previewing"); + }, + onDownloadPreview: () => { + sendEvent(`download-${captureType.replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase()}`, "download-preview-button"); + downloadShot(); + }, + onCopyPreview: () => { + sendEvent(`copy-${captureType.replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase()}`, "copy-preview-button"); + copyShot(); + }, + }; + + /** Holds all the objects that handle events for each state: */ + const stateHandlers = {}; + + function getState() { + return getState.state; + } + getState.state = "cancel"; + + function setState(s) { + if (!stateHandlers[s]) { + throw new Error("Unknown state: " + s); + } + const cur = getState.state; + const handler = stateHandlers[cur]; + if (handler.end) { + handler.end(); + } + getState.state = s; + if (stateHandlers[s].start) { + stateHandlers[s].start(); + } + } + + /** Various values that the states use: */ + let mousedownPos; + let selectedPos; + let resizeDirection; + let resizeStartPos; + let resizeStartSelected; + let resizeHasMoved; + let mouseupNoAutoselect = false; + let autoDetectRect; + + /** Represents a single x/y point, typically for a mouse click that doesn't have a drag: */ + class Pos { + constructor(x, y) { + this.x = x; + this.y = y; + } + + elementFromPoint() { + return ui.iframe.getElementFromPoint( + this.x - window.pageXOffset, + this.y - window.pageYOffset + ); + } + + distanceTo(x, y) { + return Math.sqrt(Math.pow(this.x - x, 2) + Math.pow(this.y - y, 2)); + } + } + + /** ********************************************* + * all stateHandlers + */ + + let dataUrl; + + stateHandlers.previewing = { + start() { + shooter.preview(selectedPos, captureType); + }, + }; + + stateHandlers.crosshairs = { + + cachedEl: null, + + start() { + selectedPos = mousedownPos = null; + this.cachedEl = null; + watchPromise(ui.iframe.display(installHandlersOnDocument, standardOverlayCallbacks).then(() => { + ui.iframe.usePreSelection(); + ui.Box.remove(); + })); + }, + + mousemove(event) { + ui.PixelDimensions.display(event.pageX, event.pageY, event.pageX, event.pageY); + if (event.target.classList && + (!event.target.classList.contains("preview-overlay"))) { + // User is hovering over a toolbar button or control + autoDetectRect = null; + if (this.cachedEl) { + this.cachedEl = null; + } + ui.HoverBox.hide(); + return; + } + let el; + if (event.target.classList && event.target.classList.contains("preview-overlay")) { + // The hover is on the overlay, so we need to figure out the real element + el = ui.iframe.getElementFromPoint( + event.pageX + window.scrollX - window.pageXOffset, + event.pageY + window.scrollY - window.pageYOffset + ); + const xpos = Math.floor(10 * (event.pageX - window.innerWidth / 2) / window.innerWidth); + const ypos = Math.floor(10 * (event.pageY - window.innerHeight / 2) / window.innerHeight); + + for (let i = 0; i < 2; i++) { + const move = `translate(${xpos}px, ${ypos}px)`; + event.target.getElementsByClassName("eyeball")[i].style.transform = move; + } + } else { + // The hover is on the element we care about, so we use that + el = event.target; + } + if (this.cachedEl && this.cachedEl === el) { + // Still hovering over the same element + return; + } + this.cachedEl = el; + this.setAutodetectBasedOnElement(el); + }, + + setAutodetectBasedOnElement(el) { + let lastRect; + let lastNode; + let rect; + let attemptExtend = false; + let node = el; + while (node) { + rect = Selection.getBoundingClientRect(node); + if (!rect) { + rect = lastRect; + break; + } + if (rect.width < MIN_DETECT_WIDTH || rect.height < MIN_DETECT_HEIGHT) { + // Avoid infinite loop for elements with zero or nearly zero height, + // like non-clearfixed float parents with or without borders. + break; + } + if (rect.width > MAX_DETECT_WIDTH || rect.height > MAX_DETECT_HEIGHT) { + // Then the last rectangle is better + rect = lastRect; + attemptExtend = true; + break; + } + if (rect.width >= MIN_DETECT_WIDTH && rect.height >= MIN_DETECT_HEIGHT) { + if (!doNotAutoselectTags[node.tagName]) { + break; + } + } + lastRect = rect; + lastNode = node; + node = node.parentNode; + } + if (rect && node) { + const evenBetter = this.evenBetterElement(node, rect); + if (evenBetter) { + node = lastNode = evenBetter; + rect = Selection.getBoundingClientRect(evenBetter); + attemptExtend = false; + } + } + if (rect && attemptExtend) { + let extendNode = lastNode.nextSibling; + while (extendNode) { + if (extendNode.nodeType === document.ELEMENT_NODE) { + break; + } + extendNode = extendNode.nextSibling; + if (!extendNode) { + const parent = lastNode.parentNode; + for (let i = 0; i < parent.childNodes.length; i++) { + if (parent.childNodes[i] === lastNode) { + extendNode = parent.childNodes[i + 1]; + } + } + } + } + if (extendNode) { + const extendSelection = Selection.getBoundingClientRect(extendNode); + const extendRect = rect.union(extendSelection); + if (extendRect.width <= MAX_DETECT_WIDTH && extendRect.height <= MAX_DETECT_HEIGHT) { + rect = extendRect; + } + } + } + + if (rect && (rect.width < MIN_DETECT_ABSOLUTE_WIDTH || rect.height < MIN_DETECT_ABSOLUTE_HEIGHT)) { + rect = null; + } + if (!rect) { + ui.HoverBox.hide(); + } else { + ui.HoverBox.display(rect); + } + autoDetectRect = rect; + }, + + /** When we find an element, maybe there's one that's just a little bit better... */ + evenBetterElement(node, origRect) { + let el = node.parentNode; + const ELEMENT_NODE = document.ELEMENT_NODE; + while (el && el.nodeType === ELEMENT_NODE) { + if (!el.getAttribute) { + return null; + } + const role = el.getAttribute("role"); + if (role === "article" || (el.className && typeof el.className === "string" && el.className.search("tweet ") !== -1)) { + const rect = Selection.getBoundingClientRect(el); + if (!rect) { + return null; + } + if (rect.width <= MAX_DETECT_WIDTH && rect.height <= MAX_DETECT_HEIGHT) { + return el; + } + return null; + } + el = el.parentNode; + } + return null; + }, + + mousedown(event) { + // FIXME: this is happening but we don't know why, we'll track it now + // but avoid popping up messages: + if (typeof ui === "undefined") { + const exc = new Error("Undefined ui in mousedown"); + exc.unloadTime = unloadTime; + exc.nowTime = Date.now(); + exc.noPopup = true; + exc.noReport = true; + throw exc; + } + if (ui.isHeader(event.target)) { + return undefined; + } + // If the pageX is greater than this, then probably it's an attempt to get + // to the scrollbar, or an actual scroll, and not an attempt to start the + // selection: + const maxX = window.innerWidth - SCROLLBAR_WIDTH; + if (event.pageX >= maxX) { + event.stopPropagation(); + event.preventDefault(); + return false; + } + + mousedownPos = new Pos(event.pageX + window.scrollX, event.pageY + window.scrollY); + setState("draggingReady"); + event.stopPropagation(); + event.preventDefault(); + return false; + }, + + end() { + ui.HoverBox.remove(); + ui.PixelDimensions.remove(); + }, + }; + + stateHandlers.draggingReady = { + minMove: 40, // px + minAutoImageWidth: 40, + minAutoImageHeight: 40, + maxAutoElementWidth: 800, + maxAutoElementHeight: 600, + + start() { + ui.iframe.usePreSelection(); + ui.Box.remove(); + }, + + mousemove(event) { + if (mousedownPos.distanceTo(event.pageX, event.pageY) > this.minMove) { + selectedPos = new Selection( + mousedownPos.x, + mousedownPos.y, + event.pageX + window.scrollX, + event.pageY + window.scrollY); + mousedownPos = null; + setState("dragging"); + } + }, + + mouseup(event) { + // If we don't get into "dragging" then we attempt an autoselect + if (mouseupNoAutoselect) { + sendEvent("cancel-selection", "selection-background-mousedown"); + setState("crosshairs"); + return false; + } + if (autoDetectRect) { + selectedPos = autoDetectRect; + selectedPos.x1 += window.scrollX; + selectedPos.y1 += window.scrollY; + selectedPos.x2 += window.scrollX; + selectedPos.y2 += window.scrollY; + autoDetectRect = null; + mousedownPos = null; + ui.iframe.useSelection(); + ui.Box.display(selectedPos, standardDisplayCallbacks); + sendEvent("make-selection", "selection-click", eventOptionsForBox(selectedPos)); + setState("selected"); + sendEvent("autoselect"); + } else { + sendEvent("no-selection", "no-element-found"); + setState("crosshairs"); + } + return undefined; + }, + + click(event) { + this.mouseup(event); + }, + + findGoodEl() { + let el = mousedownPos.elementFromPoint(); + if (!el) { + return null; + } + const isGoodEl = (el) => { + if (el.nodeType !== document.ELEMENT_NODE) { + return false; + } + if (el.tagName === "IMG") { + const rect = el.getBoundingClientRect(); + return rect.width >= this.minAutoImageWidth && rect.height >= this.minAutoImageHeight; + } + const display = window.getComputedStyle(el).display; + if (["block", "inline-block", "table"].includes(display)) { + return true; + // FIXME: not sure if this is useful: + // let rect = el.getBoundingClientRect(); + // return rect.width <= this.maxAutoElementWidth && rect.height <= this.maxAutoElementHeight; + } + return false; + }; + while (el) { + if (isGoodEl(el)) { + return el; + } + el = el.parentNode; + } + return null; + }, + + end() { + mouseupNoAutoselect = false; + }, + + }; + + stateHandlers.dragging = { + + start() { + ui.iframe.useSelection(); + ui.Box.display(selectedPos); + }, + + mousemove(event) { + selectedPos.x2 = util.truncateX(event.pageX); + selectedPos.y2 = util.truncateY(event.pageY); + scrollIfByEdge(event.pageX, event.pageY); + ui.Box.display(selectedPos); + ui.PixelDimensions.display(event.pageX, event.pageY, selectedPos.width, selectedPos.height); + }, + + mouseup(event) { + selectedPos.x2 = util.truncateX(event.pageX); + selectedPos.y2 = util.truncateY(event.pageY); + ui.Box.display(selectedPos, standardDisplayCallbacks); + sendEvent( + "make-selection", "selection-drag", + eventOptionsForBox({ + top: selectedPos.y1, + bottom: selectedPos.y2, + left: selectedPos.x1, + right: selectedPos.x2, + })); + setState("selected"); + }, + + end() { + ui.PixelDimensions.remove(); + }, + }; + + stateHandlers.selected = { + start() { + ui.iframe.useSelection(); + }, + + mousedown(event) { + const target = event.target; + if (target.tagName === "HTML") { + // This happens when you click on the scrollbar + return undefined; + } + const direction = ui.Box.draggerDirection(target); + if (direction) { + sendEvent("start-resize-selection", "handle"); + stateHandlers.resizing.startResize(event, direction); + } else if (ui.Box.isSelection(target)) { + sendEvent("start-move-selection", "selection"); + stateHandlers.resizing.startResize(event, "move"); + } else if (!ui.Box.isControl(target)) { + mousedownPos = new Pos(event.pageX, event.pageY); + setState("crosshairs"); + } + event.preventDefault(); + return false; + }, + }; + + stateHandlers.resizing = { + start() { + ui.iframe.useSelection(); + selectedPos.sortCoords(); + }, + + startResize(event, direction) { + selectedPos.sortCoords(); + resizeDirection = direction; + resizeStartPos = new Pos(event.pageX, event.pageY); + resizeStartSelected = selectedPos.clone(); + resizeHasMoved = false; + setState("resizing"); + }, + + mousemove(event) { + this._resize(event); + if (resizeDirection !== "move") { + ui.PixelDimensions.display(event.pageX, event.pageY, selectedPos.width, selectedPos.height); + } + return false; + }, + + mouseup(event) { + this._resize(event); + sendEvent("selection-resized"); + ui.Box.display(selectedPos, standardDisplayCallbacks); + if (resizeHasMoved) { + if (resizeDirection === "move") { + const startPos = new Pos(resizeStartSelected.left, resizeStartSelected.top); + const endPos = new Pos(selectedPos.left, selectedPos.top); + sendEvent( + "move-selection", "mouseup", + eventOptionsForMove(startPos, endPos)); + } else { + sendEvent( + "resize-selection", "mouseup", + eventOptionsForResize(resizeStartSelected, selectedPos)); + } + } else if (resizeDirection === "move") { + sendEvent("keep-resize-selection", "mouseup"); + } else { + sendEvent("keep-move-selection", "mouseup"); + } + setState("selected"); + }, + + _resize(event) { + const diffX = event.pageX - resizeStartPos.x; + const diffY = event.pageY - resizeStartPos.y; + const movement = movements[resizeDirection]; + if (movement[0]) { + let moveX = movement[0]; + moveX = moveX === "*" ? ["x1", "x2"] : [moveX]; + for (const moveDir of moveX) { + selectedPos[moveDir] = util.truncateX(resizeStartSelected[moveDir] + diffX); + } + } + if (movement[1]) { + let moveY = movement[1]; + moveY = moveY === "*" ? ["y1", "y2"] : [moveY]; + for (const moveDir of moveY) { + selectedPos[moveDir] = util.truncateY(resizeStartSelected[moveDir] + diffY); + } + } + if (diffX || diffY) { + resizeHasMoved = true; + } + scrollIfByEdge(event.pageX, event.pageY); + ui.Box.display(selectedPos); + }, + + end() { + resizeDirection = resizeStartPos = resizeStartSelected = null; + selectedPos.sortCoords(); + ui.PixelDimensions.remove(); + }, + }; + + stateHandlers.cancel = { + start() { + ui.iframe.hide(); + ui.Box.remove(); + }, + }; + + function getDocumentWidth() { + return Math.max( + document.body && document.body.clientWidth, + document.documentElement.clientWidth, + document.body && document.body.scrollWidth, + document.documentElement.scrollWidth); + } + function getDocumentHeight() { + return Math.max( + document.body && document.body.clientHeight, + document.documentElement.clientHeight, + document.body && document.body.scrollHeight, + document.documentElement.scrollHeight); + } + + function scrollIfByEdge(pageX, pageY) { + const top = window.scrollY; + const bottom = top + window.innerHeight; + const left = window.scrollX; + const right = left + window.innerWidth; + if (pageY + SCROLL_BY_EDGE >= bottom && bottom < getDocumentHeight()) { + window.scrollBy(0, SCROLL_BY_EDGE); + } else if (pageY - SCROLL_BY_EDGE <= top) { + window.scrollBy(0, -SCROLL_BY_EDGE); + } + if (pageX + SCROLL_BY_EDGE >= right && right < getDocumentWidth()) { + window.scrollBy(SCROLL_BY_EDGE, 0); + } else if (pageX - SCROLL_BY_EDGE <= left) { + window.scrollBy(-SCROLL_BY_EDGE, 0); + } + } + + /** ********************************************* + * Selection communication + */ + + exports.activate = function() { + if (!document.body) { + callBackground("abortStartShot"); + const tagName = String(document.documentElement.tagName || "").replace(/[^a-z0-9]/ig, ""); + sendEvent("abort-start-shot", `document-is-${tagName}`); + selectorLoader.unloadModules(); + return; + } + if (isFrameset()) { + callBackground("abortStartShot"); + sendEvent("abort-start-shot", "frame-page"); + selectorLoader.unloadModules(); + return; + } + addHandlers(); + setState("crosshairs"); + }; + + function isFrameset() { + return document.body.tagName === "FRAMESET"; + } + + exports.deactivate = function() { + try { + sendEvent("internal", "deactivate"); + setState("cancel"); + callBackground("closeSelector"); + selectorLoader.unloadModules(); + } catch (e) { + log.error("Error in deactivate", e); + // Sometimes this fires so late that the document isn't available + // We don't care about the exception, so we swallow it here + } + }; + + let unloadTime = 0; + + exports.unload = function() { + // Note that ui.unload() will be called on its own + unloadTime = Date.now(); + removeHandlers(); + }; + + /** ********************************************* + * Event handlers + */ + + const primedDocumentHandlers = new Map(); + let registeredDocumentHandlers = []; + + function addHandlers() { + ["mouseup", "mousedown", "mousemove", "click"].forEach((eventName) => { + const fn = watchFunction(assertIsTrusted((function(eventName, event) { + if (typeof event.button === "number" && event.button !== 0) { + // Not a left click + return undefined; + } + if (event.ctrlKey || event.shiftKey || event.altKey || event.metaKey) { + // Modified click of key + return undefined; + } + const state = getState(); + const handler = stateHandlers[state]; + if (handler[eventName]) { + return handler[eventName](event); + } + return undefined; + }).bind(null, eventName))); + primedDocumentHandlers.set(eventName, fn); + }); + primedDocumentHandlers.set("keyup", watchFunction(assertIsTrusted(keyupHandler))); + primedDocumentHandlers.set("keydown", watchFunction(assertIsTrusted(keydownHandler))); + window.document.addEventListener("visibilitychange", visibilityChangeHandler); + window.addEventListener("beforeunload", beforeunloadHandler); + } + + let mousedownSetOnDocument = false; + + function installHandlersOnDocument(docObj) { + for (const [eventName, handler] of primedDocumentHandlers) { + const watchHandler = watchFunction(handler); + const useCapture = eventName !== "keyup"; + docObj.addEventListener(eventName, watchHandler, useCapture); + registeredDocumentHandlers.push({name: eventName, doc: docObj, handler: watchHandler, useCapture}); + } + if (!mousedownSetOnDocument) { + const mousedownHandler = primedDocumentHandlers.get("mousedown"); + document.addEventListener("mousedown", mousedownHandler, true); + registeredDocumentHandlers.push({name: "mousedown", doc: document, handler: mousedownHandler, useCapture: true}); + mousedownSetOnDocument = true; + } + } + + function beforeunloadHandler() { + sendEvent("cancel-shot", "tab-load"); + exports.deactivate(); + } + + function keydownHandler(event) { + // In MacOS, the keyup event for 'c' is not fired when performing cmd+c. + if (event.code === "KeyC" && (event.ctrlKey || event.metaKey) + && ["previewing", "selected"].includes(getState.state)) { + catcher.watchPromise(callBackground("getPlatformOs").then(os => { + if ((event.ctrlKey && os !== "mac") || + (event.metaKey && os === "mac")) { + sendEvent("copy-shot", "keyboard-copy"); + copyShot(); + } + })); + } + } + + function keyupHandler(event) { + if (event.shiftKey || event.altKey || event.ctrlKey || event.metaKey) { + // unused modifier keys + return; + } + if ((event.key || event.code) === "Escape") { + sendEvent("cancel-shot", "keyboard-escape"); + exports.deactivate(); + } + // Enter to trigger Save or Download by default. But if the user tabbed to + // select another button, then we do not want this. + if ((event.key || event.code) === "Enter" + && getState.state === "selected" + && ui.iframe.document().activeElement.tagName === "BODY") { + sendEvent("download-shot", "keyboard-enter"); + downloadShot(); + } + } + + function visibilityChangeHandler(event) { + // The document is the event target + if (event.target.hidden) { + sendEvent("internal", "document-hidden"); + } + } + + function removeHandlers() { + window.removeEventListener("beforeunload", beforeunloadHandler); + window.document.removeEventListener("visibilitychange", visibilityChangeHandler); + for (const {name, doc, handler, useCapture} of registeredDocumentHandlers) { + doc.removeEventListener(name, handler, !!useCapture); + } + registeredDocumentHandlers = []; + } + + catcher.watchFunction(exports.activate)(); + + return exports; +})(); + +null; diff --git a/browser/extensions/screenshots/selector/util.js b/browser/extensions/screenshots/selector/util.js new file mode 100644 index 0000000000..91217d58c1 --- /dev/null +++ b/browser/extensions/screenshots/selector/util.js @@ -0,0 +1,107 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +this.util = (function() { // eslint-disable-line no-unused-vars + const exports = {}; + + /** Removes a node from its document, if it's a node and the node is attached to a parent */ + exports.removeNode = function(el) { + if (el && el.parentNode) { + el.remove(); + } + }; + + /** Truncates the X coordinate to the document size */ + exports.truncateX = function(x) { + const max = Math.max(document.documentElement.clientWidth, document.body.clientWidth, document.documentElement.scrollWidth, document.body.scrollWidth); + if (x < 0) { + return 0; + } else if (x > max) { + return max; + } + return x; + }; + + /** Truncates the Y coordinate to the document size */ + exports.truncateY = function(y) { + const max = Math.max(document.documentElement.clientHeight, document.body.clientHeight, document.documentElement.scrollHeight, document.body.scrollHeight); + if (y < 0) { + return 0; + } else if (y > max) { + return max; + } + return y; + }; + + // Pixels of wiggle the captured region gets in captureSelectedText: + const CAPTURE_WIGGLE = 10; + const ELEMENT_NODE = document.ELEMENT_NODE; + + exports.captureEnclosedText = function(box) { + const scrollX = window.scrollX; + const scrollY = window.scrollY; + const text = []; + function traverse(el) { + let elBox = el.getBoundingClientRect(); + elBox = { + top: elBox.top + scrollY, + bottom: elBox.bottom + scrollY, + left: elBox.left + scrollX, + right: elBox.right + scrollX, + }; + if (elBox.bottom < box.top || + elBox.top > box.bottom || + elBox.right < box.left || + elBox.left > box.right) { + // Totally outside of the box + return; + } + if (elBox.bottom > box.bottom + CAPTURE_WIGGLE || + elBox.top < box.top - CAPTURE_WIGGLE || + elBox.right > box.right + CAPTURE_WIGGLE || + elBox.left < box.left - CAPTURE_WIGGLE) { + // Partially outside the box + for (let i = 0; i < el.childNodes.length; i++) { + const child = el.childNodes[i]; + if (child.nodeType === ELEMENT_NODE) { + traverse(child); + } + } + return; + } + addText(el); + } + function addText(el) { + let t; + if (el.tagName === "IMG") { + t = el.getAttribute("alt") || el.getAttribute("title"); + } else if (el.tagName === "A") { + t = el.innerText; + if (el.getAttribute("href") && !el.getAttribute("href").startsWith("#")) { + t += " (" + el.href + ")"; + } + } else { + t = el.innerText; + } + if (t) { + text.push(t); + } + } + traverse(document.body); + if (text.length) { + let result = text.join("\n"); + result = result.replace(/^\s+/, ""); + result = result.replace(/\s+$/, ""); + result = result.replace(/[ \t]+\n/g, "\n"); + return result; + } + return null; + }; + + + return exports; +})(); +null; diff --git a/browser/extensions/screenshots/sitehelper.js b/browser/extensions/screenshots/sitehelper.js new file mode 100644 index 0000000000..916155de7b --- /dev/null +++ b/browser/extensions/screenshots/sitehelper.js @@ -0,0 +1,94 @@ +/* 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, callBackground, content */ +/** This is a content script added to all screenshots.firefox.com pages, and allows the site to + communicate with the add-on */ + +"use strict"; + +this.sitehelper = (function() { + + // This gives us the content's copy of XMLHttpRequest, instead of the wrapped + // copy that this content script gets: + const ContentXMLHttpRequest = content.XMLHttpRequest; + + catcher.registerHandler((errorObj) => { + callBackground("reportError", errorObj); + }); + + + const capabilities = {}; + function registerListener(name, func) { + capabilities[name] = name; + document.addEventListener(name, func); + } + + function sendCustomEvent(name, detail) { + if (typeof detail === "object") { + // Note sending an object can lead to security problems, while a string + // is safe to transfer: + detail = JSON.stringify(detail); + } + document.dispatchEvent(new CustomEvent(name, {detail})); + } + + /** Set the cookie, even if third-party cookies are disabled in this browser + (when they are disabled, login from the background page won't set cookies) */ + function sendBackupCookieRequest(authHeaders) { + // See https://bugzilla.mozilla.org/show_bug.cgi?id=1295660 + // This bug would allow us to access window.content.XMLHttpRequest, and get + // a safer (not overridable by content) version of the object. + + // This is a very minimal attempt to verify that the XMLHttpRequest object we got + // is legitimate. It is not a good test. + if (Object.toString.apply(ContentXMLHttpRequest) !== "function XMLHttpRequest() {\n [native code]\n}") { + console.warn("Insecure copy of XMLHttpRequest"); + return; + } + const req = new ContentXMLHttpRequest(); + req.open("POST", "/api/set-login-cookie"); + for (const name in authHeaders) { + req.setRequestHeader(name, authHeaders[name]); + } + req.send(""); + req.onload = () => { + if (req.status !== 200) { + console.warn("Attempt to set Screenshots cookie via /api/set-login-cookie failed:", req.status, req.statusText, req.responseText); + } + }; + } + + registerListener("delete-everything", catcher.watchFunction((event) => { + // FIXME: reset some data in the add-on + }, false)); + + registerListener("request-login", catcher.watchFunction((event) => { + const shotId = event.detail; + catcher.watchPromise(callBackground("getAuthInfo", shotId || null).then((info) => { + if (info) { + sendBackupCookieRequest(info.authHeaders); + sendCustomEvent("login-successful", {deviceId: info.deviceId, accountId: info.accountId, isOwner: info.isOwner, backupCookieRequest: true}); + } + })); + })); + + registerListener("copy-to-clipboard", catcher.watchFunction(event => { + catcher.watchPromise(callBackground("copyShotToClipboard", event.detail)); + })); + + registerListener("show-notification", catcher.watchFunction(event => { + catcher.watchPromise(callBackground("showNotification", event.detail)); + })); + + // Depending on the script loading order, the site might get the addon-present event, + // but probably won't - instead the site will ask for that event after it has loaded + registerListener("request-addon-present", catcher.watchFunction(() => { + sendCustomEvent("addon-present", capabilities); + })); + + sendCustomEvent("addon-present", capabilities); + +})(); +null; diff --git a/browser/extensions/screenshots/test/browser/.eslintrc.yml b/browser/extensions/screenshots/test/browser/.eslintrc.yml new file mode 100644 index 0000000000..f5cf5d3929 --- /dev/null +++ b/browser/extensions/screenshots/test/browser/.eslintrc.yml @@ -0,0 +1,5 @@ +env: + node: true + +extends: + - plugin:mozilla/browser-test diff --git a/browser/extensions/screenshots/test/browser/browser.ini b/browser/extensions/screenshots/test/browser/browser.ini new file mode 100644 index 0000000000..9c27b311fe --- /dev/null +++ b/browser/extensions/screenshots/test/browser/browser.ini @@ -0,0 +1,4 @@ +[browser_screenshots_injection.js] +support-files = injection-page.html +[browser_screenshots_ui_check.js] +skip-if = os == 'win' && debug # Bug 1394967 diff --git a/browser/extensions/screenshots/test/browser/browser_screenshots_injection.js b/browser/extensions/screenshots/test/browser/browser_screenshots_injection.js new file mode 100644 index 0000000000..571d7d6615 --- /dev/null +++ b/browser/extensions/screenshots/test/browser/browser_screenshots_injection.js @@ -0,0 +1,72 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const TEST_PATH = getRootDirectory(gTestPath).replace( + "chrome://mochitests/content", + "https://example.com" +); + +/** + * Check that web content cannot break into screenshots. + */ +add_task(async function test_inject_srcdoc() { + // If Screenshots was disabled, enable it just for this test. + const addon = await AddonManager.getAddonByID("screenshots@mozilla.org"); + const isEnabled = addon.enabled; + if (!isEnabled) { + await addon.enable({allowSystemAddons: true}); + registerCleanupFunction(async () => { + await addon.disable({allowSystemAddons: true}); + }); + } + + await BrowserTestUtils.withNewTab(TEST_PATH + "injection-page.html", + async (browser) => { + // Set up the content hijacking. Do this so we can see it without + // awaiting - the promise should never resolve. + let response = null; + let responsePromise = SpecialPowers.spawn(browser, [], () => { + return new Promise(resolve => { + // We can't pass `resolve` directly because of sandboxing. + // `responseHandler` gets invoked from the content page. + content.wrappedJSObject.responseHandler = Cu.exportFunction( + function(arg) { + resolve(arg) + }, + content + ); + }); + }).then( + r => { + ok(false, "Should not have gotten HTML but got: " + r); + response = r; + }, + () => { + // Do nothing - we expect this to error when the test finishes + // and the actor is destroyed, while the promise still hasn't + // been resolved. We need to catch it in order not to throw + // uncaught rejection errors and inadvertently fail the test. + } + ); + + let error; + let errorPromise = new Promise(resolve => { + SpecialPowers.registerConsoleListener(msg => { + if (msg.message?.match(/iframe URL does not match expected blank.html/)) { + error = msg; + resolve(); + } + }); + }); + + // Now try to start the screenshot flow: + document.querySelector("keyset[id*=screenshot] > key").doCommand(); + await Promise.race([errorPromise, responsePromise]); + ok(error, "Should get the relevant error: " + error?.message); + ok(!response, "Should not get a response from the webpage."); + + SpecialPowers.postConsoleSentinel(); + }); +}); diff --git a/browser/extensions/screenshots/test/browser/browser_screenshots_ui_check.js b/browser/extensions/screenshots/test/browser/browser_screenshots_ui_check.js new file mode 100644 index 0000000000..f77d6c32ea --- /dev/null +++ b/browser/extensions/screenshots/test/browser/browser_screenshots_ui_check.js @@ -0,0 +1,94 @@ +"use strict"; + +ChromeUtils.defineModuleGetter(this, "AddonManager", + "resource://gre/modules/AddonManager.jsm"); + +const BUTTON_ID = "pageAction-panel-screenshots_mozilla_org"; + +function checkElements(expectPresent, l) { + for (const id of l) { + is(!!document.getElementById(id), expectPresent, "element " + id + (expectPresent ? " is" : " is not") + " present"); + } +} + +async function togglePageActionPanel() { + await promiseOpenPageActionPanel(); + EventUtils.synthesizeMouseAtCenter(BrowserPageActions.mainButtonNode, {}); + await promisePageActionPanelEvent("popuphidden"); +} + +function promiseOpenPageActionPanel() { + return BrowserTestUtils.waitForCondition(() => { + // Wait for the main page action button to become visible. It's hidden for + // some URIs, so depending on when this is called, it may not yet be quite + // visible. It's up to the caller to make sure it will be visible. + info("Waiting for main page action button to have non-0 size"); + const bounds = window.windowUtils.getBoundsWithoutFlushing(BrowserPageActions.mainButtonNode); + return bounds.width > 0 && bounds.height > 0; + }).then(() => { + // Wait for the panel to become open, by clicking the button if necessary. + info("Waiting for main page action panel to be open"); + if (BrowserPageActions.panelNode.state === "open") { + return Promise.resolve(); + } + const shownPromise = promisePageActionPanelEvent("popupshown"); + EventUtils.synthesizeMouseAtCenter(BrowserPageActions.mainButtonNode, {}); + return shownPromise; + }).then(() => { + // Wait for items in the panel to become visible. + return promisePageActionViewChildrenVisible(BrowserPageActions.mainViewNode); + }); +} + +function promisePageActionPanelEvent(eventType) { + return new Promise(resolve => { + const panel = BrowserPageActions.panelNode; + if ((eventType === "popupshown" && panel.state === "open") || + (eventType === "popuphidden" && panel.state === "closed")) { + executeSoon(resolve); + return; + } + panel.addEventListener(eventType, () => { + executeSoon(resolve); + }, { once: true }); + }); +} + +function promisePageActionViewChildrenVisible(panelViewNode) { + info("promisePageActionViewChildrenVisible waiting for a child node to be visible"); + return BrowserTestUtils.waitForCondition(() => { + const bodyNode = panelViewNode.firstElementChild; + for (const childNode of bodyNode.children) { + const bounds = window.windowUtils.getBoundsWithoutFlushing(childNode); + if (bounds.width > 0 && bounds.height > 0) { + return true; + } + } + return false; + }); +} + +add_task(async function() { + // If Screenshots was disabled, enable it just for this test. + const addon = await AddonManager.getAddonByID("screenshots@mozilla.org"); + const isEnabled = addon.enabled; + if (!isEnabled) { + await addon.enable({allowSystemAddons: true}); + registerCleanupFunction(async () => { + await addon.disable({allowSystemAddons: true}); + }); + } + + // Toggle the page action panel to get it to rebuild itself. An actionable + // page must be opened first. + const url = "http://example.com/browser_screenshots_ui_check"; + await BrowserTestUtils.withNewTab(url, async () => { // eslint-disable-line space-before-function-paren + await togglePageActionPanel(); + + await BrowserTestUtils.waitForCondition( + () => document.getElementById(BUTTON_ID), + "Screenshots button should be present", 100, 100); + + checkElements(true, [BUTTON_ID]); + }); +}); diff --git a/browser/extensions/screenshots/test/browser/injection-page.html b/browser/extensions/screenshots/test/browser/injection-page.html new file mode 100644 index 0000000000..c7579aa8b5 --- /dev/null +++ b/browser/extensions/screenshots/test/browser/injection-page.html @@ -0,0 +1,24 @@ +<body> +<script> +let callback = function(mutationsList, observer) { + for (let mutation of mutationsList) { + let [added] = mutation.addedNodes; + if (added instanceof HTMLIFrameElement && added.id == "firefox-screenshots-preview-iframe") { + added.srcdoc = "<html></html>"; + // Now we have to wait for the doc to be populated. + let interval = setInterval(() => { + console.log(added.contentDocument.innerHTML); + if (added.contentDocument.body.innerHTML) { + clearInterval(interval); + window.responseHandler(added.contentDocument.body.innerHTML); + } + }, 100); + observer.disconnect(); + } + } +}; + +observer = new MutationObserver(callback); +observer.observe(document.body, {childList: true}); +</script> +</body> |