stylus/js/messaging.js

486 lines
14 KiB
JavaScript
Raw Normal View History

2017-07-12 18:17:04 +00:00
/* global BG: true, onRuntimeMessage, applyOnMessage, handleUpdate, handleDelete */
/* global FIREFOX: true */
2017-07-12 18:17:04 +00:00
'use strict';
// keep message channel open for sendResponse in chrome.runtime.onMessage listener
const KEEP_CHANNEL_OPEN = true;
const CHROME = Boolean(chrome.app) && parseInt(navigator.userAgent.match(/Chrom\w+\/(?:\d+\.){2}(\d+)|$/)[1]);
const OPERA = CHROME && parseFloat(navigator.userAgent.match(/\bOPR\/(\d+\.\d+)|$/)[1]);
2017-11-25 13:24:07 +00:00
const ANDROID = !chrome.windows;
let FIREFOX = !CHROME && parseFloat(navigator.userAgent.match(/\bFirefox\/(\d+\.\d+)|$/)[1]);
if (!CHROME && !chrome.browserAction.openPopup) {
// in FF pre-57 legacy addons can override useragent so we assume the worst
// until we know for sure in the async getBrowserInfo()
// (browserAction.openPopup was added in 57)
FIREFOX = 50;
browser.runtime.getBrowserInfo().then(info => {
FIREFOX = parseFloat(info.version);
});
}
2017-07-12 18:17:04 +00:00
const URLS = {
ownOrigin: chrome.runtime.getURL(''),
optionsUI: [
2017-07-14 08:25:33 +00:00
chrome.runtime.getURL('options.html'),
2017-07-12 18:17:04 +00:00
'chrome://extensions/?options=' + chrome.runtime.id,
],
configureCommands:
OPERA ? 'opera://settings/configureCommands'
: 'chrome://extensions/configureCommands',
// CWS cannot be scripted in chromium, see ChromeExtensionsClient::IsScriptableURL
// https://cs.chromium.org/chromium/src/chrome/common/extensions/chrome_extensions_client.cc
browserWebStore:
FIREFOX ? 'https://addons.mozilla.org/' :
OPERA ? 'https://addons.opera.com/' :
'https://chrome.google.com/webstore/',
// Chrome 61.0.3161+ doesn't run content scripts on NTP https://crrev.com/2978953002/
// TODO: remove when "minimum_chrome_version": "61" or higher
chromeProtectsNTP: CHROME >= 3161,
supported: url => (
url.startsWith('http') && !url.startsWith(URLS.browserWebStore) ||
url.startsWith('ftp') ||
url.startsWith('file') ||
url.startsWith(URLS.ownOrigin) ||
!URLS.chromeProtectsNTP && url.startsWith('chrome://newtab/')
),
2017-07-12 18:17:04 +00:00
};
let BG = chrome.extension.getBackgroundPage();
2017-08-04 09:42:34 +00:00
if (BG && !BG.getStyles && BG !== window) {
// own page like editor/manage is being loaded on browser startup
// before the background page has been fully initialized;
// it'll be resolved in onBackgroundReady() instead
BG = null;
}
2017-07-16 18:02:00 +00:00
if (!BG || BG !== window) {
2017-07-12 18:17:04 +00:00
document.documentElement.classList.toggle('firefox', FIREFOX);
document.documentElement.classList.toggle('opera', OPERA);
// TODO: remove once our manifest's minimum_chrome_version is 50+
// Chrome 49 doesn't report own extension pages in webNavigation apparently
if (CHROME && CHROME < 2661) {
2017-07-12 18:17:04 +00:00
getActiveTab().then(BG.updateIcon);
}
}
const FIREFOX_NO_DOM_STORAGE = FIREFOX && !tryCatch(() => localStorage);
if (FIREFOX_NO_DOM_STORAGE) {
// may be disabled via dom.storage.enabled
Object.defineProperty(window, 'localStorage', {value: {}});
Object.defineProperty(window, 'sessionStorage', {value: {}});
}
2017-07-12 18:17:04 +00:00
function notifyAllTabs(msg) {
const originalMessage = msg;
2017-07-16 18:02:00 +00:00
if (msg.method === 'styleUpdated' || msg.method === 'styleAdded') {
2017-07-12 18:17:04 +00:00
// apply/popup/manage use only meta for these two methods,
// editor may need the full code but can fetch it directly,
// so we send just the meta to avoid spamming lots of tabs with huge styles
msg = Object.assign({}, msg, {
style: getStyleWithNoCode(msg.style)
});
}
const affectsAll = !msg.affects || msg.affects.all;
const affectsOwnOriginOnly = !affectsAll && (msg.affects.editor || msg.affects.manager);
const affectsTabs = affectsAll || affectsOwnOriginOnly;
const affectsIcon = affectsAll || msg.affects.icon;
const affectsPopup = affectsAll || msg.affects.popup;
const affectsSelf = affectsPopup || msg.prefs;
if (affectsTabs || affectsIcon) {
const notifyTab = tab => {
// own pages will be notified via runtime.sendMessage later
if ((affectsTabs || URLS.optionsUI.includes(tab.url))
&& !(affectsSelf && tab.url.startsWith(URLS.ownOrigin))
// skip lazy-loaded aka unloaded tabs that seem to start loading on message in FF
&& (!FIREFOX || tab.width)) {
msg.tabId = tab.id;
sendMessage(msg, ignoreChromeError);
2017-07-12 18:17:04 +00:00
}
if (affectsIcon && BG) {
BG.updateIcon(tab);
}
};
// list all tabs including chrome-extension:// which can be ours
Promise.all([
queryTabs(affectsOwnOriginOnly ? {url: URLS.ownOrigin + '*'} : {}),
getActiveTab(),
]).then(([tabs, activeTab]) => {
const activeTabId = activeTab && activeTab.id;
for (const tab of tabs) {
invokeOrPostpone(tab.id === activeTabId, notifyTab, tab);
}
});
}
// notify self: the message no longer is sent to the origin in new Chrome
2017-07-16 18:02:00 +00:00
if (typeof onRuntimeMessage !== 'undefined') {
2017-07-12 18:17:04 +00:00
onRuntimeMessage(originalMessage);
}
// notify apply.js on own pages
2017-07-16 18:02:00 +00:00
if (typeof applyOnMessage !== 'undefined') {
2017-07-12 18:17:04 +00:00
applyOnMessage(originalMessage);
}
// notify background page and all open popups
if (affectsSelf) {
2017-11-25 17:24:15 +00:00
msg.tabId = undefined;
sendMessage(msg, ignoreChromeError);
}
}
function sendMessage(msg, callback) {
/*
Promise mode [default]:
- rejects on receiving {__ERROR__: message} created by background.js::onRuntimeMessage
- automatically suppresses chrome.runtime.lastError because it's autogenerated
by browserAction.setText which lacks a callback param in chrome API
Standard callback mode:
- enabled by passing a second param
*/
const {tabId, frameId} = msg;
if (tabId >= 0 && FIREFOX) {
// FF: reroute all tabs messages to styleViaAPI
const msgInBG = BG === window ? msg : BG.deepCopy(msg);
const sender = {tab: {id: tabId}, frameId};
const task = BG.styleViaAPI.process(msgInBG, sender);
return callback ? task.then(callback) : task;
}
2017-11-25 17:24:15 +00:00
const fn = tabId >= 0 ? chrome.tabs.sendMessage : chrome.runtime.sendMessage;
const args = tabId >= 0 ? [tabId, msg, {frameId}] : [msg];
if (callback) {
fn(...args, callback);
} else {
return new Promise((resolve, reject) => {
fn(...args, r => {
const err = r && r.__ERROR__;
(err ? reject : resolve)(err || r);
chrome.runtime.lastError; // eslint-disable-line no-unused-expressions
});
});
2017-07-12 18:17:04 +00:00
}
}
function queryTabs(options = {}) {
return new Promise(resolve =>
chrome.tabs.query(options, tabs =>
resolve(tabs)));
}
function getTab(id) {
return new Promise(resolve =>
chrome.tabs.get(id, tab =>
!chrome.runtime.lastError && resolve(tab)));
}
function getOwnTab() {
return new Promise(resolve =>
chrome.tabs.getCurrent(tab => resolve(tab)));
}
function getActiveTab() {
return queryTabs({currentWindow: true, active: true})
.then(tabs => tabs[0]);
}
function getActiveTabRealURL() {
return getActiveTab()
.then(getTabRealURL);
}
function getTabRealURL(tab) {
return new Promise(resolve => {
if (tab.url !== 'chrome://newtab/' || URLS.chromeProtectsNTP) {
2017-07-12 18:17:04 +00:00
resolve(tab.url);
} else {
chrome.webNavigation.getFrame({tabId: tab.id, frameId: 0, processId: -1}, frame => {
resolve(frame && frame.url || '');
});
}
});
}
// opens a tab or activates the already opened one,
// reuses the New Tab page if it's focused now
function openURL({url, index, openerTabId, currentWindow = true}) {
2017-07-12 18:17:04 +00:00
if (!url.includes('://')) {
url = chrome.runtime.getURL(url);
}
return new Promise(resolve => {
// [some] chromium forks don't handle their fake branded protocols
url = url.replace(/^(opera|vivaldi)/, 'chrome');
// FF doesn't handle moz-extension:// URLs (bug)
// API doesn't handle the hash-fragment part
const urlQuery = url.startsWith('moz-extension') ? undefined : url.replace(/#.*/, '');
queryTabs({url: urlQuery, currentWindow}).then(tabs => {
for (const tab of tabs) {
2017-07-16 18:02:00 +00:00
if (tab.url === url) {
2017-07-12 18:17:04 +00:00
activateTab(tab).then(resolve);
return;
}
}
getActiveTab().then(tab => {
const chromeInIncognito = tab && tab.incognito && url.startsWith('chrome');
if (tab && tab.url === 'chrome://newtab/' && !chromeInIncognito) {
// update current NTP, except for chrome:// or chrome-extension:// in incognito
2017-07-12 18:17:04 +00:00
chrome.tabs.update({url}, resolve);
} else {
// create a new tab
const options = {url, index};
2017-11-25 13:24:07 +00:00
// FF57+ supports openerTabId, but not in Android (indicated by the absence of chrome.windows)
if (tab && (!FIREFOX || FIREFOX >= 57 && chrome.windows) && !chromeInIncognito) {
options.openerTabId = tab.id;
}
chrome.tabs.create(options, resolve);
2017-07-12 18:17:04 +00:00
}
});
});
});
}
function activateTab(tab) {
return Promise.all([
new Promise(resolve => {
chrome.tabs.update(tab.id, {active: true}, resolve);
}),
2017-11-25 13:24:07 +00:00
chrome.windows && new Promise(resolve => {
2017-07-12 18:17:04 +00:00
chrome.windows.update(tab.windowId, {focused: true}, resolve);
}),
]);
}
function stringAsRegExp(s, flags) {
return new RegExp(s.replace(/[{}()[\]\\.+*?^$|]/g, '\\$&'), flags);
2017-07-12 18:17:04 +00:00
}
function ignoreChromeError() {
chrome.runtime.lastError; // eslint-disable-line no-unused-expressions
}
function getStyleWithNoCode(style) {
const stripped = Object.assign({}, style, {sections: []});
for (const section of style.sections) {
stripped.sections.push(Object.assign({}, section, {code: null}));
}
return stripped;
}
// js engine can't optimize the entire function if it contains try-catch
// so we should keep it isolated from normal code in a minimal wrapper
// Update: might get fixed in V8 TurboFan in the future
function tryCatch(func, ...args) {
try {
return func(...args);
} catch (e) {}
}
function tryRegExp(regexp) {
try {
return new RegExp(regexp);
} catch (e) {}
}
function tryJSONparse(jsonString) {
try {
return JSON.parse(jsonString);
} catch (e) {}
}
const debounce = Object.assign((fn, delay, ...args) => {
clearTimeout(debounce.timers.get(fn));
debounce.timers.set(fn, setTimeout(debounce.run, delay, fn, ...args));
}, {
timers: new Map(),
run(fn, ...args) {
debounce.timers.delete(fn);
fn(...args);
},
unregister(fn) {
clearTimeout(debounce.timers.get(fn));
debounce.timers.delete(fn);
},
});
function deepCopy(obj) {
2017-07-16 18:02:00 +00:00
return obj !== null && obj !== undefined && typeof obj === 'object'
? deepMerge(typeof obj.slice === 'function' ? [] : {}, obj)
2017-07-12 18:17:04 +00:00
: obj;
}
function deepMerge(target, ...args) {
2017-07-16 18:02:00 +00:00
const isArray = typeof target.slice === 'function';
2017-07-12 18:17:04 +00:00
for (const obj of args) {
if (isArray && obj !== null && obj !== undefined) {
for (const element of obj) {
target.push(deepCopy(element));
}
continue;
}
for (const k in obj) {
const value = obj[k];
2017-07-16 18:02:00 +00:00
if (k in target && typeof value === 'object' && value !== null) {
2017-07-12 18:17:04 +00:00
deepMerge(target[k], value);
} else {
target[k] = deepCopy(value);
}
}
}
return target;
}
function sessionStorageHash(name) {
return {
name,
value: tryCatch(JSON.parse, sessionStorage[name]) || {},
set(k, v) {
this.value[k] = v;
this.updateStorage();
},
unset(k) {
delete this.value[k];
this.updateStorage();
},
updateStorage() {
sessionStorage[this.name] = JSON.stringify(this.value);
}
};
}
function onBackgroundReady() {
return BG && BG.getStyles ? Promise.resolve() : new Promise(function ping(resolve) {
sendMessage({method: 'healthCheck'}, health => {
2017-07-12 18:17:04 +00:00
if (health !== undefined) {
BG = chrome.extension.getBackgroundPage();
resolve();
} else {
setTimeout(ping, 0, resolve);
}
});
});
}
// in case Chrome haven't yet loaded the bg page and displays our page like edit/manage
function getStylesSafe(options) {
return onBackgroundReady()
.then(() => BG.getStyles(options));
}
function saveStyleSafe(style) {
return onBackgroundReady()
.then(() => BG.saveStyle(BG.deepCopy(style)))
.then(savedStyle => {
if (style.notify === false) {
handleUpdate(savedStyle, style);
}
return savedStyle;
});
}
function deleteStyleSafe({id, notify = true} = {}) {
return onBackgroundReady()
.then(() => BG.deleteStyle({id, notify}))
.then(() => {
if (!notify) {
handleDelete(id);
}
return id;
});
}
function download(url, {
method = url.includes('?') ? 'POST' : 'GET',
headers = {
'Content-type': 'application/x-www-form-urlencoded',
},
body = url.includes('?') ? url.slice(url.indexOf('?')) : null,
timeout = 10e3,
requiredStatusCode = 200,
} = {}) {
2017-07-12 18:17:04 +00:00
return new Promise((resolve, reject) => {
url = new URL(url);
2017-11-09 00:16:39 +00:00
if (url.protocol === 'file:' && FIREFOX) {
// https://stackoverflow.com/questions/42108782/firefox-webextensions-get-local-files-content-by-path
// FIXME: add FetchController when it is available.
const timer = setTimeout(() => {
reject(new Error(`Fetch URL timeout: ${url.href}`));
}, timeout);
fetch(url.href, {mode: 'same-origin'})
.then(r => {
clearTimeout(timer);
return r.status === 200 ? r.text() : Promise.reject(r.status);
})
.catch(reject)
.then(resolve);
return;
}
2017-07-12 18:17:04 +00:00
const xhr = new XMLHttpRequest();
xhr.timeout = timeout;
xhr.onload = () => (
!requiredStatusCode || xhr.status === requiredStatusCode || url.protocol === 'file:' ?
resolve(xhr.responseText) :
reject(xhr.status));
xhr.onerror = reject;
xhr.open(method, url.href, true);
Object.keys(headers || {}).forEach(name => xhr.setRequestHeader(name, headers[name]));
xhr.send(body);
2017-07-12 18:17:04 +00:00
});
}
function invokeOrPostpone(isInvoke, fn, ...args) {
return isInvoke
? fn(...args)
: setTimeout(invokeOrPostpone, 0, true, fn, ...args);
}
Add: install styles from *.user.css file Fix: handle dup name+namespace Fix: eslint eqeqeq Fix: trim @name's spaces Add: check update for userstyle Add: build CSS variable Fix: only check dup when id is not provided Refactor: userStyle2json -> userstyle.json Add: style for input Add: config dialog Fix: preserve config during update Fix: onchange doesn't fire on keyboard enter event Fix: remove empty file Add: validator. Metas must stay in the same line Add: warn the user if installation failed Fix: add some delay before starting installation Add: open the editor after first installation Fix: add openEditor to globals Fix: i18n Add: preprocessor. Move userstyle.build to background page. Fix: remove unused global Fix: preserved unknown prop in saveStyleSource() like saveStyle() Add: edit userstyle source Fix: load preprocessor dynamically Fix: load content script dynamically Fix: buildCode is async function Fix: drop Object.entries Fix: style.sections is undefined Fix: don't hide the name input but disable it Fix: query the style before installation Revert: changes to editor, editor.html Refactor: use term `usercss` instead of `userstyle` Fix: don't show homepage action for usercss Refactor: move script-loader to js/ Refactor: pull out mozParser Fix: code style Fix: we don't need to build meta anymore Fix: use saveUsercss instead of saveStyle to get responsed error Fix: last is undefined, load script error Fix: switch to moz-format Fix: drop injectContentScript. Move usercss check into install-user-css Fix: response -> respond Fix: globals -> global Fix: queryUsercss -> filterUsercss Fix: add processUsercss function Fix: only open editor for usercss Fix: remove findupUsercss fixme Fix: globals -> global Fix: globals -> global Fix: global pollution Revert: update.js Refactor: checkStyle Add: support usercss Fix: no need to getURL in background page Fix: merget semver.js into usercss.js Fix: drop all_urls in match pattern Fix: drop respondWithError Move stylus -> stylus-lang Add stylus-lang/readme Fix: use include_globs Fix: global pollution
2017-08-05 16:49:25 +00:00
function openEditor(id) {
let url = '/edit.html';
if (id) {
url += `?id=${id}`;
}
2017-11-25 13:24:07 +00:00
if (chrome.windows && prefs.get('openEditInWindow')) {
Add: install styles from *.user.css file Fix: handle dup name+namespace Fix: eslint eqeqeq Fix: trim @name's spaces Add: check update for userstyle Add: build CSS variable Fix: only check dup when id is not provided Refactor: userStyle2json -> userstyle.json Add: style for input Add: config dialog Fix: preserve config during update Fix: onchange doesn't fire on keyboard enter event Fix: remove empty file Add: validator. Metas must stay in the same line Add: warn the user if installation failed Fix: add some delay before starting installation Add: open the editor after first installation Fix: add openEditor to globals Fix: i18n Add: preprocessor. Move userstyle.build to background page. Fix: remove unused global Fix: preserved unknown prop in saveStyleSource() like saveStyle() Add: edit userstyle source Fix: load preprocessor dynamically Fix: load content script dynamically Fix: buildCode is async function Fix: drop Object.entries Fix: style.sections is undefined Fix: don't hide the name input but disable it Fix: query the style before installation Revert: changes to editor, editor.html Refactor: use term `usercss` instead of `userstyle` Fix: don't show homepage action for usercss Refactor: move script-loader to js/ Refactor: pull out mozParser Fix: code style Fix: we don't need to build meta anymore Fix: use saveUsercss instead of saveStyle to get responsed error Fix: last is undefined, load script error Fix: switch to moz-format Fix: drop injectContentScript. Move usercss check into install-user-css Fix: response -> respond Fix: globals -> global Fix: queryUsercss -> filterUsercss Fix: add processUsercss function Fix: only open editor for usercss Fix: remove findupUsercss fixme Fix: globals -> global Fix: globals -> global Fix: global pollution Revert: update.js Refactor: checkStyle Add: support usercss Fix: no need to getURL in background page Fix: merget semver.js into usercss.js Fix: drop all_urls in match pattern Fix: drop respondWithError Move stylus -> stylus-lang Add stylus-lang/readme Fix: use include_globs Fix: global pollution
2017-08-05 16:49:25 +00:00
chrome.windows.create(Object.assign({url}, prefs.get('windowPosition')));
} else {
openURL({url});
}
}
2017-09-25 10:43:55 +00:00
function closeCurrentTab() {
2017-11-09 04:49:37 +00:00
// https://bugzilla.mozilla.org/show_bug.cgi?id=1409375
getOwnTab().then(tab => {
2017-09-25 10:43:55 +00:00
if (tab) {
chrome.tabs.remove(tab.id);
}
});
}