Implement search (#3)

* Handle settings response properly
* Change location of default store settings
* Generate chrome extension
* Move popup into its own folder
* Move distribution javascript into new folder
* Remove checkpoint
* Ignore dist JS files
* Rename background source file
* Update clean rule
* Fix make rule for generated files
* Also tidy CSS files
* Implement searching
* Add icon to search bar
* Add copy-password action
* Add copy-user action
* Add launch action
* Button styling
* Send action to backend
* Set targets to .PHONY
* Highlight the first entry
* Allow disabling fuzzy search by starting search with a space
This commit is contained in:
Erayd
2018-04-19 23:57:51 +12:00
committed by GitHub
parent cc6aa2440d
commit 92f2ecea1b
17 changed files with 584 additions and 255 deletions

10
.gitignore vendored
View File

@@ -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

31
Makefile Normal file
View File

@@ -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

View File

@@ -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)

View File

@@ -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);

View File

@@ -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;
}

View File

@@ -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",

View File

@@ -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;
}

View File

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

1
src/popup/icon-key.svg Normal file
View File

@@ -0,0 +1 @@
<?xml version="1.0" ?><!DOCTYPE svg PUBLIC '-//W3C//DTD SVG 1.1//EN' 'http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd'><svg enable-background="new 0 0 100 100" height="100px" id="Layer_1" version="1.1" viewBox="0 0 100 100" width="100px" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><g id="Captions"/><path d="M70,0C53.432,0,40,13.432,40,30v10L30,50L20,60L10,70L0,80v10v10h10h10V90h10V80h10V70h10V60h10h10 c16.568,0,30-13.432,30-30S86.568,0,70,0z M70,40c-5.522,0-10-4.478-10-10s4.478-10,10-10s10,4.478,10,10S75.522,40,70,40z"/></svg>

After

Width:  |  Height:  |  Size: 591 B

11
src/popup/icon-launch.svg Normal file
View File

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="iso-8859-1"?>
<!-- Generator: Adobe Illustrator 16.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" id="Capa_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
width="459px" height="459px" viewBox="0 0 459 459" style="enable-background:new 0 0 459 459;" xml:space="preserve">
<g>
<g id="share">
<path d="M459,216.75L280.5,38.25v102c-178.5,25.5-255,153-280.5,280.5C63.75,331.5,153,290.7,280.5,290.7v104.55L459,216.75z"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 657 B

View File

@@ -0,0 +1 @@
<?xml version="1.0" ?><!DOCTYPE svg PUBLIC '-//W3C//DTD SVG 1.1//EN' 'http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd'><svg enable-background="new 0 0 100 100" height="100px" id="Layer_1" version="1.1" viewBox="0 0 100 100" width="100px" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><g id="Captions"/><path d="M64.238,54.239C67.879,48.719,70,42.107,70,35C70,15.67,54.33,0,35,0S0,15.67,0,35s15.67,35,35,35 c7.107,0,13.718-2.121,19.238-5.761L90,100l10-10L64.238,54.239z M10,35c0-13.785,11.215-25,25-25c13.785,0,25,11.215,25,25 S48.785,60,35,60C21.215,60,10,48.785,10,35z"/></svg>

After

Width:  |  Height:  |  Size: 636 B

15
src/popup/icon-user.svg Normal file
View File

@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="iso-8859-1"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" id="Capa_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 350 350" style="enable-background:new 0 0 350 350;" xml:space="preserve">
<g>
<path d="M175,171.173c38.914,0,70.463-38.318,70.463-85.586C245.463,38.318,235.105,0,175,0s-70.465,38.318-70.465,85.587
C104.535,132.855,136.084,171.173,175,171.173z"/>
<path d="M41.909,301.853C41.897,298.971,41.885,301.041,41.909,301.853L41.909,301.853z"/>
<path d="M308.085,304.104C308.123,303.315,308.098,298.63,308.085,304.104L308.085,304.104z"/>
<path d="M307.935,298.397c-1.305-82.342-12.059-105.805-94.352-120.657c0,0-11.584,14.761-38.584,14.761
s-38.586-14.761-38.586-14.761c-81.395,14.69-92.803,37.805-94.303,117.982c-0.123,6.547-0.18,6.891-0.202,6.131
c0.005,1.424,0.011,4.058,0.011,8.651c0,0,19.592,39.496,133.08,39.496c113.486,0,133.08-39.496,133.08-39.496
c0-2.951,0.002-5.003,0.005-6.399C308.062,304.575,308.018,303.664,307.935,298.397z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

