first commit

This commit is contained in:
编码猿
2024-09-27 01:01:17 +08:00
commit 14b8ae24fa
198 changed files with 22193 additions and 0 deletions

20
uebersicht-code/server/.babelrc Executable file
View File

@@ -0,0 +1,20 @@
{
"presets": [
"@babel/preset-react",
[
"@babel/preset-env",
{
"targets": [
"Explorer 11",
"last 5 Safari versions",
],
"useBuiltIns": "usage",
"corejs": "2",
"modules": "commonjs"
}
]
],
"plugins": [
"@babel/plugin-proposal-object-rest-spread"
]
}

View File

@@ -0,0 +1 @@
160a04a8-07e3-427c-9d42-0ed982a3189e

View File

@@ -0,0 +1,90 @@
redux = require 'redux'
window.$ = require 'jquery'
reducer = require './src/reducer'
listenToRemote = require './src/listen'
sharedSocket = require './src/SharedSocket'
render = require './src/render'
actions = require './src/actions'
userCssLink = null
detectWidgetHover = require './src/detectWidgetHover'
window.onload = ->
sharedSocket.open("ws://#{window.location.host}")
path = window.location.pathname.split('/')
screen =
id: Number(path[1])
layer: path[2]
contentEl = document.getElementById('uebersicht')
contentEl.innerHTML = ''
userCssLink = Array.from(document.querySelectorAll('link'))
.find((el) => el.href.match('userMain.css'))
detectWidgetHover(contentEl);
getState (err, initialState) ->
bail err, 10000 if err?
store = redux.createStore(reducer, initialState)
Object.keys(initialState.widgets).forEach (id) ->
fetchWidget(id)
.then (widgetImpl) -> store.dispatch(actions.showWidget(id, widgetImpl))
prevState = null
store.subscribe ->
nextState = store.getState()
return if nextState == prevState
render(store.getState(), screen, contentEl, store.dispatch)
prevState = nextState
listenToRemote (action) ->
if action.type == 'WIDGET_WANTS_REFRESH'
render.rendered[action.payload]?.instance?.forceRefresh()
else if action.type == 'WIDGET_ADDED'
store.dispatch(action)
return if action.payload.error
fetchWidget(action.payload.id)
.then (widgetImpl) ->
store.dispatch(actions.showWidget(action.payload.id, widgetImpl))
else if action.type == 'MASTER_STYLE_CHANGED'
reloadUserCSS()
else
store.dispatch(action)
render(initialState, screen, contentEl, store.dispatch)
# legacy
window.uebersicht =
makeBgSlice: (canvas) ->
console.warn 'makeBgSlice has been deprecated. Please use CSS \
backdrop-filter instead: \
https://developer.mozilla.org/en-US/docs/Web/CSS/backdrop-filter'
window.addEventListener 'contextmenu', (e) ->
e.preventDefault()
getState = (callback) ->
$.get("/state/")
.done((response) -> callback null, JSON.parse(response))
.fail -> callback response, null
fetchWidget = (id) -> new Promise (resolve, reject) ->
scriptTag = document.createElement('SCRIPT')
scriptTag.id = id
scriptTag.src = '/widgets/' + id
scriptTag.onload = ->
document.head.removeChild(scriptTag)
resolve(require(id))
scriptTag.onerror = (err) ->
document.head.removeChild(scriptTag)
reject(err)
document.head.appendChild(scriptTag)
reloadUserCSS = ->
href = userCssLink.href.split('?')[0]
userCssLink.href = "#{href}?#{new Date().getTime()}"
bail = (err, timeout = 0) ->
console.log err if err?
setTimeout ->
window.location.reload(true)
, timeout

5648
uebersicht-code/server/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,63 @@
{
"name": "uebersicht-server",
"version": "0.0.0",
"description": "Node.js backend for Übersicht",
"main": "server.coffee",
"scripts": {
"test": "npm run-script test-local && npm run-script test-browser",
"test-local": "tape -r coffee-script/register spec/backend/**/* | tap-spec",
"test-browser": "browserify -t coffeeify spec/frontend/*.* | tape-run | tap-spec",
"start": "coffee server.coffee",
"release": "npm run-script build-client && npm run-script build-server",
"build-client": "browserify -i ws -t coffeeify -t babelify -r ./src/uebersicht.js:uebersicht client.coffee | uglifyjs -c > release/public/client.js",
"build-server": "browserify -t coffeeify --node --detect-globals false --no-bundle-external server.coffee > release/server.js && cd release && npm prune --production && npm install --production --no-progress"
},
"author": "Felix Hageloh",
"license": "GPL v3 <http://www.gnu.org/licenses/>",
"private": true,
"devDependencies": {
"coffee-script": "^1.12.7",
"sinon": "^4.0.1",
"tap-spec": "^5.0.0",
"tape": "^4.13.3",
"tape-run": "^6.0.1",
"uglify-js": "^3.10.4"
},
"dependencies": {
"@babel/core": "^7.11.6",
"@babel/plugin-proposal-object-rest-spread": "^7.11.0",
"@babel/preset-env": "^7.11.5",
"@babel/preset-react": "^7.10.4",
"@emotion/core": "^10.0.35",
"@emotion/styled": "^10.0.27",
"babel-plugin-emotion": "^10.0.33",
"babelify": "^10.0.0",
"browserify": "^16.5.2",
"byline": "^5.0.0",
"coffeeify": "^2.1.0",
"connect": "^3.6.5",
"convert-source-map": "^1.7.0",
"core-js": "^2.6.12",
"cors-anywhere": "^0.4.3",
"emotion": "^10.0.27",
"escodegen": "^1.14.3",
"esprima": "^2.7.3",
"fsevents": "^2.1.3",
"jquery": "^3.5.1",
"minimist": "^1.2.5",
"ms": "^2.0.0",
"nib": "~1.1.2",
"raf": "^3.4.0",
"react": "^16.13.1",
"react-dom": "^16.13.1",
"redux": "^3.7.2",
"serve-static": "^1.13.1",
"source-map": "^0.7.3",
"stylus": "^0.54.8",
"superagent": "^3.8.3",
"through2": "^2.0.3",
"tmp": "0.0.33",
"tosource": "~1.0.0",
"ws": "^6.0.0"
}
}

View File

@@ -0,0 +1 @@
77b65ec0-9cd4-4c2e-b745-deb1f510bfc0

View File

@@ -0,0 +1,16 @@
<!DOCTYPE html>
<html>
<head>
<title>Übersicht</title>
<base href="/" />
<meta name="referrer" content="no-referrer" />
<meta http-equiv="Content-type" content="text/html; charset=utf-8" />
<link rel="stylesheet" type="text/css" href="main.css" />
<link rel="stylesheet" type="text/css" href="userMain.css" />
<script type="text/javascript" src="client.js"></script>
</head>
<body>
<div id="uebersicht"></div>
</body>
</html>

View File

@@ -0,0 +1 @@
b286686a-a486-45de-9c06-d1ceccfec10d

Binary file not shown.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1 @@
../package.json

View File

@@ -0,0 +1,20 @@
{
"presets": [
"@babel/preset-react",
[
"@babel/preset-env",
{
"targets": [
"Explorer 11",
"last 5 Safari versions",
],
"useBuiltIns": "usage",
"corejs": "2",
"modules": "commonjs"
}
]
],
"plugins": [
"@babel/plugin-proposal-object-rest-spread"
]
}

View File

@@ -0,0 +1 @@
f43bff2f-1229-4bc5-99fd-61244f6aaaf6

View File

@@ -0,0 +1,16 @@
<!DOCTYPE html>
<html>
<head>
<title>Übersicht</title>
<base href="/" />
<meta name="referrer" content="no-referrer" />
<meta http-equiv="Content-type" content="text/html; charset=utf-8" />
<link rel="stylesheet" type="text/css" href="main.css" />
<link rel="stylesheet" type="text/css" href="userMain.css" />
<script type="text/javascript" src="client.js"></script>
</head>
<body>
<div id="uebersicht"></div>
</body>
</html>

View File

@@ -0,0 +1,22 @@
body,
html {
background: transparent;
padding: 0;
margin: 0;
height: 100%;
width: 100%;
overflow: hidden;
}
#uebersicht {
position: absolute;
height: 100%;
width: 100%;
padding: 0;
margin: 0;
overflow: hidden;
}
.widget {
position: absolute;
}

View File

@@ -0,0 +1,41 @@
parseArgs = require 'minimist'
UebersichtServer = require './src/app.coffee'
cors_proxy = require 'cors-anywhere'
path = require 'path'
handleError = (err) ->
console.log(err.message || err)
throw err
try
args = parseArgs process.argv.slice(2)
widgetPath = path.resolve(__dirname, args.d ? args.dir ? './widgets')
port = args.p ? args.port ? 41416
settingsPath = path.resolve(__dirname, args.s ? args.settings ? './settings')
publicPath = path.resolve(__dirname, './public')
options =
loginShell: args['login-shell']
server = UebersichtServer(
Number(port),
widgetPath,
settingsPath,
publicPath,
options,
-> console.log 'server started on port', port
)
server.on 'close', handleError
server.on 'error', handleError
cors_host = '127.0.0.1'
cors_port = port + 1
cors_proxy.createServer(
originWhitelist: ['http://127.0.0.1:' + port]
requireHeader: ['origin']
removeHeaders: ['cookie']
).listen(cors_port, cors_host, ->
console.log 'CORS Anywhere on port', cors_port
)
catch e
handleError e

View File

@@ -0,0 +1 @@
8fa7f9cc-9151-42bf-bd0d-b2dd84d7c529

View File

@@ -0,0 +1 @@
{"top-jsx":{"showOnAllScreens":true,"showOnMainScreen":false,"showOnSelectedScreens":false,"hidden":false,"screens":[]},"index-coffee":{"showOnAllScreens":true,"showOnMainScreen":false,"showOnSelectedScreens":false,"hidden":false,"screens":[]},"top-cpu-coffee":{"showOnAllScreens":true,"showOnMainScreen":false,"showOnSelectedScreens":false,"hidden":false,"screens":[]},"circle-ci-coffee":{"showOnAllScreens":true,"showOnMainScreen":false,"showOnSelectedScreens":false,"hidden":false,"screens":[]},"top-mem-widget-index-coffee":{"showOnAllScreens":true,"showOnMainScreen":false,"showOnSelectedScreens":false,"hidden":false,"screens":[]},"Playbox-widget-index-coffee":{"showOnAllScreens":true,"showOnMainScreen":false,"showOnSelectedScreens":false,"hidden":false,"screens":[]},"simple-clock-widget-index-coffee":{"showOnAllScreens":true,"showOnMainScreen":false,"showOnSelectedScreens":false,"hidden":false,"screens":[]},"Eva-widget-index-coffee":{"showOnAllScreens":true,"showOnMainScreen":false,"showOnSelectedScreens":false,"hidden":false,"screens":[]},"GettingStarted-jsx":{"showOnAllScreens":true,"showOnMainScreen":false,"showOnSelectedScreens":false,"hidden":false,"screens":[]}}

View File

@@ -0,0 +1 @@
53020e3e-873e-430a-87dd-1ec10c273ad9

View File

@@ -0,0 +1 @@
4eca1fdb-0cf9-45cc-8990-38c4f95d5c6a

View File

@@ -0,0 +1,20 @@
var test = require('tape');
var WebSocket = require('ws');
var server = new WebSocket.Server({ port: 8890 });
var sharedSocket = require('../../src/SharedSocket');
var url = 'ws://localhost:8890';
test('subscribing listeners', (t) => {
sharedSocket.onMessage((message) => {
t.equal(message, 'yay');
sharedSocket.close();
server.close(() => t.end());
});
sharedSocket.open(url);
server.on('connection', (ws) => {
ws.send('yay');
});
});

View File

