Skip to content
This repository has been archived by the owner on Feb 26, 2021. It is now read-only.

Commit

Permalink
Start #75, implement music intents
Browse files Browse the repository at this point in the history
- This adds some support just for Spotify, as a first service
- Does some service detection, so in the future other services can be detected from history
- Loads services dynamically from extension/services/*
- Renames the services.js module to background/serviceList.js
- Implements a generic content script injection system
  • Loading branch information
ianb committed Sep 23, 2019
1 parent 08d632d commit 4087ef7
Show file tree
Hide file tree
Showing 13 changed files with 359 additions and 10 deletions.
17 changes: 16 additions & 1 deletion bin/substitute-manifest.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,28 @@ const BUILD_OUTPUT = path.normalize(
);
const BUILD_TEMPLATE = BUILD_OUTPUT + ".ejs";
const INTENT_DIR = path.normalize(path.join(__dirname, "../extension/intents"));
const SERVICE_DIR = path.normalize(
path.join(__dirname, "../extension/services")
);

function ignoreFilename(filename) {
return filename.startsWith(".") || filename.endsWith(".txt");
}

const filenames = fs.readdirSync(INTENT_DIR, { encoding: "UTF-8" });
const intentNames = [];
for (const filename of filenames) {
if (!filename.startsWith(".") && !filename.endsWith(".txt")) {
if (!ignoreFilename(filename)) {
intentNames.push(filename);
}
}
const serviceNames = [];
for (const filename of fs.readdirSync(SERVICE_DIR, { encoding: "UTF-8" })) {
if (!ignoreFilename(filename)) {
serviceNames.push(filename);
}
}

const gitCommit = child_process
.execSync("git describe --always --dirty", {
encoding: "UTF-8",
Expand All @@ -31,6 +45,7 @@ const context = {
env: process.env,
package_json,
intentNames,
serviceNames,
gitCommit,
buildTime: new Date().toISOString(),
};
Expand Down
47 changes: 47 additions & 0 deletions extension/background/content.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
/* globals log */

