├── test ├── html │ ├── import.css │ ├── scss │ │ ├── style.css │ │ ├── style.scss │ │ └── index.html │ ├── file.css │ ├── sample.html │ ├── random-stylesheet.js │ └── select-box.html ├── compact-paths.js ├── deferred.js ├── client-expect.js └── model.js ├── .gitignore ├── deprecated.png ├── icon ├── icon16.png ├── icon48.png ├── ba-active.png ├── ba-error1.png ├── ba-error2.png ├── icon128.png ├── ba-disabled.png └── ba-warning.png ├── .travis.yml ├── styles ├── assets │ ├── fontawesome.woff │ ├── ptsans-bold.woff │ ├── ptsans-regular.woff │ ├── fonts.css │ ├── button.css │ ├── global-message.css │ ├── select-box.css │ ├── toggler.css │ └── remote-view.css └── popup.css ├── scripts ├── worker.js ├── deprecated.js ├── lib │ ├── tracker.js │ ├── port-expect.js │ ├── livestyle-model.js │ ├── deferred.js │ ├── crc32.js │ ├── client-expect.js │ ├── associations.js │ ├── browser-action-icon.js │ ├── event-emitter.js │ ├── utils.js │ ├── tween.js │ └── model.js ├── helpers │ ├── compact-paths.js │ ├── get-stylesheet-content.js │ ├── origin.js │ ├── user-stylesheets.js │ └── shadow-css.js ├── devtools.js ├── error-log.js ├── controllers │ ├── error-logger.js │ ├── editor.js │ ├── browser-action-icon.js │ ├── error-tracker.js │ ├── devtools.js │ ├── remote-view.js │ └── model.js ├── ui │ ├── select-box.js │ └── remote-view.js ├── content-script.js ├── popup.js ├── devtools │ └── resources.js └── background.js ├── devtools.html ├── error-log.html ├── deprecated.html ├── package.json ├── manifest.json ├── gulpfile.js ├── popup.html └── third-party └── advisor-media.js /test/html/import.css: -------------------------------------------------------------------------------- 1 | body { 2 | background: red; 3 | } -------------------------------------------------------------------------------- /test/html/scss/style.css: -------------------------------------------------------------------------------- 1 | div { 2 | margin-top: 100px; 3 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | out 3 | livestyle-alpha.crx 4 | .DS_Store 5 | -------------------------------------------------------------------------------- /test/html/scss/style.scss: -------------------------------------------------------------------------------- 1 | $a: 100px; 2 | div { 3 | margin-top: $a; 4 | } -------------------------------------------------------------------------------- /deprecated.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/livestyle/chrome/HEAD/deprecated.png -------------------------------------------------------------------------------- /icon/icon16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/livestyle/chrome/HEAD/icon/icon16.png -------------------------------------------------------------------------------- /icon/icon48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/livestyle/chrome/HEAD/icon/icon48.png -------------------------------------------------------------------------------- /icon/ba-active.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/livestyle/chrome/HEAD/icon/ba-active.png -------------------------------------------------------------------------------- /icon/ba-error1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/livestyle/chrome/HEAD/icon/ba-error1.png -------------------------------------------------------------------------------- /icon/ba-error2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/livestyle/chrome/HEAD/icon/ba-error2.png -------------------------------------------------------------------------------- /icon/icon128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/livestyle/chrome/HEAD/icon/icon128.png -------------------------------------------------------------------------------- /icon/ba-disabled.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/livestyle/chrome/HEAD/icon/ba-disabled.png -------------------------------------------------------------------------------- /icon/ba-warning.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/livestyle/chrome/HEAD/icon/ba-warning.png -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | sudo: false 3 | node_js: 4 | - "4" 5 | - "5" 6 | - "6" 7 | -------------------------------------------------------------------------------- /styles/assets/fontawesome.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/livestyle/chrome/HEAD/styles/assets/fontawesome.woff -------------------------------------------------------------------------------- /styles/assets/ptsans-bold.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/livestyle/chrome/HEAD/styles/assets/ptsans-bold.woff -------------------------------------------------------------------------------- /scripts/worker.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import worker from 'livestyle-patcher/lib/worker'; 4 | export default worker; -------------------------------------------------------------------------------- /styles/assets/ptsans-regular.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/livestyle/chrome/HEAD/styles/assets/ptsans-regular.woff -------------------------------------------------------------------------------- /scripts/deprecated.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import trackEvent from './lib/tracker'; 4 | trackEvent('DevTools', 'open'); -------------------------------------------------------------------------------- /test/html/file.css: -------------------------------------------------------------------------------- 1 | @charset 'utf-8'; 2 | @import url(import.css); 3 | 4 | div { 5 | background: yellow; 6 | } 7 | 8 | .foo { 9 | color: red; 10 | } 11 | 12 | .bar { 13 | color: blue; 14 | } -------------------------------------------------------------------------------- /devtools.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | LiveStyle devtools page 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /test/html/scss/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | SCSS sample 6 | 7 | 8 | 9 |
A div
10 | 11 | -------------------------------------------------------------------------------- /test/html/sample.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Sample page 6 | 7 | 8 | 9 | 10 |
A div
11 |

12 | foo 13 | bar 14 | baz 15 |

16 | 17 | -------------------------------------------------------------------------------- /styles/assets/fonts.css: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: 'PT Sans'; 3 | src: url('ptsans-regular.woff') format('woff'); 4 | font-weight: normal; 5 | font-style: normal; 6 | } 7 | 8 | @font-face { 9 | font-family: 'PT Sans'; 10 | src: url('ptsans-bold.woff') format('woff'); 11 | font-weight: bold; 12 | font-style: normal; 13 | } 14 | 15 | @font-face { 16 | font-family: 'Font Awesome'; 17 | src: url('fontawesome.woff') format('woff'); 18 | font-weight: normal; 19 | font-style: normal; 20 | } -------------------------------------------------------------------------------- /scripts/lib/tracker.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | window._gaq = window._gaq || []; 4 | window._gaq.push(['_setAccount', 'UA-4523560-11']); 5 | loadTracker(); 6 | 7 | export default function(category, action, label) { 8 | window._gaq.push(['_trackEvent', category, action, label]); 9 | } 10 | 11 | function loadTracker() { 12 | var ga = document.createElement('script'); 13 | ga.async = true; 14 | ga.src = 'https://ssl.google-analytics.com/ga.js'; 15 | var s = document.getElementsByTagName('script')[0]; 16 | s.parentNode.insertBefore(ga, s); 17 | } -------------------------------------------------------------------------------- /styles/assets/button.css: -------------------------------------------------------------------------------- 1 | button { 2 | border: none; 3 | font-size: 13px; 4 | font-weight: normal; 5 | line-height: 1.2; 6 | border-radius: 4px; 7 | padding: 7px 10px; 8 | background: #344a5d; 9 | color: #fff; 10 | cursor: pointer; 11 | outline: none; 12 | 13 | -webkit-transition: background-color 0.25s linear; 14 | -moz-transition: background-color 0.25s linear; 15 | -ms-transition: background-color 0.25s linear; 16 | -o-transition: background-color 0.25s linear; 17 | transition: background-color 0.25s linear; 18 | 19 | -webkit-appearance: none; 20 | -moz-appearance: none; 21 | appearance: none; 22 | } 23 | 24 | button:hover { 25 | background-color: #415c75; 26 | } 27 | 28 | button:active { 29 | background-color: #2c3e50; 30 | } -------------------------------------------------------------------------------- /styles/assets/global-message.css: -------------------------------------------------------------------------------- 1 | .global-message { 2 | position: absolute; 3 | z-index: 30; 4 | top: 0; 5 | right: 0; 6 | bottom: 0; 7 | left: 0; 8 | background: #fff; 9 | padding: 30px; 10 | display: none; 11 | } 12 | 13 | .global-message__title { 14 | font-weight: normal; 15 | font-size: 2.5em; 16 | text-align: center; 17 | margin-top: 0; 18 | } 19 | 20 | .global-message__comment { 21 | font-size: 0.8em; 22 | color: #95a5a6; 23 | line-height: 1.7; 24 | } 25 | 26 | .global-message__comment a { 27 | color: #2a82b7; 28 | } 29 | 30 | .status__no-editor .global-message_no-editor, 31 | .status__needs-refresh .global-message_extension-update { 32 | display: block; 33 | } 34 | 35 | .status__is-chrome.status__no-devtools .global-message_chrome-protocol { 36 | display: block; 37 | } 38 | 39 | .status__is-chrome .add-file { 40 | display: none; 41 | } -------------------------------------------------------------------------------- /test/html/random-stylesheet.js: -------------------------------------------------------------------------------- 1 | /** 2 | * A small Node.js web-server for serving static files. 3 | * Outputs HTML with random stylesteet URL 4 | */ 5 | var fs = require('fs'); 6 | var path = require('path'); 7 | var http = require('http'); 8 | var connect = require('connect'); 9 | var serveStatic = require('serve-static'); 10 | 11 | var app = connect(); 12 | app.use(function(req, res, next) { 13 | req.url = req.url.replace(/^\/\-\/\w+\//, '/'); 14 | if (!/\.html?$/.test(req.url)) { 15 | return next(); 16 | } 17 | 18 | var contents = fs.readFileSync(path.join(__dirname, req.url), 'utf8'); 19 | res.end(contents.replace(/("|')([\w\/]+.css)\1/g, function(str, quote, url) { 20 | return quote + path.join('/-/' + (Math.random() * 1000 | 0), url) + quote; 21 | })); 22 | }); 23 | app.use(serveStatic('./')); 24 | 25 | console.log('Starting local web-server on http://localhost:3000'); 26 | http.createServer(app).listen(3000); -------------------------------------------------------------------------------- /scripts/lib/port-expect.js: -------------------------------------------------------------------------------- 1 | /** 2 | * A wrapper for Chrome Port messaging able to send given message and wait for 3 | * response with expected message name 4 | */ 5 | 'use strict'; 6 | 7 | export default function(port, name, data, expectResponse) { 8 | if (typeof data === 'string' && expectResponse == null) { 9 | expectResponse = data; 10 | data = null; 11 | } 12 | 13 | return new Promise(function(resolve, reject) { 14 | var isResponded = false; 15 | var handleResponse = function(message) { 16 | if (message && message.name === expectResponse) { 17 | resolve(message.data); 18 | port.onMessage.removeListener(handleResponse); 19 | } 20 | }; 21 | 22 | // in case of any error in DevTools page, respond after some time 23 | setTimeout(() => { 24 | var err = new Error(`Expectation timeout: did not received "${expectResponse}" response`); 25 | }, 3000); 26 | 27 | port.onMessage.addListener(handleResponse); 28 | port.postMessage({name, data}); 29 | }); 30 | }; -------------------------------------------------------------------------------- /error-log.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | LiveStyle log 6 | 49 | 50 | 51 |

LiveStyle log

52 | 53 | 54 | 55 | -------------------------------------------------------------------------------- /deprecated.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | LiveStyle DevTools panel (deprecated) 6 | 28 | 29 | 30 | 31 |
32 |

Welcome to all-new Emmet LiveStyle!

33 |

LiveStyle is finally reached v1.0. It no longer requires opened DevTools to work and its UI is moved to browser toolbar.

34 |

Check out all exiting features, including LESS and SCSS support, at http://livestyle.io.

35 |
36 | 37 | 38 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "livestyle-chrome", 3 | "version": "1.0.8", 4 | "description": "A Google Chrome extension for LiveStyle", 5 | "main": "index.js", 6 | "directories": { 7 | "test": "test" 8 | }, 9 | "scripts": { 10 | "test": "mocha", 11 | "pack": "gulp pack --production" 12 | }, 13 | "repository": { 14 | "type": "git", 15 | "url": "git://github.com/livestyle/chrome.git" 16 | }, 17 | "author": "Sergey Chikuyonok ", 18 | "license": "MIT", 19 | "bugs": { 20 | "url": "https://github.com/livestyle/chrome/issues" 21 | }, 22 | "homepage": "https://github.com/livestyle/chrome", 23 | "devDependencies": { 24 | "babel": "^4.7.16", 25 | "connect": "^3.3.5", 26 | "gulp": "^3.9.0", 27 | "gulp-zip": "^3.0.2", 28 | "js-bundler": "github:sergeche/js-bundler.git#v1.1.0", 29 | "mocha": "^3.0.0", 30 | "node-notifier": "^4.1.2", 31 | "serve-static": "^1.9.2", 32 | "through2": "^2.0.0" 33 | }, 34 | "dependencies": { 35 | "livestyle-client": "livestyle/client", 36 | "livestyle-cssom-patcher": "livestyle/cssom-patcher", 37 | "livestyle-patcher": "livestyle/patcher#v1.0.1" 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /test/compact-paths.js: -------------------------------------------------------------------------------- 1 | var assert = require('assert'); 2 | require('babel/register'); 3 | var compactPaths = require('../scripts/helpers/compact-paths'); 4 | 5 | describe('Compact paths', function() { 6 | function pluck(items, key) { 7 | return items.map(function(item) { 8 | return item[key]; 9 | }); 10 | } 11 | 12 | function process(paths) { 13 | return pluck(compactPaths(paths), 'label'); 14 | } 15 | 16 | it('keep names', function() { 17 | assert.deepEqual( 18 | process(['/path/to/file1.css', 'path/to/file2.css', 'file3.css']), 19 | ['file1.css', 'file2.css', 'file3.css'] 20 | ); 21 | }); 22 | 23 | it('keep partial names', function() { 24 | assert.deepEqual( 25 | process(['/path/to1/file.css', 'path/to2/file.css', 'file3.css']), 26 | ['to1/file.css', 'to2/file.css', 'file3.css'] 27 | ); 28 | }); 29 | 30 | it('keep full names', function() { 31 | assert.deepEqual( 32 | process(['/path1/to/file.css', 'path2/to/file.css', 'file3.css']), 33 | ['/path1/to/file.css', 'path2/to/file.css', 'file3.css'] 34 | ); 35 | }); 36 | 37 | it('Windows path separator', function() { 38 | assert.deepEqual( 39 | process(['C:\\path\\to\\file1.css', 'path/to/file2.css', 'file3.css']), 40 | ['file1.css', 'file2.css', 'file3.css'] 41 | ); 42 | }); 43 | }); -------------------------------------------------------------------------------- /scripts/helpers/compact-paths.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Compacts given list of paths: keeps smallest right-hand 3 | * difference between paths 4 | */ 5 | 'use strict'; 6 | 7 | import {unique} from '../lib/utils'; 8 | 9 | export default function(list) { 10 | var data = unique(list).map(function(path) { 11 | return { 12 | parts: path.split(/\/|\\/).filter(Boolean), 13 | rightParts: [], 14 | path: path 15 | }; 16 | }); 17 | 18 | var lookup = {}; 19 | var hasCollision = true, hasNext = true; 20 | var process = function(item) { 21 | if (item.parts.length) { 22 | item.rightParts.unshift(item.parts.pop()); 23 | var lookupKey = item.rightParts.join('/'); 24 | if (!lookup[lookupKey]) { 25 | lookup[lookupKey] = true; 26 | } else { 27 | hasCollision = true; 28 | } 29 | } 30 | return !!item.parts.length; 31 | }; 32 | 33 | while (hasNext) { 34 | hasNext = false; 35 | hasCollision = false; 36 | lookup = {}; 37 | for (var i = 0, il = data.length; i < il; i++) { 38 | hasNext = process(data[i]) || hasNext; 39 | } 40 | 41 | if (!hasCollision) { 42 | break; 43 | } 44 | } 45 | 46 | return data.map(function(item) { 47 | return { 48 | label: item.parts.length ? item.rightParts.join('/') : item.path, 49 | value: item.path 50 | }; 51 | }); 52 | }; -------------------------------------------------------------------------------- /test/html/select-box.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Select box test 6 | 7 | 8 | 22 | 23 | 24 |
25 |

Regular box

26 | 32 |
33 | 34 |
35 |

Offscreen dropdown box

36 | 42 |
43 | 44 | 47 | 48 | -------------------------------------------------------------------------------- /scripts/helpers/get-stylesheet-content.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Fetches content of given stylesheet URL by any possible way: either from 3 | * DevTools resource (faster, contains most recent version) or via XHR 4 | */ 5 | 'use strict'; 6 | import * as devtools from '../controllers/devtools'; 7 | 8 | export default function(url, tabId, callback) { 9 | if (typeof tabId === 'function') { 10 | callback = tabId; 11 | tabId = null; 12 | } 13 | 14 | var p; 15 | if (tabId && devtools.isOpenedForTab(tabId)) { 16 | p = devtools.stylesheetContent(tabId, url); 17 | } else { 18 | p = load(url); 19 | } 20 | 21 | p.then(callback, err => { 22 | console.error('Error fetching %s stylesheet content', url, err); 23 | callback(null); 24 | }); 25 | }; 26 | 27 | function load(url) { 28 | // no `fetch` here since it doesn’t support 'file:' protocol 29 | return new Promise(function(resolve, reject) { 30 | var xhr = new XMLHttpRequest(); 31 | xhr.onreadystatechange = function() { 32 | if (xhr.readyState === 4) { 33 | if (xhr.status < 300) { 34 | resolve(xhr.responseText); 35 | } else { 36 | var err = new Error(`Unable to fetch ${url}: received ${xhr.status} code`); 37 | err.code = xhr.status; 38 | reject(new Error(err)); 39 | } 40 | } 41 | }; 42 | xhr.open('GET', url, true); 43 | xhr.send(); 44 | }); 45 | } 46 | 47 | -------------------------------------------------------------------------------- /scripts/helpers/origin.js: -------------------------------------------------------------------------------- 1 | /** 2 | * A content script for extracting page’s URL origin. Mostly used for getting 3 | * origin of documents with `file:` protocol for Remote View. 4 | * By default it’s a filesystem root, so if RV will open a public HTTP server 5 | * pointing filesystem root, it’s gonna be a huge security breach. This module 6 | * will try to find a largest common dir prefix for resources from current 7 | * page. 8 | */ 9 | 'use strict'; 10 | 11 | const reIsFile = /^file:/; 12 | 13 | export default function() { 14 | var origin = location.origin; 15 | if (/^https?:/.test(origin)) { 16 | return origin; 17 | } 18 | 19 | if (reIsFile.test(origin)) { 20 | return findFileOrigin(); 21 | } 22 | 23 | return null; 24 | }; 25 | 26 | function $$(sel, context) { 27 | var items = (context || document).querySelectorAll(sel); 28 | return Array.prototype.slice.call(items, 0); 29 | } 30 | 31 | function findFileOrigin() { 32 | return $$('link, img, a, video, audio, script, iframe').concat([location]) 33 | .map(elem => elem.currentSrc || elem.src || elem.href) 34 | .filter(url => url && reIsFile.test(url)) 35 | .map(url => { 36 | // remove file from url and normalize it 37 | var parts = url.replace(/^file:\/\//, '').split('/'); 38 | if (/\.[\w-]+$/.test(parts[parts.length - 1] || '')) { 39 | parts.pop(); 40 | } 41 | return 'file://' + parts.join('/').replace(/\/+$/, ''); 42 | }) 43 | .reduce((prev, cur) => cur.length < prev.length ? cur : prev); 44 | } -------------------------------------------------------------------------------- /scripts/devtools.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import * as resources from './devtools/resources'; 4 | 5 | var port = chrome.runtime.connect({ 6 | name: 'devtools-page:' + chrome.devtools.inspectedWindow.tabId 7 | }); 8 | 9 | function send(name, data) { 10 | port.postMessage({ 11 | name: name, 12 | data: data 13 | }); 14 | } 15 | 16 | function log() { 17 | send('log', Array.prototype.slice.call(arguments, 0)); 18 | } 19 | 20 | port.onMessage.addListener(function(message) { 21 | log('Received message', message); 22 | switch (message.name) { 23 | case 'diff': 24 | resources.get(message.data.uri, function(res) { 25 | res && res.patch(message.data.patches); 26 | }); 27 | break; 28 | case 'pending-patches': 29 | resources.applyPendingPatches(message.data); 30 | break; 31 | case 'get-stylesheets': 32 | resources.list(function(urls) { 33 | send('stylesheets', urls.filter(Boolean)); 34 | }); 35 | break; 36 | case 'get-stylesheet-content': 37 | resources.get(message.data.url, function(res) { 38 | send('stylesheet-content', { 39 | content: res ? res.content : null 40 | }); 41 | }); 42 | break; 43 | case 'reset': 44 | resources.reset(); 45 | break; 46 | } 47 | }); 48 | 49 | resources 50 | .on('log', strings => log(...strings)) 51 | .on('update', (url, content) => send('resource-updated', {url, content})); 52 | 53 | chrome.devtools.panels.create('LiveStyle', 'icon/icon48.png', 'deprecated.html'); 54 | 55 | log('Connected'); -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Emmet LiveStyle", 3 | "description": "LiveStyle — the first bi-directional real-time edit tool for CSS, LESS and SCSS.", 4 | "short_name": "LiveStyle", 5 | "version": "1.0.0", 6 | "permissions": [ 7 | "tabs", 8 | "storage", 9 | "identity", 10 | "webRequest", 11 | "http://*/*", 12 | "https://*/*" 13 | ], 14 | "icons": { 15 | "16": "icon/icon16.png", 16 | "48": "icon/icon48.png", 17 | "128": "icon/icon128.png" 18 | }, 19 | "background": { 20 | "scripts": ["scripts/background.js", "third-party/advisor-media.js"] 21 | }, 22 | "content_scripts": [{ 23 | "matches": ["http://*/*", "https://*/*", "file:///*"], 24 | "js": ["scripts/content-script.js"], 25 | "run_at": "document_start" 26 | }], 27 | "content_security_policy": "script-src 'self' 'unsafe-eval' https://ssl.google-analytics.com https://www.google.com; object-src 'self'", 28 | "devtools_page": "./devtools.html", 29 | "browser_action": { 30 | "default_title": "LiveStyle Control Panel", 31 | "default_popup": "popup.html", 32 | "default_icon": "icon/ba-disabled.png" 33 | }, 34 | "key": "MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDcrTJSUd51qTt4JPhpve5/bMwmJ5SFhUU2blEF9tUdt7TNCS/Y2oFz5yGWWXgLnLQ5822/+PkiSOwoG1QoLjaRz+TBIup+vctPuEsYk3+H1bGhdULjxhrsczKjYA2KPMX0ll/ncR1C3lalRx8PMwYW68bkXH/Z9USp5ITlBjoJ6QIDAQAB", 35 | "oauth2": { 36 | "client_id": "429076250235-ik9plskehth6ihmslgnl5a83do6e9tm9.apps.googleusercontent.com", 37 | "scopes": ["email"] 38 | }, 39 | "manifest_version": 2 40 | } 41 | -------------------------------------------------------------------------------- /scripts/lib/livestyle-model.js: -------------------------------------------------------------------------------- 1 | /** 2 | * LiveStyle model: responsible for storing info about 3 | * LiveStyle state for context page 4 | */ 5 | 'use strict'; 6 | 7 | import Model from './model'; 8 | import EventEmitter from './event-emitter'; 9 | import associations from './associations'; 10 | 11 | var emitter = new EventEmitter(); 12 | 13 | export default class LiveStyleModel extends Model { 14 | constructor(id) { 15 | super(); 16 | this.id = id; 17 | this.lastUpdate = Date.now(); 18 | this 19 | .on('change:browserFiles change:editorFiles change:assocs change:userStylesheets', function() { 20 | this.emit('update'); 21 | }) 22 | .on('all', function() { 23 | // pass all inner model events to the global dispatcher 24 | LiveStyleModel.emit.apply(LiveStyleModel, arguments); 25 | }); 26 | } 27 | 28 | /** 29 | * Returns virtual file associations. Unlike “real“ associations, 30 | * where user explicitly pick files, virtual ones contains guessed 31 | * associations for files user didn’t picked yet 32 | * @return {Object} 33 | */ 34 | associations() { 35 | var browserFiles = this.get('browserFiles') || []; 36 | var userStylesheets = Object.keys(this.get('userStylesheets') || {}); 37 | return associations( 38 | browserFiles.concat(userStylesheets), 39 | this.get('editorFiles'), 40 | this.get('assocs') 41 | ); 42 | } 43 | } 44 | 45 | LiveStyleModel.on = emitter.on.bind(emitter); 46 | LiveStyleModel.off = emitter.off.bind(emitter); 47 | LiveStyleModel.emit = emitter.emit.bind(emitter); -------------------------------------------------------------------------------- /scripts/lib/deferred.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var STATE_PENDING = 'pending'; 4 | var STATE_FULFILLED = 'fulfilled'; 5 | var STATE_REJECTED = 'rejected'; 6 | 7 | import {toArray} from './utils'; 8 | 9 | function fulfill(listeners, args) { 10 | listeners.forEach(function(fn) { 11 | fn.apply(null, args); 12 | }); 13 | } 14 | 15 | function isFn(obj) { 16 | return typeof obj === 'function'; 17 | } 18 | 19 | export default function Deferred(fn) { 20 | if (!(this instanceof Deferred)) { 21 | return new Deferred(fn); 22 | } 23 | 24 | var state = STATE_PENDING; 25 | var value = void 0; 26 | var fulfilled = []; 27 | var rejected = []; 28 | var self = this; 29 | 30 | var respond = function(callbacks) { 31 | fulfill(callbacks, value); 32 | fulfilled.length = rejected.length = 0; 33 | }; 34 | 35 | var changeState = function(newState, callbacks) { 36 | return function() { 37 | if (state === STATE_PENDING) { 38 | state = newState; 39 | value = toArray(arguments); 40 | respond(callbacks); 41 | } 42 | return self; 43 | }; 44 | }; 45 | 46 | this.resolve = changeState(STATE_FULFILLED, fulfilled); 47 | this.reject = changeState(STATE_REJECTED, rejected); 48 | this.then = function(onFulfilled, onRejected) { 49 | isFn(onFulfilled) && fulfilled.push(onFulfilled); 50 | isFn(onRejected) && rejected.push(onRejected); 51 | if (state === STATE_FULFILLED) { 52 | respond(fulfilled); 53 | } else if (state === STATE_REJECTED) { 54 | respond(rejected); 55 | } 56 | 57 | return this; 58 | }; 59 | 60 | Object.defineProperty(this, 'state', { 61 | enumerable: true, 62 | get: function() { 63 | return state; 64 | } 65 | }); 66 | 67 | if (isFn(fn)) { 68 | fn.call(this); 69 | } 70 | } -------------------------------------------------------------------------------- /test/deferred.js: -------------------------------------------------------------------------------- 1 | var assert = require('assert'); 2 | require('babel/register'); 3 | var deferred = require('../scripts/lib/deferred'); 4 | 5 | describe('Deferred', function() { 6 | it('resolve', function() { 7 | var resolved = 0, rejected = 0; 8 | var d = deferred() 9 | .then(function() {resolved++;}, function() {rejected++;}) 10 | .resolve() 11 | .then(function() {resolved++;}); 12 | 13 | assert.equal(resolved, 2); 14 | assert.equal(rejected, 0); 15 | assert.equal(d.state, 'fulfilled'); 16 | }); 17 | 18 | it('reject', function() { 19 | var resolved = 0, rejected = 0; 20 | var d = deferred() 21 | .then(function() {resolved++;}, function() {rejected++;}) 22 | .reject() 23 | .then(null, function() {rejected++;}); 24 | 25 | assert.equal(resolved, 0); 26 | assert.equal(rejected, 2); 27 | assert.equal(d.state, 'rejected'); 28 | }); 29 | 30 | it('preserve state', function() { 31 | var resolved = 0, rejected = 0; 32 | var d = deferred() 33 | .resolve() 34 | .reject() 35 | .then(function() {resolved++;}, function() {rejected++;}); 36 | 37 | assert.equal(resolved, 1); 38 | assert.equal(rejected, 0); 39 | assert.equal(d.state, 'fulfilled'); 40 | }); 41 | 42 | it('default handler', function() { 43 | var d = deferred(function() { 44 | this.resolve(); 45 | }); 46 | assert.equal(d.state, 'fulfilled'); 47 | }); 48 | 49 | it('arguments passing', function() { 50 | var result = ''; 51 | var d = deferred() 52 | .then(function(a, b) {result = a + ':' + b;}, function(a, b) {result = b + ':' + a;}) 53 | .resolve('foo', 'bar'); 54 | 55 | assert.equal(result, 'foo:bar'); 56 | }); 57 | 58 | it('async', function(done) { 59 | var d = deferred().then(function() { 60 | assert('ok'); 61 | done(); 62 | }); 63 | 64 | setTimeout(d.resolve, 10); 65 | }); 66 | }); -------------------------------------------------------------------------------- /scripts/lib/crc32.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Fast CRC32 algorithm for strings. 3 | * Original source: https://github.com/SheetJS/js-crc32 4 | * © 2014 SheetJS — http://sheetjs.com 5 | */ 6 | 'use strict'; 7 | 8 | var table = new Array(256); 9 | for (var n = 0, c; n != 256; ++n) { 10 | c = n; 11 | c = ((c&1) ? (-306674912 ^ (c >>> 1)) : (c >>> 1)); 12 | c = ((c&1) ? (-306674912 ^ (c >>> 1)) : (c >>> 1)); 13 | c = ((c&1) ? (-306674912 ^ (c >>> 1)) : (c >>> 1)); 14 | c = ((c&1) ? (-306674912 ^ (c >>> 1)) : (c >>> 1)); 15 | c = ((c&1) ? (-306674912 ^ (c >>> 1)) : (c >>> 1)); 16 | c = ((c&1) ? (-306674912 ^ (c >>> 1)) : (c >>> 1)); 17 | c = ((c&1) ? (-306674912 ^ (c >>> 1)) : (c >>> 1)); 18 | c = ((c&1) ? (-306674912 ^ (c >>> 1)) : (c >>> 1)); 19 | table[n] = c; 20 | } 21 | 22 | if (typeof Int32Array !== 'undefined') { 23 | table = new Int32Array(table); 24 | } 25 | 26 | export default function(str) { 27 | for (var crc = -1, i = 0, L=str.length, c, d; i < L;) { 28 | c = str.charCodeAt(i++); 29 | if (c < 0x80) { 30 | crc = (crc >>> 8) ^ table[(crc ^ c) & 0xFF]; 31 | } else if (c < 0x800) { 32 | crc = (crc >>> 8) ^ table[(crc ^ (192|((c>>6)&31))) & 0xFF]; 33 | crc = (crc >>> 8) ^ table[(crc ^ (128|(c&63))) & 0xFF]; 34 | } else if (c >= 0xD800 && c < 0xE000) { 35 | c = (c&1023)+64; d = str.charCodeAt(i++) & 1023; 36 | crc = (crc >>> 8) ^ table[(crc ^ (240|((c>>8)&7))) & 0xFF]; 37 | crc = (crc >>> 8) ^ table[(crc ^ (128|((c>>2)&63))) & 0xFF]; 38 | crc = (crc >>> 8) ^ table[(crc ^ (128|((d>>6)&15)|(c&3))) & 0xFF]; 39 | crc = (crc >>> 8) ^ table[(crc ^ (128|(d&63))) & 0xFF]; 40 | } else { 41 | crc = (crc >>> 8) ^ table[(crc ^ (224|((c>>12)&15))) & 0xFF]; 42 | crc = (crc >>> 8) ^ table[(crc ^ (128|((c>>6)&63))) & 0xFF]; 43 | crc = (crc >>> 8) ^ table[(crc ^ (128|(c&63))) & 0xFF]; 44 | } 45 | } 46 | return crc ^ -1; 47 | }; -------------------------------------------------------------------------------- /scripts/lib/client-expect.js: -------------------------------------------------------------------------------- 1 | /** 2 | * A wrapper around LiveStyle client able to send messages and wait for expected 3 | * response 4 | */ 5 | 'use strict'; 6 | 7 | import client from 'livestyle-client'; 8 | 9 | export function send(name, data) { 10 | var messageSent = false; 11 | setTimeout(function() { 12 | client.send(name, data); 13 | messageSent = true; 14 | }, 0); 15 | 16 | return { 17 | expect(expectedMessageName, validate, timeout=1000) { 18 | if (messageSent) { 19 | var err = new Error(`Message "${expectedMessageName}" already sent`); 20 | err.code = 'EMESSAGESENT'; 21 | return Promise.reject(err); 22 | } 23 | 24 | if (typeof validate === 'number') { 25 | timeout = validate; 26 | validate = null; 27 | } 28 | 29 | return new Promise(function(resolve, reject) { 30 | var cancelId = setTimeout(function() { 31 | client.off('message-receive', callback); 32 | var err = new Error(`Expected message "${expectedMessageName}" timed out`); 33 | err.code = 'EEXPECTTIMEOUT'; 34 | err.messageName = expectedMessageName; 35 | reject(err); 36 | }, timeout); 37 | 38 | var callback = function(name, data) { 39 | if (name === expectedMessageName) { 40 | var isValid = true; 41 | if (validate) { 42 | try { 43 | isValid = validate(data); 44 | } catch (e) { 45 | isValid = false; 46 | } 47 | } 48 | 49 | if (isValid) { 50 | client.off('message-receive', callback); 51 | clearTimeout(cancelId); 52 | resolve(data); 53 | } 54 | } 55 | }; 56 | 57 | client.on('message-receive', callback); 58 | }); 59 | } 60 | }; 61 | } 62 | 63 | export function on() { 64 | client.on.apply(client, arguments); 65 | } 66 | 67 | export function off() { 68 | client.off.apply(client, arguments); 69 | } 70 | 71 | export function emit() { 72 | client.emit.apply(client, arguments); 73 | } -------------------------------------------------------------------------------- /scripts/error-log.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | function padNum(num) { 4 | return (num < 10 ? '0' : '') + num; 5 | } 6 | 7 | function toDOM(html) { 8 | var div = document.createElement('div'); 9 | div.innerHTML = html; 10 | var df = document.createDocumentFragment(); 11 | while (div.firstChild) { 12 | df.appendChild(div.firstChild); 13 | } 14 | return df; 15 | } 16 | 17 | function renderLogItem(item) { 18 | var date = new Date(item.date); 19 | var time = padNum(date.getHours()) + ':' + padNum(date.getMinutes()); 20 | 21 | return toDOM('
  • ' 22 | + '[' + time + '] ' 23 | + item.message.replace(/\t/g, ' ') 24 | + '
  • ' 25 | ); 26 | } 27 | 28 | function updateLog(items) { 29 | // show log items in reverse order, e.g. newer on top 30 | items = items.reverse(); 31 | 32 | var container = document.querySelector('.log'); 33 | var currentItems = container.querySelectorAll('.log__item'); 34 | var lookup = {}; 35 | for (var i = 0, il = currentItems.length; i < il; i++) { 36 | lookup[currentItems[i].getAttribute('id')] = currentItems[i]; 37 | } 38 | 39 | var df = document.createDocumentFragment(); 40 | items.forEach(function(item) { 41 | var itemId = item.messageId + ''; 42 | if (lookup[itemId]) { 43 | df.appendChild(lookup[itemId]); 44 | delete lookup[itemId]; 45 | } else { 46 | df.appendChild(renderLogItem(item)); 47 | } 48 | }); 49 | 50 | // Remove old messages 51 | Object.keys(lookup).forEach(function(id) { 52 | container.removeChild(lookup[id]); 53 | }); 54 | 55 | // Insert current messages 56 | container.appendChild(df); 57 | } 58 | 59 | // Listen to log updates 60 | chrome.runtime.onMessage.addListener(function(message) { 61 | if (message.name === 'log-updated') { 62 | updateLog(message.data); 63 | } 64 | }); 65 | 66 | // Request current log 67 | chrome.runtime.sendMessage({name: 'get-log'}, updateLog); -------------------------------------------------------------------------------- /scripts/controllers/error-logger.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Logs all errors in LiveStyle worker. Unlike error tracker, which 3 | * simply notifies user about possible errors, this method actually 4 | * logs error messages and displays them upon request 5 | */ 6 | 'use strict'; 7 | 8 | var logItems = []; 9 | var maxLogItems = 50; 10 | var messageId = 0; 11 | 12 | /** 13 | * Watches for errors on given LiveStyle patcher instance 14 | * @param {CommandQueue} patcher LiveStyle patcher 15 | */ 16 | export function watch(patcher) { 17 | patcher.worker.addEventListener('message', handleWorkerEvent); 18 | } 19 | 20 | /** 21 | * Stops watching for errors on given LiveStyle patcher instance 22 | * @param {CommandQueue} patcher LiveStyle patcher 23 | */ 24 | export function unwatch(patcher) { 25 | patcher.worker.removeEventListener('message', handleWorkerEvent); 26 | } 27 | 28 | /** 29 | * Returns currently logged items 30 | * @return {Array} Array of log items 31 | */ 32 | export function getLog() { 33 | return logItems; 34 | } 35 | 36 | function logMessage(message, type) { 37 | // Remove items with the same message 38 | for (var i = logItems.length - 1; i >= 0; i--) { 39 | if (logItems[i].message == message) { 40 | logItems.splice(i, 1); 41 | } 42 | } 43 | 44 | logItems.push({ 45 | messageId: messageId++, 46 | date: Date.now(), 47 | message: message, 48 | type: type 49 | }); 50 | 51 | messageId %= 10000; 52 | 53 | while (logItems.length > maxLogItems) { 54 | logItems.shift(); 55 | } 56 | 57 | chrome.runtime.sendMessage({ 58 | name: 'log-updated', 59 | data: logItems 60 | }); 61 | } 62 | 63 | function handleWorkerEvent(message) { 64 | var payload = message.data; 65 | if (payload.status === 'error') { 66 | logMessage(payload.data, 'error'); 67 | } 68 | } 69 | 70 | // handle internal extension communication 71 | chrome.runtime.onMessage.addListener(function(message, sender, callback) { 72 | if (message.name === 'get-log') { 73 | callback(logItems); 74 | return true; 75 | } 76 | }); -------------------------------------------------------------------------------- /scripts/helpers/user-stylesheets.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Manages user stylesheets life cycle. 3 | * A user stylesheet is a stylesheet created by LiveStyle 4 | * on current page specifically for live updates: 5 | * it is added below page stylesheets (hence has higher 6 | * priority), it’s small and fast: a good alternative 7 | * for very large page stylesheets where each update 8 | * could take some time 9 | */ 10 | 'use strict'; 11 | 12 | var reUser = /^livestyle:([0-9]+)$/; 13 | 14 | /** 15 | * Creates user stylsheets for given IDs and 16 | * returns hash where key is given ID and value 17 | * is generated Blob URL 18 | * @param {Array} urls Array of interlat LiveStyle IDs 19 | * @param {Function} callback Invoked with hash result 20 | */ 21 | export function create(tabId, url, callback) { 22 | if (!url || !url.length) { 23 | return callback({}); 24 | } 25 | 26 | chrome.tabs.sendMessage(tabId, { 27 | name: 'create-user-stylesheet', 28 | data: {url: url} 29 | }, callback); 30 | } 31 | 32 | /** 33 | * Removes stylesheet with given URL (blob or internal LiveStyle ID) 34 | * @param {String} url Stylesteet URL 35 | */ 36 | export function remove(tabId, url) { 37 | chrome.tabs.sendMessage(tabId, { 38 | name: 'remove-user-stylesheet', 39 | data: {url: url} 40 | }); 41 | } 42 | 43 | /** 44 | * Validates given list of interla URLs: creates missing 45 | * and removes redundant stylesheets 46 | * @param {String} url Internal URL or array of URLs 47 | * @param {Function} callback Callback function receives hash 48 | * where key is given URL and value is generated blob URL 49 | */ 50 | export function validate(tabId, url, callback) { 51 | if (!url || !url.length) { 52 | return callback({}); 53 | } 54 | 55 | chrome.tabs.sendMessage(tabId, { 56 | name: 'validate-user-stylesheet', 57 | data: {url: url} 58 | }, callback); 59 | } 60 | 61 | /** 62 | * Check if given URL is user stylesheet file 63 | * @param {String} url 64 | * @return {Boolean} 65 | */ 66 | export function is(url) { 67 | var m = url.match(reUser); 68 | return m && m[1]; 69 | } 70 | -------------------------------------------------------------------------------- /styles/assets/select-box.css: -------------------------------------------------------------------------------- 1 | .select-box { 2 | position: relative; 3 | display: block; 4 | font-size: 14px; 5 | font-weight: normal; 6 | line-height: 1.4; 7 | cursor: pointer; 8 | background: #26bb9d; 9 | border-radius: 3px; 10 | 11 | -webkit-transition: background-color 0.5s; 12 | -moz-transition: background-color 0.5s; 13 | -ms-transition: background-color 0.5s; 14 | -o-transition: background-color 0.5s; 15 | transition: background-color 0.5s; 16 | } 17 | 18 | .select-box:hover { 19 | background-color: #48c9b0; 20 | } 21 | 22 | .select-box__label { 23 | padding: 7px 30px 7px 10px; 24 | color: #fff; 25 | display: block; 26 | } 27 | 28 | .select-box:after { 29 | position: absolute; 30 | top: 42%; 31 | right: 13px; 32 | display: inline-block; 33 | border-color: transparent; 34 | border-top-color: #fff; 35 | border-style: solid; 36 | border-width: 6px 4px; 37 | pointer-events: none; 38 | content: ''; 39 | } 40 | 41 | .select-box_active > .select-box__picker { 42 | display: block; 43 | } 44 | 45 | .select-box__picker { 46 | display: none; 47 | position: absolute; 48 | list-style-type: none; 49 | z-index: 10; 50 | min-width: 220px; 51 | max-height: 165px; 52 | min-height: 37px; 53 | overflow: auto; 54 | width: 100%; 55 | left: 0; 56 | top: 100%; 57 | padding: 0; 58 | margin-top: 9px; 59 | font-size: 14px; 60 | background-color: #f3f4f5; 61 | color: #34495e; 62 | border-radius: 4px; 63 | } 64 | 65 | .select-box__picker_attop { 66 | margin-top: 0; 67 | margin-bottom: 9px; 68 | top: auto; 69 | bottom: 100%; 70 | } 71 | 72 | .select-box__picker-item { 73 | padding: 8px 10px; 74 | line-height: 1.5; 75 | } 76 | 77 | .select-box__picker-item:first-child { 78 | border-top-left-radius: 4px; 79 | border-top-right-radius: 4px; 80 | } 81 | 82 | .select-box__picker-item:last-child { 83 | border-bottom-left-radius: 4px; 84 | border-bottom-right-radius: 4px; 85 | } 86 | 87 | .select-box__picker-item:hover { 88 | background: #e1e4e7; 89 | } 90 | 91 | .select-box__picker-item_selected, 92 | .select-box__picker-item_selected:hover { 93 | background: #95a5a6; 94 | color: #fff; 95 | } -------------------------------------------------------------------------------- /gulpfile.js: -------------------------------------------------------------------------------- 1 | var path = require('path'); 2 | var gulp = require('gulp'); 3 | var zip = require('gulp-zip'); 4 | var js = require('js-bundler'); 5 | var notifier = require('node-notifier'); 6 | var through = require('through2'); 7 | var pkg = require('./package.json'); 8 | 9 | var production = process.argv.indexOf('--production') !== -1; 10 | var dest = './out'; 11 | var src = { 12 | js: './scripts/*.js', 13 | assets: ['./{icon,styles}/**', './*.{html,png}', './manifest.json', './third-party/advisor-media.js'], 14 | options: {base: './'} 15 | }; 16 | 17 | function cleanup() { 18 | return through.obj(function(file, enc, next) { 19 | var str = file.contents.toString(); 20 | if (str.indexOf(__dirname)) { 21 | file.contents = new Buffer(str.replace(__dirname, '')); 22 | } 23 | next(null, file); 24 | }); 25 | } 26 | 27 | function np(lib) { 28 | return path.join(__dirname, 'node_modules', lib); 29 | } 30 | 31 | gulp.task('js', function() { 32 | return gulp.src(src.js, src.options) 33 | .pipe(js({ 34 | standalone: true, 35 | sourceMap: !production, 36 | noParse: [np('livestyle-cssom-patcher/out/livestyle-cssom.js')], 37 | detectGlobals: false 38 | })) 39 | .pipe(cleanup()) 40 | .pipe(gulp.dest(dest)) 41 | }); 42 | 43 | gulp.task('assets', function() { 44 | return gulp.src(src.assets, src.options) 45 | .pipe(through.obj(function(file, enc, next) { 46 | if (path.basename(file.path) === 'manifest.json') { 47 | var data = JSON.parse(file.contents.toString()); 48 | data.version = pkg.version; 49 | file.contents = new Buffer(JSON.stringify(data), null, '\t'); 50 | } 51 | next(null, file); 52 | })) 53 | .pipe(gulp.dest(dest)); 54 | }); 55 | 56 | gulp.task('pack', ['build'], function() { 57 | return gulp.src(['**', '!*.zip'], {cwd: dest}) 58 | .pipe(zip('livestyle.zip')) 59 | .pipe(gulp.dest(dest)); 60 | }); 61 | 62 | gulp.task('watch', ['build'], function() { 63 | js.watch({sourceMap: true, uglify: false}); 64 | gulp.watch('./scripts/**/*.js', ['js']); 65 | gulp.watch(src.assets, ['assets']); 66 | }); 67 | 68 | gulp.task('build', ['js', 'assets']); 69 | gulp.task('default', ['build']); 70 | -------------------------------------------------------------------------------- /scripts/controllers/editor.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Editor files controller: provides model with 3 | * available files from all connected editors. This model 4 | * is updated whenever user connects new editor or opens/closes 5 | * stylesheet files 6 | */ 7 | 'use strict'; 8 | 9 | import Model from '../lib/model'; 10 | import {unique} from '../lib/utils'; 11 | 12 | // The `active` key tells if there are any connected editor 13 | var editorFiles = new Model(); 14 | var connectedEditors = {}; 15 | 16 | export default editorFiles; 17 | 18 | /** 19 | * Sync all connect editor files with underlying 20 | * editor files model 21 | */ 22 | function sync() { 23 | var allFiles = []; 24 | var ids = Object.keys(connectedEditors); 25 | ids.forEach(function(id) { 26 | allFiles = allFiles.concat(connectedEditors[id] || []); 27 | }); 28 | 29 | allFiles = unique(allFiles); 30 | editorFiles.set('files', allFiles); 31 | editorFiles.set('active', ids.length > 0); 32 | return allFiles; 33 | } 34 | 35 | function onFileListReceived(payload) { 36 | connectedEditors[payload.id] = payload.files || []; 37 | sync(); 38 | } 39 | 40 | function onEditorDisconnect(payload) { 41 | if (payload.id in connectedEditors) { 42 | delete connectedEditors[payload.id]; 43 | sync(); 44 | } 45 | } 46 | 47 | function onConnectionClosed() { 48 | connectedEditors = {}; 49 | sync(); 50 | } 51 | 52 | /** 53 | * Connects model with given LiveStyle client: 54 | * model now tracks all editor file-related changes and 55 | * notifies all listener on update 56 | * @param {LiveStyleClient} client 57 | */ 58 | editorFiles.connect = function(client) { 59 | client 60 | .on('editor-files', onFileListReceived) 61 | .on('editor-disconnect', onEditorDisconnect) 62 | .on('close', onConnectionClosed); 63 | }; 64 | 65 | /** 66 | * Disconnects model from given client: it no longer 67 | * listens to editor files update 68 | * @param {LiveStyleClient} client 69 | */ 70 | editorFiles.disconnect = function(client) { 71 | client 72 | .off('editor-files', onFileListReceived) 73 | .off('editor-disconnect', onEditorDisconnect) 74 | .off('close', onConnectionClosed); 75 | }; 76 | 77 | sync(); -------------------------------------------------------------------------------- /scripts/controllers/browser-action-icon.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Controls browser action icon state depending on 3 | * user activity with tabs 4 | */ 5 | 'use strict'; 6 | 7 | import * as modelController from './model'; 8 | import * as icon from '../lib/browser-action-icon'; 9 | import LiveStyleModel from '../lib/livestyle-model'; 10 | 11 | export function watchErrors(tracker) { 12 | tracker.on('change:error', function() { 13 | if (this.get('error')) { 14 | modelController.current((model, tab) => icon.state(tab.id, 'error')); 15 | } else { 16 | update(); 17 | } 18 | }); 19 | } 20 | 21 | export function update() { 22 | chrome.tabs.query({active: true, windowType: 'normal'}, function(tabs) { 23 | tabs.forEach(function(tab) { 24 | modelController.get(tab, model => updateIconState(tab, model)); 25 | }); 26 | }); 27 | } 28 | 29 | function updateIconState(tab, model) { 30 | if (typeof tab === 'object') { 31 | tab = tab.id; 32 | } 33 | 34 | var state = model.get('enabled') ? 'active' : 'disabled'; 35 | if (state === 'active' && model.get('needsRefresh')) { 36 | state = 'warning'; 37 | } 38 | icon.state(tab, state); 39 | } 40 | 41 | /** 42 | * Returns list of active tabs that matches given module 43 | * @param {LiveStyleModel} model 44 | * @param {Function} callback 45 | */ 46 | function activeTabsForModel(model, callback) { 47 | chrome.tabs.query({active: true, windowType: 'normal'}, function(tabs) { 48 | callback(tabs.filter(function(tab) { 49 | return modelController.id(tab) === model.id; 50 | })); 51 | }); 52 | } 53 | 54 | // listen to changes on activity state of models and update 55 | // browser icons accordingly 56 | LiveStyleModel.on('change:enabled', function(model) { 57 | activeTabsForModel(model, function(tabs) { 58 | tabs.forEach(tab => updateIconState(tab, model)); 59 | }); 60 | }); 61 | 62 | update(); 63 | chrome.tabs.onActivated.addListener(update); 64 | chrome.tabs.onRemoved.addListener(icon.clearState); 65 | chrome.tabs.onUpdated.addListener(function(tabId, changeInfo, tab) { 66 | if (changeInfo.status === 'loading') { 67 | return icon.clearState(tabId); 68 | } 69 | 70 | if (changeInfo.status === 'complete' && tab.active) { 71 | update(); 72 | } 73 | }); 74 | -------------------------------------------------------------------------------- /test/client-expect.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var assert = require('assert'); 4 | var _client = require('livestyle-client'); 5 | require('babel/register'); 6 | var client = require('../scripts/lib/client-expect'); 7 | 8 | describe('Client Expect', function() { 9 | var oldStatus = _client.status; 10 | before(function() { 11 | _client._setStatus('connected'); 12 | }); 13 | after(function() { 14 | _client._setStatus(oldStatus); 15 | }); 16 | 17 | it('send', function(done) { 18 | client.on('message-send', function onMessage(name) { 19 | assert.equal(name, 'ping'); 20 | client.off('message-send', onMessage); 21 | done(); 22 | }); 23 | 24 | client.send('ping'); 25 | }); 26 | 27 | it('send & expect (success)', function(done) { 28 | var onMessage = function(name) { 29 | if (name === 'ping') { 30 | client.emit('message-receive', 'pong'); 31 | } 32 | }; 33 | 34 | client.on('message-send', onMessage); 35 | 36 | client.send('ping').expect('pong').then(function() { 37 | client.off('message-send', onMessage); 38 | done(); 39 | }, done); 40 | }); 41 | 42 | it('send & expect (fail)', function(done) { 43 | client.send('ping').expect('pong', 100).then(function() { 44 | done(new Error('Should not resolve')); 45 | }, function(err) { 46 | assert(err); 47 | assert.equal(err.messageName, 'pong'); 48 | done(); 49 | }); 50 | }); 51 | 52 | it('expect after send', function(done) { 53 | var obj = client.send('ping'); 54 | setTimeout(function() { 55 | obj.expect('pong').then(function() { 56 | done(new Error('Should not resolve')); 57 | }, function(err) { 58 | assert(err); 59 | assert(err.message.indexOf('already sent') !== -1); 60 | done(); 61 | }); 62 | }, 10); 63 | }); 64 | 65 | it('validate', function(done) { 66 | var onMessage = function(name) { 67 | if (name === 'ping') { 68 | client.emit('message-receive', 'pong', {a: 1}); 69 | 70 | setTimeout(function() { 71 | client.emit('message-receive', 'pong', {a: 2, foo: 'bar'}); 72 | }, 100); 73 | } 74 | }; 75 | 76 | client.on('message-send', onMessage); 77 | client.send('ping') 78 | .expect('pong', function(data) { 79 | return data.a === 2; 80 | }) 81 | .then(function(data) { 82 | assert.equal(data.a, 2); 83 | assert.equal(data.foo, 'bar'); 84 | client.off('message-send', onMessage); 85 | done(); 86 | }, done); 87 | }); 88 | }); -------------------------------------------------------------------------------- /scripts/lib/associations.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Returns virtual associations between browser and editor files. 3 | * 4 | * Unlike “real” associations where user manually picks files, 5 | * virtual associations may contain guessed matches that 6 | * may change if user opens another file in editor 7 | */ 8 | 'use strict'; 9 | 10 | export default function(browserFiles, editorFiles, assocs) { 11 | assocs = assocs || {}; 12 | browserFiles = browserFiles || []; 13 | editorFiles = editorFiles || []; 14 | 15 | return browserFiles.reduce(function(result, browserFile) { 16 | var editorFile = assocs[browserFile]; 17 | if (editorFile == null) { 18 | // user didn’t picked association yet: guess it 19 | // XXX compare with `null` and `undefined` because empty string 20 | // means user forcibly removed association (for example, from 21 | // guessed association) 22 | editorFile = ~editorFiles.indexOf(browserFile) ? browserFile : guessAssoc(editorFiles, browserFile); 23 | } else if (!~editorFiles.indexOf(editorFile)) { 24 | // we have association but user didn’t opened it yet: 25 | // assume there’s no association 26 | editorFile = null; 27 | } 28 | result[browserFile] = editorFile; 29 | return result; 30 | }, {}); 31 | } 32 | 33 | function pathLookup(path) { 34 | return path.split('?')[0].split('/').filter(Boolean); 35 | } 36 | 37 | function guessAssoc(list, file) { 38 | var fileLookup = pathLookup(file).reverse(); 39 | var candidates = list.map(function(path) { 40 | return { 41 | path: path, 42 | lookup: pathLookup(path) 43 | }; 44 | }); 45 | 46 | var chunk, prevCandidates; 47 | for (var i = 0, il = fileLookup.length; i < il; i++) { 48 | prevCandidates = candidates; 49 | candidates = candidates.filter(function(candidate) { 50 | var part = candidate.lookup.pop(); 51 | if (fileLookup[i] === part) { 52 | return true; 53 | } 54 | 55 | if (i === 0) { 56 | // comparing file names: also try names without extension 57 | return cleanFileName(fileLookup[i]) === cleanFileName(part); 58 | } 59 | }); 60 | 61 | if (candidates.length === 1) { 62 | break; 63 | } else if (!candidates.length) { 64 | // empty candidates list on first pass means we 65 | // didn’t found anything at all 66 | candidates = i ? prevCandidates : null; 67 | break; 68 | } 69 | } 70 | 71 | if (candidates && candidates.length) { 72 | return candidates[0].path; 73 | } 74 | } 75 | 76 | function cleanFileName(file) { 77 | return file.replace(/\.\w+$/, ''); 78 | } -------------------------------------------------------------------------------- /scripts/lib/browser-action-icon.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Displays browser action icon according to current activity state 3 | */ 4 | 'use strict'; 5 | 6 | import deferred from './deferred'; 7 | 8 | var states = {}; 9 | var loaded = deferred(); 10 | var canvas = document.createElement('canvas'); 11 | var ctx = canvas.getContext('2d'); 12 | var images = { 13 | disabled: image('./icon/ba-disabled.png'), 14 | active: image('./icon/ba-active.png'), 15 | warning: image('./icon/ba-warning.png'), 16 | error1: image('./icon/ba-error1.png'), 17 | error2: image('./icon/ba-error2.png') 18 | }; 19 | var PI2 = Math.PI * 2; 20 | var errState = { 21 | pos: 0, 22 | step: 0.05 23 | }; 24 | 25 | canvas.width = canvas.height = 19; 26 | 27 | export function state(tabId, value) { 28 | if (typeof value !== 'undefined' && value !== states[tabId]) { 29 | states[tabId] = value; 30 | loaded.then(function() { 31 | renderState(tabId, value); 32 | }); 33 | } 34 | return states[tabId]; 35 | } 36 | 37 | export function reset() { 38 | Object.keys(states).forEach(function(tabId) { 39 | renderState(tabId, 'disabled'); 40 | clearState(tabId) 41 | }); 42 | } 43 | 44 | export function clearState(tabId) { 45 | delete states[tabId]; 46 | } 47 | 48 | function image(src) { 49 | var img = new Image(); 50 | img.onload = function() { 51 | if (!loaded.total) { 52 | loaded.total = 0; 53 | } 54 | 55 | img.onload = null; 56 | if (++loaded.total >= Object.keys(images).length) { 57 | loaded.resolve(images); 58 | } 59 | }; 60 | img.src = src; 61 | return img; 62 | } 63 | 64 | function clear() { 65 | ctx.clearRect(0, 0, canvas.width, canvas.height); 66 | return ctx; 67 | } 68 | 69 | function draw(image) { 70 | ctx.drawImage(image, 0, 0, canvas.width, canvas.height); 71 | } 72 | 73 | function renderState(tabId, state) { 74 | if (states[tabId] === 'error') { 75 | renderErrorState(); 76 | } else if (images[state]) { 77 | clear(); 78 | draw(images[state]); 79 | paintIcon(tabId); 80 | } else { 81 | console.warn('Unknown icon state:', state); 82 | } 83 | } 84 | 85 | function renderErrorState() { 86 | var tabs = Object.keys(states); 87 | var errTabs = tabs.filter(function(tabId) { 88 | return states[tabId] === 'error'; 89 | }); 90 | 91 | if (!errTabs.length) { 92 | return tabs.forEach(function(tabId) { 93 | renderState(tabId, states[tabId]); 94 | }); 95 | } 96 | 97 | errState.pos = (errState.pos + errState.step) % PI2; 98 | var alpha = Math.cos(errState.pos) * 0.5 + 0.5; 99 | 100 | clear(); 101 | ctx.save(); 102 | ctx.globalAlpha = alpha; 103 | draw(images.error1); 104 | ctx.globalAlpha = 1 - alpha; 105 | draw(images.error2); 106 | ctx.restore(); 107 | 108 | errTabs.forEach(paintIcon); 109 | setTimeout(renderErrorState, 16); 110 | } 111 | 112 | function paintIcon(tabId) { 113 | chrome.browserAction.setIcon({ 114 | imageData: ctx.getImageData(0, 0, canvas.width, canvas.height), 115 | tabId: +tabId 116 | }); 117 | } -------------------------------------------------------------------------------- /styles/assets/toggler.css: -------------------------------------------------------------------------------- 1 | .toggler { 2 | display: inline-block; 3 | position: relative; 4 | overflow: hidden; 5 | font-size: 12px; 6 | width: 60px; 7 | height: 23px; 8 | line-height: 23px; 9 | } 10 | 11 | .toggler > input[type="checkbox"] { 12 | position: absolute; 13 | display: inline-block; 14 | -webkit-transform-origin: 0 0; 15 | -moz-transform-origin: 0 0; 16 | -ms-transform-origin: 0 0; 17 | -o-transform-origin: 0 0; 18 | transform-origin: 0 0; 19 | 20 | -webkit-transform: scale(6, 2); 21 | -moz-transform: scale(6, 2); 22 | -ms-transform: scale(6, 2); 23 | -o-transform: scale(6, 2); 24 | transform: scale(6, 2); 25 | z-index: 1; 26 | opacity: 0; 27 | margin: 0; 28 | padding: 0; 29 | } 30 | 31 | .toggler__bg { 32 | position: relative; 33 | display: inline-block; 34 | width: 100%; 35 | height: 100%; 36 | border-radius: 2em; 37 | color: #fff; 38 | background-color: #bdc3c7; 39 | overflow: hidden; 40 | 41 | -webkit-transition-property: background-color, color; 42 | -moz-transition-property: background-color, color; 43 | -ms-transition-property: background-color, color; 44 | -o-transition-property: background-color, color; 45 | transition-property: background-color, color; 46 | 47 | -webkit-transition-duration: 0.3s; 48 | -moz-transition-duration: 0.3s; 49 | -ms-transition-duration: 0.3s; 50 | -o-transition-duration: 0.3s; 51 | transition-duration: 0.3s; 52 | } 53 | 54 | .toggler__knob { 55 | display: inline-block; 56 | position: absolute; 57 | width: 17px; 58 | height: 17px; 59 | border-radius: 17px; 60 | line-height: 17px; 61 | top: 3px; 62 | left: 3px; 63 | background: #7f8c9a; 64 | font-style: normal; 65 | 66 | -webkit-transition-property: -webkit-transform, background-color; 67 | -moz-transition-property: -moz-transform, background-color; 68 | -ms-transition-property: -ms-transform, background-color; 69 | -o-transition-property: -o-transform, background-color; 70 | transition-property: transform, background-color; 71 | 72 | -webkit-transition-duration: 0.3s; 73 | -moz-transition-duration: 0.3s; 74 | -ms-transition-duration: 0.3s; 75 | -o-transition-duration: 0.3s; 76 | transition-duration: 0.3s; 77 | } 78 | 79 | .toggler__knob:before, 80 | .toggler__knob:after { 81 | display: inline-block; 82 | width: 2.2em; 83 | text-align: center; 84 | position: absolute; 85 | top: 0; 86 | } 87 | 88 | .toggler__knob:before { 89 | content: 'ON'; 90 | right: 100%; 91 | margin-right: 4px; 92 | } 93 | 94 | .toggler__knob:after { 95 | content: 'OFF'; 96 | left: 100%; 97 | margin-left: 4px; 98 | } 99 | 100 | input[type="checkbox"]:checked + .toggler__bg { 101 | background: #344a5d; 102 | color: #26bb9d; 103 | } 104 | 105 | input[type="checkbox"]:checked + .toggler__bg .toggler__knob { 106 | -webkit-transform: translateX(37px); 107 | -moz-transform: translateX(37px); 108 | -ms-transform: translateX(37px); 109 | -o-transform: translateX(37px); 110 | transform: translateX(37px); 111 | background: #26bb9d; 112 | } -------------------------------------------------------------------------------- /scripts/controllers/error-tracker.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Keeps track of errors occurred LiveStyle activity. 3 | * Provides model with `error` boolean attribute indicating 4 | * if there’s something that user should be aware of. 5 | * 6 | * This controller tries to detect intermediate error states: 7 | * for example, when user type something he may accidentally 8 | * put stylesheet in error state but fix it later. In this case, 9 | * we shouldn’t trigger error state. 10 | */ 11 | 'use strict'; 12 | 13 | import Model from '../lib/model'; 14 | import {debounce} from '../lib/utils'; 15 | 16 | // Worker commads that commands may generate errors we should track 17 | var trackCommands = ['calculate-diff', 'apply-patch', 'initial-content']; 18 | var commandState = {}; 19 | var errorFiles = []; 20 | var model = new Model(); 21 | 22 | export default model; 23 | 24 | /** 25 | * Listens to events on given LiveStyle worker command queue 26 | * @param {CommandQueue} commandQueue 27 | */ 28 | model.watch = function(commandQueue) { 29 | commandQueue.on('command-create command-reply', handleWorkerEvent); 30 | return this; 31 | }; 32 | 33 | /** 34 | * Stops listening events on given LiveStyle worker 35 | * @param {CommandQueue} commandQueue 36 | */ 37 | model.unwatch = function(commandQueue) { 38 | commandQueue.off('command-create command-reply', handleWorkerEvent); 39 | return this; 40 | }; 41 | 42 | model.set({ 43 | error: false, 44 | warning: false 45 | }); 46 | 47 | var setErrorState = debounce(function() { 48 | if (errorFiles.length) { 49 | model.set('error', true); 50 | resetErrorState(); 51 | } 52 | }, 2000); 53 | 54 | var resetErrorState = debounce(function() { 55 | model.set('error', false); 56 | errorFiles.length = 0; 57 | }, 30000); 58 | 59 | 60 | function markError(uri) { 61 | if (!~errorFiles.indexOf(uri)) { 62 | errorFiles.push(uri); 63 | setErrorState(); 64 | } 65 | } 66 | 67 | function unmarkError(uri) { 68 | var ix = errorFiles.indexOf(uri); 69 | if (~ix) { 70 | errorFiles.splice(ix, 1); 71 | } 72 | } 73 | 74 | function handleWorkerEvent(payload) { 75 | if (!payload.commandId) { 76 | return; 77 | } 78 | 79 | if ('name' in payload && ~trackCommands.indexOf(payload.name)) { 80 | // a command request sent to worker 81 | commandState[payload.commandId] = { 82 | created: Date.now(), 83 | uri: payload.data.uri 84 | }; 85 | } else if ('status' in payload && payload.commandId in commandState) { 86 | // a reply from worker on previous command request 87 | var state = commandState[payload.commandId]; 88 | if (payload.status === 'error') { 89 | markError(state.uri); 90 | } else { 91 | unmarkError(state.uri); 92 | } 93 | delete commandState[payload.commandId]; 94 | } 95 | } 96 | 97 | // Watch for hung states: ones we didn’t received reply on 98 | setInterval(function() { 99 | var end = Date.now() + 10000; 100 | Object.keys(commandState).forEach(function(id) { 101 | if (commandState[id].created < end) { 102 | delete commandState[id]; 103 | } 104 | }); 105 | }, 5000); -------------------------------------------------------------------------------- /test/model.js: -------------------------------------------------------------------------------- 1 | var assert = require('assert'); 2 | require('babel/register'); 3 | var Model = require('../scripts/lib/livestyle-model'); 4 | 5 | describe('LiveStyle Model', function() { 6 | it('get & set attributes', function() { 7 | var model = new Model(); 8 | model.set('enabled', true); 9 | model.set('browserFiles', ['a.css', 'b.css']); 10 | 11 | assert.equal(model.get('enabled'), true); 12 | assert.deepEqual(model.get('browserFiles'), ['a.css', 'b.css']); 13 | }); 14 | 15 | it('event dispatching', function() { 16 | var model = new Model(); 17 | var update = 0, enabled = 0, browserFiles = 0; 18 | model.on('change:enabled', function() { 19 | enabled++; 20 | }) 21 | .on('change:browserFiles', function() { 22 | browserFiles++; 23 | }) 24 | .on('update', function() { 25 | // cumulative event 26 | update++; 27 | }); 28 | 29 | model 30 | .set('enabled', true) 31 | .set('enabled', true) 32 | .set('browserFiles', ['a', 'b']) 33 | .set('browserFiles', ['a', 'b']); 34 | 35 | assert.equal(update, 1); 36 | assert.equal(enabled, 1); 37 | assert.equal(browserFiles, 1); 38 | }); 39 | 40 | // it('global event dispatching', function() { 41 | // var m1 = new Model('m1'); 42 | // var m2 = new Model('m2'); 43 | 44 | // var changes = {} 45 | // Model.on('change:editorFiles update', function(model) { 46 | // if (!changes[model.id]) { 47 | // changes[model.id] = 0; 48 | // } 49 | 50 | // changes[model.id]++; 51 | // }); 52 | 53 | // m1.set('editorFiles', ['a', 'b']); 54 | // m2.set('editorFiles', ['c', 'd']); 55 | 56 | // assert.deepEqual(changes, {m1: 2, m2: 2}); 57 | // }); 58 | 59 | it('file assocs', function() { 60 | var model = new Model(); 61 | model.set('browserFiles', [ 62 | '/assets/css/file1.css', 63 | '/assets/css/file2.css', 64 | '/assets/css/file3.css', 65 | '/assets/css/file4.css', 66 | '/assets/css/file5.css', 67 | '/assets/css/file6.css?v=123' 68 | ]); 69 | 70 | model.set('editorFiles', [ 71 | '/assets/css/file1.css', 72 | 73 | '/files/css2/file2.css', 74 | '/files/css3/file2.css', 75 | '/files/css3/file5.less', 76 | '/files/css3/file6.scss', 77 | 78 | '/assets/css/foo.css' 79 | ]); 80 | 81 | model.set('assocs', {'/assets/css/file4.css': '/assets/css/foo.css'}); 82 | 83 | var assocs = model.associations(); 84 | 85 | // strict guessing 86 | assert.equal(assocs['/assets/css/file1.css'], '/assets/css/file1.css'); 87 | 88 | // semi-strict guessing (match by extension) 89 | assert.equal(assocs['/assets/css/file5.css'], '/files/css3/file5.less'); 90 | 91 | // semi-strict guessing (remove query sting) 92 | assert.equal(assocs['/assets/css/file6.css?v=123'], '/files/css3/file6.scss'); 93 | 94 | // fuzzy guessing 95 | assert.equal(assocs['/assets/css/file2.css'], '/files/css2/file2.css'); 96 | 97 | // no match 98 | assert.equal(assocs['/assets/css/file3.css'], undefined); 99 | 100 | // explicit association 101 | assert.equal(assocs['/assets/css/file4.css'], '/assets/css/foo.css'); 102 | }); 103 | }); -------------------------------------------------------------------------------- /styles/assets/remote-view.css: -------------------------------------------------------------------------------- 1 | .rv { 2 | background-color: #e57c2c; 3 | color: #fff; 4 | flex-grow: 0; 5 | flex-shrink: 0; 6 | z-index: 3; 7 | position: relative; 8 | transition: background-color 0.25s; 9 | } 10 | 11 | .rv-header, .rv-description { 12 | position: relative; 13 | overflow: hidden; 14 | font-size: 13px; 15 | line-height: 1.4; 16 | } 17 | 18 | .rv-header { 19 | position: relative; 20 | } 21 | 22 | .rv-title { 23 | font-size: 18px; 24 | padding: 7px 20px 2px; 25 | line-height: 22px; 26 | box-sizing: border-box; 27 | overflow: hidden; 28 | white-space: nowrap; 29 | text-overflow: ellipsis; 30 | } 31 | 32 | .rv-title a { 33 | color: inherit; 34 | text-decoration: none; 35 | font-size: 0.9em; 36 | } 37 | 38 | .rv-title a:hover { 39 | text-decoration: underline; 40 | } 41 | 42 | .rv-comment { 43 | color: #f1c22a; 44 | position: relative; 45 | font-size: 12px; 46 | padding: 0 20px 7px; 47 | overflow: hidden; 48 | box-sizing: border-box; 49 | } 50 | 51 | .rv-comment a { 52 | color: #ecf0f1; 53 | } 54 | 55 | .rv-comment a:hover { 56 | color: #fff; 57 | } 58 | 59 | .rv-message { 60 | position: relative; 61 | } 62 | 63 | .rv-learn-more { 64 | cursor: pointer; 65 | border-bottom: 1px dotted currentColor; 66 | color: #ecf0f1; 67 | } 68 | 69 | .rv-learn-more:hover { 70 | color: #fff; 71 | } 72 | 73 | .rv .toggler { 74 | float: right; 75 | margin-right: 17px; 76 | margin-top: 7px; 77 | } 78 | 79 | .rv .toggler__bg { 80 | background-color: #f29a26; 81 | } 82 | 83 | .rv .toggler__knob { 84 | background-color: #e57c2c; 85 | } 86 | 87 | .rv input[type="checkbox"]:checked + .toggler__bg { 88 | background-color: #fff; 89 | color: #e57c2c; 90 | } 91 | 92 | .rv input[type="checkbox"]:checked + .toggler__bg .toggler__knob { 93 | background-color: #e57c2c; 94 | } 95 | 96 | .rv-description { 97 | position: absolute; 98 | left: 0; 99 | right: 0; 100 | height: 0; 101 | overflow: hidden; 102 | padding: 0 20px; 103 | background-color: inherit; 104 | margin-top: -1px; 105 | } 106 | 107 | .rv-description a { 108 | color: #fff; 109 | } 110 | 111 | .rv-description em { 112 | font-style: normal; 113 | color: #f1c22a; 114 | } 115 | 116 | /* Unavailable state */ 117 | .rv__unavailable { 118 | background-color: #bdc3c7; 119 | } 120 | 121 | .rv__unavailable .toggler { 122 | display: none; 123 | } 124 | 125 | .rv__unavailable .rv-comment { 126 | color: #ecf0f1; 127 | } 128 | 129 | /* Spinner */ 130 | .rv-spinner { 131 | display: inline-block; 132 | margin-left: 5px; 133 | } 134 | 135 | .rv-spinner__item { 136 | display: inline-block; 137 | width: 4px; 138 | height: 4px; 139 | background: #fff; 140 | border-radius: 2px; 141 | vertical-align: middle; 142 | animation: rv-spinner 0.7s infinite; 143 | } 144 | 145 | .rv-spinner__item:nth-of-type(2) { 146 | animation-delay: 0.1s; 147 | } 148 | 149 | .rv-spinner__item:nth-of-type(3) { 150 | animation-delay: 0.2s; 151 | } 152 | 153 | @keyframes rv-spinner { 154 | from { 155 | transform: scale(0); 156 | opacity: 1; 157 | } 158 | 159 | to { 160 | transform: scale(2); 161 | opacity: 0; 162 | } 163 | } -------------------------------------------------------------------------------- /scripts/helpers/shadow-css.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Shadow CSS is a concept used to bypass Chrome security restrictions for CSSOM: 3 | * if a stylesheet is loaded from different origin or 'file:' protocol, you cannot 4 | * access its `cssRules`. But `insertRule()` and `deleteRule()` works fine though. 5 | * 6 | * Here’s how Shadow CSS works: 7 | * 1. Loads contents of given (security restricted) stylesheet either from 8 | * DevTools resource (faster, contains most recent changes) or via XHR 9 | * (extensions can bypass CORS restrictions). 10 | * 2. Creates inline style with this stylesheet contents in hidden iframe. This 11 | * stylesheets always allows access to `cssRules`. 12 | * 3. Use this inline stylesheet for CSSOM patching. The patching will return 13 | * an update plan: a set of `insterRule()` and `deleteRule()` instructions that 14 | * must be applied to origin stylesheet to get the same result. 15 | * 4. Blindly apply this update plan to original stylesheet and hope everything 16 | * works as expected. 17 | * 5. Automatically keep track of all DevTools Resources updates and keep 18 | * shadow CSS in sync. 19 | */ 20 | 'use strict'; 21 | 22 | var host = null; 23 | var shadowCSS = {}; 24 | 25 | export default function(url) { 26 | return new Promise(function(resolve, reject) { 27 | if (url in shadowCSS) { 28 | return resolve(shadowCSS[url].sheet); 29 | } 30 | 31 | // reject promise if no answer for too long 32 | var _timer = setTimeout(() => { 33 | reject(makeError('ESHADOWSTNORESPONSE', `Unable to fetch ${url}: no response from background page`)); 34 | }, 5000); 35 | 36 | // fetch stylesheet contents first 37 | chrome.runtime.sendMessage({name: 'get-stylesheet-content', data: {url}}, resp => { 38 | if (_timer) { 39 | clearTimeout(_timer); 40 | _timer = null; 41 | } 42 | 43 | // A stylesheet may be already created with another request 44 | if (!shadowCSS[url]) { 45 | if (resp == null) { 46 | // `null` or `undefined` means error while fetching CSS contents, 47 | // try again later 48 | return reject(makeError('ESHADOWSTEMPTY', `Content fetch request for ${url} returned null`)); 49 | } 50 | 51 | shadowCSS[url] = createShadowStylesheet(resp); 52 | shadowCSS[url].dataset.href = url; 53 | } 54 | 55 | resolve(shadowCSS[url].sheet); 56 | }); 57 | }); 58 | }; 59 | 60 | function getHost() { 61 | if (!host) { 62 | var iframe = document.createElement('iframe'); 63 | iframe.style.cssText = 'width:1px;height:1px;border:0;position:absolute;display:none'; 64 | iframe.id = 'livestyle-shadow-css'; 65 | var content = new Blob([''], {type: 'text/html'}); 66 | iframe.src = URL.createObjectURL(content); 67 | document.body.appendChild(iframe); 68 | host = iframe.contentDocument; 69 | } 70 | return host; 71 | } 72 | 73 | function createShadowStylesheet(content) { 74 | var style = getHost().createElement('style'); 75 | getHost().head.appendChild(style); 76 | if (style.sheet) { 77 | style.sheet.disabled = true; 78 | } 79 | style.textContent = content || ''; 80 | return style; 81 | } 82 | 83 | function makeError(code, message) { 84 | var err = new Error(message || code); 85 | err.code = code; 86 | return err; 87 | } 88 | 89 | // listen to DevTools Resource updates 90 | chrome.runtime.onMessage.addListener(function(message) { 91 | if (message && message.name === 'resource-updated' && shadowCSS[message.data.url]) { 92 | var data = message.data; 93 | shadowCSS[data.url].textContent = data.content; 94 | } 95 | }); -------------------------------------------------------------------------------- /scripts/lib/event-emitter.js: -------------------------------------------------------------------------------- 1 | /** 2 | * A simple event emitter, borrowed from Backbone.Event. 3 | */ 4 | 'use strict'; 5 | 6 | // Regular expression used to split event strings 7 | var eventSplitter = /\s+/; 8 | 9 | // Create a local reference to slice/splice. 10 | var slice = Array.prototype.slice; 11 | 12 | export default class EventEmitter { 13 | /** 14 | * Bind one or more space separated events, `events`, to a `callback` 15 | * function. Passing `"all"` will bind the callback to all events fired. 16 | * @param {String} events 17 | * @param {Function} callback 18 | * @param {Object} context 19 | * @memberOf eventDispatcher 20 | */ 21 | on(events, callback, context) { 22 | var calls, event, node, tail, list; 23 | if (!callback) 24 | return this; 25 | 26 | events = events.split(eventSplitter); 27 | calls = this._callbacks || (this._callbacks = {}); 28 | 29 | // Create an immutable callback list, allowing traversal during 30 | // modification. The tail is an empty object that will always be used 31 | // as the next node. 32 | while (event = events.shift()) { 33 | list = calls[event]; 34 | node = list ? list.tail : {}; 35 | node.next = tail = {}; 36 | node.context = context; 37 | node.callback = callback; 38 | calls[event] = { 39 | tail : tail, 40 | next : list ? list.next : node 41 | }; 42 | } 43 | 44 | return this; 45 | } 46 | 47 | /** 48 | * Remove one or many callbacks. If `context` is null, removes all 49 | * callbacks with that function. If `callback` is null, removes all 50 | * callbacks for the event. If `events` is null, removes all bound 51 | * callbacks for all events. 52 | * @param {String} events 53 | * @param {Function} callback 54 | * @param {Object} context 55 | */ 56 | off(events, callback, context) { 57 | var event, calls, node, tail, cb, ctx; 58 | 59 | // No events, or removing *all* events. 60 | if (!(calls = this._callbacks)) 61 | return; 62 | if (!(events || callback || context)) { 63 | delete this._callbacks; 64 | return this; 65 | } 66 | 67 | // Loop through the listed events and contexts, splicing them out of the 68 | // linked list of callbacks if appropriate. 69 | events = events ? events.split(eventSplitter) : _.keys(calls); 70 | while (event = events.shift()) { 71 | node = calls[event]; 72 | delete calls[event]; 73 | if (!node || !(callback || context)) 74 | continue; 75 | // Create a new list, omitting the indicated callbacks. 76 | tail = node.tail; 77 | while ((node = node.next) !== tail) { 78 | cb = node.callback; 79 | ctx = node.context; 80 | if ((callback && cb !== callback) || (context && ctx !== context)) { 81 | this.on(event, cb, ctx); 82 | } 83 | } 84 | } 85 | 86 | return this; 87 | } 88 | 89 | /** 90 | * Trigger one or many events, firing all bound callbacks. Callbacks are 91 | * passed the same arguments as `emit` is, apart from the event name 92 | * (unless you're listening on `"all"`, which will cause your callback 93 | * to receive the true name of the event as the first argument). 94 | * @param {String} events 95 | */ 96 | emit(events) { 97 | var event, node, calls, tail, args, all, rest; 98 | if (!(calls = this._callbacks)) { 99 | return this; 100 | } 101 | all = calls.all; 102 | events = events.split(eventSplitter); 103 | rest = slice.call(arguments, 1); 104 | 105 | // For each event, walk through the linked list of callbacks twice, 106 | // first to trigger the event, then to trigger any `"all"` callbacks. 107 | while (event = events.shift()) { 108 | if (node = calls[event]) { 109 | tail = node.tail; 110 | while ((node = node.next) !== tail) { 111 | node.callback.apply(node.context || this, rest); 112 | } 113 | } 114 | if (node = all) { 115 | tail = node.tail; 116 | args = [ event ].concat(rest); 117 | while ((node = node.next) !== tail) { 118 | node.callback.apply(node.context || this, args); 119 | } 120 | } 121 | } 122 | 123 | return this; 124 | } 125 | } -------------------------------------------------------------------------------- /scripts/lib/utils.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | export function $(selector, context=document) { 4 | return context.querySelector(selector); 5 | } 6 | 7 | export function $$(selector, context=document) { 8 | return toArray(context.querySelectorAll(selector)); 9 | } 10 | 11 | export function toArray(obj, ix=0) { 12 | return Array.prototype.slice.call(obj, 0); 13 | } 14 | 15 | export function toDom(html) { 16 | var div = document.createElement('div'); 17 | div.innerHTML = html; 18 | var result = div.firstChild; 19 | div.removeChild(result); 20 | return result; 21 | } 22 | 23 | /** 24 | * Extend given object with properties from other objects 25 | * @param {Object} obj 26 | * @return {Object} 27 | */ 28 | export function extend(obj, ...args) { 29 | args.forEach(arg => { 30 | if (arg) { 31 | for (var key in arg) if (arg.hasOwnProperty(key)) { 32 | obj[key] = arg[key]; 33 | } 34 | } 35 | }); 36 | return obj; 37 | } 38 | 39 | export function copy(...args) { 40 | return extend({}, ...args); 41 | } 42 | 43 | export function closest(elem, sel) { 44 | while (elem && elem !== document) { 45 | if (elem.matches && elem.matches(sel)) { 46 | return elem; 47 | } 48 | elem = elem.parentNode; 49 | } 50 | } 51 | 52 | /** 53 | * Returns copy of given array with unique values 54 | * @param {Array} arr 55 | * @return {Array} 56 | */ 57 | export function unique(arr) { 58 | var lookup = []; 59 | return arr.filter(val => { 60 | if (lookup.indexOf(val) < 0) { 61 | lookup.push(val); 62 | return true; 63 | } 64 | }); 65 | } 66 | 67 | /** 68 | * Returns a function, that, as long as it continues to be invoked, will not 69 | * be triggered. The function will be called after it stops being called for 70 | * N milliseconds. If `immediate` is passed, trigger the function on the 71 | * leading edge, instead of the trailing. 72 | * 73 | * @src underscore.js 74 | * 75 | * @param {Function} func 76 | * @param {Number} wait 77 | * @param {Boolean} immediate 78 | * @return {Function} 79 | */ 80 | export function debounce(func, wait, immediate) { 81 | var timeout, args, context, timestamp, result; 82 | 83 | var later = function() { 84 | var last = Date.now() - timestamp; 85 | 86 | if (last < wait && last >= 0) { 87 | timeout = setTimeout(later, wait - last); 88 | } else { 89 | timeout = null; 90 | if (!immediate) { 91 | result = func.apply(context, args); 92 | if (!timeout) context = args = null; 93 | } 94 | } 95 | }; 96 | 97 | return function() { 98 | context = this; 99 | args = arguments; 100 | timestamp = Date.now(); 101 | var callNow = immediate && !timeout; 102 | if (!timeout) timeout = setTimeout(later, wait); 103 | if (callNow) { 104 | result = func.apply(context, args); 105 | context = args = null; 106 | } 107 | 108 | return result; 109 | }; 110 | } 111 | 112 | /** 113 | * Returns string representation for given node path 114 | * @param {Array} nodePath 115 | * @type {String} 116 | */ 117 | export function stringifyPath(nodePath) { 118 | return nodePath.map(c => c[0] + (c[1] > 1 ? '|' + c[1] : '')).join(' / '); 119 | } 120 | 121 | /** 122 | * Returns string representation of given patch JSON 123 | * @param {Object} patch 124 | * @type {String} 125 | */ 126 | export function stringifyPatch(patch) { 127 | var str = this.stringifyPath(patch.path) + ' {\n' + 128 | patch.update.map(prop => ` ${prop.name}: ${prop.value};\n`).join('') + 129 | patch.remove.map(prop => ` /* ${prop.name}: ${prop.value}; */\n`).join('') + 130 | '}'; 131 | 132 | if (patch.action === 'remove') { 133 | str = '/* remove: ' + this.stringifyPath(patch.path) + ' */'; 134 | } 135 | 136 | if (patch.hints && patch.hints.length) { 137 | var hint = patch.hints[patch.hints.length - 1]; 138 | var self = this; 139 | 140 | var before = (hint.before || []).map(function(p) { 141 | return self.stringifyPath([p]); 142 | }).join(' / '); 143 | 144 | var after = (hint.after || []).map(function(p) { 145 | return self.stringifyPath([p]); 146 | }).join(' / '); 147 | 148 | if (before) { 149 | str = `/** before: ${before} */\n${str}`; 150 | } 151 | 152 | if (after) { 153 | str += `\n/** after: ${after} */\n`; 154 | } 155 | } 156 | 157 | return str.trim(); 158 | } -------------------------------------------------------------------------------- /scripts/controllers/devtools.js: -------------------------------------------------------------------------------- 1 | /** 2 | * A DevTools controller for background page. 3 | * 4 | * For generic CSS patching extension uses CSSOM 5 | * which is very fast even on large sources. The problem is 6 | * that these changes in CSSOM are not reflected into original 7 | * source, e.g. in DevTools you’ll still see unchanges properties. 8 | * Moreover, any change in DevTools will reset all CSSOM changes. 9 | * 10 | * This module keeps track of all pending diffs for tabs and 11 | * when DevTools for tab became available, it flushes these 12 | * changes to DevTools page so it can apply diffs on page resources. 13 | */ 14 | 'use strict'; 15 | 16 | import portExpect from '../lib/port-expect'; 17 | 18 | var openedDevtools = {}; 19 | var pendingPatches = {}; 20 | 21 | var devtoolsPort = /^devtools\-page:(\d+)$/; 22 | 23 | export function saveDiff(tabId, stylesheetUrl, patches) { 24 | if (isOpenedForTab(tabId)) { 25 | // we have opened DevTools for this tab, 26 | // send diff directly to it 27 | console.log('DevTools opened, send diff directly'); 28 | return getPort(tabId).postMessage({ 29 | name: 'diff', 30 | data: { 31 | uri: stylesheetUrl, 32 | syntax: 'css', // always CSS 33 | patches: patches 34 | } 35 | }); 36 | } 37 | 38 | // no opened DevTools, accumulate changes 39 | if (!pendingPatches[tabId]) { 40 | pendingPatches[tabId] = {}; 41 | } 42 | 43 | if (!pendingPatches[tabId][stylesheetUrl]) { 44 | pendingPatches[tabId][stylesheetUrl] = []; 45 | } 46 | 47 | console.log('Append patches for', stylesheetUrl); 48 | pendingPatches[tabId][stylesheetUrl] = pendingPatches[tabId][stylesheetUrl].concat(patches); 49 | } 50 | 51 | export function getPort(tabId) { 52 | if (typeof tabId === 'object') { 53 | tabId = tabId.id; 54 | } 55 | 56 | return openedDevtools[tabId]; 57 | } 58 | 59 | export function isOpenedForTab(tabId) { 60 | return !!getPort(tabId); 61 | } 62 | 63 | /** 64 | * Resets current DevTools state for given tab id 65 | */ 66 | export function reset(tabId) { 67 | var port = getPort(tabId); 68 | if (port) { 69 | port.postMessage({name: 'reset'}); 70 | } 71 | } 72 | 73 | export function stylesheets(tabId, callback) { 74 | if (!this.isOpenedForTab(tabId)) { 75 | return callback([]); 76 | } 77 | 78 | return portExpect(getPort(tabId), 'get-stylesheets', 'stylesheets') 79 | .then(callback, err => callback([])); 80 | } 81 | 82 | export function stylesheetContent(tabId, url) { 83 | if (!this.isOpenedForTab(tabId)) { 84 | return callback([]); 85 | } 86 | 87 | return portExpect(getPort(tabId), 'get-stylesheet-content', {url}, 'stylesheet-content') 88 | .then(resp => resp.content); 89 | } 90 | 91 | function normalizeUrl(url) { 92 | return url.split('#')[0]; 93 | } 94 | 95 | /** 96 | * Show log messages coming from DevTools 97 | * @param {Array} strings Array of string 98 | */ 99 | function devtoolsLog(strings) { 100 | var args = ['%c[DevTools]', 'background-color:#344a5d;color:#fff'].concat(strings); 101 | console.log.apply(console, args); 102 | } 103 | 104 | /** 105 | * Handles incoming messages from DevTools connection port 106 | * @param {Object} message Incoming message 107 | */ 108 | function devtoolsMessageHandler(tabId, message) { 109 | if (message.name === 'log') { 110 | devtoolsLog(message.data); 111 | } else if (message.name === 'resource-updated') { 112 | // notify tabs about updates resources 113 | chrome.tabs.sendMessage(tabId, message); 114 | } 115 | } 116 | 117 | function resetPatches(tabId) { 118 | if (tabId in pendingPatches) { 119 | delete pendingPatches[tabId]; 120 | } 121 | } 122 | 123 | chrome.runtime.onConnect.addListener(function(port) { 124 | var m = port.name.match(devtoolsPort); 125 | if (m) { 126 | var tabId = +m[1]; 127 | openedDevtools[tabId] = port; 128 | console.log('Opened devtools for', tabId); 129 | 130 | if (tabId in pendingPatches) { 131 | // flush pending patches 132 | port.postMessage({ 133 | name: 'pending-patches', 134 | data: pendingPatches[tabId] 135 | }); 136 | delete pendingPatches[tabId]; 137 | } 138 | 139 | var messageHandler = message => { 140 | devtoolsMessageHandler(tabId, message); 141 | }; 142 | 143 | port.onMessage.addListener(messageHandler); 144 | 145 | port.onDisconnect.addListener(function() { 146 | console.log('Closed devtools for', tabId); 147 | delete openedDevtools[tabId]; 148 | port.onMessage.removeListener(messageHandler); 149 | }); 150 | } 151 | }); 152 | 153 | // cleanup patches when tab is closed or refreshed 154 | chrome.tabs.onRemoved.addListener(resetPatches); 155 | chrome.tabs.onUpdated.addListener(resetPatches); -------------------------------------------------------------------------------- /scripts/ui/select-box.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import {$$, closest} from '../lib/utils'; 4 | var boxes = []; 5 | 6 | export function init(container) { 7 | $$('select', container).forEach(convert); 8 | } 9 | 10 | export function find(sel) { 11 | var matchedBox = null; 12 | boxes.some(function(box) { 13 | if (box._sel === sel) { 14 | return matchedBox = box; 15 | } 16 | }); 17 | 18 | return matchedBox; 19 | }; 20 | 21 | export function convert(sel) { 22 | if (!sel.getAttribute('data-select-box')) { 23 | return new SelectBox(sel); 24 | } 25 | 26 | return find(sel); 27 | }; 28 | 29 | export function sync(sel) { 30 | var box = find(sel); 31 | if (box) { 32 | return box.sync(); 33 | } 34 | } 35 | 36 | /** 37 | * Creates custom select box from given element 127 | */ 128 | sync() { 129 | var options = this._sel.options; 130 | var selIx = this._sel.selectedIndex; 131 | this.label.innerText = options[selIx] ? options[selIx].label : '...'; 132 | 133 | // remove old picker items 134 | while (this.picker.firstChild) { 135 | this.picker.removeChild(this.picker.firstChild); 136 | } 137 | 138 | for (var i = 0, il = options.length, item; i < il; i++) { 139 | item = el('li', 'select-box__picker-item'); 140 | item.innerText = options[i].label; 141 | item.setAttribute('data-ix', i); 142 | if (i === selIx) { 143 | item.classList.add('select-box__picker-item_selected'); 144 | } 145 | this.picker.appendChild(item); 146 | } 147 | } 148 | 149 | destroy() { 150 | var ix = boxes.indexOf(this); 151 | if (~ix) { 152 | boxes.splice(ix, 1); 153 | } 154 | this._sel = this.box = this.label = this.picker = null; 155 | } 156 | } 157 | 158 | function el(name, className) { 159 | var elem = document.createElement('div'); 160 | if (className) { 161 | elem.className = className; 162 | } 163 | return elem; 164 | } 165 | 166 | document.addEventListener('click', function(evt) { 167 | // if clicked inside select box – do nothing 168 | if (!closest(evt.target, '.select-box')) { 169 | boxes.forEach(function(box) { 170 | box.hide(); 171 | }); 172 | } 173 | }); -------------------------------------------------------------------------------- /popup.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | LiveStyle Popup 6 | 7 | 17 | 18 | 19 | 84 |
    85 |
    86 | 87 |
    88 |
    Remote View
    89 |
    90 |
    91 |
    Easy way to view local web-sites with LiveStyle updates in other browsers and mobile devices. Learn more
    92 |
    93 |
    94 |
    95 |

    Remote View creates a publicly available domain name like http://some-name.livestyle.io and connects it with your local domain or IP address (aka “reverse tunnel”). So it looks like anyone browsing the public domain is actually browsing your local web-site from your computer.

    96 |

    This way you can easily share your local web-site with colleagues or customers, preview it on any internet-connected mobile device, virtual machine and so on—with instant LiveStyle updates.

    97 |

    Requires LiveStyle app to be running in background.

    98 |

    Remote View is a paid service available for free during beta-testing.

    99 |
    100 |
    101 | 102 | 103 | 104 | -------------------------------------------------------------------------------- /styles/popup.css: -------------------------------------------------------------------------------- 1 | @import url(./assets/fonts.css); 2 | @import url(./assets/toggler.css); 3 | @import url(./assets/select-box.css); 4 | @import url(./assets/button.css); 5 | @import url(./assets/global-message.css); 6 | @import url(./assets/remote-view.css); 7 | 8 | body { 9 | display: flex; 10 | flex-direction: column; 11 | font-family: 'PT Sans', Arial, sans-serif; 12 | height: 100%; 13 | } 14 | 15 | a { 16 | transition: color 0.2s; 17 | } 18 | 19 | ol { 20 | margin-left: 0; 21 | padding-left: 0; 22 | } 23 | 24 | .popup { 25 | background: #fff; 26 | font-size: 14px; 27 | padding: 20px; 28 | color: #2c3f4f; 29 | position: relative; 30 | min-height: 300px; 31 | overflow: hidden; 32 | flex-grow: 1; 33 | } 34 | 35 | .popup_enabled { 36 | overflow: auto; 37 | } 38 | 39 | .active-editor { 40 | padding: 5px; 41 | text-align: right; 42 | font-size: 0.8em; 43 | background: #ecf0f1; 44 | margin: -20px; 45 | margin-bottom: 14px; 46 | } 47 | 48 | .activity { 49 | border: 0; 50 | padding: 0; 51 | position: relative; 52 | margin:0 0 20px; 53 | z-index: 2; 54 | } 55 | 56 | .activity label { 57 | display: block; 58 | line-height: 1; 59 | font-size: 1.8em; 60 | } 61 | 62 | .activity em { 63 | display: block; 64 | font-size: 0.9em; 65 | line-height: 1; 66 | margin-top: 0.6em; 67 | font-style: normal; 68 | color: #95a5a6; 69 | } 70 | 71 | .activity .toggler { 72 | position: absolute; 73 | right: -3px; 74 | margin-bottom: -1px; 75 | z-index: 2; 76 | } 77 | 78 | .update-direction { 79 | padding: 2px 7px 4px; 80 | border-radius: 4px; 81 | background: #5eafe0; 82 | color: #fff; 83 | cursor: pointer; 84 | } 85 | 86 | .update-direction__icon { 87 | display: inline-block; 88 | } 89 | 90 | .update-direction__icon:before { 91 | font-family: 'Font Awesome'; 92 | content: '\f0ec'; 93 | display: inline-block; 94 | width: 1.5em; 95 | text-align: center; 96 | font-size: 12px; 97 | } 98 | 99 | [data-direction="to browser"] .update-direction__icon:before { 100 | content: '\f178'; 101 | } 102 | 103 | [data-direction="to editor"] .update-direction__icon:before { 104 | content: '\f177'; 105 | } 106 | 107 | .file-list { 108 | list-style-type: none; 109 | display: block; 110 | margin: 0 0 20px; 111 | padding: 0; 112 | } 113 | 114 | .file-list__item { 115 | margin-bottom: 20px; 116 | } 117 | 118 | .file-list__item:last-child { 119 | margin-bottom: 0; 120 | } 121 | 122 | .file__browser { 123 | margin-bottom: 4px; 124 | padding-left: 10px; 125 | } 126 | 127 | .file-list__item_user .file__browser { 128 | /* color: #e57c2c; */ 129 | font-weight: bold; 130 | } 131 | 132 | .file__remove { 133 | display: inline-block; 134 | margin-left: 10px; 135 | color: #bf382e; 136 | cursor: pointer; 137 | font-style: normal; 138 | font-weight: normal; 139 | font-size: 10px; 140 | line-height: 10px; 141 | position: relative; 142 | top: 1px; 143 | opacity: 0; 144 | transition: opacity 0.2s; 145 | } 146 | 147 | .file__remove:hover { 148 | color: #e64b3f; 149 | } 150 | 151 | .file__remove:before { 152 | font-family: 'Font Awesome'; 153 | content: '\f00d'; 154 | display: inline-block; 155 | font-size: 1.4em; 156 | padding: 4px; 157 | margin: -4px; 158 | position: relative; 159 | top: -1px; 160 | } 161 | 162 | .file-list__item:hover .file__remove { 163 | opacity: 1; 164 | } 165 | 166 | .file__browser-addon { 167 | display: block; 168 | white-space: nowrap; 169 | overflow: hidden; 170 | text-overflow: ellipsis; 171 | font-size: 0.7em; 172 | margin-top: 2px; 173 | color: #95a5a6; 174 | } 175 | 176 | .error-message { 177 | font-size: 12px; 178 | background: #e64b3f; 179 | color: #fff; 180 | padding: 5px; 181 | text-align: center; 182 | margin-bottom: 20px; 183 | -webkit-border-radius: 6px; 184 | -moz-border-radius: 6px; 185 | border-radius: 6px; 186 | } 187 | 188 | .error-message a { 189 | outline: none; 190 | color: inherit; 191 | } 192 | 193 | .error-log-link { 194 | font-size: 11px; 195 | text-align: center; 196 | color: #bdc3c7; 197 | } 198 | 199 | .error-log-link > a { 200 | color: inherit; 201 | } 202 | 203 | .error-log-link > a:hover { 204 | color: #e64b3f; 205 | } 206 | 207 | .offscreen { 208 | position: absolute; 209 | left: -9999px; 210 | visibility: hidden; 211 | } 212 | 213 | .inactive-overlay { 214 | position: absolute; 215 | top: 0; 216 | right: 0; 217 | bottom: 0; 218 | left: 0; 219 | background: #fff; 220 | z-index: 1; 221 | text-align: center; 222 | padding-top: 200px; 223 | transition: opacity 0.3s; 224 | } 225 | 226 | .inactive-overlay > p { 227 | margin: 0 0 0.3em; 228 | } 229 | 230 | .popup_enabled .inactive-overlay { 231 | opacity: 0; 232 | pointer-events: none; 233 | } 234 | 235 | .getting-started-link { 236 | text-decoration: none; 237 | font-size: 1.2em; 238 | color: #999; 239 | } 240 | 241 | .getting-started-link:hover { 242 | color: #000; 243 | } 244 | 245 | .getting-started-link::after { 246 | font-family: 'Font Awesome'; 247 | content: '\f105'; 248 | margin-left: 5px; 249 | display: inline-block; 250 | margin-top: 0.1em; 251 | } 252 | 253 | .livestyle-homepage-link { 254 | font-size: 0.8em; 255 | text-decoration: none; 256 | color: #3498db; 257 | } 258 | 259 | .livestyle-homepage-link:hover { 260 | color: #2980b9; 261 | } 262 | 263 | .message_file-proto { 264 | color: #95a5a6; 265 | font-size: 0.85em; 266 | } 267 | 268 | .hidden { 269 | display: none; 270 | } -------------------------------------------------------------------------------- /scripts/controllers/remote-view.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Remote View controller: handles communication with LiveStyle app 3 | * regarding Remote View sessions. 4 | */ 5 | 'use strict'; 6 | 7 | import {extend} from '../lib/utils'; 8 | import * as client from '../lib/client-expect'; 9 | 10 | const RV_REQUEST_SESSION_URL = 'http://livestyle.io:9000/connect/'; 11 | 12 | export function checkConnection() { 13 | return client.send('rv-ping') 14 | .expect('rv-pong') 15 | .then(null, function(err) { 16 | if (isExpectError(err)) { 17 | err = new Error('No connection with LiveStyle app'); 18 | err.code = 'ERVNOCONNECTION'; 19 | } 20 | throw err; 21 | }); 22 | } 23 | 24 | export function getSession(localSite) { 25 | return checkConnection() 26 | .then(function() { 27 | console.log('connection is active, get session for', localSite); 28 | return client.send('rv-get-session', {localSite}) 29 | .expect('rv-session', data => data.localSite === localSite); 30 | }) 31 | .then(null, function(err) { 32 | if (isExpectError(err)) { 33 | err = new Error(`No active session for ${localSite}`); 34 | err.code = 'ERVNOSESSION'; 35 | } 36 | throw err; 37 | }); 38 | } 39 | 40 | export function closeSession(localSite) { 41 | client.send('rv-close-session', {localSite}); 42 | } 43 | 44 | export function createSession(localSite) { 45 | return getSession(localSite) 46 | .then(function(resp) { 47 | if (resp.error) { 48 | // no valid session, create it 49 | return getUserToken(localSite) 50 | .then(createHTTPServerIfRequired) 51 | .then(requestRvSession) 52 | .then(function(payload) { 53 | return client.send('rv-create-session', payload) 54 | .expect('rv-session', 15000, data => data.localSite === localSite); 55 | }); 56 | } 57 | }); 58 | } 59 | 60 | /** 61 | * Event router for Remote View messages 62 | */ 63 | export function router(message, sender, callback) { 64 | var data = message.data; 65 | var errResponse = function(err) { 66 | callback(errorJSON(err)); 67 | }; 68 | 69 | switch (message.name) { 70 | case 'rv-check-connection': 71 | checkConnection() 72 | .then(() => callback({connected: true}), () => callback({connected: false})); 73 | return true; 74 | case 'rv-get-session': 75 | getSession(data.localSite) 76 | .then(callback, errResponse); 77 | return true; 78 | case 'rv-create-session': 79 | createSession(data.localSite) 80 | .then(callback, errResponse); 81 | return true; 82 | case 'rv-close-session': 83 | closeSession(data.localSite); 84 | break; 85 | } 86 | } 87 | 88 | /** 89 | * Check if user granted `identity` permission for current extension. 90 | * `identity` permission allows fetching user token 91 | */ 92 | function checkIdentityPermission() { 93 | return new Promise(function(resolve, reject) { 94 | var payload = { 95 | permissions: ['identity'] 96 | }; 97 | 98 | chrome.permissions.contains(payload, function(result) { 99 | if (result) { 100 | return resolve(); 101 | } 102 | 103 | // no permission to user identity, request it 104 | chrome.permissions.request(payload, function(granted) { 105 | if (granted) { 106 | resolve(); 107 | } else { 108 | var err = new Error('User rejected identity permission'); 109 | err.code = 'ERVIDENTITYPERM'; 110 | reject(err); 111 | } 112 | }); 113 | }); 114 | }); 115 | } 116 | 117 | function getUserToken(localSite, oldToken) { 118 | return new Promise(function(resolve, reject) { 119 | var getToken = function() { 120 | chrome.identity.getAuthToken({interactive: true}, function(token) { 121 | if (chrome.runtime.lastError) { 122 | var err = new Error(`Unable to fetch auth token: ${chrome.runtime.lastError.message}`); 123 | err.code = 'ERVTOKEN'; 124 | return reject(err); 125 | } 126 | 127 | resolve({localSite, token, retry: !!oldToken}); 128 | }); 129 | }; 130 | 131 | if (oldToken) { 132 | chrome.identity.removeCachedAuthToken({token: oldToken}, getToken); 133 | } else { 134 | getToken(); 135 | } 136 | }); 137 | } 138 | 139 | function requestRvSession(payload) { 140 | var errMessage = 'Unable to create session, Remote View server is not available. Please try again later.'; 141 | 142 | return fetch(RV_REQUEST_SESSION_URL, { 143 | method: 'POST', 144 | headers: { 145 | Authorization: 'google ' + payload.token, 146 | Accept: 'application/json', 147 | 'Content-Type': 'application/json' 148 | }, 149 | body: JSON.stringify({ 150 | localSite: payload.localSite 151 | }) 152 | }) 153 | .then(function(res) { 154 | if (res.ok) { 155 | return res.json(); 156 | } 157 | 158 | if (res.status === 401 && !payload.retry) { 159 | // unauthorized request, might be because of expired token 160 | return getUserToken(payload.localSite, payload.token) 161 | .then(requestRvSession); 162 | } 163 | 164 | // unable to handle this response, fail with JSON data 165 | return res.json() 166 | .then(function(data) { 167 | var err = new Error(data && data.error ? data.error.message : errMessage); 168 | err.code = res.status; 169 | throw err; 170 | }); 171 | }, function() { 172 | var err = new Error(errMessage); 173 | err.code = 'ERVSESSION'; 174 | throw err; 175 | }); 176 | } 177 | 178 | function createHTTPServerIfRequired(payload) { 179 | if (!/^file:/.test(payload.localSite)) { 180 | return Promise.resolve(payload); 181 | } 182 | 183 | var docroot = payload.localSite; 184 | console.log('create HTTP server for %s', docroot); 185 | return client.send('rv-create-http-server', {docroot}) 186 | .expect('rv-http-server', data => data.docroot === docroot) 187 | .then(data => extend(payload, {localSite: data.origin})); 188 | } 189 | 190 | function isExpectError(err) { 191 | return err && err.code === 'EEXPECTTIMEOUT'; 192 | } 193 | 194 | function errorJSON(err) { 195 | var json = {}; 196 | if (err instanceof Error) { 197 | json.error = err.message; 198 | if (err.code) { 199 | json.errorCode = err.code; 200 | } 201 | } else if (typeof err === 'string') { 202 | json.error = err; 203 | } else if (err && typeof err === object) { 204 | json.error = err.error; 205 | } 206 | 207 | if (!json.error) { 208 | json.error = 'Unknown error format'; 209 | } 210 | 211 | return json; 212 | } -------------------------------------------------------------------------------- /scripts/content-script.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import * as cssom from 'livestyle-cssom-patcher'; 4 | import shadowCSS from './helpers/shadow-css'; 5 | import origin from './helpers/origin'; 6 | 7 | var pendingShadowCSSPatches = []; 8 | 9 | function $$(sel, context) { 10 | var items = (context || document).querySelectorAll(sel); 11 | return Array.prototype.slice.call(items, 0); 12 | } 13 | 14 | function applyPatches(url, patches) { 15 | if (!url || !patches || !patches.length) { 16 | return; 17 | } 18 | 19 | var stylesheets = cssom.stylesheets(); 20 | var originalCSS = stylesheets[url]; 21 | if (!originalCSS) { 22 | // no such stylessheet, aborting 23 | return; 24 | } 25 | 26 | if (originalCSS.cssRules) { 27 | // console.log('apply patch %o on %o', patches, stylesheets[url]); 28 | cssom.patch(stylesheets[url], patches); 29 | } else { 30 | // Empty `cssRules` property means security restrictions applied 31 | // by Chrome. Try Shadow CSS 32 | var pending = !!pendingShadowCSSPatches.length; 33 | pendingShadowCSSPatches = pendingShadowCSSPatches.concat(patches); 34 | if (pending) { 35 | // there’s already a request for patching shadow, simply delegate 36 | // new patches to it 37 | return; 38 | } 39 | 40 | shadowCSS(url).then(css => { 41 | // Empty pending patches as soon as possible so new patching request 42 | // can trigger new patching session even there was an error during 43 | // CSSOM syncing 44 | var patches = pendingShadowCSSPatches.slice(0); 45 | pendingShadowCSSPatches.length = 0; 46 | cssom.patch(css, patches).forEach(item => { 47 | if (item.action === 'delete') { 48 | originalCSS.deleteRule(item.index); 49 | } else if (item.action === 'insert') { 50 | originalCSS.insertRule(item.value, item.index); 51 | } else if (item.action === 'update') { 52 | originalCSS.deleteRule(item.index); 53 | originalCSS.insertRule(item.value, item.index); 54 | } 55 | }); 56 | }, err => console.error(err)); 57 | } 58 | } 59 | 60 | function userStylesheets() { 61 | return $$('link[rel="stylesheet"]').filter(link => !!lsId(link)); 62 | } 63 | 64 | /** 65 | * Creates `amount` new stylesheets on current page 66 | * @param {Number} amount How many stylesheets should be created 67 | * @returns {Array} Array of stylesheet URLs 68 | */ 69 | function generateUserStylesheets(url) { 70 | if (!Array.isArray(url)) { 71 | url = [url]; 72 | } 73 | 74 | var result = {}; 75 | url.forEach(function(internalUrl) { 76 | console.log('Creating stylesheet', internalUrl); 77 | var uss = createUserStylesheet(); 78 | uss.dataset.livestyleId = internalUrl; 79 | document.head.appendChild(uss); 80 | result[internalUrl] = uss.href; 81 | }); 82 | 83 | return result; 84 | } 85 | 86 | function createUserStylesheet(content) { 87 | var blob = new Blob([content || ''], {type: 'text/css'}); 88 | var url = URL.createObjectURL(blob); 89 | var link = document.createElement('link'); 90 | link.rel = 'stylesheet'; 91 | link.href = url; 92 | return link; 93 | } 94 | 95 | /** 96 | * Removes stylesheet with given URL (blob or internal LiveStyle ID) 97 | * @param {String} url 98 | */ 99 | function removeUserStylesheet(url) { 100 | console.log('Removing stylesheet', url); 101 | userStylesheets().forEach(function(link) { 102 | if (link.href === url || lsId(link) == url) { 103 | removeLink(link); 104 | } 105 | }); 106 | } 107 | 108 | function removeLink(link) { 109 | link.parentNode.removeChild(link); 110 | window.URL.revokeObjectURL(link.href); 111 | } 112 | 113 | /** 114 | * Validates given user stylesheets: adds missing and removes redundant ones 115 | * @param {String} url Internal URL or array of URLs 116 | * @return {Object} Hash where key is given URL and value if stylesheets’ 117 | * blob URL 118 | */ 119 | function validateUserStylesheets(url) { 120 | var result = {}; 121 | var cur = userStylesheets(); 122 | if (!Array.isArray(url)) { 123 | url = [url]; 124 | } 125 | 126 | // remove redundant 127 | var exists = {}; 128 | cur.forEach(function(item) { 129 | var id = lsId(item); 130 | if (!~url.indexOf(id)) { 131 | removeLink(item); 132 | } else { 133 | exists[id] = item.href; 134 | } 135 | }); 136 | 137 | // create missing 138 | var missing = generateUserStylesheets(url.filter(item => !(item in exists))); 139 | 140 | // re-create result hash with keys in right order 141 | var result = {}; 142 | url.forEach(function(item) { 143 | result[item] = exists[item] || missing[item]; 144 | }); 145 | return result; 146 | } 147 | 148 | /** 149 | * Findes all stylesheets in given context, including 150 | * nested `@import`s 151 | * @param {StyleSheetList} ctx List of stylesheets to scan 152 | * @return {Array} Array of stylesheet URLs 153 | */ 154 | function findStyleSheets(ctx, out) { 155 | out = out || []; 156 | for (var i = 0, il = ctx.length, url, item; i < il; i++) { 157 | item = ctx[i]; 158 | url = item.href; 159 | if (~out.indexOf(url) || lsId(item.ownerNode)) { 160 | // stylesheet already added or it’s a user stylesheet 161 | continue; 162 | } 163 | 164 | if (url) { 165 | out.push(url); 166 | } 167 | 168 | // find @import rules 169 | if (item.cssRules) { 170 | for (var j = 0, jl = item.cssRules.length; j < jl; j++) { 171 | if (item.cssRules[j].type == 3) { 172 | findStyleSheets([item.cssRules[j].styleSheet], out); 173 | } 174 | } 175 | } 176 | } 177 | 178 | return out; 179 | } 180 | 181 | function lsId(node) { 182 | var dataset = (node && node.dataset) || {}; 183 | return dataset.livestyleId; 184 | } 185 | 186 | // disable in-page LiveStyle extension 187 | document.documentElement.setAttribute('data-livestyle-extension', 'available'); 188 | 189 | chrome.runtime.onMessage.addListener(function(message, sender, callback) { 190 | if (!message) { 191 | return; 192 | } 193 | 194 | var data = message.data; 195 | switch (message.name) { 196 | case 'apply-cssom-patch': 197 | return applyPatches(data.stylesheetUrl, data.patches); 198 | case 'create-user-stylesheet': 199 | callback(generateUserStylesheets(data.url)); 200 | return true; 201 | case 'remove-user-stylesheet': 202 | return removeUserStylesheet(data.url); 203 | case 'validate-user-stylesheet': 204 | callback(validateUserStylesheets(data.url)); 205 | return true; 206 | case 'get-stylesheets': 207 | callback(findStyleSheets(document.styleSheets)); 208 | return true; 209 | case 'get-origin': 210 | callback(origin()); 211 | return true; 212 | } 213 | }); -------------------------------------------------------------------------------- /scripts/popup.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Main popup controller 3 | */ 4 | 'use strict'; 5 | 6 | import compactPaths from './helpers/compact-paths'; 7 | import * as selectBox from './ui/select-box'; 8 | import {$, $$, copy, closest, toDom} from './lib/utils'; 9 | import setupRemoteView from './ui/remote-view'; 10 | import trackEvent from './lib/tracker'; 11 | 12 | var updateDirections = ['both', 'to browser', 'to editor']; 13 | var currentModel = null; 14 | 15 | trackEvent('Popup', 'open'); 16 | 17 | function sendMessage(name, data) { 18 | if (typeof chrome !== 'undefined') { 19 | chrome.runtime.sendMessage({ 20 | name: name, 21 | data: data 22 | }); 23 | } 24 | } 25 | 26 | function populateSelect(name, options, selected) { 27 | var opt = options.map((option, i) => { 28 | var selectedAttr = (selected === i || selected === option.value) 29 | ? ' selected="selected"' 30 | : ''; 31 | return ``; 32 | }); 33 | 34 | return ``; 38 | } 39 | 40 | function renderFileItem(label, value, editorFilesView, isUserFile) { 41 | var parts = label.split('?'); 42 | label = parts.shift(); 43 | if (isUserFile) { 44 | label += ''; 45 | } 46 | if (parts.length) { 47 | label += `${parts.join('?')}`; 48 | } 49 | 50 | return `
  • 51 |
    ${label}
    52 |
    ${editorFilesView}
    53 |
  • `; 54 | } 55 | 56 | function renderFileList() { 57 | var browserFiles = compactPaths(currentModel.get('browserFiles') || []); 58 | var editorFiles = compactPaths(currentModel.get('editorFiles') || []); 59 | var userStylesheets = currentModel.get('userStylesheets') || {}; 60 | var assocs = currentModel.associations(); 61 | 62 | var html = ''; 66 | 67 | var fileList = toDom(html); 68 | var prevFileList = $('.file-list'); 69 | var parent = prevFileList.parentNode; 70 | parent.insertBefore(fileList, prevFileList); 71 | parent.removeChild(prevFileList); 72 | selectBox.init(fileList); 73 | $$('select', fileList).forEach(select => { 74 | select.addEventListener('change', function() { 75 | var assocs = copy(currentModel.get('assocs')); 76 | assocs[select.name] = select.value; 77 | currentModel.set('assocs', assocs); 78 | }, false); 79 | }); 80 | 81 | return fileList; 82 | } 83 | 84 | function renderUpdateDirection() { 85 | var dir = currentModel.get('updateDirection') || updateDirections[0]; 86 | $('.update-direction').dataset.direction = dir; 87 | } 88 | 89 | function cycleUpdateDirection() { 90 | var elem = $('.update-direction'); 91 | var dir = elem.dataset.direction || updateDirections[0]; 92 | var next = (updateDirections.indexOf(dir) + 1) % updateDirections.length; 93 | currentModel.set('updateDirection', updateDirections[next]); 94 | } 95 | 96 | function toggleEnabledState() { 97 | currentModel.set('enabled', $('#fld-enabled').checked); 98 | } 99 | 100 | function renderEnabledState() { 101 | var enabled = !!currentModel.get('enabled'); 102 | $('.popup').classList.toggle('popup_enabled', enabled); 103 | $('#fld-enabled').checked = enabled; 104 | } 105 | 106 | /** 107 | * Displays temporary message about errors happened 108 | * in LiveStyle patcher 109 | * @param {Boolean} hasError 110 | */ 111 | function toggleErrorStateMessage(hasError) { 112 | $('.error-message').classList.toggle('hidden', !hasError); 113 | } 114 | 115 | /** 116 | * Displays permanent link on error log 117 | */ 118 | function showErrorLogLink() { 119 | $('.error-log-link').classList.remove('hidden'); 120 | } 121 | 122 | function setup() { 123 | $('#fld-enabled').addEventListener('change', toggleEnabledState); 124 | $('.update-direction').addEventListener('click', cycleUpdateDirection); 125 | $('.add-file').addEventListener('click', function(evt) { 126 | evt.stopPropagation(); 127 | sendMessage('add-user-stylesheet'); 128 | }); 129 | 130 | document.addEventListener('click', function(evt) { 131 | if (evt.target.classList.contains('file__remove')) { 132 | evt.stopPropagation(); 133 | var browserFile = closest(evt.target, '.file__browser'); 134 | sendMessage('remove-user-stylesheet', {url: browserFile.dataset.fullPath}); 135 | } 136 | }); 137 | } 138 | 139 | function setupModel(model) { 140 | currentModel = model; 141 | renderEnabledState(); 142 | renderFileList(); 143 | renderUpdateDirection(); 144 | model 145 | .on('update', renderFileList) 146 | .on('change:enabled', renderEnabledState) 147 | .on('change:updateDirection', renderUpdateDirection); 148 | } 149 | 150 | function resetModel() { 151 | currentModel 152 | .off('update', renderFileList) 153 | .off('change:enabled', renderEnabledState) 154 | .off('change:updateDirection', renderUpdateDirection); 155 | currentModel = null; 156 | } 157 | 158 | if (typeof chrome !== 'undefined' && chrome.runtime && chrome.runtime.onMessage) { 159 | chrome.runtime.onMessage.addListener(function(message) { 160 | if (message && message.name === 'log-updated') { 161 | showErrorLogLink(); 162 | } 163 | }); 164 | 165 | // bind model with view 166 | chrome.runtime.getBackgroundPage(function(bg) { 167 | var LiveStyle = bg.LiveStyle; 168 | var updateActivityState = () => { 169 | $('.popup').classList.toggle('status__no-editor', !LiveStyle.isActive()); 170 | }; 171 | 172 | // keep track of errors 173 | LiveStyle.errorStateTracker.on('change:error', toggleErrorStateMessage); 174 | toggleErrorStateMessage(LiveStyle.errorStateTracker.get('error')); 175 | 176 | if (LiveStyle.hasErrors()) { 177 | showErrorLogLink(); 178 | } 179 | 180 | updateActivityState(); 181 | setup(); 182 | LiveStyle.updateIconState(); 183 | LiveStyle.editorController.on('change:active', updateActivityState); 184 | LiveStyle.getCurrentModel(function(model, tab) { 185 | setupModel(model); 186 | setupRemoteView(model, $('.rv')); 187 | 188 | var displayFileWarning = /^file:/.test(model.get('origin') || '') && !LiveStyle.hasOpenedDevTools(tab.id); 189 | $('.message_file-proto').classList.toggle('hidden', !displayFileWarning); 190 | var popup = $('.popup'); 191 | popup.classList.toggle('status__is-chrome', /^chrome/.test(tab.url)); 192 | popup.classList.toggle('status__no-devtools', !LiveStyle.hasOpenedDevTools(tab.id)); 193 | popup.classList.toggle('status__needs-refresh', model.get('needsRefresh')); 194 | 195 | window.addEventListener('unload', function() { 196 | resetModel(); 197 | LiveStyle.editorController.off('change:active', updateActivityState); 198 | LiveStyle.errorStateTracker.off('change:error', toggleErrorStateMessage); 199 | }, false); 200 | }); 201 | }); 202 | } -------------------------------------------------------------------------------- /scripts/devtools/resources.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Resource manager for DevTools: handles updates and 3 | * patching of instected we page resources 4 | */ 5 | 'use strict'; 6 | 7 | import client from 'livestyle-client'; 8 | import {debounce} from '../lib/utils'; 9 | import crc32 from '../lib/crc32'; 10 | import EventEmitter from '../lib/event-emitter'; 11 | 12 | var stylesheets = {}; 13 | var reStylesheet = /^blob:|\.css$/; 14 | var emitter = new EventEmitter(); 15 | 16 | var on = emitter.on.bind(emitter); 17 | var off = emitter.off.bind(emitter); 18 | var emit = emitter.emit.bind(emitter); 19 | 20 | /** 21 | * Initial resource loader: retrieves all stylesheet resources 22 | * from inspected page and keeps them in `stylesheets` collection 23 | * for patching 24 | */ 25 | var loadStylesheets = initStylesheetLoader(); 26 | 27 | export {on, off, emit}; 28 | 29 | /** 30 | * Returns resource by its url 31 | * @param {String} url Resource URL 32 | * @param {Function} callback Callback invoked when resource 33 | * is fetched from backend 34 | */ 35 | export function get(url, callback) { 36 | log('Requested', url); 37 | loadStylesheets.then(function() { 38 | callback(stylesheets[url]); 39 | }); 40 | } 41 | 42 | export function applyPendingPatches(payload) { 43 | loadStylesheets.then(function() { 44 | Object.keys(payload).forEach(function(url) { 45 | if (url in stylesheets) { 46 | log('Apply pending patches on', url, payload[url]); 47 | stylesheets[url].patch(payload[url]); 48 | } 49 | }); 50 | }); 51 | } 52 | 53 | /** 54 | * Returns list of URLs of all available stylesheets 55 | */ 56 | export function list(callback) { 57 | loadStylesheets.then(function() { 58 | callback(Object.keys(stylesheets)); 59 | }); 60 | } 61 | 62 | export function reset() { 63 | // explicitly break reference for Resource 64 | Object.keys(stylesheets).forEach(key => stylesheets[key].reset()); 65 | stylesheets = {}; 66 | loadStylesheets = initStylesheetLoader(); 67 | } 68 | 69 | function initStylesheetLoader() { 70 | return new Promise(function(resolve, reject) { 71 | chrome.devtools.inspectedWindow.getResources(function(resources) { 72 | resources = resources.filter(isStylesheet); 73 | stylesheets = {}; 74 | 75 | var next = function() { 76 | if (!resources.length) { 77 | log('Loaded stylesheets:', Object.keys(stylesheets)); 78 | return resolve(stylesheets); 79 | } 80 | 81 | var res = resources.pop(); 82 | res.getContent(function(content) { 83 | stylesheets[res.url] = new Resource(res, content); 84 | next(); 85 | }); 86 | }; 87 | next(); 88 | }); 89 | }); 90 | } 91 | 92 | function isStylesheet(res) { 93 | return res.type 94 | ? (res.type === 'stylesheet' && res.url) 95 | : isStylesheetURL(res.url); 96 | } 97 | 98 | function isStylesheetURL(url) { 99 | return reStylesheet.test(url.split('?')[0]); 100 | } 101 | 102 | function log() { 103 | emit('log', Array.prototype.slice.call(arguments, 0)); 104 | } 105 | 106 | class Resource { 107 | constructor(reference, content) { 108 | this._content = ''; 109 | this._hash = null; 110 | this._patching = false; 111 | this._commitTimeout = null; 112 | 113 | this.reference = reference; 114 | this.content = content; 115 | this.pendingPatches = []; 116 | this._setInitialContent(); 117 | } 118 | 119 | get url() { 120 | return this.reference.url; 121 | } 122 | 123 | get content() { 124 | return this._content; 125 | } 126 | 127 | set content(value) { 128 | this._content = value || ''; 129 | this._hash = null; 130 | } 131 | 132 | get hash() { 133 | if (!this._hash) { 134 | this._hash = crc32(this.content); 135 | } 136 | return this._hash; 137 | } 138 | 139 | get isPatching() { 140 | return this._patching; 141 | } 142 | 143 | _setInitialContent() { 144 | client.send('initial-content', { 145 | uri: this.url, 146 | syntax: 'css', 147 | hash: this.hash, 148 | content: this.content 149 | }); 150 | } 151 | 152 | patch(patches) { 153 | if (patches) { 154 | this.pendingPatches = this.pendingPatches.concat(patches); 155 | } 156 | 157 | log('Patch request for', this.reference.url); 158 | if (!this.isPatching && this.pendingPatches.length) { 159 | this._patching = true; 160 | log('Applying patch on', this.url); 161 | client.send('apply-patch', { 162 | uri: this.url, 163 | syntax: 'css', 164 | hash: 'devtools', 165 | content: this.content, 166 | patches: this.pendingPatches 167 | }); 168 | this.pendingPatches = []; 169 | } 170 | } 171 | 172 | commitPatch(content, ranges) { 173 | // Resource commiting is very slow operation, especially combined 174 | // with preceding `apply-patch`/`patch` operations. If we commit 175 | // resource upon request, we can introduce “jank”: it may revert 176 | // changes already applied by much faster CSSOM updater. 177 | // Thus we have to postpone resource commiting as mush as possible to 178 | // apply only the most recent updates 179 | if (this._commitTimeout) { 180 | clearTimeout(this._commitTimeout); 181 | } 182 | 183 | log('Queueing patch commit for', this.url); 184 | this._commitTimeout = setTimeout(() => { 185 | this._commitTimeout = null; 186 | if (this.pendingPatches.length) { 187 | log('Pending patches, cancel current update for', this.url); 188 | // there are more recent updates waiting to be applied, skip 189 | // current update to not revert CSSOM updates and apply pending 190 | // patches since last patch() request 191 | this._patching = false; 192 | this.content = content; 193 | return this.patch(); 194 | } 195 | 196 | log('Request resource commit for', this.url); 197 | this.reference.setContent(content, true, err => { 198 | if (!err || err.code === 'OK') { 199 | log('Resource committed successfully for', this.url); 200 | this.content = content; 201 | this._setInitialContent(); 202 | } else { 203 | log('Error commiting new content for', this.url, err); 204 | } 205 | 206 | // apply pending patches since last patch() request 207 | this._patching = false; 208 | this.patch(); 209 | }); 210 | }, 200); 211 | } 212 | 213 | reset() { 214 | this.reference = this._content = this.pendingPatches = null; 215 | if (this._commitTimeout) { 216 | clearTimeout(this._commitTimeout); 217 | } 218 | } 219 | } 220 | 221 | chrome.devtools.inspectedWindow.onResourceContentCommitted.addListener(function(res, content) { 222 | var stylesheet = stylesheets[res.url]; 223 | if (stylesheet && !stylesheet.isPatching && stylesheet.content !== content) { 224 | // This update is coming from user update 225 | log('Resource committed, request diff for', res.url); 226 | stylesheet.content = content; 227 | client.send('calculate-diff', { 228 | uri: res.url, 229 | syntax: 'css', 230 | content: content, 231 | hash: crc32(content) 232 | }); 233 | emit('update', res.url, content); 234 | } 235 | }); 236 | 237 | chrome.devtools.inspectedWindow.onResourceAdded.addListener(function(res) { 238 | if (isStylesheet(res) && !stylesheets[res.url]) { 239 | res.getContent(function(content) { 240 | stylesheets[res.url] = new Resource(res, content); 241 | }); 242 | } 243 | }); 244 | 245 | 246 | // connect to LiveStyle server 247 | client.on('patch', function(data) { 248 | if (data.uri in stylesheets && data.hash === 'devtools') { 249 | stylesheets[data.uri].commitPatch(data.content, data.ranges); 250 | } 251 | }) 252 | .connect(); -------------------------------------------------------------------------------- /scripts/lib/tween.js: -------------------------------------------------------------------------------- 1 | function extend(obj) { 2 | for (var i = 1, il = arguments.length, source; i < il; i++) { 3 | source = arguments[i]; 4 | if (source) { 5 | for (var prop in source) { 6 | obj[prop] = source[prop]; 7 | } 8 | } 9 | } 10 | 11 | return obj; 12 | } 13 | 14 | var dummyFn = function() {}; 15 | var anims = []; 16 | var idCounter = 0; 17 | 18 | var defaults = { 19 | duration: 500, // ms 20 | delay: 0, 21 | easing: 'linear', 22 | start: dummyFn, 23 | step: dummyFn, 24 | complete: dummyFn, 25 | autostart: true, 26 | reverse: false 27 | }; 28 | 29 | export default function(options) { 30 | return new Tween(options); 31 | } 32 | 33 | /** 34 | * Get or set default value 35 | * @param {String} name 36 | * @param {Object} value 37 | * @return {Object} 38 | */ 39 | export function defaults(name, value) { 40 | if (typeof value != 'undefined') { 41 | defaults[name] = value; 42 | } 43 | 44 | return defaults[name]; 45 | } 46 | 47 | /** 48 | * Returns all active animation objects. 49 | * For debugging mostly 50 | * @return {Array} 51 | */ 52 | export function _all() { 53 | return anims; 54 | } 55 | 56 | export function stop() { 57 | anims.forEach(anim => anim.stop()); 58 | anims.length = 0; 59 | } 60 | 61 | export var easings = { 62 | linear(t, b, c, d) { 63 | return c * t / d + b; 64 | }, 65 | inQuad(t, b, c, d) { 66 | return c*(t/=d)*t + b; 67 | }, 68 | outQuad(t, b, c, d) { 69 | return -c *(t/=d)*(t-2) + b; 70 | }, 71 | inOutQuad(t, b, c, d) { 72 | if((t/=d/2) < 1) return c/2*t*t + b; 73 | return -c/2 *((--t)*(t-2) - 1) + b; 74 | }, 75 | inCubic(t, b, c, d) { 76 | return c*(t/=d)*t*t + b; 77 | }, 78 | outCubic(t, b, c, d) { 79 | return c*((t=t/d-1)*t*t + 1) + b; 80 | }, 81 | inOutCubic(t, b, c, d) { 82 | if((t/=d/2) < 1) return c/2*t*t*t + b; 83 | return c/2*((t-=2)*t*t + 2) + b; 84 | }, 85 | inExpo(t, b, c, d) { 86 | return(t==0) ? b : c * Math.pow(2, 10 *(t/d - 1)) + b - c * 0.001; 87 | }, 88 | outExpo(t, b, c, d) { 89 | return(t==d) ? b+c : c * 1.001 *(-Math.pow(2, -10 * t/d) + 1) + b; 90 | }, 91 | inOutExpo(t, b, c, d) { 92 | if(t==0) return b; 93 | if(t==d) return b+c; 94 | if((t/=d/2) < 1) return c/2 * Math.pow(2, 10 *(t - 1)) + b - c * 0.0005; 95 | return c/2 * 1.0005 *(-Math.pow(2, -10 * --t) + 2) + b; 96 | }, 97 | inElastic(t, b, c, d, a, p) { 98 | var s; 99 | if(t==0) return b; if((t/=d)==1) return b+c; if(!p) p=d*.3; 100 | if(!a || a < Math.abs(c)) { a=c; s=p/4; } else s = p/(2*Math.PI) * Math.asin(c/a); 101 | return -(a*Math.pow(2,10*(t-=1)) * Math.sin((t*d-s)*(2*Math.PI)/p )) + b; 102 | }, 103 | outElastic(t, b, c, d, a, p) { 104 | var s; 105 | if(t==0) return b; if((t/=d)==1) return b+c; if(!p) p=d*.3; 106 | if(!a || a < Math.abs(c)) { a=c; s=p/4; } else s = p/(2*Math.PI) * Math.asin(c/a); 107 | return(a*Math.pow(2,-10*t) * Math.sin((t*d-s)*(2*Math.PI)/p ) + c + b); 108 | }, 109 | inOutElastic(t, b, c, d, a, p) { 110 | var s; 111 | if(t==0) return b; 112 | if((t/=d/2)==2) return b+c; 113 | if(!p) p=d*(.3*1.5); 114 | if(!a || a < Math.abs(c)) { a=c; s=p/4; } else s = p/(2*Math.PI) * Math.asin(c/a); 115 | if(t < 1) return -.5*(a*Math.pow(2,10*(t-=1)) * Math.sin((t*d-s)*(2*Math.PI)/p )) + b; 116 | return a*Math.pow(2,-10*(t-=1)) * Math.sin((t*d-s)*(2*Math.PI)/p )*.5 + c + b; 117 | }, 118 | inBack(t, b, c, d, s) { 119 | if(s == undefined) s = 1.70158; 120 | return c*(t/=d)*t*((s+1)*t - s) + b; 121 | }, 122 | outBack(t, b, c, d, s) { 123 | if(s == undefined) s = 1.70158; 124 | return c*((t=t/d-1)*t*((s+1)*t + s) + 1) + b; 125 | }, 126 | inOutBack(t, b, c, d, s) { 127 | if(s == undefined) s = 1.70158; 128 | if((t/=d/2) < 1) return c/2*(t*t*(((s*=(1.525))+1)*t - s)) + b; 129 | return c/2*((t-=2)*t*(((s*=(1.525))+1)*t + s) + 2) + b; 130 | }, 131 | inBounce(t, b, c, d) { 132 | return c - this.outBounce(t, d-t, 0, c, d) + b; 133 | }, 134 | outBounce(t, b, c, d) { 135 | if((t/=d) <(1/2.75)) { 136 | return c*(7.5625*t*t) + b; 137 | } else if(t <(2/2.75)) { 138 | return c*(7.5625*(t-=(1.5/2.75))*t + .75) + b; 139 | } else if(t <(2.5/2.75)) { 140 | return c*(7.5625*(t-=(2.25/2.75))*t + .9375) + b; 141 | } else { 142 | return c*(7.5625*(t-=(2.625/2.75))*t + .984375) + b; 143 | } 144 | }, 145 | inOutBounce(t, b, c, d) { 146 | if(t < d/2) return this.inBounce(t*2, 0, c, d) * .5 + b; 147 | return this.outBounce(t*2-d, 0, c, d) * .5 + c*.5 + b; 148 | }, 149 | outHard(t, b, c, d) { 150 | var ts = (t/=d)*t; 151 | var tc = ts*t; 152 | return b + c*(1.75*tc*ts + -7.4475*ts*ts + 12.995*tc + -11.595*ts + 5.2975*t); 153 | } 154 | }; 155 | 156 | export class Tween { 157 | constructor(options) { 158 | this.options = extend({}, defaults, options); 159 | 160 | var e = this.options.easing; 161 | if (typeof e == 'string') { 162 | if (!easings[e]) { 163 | throw new Error('Unknown "' + e + '" easing function'); 164 | } 165 | this.options.easing = easings[e]; 166 | } 167 | 168 | if (typeof this.options.easing !== 'function') { 169 | throw 'Easing should be a function'; 170 | } 171 | 172 | this._id = 'tw' + (idCounter++); 173 | 174 | if (this.options.autostart) { 175 | this.start(); 176 | } 177 | } 178 | 179 | /** 180 | * Start animation from the beginning 181 | */ 182 | start() { 183 | if (!this.animating) { 184 | this.pos = 0; 185 | this.startTime = Date.now() + (this.options.delay || 0); 186 | this.infinite = this.options.duration === 'infinite'; 187 | this.endTime = this.infinite ? 0 : this.startTime + this.options.duration; 188 | this.animating = true; 189 | this.options.start(this); 190 | addToQueue(this); 191 | } 192 | 193 | return this; 194 | } 195 | 196 | /** 197 | * Stop animation 198 | */ 199 | stop() { 200 | if (this.animating) { 201 | this.animating = false; 202 | if (this.options.complete) { 203 | this.options.complete(this); 204 | } 205 | } 206 | return this; 207 | } 208 | 209 | toggle() { 210 | if (this.animating) { 211 | this.stop(); 212 | } else { 213 | this.start(); 214 | } 215 | } 216 | } 217 | 218 | function mainLoop() { 219 | if (!anims.length) { 220 | // no animations left, stop polling 221 | return; 222 | } 223 | 224 | var now = Date.now(); 225 | var filtered = [], tween, opt; 226 | 227 | // do not use Array.filter() of _.filter() function 228 | // since tween’s callbacks can add new animations 229 | // in runtime. In this case, filter function will loose 230 | // newly created animation 231 | for (var i = 0; i < anims.length; i++) { 232 | tween = anims[i]; 233 | 234 | if (!tween.animating) { 235 | continue; 236 | } 237 | 238 | opt = tween.options; 239 | 240 | if (tween.startTime > now) { 241 | filtered.push(tween); 242 | continue; 243 | } 244 | 245 | if (tween.infinite) { 246 | // opt.step.call(tween, 0); 247 | opt.step(0, tween); 248 | filtered.push(tween); 249 | } else if (tween.pos === 1 || tween.endTime <= now) { 250 | tween.pos = 1; 251 | // opt.step.call(tween, opt.reverse ? 0 : 1); 252 | opt.step(opt.reverse ? 0 : 1, tween); 253 | tween.stop(); 254 | } else { 255 | tween.pos = opt.easing(now - tween.startTime, 0, 1, opt.duration); 256 | // opt.step.call(tween, opt.reverse ? 1 - tween.pos : tween.pos); 257 | opt.step(opt.reverse ? 1 - tween.pos : tween.pos, tween); 258 | filtered.push(tween); 259 | } 260 | } 261 | 262 | anims = filtered; 263 | 264 | if (anims.length) { 265 | requestAnimationFrame(mainLoop); 266 | } 267 | } 268 | 269 | function addToQueue(tween) { 270 | if (anims.indexOf(tween) === -1) { 271 | anims.push(tween); 272 | if (anims.length === 1) { 273 | mainLoop(); 274 | } 275 | } 276 | } -------------------------------------------------------------------------------- /scripts/lib/model.js: -------------------------------------------------------------------------------- 1 | /** 2 | * A simple model object: a very simplified version of 3 | * Backbone.Model 4 | */ 5 | 'use strict'; 6 | 7 | import EventEmitter from './event-emitter'; 8 | import {copy} from './utils'; 9 | 10 | var hasOwnProperty = Object.prototype.hasOwnProperty; 11 | 12 | export default class Model extends EventEmitter { 13 | constructor() { 14 | super(); 15 | if (!(this instanceof Model)) { 16 | return new Model(); 17 | } 18 | this.attributes = {}; 19 | } 20 | 21 | get(key) { 22 | return this.attributes[key]; 23 | } 24 | 25 | set(key, val, options) { 26 | var attr, attrs, unset, changes, silent, changing, prev, current; 27 | if (key == null) { 28 | return this; 29 | } 30 | 31 | // Handle both `"key", value` and `{key: value}` -style arguments. 32 | if (typeof key === 'object') { 33 | attrs = key; 34 | options = val; 35 | } else { 36 | (attrs = {})[key] = val; 37 | } 38 | 39 | options || (options = {}); 40 | 41 | // Extract attributes and options. 42 | unset = options.unset; 43 | silent = options.silent; 44 | changes = []; 45 | changing = this._changing; 46 | this._changing = true; 47 | 48 | if (!changing) { 49 | this._previousAttributes = copy(this.attributes); 50 | this.changed = {}; 51 | } 52 | current = this.attributes; 53 | prev = this._previousAttributes; 54 | 55 | // For each `set` attribute, update or delete the current value. 56 | for (attr in attrs) { 57 | val = attrs[attr]; 58 | if (!isEqual(current[attr], val)) { 59 | changes.push(attr); 60 | } 61 | if (!isEqual(prev[attr], val)) { 62 | this.changed[attr] = val; 63 | } else { 64 | delete this.changed[attr]; 65 | } 66 | unset ? delete current[attr] : current[attr] = val; 67 | } 68 | 69 | // Trigger all relevant attribute changes. 70 | if (!silent) { 71 | if (changes.length) { 72 | this._pending = options; 73 | } 74 | for (var i = 0, l = changes.length; i < l; i++) { 75 | this.emit('change:' + changes[i], this, current[changes[i]], options); 76 | } 77 | } 78 | 79 | // You might be wondering why there's a `while` loop here. Changes can 80 | // be recursively nested within `"change"` events. 81 | if (changing) { 82 | return this; 83 | } 84 | 85 | if (!silent) { 86 | while (this._pending) { 87 | options = this._pending; 88 | this._pending = false; 89 | this.emit('change', this, options); 90 | } 91 | } 92 | 93 | this._pending = false; 94 | this._changing = false; 95 | return this; 96 | } 97 | 98 | // Remove an attribute from the model, firing `"change"`. `unset` is a noop 99 | // if the attribute doesn't exist. 100 | unset(attr, options) { 101 | return this.set(attr, void 0, copy(options, {unset: true})); 102 | } 103 | 104 | // Clear all attributes on the model, firing `"change"`. 105 | clear(options) { 106 | var attrs = {}; 107 | for (var key in this.attributes) { 108 | attrs[key] = void 0; 109 | } 110 | return this.set(attrs, copy(options, {unset: true})); 111 | } 112 | 113 | // Determine if the model has changed since the last `"change"` event. 114 | // If you specify an attribute name, determine if that attribute has changed. 115 | hasChanged(attr) { 116 | if (attr == null) { 117 | return !isEmpty(this.changed); 118 | } 119 | return has(this.changed, attr); 120 | } 121 | 122 | toJSON() { 123 | return copy(this.attributes); 124 | } 125 | 126 | destroy() { 127 | this.off(); 128 | } 129 | } 130 | 131 | function has(obj, key) { 132 | return obj != null && hasOwnProperty.call(obj, key); 133 | } 134 | 135 | // Perform a deep comparison to check if two objects are equal. 136 | function isEqual(a, b) { 137 | return eq(a, b, [], []); 138 | } 139 | 140 | // Is a given array, string, or object empty? 141 | // An "empty" object has no enumerable own-properties. 142 | function isEmpty(obj) { 143 | if (obj == null) { 144 | return true; 145 | } 146 | if (Array.isArray(obj) || typeof obj === 'string') { 147 | return obj.length === 0; 148 | } 149 | for (var key in obj) if (has(obj, key)) { 150 | return false; 151 | } 152 | 153 | return true; 154 | } 155 | 156 | // Internal recursive comparison function for `isEqual`. 157 | function eq(a, b, aStack, bStack) { 158 | // Identical objects are equal. `0 === -0`, but they aren't identical. 159 | // See the [Harmony `egal` proposal](http://wiki.ecmascript.org/doku.php?id=harmony:egal). 160 | if (a === b) return a !== 0 || 1 / a === 1 / b; 161 | // A strict comparison is necessary because `null == undefined`. 162 | if (a == null || b == null) return a === b; 163 | 164 | // Compare `[[Class]]` names. 165 | var className = toString.call(a); 166 | if (className !== toString.call(b)) return false; 167 | switch (className) { 168 | // Strings, numbers, regular expressions, dates, and booleans are compared by value. 169 | case '[object RegExp]': 170 | // RegExps are coerced to strings for comparison (Note: '' + /a/i === '/a/i') 171 | case '[object String]': 172 | // Primitives and their corresponding object wrappers are equivalent; thus, `"5"` is 173 | // equivalent to `new String("5")`. 174 | return '' + a === '' + b; 175 | case '[object Number]': 176 | // `NaN`s are equivalent, but non-reflexive. 177 | // Object(NaN) is equivalent to NaN 178 | if (+a !== +a) return +b !== +b; 179 | // An `egal` comparison is performed for other numeric values. 180 | return +a === 0 ? 1 / +a === 1 / b : +a === +b; 181 | case '[object Date]': 182 | case '[object Boolean]': 183 | // Coerce dates and booleans to numeric primitive values. Dates are compared by their 184 | // millisecond representations. Note that invalid dates with millisecond representations 185 | // of `NaN` are not equivalent. 186 | return +a === +b; 187 | } 188 | if (typeof a != 'object' || typeof b != 'object') return false; 189 | // Assume equality for cyclic structures. The algorithm for detecting cyclic 190 | // structures is adapted from ES 5.1 section 15.12.3, abstract operation `JO`. 191 | var length = aStack.length; 192 | while (length--) { 193 | // Linear search. Performance is inversely proportional to the number of 194 | // unique nested structures. 195 | if (aStack[length] === a) return bStack[length] === b; 196 | } 197 | // Objects with different constructors are not equivalent, but `Object`s 198 | // from different frames are. 199 | var aCtor = a.constructor, bCtor = b.constructor; 200 | if ( 201 | aCtor !== bCtor && 202 | // Handle Object.create(x) cases 203 | 'constructor' in a && 'constructor' in b && 204 | !(typeof aCtor === 'function' && aCtor instanceof aCtor && 205 | typeof bCtor === 'function' && bCtor instanceof bCtor) 206 | ) { 207 | return false; 208 | } 209 | // Add the first object to the stack of traversed objects. 210 | aStack.push(a); 211 | bStack.push(b); 212 | var size, result; 213 | // Recursively compare objects and arrays. 214 | if (className === '[object Array]') { 215 | // Compare array lengths to determine if a deep comparison is necessary. 216 | size = a.length; 217 | result = size === b.length; 218 | if (result) { 219 | // Deep compare the contents, ignoring non-numeric properties. 220 | while (size--) { 221 | if (!(result = eq(a[size], b[size], aStack, bStack))) break; 222 | } 223 | } 224 | } else { 225 | // Deep compare objects. 226 | var keys = Object.keys(a), key; 227 | size = keys.length; 228 | // Ensure that both objects contain the same number of properties before comparing deep equality. 229 | result = Object.keys(b).length === size; 230 | if (result) { 231 | while (size--) { 232 | // Deep compare each member 233 | key = keys[size]; 234 | if (!(result = has(b, key) && eq(a[key], b[key], aStack, bStack))) break; 235 | } 236 | } 237 | } 238 | // Remove the first object from the stack of traversed objects. 239 | aStack.pop(); 240 | bStack.pop(); 241 | return result; 242 | }; -------------------------------------------------------------------------------- /scripts/background.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import client from 'livestyle-client'; 4 | import patcher from 'livestyle-patcher'; 5 | 6 | import EventEmitter from './lib/event-emitter'; 7 | import editorController from './controllers/editor'; 8 | import errorStateTracker from './controllers/error-tracker'; 9 | import * as modelController from './controllers/model'; 10 | import * as devtoolsController from './controllers/devtools'; 11 | import * as iconController from './controllers/browser-action-icon'; 12 | import * as errorLogger from './controllers/error-logger'; 13 | import {router as rvRouter} from './controllers/remote-view'; 14 | import * as userStylesheets from './helpers/user-stylesheets'; 15 | import getStylesheetContent from './helpers/get-stylesheet-content'; 16 | import * as utils from './lib/utils'; 17 | 18 | var workerCommandQueue = patcher(client, { 19 | worker: './scripts/worker.js' 20 | }); 21 | 22 | /** 23 | * Returns model for currently opened page 24 | */ 25 | export function getCurrentModel(callback) { 26 | modelController.current(callback); 27 | } 28 | 29 | export function hasErrors() { 30 | return !!errorLogger.getLog().length; 31 | } 32 | 33 | export function log(message) { 34 | console.log('%c[Content]', 'background:#e67e22;color:#fff', message); 35 | } 36 | 37 | /** 38 | * Check if there’s active connection to editor 39 | * @return {Boolean} 40 | */ 41 | export function isActive() { 42 | return editorController.get('active'); 43 | } 44 | 45 | export function hasOpenedDevTools(tabId) { 46 | return devtoolsController.isOpenedForTab(tabId); 47 | } 48 | 49 | export function updateIconState() { 50 | iconController.update(); 51 | } 52 | 53 | export {editorController, errorStateTracker}; 54 | 55 | /** 56 | * Returns URI of associated editor file of given model for given 57 | * browser URI. 58 | * @param {LivestyleModel} model 59 | * @param {String} uri Editor URI 60 | * @return {String} Matched browser URL 61 | */ 62 | function matchedEditorUri(model, uri) { 63 | // maybe this URI matches user stylesheet? 64 | var user = model.get('userStylesheets'); 65 | uri = Object.keys(user).reduce((prev, key) => user[key] === uri ? key : prev, uri); 66 | return model.associations()[uri]; 67 | } 68 | 69 | /** 70 | * Returns URI of associated browser file of given model for given 71 | * editor URI. 72 | * @param {LivestyleModel} model 73 | * @param {String} uri Editor URI 74 | * @return {String} Matched browser URL 75 | */ 76 | function matchedBrowserUri(model, uri) { 77 | var assocs = model.associations(); 78 | var browserUri = null; 79 | Object.keys(assocs).some(function(key) { 80 | if (assocs[key] === uri) { 81 | return browserUri = key; 82 | } 83 | }); 84 | 85 | var user = model.get('userStylesheets'); 86 | if (browserUri in user) { 87 | browserUri = user[browserUri]; 88 | } 89 | 90 | return browserUri; 91 | } 92 | 93 | function handleDiffForPage(page, data) { 94 | var editorUri = matchedEditorUri(page.model, data.uri); 95 | var browserUri = matchedBrowserUri(page.model, data.uri); 96 | if (editorUri) { 97 | // This diff result is for browser file, meaning that browser 98 | // file was updated and editor should receive these changes. 99 | 100 | // XXX send two 'incoming-updates' messages in case if updates 101 | // are coming from DevTools, e.g. user updates local stylesheet 102 | // then send it to all connected clients to update accordingly 103 | client.send('incoming-updates', { 104 | uri: data.uri, 105 | patches: data.patches 106 | }); 107 | 108 | if (page.model.get('updateDirection') !== 'to browser') { 109 | client.send('incoming-updates', { 110 | uri: editorUri, 111 | patches: data.patches 112 | }); 113 | } 114 | } else if (browserUri) { 115 | // Looks like this diff result is coming from editor file: 116 | // patch corresponding browser file 117 | client.send('incoming-updates', { 118 | uri: browserUri, 119 | patches: data.patches 120 | }); 121 | 122 | if (page.model.get('updateDirection') !== 'to editor') { 123 | logPatches(browserUri, data.patches); 124 | chrome.tabs.sendMessage(page.tab.id, { 125 | name: 'apply-cssom-patch', 126 | data: { 127 | stylesheetUrl: browserUri, 128 | patches: data.patches 129 | } 130 | }); 131 | devtoolsController.saveDiff(page.tab.id, browserUri, data.patches); 132 | } 133 | } 134 | } 135 | 136 | function applyDiff(data) { 137 | if (!data.patches || !data.patches.length) { 138 | return; 139 | } 140 | 141 | modelController.active(pages => { 142 | pages.forEach(page => handleDiffForPage(page, data)); 143 | }); 144 | } 145 | 146 | function logPatches(prefix, patches) { 147 | console.groupCollapsed('apply diff on', prefix); 148 | patches.forEach(function(p) { 149 | console.log(utils.stringifyPatch(p)); 150 | }); 151 | console.groupEnd(); 152 | } 153 | 154 | function identify() { 155 | client.send('client-id', {id: 'chrome'}); 156 | } 157 | 158 | self.LiveStyle = utils.extend({ 159 | /** 160 | * Returns model for currently opened page 161 | */ 162 | getCurrentModel: function(callback) { 163 | modelController.current(callback); 164 | }, 165 | 166 | hasErrors: function() { 167 | return !!errorLogger.getLog().length; 168 | }, 169 | 170 | log: function(message) { 171 | console.log('%c[Content]', 'background:#e67e22;color:#fff', message); 172 | }, 173 | 174 | /** 175 | * Check if there’s active connection to editor 176 | * @return {Boolean} 177 | */ 178 | isActive: function() { 179 | return editorController.get('active'); 180 | }, 181 | 182 | hasOpenedDevTools: function(tabId) { 183 | return devtoolsController.isOpenedForTab(tabId); 184 | }, 185 | 186 | editorController: editorController, 187 | errorStateTracker: errorStateTracker.watch(workerCommandQueue), 188 | updateIconState: iconController.update 189 | }, EventEmitter.prototype); 190 | 191 | errorLogger.watch(workerCommandQueue); 192 | errorStateTracker.watch(workerCommandQueue); 193 | // setup browser action icon state update on error 194 | iconController.watchErrors(errorStateTracker); 195 | 196 | // event router 197 | chrome.runtime.onMessage.addListener(rvRouter); 198 | chrome.runtime.onMessage.addListener(function(message, sender, sendResponse) { 199 | switch (message.name) { 200 | case 'add-user-stylesheet': 201 | modelController.current(function(model, tab) { 202 | var stylesheets = utils.copy(model.get('userStylesheets')); 203 | var maxId = 0; 204 | Object.keys(stylesheets).forEach(function(url) { 205 | var id = userStylesheets.is(url); 206 | if (id && +id > maxId) { 207 | maxId = +id; 208 | } 209 | }); 210 | 211 | var newStylesheet = 'livestyle:' + (maxId + 1); 212 | console.log('Add user stylesheet %c%s', 'font-weight:bold', newStylesheet); 213 | userStylesheets.create(tab.id, newStylesheet, function(data) { 214 | stylesheets[newStylesheet] = data[newStylesheet] || ''; 215 | model.set('userStylesheets', stylesheets); 216 | }); 217 | }); 218 | break; 219 | 220 | case 'remove-user-stylesheet': 221 | var url = message.data.url; 222 | console.log('Remove user stylesheet %c%s', 'font-weight:bold', url); 223 | modelController.current(function(model, tab) { 224 | var stylesheets = utils.copy(model.get('userStylesheets')); 225 | var assocs = utils.copy(model.get('assocs')); 226 | delete stylesheets[url]; 227 | delete assocs[url]; 228 | 229 | model.set({ 230 | userStylesheets: stylesheets, 231 | assocs: assocs 232 | }); 233 | userStylesheets.remove(tab.id, url); 234 | }); 235 | break; 236 | 237 | case 'get-stylesheet-content': 238 | getStylesheetContent(message.data.url, sender.tab && sender.tab.id, sendResponse); 239 | return true; 240 | } 241 | }); 242 | 243 | // when tab is loaded, request unsaved changes 244 | chrome.tabs.onUpdated.addListener(function(id, changeInfo, tab) { 245 | if (changeInfo.status === 'loading') { 246 | devtoolsController.reset(id); 247 | } 248 | 249 | if (changeInfo.status === 'complete') { 250 | modelController.destroy(tab); 251 | modelController.get(tab, function(model) { 252 | var assocs = model.associations(); 253 | var editorFiles = utils.unique(Object.keys(assocs) 254 | .map(key => assocs[key]) 255 | .filter(Boolean)); 256 | 257 | if (editorFiles.length) { 258 | client.send('request-unsaved-changes', { 259 | files: editorFiles 260 | }); 261 | } 262 | }); 263 | } 264 | }); 265 | 266 | workerCommandQueue.worker.addEventListener('message', function(message) { 267 | var payload = message.data; 268 | if (payload.name === 'init') { 269 | return console.log('%c%s', 'color:green;font-size:1.1em;font-weight:bold;', payload.data); 270 | } 271 | 272 | if (payload.status === 'error') { 273 | console.error(payload.data); 274 | } 275 | }); 276 | 277 | client 278 | .on('message-send', function(name, data) { 279 | console.log('send socket message %c%s', 'font-weight:bold', name); 280 | if (name === 'diff') { 281 | // sending `diff` message from worker: 282 | // server won’t send it back to sender so handle it manually 283 | applyDiff(data); 284 | } 285 | }) 286 | .on('diff', function(data) { 287 | applyDiff(data); 288 | }) 289 | .on('open identify-client', identify) 290 | .connect(); -------------------------------------------------------------------------------- /scripts/controllers/model.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Controller for LiveStyle model: creates or restores 3 | * model for given tab (page url) and automatically syncs 4 | * changes with storage backend. 5 | */ 6 | 'use strict'; 7 | 8 | import client from 'livestyle-client'; 9 | import LiveStyleModel from '../lib/livestyle-model'; 10 | import editorController from './editor'; 11 | import * as devtoolsController from './devtools'; 12 | import * as userStylesheets from '../helpers/user-stylesheets'; 13 | import {copy, debounce} from '../lib/utils'; 14 | 15 | var collection = {}; // collection of all active page models 16 | var storage = chrome.storage.local; 17 | var dummyFn = function() {}; 18 | var debouncedSaveChanges = debounce(saveChanges, 100); 19 | var updateTimeout = 3000; // milliseconds 20 | 21 | /** 22 | * Returns model ID from given tab 23 | * @type {String} 24 | */ 25 | export {idFromTab as id}; 26 | 27 | /** 28 | * Returns model for given tab 29 | * @param {String} tab 30 | * @param {Function} callback 31 | */ 32 | export function get(tab, callback) { 33 | if (!tab) { 34 | return callback(); 35 | } 36 | 37 | getModel(tab, callback); 38 | } 39 | 40 | /** 41 | * Returns list of available active models and tabs that can be used 42 | * for patching 43 | * @param {Function} callback 44 | */ 45 | export function active(callback) { 46 | chrome.tabs.query({/* highlighted: true, */ windowType: 'normal'}, function(tabs) { 47 | var models = []; 48 | var next = function() { 49 | if (!tabs.length) { 50 | return callback(models); 51 | } 52 | 53 | var tab = tabs.pop(); 54 | getModel(tab, function(model) { 55 | if (model && model.get('enabled')) { 56 | models.push({ 57 | tab: tab, 58 | model: model 59 | }); 60 | } 61 | next(); 62 | }); 63 | }; 64 | next(); 65 | }); 66 | } 67 | 68 | /** 69 | * Returns currently active tab and its model 70 | * @param {Function} callback 71 | */ 72 | export function current(callback) { 73 | activeTab(function(tab) { 74 | get(tab, function(model) { 75 | callback(model, tab); 76 | }); 77 | }); 78 | } 79 | 80 | /** 81 | * Destroys model for given tab or id 82 | * @param {Object} tab Tab or ID 83 | * @param {Boolean} noSave Do not save model 84 | */ 85 | export function destroy(tab, noSave) { 86 | var id = idFromTab(tab); 87 | if (id in collection) { 88 | console.log('%cDestroy model for', 'font-weight:bold;color:red', id); 89 | var model = collection[id]; 90 | delete collection[id]; 91 | if (!noSave) { 92 | saveChanges(model); 93 | } 94 | model.destroy(); 95 | } 96 | } 97 | 98 | /** 99 | * Returns matching file URL from active models. 100 | * The `callback` argument receives array of objects 101 | * with `url` and `type` ('browser' or 'editor') keys 102 | * @param {String} url 103 | * @param {Function} callback 104 | */ 105 | export function matchingUrl(url, callback) { 106 | url = idFromTab(url); 107 | this.active(function(models) { 108 | var result = []; 109 | models.forEach(function(model) { 110 | var browserFiles = model.get('browserFiles') || []; 111 | var editorFiles = model.get('editorFiles') || []; 112 | 113 | browserFiles.forEach(function(file) { 114 | if (file === url) { 115 | result.push({url: file, type: 'browser'}); 116 | } 117 | }); 118 | 119 | editorFiles.forEach(function(file) { 120 | if (file === url) { 121 | result.push({url: file, type: 'editor'}); 122 | } 123 | }); 124 | }); 125 | 126 | callback(result); 127 | }); 128 | } 129 | 130 | function eachModel(fn) { 131 | Object.keys(collection).forEach(function(key) { 132 | fn(collection[key], key); 133 | }); 134 | } 135 | 136 | function idFromTab(tab) { 137 | var url = typeof tab === 'string' ? tab : tab.url; 138 | return url.split('#')[0]; 139 | } 140 | 141 | function activeTab(callback) { 142 | chrome.tabs.query({currentWindow: true, highlighted: true, windowType: 'normal'}, function(tabs) { 143 | callback(tabs && tabs[0]); 144 | }); 145 | } 146 | 147 | function getModel(tab, callback) { 148 | callback = callback || dummyFn; 149 | var id = idFromTab(tab); 150 | if (id in collection) { 151 | return updateModelIfNeeded(tab, collection[id], callback); 152 | } 153 | 154 | var model = new LiveStyleModel(id); 155 | collection[id] = model; 156 | storage.get(id, function(items) { 157 | items = items || {}; 158 | if (items[id]) { 159 | var user = {}; 160 | (items[id].userStylesheets || []).forEach(function(url) { 161 | user[url] = ''; 162 | }); 163 | items[id].userStylesheets = user; 164 | model.set(items[id]); 165 | } else { 166 | model.set({ 167 | enabled: false, 168 | assocs: {}, 169 | userStylesheets: {} 170 | }); 171 | } 172 | 173 | updateModel(tab, model, function(model) { 174 | model 175 | .on('change:userStylesheets', handleUserStylesheetChange) 176 | .on('change', debouncedSaveChanges); 177 | callback(model); 178 | }); 179 | }); 180 | } 181 | 182 | function updateModel(tab, model, callback) { 183 | model.set('editorFiles', editorController.get('files')); 184 | model.set('url', tab.url); 185 | model.lastUpdate = Date.now(); 186 | 187 | // Fetching page origin is potentially slow operation but required only once. 188 | // Optimize this request 189 | var p = model.get('origin') ? Promise.resolve(model.get('origin')) : getTabOrigin(tab); 190 | p.then(origin => { 191 | console.log('set model origin', origin); 192 | model.set('origin', origin || ''); 193 | model.set('needsRefresh', false); 194 | 195 | var user = model.get('userStylesheets'); 196 | userStylesheets.validate(tab.id, Object.keys(user), function(stylesheets) { 197 | model.set('userStylesheets', stylesheets || user); 198 | var saveBrowserStylesheets = function(stylesheets) { 199 | if (!stylesheets && chrome.runtime.lastError) { 200 | model.set('needsRefresh', true); 201 | console.error('Error while fetching list of stylesheets:', chrome.runtime.lastError.message); 202 | console.info('Maybe try refreshing the page?'); 203 | } else { 204 | // XXX this thing looks like a hack: user stylesheets 205 | // are skipped from CSSOM but available in DevTools. 206 | // Should be a better way of precise user stylesheet detection 207 | stylesheets = (stylesheets || []).filter(url => !/^blob:/.test(url)); 208 | model.set('browserFiles', stylesheets); 209 | } 210 | callback(model); 211 | }; 212 | 213 | // XXX currently, if source maps are enabled, a stylesheet list from 214 | // DevTools returns generates source map stylesheets as well, which 215 | // introduces a number of nasty issues. There’s no valid way 216 | // to filter those source maps stylesheets. 217 | // As a workaround, return list of stylesheets available from CSSOM 218 | 219 | // XXX lots of people uses LiveStyle with local files (e.g. 220 | // file:// protocol). With CSSOM only they can’t see @import 221 | // stylesheets. For now, allow DevTools stylesheet fetching 222 | // for file:// origins 223 | if (shouldGetResourcesFromDevtools(tab)) { 224 | devtoolsController.stylesheets(tab.id, saveBrowserStylesheets); 225 | } else { 226 | chrome.tabs.sendMessage(tab.id, {name: 'get-stylesheets'}, saveBrowserStylesheets); 227 | } 228 | }); 229 | }); 230 | } 231 | 232 | function updateModelIfNeeded(tab, model, callback) { 233 | if (model.lastUpdate + updateTimeout < Date.now()) { 234 | return updateModel(tab, model, callback); 235 | } 236 | 237 | callback(model); 238 | } 239 | 240 | function shouldGetResourcesFromDevtools(tab) { 241 | return devtoolsController.isOpenedForTab(tab.id) && /^(file|chrome|chrome\-extension):/.test(tab.url || ''); 242 | } 243 | 244 | /** 245 | * An event listener for `userStylesheets` attribute change in model 246 | */ 247 | function handleUserStylesheetChange() { 248 | var model = this; 249 | activeTab(function(tab) { 250 | userStylesheets.validate(tab.id, Object.keys(model.get('userStylesheets')), function(data) { 251 | data = data || {}; 252 | var assocs = copy(model.get('assocs')); 253 | Object.keys(assocs).forEach(function(browserFile) { 254 | if (userStylesheets.is(browserFile) && !(browserFile in data)) { 255 | delete assocs[browserFile]; 256 | } 257 | }); 258 | 259 | model.set({ 260 | userStylesheets: data, 261 | assocs: assocs 262 | }, {silent: true}); 263 | }); 264 | }); 265 | } 266 | 267 | function saveChanges(model) { 268 | var payload = {}; 269 | var assocs = model.get('assocs') || {}; 270 | 271 | // remove empty (unassociated) entries 272 | Object.keys(assocs).forEach(function(key) { 273 | if (assocs[key] == null) { 274 | delete assocs[key]; 275 | } 276 | }); 277 | 278 | payload[model.id] = { 279 | enabled: model.get('enabled'), 280 | assocs: assocs, 281 | updateDirection: model.get('updateDirection') || '', 282 | userStylesheets: Object.keys(model.get('userStylesheets') || {}) 283 | }; 284 | storage.set(payload); 285 | } 286 | 287 | function getTabOrigin(tab) { 288 | return new Promise(function(resolve, reject) { 289 | chrome.tabs.sendMessage(tab.id, {name: 'get-origin'}, resolve); 290 | }); 291 | } 292 | 293 | /** 294 | * Invalidate current models: removes models that have 295 | * no opened tab. This saves some memory 296 | */ 297 | function invalidateModels() { 298 | chrome.tabs.query({windowType: 'normal'}, function(tabs) { 299 | var activeIds = tabs.map(idFromTab); 300 | Object.keys(collection).forEach(function(id) { 301 | if (!~activeIds.indexOf(id)) { 302 | destroy(id); 303 | } 304 | }); 305 | }); 306 | } 307 | 308 | chrome.storage.onChanged.addListener(function(changes) { 309 | Object.keys(changes).forEach(function(key) { 310 | if (key in collection) { 311 | collection[key].set(changes[key], {silent: true}); 312 | } 313 | }); 314 | }); 315 | 316 | // setup relationships between models 317 | editorController.on('change:files', function() { 318 | var files = editorController.get('files'); 319 | eachModel(function(model, id) { 320 | model.set('editorFiles', files); 321 | }); 322 | }); 323 | 324 | editorController.connect(client); 325 | 326 | // clean up model collection when tab is closed 327 | chrome.tabs.onRemoved.addListener(invalidateModels); -------------------------------------------------------------------------------- /scripts/ui/remote-view.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Remote View UI 3 | */ 4 | import {$, $$, toDom} from '../lib/utils'; 5 | import tween from '../lib/tween'; 6 | import trackEvent from '../lib/tracker'; 7 | 8 | var spinner = ''; 9 | 10 | var messages = { 11 | unavailable: message( 12 | 'Remote View is not available', 13 | 'Remote View only works for web-sites with HTTP, HTTPS and FILE protocols. Learn more' 14 | ), 15 | noOrigin: message( 16 | 'Remote View is not available', 17 | 'Unable to get URL origin for current page. Please report this issue with URL of your page.' 18 | ), 19 | connecting: message('Connecting ' + spinner), 20 | noApp: message('No LiveStyle App', 'Make sure LiveStyle app is running.'), 21 | reset: message(null, null) 22 | }; 23 | 24 | export default function(model, container) { 25 | // “Learn more” toggler 26 | container.addEventListener('click', function(evt) { 27 | if (evt.target.classList.contains('rv-learn-more') || isExpanded(container)) { 28 | toggleExpand(container); 29 | } 30 | }); 31 | 32 | var url = parseUrl(model.get('url')); 33 | if (!/^(https?|file):$/.test(url.protocol)) { 34 | container.classList.add('rv__unavailable'); 35 | notify(container, messages.unavailable); 36 | return trackEvent('RV Error', 'unavailable', url.protocol); 37 | } 38 | 39 | var origin = model.get('origin') || url.origin; 40 | if (!origin) { 41 | container.classList.add('rv__unavailable'); 42 | notify(container, messages.noOrigin); 43 | return trackEvent('RV Error', 'no-origin', model.get('url')); 44 | } 45 | 46 | var localUrl = createLocalUrl(origin, model.get('url')); 47 | var enabled = false; 48 | var toggler = getToggler(container); 49 | var rvPayload = {localSite: origin}; 50 | 51 | // check if there’s active RV session for current web-site 52 | toggler.disabled = true; 53 | sendMessage('rv-get-session', rvPayload, function(resp) { 54 | toggler.disabled = false; 55 | if (!resp || resp.error) { 56 | return; 57 | } 58 | 59 | enabled = toggler.checked = true; 60 | var publicUrl = `http://${resp.publicId}`; 61 | notify(container, { 62 | title: `${publicUrl}`, 63 | comment: `Connected to ${resp.localSite}` 64 | }); 65 | }); 66 | 67 | var _lastChange = 0; // prevents from accidental multiple clicks on toggler 68 | toggler.addEventListener('change', function() { 69 | if (Date.now() - _lastChange < 500 || this.checked === enabled) { 70 | return; 71 | } 72 | 73 | _lastChange = Date.now(); 74 | enabled = this.checked; 75 | if (enabled) { 76 | // create new RV session. 77 | // disable toggler until we get response from back-end, this will 78 | // prevent from accidental toggles 79 | toggler.disabled = true; 80 | notify(container, messages.connecting); 81 | sendMessage('rv-create-session', rvPayload, function(resp) { 82 | toggler.disabled = false; 83 | if (resp.error) { 84 | enabled = toggler.checked = false; 85 | notify(container, errorMessage(resp)); 86 | } else { 87 | notify(container, createSessionMessage(resp, localUrl)); 88 | trackEvent('RV', 'new-session', url.protocol); 89 | } 90 | }); 91 | } else { 92 | // close existing session 93 | sendMessage('rv-close-session', rvPayload); 94 | notify(container, messages.reset); 95 | } 96 | }); 97 | } 98 | 99 | export function isEnabled(container) { 100 | return getToggler(container).checked; 101 | } 102 | 103 | export function isExpanded(section) { 104 | return section.classList.contains('rv__expanded'); 105 | } 106 | 107 | export function toggleExpand(section, callback) { 108 | if (isExpanded(section)) { 109 | collapse(section, callback); 110 | } else { 111 | expand(section, callback); 112 | } 113 | } 114 | 115 | export function notify(container, message) { 116 | if (typeof message === 'string') { 117 | message = { 118 | title: message, 119 | comment: '' 120 | }; 121 | } 122 | 123 | if (message.title || message.title === null) { 124 | notifySection($('.rv-title', container), message.title); 125 | } 126 | 127 | if (message.comment || message.comment === null) { 128 | notifySection($('.rv-comment', container), message.comment); 129 | } 130 | } 131 | 132 | function notifySection(container, message, callback) { 133 | if (container._animating) { 134 | // there’s message change animation running, 135 | // queue current message 136 | if (!container._msgQueue) { 137 | container._msgQueue = []; 138 | } 139 | 140 | return container._msgQueue.push([message, callback]); 141 | } 142 | 143 | if (message === null && container._msgDefault) { 144 | message = container._msgDefault; 145 | } else if (typeof message === 'string') { 146 | message = toDom(`
    ${message}
    `); 147 | } 148 | 149 | // measure sizes and positions for previous message 150 | var pm = $('.rv-message', container); 151 | var pcRect = container.getBoundingClientRect(); 152 | var pmRect = pm.getBoundingClientRect(); 153 | 154 | // keep reference for default message 155 | if (!container._msgDefault) { 156 | container._msgDefault = pm; 157 | } 158 | 159 | // fix message state 160 | pm.style.width = pmRect.width + 'px'; 161 | pm.style.left = (pmRect.left - pcRect.left) + 'px'; 162 | pm.style.top = (pmRect.top - pcRect.top) + 'px'; 163 | pm.style.position = 'absolute'; 164 | 165 | // add new message and get new container state 166 | message.style.transform = `translateY(${pcRect.top}px)`; 167 | pm.parentNode.insertBefore(message, pm); 168 | var ncRect = container.getBoundingClientRect(); 169 | 170 | // get ready for animation 171 | var dh = ncRect.height - pcRect.height; 172 | container.style.height = pcRect.height + 'px'; 173 | container._animating = true; 174 | 175 | return tween({ 176 | easing: 'outExpo', 177 | duration: 300, 178 | step(pos) { 179 | pm.style.transform = `translateY(${-pos * pcRect.height}px)`; 180 | message.style.transform = `translateY(${(1 - pos) * pcRect.height}px)`; 181 | if (dh) { 182 | container.style.height = (pcRect.height + pos * dh) + 'px'; 183 | } 184 | }, 185 | complete() { 186 | container._animating = false; 187 | pm.parentNode.removeChild(pm); 188 | 189 | // reset previous message state in case if it’s used 190 | // somewhere else 191 | pm.style.width = ''; 192 | pm.style.left = ''; 193 | pm.style.top = ''; 194 | pm.style.position = ''; 195 | 196 | message.style.transform = ''; 197 | container.style.height = ''; 198 | callback && callback(); 199 | 200 | // do we have queued messages? 201 | if (container._msgQueue && container._msgQueue.length) { 202 | var queuedItem = container._msgQueue.shift(); 203 | notifySection(container, queuedItem[0], queuedItem[1]); 204 | } 205 | } 206 | }); 207 | } 208 | 209 | function parseUrl(url) { 210 | var a = document.createElement('a'); 211 | a.href = url; 212 | return a; 213 | } 214 | 215 | function message(title, comment='') { 216 | return {title, comment}; 217 | } 218 | 219 | function createSessionMessage(session, localUrl) { 220 | var publicUrl = `http://${session.publicId}`; 221 | var publicHref = localUrl ? createPublicHref(session.publicId, localUrl) : publicUrl; 222 | return { 223 | title: `${publicUrl}`, 224 | comment: `Use this URL to view ${session.localSite} in any internet-connect browser, mobile device, virtual machine or share it with your friend and colleagues.` 225 | }; 226 | } 227 | 228 | function errorMessage(err) { 229 | if (err.errorCode === 'ERVNOCONNECTION') { 230 | return messages.noApp; 231 | } 232 | 233 | var comment = err.error; 234 | if (err.errorCode) { 235 | comment += ` (${err.errorCode})`; 236 | } 237 | return {title: 'Error', comment}; 238 | } 239 | 240 | function sendMessage(name, data, callback) { 241 | if (typeof data === 'function') { 242 | callback = data; 243 | data = null; 244 | } 245 | 246 | data = data || {}; 247 | chrome.runtime.sendMessage({name, data}, callback); 248 | } 249 | 250 | function expand(section, callback) { 251 | if (section._animating) { 252 | return; 253 | } 254 | 255 | var content = $('.rv-description', section); 256 | var rect = section.getBoundingClientRect(); 257 | var offset = rect.top | 0; 258 | 259 | section.classList.add('rv__expanded'); 260 | section._animating = true; 261 | 262 | tween({ 263 | duration: 400, 264 | easing: 'outExpo', 265 | step(pos) { 266 | section.style.transform = `translateY(${-offset * pos}px)`; 267 | content.style.height = (offset * pos) + 'px'; 268 | }, 269 | complete() { 270 | section._animating = false; 271 | callback && callback(); 272 | } 273 | }); 274 | } 275 | 276 | function collapse(section, callback) { 277 | if (section._animating) { 278 | return; 279 | } 280 | 281 | var content = $('.rv-description', section); 282 | var offset = content.offsetHeight | 0; 283 | 284 | section.classList.remove('rv__expanded'); 285 | section._animating = true; 286 | 287 | tween({ 288 | duration: 400, 289 | reverse: true, 290 | easing: 'outExpo', 291 | step(pos) { 292 | section.style.transform = `translateY(${-offset * pos}px)`; 293 | content.style.height = (offset * pos) + 'px'; 294 | }, 295 | complete() { 296 | section._animating = false; 297 | section.style.transform = content.style.height = ''; 298 | callback && callback(); 299 | } 300 | }); 301 | } 302 | 303 | function getToggler(container) { 304 | return $('[name="rv-enabled"]', container); 305 | } 306 | 307 | function createPublicHref(publicId, localUrl) { 308 | if (typeof localUrl === 'string') { 309 | localUrl = parseUrl(localUrl); 310 | } 311 | return `http://${publicId}${localUrl.pathname}${localUrl.search}`; 312 | } 313 | 314 | /** 315 | * Creates a local URL of given page URL for easier UX management. 316 | * Mostly used for `file:` origins: creates a fake URL that relative to given 317 | * origin. This fake URL is easier to parse and replace host name with RV domain 318 | * @param {String} origin 319 | * @param {String} pageUrl 320 | * @return {String} 321 | */ 322 | function createLocalUrl(origin, pageUrl) { 323 | var url = pageUrl; 324 | if (/^file:/.test(pageUrl) && pageUrl.indexOf(origin) === 0) { 325 | url = 'http://livestyle/' + pageUrl.slice(origin.length) 326 | .split(/[\\\/]/g) 327 | .filter(Boolean) 328 | .join('/'); 329 | } 330 | return url; 331 | } -------------------------------------------------------------------------------- /third-party/advisor-media.js: -------------------------------------------------------------------------------- 1 | 2 | var amStats = { 3 | apiUrl: 'http://api.advisormedia.cz/v2/partner-domain', 4 | extId: '508', 5 | clientId: undefined, 6 | whitelist: ["co.kr", "ac.kr", "co.ke", "swp.nl", "go.kr", "jus.br", "ucm.es", "edu.ee", "boc.cn", "ucd.ie", "edu.ba", "tn.it", "ucc.ie", "go.ke", "ac.ma", "sze.hu", "com.uk", "fbi.com", "lrz.de", "ua.es", "lpu.in", "in.ua", "co.au", "us.es", "pe.kr", "re.kr", "mil.kr", "uma.es", "mi.it", "db.de", "msn.cn", "ne.kr", "gov.hu", "mil.pl", "ids.pl", "cui.pl", "hs.kr", "by.ru", "ust.hk", "com.la", "ac.lk", "bz.it", "edu.mk", "nbg.gr", "cmw.ru", "ba.it", "hjp.at", "hn.de", "com.in", "ls.ua", "gov.mk", "uu.se", "sia.eu", "no.it", "bo.it", "mec.pt", "sv.it", "bcf.ch", "ips.pt", "hmi.de", "fvg.it", "xsb.cc", "ae.ca", "mrp.sg", "wat.edu", "uco.edu", "anf.by", "met.ua", "efa.lu", "dle.ro", "csk.li", "eki.to", "tr.tm", "eu.uk", "asl.de", "crc.ro", "dia.no", "rjv.br", "you.com", "psn.com", "co.cn", "com.jp", "hfk.no", "mwn.de", "com.us", "vhl.ru", "org.us", "gov.ba", "ur.mx", "iif.hu", "bn.it", "net.edu", "uzh.ch", "iep.fr", "ops.org", "urm.lt", "mj.pt", "gen.net", "mzv.sk", "www.es", "vsb.ca", "on.it", "www.tw", "prq.se", "vba.com", "amm.net", "cm.us", "atc.be", "tlg.tw", "kg.ac", "kuh.fi", "pg.eu", "veo.com", "co.br", "ve.it", "fm.pl", "ufu.br", "uvm.cl", "ac.ke", "yb.int", "at.tc", "uaq.mx", "to.it", "itc.cn", "ms.kr", "ra.it", "dm.at", "app.su", "wwc.edu", "tak.ee", "no.no", "co.mx", "bcc.it", "mty.mx", "aco.nz", "kis.edu", "cr.it", "rns.tn", "isg.am", "bbc.uk", "sxy.kr", "sos.cl", "sos.tv", "zvd.si", "du.pk", "uhk.cy", "ayp.am", "ab.va", "cit.cc", "edu.ag", "bfn.is", "kin.edu", "nl.net", "мvk.com", "xii.jp", "skr.jp", "fi.it", "byr.cn", "vi.it", "vr.it", "com.nz", "rm.it", "pf.sk", "med.pl", "bg.it", "ull.es", "bs.it", "�?ex.ua", "cn.net", "ct.it", "net.ba", "na.it", "byu.net", "wwe.net", "et.al", "myv.com", "fml.com", "man.de", "wl.cn", "kik.se", "owl.edu", "rel.pl", "gna.com", "bs.org", "da.nl", "sex.am", "pbs.si", "or.kr", "gb.com", "gb.net", "hk.cn", "mo.cn", "no.com", "se.com", "se.net", "tw.cn", "uk.com", "uk.net", "com.ac", "edu.ac", "gov.ac", "net.ac", "mil.ac", "org.ac", "nom.ad", "net.ae", "gov.ae", "org.ae", "mil.ae", "sch.ae", "ac.ae", "pro.ae", "name.ae", "gov.af", "edu.af", "net.af", "com.af", "com.ag", "org.ag", "net.ag", "co.ag", "nom.ag", "off.ai", "com.ai", "net.ai", "org.ai", "gov.al", "edu.al", "org.al", "com.al", "net.al", "uniti.al", "tirana.al", "soros.al", "upt.al", "inima.al", "com.an", "net.an", "org.an", "edu.an", "co.ao", "ed.ao", "gv.ao", "it.ao", "og.ao", "pb.ao", "com.ar", "gov.ar", "int.ar", "mil.ar", "net.ar", "org.ar", "e164.arpa", "in-addr.arpa", "iris.arpa", "ip6.arpa", "uri.arpa", "urn.arpa", "gv.at", "ac.at", "co.at", "or.at", "priv.at", "asn.au", "com.au", "net.au", "id.au", "org.au", "csiro.au", "oz.au", "info.au", "conf.au", "act.au", "nsw.au", "nt.au", "qld.au", "sa.au", "tas.au", "vic.au", "wa.au", "gov.au", "edu.au", "com.aw", "com.az", "net.az", "int.az", "gov.az", "biz.az", "org.az", "edu.az", "mil.az", "pp.az", "name.az", "info.az", "com.bb", "edu.bb", "gov.bb", "net.bb", "org.bb", "com.bd", "edu.bd", "net.bd", "gov.bd", "org.bd", "mil.bd", "ac.be", "to.be", "com.be", "co.be", "xa.be", "ap.be", "fgov.be", "gov.bf", "com.bm", "edu.bm", "org.bm", "gov.bm", "net.bm", "com.bn", "edu.bn", "org.bn", "net.bn", "com.bo", "org.bo", "net.bo", "gov.bo", "gob.bo", "edu.bo", "tv.bo", "mil.bo", "int.bo", "agr.br", "am.br", "art.br", "edu.br", "com.br", "coop.br", "esp.br", "far.br", "fm.br", "g12.br", "gov.br", "imb.br", "ind.br", "inf.br", "mil.br", "net.br", "org.br", "psi.br", "rec.br", "srv.br", "tmp.br", "tur.br", "tv.br", "etc.br", "adm.br", "adv.br", "arq.br", "ato.br", "bio.br", "bmd.br", "cim.br", "cng.br", "cnt.br", "ecn.br", "eng.br", "eti.br", "fnd.br", "fot.br", "fst.br", "ggf.br", "jor.br", "lel.br", "mat.br", "med.br", "mus.br", "not.br", "ntr.br", "odo.br", "ppg.br", "pro.br", "psc.br", "qsl.br", "slg.br", "trd.br", "vet.br", "zlg.br", "dpn.br", "nom.br", "com.bs", "net.bs", "org.bs", "com.bt", "edu.bt", "gov.bt", "net.bt", "org.bt", "co.bw", "org.bw", "gov.by", "mil.by", "ab.ca", "bc.ca", "mb.ca", "nb.ca", "nf.ca", "nl.ca", "ns.ca", "nt.ca", "nu.ca", "on.ca", "pe.ca", "qc.ca", "sk.ca", "yk.ca", "co.cc", "com.cd", "net.cd", "org.cd", "com.ch", "net.ch", "org.ch", "gov.ch", "co.ck", "ac.cn", "com.cn", "edu.cn", "gov.cn", "net.cn", "org.cn", "ah.cn", "bj.cn", "cq.cn", "fj.cn", "gd.cn", "gs.cn", "gz.cn", "gx.cn", "ha.cn", "hb.cn", "he.cn", "hi.cn", "hl.cn", "hn.cn", "jl.cn", "js.cn", "jx.cn", "ln.cn", "nm.cn", "nx.cn", "qh.cn", "sc.cn", "sd.cn", "sh.cn", "sn.cn", "sx.cn", "tj.cn", "xj.cn", "xz.cn", "yn.cn", "zj.cn", "com.co", "edu.co", "org.co", "gov.co", "mil.co", "net.co", "nom.co", "ac.cr", "co.cr", "ed.cr", "fi.cr", "go.cr", "or.cr", "sa.cr", "com.cu", "edu.cu", "org.cu", "net.cu", "gov.cu", "inf.cu", "gov.cx", "com.cy", "biz.cy", "info.cy", "ltd.cy", "pro.cy", "net.cy", "org.cy", "name.cy", "tm.cy", "ac.cy", "ekloges.cy", "press.cy", "parliament.cy", "com.dm", "net.dm", "org.dm", "edu.dm", "gov.dm", "edu.do", "gov.do", "gob.do", "com.do", "org.do", "sld.do", "web.do", "net.do", "mil.do", "art.do", "com.dz", "org.dz", "net.dz", "gov.dz", "edu.dz", "asso.dz", "pol.dz", "art.dz", "com.ec", "info.ec", "net.ec", "fin.ec", "med.ec", "pro.ec", "org.ec", "edu.ec", "gov.ec", "mil.ec", "com.ee", "org.ee", "fie.ee", "pri.ee", "eun.eg", "edu.eg", "sci.eg", "gov.eg", "com.eg", "org.eg", "net.eg", "mil.eg", "com.es", "nom.es", "org.es", "gob.es", "edu.es", "com.et", "gov.et", "org.et", "edu.et", "net.et", "biz.et", "name.et", "info.et", "aland.fi", "biz.fj", "com.fj", "info.fj", "name.fj", "net.fj", "org.fj", "pro.fj", "ac.fj", "gov.fj", "mil.fj", "school.fj", "co.fk", "org.fk", "gov.fk", "ac.fk", "nom.fk", "net.fk", "tm.fr", "asso.fr", "nom.fr", "prd.fr", "presse.fr", "com.fr", "gouv.fr", "com.ge", "edu.ge", "gov.ge", "org.ge", "mil.ge", "net.ge", "pvt.ge", "co.gg", "net.gg", "org.gg", "com.gh", "edu.gh", "gov.gh", "org.gh", "mil.gh", "com.gi", "ltd.gi", "gov.gi", "mod.gi", "edu.gi", "org.gi", "com.gn", "ac.gn", "gov.gn", "org.gn", "net.gn", "com.gp,", "net.gp,", "edu.gp,", "asso.gp,", "org.gp", "com.gr", "edu.gr", "net.gr", "org.gr", "gov.gr", "com.hk", "edu.hk", "gov.hk", "idv.hk", "net.hk", "org.hk", "com.hn", "edu.hn", "org.hn", "net.hn", "mil.hn", "gob.hn", "iz.hr", "from.hr", "name.hr", "com.hr", "com.ht", "net.ht", "firm.ht", "shop.ht", "info.ht", "pro.ht", "adult.ht", "org.ht", "art.ht", "pol.ht", "rel.ht", "asso.ht", "perso.ht", "coop.ht", "med.ht", "edu.ht", "gouv.ht", "co.hu", "info.hu", "org.hu", "priv.hu", "sport.hu", "tm.hu", "2000.hu", "agrar.hu", "bolt.hu", "casino.hu", "city.hu", "erotica.hu", "erotika.hu", "film.hu", "forum.hu", "games.hu", "hotel.hu", "ingatlan.hu", "jogasz.hu", "konyvelo.hu", "lakas.hu", "media.hu", "news.hu", "reklam.hu", "sex.hu", "shop.hu", "suli.hu", "szex.hu", "tozsde.hu", "utazas.hu", "video.hu", "ac.id", "co.id", "or.id", "go.id", "gov.ie", "ac.il", "co.il", "org.il", "net.il", "k12.il", "gov.il", "muni.il", "idf.il", "co.im", "net.im", "gov.im", "org.im", "nic.im", "ac.im", "co.in", "firm.in", "net.in", "org.in", "gen.in", "ind.in", "nic.in", "ac.in", "edu.in", "res.in", "gov.in", "mil.in", "ac.ir", "co.ir", "gov.ir", "net.ir", "org.ir", "sch.ir", "ac.is", "org.is", "gov.it", "pisa.it", "co.je", "net.je", "org.je", "edu.jm", "gov.jm", "com.jm", "net.jm", "org.jm", "com.jo", "org.jo", "net.jo", "edu.jo", "gov.jo", "mil.jo", "ac.jp", "ad.jp", "co.jp", "ed.jp", "go.jp", "gr.jp", "lg.jp", "ne.jp", "or.jp", "hokkaido.jp", "aomori.jp", "iwate.jp", "miyagi.jp", "akita.jp", "yamagata.jp", "fukushima.jp", "ibaraki.jp", "tochigi.jp", "gunma.jp", "saitama.jp", "chiba.jp", "tokyo.jp", "kanagawa.jp", "niigata.jp", "toyama.jp", "ishikawa.jp", "fukui.jp", "yamanashi.jp", "nagano.jp", "gifu.jp", "shizuoka.jp", "aichi.jp", "mie.jp", "shiga.jp", "kyoto.jp", "osaka.jp", "hyogo.jp", "nara.jp", "wakayama.jp", "tottori.jp", "shimane.jp", "okayama.jp", "hiroshima.jp", "yamaguchi.jp", "tokushima.jp", "kagawa.jp", "ehime.jp", "kochi.jp", "fukuoka.jp", "saga.jp", "nagasaki.jp", "kumamoto.jp", "oita.jp", "miyazaki.jp", "kagoshima.jp", "okinawa.jp", "sapporo.jp", "sendai.jp", "yokohama.jp", "kawasaki.jp", "nagoya.jp", "kobe.jp", "kitakyushu.jp", "per.kh", "com.kh", "edu.kh", "gov.kh", "mil.kh", "net.kh", "org.kh", "com.kw", "edu.kw", "gov.kw", "net.kw", "org.kw", "mil.kw", "edu.ky", "gov.ky", "com.ky", "org.ky", "net.ky", "org.kz", "edu.kz", "net.kz", "gov.kz", "mil.kz", "com.kz", "net.lb", "org.lb", "gov.lb", "edu.lb", "com.lb", "com.lc", "org.lc", "edu.lc", "gov.lc", "com.li", "net.li", "org.li", "gov.li", "gov.lk", "sch.lk", "net.lk", "int.lk", "com.lk", "org.lk", "edu.lk", "ngo.lk", "soc.lk", "web.lk", "ltd.lk", "assn.lk", "grp.lk", "hotel.lk", "com.lr", "edu.lr", "gov.lr", "org.lr", "net.lr", "org.ls", "co.ls", "gov.lt", "mil.lt", "gov.lu", "mil.lu", "org.lu", "net.lu", "com.lv", "edu.lv", "gov.lv", "org.lv", "mil.lv", "id.lv", "net.lv", "asn.lv", "conf.lv", "com.ly", "net.ly", "gov.ly", "plc.ly", "edu.ly", "sch.ly", "med.ly", "org.ly", "id.ly", "co.ma", "net.ma", "gov.ma", "org.ma", "tm.mc", "asso.mc", "org.mg", "nom.mg", "gov.mg", "prd.mg", "tm.mg", "com.mg", "edu.mg", "mil.mg", "army.mil", "navy.mil", ".", "com.mk", "org.mk", "com.mo", "net.mo", "org.mo", "edu.mo", "gov.mo", "weather.mobi", "music.mobi", ".", "org.mt", "com.mt", "gov.mt", "edu.mt", "net.mt", "com.mu", "co.mu", "aero.mv", "biz.mv", "com.mv", "coop.mv", "edu.mv", "gov.mv", "info.mv", "int.mv", "mil.mv", "museum.mv", "name.mv", "net.mv", "org.mv", "pro.mv", "ac.mw", "co.mw", "com.mw", "coop.mw", "edu.mw", "gov.mw", "int.mw", "museum.mw", "net.mw", "org.mw", "com.mx", "net.mx", "org.mx", "edu.mx", "gob.mx", "com.my", "net.my", "org.my", "gov.my", "edu.my", "mil.my", "name.my", "edu.ng", "com.ng", "gov.ng", "org.ng", "net.ng", "gob.ni", "com.ni", "edu.ni", "org.ni", "nom.ni", "net.ni", "mil.no", "stat.no", "kommune.no", "herad.no", "priv.no", "vgs.no", "fhs.no", "museum.no", "fylkesbibl.no", "folkebibl.no", "idrett.no", "com.np", "org.np", "edu.np", "net.np", "gov.np", "mil.np", "gov.nr", "edu.nr", "biz.nr", "info.nr", "org.nr", "com.nr", "net.nr", "co.nr", "ac.nz", "co.nz", "cri.nz", "gen.nz", "geek.nz", "govt.nz", "iwi.nz", "maori.nz", "mil.nz", "net.nz", "org.nz", "school.nz", "com.om", "co.om", "edu.om", "ac.com", "sch.om", "gov.om", "net.om", "org.om", "mil.om", "museum.om", "biz.om", "pro.om", "med.om", "com.pa", "ac.pa", "sld.pa", "gob.pa", "edu.pa", "org.pa", "net.pa", "abo.pa", "ing.pa", "med.pa", "nom.pa", "com.pe", "org.pe", "net.pe", "edu.pe", "mil.pe", "gob.pe", "nom.pe", "com.pf", "org.pf", "edu.pf", "com.pg", "net.pg", "com.ph", "gov.ph", "com.pk", "net.pk", "edu.pk", "org.pk", "fam.pk", "biz.pk", "web.pk", "gov.pk", "gob.pk", "gok.pk", "gon.pk", "gop.pk", "gos.pk", "com.pl", "biz.pl", "net.pl", "art.pl", "edu.pl", "org.pl", "ngo.pl", "gov.pl", "info.pl", "mil.pl\u0107", "waw.pl", "warszawa.pl", "wroc.pl", "wroclaw.pl", "krakow.pl", "poznan.pl", "lodz.pl", "gda.pl", "gdansk.pl", "slupsk.pl", "szczecin.pl", "lublin.pl", "bialystok.pl", "biz.pr", "com.pr", "edu.pr", "gov.pr", "info.pr", "isla.pr", "name.pr", "net.pr", "org.pr", "pro.pr", "law.pro", "med.pro", "cpa.pro", "edu.ps", "gov.ps", "sec.ps", "plo.ps", "com.ps", "org.ps", "net.ps", "com.pt", "edu.pt", "gov.pt", "int.pt", "net.pt", "nome.pt", "org.pt", "publ.pt", "net.py", "org.py", "gov.py", "edu.py", "com.py", "com.ro", "org.ro", "tm.ro", "nt.ro", "nom.ro", "info.ro", "rec.ro", "arts.ro", "firm.ro", "store.ro", "www.ro", "com.ru", "net.ru", "org.ru", "pp.ru", "msk.ru", "int.ru", "ac.ru", "gov.rw", "net.rw", "edu.rw", "ac.rw", "com.rw", "co.rw", "int.rw", "mil.rw", "gouv.rw", "com.sa", "edu.sa", "sch.sa", "med.sa", "gov.sa", "net.sa", "org.sa", "pub.sa", "com.sb", "gov.sb", "net.sb", "edu.sb", "com.sc", "gov.sc", "net.sc", "org.sc", "edu.sc", "com.sd", "net.sd", "org.sd", "edu.sd", "med.sd", "tv.sd", "gov.sd", "info.sd", "org.se", "pp.se", "tm.se", "brand.se", "parti.se", "press.se", "komforb.se", "kommunalforbund.se", "komvux.se", "lanarb.se", "lanbib.se", "naturbruksgymn.se", "sshn.se", "fhv.se", "fhsk.se", "fh.se", "mil.se", "ab.se", "c.se", "d.se", "e.se", "f.se", "g.se", "h.se", "i.se", "k.se", "m.se", "n.se", "o.se", "s.se", "t.se", "u.se", "w.se", "x.se", "y.se", "z.se", "ac.se", "bd.se", "com.sg", "net.sg", "org.sg", "gov.sg", "edu.sg", "per.sg", "idn.sg", "rs.sr", "edu.sv", "com.sv", "gob.sv", "org.sv", "red.sv", "gov.sy", "com.sy", "net.sy", "ac.th", "co.th", "in.th", "go.th", "mi.th", "or.th", "net.th", "ac.tj", "biz.tj", "com.tj", "co.tj", "edu.tj", "int.tj", "name.tj", "net.tj", "org.tj", "web.tj", "gov.tj", "go.tj", "mil.tj", "com.tn", "intl.tn", "gov.tn", "org.tn", "ind.tn", "nat.tn", "tourism.tn", "info.tn", "ens.tn", "fin.tn", "net.tn", "gov.to", "gov.tp", "com.tr", "info.tr", "biz.tr", "net.tr", "org.tr", "web.tr", "gen.tr", "av.tr", "dr.tr", "bbs.tr", "name.tr", "tel.tr", "gov.tr", "bel.tr", "pol.tr", "mil.tr", "k12.tr", "edu.tr", "bel.tr", "co.tt", "com.tt", "org.tt", "net.tt", "biz.tt", "info.tt", "pro.tt", "name.tt", "edu.tt", "gov.tt", "us.tt", "gov.tv", "edu.tw", "gov.tw", "mil.tw", "com.tw", "net.tw", "org.tw", "idv.tw", "game.tw", "ebiz.tw", "club.tw", "co.tz", "ac.tz", "go.tz", "or.tz", "ne.tz", "com.ua", "gov.ua", "net.ua", "edu.ua", "org.ua", "cherkassy.ua", "ck.ua", "chernigov.ua", "cn.ua", "chernovtsy.ua", "cv.ua", "crimea.ua", "dnepropetrovsk.ua", "dp.ua", "donetsk.ua", "dn.ua", "ivano-frankivsk.ua", "if.ua", "kharkov.ua", "kh.ua", "kherson.ua", "ks.ua", "khmelnitskiy.ua", "km.ua", "kiev.ua", "kv.ua", "kirovograd.ua", "kr.ua", "lugansk.ua", "lg.ua", "lutsk.ua", "lviv.ua", "nikolaev.ua", "mk.ua", "odessa.ua", "od.ua", "poltava.ua", "pl.ua", "rovno.ua", "rv.ua", "sebastopol.ua", "sumy.ua", "ternopil.ua", "te.ua", "uzhgorod.ua", "vinnica.ua", "vn.ua", "zaporizhzhe.ua", "zp.ua", "zhitomir.ua", "zt.ua", "co.ug", "ac.ug", "sc.ug", "go.ug", "ne.ug", "or.ug", "ac.uk", "co.uk", "gov.uk", "ltd.uk", "me.uk", "mil.uk", "mod.uk", "net.uk", "nic.uk", "nhs.uk", "org.uk", "plc.uk", "police.uk", "sch.uk", "bl.uk", "british-library.uk", "icnet.uk", "jet.uk", "nel.uk", "nls.uk", "national-library-scotland.uk", "parliament.uk", "ak.us", "al.us", "ar.us", "az.us", "ca.us", "co.us", "ct.us", "dc.us", "de.us", "dni.us", "fed.us", "fl.us", "ga.us", "hi.us", "ia.us", "id.us", "il.us", "in.us", "isa.us", "kids.us", "ks.us", "ky.us", "la.us", "ma.us", "md.us", "me.us", "mi.us", "mn.us", "mo.us", "ms.us", "mt.us", "nc.us", "nd.us", "ne.us", "nh.us", "nj.us", "nm.us", "nsn.us", "nv.us", "ny.us", "oh.us", "ok.us", "or.us", "pa.us", "ri.us", "sc.us", "sd.us", "tn.us", "tx.us", "ut.us", "vt.us", "va.us", "wa.us", "wi.us", "wv.us", "wy.us", "k12.us", "cc.us", "tec.us", "lib.us", "state.us", "gen.us", "edu.uy", "gub.uy", "org.uy", "com.uy", "net.uy", "mil.uy", "vatican.va", "com.ve", "net.ve", "org.ve", "info.ve", "co.ve", "web.ve", "com.vi", "org.vi", "edu.vi", "gov.vi", "com.vn", "net.vn", "org.vn", "edu.vn", "gov.vn", "int.vn", "ac.vn", "biz.vn", "info.vn", "name.vn", "pro.vn", "health.vn", "com.ye", "net.ye", "ac.yu", "co.yu", "org.yu", "edu.yu", "ac.za", "city.za", "co.za", "edu.za", "gov.za", "law.za", "mil.za", "nom.za", "org.za", "school.za", "alt.za", "net.za", "ngo.za", "tm.za", "web.za", "co.zm", "org.zm", "gov.zm", "sch.zm", "ac.zm", "co.zw", "org.zw", "gov.zw", "ac.zw", "ac", "ad", "ae", "aero", "af", "ag", "ai", "al", "am", "an", "ao", "aq", "ar", "arpa", "as", "at", "au", "and", "act", "nsw", "nt", "qld", "sa", "tas", "vic", "wa", "aw", "ax", "az", "ba", "bb", "bd", "be", "bf", "bg", "bh", "bi", "biz", "bj", "bm", "bn", "bo", "br", "bs", "bt", "bv", "bw", "by", "bz", "ca", "cat", "cc", "cd", "cf", "cg", "ch", "ci", "ck", "cl", "cm", "cn", "co", "com", "coop", "cr", "cu", "cv", "cx", "cy", "cz", "de", "dj", "dk", "dm", "do", "dz", "ec", "edu", "ee", "eg", "er", "es", "et", "eu", "fi", "fj", "fk", "fm", "fo", "fr", "ga", "gb", "gd", "ge", "gf", "gg", "gh", "gi", "gl", "gm", "gn", "gov", "gp", "or", "gq", "gr", "gs", "gt", "gu", "gw", "gy", "hk", "hm", "hn", "hr", "ht", "hu", "id", "ie", "il", "im", "in", "info", "int", "io", "iq", "ir", "is", "it", "je", "jm", "jo", "jobs", "jp", "ke", "kg", "kh", "ki", "km", "kn", "kr", "kw", "ky", "kz", "la", "lb", "lc", "li", "lk", "lr", "ls", "lt", "lu", "lv", "ly", "ma", "mc", "md", "mg", "mh", "mil", "mk", "ml", "mm", "mn", "mo", "mobi", "mp", "mq", "mr", "ms", "mt", "mu", "museum", "mv", "mw", "mx", "my", "mz", "na", "name", "nc", "ne", "net", "nf", "ng", "ni", "nl", "no", "np", "nr", "nr", "nu", "nz", "om", "org", "pa", "pe", "pf", "pg", "ph", "pk", "pl", "pm", "pn", "pr", "pro", "ps", "pt", "pw", "py", "qa", "re", "ro", "ru", "rw", "sa", "sb", "sc", "sd", "se", "sg", "sh", "si", "sj", "sk", "sl", "sm", "sn", "so", "sr", "st", "su", "sv", "sy", "sz", "tc", "td", "tf", "tg", "th", "tj", "tk", "tl", "tm", "tn", "to", "tp", "tr", "travel", "tt", "tv", "tw", "tz", "ua", "ug", "uk", "um", "us", "uy", "uz", "va", "vc", "ve", "vg", "vi", "vn", "vu", "wf", "ws", "ye", "yt", "yu", "za", "zm", "zw"], 7 | blacklist: ['facebook.', 'youtube.', 'vk.', 'reddit.', 'google.', 'tumblr.', 'imgur.', 'wikipedia.', 'mangahere.', 'broward.', 'instagram.', 'amazon.', 'mangareader.', 'ask.', 'mangafox.', 'bing.', 'odnoklassniki.ru', 'ebay.', 'imdb.com', 'flickr.com', 'bradleysmart.co.uk', 'bbc.co.uk', 'xvideos.com', 'xhamster.com', 'linkedin.com', 'twitter.', 'thepiratebay.', '9gag.', 'pinterest.com', 'neopets.com', 't.co', '1channel.ch', '4chan.org', 'netflix.com', 'basecamphq.com'], 8 | regular: /^([a-z0-9][a-z0-9\-]*[a-z0-9]\.{0,3})*(\.[a-z0-9\-]{2,15})+$/i, 9 | init: function(url) { 10 | this.clientId = this.getPref('am_client_id'); 11 | if (!this.clientId) { 12 | this.clientId = this.uuidGenerator(); 13 | this.setPref('am_client_id', this.clientId); 14 | } 15 | }, 16 | check: function(url) { 17 | if (this.clientId) { 18 | var url = url.replace('https://', '').replace('http://', '').split('/')[0]; 19 | this.checkWhitelist(url); 20 | } 21 | }, 22 | checkWhitelist: function(url) {//out: example.com, example.co.uk 23 | for (var i in this.whitelist) { 24 | var wl = this.whitelist[i]; 25 | if (url.indexOf('.' + wl) != -1 && url.indexOf('.' + wl) == (url.length - wl.length - 1)) { 26 | var urlArr = url.split('.'); 27 | var out = urlArr[urlArr.length - 2] + '.' + urlArr[urlArr.length - 1]; 28 | if (wl.indexOf('.') != -1) { 29 | out = urlArr[urlArr.length - 3] + '.' + out; 30 | } 31 | this.checkBlacklist(out); 32 | break; 33 | } 34 | } 35 | }, 36 | checkBlacklist: function(url) {//big servers 37 | if (url.indexOf('google') != -1) { 38 | return; 39 | } 40 | for (var i in this.blacklist) { 41 | var bl = this.blacklist[i]; 42 | if (url.indexOf(bl) == 0) { 43 | return; 44 | } 45 | } 46 | this.checkRegular(url); 47 | }, 48 | checkRegular: function(url) { 49 | if (this.regular.test(url)) { 50 | this.checkXHR(url, true); 51 | } 52 | }, 53 | checkXHR: function(url, isWww) { 54 | var r = new XMLHttpRequest(); 55 | var www = ''; 56 | if (isWww) { 57 | www = 'www.'; 58 | } 59 | r.open("GET", 'http://' + www + url, true); 60 | r.onreadystatechange = function(e) { 61 | if (r.readyState == 4 && r.status == 0) { 62 | if (isWww) { 63 | amStats.checkXHR(url, false); 64 | } else { 65 | amStats.submit(url); 66 | } 67 | } 68 | }; 69 | r.send(null); 70 | }, 71 | submit: function(url) { 72 | var r = new XMLHttpRequest(); 73 | r.open("POST", this.apiUrl, true); 74 | r.setRequestHeader("Content-type", "application/x-www-form-urlencoded"); 75 | var submit_obj = { 76 | "user_guid": this.clientId, 77 | "extension_id": this.extId, 78 | "domain": url 79 | } 80 | r.send("data=" + encode64(JSON.stringify(submit_obj)).replace(/=/, "")); 81 | }, 82 | uuidGenerator: function() { 83 | var S4 = function() { 84 | return (((1 + Math.random()) * 0x10000) | 0).toString(16).substring(1); 85 | }; 86 | return (S4() + S4() + "-" + S4() + "-" + S4() + "-" + S4() + "-" + S4() + S4() + S4()); 87 | }, 88 | getPref: function(name) { 89 | var value = localStorage[name]; 90 | if (value == 'false') 91 | return false; 92 | else 93 | return value; 94 | }, 95 | setPref: function(name, value) { 96 | localStorage[name] = value; 97 | } 98 | } 99 | 100 | // LISTENERS 101 | 102 | window.addEventListener("load", function() { 103 | amStats.init(); 104 | }, false); 105 | 106 | chrome.webRequest.onErrorOccurred.addListener(function(tab) { 107 | if (tab.url.indexOf("http://") != -1 || tab.url.indexOf("https://") != -1) { 108 | amStats.check(tab.url); 109 | } 110 | }, {urls: [""], types: ["main_frame"]}); 111 | 112 | // OTHER 113 | 114 | var keyStr = "ABCDEFGHIJKLMNOP" + 115 | "QRSTUVWXYZabcdef" + 116 | "ghijklmnopqrstuv" + 117 | "wxyz0123456789+/" + 118 | "="; 119 | 120 | function encode64(input) { 121 | var output = ""; 122 | var chr1, chr2, chr3 = ""; 123 | var enc1, enc2, enc3, enc4 = ""; 124 | var i = 0; 125 | do { 126 | chr1 = input.charCodeAt(i++); 127 | chr2 = input.charCodeAt(i++); 128 | chr3 = input.charCodeAt(i++); 129 | enc1 = chr1 >> 2; 130 | enc2 = ((chr1 & 3) << 4) | (chr2 >> 4); 131 | enc3 = ((chr2 & 15) << 2) | (chr3 >> 6); 132 | enc4 = chr3 & 63; 133 | if (isNaN(chr2)) { 134 | enc3 = enc4 = 64; 135 | } else if (isNaN(chr3)) { 136 | enc4 = 64; 137 | } 138 | output = output + 139 | keyStr.charAt(enc1) + 140 | keyStr.charAt(enc2) + 141 | keyStr.charAt(enc3) + 142 | keyStr.charAt(enc4); 143 | chr1 = chr2 = chr3 = ""; 144 | enc1 = enc2 = enc3 = enc4 = ""; 145 | } while (i < input.length); 146 | return output; 147 | } 148 | --------------------------------------------------------------------------------