diff --git a/bin/substitute-manifest.js b/bin/substitute-manifest.js index e8da7c74a..e14fa160b 100644 --- a/bin/substitute-manifest.js +++ b/bin/substitute-manifest.js @@ -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", @@ -31,6 +45,7 @@ const context = { env: process.env, package_json, intentNames, + serviceNames, gitCommit, buildTime: new Date().toISOString(), }; diff --git a/extension/background/content.js b/extension/background/content.js new file mode 100644 index 000000000..9a1ea52f2 --- /dev/null +++ b/extension/background/content.js @@ -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; +})(); diff --git a/extension/background/intentParser.js b/extension/background/intentParser.js index 4336182c6..4c0a7eecc 100644 --- a/extension/background/intentParser.js +++ b/extension/background/intentParser.js @@ -1,4 +1,4 @@ -/* globals log, util, services */ +/* globals log, util, serviceList */ this.intentParser = (function() { const exports = {}; @@ -14,7 +14,7 @@ this.intentParser = (function() { */ const ENTITY_TYPES = { - serviceName: services.allServiceNames(), + serviceName: serviceList.allServiceNames(), }; const Matcher = (exports.Matcher = class Matcher { diff --git a/extension/services.js b/extension/background/serviceList.js similarity index 53% rename from extension/services.js rename to extension/background/serviceList.js index cda6a3b5c..fa9c358cc 100644 --- a/extension/services.js +++ b/extension/background/serviceList.js @@ -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 @@ -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; })(); diff --git a/extension/content/communicate.js b/extension/content/communicate.js new file mode 100644 index 000000000..e41d26eb6 --- /dev/null +++ b/extension/content/communicate.js @@ -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; +})(); diff --git a/extension/content/helpers.js b/extension/content/helpers.js new file mode 100644 index 000000000..b66a779ca --- /dev/null +++ b/extension/content/helpers.js @@ -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; +})(); diff --git a/extension/content/responder.js b/extension/content/responder.js new file mode 100644 index 000000000..612a9d223 --- /dev/null +++ b/extension/content/responder.js @@ -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(); +})(); diff --git a/extension/intents/music/music.js b/extension/intents/music/music.js new file mode 100644 index 000000000..d2fff5144 --- /dev/null +++ b/extension/intents/music/music.js @@ -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; +})(); diff --git a/extension/intents/navigation/navigation.js b/extension/intents/navigation/navigation.js index c826c5a1a..fed927918 100644 --- a/extension/intents/navigation/navigation.js +++ b/extension/intents/navigation/navigation.js @@ -1,4 +1,4 @@ -/* globals searching, services */ +/* globals searching, serviceList */ this.intents.navigation = (function() { this.intentRunner.registerIntent({ @@ -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({ diff --git a/extension/manifest.json.ejs b/extension/manifest.json.ejs index 4154ad8a1..c12bc4ffa 100644 --- a/extension/manifest.json.ejs +++ b/extension/manifest.json.ejs @@ -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", @@ -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" ] }, @@ -54,7 +61,8 @@ "tabs", "mozillaAddons", "about:reader*", - "telemetry" + "telemetry", + "history" ], "browser_action": { "browser_style": false, diff --git a/extension/searching.js b/extension/searching.js index 65254a282..07b309e2e 100644 --- a/extension/searching.js +++ b/extension/searching.js @@ -1,4 +1,4 @@ -/* globals services */ +/* globals serviceList */ this.searching = (function() { const exports = {}; @@ -39,7 +39,7 @@ this.searching = (function() { }; exports.ddgBangSearchUrl = async function(query, service) { - const bang = services.ddgBangServiceName(service); + const bang = serviceList.ddgBangServiceName(service); const response = await fetch( `https://api.duckduckgo.com/?q=!${encodeURIComponent( bang diff --git a/extension/services/spotify/player.js b/extension/services/spotify/player.js new file mode 100644 index 000000000..e77def8e5 --- /dev/null +++ b/extension/services/spotify/player.js @@ -0,0 +1,26 @@ +/* globals communicate, helpers */ + +this.player = (function() { + communicate.register("play", message => { + const button = document.querySelector("button[title='Play']"); + button.click(); + }); + + communicate.register("search", async message => { + const searchButton = document.querySelector("a[aria-label='Search']"); + searchButton.click(); + const input = await helpers.waitForSelector(".SearchInputBox input"); + helpers.setReactInputValue(input, message.query); + if (message.thenPlay) { + const player = await helpers.waitForSelector(".tracklist-play-pause", { + timeout: 2000, + }); + player.click(); + } + }); + + communicate.register("pause", message => { + const button = document.querySelector("button[title='Pause']"); + button.click(); + }); +})(); diff --git a/extension/services/spotify/spotify.js b/extension/services/spotify/spotify.js new file mode 100644 index 000000000..f69e841a1 --- /dev/null +++ b/extension/services/spotify/spotify.js @@ -0,0 +1,37 @@ +/* globals intents, serviceList, content */ + +this.services.spotify = (function() { + class Spotify extends serviceList.Service { + async playQuery(query) { + const tab = await this.getTab(); + await content.lazyInject(tab.id, "/services/spotify/player.js"); + await browser.tabs.sendMessage(tab.id, { + type: "search", + query, + thenPlay: true, + }); + } + + async pause() { + const tab = await this.getTab(); + await content.lazyInject(tab.id, "/services/spotify/player.js"); + await browser.tabs.sendMessage(tab.id, { + type: "pause", + }); + } + + async unpause() { + const tab = await this.getTab(); + await content.lazyInject(tab.id, "/services/spotify/player.js"); + await browser.tabs.sendMessage(tab.id, { + type: "play", + }); + } + } + Object.assign(Spotify.prototype, { + name: "spotify", + title: "Spotify", + baseUrl: "https://open.spotify.com/", + }); + intents.music.register(new Spotify()); +})();