@@ -0,0 +1,82 @@
const test = require('tape');
const path = require('path');
const fs = require('fs');
const WidgetBundler = require('../../src/WidgetBundler.js');
const fixturePath = path.resolve(__dirname, '../test_widgets');
const bundler = WidgetBundler();
var callback = () => {};
test('bundling widgets', (t) => {
const action = {
type: 'added',
filePath: path.join(fixturePath, 'widget-1.coffee'),
id: 'widget-1',
};
callback = (event) => {
t.equal(event.type, 'added', 'it emits an "added" event');
t.equal(typeof event.widget, 'object', 'it emits a widget object');
t.equal(event.widget.id, 'widget-1', 'the widget object has an id');
t.equal(
event.widget.filePath,
action.filePath,
'the widget object contains the original file path',
);
t.equal(
typeof event.widget.body,
'string',
'it also contains a string with the widget source code',
);
const widget = eval(event.widget.body)('widget-1');
t.equal(
widget.command,
'foo',
'the source evals to a require function which returns the widget by id',
);
callback = () => {};
t.end();
};
bundler.push(action, (event) => callback(event));
});
test('watching widgets', (t) => {
callback = (event) => {
t.equal(event.type, 'added', 'it emits another "added" event');
t.equal(event.widget.id, 'widget-1', 'for the correct widget');
t.equal(typeof event.widget.body, 'string', 'with the widget source code');
t.end();
};
fs.utimes(
path.join(fixturePath, 'widget-1.coffee'),
Date.now(),
Date.now(),
() => {},
);
});
test('removing widgets', (t) => {
const action = {
type: 'removed',
filePath: path.join(fixturePath, 'widget-1.coffee'),
id: 'widget-1',
};
callback = (event) => {
t.equal(event.type, 'removed', 'it emits a "removed" event');
t.equal(event.id, 'widget-1', 'for the correct widget');
callback = () => {};
t.end();
};
bundler.push(action, (event) => callback(event));
});
test('closing', (t) => {
bundler.close();
t.pass('it closes');
t.end();
});

View File

@@ -0,0 +1,78 @@
const test = require('tape');
const path = require('path');
const bundleWidget = require('../../src/bundleWidget');
const testDir = path.resolve(__dirname, path.join('..', 'test_widgets'));
test('bundling coffeescript widgets', (t) => {
const widgetPath = path.join(testDir, 'widget-1.coffee');
const bundle = bundleWidget('widget-id', widgetPath);
t.plan(2);
t.ok(
bundle.constructor.name === 'Browserify',
'it returns a browserify bundle',
);
bundle.bundle((err, src) => {
t.ok(
!err && src && src.indexOf('command') > -1,
'the source code it generates looks ok',
);
bundle.close();
});
});
test('bundling javascript widgets', (t) => {
const widgetPath = path.join(testDir, 'widget-2.js');
const bundle = bundleWidget('other-widget-id', widgetPath);
t.plan(2);
t.ok(
bundle.constructor.name === 'Browserify',
'it returns a browserify bundle',
);
bundle.bundle((err, src) => {
t.ok(
!err && src && src.indexOf('command') > -1,
'the source code looks ok',
);
bundle.close();
});
});
test('bundling jsx widgets', (t) => {
const widgetPath = path.join(testDir, 'widget-3.jsx');
const bundle = bundleWidget('widget-3', widgetPath);
t.plan(2);
t.ok(
bundle.constructor.name === 'Browserify',
'it returns a browserify bundle',
);
bundle.bundle((err, src) => {
t.ok(
!err && src && src.indexOf('command') > -1,
'the source code looks ok',
);
bundle.close();
});
});
test('bundling widgets with syntax errors', (t) => {
const widgetPath = path.join(testDir, 'broken-widget.coffee');
const bundle = bundleWidget('broken', widgetPath);
t.plan(2);
t.ok(
bundle.constructor.name === 'Browserify',
'it returns a browserify bundle',
);
bundle.bundle((err, src) => {
t.equal(
err && err.message,
'unexpected indentation while parsing file: ' + widgetPath,
'it spits out an error when bundling',
);
bundle.close();
});
});

View File

@@ -0,0 +1,94 @@
var test = require('tape');
var connect = require('connect');
var path = require('path');
var httpGet = require('../helpers/httpGet');
var httpPost = require('../helpers/httpPost');
var commandServer = require('../../src/command_server.coffee');
var workingDir = path.resolve(__dirname, path.join('..', 'test_widgets'));
var server = connect().use(commandServer(workingDir)).listen(8887);
var url = 'http://localhost:8887/run/';
test('responding to POST /run/', (t) => {
t.plan(3);
httpPost(url, 'echo', (res) => {
t.equal(res.statusCode, 200, 'it reponds');
});
httpPost('http://localhost:8887/foo/', 'echo', (res) => {
t.equal(res.statusCode, 404, 'it ignores requests to other paths');
});
httpGet(url, (res) => {
t.equal(res.statusCode, 404, 'it ignores GET requests');
});
});
test('running commands', (t) => {
t.plan(2);
httpPost(url, 'echo "yay"', (res, body) => {
t.equal(body, 'yay\n', 'it runs commands');
});
httpPost(url, 'pwd', (res, body) => {
t.equal(
body,
workingDir + '\n',
'it runs commands in the supplied working dir',
);
});
});
test('shell type', (t) => {
httpPost(url, 'echo $(shopt | grep login_shell)', (res, body) => {
t.equal(body, 'login_shell off\n', 'it is not a login shell');
t.end();
});
});
test('running broken commands', (t) => {
t.plan(2);
httpPost(url, 'fake-command', (res, body) => {
t.equal(res.statusCode, 500, 'it responds with a 500 code');
t.equal(
body,
'bash: line 1: fake-command: command not found\n',
'it responds with an error message',
);
});
});
test('forwarding stderr', (t) => {
t.plan(2);
httpPost(url, 'echo "yay" >&2', (res, body) => {
t.equal(res.statusCode, 500, 'it responds with a 500 code');
t.equal(body, 'yay\n', 'it sends stderr along');
});
});
test('closing', (t) => {
server.close();
t.pass('it closes');
t.end();
});
test('using a login shell', (t) => {
server = connect().use(commandServer(workingDir, true)).listen(8887);
httpPost(url, 'echo $(shopt | grep login_shell)', (res, body) => {
const lines = body.trim().split('\n');
t.equal(
lines[lines.length - 1],
'login_shell on',
'it indeed runs in a login shell',
);
server.close();
t.end();
});
});

View File

@@ -0,0 +1,116 @@
var test = require('tape');
var path = require('path');
var fs = require('fs');
var execSync = require('child_process').execSync;
var DirWatcher = require('../../src/directory_watcher.coffee');
var fixturePath = path.resolve(__dirname, '../test_widgets');
var newWidgetPath = path.join(fixturePath, 'new-widget.coffee');
var stopWatching;
var callback;
const throwError = (err) => {
if (err) throw err;
};
test('files that are already present in the widget dir', (t) => {
t.timeoutAfter(300);
var expectedWidgets = [
path.join(fixturePath, 'widget-1.coffee'),
path.join(fixturePath, 'widget-2.js'),
path.join(fixturePath, 'some-dir.widget', 'index-1.coffee'),
path.join(fixturePath, 'broken-widget.coffee'),
path.join(fixturePath, 'invalid-widget.coffee'),
];
callback = (event) => {
if (event.type !== 'added') {
return;
}
var idx = expectedWidgets.indexOf(event.filePath);
if (idx > -1) {
expectedWidgets.splice(idx, 1);
}
if (expectedWidgets.length === 0) {
callback = () => {};
t.pass('it emits an event for all widgets already in the folder');
t.end();
}
};
stopWatching = DirWatcher(fixturePath, (event) => callback(event));
});
test('adding files', (t) => {
t.timeoutAfter(300);
callback = (event) => {
if (event.type === 'added' && event.filePath === newWidgetPath) {
callback = () => {};
t.pass('it emits an event for new files');
t.equal(event.rootPath, fixturePath, 'the event includes the root path');
t.end();
}
};
fs.writeFile(newWidgetPath, "command: ''", throwError);
});
test('removing files', (t) => {
t.timeoutAfter(300);
callback = (event) => {
if (event.type === 'removed' && event.filePath === newWidgetPath) {
callback = () => {};
t.pass('it emits a removed event when a widget file is removed');
t.equal(event.rootPath, fixturePath, 'the event includes the root path');
t.end();
}
};
fs.unlink(newWidgetPath, throwError);
});
test('adding folders', (t) => {
t.timeoutAfter(300);
var aWidgetFolder = path.resolve(__dirname, '../tmp2');
if (fs.existsSync(aWidgetFolder)) {
execSync('rm -rf ' + aWidgetFolder);
}
fs.mkdirSync(aWidgetFolder);
fs.writeFileSync(path.join(aWidgetFolder, 'widget.js'), "command: 'yay'");
var expectedPath = path.join(fixturePath, 'another', 'widget.js');
callback = (event) => {
if (event.type === 'added' && event.filePath === expectedPath) {
callback = () => {};
t.pass('it emits an event when a subfolder containing a widget is added');
t.end();
}
};
fs.rename(aWidgetFolder, path.join(fixturePath, 'another'), throwError);
});
test('removing folders', (t) => {
t.timeoutAfter(300);
var expectedPath = path.join(fixturePath, 'another', 'widget.js');
callback = (event) => {
if (event.type === 'removed' && event.filePath === expectedPath) {
callback = () => {};
t.pass(
'it emits a removed event when a subfolder containing a ' +
'widget is removed',
);
t.end();
}
};
var newPath = path.resolve(__dirname, '../tmp3');
fs.renameSync(path.join(fixturePath, 'another'), newPath);
execSync('rm -rf ' + newPath);
});
test('stopping', (t) => {
stopWatching();
t.pass('it can be stopped');
t.end();
});

View File

@@ -0,0 +1,33 @@
var test = require('tape');
var WebSocket = require('ws');
var server = new WebSocket.Server({ port: 8888 });
var url = 'ws://localhost:8888';
var sharedSocket = require('../../src/SharedSocket');
var dispatch = require('../../src/dispatch');
test('queuing up messages', (t) => {
expectedMessages = ['a', 'b'];
server.on('connection', (ws) => {
ws.on('message', (message) => {
parsed = JSON.parse(message);
var idx = expectedMessages.indexOf(parsed);
if (idx > -1) {
expectedMessages.splice(idx, 1);
}
if (expectedMessages.length === 0) {
t.pass('it queues up messages and sends them once the socket opens');
server.close(() => t.end());
}
});
});
dispatch('a');
dispatch('b');
sharedSocket.open(url);
});

View File

@@ -0,0 +1,26 @@
var test = require('tape');
var WebSocket = require('ws');
var server = new WebSocket.Server({ port: 8889 });
var sharedSocket = require('../../src/SharedSocket');
var listen = require('../../src/listen');
test('listen', (t) => {
sharedSocket.open('ws://localhost:8889');
listen((message) => {
t.looseEqual(
message,
{ type: 'YASS', payload: 'yay' },
'it calls listeners with deserialized messages'
);
server.close(() => t.end());
});
server.on('connection', (ws) => {
ws.send(JSON.stringify({
type: 'YASS',
payload: 'yay',
}));
});
});

View File

