FIX: Not all browsers send onreadystatechange for new data (Opera doesn't)
[guacamole-common-js.git] / src / main / resources / guacamole.js
1
2 /*
3  *  Guacamole - Clientless Remote Desktop
4  *  Copyright (C) 2010  Michael Jumper
5  *
6  *  This program is free software: you can redistribute it and/or modify
7  *  it under the terms of the GNU Affero General Public License as published by
8  *  the Free Software Foundation, either version 3 of the License, or
9  *  (at your option) any later version.
10  *
11  *  This program is distributed in the hope that it will be useful,
12  *  but WITHOUT ANY WARRANTY; without even the implied warranty of
13  *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
14  *  GNU Affero General Public License for more details.
15  *
16  *  You should have received a copy of the GNU Affero General Public License
17  *  along with this program.  If not, see <http://www.gnu.org/licenses/>.
18  */
19
20 function GuacamoleClient(display, tunnelURL) {
21
22     var TUNNEL_CONNECT = tunnelURL + "?connect";
23     var TUNNEL_READ    = tunnelURL + "?read";
24     var TUNNEL_WRITE   = tunnelURL + "?write";
25
26     var STATE_IDLE          = 0;
27     var STATE_CONNECTING    = 1;
28     var STATE_WAITING       = 2;
29     var STATE_CONNECTED     = 3;
30     var STATE_DISCONNECTING = 4;
31     var STATE_DISCONNECTED  = 5;
32
33     var currentState = STATE_IDLE;
34     var stateChangeHandler = null;
35
36     function setState(state) {
37         if (state != currentState) {
38             currentState = state;
39             if (stateChangeHandler)
40                 stateChangeHandler(currentState);
41         }
42     }
43
44     this.setOnStateChangeHandler = function(handler) {
45         stateChangeHandler = handler;
46     }
47
48     function isConnected() {
49         return currentState == STATE_CONNECTED
50             || currentState == STATE_WAITING;
51     }
52
53     var cursorImage = null;
54     var cursorHotspotX = 0;
55     var cursorHotspotY = 0;
56
57     // FIXME: Make object. Clean up.
58     var cursorRectX = 0;
59     var cursorRectY = 0;
60     var cursorRectW = 0;
61     var cursorRectH = 0;
62
63     var cursorHidden = 0;
64
65     function redrawCursor() {
66
67         // Hide hardware cursor
68         if (cursorHidden == 0) {
69             display.className += " guac-hide-cursor";
70             cursorHidden = 1;
71         }
72
73         // Erase old cursor
74         cursor.clearRect(cursorRectX, cursorRectY, cursorRectW, cursorRectH);
75
76         // Update rect
77         cursorRectX = mouse.getX() - cursorHotspotX;
78         cursorRectY = mouse.getY() - cursorHotspotY;
79         cursorRectW = cursorImage.width;
80         cursorRectH = cursorImage.height;
81
82         // Draw new cursor
83         cursor.drawImage(cursorRectX, cursorRectY, cursorImage);
84     }
85
86
87
88
89         /*****************************************/
90         /*** Keyboard                          ***/
91         /*****************************************/
92
93     var keyboard = new GuacamoleKeyboard(document);
94
95     this.disableKeyboard = function() {
96         keyboard.setKeyPressedHandler(null);
97         keyboard.setKeyReleasedHandler(null);
98     };
99
100     this.enableKeyboard = function() {
101         keyboard.setKeyPressedHandler(
102             function (keysym) {
103                 sendKeyEvent(1, keysym);
104             }
105         );
106
107         keyboard.setKeyReleasedHandler(
108             function (keysym) {
109                 sendKeyEvent(0, keysym);
110             }
111         );
112     };
113
114     // Enable keyboard by default
115     this.enableKeyboard();
116
117     function sendKeyEvent(pressed, keysym) {
118         // Do not send requests if not connected
119         if (!isConnected())
120             return;
121
122         sendMessage("key:" +  keysym + "," + pressed + ";");
123     }
124
125     this.pressKey = function(keysym) {
126         sendKeyEvent(1, keysym);
127     };
128
129     this.releaseKey = function(keysym) {
130         sendKeyEvent(0, keysym);
131     };
132
133
134         /*****************************************/
135         /*** Mouse                             ***/
136         /*****************************************/
137
138     var mouse = new GuacamoleMouse(display);
139     mouse.setButtonPressedHandler(
140         function(mouseState) {
141             sendMouseState(mouseState);
142         }
143     );
144
145     mouse.setButtonReleasedHandler(
146         function(mouseState) {
147             sendMouseState(mouseState);
148         }
149     );
150
151     mouse.setMovementHandler(
152         function(mouseState) {
153
154             // Draw client-side cursor
155             if (cursorImage != null) {
156                 redrawCursor();
157             }
158
159             sendMouseState(mouseState);
160         }
161     );
162
163
164     function sendMouseState(mouseState) {
165
166         // Do not send requests if not connected
167         if (!isConnected())
168             return;
169
170         // Build mask
171         var buttonMask = 0;
172         if (mouseState.getLeft())   buttonMask |= 1;
173         if (mouseState.getMiddle()) buttonMask |= 2;
174         if (mouseState.getRight())  buttonMask |= 4;
175         if (mouseState.getUp())     buttonMask |= 8;
176         if (mouseState.getDown())   buttonMask |= 16;
177
178         // Send message
179         sendMessage("mouse:" + mouseState.getX() + "," + mouseState.getY() + "," + buttonMask + ";");
180     }
181
182     var sendingMessages = 0;
183     var outputMessageBuffer = "";
184
185     function sendMessage(message) {
186
187         // Add event to queue, restart send loop if finished.
188         outputMessageBuffer += message;
189         if (sendingMessages == 0)
190             sendPendingMessages();
191
192     }
193
194     function sendPendingMessages() {
195
196         if (outputMessageBuffer.length > 0) {
197
198             sendingMessages = 1;
199
200             var message_xmlhttprequest = new XMLHttpRequest();
201             message_xmlhttprequest.open("POST", TUNNEL_WRITE);
202             message_xmlhttprequest.setRequestHeader("Content-type", "application/x-www-form-urlencoded");
203             message_xmlhttprequest.setRequestHeader("Content-length", outputMessageBuffer.length);
204
205             // Once response received, send next queued event.
206             message_xmlhttprequest.onreadystatechange = function() {
207                 if (message_xmlhttprequest.readyState == 4)
208                     sendPendingMessages();
209             }
210
211             message_xmlhttprequest.send(outputMessageBuffer);
212             outputMessageBuffer = ""; // Clear buffer
213
214         }
215         else
216             sendingMessages = 0;
217
218     }
219
220
221         /*****************************************/
222         /*** Clipboard                         ***/
223         /*****************************************/
224
225     this.setClipboard = function(data) {
226
227         // Do not send requests if not connected
228         if (!isConnected())
229             return;
230
231         sendMessage("clipboard:" + escapeGuacamoleString(data) + ";");
232     }
233
234
235     function desaturateFilter(data, width, height) {
236
237         for (var i=0; i<data.length; i+=4) {
238
239             // Get RGB values
240             var r = data[i];
241             var g = data[i+1];
242             var b = data[i+2];
243
244             // Desaturate
245             var v = Math.max(r, g, b) / 2;
246             data[i]   = v;
247             data[i+1] = v;
248             data[i+2] = v;
249
250         }
251
252     }
253
254     var nameHandler = null;
255     this.setNameHandler = function(handler) {
256         nameHandler = handler;
257     }
258
259     var errorHandler = null;
260     this.setErrorHandler = function(handler) {
261         errorHandler = handler;
262     };
263
264     var errorEncountered = 0;
265     function showError(error) {
266         // Only display first error (avoid infinite error loops)
267         if (errorEncountered == 0) {
268             errorEncountered = 1;
269
270             disconnect();
271
272             // Show error by desaturating display
273             for (var i=0; i<layers.length; i++) {
274                 layers[i].filter(desaturateFilter);
275             }
276
277             if (errorHandler)
278                 errorHandler(error);
279         }
280     }
281
282     function handleErrors(message) {
283         var errors = message.getErrors();
284         for (var errorIndex=0; errorIndex<errors.length; errorIndex++)
285             showError(errors[errorIndex].getMessage());
286     }
287
288     var clipboardHandler = null;
289     var requests = 0;
290
291     this.setClipboardHandler = function(handler) {
292         clipboardHandler = handler;
293     };
294
295
296     function handleResponse(xmlhttprequest) {
297
298         var interval = null;
299         var nextRequest = null;
300
301         var instructionStart = 0;
302         var startIndex = 0;
303
304         function parseResponse() {
305
306             // Start next request as soon as possible
307             if (xmlhttprequest.readyState >= 2 && nextRequest == null)
308                 nextRequest = makeRequest();
309
310             // Parse stream when data is received and when complete.
311             if (xmlhttprequest.readyState == 3 ||
312                 xmlhttprequest.readyState == 4) {
313
314                 // Also poll every 30ms (some browsers don't repeatedly call onreadystatechange for new data)
315                 if (xmlhttprequest.readyState == 3 && interval == null)
316                     interval = setInterval(parseResponse, 30);
317                 else if (xmlhttprequest.readyState == 4 && interval != null)
318                     clearInterval(interval);
319
320                 // Halt on error during request
321                 if (xmlhttprequest.status == 0) {
322                     showError("Request canceled by browser.");
323                     return;
324                 }
325                 else if (xmlhttprequest.status != 200) {
326                     showError("Error during request (HTTP " + xmlhttprequest.status + "): " + xmlhttprequest.statusText);
327                     return;
328                 }
329
330                 var current = xmlhttprequest.responseText;
331                 var instructionEnd;
332                 
333                 while ((instructionEnd = current.indexOf(";", startIndex)) != -1) {
334
335                     // Start next search at next instruction
336                     startIndex = instructionEnd+1;
337
338                     var instruction = current.substr(instructionStart,
339                             instructionEnd - instructionStart);
340
341                     instructionStart = startIndex;
342
343                     var opcodeEnd = instruction.indexOf(":");
344
345                     var opcode;
346                     var parameters;
347                     if (opcodeEnd == -1) {
348                         opcode = instruction;
349                         parameters = new Array();
350                     }
351                     else {
352                         opcode = instruction.substr(0, opcodeEnd);
353                         parameters = instruction.substr(opcodeEnd+1).split(",");
354                     }
355
356                     // If we're done parsing, handle the next response.
357                     if (opcode.length == 0) {
358
359                         if (isConnected()) {
360                             delete xmlhttprequest;
361                             if (nextRequest)
362                                 handleResponse(nextRequest);
363                         }
364
365                         break;
366                     }
367
368                     // Call instruction handler.
369                     doInstruction(opcode, parameters);
370                 }
371
372                 // Start search at end of string.
373                 startIndex = current.length;
374
375                 delete instruction;
376                 delete parameters;
377
378             }
379
380         }
381
382         xmlhttprequest.onreadystatechange = parseResponse;
383         parseResponse();
384
385     }
386
387
388     function makeRequest() {
389
390         // Download self
391         var xmlhttprequest = new XMLHttpRequest();
392         xmlhttprequest.open("POST", TUNNEL_READ);
393         xmlhttprequest.send(null); 
394
395         return xmlhttprequest;
396
397     }
398
399     function escapeGuacamoleString(str) {
400
401         var escapedString = "";
402
403         for (var i=0; i<str.length; i++) {
404
405             var c = str.charAt(i);
406             if (c == ",")
407                 escapedString += "\\c";
408             else if (c == ";")
409                 escapedString += "\\s";
410             else if (c == "\\")
411                 escapedString += "\\\\";
412             else
413                 escapedString += c;
414
415         }
416
417         return escapedString;
418
419     }
420
421     function unescapeGuacamoleString(str) {
422
423         var unescapedString = "";
424
425         for (var i=0; i<str.length; i++) {
426
427             var c = str.charAt(i);
428             if (c == "\\" && i<str.length-1) {
429
430                 var escapeChar = str.charAt(++i);
431                 if (escapeChar == "c")
432                     unescapedString += ",";
433                 else if (escapeChar == "s")
434                     unescapedString += ";";
435                 else if (escapeChar == "\\")
436                     unescapedString += "\\";
437                 else
438                     unescapedString += "\\" + escapeChar;
439
440             }
441             else
442                 unescapedString += c;
443
444         }
445
446         return unescapedString;
447
448     }
449
450     // Layers
451     var displayWidth = 0;
452     var displayHeight = 0;
453
454     var layers = new Array();
455     var buffers = new Array();
456     var cursor = null;
457
458     function getLayer(index) {
459
460         // If negative index, use buffer
461         if (index < 0) {
462
463             index = -1 - index;
464             var buffer = buffers[index];
465
466             // Create buffer if necessary
467             if (buffer == null) {
468                 buffer = new Layer(0, 0);
469                 buffer.setAutosize(1);
470                 buffers[index] = buffer;
471             }
472
473             return buffer;
474         }
475
476         // If non-negative, use visible layer
477         else {
478
479             var layer = layers[index];
480             if (layer == null) {
481
482                 // Add new layer
483                 layer = new Layer(displayWidth, displayHeight);
484                 layers[index] = layer;
485
486                 // (Re)-add existing layers in order
487                 for (var i=0; i<layers.length; i++) {
488                     if (layers[i]) {
489
490                         // If already present, remove
491                         if (layers[i].parentNode === display)
492                             display.removeChild(layers[i]);
493
494                         // Add to end
495                         display.appendChild(layers[i]);
496                     }
497                 }
498
499                 // Add cursor layer last
500                 if (cursor != null) {
501                     if (cursor.parentNode === display)
502                         display.removeChild(cursor);
503                     display.appendChild(cursor);
504                 }
505
506             }
507             else {
508                 // Reset size
509                 layer.resize(displayWidth, displayHeight);
510             }
511
512             return layer;
513         }
514
515     }
516
517     var instructionHandlers = {
518
519         "error": function(parameters) {
520             showError(unescapeGuacamoleString(parameters[0]));
521         },
522
523         "name": function(parameters) {
524             nameHandler(unescapeGuacamoleString(parameters[0]));
525         },
526
527         "clipboard": function(parameters) {
528             clipboardHandler(unescapeGuacamoleString(parameters[0]));
529         },
530
531         "size": function(parameters) {
532
533             displayWidth = parseInt(parameters[0]);
534             displayHeight = parseInt(parameters[1]);
535
536             // Update (set) display size
537             if (display) {
538                 display.style.width = displayWidth + "px";
539                 display.style.height = displayHeight + "px";
540             }
541
542             // Set cursor layer width/height
543             if (cursor != null)
544                 cursor.resize(displayWidth, displayHeight);
545
546         },
547
548         "png": function(parameters) {
549
550             var layer = parseInt(parameters[0]);
551             var x = parseInt(parameters[1]);
552             var y = parseInt(parameters[2]);
553             var data = parameters[3];
554
555             getLayer(layer).draw(
556                 x,
557                 y,
558                 "data:image/png;base64," + data
559             );
560
561             // If received first update, no longer waiting.
562             if (currentState == STATE_WAITING)
563                 setState(STATE_CONNECTED);
564
565         },
566
567         "copy": function(parameters) {
568
569             var srcL = parseInt(parameters[0]);
570             var srcX = parseInt(parameters[1]);
571             var srcY = parseInt(parameters[2]);
572             var srcWidth = parseInt(parameters[3]);
573             var srcHeight = parseInt(parameters[4]);
574             var dstL = parseInt(parameters[5]);
575             var dstX = parseInt(parameters[6]);
576             var dstY = parseInt(parameters[7]);
577
578             getLayer(dstL).copyRect(
579                 getLayer(srcL),
580                 srcX,
581                 srcY,
582                 srcWidth, 
583                 srcHeight, 
584                 dstX,
585                 dstY 
586             );
587
588         },
589
590         "cursor": function(parameters) {
591
592             var x = parseInt(parameters[0]);
593             var y = parseInt(parameters[1]);
594             var data = parameters[2];
595
596             if (cursor == null) {
597                 cursor = new Layer(displayWidth, displayHeight);
598                 display.appendChild(cursor);
599             }
600
601             // Start cursor image load
602             var image = new Image();
603             image.onload = function() {
604                 cursorImage = image;
605                 cursorHotspotX = x;
606                 cursorHotspotY = y;
607                 redrawCursor();
608             };
609             image.src = "data:image/png;base64," + data
610
611         }
612       
613     };
614
615
616     function doInstruction(opcode, parameters) {
617
618         var handler = instructionHandlers[opcode];
619         if (handler)
620             handler(parameters);
621
622     }
623         
624
625     this.connect = function() {
626
627         setState(STATE_CONNECTING);
628
629         // Start tunnel and connect synchronously
630         var connect_xmlhttprequest = new XMLHttpRequest();
631         connect_xmlhttprequest.open("POST", TUNNEL_CONNECT, false);
632         connect_xmlhttprequest.setRequestHeader("Content-type", "application/x-www-form-urlencoded");
633         connect_xmlhttprequest.setRequestHeader("Content-length", 0);
634         connect_xmlhttprequest.send(null);
635
636         // Start reading data
637         setState(STATE_WAITING);
638         handleResponse(makeRequest());
639
640     };
641
642     
643     function disconnect() {
644
645         // Only attempt disconnection not disconnected.
646         if (currentState != STATE_DISCONNECTED
647                 && currentState != STATE_DISCONNECTING) {
648
649             var message = "disconnect;";
650             setState(STATE_DISCONNECTING);
651
652             // Send disconnect message (synchronously... as necessary until handoff is implemented)
653             var disconnect_xmlhttprequest = new XMLHttpRequest();
654             disconnect_xmlhttprequest.open("POST", TUNNEL_WRITE, false);
655             disconnect_xmlhttprequest.setRequestHeader("Content-type", "application/x-www-form-urlencoded");
656             disconnect_xmlhttprequest.setRequestHeader("Content-length", message.length);
657             disconnect_xmlhttprequest.send(message);
658
659             setState(STATE_DISCONNECTED);
660         }
661
662     }
663
664     this.disconnect = disconnect;
665
666 }