166
src/popup/interface.js Normal file
View File

@@ -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], "<em>", "</em>")
: 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;
}

117
src/popup/popup.css Normal file
View File

@@ -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;
}

View File

@@ -1,9 +1,9 @@
<!DOCTYPE html>
<html>
<head>
<link rel="stylesheet" type="text/css" href="global.css" />
<link rel="stylesheet" type="text/css" href="../global.css" />
<link rel="stylesheet" type="text/css" href="popup.css" />
<script src="popup.js"></script>
<script src="../js/popup.dist.js"></script>
</head>
<body>
<div class="part notice">Loading available logins...</div>

125
src/popup/popup.js Normal file
View File

@@ -0,0 +1,125 @@
//------------------------------------- Initialisation --------------------------------------//
"use strict";
require("chrome-extension-async");
var TldJS = require("tldjs");
var Interface = require("./interface");
if (typeof browser === "undefined") {
var browser = chrome;
}
// wrap with current tab & settings
browser.tabs.query({ active: true, currentWindow: true }, async function(tabs) {
try {
var response = await browser.runtime.sendMessage({ action: "getSettings" });
if (response.status != "ok") {
throw new Error(response.message);
}
var settings = response.settings;
settings.tab = tabs[0];
settings.host = new URL(settings.tab.url).hostname;
run(settings);
} catch (e) {
handleError(e);
}
});
//----------------------------------- Function definitions ----------------------------------//
/**
* Handle an error
*
* @since 3.0.0
*
* @param Error error Error object
*/
function handleError(error) {
console.log(error);
var errorNode = document.createElement("div");
errorNode.setAttribute("class", "part error");
errorNode.textContent = error.toString();
document.body.innerHTML = "";
document.body.appendChild(errorNode);
}
/**
* 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;
}
/**
* Run the main popup logic
*
* @since 3.0.0
*
* @param object settings Settings object
* @return void
*/
async function run(settings) {
try {
// get list of logins
var response = await browser.runtime.sendMessage({ action: "listFiles" });
var logins = [];
var index = 0;
for (var store in response) {
for (var key in response[store]) {
// set login fields
var login = {
index: index++,
store: store,
login: response[store][key].replace(/\.gpg$/i, "")
};
login.domain = pathToDomain(login.store + "/" + login.login);
login.active =
settings.host == login.domain || settings.host.endsWith("." + login.domain);
// bind handlers
login.doAction = withLogin.bind(login);
logins.push(login);
}
}
var popup = new Interface(settings, logins);
popup.attach(document.body);
} catch (e) {
handleError(e);
}
}
/**
* Do a login action
*
* @since 3.0.0
*
* @param string action Action to take
* @return void
*/
async function withLogin(action) {
try {
var response = await browser.runtime.sendMessage({ action: action, login: this });
if (response.status != "ok") {
throw new Error(response.message);
}
} catch (e) {
handleError(e);
}
}

View File

@@ -0,0 +1,77 @@
module.exports = SearchInterface;
var m = require("mithril");
/**
* Search interface
*
* @since 3.0.0
*
* @param object interface Popup main interface
* @return void
*/
function SearchInterface(popup) {
// public methods
this.view = view;
// fields
this.popup = popup;
}
/**
* Generates vnodes for render
*
* @since 3.0.0
*
* @param function ctl Controller
* @param object params Runtime params
* @return []Vnode
*/
function view(ctl, params) {
var self = this;
return m(
"form.part.search",
{
onsubmit: function(e) {
e.preventDefault();
},
onkeydown: function(e) {
switch (e.code) {
case "ArrowDown":
if (self.popup.results.length) {
document.querySelector("*[tabindex]").focus();
}
break;
case "Enter":
if (self.popup.results.length) {
self.popup.results[0].doAction("fill");
}
break;
}
}
},
[
this.popup.active ? m("div.hint.badge", this.popup.settings.host) : null,
m("input[type=text]", {
focused: true,
placeholder: "Search logins...",
oncreate: function(e) {
e.dom.focus();
},
oninput: function(e) {
self.popup.search(e.target.value.trim(), e.target.value.substr(0, 1) !== " ");
},
onkeydown: function(e) {
switch (e.code) {
case "Backspace":
if (self.popup.active && e.target.value.length == 0) {
self.popup.active = false;
self.popup.search("");
}
break;
}
}
})
]
);
}