@@ -0,0 +1,115 @@
var test = require('tape');
var reduce = require('../../src/reducer');
test('WIDGET_ADDED', (t) => {
var action = {
type: 'WIDGET_ADDED',
payload: { id: 'foo', error: 'oh no', filePath: '/foo/' },
};
var newState = reduce({ widgets: {} }, action);
t.looseEqual(
newState.widgets,
{ foo: { id: 'foo', error: 'oh no', filePath: '/foo/' } },
'it adds new widgets'
);
t.ok(
typeof newState.settings === 'object',
'it creates a new settings hash if none exists'
);
t.looseEqual(
newState.settings.foo, {
showOnAllScreens: true,
showOnMainScreen: false,
showOnSelectedScreens: false,
hidden: false,
screens: [],
},
'it initializes settings for a widget'
);
action = {
type: 'WIDGET_ADDED',
payload: { id: 'foo', body: 'yay', filePath: '/foo/' },
};
newState = reduce(newState, action);
t.looseEqual(
newState.widgets,
{ foo: { id: 'foo', body: 'yay', filePath: '/foo/' } },
'it updates existing widgets'
);
t.end();
});
test('WIDGET_REMOVED', (t) => {
var action = { type: 'WIDGET_REMOVED', payload: 'foo' };
var state = { widgets: {} };
var newState = reduce(state, action);
t.equal(state, newState, 'it ignores non existing widgets');
newState = reduce({
widgets: { foo: {}, bar: {}},
}, action);
t.looseEqual(newState.widgets, {bar: {}}, 'it removes existing widgets');
t.end();
});
test('WIDGET_SETTINGS_CHANGED', (t) => {
var action = {
type: 'WIDGET_SETTINGS_CHANGED',
payload: { id: 'foo', settings: { a: 'b' } },
};
newState = reduce({ settings: {} }, action);
t.looseEqual(
newState.settings,
{ foo: { a: 'b' } },
'it applies new settings'
);
newState = reduce({ settings: { bar: {} } }, action);
t.looseEqual(
newState.settings,
{ foo: { a: 'b' }, bar: {}},
'it merges with existing settings'
);
t.end();
});
test('WIDGET_SET_TO_HIDE / SHOW', (t) => {
var action = { type: 'WIDGET_SET_TO_HIDE', payload: 'bar' };
var state = {
settings: {
foo: { hidden: false, some: 'other', stuff: 1 },
bar: { hidden: false, many: 'other', things: 42 },
},
};
var newState = reduce(state, action);
t.looseEqual(
state.settings,
{
foo: { hidden: false, some: 'other', stuff: 1 },
bar: { hidden: false, many: 'other', things: 42 },
},
'it hides widgets'
);
action = { type: 'WIDGET_SET_TO_SHOW', payload: 'bar' };
newState = reduce(newState, action);
t.looseEqual(
state.settings,
{
foo: { hidden: false, some: 'other', stuff: 1 },
bar: { hidden: false, many: 'other', things: 42 },
},
'it shows widgets'
);
t.end();
});

View File

@@ -0,0 +1,102 @@
const test = require('tape');
const resolveWidget = require('../../src/resolveWidget.js');
test('resolving widget actions from file events', (t) => {
const action = resolveWidget({
type: 'added',
filePath: '/widget/dir/widget File name.js',
rootPath: '/widget/dir/',
});
t.plan(3);
t.equal(action.id, 'widget_File_name-js', 'it derives a widget id');
t.equal(
action.filePath, '/widget/dir/widget File name.js',
'it contains the original file path'
);
const action2 = resolveWidget({
type: 'removed',
filePath: '/widget/dir/widget File name.js',
rootPath: '/widget/dir/',
});
t.ok(
action.type === 'added' && action2.type === 'removed',
'it passes on the action type'
);
});
test('separating widgets from non-wigets', (t) => {
var action = resolveWidget({
filePath: '/widget/dir/file.js',
rootPath: '/widget/dir/',
});
t.ok(!!action, 'it accepts js files');
action = resolveWidget({
filePath: '/widget/dir/file.coffee',
rootPath: '/widget/dir/',
});
t.ok(!!action, 'it accepts coffee files');
action = resolveWidget({
filePath: '/widget/dir/file.jsx',
rootPath: '/widget/dir/',
});
t.ok(!!action, 'it accepts jsx files');
action = resolveWidget({
filePath: '/widget/dir/file.txt',
rootPath: '/widget/dir/',
});
t.ok(!action, 'it ignores other files');
action = resolveWidget({
filePath: '/widget/dir/node_modules/file.js',
rootPath: '/widget/dir/',
});
t.ok(!action, 'it ignores files inside node_modules');
action = resolveWidget({
filePath: '/widget/dir/src/file.js',
rootPath: '/widget/dir/',
});
t.ok(!action, 'it ignores files inside a src dir');
action = resolveWidget({
filePath: '/widget/dir/lib/file.js',
rootPath: '/widget/dir/',
});
t.ok(!action, 'it ignores files inside a lib dir');
action = resolveWidget({
filePath: '/widget/dir/some/other/dir/file.js',
rootPath: '/widget/dir/',
});
t.ok(!!action, 'it detects widgets in any other dir');
t.end();
});
test('deriving widget ids', (t) => {
var action = resolveWidget({
type: 'added',
filePath: '/widget/dir/spaces in the Name.js',
rootPath: '/widget/dir/',
});
t.equal(
action.id, 'spaces_in____the__Name-js', 'it replaces spaces with underscores'
);
action = resolveWidget({
type: 'added',
filePath: '/widget/dir/some-dir/widget.js',
rootPath: '/widget/dir/',
});
t.equal(
action.id, 'some-dir-widget-js', 'it replaces slashes with dashes'
);
t.end();
});

View File

@@ -0,0 +1,36 @@
var test = require('tape');
var validateWidget = require('../../src/validateWidget');
test('checking for empty implementations', (t) => {
var issues = validateWidget();
t.ok(
issues.indexOf('empty implementation') > -1,
'it does not allow them'
);
t.end();
});
test('checking for commands', (t) => {
var issues = validateWidget({});
t.ok(
issues.indexOf('no command given') > -1,
'it complains if when there is no command'
);
issues = validateWidget({ refreshFrequency: false });
t.ok(
issues.indexOf('no command given') === -1,
'it allows no commands when refreshFrequency is false'
);
t.end();
});
test('valid widgets', (t) => {
var issues = validateWidget({
command: 'yay'
});
t.ok(issues.length === 0, 'it finds no issues');
t.end();
});

View File

@@ -0,0 +1,83 @@
var test = require('tape');
var widgetify = require('../../src/widgetify');
var through = require('through2');
function grabOutput(then) {
var output = '';
return through(
(chunk, enc, next) => { output += chunk; next(); },
(next) => { then(output); next(); }
);
}
test('transforming valid widgets', (t) => {
var transform = widgetify('path/', { id: 'foo' });
var src = `
var color = '#ff';
var stuff = 1+2;
color = color + 'f';
({
foo: 14,
style: 'color: ' + color,
refreshFrequency: '2s'
})
`;
transform.pipe( grabOutput((transformed) => {
const module = {};
new Function('module', transformed)(module);
t.ok(
typeof module.exports === 'object',
'it assigns the last object expression to module.exports'
);
t.equal(
module.exports.id, 'foo',
'it adds the widget id'
);
t.equal(
module.exports.refreshFrequency, 2000,
'it parses string refresh frequencies'
);
t.equal(
module.exports.css, '#foo {\n color: #fff;\n}\n',
'it parses and scopes styles, including interpolated variables'
);
t.equal(
module.exports.style, undefined,
'it cleans up the style property'
);
t.end();
}));
transform.write(src);
transform.end();
});
test('transforming a widget with a syntax error', (t) => {
var transform = widgetify('path/', { id: 'foo' });
var src = `
({
foo: 14,
style: 'color: ' + color,
refreshFrequency: '2s'
})
`;
transform
.on('error', (e) => {
t.pass('it emits an error');
t.ok(
e.name === 'ReferenceError' && e.message === 'color is not defined',
'the error looks ok'
);
t.end();
})
.pipe(grabOutput((transformed) => {
t.ok(!transformed, 'and there is no outout');
}));
transform.write(src);
transform.end();
});

View File

@@ -0,0 +1 @@
0a8f99dc-3af2-4fb0-8999-8ba5fba7c2f9

View File

@@ -0,0 +1,195 @@
var test = require('tape');
var render = require('../../src/render');
var domEl = document.createElement('div');
document.body.appendChild(domEl); // needed to use selectors
function buildWidget(id) {
return {
id: id,
implementation: {id: id, refreshFrequency: false},
mtime: new Date(),
};
}
var state = {
widgets: {
foo: buildWidget('foo'),
bar: buildWidget('bar'),
},
settings: {},
screens: ['123'],
};
test('rendering a clean slate', (t) => {
render(state, '123', domEl);
t.equal(domEl.childNodes.length, 2, 'it renders 2 widgets');
t.ok(!!domEl.querySelector('#foo'), 'it renders widget foo');
t.ok(!!domEl.querySelector('#bar'), 'it renders widget bar');
t.end();
});
test('rendering new widgets', (t) => {
state.widgets.baz = buildWidget('baz');
render(state, '123', domEl);
t.equal(domEl.childNodes.length, 3, 'it renders 3 widgets');
t.ok(!!domEl.querySelector('#foo'), 'it renders widget foo');
t.ok(!!domEl.querySelector('#bar'), 'it renders widget bar');
t.ok(!!domEl.querySelector('#baz'), 'it renders widget baz');
t.end();
});
test('destroying removed widgets', (t) => {
delete state.widgets.bar;
render(state, '123', domEl);
t.equal(domEl.childNodes.length, 2, 'it leaves 2');
t.ok(!!domEl.querySelector('#foo'), 'it does not remove widget foo');
t.ok(!!domEl.querySelector('#baz'), 'it does not remove widget baz');
t.end();
});
test('rendering widgets that are visible on all screens', (t) => {
state.settings.baz = {
showOnAllScreens: true,
};
render(state, '123', domEl);
t.equal(domEl.childNodes.length, 2, 'it renders them');
render(state, '678', domEl);
t.equal(domEl.childNodes.length, 2, 'it renders them on any screen');
t.end();
});
test('rendering widgets that are pinned to the main screen', (t) => {
state.settings.baz = {
showOnAllScreens: false,
showOnMainScreen: true,
};
render(state, '123', domEl);
t.equal(
domEl.childNodes.length,
2,
'it renders them if current screen is main',
);
render(state, '156', domEl);
t.equal(
domEl.childNodes.length,
1,
'it does not render them if current screen is mot main',
);
t.end();
});
test('rendering widgets that are pinned to selected screens', (t) => {
state.settings.baz = {
showOnAllScreens: false,
showOnMainScreen: false,
showOnSelectedScreens: true,
};
render(state, '123', domEl);
t.equal(
domEl.childNodes.length,
1,
'it does not render them if no screen is selected',
);
state.settings.baz.screens = ['567'];
render(state, '123', domEl);
t.equal(
domEl.childNodes.length,
1,
'it does not render them if current screen is not in selected screens',
);
state.settings.baz.screens = ['567', '123'];
render(state, '123', domEl);
t.equal(
domEl.childNodes.length,
2,
'it renders them if current screen is in selected screens',
);
t.end();
});
test('performance when re-rendering', (t) => {
var prevNode = domEl.querySelector('#foo');
render(state, '123', domEl);
var newNode = domEl.querySelector('#foo');
t.ok(
prevNode === newNode,
'it does not re-render nodes if it does not need to',
);
prevNode = domEl.querySelector('#foo');
// new mtime
state.widgets.foo = buildWidget('foo');
render(state, '123', domEl);
newNode = domEl.querySelector('#foo');
t.ok(prevNode !== newNode, 'it does re-render nodes when it has to');
t.end();
});
test('rendering background widgets', (t) => {
var state = {
widgets: {
foo: buildWidget('foo'),
},
settings: {foo: {inBackground: true, showOnAllScreens: true}},
screens: ['123'],
};
render(state, '123', domEl);
t.equal(
domEl.childNodes.length,
1,
'it renders them if window.isBackground is not set',
);
window.isBackground = false;
render(state, '123', domEl);
t.equal(
domEl.childNodes.length,
0,
'it does not render them if window.background is false',
);
window.isBackground = true;
render(state, '123', domEl);
t.equal(
domEl.childNodes.length,
1,
'it renders them if window.background is true',
);
window.isBackground = false;
var state = {
widgets: {
foo: buildWidget('foo'),
},
settings: {foo: {showOnAllScreens: true}},
screens: ['123'],
};
render(state, '123', domEl);
t.equal(
domEl.childNodes.length,
1,
'it renders them in foreground if setting is undefined',
);
window.isBackground = undefined;
t.end();
});

