├── .gitignore ├── Makefile ├── README.md ├── app.js ├── client ├── blob.js ├── keymap.js └── main.js ├── computer.js ├── emu-runner.js ├── emu.js ├── io.js ├── package.json ├── presence.js ├── public ├── blank.gif ├── build.js ├── css │ └── main.css ├── laptop.jpg └── macbook.png ├── qemu.js ├── redis.js ├── turn.js ├── views └── index.mustache └── vnc.js /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | 5 | # Runtime data 6 | pids 7 | *.pid 8 | *.seed 9 | 10 | # Directory for instrumented libs generated by jscoverage/JSCover 11 | lib-cov 12 | 13 | # Coverage directory used by tools like istanbul 14 | coverage 15 | 16 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 17 | .grunt 18 | 19 | # Compiled binary addons (http://nodejs.org/api/addons.html) 20 | build/Release 21 | 22 | # Dependency directory 23 | # Deployed apps should consider commenting this line out: 24 | # see https://npmjs.org/doc/faq.html#Should-I-check-my-node_modules-folder-into-git 25 | node_modules 26 | 27 | .DS_STORE 28 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | build: 2 | ./node_modules/browserify-middleware/node_modules/browserify/bin/cmd.js client/main.js > public/build.js 3 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | # socket.io-computer 3 | 4 | A collaborative virtual machine where players take turns in 5 | controlling it. 6 | 7 | It works by running [qemu](http://wiki.qemu.org/Main_Page) on the 8 | server-side and streaming the image binary data to the browser. 9 | 10 | ![](https://i.cloudup.com/eLzCA3vYK5.gif) 11 | 12 | ## Dependencies 13 | 14 | In order to run `socket.io-computer` you must have the following 15 | dependencies installed: 16 | 17 | - `qemu` 18 | - `redis-server` 19 | 20 | On the mac, all of the above are available on [homebrew](http://brew.sh/). 21 | 22 | ## How to run 23 | 24 | First you should create an image onto which you'll load (install) the 25 | operating system ISO. We'll call it for this example `winxp.img`. 26 | 27 | ```bash 28 | $ qemu-img create -f qcow2 winxp.img 3G 29 | ``` 30 | 31 | Then you can run the additional needed processes: 32 | 33 | ```bash 34 | # web server 35 | $ node app.js 36 | 37 | # io server 38 | $ node io.js 39 | 40 | # qemu instance 41 | $ COMPUTER_ISO=winxp.iso COMPUTER_IMG=winxp.img node qemu.js 42 | 43 | # emulator communication process 44 | $ COMPUTER_IMG=winxp.img node emu-runner.js 45 | ``` 46 | 47 | Then point your browser to `http://localhost:5000`. 48 | 49 | ## License 50 | 51 | MIT 52 | -------------------------------------------------------------------------------- /app.js: -------------------------------------------------------------------------------- 1 | 2 | var browserify = require('browserify-middleware'); 3 | var mustache = require('mustache-express'); 4 | var express = require('express'); 5 | var app = express(); 6 | var redis = require('./redis').web(); 7 | 8 | var port = process.env.COMPUTER_IO_WEB_PORT || 5000; 9 | 10 | process.title = 'socket.io-computer'; 11 | 12 | app.listen(port); 13 | console.log('listening on *:' + port); 14 | 15 | app.engine('mustache', mustache()); 16 | app.set('views', __dirname + '/views'); 17 | 18 | if ('development' == process.env.NODE_ENV) { 19 | app.use('/build.js', browserify('./client/main.js')); 20 | } 21 | 22 | app.use(express.static(__dirname + '/public')); 23 | 24 | app.use(function(req, res, next) { 25 | if (req.socket.listeners('error').length) return next(); 26 | req.socket.on('error', function(err) { 27 | console.error(err.stack); 28 | }); 29 | next(); 30 | }); 31 | 32 | var url = process.env.COMPUTER_IO_URL || 'http://localhost:6001'; 33 | app.get('/', function(req, res, next) { 34 | redis.get('computer:frame', function(err, image) { 35 | if (err) return next(err); 36 | redis.get('computer:connections-total', function(err, total) { 37 | if (err) return next(err); 38 | res.render('index.mustache', { 39 | img: image ? image.toString('base64') : '', 40 | count: total, 41 | io: url 42 | }); 43 | }); 44 | }); 45 | }); 46 | -------------------------------------------------------------------------------- /client/blob.js: -------------------------------------------------------------------------------- 1 | /*global URL*/ 2 | 3 | /* dependencies */ 4 | var Blob = require('blob'); 5 | 6 | module.exports = blobToImage; 7 | 8 | function blobToImage(imageData) { 9 | if (Blob && 'undefined' != typeof URL) { 10 | var blob = new Blob([imageData], {type: 'image/jpeg'}); 11 | return URL.createObjectURL(blob); 12 | } else if (imageData.base64) { 13 | return 'data:image/jpeg;base64,' + imageData.data; 14 | } else { 15 | return 'about:blank'; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /client/keymap.js: -------------------------------------------------------------------------------- 1 | 2 | var shifting = module.exports.shifting = false; 3 | var ctrling = module.exports.ctrling = false; 4 | var alting = module.exports.alting = false; 5 | 6 | module.exports.blankState = '0'; // when a mouse is not pressed 7 | 8 | // maps javascript keycodes to qemu key names 9 | var keymap = module.exports.keymap = { 10 | 8: 'backspace' 11 | , 9: 'tab' 12 | , 13: 'ret' 13 | , 16: 'shift' 14 | , 17: 'ctrl' 15 | , 18: 'alt' 16 | , 19: '?'// pause 17 | , 20: 'caps_lock' 18 | , 27: 'esc' 19 | , 32: 'spc' 20 | , 33: 'pgup' 21 | , 34: 'pgdn' 22 | , 35: 'end' 23 | , 36: 'home' 24 | , 37: 'left' 25 | , 38: 'up' 26 | , 39: 'right' 27 | , 40: 'down' 28 | , 44: 'print' 29 | , 45: 'insert' 30 | , 46: 'delete' 31 | , 48: '0' 32 | , 49: '1' 33 | , 50: '2' 34 | , 51: '3' 35 | , 52: '4' 36 | , 53: '5' 37 | , 54: '6' 38 | , 55: '7' 39 | , 56: '8' 40 | , 57: '9' 41 | , 59: 'semicolon' 42 | , 61: 'equal' 43 | , 65: 'a' 44 | , 66: 'b' 45 | , 67: 'c' 46 | , 68: 'd' 47 | , 69: 'e' 48 | , 70: 'f' 49 | , 71: 'g' 50 | , 72: 'h' 51 | , 73: 'i' 52 | , 74: 'j' 53 | , 75: 'k' 54 | , 76: 'l' 55 | , 77: 'm' 56 | , 78: 'n' 57 | , 79: 'o' 58 | , 80: 'p' 59 | , 81: 'q' 60 | , 82: 'r' 61 | , 83: 's' 62 | , 84: 't' 63 | , 85: 'u' 64 | , 86: 'v' 65 | , 87: 'w' 66 | , 88: 'x' 67 | , 89: 'y' 68 | , 90: 'z' 69 | , 91: 'ctrl' // left command 70 | , 93: 'ctrl' // right command 71 | , 107: 'equal' 72 | , 109: 'minus' 73 | , 112: 'f1' 74 | , 113: 'f2' 75 | , 114: 'f3' 76 | , 115: 'f4' 77 | , 116: 'f5' 78 | , 117: 'f6' 79 | , 118: 'f7' 80 | , 119: 'f8' 81 | , 120: 'f9' 82 | , 121: 'f10' 83 | , 122: 'f11' 84 | , 123: 'f12' 85 | , 144: 'num_lock' 86 | , 145: 'scroll_lock' 87 | , 186: 'semicolon' 88 | , 187: 'equal' 89 | , 188: 'comma' 90 | , 189: 'minus' 91 | , 190: 'dot' 92 | , 191: 'slash' 93 | , 192: 'apostrophe' 94 | , 219: 'bracket_left' 95 | , 220: 'backslash' 96 | , 221: 'bracket_right' 97 | , 222: '\'' 98 | , 224: 'ctrl' // command in firefox 99 | } 100 | 101 | // returns the string to send to the qemu sendkey api from a javascript 102 | // key-event keycode. 103 | module.exports.qemukey = function(keycode) { 104 | var mapping = keymap[keycode]; 105 | if (!mapping) return null; 106 | 107 | if (mapping == 'shift') { 108 | shifting = true; return null; 109 | } else if (mapping == 'ctrl') { 110 | ctrling = true; return null; 111 | } else if (mapping == 'alt') { 112 | alting = true; return null; 113 | } 114 | 115 | var prefix = ''; 116 | if (shifting) prefix += 'shift-'; 117 | if (ctrling) prefix += 'ctrl-'; 118 | if (alting) prefix += 'alt-'; 119 | 120 | return prefix + mapping; 121 | } 122 | 123 | module.exports.keyup = function(keycode) { 124 | var mapping = keymap[keycode]; 125 | 126 | if (mapping == 'shift') { 127 | shifting = false; 128 | } else if (mapping == 'ctrl'){ 129 | ctrling = false; 130 | } else if (mapping == 'alt') { 131 | alting = false; 132 | } 133 | } 134 | 135 | // takes a mouse click event and returns qemu state of mouse 136 | module.exports.mouseclick = function(ev) { 137 | switch (ev.which) { 138 | case 1: return '1'; 139 | case 2: return '4'; 140 | case 3: return '2'; 141 | default: return '1'; 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /client/main.js: -------------------------------------------------------------------------------- 1 | /*global config,URL*/ 2 | 3 | var $ = require('jquery'); 4 | var io = require('socket.io-client')(config.io); 5 | var keymap = require('./keymap'); 6 | var blobToImage = require('./blob'); 7 | var Queue = require('queue3'); 8 | 9 | var xp = $('.xp-image'); 10 | var imWidth = parseInt(xp.width(), 10); 11 | var imHeight = parseInt(xp.height(), 10); 12 | var chWidth = 650; 13 | var chHeight = 440; 14 | var TURN_TIME = 15000; 15 | 16 | var natWidth = 800; 17 | var natHeight = 600; 18 | 19 | var focused = false; 20 | var waitingForTurn = false; 21 | var hasTurn = false; 22 | var turnInt; 23 | 24 | function inRect(rect, ev) { 25 | // iphone 26 | if (ev.originalEvent) ev = ev.originalEvent; 27 | if (null == ev.clientX) { 28 | ev.clientX = ev.layerX; 29 | ev.clientY = ev.layerY; 30 | } 31 | 32 | return ev.clientX > rect.left && ev.clientX < rect.right 33 | && ev.clientY > rect.top && ev.clientY < rect.bottom; 34 | } 35 | 36 | var turnRequestPos; 37 | var buttonsState = 0; 38 | 39 | function checkFocus(ev) { 40 | var rect = xp.get(0).getBoundingClientRect(); 41 | if (!focused && inRect(rect, ev)) { 42 | 43 | var pos = getPos(ev); 44 | 45 | if(!hasTurn && !waitingForTurn) { 46 | waitingForTurn = true; 47 | io.emit('turn-request', new Date()); 48 | xp.addClass('waiting'); 49 | turnRequestPos = pos; 50 | return focused; 51 | } 52 | 53 | focused = true; 54 | xp.addClass('focused'); 55 | io.emit('pointer', pos.x, pos.y, buttonsState); 56 | } else if (focused && !inRect(rect, ev)) { 57 | focused = false; 58 | xp.removeClass('focused'); 59 | } 60 | return focused; 61 | } 62 | 63 | function giveTurn() { 64 | focused = true; 65 | hasTurn = true; 66 | waitingForTurn = false; 67 | xp.removeClass('waiting'); 68 | xp.addClass('focused'); 69 | $('body').css('cursor', 'crosshair'); 70 | if (turnInt) { 71 | clearInterval(turnInt); 72 | $('.turn-timer').html(''); 73 | } 74 | waitingTimer('Your turn expires', TURN_TIME, false); 75 | var pos = turnRequestPos; 76 | io.emit('pointer', pos.x, pos.y, buttonsState); 77 | } 78 | 79 | function removeTurn() { 80 | hasTurn = false; 81 | focused = false; 82 | if (turnInt) { 83 | clearInterval(turnInt); 84 | $('.turn-timer').html(''); 85 | } 86 | xp.removeClass('focused'); 87 | $('body').css('cursor', 'default'); 88 | } 89 | 90 | function getPos(ev) { 91 | var rect = xp.get(0).getBoundingClientRect(); 92 | 93 | // iphone 94 | if (ev.originalEvent) ev = ev.originalEvent; 95 | if (null == ev.clientX) { 96 | ev.clientX = ev.layerX; 97 | ev.clientY = ev.layerY; 98 | } 99 | 100 | var x = ev.clientX - rect.left; 101 | var y = ev.clientY - rect.top; 102 | 103 | //x *= natWidth / imWidth; 104 | //y *= natHeight / imHeight; 105 | 106 | if (x < 0) x = 0; 107 | if (y < 0) y = 0; 108 | if (x > imWidth) x = imWidth; 109 | if (y > imHeight) y = imHeight; 110 | 111 | var pos = {x: Math.round(x / imWidth * natWidth), y: Math.round(y / imHeight * natHeight)}; 112 | return pos; 113 | } 114 | 115 | io.on('your-turn', function() { 116 | giveTurn(); 117 | }); 118 | 119 | io.on('lose-turn', function() { 120 | removeTurn(); 121 | }); 122 | 123 | io.on('turn-ack', function(time) { 124 | waitingTimer('Waiting for turn', time, true); 125 | }); 126 | 127 | function waitingTimer(text, ms, dot) { 128 | var dots = ''; 129 | turnInt = setInterval(function() { 130 | ms -= 1000; 131 | var seconds = Math.floor(ms / 1000); 132 | if (seconds <= 0) { 133 | clearInterval(turnInt); 134 | $('.turn-timer').html(''); 135 | } else { 136 | if (dots.length < 3) 137 | dots += '.'; 138 | else 139 | dots = ''; 140 | 141 | var str = text + ' in ~' + seconds + ' seconds'; 142 | if (dot) str += dots; 143 | $('.turn-timer').html(str); 144 | } 145 | }, 1000); 146 | } 147 | 148 | $(document).keydown(function(ev) { 149 | if (!focused || !hasTurn) return; 150 | 151 | ev.preventDefault(); 152 | var qemuKey = keymap.qemukey(ev.keyCode); 153 | if(qemuKey) { 154 | io.emit('keydown', qemuKey); 155 | } 156 | }); 157 | 158 | $(document).keyup(function(ev) { 159 | if (!focused || !hasTurn) return; 160 | 161 | ev.preventDefault(); 162 | keymap.keyup(ev.keyCode); 163 | }); 164 | 165 | $(document).mousemove(function(ev) { 166 | if (!hasTurn) return; 167 | 168 | var rect = xp.get(0).getBoundingClientRect(); 169 | if (inRect(rect, ev)) { 170 | $('body').css('cursor', 'crosshair'); 171 | } else { 172 | $('body').css('cursor', 'default'); 173 | return; 174 | } 175 | 176 | if (!natWidth || !natHeight) { 177 | return; 178 | } 179 | 180 | var pos = getPos(ev); 181 | io.emit('pointer', pos.x, pos.y, buttonsState); 182 | }); 183 | 184 | var eventDown = 'ontouchstart' in document ? 'touchstart' : 'mousedown'; 185 | 186 | $(document).bind(eventDown, function(ev) { 187 | buttonsState |= getMouseMask(ev); 188 | 189 | //if (!checkFocus(ev)) return; 190 | checkFocus(ev); 191 | if (!hasTurn) return; 192 | 193 | ev.preventDefault(); 194 | 195 | var pos = getPos(ev); 196 | io.emit('pointer', pos.x, pos.y, buttonsState); 197 | 198 | if ('ontouchend' in document) { 199 | buttonsState ^= getMouseMask(ev); 200 | io.emit('pointer', pos.x, pos.y, buttonsState); 201 | } 202 | }); 203 | 204 | if ('onmouseup' in document) { 205 | $(document).mouseup(function(ev) { 206 | buttonsState ^= getMouseMask(ev); 207 | 208 | //if (!focused || !hasTurn) return; 209 | if(!hasTurn) return; 210 | 211 | ev.preventDefault(); 212 | 213 | // click is finished 214 | var pos = getPos(ev); 215 | io.emit('pointer', pos.x, pos.y, buttonsState); 216 | }); 217 | } 218 | 219 | function getMouseMask(ev){ 220 | if ('ontouchstart' in document) return 1; 221 | 222 | var bmask; 223 | // from novnc 224 | if (ev.which) { 225 | /* everything except IE */ 226 | bmask = 1 << ev.button; 227 | } else { 228 | /* IE including 9 */ 229 | bmask = (ev.button & 0x1) + // Left 230 | (ev.button & 0x2) * 2 + // Right 231 | (ev.button & 0x4) / 2; // Middle 232 | } 233 | return bmask; 234 | } 235 | 236 | xp.bind('contextmenu', function(e){ 237 | return false; 238 | }); 239 | 240 | var lastImage; 241 | var replaced; 242 | var canvas; 243 | var ctx; 244 | 245 | var image = $('.xp-image img'); 246 | var queue = new Queue({ concurrency: 1 }); 247 | 248 | io.on('raw', function(frame) { 249 | queue.push(function(fn){ 250 | var src = blobToImage(frame.image); 251 | if (!src) return; 252 | 253 | var img = document.createElement('img'); 254 | img.src = src; 255 | img.onload = function(){ 256 | if (!replaced) { 257 | canvas = document.createElement('canvas'); 258 | canvas.width = natWidth; 259 | canvas.height = natHeight; 260 | image.replaceWith(canvas); 261 | ctx = canvas.getContext('2d'); 262 | replaced = true; 263 | } 264 | 265 | ctx.drawImage(img, frame.x, frame.y); 266 | 267 | if ('undefined' != typeof URL) { 268 | URL.revokeObjectURL(src); 269 | } 270 | 271 | fn(); 272 | }; 273 | }); 274 | }); 275 | 276 | io.on('copy', function(rect){ 277 | queue.push(function(fn){ 278 | var imgData = ctx.getImageData(rect.src.x, rect.src.y, rect.width, rect.height); 279 | ctx.putImageData(imgData, rect.x, rect.y); 280 | fn(); 281 | }); 282 | }); 283 | 284 | io.on('connections', function(count) { 285 | console.log('got connections: ' + count); 286 | $('.count').text(count); 287 | }); 288 | -------------------------------------------------------------------------------- /computer.js: -------------------------------------------------------------------------------- 1 | 2 | var Emitter = require('events').EventEmitter; 3 | var sys = require('sys'); 4 | var fs = require('fs'); 5 | var exec = require('child_process').exec; 6 | var VNC = require('./vnc'); 7 | var Canvas = require('canvas'); 8 | var debug = require('debug')('computer:computer'); 9 | var net = require('net'); 10 | 11 | var hostName = process.env.COMPUTER_VNC_HOST || '127.0.0.1'; 12 | var displayNum = process.env.COMPUTER_DISPLAY || '0'; 13 | var port = 5900 + parseInt(displayNum, 10); 14 | var tcp = process.env.COMPUTER_TCP || '127.0.0.1:4444'; 15 | 16 | module.exports = Computer; 17 | 18 | function Computer() { 19 | if (!(this instanceof Computer)) return new Computer(); 20 | this.running = false; 21 | this.img = process.env.COMPUTER_IMG || null; 22 | } 23 | 24 | Computer.prototype.__proto__ = Emitter.prototype; 25 | 26 | Computer.prototype.closed = function() { 27 | this.running = false; 28 | setTimeout(this.run, 100); 29 | return; 30 | }; 31 | 32 | Computer.prototype.run = function() { 33 | var self = this; 34 | 35 | try { 36 | this.vnc = new VNC(hostName, port); 37 | } catch(e) { 38 | console.log('connection error'); 39 | return self.closed(); 40 | } 41 | 42 | try { 43 | var split = tcp.split(':'); 44 | var tcpHost = split[0]; 45 | var tcpPort = split[1]; 46 | this.tcp = net.connect({host: tcpHost, port: tcpPort}); 47 | this.tcp.on('end', function() { 48 | return self.closed(); 49 | }); 50 | } catch(e) { 51 | console.log('tcp connection error'); 52 | return self.closed(); 53 | } 54 | 55 | this.vnc.on('copy', function(rect){ 56 | self.emit('copy', rect); 57 | }); 58 | 59 | this.vnc.on('raw', function(frame){ 60 | self.emit('raw', frame); 61 | }); 62 | 63 | this.vnc.on('frame', function(frame){ 64 | self.emit('frame', frame); 65 | }); 66 | 67 | this.running = true; 68 | }; 69 | 70 | // saves a snapshot of disk image to the given filename 71 | Computer.prototype.snapshot = function(name) { 72 | if (!this.running || !this.img) return; 73 | 74 | var command = 'qemu-img create -f qcow2 -b ' + this.img + ' ' + name; 75 | exec(command, function(error, stdout, stderr) { 76 | if (!error) { 77 | console.log('saved emu to ' + name); 78 | } 79 | }); 80 | }; 81 | 82 | Computer.prototype.pointer = function(x, y, state) { 83 | if (!this.running) return this; 84 | try { 85 | this.vnc.r.pointerEvent(x, y, state); 86 | } catch(e) { 87 | debug('vnc error -- qemu probably down'); 88 | this.closed(); 89 | } 90 | }; 91 | 92 | Computer.prototype.key = function(key) { 93 | if (!this.running) return this; 94 | var command = 'sendkey ' + key + '\n'; 95 | this.tcpWrite(command); 96 | }; 97 | 98 | Computer.prototype.destroy = function(){ 99 | if (this.destroyed) return this; 100 | this.destroyed = true; 101 | this.running = false; 102 | return this; 103 | }; 104 | 105 | Computer.prototype.tcpWrite = function(command) { 106 | try { 107 | this.tcp.write(command); 108 | } catch (e) { 109 | debug('tcp error -- qemu probably down'); 110 | this.closed(); 111 | } 112 | }; 113 | -------------------------------------------------------------------------------- /emu-runner.js: -------------------------------------------------------------------------------- 1 | var forever = require('forever-monitor'); 2 | 3 | function startEmu() { 4 | var child = new (forever.Monitor)('emu.js', { 5 | max: 1, 6 | silent: false, 7 | options: [] 8 | }); 9 | 10 | child.on('exit', function () { 11 | console.log('THE EMULATOR DIED'); 12 | setTimeout(function() { 13 | startEmu(); 14 | }, 2000); 15 | }); 16 | 17 | child.start(); 18 | } 19 | 20 | startEmu(); 21 | -------------------------------------------------------------------------------- /emu.js: -------------------------------------------------------------------------------- 1 | 2 | var fs = require('fs'); 3 | var Computer = require('./computer'); 4 | var crypto = require('crypto'); 5 | var debug = require('debug')('computer:worker'); 6 | var turn = require('./turn'); 7 | 8 | process.title = 'socket.io-computer-emulator'; 9 | 10 | // redis 11 | var redis = require('./redis').emu(); 12 | var sub = require('./redis').emu(); 13 | var io = require('socket.io-emitter')(redis, {key: 'xpemu'}); 14 | 15 | var saveInterval = null; 16 | 17 | // load computer emulator 18 | var emu; 19 | 20 | function load() { 21 | debug('loading emulator'); 22 | emu = new Computer(); 23 | 24 | emu.on('error', function() { 25 | console.log(new Date + ' - restarting emulator'); 26 | emu.destroy(); 27 | setTimeout(load, 1000); 28 | }); 29 | 30 | var state; 31 | 32 | emu.on('raw', function(frame) { 33 | io.emit('raw', frame); 34 | }); 35 | 36 | emu.on('frame', function(buf) { 37 | redis.set('computer:frame', buf); 38 | }); 39 | 40 | emu.on('copy', function(rect) { 41 | io.emit('copy', rect); 42 | }); 43 | 44 | setTimeout(function() { 45 | console.log('running emu'); 46 | emu.run(); 47 | }, 2000); 48 | 49 | function save() { 50 | if (saveInterval) { 51 | debug('will save in %d', saveInterval); 52 | emu.snapshot('xpsnapshot.img'); 53 | setTimeout(save, saveInterval); 54 | } 55 | } 56 | } 57 | 58 | // controlling 59 | sub.subscribe('computer:keydown'); 60 | sub.subscribe('computer:pointer'); 61 | sub.subscribe('computer:turn'); 62 | 63 | sub.on('message', function(channel, data) { 64 | data = data.toString(); 65 | 66 | if ('computer:keydown' == channel) { 67 | // data is a key for send_press 68 | emu.key(data, 0); 69 | } else if ('computer:pointer' == channel) { 70 | // absolute x and y of client 71 | var split = data.split(':'); 72 | var x = parseInt(split[0], 10); 73 | var y = parseInt(split[1], 10); 74 | var state = parseInt(split[2], 10); 75 | emu.pointer(x, y, state); 76 | } else if ('computer:turn' == channel) { 77 | // turn request (data is socket.id) 78 | turn.push(data); 79 | turn.checkQueue(true); 80 | } 81 | }); 82 | 83 | function checksum(str) { 84 | return crypto.createHash('md5').update(str).digest('hex'); 85 | } 86 | 87 | load(); 88 | -------------------------------------------------------------------------------- /io.js: -------------------------------------------------------------------------------- 1 | 2 | var sio = require('socket.io'); 3 | var debug = require('debug'); 4 | 5 | process.title = 'socket.io-computer-io'; 6 | 7 | var port = process.env.COMPUTER_IO_PORT || 6001; 8 | var io = module.exports = sio(port); 9 | console.log('listening on *:' + port); 10 | 11 | // redis socket.io adapter 12 | var uri = require('redis').uri; 13 | io.adapter(require('socket.io-redis')(uri, {key: 'xpemu'})); 14 | 15 | // redis queries instance 16 | var redis = require('./redis').io(); 17 | 18 | var uid = process.env.COMPUTER_IO_SERVER_UID || port; 19 | debug('server uid %s', uid); 20 | 21 | io.total = 0; 22 | io.on('connection', function(socket) { 23 | var req = socket.request; 24 | 25 | // keep track of connected clients 26 | updateClientCount(++io.total); 27 | socket.on('disconnect', function() { 28 | updateClientCount(--io.total); 29 | }); 30 | 31 | // in case user is reconneting send last known state 32 | redis.get('computer:frame', function(err, image) { 33 | if (image) socket.emit('raw', { 34 | width: 800, 35 | height: 600, 36 | x: 0, 37 | y: 0, 38 | image: image 39 | }); 40 | }); 41 | 42 | // send keypress to emulator 43 | socket.on('keydown', function(key) { 44 | redis.publish('computer:keydown', key); 45 | }); 46 | 47 | // pointer events 48 | socket.on('pointer', function(x, y, state) { 49 | redis.publish('computer:pointer', x + ':' + y + ':' + state); 50 | }); 51 | 52 | socket.on('turn-request', function(time) { 53 | redis.publish('computer:turn', socket.id); 54 | }); 55 | }); 56 | 57 | function updateClientCount(total) { 58 | redis.hset('computer:connections', uid, total); 59 | } 60 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "socket.io-computer", 3 | "version": "0.0.0", 4 | "description": "Uses socket.io to do virtual machine usage from browser", 5 | "main": "app.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "repository": { 10 | "type": "git", 11 | "url": "https://github.com/kevin-roark/socket.io-computer.git" 12 | }, 13 | "author": "Kevin Roark", 14 | "license": "ISC", 15 | "bugs": { 16 | "url": "https://github.com/kevin-roark/socket.io-computer/issues" 17 | }, 18 | "homepage": "https://github.com/kevin-roark/socket.io-computer", 19 | "dependencies": { 20 | "blob": "0.0.4", 21 | "browserify-middleware": "2.5.0", 22 | "canvas": "~1.1.3", 23 | "debug": "^0.8.0", 24 | "express": "3.4.8", 25 | "jpeg": "https://github.com/yztaoj/node-jpeg/archive/master.tar.gz", 26 | "jquery": "2.1.0", 27 | "mustache-express": "1.0.1", 28 | "queue3": "1.0.2", 29 | "redis": "0.10.1", 30 | "rfb2": "0.0.9", 31 | "socket.io": "1.3.7", 32 | "socket.io-client": "1.3.7", 33 | "socket.io-emitter": "1.0.0", 34 | "socket.io-redis": "1.0.0", 35 | "forever-monitor": "~1.2.3" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /presence.js: -------------------------------------------------------------------------------- 1 | 2 | // shamelessly lifted from https://github.com/rauchg/weplay-presence 3 | // Thanks guillermo 4 | 5 | var redis = require('./redis').presence(); 6 | var io = require('socket.io-emitter')(redis, {key: 'xpemu'}); 7 | var interval = process.env.COMPUTER_INTERVAL || 5000; 8 | 9 | console.log("\nI'm starting to accumulate connections for you ...\n"); 10 | 11 | setInterval(function() { 12 | redis.hgetall('computer:connections', function(err, counts) { 13 | if (!counts) return; 14 | 15 | var count = 0; 16 | for (var i in counts) count += Number(counts[i]); 17 | 18 | redis.set('computer:connections-total', count); 19 | io.emit('connections', count); 20 | }); 21 | }, interval); 22 | -------------------------------------------------------------------------------- /public/blank.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rauchg/socket.io-computer/dead6230b77f39526210a9f6ecda71b78705968f/public/blank.gif -------------------------------------------------------------------------------- /public/css/main.css: -------------------------------------------------------------------------------- 1 | html, body { 2 | margin: 0; 3 | padding: 0; 4 | } 5 | 6 | body { 7 | width: 100%; 8 | height: 100%; 9 | background-color: #fff; 10 | font-family: Helvetica, sans-serif; 11 | } 12 | 13 | #window-chrome { 14 | display: none; 15 | } 16 | 17 | img { 18 | user-drag: none; 19 | -moz-user-drag: none; 20 | -webkit-user-drag: none; 21 | } 22 | 23 | #xp-window { 24 | /*cursor: none; /* let windows do that ;) */ 25 | background: url(/macbook.png) no-repeat center; 26 | background-size: 100% 100%; 27 | width: 690px; 28 | height: 460px; 29 | margin: auto; 30 | text-align: center; 31 | } 32 | 33 | .xp-image, .xp-image img, .xp-image canvas { 34 | width: 500px; 35 | height: 375px; 36 | display: inline-block; 37 | } 38 | 39 | .xp-image { 40 | margin-top: 38px; 41 | } 42 | 43 | .focused img, .focused canvas { 44 | box-shadow: 0px 0px 9px 0px rgba(45, 213, 255, 0.75); 45 | -moz-box-shadow: 0px 0px 9px 0px rgba(45, 213, 255, 0.75); 46 | -webkit-box-shadow: 0px 0px 9px 0px rgba(45, 213, 255, 0.75); 47 | } 48 | 49 | .waiting img, .waiting canvas { 50 | box-shadow: 0px 0px 9px 0px rgba(242, 255, 63, 0.75); 51 | -moz-box-shadow: 0px 0px 9px 0px rgba(242, 255, 63, 0.75); 52 | -webkit-box-shadow: 0px 0px 9px 0px rgba(242, 255, 63, 0.75); 53 | } 54 | 55 | .turn-timer { 56 | font-size: 13px; 57 | color: #999; 58 | padding: 10px 0; 59 | text-align: center; 60 | } 61 | 62 | .user-count-wrapper { 63 | text-align: center; 64 | position: absolute; 65 | top: 10px; 66 | } 67 | 68 | .user-count { 69 | background-color: #FFFF66; 70 | padding: 4px 12px; 71 | font-size: 12px; 72 | color: #906F95; 73 | } 74 | 75 | .count { 76 | color: #61226B; 77 | padding-right: 12px; 78 | font-weight: 500; 79 | } 80 | 81 | @media only screen and (min-width : 320px) and (max-width : 480px), (max-width : 568px) { 82 | #xp-window { 83 | background: none; 84 | height: auto; 85 | width: auto; 86 | } 87 | 88 | .xp-image { 89 | padding: 5px; 90 | background: #000; 91 | } 92 | 93 | .xp-image, .xp-image img, .xp-image canvas { 94 | width: 310px; 95 | margin-top: 0; 96 | height: 234px; 97 | display: block; 98 | } 99 | 100 | .user-count-wrapper { 101 | position: relative; 102 | } 103 | 104 | .count { 105 | padding: 0; 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /public/laptop.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rauchg/socket.io-computer/dead6230b77f39526210a9f6ecda71b78705968f/public/laptop.jpg -------------------------------------------------------------------------------- /public/macbook.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rauchg/socket.io-computer/dead6230b77f39526210a9f6ecda71b78705968f/public/macbook.png -------------------------------------------------------------------------------- /qemu.js: -------------------------------------------------------------------------------- 1 | 2 | var spawn = require('child_process').spawn; 3 | var join = require('path').join; 4 | var debug = require('debug')('computer:qemu'); 5 | 6 | if (!process.env.COMPUTER_ISO) { 7 | console.log('You must specify the ENV variable `COMPUTER_ISO` ' + 8 | 'to location of iso file to broadcast.'); 9 | process.exit(1); 10 | } 11 | 12 | if (!process.env.COMPUTER_IMG) { 13 | console.log('Must specificy the ENV variable `COMPUTER_IMG` ' + 14 | 'to location of disk image to use'); 15 | process.exit(1); 16 | } 17 | 18 | process.title = 'socket.io-computer-qemu'; 19 | 20 | var displayNum = process.env.COMPUTER_DISPLAY || '0'; 21 | var hostName = process.env.COMPUTER_VNC_HOST || '127.0.0.1'; 22 | var tcp = process.env.COMPUTER_TCP || '127.0.0.1:4444'; 23 | 24 | // iso 25 | var iso = process.env.COMPUTER_ISO; 26 | if ('/' != iso[0]) iso = join(process.cwd(), iso); 27 | debug('iso %s', iso); 28 | 29 | // img 30 | var img = process.env.COMPUTER_IMG; 31 | if ('/' != img[0]) img = join(process.cwd(), img); 32 | debug('img %s', img); 33 | 34 | init(img, iso); 35 | 36 | function init(img, iso) { 37 | var command = 'qemu-system-x86_64'; 38 | var args = [ 39 | '-m', '1024', 40 | '-vnc', hostName + ':' + displayNum, 41 | '-net', 'nic,model=rtl8139', 42 | '-net', 'user', 43 | '-usbdevice', 'tablet', 44 | '-hda', img, 45 | '-cdrom', iso, 46 | '-monitor', 'tcp:' + tcp + ',server,nowait', 47 | '-boot', 'c' 48 | ]; 49 | var options = { 50 | stdio: 'inherit' 51 | }; 52 | 53 | var instance = spawn(command, args, options); 54 | instance.on('close', function(code) { 55 | debug(new Date + ' - qemu closed with code: ' + code); 56 | setTimeout(function() { 57 | init(img, iso); 58 | }, 500); 59 | }); 60 | } 61 | -------------------------------------------------------------------------------- /redis.js: -------------------------------------------------------------------------------- 1 | 2 | var redis = require('redis'); 3 | 4 | var uri = process.env.COMPUTER_REDIS_URI || 'localhost:6379'; 5 | module.exports.uri = uri; 6 | 7 | var pieces = uri.split(':'); 8 | 9 | module.exports.web = function() { 10 | return redis.createClient(pieces[1], pieces[0], {return_buffers: true}); 11 | }; 12 | 13 | module.exports.io = module.exports.web; 14 | 15 | module.exports.emu = module.exports.web; 16 | 17 | module.exports.presence = module.exports.web; 18 | -------------------------------------------------------------------------------- /turn.js: -------------------------------------------------------------------------------- 1 | var turnQueue = []; 2 | var activeTurn = false; 3 | var redis = require('./redis').emu(); 4 | 5 | var TURN_TIME = 15000; 6 | 7 | module.exports.push = function(sockid) { 8 | turnQueue.push(sockid); 9 | } 10 | 11 | module.exports.checkQueue = checkQueue; 12 | function checkQueue(newReq) { 13 | var io = require('socket.io-emitter')(redis, {key: 'xpemu'}); 14 | 15 | if (!activeTurn && turnQueue.length >= 1) { 16 | activeTurn = true; 17 | var sockid = turnQueue.shift(); 18 | io = io.in(sockid); 19 | io.emit('your-turn'); 20 | 21 | setTimeout(function() { 22 | io.emit('lose-turn'); 23 | activeTurn = false; 24 | checkQueue(false); 25 | }, TURN_TIME); 26 | 27 | } else if (newReq) { 28 | var time = turnQueue.length * TURN_TIME; 29 | var sockid = turnQueue[turnQueue.length - 1]; 30 | io = io.in(sockid); 31 | io.emit('turn-ack', time); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /views/index.mustache: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | socket.io makes computers 5 | 6 | 7 | 8 | 9 |

