//-------------------------------- Background initialisation --------------------------------// "use strict"; require("chrome-extension-async"); const sha1 = require("sha1"); const idb = require("idb"); const BrowserpassURL = require("@browserpass/url"); const helpers = require("./helpers"); // native application id var appID = "com.github.browserpass.native"; // OTP extension id var otpID = [ "afjjoildnccgmjbblnklbohcbjehjaph", // webstore releases "jbnpmhhgnchcoljeobafpinmchnpdpin", // github releases "fcmmcnalhjjejhpnlfnddimcdlmpkbdf", // local unpacked "browserpass-otp@maximbaz.com" // firefox ]; // default settings var defaultSettings = { autoSubmit: false, gpgPath: null, stores: {}, foreignFills: {}, username: null, theme: "dark" }; var authListeners = {}; var badgeCache = { files: null, settings: null, expires: Date.now(), isRefreshing: false }; // the last text copied to the clipboard is stored here in order to be cleared after 60 seconds let lastCopiedText = null; chrome.browserAction.setBadgeBackgroundColor({ color: "#666" }); // watch for tab updates chrome.tabs.onUpdated.addListener((tabId, info) => { // unregister any auth listeners for this tab if (info.status === "complete") { if (authListeners[tabId]) { chrome.webRequest.onAuthRequired.removeListener(authListeners[tabId]); delete authListeners[tabId]; } } // redraw badge counter updateMatchingPasswordsCount(tabId); }); // handle incoming messages chrome.runtime.onMessage.addListener(function(message, sender, sendResponse) { receiveMessage(message, sender, sendResponse); // allow async responses after this function returns return true; }); // handle keyboard shortcuts chrome.commands.onCommand.addListener(async command => { switch (command) { case "fillBest": try { const settings = await getFullSettings(); if (settings.tab.url.match(/^(chrome|about):/)) { // only fill on real domains return; } handleMessage(settings, { action: "listFiles" }, listResults => { const logins = helpers.prepareLogins(listResults.files, settings); const bestLogin = helpers.filterSortLogins(logins, "", true)[0]; if (bestLogin) { handleMessage(settings, { action: "fill", login: bestLogin }, () => {}); } }); } catch (e) { console.log(e); } break; } }); // handle fired alarms chrome.alarms.onAlarm.addListener(alarm => { if (alarm.name === "clearClipboard") { if (readFromClipboard() === lastCopiedText) { copyToClipboard("", false); } lastCopiedText = null; } }); chrome.runtime.onInstalled.addListener(onExtensionInstalled); //----------------------------------- Function definitions ----------------------------------// /** * Set badge text with the number of matching password entries * * @since 3.0.0 * * @param int tabId Tab id * @param bool forceRefresh force invalidate cache * @return void */ async function updateMatchingPasswordsCount(tabId, forceRefresh = false) { if (badgeCache.isRefreshing) { return; } try { if (forceRefresh || Date.now() > badgeCache.expires) { badgeCache.isRefreshing = true; let settings = await getFullSettings(); let response = await hostAction(settings, "list"); if (response.status != "ok") { throw new Error(JSON.stringify(response)); } const CACHE_TTL_MS = 60 * 1000; badgeCache = { files: response.data.files, settings: settings, expires: Date.now() + CACHE_TTL_MS, isRefreshing: false }; } try { const tab = await chrome.tabs.get(tabId); badgeCache.settings.origin = new BrowserpassURL(tab.url).origin; } catch (e) { throw new Error(`Unable to determine domain of the tab with id ${tabId}`); } // Compule badge counter const files = helpers.ignoreFiles(badgeCache.files, badgeCache.settings); const logins = helpers.prepareLogins(files, badgeCache.settings); const matchedPasswordsCount = logins.reduce( (acc, login) => acc + (login.recent.count || login.inCurrentHost ? 1 : 0), 0 ); // Set badge for the current tab chrome.browserAction.setBadgeText({ text: "" + (matchedPasswordsCount || ""), tabId: tabId }); } catch (e) { console.log(e); } } /** * Copy text to clipboard and optionally clear it from the clipboard after one minute * * @since 3.2.0 * * @param string text Text to copy * @param boolean clear Whether to clear the clipboard after one minute * @return void */ function copyToClipboard(text, clear = true) { document.addEventListener( "copy", function(e) { e.clipboardData.setData("text/plain", text); e.preventDefault(); }, { once: true } ); document.execCommand("copy"); if (clear) { lastCopiedText = text; chrome.alarms.create("clearClipboard", { delayInMinutes: 1 }); } } /** * Read plain text from clipboard * * @since 3.2.0 * * @return string The current plaintext content of the clipboard */ function readFromClipboard() { const ta = document.createElement("textarea"); // these lines are carefully crafted to make paste work in both Chrome and Firefox ta.contentEditable = true; ta.textContent = ""; document.body.appendChild(ta); ta.select(); document.execCommand("paste"); const content = ta.value; document.body.removeChild(ta); return content; } /** * Save login to recent list for current domain * * @since 3.0.0 * * @param object settings Settings object * @param string host Hostname * @param object login Login object * @param bool remove Remove this item from recent history * @return void */ async function saveRecent(settings, login, remove = false) { var ignoreInterval = 60000; // 60 seconds - don't increment counter twice within this window // save store timestamp localStorage.setItem("recent:" + login.store.id, JSON.stringify(Date.now())); // update login usage count & timestamp if (Date.now() > login.recent.when + ignoreInterval) { login.recent.count++; } login.recent.when = Date.now(); settings.recent[sha1(settings.origin + sha1(login.store.id + sha1(login.login)))] = login.recent; // save to local storage localStorage.setItem("recent", JSON.stringify(settings.recent)); // a new entry was added to the popup matching list, need to refresh the count if (!login.inCurrentHost && login.recent.count === 1) { updateMatchingPasswordsCount(settings.tab.id, true); } // save to usage log try { const DB_VERSION = 1; const db = await idb.openDB("browserpass", DB_VERSION, { upgrade(db) { db.createObjectStore("log", { keyPath: "time" }); } }); await db.add("log", { time: Date.now(), host: settings.origin, login: login.login }); } catch { // ignore any errors and proceed without saving a log entry to Indexed DB } } /** * Call injected code to fill the form * * @param object settings Settings object * @param object request Request details * @param boolean allFrames Dispatch to all frames * @param boolean allowForeign Allow foreign-origin iframes * @param boolean allowNoSecret Allow forms that don't contain a password field * @return array list of filled fields */ async function dispatchFill(settings, request, allFrames, allowForeign, allowNoSecret) { request = Object.assign(deepCopy(request), { allowForeign: allowForeign, allowNoSecret: allowNoSecret, foreignFills: settings.foreignFills[settings.origin] || {} }); let perFrameResults = await chrome.tabs.executeScript(settings.tab.id, { allFrames: allFrames, code: `window.browserpass.fillLogin(${JSON.stringify(request)});` }); // merge filled fields into a single array let filledFields = perFrameResults .reduce((merged, frameResult) => merged.concat(frameResult.filledFields), []) .filter((val, i, merged) => merged.indexOf(val) === i); // if user answered a foreign-origin confirmation, // store the answers in the settings let foreignFillsChanged = false; for (let frame of perFrameResults) { if (typeof frame.foreignFill !== "undefined") { if (typeof settings.foreignFills[settings.origin] === "undefined") { settings.foreignFills[settings.origin] = {}; } settings.foreignFills[settings.origin][frame.foreignOrigin] = frame.foreignFill; foreignFillsChanged = true; } } if (foreignFillsChanged) { await saveSettings(settings); } return filledFields; } /** * Call injected code to focus or submit the form * * @param object settings Settings object * @param object request Request details * @param boolean allFrames Dispatch to all frames * @param boolean allowForeign Allow foreign-origin iframes * @return void */ async function dispatchFocusOrSubmit(settings, request, allFrames, allowForeign) { request = Object.assign(deepCopy(request), { allowForeign: allowForeign, foreignFills: settings.foreignFills[settings.origin] || {} }); let perFrameResults = await chrome.tabs.executeScript(settings.tab.id, { allFrames: allFrames, code: `window.browserpass.focusOrSubmit(${JSON.stringify(request)});` }); // if necessary, dispatch Enter keypress to autosubmit the form // currently only works on Chromium and requires debugger permission try { for (let frame of perFrameResults) { if (frame.needPressEnter) { chrome.debugger.attach({ tabId: settings.tab.id }, "1.2"); for (let type of ["keyDown", "char", "keyUp"]) { chrome.debugger.sendCommand( { tabId: settings.tab.id }, "Input.dispatchKeyEvent", { type: type, key: "Enter", windowsVirtualKeyCode: 13, nativeVirtualKeyCode: 13, unmodifiedText: "\r", text: "\r" } ); } chrome.debugger.detach({ tabId: settings.tab.id }); break; } } } catch (e) {} } /** * Inject script * * @param object settings Settings object * @param boolean allFrames Inject in all frames * @return object Cancellable promise */ async function injectScript(settings, allFrames) { const MAX_WAIT = 1000; return new Promise(async (resolve, reject) => { const waitTimeout = setTimeout(reject, MAX_WAIT); await chrome.tabs.executeScript(settings.tab.id, { allFrames: allFrames, file: "js/inject.dist.js" }); clearTimeout(waitTimeout); resolve(true); }); } /** * Fill form fields * * @param object settings Settings object * @param object login Login object * @param array fields List of fields to fill * @return array List of filled fields */ async function fillFields(settings, login, fields) { // inject script try { await injectScript(settings, false); } catch { throw new Error("Unable to inject script in the top frame"); } let injectedAllFrames = false; try { await injectScript(settings, true); injectedAllFrames = true; } catch { // we'll proceed with trying to fill only the top frame } // build fill request var fillRequest = { origin: new BrowserpassURL(settings.tab.url).origin, login: login, fields: fields }; let allFrames = false; let allowForeign = false; let allowNoSecret = !fields.includes("secret"); let filledFields = []; let importantFieldToFill = fields.includes("openid") ? "openid" : "secret"; // fill form via injected script filledFields = filledFields.concat( await dispatchFill(settings, fillRequest, allFrames, allowForeign, allowNoSecret) ); if (injectedAllFrames) { // try again using same-origin frames if we couldn't fill an "important" field if (!filledFields.includes(importantFieldToFill)) { allFrames = true; filledFields = filledFields.concat( await dispatchFill(settings, fillRequest, allFrames, allowForeign, allowNoSecret) ); } // try again using all available frames if we couldn't fill an "important" field if ( !filledFields.includes(importantFieldToFill) && settings.foreignFills[settings.origin] !== false ) { allowForeign = true; filledFields = filledFields.concat( await dispatchFill(settings, fillRequest, allFrames, allowForeign, allowNoSecret) ); } } // try again, but don't require a password field (if it was required until now) if (!allowNoSecret) { allowNoSecret = true; // try again using only the top frame if (!filledFields.length) { allFrames = false; allowForeign = false; filledFields = filledFields.concat( await dispatchFill(settings, fillRequest, allFrames, allowForeign, allowNoSecret) ); } if (injectedAllFrames) { // try again using same-origin frames if (!filledFields.length) { allFrames = true; filledFields = filledFields.concat( await dispatchFill( settings, fillRequest, allFrames, allowForeign, allowNoSecret ) ); } // try again using all available frames if (!filledFields.length && settings.foreignFills[settings.origin] !== false) { allowForeign = true; filledFields = filledFields.concat( await dispatchFill( settings, fillRequest, allFrames, allowForeign, allowNoSecret ) ); } } } if (!filledFields.length) { throw new Error(`No fillable forms available for fields: ${fields.join(", ")}`); } // build focus or submit request let focusOrSubmitRequest = { origin: new BrowserpassURL(settings.tab.url).origin, autoSubmit: getSetting("autoSubmit", login, settings), filledFields: filledFields }; // try to focus or submit form with the settings that were used to fill it await dispatchFocusOrSubmit(settings, focusOrSubmitRequest, allFrames, allowForeign); return filledFields; } /** * Get Local settings from the extension * * @since 3.0.0 * * @return object Local settings from the extension */ function getLocalSettings() { var settings = deepCopy(defaultSettings); for (var key in settings) { var value = localStorage.getItem(key); if (value !== null) { settings[key] = JSON.parse(value); } } return settings; } /** * Get full settings from the extension and host application * * @since 3.0.0 * * @return object Full settings object */ async function getFullSettings() { var settings = getLocalSettings(); var configureSettings = Object.assign(deepCopy(settings), { defaultStore: {} }); var response = await hostAction(configureSettings, "configure"); if (response.status != "ok") { settings.hostError = response; } settings.version = response.version; // Fill store settings, only makes sense if 'configure' succeeded if (response.status === "ok") { if (Object.keys(settings.stores).length > 0) { // there are user-configured stores present for (var storeId in settings.stores) { if (response.data.storeSettings.hasOwnProperty(storeId)) { var fileSettings = JSON.parse(response.data.storeSettings[storeId]); if (typeof settings.stores[storeId].settings !== "object") { settings.stores[storeId].settings = {}; } var storeSettings = settings.stores[storeId].settings; for (var settingKey in fileSettings) { if (!storeSettings.hasOwnProperty(settingKey)) { storeSettings[settingKey] = fileSettings[settingKey]; } } } } } else { // no user-configured stores, so use the default store settings.stores.default = { id: "default", name: "pass", path: response.data.defaultStore.path }; var fileSettings = JSON.parse(response.data.defaultStore.settings); if (typeof settings.stores.default.settings !== "object") { settings.stores.default.settings = {}; } var storeSettings = settings.stores.default.settings; for (var settingKey in fileSettings) { if (!storeSettings.hasOwnProperty(settingKey)) { storeSettings[settingKey] = fileSettings[settingKey]; } } } } // Fill recent data for (var storeId in settings.stores) { var when = localStorage.getItem("recent:" + storeId); if (when) { settings.stores[storeId].when = JSON.parse(when); } else { settings.stores[storeId].when = 0; } } settings.recent = localStorage.getItem("recent"); if (settings.recent) { settings.recent = JSON.parse(settings.recent); } else { settings.recent = {}; } // Fill current tab info try { settings.tab = (await chrome.tabs.query({ active: true, currentWindow: true }))[0]; let originInfo = new BrowserpassURL(settings.tab.url); settings.host = originInfo.host; // TODO remove this after OTP extension is migrated settings.origin = originInfo.origin; } catch (e) {} return settings; } /** * Get most relevant setting value * * @param string key Setting key * @param object login Login object * @param object settings Settings object * @return object Setting value */ function getSetting(key, login, settings) { if (typeof login.settings[key] !== "undefined") { return login.settings[key]; } if (typeof settings.stores[login.store.id].settings[key] !== "undefined") { return settings.stores[login.store.id].settings[key]; } return settings[key]; } /** * Deep copy an object * * @since 3.0.0 * * @param object obj an object to copy * @return object a new deep copy */ function deepCopy(obj) { return JSON.parse(JSON.stringify(obj)); } /** * Handle modal authentication requests (e.g. HTTP basic) * * @since 3.0.0 * * @param object requestDetails Auth request details * @return object Authentication credentials or {} */ function handleModalAuth(requestDetails) { var launchHost = requestDetails.url.match(/:\/\/([^\/]+)/)[1]; // don't attempt authentication against the same login more than once if (!this.login.allowFill) { return {}; } this.login.allowFill = false; // don't attempt authentication outside the main frame if (requestDetails.type !== "main_frame") { return {}; } // ensure the auth domain is the same, or ask the user for permissions to continue if (launchHost !== requestDetails.challenger.host) { var message = "You are about to send login credentials to a domain that is different than " + "the one you launched from the browserpass extension. Do you wish to proceed?\n\n" + "Realm: " + requestDetails.realm + "\n" + "Launched URL: " + this.url + "\n" + "Authentication URL: " + requestDetails.url; if (!confirm(message)) { return {}; } } // ask the user before sending credentials over an insecure connection if (!requestDetails.url.match(/^https:/i)) { var message = "You are about to send login credentials via an insecure connection!\n\n" + "Are you sure you want to do this? If there is an attacker watching your " + "network traffic, they may be able to see your username and password.\n\n" + "URL: " + requestDetails.url; if (!confirm(message)) { return {}; } } // supply credentials return { authCredentials: { username: this.login.fields.login, password: this.login.fields.secret } }; } /** * Handle a message from elsewhere within the extension * * @since 3.0.0 * * @param object settings Settings object * @param mixed message Incoming message * @param function(mixed) sendResponse Callback to send response * @return void */ async function handleMessage(settings, message, sendResponse) { // check that action is present if (typeof message !== "object" || !message.hasOwnProperty("action")) { sendResponse({ status: "error", message: "Action is missing" }); return; } // fetch file & parse fields if a login entry is present try { if (typeof message.login !== "undefined") { await parseFields(settings, message.login); } } catch (e) { sendResponse({ status: "error", message: "Unable to fetch and parse login fields: " + e.toString() }); return; } // route action switch (message.action) { case "getSettings": sendResponse({ status: "ok", settings: settings }); break; case "saveSettings": try { await saveSettings(message.settings); sendResponse({ status: "ok" }); } catch (e) { sendResponse({ status: "error", message: e.message }); } break; case "listFiles": try { var response = await hostAction(settings, "list"); if (response.status != "ok") { throw new Error(JSON.stringify(response)); // TODO handle host error } let files = helpers.ignoreFiles(response.data.files, settings); sendResponse({ status: "ok", files }); } catch (e) { sendResponse({ status: "error", message: "Unable to enumerate password files" + e.toString() }); } break; case "copyPassword": try { copyToClipboard(message.login.fields.secret); await saveRecent(settings, message.login); sendResponse({ status: "ok" }); } catch (e) { sendResponse({ status: "error", message: "Unable to copy password" }); } break; case "copyUsername": try { copyToClipboard(message.login.fields.login); await saveRecent(settings, message.login); sendResponse({ status: "ok" }); } catch (e) { sendResponse({ status: "error", message: "Unable to copy username" }); } break; case "launch": case "launchInNewTab": try { var url = message.login.fields.url || message.login.host; if (!url) { throw new Error("No URL is defined for this entry"); } if (!url.match(/:\/\//)) { url = "http://" + url; } const tab = message.action === "launch" ? await chrome.tabs.update(settings.tab.id, { url: url }) : await chrome.tabs.create({ url: url }); if (authListeners[tab.id]) { chrome.tabs.onUpdated.removeListener(authListeners[tab.id]); delete authListeners[tab.id]; } authListeners[tab.id] = handleModalAuth.bind({ url: url, login: message.login }); chrome.webRequest.onAuthRequired.addListener( authListeners[tab.id], { urls: ["*://*/*"], tabId: tab.id }, ["blocking"] ); sendResponse({ status: "ok" }); } catch (e) { sendResponse({ status: "error", message: "Unable to launch URL: " + e.toString() }); } break; case "fill": try { let fields = message.login.fields.openid ? ["openid"] : ["login", "secret"]; // dispatch initial fill request var filledFields = await fillFields(settings, message.login, fields); await saveRecent(settings, message.login); // no need to check filledFields, because fillFields() already throws an error if empty sendResponse({ status: "ok", filledFields: filledFields }); } catch (e) { try { sendResponse({ status: "error", message: e.toString() }); } catch (e) { // TODO An error here is typically a closed message port, due to a popup taking focus // away from the extension menu and the menu closing as a result. Need to investigate // whether triggering the extension menu from the background script is possible. console.log(e); } } break; case "clearUsageData": try { await clearUsageData(); sendResponse({ status: "ok" }); } catch (e) { sendResponse({ status: "error", message: e.message }); } break; default: sendResponse({ status: "error", message: "Unknown action: " + message.action }); break; } // trigger browserpass-otp if (typeof message.login !== "undefined" && message.login.fields.hasOwnProperty("otp")) { triggerOTPExtension(settings, message.action, message.login.fields.otp); } } /** * Send a request to the host app * * @since 3.0.0 * * @param object settings Live settings object * @param string action Action to run * @param params object Additional params to pass to the host app * @return Promise */ function hostAction(settings, action, params = {}) { var request = { settings: settings, action: action }; for (var key in params) { request[key] = params[key]; } return chrome.runtime.sendNativeMessage(appID, request); } /** * Fetch file & parse fields * * @since 3.0.0 * * @param object settings Settings object * @param object login Login object * @return void */ async function parseFields(settings, login) { var response = await hostAction(settings, "fetch", { storeId: login.store.id, file: login.login + ".gpg" }); if (response.status != "ok") { throw new Error(JSON.stringify(response)); // TODO handle host error } // save raw data inside login login.raw = response.data.contents; // parse lines login.fields = { secret: ["secret", "password", "pass"], login: ["login", "username", "user"], openid: ["openid"], otp: ["otp", "totp", "hotp"], url: ["url", "uri", "website", "site", "link", "launch"] }; login.settings = { autoSubmit: { name: "autosubmit", type: "bool" } }; var lines = login.raw.split(/[\r\n]+/).filter(line => line.trim().length > 0); lines.forEach(function(line) { // check for uri-encoded otp if (line.match(/^otpauth:\/\/.+/)) { login.fields.otp = { key: null, data: line }; return; } // split key / value & ignore non-k/v lines var parts = line.match(/^(.+?):(.+)$/); if (parts === null) { return; } parts = parts .slice(1) .map(value => value.trim()) .filter(value => value.length); if (parts.length != 2) { return; } // assign to fields for (var key in login.fields) { if ( Array.isArray(login.fields[key]) && login.fields[key].includes(parts[0].toLowerCase()) ) { if (key === "otp") { login.fields[key] = { key: parts[0].toLowerCase(), data: parts[1] }; } else { login.fields[key] = parts[1]; } break; } } // assign to settings for (var key in login.settings) { if ( typeof login.settings[key].type !== "undefined" && login.settings[key].name === parts[0].toLowerCase() ) { if (login.settings[key].type === "bool") { login.settings[key] = ["true", "yes"].includes(parts[1].toLowerCase()); } else { login.settings[key] = parts[1]; } break; } } }); // clean up unassigned fields for (var key in login.fields) { if (Array.isArray(login.fields[key])) { if (key === "secret" && lines.length) { login.fields.secret = lines[0]; } else if (key === "login") { const defaultUsername = getSetting("username", login, settings); login.fields[key] = defaultUsername || login.login.match(/([^\/]+)$/)[1]; } else { delete login.fields[key]; } } } for (var key in login.settings) { if (typeof login.settings[key].type !== "undefined") { delete login.settings[key]; } } } /** * Wrap inbound messages to fetch native configuration * * @since 3.0.0 * * @param mixed message Incoming message * @param MessageSender sender Message sender * @param function(mixed) sendResponse Callback to send response * @return void */ async function receiveMessage(message, sender, sendResponse) { // restrict messages to this extension only if (sender.id !== chrome.runtime.id) { // silently exit without responding when the source is foreign return; } try { const settings = await getFullSettings(); handleMessage(settings, message, sendResponse); } catch (e) { // handle error console.log(e); sendResponse({ status: "error", message: e.toString() }); } } /** * Clear usage data * * @since 3.0.10 * * @return void */ async function clearUsageData() { // clear local storage localStorage.removeItem("foreignFills"); localStorage.removeItem("recent"); Object.keys(localStorage).forEach(key => { if (key.startsWith("recent:")) { localStorage.removeItem(key); } }); // clear Indexed DB await idb.deleteDB("browserpass"); } /** * Save settings if they are valid * * @since 3.0.0 * * @param object Final settings object * @return void */ async function saveSettings(settings) { let settingsToSave = deepCopy(settings); // 'default' is our reserved name for the default store delete settingsToSave.stores.default; // verify that the native host is happy with the provided settings var response = await hostAction(settingsToSave, "configure"); if (response.status != "ok") { throw new Error(`${response.params.message}: ${response.params.error}`); } // before save, make sure to remove store settings that we receive from the host app if (typeof settingsToSave.stores === "object") { for (var store in settingsToSave.stores) { delete settingsToSave.stores[store].settings; } } for (var key in defaultSettings) { if (settingsToSave.hasOwnProperty(key)) { localStorage.setItem(key, JSON.stringify(settingsToSave[key])); } } } /** * Trigger OTP extension (browserpass-otp) * * @since 3.0.13 * * @param object settings Settings object * @param string action Browserpass action * @param object otp OTP field data * @return void */ function triggerOTPExtension(settings, action, otp) { // trigger otp extension for (let targetID of otpID) { chrome.runtime .sendMessage(targetID, { version: chrome.runtime.getManifest().version, action: action, otp: otp, settings: { host: settings.host, origin: settings.origin, tab: settings.tab } }) // Both response & error are noop functions, because we don't care about // the response, and if there's an error it just means the otp extension // is probably not installed. We can't detect that without requesting the // management permission, so this is an acceptable workaround. .then( noop => null, noop => null ); } } /** * Handle browser extension installation and updates * * @since 3.0.0 * * @param object Event details * @return void */ function onExtensionInstalled(details) { // No permissions if (!chrome.notifications) { return; } var show = (id, title, message) => { chrome.notifications.create(id, { title: title, message: message, iconUrl: "icon.png", type: "basic" }); }; if (details.reason === "install") { if (localStorage.getItem("installed") === null) { localStorage.setItem("installed", Date.now()); show( "installed", "browserpass: Install native host app", "Remember to install the complementary native host app to use this extension.\n" + "Instructions here: https://github.com/browserpass/browserpass-native" ); } } else if (details.reason === "update") { var changelog = { 3002000: "New permissions added to clear copied credentials after 60 seconds.", 3000000: "New major update is out, please update the native host app to v3.\n" + "Instructions here: https://github.com/browserpass/browserpass-native" }; var parseVersion = version => { var [major, minor, patch] = version.split("."); return parseInt(major) * 1000000 + parseInt(minor) * 1000 + parseInt(patch); }; var newVersion = parseVersion(chrome.runtime.getManifest().version); var prevVersion = parseVersion(details.previousVersion); Object.keys(changelog) .sort() .forEach(function(version) { if (prevVersion < version && newVersion >= version) { show(version.toString(), "browserpass: Important changes", changelog[version]); } }); } }