├── 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 |
31 |
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 |
51 |
52 | -
53 |
56 |
59 |
60 |
61 |
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+)>.*?\\3>');
111 | return str.replace(re, '$1$2<$3>' + data[key] + '$3>');
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 | });
--------------------------------------------------------------------------------