View File

@@ -0,0 +1,412 @@
var test = require('tape');
var sinon = require('sinon');
var tosource = require('tosource');
var Widget = require('../../src/Widget.js');
function makeFakeServer() {
var server = sinon.fakeServer.create();
server.respondToRun = function respondToRun(body) {
server.respondWith('POST', '/run/', [
status,
{ 'Content-Type': 'text/plain' },
body,
]);
};
return server;
}
function buildWidget(impl) {
return Widget({implementation: impl});
}
test('widget creation', (t) => {
var widget = buildWidget({ command: '', id: 'foo', css: 'background: red' });
var el = widget.create();
t.ok(
el && el.tagName === 'DIV',
'it creates a wrapper dom element for a widget'
);
t.ok(
!!el.querySelector('#foo'),
'it creates an element for the widget itself'
);
var style = el.querySelector('style');
t.ok(!!style, "it includes a style tag for the widget's css");
t.ok(
style.innerHTML.indexOf('background: red') > -1,
"the tag includes the widget's style"
);
widget.destroy();
t.end();
});
test('defaults', (t) => {
var implementation = buildWidget({ command: '', id: 'foo', css: '' })
.implementation();
t.equal(
implementation.refreshFrequency, 1000,
'it sets the refresh frequency to 1s'
);
t.equal(
typeof implementation.render, 'function',
'it provides a default render function'
);
t.ok(
implementation.render('stuff') === 'stuff',
'the default render method returns what is passed to it'
);
t.equal(
typeof implementation.afterRender, 'function',
'it provides a default afterRender function'
);
implementation = buildWidget({
id: 'foo',
command: '',
css: '',
refreshFrequency: 42,
render: () => 'render!',
afterRender: () => 'afterRender!',
}).implementation();
t.equal(
implementation.refreshFrequency, 42,
"it doesn't override the refreshFrequency"
);
t.equal(
implementation.render(), 'render!',
"it doesn't override the render method"
);
t.equal(
implementation.afterRender(), 'afterRender!',
"it doesn't override the afterRender method"
);
t.end();
});
test('internal api', (t) => {
var api = buildWidget({ command: '', id: 'foo', css: '' })
.internalApi();
t.equal(typeof api.start, 'function', 'it has a start method');
t.equal(typeof api.stop, 'function', 'it has a stop method');
t.equal(typeof api.refresh, 'function', 'it has a refresh method');
t.equal(typeof api.run, 'function', 'it has a run method');
t.end();
});
test('running commands', (t) => {
var clock = sinon.useFakeTimers();
var instance = buildWidget({ id: 'foo', command: 'command', css: ''});
var widget = instance.implementation();
var server = makeFakeServer();
server.respondToRun('some output');
server.autoRespond = true;
server.respondImmediately = true;
var requests = server.requests;
instance.create();
t.equal(
server.requests[0].requestBody, 'command',
'it sends the command to the server'
);
clock.tick(1000);
t.ok(
requests.length === 2 && requests[1].requestBody === 'command',
'for every tick'
);
widget.command = 'new command';
clock.tick(1000);
t.equal(
requests[2].requestBody, 'new command',
'when updating command it sends the new command to the server'
);
t.end();
instance.destroy();
clock.restore();
});
test('manually running commands', (t) => {
var widget = buildWidget({ id: 'foo', command: '', css: ''}).internalApi();
var server = makeFakeServer();
server.respondToRun('some output');
widget.run('some command', (err, output) => {
t.equal(null, err, 'sends no errors');
t.equal(output, 'some output', 'it responds with the output');
server.restore();
t.end();
});
t.equal(
server.requests[0].requestBody, 'some command',
'it sends the command to the server'
);
server.respond();
});
test('standard rendering', (t) => {
var clock = sinon.useFakeTimers();
var instance = buildWidget({
id: 'foo',
command: '',
refreshFrequency: 100,
numRenders: 0,
render(out) {
this.numRenders++;
return `rendered ${this.numRenders} ${out}`;
},
});
var server = makeFakeServer();
server.respondToRun('Hello World!');
server.autoRespond = true;
server.respondImmediately = true;
var domEl = instance.create();
var contentEl = domEl.querySelector('.widget');
t.equal(
contentEl.textContent, 'rendered 1 Hello World!',
'it does an initial render'
);
clock.tick(100);
t.equal(
contentEl.textContent, 'rendered 2 Hello World!',
'it renders after the first tick'
);
clock.tick(100);
t.equal(
contentEl.textContent, 'rendered 3 Hello World!',
'it renders after the second tick'
);
var internalApi = instance.internalApi();
internalApi.stop();
clock.tick(100);
clock.tick(100);
t.equal(
contentEl.textContent, 'rendered 3 Hello World!',
'it pauses when stopped'
);
internalApi.start();
t.equal(
contentEl.textContent, 'rendered 4 Hello World!',
'it resumes when started'
);
t.equal(
contentEl.textContent, 'rendered 4 Hello World!',
'it continues rendering'
);
instance.destroy();
server.restore();
clock.restore();
t.end();
});
test('rendering when refreshFrequency is false', (t) => {
var clock = sinon.useFakeTimers();
var instance = buildWidget({
id: 'foo',
refreshFrequency: false,
numRenders: 0,
render(out) {
this.numRenders++;
return `rendered ${this.numRenders} times`;
},
});
var server = makeFakeServer();
server.respondToRun('');
server.autoRespond = true;
server.respondImmediately = true;
var domEl = instance.create();
var contentEl = domEl.querySelector('.widget');
t.equal(
contentEl.textContent, 'rendered 1 times',
'it does an initial render'
);
clock.tick(1000);
clock.tick(1000);
t.equal(
contentEl.textContent, 'rendered 1 times',
'it does\'t render after that'
);
clock.restore();
instance.destroy();
server.restore();
t.end();
});
test('refreshing manually', (t) => {
var instance = buildWidget({
id: 'bar',
refreshFrequency: false,
command: 'refresh me',
css: '',
});
var domEl = instance.create();
var server = makeFakeServer();
var internalApi = instance.internalApi();
server.respondToRun('some output');
internalApi.start();
internalApi.refresh();
t.equal(server.requests[0].requestBody, 'refresh me');
server.respond();
t.equal(
domEl.textContent.replace(/^\s+/g, ''), 'some output',
'it renders the output to the DOM'
);
instance.destroy();
server.restore();
t.end();
});
test('afterRender hook', (t) => {
var server = makeFakeServer();
var clock = sinon.useFakeTimers();
server.respondToRun('Hello World!');
server.autoRespond = true;
var instance = buildWidget({
id: 'fred',
command: '',
refreshFrequency: 100,
numCalls: 0,
afterRender(el) {
this.numCalls++;
el.innerHTML = `called ${this.numCalls} times`;
},
});
var domEl = instance.create();
clock.tick(300);
t.equal(
domEl.querySelector('.widget').textContent, 'called 3 times',
'it get\'s called after every render, with the content dom element'
);
instance.destroy();
server.restore();
clock.restore();
t.end();
});
test('update', (t) => {
var instance = buildWidget({
id: 'fred',
command: '',
refreshFrequency: false,
update(output, el) {
el.innerHTML = `content: ${el.textContent}, output: ${output}`;
},
});
var server = makeFakeServer();
server.respondToRun('stuff');
server.autoRespond = true;
server.respondImmediately = true;
var domEl = instance.create();
var contentEl = domEl.querySelector('.widget');
t.equal(
contentEl.textContent, 'content: stuff, output: stuff',
'it calls update after rendering, with the output and widget dom el'
);
instance.destroy();
server.restore();
t.end();
});
test('error handling', (t) => {
var instance = buildWidget({
id: 'foo',
refreshFrequency: false,
render() { throw new Error('something went sorry'); },
update() { throw new Error('should not call update when render fails'); },
});
var domEl = instance.create();
t.equal(
domEl.querySelector('.widget').textContent, 'something went sorry\n',
'it catches and renders errors in render()'
);
instance.destroy();
instance = buildWidget({
id: 'foo',
refreshFrequency: false,
update() { throw new Error('ohoh'); },
});
domEl = instance.create();
t.equal(
domEl.querySelector('.widget').textContent, 'ohoh\n',
'it catches and renders errors in update()'
);
instance.destroy();
instance = buildWidget({
id: 'foo',
command: 'yay',
refreshFrequency: 100,
});
var clock = sinon.useFakeTimers();
var server = sinon.fakeServer.create({ respondImmediately: true });
server.respondWith('POST', '/run/', [ 500, {}, 'oh noez!']);
server.respondImmediately = true;
domEl = instance.create();
server.respond();
t.equal(
domEl.querySelector('.widget').textContent, 'oh noez!\n',
'it renders command errors'
);
server.respondWith('POST', '/run/', [ 200, {}, 'all good']);
clock.tick(100);
server.respond();
t.equal(
domEl.querySelector('.widget').textContent, 'all good',
'it recovers from command errors'
);
clock.restore();
server.restore();
instance.destroy();
t.end();
});

View File

@@ -0,0 +1 @@
11e03a6d-84be-4539-a4b3-5cd819ff4112

View File

@@ -0,0 +1,12 @@
http = require('http');
module.exports = function httpGet(url, callback) {
var buffer = '';
http.get(url, function(res) {
res.setEncoding('utf8');
res.on('data', (chunk) => buffer += chunk );
res.on('end', () => callback(res, buffer) );
});
};

View File

@@ -0,0 +1,18 @@
var http = require('http');
var URL = require('url');
module.exports = function httpPost(url, postData, callback) {
var options = URL.parse(url);
options.method = 'POST';
options.headers = { 'Content-Length': postData.length };
var req = http.request(options, (res) => {
var buffer = '';
res.setEncoding('utf8');
res.on('data', (chunk) => buffer += chunk);
res.on('end', () => callback(res, buffer));
});
req.write(postData);
req.end();
};

View File

@@ -0,0 +1 @@
f57a5179-eb69-450a-b864-836643c3a65b

View File

@@ -0,0 +1 @@
{"spec-test_widgets-broken-widget-coffee":{"showOnAllScreens":true,"showOnMainScreen":false,"showOnSelectedScreens":false,"hidden":false,"screens":[]},"spec-test_widgets-widget-2-js":{"showOnAllScreens":true,"showOnMainScreen":false,"showOnSelectedScreens":false,"hidden":false,"screens":[]},"spec-test_widgets-widget-1-coffee":{"showOnAllScreens":true,"showOnMainScreen":false,"showOnSelectedScreens":false,"hidden":false,"screens":[]},"spec-test_widgets-invalid-widget-coffee":{"showOnAllScreens":true,"showOnMainScreen":false,"showOnSelectedScreens":false,"hidden":false,"screens":[]},"spec-test_widgets-some-dir-widget-index-1-coffee":{"showOnAllScreens":true,"showOnMainScreen":false,"showOnSelectedScreens":false,"hidden":false,"screens":[]}}

View File

@@ -0,0 +1 @@
126b9e6a-6167-4ab9-8404-6c546f66fba6

View File

@@ -0,0 +1,5 @@
command: ""
render: ->
'this is a broken widget'
'unexpected indentation'

View File

@@ -0,0 +1,3 @@
refreshFrequency: 1000
render: (output) -> output

View File

@@ -0,0 +1 @@
3d8f802f-b31a-4e65-b7df-87a88645cd2c

