├── test ├── google-chrome │ ├── dir1 │ │ └── empty.txt │ └── dir2 │ │ └── obipchajaiohjoohongibhgbfgchblei │ │ └── empty.txt ├── sublime-text │ ├── dir1 │ │ └── empty.txt │ ├── dir4 │ │ └── Packages │ │ │ └── empty.txt │ ├── dir2 │ │ ├── LiveStyle │ │ │ └── livestyle.exe │ │ └── Packages │ │ │ └── LiveStyle │ │ │ └── empty.txt │ ├── dir3 │ │ └── Installed Packages │ │ │ └── LiveStyle.sublime-package │ ├── plugin.zip │ └── commit.json ├── static │ ├── sample1.zip │ ├── sample2.zip │ ├── index.html │ └── inner │ │ ├── foo.html │ │ └── index.html ├── google-chrome.js ├── server.js ├── unzip.js ├── tunnel.js ├── download.js └── sublime-text.js ├── assets ├── chrome.png ├── menu-icon.ico ├── lato-regular.woff ├── sublime-text.png ├── win-menu-icon.png ├── menu-iconTemplate.png ├── menu-iconTemplate@2x.png ├── flat-ui-icons-regular.woff └── main.css ├── livestyle.tar.enc ├── tools ├── windows │ └── livestyle.pfx.enc ├── branding │ ├── icon │ │ ├── livestyle.icns │ │ └── livestyle.ico │ ├── resources │ │ └── install-spinner.gif │ ├── zip.js │ ├── cmd.js │ ├── win.js │ └── osx.js ├── release-info.js ├── osx │ ├── codesign.sh │ └── import-key.sh ├── publish.js ├── distribute.js └── release.js ├── .gitignore ├── lib ├── google-chrome │ ├── index.js │ ├── detect.js │ └── install.js ├── sublime-text │ ├── index.js │ ├── detect.js │ ├── install.js │ └── autoupdate.js ├── win-env.js ├── helpers │ ├── request.js │ ├── download.js │ └── unzip.js ├── autoupdate │ ├── darwin.js │ ├── win32.js │ ├── index.js │ └── gh-release.js ├── file-server.js ├── identify.js ├── utils.js ├── apps.js ├── node-utils.js ├── client.js ├── controller │ ├── file-server.js │ ├── tunnel.js │ └── app-model.js ├── server.js └── model.js ├── ui ├── chrome.js ├── utils.js ├── popup.js ├── plugin-status.js ├── rv-sessions.js ├── sublime-text-status.js ├── sublime-text.js └── controller.js ├── appveyor.yml ├── package.json ├── .travis.yml ├── index.html ├── main.js └── backend.js /test/google-chrome/dir1/empty.txt: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/sublime-text/dir1/empty.txt: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/sublime-text/dir4/Packages/empty.txt: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/sublime-text/dir2/LiveStyle/livestyle.exe: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/sublime-text/dir2/Packages/LiveStyle/empty.txt: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/google-chrome/dir2/obipchajaiohjoohongibhgbfgchblei/empty.txt: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/sublime-text/dir3/Installed Packages/LiveStyle.sublime-package: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /assets/chrome.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/livestyle/app/HEAD/assets/chrome.png -------------------------------------------------------------------------------- /livestyle.tar.enc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/livestyle/app/HEAD/livestyle.tar.enc -------------------------------------------------------------------------------- /assets/menu-icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/livestyle/app/HEAD/assets/menu-icon.ico -------------------------------------------------------------------------------- /assets/lato-regular.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/livestyle/app/HEAD/assets/lato-regular.woff -------------------------------------------------------------------------------- /assets/sublime-text.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/livestyle/app/HEAD/assets/sublime-text.png -------------------------------------------------------------------------------- /assets/win-menu-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/livestyle/app/HEAD/assets/win-menu-icon.png -------------------------------------------------------------------------------- /test/static/sample1.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/livestyle/app/HEAD/test/static/sample1.zip -------------------------------------------------------------------------------- /test/static/sample2.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/livestyle/app/HEAD/test/static/sample2.zip -------------------------------------------------------------------------------- /assets/menu-iconTemplate.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/livestyle/app/HEAD/assets/menu-iconTemplate.png -------------------------------------------------------------------------------- /test/sublime-text/plugin.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/livestyle/app/HEAD/test/sublime-text/plugin.zip -------------------------------------------------------------------------------- /assets/menu-iconTemplate@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/livestyle/app/HEAD/assets/menu-iconTemplate@2x.png -------------------------------------------------------------------------------- /tools/windows/livestyle.pfx.enc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/livestyle/app/HEAD/tools/windows/livestyle.pfx.enc -------------------------------------------------------------------------------- /assets/flat-ui-icons-regular.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/livestyle/app/HEAD/assets/flat-ui-icons-regular.woff -------------------------------------------------------------------------------- /tools/branding/icon/livestyle.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/livestyle/app/HEAD/tools/branding/icon/livestyle.icns -------------------------------------------------------------------------------- /tools/branding/icon/livestyle.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/livestyle/app/HEAD/tools/branding/icon/livestyle.ico -------------------------------------------------------------------------------- /tools/branding/resources/install-spinner.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/livestyle/app/HEAD/tools/branding/resources/install-spinner.gif -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | /*.zip 3 | dist 4 | tools/osx/livestyle.cer 5 | tools/osx/livestyle.p12 6 | osx-auto-update.json 7 | tools/windows/livestyle.pfx 8 | test/sublime-text/out 9 | -------------------------------------------------------------------------------- /lib/google-chrome/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Module for working with Google Chrome plugin 3 | */ 4 | 'use strict'; 5 | 6 | module.exports = { 7 | detect: require('./detect'), 8 | install: require('./install') 9 | }; -------------------------------------------------------------------------------- /test/static/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Document 6 | 7 | 8 | Local file server example 9 | 10 | -------------------------------------------------------------------------------- /test/static/inner/foo.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Document 6 | 7 | 8 | Local file server example 9 | 10 | -------------------------------------------------------------------------------- /test/static/inner/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Document 6 | 7 | 8 | Local file server example 9 | 10 | -------------------------------------------------------------------------------- /lib/sublime-text/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Module for working with Sublime Text plugin 3 | */ 4 | 'use strict'; 5 | 6 | module.exports = { 7 | detect: require('./detect'), 8 | install: require('./install'), 9 | autoupdate: require('./autoupdate') 10 | }; -------------------------------------------------------------------------------- /tools/release-info.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var pkg = require('../package.json'); 4 | var parseUrl = require('url').parse; 5 | 6 | module.exports = { 7 | repo: parseUrl(pkg.repository.url).pathname.slice(1).replace(/\.git$/, ''), 8 | release: 'v' + pkg.version 9 | }; -------------------------------------------------------------------------------- /tools/osx/codesign.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | KEY_CHAIN=osx-build.keychain 4 | IDENTITY=A092C6443530A5B3106C48C98885B3FA3B51772A 5 | APP="./dist/darwin/LiveStyle.app" 6 | 7 | # sign 8 | codesign --deep --force --verify --verbose --keychain $KEY_CHAIN --sign "$IDENTITY" "$APP" 9 | 10 | # verify 11 | codesign -vvvv -d "$APP" 12 | spctl -a -vvvv "$APP" -------------------------------------------------------------------------------- /lib/win-env.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Windows-specific environment variables 3 | */ 4 | var env = process.env; 5 | var winEnv = module.exports = { 6 | PROGRAMFILES_X86: env['PROGRAMFILES(X86)'] || env['PROGRAMFILES'], 7 | PROGRAMFILES_X64: env.PROGRAMW6432, // "C:\Program Files" on x64 8 | USERPROFILE: env.USERPROFILE || env.HOMEDRIVE + env.HOMEPATH, 9 | X64: process.arch == 'x64' || 'PROGRAMFILES(X86)' in env || 'PROCESSOR_ARCHITEW6432' in env 10 | }; -------------------------------------------------------------------------------- /tools/osx/import-key.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | KEY_CHAIN=osx-build.keychain 4 | 5 | # create keychain 6 | security create-keychain -p travis $KEY_CHAIN 7 | security default-keychain -s $KEY_CHAIN 8 | security unlock-keychain -p travis $KEY_CHAIN 9 | security set-keychain-settings -t 3600 -u $KEY_CHAIN 10 | 11 | security import ./livestyle.cer -k $KEY_CHAIN -T /usr/bin/codesign 12 | security import ./livestyle.p12 -k $KEY_CHAIN -P $CERT_ACCESS -T /usr/bin/codesign 13 | -------------------------------------------------------------------------------- /ui/chrome.js: -------------------------------------------------------------------------------- 1 | /** 2 | * A module for rendering Chrome plugin state: returns a function 3 | * that can takes model and updates given view accordingly 4 | */ 5 | 'use strict'; 6 | 7 | const ipc = require('electron').ipcRenderer; 8 | const $ = require('./utils').qs; 9 | const pluginStatus = require('./plugin-status'); 10 | 11 | module.exports = function(elem) { 12 | $('.extension-install-btn', elem) 13 | .addEventListener('click', evt => ipc.send('install-plugin', 'chrome')); 14 | 15 | return function render(model) { 16 | pluginStatus.update(elem, model.chrome); 17 | }; 18 | }; -------------------------------------------------------------------------------- /ui/utils.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Utils for app’s UI part 3 | */ 4 | 'use strict'; 5 | 6 | module.exports.qs = function(sel, context) { 7 | return (context || document).querySelector(sel); 8 | }; 9 | 10 | module.exports.qsa = function(sel, context) { 11 | return toArray((context || document).querySelectorAll(sel)); 12 | }; 13 | 14 | module.exports.closest = function(elem, sel) { 15 | while (elem && elem !== document) { 16 | if (elem.matches(sel)) { 17 | return elem; 18 | } 19 | 20 | elem = elem.parentNode; 21 | } 22 | }; 23 | 24 | function toArray(obj) { 25 | return Array.prototype.slice.call(obj); 26 | } -------------------------------------------------------------------------------- /lib/helpers/request.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const http = require('http'); 4 | const https = require('https'); 5 | const parseUrl = require('url').parse; 6 | 7 | module.exports = function(url) { 8 | return new Promise((resolve, reject) => { 9 | let transport = /^https:/.test(url) ? https : http; 10 | let payload = parseUrl(url); 11 | payload.headers = { 12 | 'User-Agent': 'LiveStyle app' 13 | }; 14 | 15 | transport.get(payload, res => { 16 | let data = []; 17 | res 18 | .on('data', chunk => data.push(chunk)) 19 | .on('end', () => resolve(Buffer.concat(data).toString())) 20 | .on('error', reject); 21 | }) 22 | .on('error', reject); 23 | }); 24 | } -------------------------------------------------------------------------------- /tools/publish.js: -------------------------------------------------------------------------------- 1 | /** 2 | * A script for packing and publishing app 3 | */ 4 | 'use strict'; 5 | 6 | var path = require('path'); 7 | var fs = require('fs'); 8 | var bundle = require('./distribute'); 9 | var publish = require('./release'); 10 | var info = require('./release-info'); 11 | 12 | console.log('Packing and publishing app for %s platform (%s)', process.platform, info.release); 13 | 14 | bundle() 15 | .then(assets => { 16 | console.log('Created assets', assets); 17 | return publish({ 18 | release: info.release, 19 | repo: info.repo, 20 | assets 21 | }); 22 | }) 23 | .then(() => console.log('Published assets in %s release', info.release)) 24 | .catch(err => { 25 | console.error(err); 26 | process.exit(1); 27 | }); -------------------------------------------------------------------------------- /lib/autoupdate/darwin.js: -------------------------------------------------------------------------------- 1 | /** 2 | * GitHub Release auto-update module for OSX app. 3 | * Fetches all releases for current project repo and finds most recent one. 4 | * Then it checks if there is a `osx-auto-update.json` asset exists. If so, 5 | * returns it as auto-update feed URL. 6 | */ 7 | 'use strict'; 8 | 9 | const ghRelease = require('./gh-release'); 10 | 11 | const feedFile = 'osx-auto-update.json'; 12 | 13 | module.exports = function(pkg) { 14 | return ghRelease.findUpdateRelease(pkg) 15 | .then(release => { 16 | var feed = release.assets[feedFile]; 17 | if (!feed) { 18 | return ghRelease.warn(`No ${feedFile} asset in latest ${release.name} release`); 19 | } 20 | 21 | return feed.browser_download_url; 22 | }); 23 | }; -------------------------------------------------------------------------------- /lib/autoupdate/win32.js: -------------------------------------------------------------------------------- 1 | /** 2 | * GitHub Release auto-update module for OSX app. 3 | * Fetches most recent release from project repo. 4 | * Then it checks if there is a `RELEASES` asset exists. If so, 5 | * returns its parent foldder as auto-update feed URL. 6 | */ 7 | 'use strict'; 8 | 9 | const path = require('path'); 10 | const ghRelease = require('./gh-release'); 11 | 12 | const feedFile = 'RELEASES'; 13 | 14 | module.exports = function(pkg) { 15 | return ghRelease.findUpdateRelease(pkg) 16 | .then(release => { 17 | var feed = release.assets[feedFile]; 18 | if (!feed) { 19 | return ghRelease.warn(`No ${feedFile} asset in latest ${release.name} release`); 20 | } 21 | 22 | return path.dirname(feed.browser_download_url); 23 | }); 24 | }; -------------------------------------------------------------------------------- /appveyor.yml: -------------------------------------------------------------------------------- 1 | # Test against this version of Node.js 2 | environment: 3 | nodejs_version: "4.1.1" 4 | SIGN_TOOL: 'C:\Program Files (x86)\Microsoft SDKs\Windows\v7.1A\Bin\signtool.exe' 5 | PUBLISH_TOKEN: 6 | secure: w/pAoJMD7xgxclssGbzQOfgs81EQyiAfkRJMeH6e6bPiJXyUHtr403v9z10zb4GZ 7 | WIN_CERTIFICATE_PASSWORD: 8 | secure: XiZURshRBagjOmfggTF+jxSgikSYaNFr79q/zSPKkQo= 9 | 10 | platform: 11 | - x86 12 | 13 | branches: 14 | only: 15 | - release 16 | 17 | # Install scripts. (runs after repo cloning) 18 | install: 19 | - ps: Install-Product node $env:nodejs_version 20 | - nuget install secure-file -ExcludeVersion 21 | - secure-file\tools\secure-file -decrypt tools\windows\livestyle.pfx.enc -secret %WIN_CERTIFICATE_PASSWORD% -out tools\windows\livestyle.pfx 22 | - npm install 23 | 24 | test_script: 25 | - npm test 26 | - npm run publish 27 | 28 | artifacts: 29 | - path: dist\win32\installer 30 | 31 | # Don't actually build. 32 | build: off -------------------------------------------------------------------------------- /test/google-chrome.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const path = require('path'); 4 | const assert = require('assert'); 5 | const chrome = require('../lib/google-chrome'); 6 | 7 | describe('Google Chrome', () => { 8 | let dir = d => path.resolve(__dirname, d); 9 | 10 | // Google Chrome app detection relies on third-party lib (browser-launcher2), 11 | // no need to test. 12 | // Test plugin detection only 13 | describe('detect plugin', () => { 14 | var extensionId = ['obipchajaiohjoohongibhgbfgchblei', 'diebikgmpmeppiilkaijjbdgciafajmg']; 15 | it('not exists', done => { 16 | chrome.detect.plugin({ 17 | lookup: [dir('google-chrome/dir1')], 18 | extensionId 19 | }) 20 | .then(result => { 21 | assert.equal(result, false); 22 | done(); 23 | }) 24 | .catch(done); 25 | }); 26 | 27 | it('exists', done => { 28 | chrome.detect.plugin({ 29 | lookup: [dir('google-chrome/dir2')], 30 | extensionId 31 | }) 32 | .then(result => { 33 | assert(result); 34 | assert.equal(path.basename(result), extensionId[0]); 35 | done(); 36 | }) 37 | .catch(done); 38 | }); 39 | }); 40 | }); -------------------------------------------------------------------------------- /ui/popup.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Popup controller 3 | */ 4 | 'use strict'; 5 | var $ = require('./utils').qs; 6 | var $$ = require('./utils').qsa; 7 | var closest = require('./utils').closest; 8 | 9 | module.exports = function() { 10 | document.addEventListener('click', function(evt) { 11 | var trigger = closest(evt.target, '[data-popup]'); 12 | if (trigger) { 13 | var popupId = trigger.dataset.popup; 14 | var popup = document.getElementById(popupId); 15 | if (popup) { 16 | show(popup); 17 | } 18 | return; 19 | } 20 | 21 | if (!closest(evt.target, '.popup-content') || closest(evt.target, '.popup-close')) { 22 | // clicked outside popup content or on popup close icon: 23 | // hide all popups 24 | return hideAll(); 25 | } 26 | }); 27 | 28 | document.addEventListener('keyup', function(evt) { 29 | if (evt.keyCode === 27) { // ESC key 30 | hideAll(); 31 | } 32 | }); 33 | }; 34 | 35 | function show(popup) { 36 | popup.classList.add('popup_visible'); 37 | } 38 | 39 | function hide(popup) { 40 | popup.classList.remove('popup_visible'); 41 | } 42 | 43 | function hideAll() { 44 | $$('.popup').forEach(hide); 45 | } -------------------------------------------------------------------------------- /lib/autoupdate/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const autoUpdater = require('electron').autoUpdater; 4 | const debug = require('debug')('lsapp:autoupdate'); 5 | var feedFetcher; 6 | try { 7 | feedFetcher = require(`./${process.platform}`); 8 | } catch(e) {}; 9 | 10 | const checkTimeout = 60 * 60 * 1000; // interval between polls 11 | 12 | module.exports = function(pkg) { 13 | if (!feedFetcher) { 14 | console.warn('No valid feed fetcher for current platform'); 15 | } else { 16 | check(pkg); 17 | } 18 | 19 | return autoUpdater; 20 | }; 21 | 22 | function check(pkg) { 23 | debug('Checking for updates...'); 24 | return feedFetcher(pkg) 25 | .then(feedUrl => { 26 | debug('Update available, feed url: %s', feedUrl); 27 | autoUpdater.setFeedURL(feedUrl); 28 | autoUpdater.checkForUpdates(); 29 | }) 30 | .catch(err => { 31 | if (err) { 32 | if (err.code === 'EAUTOUPDATEWARN') { 33 | // a simple warning, try again later 34 | console.warn(err); 35 | setTimeout(() => check(pkg), checkTimeout).unref(); 36 | } else { 37 | // looks like a fatal error, can’t recover 38 | console.error(err); 39 | return Promise.reject(err); 40 | } 41 | } 42 | }); 43 | } -------------------------------------------------------------------------------- /test/server.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var assert = require('assert'); 4 | var server = require('../lib/server'); 5 | var client = require('../lib/client'); 6 | 7 | describe('Server Connectivity', function() { 8 | var opt = {reconnectOnClose: false}; 9 | it('connect to server', function(done) { 10 | server(54001, function() { 11 | client('ws://127.0.0.1:54001/livestyle', opt, function(err, ws) { 12 | assert(!err); 13 | assert(ws); 14 | assert.equal(ws.readyState, 1); 15 | server.destroy(done); 16 | }); 17 | }); 18 | }); 19 | 20 | it('auto-create server', function(done) { 21 | client('ws://127.0.0.1:54001/livestyle', opt, function(err, ws) { 22 | assert(!err); 23 | assert(ws); 24 | assert.equal(ws.readyState, 1); 25 | server.destroy(done); 26 | }); 27 | }); 28 | 29 | it('send & receive message', function(done) { 30 | client('ws://127.0.0.1:54001/livestyle', opt, function(err, ws) { 31 | assert(!err); 32 | assert(ws); 33 | 34 | var message = { 35 | name: 'foo', 36 | data: 'bar' 37 | }; 38 | 39 | ws.on(message.name, function(data) { 40 | assert.equal(data, message.data); 41 | server.destroy(done); 42 | }); 43 | server.send(message); 44 | }); 45 | }); 46 | }); -------------------------------------------------------------------------------- /lib/file-server.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Creates a siple HTTP server for given folder. Used by Remote View for 3 | * creating connections for `file:` origins 4 | */ 5 | 'use strict'; 6 | 7 | var fs = require('graceful-fs'); 8 | var path = require('path'); 9 | var http = require('http'); 10 | var connect = require('connect'); 11 | var serveStatic = require('serve-static'); 12 | 13 | module.exports = function(dir) { 14 | return check(dir).then(createServer); 15 | }; 16 | 17 | /** 18 | * Check if given folder exists and is readable 19 | * @param {String} dir 20 | * @return {Promise} 21 | */ 22 | function check(dir) { 23 | dir = path.normalize(dir.replace(/^file:\/\//, '')); 24 | return new Promise(function(resolve, reject) { 25 | fs.readdir(dir, err => err ? reject(err) : resolve(dir)); 26 | }); 27 | } 28 | 29 | /** 30 | * Creates HTTP server for given folder 31 | * @param {String} dir 32 | * @return {Promise} 33 | */ 34 | function createServer(dir) { 35 | return new Promise(function(resolve, reject) { 36 | var app = connect().use(serveStatic(dir)); 37 | var server = http.createServer(app); 38 | server.once('error', reject); 39 | // start server on random port 40 | server.listen(0, 'localhost', () => resolve(server)); 41 | }); 42 | } -------------------------------------------------------------------------------- /ui/plugin-status.js: -------------------------------------------------------------------------------- 1 | /** 2 | * A helper module for resolving given plugin state 3 | */ 4 | 'use strict'; 5 | 6 | const messages = { 7 | 'not-installed': '', 8 | 'installed': 'Installed' 9 | }; 10 | 11 | module.exports = function(value) { 12 | var state = value || 'progress'; 13 | var message = ''; 14 | var progressMessage = 'Checking status'; 15 | 16 | if (isError(value)) { 17 | state = 'error'; 18 | message = value.error; 19 | } 20 | 21 | if (state in messages) { 22 | message = messages[state]; 23 | } 24 | 25 | if (state === 'updating') { 26 | state = 'progress'; 27 | progressMessage = 'Updating plugin'; 28 | } 29 | 30 | if (state === 'installing') { 31 | state = 'progress'; 32 | progressMessage = 'Installing plugin'; 33 | } 34 | 35 | return {state, message, progressMessage, value}; 36 | }; 37 | 38 | module.exports.update = function(ctx, status) { 39 | if (typeof status !== 'object' || isError(status)) { 40 | status = module.exports(status); 41 | } 42 | 43 | ctx.dataset.extensionState = status.state; 44 | ctx.querySelector('.extension-message').innerHTML = status.message; 45 | ctx.querySelector('.extension-progress__message').innerHTML = status.progressMessage; 46 | } 47 | 48 | function isError(value) { 49 | return typeof value === 'object' && 'error' in value; 50 | } -------------------------------------------------------------------------------- /ui/rv-sessions.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Renders UI for Remote View sessions 3 | */ 4 | 'use strict'; 5 | 6 | var ipc = require('electron').ipcRenderer; 7 | var $ = require('./utils').qs; 8 | var closest = require('./utils').closest; 9 | 10 | module.exports = function(elem) { 11 | elem.addEventListener('click', function(evt) { 12 | if (evt.target.classList.contains('rv-session-remove')) { 13 | // clicked on "remove" icon 14 | var item = closest(evt.target, '.rv-session-item'); 15 | if (item) { 16 | ipc.send('rv-close-session', item.id); 17 | } 18 | } 19 | }); 20 | 21 | return function render(model) { 22 | var sessionList = model.rvSessions || []; 23 | console.log('update with list', sessionList); 24 | 25 | elem.classList.toggle('rv-pane_disabled', !sessionList.length); 26 | $('.rv-session', elem).innerHTML = sessionList.map(function(session) { 27 | return `
  • 28 |
    29 | http://${session.publicId} 30 |
    31 |
    32 | ${session.localSite} 33 |
    34 | 35 |
  • `; 36 | }).join('\n'); 37 | } 38 | }; -------------------------------------------------------------------------------- /test/unzip.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const fs = require('graceful-fs'); 4 | const path = require('path'); 5 | const assert = require('assert'); 6 | const temp = require('temp').track(); 7 | const unzip = require('../lib/helpers/unzip'); 8 | 9 | describe('Unzip', () => { 10 | it('basic unpack', done => { 11 | temp.mkdir('lsapp-zip', (err, dir) => { 12 | if (err) { 13 | return done(err); 14 | } 15 | 16 | unzip(path.resolve(__dirname, 'static/sample1.zip'), dir) 17 | .then(dir => { 18 | assert(dir); 19 | fs.readdir(dir, (err, items) => { 20 | assert.equal(items.length, 2); 21 | assert(items.indexOf('index.html') !== -1); 22 | assert(items.indexOf('inner') !== -1); 23 | done(); 24 | }); 25 | }) 26 | .catch(done); 27 | }); 28 | }); 29 | 30 | it('unpack with dir switch', done => { 31 | unzip(path.resolve(__dirname, 'static/sample2.zip')) 32 | .then(dir => { 33 | // should switch to `inner` dir in result 34 | assert(dir); 35 | assert.equal(path.basename(dir), 'inner'); 36 | fs.readdir(dir, (err, items) => { 37 | assert.equal(items.length, 2); 38 | assert(items.indexOf('index.html') !== -1); 39 | assert(items.indexOf('foo.html') !== -1); 40 | assert(items.indexOf('inner') === -1); 41 | done(); 42 | }); 43 | }) 44 | .catch(done); 45 | }); 46 | }); -------------------------------------------------------------------------------- /ui/sublime-text-status.js: -------------------------------------------------------------------------------- 1 | /** 2 | * A dedicated module for resolving status of Sublime Text plugin. 3 | * The problem with ST plugin UI is that it must render status 4 | * for two plugins (two versions of Sumlime Text). 5 | * 6 | * Extracted as separate module for unit testing 7 | */ 8 | 'use strict'; 9 | 10 | const extend = require('xtend'); 11 | const pluginStatus = require('./plugin-status'); 12 | 13 | module.exports = function(model) { 14 | var st2 = pluginStatus(model.st2); 15 | var st3 = pluginStatus(model.st3); 16 | var is = state => (st2.state === state) ? st2 : (st3.state === state) && st3; 17 | 18 | if (is('error')) { 19 | let errState = is('error'); 20 | // if either ST2 or ST3 is not installed, return state of installed app 21 | if (errState.value.errorCode === 'ENOSUBLIMETEXT' && st2.state !== st3.state) { 22 | let result = errState === st2 ? st3 : st2; 23 | if (result.state === 'not-installed') { 24 | result = extend(result, {missing: [result === st2 ? 'st2' : 'st3']}); 25 | } 26 | return result; 27 | } 28 | 29 | return errState; 30 | } 31 | 32 | if (is('not-installed')) { 33 | // both plugins are not installed 34 | if (st2.state === st3.state) { 35 | return extend(st2, {missing: ['st2', 'st3']}); 36 | } 37 | 38 | // one of the plugins is not installed 39 | return { 40 | state: 'partially-installed', 41 | message: '', 42 | progressMessage: '', 43 | missing: [is('not-installed') === st2 ? 'st2' : 'st3'] 44 | }; 45 | } 46 | 47 | return is('progress') || st3 || st2; 48 | }; 49 | 50 | module.exports.update = pluginStatus.update; -------------------------------------------------------------------------------- /lib/identify.js: -------------------------------------------------------------------------------- 1 | /** 2 | * A common plugin identification protocol: send `identify-client` 3 | * message and wait for `client-id` response with given `id` value 4 | */ 5 | 'use strict'; 6 | var debug = require('debug')('lsapp:identify'); 7 | var utils = require('./utils'); 8 | 9 | var throttled = new Map(); 10 | 11 | module.exports = function identify(client, id) { 12 | return new Promise(function(resolve, reject) { 13 | if (!client) { 14 | let err = new Error('Client is not provided'); 15 | err.code = 'ENOCLIENT'; 16 | return reject(err); 17 | } 18 | 19 | var timer = setTimeout(function() { 20 | cleanUp(); 21 | reject(); 22 | }, 500); 23 | 24 | var cleanUp = function() { 25 | if (timer) { 26 | clearTimeout(timer); 27 | timer = null; 28 | } 29 | client.removeListener('client-id', onClientId); 30 | }; 31 | 32 | var onClientId = function(data) { 33 | debug('received "client-id" with %s', data && data.id); 34 | if (data && data.id) { 35 | let expected = Array.isArray(id) 36 | ? id.indexOf(data.id) !== -1 37 | : id === data.id; 38 | if (expected) { 39 | cleanUp() 40 | resolve(); 41 | } 42 | } 43 | }; 44 | 45 | client.on('client-id', onClientId); 46 | sendRequest(client); 47 | }); 48 | }; 49 | 50 | function sendRequest(client) { 51 | if (!throttled.has(client)) { 52 | throttled.set(client, utils.throttle(function() { 53 | debug('sending "identify-client" request'); 54 | client.send('identify-client'); 55 | throttled.delete(client); 56 | }, 10, {leading: false})); 57 | } 58 | throttled.get(client)(); 59 | } -------------------------------------------------------------------------------- /ui/sublime-text.js: -------------------------------------------------------------------------------- 1 | /** 2 | * A module for rendering Sublime Text plugin state: returns a function 3 | * that can takes model attribute and updates given view accordingly. 4 | * 5 | * This UI component actually works with two apps: Sublime Text 2 6 | * and Sublime Text 3, so we have to use aggregates state 7 | */ 8 | 'use strict'; 9 | 10 | const ipc = require('electron').ipcRenderer; 11 | const $ = require('./utils').qs; 12 | const pluginStatus = require('./sublime-text-status'); 13 | 14 | module.exports = function(elem) { 15 | var btn = $('.extension-install-btn', elem); 16 | 17 | btn.addEventListener('click', function() { 18 | (this.dataset.missing || '').split(',') 19 | .filter(Boolean) 20 | .forEach(version => ipc.send('install-plugin', version)) 21 | }); 22 | 23 | return function render(model) { 24 | var status = pluginStatus(model); 25 | console.log('Sublime Text status', status); 26 | if (status.state === 'error' && status.value.errorCode === 'ENOSUBLIMETEXT') { 27 | // TODO help Windows users with portable installation: 28 | // pick folder and scan it for LiveStyle plugin installation. 29 | // For now, simply tell users install it manually 30 | status.message = 'Unable to find Sublime Text installation folder. If you’re using portable version, try to install it manually.'; 31 | } 32 | 33 | var missing = status.missing || ['']; 34 | btn.dataset.missing = missing.join(','); 35 | $('.version', btn).innerText = missing.length === 1 36 | ? missing[0].replace(/^[a-z]+/, '') 37 | : ''; 38 | 39 | pluginStatus.update(elem, status); 40 | }; 41 | }; -------------------------------------------------------------------------------- /lib/utils.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports.removeFromArray = function(arr, item) { 4 | for (var i = arr.length - 1; i >= 0; i--) { 5 | if (arr[i] === item) { 6 | arr.splice(i, 1); 7 | } 8 | } 9 | return arr; 10 | }; 11 | 12 | 13 | /** 14 | * Returns a function, that, when invoked, will only be triggered at most once 15 | * during a given window of time. Normally, the throttled function will run 16 | * as much as it can, without ever going more than once per wait duration; 17 | * but if you’d like to disable the execution on the leading edge, 18 | * pass {leading: false}. To disable execution on the trailing edge, ditto. 19 | * 20 | * @copyright Underscore.js 21 | */ 22 | module.exports.throttle = function(func, wait, options) { 23 | var context, args, result; 24 | var timeout = null; 25 | var previous = 0; 26 | if (!options) options = {}; 27 | var later = function() { 28 | previous = options.leading === false ? 0 : Date.now(); 29 | timeout = null; 30 | result = func.apply(context, args); 31 | if (!timeout) context = args = null; 32 | }; 33 | return function() { 34 | var now = Date.now(); 35 | if (!previous && options.leading === false) previous = now; 36 | var remaining = wait - (now - previous); 37 | context = this; 38 | args = arguments; 39 | if (remaining <= 0 || remaining > wait) { 40 | if (timeout) { 41 | clearTimeout(timeout); 42 | timeout = null; 43 | } 44 | previous = now; 45 | result = func.apply(context, args); 46 | if (!timeout) context = args = null; 47 | } else if (!timeout && options.trailing !== false) { 48 | timeout = setTimeout(later, remaining); 49 | } 50 | return result; 51 | }; 52 | }; -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "livestyle-app", 3 | "version": "1.0.0", 4 | "description": "LiveStyle desktop app with Remote View", 5 | "main": "main.js", 6 | "scripts": { 7 | "test": "./node_modules/.bin/mocha --reporter spec", 8 | "publish": "node ./tools/publish.js", 9 | "bundle": "node ./tools/distribute.js", 10 | "dev-bundle": "node ./tools/distribute.js --dev" 11 | }, 12 | "repository": { 13 | "type": "git", 14 | "url": "https://github.com/livestyle/app.git" 15 | }, 16 | "author": "Sergey Chikuyonok ", 17 | "license": "ISC", 18 | "bugs": { 19 | "url": "https://github.com/livestyle/app/issues" 20 | }, 21 | "homepage": "https://github.com/livestyle/app", 22 | "dependencies": { 23 | "browser-launcher2": "^0.4.5", 24 | "connect": "^3.4.0", 25 | "debug": "^2.2.0", 26 | "electron-debug": "^0.5.1", 27 | "electron-squirrel-startup": "^0.1.4", 28 | "graceful-fs": "^4.1.2", 29 | "menubar": "^3.0.0", 30 | "mime": "^1.3.4", 31 | "mkdirp": "^0.5.1", 32 | "mv": "^2.1.1", 33 | "ncp": "^2.0.0", 34 | "remote-view-client": "livestyle/remote-view-client", 35 | "request": "^2.67.0", 36 | "rimraf": "^2.4.5", 37 | "semver": "^5.1.0", 38 | "serve-static": "^1.10.0", 39 | "temp": "^0.8.3", 40 | "unzip": "^0.1.11", 41 | "ws": "^0.8.0", 42 | "xtend": "^4.0.0" 43 | }, 44 | "devDependencies": { 45 | "cpy": "^3.4.0", 46 | "del": "^2.0.0", 47 | "electron-installer-squirrel-windows": "^1.2.2", 48 | "electron-prebuilt": "~0.35.0", 49 | "glob-all": "^3.0.1", 50 | "mocha": "^2.2.5", 51 | "rcedit": "^0.3.0", 52 | "yazl": "^2.2.2" 53 | }, 54 | "config": { 55 | "websocketUrl": "ws://127.0.0.1:54000/livestyle" 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /tools/branding/zip.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Packs app as ZIP archive 3 | */ 4 | 'use strict'; 5 | 6 | var fs = require('fs'); 7 | var path = require('path'); 8 | var glob = require('glob-all'); 9 | var yazl = require('yazl'); 10 | var debug = require('debug')('lsapp:branding:zip'); 11 | var cmd = require('./cmd'); 12 | 13 | module.exports = function(app, dest) { 14 | var pack = process.platform === 'darwin' ? packOSX : packCommon; 15 | return prepare(app, dest) 16 | .then(function() { 17 | return pack(app, dest); 18 | }); 19 | }; 20 | 21 | function packOSX(app, dest) { 22 | // XXX I have to preserve symlinks in zip. Currently, I didn’t found 23 | // and Node.js zip module that supports symlinks in archive so I’m using 24 | // `ditto` command 25 | return new Promise(function(resolve, reject) { 26 | cmd('ditto', ['-ck', '--sequesterRsrc', '--keepParent', app.dir, dest], function(err) { 27 | err ? reject(err) : resolve(dest); 28 | }); 29 | }); 30 | } 31 | 32 | function packCommon(app, dest) { 33 | return new Promise(function(resolve, reject) { 34 | var cwd = path.dirname(app.dir); 35 | glob(['**'], {cwd, nodir: true}, function(err, files) { 36 | if (err) { 37 | return reject(err); 38 | } 39 | 40 | debug('files to pack: %d', files.length); 41 | 42 | fs.unlink(dest, function() { 43 | var archive = new yazl.ZipFile(); 44 | files.forEach(function(file) { 45 | var absPath = path.resolve(cwd, file); 46 | archive.addFile(absPath, file.replace(/\\/g, '/')); 47 | }); 48 | 49 | archive.outputStream 50 | .pipe(fs.createWriteStream(dest)) 51 | .on('close', function() { 52 | resolve(dest); 53 | }); 54 | archive.end(); 55 | }); 56 | }); 57 | }); 58 | } 59 | 60 | function prepare(app, dest) { 61 | return new Promise(function(resolve, reject) { 62 | fs.unlink(dest, resolve); 63 | }); 64 | } -------------------------------------------------------------------------------- /lib/google-chrome/detect.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Detect Chrome extension 3 | */ 4 | 'use strict'; 5 | 6 | const os = require('os'); 7 | const path = require('path'); 8 | const fs = require('graceful-fs'); 9 | const launcher = require('browser-launcher2'); 10 | const debug = require('debug')('lsapp:detect-chrome'); 11 | const utils = require('../node-utils'); 12 | const identify = require('../identify'); 13 | 14 | /** 15 | * Returns a promise that fulfilled if user has installed Chrome extension 16 | * @param {Object} app App definition 17 | * @param {LivestyleClient} client A LiveStyle WebSocket client 18 | * @return {Promise} 19 | */ 20 | module.exports = function(app, client) { 21 | debug('detecting Chrome browser extension'); 22 | return identify(client, 'chrome') 23 | .catch(err => detectApp(app)) 24 | .then(() => detectPlugin(app)); 25 | }; 26 | 27 | var detectApp = module.exports.app = function(app) { 28 | return new Promise(function(resolve, reject) { 29 | launcher.detect(function(browsers) { 30 | debug('browser found: %d', browsers.length); 31 | var hasChrome = browsers.some(function(browser) { 32 | return browser.type === 'chrome'; 33 | }); 34 | debug('has Chrome installed? %o', hasChrome); 35 | if (hasChrome) { 36 | resolve(); 37 | } else { 38 | var err = new Error('No Chrome browser installed'); 39 | err.code = 'EDETECTNOCHROME'; 40 | reject(err); 41 | } 42 | }); 43 | }); 44 | } 45 | 46 | var detectPlugin = module.exports.plugin = function(app) { 47 | return utils.pathContents(app.lookup) 48 | .then(function(found) { 49 | var extPath = null; 50 | found.some(function(obj) { 51 | return obj.items.some(function(item) { 52 | if (app.extensionId.indexOf(item) !== -1) { 53 | return extPath = path.join(obj.path, item); 54 | } 55 | }); 56 | }); 57 | 58 | return extPath || false; 59 | }); 60 | } -------------------------------------------------------------------------------- /lib/helpers/download.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Returns a Promise that downloads given resource and stores it in 3 | * a temp folder. The Promise is resolved with path to downloaded 4 | * file in temp folder 5 | */ 6 | 'use strict'; 7 | 8 | const http = require('http'); 9 | const https = require('https'); 10 | const extend = require('xtend'); 11 | const temp = require('temp').track(); 12 | const debug = require('debug')('lsapp:download'); 13 | 14 | const defaultOptions = { 15 | attempt: 0, 16 | maxAttempts: 5 17 | }; 18 | 19 | var download = module.exports = function(url, options) { 20 | options = extend(defaultOptions, options || {}); 21 | return new Promise(function(resolve, reject) { 22 | if (options.attempt >= options.maxAttempts) { 23 | return reject(error(`Failed to download ${url} in ${options.attempt} attempts`, 'EMAXATTEMPTS')); 24 | } 25 | 26 | debug('downloading %s, attempt %d', url, options.attempt); 27 | let transport = /^https:/.test(url) ? https : http; 28 | transport.get(url, function(res) { 29 | debug('response: %d', res.statusCode); 30 | if (res.statusCode === 200) { 31 | let dest = temp.createWriteStream(); 32 | return res.pipe(dest) 33 | .once('finish', () => resolve(dest.path)) 34 | .once('error', reject); 35 | } 36 | 37 | if (res.statusCode === 301 || res.statusCode === 302) { 38 | // redirect 39 | let location = res.headers.location; 40 | if (location) { 41 | let opt = extend(options, {attempt: options.attempt + 1}); 42 | return download(location, opt).then(resolve, reject); 43 | } else { 44 | return reject(error('Got redirect (' + res.statusCode + ') but no Location header', 'EINVALIDRESPONSE')); 45 | } 46 | } 47 | 48 | reject(error('Unknown response code: ' + res.statusCode, 'EUNKNOWNRESPONSE')); 49 | }).once('error', reject); 50 | }); 51 | }; 52 | 53 | function error(message, code) { 54 | var err = new Error(message); 55 | if (code) { 56 | err.code = code; 57 | } 58 | return err; 59 | } -------------------------------------------------------------------------------- /tools/branding/cmd.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Runs given command in terminal 3 | */ 4 | 'use strict'; 5 | 6 | var cp = require('child_process'); 7 | var EventEmitter = require('events'); 8 | 9 | module.exports = function(command, args, options, callback) { 10 | var process; 11 | var stderr = ''; 12 | var stdout = ''; 13 | var ev = new EventEmitter(); 14 | 15 | if (typeof args === 'function') { 16 | callback = args; 17 | args = []; 18 | options = {}; 19 | } 20 | 21 | if (typeof options === 'function') { 22 | callback = options; 23 | options = {}; 24 | } 25 | 26 | if (typeof args === 'object' && !Array.isArray(args)) { 27 | options = args; 28 | args = []; 29 | } 30 | 31 | // Buffer output, reporting progress 32 | process = cp.spawn(command, args, options); 33 | process.stdout.on('data', function (data) { 34 | data = data.toString(); 35 | ev.emit('stdout', data); 36 | ev.emit('data', data, 'stdout'); 37 | stdout += data; 38 | }); 39 | process.stderr.on('data', function (data) { 40 | data = data.toString(); 41 | ev.emit('stderr', data); 42 | ev.emit('data', data, 'stderr'); 43 | stderr += data; 44 | }); 45 | 46 | // If there is an error spawning the command, return error 47 | process.on('error', function (error) { 48 | return callback(error); 49 | }); 50 | 51 | // Listen to the close event instead of exit 52 | // They are similar but close ensures that streams are flushed 53 | process.on('close', function (code) { 54 | var fullCommand; 55 | var error; 56 | 57 | if (code) { 58 | // Generate the full command to be presented in the error message 59 | if (!Array.isArray(args)) { 60 | args = []; 61 | } 62 | 63 | fullCommand = command; 64 | fullCommand += args.length ? ' ' + args.join(' ') : ''; 65 | 66 | // Build the error instance 67 | var error = new Error('Failed to execute "' + fullCommand + '", exit code of #' + code); 68 | error.code = 'ECMDERR'; 69 | error.details = stderr; 70 | error.exitCode = code; 71 | return callback(error); 72 | } 73 | 74 | return callback(null, stdout, stderr); 75 | }); 76 | 77 | return ev; 78 | } -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: 2 | - objective-c 3 | env: 4 | matrix: 5 | - NODE_VERSION="4.1.1" 6 | global: 7 | - secure: BuCxvkJOa8k0NzsoiFDlJQjzj5+xcbnaA4+A8maNJDAe3etcBSZBA+4nxm18jABeuE9NNg5k2rayrGKhF6kqpy0TcgvtUOx38of0FP7zaO34kKwbPuKymvoLVJ58caQsQCbBGc/UTdBxHLTe8WN2G0wkRFmWg5YKNqg5rgyrNB5e8V1bWfILg/PRX5jla/59sG5+A/2vGa6HRpiEdRk0QvxUIJoHrh0DmkaOkQ3FK5wKd1vvfPfqQVLF6xiDjZ+C2203eqjcpapNz/6yX/C7X4XtSgNHcyOnWpcY2A9m+CYINzPfoAPnZJpdc5vWfDyR6rQJ/3PcG3Ol8M2eDwH2FjhaPpNwotrAHZUNod7WCaRaWVfb8Qku4fnO0LpQQ+eBIKbHCR4VeNWUo7EX7T3445oxxft7nwszaLpL1DYKFgoMAAbTclycJFUK4cZwYCSxOYKfdYKi9R5889TEw32FRCLrOjW3Pglqo2ljPsGk/pG8MmRzA2sI96xUFUwHuuUhmmbXd8oSCbWZjWaB3gxxVRKG26LfK8DAR6w0+7C42Z2JuNsdnQopTRxicbXCr13i5ZPOSrZrgEpwm3tHU3MCO5uXBlrpl4jwyBBqkQusJOiP1tc/5Iu008Y0cHty/Z5Juu1apaAm8NrXKkJt+jerewbIT4cSqYFzWRAfC8opQUc= 8 | - secure: LbuFSJhaqH8X84n9D754UN0dqeTbHEFU1Z4TRvtSZfG9tRrMWqvf5vOaiV/6fAqgLVo1eL5CUcEvrDrLxVLJTXs8V4UPJT+H8mUPVFlX4fOLCYDHllphUUnyDH7rPSb5QkfPAIa0rl5YtHPpMi0HwYD75L5uEwZGJEaj1rE/BqjrXROoCakIRcgi2GzUA3WiPSQPxry2AEI948Kv+jotD990UjRYQDlx+RCdS3bzHaBHvKJPKvVNWaf+g29xNZI3vMu7bZZj25BeheOmta1mMfGduSEKjxc4BukHOaN7ydyjiVEMIvqx5es3VQNr0CfJpkZAS3Y+6r2GxZ4zmUdwEKH5nJZ5RmKvXt4L3mp+fs1W5IdOCmxihJtRvt+JjZGEDdgqx33t2uv+kZOdaFHpVzD8DdQZfcfzTgLS3TFSo/7QgbAlCq88kwV+OwHoIPiW07cqHHon4pyS5IdvX8L0wEymLXna+l2TM0eELGnttd4mFu1BBgWg2HLoQKohmCztcof1bw+63Ugi2nFdsHWPfOqvQGhyXBBHMcFdWO7a1fm3JaQvD99svl6vNyI58q7byHTYVoHcMTVGSTYA4bEe2s6l/GIJTiJP5y8d9uLz9ASq6OKUJ/8OCulFbzLzN/BkJ09r7kmVGJsNkyUyuqFtLBMsR0fzUPNahLsj8+v0Ns8= 9 | before_install: 10 | - openssl aes-256-cbc -K $encrypted_2eef65e88e3a_key -iv $encrypted_2eef65e88e3a_iv 11 | -in livestyle.tar.enc -out livestyle.tar -d 12 | - tar xvf livestyle.tar 13 | - ./tools/osx/import-key.sh 14 | install: 15 | - rm -rf ~/.nvm 16 | - git clone https://github.com/creationix/nvm.git ~/.nvm 17 | - source ~/.nvm/nvm.sh 18 | - nvm install $NODE_VERSION 19 | - nvm use $NODE_VERSION 20 | - node --version 21 | - npm install 22 | - npm test 23 | - 'if [ "$TRAVIS_BRANCH" == "release" ] && [ "$TRAVIS_PULL_REQUEST" == "false" ]; then npm run publish; fi' 24 | after_script: 25 | - rm livestyle.tar livestyle.cer livestyle.p12 26 | - security delete-keychain osx-build.keychain 27 | -------------------------------------------------------------------------------- /lib/google-chrome/install.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Plugin installer script for Google Chrome 3 | */ 4 | 'use strict'; 5 | const cp = require('child_process'); 6 | const launcher = require('browser-launcher2'); 7 | 8 | module.exports = function(app) { 9 | // Chrome installer basically opens special web pages in Chrome 10 | // browser that guides user, no big deal 11 | return new Promise(function(resolve, reject) { 12 | launcher(function(err, launch) { 13 | if (err) { 14 | return reject(err); 15 | } 16 | 17 | // for all Chrome-based browsers reset `profile` option 18 | // because we need to install extension into default profile 19 | var chrome, chromium, chromeLike; 20 | launch.browsers.forEach(function(browser) { 21 | if (browser.name.indexOf('chrom') !== -1) { 22 | browser.profile = null; 23 | chromeLike = browser; 24 | if (browser.name === 'chrome') { 25 | chrome = browser; 26 | } else if (browser.name === 'chromium') { 27 | chromium = browser; 28 | } 29 | } 30 | }); 31 | 32 | if (process.platform === 'darwin') { 33 | launchOSX(chrome || chromium || chromeLike, app.install) 34 | .then(resolve, reject); 35 | } else { 36 | launch(app.install, { 37 | browser: 'chrome', 38 | detached: true 39 | }, function(err, instance) { 40 | err ? reject(err) : resolve(); 41 | }); 42 | } 43 | }); 44 | }); 45 | }; 46 | 47 | /** 48 | * Current implementation of browser-launcher2 uses a set of commands 49 | * that prevent Chrome from opening given URL in default profile of 50 | * currently running Chrome process. This function fixes this issue 51 | * @param {Object} browser 52 | * @param {String} url 53 | */ 54 | function launchOSX(browser, url) { 55 | return new Promise((resolve, reject) => { 56 | cp.spawn('open', ['-a', browser.command, url], {detached: true}) 57 | .on('error', reject) 58 | .on('close', code => { 59 | if (code) { 60 | var err = new Error(`Failed to open ${browser.command}, exit code of #${code}`); 61 | err.code = 'ECMDERR'; 62 | err.exitCode = code; 63 | return reject(err); 64 | } 65 | resolve(); 66 | }); 67 | }); 68 | } -------------------------------------------------------------------------------- /lib/helpers/unzip.js: -------------------------------------------------------------------------------- 1 | /** 2 | * A promisified Unzip 3 | */ 4 | 'use strict'; 5 | 6 | const fs = require('graceful-fs'); 7 | const path = require('path'); 8 | const unzip = require('unzip'); 9 | const mkdirp = require('mkdirp'); 10 | const temp = require('temp').track(); 11 | const debug = require('debug')('lsapp:unzip'); 12 | 13 | module.exports = function(src, dest) { 14 | return checkDest(dest) 15 | .then(dest => unpack(src, dest)) 16 | .then(checkResult); 17 | }; 18 | 19 | function checkDest(dest) { 20 | if (dest) { 21 | return Promise.resolve(dest); 22 | } 23 | 24 | return new Promise((resolve, reject) => { 25 | temp.mkdir('lsapp-unzip', (err, dir) => err ? reject(err) : resolve(dir)); 26 | }); 27 | } 28 | 29 | function unpack(src, dest) { 30 | debug('unpacking %s into %s', src, dest); 31 | return new Promise(function(resolve, reject) { 32 | fs.createReadStream(src) 33 | .pipe(unzip.Parse()) 34 | .on('entry', function(entry) { 35 | if (entry.type === 'Directory') { 36 | return entry.autodrain(); 37 | } 38 | 39 | var filePath = path.join(dest, entry.path); 40 | mkdirp(path.dirname(filePath), err => { 41 | if (err) { 42 | entry.autodrain(); 43 | return this.emit('error', err); 44 | } 45 | entry.pipe(fs.createWriteStream(filePath)); 46 | }); 47 | }) 48 | .once('close', () => resolve(dest)) 49 | .once('error', reject); 50 | }); 51 | } 52 | 53 | /** 54 | * Reads result of unpacked data. If it contains just a single folder, 55 | * switch context into it 56 | * @param {String} dest 57 | * @return {Promise} 58 | */ 59 | function checkResult(dest) { 60 | return new Promise((resolve, reject) => { 61 | let skipFiles = ['.DS_Store']; 62 | fs.readdir(dest, (err, items) => { 63 | if (err) { 64 | return reject(err); 65 | } 66 | 67 | items = items.filter(item => skipFiles.indexOf(item) === -1); 68 | debug('items found in result: %d', items.length); 69 | 70 | if (items.length !== 1) { 71 | return resolve(dest); 72 | } 73 | 74 | // got just one item, make sure it’s a directory 75 | let candidate = path.join(dest, items[0]); 76 | fs.stat(candidate, (err, stat) => { 77 | resolve(stat && stat.isDirectory() ? candidate : dest); 78 | }); 79 | }); 80 | }); 81 | } -------------------------------------------------------------------------------- /lib/sublime-text/detect.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Detects installed Sublime Text plugin 3 | */ 4 | 'use strict'; 5 | const fs = require('graceful-fs'); 6 | const path = require('path'); 7 | const debug = require('debug')('lsapp:sublime-text:detect'); 8 | const utils = require('../node-utils'); 9 | const identify = require('../identify'); 10 | 11 | const errorMessages = { 12 | ENOSUBLIMETEXT: 'No Sublime Text installed' 13 | }; 14 | 15 | module.exports = function(app, client) { 16 | debug('detecting Sublime Text extension'); 17 | return identify(client, 'sublime-text') 18 | .catch(() => detectApp(app)) 19 | .then(() => detectPlugin(app)); 20 | }; 21 | 22 | var detectApp = module.exports.app = function(app) { 23 | return utils.existsSome(app.lookup) 24 | .catch(() => Promise.reject(error('ENOSUBLIMETEXT'))); 25 | }; 26 | 27 | var detectPlugin = module.exports.plugin = function(app) { 28 | var dir = path.dirname(utils.expandUser(app.install)); 29 | debug('looking for plugin in %s', dir); 30 | debug('lookup paths: %o', [ 31 | path.resolve(dir, 'Packages'), 32 | path.resolve(dir, 'Installed Packages') 33 | ]); 34 | var extIds = app.extensionId; 35 | var lookup = utils.expandPaths([ 36 | path.resolve(dir, 'Packages'), 37 | path.resolve(dir, 'Installed Packages') 38 | ]); 39 | 40 | return Promise.all(lookup.map(readdir)) 41 | .then(lists => { 42 | // Make sure any of the lookup folders exists, which means app is installed. 43 | // Otherwise throw exception (app does not exists) 44 | if (!lists.some(item => item)) { 45 | return Promise.reject(error('ENOSUBLIMETEXT')); 46 | } 47 | 48 | lists = lists.map(item => item || []); 49 | var found = lookup.map((dir, i) => { 50 | let matched = lists[i].filter(m => extIds.indexOf(m) !== -1); 51 | return matched.length ? path.join(dir, matched[0]) : null; 52 | }) 53 | .filter(Boolean); 54 | return found[0] || false; 55 | }); 56 | }; 57 | 58 | function readdir(dir) { 59 | return new Promise((resolve, reject) => { 60 | debug('reading %s', dir); 61 | // always resolve even if there was error 62 | fs.readdir(utils.expandUser(dir), (err, items) => resolve(items)); 63 | }); 64 | } 65 | 66 | function error(code, message) { 67 | var err = new Error(message || errorMessages[code] || code); 68 | err.code = code; 69 | return err; 70 | } -------------------------------------------------------------------------------- /test/tunnel.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var assert = require('assert'); 4 | var path = require('path'); 5 | var http = require('http'); 6 | var tc = require('../lib/controller/tunnel'); 7 | var fileServer = require('../lib/controller/file-server'); 8 | 9 | // No need to check full Remote View connectivity, 10 | // simply check that controller is properly instantiated and emits messages 11 | 12 | describe('Tunnel Cluster controller', function() { 13 | it('create and emit messages', function(done) { 14 | var clusterCreated = false; 15 | var updates = []; 16 | 17 | // for some reason networking is very slow on Windows, has to increase 18 | // test timeout 19 | this.timeout(5000); 20 | 21 | tc.on('update', list => updates.push(list)) 22 | .once('clusterCreate', cluster => clusterCreated = true) 23 | .once('clusterDestroy', cluster => { 24 | setTimeout(() => { 25 | assert(clusterCreated); 26 | assert.equal(updates.length, 2); 27 | assert.equal(updates[0][0].publicId, 'rv-test.livestyle.io'); 28 | assert.equal(updates[0][0].state, 'idle'); 29 | 30 | // the second update is empty list because cluster was destroyed 31 | assert.deepEqual(updates[1], []); 32 | done(); 33 | }, 30); 34 | }); 35 | 36 | tc.create({ 37 | publicId: 'rv-test.livestyle.io', 38 | localSite: 'http://localhost:8901', 39 | connectUrl: 'http://localhost:8902/fake-session', 40 | maxConnections: 2, 41 | retryCount: 2, 42 | retryDelay: 100, 43 | }); 44 | }); 45 | 46 | it('"file:" origin', function(done) { 47 | tc.once('clusterCreate', function(cluster) { 48 | // server must be closed when session is destroyed 49 | var server = fileServer.find(cluster.options.localSite); 50 | assert(server); 51 | server.once('close', done); 52 | http.get(cluster.options.localSite + '/index.html', res => { 53 | assert.equal(res.statusCode, 200); 54 | assert.equal(res.headers['content-type'], 'text/html; charset=UTF-8'); 55 | cluster.destroy(); 56 | }).on('error', done); 57 | }); 58 | 59 | fileServer(`file://` + path.join(__dirname, 'static').replace(/\\/g, '/')) 60 | .then(origin => { 61 | tc.create({ 62 | publicId: 'rv-test.livestyle.io', 63 | localSite: origin, 64 | connectUrl: 'http://localhost:8902/fake-session' 65 | }); 66 | }, done); 67 | }); 68 | }); -------------------------------------------------------------------------------- /ui/controller.js: -------------------------------------------------------------------------------- 1 | /** 2 | * UI controller 3 | */ 4 | 'use strict'; 5 | 6 | var ipc = require('electron').ipcRenderer; 7 | var shell = require('electron').shell; 8 | var chrome = require('./chrome'); 9 | var sublimeText = require('./sublime-text'); 10 | var rv = require('./rv-sessions'); 11 | var $ = require('./utils').qs; 12 | var closest = require('./utils').closest; 13 | var apps = require('../lib/apps'); 14 | 15 | function init() { 16 | var chromeRender = chrome($('.extension-item[data-extension-id=chrome]')); 17 | var sublimeTextRender = sublimeText($('.extension-item[data-extension-id=st]')); 18 | var rvRender = rv($('.rv-pane')); 19 | var updateBtn = $('.update-available'); 20 | 21 | ipc.on('model', function(event, model) { 22 | chromeRender(model); 23 | sublimeTextRender(model); 24 | rvRender(model); 25 | updateBtn.classList.toggle('hidden', !model.updateAvailable); 26 | 27 | if (model.updateAvailable) { 28 | notifyUpdateAvailable(); 29 | } 30 | }) 31 | .on('log', function(event, args) { 32 | console.log.apply(console, args); 33 | }) 34 | .on('info', function(event, args) { 35 | console.info.apply(console, args); 36 | }) 37 | .on('warn', function(event, args) { 38 | console.warn.apply(console, args); 39 | }) 40 | .on('error', function(event, args) { 41 | console.error.apply(console, args); 42 | }); 43 | 44 | $('.quit').addEventListener('click', evt => { 45 | if (!ipc.sendSync('will-quit')) { 46 | ipc.send('quit'); 47 | } 48 | }); 49 | 50 | updateBtn.addEventListener('click', function(evt) { 51 | evt.stopPropagation(); 52 | evt.preventDefault(); 53 | ipc.send('install-update'); 54 | }); 55 | 56 | // open all URLs in default system browser 57 | document.addEventListener('click', function(evt) { 58 | var a = closest(evt.target, 'a'); 59 | if (a) { 60 | evt.preventDefault(); 61 | evt.stopPropagation(); 62 | shell.openExternal(a.href); 63 | } 64 | }); 65 | } 66 | 67 | var _didNotifiedUpdateAvailable = false; 68 | function notifyUpdateAvailable() { 69 | if (_didNotifiedUpdateAvailable) { 70 | return; 71 | } 72 | 73 | _didNotifiedUpdateAvailable = true; 74 | var n = new Notification('LiveStyle', { 75 | body: 'A new version of LiveStyle app is available, click to install' 76 | }); 77 | n.onclick = () => ipc.send('install-update'); 78 | } 79 | 80 | init(); -------------------------------------------------------------------------------- /lib/sublime-text/install.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Installer script for Sublime Text plugin. 3 | * Scenario: 4 | * 1. Download zip from url in `downloadUrl` property of app object 5 | * 2. Unpack downloaded zip into temp dir 6 | * 3. If `commitUrl` in app object exists, download latest commit SHA 7 | * form this url and save it as `autoupdate.json` file with updacked plugin 8 | * 4. Move downloaded package into LiveStyle folder in `install` property of app 9 | */ 10 | 'use strict'; 11 | 12 | const fs = require('graceful-fs'); 13 | const path = require('path'); 14 | const http = require('http'); 15 | const https = require('https'); 16 | const mv = require('mv'); 17 | const rimraf = require('rimraf'); 18 | const debug = require('debug')('lsapp:sublime-text:install'); 19 | const download = require('../helpers/download'); 20 | const unzip = require('../helpers/unzip'); 21 | const request = require('../helpers/request'); 22 | const utils = require('../node-utils'); 23 | 24 | module.exports = function(app) { 25 | return download(app.downloadUrl) 26 | .then(unzip) 27 | .then(dest => setupAutoupdate(app, dest).then(() => dest)) 28 | .then(dest => moveToApp(app, dest)); 29 | }; 30 | 31 | function setupAutoupdate(app, dest) { 32 | if (!app.commitUrl) { 33 | debug('no "commitUrl" property in app, skip autoupdate setup'); 34 | return Promise.resolve(); 35 | } 36 | 37 | return request(app.commitUrl) 38 | .then(data => { 39 | data = JSON.parse(data); 40 | debug('latest sha: %s', data.sha); 41 | if (!data.sha) { 42 | debug('no sha in received payload'); 43 | return null; 44 | } 45 | 46 | return writeFile(path.join(dest, 'autoupdate.json'), JSON.stringify({ 47 | sha: data.sha, 48 | created: new Date() 49 | })); 50 | }) 51 | .catch(err => { 52 | console.warn('Unable to setup autoupdate', err); 53 | console.warn(err.stack); 54 | }); 55 | } 56 | 57 | function moveToApp(app, from) { 58 | let to = path.join(utils.expandUser(app.install), 'LiveStyle'); 59 | debug('moving contents of %s into %s', from, to); 60 | return new Promise((resolve, reject) => { 61 | rimraf(to, err => { 62 | if (err) { 63 | return reject(err); 64 | } 65 | mv(from, to, {mkdirp: true}, err => err ? reject(err) : resolve(to)); 66 | }); 67 | }); 68 | } 69 | 70 | function writeFile(filePath, contents) { 71 | return new Promise((resolve, reject) => { 72 | fs.writeFile(filePath, contents, err => err ? reject(err) : resolve(filePath)); 73 | }); 74 | } -------------------------------------------------------------------------------- /test/download.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const path = require('path'); 4 | const http = require('http'); 5 | const assert = require('assert'); 6 | const connect = require('connect'); 7 | const serveStatic = require('serve-static'); 8 | const download = require('../lib/helpers/download'); 9 | 10 | describe('Downloader', () => { 11 | const port = 8888; 12 | const host = `http://localhost:${port}`; 13 | var server; 14 | before(done => { 15 | let dir = path.resolve(__dirname, 'static'); 16 | let app = connect() 17 | .use((req, res, next) => { 18 | server.emit('requested', req.url); 19 | next(); 20 | }) 21 | .use('/redirect.html', (req, res, next) => { 22 | res.writeHead(302, {location: `${host}/index.html`}); 23 | res.end(); 24 | }) 25 | .use('/recursive', (req, res, next) => { 26 | res.writeHead(302, {location: `${host}/recursive`}); 27 | res.end(); 28 | }) 29 | .use(serveStatic(dir)); 30 | 31 | server = http.createServer(app); 32 | server.listen(port, done); 33 | }); 34 | 35 | after(done => server.close(done)); 36 | 37 | it('download file', done => { 38 | let requests = []; 39 | let reqHandler = file => requests.push(file); 40 | server.on('requested', reqHandler); 41 | 42 | download(`${host}/index.html`) 43 | .then(file => { 44 | assert(file); 45 | assert.deepEqual(requests, ['/index.html']); 46 | server.removeListener('requested', reqHandler); 47 | done(); 48 | }) 49 | .catch(done); 50 | }); 51 | 52 | it('follow redirect', done => { 53 | let requests = []; 54 | let reqHandler = file => requests.push(file); 55 | server.on('requested', reqHandler); 56 | 57 | download(`${host}/redirect.html`) 58 | .then(file => { 59 | assert(file); 60 | assert.deepEqual(requests, ['/redirect.html', '/index.html']); 61 | done(); 62 | }) 63 | .catch(done); 64 | }); 65 | 66 | it('not found', done => { 67 | download(`${host}/not-found`) 68 | .then(file => done(new Error('Should fail'))) 69 | .catch(err => { 70 | assert.equal(err.code, 'EUNKNOWNRESPONSE'); 71 | done(); 72 | }); 73 | }); 74 | 75 | it('recursive redirect', done => { 76 | let requests = []; 77 | let reqHandler = file => requests.push(file); 78 | server.on('requested', reqHandler); 79 | 80 | download(`${host}/recursive`) 81 | .then(file => done(new Error('Should fail'))) 82 | .catch(err => { 83 | assert.equal(err.code, 'EMAXATTEMPTS'); 84 | assert(requests.length > 2); 85 | done(); 86 | }); 87 | }); 88 | }); -------------------------------------------------------------------------------- /lib/apps.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const resolve = data => data[process.platform]; 4 | const stDownload = 'https://github.com/livestyle/sublime-text/archive/master.zip'; 5 | const stCommitUrl = 'https://api.github.com/repos/livestyle/sublime-text/commits/master'; 6 | 7 | module.exports = { 8 | "st3": { 9 | "id": "st3", 10 | "title": "Sublime Text 3", 11 | "downloadUrl": stDownload, 12 | "commitUrl": stCommitUrl, 13 | "install": resolve({ 14 | "win32": "~\\AppData\\Roaming\\Sublime Text 3\\Packages", 15 | "darwin": "~/Library/Application Support/Sublime Text 3/Packages" 16 | }), 17 | "lookup": resolve({ 18 | "win32": [ 19 | "%PROGRAMFILES%\\Sublime Text 3\\sublime.exe", 20 | "~\\AppData\\Roaming\\Sublime Text 3" 21 | ], 22 | "darwin": [ 23 | "~/Library/Application Support/Sublime Text 3", 24 | "/Applications/Sublime Text.app/Contents/MacOS/plugin_host", 25 | "/Applications/Sublime Text 3.app/Contents/MacOS/plugin_host" 26 | ] 27 | }), 28 | "extensionId": ["LiveStyle", "LiveStyle.sublime-package"] 29 | }, 30 | "st2": { 31 | "id": "st2", 32 | "title": "Sublime Text 2", 33 | "downloadUrl": stDownload, 34 | "commitUrl": stCommitUrl, 35 | "install": resolve({ 36 | "win32": "~\\AppData\\Roaming\\Sublime Text 2\\Packages", 37 | "darwin": "~/Library/Application Support/Sublime Text 2/Packages" 38 | }), 39 | "lookup": resolve({ 40 | "win32": [ 41 | "%PROGRAMFILES%\\Sublime Text 2\\sublime.exe", 42 | "~\\AppData\\Roaming\\Sublime Text 2" 43 | ], 44 | "darwin": [ 45 | "~/Library/Application Support/Sublime Text 2", 46 | "/Applications/Sublime Text 2.app/Contents/MacOS/Sublime Text 2", 47 | "/Applications/Sublime Text.app/Contents/MacOS/Sublime Text 2" 48 | ] 49 | }), 50 | "extensionId": ["LiveStyle"] 51 | }, 52 | "chrome": { 53 | "id": "chrome", 54 | "title": "Google Chrome", 55 | "install": "https://chrome.google.com/webstore/detail/emmet-livestyle/diebikgmpmeppiilkaijjbdgciafajmg", 56 | "lookup": resolve({ 57 | "win32": [ 58 | "~\\AppData\\Local\\Google\\Chrome\\User Data\\Default\\Extensions", 59 | "~\\AppData\\Local\\Chromium\\User Data\\Default\\Extensions" 60 | ], 61 | "darwin": [ 62 | "~/Library/Application Support/Google/Chrome/Default/Extensions", 63 | "~/Library/Application Support/Chromium/Default/Extensions" 64 | ] 65 | }), 66 | "extensionId": [ 67 | "obipchajaiohjoohongibhgbfgchblei", 68 | "diebikgmpmeppiilkaijjbdgciafajmg" 69 | ] 70 | } 71 | }; -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | LiveStyle 6 | 7 | 8 | 9 |
    10 |

    LiveStyle

    11 | 12 | 13 | 14 | 44 |
    45 |
    46 |

    Remote View sessions

    47 |
    48 |

    No active sessions

    49 |

    What is Remote View?

    50 |
    51 | 62 |
    63 | 64 | 67 | 68 | -------------------------------------------------------------------------------- /lib/node-utils.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Utils for Node.js part 3 | */ 4 | var os = require('os'); 5 | var path = require('path'); 6 | var fs = require('graceful-fs'); 7 | var debug = require('debug')('lsapp:node-utils'); 8 | var winEnv = require('./win-env'); 9 | 10 | module.exports.expandUser = function(p) { 11 | return p.replace(/^~[\\\/]/, function() { 12 | return os.homedir() + path.sep; 13 | }); 14 | }; 15 | 16 | module.exports.expandPaths = function(paths) { 17 | if (!Array.isArray(paths)) { 18 | paths = [paths]; 19 | } 20 | 21 | var result = []; 22 | debug('expanding %o', paths); 23 | paths.forEach(function(p) { 24 | p = module.exports.expandUser(p).replace(/%(\w+)%/g, function(str, token) { 25 | if (token in winEnv) { 26 | return winEnv[token]; 27 | } 28 | 29 | if (token in process.env) { 30 | return process.env[token]; 31 | } 32 | 33 | return str; 34 | }); 35 | 36 | // do a special replacement for %PROGRAMFILES% token: 37 | // on x64 systems, there are two possible paths 38 | var reProgFiles = /%PROGRAMFILES%/; 39 | if (reProgFiles.test(p)) { 40 | result.push(p.replace(reProgFiles, winEnv.PROGRAMFILES_X86)); 41 | if (winEnv.X64) { 42 | result.push(p.replace(reProgFiles, winEnv.PROGRAMFILES_X64)); 43 | } 44 | } else { 45 | result.push(p); 46 | } 47 | }); 48 | debug('expanded %o', result); 49 | return result; 50 | }; 51 | 52 | /** 53 | * TODO: remove 54 | */ 55 | module.exports.pathContents = function(paths) { 56 | paths = module.exports.expandPaths(paths); 57 | var result = []; 58 | return new Promise(function(resolve, reject) { 59 | var next = function() { 60 | if (!paths.length) { 61 | return resolve(result); 62 | } 63 | 64 | var p = module.exports.expandUser(paths.shift()); 65 | debug('testing %s', p); 66 | fs.readdir(p, function(err, items) { 67 | if (!err) { 68 | result.push({ 69 | path: p, 70 | items: items 71 | }); 72 | } else { 73 | debug(err); 74 | } 75 | 76 | next(); 77 | }); 78 | }; 79 | 80 | next(); 81 | }); 82 | }; 83 | 84 | /** 85 | * Finds first existing path in given array and resolves returned Promise with it 86 | * @param {Array} paths Path or array of paths 87 | * @return {Promise} 88 | */ 89 | module.exports.existsSome = function(paths) { 90 | paths = module.exports.expandPaths(paths); 91 | 92 | return new Promise(function(resolve, reject) { 93 | var ix = 0; 94 | var next = function() { 95 | if (ix >= paths.length) { 96 | return reject(); 97 | } 98 | var p = paths[ix++]; 99 | debug('check existence of %s', p); 100 | fs.stat(p, function(err, stats) { 101 | if (err) { 102 | return next(); 103 | } 104 | resolve(p); 105 | }); 106 | }; 107 | next(); 108 | }); 109 | }; -------------------------------------------------------------------------------- /lib/autoupdate/gh-release.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const semver = require('semver'); 4 | const parseUrl = require('url').parse; 5 | const debug = require('debug')('lsapp:autoupdate:gh-release'); 6 | const request = require('request').defaults({ 7 | json: true, 8 | headers: { 9 | accept: 'application/vnd.github.v3+json', 10 | 'user-agent': 'GitHub Realease auto-updater for Electron' 11 | } 12 | }); 13 | 14 | const reVersion = /^v?(\d+\.\d+\.\d+)/; 15 | 16 | var latest = module.exports.latest = function(pkg) { 17 | return new Promise((resolve, reject) => { 18 | if (!pkg) { 19 | return reject(new Error('No package meta-data object is given')); 20 | } 21 | 22 | if (!pkg.repository || pkg.repository.type !== 'git' || isGitHubUrl(pkg.repository.url)) { 23 | return reject(new Error('No valid repository field in package data')); 24 | } 25 | 26 | var url = releaseApiEndpoint(pkg.repository.url, '/latest'); 27 | debug('get latest release from %s', url); 28 | request(url, expectResponse(resolve, reject)); 29 | }); 30 | }; 31 | 32 | var findUpdateRelease = module.exports.findUpdateRelease = function(pkg) { 33 | return latest(pkg) 34 | .then(release => { 35 | // check if latest release is newer than current one 36 | let m = release.tag_name.match(reVersion) || release.name.match(reVersion); 37 | if (!m) { 38 | return warn('Latest release does not contain valid semver tag'); 39 | } 40 | 41 | // compare versions 42 | if (!semver.lt(pkg.version, m[1])) { 43 | return warn('Current package version is the most recent'); 44 | } 45 | 46 | return Promise.resolve({ 47 | name: release.name, 48 | assets: assets(release) 49 | }); 50 | }); 51 | }; 52 | 53 | var warn = module.exports.warn = function(message) { 54 | var err = new Error(message); 55 | err.code = 'EAUTOUPDATEWARN'; 56 | return Promise.reject(err); 57 | } 58 | 59 | function isGitHubUrl(url) { 60 | return parseUrl(url || '').hostname !== 'github.com'; 61 | } 62 | 63 | function releaseApiEndpoint(repoUrl, suffix) { 64 | var url = parseUrl(repoUrl); 65 | var repo = url.pathname.replace(/^\/|\.git$/g, ''); 66 | return `https://api.github.com/repos/${repo}/releases${suffix || ''}`; 67 | } 68 | 69 | function expectResponse(resolve, reject, code) { 70 | code = code || 200; 71 | return function(err, res, content) { 72 | if (!err && res.statusCode === code) { 73 | if (typeof content === 'string') { 74 | content = JSON.parse(content); 75 | } 76 | return resolve(content); 77 | } 78 | 79 | if (!err) { 80 | if (typeof content !== 'string') { 81 | content = JSON.stringify(content); 82 | } 83 | err = new Error('Unexpected response code: ' + res.statusCode + '\n\n' + content); 84 | } 85 | reject(err); 86 | }; 87 | } 88 | 89 | function assets(release) { 90 | return release.assets.reduce((r, asset) => { 91 | if (asset.state === 'uploaded') { 92 | r[asset.name] = asset; 93 | } 94 | return r; 95 | }, {}); 96 | } -------------------------------------------------------------------------------- /lib/client.js: -------------------------------------------------------------------------------- 1 | /** 2 | * A minimal LiveStyle server client. Unlike existing 3 | * `livestyle/client`, this one will not reconnect when connection 4 | * in dropped. Instead, it will start its own WeSocket server instance. 5 | */ 6 | 'use strict'; 7 | 8 | var WebSocket = require('ws'); 9 | var parseUrl = require('url').parse; 10 | var debug = require('debug')('lsapp:client'); 11 | var extend = require('xtend'); 12 | var createServer = require('./server'); 13 | 14 | var errCount = 0; 15 | var defaultOptions = { 16 | reconnectOnClose: true, 17 | maxRetries: 5 18 | }; 19 | 20 | var connect = module.exports = function(url, options, callback) { 21 | if (typeof options === 'function') { 22 | callback = options; 23 | options = {}; 24 | } 25 | 26 | callback = callback || noop; 27 | options = extend(defaultOptions, options || {}); 28 | 29 | debug('connecting to %s', url); 30 | var client = new WebSocket(url); 31 | 32 | return client 33 | .on('message', onMessage) 34 | .once('open', function() { 35 | debug('connection opened'); 36 | errCount = 0; 37 | callback(null, wrapClient(client)); 38 | }) 39 | .once('close', function() { 40 | // reconnect if connection dropped 41 | var reconnect = !this._destroyed && options.reconnectOnClose; 42 | debug('connection closed%s', reconnect ? ', reconnecting' : ''); 43 | if (reconnect) { 44 | connect(url, options, callback); 45 | } 46 | }) 47 | .once('error', function(err) { 48 | debug(err); 49 | if (err.code === 'ECONNREFUSED') { 50 | // ECONNREFUSED means there’s no active LiveStyle 51 | // server, we should create our own instance and reconnect again 52 | errCount++; 53 | if (errCount < options.maxRetries) { 54 | return createServer(parseUrl(url).port, function() { 55 | this.removeListener('error', callback); 56 | var c = connect(url, options, callback); 57 | c.server = this; 58 | }) 59 | .once('error', callback); 60 | } 61 | } 62 | 63 | // unknown error, aborting 64 | callback(err); 65 | }); 66 | }; 67 | 68 | function noop() {} 69 | 70 | function onMessage(message) { 71 | try { 72 | message = JSON.parse(message); 73 | } catch (err) { 74 | return debug('Error parsing message: %s', err.message); 75 | } 76 | 77 | this.emit('message-receive', message.name, message.data); 78 | this.emit(message.name, message.data); 79 | } 80 | 81 | function wrapClient(client) { 82 | var _send = client.send; 83 | client.send = function(name, data) { 84 | try { 85 | _send.call(this, JSON.stringify({name, data})); 86 | } catch(err) { 87 | debug('Error while sending message: %o', err); 88 | client.emit('error', err); 89 | } 90 | }; 91 | 92 | client.destroy = function() { 93 | this.close(); 94 | this._destroyed = true; 95 | if (this.server) { 96 | this.server.destroy(); 97 | this.server = null; 98 | } 99 | }; 100 | return client; 101 | } -------------------------------------------------------------------------------- /lib/controller/file-server.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Local file web-server controller: wraps given file origin with local HTTP 3 | * server (if possible) and manages server lifecycle. 4 | */ 5 | 'use strict'; 6 | 7 | var path = require('path'); 8 | var extend = require('xtend'); 9 | var debug = require('debug')('lsapp:file-server'); 10 | var tunnel = require('./tunnel'); 11 | var fileServer = require('../file-server'); 12 | 13 | var reIsFile = /^file:/; 14 | var servers = []; 15 | var allowedMessages = ['incoming-updates', 'diff']; 16 | 17 | module.exports = function(docroot) { 18 | docroot = normalize(docroot); 19 | 20 | // maybe there’s already a web-server for current docroot? 21 | var server = find(docroot); 22 | if (server) { 23 | debug('local file server already exists for %s', docroot); 24 | return Promise.resolve(server.rv.address); 25 | } 26 | 27 | debug('should create local file server'); 28 | return fileServer(docroot).then(function(server) { 29 | var addr = server.address(); 30 | var localSite = `http://${addr.address}:${addr.port}`; 31 | server.rv = {address: localSite, docroot}; 32 | servers.push(server); 33 | 34 | debug('created local file server %s for %s', localSite, docroot); 35 | return localSite; 36 | }); 37 | }; 38 | 39 | module.exports.forward = function(client) { 40 | // forward updates from filesystem origin to local HTTP server 41 | client.on('message', function(payload) { 42 | if (typeof payload === 'string') { 43 | payload = JSON.parse(payload); 44 | } 45 | 46 | if (!payload || !payload.data || allowedMessages.indexOf(payload.name) === -1) { 47 | return debug('skip message forward: unsupported message "%s"', payload.name); 48 | } 49 | 50 | // is this a filesystem? 51 | if (!reIsFile.test(payload.data.uri)) { 52 | return debug('skip message forward: "%s" is not a file origin', payload.data.uri); 53 | } 54 | 55 | var file = normalize(payload.data.uri); 56 | // find server(s) that match given origin and forward messages 57 | servers.forEach(function(server) { 58 | if (file.indexOf(server.rv.docroot) !== 0) { 59 | return; 60 | } 61 | 62 | // local server found, rebuild URL and forward message 63 | var uri = server.rv.address + '/' + file 64 | .slice(server.rv.docroot.length) 65 | .split(/[\\\/]/g) 66 | .filter(Boolean) 67 | .join('/'); 68 | 69 | debug('forward message to %s', uri); 70 | client.send(payload.name, extend(payload.data, {uri})); 71 | }); 72 | }); 73 | }; 74 | 75 | var find = module.exports.find = function(key) { 76 | var docroot = normalize(key); 77 | for (let server of servers) { 78 | if (server.rv.address === key || server.rv.docroot === docroot) { 79 | return server; 80 | } 81 | } 82 | }; 83 | 84 | var normalize = module.exports.normalize = function(dir) { 85 | return path.normalize(dir.replace(/^file:\/\//, '')); 86 | }; 87 | 88 | // destroy local web-server when session is closed 89 | tunnel.on('clusterDestroy', function(cluster) { 90 | var server = find(cluster.options.localSite); 91 | // make sure no other session uses current server 92 | var cmp = function(c) {return c.localSite === server.rv.address;}; 93 | if (server && !tunnel.list().some(cmp)) { 94 | server.close(); 95 | servers.splice(servers.indexOf(server), 1); 96 | } 97 | }); -------------------------------------------------------------------------------- /lib/controller/tunnel.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Remote View tunnel controller: manages all Remote View sessions and tunnel 3 | * connections 4 | */ 5 | 'use strict'; 6 | 7 | var EventEmitter = require('events'); 8 | var debug = require('debug')('lsapp:tunnel'); 9 | var TunnelCluster = require('remote-view-client').TunnelCluster; 10 | var utils = require('../utils'); 11 | 12 | class TunnelClusterController extends EventEmitter { 13 | constructor() { 14 | super(); 15 | this.clusters = []; 16 | 17 | var self = this; 18 | this._onClusterDestroy = function(err) { 19 | debug('cluster destroyed with error %o', err); 20 | utils.removeFromArray(self.clusters, this); 21 | this.removeListener('error', self._onClusterError); 22 | this.removeListener('state', self._onClusterStateChange); 23 | self.emit('clusterDestroy', this, err); 24 | self._emitUpdate(); 25 | }; 26 | 27 | this._onClusterStateChange = function(state) { 28 | debug('cluster changed state to %s', state); 29 | self.emit('clusterState', this, state); 30 | self._emitUpdate(); 31 | }; 32 | 33 | this._onClusterError = function(err) { 34 | self.emit('clusterError', this, err); 35 | }; 36 | 37 | this._emitUpdate = utils.throttle(function() { 38 | self.emit('update', self.list()); 39 | }, 20, {leading: false}); 40 | } 41 | 42 | /** 43 | * Returns JSON list of available clusters 44 | * @return {Object} 45 | */ 46 | list() { 47 | return this.clusters.map(clusterJSON); 48 | } 49 | 50 | /** 51 | * Creates new tunnel cluster with given Remote View session data 52 | * @param {Object} data Remote View session data 53 | * @return {Promise} 54 | */ 55 | create(data) { 56 | var cluster = new TunnelCluster(data) 57 | .once('state', this._onClusterStateChange) 58 | .once('error', this._onClusterError) 59 | .once('destroy', this._onClusterDestroy); 60 | 61 | this.clusters.push(cluster); 62 | this.emit('clusterCreate', cluster); 63 | this._emitUpdate(); 64 | return cluster; 65 | } 66 | 67 | /** 68 | * Closes session for given cluster 69 | * @param {TunnelCluster|String} cluster Cluster or cluster ID to close 70 | * @return {TunnelCluster} 71 | */ 72 | close(cluster) { 73 | if (typeof cluster === 'string') { 74 | cluster = this.getById(cluster); 75 | } 76 | 77 | cluster && cluster.destroy(); 78 | return cluster; 79 | } 80 | 81 | /** 82 | * Closes all Remote View sessions 83 | */ 84 | closeAll() { 85 | for (var i = this.clusters.length - 1; i >= 0; i--) { 86 | this.close(this.clusters[i]); 87 | } 88 | } 89 | 90 | /** 91 | * Returns cluster for given public id 92 | * @param {String} id Remote View session public id 93 | * @return {TunnelCluster} 94 | */ 95 | getById(id) { 96 | for (var i = 0, il = this.clusters.length; i < il; i++) { 97 | if (this.clusters[i].options.publicId === id) { 98 | return this.clusters[i]; 99 | } 100 | } 101 | } 102 | }; 103 | 104 | module.exports = new TunnelClusterController(); 105 | module.exports.TunnelClusterController = TunnelClusterController; 106 | 107 | function clusterJSON(cluster) { 108 | return { 109 | sessionId: cluster.options.sessionId, 110 | publicId: cluster.options.publicId, 111 | localSite: cluster.options.localSite, 112 | state: cluster.state 113 | }; 114 | } -------------------------------------------------------------------------------- /lib/controller/app-model.js: -------------------------------------------------------------------------------- 1 | /** 2 | * A model instance that represents current application state: installed plugins 3 | * and active Remote View sessions 4 | */ 5 | 'use strict'; 6 | 7 | var debug = require('debug')('lsapp:app-model'); 8 | var tunnelController = require('./tunnel'); 9 | var appsDfn = require('../apps'); 10 | var googleChrome = require('../google-chrome'); 11 | var sublimeText = require('../sublime-text'); 12 | 13 | module.exports = function(model, client) { 14 | tunnelController.on('update', sessions => model.set('rvSessions', sessions)); 15 | model.on('change', () => client.send('app-model', model.toJSON())); 16 | 17 | var apps = { 18 | st2: setupApp(appsDfn.st2, sublimeText, model, client), 19 | st3: setupApp(appsDfn.st3, sublimeText, model, client), 20 | chrome: setupApp(appsDfn.chrome, googleChrome, model, client) 21 | }; 22 | 23 | Object.keys(apps).forEach(k => apps[k].detect()); 24 | 25 | return { 26 | install(id) { 27 | return apps[id] 28 | ? apps[id].install() 29 | : Promise.reject(new Error(`Unknown app ${id}`)); 30 | }, 31 | detect(id) { 32 | return apps[id] 33 | ? apps[id].detect() 34 | : Promise.reject(new Error(`Unknown app ${id}`)); 35 | } 36 | }; 37 | }; 38 | 39 | function setupApp(app, handler, model, client) { 40 | var attributeName = app.id; 41 | var installPromise = null; 42 | var autoupdater; 43 | 44 | if (handler.autoupdate) { 45 | autoupdater = handler.autoupdate(app) 46 | .on('shouldUpdate', app => install('updating')) 47 | .start(60 * 60); // check every hour 48 | } 49 | 50 | var detect = pollFactory(model, attributeName, () => { 51 | return handler.detect(app, client) 52 | .then(result => { 53 | if (result && autoupdater) { 54 | debug('%s plugin installed, check for updates', app.id); 55 | autoupdater.check(); 56 | } 57 | return result; 58 | }); 59 | }); 60 | 61 | var install = (state) => { 62 | if (installPromise) { 63 | return installPromise; 64 | } 65 | 66 | model.set(attributeName, state || 'installing'); 67 | return installPromise = handler.install(app) 68 | .then(() => { 69 | installPromise = null; 70 | detect(model.unset(attributeName)) 71 | }) 72 | .catch(err => { 73 | debug(err); 74 | installPromise = null; 75 | model.set(attributeName, createError(err)); 76 | return Promise.reject(err); 77 | }); 78 | }; 79 | 80 | return {detect, install, autoupdater}; 81 | } 82 | 83 | function pollFactory(model, attributeName, detectFn) { 84 | var timerId = null; 85 | return function poll() { 86 | if (timerId) { 87 | clearTimeout(timerId); 88 | timerId = null; 89 | } 90 | 91 | debug('polling install status for %s', attributeName); 92 | detectFn() 93 | .then(result => { 94 | model.set(attributeName, result ? 'installed' : 'not-installed'); 95 | timerId = null; 96 | }) 97 | .catch(err => { 98 | model.set(attributeName, !err ? 'not-installed' : { 99 | error: err.message, 100 | errorCode: err.code 101 | }); 102 | timerId = setTimeout(poll, 5000).unref(); 103 | }); 104 | }; 105 | } 106 | 107 | function createError(err) { 108 | var data = {error: err.message}; 109 | if (err.code) { 110 | data.errorCode = err.code; 111 | } 112 | return data; 113 | } 114 | 115 | if (require.main === module) { 116 | let pkg = require('../../package.json'); 117 | require('../client')(pkg.config.websocketUrl, function(err, client) { 118 | if (err) { 119 | return debug(err); 120 | } 121 | module.exports(client).on('change', () => console.log(this.attributes)); 122 | }); 123 | } -------------------------------------------------------------------------------- /lib/sublime-text/autoupdate.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Setup autoupdate process for given Sublime Text app object 3 | */ 4 | 'use strict'; 5 | 6 | const fs = require('graceful-fs'); 7 | const path = require('path'); 8 | const EventEmitter = require('events'); 9 | const debug = require('debug')('lsapp:sublime-text:autoupdate'); 10 | const request = require('../helpers/request'); 11 | const utils = require('../node-utils'); 12 | 13 | module.exports = function(app) { 14 | return new AutoUpdate(app); 15 | }; 16 | 17 | class AutoUpdate extends EventEmitter { 18 | constructor(app) { 19 | super(); 20 | this.app = app; 21 | this._running = false; 22 | } 23 | 24 | check() { 25 | let app = this.app; 26 | if (!app.commitUrl) { 27 | debug('Aborting auto-update check: no commitUrl in app'); 28 | return Promise.reject(new Error('No commitUrl')); 29 | } 30 | 31 | debug('Check for auto-update'); 32 | this.emit('checkForUpdate', app); 33 | let extensionPath = path.resolve(utils.expandUser(app.install), 'LiveStyle'); 34 | return validateDir(extensionPath) 35 | .then(dir => { 36 | dir = path.resolve(dir, 'autoupdate.json'); 37 | debug('check auto-update in %s', dir); 38 | return read(dir); 39 | }) 40 | .then(local => { 41 | debug('check commit in %s', local); 42 | return request(app.commitUrl) 43 | .then(remote => JSON.parse(local).sha !== JSON.parse(remote).sha); 44 | }) 45 | .catch(err => checkDeprecatedPlugin(extensionPath)) 46 | .then(shouldUpdate => { 47 | debug('should auto-update? %o', shouldUpdate); 48 | this.emit('checkForUpdateStatus', shouldUpdate, app); 49 | if (shouldUpdate) { 50 | this.emit('shouldUpdate', app); 51 | } 52 | return shouldUpdate; 53 | }); 54 | } 55 | 56 | /** 57 | * Starts auto-update polling 58 | * @return 59 | */ 60 | start(seconds) { 61 | if (!this._running) { 62 | this._running = true; 63 | this._updateLoop(seconds * 1000); 64 | } 65 | return this; 66 | } 67 | 68 | stop() { 69 | if (this._timer) { 70 | clearTimeout(this._timer); 71 | this._timer = null 72 | } 73 | this._running = false; 74 | return this; 75 | } 76 | 77 | _updateLoop(timeout) { 78 | if (this._running) { 79 | this._timer = setTimeout(() => { 80 | this.check() 81 | .catch(err => true) 82 | .then(() => this._updateLoop(timeout)); 83 | }, timeout).unref(); 84 | } 85 | } 86 | }; 87 | 88 | function read(file) { 89 | return new Promise((resolve, reject) => { 90 | fs.readFile(file, 'utf8', (err, contents) => err ? reject(err) : resolve(contents)); 91 | }); 92 | } 93 | 94 | function validateDir(dir) { 95 | // for sake of development, make sure extension path is not a symlink 96 | return new Promise((resolve, reject) => { 97 | fs.lstat(dir, (err, stat) => { 98 | if (stat && stat.isSymbolicLink()) { 99 | debug('skip auto-update: extension dir is a symlink') 100 | err = new Error('Extension dir is a symlink'); 101 | err.code = 'ESYMLINKEXT'; 102 | } 103 | err ? reject(err) : resolve(dir); 104 | }); 105 | }); 106 | } 107 | 108 | /** 109 | * Check if given path contains old LiveStyle plugin 110 | * @param {String} dir 111 | * @return {Promise} 112 | */ 113 | function checkDeprecatedPlugin(dir) { 114 | return new Promise((resolve, reject) => { 115 | fs.readdir(dir, (err, items) => { 116 | // dir exists (no error) and contains old plugin: should update it 117 | let isOldPlugin = !err && items && items.indexOf('livestyle.py') !== -1; 118 | debug('contains old plugin? %o', isOldPlugin); 119 | resolve(isOldPlugin); 120 | }); 121 | }); 122 | } -------------------------------------------------------------------------------- /tools/branding/win.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var fs = require('graceful-fs'); 4 | var path = require('path'); 5 | var cp = require('child_process'); 6 | var rcedit = require('rcedit'); 7 | var extend = require('xtend'); 8 | var debug = require('debug')('lsapp:distribute:win'); 9 | var installer = require('electron-installer-squirrel-windows'); 10 | var pkg = require('../../package.json'); 11 | 12 | const EXE = 'electron.exe'; 13 | const certificatePath = makePath('../windows/livestyle.pfx'); 14 | const certificatePassword = process.env.WIN_CERTIFICATE_PASSWORD; 15 | const certificateTimestamp = 'http://timestamp.comodoca.com/authenticode'; 16 | const devMode = process.argv.indexOf('--dev') !== -1; 17 | 18 | module.exports = function(app) { 19 | var p = editResources(app).then(renameExecutable); 20 | 21 | if (!devMode) { 22 | p = p.then(codesign) 23 | .then(createInstaller) 24 | .then(getAssets); 25 | } 26 | 27 | return p; 28 | }; 29 | 30 | function editResources(app) { 31 | debug('edit app resources'); 32 | return new Promise(function(resolve, reject) { 33 | debug('set icon %s', app.icon); 34 | var opt = { 35 | 'version-string': { 36 | 'ProductName': app.productName, 37 | 'CompanyName': app.companyName, 38 | 'FileDescription': app.description, 39 | 'LegalCopyright': app.copyright, 40 | 'OriginalFilename': app.name.toLowerCase() + '.exe' 41 | }, 42 | 'icon': app.icon, 43 | 'file-version': app.version, 44 | 'product-version': app.version 45 | }; 46 | 47 | rcedit(path.join(app.dir, EXE), opt, function(err) { 48 | err ? reject(err) : resolve(app); 49 | }); 50 | }); 51 | } 52 | 53 | function renameExecutable(app) { 54 | debug('rename executable'); 55 | return new Promise(function(resolve, reject) { 56 | var newName = app.name.toLowerCase() + path.extname(EXE); 57 | fs.rename(path.join(app.dir, EXE), path.join(app.dir, newName), function(err) { 58 | err ? reject(err) : resolve(extend(app, {exe: newName})); 59 | }); 60 | }); 61 | } 62 | 63 | function createInstaller(app) { 64 | return new Promise((resolve, reject) => { 65 | var out = path.join(path.dirname(app.dir), 'installer'); 66 | installer({ 67 | name: app.name, 68 | product_name: app.productName, 69 | path: app.dir, 70 | authors: pkg.author, 71 | loading_gif: makePath('resources/install-spinner.gif'), 72 | setup_icon: makePath('icon/livestyle.ico'), 73 | exe: app.exe, 74 | out, 75 | sign_with_params: `/a /f "${certificatePath}" /p "${certificatePassword}" /d "${app.productName}" /du "${app.url}" /t "${certificateTimestamp}"`, 76 | overwrite: true 77 | }, err => { 78 | if (err) { 79 | return reject(err); 80 | } 81 | 82 | resolve(extend(app, {dir: out})); 83 | }); 84 | }); 85 | } 86 | 87 | function codesign(app) { 88 | return new Promise((resolve, reject) => { 89 | cp.execFile(process.env.SIGN_TOOL, ['sign', 90 | '/f', certificatePath, 91 | '/p', certificatePassword, 92 | '/d', app.productName, 93 | '/du', app.url, 94 | '/t', certificateTimestamp, 95 | path.resolve(app.dir, app.exe) 96 | ], function(err, stdout, stderr) { 97 | stdout && console.log(stdout); 98 | stderr && console.log(stderr); 99 | err ? reject(err) : resolve(app) 100 | }); 101 | }); 102 | } 103 | 104 | function makePath(fileName) { 105 | return path.resolve(__dirname, fileName); 106 | } 107 | 108 | function getAssets(app) { 109 | return new Promise((resolve, reject) => { 110 | fs.readdir(app.dir, (err, files) => { 111 | if (err) { 112 | return reject(err) 113 | } 114 | 115 | resolve(files.map(file => path.resolve(app.dir, file))); 116 | }); 117 | }); 118 | } -------------------------------------------------------------------------------- /main.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const path = require('path'); 4 | const menubar = require('menubar') 5 | const electron = require('electron'); 6 | const debug = require('debug')('lsapp:main'); 7 | const backend = require('./backend'); 8 | const Model = require('./lib/model'); 9 | const appModelController = require('./lib/controller/app-model'); 10 | const connect = require('./lib/client'); 11 | const autoUpdate = require('./lib/autoupdate'); 12 | const pkg = require('./package.json'); 13 | 14 | if (require('electron-squirrel-startup')) { 15 | return; 16 | } 17 | 18 | const ipc = electron.ipcMain; 19 | const BrowserWindow = electron.BrowserWindow; 20 | const startAutoupdateTimeout = 20 * 1000; // when to start auto-update polling 21 | 22 | // XXX init 23 | require('electron-debug')(); 24 | var appModel = new Model(); 25 | var app = menubar({ 26 | width: 400, 27 | height: 360, 28 | resizable: false, 29 | 'always-on-top': process.argv.indexOf('--on-top') !== -1, 30 | icon: path.resolve(__dirname, `assets/${process.platform === 'win32' ? 'menu-icon.ico' : 'menu-iconTemplate.png'}`) 31 | }) 32 | .on('ready', function() { 33 | connect(pkg.config.websocketUrl, (err, client) => { 34 | if (err) { 35 | return error(err); 36 | } 37 | 38 | info('Client connected'); 39 | 40 | // supress 'error' event since in Node.js, in most cases it means unhandled exception 41 | client.on('error', err => console.error(err)); 42 | 43 | var controller = appModelController(appModel, client); 44 | backend(client); 45 | updateMainWindow(appModel); 46 | setupAppEvents(app, controller); 47 | initialWindowDisplay(app); 48 | 49 | setupAutoUpdate(appModel); 50 | }); 51 | }) 52 | .on('show', () => updateMainWindow(appModel)) 53 | .on('after-create-window', () => { 54 | app.window.webContents.on('did-finish-load', () => updateMainWindow(appModel)); 55 | }); 56 | 57 | appModel.on('change', function() { 58 | debug('model update %o', this.attributes); 59 | updateMainWindow(this); 60 | }); 61 | 62 | //////////////////////////////////// 63 | 64 | function setupAppEvents(app, controller) { 65 | ipc.on('install-plugin', function(event, id) { 66 | log(`install plugin ${id}`); 67 | controller.install(id).catch(error); 68 | }) 69 | .on('install-update', () => electron.autoUpdater.quitAndInstall()) 70 | .on('rv-close-session', (event, key) => backend.closeRvSession(key)) 71 | .on('quit', () => app && app.app && app.app.quit()); 72 | } 73 | 74 | function setupAutoUpdate(model) { 75 | setTimeout(() => { 76 | autoUpdate(pkg) 77 | .on('update-downloaded', () => model.set('updateAvailable', true)) 78 | .on('update-not-available', () => model.set('updateAvailable', false)) 79 | .on('error', error); 80 | }, startAutoupdateTimeout).unref(); 81 | } 82 | 83 | function toArray(obj) { 84 | return Array.prototype.slice.call(obj, 0); 85 | } 86 | 87 | function updateMainWindow(model) { 88 | if (model) { 89 | _send('model', model.toJSON()); 90 | } 91 | } 92 | 93 | /** 94 | * Initial window display when app starts: do not quit app when window requested 95 | * it for the first time 96 | * @param {App} app 97 | * @param {BrowserWindow} wnd 98 | */ 99 | function initialWindowDisplay(menuApp) { 100 | var handled = false; 101 | ipc.on('will-quit', evt => { 102 | if (!handled) { 103 | evt.returnValue = handled = true; 104 | menuApp.hideWindow(); 105 | } else { 106 | evt.returnValue = false; 107 | } 108 | }); 109 | menuApp.once('after-hide', () => handled = true).showWindow(); 110 | } 111 | 112 | function log() { 113 | debug.apply(null, toArray(arguments)); 114 | _send('log', toArray(arguments)); 115 | } 116 | 117 | function warn() { 118 | _send('warn', toArray(arguments)); 119 | } 120 | 121 | function info() { 122 | _send('info', toArray(arguments)); 123 | } 124 | 125 | function error() { 126 | _send('error', toArray(arguments)); 127 | } 128 | 129 | function _send(name, args) { 130 | app.window && app.window.webContents.send(name, args); 131 | } 132 | -------------------------------------------------------------------------------- /tools/distribute.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 'use strict'; 3 | 4 | var fs = require('fs'); 5 | var path = require('path'); 6 | var cpy = require('cpy'); 7 | var ncp = require('ncp'); 8 | var del = require('del'); 9 | var extend = require('xtend'); 10 | var mkdirp = require('mkdirp'); 11 | var debug = require('debug')('lsapp:distribute'); 12 | var pkg = require('../package.json'); 13 | var brand = { 14 | 'darwin': require('./branding/osx'), 15 | 'win32': require('./branding/win') 16 | }; 17 | 18 | const ELECTRON_VERSION = require('electron-prebuilt/package').version.replace(/-.*/, ''); 19 | var appBaseDir = path.resolve(__dirname, '../node_modules/electron-prebuilt/dist'); 20 | var appDir = { 21 | 'darwin': path.resolve(appBaseDir, 'Electron.app'), 22 | 'win32': appBaseDir 23 | }; 24 | 25 | var resDir = { 26 | 'darwin': 'Contents/Resources', 27 | 'win32': 'resources' 28 | }; 29 | 30 | var appFiles = [ 31 | '{assets,lib,ui}/**', 32 | '{main,backend}.js', 33 | 'index.html', 34 | 'package.json', 35 | 'node_modules/{' + Object.keys(pkg.dependencies).join(',') + '}/**' 36 | ]; 37 | 38 | module.exports = function(platform) { 39 | platform = platform || getPlatform(); 40 | var isOSX = platform === 'darwin'; 41 | console.log('Branding and packing app for %s (%s) platform', platform, process.arch); 42 | var app = { 43 | id: 'io.livestyle.app', 44 | name: 'LiveStyle', 45 | url: 'http://livestyle.io', 46 | platform, 47 | productName: 'Emmet LiveStyle', 48 | companyName: 'Emmet.io', 49 | copyright: 'Copyright (c) 2015 Sergey Chikuyonok', 50 | description: pkg.description, 51 | icon: path.resolve(__dirname, `./branding/icon/${isOSX ? 'livestyle.icns' : 'livestyle.ico'}`), 52 | dir: appDir[platform], 53 | resDir: resDir[platform], 54 | appDirName: isOSX ? 'LiveStyle.app' : 'livestyle', 55 | version: pkg.version 56 | }; 57 | 58 | return copyApp(app) 59 | .then(clean) 60 | .then(copyResources) 61 | .then(brand[platform]); 62 | }; 63 | 64 | function getPlatform() { 65 | var platformArg = '--platform'; 66 | var eqArg = platformArg + '='; 67 | var platform = process.platform; 68 | process.argv.slice(2).forEach(function(arg, i) { 69 | if (arg === platformArg) { 70 | platform = process.argv[i + 3]; 71 | } else if (arg.indexOf(eqArg) === 0) { 72 | platform = arg.slice(eqArg.length); 73 | } 74 | }); 75 | 76 | debug('picked platform: %s', platform); 77 | 78 | if (!platform || !appDir[platform]) { 79 | throw new Error('Unsupported platform: ' + platform); 80 | } 81 | 82 | return platform; 83 | } 84 | 85 | function copyApp(app) { 86 | return new Promise(function(resolve, reject) { 87 | var dest = path.resolve(__dirname, `../dist/${app.platform}/${app.appDirName}`); 88 | debug('clean-up old dest'); 89 | del(dest).then(() => { 90 | debug('copy pristine app from %s to %s', app.dir, dest); 91 | mkdirp(dest, function(err) { 92 | if (err) { 93 | return reject(err); 94 | } 95 | 96 | // have to use `ncp` instead of `cpy` to preserve symlinks and file mode 97 | ncp(app.dir, path.resolve(dest), function(err) { 98 | if (err) { 99 | return reject(err); 100 | } 101 | resolve(extend(app, {dir: dest})); 102 | }); 103 | }); 104 | }); 105 | }); 106 | } 107 | 108 | function clean(app) { 109 | var dest = path.join(app.dir, app.resDir); 110 | debug('clean up %s dir', dest); 111 | return del(['atom.icns', 'default_app'], {cwd: dest}).then(function() { 112 | return app; 113 | }); 114 | } 115 | 116 | function copyResources(app) { 117 | return new Promise(function(resolve, reject) { 118 | var dest = path.join(app.dir, app.resDir, 'app'); 119 | debug('copy app files to %s', dest); 120 | cpy(appFiles, dest, {parents: true, nodir: true}, function(err) { 121 | err ? reject(err) : resolve(app); 122 | }); 123 | }); 124 | } 125 | 126 | if (require.main === module) { 127 | module.exports().then(function(assets) { 128 | console.log(assets); 129 | }, function(err) { 130 | console.error(err.stack ? err.stack : err); 131 | process.exit(1); 132 | }); 133 | } -------------------------------------------------------------------------------- /backend.js: -------------------------------------------------------------------------------- 1 | /** 2 | * A Node.JS back-end for Remote View feature of LiveStyle app: 3 | * manages connections to LiveStyle and Remote View servers and 4 | * responds to RV messages. 5 | * 6 | * Designed to use in pure Node.JS environment for testing and 7 | * debugging (e.g. do not use any Electron-specific packages) 8 | */ 9 | 'use strict'; 10 | 11 | var debug = require('debug')('lsapp:backend'); 12 | var extend = require('xtend'); 13 | var tunnelController = require('./lib/controller/tunnel'); 14 | var fileServerController = require('./lib/controller/file-server'); 15 | 16 | module.exports = function(client) { 17 | client 18 | .on('rv-ping', function() { 19 | debug('ping'); 20 | client.send('rv-pong'); 21 | }) 22 | .on('rv-get-session', function(data) { 23 | var origin = data && data.localSite; 24 | debug('get session for %s', origin); 25 | client.send('rv-session', sessionPayload(origin)); 26 | }) 27 | .on('rv-get-session-list', function() { 28 | var sessions = tunnelController.list() 29 | .map(function(session) { 30 | // if this is a local server, rewrite its localSite to server docroot 31 | var localServer = fileServerController.find(session.localSite); 32 | if (localServer) { 33 | session = extend(session, {localSite: localServer.rv.docroot}); 34 | } 35 | return session; 36 | }); 37 | client.send('rv-session-list', sessions); 38 | }) 39 | .on('rv-create-session', function(data) { 40 | debug('create session %o', data); 41 | 42 | var onError = function(err) { 43 | debug('error when creating session for %s: %s', data.localSite, err ? err.message : 'unknown'); 44 | var message = err ? err.message : 'Unable to establish tunnel with Remote View server'; 45 | client.send('rv-session', { 46 | localSite: data.localSite, 47 | error: message + '. Please try again later.' 48 | }); 49 | }; 50 | 51 | var onConnect = function() { 52 | debug('created session for %s', data.localSite); 53 | client.send('rv-session', sessionPayload(data.localSite)); 54 | this.removeListener('destroy', onDestroy); 55 | }; 56 | 57 | var onDestroy = function(err) { 58 | err && onError(err); 59 | this.removeListener('connect', onConnect); 60 | }; 61 | 62 | tunnelController.create(data) 63 | .once('connect', onConnect) 64 | .once('destroy', onDestroy); 65 | }) 66 | .on('rv-close-session', function(data) { 67 | debug('close session %o', data); 68 | module.exports.closeRvSession(data.localSite); 69 | }) 70 | .on('rv-create-http-server', function(data) { 71 | fileServerController(data.docroot) 72 | .then(function(origin) { 73 | client.send('rv-http-server', { 74 | docroot: data.docroot, 75 | origin 76 | }); 77 | }) 78 | .catch(function(err) { 79 | client.send('rv-http-server', { 80 | docroot: data.docroot, 81 | error: err.message 82 | }); 83 | }); 84 | }); 85 | 86 | fileServerController.forward(client); 87 | 88 | return client; 89 | }; 90 | 91 | module.exports.closeRvSession = function(key) { 92 | debug('requested session %o close', key); 93 | var session = sessionPayload(key); 94 | if (session && !session.error) { 95 | debug('closing %s', session.publicId); 96 | tunnelController.close(session.publicId); 97 | } 98 | }; 99 | 100 | function findSession(key) { 101 | for (let session of tunnelController.list()) { 102 | if (session.localSite === key || session.publicId === key) { 103 | return session; 104 | } 105 | } 106 | } 107 | 108 | function sessionPayload(localSite) { 109 | var session = findSession(localSite); 110 | if (!session) { 111 | // mayabe its a local web-server? 112 | var localServer = fileServerController.find(localSite); 113 | if (localServer) { 114 | session = findSession(localServer.rv.address); 115 | if (session) { 116 | session = extend(session, {localSite}) 117 | } 118 | } 119 | } 120 | 121 | return session || { 122 | localSite, 123 | error: 'Session not found' 124 | }; 125 | } 126 | 127 | if (require.main === module) { 128 | let pkg = require('./package.json'); 129 | require('./lib/client')(pkg.config.websocketUrl, function(err, client) { 130 | if (err) { 131 | return console.error(err); 132 | } 133 | 134 | module.exports(client); 135 | console.log('RV client connected'); 136 | }); 137 | } -------------------------------------------------------------------------------- /lib/server.js: -------------------------------------------------------------------------------- 1 | /** 2 | * LiveStyle WebSocket server 3 | */ 4 | 'use strict'; 5 | var http = require('http'); 6 | var WebSocketServer = require('ws').Server; 7 | var debug = require('debug')('lsapp:server'); 8 | var utils = require('./utils'); 9 | 10 | var server; 11 | var clients = []; // all connected clients 12 | var patchers = []; // clients identified as 'patcher' 13 | var editors = {}; // clients identified as 'editor' 14 | 15 | 16 | /** 17 | * Start server on given port 18 | * @param {Number} port 19 | * @param {Function} callback 20 | */ 21 | module.exports = function(port, callback) { 22 | debug('starting server at %d', port); 23 | // destroy previous server instance 24 | module.exports.destroy(); 25 | 26 | server = http.createServer(handleHttpRequest) 27 | .once('close', onServerClose); 28 | 29 | server.ws = new WebSocketServer({server, verifyClient}); 30 | server.ws.on('connection', onWsConnection); 31 | server.listen(port, callback); 32 | return server; 33 | }; 34 | 35 | module.exports.send = function(message, exclude) { 36 | sendMessage(clients, message, exclude); 37 | }; 38 | 39 | module.exports.destroy = function(callback) { 40 | while (clients.length) { 41 | clients.pop().terminate(); 42 | } 43 | 44 | patchers.length = 0; 45 | editors = {}; 46 | if (server) { 47 | server.close(callback); 48 | } else if (typeof callback === 'function') { 49 | callback(); 50 | } 51 | }; 52 | 53 | Object.defineProperties(module.exports, { 54 | server: { 55 | get() { 56 | return server; 57 | } 58 | } 59 | }); 60 | 61 | function verifyClient(info) { 62 | debug('verifying request %o', /^\/livestyle\/?/.test(info.req.url)); 63 | return /^\/livestyle\/?/.test(info.req.url); 64 | } 65 | 66 | /** 67 | * Response stub for HTTP requests to WebSocket server 68 | * @param {http.IncomingMessage} req 69 | * @param {http.ServerResponse} res 70 | */ 71 | function handleHttpRequest(req, res) { 72 | debug('got http request'); 73 | res.writeHead(200, {Connection: 'close'}); 74 | res.end('LiveStyle WebSocket server is up and running by LiveStyle App.'); 75 | } 76 | 77 | function onWsConnection(conn) { 78 | debug('received ws connection'); 79 | clients.push(conn); 80 | conn 81 | .on('message', handleMessage) 82 | .on('close', removeClient); 83 | 84 | sendMessage(clients, {name: 'client-connect'}, conn); 85 | } 86 | 87 | function onServerClose() { 88 | debug('closed'); 89 | if (this.ws) { 90 | this.ws.removeListener('connection', onWsConnection); 91 | this.ws = null; 92 | } 93 | } 94 | 95 | function handleMessage(message) { 96 | var payload = JSON.parse(message); 97 | var receivers = clients; 98 | debug('got message', payload.name); 99 | switch (payload.name) { 100 | case 'editor-connect': 101 | editors[payload.data.id] = this; 102 | break; 103 | case 'patcher-connect': 104 | patchers.push(this); 105 | break; 106 | case 'calculate-diff': 107 | case 'apply-patch': 108 | receivers = patchers; 109 | break; 110 | } 111 | 112 | // Send all incoming messages to all connected clients 113 | // except current one 114 | sendMessage(receivers, message, this); 115 | } 116 | 117 | /** 118 | * Sends message to given receivers 119 | * @param {Array} receivers List of receivers (websocket clients) 120 | * @param {Object} message Message to send 121 | * @param {Websocket|Array} exclude Exclude given client(s) from receivers 122 | */ 123 | function sendMessage(receivers, message, exclude) { 124 | if (typeof message !== 'string') { 125 | message = JSON.stringify(message); 126 | } 127 | 128 | if (exclude) { 129 | if (!Array.isArray(exclude)) { 130 | exclude = [exclude]; 131 | } 132 | receivers = receivers.filter(function(client) { 133 | return exclude.indexOf(client) === -1; 134 | }); 135 | } 136 | 137 | for (var i = 0, il = receivers.length; i < il; i++) { 138 | receivers[i].send(message); 139 | } 140 | } 141 | 142 | /** 143 | * Removes given client from all collections 144 | * @param {Websocket} client 145 | */ 146 | function removeClient() { 147 | utils.removeFromArray(clients, this); 148 | utils.removeFromArray(patchers, this); 149 | 150 | var editorKeys = Object.keys(editors); 151 | for (let i = 0, il = editorKeys.length, id; i < il; i++) { 152 | id = editorKeys[i]; 153 | if (editors[id] === this) { 154 | sendMessage(clients, { 155 | name: 'editor-disconnect', 156 | data: {id: id} 157 | }); 158 | delete editors[id]; 159 | } 160 | } 161 | } -------------------------------------------------------------------------------- /tools/release.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Updates GitHub release with given file for given version 3 | */ 4 | 'use strict'; 5 | 6 | var fs = require('fs'); 7 | var path = require('path'); 8 | var extend = require('xtend'); 9 | var debug = require('debug')('lsapp:release'); 10 | var mime = require('mime'); 11 | var request = require('request').defaults({ 12 | json: true, 13 | headers: { 14 | accept: 'application/vnd.github.v3+json', 15 | authorization: 'token ' + process.env.PUBLISH_TOKEN, 16 | 'user-agent': 'LiveStyle publisher bot' 17 | } 18 | }); 19 | 20 | if (!process.env.PUBLISH_TOKEN) { 21 | throw new Error('No PUBLISH_TOKEN env variable'); 22 | } 23 | 24 | module.exports = function(data) { 25 | if (!data.domain) { 26 | data = extend(data, {domain: 'https://api.github.com'}); 27 | } 28 | 29 | return getReleases(data) 30 | .then(releases => { 31 | // Check if given release exists. If so, we should update it, 32 | // otherwise create new one 33 | var current = releases.reduce((prev, cur) => cur.name === data.release ? cur : prev, null); 34 | debug(current ? 'found release for %s' : 'no release for %s', data.release); 35 | return current ? Promise.resolve(current) : createRelease(data); 36 | }) 37 | .then(release => { 38 | // create an assets upload pipeline: check if asset with given name exists; 39 | // if so, delete it first, then upload a new version 40 | var assetsToUpload = data.assets || []; 41 | if (!Array.isArray(assetsToUpload)) { 42 | assetsToUpload = [assetsToUpload]; 43 | } 44 | 45 | var existingAssets = (release.assets || []).reduce((result, asset) => { 46 | result[asset.name] = asset; 47 | return result; 48 | }, {}); 49 | 50 | var pipeline = Promise.resolve('aaa'); 51 | assetsToUpload.forEach(function(asset) { 52 | var ex = existingAssets[path.basename(asset)]; 53 | if (ex) { 54 | pipeline = pipeline.then(() => deleteAsset(data, ex.id)); 55 | } 56 | pipeline = pipeline.then(() => uploadAsset(release, asset)); 57 | }); 58 | return pipeline; 59 | }); 60 | }; 61 | 62 | 63 | /** 64 | * Fetches all available releases for given repo 65 | * @param {String} repo Path to repo 66 | * @return {Promise} 67 | */ 68 | function getReleases(data) { 69 | return new Promise(function(resolve, reject) { 70 | var url = `${data.domain}/repos/${data.repo}/releases`; 71 | debug('fetching releases from %s', url); 72 | request(url, expectResponse(resolve, reject)); 73 | }); 74 | } 75 | 76 | /** 77 | * Creates new release for given payload 78 | * @param {Object} data 79 | * @return {Promise} 80 | */ 81 | function createRelease(data) { 82 | debug('creating release %s', data.release); 83 | return new Promise(function(resolve, reject) { 84 | request.post(`${data.domain}/repos/${data.repo}/releases`, {body: { 85 | tag_name: data.release, 86 | target_commitish: data.target || 'master', 87 | name: data.release 88 | }}, expectResponse(resolve, reject, 201)); 89 | }); 90 | } 91 | 92 | function uploadAsset(release, asset) { 93 | return new Promise(function(resolve, reject) { 94 | debug('uploading asset %s for release %s', asset, release.name); 95 | var stat = fs.statSync(asset); 96 | var fileName = path.basename(asset); 97 | var uploadUrl = release.upload_url.split('{')[0] + '?name=' + fileName; 98 | 99 | fs.createReadStream(asset) 100 | .pipe(request.post(uploadUrl, { 101 | headers: { 102 | 'Content-Type': mime.lookup(fileName), 103 | 'Content-Length': stat.size, 104 | } 105 | })) 106 | .on('error', reject) 107 | .on('end', resolve); 108 | }); 109 | } 110 | 111 | function deleteAsset(data, assetId) { 112 | return new Promise(function(resolve, reject) { 113 | debug('removing asset %s', assetId); 114 | request.del( 115 | `${data.domain}/repos/${data.repo}/releases/assets/${assetId}`, 116 | expectResponse(resolve, reject, 204) 117 | ); 118 | }); 119 | } 120 | 121 | function expectResponse(resolve, reject, code) { 122 | code = code || 200; 123 | return function(err, res, content) { 124 | if (!err && res.statusCode === code) { 125 | if (typeof content === 'string') { 126 | content = JSON.parse(content); 127 | } 128 | return resolve(content); 129 | } 130 | 131 | if (!err) { 132 | if (typeof content !== 'string') { 133 | content = JSON.stringify(content); 134 | } 135 | err = new Error(`Unexpected response code: ${res.statusCode}\n\n${content}`); 136 | } 137 | reject(err); 138 | }; 139 | } 140 | 141 | if (require.main === module) { 142 | var app = { 143 | repo: 'livestyle/app', 144 | release: 'sample', 145 | assets: path.resolve(__dirname, '../livestyle-osx.zip') 146 | }; 147 | 148 | module.exports(app).then(function() { 149 | console.log('Done!'); 150 | }, function(err) { 151 | console.error(err.stack ? err.stack : err); 152 | process.exit(1); 153 | }); 154 | } -------------------------------------------------------------------------------- /tools/branding/osx.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var fs = require('fs'); 4 | var path = require('path'); 5 | var cpy = require('cpy'); 6 | var parseUrl = require('url').parse; 7 | var debug = require('debug')('lsapp:distribute:osx'); 8 | var cmd = require('./cmd'); 9 | var info = require('../release-info'); 10 | 11 | const devMode = process.argv.indexOf('--dev') !== -1; 12 | 13 | module.exports = function(app) { 14 | var p = updateMainApp(app) 15 | .then(app => updateHelperApp(path.resolve(app.dir, 'Contents/Frameworks/Electron Helper.app'), app)) 16 | .then(app => updateHelperApp(path.resolve(app.dir, 'Contents/Frameworks/Electron Helper EH.app'), app)) 17 | .then(app => updateHelperApp(path.resolve(app.dir, 'Contents/Frameworks/Electron Helper NP.app'), app)) 18 | .then(copyIcon) 19 | 20 | if (!devMode) { 21 | p = p.then(codesign) 22 | .then(pack) 23 | .then(autoUpdate); 24 | } 25 | 26 | return p; 27 | } 28 | 29 | function updateMainApp(app) { 30 | // update plist then rename binary 31 | var file = path.resolve(app.dir, 'Contents/Info.plist'); 32 | return readFile(file) 33 | .then(contents => { 34 | debug('update plist %s', file); 35 | return replacePlistKeyValue(contents, { 36 | CFBundleDisplayName: app.name, 37 | CFBundleName: app.name, 38 | CFBundleIdentifier: app.id, 39 | CFBundleIconFile: path.basename(app.icon), 40 | CFBundleVersion: app.version, 41 | CFBundleExecutable: app.name, 42 | CFBundleShortVersionString: app.version 43 | }); 44 | }) 45 | .then(contents => writeFile(file, contents)) 46 | .then(() => { 47 | // rename binary 48 | var base = path.resolve(app.dir, 'Contents/MacOS') 49 | return renameFile(path.join(base, 'Electron'), path.join(base, app.name)) 50 | }) 51 | .then(() => app); 52 | } 53 | 54 | function updateHelperApp(helperPath, app) { 55 | // update plist then rename binary 56 | var baseName = path.basename(helperPath).replace(/\.\w+$/, ''); 57 | var suffix = baseName.substr('Electron Helper'.length) || ''; 58 | var helperName = `${app.name} Helper${suffix}`; 59 | var file = path.resolve(helperPath, 'Contents/Info.plist'); 60 | return readFile(file) 61 | .then(contents => { 62 | debug('update plist %s', file); 63 | return replacePlistKeyValue(contents, { 64 | CFBundleDisplayName: helperName, 65 | CFBundleName: helperName, 66 | CFBundleIdentifier: `${app.id}.helper${suffix ? '.' + suffix.trim() : ''}`, 67 | CFBundleExecutable: helperName 68 | }); 69 | }) 70 | .then(contents => writeFile(file, contents)) 71 | .then(() => { 72 | // rename binary 73 | var base = path.resolve(helperPath, 'Contents/MacOS') 74 | return renameFile(path.resolve(base, baseName), path.join(base, helperName)); 75 | }) 76 | .then(() => { 77 | // rename host folder 78 | return renameFile(helperPath, path.join(path.dirname(helperPath), `${helperName}.app`)); 79 | }) 80 | .then(() => app); 81 | } 82 | 83 | function copyIcon(app) { 84 | return new Promise(function(resolve, reject) { 85 | var dest = path.join(app.dir, app.resDir); 86 | debug('copy app icon %s to %s', app.icon, dest); 87 | return cpy([app.icon], dest, function(err) { 88 | err ? reject(err) : resolve(app); 89 | }); 90 | }); 91 | } 92 | 93 | function codesign(app) { 94 | return new Promise(function(resolve, reject) { 95 | var cwd = path.resolve(__dirname, '../../'); 96 | cmd('tools/osx/codesign.sh', {cwd}, e => e ? reject(e) : resolve(app)) 97 | .on('data', chunk => console.log(chunk)); 98 | }); 99 | } 100 | 101 | function replacePlistKeyValue(str, key, value) { 102 | var data = {}; 103 | if (typeof key === 'string') { 104 | data[key] = value; 105 | } else if (typeof key === 'object') { 106 | data = key; 107 | } 108 | 109 | return Object.keys(data).reduce(function(str, key) { 110 | var re = new RegExp('(' + key + ')([\\s\\n]*)<(\\w+)>.*?'); 111 | return str.replace(re, '$1$2<$3>' + data[key] + ''); 112 | }, str); 113 | } 114 | 115 | function readFile(filePath) { 116 | return new Promise(function(resolve, reject) { 117 | fs.readFile(filePath, 'utf8', function(err, contents) { 118 | err ? reject(err) : resolve(contents); 119 | }); 120 | }); 121 | } 122 | 123 | function writeFile(filePath, contents) { 124 | return new Promise(function(resolve, reject) { 125 | fs.writeFile(filePath, contents, err => err ? reject(err) : resolve()); 126 | }); 127 | } 128 | 129 | function renameFile(from, to) { 130 | return new Promise(function(resolve, reject) { 131 | fs.rename(from, to, function(err) { 132 | err ? reject(err) : resolve(to); 133 | }); 134 | }); 135 | } 136 | 137 | function pack(app) { 138 | var dest = path.resolve(path.dirname(app.dir), 'livestyle-osx.zip'); 139 | debug('packing app into %s', dest); 140 | 141 | return new Promise(function(resolve, reject) { 142 | cmd('ditto', ['-ck', '--sequesterRsrc', '--keepParent', app.dir, dest], err => err ? reject(err) : resolve(dest)); 143 | }); 144 | } 145 | 146 | function autoUpdate(assets) { 147 | // create file with auto-update 148 | debug('create auto-update file for assets'); 149 | 150 | if (!Array.isArray(assets)) { 151 | assets = [assets]; 152 | } 153 | var reAppPackage = /\.zip$/; 154 | let appPackage = assets.reduce((prev, cur) => reAppPackage.test(cur) ? cur : prev, null); 155 | if (!appPackage) { 156 | return Promise.reject(new Error('No app package for OSX bundle, aborting')); 157 | } 158 | 159 | return new Promise((resolve, reject) => { 160 | var contents = JSON.stringify({ 161 | url: `https://github.com/${info.repo}/releases/download/${info.release}/${path.basename(appPackage)}` 162 | }); 163 | 164 | var updateFile = path.join(path.dirname(appPackage), 'osx-auto-update.json'); 165 | fs.writeFile(updateFile, contents, err => { 166 | if (err) { 167 | return reject(err); 168 | } 169 | 170 | assets.push(updateFile); 171 | resolve(assets); 172 | }); 173 | }); 174 | } -------------------------------------------------------------------------------- /lib/model.js: -------------------------------------------------------------------------------- 1 | /** 2 | * A simple Backbone-like model implementation 3 | */ 4 | 'use strict'; 5 | 6 | var EventEmitter = require('events'); 7 | var copy = require('xtend'); 8 | 9 | module.exports = class Model extends EventEmitter { 10 | constructor() { 11 | super(); 12 | this.attributes = {}; 13 | } 14 | 15 | get(key) { 16 | return this.attributes[key]; 17 | } 18 | 19 | set(key, val, options) { 20 | var attr, attrs, unset, changes, silent, changing, prev, current; 21 | if (key == null) { 22 | return this; 23 | } 24 | 25 | // Handle both `"key", value` and `{key: value}` -style arguments. 26 | if (typeof key === 'object') { 27 | attrs = key; 28 | options = val; 29 | } else { 30 | (attrs = {})[key] = val; 31 | } 32 | 33 | options || (options = {}); 34 | 35 | // Extract attributes and options. 36 | unset = options.unset; 37 | silent = options.silent; 38 | changes = []; 39 | changing = this._changing; 40 | this._changing = true; 41 | 42 | if (!changing) { 43 | this._previousAttributes = copy(this.attributes); 44 | this.changed = {}; 45 | } 46 | current = this.attributes; 47 | prev = this._previousAttributes; 48 | 49 | // For each `set` attribute, update or delete the current value. 50 | for (attr in attrs) { 51 | val = attrs[attr]; 52 | if (!isEqual(current[attr], val)) { 53 | changes.push(attr); 54 | } 55 | if (!isEqual(prev[attr], val)) { 56 | this.changed[attr] = val; 57 | } else { 58 | delete this.changed[attr]; 59 | } 60 | unset ? delete current[attr] : current[attr] = val; 61 | } 62 | 63 | // Trigger all relevant attribute changes. 64 | if (!silent) { 65 | if (changes.length) { 66 | this._pending = options; 67 | } 68 | for (var i = 0, l = changes.length; i < l; i++) { 69 | this.emit('change:' + changes[i], this, current[changes[i]], options); 70 | } 71 | } 72 | 73 | // You might be wondering why there's a `while` loop here. Changes can 74 | // be recursively nested within `"change"` events. 75 | if (changing) { 76 | return this; 77 | } 78 | 79 | if (!silent) { 80 | while (this._pending) { 81 | options = this._pending; 82 | this._pending = false; 83 | this.emit('change', this, options); 84 | } 85 | } 86 | 87 | this._pending = false; 88 | this._changing = false; 89 | return this; 90 | } 91 | 92 | // Remove an attribute from the model, firing `"change"`. `unset` is a noop 93 | // if the attribute doesn't exist. 94 | unset(attr, options) { 95 | return this.set(attr, void 0, copy(options, {unset: true})); 96 | } 97 | 98 | // Clear all attributes on the model, firing `"change"`. 99 | clear(options) { 100 | var attrs = {}; 101 | for (var key in this.attributes) { 102 | attrs[key] = void 0; 103 | } 104 | return this.set(attrs, copy(options, {unset: true})); 105 | } 106 | 107 | // Determine if the model has changed since the last `"change"` event. 108 | // If you specify an attribute name, determine if that attribute has changed. 109 | hasChanged(attr) { 110 | if (attr == null) { 111 | return !isEmpty(this.changed); 112 | } 113 | return has(this.changed, attr); 114 | } 115 | 116 | toJSON() { 117 | return copy(this.attributes); 118 | } 119 | 120 | destroy() { 121 | this.off(); 122 | } 123 | }; 124 | 125 | function has(obj, key) { 126 | return obj != null && hasOwnProperty.call(obj, key); 127 | } 128 | 129 | // Perform a deep comparison to check if two objects are equal. 130 | function isEqual(a, b) { 131 | return eq(a, b, [], []); 132 | } 133 | 134 | // Is a given array, string, or object empty? 135 | // An "empty" object has no enumerable own-properties. 136 | function isEmpty(obj) { 137 | if (obj == null) { 138 | return true; 139 | } 140 | if (Array.isArray(obj) || typeof obj === 'string') { 141 | return obj.length === 0; 142 | } 143 | for (var key in obj) if (has(obj, key)) { 144 | return false; 145 | } 146 | 147 | return true; 148 | } 149 | 150 | // Internal recursive comparison function for `isEqual`. 151 | function eq(a, b, aStack, bStack) { 152 | // Identical objects are equal. `0 === -0`, but they aren't identical. 153 | // See the [Harmony `egal` proposal](http://wiki.ecmascript.org/doku.php?id=harmony:egal). 154 | if (a === b) return a !== 0 || 1 / a === 1 / b; 155 | // A strict comparison is necessary because `null == undefined`. 156 | if (a == null || b == null) return a === b; 157 | 158 | // Compare `[[Class]]` names. 159 | var className = toString.call(a); 160 | if (className !== toString.call(b)) return false; 161 | switch (className) { 162 | // Strings, numbers, regular expressions, dates, and booleans are compared by value. 163 | case '[object RegExp]': 164 | // RegExps are coerced to strings for comparison (Note: '' + /a/i === '/a/i') 165 | case '[object String]': 166 | // Primitives and their corresponding object wrappers are equivalent; thus, `"5"` is 167 | // equivalent to `new String("5")`. 168 | return '' + a === '' + b; 169 | case '[object Number]': 170 | // `NaN`s are equivalent, but non-reflexive. 171 | // Object(NaN) is equivalent to NaN 172 | if (+a !== +a) return +b !== +b; 173 | // An `egal` comparison is performed for other numeric values. 174 | return +a === 0 ? 1 / +a === 1 / b : +a === +b; 175 | case '[object Date]': 176 | case '[object Boolean]': 177 | // Coerce dates and booleans to numeric primitive values. Dates are compared by their 178 | // millisecond representations. Note that invalid dates with millisecond representations 179 | // of `NaN` are not equivalent. 180 | return +a === +b; 181 | } 182 | if (typeof a != 'object' || typeof b != 'object') return false; 183 | // Assume equality for cyclic structures. The algorithm for detecting cyclic 184 | // structures is adapted from ES 5.1 section 15.12.3, abstract operation `JO`. 185 | var length = aStack.length; 186 | while (length--) { 187 | // Linear search. Performance is inversely proportional to the number of 188 | // unique nested structures. 189 | if (aStack[length] === a) return bStack[length] === b; 190 | } 191 | // Objects with different constructors are not equivalent, but `Object`s 192 | // from different frames are. 193 | var aCtor = a.constructor, bCtor = b.constructor; 194 | if ( 195 | aCtor !== bCtor && 196 | // Handle Object.create(x) cases 197 | 'constructor' in a && 'constructor' in b && 198 | !(typeof aCtor === 'function' && aCtor instanceof aCtor && 199 | typeof bCtor === 'function' && bCtor instanceof bCtor) 200 | ) { 201 | return false; 202 | } 203 | // Add the first object to the stack of traversed objects. 204 | aStack.push(a); 205 | bStack.push(b); 206 | var size, result; 207 | // Recursively compare objects and arrays. 208 | if (className === '[object Array]') { 209 | // Compare array lengths to determine if a deep comparison is necessary. 210 | size = a.length; 211 | result = size === b.length; 212 | if (result) { 213 | // Deep compare the contents, ignoring non-numeric properties. 214 | while (size--) { 215 | if (!(result = eq(a[size], b[size], aStack, bStack))) break; 216 | } 217 | } 218 | } else { 219 | // Deep compare objects. 220 | var keys = Object.keys(a), key; 221 | size = keys.length; 222 | // Ensure that both objects contain the same number of properties before comparing deep equality. 223 | result = Object.keys(b).length === size; 224 | if (result) { 225 | while (size--) { 226 | // Deep compare each member 227 | key = keys[size]; 228 | if (!(result = has(b, key) && eq(a[key], b[key], aStack, bStack))) break; 229 | } 230 | } 231 | } 232 | // Remove the first object from the stack of traversed objects. 233 | aStack.pop(); 234 | bStack.pop(); 235 | return result; 236 | }; -------------------------------------------------------------------------------- /assets/main.css: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: 'Lato'; 3 | src: url('./lato-regular.woff') format('woff'); 4 | font-weight: normal; 5 | font-style: normal; 6 | } 7 | 8 | @font-face { 9 | font-family: 'Flat-UI-Icons'; 10 | src: url('./flat-ui-icons-regular.woff') format('woff'); 11 | } 12 | 13 | html, body { 14 | padding: 0; 15 | margin: 0; 16 | width: 100%; 17 | height: 100%; 18 | overflow: hidden; 19 | } 20 | 21 | body { 22 | font-family: Lucida Grande, 'Helvetica Neue', arial, sans-serif; 23 | font-size: 14px; 24 | color: #000; 25 | line-height: 1.6; 26 | background-image: linear-gradient(#ddd 0%, #ffffff 60%); 27 | display: flex; 28 | flex-direction: column; 29 | } 30 | 31 | h1, h2 { 32 | font-size: 2em; 33 | line-height: 1.2; 34 | margin-top: 0; 35 | font-weight: normal; 36 | color: #000; 37 | } 38 | 39 | h1 { 40 | text-align: center; 41 | font-size: 18px; 42 | margin: 0; 43 | height: 40px; 44 | line-height: 40px; 45 | background: #F9F9F9; 46 | border-bottom: 1px solid #bbb; 47 | box-shadow: 0 1px 2px rgba(0,0,0,0.16); 48 | } 49 | 50 | a, .pseudo-href { 51 | color: #2980B9; 52 | cursor: pointer; 53 | transition: color .25s linear; 54 | } 55 | 56 | a:hover, .pseudo-href:hover { 57 | color: #3498DB; 58 | } 59 | 60 | p { 61 | margin-top: 0; 62 | } 63 | 64 | i { 65 | font-family: Flat-UI-Icons; 66 | font-style: normal; 67 | } 68 | 69 | button { 70 | padding: 4px 10px 5px; 71 | line-height: 1.4; 72 | font-family: Lucida Grande, 'Helvetica Neue', arial, sans-serif; 73 | color: #20B89A; 74 | cursor: pointer; 75 | background-color: #fff; 76 | border: 1px solid #20B89A; 77 | border-radius: 1.5em; 78 | transition: background-color .25s linear; 79 | transition-property: background-color, color; 80 | outline: none; 81 | } 82 | 83 | button:hover { 84 | background-color: #20B89A; 85 | color: #fff; 86 | } 87 | 88 | .pseudo-href { 89 | border-bottom: 1px dotted currentColor; 90 | } 91 | 92 | .main-pane, 93 | .rv-pane { 94 | box-sizing: border-box; 95 | position: relative; 96 | overflow: auto; 97 | } 98 | 99 | .main-pane { 100 | flex-grow: 0; 101 | flex-shrink: 0; 102 | } 103 | 104 | i.help, 105 | i.quit { 106 | position: absolute; 107 | font-size: 15px; 108 | top: 8px; 109 | right: 10px; 110 | color: #bbb; 111 | z-index: 5; 112 | cursor: pointer; 113 | transition: color 0.3s; 114 | } 115 | 116 | i.quit { 117 | left: 10px; 118 | right: auto; 119 | } 120 | 121 | i.help:hover, 122 | i.quit:hover { 123 | color: #888; 124 | } 125 | 126 | i.help::before { 127 | content: '\e611'; 128 | } 129 | 130 | i.quit::before { 131 | content: '\e609'; 132 | } 133 | 134 | .extension { 135 | display: flex; 136 | justify-content: space-between; 137 | padding: 0; 138 | list-style-type: none; 139 | margin: 0; 140 | font-size: 11px; 141 | } 142 | 143 | .extension-item { 144 | display: block; 145 | width: 50%; 146 | text-align: center; 147 | flex-grow: 1; 148 | padding: 15px 0; 149 | } 150 | 151 | .extension-icon { 152 | display: block; 153 | width: 64px; 154 | height: 64px; 155 | margin: 0 auto 8px; 156 | position: relative; 157 | font-style: normal; 158 | } 159 | 160 | .extension-icon::before { 161 | display: inline-block; 162 | content: ''; 163 | width: 100%; 164 | height: 100%; 165 | background-size: contain; 166 | background-repeat: no-repeat; 167 | } 168 | 169 | .extension-icon::after { 170 | display: inline-block; 171 | position: absolute; 172 | bottom: 0; 173 | right: 0; 174 | font-family: Flat-UI-Icons; 175 | font-size: 18px; 176 | } 177 | 178 | .extension-message { 179 | padding: 5px 0; 180 | line-height: 1.4; 181 | font-size: 0.9em; 182 | color: #999; 183 | display: none; 184 | } 185 | 186 | .extension-progress { 187 | display: none; 188 | padding-top: 5px; 189 | } 190 | 191 | .extension-manual-install { 192 | font-size: 0.9em; 193 | margin-top: 0.4em; 194 | display: none; 195 | } 196 | 197 | [data-extension-id=chrome] .extension-icon::before { 198 | background-image: url(./chrome.png); 199 | } 200 | 201 | [data-extension-id=st] .extension-icon::before { 202 | background-image: url(./sublime-text.png); 203 | } 204 | 205 | /* Extension states */ 206 | [data-extension-state=installed] .extension-icon::before, 207 | [data-extension-state=error] .extension-icon::before { 208 | opacity: 0.2; 209 | } 210 | 211 | [data-extension-state=installed] .extension-icon::after, 212 | [data-extension-state=partially-installed] .extension-icon::after { 213 | content: '\e612'; 214 | color: #00b763; 215 | } 216 | 217 | [data-extension-state=error] .extension-icon::after { 218 | content: '\e613'; 219 | color: #e74c3c; 220 | } 221 | 222 | [data-extension-state=installed] .extension-install-btn, 223 | [data-extension-state=installing] .extension-install-btn, 224 | [data-extension-state=progress] .extension-install-btn { 225 | display: none; 226 | } 227 | 228 | [data-extension-state=installed] .extension-message, 229 | [data-extension-state=error] .extension-message, 230 | [data-extension-state=error] .extension-manual-install, 231 | [data-extension-state=partially-installed] .extension-manual-install { 232 | display: block; 233 | } 234 | 235 | [data-extension-state=progress] .extension-progress { 236 | display: block; 237 | } 238 | 239 | .action-btn { 240 | display: inline-block; 241 | padding: 7px 11px; 242 | font-size: 13px; 243 | line-height: 1.4; 244 | border: none; 245 | border-radius: 4px; 246 | background-color: #e74c3c; 247 | transition: border .25s linear,color .25s linear,background-color .25s linear; 248 | } 249 | 250 | .action-btn:hover { 251 | background-color: #ec7063; 252 | } 253 | 254 | /******************************* 255 | Remote View pane 256 | *******************************/ 257 | 258 | .rv-pane { 259 | background: #fff; 260 | box-shadow: 0px 0px 7px 0px rgba(0,0,0,0.18); 261 | font-size: 12px; 262 | flex-grow: 1; 263 | } 264 | 265 | .rv-pane h2 { 266 | color: #8C8C8C; 267 | text-transform: uppercase; 268 | font-size: 12px; 269 | font-weight: bold; 270 | padding: 6px 12px; 271 | border-bottom: 1px solid #E6E6E6; 272 | margin: 0; 273 | } 274 | 275 | .rv-description { 276 | display: none; 277 | text-align: center; 278 | padding-top: 40px; 279 | color: #ccc; 280 | } 281 | 282 | .rv-description__no-sessions { 283 | margin-bottom: 5px; 284 | font-size: 1.2em; 285 | } 286 | 287 | .rv-description__help { 288 | font-size: 0.9em; 289 | } 290 | 291 | .rv-pane_disabled .rv-description { 292 | display: block; 293 | } 294 | 295 | .rv-pane_disabled .rv-session { 296 | display: none; 297 | } 298 | 299 | .rv-session { 300 | display: block; 301 | list-style-type: none; 302 | padding: 0; 303 | margin: 0; 304 | } 305 | 306 | .rv-session-item { 307 | padding: 8px 12px; 308 | border-bottom: 1px solid #E6E6E6; 309 | } 310 | 311 | .rv-session-item:first-child { 312 | border-top: 0; 313 | } 314 | 315 | .rv-session-item a { 316 | text-decoration: none; 317 | color: inherit; 318 | } 319 | 320 | .rv-session-item a:hover { 321 | text-decoration: underline; 322 | } 323 | 324 | .rv-session-local { 325 | color: #9E9E9E; 326 | font-size: 0.9em; 327 | } 328 | 329 | .rv-session-remove { 330 | position: absolute; 331 | right: 20px; 332 | color: #C0392B; 333 | font-size: 1.3em; 334 | cursor: pointer; 335 | opacity: 0; 336 | transition: opacity 0.25s linear; 337 | -webkit-font-smoothing: antialiased; 338 | margin-top: -1.9em; 339 | } 340 | 341 | .rv-session-remove::before { 342 | content: '\e609'; 343 | } 344 | 345 | .rv-session-item:hover .rv-session-remove { 346 | opacity: 1; 347 | } 348 | 349 | .rv-notice { 350 | font-size: 1.2em; 351 | color: #293d51; 352 | } 353 | 354 | .update-available { 355 | position: absolute; 356 | z-index: 4; 357 | top: 11px; 358 | right: 35px; 359 | padding: 2px 7px 3px; 360 | font-size: 9px; 361 | border: none; 362 | background: #8e44ad; 363 | color: #fff; 364 | box-shadow: inset 1px 1px 4px rgba(0, 0, 0, 0.5); 365 | } 366 | 367 | .update-available:hover { 368 | background: #9c59b6; 369 | } 370 | 371 | .hidden { 372 | display: none; 373 | } 374 | 375 | /* Spinner */ 376 | .spinner { 377 | display: inline-block; 378 | margin-left: 5px; 379 | } 380 | 381 | .spinner__item { 382 | display: inline-block; 383 | width: 4px; 384 | height: 4px; 385 | background: #7c8d8e; 386 | border-radius: 2px; 387 | vertical-align: middle; 388 | animation: spinner 0.7s infinite; 389 | } 390 | 391 | .spinner__item:nth-of-type(2) { 392 | animation-delay: 0.1s; 393 | } 394 | 395 | .spinner__item:nth-of-type(3) { 396 | animation-delay: 0.2s; 397 | } 398 | 399 | @keyframes spinner { 400 | from { 401 | transform: scale(0); 402 | opacity: 1; 403 | } 404 | 405 | to { 406 | transform: scale(2); 407 | opacity: 0; 408 | } 409 | } -------------------------------------------------------------------------------- /test/sublime-text/commit.json: -------------------------------------------------------------------------------- 1 | { 2 | "author": { 3 | "avatar_url": "https://avatars.githubusercontent.com/u/93595?v=3", 4 | "events_url": "https://api.github.com/users/sergeche/events{/privacy}", 5 | "followers_url": "https://api.github.com/users/sergeche/followers", 6 | "following_url": "https://api.github.com/users/sergeche/following{/other_user}", 7 | "gists_url": "https://api.github.com/users/sergeche/gists{/gist_id}", 8 | "gravatar_id": "", 9 | "html_url": "https://github.com/sergeche", 10 | "id": 93595, 11 | "login": "sergeche", 12 | "organizations_url": "https://api.github.com/users/sergeche/orgs", 13 | "received_events_url": "https://api.github.com/users/sergeche/received_events", 14 | "repos_url": "https://api.github.com/users/sergeche/repos", 15 | "site_admin": false, 16 | "starred_url": "https://api.github.com/users/sergeche/starred{/owner}{/repo}", 17 | "subscriptions_url": "https://api.github.com/users/sergeche/subscriptions", 18 | "type": "User", 19 | "url": "https://api.github.com/users/sergeche" 20 | }, 21 | "comments_url": "https://api.github.com/repos/livestyle/sublime-text/commits/58e94a3a3cbfe889450dac92c4450527b40410ff/comments", 22 | "commit": { 23 | "author": { 24 | "date": "2015-11-16T22:21:38Z", 25 | "email": "serge.che@gmail.com", 26 | "name": "Sergey Chikuyonok" 27 | }, 28 | "comment_count": 0, 29 | "committer": { 30 | "date": "2015-11-16T22:21:38Z", 31 | "email": "serge.che@gmail.com", 32 | "name": "Sergey Chikuyonok" 33 | }, 34 | "message": "Fixed regression with ST2 support", 35 | "tree": { 36 | "sha": "4e056fd7d2b5012ef7293262005a12f0806e78a3", 37 | "url": "https://api.github.com/repos/livestyle/sublime-text/git/trees/4e056fd7d2b5012ef7293262005a12f0806e78a3" 38 | }, 39 | "url": "https://api.github.com/repos/livestyle/sublime-text/git/commits/58e94a3a3cbfe889450dac92c4450527b40410ff" 40 | }, 41 | "committer": { 42 | "avatar_url": "https://avatars.githubusercontent.com/u/93595?v=3", 43 | "events_url": "https://api.github.com/users/sergeche/events{/privacy}", 44 | "followers_url": "https://api.github.com/users/sergeche/followers", 45 | "following_url": "https://api.github.com/users/sergeche/following{/other_user}", 46 | "gists_url": "https://api.github.com/users/sergeche/gists{/gist_id}", 47 | "gravatar_id": "", 48 | "html_url": "https://github.com/sergeche", 49 | "id": 93595, 50 | "login": "sergeche", 51 | "organizations_url": "https://api.github.com/users/sergeche/orgs", 52 | "received_events_url": "https://api.github.com/users/sergeche/received_events", 53 | "repos_url": "https://api.github.com/users/sergeche/repos", 54 | "site_admin": false, 55 | "starred_url": "https://api.github.com/users/sergeche/starred{/owner}{/repo}", 56 | "subscriptions_url": "https://api.github.com/users/sergeche/subscriptions", 57 | "type": "User", 58 | "url": "https://api.github.com/users/sergeche" 59 | }, 60 | "files": [ 61 | { 62 | "additions": 4, 63 | "blob_url": "https://github.com/livestyle/sublime-text/blob/58e94a3a3cbfe889450dac92c4450527b40410ff/livestyle-plugin.py", 64 | "changes": 8, 65 | "contents_url": "https://api.github.com/repos/livestyle/sublime-text/contents/livestyle-plugin.py?ref=58e94a3a3cbfe889450dac92c4450527b40410ff", 66 | "deletions": 4, 67 | "filename": "livestyle-plugin.py", 68 | "patch": "@@ -31,7 +31,7 @@\n sublime_ver = int(sublime.version()[0])\n conn_attempts = 0\n max_conn_attempts = 10\n-ls_server_port = 54000\n+ls_server_port = int(editor_utils.get_setting('port') or 54000)\n \n #############################\n # Editor\n@@ -171,11 +171,11 @@ def on_client_close():\n \n @gen.coroutine\n def client_connect():\n-\tport = editor_utils.get_setting('port', ls_server_port)\n+\tport = ls_server_port\n \ttry:\n \t\tyield client.connect(port=port)\n \t\tlogger.info('Editor client connected')\n-\texcept OSError as e:\n+\texcept Exception as e:\n \t\tlogger.info('Client connection error: %s' % e)\n \t\t# In most cases this exception means there's no\n \t\t# LiveStyle server running. Create our own one\n@@ -186,7 +186,7 @@ def create_server(port):\n \t# Due to concurrency, it is possible that LiveStyle server\n \t# is already running when we call this function\n \ttry:\n-\t\tlogger.info('Create own server')\n+\t\tlogger.info('Create own server on port %d' % port)\n \t\tserver.start(port=port)\n \texcept OSError as e:\n \t\tif e.errno != 48:", 69 | "raw_url": "https://github.com/livestyle/sublime-text/raw/58e94a3a3cbfe889450dac92c4450527b40410ff/livestyle-plugin.py", 70 | "sha": "a7adf1db4549a1f37f7e58ef6a30e5b50f46e634", 71 | "status": "modified" 72 | }, 73 | { 74 | "additions": 12, 75 | "blob_url": "https://github.com/livestyle/sublime-text/blob/58e94a3a3cbfe889450dac92c4450527b40410ff/livestyle/client.py", 76 | "changes": 16, 77 | "contents_url": "https://api.github.com/repos/livestyle/sublime-text/contents/livestyle/client.py?ref=58e94a3a3cbfe889450dac92c4450527b40410ff", 78 | "deletions": 4, 79 | "filename": "livestyle/client.py", 80 | "patch": "@@ -23,6 +23,10 @@\n \t'queue': []\n }\n \n+def main_thread(fn):\n+\t\"Run function in main thread\"\n+\treturn lambda *args, **kwargs: sublime.set_timeout(lambda: fn(*args, **kwargs), 1)\n+\n @gen.coroutine\n def connect(host='ws://127.0.0.1', port=54000, endpoint='/livestyle'):\n \t\"Connects to LiveStyle server\"\n@@ -35,7 +39,7 @@ def connect(host='ws://127.0.0.1', port=54000, endpoint='/livestyle'):\n \turl = '%s:%d%s' % (host, port, endpoint)\n \tsock = yield tornado.websocket.websocket_connect(url)\n \n-\tdispatcher.emit('open')\n+\t_emit('open')\n \t_reset_queue()\n \tlogger.debug('Connected to server at %s' % url)\n \n@@ -45,7 +49,7 @@ def connect(host='ws://127.0.0.1', port=54000, endpoint='/livestyle'):\n \t\t\tsock = None\n \t\t\tlogger.debug('Disconnected from server')\n \t\t\t_reset_queue()\n-\t\t\tdispatcher.emit('close')\n+\t\t\t_emit('close')\n \t\t\treturn\n \t\t_handle_message(msg)\n \n@@ -61,7 +65,7 @@ def send(name, data=None):\n def _handle_message(message):\n \tpayload = json.loads(message)\n \tlogger.debug('Received message \"%s\"' % payload['name'])\n-\tdispatcher.emit(payload['name'], payload.get('data'))\n+\t_emit(payload['name'], payload.get('data'))\n \n def on(name, callback=None):\n \tif callback is None: # using as decorator\n@@ -76,6 +80,10 @@ def once(name, callback=None):\n \t\treturn lambda f: dispatcher.once(name, f)\n \tdispatcher.once(name, callback)\n \n+@main_thread\n+def _emit(name, payload=None):\n+\tdispatcher.emit(name, payload)\n+\n # Message queuing\n \n def _next_in_queue():\n@@ -110,4 +118,4 @@ def _on_message_sent(f=None):\n \n def _reset_queue():\n \t_state['locked'] = False\n-\t_state['queue'].clear()\n+\t_state['queue'][:] = [] # instead of .clear(), unsupported in 2.6\n\\ No newline at end of file", 81 | "raw_url": "https://github.com/livestyle/sublime-text/raw/58e94a3a3cbfe889450dac92c4450527b40410ff/livestyle/client.py", 82 | "sha": "f48259ba18ae0a737ed66908b8f2fc086c1e0eb2", 83 | "status": "modified" 84 | }, 85 | { 86 | "additions": 0, 87 | "blob_url": "https://github.com/livestyle/sublime-text/blob/58e94a3a3cbfe889450dac92c4450527b40410ff/tornado.zip", 88 | "changes": 0, 89 | "contents_url": "https://api.github.com/repos/livestyle/sublime-text/contents/tornado.zip?ref=58e94a3a3cbfe889450dac92c4450527b40410ff", 90 | "deletions": 0, 91 | "filename": "tornado.zip", 92 | "raw_url": "https://github.com/livestyle/sublime-text/raw/58e94a3a3cbfe889450dac92c4450527b40410ff/tornado.zip", 93 | "sha": "78acacc0d05ce2efee81e919b2bd26be462cad2e", 94 | "status": "modified" 95 | } 96 | ], 97 | "html_url": "https://github.com/livestyle/sublime-text/commit/58e94a3a3cbfe889450dac92c4450527b40410ff", 98 | "parents": [ 99 | { 100 | "html_url": "https://github.com/livestyle/sublime-text/commit/db25df437293a20e20bfcf37c5a78d6430b9dced", 101 | "sha": "db25df437293a20e20bfcf37c5a78d6430b9dced", 102 | "url": "https://api.github.com/repos/livestyle/sublime-text/commits/db25df437293a20e20bfcf37c5a78d6430b9dced" 103 | } 104 | ], 105 | "sha": "58e94a3a3cbfe889450dac92c4450527b40410ff", 106 | "stats": { 107 | "additions": 16, 108 | "deletions": 8, 109 | "total": 24 110 | }, 111 | "url": "https://api.github.com/repos/livestyle/sublime-text/commits/58e94a3a3cbfe889450dac92c4450527b40410ff" 112 | } -------------------------------------------------------------------------------- /test/sublime-text.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Test detection and installation process of Sublime Text 3 | */ 4 | 'use strict'; 5 | 6 | const fs = require('fs'); 7 | const path = require('path'); 8 | const http = require('http'); 9 | const assert = require('assert'); 10 | const connect = require('connect'); 11 | const serveStatic = require('serve-static'); 12 | const st = require('../lib/sublime-text'); 13 | const status = require('../ui/sublime-text-status'); 14 | 15 | describe('Sublime Text', () => { 16 | let dir = d => path.resolve(__dirname, d); 17 | let read = f => fs.readFileSync(dir(f), 'utf8'); 18 | let readJSON = f => JSON.parse(read(f)); 19 | 20 | describe('detect app', () => { 21 | it('does not exists', done => { 22 | st.detect.app({lookup: dir('sublime-text/dir1/LiveStyle/livestyle.exe')}) 23 | .then(() => done(new Error('Should fail'))) 24 | .catch(err => { 25 | assert.equal(err.code, 'ENOSUBLIMETEXT'); 26 | done(); 27 | }); 28 | }); 29 | 30 | it('exists (single path)', done => { 31 | st.detect.app({lookup: dir('sublime-text/dir2/LiveStyle/livestyle.exe')}) 32 | .then(appPath => { 33 | assert.equal(path.basename(appPath), 'livestyle.exe'); 34 | done(); 35 | }) 36 | .catch(done); 37 | }); 38 | 39 | it('exists (multiple paths)', done => { 40 | st.detect.app({lookup: [ 41 | dir('sublime-text/dir1/LiveStyle/livestyle.exe'), 42 | dir('sublime-text/dir2/LiveStyle/livestyle.exe') 43 | ]}) 44 | .then(appPath => { 45 | assert.equal(path.basename(appPath), 'livestyle.exe'); 46 | assert(appPath.indexOf('dir2') !== -1); 47 | done(); 48 | }) 49 | .catch(done); 50 | }); 51 | }); 52 | 53 | describe('detect plugin', () => { 54 | let extensionId = ['LiveStyle', 'LiveStyle.sublime-package']; 55 | it('app not installed', done => { 56 | st.detect.plugin({ 57 | install: dir('sublime-text/dir1/Packages'), 58 | extensionId 59 | }) 60 | .then(result => { 61 | done(new Error('Should fail')); 62 | }, err => { 63 | // must throw exception which means app does not exists 64 | assert.equal(err.code, 'ENOSUBLIMETEXT'); 65 | done() 66 | }); 67 | }); 68 | 69 | it('exists (unpacked)', done => { 70 | st.detect.plugin({ 71 | install: dir('sublime-text/dir2/Packages'), 72 | extensionId 73 | }) 74 | .then(result => { 75 | assert(result); 76 | assert.equal(path.basename(result), 'LiveStyle'); 77 | done(); 78 | }) 79 | .catch(done); 80 | }); 81 | 82 | it('exists (packed)', done => { 83 | st.detect.plugin({ 84 | install: dir('sublime-text/dir3/Packages'), 85 | extensionId 86 | }) 87 | .then(result => { 88 | assert(result); 89 | assert.equal(path.basename(result), 'LiveStyle.sublime-package'); 90 | done(); 91 | }) 92 | .catch(done); 93 | }); 94 | 95 | it('not exists', done => { 96 | st.detect.plugin({ 97 | install: dir('sublime-text/dir4/Packages'), 98 | extensionId 99 | }) 100 | .then(result => { 101 | assert.equal(result, false); 102 | done(); 103 | }) 104 | .catch(done); 105 | }); 106 | }); 107 | 108 | describe('install', () => { 109 | const port = 8888; 110 | const host = `http://localhost:${port}`; 111 | var server; 112 | before(done => { 113 | let app = connect().use(serveStatic(dir('sublime-text'))); 114 | server = http.createServer(app); 115 | server.listen(port, done); 116 | }); 117 | 118 | after(done => server.close(done)); 119 | 120 | it('with auto-update', done => { 121 | let app = { 122 | downloadUrl: `${host}/plugin.zip`, 123 | commitUrl: `${host}/commit.json`, 124 | install: dir('sublime-text/out/install-auto-update') 125 | }; 126 | let out = p => path.resolve(app.install, p); 127 | 128 | st.install(app) 129 | .then(result => { 130 | assert(result); 131 | assert.equal(path.basename(result), 'LiveStyle'); 132 | assert(read(out('Livestyle/livestyle-plugin.py'))); 133 | assert.equal(readJSON(out('Livestyle/autoupdate.json')).sha, readJSON('sublime-text/commit.json').sha); 134 | done(); 135 | }) 136 | .catch(done); 137 | }); 138 | 139 | it('without auto-update', done => { 140 | let app = { 141 | downloadUrl: `${host}/plugin.zip`, 142 | install: dir('sublime-text/out/install-auto-update') 143 | }; 144 | let out = p => path.resolve(app.install, p); 145 | 146 | st.install(app) 147 | .then(result => { 148 | assert(result); 149 | assert.equal(path.basename(result), 'LiveStyle'); 150 | assert(read(out('Livestyle/livestyle-plugin.py'))); 151 | assert.throws(() => read(out('Livestyle/autoupdate.json')), /ENOENT/); 152 | done(); 153 | }) 154 | .catch(done); 155 | }); 156 | }); 157 | 158 | describe('update', () => { 159 | const port = 8888; 160 | const host = `http://localhost:${port}`; 161 | var server; 162 | before(done => { 163 | let app = connect().use(serveStatic(dir('sublime-text'))); 164 | server = http.createServer(app); 165 | server.listen(port, done); 166 | }); 167 | 168 | after(done => server.close(done)); 169 | 170 | it('should trigger', done => { 171 | let app = { 172 | downloadUrl: `${host}/plugin.zip`, 173 | commitUrl: `${host}/commit.json`, 174 | install: dir('sublime-text/out/install-auto-update') 175 | }; 176 | let out = p => path.resolve(app.install, p); 177 | 178 | st.install(app) 179 | .then(() => { 180 | let updater = st.autoupdate(app); 181 | let eventEmitted = false; 182 | return updater.check() 183 | .then(result => { 184 | // sha’s are equal, update file and try again 185 | assert.equal(result, false); 186 | updater.on('shouldUpdate', () => eventEmitted = true); 187 | fs.writeFileSync(out('Livestyle/autoupdate.json'), '{"sha":"foo"}'); 188 | return updater.check(); 189 | }) 190 | .then(result => { 191 | assert.equal(result, true); 192 | assert.equal(eventEmitted, true); 193 | done(); 194 | }); 195 | }) 196 | .catch(done); 197 | }); 198 | 199 | it('should not trigger', done => { 200 | let app = { 201 | downloadUrl: `${host}/plugin.zip`, 202 | install: dir('sublime-text/out/install-auto-update') 203 | }; 204 | let out = p => path.resolve(app.install, p); 205 | 206 | st.install(app) 207 | .then(() => st.autoupdate(app).check()) 208 | .then(result => done(new Error('Should fail'))) 209 | .catch(err => done()); 210 | }); 211 | 212 | it('periodic check', function(done) { 213 | let app = { 214 | downloadUrl: `${host}/plugin.zip`, 215 | commitUrl: `${host}/commit.json`, 216 | install: dir('sublime-text/out/install-auto-update') 217 | }; 218 | let out = p => path.resolve(app.install, p); 219 | let checks = 0; 220 | this.timeout(6000); 221 | 222 | st.install(app) 223 | .then(() => { 224 | // update autoupdate file after some time to trigger update 225 | setTimeout(() => { 226 | fs.writeFileSync(out('Livestyle/autoupdate.json'), '{"sha":"foo"}'); 227 | }, 5000); 228 | 229 | return st.autoupdate(app) 230 | .on('checkForUpdate', () => checks++) 231 | .on('shouldUpdate', function() { 232 | this.stop(); 233 | assert(checks > 3 && checks < 10); 234 | done(); 235 | }) 236 | .start(1); 237 | }) 238 | .catch(done); 239 | }); 240 | }); 241 | 242 | describe('UI status', () => { 243 | let noApp = () => ({error: 'No app installed', errorCode: 'ENOSUBLIMETEXT'}); 244 | let check = (st2, st3) => status({st2, st3}); 245 | 246 | it('no ST2, no ST3', () => { 247 | let s = check(noApp(), noApp()); 248 | assert.equal(s.state, 'error'); 249 | assert.equal(s.value.errorCode, 'ENOSUBLIMETEXT'); 250 | }); 251 | 252 | it('ST2, no ST3', () => { 253 | let s = check('not-installed', noApp()); 254 | assert.equal(s.state, 'not-installed'); 255 | assert.deepEqual(s.missing, ['st2']); 256 | }); 257 | 258 | it('no ST2, ST3', () => { 259 | let s = check(noApp(), 'installed'); 260 | assert.equal(s.state, 'installed'); 261 | assert.equal(s.missing, undefined); 262 | }); 263 | 264 | it('ST2 not installed, ST3 not installed', () => { 265 | let s = check('not-installed', 'not-installed'); 266 | assert.equal(s.state, 'not-installed'); 267 | assert.deepEqual(s.missing, ['st2', 'st3']); 268 | }); 269 | 270 | it('ST2 installed, ST3 not installed', () => { 271 | let s = check('installed', 'not-installed'); 272 | assert.equal(s.state, 'partially-installed'); 273 | assert.deepEqual(s.missing, ['st3']); 274 | }); 275 | 276 | it('ST2 not installed, ST3 installed', () => { 277 | let s = check('not-installed', 'installed'); 278 | assert.equal(s.state, 'partially-installed'); 279 | assert.deepEqual(s.missing, ['st2']); 280 | }); 281 | 282 | it('ST2 installed, ST3 installed', () => { 283 | let s = check('installed', 'installed'); 284 | assert.equal(s.state, 'installed'); 285 | assert.deepEqual(s.missing, undefined); 286 | }); 287 | 288 | it('ST2 installed, ST3 progress', () => { 289 | let s = check('installed', 'progress'); 290 | assert.equal(s.state, 'progress'); 291 | assert.deepEqual(s.missing, undefined); 292 | }); 293 | 294 | it('ST2 progress, ST3 installed', () => { 295 | let s = check('progress', 'installed'); 296 | assert.equal(s.state, 'progress'); 297 | assert.deepEqual(s.missing, undefined); 298 | }); 299 | 300 | it('ST2 progress, ST3 not-installed', () => { 301 | let s = check('progress', 'not-installed'); 302 | assert.equal(s.state, 'partially-installed'); 303 | assert.deepEqual(s.missing, ['st3']); 304 | }); 305 | }); 306 | }); --------------------------------------------------------------------------------