instant style injection via synchronous XHR (#1070)
* don't run web-ext test as it fails on Chrome-only permissions * generate stylus-firefox.zip without declarativeContent * limit note's width in options * run updateExposeIframes only in frames
This commit is contained in:
parent
7f15ae324d
commit
f9804036b2
|
@ -963,6 +963,12 @@
|
||||||
"optionsAdvancedNewStyleAsUsercss": {
|
"optionsAdvancedNewStyleAsUsercss": {
|
||||||
"message": "Write new style as usercss"
|
"message": "Write new style as usercss"
|
||||||
},
|
},
|
||||||
|
"optionsAdvancedStyleViaXhr": {
|
||||||
|
"message": "Instant inject mode"
|
||||||
|
},
|
||||||
|
"optionsAdvancedStyleViaXhrNote": {
|
||||||
|
"message": "Enable this if you encounter flashing of unstyled content (FOUC) when browsing, which is especially noticeable with dark themes.\n\nThe technical reason is that Chrome/Chromium postpones asynchronous communication of extensions, in a usually meaningless attempt to improve page load speed, potentially causing styles to be late to apply. To circumvent this, since web extensions are not provided a synchronous API, Stylus provides this option to utilize the \"deprecated\" synchronous XMLHttpRequest web API to fetch applicable styles. There shouldn't be any detrimental effects, since the request is fulfilled within a few milliseconds while the page is still being downloaded from the server.\n\nNevertheless, Chromium will print a warning in devtools' console. Right-clicking a warning, and hiding them, will prevent future warnings from being shown."
|
||||||
|
},
|
||||||
"optionsBadgeDisabled": {
|
"optionsBadgeDisabled": {
|
||||||
"message": "Background color when disabled"
|
"message": "Background color when disabled"
|
||||||
},
|
},
|
||||||
|
|
85
background/style-via-xhr.js
Normal file
85
background/style-via-xhr.js
Normal file
|
@ -0,0 +1,85 @@
|
||||||
|
/* global API CHROME prefs */
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// eslint-disable-next-line no-unused-expressions
|
||||||
|
CHROME && (async () => {
|
||||||
|
const prefId = 'styleViaXhr';
|
||||||
|
const blobUrlPrefix = 'blob:' + chrome.runtime.getURL('/');
|
||||||
|
const stylesToPass = {};
|
||||||
|
|
||||||
|
await prefs.initializing;
|
||||||
|
toggle(prefId, prefs.get(prefId));
|
||||||
|
prefs.subscribe([prefId], toggle);
|
||||||
|
|
||||||
|
function toggle(key, value) {
|
||||||
|
if (!chrome.declarativeContent) { // not yet granted in options page
|
||||||
|
value = false;
|
||||||
|
}
|
||||||
|
if (value) {
|
||||||
|
const reqFilter = {
|
||||||
|
urls: ['<all_urls>'],
|
||||||
|
types: ['main_frame', 'sub_frame'],
|
||||||
|
};
|
||||||
|
chrome.webRequest.onBeforeRequest.addListener(prepareStyles, reqFilter);
|
||||||
|
chrome.webRequest.onHeadersReceived.addListener(passStyles, reqFilter, [
|
||||||
|
'blocking',
|
||||||
|
'responseHeaders',
|
||||||
|
chrome.webRequest.OnHeadersReceivedOptions.EXTRA_HEADERS,
|
||||||
|
].filter(Boolean));
|
||||||
|
} else {
|
||||||
|
chrome.webRequest.onBeforeRequest.removeListener(prepareStyles);
|
||||||
|
chrome.webRequest.onHeadersReceived.removeListener(passStyles);
|
||||||
|
}
|
||||||
|
if (!chrome.declarativeContent) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
chrome.declarativeContent.onPageChanged.removeRules([prefId], async () => {
|
||||||
|
if (!value) return;
|
||||||
|
chrome.declarativeContent.onPageChanged.addRules([{
|
||||||
|
id: prefId,
|
||||||
|
conditions: [
|
||||||
|
new chrome.declarativeContent.PageStateMatcher({
|
||||||
|
pageUrl: {urlContains: ':'},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
actions: [
|
||||||
|
new chrome.declarativeContent.RequestContentScript({
|
||||||
|
allFrames: true,
|
||||||
|
// This runs earlier than document_start
|
||||||
|
js: chrome.runtime.getManifest().content_scripts[0].js,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {chrome.webRequest.WebRequestBodyDetails} req */
|
||||||
|
function prepareStyles(req) {
|
||||||
|
API.getSectionsByUrl(req.url).then(sections => {
|
||||||
|
const str = JSON.stringify(sections);
|
||||||
|
if (str !== '{}') {
|
||||||
|
stylesToPass[req.requestId] = URL.createObjectURL(new Blob([str])).slice(blobUrlPrefix.length);
|
||||||
|
setTimeout(cleanUp, 600e3, req.requestId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {chrome.webRequest.WebResponseHeadersDetails} req */
|
||||||
|
function passStyles(req) {
|
||||||
|
const blobId = stylesToPass[req.requestId];
|
||||||
|
if (blobId) {
|
||||||
|
const {responseHeaders} = req;
|
||||||
|
responseHeaders.push({
|
||||||
|
name: 'Set-Cookie',
|
||||||
|
value: `${chrome.runtime.id}=${blobId}`,
|
||||||
|
});
|
||||||
|
return {responseHeaders};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanUp(key) {
|
||||||
|
const blobId = stylesToPass[key];
|
||||||
|
delete stylesToPass[key];
|
||||||
|
if (blobId) URL.revokeObjectURL(blobUrlPrefix + blobId);
|
||||||
|
}
|
||||||
|
})();
|
|
@ -20,6 +20,7 @@ self.INJECTED !== 1 && (() => {
|
||||||
/** @type chrome.runtime.Port */
|
/** @type chrome.runtime.Port */
|
||||||
let port;
|
let port;
|
||||||
let lazyBadge = IS_FRAME;
|
let lazyBadge = IS_FRAME;
|
||||||
|
let parentDomain;
|
||||||
|
|
||||||
// the popup needs a check as it's not a tab but can be opened in a tab manually for whatever reason
|
// the popup needs a check as it's not a tab but can be opened in a tab manually for whatever reason
|
||||||
if (!IS_TAB) {
|
if (!IS_TAB) {
|
||||||
|
@ -42,24 +43,39 @@ self.INJECTED !== 1 && (() => {
|
||||||
window.addEventListener(orphanEventId, orphanCheck, true);
|
window.addEventListener(orphanEventId, orphanCheck, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
let parentDomain;
|
|
||||||
|
|
||||||
prefs.subscribe(['disableAll'], (key, value) => doDisableAll(value));
|
|
||||||
if (IS_FRAME) {
|
|
||||||
prefs.subscribe(['exposeIframes'], updateExposeIframes);
|
|
||||||
}
|
|
||||||
|
|
||||||
function onInjectorUpdate() {
|
function onInjectorUpdate() {
|
||||||
if (!isOrphaned) {
|
if (!isOrphaned) {
|
||||||
updateCount();
|
updateCount();
|
||||||
updateExposeIframes();
|
const onOff = prefs[styleInjector.list.length ? 'subscribe' : 'unsubscribe'];
|
||||||
|
onOff(['disableAll'], updateDisableAll);
|
||||||
|
if (IS_FRAME) {
|
||||||
|
updateExposeIframes();
|
||||||
|
onOff(['exposeIframes'], updateExposeIframes);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function init() {
|
async function init() {
|
||||||
return STYLE_VIA_API ?
|
if (STYLE_VIA_API) {
|
||||||
API.styleViaAPI({method: 'styleApply'}) :
|
await API.styleViaAPI({method: 'styleApply'});
|
||||||
API.getSectionsByUrl(getMatchUrl()).then(styleInjector.apply);
|
} else {
|
||||||
|
const styles = chrome.app && getStylesViaXhr() || await API.getSectionsByUrl(getMatchUrl());
|
||||||
|
await styleInjector.apply(styles);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStylesViaXhr() {
|
||||||
|
if (new RegExp(`(^|\\s|;)${chrome.runtime.id}=\\s*([-\\w]+)\\s*(;|$)`).test(document.cookie)) {
|
||||||
|
const url = 'blob:' + chrome.runtime.getURL(RegExp.$2);
|
||||||
|
const xhr = new XMLHttpRequest();
|
||||||
|
document.cookie = `${chrome.runtime.id}=1; max-age=0`; // remove our cookie
|
||||||
|
try {
|
||||||
|
xhr.open('GET', url, false); // synchronous
|
||||||
|
xhr.send();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
return JSON.parse(xhr.response);
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getMatchUrl() {
|
function getMatchUrl() {
|
||||||
|
@ -138,7 +154,7 @@ self.INJECTED !== 1 && (() => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function doDisableAll(disableAll) {
|
function updateDisableAll(key, disableAll) {
|
||||||
if (STYLE_VIA_API) {
|
if (STYLE_VIA_API) {
|
||||||
API.styleViaAPI({method: 'prefChanged', prefs: {disableAll}});
|
API.styleViaAPI({method: 'prefChanged', prefs: {disableAll}});
|
||||||
} else {
|
} else {
|
||||||
|
@ -146,22 +162,18 @@ self.INJECTED !== 1 && (() => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function fetchParentDomain() {
|
async function updateExposeIframes(key, value = prefs.get('exposeIframes')) {
|
||||||
return parentDomain ?
|
const attr = 'stylus-iframe';
|
||||||
Promise.resolve() :
|
const el = document.documentElement;
|
||||||
API.getTabUrlPrefix()
|
if (!el) return; // got no styles so styleInjector didn't wait for <html>
|
||||||
.then(newDomain => {
|
if (!value || !styleInjector.list.length) {
|
||||||
parentDomain = newDomain;
|
el.removeAttribute(attr);
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateExposeIframes() {
|
|
||||||
if (!prefs.get('exposeIframes') || window === parent || !styleInjector.list.length) {
|
|
||||||
document.documentElement.removeAttribute('stylus-iframe');
|
|
||||||
} else {
|
} else {
|
||||||
fetchParentDomain().then(() => {
|
if (!parentDomain) parentDomain = await API.getTabUrlPrefix();
|
||||||
document.documentElement.setAttribute('stylus-iframe', parentDomain);
|
// Check first to avoid triggering DOM mutation
|
||||||
});
|
if (el.getAttribute(attr) !== parentDomain) {
|
||||||
|
el.setAttribute(attr, parentDomain);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -12,6 +12,7 @@ self.prefs = self.INJECTED === 1 ? self.prefs : (() => {
|
||||||
'disableAll': false, // boss key
|
'disableAll': false, // boss key
|
||||||
'exposeIframes': false, // Add 'stylus-iframe' attribute to HTML element in all iframes
|
'exposeIframes': false, // Add 'stylus-iframe' attribute to HTML element in all iframes
|
||||||
'newStyleAsUsercss': false, // create new style in usercss format
|
'newStyleAsUsercss': false, // create new style in usercss format
|
||||||
|
'styleViaXhr': false, // early style injection to avoid FOUC
|
||||||
|
|
||||||
// checkbox in style config dialog
|
// checkbox in style config dialog
|
||||||
'config.autosave': true,
|
'config.autosave': true,
|
||||||
|
|
|
@ -23,6 +23,9 @@
|
||||||
"identity",
|
"identity",
|
||||||
"<all_urls>"
|
"<all_urls>"
|
||||||
],
|
],
|
||||||
|
"optional_permissions": [
|
||||||
|
"declarativeContent"
|
||||||
|
],
|
||||||
"background": {
|
"background": {
|
||||||
"scripts": [
|
"scripts": [
|
||||||
"js/polyfill.js",
|
"js/polyfill.js",
|
||||||
|
@ -53,6 +56,7 @@
|
||||||
"background/usercss-helper.js",
|
"background/usercss-helper.js",
|
||||||
"background/usercss-install-helper.js",
|
"background/usercss-install-helper.js",
|
||||||
"background/style-via-api.js",
|
"background/style-via-api.js",
|
||||||
|
"background/style-via-xhr.js",
|
||||||
"background/search-db.js",
|
"background/search-db.js",
|
||||||
"background/update.js",
|
"background/update.js",
|
||||||
"background/openusercss-api.js"
|
"background/openusercss-api.js"
|
||||||
|
|
16
options.html
16
options.html
|
@ -239,9 +239,25 @@
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
<div class="items">
|
<div class="items">
|
||||||
|
<label class="chromium-only">
|
||||||
|
<span i18n-text="optionsAdvancedStyleViaXhr">
|
||||||
|
<a data-cmd="note"
|
||||||
|
i18n-title="optionsAdvancedStyleViaXhrNote"
|
||||||
|
href="#"
|
||||||
|
class="svg-inline-wrapper"
|
||||||
|
tabindex="0">
|
||||||
|
<svg class="svg-icon info"><use xlink:href="#svg-icon-help"/></svg>
|
||||||
|
</a>
|
||||||
|
</span>
|
||||||
|
<span class="onoffswitch">
|
||||||
|
<input type="checkbox" id="styleViaXhr" class="slider">
|
||||||
|
<span></span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
<label>
|
<label>
|
||||||
<span i18n-text="optionsAdvancedExposeIframes">
|
<span i18n-text="optionsAdvancedExposeIframes">
|
||||||
<a data-cmd="note"
|
<a data-cmd="note"
|
||||||
|
i18n-data-title="optionsAdvancedExposeIframesNote"
|
||||||
i18n-title="optionsAdvancedExposeIframesNote"
|
i18n-title="optionsAdvancedExposeIframesNote"
|
||||||
href="#"
|
href="#"
|
||||||
class="svg-inline-wrapper"
|
class="svg-inline-wrapper"
|
||||||
|
|
|
@ -342,10 +342,11 @@ html:not(.firefox):not(.opera) #updates {
|
||||||
#message-box.note {
|
#message-box.note {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
white-space: pre-wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
#message-box.note > div {
|
#message-box.note > div {
|
||||||
max-width: calc(100% - 5rem);
|
max-width: 40em;
|
||||||
top: unset;
|
top: unset;
|
||||||
right: unset;
|
right: unset;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
|
@ -38,6 +38,27 @@ if (FIREFOX && 'update' in (chrome.commands || {})) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (CHROME) {
|
||||||
|
// Show the option as disabled until the permission is actually granted
|
||||||
|
const el = $('#styleViaXhr');
|
||||||
|
el.addEventListener('click', () => {
|
||||||
|
if (el.checked && !chrome.declarativeContent) {
|
||||||
|
chrome.permissions.request({permissions: ['declarativeContent']}, ok => {
|
||||||
|
if (chrome.runtime.lastError || !ok) {
|
||||||
|
el.checked = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!chrome.declarativeContent) {
|
||||||
|
prefs.initializing.then(() => {
|
||||||
|
if (prefs.get('styleViaXhr')) {
|
||||||
|
el.checked = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// actions
|
// actions
|
||||||
$('#options-close-icon').onclick = () => {
|
$('#options-close-icon').onclick = () => {
|
||||||
top.dispatchEvent(new CustomEvent('closeOptions'));
|
top.dispatchEvent(new CustomEvent('closeOptions'));
|
||||||
|
@ -79,7 +100,7 @@ document.onclick = e => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
messageBox({
|
messageBox({
|
||||||
className: 'note',
|
className: 'note',
|
||||||
contents: target.title,
|
contents: target.dataset.title,
|
||||||
buttons: [t('confirmClose')],
|
buttons: [t('confirmClose')],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -233,6 +254,7 @@ function splitLongTooltips() {
|
||||||
.map(s => s.replace(/(.{50,80}(?=.{40,}))\s+/g, '$1\n'))
|
.map(s => s.replace(/(.{50,80}(?=.{40,}))\s+/g, '$1\n'))
|
||||||
.join('\n');
|
.join('\n');
|
||||||
if (newTitle !== el.title) {
|
if (newTitle !== el.title) {
|
||||||
|
el.dataset.title = el.title;
|
||||||
el.title = newTitle;
|
el.title = newTitle;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -32,7 +32,7 @@
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"lint": "eslint \"**/*.js\" --cache",
|
"lint": "eslint \"**/*.js\" --cache",
|
||||||
"test": "npm run lint && web-ext lint",
|
"test": "npm run lint",
|
||||||
"update-locales": "tx pull --all && webext-tx-fix",
|
"update-locales": "tx pull --all && webext-tx-fix",
|
||||||
"update-transifex": "tx push -s",
|
"update-transifex": "tx push -s",
|
||||||
"build-vendor": "shx rm -rf vendor/* && node tools/build-vendor",
|
"build-vendor": "shx rm -rf vendor/* && node tools/build-vendor",
|
||||||
|
|
34
tools/zip.js
34
tools/zip.js
|
@ -3,10 +3,11 @@
|
||||||
|
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const archiver = require('archiver');
|
const archiver = require('archiver');
|
||||||
|
const manifest = require('../manifest.json');
|
||||||
|
|
||||||
function createZip() {
|
function createZip({isFirefox} = {}) {
|
||||||
const fileName = 'stylus.zip';
|
const fileName = `stylus${isFirefox ? '-firefox' : ''}.zip`;
|
||||||
const exclude = [
|
const ignore = [
|
||||||
'.*', // dot files/folders (glob, not regexp)
|
'.*', // dot files/folders (glob, not regexp)
|
||||||
'vendor/codemirror/lib/**', // get unmodified copy from node_modules
|
'vendor/codemirror/lib/**', // get unmodified copy from node_modules
|
||||||
'node_modules/**',
|
'node_modules/**',
|
||||||
|
@ -38,15 +39,30 @@ function createZip() {
|
||||||
});
|
});
|
||||||
|
|
||||||
archive.pipe(file);
|
archive.pipe(file);
|
||||||
archive.glob('**', {ignore: exclude});
|
if (isFirefox) {
|
||||||
|
const name = 'manifest.json';
|
||||||
|
const keyOpt = 'optional_permissions';
|
||||||
|
ignore.unshift(name);
|
||||||
|
manifest[keyOpt] = manifest[keyOpt].filter(p => p !== 'declarativeContent');
|
||||||
|
if (!manifest[keyOpt].length) {
|
||||||
|
delete manifest[keyOpt];
|
||||||
|
}
|
||||||
|
archive.append(JSON.stringify(manifest, null, ' '), {name, stats: fs.lstatSync(name)});
|
||||||
|
}
|
||||||
|
archive.glob('**', {ignore});
|
||||||
// Don't use modified codemirror.js (see "update-libraries.js")
|
// Don't use modified codemirror.js (see "update-libraries.js")
|
||||||
archive.directory('node_modules/codemirror/lib', 'vendor/codemirror/lib');
|
archive.directory('node_modules/codemirror/lib', 'vendor/codemirror/lib');
|
||||||
archive.finalize();
|
archive.finalize();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
createZip()
|
(async () => {
|
||||||
.then(() => console.log('\x1b[32m%s\x1b[0m', 'Stylus zip complete'))
|
try {
|
||||||
.catch(err => {
|
await createZip();
|
||||||
throw err;
|
await createZip({isFirefox: true});
|
||||||
});
|
console.log('\x1b[32m%s\x1b[0m', 'Stylus zip complete');
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
Loading…
Reference in New Issue
Block a user