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:
tophf 2020-10-22 22:16:55 +03:00 committed by GitHub
parent 7f15ae324d
commit f9804036b2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 203 additions and 40 deletions

View File

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

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

View File

@ -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();
const onOff = prefs[styleInjector.list.length ? 'subscribe' : 'unsubscribe'];
onOff(['disableAll'], updateDisableAll);
if (IS_FRAME) {
updateExposeIframes(); 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);
}
} }
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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