Click the screen to request a turn and control the computer!

10 |
11 |
12 | {{#img}} 13 | XP 14 | {{/img}} 15 | {{^img}} 16 | XP 17 | {{/img}} 18 |
19 |
20 |
21 |
22 | 23 | Users logged in: {{count}} 24 | 25 |
26 | 27 |

28 | To avoid misusage, we re-snapshot the computer every 15 minutes! [Source code] 29 |

30 | 33 | 34 | 35 |

Powered by Socket.IO

36 | 45 | 46 | 47 | -------------------------------------------------------------------------------- /vnc.js: -------------------------------------------------------------------------------- 1 | 2 | var Canvas = require('canvas'); 3 | var Emitter = require('events').EventEmitter; 4 | var rfb = require('rfb2'); 5 | var exec = require('child_process').exec; 6 | var Jpeg = require('jpeg').Jpeg; 7 | var FixedJpegStack = require('jpeg').FixedJpegStack; 8 | var fs = require('fs'); 9 | 10 | var SS_NAME = 'ss.jpg'; 11 | 12 | module.exports = VNC; 13 | 14 | function VNC(host, port) { 15 | 16 | this.host = host; 17 | this.port = port; 18 | this.displayNum = port - 5900; // vnc convention 19 | 20 | this.width = 800; 21 | this.height = 600; 22 | 23 | this.r = rfb.createConnection({ 24 | host: host, 25 | port: port 26 | }); 27 | 28 | var self = this; 29 | this.r.on('rect', this.drawRect.bind(this)); 30 | } 31 | 32 | VNC.prototype.__proto__ = Emitter.prototype; 33 | 34 | function putData(ctx, id, rect) { 35 | ctx.putImageData(id, rect.x, rect.y); 36 | } 37 | 38 | VNC.prototype.drawRect = function(rect) { 39 | if (rect.encoding != 0) { 40 | this.emit('copy', rect); 41 | return; 42 | } 43 | 44 | var date = new Date; 45 | var rgb = new Buffer(rect.width * rect.height * 3); 46 | 47 | for (var i = 0, o = 0; i < rect.data.length; i += 4) { 48 | rgb[o++] = rect.data[i + 2]; 49 | rgb[o++] = rect.data[i + 1]; 50 | rgb[o++] = rect.data[i]; 51 | } 52 | 53 | var self = this; 54 | var image = new Jpeg(rgb, rect.width, rect.height, 'rgb'); 55 | image.encode(function(img, err){ 56 | if (img) self.emit('raw', { 57 | x: rect.x, 58 | y: rect.y, 59 | width: rect.width, 60 | height: rect.height, 61 | image: img 62 | }); 63 | }); 64 | 65 | if (!this.state) { 66 | // first frame 67 | this.state = new FixedJpegStack(this.width, this.height, 'rgb'); 68 | this.state.push(rgb, 0, 0, rect.width, rect.height); 69 | } else { 70 | this.state.push(rgb, rect.x, rect.y, rect.width, rect.height); 71 | } 72 | 73 | this.state.encode(function(img, err) { 74 | if (img) self.emit('frame', img); 75 | }); 76 | }; 77 | --------------------------------------------------------------------------------