diff --git a/.gitignore b/.gitignore index 08639d2..47ef6b0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,6 @@ -src/node_modules -src/*.js -src/*.log -!src/*.src.js +/chrome +/src/node_modules +/src/js +/src/*.js +/src/*.log +!/src/*.src.js diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..0e08122 --- /dev/null +++ b/Makefile @@ -0,0 +1,31 @@ +CLEAN_FILES := chrome + +.PHONY: all +all: extension chrome + +.PHONY: extension +extension: + $(MAKE) -C src + +CHROME_FILES := manifest.json \ + *.css \ + *.png \ + popup/*.html \ + popup/*.css \ + popup/*.svg +CHROME_FILES := $(wildcard $(addprefix src/,$(CHROME_FILES))) \ + src/js/background.dist.js \ + src/js/popup.dist.js +CHROME_FILES := $(patsubst src/%,chrome/%,$(CHROME_FILES)) + +.PHONY: chrome +chrome: extension $(CHROME_FILES) + +$(CHROME_FILES) : chrome/% : src/% + [ -d $(dir $@) ] || mkdir -p $(dir $@) + cp $< $@ + +.PHONY: clean +clean: + rm -rf $(CLEAN_FILES) + $(MAKE) -C src clean diff --git a/src/Makefile b/src/Makefile index abfcabe..5b42482 100644 --- a/src/Makefile +++ b/src/Makefile @@ -1,18 +1,28 @@ BROWSERIFY := node_modules/.bin/browserify PRETTIER := node_modules/.bin/prettier +CLEAN_FILES := js +PRETTIER_FILES := $(wildcard *.js popup/*.js *.css popup/*.css) + .PHONY: all -all: deps prettier background.js popup.js +all: deps prettier js/background.dist.js js/popup.dist.js .PHONY: deps deps: yarn install -prettier: $(PRETTIER) *.src.js - $(PRETTIER) --write *.src.js +.PHONY: prettier +prettier: $(PRETTIER) $(PRETTIER_FILES) + $(PRETTIER) --write $(PRETTIER_FILES) -background.js: $(BROWSERIFY) background.src.js - $(BROWSERIFY) -o background.js background.src.js +js/background.dist.js: $(BROWSERIFY) background.js + [ -d js ] || mkdir -p js + $(BROWSERIFY) -o js/background.dist.js background.js -popup.js: $(BROWSERIFY) popup.src.js - $(BROWSERIFY) -o popup.js popup.src.js +js/popup.dist.js: $(BROWSERIFY) popup/*.js + [ -d js ] || mkdir -p js + $(BROWSERIFY) -o js/popup.dist.js popup/popup.js + +.PHONY: clean +clean: + rm -rf $(CLEAN_FILES) diff --git a/src/background.src.js b/src/background.js similarity index 97% rename from src/background.src.js rename to src/background.js index 63e4967..93a7a07 100644 --- a/src/background.src.js +++ b/src/background.js @@ -155,8 +155,8 @@ async function receiveMessage(message, sender, sendResponse) { // no user-configured stores, so use the default store settings.stores.default = { name: "default", - path: response.data.defaultPath, - settings: response.data.defaultSettings + path: response.data.defaultStore.path, + settings: response.data.defaultStore.settings }; } handleMessage(settings, message, sendResponse); diff --git a/src/global.css b/src/global.css index 2c8ef1f..d6c5196 100644 --- a/src/global.css +++ b/src/global.css @@ -1,6 +1,16 @@ -html, body { +html, +body { font-family: sans; font-size: 14px; margin: 0; padding: 0; } + +.badge { + background-color: #0a0; + border-radius: 4px; + color: #fff; + font-size: 12px; + margin: 0 4px; + padding: 2px 4px; +} diff --git a/src/manifest.json b/src/manifest.json index 633c98c..79cb9e7 100644 --- a/src/manifest.json +++ b/src/manifest.json @@ -12,12 +12,12 @@ "background": { "persistent": true, "scripts": [ - "background.js" + "js/background.dist.js" ] }, "browser_action": { "default_icon": "icon-lock.png", - "default_popup": "popup.html" + "default_popup": "popup/popup.html" }, "permissions": [ "activeTab", diff --git a/src/popup.css b/src/popup.css deleted file mode 100644 index d89d0c1..0000000 --- a/src/popup.css +++ /dev/null @@ -1,28 +0,0 @@ -html, body { - height: 100%; - min-width: 260px; -} - -body { - box-sizing: border-box; - display: flex; - flex-direction: column; -} - -.part { - box-sizing: border-box; - padding: 4px 4px 0 4px; -} - -.part:last-child { - padding-bottom: 4px; -} - -.part.error { - color: #F00; -} - -.part.notice { - color: #090; -} - diff --git a/src/popup.src.js b/src/popup.src.js deleted file mode 100644 index 06c9efa..0000000 --- a/src/popup.src.js +++ /dev/null @@ -1,209 +0,0 @@ -//------------------------------------- Initialisation --------------------------------------// -"use strict"; - -require("chrome-extension-async"); -var Mithril = require("mithril"); -var TldJS = require("tldjs"); -var FuzzySort = require("fuzzysort"); - -var startTime = Date.now(); -var settings = null; -var error = null; -var notice = null; -var logins = []; -var domainLogins = []; - -if (typeof browser === "undefined") { - var browser = chrome; -} - -// performance debugging function - TODO remove once extension is ready for release -function checkpoint(activity) { - console.log("Elapsed: " + (Date.now() - startTime) + "ms (" + activity + ")"); -} - -// wrap with current tab & settings -checkpoint("start"); -browser.tabs.query({ active: true, currentWindow: true }, async function(tabs) { - checkpoint("after tab"); - try { - var response = browser.runtime.sendMessage({ action: "getSettings" }); - checkpoint("after getSettings"); - settings = response; - settings.tab = tabs[0]; - settings.host = new URL(settings.tab.url).hostname; - run(); - } catch (e) { - console.log(e.toString()); // TODO - } -}); - -//----------------------------------- Function definitions ----------------------------------// - -/** - * Get the logins which match the provided domain - * - * @since 3.0.0 - * - * @param string domain Domain to filter against - * @return array - */ -function getDomainLogins(domain) { - var domainLogins = []; - var t = TldJS.parse(domain); - - // ignore invalid domains - if (!t.isValid || t.domain === null) { - return []; - } - - // filter against the domain - for (var key in logins) { - if (logins[key].domain === t.hostname) { - domainLogins.push(logins[key]); - } - } - - // recurse and add matching domains to the list - domainLogins = domainLogins.concat(getDomainLogins(t.hostname.replace(/^.+?\./, ""))); - - return domainLogins; -} - -/** - * Get the deepest available domain component of a path - * - * @since 3.0.0 - * - * @param string path Path to parse - * @return string|null Extracted domain - */ -function pathToDomain(path) { - var parts = path.split(/\//).reverse(); - for (var key in parts) { - if (parts[key].indexOf("@") >= 0) { - continue; - } - var t = TldJS.parse(parts[key]); - if (t.isValid && t.domain !== null) { - return t.hostname; - } - } - - return null; -} - -/** - * Render the popup contents - * - * @since 3.0.0 - * - * @return void - */ -function render() { - var body = document.getElementsByTagName("body")[0]; - Mithril.mount(body, { - view: function() { - return [renderError(), renderNotice(), renderList()]; - } - }); - checkpoint("after render"); -} - -/** - * Render any error messages - * - * @since 3.0.0 - * - * @return Vnode - */ -function renderError() { - return error === null ? null : Mithril("div.part.error", error); -} - -/** - * Render any notices - * - * @since 3.0.0 - * - * @return Vnode - */ -function renderNotice() { - return notice === null ? null : Mithril("div.part.notice", notice); -} - -/** - * Render the list of available logins - * - * @since 3.0.0 - * - * @return []Vnode - */ -function renderList() { - if (!logins.length) { - showError("There are no matching logins available"); - return null; - } - - var list = []; - domainLogins.forEach(function(login) { - list.push( - Mithril("div.part.login", { title: login.domain }, login.store + ":" + login.login) - ); - }); - if (!list.length) { - showNotice("There are no logins matching " + settings.host + "."); - } - - checkpoint("after renderList"); - return Mithril("div.logins", list); -} - -async function run() { - try { - // get list of logins - var response = await browser.runtime.sendMessage({ action: "listFiles" }); - checkpoint("after listFiles"); - for (var store in response) { - for (var key in response[store]) { - var login = { - store: store, - login: response[store][key].replace(/\.gpg$/i, "") - }; - login.domain = pathToDomain(login.store + "/" + login.login); - logins.push(login); - } - } - checkpoint("after listFiles post-processing"); - - domainLogins = getDomainLogins(settings.host); - - render(); - } catch (e) { - showError(e.toString()); - } -} - -/** - * Show an error message - * - * @since 3.0.0 - * - * @param string message Message text - */ -function showError(message) { - error = message; - Mithril.redraw(); -} - -/** - * Show an informational message - * - * @since 3.0.0 - * - * @param string message Message text - */ -function showNotice(message) { - notice = message; - Mithril.redraw(); -} diff --git a/src/popup/icon-key.svg b/src/popup/icon-key.svg new file mode 100644 index 0000000..8aab588 --- /dev/null +++ b/src/popup/icon-key.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/popup/icon-launch.svg b/src/popup/icon-launch.svg new file mode 100644 index 0000000..6014a4c --- /dev/null +++ b/src/popup/icon-launch.svg @@ -0,0 +1,11 @@ + + + + diff --git a/src/popup/icon-search.svg b/src/popup/icon-search.svg new file mode 100644 index 0000000..1ae9d00 --- /dev/null +++ b/src/popup/icon-search.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/popup/icon-user.svg b/src/popup/icon-user.svg new file mode 100644 index 0000000..01d35bc --- /dev/null +++ b/src/popup/icon-user.svg @@ -0,0 +1,15 @@ + + + diff --git a/src/popup/interface.js b/src/popup/interface.js new file mode 100644 index 0000000..b229bc7 --- /dev/null +++ b/src/popup/interface.js @@ -0,0 +1,166 @@ +module.exports = Interface; + +var m = require("mithril"); +var FuzzySort = require("fuzzysort"); +var SearchInterface = require("./searchinterface"); + +/** + * Popup main interface + * + * @since 3.0.0 + * + * @param object settings Settings object + * @param array logins Array of available logins + * @return void + */ +function Interface(settings, logins) { + // public methods + this.attach = attach; + this.view = view; + this.search = search; + + // fields + this.settings = settings; + this.logins = logins; + this.results = []; + this.active = true; + this.searchPart = new SearchInterface(this); + + // initialise with empty search + this.search(""); +} + +/** + * Attach the interface on the given element + * + * @since 3.0.0 + * + * @param DOMElement element Target element + * @return void + */ +function attach(element) { + m.mount(element, this); +} + +/** + * Generates vnodes for render + * + * @since 3.0.0 + * + * @param function ctl Controller + * @param object params Runtime params + * @return []Vnode + */ +function view(ctl, params) { + var badges = Object.keys(this.settings.stores).length > 1; + var nodes = []; + nodes.push(m(this.searchPart)); + + nodes.push( + m( + "div.logins", + this.results.map(function(result) { + return m( + "div.part.login", + { + key: result.index, + tabindex: 0, + onclick: function(e) { + result.doAction("fill"); + }, + onkeydown: function(e) { + switch (e.code) { + case "ArrowDown": + if (e.target.nextSibling) { + e.target.nextSibling.focus(); + } + break; + case "ArrowUp": + if (e.target.previousSibling) { + e.target.previousSibling.focus(); + } else { + document + .querySelector(".part.search input[type=text]") + .focus(); + } + break; + case "Enter": + result.doAction("fill"); + break; + } + } + }, + [ + badges ? m("div.store.badge", result.store) : null, + m("div.name", m.trust(result.display)), + m("div.action.copy-password", { + title: "Copy password", + onclick: function(e) { + e.stopPropagation(); + result.doAction("copyPassword"); + } + }), + m("div.action.copy-user", { + title: "Copy username", + onclick: function(e) { + e.stopPropagation(); + result.doAction("copyUser"); + } + }), + m("div.action.launch", { + title: "Open URL", + onclick: function(e) { + e.stopPropagation(); + result.doAction("launch"); + } + }) + ] + ); + }) + ) + ); + + return nodes; +} + +/** + * Run a search + * + * @param string s Search string + * @param bool fuzzyFirstWord Whether to use fuzzy search on the first word + * @return void + */ +function search(s, fuzzyFirstWord = true) { + var self = this; + + // get candidate list + var candidates = this.logins.map(result => Object.assign(result, { display: result.login })); + if (this.active) { + candidates = candidates.filter(login => login.active); + } + + if (s.length) { + var filter = s.split(/\s+/); + // fuzzy-search first word & add highlighting + if (fuzzyFirstWord) { + candidates = FuzzySort.go(filter[0], candidates, { + keys: ["login", "store"], + allowTypo: false + }).map(result => + Object.assign(result.obj, { + display: result[0] + ? FuzzySort.highlight(result[0], "", "") + : result.obj.login + }) + ); + } + // substring-search to refine against each remaining word + filter.slice(fuzzyFirstWord ? 1 : 0).forEach(function(word) { + candidates = candidates.filter( + login => login.login.toLowerCase().indexOf(word.toLowerCase()) >= 0 + ); + }); + } + + this.results = candidates; +} diff --git a/src/popup/popup.css b/src/popup/popup.css new file mode 100644 index 0000000..9256a73 --- /dev/null +++ b/src/popup/popup.css @@ -0,0 +1,117 @@ +html, +body { + min-width: 260px; + white-space: nowrap; +} + +body { + display: flex; + flex-direction: column; +} + +.logins { + box-sizing: border-box; + display: flex; + flex-direction: column; + overflow-y: auto; + height: inherit; + max-height: 203px; /* 7 x 29px login part*/ +} + +.part { + border-bottom: 1px solid #eee; + box-sizing: border-box; + display: flex; + flex-shrink: 0; + min-height: 29px; + padding: 6px; +} + +.part:last-child { + border-bottom: none; +} + +.part > .badge:first-child { + margin-left: 0; +} + +.part.error { + color: #f00; + white-space: normal; +} + +.part.notice { + color: #090; + white-space: normal; +} + +.part.search { + padding-right: 28px; + background-image: url("icon-search.svg"); + background-position: top 6px right 6px; + background-repeat: no-repeat; + background-size: 18px; +} + +.part.search:focus-within { + background-color: #eef; +} + +.part.search > .hint { + background-color: #66c; +} + +.part.search > input[type="text"] { + background-color: transparent; + border: none; + outline: none; + width: 100%; +} + +.part.login { + display: flex; +} + +.part.login:first-child { + background-color: #efe; +} + +.part.login:hover, +.part.login:focus { + background-color: #eee; + outline: none; +} + +.part.login > .name { + width: 100%; +} + +.part.login > .action { + background-position: right 3px top 0; + background-repeat: no-repeat; + background-size: 20px; + filter: drop-shadow(2px 2px 1px #666) invert(18%); + margin: -2px 0; + width: 30px; +} + +.past.login > .action:hover { + background-color: #f00; +} + +.part.login > .action.copy-password { + background-image: url("icon-key.svg"); +} + +.part.login > .action.copy-user { + background-image: url("icon-user.svg"); +} + +.part.login > .action.launch { + background-image: url("icon-launch.svg"); +} + +.part.login em { + color: #00c; + font-style: normal; +} diff --git a/src/popup.html b/src/popup/popup.html similarity index 63% rename from src/popup.html rename to src/popup/popup.html index 1793316..b09e8a3 100644 --- a/src/popup.html +++ b/src/popup/popup.html @@ -1,9 +1,9 @@
- + - +