/* 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; })();