View File

@@ -0,0 +1,2 @@
command: "do stuff"
refreshFrequency: 3000

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

View File

@@ -0,0 +1,14 @@
command: "foo"
refreshFrequency: 1000
style: """
bottom: 10px
left: 100px
div
font-size: 12px
"""
render: (output) ->
output

View File

@@ -0,0 +1,2 @@
command: "bar",
refreshFrequency: 1020

View File

@@ -0,0 +1,13 @@
export const command = "echo Hello World!"
export const refreshFrequency = 5000 // ms
export const render = ({ output }) => (
<h1>{output}</h1>
)
export const className = `
left: 20px
top: 20px
color: #fff
`

View File

@@ -0,0 +1 @@
4e7c972d-b67e-4e30-8bc1-415c8586ba61

View File

@@ -0,0 +1,142 @@
$ = require('jquery')
window.jQuery = $
Timer = require('./Timer')
runCommand = require('./runCommand')
runShellCommand = require('./runShellCommand')
defaults =
id: 'widget'
refreshFrequency: 1000
render: (output) -> output
afterRender: ->
# This is a wrapper (something like a base class), around the
# specific implementation of a widget.
module.exports = ClassicWidget = (widgetObject) ->
api = {}
internalApi = {}
el = null
contentEl = null
timer = null
started = false
rendered = false
mounted = false
commandLoop = null
implementation = {}
currentError = null
init = (widget) ->
currentError = if widget.error then JSON.parse(widget.error) else null
implementation = widget.implementation || {}
implementation.id == widget.id
implementation[k] ?= v for k, v of defaults
implementation[k] ||= v for k, v of internalApi
commandLoop = Timer().map (done) ->
runCommand implementation, (err, output) ->
redraw(err, output)
done(implementation.refreshFrequency)
api
# renders and returns the widget's dom element
api.create = ->
el = document.createElement 'div'
contentEl = document.createElement 'div'
contentEl.id = implementation.id
contentEl.className = 'widget'
el.innerHTML = "<style>#{implementation.css}</style>\n"
el.appendChild(contentEl)
start()
el
api.destroy = ->
stop()
return unless el?
el.parentNode?.removeChild(el)
el = null
contentEl = null
rendered = false
api.update = (newImplementation) ->
parentEl = el.parentNode
api.destroy()
init(newImplementation)
parentEl.appendChild(api.create())
api.domEl = -> el
api.isRendered = ->
!!el
api.internalApi = ->
internalApi
api.implementation = ->
implementation
api.forceRefresh = ->
internalApi.refresh()
# starts the widget refresh cycle
internalApi.start = start = ->
return redraw(currentError) if currentError
commandLoop.start()
# stops the widget refresh cycle
internalApi.stop = stop = ->
commandLoop.stop()
# run widget command and redraw the widget
internalApi.refresh = refresh = ->
return redraw() unless implementation.command?
commandLoop.forceTick()
# runs command in the shell and calls callback with the result (err, stdout)
internalApi.run = run = (command, callback) ->
runShellCommand(command, callback)
redraw = (error, output) ->
if error
contentEl.style.fontFamily = 'monospace'
contentEl.style.fontSize = '12px'
contentEl.style.whiteSpace = 'pre'
contentEl.style.background = '#fff'
contentEl.style.padding = '20px'
contentEl.innerHTML = error.message + '\n' + (error.lines || '')
console.error "#{implementation.id}:", error
return rendered = false
else
contentEl.style.fontFamily = ''
contentEl.style.fontSize = ''
contentEl.style.whiteSpace = ''
contentEl.style.background = ''
contentEl.style.padding = ''
try
renderOutput output
catch e
redraw(e)
renderOutput = (output) ->
if implementation.update? and rendered
implementation.update(output, contentEl)
else
contentEl.innerHTML = implementation.render(output)
loadScripts(contentEl)
implementation.afterRender(contentEl)
rendered = true
implementation.update(output, contentEl) if implementation.update?
loadScripts = (domEl) ->
for script in domEl.getElementsByTagName('script')
s = document.createElement('script')
s.src = script.src
domEl.replaceChild s, script
init(widgetObject)

View File

@@ -0,0 +1 @@
1785b276-85d2-4dbe-beda-c235f170d0ee

View File

@@ -0,0 +1,33 @@
const codeLine = {
padding: '0 10px',
position: 'relative',
};
const lineNum = {
color: '#787878',
padding: '0 10px',
borderRight: '0.5px solid #ddd',
};
const marker = {
background: 'rgba(255, 0, 0, 0.3)',
fontStyle: 'normal',
};
function slice(line, col) {
return [
line.slice(0, col),
html('em', {style: marker, key: 'marker'}, line.slice(col, col + 1)),
line.slice(col + 1),
];
}
module.exports = function ErrorLine(props) {
const style = {
background: props.hasError ? 'rgba(255, 0, 0, 0.2)' : '',
};
const content = props.hasError ? slice(props.line, props.column) : props.line;
return html('tr', {key: props.key, style: style}, [
html('td', {style: lineNum, key: props.key + '-0'}, props.lineNum),
html('td', {style: codeLine, key: props.key + '-1'}, content),
]);
};

View File

@@ -0,0 +1,38 @@
const ErrorLine = require('./ErrorLine.js');
const style = {
background: '#fff',
padding: '20px 30px',
fontSize: '12px',
fontFamily: 'monospace',
};
const message = {
fontSize: '12px',
color: 'red',
marginBottom: 20,
whiteSpace: 'pre',
};
const code = {
lineHeight: '1.5',
whiteSpace: 'pre',
fontFamily: 'monospace',
};
const table = {
borderCollapse: 'collapse',
};
module.exports = function ErrorDetails(props) {
const {lines, line, column} = props;
return html('div', {style: style},
html('h1', {style: message, key: 'h1'}, props.message),
html('p', {key: 'p'}, 'in ' + props.path + ':'),
html('table', {style: table, key: 'table'},
html('tbody', {style: code},
(lines || []).map((l, i) => {
const args = {key: i, hasError: l.lineNum === line, column: column};
return ErrorLine(Object.assign({}, l, args));
})
)
)
);
};

View File

@@ -0,0 +1,25 @@
'use strict';
const WebSocket = require('ws');
module.exports = function MessageBus(options) {
const wss = new WebSocket.Server(options);
function broadcast(data) {
wss.clients.forEach((client) => {
if (client.readyState === WebSocket.OPEN) {
client.send(data);
}
});
}
wss.on('connection', function connection(ws) {
ws.on('message', broadcast);
});
wss.on('error', function handleError(err) {
console.error(err);
});
return wss;
};

View File

@@ -0,0 +1,41 @@
'use strict';
const path = require('path');
const fs = require('fs');
module.exports = function Settings(settingsDirPath) {
const api = {};
let settings;
const settingsFile = path.join(settingsDirPath, 'WidgetSettings.json');
initSettingsFile(settingsDirPath);
function initSettingsFile(dirPath) {
if (!fs.existsSync(dirPath)) {
fs.mkdirSync(dirPath);
}
}
api.load = function load() {
let persistedSettings = {};
try {
persistedSettings = require(settingsFile);
} catch (e) { /* do nothing */ }
return persistedSettings;
};
api.persist = function persist(newSettings) {
if (newSettings !== settings) {
fs.writeFile(settingsFile, JSON.stringify(newSettings), (err) => {
if (err) {
console.log(err);
} else {
settings = newSettings;
}
});
}
};
return api;
};

View File

@@ -0,0 +1,66 @@
'use strict';
const WebSocket = typeof window !== 'undefined'
? window.WebSocket
: require('ws');
let ws = null;
let isOpen = false;
const messageListeners = [];
const openListeners = [];
function handleWSOpen() {
isOpen = true;
openListeners.forEach((f) => f());
}
function handleWSCosed() {
isOpen = false;
}
function handleMessage(data) {
messageListeners.forEach((f) => f(data));
}
function handleError(err) {
console.error(err);
}
exports.open = function open(url) {
ws = new WebSocket(url, ['ws'], {origin: 'Übersicht'});
if (ws.on) {
ws.on('open', handleWSOpen);
ws.on('close', handleWSCosed);
ws.on('message', handleMessage);
ws.on('error', handleError);
} else {
ws.onopen = handleWSOpen;
ws.onclose = handleWSCosed;
ws.onmessage = (e) => handleMessage(e.data);
ws.onerror = handleError;
}
};
exports.close = function close() {
ws.close();
ws = null;
};
exports.isOpen = function() {
return ws && isOpen;
};
exports.onMessage = function onMessage(listener) {
messageListeners.push(listener);
};
exports.onOpen = function onOpen(listener) {
openListeners.push(listener);
};
exports.send = function send(data) {
ws.send(data);
};

View File

@@ -0,0 +1,10 @@
'use strict';
// middleware to serve the current state
module.exports = (store) => (req, res, next) => {
if (req.url === '/state/') {
res.end(JSON.stringify(store.getState()));
} else {
next();
}
};

View File

@@ -0,0 +1,49 @@
function scheduleTick(tick, duration) {
if (duration !== false) {
return setTimeout(tick, duration);
}
}
module.exports = function Timer() {
const api = {};
let callback = (done) => done();
let started = false;
let timer;
function loop() {
clearTimeout(timer);
if (started) {
callback((nextTickDuration) => {
timer = scheduleTick(loop, nextTickDuration);
});
}
}
api.start = function start() {
if (!started) {
started = true;
loop();
}
return api;
};
api.stop = function stop() {
if (started) {
started = false;
clearTimeout(timer);
}
return api;
};
api.map = function map(cb) {
callback = cb;
return api;
};
api.forceTick = function tick() {
callback(() => {});
return api;
};
return api;
};

View File

@@ -0,0 +1,161 @@
const css = require('emotion').css;
const RenderLoop = require('./RenderLoop');
const Timer = require('./Timer');
const runShellCommand = require('./runShellCommand');
const ReactDom = require('react-dom');
const html = require('react').createElement;
const ErrorDetails = require('./ErrorDetails');
window.html = html;
const defaults = {
id: 'widget',
refreshFrequency: 1000,
init: function init() {},
render: function render(props) {
return html('div', null, props.error ? String(props.error) : props.output);
},
updateState: function updateState(event) {
return {error: event.error, output: event.output};
},
initialState: {output: ''},
};
module.exports = function VirtualDomWidget(widgetObject) {
const api = {};
let implementation;
let contentEl;
let commandLoop;
let renderLoop;
let currentError;
function init(widget) {
currentError = widget.error ? JSON.parse(widget.error) : undefined;
implementation = Object.create(defaults);
Object.assign(implementation, widget.implementation || {}, {id: widget.id});
return api;
}
function start() {
if (currentError) {
renderErrorDetails(currentError);
return;
}
if (renderLoop) {
renderLoop.update(renderLoop.state); // force redraw
} else {
renderLoop = RenderLoop(implementation.initialState, render);
}
run();
}
function run() {
implementation.init(dispatch);
if (!implementation.command) return;
commandLoop = Timer()
.start()
.map((done) => {
execWidgetCommand()
.then(commandCompleted)
.catch(commandErrored)
.then(() => done(implementation.refreshFrequency));
});
}
function commandCompleted(output) {
dispatch({type: 'UB/COMMAND_RAN', output});
}
function commandErrored(error) {
dispatch({type: 'UB/COMMAND_RAN', error});
}
const runCommandFunction = (command) => {
try {
return command.apply(implementation, [dispatch]);
} catch (err) {
handleError(err);
}
};
function execWidgetCommand() {
const {command} = implementation;
if (typeof command === 'function')
return Promise.resolve(runCommandFunction(command));
else if (typeof command === 'string') return runShellCommand(command);
else return Promise.resolve();
}
function dispatch(event) {
try {
const nextState = implementation.updateState(event, renderLoop.state);
renderLoop.update(nextState);
} catch (err) {
handleError(err);
}
}
function fetchErrorDetails(err) {
return fetch(
`/widgets/${widgetObject.id}?line=${err.line}&column=${err.column}`,
).then((res) => res.json());
}
function render(state) {
try {
ReactDom.render(implementation.render(state, dispatch), contentEl);
} catch (err) {
handleError(err);
}
}
function handleError(err) {
currentError = err;
commandLoop && commandLoop.stop();
fetchErrorDetails(err).then((details) => {
if (err !== currentError) return;
renderErrorDetails(Object.assign({message: err.message}, details));
});
}
function renderErrorDetails(details) {
ReactDom.render(html(ErrorDetails, details), contentEl);
}
api.create = function create() {
contentEl = document.createElement('div');
contentEl.id = implementation.id;
contentEl.classList.add('widget');
if (implementation.className) {
contentEl.classList.add(css(implementation.className));
}
document.body.appendChild(contentEl);
start();
return contentEl;
};
api.destroy = function destroy() {
commandLoop && commandLoop.stop();
if (contentEl && contentEl.parentNode) {
contentEl.parentNode.removeChild(contentEl);
}
renderLoop = null;
contentEl = null;
currentError = null;
};
api.update = function update(newImplementation) {
commandLoop && commandLoop.stop();
contentEl.classList.remove(css(implementation.className));
init(newImplementation);
if (implementation.className) {
contentEl.classList.add(css(implementation.className));
}
start();
};
api.forceRefresh = function forceRefresh() {
commandLoop.forceTick();
};
return init(widgetObject);
};