this.content = (function() {
const NO_RECEIVER_MESSAGE =
"Could not establish connection. Receiving end does not exist";
const exports = {};
exports.lazyInject = async function(tabId, scripts) {
if (typeof scripts === "string") {
scripts = [scripts];
}
const scriptKey = scripts.join(",");
let available = true;
try {
available = await browser.tabs.sendMessage(tabId, {
type: "ping",
scriptKey,
});
} catch (e) {
available = false;
if (String(e).includes(NO_RECEIVER_MESSAGE)) {
// This is a normal error
} else {
log.error("Error sending message:", String(e));
}
available = false;
}
if (available) {
return;
}
scripts = [
"/buildSettings.js",
"/log.js",
"/content/helpers.js",
"/content/communicate.js",
]
.concat(scripts)
.concat(["/content/responder.js"]);
for (const script of scripts) {
await browser.tabs.executeScript(tabId, { file: script });
}
await browser.tabs.sendMessage(tabId, {
type: "scriptsLoaded",
scriptKey,
});
};
return exports;
})();
4 changes: 2 additions & 2 deletions extension/background/intentParser.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
/* globals log, util, services */
/* globals log, util, serviceList */
this.intentParser = (function() {
const exports = {};

Expand All @@ -14,7 +14,7 @@ this.intentParser = (function() {
*/

const ENTITY_TYPES = {
serviceName: services.allServiceNames(),
serviceName: serviceList.allServiceNames(),
};

const Matcher = (exports.Matcher = class Matcher {
Expand Down
67 changes: 66 additions & 1 deletion extension/services.js → extension/background/serviceList.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
this.services = (function() {
this.services = {};

this.serviceList = (function() {
const exports = {};

// See https://duckduckgo.com/bang for a list of potential services
Expand Down Expand Up @@ -87,5 +89,68 @@ this.services = (function() {
return bang;
};

exports.Service = class Service {
constructor() {}
get matchPatterns() {
const url = new URL(this.baseUrl);
if (url.pathname && url.pathname !== "/") {
const path = url.pathname.replace(/\/+$/, "");
return [
`${url.protocol}//${url.hostname}${path}`,
`${url.protocol}//${url.hostname}${path}/*`,
];
}
return [`${url.protocol}//${url.hostname}/*`];
}
async activateOrOpen() {
return this.getTab(true);
}
async getTab(activate = false) {
const tabs = await browser.tabs.query({ url: this.matchPatterns });
if (!tabs.length) {
return browser.tabs.create({ url: this.baseUrl, active: activate });
}
if (activate) {
await browser.tabs.update(tabs[0].id, { active: activate });
}
return tabs[0];
}
};

exports.detectServiceFromHistory = async function(services) {
const now = Date.now();
const oneMonth = now - 1000 * 60 * 60 * 24 * 30; // last 30 days
let best = null;
let bestScore = 0;
for (const name in services) {
const service = services[name];
const history = await browser.history.search({
text: service.baseUrl,
startTime: oneMonth,
});
let score = 0;
for (const item of history) {
if (!item.url.startsWith(service.baseUrl)) {
continue;
}
const daysAgo = (now - item.lastVisitTime) / (1000 * 60 * 60 * 24);
score +=
(100 - daysAgo) * item.visitCount * (10 + (item.typedCount || 1));
}
if (score > bestScore) {
bestScore = score;
best = name;
}
}
return best;
};

exports.getService = async function(serviceType, serviceMap) {
// TODO: serviceType should be used to store a preference related to this service
// (which would override any automatic detection).
const serviceName = await exports.detectServiceFromHistory(serviceMap);
return serviceMap[serviceName];
};

return exports;
})();
25 changes: 25 additions & 0 deletions extension/content/communicate.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
/* globals log */

this.communicate = (function() {
const exports = {};
const HANDLERS = {};
exports.register = function(type, handler) {
if (HANDLERS[type]) {
throw new Error(`There is already a handler registerd for ${type}`);
}
HANDLERS[type] = handler;
};
exports.handle = function(message, sender) {
if (!HANDLERS[message.type]) {
log.warn("Message of unknown type:", message.type, message);
throw new Error(`No handler for ${message.type}`);
}
try {
return HANDLERS[message.type](message, sender);
} catch (e) {
log.error(`Error in ${message.type} handler: ${e}`, e.stack);
throw e;
}
};
return exports;
})();
39 changes: 39 additions & 0 deletions extension/content/helpers.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
this.helpers = (function() {
const exports = {};

exports.waitForSelector = function(selector, options) {
const interval = (options && options.interval) || 50;
const timeout = (options && options.timeout) || 1000;
return new Promise((resolve, reject) => {
const start = Date.now();
const id = setInterval(() => {
const result = document.querySelector(selector);
if (result) {
clearTimeout(id);
resolve(result);
return;
}
if (Date.now() > start + timeout) {
const e = new Error(`Timeout waiting for ${selector}`);
e.name = "TimeoutError";
clearTimeout(id);
reject(e);
}
}, interval);
});
};

exports.setReactInputValue = function(input, value) {
// See https://hustle.bizongo.in/simulate-react-on-change-on-controlled-components-baa336920e04
// for the why of this
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
window.HTMLInputElement.prototype,
"value"
).set;
nativeInputValueSetter.call(input, value);
const inputEvent = new Event("input", { bubbles: true });
input.dispatchEvent(inputEvent);
};

return exports;
})();
22 changes: 22 additions & 0 deletions extension/content/responder.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
/* globals communicate */
// Note this should always be loaded last, because once it's ready to response we consider the content script "loaded"

this.responder = (function() {
const loadedScripts = {};

function init() {
browser.runtime.onMessage.addListener((message, sender) => {
if (message.type === "ping") {
if (message.scriptKey) {
return !!loadedScripts[message.scriptKey];
}
return true;
} else if (message.type === "scriptsLoaded") {
loadedScripts[message.scriptKey] = true;
return null;
}
return communicate.handle(message, sender);
});
}
init();
})();
65 changes: 65 additions & 0 deletions extension/intents/music/music.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
/* globals intentRunner, serviceList */

this.intents.music = (function() {
const exports = {};
const SERVICES = {};
exports.register = function(service) {
SERVICES[service.name] = service;
};

intentRunner.registerIntent({
name: "music.play",
examples: ["Play Green Day"],
match: `
play [query]
`,
async run(context) {
const service = await serviceList.getService("music", SERVICES);
await service.playQuery(context.slots.query);
},
});

intentRunner.registerIntent({
name: "music.pause",
examples: ["Pause music"],
match: `
pause music
stop music
`,
async run(context) {
const service = await serviceList.getService("music", SERVICES);
await service.pause();
},
});

intentRunner.registerIntent({
name: "music.unpause",
examples: ["Unpause", "continue music", "play music"],
match: `
unpause
continue music
play music
`,
priority: "high",
async run(context) {
const service = await serviceList.getService("music", SERVICES);
await service.unpause();
},
});

intentRunner.registerIntent({
name: "music.focus",
examples: ["Open music"],
match: `
open music
show music
focus music
`,
async run(context) {
const service = await serviceList.getService("music", SERVICES);
await service.activateOrOpen();
},
});

return exports;
})();
4 changes: 2 additions & 2 deletions extension/intents/navigation/navigation.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
/* globals searching, services */
/* globals searching, serviceList */

this.intents.navigation = (function() {
this.intentRunner.registerIntent({
Expand Down Expand Up @@ -54,7 +54,7 @@ this.intents.navigation = (function() {
desc.slots.service
);
desc.addTelemetryServiceName(
`ddg:${services.ddgBangServiceName(desc.slots.service)}`
`ddg:${serviceList.ddgBangServiceName(desc.slots.service)}`
);
await browser.tabs.create({ url: myurl });
browser.runtime.sendMessage({
Expand Down
12 changes: 10 additions & 2 deletions extension/manifest.json.ejs
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,16 @@
"util.js",
"buildSettings.js",
"log.js",
<% if (! env.NO_SENTRY) { %>
"js/vendor/sentry.js",
<% } %>
"catcher.js",
"js/vendor/fuse.js",
"background/main.js",
"background/voiceSchema.js",
"background/telemetry.js",
"services.js",
"background/serviceList.js",
"background/content.js",
"background/intentParser.js",
"background/intentRunner.js",
"background/intentExamples.js",
Expand All @@ -39,6 +42,10 @@
"intents/<%- intentName %>/<%- intentName %>.js",
<% } %>
// End of intent list
// These service listings are generated from extension/services/*
<% for (let serviceName of serviceNames) { %>
"services/<%- serviceName %>/<%- serviceName %>.js",
<% } %>
"searching.js"
]
},
Expand All @@ -54,7 +61,8 @@
"tabs",
"mozillaAddons",
"about:reader*",
"telemetry"
"telemetry",
"history"
],
"browser_action": {
"browser_style": false,
Expand Down
Loading

0 comments on commit 4087ef7

Please sign in to comment.