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