View File

@@ -0,0 +1,14 @@
var ClassicWidget = require('./ClassicWidget.coffee');
var VirtualDomWidget = require('./VirtualDomWidget');
module.exports = function Widget(widget) {
var api;
if (/\.jsx$/.test(widget.filePath)) {
api = VirtualDomWidget(widget);
} else {
api = ClassicWidget(widget);
}
return api;
};

View File

@@ -0,0 +1,106 @@
'use strict';
const bundleWidget = require('./bundleWidget');
const fs = require('fs');
module.exports = function WidgetBundler() {
const api = {};
const bundles = {};
api.push = function push(action, callback) {
if (action && action.type) {
action.type === 'added'
? addWidget(action.id, action.filePath, callback)
: removeWidget(action.id, action.filePath, callback)
;
}
};
api.close = function close() {
for (var id in bundles) {
bundles[id].close();
delete bundles[id];
}
};
api.get = function get(id) {
return bundles[id].widget.body;
};
function addWidget(id, filePath, emit) {
if (!bundles[id]) {
bundles[id] = WidgetBundle(id, filePath, (widget) => {
emit({type: 'added', widget: widget});
});
}
}
function removeWidget(id, filePath, emit) {
if (bundles[id]) {
bundles[id].close();
delete bundles[id];
emit({type: 'removed', id: id});
}
}
function WidgetBundle(id, filePath, callback) {
const bundle = bundleWidget(id, filePath);
const buildWidget = (paths = []) => {
const widget = {
id: id,
filePath: filePath,
};
fs.access(filePath, fs.R_OK, (couldNotRead) => {
if (couldNotRead) return;
bundle.bundle((err, srcBuffer) => {
if (err) {
widget.error = errorJSON(filePath, err);
} else {
widget.body = srcBuffer.toString();
}
widget.mtime = fs.statSync(paths[0] || filePath).mtime;
bundle.widget = widget;
callback(widget);
});
});
};
bundle.on('update', buildWidget);
buildWidget();
return bundle;
}
function errorJSON(filePath, error) {
if (!error._babel) {
return JSON.stringify({
line: error.line,
column: error.column,
path: filePath,
lines: error.annotated,
message: error.message,
});
}
return JSON.stringify({
line: error.loc.line,
column: error.loc.column,
lines: parseCodeFrame(error.codeFrame),
path: filePath,
message: error.message,
});
}
function parseCodeFrame(codeFrame) {
return codeFrame
.split('\n')
.map(l => {
const [num, line] = l.split('|', 2);
const lineNum = parseInt(num.replace(/^>/, ''), 10);
return isNaN(lineNum) ? undefined : {lineNum: lineNum, line: line};
})
.filter(i => i);
}
return api;
};

View File

View File

@@ -0,0 +1,38 @@
'use strict';
function addWidget(widget) {
const {id, filePath, error, mtime} = widget;
return {
type: 'WIDGET_ADDED',
payload: {id, filePath, error, mtime},
};
}
exports.showWidget = function showWidget(id, impl) {
return {
type: 'WIDGET_LOADED',
id: id,
payload: impl,
};
};
function removeWidget(id) {
return {
type: 'WIDGET_REMOVED',
payload: id,
};
}
exports.applyWidgetSettings = function applyWidgetSettings(id, settings) {
return {
type: 'WIDGET_SETTINGS_CHANGED',
payload: { id: id, settings: settings },
};
};
exports.get = function(widgetEvent) {
switch (widgetEvent.type) {
case 'added': return addWidget(widgetEvent.widget);
case 'removed': return removeWidget(widgetEvent.id);
};
};

View File

@@ -0,0 +1,114 @@
connect = require 'connect'
http = require 'http'
serveStatic = require 'serve-static'
path = require 'path'
fs = require 'fs'
redux = require 'redux'
MessageBus = require('./MessageBus')
watchDir = require('./directory_watcher.coffee')
WidgetBundler = require('./WidgetBundler.js')
Settings = require('./Settings')
StateServer = require('./StateServer')
ensureSameOrigin = require('./ensureSameOrigin')
disallowIFraming = require('./disallowIFraming')
CommandServer = require('./command_server.coffee')
serveWidgets = require('./serveWidgets')
serveClient = require('./serveClient')
serveCss = require('./serveCss')
sharedSocket = require('./SharedSocket')
actions = require('./actions')
reducer = require('./reducer')
resolveWidget = require('./resolveWidget')
dispatchToRemote = require('./dispatch')
listenToRemote = require('./listen')
module.exports = (port, widgetPath, settingsPath, publicPath, options, callback) ->
console.log("port ========== :", port);
console.log("widgetPath ========== :", widgetPath);
console.log("settingsPath ========== :", settingsPath);
console.log("publicPath ========== :", publicPath);
console.log("options ========== :", options);
options ||= {}
# global store for app state
store = redux.createStore(
reducer,
{ widgets: {}, settings: {}, screens: [] }
)
# listen to remote actions
listenToRemote (action) ->
store.dispatch(action)
# follow symlink if widgetDirectory is one
if fs.lstatSync(widgetPath).isSymbolicLink()
widgetPath = fs.readlinkSync(widgetPath)
widgetPath = widgetPath.normalize()
bundler = WidgetBundler(widgetPath)
# TODO: use a stream/generator/promise pattern instead of nested callbacks
stopWatchingDir = watchDir(widgetPath, (fileEvent) ->
if (fileEvent.filePath.replace(fileEvent.rootPath, '') == '/main.css')
dispatchToRemote({type: 'MASTER_STYLE_CHANGED'})
return
bundler.push(resolveWidget(fileEvent), (widgetEvent) ->
action = actions.get(widgetEvent)
if (action)
store.dispatch(action)
dispatchToRemote(action)
)
)
# load and replay settings
settings = Settings(settingsPath)
for id, value of settings.load()
action = actions.applyWidgetSettings(id, value)
store.dispatch(action)
dispatchToRemote(action)
store.subscribe ->
settings.persist(store.getState().settings)
# set up the server
host = "127.0.0.1"
messageBus = null
allowedOrigin = "http://#{host}:#{port}"
middleware = connect()
.use(disallowIFraming)
.use(ensureSameOrigin(allowedOrigin))
.use(CommandServer(widgetPath, options.loginShell))
.use(StateServer(store))
.use(serveWidgets(bundler, widgetPath))
.use(serveStatic(publicPath))
.use(serveStatic(widgetPath))
.use(serveCss(widgetPath))
.use(serveClient(publicPath))
server = http.createServer(middleware)
server.keepAliveTimeout = 35000
server.listen port, host, (err) ->
try
return server.emit('error', err) if err
messageBus = MessageBus(
server: server,
verifyClient: (info) ->
info.origin == allowedOrigin || info.origin == 'Übersicht'
)
sharedSocket.open("ws://#{host}:#{port}")
callback?()
catch e
server.emit('error', e)
# api
close: (cb) ->
stopWatchingDir()
bundler.close()
server.close()
sharedSocket.close()
messageBus.close(cb)
on: (ev, handler) ->
server.on(ev, handler)

View File

@@ -0,0 +1,61 @@
const browserify = require('browserify');
const watchify = require('./watchify');
const widgetify = require('./widgetify');
const coffeeify = require('coffeeify');
const babelify = require('babelify');
const jsxTransform = require('@babel/preset-react');
const restSpreadTransform = require('@babel/plugin-proposal-object-rest-spread');
const emotion = require('babel-plugin-emotion');
const envPreset = require('@babel/preset-env');
const through = require('through2');
function wrapJSWidget() {
let start = true;
function write(chunk, enc, next) {
if (start) {
this.push('({');
start = false;
}
next(null, chunk);
}
function end(next) {
this.push('})');
next();
}
return through(write, end);
}
module.exports = function bundleWidget(id, filePath) {
const isJsxWidget = filePath.match(/\.jsx$/);
const bundle = browserify(filePath, {
detectGlobals: false,
cache: {},
packageCache: {},
debug: isJsxWidget,
});
bundle.plugin(watchify);
bundle.require(filePath, {expose: id});
bundle.external('uebersicht');
if (filePath.match(/\.coffee$/)) {
bundle.transform(coffeeify, {
bare: true,
header: false,
});
bundle.transform(widgetify, {id: id});
} else if (isJsxWidget) {
bundle.transform(babelify, {
presets: [
[envPreset, {targets: 'last 4 Safari versions', modules: 'commonjs'}],
[jsxTransform, {pragma: 'html'}],
],
plugins: [restSpreadTransform, emotion],
});
} else {
bundle.transform(wrapJSWidget);
bundle.transform(widgetify, {id: id});
}
return bundle;
};

View File

@@ -0,0 +1,41 @@
# middleware to serve the results of shell commands
# Listens to POST /run/
{spawn} = require('child_process')
module.exports = (workingDir, useLoginShell) ->
args = if useLoginShell then ['-l'] else []
# the Connect middleware
(req, res, next) ->
return next() unless req.method == 'POST' and req.url == '/run/'
shell = spawn 'bash', args, cwd: workingDir
command = ''
req.on 'data', (chunk) -> shell.stdin.write chunk
req.on 'end', ->
setStatusOnce = (status) ->
res.writeHead status
setStatusOnce = ->
shell.stderr.on 'data', (d) ->
setStatusOnce 500
res.write d
shell.stdout.on 'data', (d) ->
setStatusOnce 200
res.write d
shell.on 'error', (err) ->
setStatusOnce 500
res.write err.message
shell.on 'close', ->
setStatusOnce 200
res.end()
shell.stdin.write '\n'
shell.stdin.end()

View File

@@ -0,0 +1,26 @@
module.exports = (containerEl) => {
let insideWidget = false;
const checkHover = (e) => {
if (insideWidget && containerEl === e.target) {
insideWidget = false;
window.webkit.messageHandlers.uebersicht.postMessage('widgetLeave');
} else if (!insideWidget && containerEl !== e.target) {
insideWidget = true;
window.webkit.messageHandlers.uebersicht.postMessage('widgetEnter');
}
};
const checkHoverRecursive = () => {
window.addEventListener(
'mousemove',
(e) => {
checkHover(e);
setTimeout(checkHoverRecursive, 32);
},
{once: true},
);
};
checkHoverRecursive();
};

View File

@@ -0,0 +1,73 @@
paths = require 'path'
fs = require 'fs'
fsevents = require('fsevents')
module.exports = (directoryPath, callback) ->
api = {}
foundPaths = {}
closed = true
stopWatching = null;
init = ->
if !fs.existsSync(directoryPath)
throw new Error "could not find #{directoryPath}"
closed = false
stopWatching = fsevents.watch(directoryPath, (filePath, flags, id) ->
return if closed
info = fsevents.getInfo(filePath, flags, id);
switch info.event
when 'modified', 'created'
findFiles filePath, info.type, registerFile
when 'deleted'
unregisterFiles filePath
when 'moved'
unregisterFiles filePath
findFiles filePath, info.type, registerFile
)
console.log 'watching', directoryPath
findFiles directoryPath, 'directory', registerFile
close
close = ->
closed = true
stopWatching?()
registerFile = (filePath) ->
filePath = filePath.normalize()
foundPaths[filePath] = true
callback({
type: 'added',
filePath: filePath.normalize(),
rootPath: directoryPath,
})
unregisterFiles = (path) ->
path = path.normalize()
for filePath in Object.keys(foundPaths) when filePath.indexOf(path) == 0
callback({type: 'removed', filePath: filePath, rootPath: directoryPath})
# recursively walks the directory tree and calls onFound for every file it
# finds
findFiles = (path, type, onFound) ->
if type == 'file'
onFound path
else
fs.readdir path, (err, subPaths) ->
return console.log err if err
for subPath in subPaths
fullPath = paths.join(path, subPath)
getPathType fullPath, (p, t) -> findFiles(p, t, onFound)
# get type of path as either 'file' or 'directory'
# callback gets called with (path, type) where path is the path passed in,
# for convenience
getPathType = (path, callback) ->
fs.stat path, (err, stat) ->
return console.log err if err
type = if stat.isDirectory() then 'directory' else 'file'
callback path, type
init()

View File

@@ -0,0 +1,4 @@
module.exports = function disallowIFraming(req, res, next) {
res.setHeader('X-Frame-Options', 'sameorigin');
next();
};

View File

@@ -0,0 +1,21 @@
'use strict';
const ws = require('./SharedSocket');
const queuedMessages = [];
function drainQueuedMessages() {
queuedMessages.forEach((m) => ws.send(m));
queuedMessages.length = 0;
}
ws.onOpen(drainQueuedMessages);
module.exports = function dispatch(message) {
const serializedMessage = JSON.stringify(message);
if (ws.isOpen()) {
ws.send(serializedMessage);
} else {
queuedMessages.push(serializedMessage);
}
};

View File

@@ -0,0 +1,12 @@
module.exports = function ensureSameOrgin(origin) {
const fromSameOrigin = (req) => {
return req.method == 'GET' ||
(req.headers.origin && req.headers.origin === origin);
}
return ((req, res, next) => {
if (fromSameOrigin(req)) return next();
res.writeHead(403);
res.end();
})
}

View File

@@ -0,0 +1,17 @@
'use strict';
const ws = require('./SharedSocket');
const listeners = [];
ws.onMessage(function handleMessage(data) {
let message;
try { message = JSON.parse(data); } catch (e) { null; }
if (message) {
listeners.forEach((f) => f(message));
}
});
module.exports = function listen(callback) {
listeners.push(callback);
};

View File

@@ -0,0 +1,162 @@
'use strict';
const defaultSettings = {
showOnAllScreens: true,
showOnMainScreen: false,
showOnSelectedScreens: false,
hidden: false,
screens: [],
};
const handlers = {
WIDGET_ADDED: (state, action) => {
const widget = action.payload;
const newWidgets = Object.assign({}, state.widgets, {
[widget.id]: widget,
});
const settings = state.settings || {};
const newSettings = settings[widget.id]
? state.settings
: Object.assign({}, settings, {[widget.id]: defaultSettings});
return Object.assign({}, state, {
widgets: newWidgets,
settings: newSettings,
});
},
WIDGET_LOADED: (state, action) => {
if (!state.widgets[action.id]) {
return state;
}
const widget = Object.assign({}, state.widgets[action.id], {
implementation: action.payload,
});
const newWidgets = Object.assign({}, state.widgets, {[widget.id]: widget});
return Object.assign({}, state, {widgets: newWidgets});
},
WIDGET_REMOVED: (state, action) => {
const id = action.payload;
if (!state.widgets[id]) {
return state;
}
const newWidgets = Object.assign({}, state.widgets);
delete newWidgets[id];
return Object.assign({}, state, {widgets: newWidgets});
},
WIDGET_SETTINGS_CHANGED: (state, action) => {
const newSettings = Object.assign({}, state.settings, {
[action.payload.id]: action.payload.settings,
});
return Object.assign({}, state, {settings: newSettings});
},
WIDGET_SET_TO_ALL_SCREENS: (state, action) => {
return updateSettings(state, action.payload, {
showOnAllScreens: true,
showOnSelectedScreens: false,
showOnMainScreen: false,
hidden: false,
screens: [],
});
},
WIDGET_SET_TO_SELECTED_SCREENS: (state, action) => {
return updateSettings(state, action.payload, {
showOnSelectedScreens: true,
showOnAllScreens: false,
showOnMainScreen: false,
hidden: false,
});
},
WIDGET_SET_TO_MAIN_SCREEN: (state, action) => {
return updateSettings(state, action.payload, {
showOnSelectedScreens: false,
showOnAllScreens: false,
showOnMainScreen: true,
hidden: false,
screens: [],
});
},
WIDGET_SET_TO_HIDE: (state, action) => {
return updateSettings(state, action.payload, {
hidden: true,
});
},
WIDGET_SET_TO_SHOW: (state, action) => {
return updateSettings(state, action.payload, {
hidden: false,
});
},
WIDGET_SET_TO_BACKGROUND: (state, action) => {
return updateSettings(state, action.payload, {
inBackground: true,
});
},
WIDGET_SET_TO_FOREGROUND: (state, action) => {
return updateSettings(state, action.payload, {
inBackground: false,
});
},
SCREEN_SELECTED_FOR_WIDGET: (state, action) => {
const settings = state.settings[action.payload.id];
const newScreens = (settings.screens || []).slice();
if (newScreens.indexOf(action.payload.screenId) === -1) {
newScreens.push(action.payload.screenId);
}
return updateSettings(state, action.payload.id, {
screens: newScreens,
});
},
SCREEN_DESELECTED_FOR_WIDGET: (state, action) => {
const newScreens = (state.settings[action.payload.id].screens || []).filter(
(s) => s !== action.payload.screenId,
);
return updateSettings(state, action.payload.id, {
screens: newScreens,
});
},
SCREENS_DID_CHANGE: (state, action) => {
return Object.assign({}, state, {
screens: action.payload,
});
},
};
function updateSettings(state, widgetId, patch) {
const widgetSettings = state.settings[widgetId];
const newSettings = Object.assign({}, state.settings, {
[widgetId]: Object.assign({}, widgetSettings, patch),
});
return Object.assign({}, state, {settings: newSettings});
}
module.exports = function reduce(state, action) {
let newState;
const handler = handlers[action.type];
if (handler) {
newState = handler(state, action);
} else {
newState = state;
}
return newState;
};

View File

@@ -0,0 +1,77 @@
var Widget = require('./Widget');
var rendered = {};
function isVisibleOnScreen(widgetId, screenId, state) {
var settings = state.settings[widgetId] || {};
var isVisible = false;
if (settings.hidden) {
isVisible = false;
} else if (
settings.showOnAllScreens ||
settings.showOnAllScreens === undefined
) {
isVisible = true;
} else if (settings.showOnMainScreen) {
isVisible = state.screens.indexOf(screenId) === 0;
} else if (settings.showOnSelectedScreens) {
isVisible = (settings.screens || []).indexOf(screenId) !== -1;
}
return isVisible;
}
function isInBackground(widgetId, state) {
const settings = state.settings[widgetId] || {};
return settings.inBackground === true;
}
function renderWidget(widget, domEl) {
var prevRendered = rendered[widget.id];
if (prevRendered && prevRendered.widget.mtime === widget.mtime) {
return;
} else if (prevRendered) {
prevRendered.instance.update(widget);
prevRendered.widget = widget;
} else {
var instance = Widget(widget);
domEl.appendChild(instance.create());
rendered[widget.id] = {
instance: instance,
widget: widget,
};
}
}
function destroyWidget(id) {
rendered[id].instance.destroy();
delete rendered[id];
}
function render(state, screen, domEl, dispatch) {
const remaining = Object.keys(rendered);
for (var id in state.widgets) {
const widget = state.widgets[id];
if (!isVisibleOnScreen(id, screen.id, state)) continue;
if (
screen.layer &&
(screen.layer === 'background') != isInBackground(id, state)
)
continue;
if (widget.error || widget.implementation)
renderWidget(widget, domEl, dispatch);
const idx = remaining.indexOf(widget.id);
if (idx > -1) remaining.splice(idx, 1);
}
remaining.forEach((obsolete) => destroyWidget(obsolete));
}
render.rendered = rendered;
module.exports = render;

View File

@@ -0,0 +1,45 @@
var raf = require('raf');
module.exports = function RenderLoop(initialState, render) {
var currentState = null;
var redrawScheduled = false;
var inRenderingTransaction = false;
var loop = {
state: initialState,
update: update,
};
function update(state) {
if (inRenderingTransaction) {
throw Error("can't update while rendering");
}
if (currentState === null && !redrawScheduled) {
redrawScheduled = true;
raf(redraw);
}
currentState = state;
loop.state = currentState;
return loop;
}
function redraw() {
redrawScheduled = false;
if (currentState === null) {
return;
}
inRenderingTransaction = true;
try {
render(currentState);
} catch (err) {
console.error(err);
}
inRenderingTransaction = false;
currentState = null;
}
return update(initialState);
};

View File

@@ -0,0 +1,33 @@
'use strict';
function isWidgetPath(filePath) {
return (
filePath.indexOf('/node_modules/') === -1 &&
filePath.indexOf('/src/') === -1 &&
filePath.indexOf('/lib/') === -1 &&
/\.coffee$|\.js$|\.jsx$/.test(filePath)
);
}
function widgetId(filePath, rootPath) {
const fileParts = filePath
.replace(rootPath, '')
.split(/\/+/)
.filter((part) => !!part);
return fileParts.join('-')
.replace(/\./g, '-')
.replace(/\s/g, '_');
}
module.exports = function resolveWidget(fileEvent) {
if (!isWidgetPath(fileEvent.filePath)) {
return undefined;
}
return {
id: widgetId(fileEvent.filePath, fileEvent.rootPath),
filePath: fileEvent.filePath,
type: fileEvent.type,
};
};

View File

@@ -0,0 +1,15 @@
'use strict';
const runShellCommand = require('./runShellCommand');
module.exports = function runCommand(widget, callback, dispatch) {
const {command, refreshFrequency} = widget;
if (typeof command === 'function') {
command.apply(widget, [callback]);
} else if (typeof command === 'string') {
runShellCommand(command, callback).timeout(refreshFrequency);
} else {
callback();
}
};

View File

@@ -0,0 +1,22 @@
const post = require('superagent').post;
function wrapError(err, res) {
return err ? new Error((res || {}).text || 'error running command') : null;
}
function isKeepAliveError(err) {
return err && err.message.indexOf('Request has been terminated') === 0;
}
module.exports = function runShellCommand(command, callback) {
const request = post('/run/')
.retry(2, isKeepAliveError)
.send(command);
return callback
? request.end((err, res) => callback(wrapError(err, res), (res || {}).text))
: request
.catch((err) => {
throw wrapError(err, err.response);
})
.then((res) => res.text);
};

View File

@@ -0,0 +1,14 @@
'use strict';
const fs = require('fs');
const path = require('path');
const stream = require('stream');
module.exports = (publicDir) => {
const indexHTML = fs.readFileSync(path.join(publicDir, 'index.html'));
return function serveClient(req, res, next) {
const bufferStream = new stream.PassThrough();
bufferStream.pipe(res);
bufferStream.end(indexHTML);
};
};

View File

@@ -0,0 +1,15 @@
const fs = require('fs');
const path = require('path');
const urls = require('url');
module.exports = (widgetsDir) => (req, res, next) => {
const url = urls.parse(req.url);
if (url.pathname !== '/userMain.css') return next();
fs.ReadStream(path.join(widgetsDir, 'main.css'))
.on('error', (err) => {
if (err.code !== 'ENOENT') throw err;
res.end('');
})
.pipe(res);
};

View File

@@ -0,0 +1,79 @@
const URL = require('url');
const fs = require('fs');
const SourceMapConsumer = require('source-map').SourceMapConsumer;
const convert = require('convert-source-map');
const {Transform} = require('stream');
const byline = require('byline');
const path = require('path');
// middleware to serve widget bundles
module.exports = (bundler, widgetPath) => (req, res, next) => {
const url = URL.parse(req.url, true);
const match = url.pathname.match(/\/widgets\/(.+)$/);
if (match) {
const code = bundler.get(match[1]);
if (!code) {
res.writeHead(404);
return res.end();
}
return url.search
? codeLines(code, widgetPath, url.query, res)
: res.end(code);
}
return next();
};
function asErrorJSON(codeLocation, padding) {
const lineNum = codeLocation.line;
let i = 0;
let lines = [];
return new Transform({
transform(line, _, next) {
if (i >= lineNum - padding && i < lineNum + padding) {
lines.push({lineNum: i + 1, line: line.toString()});
}
i++;
next();
},
flush(done) {
done(null, JSON.stringify({
line: codeLocation.line,
column: codeLocation.column,
lines: lines,
path: codeLocation.path,
}));
},
});
}
function codeLines(source, widgetDir, options, res) {
const padding = 5;
const lineNum = Number(options.line) || 0;
const column = Number(options.column) || 0;
const converter = convert.fromSource(source);
if (!converter) {
res.writeHead(404);
res.end('could not find sourcemap comment');
return;
}
SourceMapConsumer.with(converter.toObject(), null, (smc) => {
var origpos = smc.originalPositionFor({ line: lineNum, column: column });
if (!origpos.source) {
res.writeHead(404);
res.end('no match found for line ' + lineNum + ':' + column + '\n');
return;
}
origpos.path = path.relative(widgetDir, origpos.source);
byline(fs.createReadStream(origpos.source), {keepEmptyLines: true})
.pipe(asErrorJSON(origpos, padding))
.pipe(res)
.on('error', err => {
res.writeHead(500);
res.end(err.message);
});
});
}

View File

@@ -0,0 +1,7 @@
import run from './runShellCommand';
import request from 'superagent';
import {css} from 'emotion';
import styled from '@emotion/styled';
import React from 'react';
export {run, request, css, styled, React};

View File

@@ -0,0 +1,23 @@
'use strict';
function validateHasCommand(impl, issues, message) {
if (impl.refreshFrequency === false) {
return;
}
if (typeof impl.command !== 'string' && impl.command !== 'function') {
issues.push(message);
}
}
module.exports = function validateWidget(impl) {
const issues = [];
if (impl) {
validateHasCommand(impl, issues, 'no command given');
} else {
issues.push('empty implementation');
}
return issues;
};

View File

@@ -0,0 +1,180 @@
var through = require('through2');
var path = require('path');
var fs = require('fs');
module.exports = watchify;
module.exports.args = {
cache: {}, packageCache: {}
};
function watchify (b, opts) {
if (!opts) opts = {};
var cache = b._options.cache;
var pkgcache = b._options.packageCache;
var delay = typeof opts.delay === 'number' ? opts.delay : 0;
var changingDeps = {};
var pending = false;
var updating = false;
var mtimes = {};
var wopts = {persistent: true};
if (opts.ignoreWatch) {
wopts.ignored = opts.ignoreWatch !== true
? opts.ignoreWatch
: '**/node_modules/**';
}
if (opts.poll || typeof opts.poll === 'number') {
wopts.usePolling = true;
wopts.interval = opts.poll !== true
? opts.poll
: undefined;
}
if (cache) {
b.on('reset', collect);
collect();
}
function collect () {
b.pipeline.get('deps').push(through.obj(function(row, enc, next) {
var file = row.expose ? b._expose[row.id] : row.file;
cache[file] = {
source: row.source,
deps: Object.assign({}, row.deps)
};
this.push(row);
next();
}));
}
b.on('file', function (file) {
watchFile(file);
});
b.on('package', function (pkg) {
var file = path.join(pkg.__dirname, 'package.json');
if (fs.existsSync(file)) {
watchFile(file);
}
if (pkgcache) pkgcache[file] = pkg;
});
b.on('reset', reset);
reset();
function reset () {
var time = null;
var bytes = 0;
b.pipeline.get('record').on('end', function () {
time = Date.now();
});
b.pipeline.get('wrap').push(through(write, end));
function write (buf, enc, next) {
bytes += buf.length;
this.push(buf);
next();
}
function end () {
var delta = Date.now() - time;
b.emit('time', delta);
b.emit('bytes', bytes);
b.emit('log', bytes + ' bytes written ('
+ (delta / 1000).toFixed(2) + ' seconds)'
);
this.push(null);
}
}
var fwatchers = {};
var fwatcherFiles = {};
var ignoredFiles = {};
b.on('transform', function (tr, mfile) {
tr.on('file', function (dep) {
watchFile(mfile, dep);
});
});
b.on('bundle', function (bundle) {
updating = true;
bundle.on('error', onend);
bundle.on('end', onend);
function onend () { updating = false }
});
function watchFile (file, dep) {
dep = dep || file;
if (!fwatchers[file]) fwatchers[file] = [];
if (!fwatcherFiles[file]) fwatcherFiles[file] = [];
if (fwatcherFiles[file].indexOf(dep) >= 0) return;
var w = b._watcher(dep, wopts);
w.setMaxListeners(0);
w.on('error', b.emit.bind(b, 'error'));
w.on('change', function () {
invalidate(file);
});
fwatchers[file].push(w);
fwatcherFiles[file].push(dep);
}
function getMTime(filePath) {
var mtime;
try {
fs.statSync(filePath).mtime.getTime();
} catch (e) {
if (e.code === 'ENOENT') {
mtime = new Date().getTime();
} else {
throw(e);
}
}
return mtime;
}
function invalidate (id) {
var mtime = getMTime(id);
if ((mtimes[id] || 0) >= mtime) return;
mtimes[id] = mtime;
if (cache) delete cache[id];
if (pkgcache) delete pkgcache[id];
changingDeps[id] = true;
if (!updating && fwatchers[id]) {
fwatchers[id].forEach(function (w) {
w.close();
});
delete fwatchers[id];
delete fwatcherFiles[id];
}
// wait for the disk/editor to quiet down first:
if (pending) clearTimeout(pending);
pending = setTimeout(notify, delay);
}
function notify () {
if (updating) {
pending = setTimeout(notify, delay);
} else {
pending = false;
b.emit('update', Object.keys(changingDeps));
changingDeps = {};
}
}
b.close = function () {
Object.keys(fwatchers).forEach(function (id) {
fwatchers[id].forEach(function (w) { w.close() });
});
};
b._watcher = function (file, opts) {
return fs.watch(file, opts);
};
return b;
}

View File

@@ -0,0 +1,132 @@
var through = require('through2');
var esprima = require('esprima');
var escodegen = require('escodegen');
var stylus = require('stylus');
var nib = require('nib');
var ms = require('ms');
function addExports(node) {
var widgetObjectExp = node.expression;
node.expression = {
type: 'AssignmentExpression',
operator: '=',
left: { type: 'Identifier', name: 'module.exports' },
right: widgetObjectExp,
};
}
function addId(widgetObjectExp, widetId) {
var idProperty = {
type: 'Property',
key: { type: 'Identifier', name: 'id' },
value: { type: 'Literal', value: widetId },
computed: false,
};
widgetObjectExp.properties.push(idProperty);
}
function flattenStyle(styleProp, tree) {
var preface = {
type: 'Program',
body: tree.body.slice(0, -1),
};
preface.body.push({
type: 'ExpressionStatement',
expression: styleProp.value,
});
return eval(escodegen.generate(preface));
}
function parseStyle(styleProp, widetId, tree) {
var styleString;
if (styleProp.value.type === 'Literal') {
styleString = styleProp.value.value;
} else {
styleString = flattenStyle(styleProp, tree);
}
if (typeof styleString !== 'string') {
return;
}
var scopedStyle = '#' + widetId
+ '\n '
+ styleString.replace(/\n/g, '\n ');
var css = stylus(scopedStyle)
.import('nib')
.use(nib())
.render();
styleProp.key.name = 'css';
styleProp.value.type = 'Literal';
styleProp.value.value = css;
}
function parseRefreshFrequency(prop) {
if (typeof prop.value.value === 'string') {
prop.value.value = ms(prop.value.value);
}
}
function parseWidgetProperty(prop, widgetId, tree) {
switch (prop.key.name) {
case 'style': parseStyle(prop, widgetId, tree); break;
case 'refreshFrequency': parseRefreshFrequency(prop); break;
}
}
function modifyAST(tree, widgetId) {
var widgetObjectExp = getWidgetObjectExpression(tree);
if (widgetObjectExp) {
widgetObjectExp.properties.map(function(prop) {
parseWidgetProperty(prop, widgetId, tree);
});
addId(widgetObjectExp, widgetId);
addExports(tree.body[tree.body.length - 1]);
}
return tree;
}
function getWidgetObjectExpression(tree) {
var lastStatement = tree.body[tree.body.length - 1];
if (lastStatement && lastStatement.type === 'ExpressionStatement' ) {
var widgetObjectExp = lastStatement.expression;
if (widgetObjectExp.type === 'ObjectExpression') {
return widgetObjectExp;
}
}
return undefined;
}
module.exports = function(file, options) {
var widgetId = options.id;
var src = '';
function write(buf, enc, next) { src += buf; next(); }
function end(next) {
var tree;
try {
tree = esprima.parse(src);
if (tree) {
this.push(escodegen.generate(modifyAST(tree, widgetId)));
}
} catch (e) {
this.emit('error', e);
}
next();
}
return through(write, end);
};

View File

@@ -0,0 +1 @@
4fa758e9-532d-45cc-b203-d99026f2950b

View File

@@ -0,0 +1,60 @@
import { css, run } from "uebersicht"
export const command = "ls -a"
export const refreshFrequency = 100000 // ms
export const className = css`
left: 40px;
top: 50px;
`
const hello = css`
font-size: 40px;
color: #fff;
`
const liclass = css`
color: #fff;
`
// 定义变量
export const initialState = {
count: 2,
list: [
{ id: 1, title: '你好1' },
{ id: 2, title: '你好2' },
{ id: 3, title: '你好3' },
]
};
// 初始化函数
export const init = (dispatch) => {
console.log("init run....")
}
// 更新数据状态,刷新页面
export const updateState = (event, previousState) => {
console.log("event: ", event);
console.log("previousState: ", previousState);
return Object.assign(previousState,event);
}
export const render = ({ output, count, list }, dispatch) => (
<div>
<h1 className={hello}>测试{count}</h1>
<h1 className={hello}>你好世界{output}</h1>
<ul>
{
list.map((e,i) => {
return <li className={liclass} key={i}>{e.title}</li>
})
}
</ul>
<button onClick={() => {
count+=1;
dispatch({ count: count })
console.log("点击事件");
}}>button按钮 - 点我</button>
</div>
)

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB