main
1/*
2 * noVNC: HTML5 VNC client
3 * Copyright (C) 2019 The noVNC authors
4 * Licensed under MPL 2.0 (see LICENSE.txt)
5 *
6 * See README.md for usage and integration instructions.
7 */
8
9import * as Log from './util/logging.js';
10import Base64 from "./base64.js";
11import { toSigned32bit } from './util/int.js';
12
13export default class Display {
14 constructor(target) {
15 this._drawCtx = null;
16
17 this._renderQ = []; // queue drawing actions for in-order rendering
18 this._flushPromise = null;
19
20 // the full frame buffer (logical canvas) size
21 this._fbWidth = 0;
22 this._fbHeight = 0;
23
24 this._prevDrawStyle = "";
25
26 Log.Debug(">> Display.constructor");
27
28 // The visible canvas
29 this._target = target;
30
31 if (!this._target) {
32 throw new Error("Target must be set");
33 }
34
35 if (typeof this._target === 'string') {
36 throw new Error('target must be a DOM element');
37 }
38
39 if (!this._target.getContext) {
40 throw new Error("no getContext method");
41 }
42
43 this._targetCtx = this._target.getContext('2d');
44
45 // the visible canvas viewport (i.e. what actually gets seen)
46 this._viewportLoc = { 'x': 0, 'y': 0, 'w': this._target.width, 'h': this._target.height };
47
48 // The hidden canvas, where we do the actual rendering
49 this._backbuffer = document.createElement('canvas');
50 this._drawCtx = this._backbuffer.getContext('2d');
51
52 this._damageBounds = { left: 0, top: 0,
53 right: this._backbuffer.width,
54 bottom: this._backbuffer.height };
55
56 Log.Debug("User Agent: " + navigator.userAgent);
57
58 Log.Debug("<< Display.constructor");
59
60 // ===== PROPERTIES =====
61
62 this._scale = 1.0;
63 this._clipViewport = false;
64 }
65
66 // ===== PROPERTIES =====
67
68 get scale() { return this._scale; }
69 set scale(scale) {
70 this._rescale(scale);
71 }
72
73 get clipViewport() { return this._clipViewport; }
74 set clipViewport(viewport) {
75 this._clipViewport = viewport;
76 // May need to readjust the viewport dimensions
77 const vp = this._viewportLoc;
78 this.viewportChangeSize(vp.w, vp.h);
79 this.viewportChangePos(0, 0);
80 }
81
82 get width() {
83 return this._fbWidth;
84 }
85
86 get height() {
87 return this._fbHeight;
88 }
89
90 // ===== PUBLIC METHODS =====
91
92 viewportChangePos(deltaX, deltaY) {
93 const vp = this._viewportLoc;
94 deltaX = Math.floor(deltaX);
95 deltaY = Math.floor(deltaY);
96
97 if (!this._clipViewport) {
98 deltaX = -vp.w; // clamped later of out of bounds
99 deltaY = -vp.h;
100 }
101
102 const vx2 = vp.x + vp.w - 1;
103 const vy2 = vp.y + vp.h - 1;
104
105 // Position change
106
107 if (deltaX < 0 && vp.x + deltaX < 0) {
108 deltaX = -vp.x;
109 }
110 if (vx2 + deltaX >= this._fbWidth) {
111 deltaX -= vx2 + deltaX - this._fbWidth + 1;
112 }
113
114 if (vp.y + deltaY < 0) {
115 deltaY = -vp.y;
116 }
117 if (vy2 + deltaY >= this._fbHeight) {
118 deltaY -= (vy2 + deltaY - this._fbHeight + 1);
119 }
120
121 if (deltaX === 0 && deltaY === 0) {
122 return;
123 }
124 Log.Debug("viewportChange deltaX: " + deltaX + ", deltaY: " + deltaY);
125
126 vp.x += deltaX;
127 vp.y += deltaY;
128
129 this._damage(vp.x, vp.y, vp.w, vp.h);
130
131 this.flip();
132 }
133
134 viewportChangeSize(width, height) {
135
136 if (!this._clipViewport ||
137 typeof(width) === "undefined" ||
138 typeof(height) === "undefined") {
139
140 Log.Debug("Setting viewport to full display region");
141 width = this._fbWidth;
142 height = this._fbHeight;
143 }
144
145 width = Math.floor(width);
146 height = Math.floor(height);
147
148 if (width > this._fbWidth) {
149 width = this._fbWidth;
150 }
151 if (height > this._fbHeight) {
152 height = this._fbHeight;
153 }
154
155 const vp = this._viewportLoc;
156 if (vp.w !== width || vp.h !== height) {
157 vp.w = width;
158 vp.h = height;
159
160 const canvas = this._target;
161 canvas.width = width;
162 canvas.height = height;
163
164 // The position might need to be updated if we've grown
165 this.viewportChangePos(0, 0);
166
167 this._damage(vp.x, vp.y, vp.w, vp.h);
168 this.flip();
169
170 // Update the visible size of the target canvas
171 this._rescale(this._scale);
172 }
173 }
174
175 absX(x) {
176 if (this._scale === 0) {
177 return 0;
178 }
179 return toSigned32bit(x / this._scale + this._viewportLoc.x);
180 }
181
182 absY(y) {
183 if (this._scale === 0) {
184 return 0;
185 }
186 return toSigned32bit(y / this._scale + this._viewportLoc.y);
187 }
188
189 resize(width, height) {
190 this._prevDrawStyle = "";
191
192 this._fbWidth = width;
193 this._fbHeight = height;
194
195 const canvas = this._backbuffer;
196 if (canvas.width !== width || canvas.height !== height) {
197
198 // We have to save the canvas data since changing the size will clear it
199 let saveImg = null;
200 if (canvas.width > 0 && canvas.height > 0) {
201 saveImg = this._drawCtx.getImageData(0, 0, canvas.width, canvas.height);
202 }
203
204 if (canvas.width !== width) {
205 canvas.width = width;
206 }
207 if (canvas.height !== height) {
208 canvas.height = height;
209 }
210
211 if (saveImg) {
212 this._drawCtx.putImageData(saveImg, 0, 0);
213 }
214 }
215
216 // Readjust the viewport as it may be incorrectly sized
217 // and positioned
218 const vp = this._viewportLoc;
219 this.viewportChangeSize(vp.w, vp.h);
220 this.viewportChangePos(0, 0);
221 }
222
223 getImageData() {
224 return this._drawCtx.getImageData(0, 0, this.width, this.height);
225 }
226
227 toDataURL(type, encoderOptions) {
228 return this._backbuffer.toDataURL(type, encoderOptions);
229 }
230
231 toBlob(callback, type, quality) {
232 return this._backbuffer.toBlob(callback, type, quality);
233 }
234
235 // Track what parts of the visible canvas that need updating
236 _damage(x, y, w, h) {
237 if (x < this._damageBounds.left) {
238 this._damageBounds.left = x;
239 }
240 if (y < this._damageBounds.top) {
241 this._damageBounds.top = y;
242 }
243 if ((x + w) > this._damageBounds.right) {
244 this._damageBounds.right = x + w;
245 }
246 if ((y + h) > this._damageBounds.bottom) {
247 this._damageBounds.bottom = y + h;
248 }
249 }
250
251 // Update the visible canvas with the contents of the
252 // rendering canvas
253 flip(fromQueue) {
254 if (this._renderQ.length !== 0 && !fromQueue) {
255 this._renderQPush({
256 'type': 'flip'
257 });
258 } else {
259 let x = this._damageBounds.left;
260 let y = this._damageBounds.top;
261 let w = this._damageBounds.right - x;
262 let h = this._damageBounds.bottom - y;
263
264 let vx = x - this._viewportLoc.x;
265 let vy = y - this._viewportLoc.y;
266
267 if (vx < 0) {
268 w += vx;
269 x -= vx;
270 vx = 0;
271 }
272 if (vy < 0) {
273 h += vy;
274 y -= vy;
275 vy = 0;
276 }
277
278 if ((vx + w) > this._viewportLoc.w) {
279 w = this._viewportLoc.w - vx;
280 }
281 if ((vy + h) > this._viewportLoc.h) {
282 h = this._viewportLoc.h - vy;
283 }
284
285 if ((w > 0) && (h > 0)) {
286 // FIXME: We may need to disable image smoothing here
287 // as well (see copyImage()), but we haven't
288 // noticed any problem yet.
289 this._targetCtx.drawImage(this._backbuffer,
290 x, y, w, h,
291 vx, vy, w, h);
292 }
293
294 this._damageBounds.left = this._damageBounds.top = 65535;
295 this._damageBounds.right = this._damageBounds.bottom = 0;
296 }
297 }
298
299 pending() {
300 return this._renderQ.length > 0;
301 }
302
303 flush() {
304 if (this._renderQ.length === 0) {
305 return Promise.resolve();
306 } else {
307 if (this._flushPromise === null) {
308 this._flushPromise = new Promise((resolve) => {
309 this._flushResolve = resolve;
310 });
311 }
312 return this._flushPromise;
313 }
314 }
315
316 fillRect(x, y, width, height, color, fromQueue) {
317 if (this._renderQ.length !== 0 && !fromQueue) {
318 this._renderQPush({
319 'type': 'fill',
320 'x': x,
321 'y': y,
322 'width': width,
323 'height': height,
324 'color': color
325 });
326 } else {
327 this._setFillColor(color);
328 this._drawCtx.fillRect(x, y, width, height);
329 this._damage(x, y, width, height);
330 }
331 }
332
333 copyImage(oldX, oldY, newX, newY, w, h, fromQueue) {
334 if (this._renderQ.length !== 0 && !fromQueue) {
335 this._renderQPush({
336 'type': 'copy',
337 'oldX': oldX,
338 'oldY': oldY,
339 'x': newX,
340 'y': newY,
341 'width': w,
342 'height': h,
343 });
344 } else {
345 // Due to this bug among others [1] we need to disable the image-smoothing to
346 // avoid getting a blur effect when copying data.
347 //
348 // 1. https://bugzilla.mozilla.org/show_bug.cgi?id=1194719
349 //
350 // We need to set these every time since all properties are reset
351 // when the the size is changed
352 this._drawCtx.mozImageSmoothingEnabled = false;
353 this._drawCtx.webkitImageSmoothingEnabled = false;
354 this._drawCtx.msImageSmoothingEnabled = false;
355 this._drawCtx.imageSmoothingEnabled = false;
356
357 this._drawCtx.drawImage(this._backbuffer,
358 oldX, oldY, w, h,
359 newX, newY, w, h);
360 this._damage(newX, newY, w, h);
361 }
362 }
363
364 imageRect(x, y, width, height, mime, arr) {
365 /* The internal logic cannot handle empty images, so bail early */
366 if ((width === 0) || (height === 0)) {
367 return;
368 }
369
370 const img = new Image();
371 img.src = "data: " + mime + ";base64," + Base64.encode(arr);
372
373 this._renderQPush({
374 'type': 'img',
375 'img': img,
376 'x': x,
377 'y': y,
378 'width': width,
379 'height': height
380 });
381 }
382
383 videoFrame(x, y, width, height, frame) {
384 this._renderQPush({
385 'type': 'frame',
386 'frame': frame,
387 'x': x,
388 'y': y,
389 'width': width,
390 'height': height
391 });
392 }
393
394 blitImage(x, y, width, height, arr, offset, fromQueue) {
395 if (this._renderQ.length !== 0 && !fromQueue) {
396 // NB(directxman12): it's technically more performant here to use preallocated arrays,
397 // but it's a lot of extra work for not a lot of payoff -- if we're using the render queue,
398 // this probably isn't getting called *nearly* as much
399 const newArr = new Uint8Array(width * height * 4);
400 newArr.set(new Uint8Array(arr.buffer, 0, newArr.length));
401 this._renderQPush({
402 'type': 'blit',
403 'data': newArr,
404 'x': x,
405 'y': y,
406 'width': width,
407 'height': height,
408 });
409 } else {
410 // NB(directxman12): arr must be an Type Array view
411 let data = new Uint8ClampedArray(arr.buffer,
412 arr.byteOffset + offset,
413 width * height * 4);
414 let img = new ImageData(data, width, height);
415 this._drawCtx.putImageData(img, x, y);
416 this._damage(x, y, width, height);
417 }
418 }
419
420 drawImage(img, ...args) {
421 this._drawCtx.drawImage(img, ...args);
422
423 if (args.length <= 4) {
424 const [x, y] = args;
425 this._damage(x, y, img.width, img.height);
426 } else {
427 const [,, sw, sh, dx, dy] = args;
428 this._damage(dx, dy, sw, sh);
429 }
430 }
431
432 autoscale(containerWidth, containerHeight) {
433 let scaleRatio;
434
435 if (containerWidth === 0 || containerHeight === 0) {
436 scaleRatio = 0;
437
438 } else {
439
440 const vp = this._viewportLoc;
441 const targetAspectRatio = containerWidth / containerHeight;
442 const fbAspectRatio = vp.w / vp.h;
443
444 if (fbAspectRatio >= targetAspectRatio) {
445 scaleRatio = containerWidth / vp.w;
446 } else {
447 scaleRatio = containerHeight / vp.h;
448 }
449 }
450
451 this._rescale(scaleRatio);
452 }
453
454 // ===== PRIVATE METHODS =====
455
456 _rescale(factor) {
457 this._scale = factor;
458 const vp = this._viewportLoc;
459
460 // NB(directxman12): If you set the width directly, or set the
461 // style width to a number, the canvas is cleared.
462 // However, if you set the style width to a string
463 // ('NNNpx'), the canvas is scaled without clearing.
464 const width = factor * vp.w + 'px';
465 const height = factor * vp.h + 'px';
466
467 if ((this._target.style.width !== width) ||
468 (this._target.style.height !== height)) {
469 this._target.style.width = width;
470 this._target.style.height = height;
471 }
472 }
473
474 _setFillColor(color) {
475 const newStyle = 'rgb(' + color[0] + ',' + color[1] + ',' + color[2] + ')';
476 if (newStyle !== this._prevDrawStyle) {
477 this._drawCtx.fillStyle = newStyle;
478 this._prevDrawStyle = newStyle;
479 }
480 }
481
482 _renderQPush(action) {
483 this._renderQ.push(action);
484 if (this._renderQ.length === 1) {
485 // If this can be rendered immediately it will be, otherwise
486 // the scanner will wait for the relevant event
487 this._scanRenderQ();
488 }
489 }
490
491 _resumeRenderQ() {
492 // "this" is the object that is ready, not the
493 // display object
494 this.removeEventListener('load', this._noVNCDisplay._resumeRenderQ);
495 this._noVNCDisplay._scanRenderQ();
496 }
497
498 _scanRenderQ() {
499 let ready = true;
500 while (ready && this._renderQ.length > 0) {
501 const a = this._renderQ[0];
502 switch (a.type) {
503 case 'flip':
504 this.flip(true);
505 break;
506 case 'copy':
507 this.copyImage(a.oldX, a.oldY, a.x, a.y, a.width, a.height, true);
508 break;
509 case 'fill':
510 this.fillRect(a.x, a.y, a.width, a.height, a.color, true);
511 break;
512 case 'blit':
513 this.blitImage(a.x, a.y, a.width, a.height, a.data, 0, true);
514 break;
515 case 'img':
516 if (a.img.complete) {
517 if (a.img.width !== a.width || a.img.height !== a.height) {
518 Log.Error("Decoded image has incorrect dimensions. Got " +
519 a.img.width + "x" + a.img.height + ". Expected " +
520 a.width + "x" + a.height + ".");
521 return;
522 }
523 this.drawImage(a.img, a.x, a.y);
524 // This helps the browser free the memory right
525 // away, rather than ballooning
526 a.img.src = "";
527 } else {
528 a.img._noVNCDisplay = this;
529 a.img.addEventListener('load', this._resumeRenderQ);
530 // We need to wait for this image to 'load'
531 // to keep things in-order
532 ready = false;
533 }
534 break;
535 case 'frame':
536 if (a.frame.ready) {
537 // The encoded frame may be larger than the rect due to
538 // limitations of the encoder, so we need to crop the
539 // frame.
540 let frame = a.frame.frame;
541 if (frame.codedWidth < a.width || frame.codedHeight < a.height) {
542 Log.Warn("Decoded video frame does not cover its full rectangle area. Expecting at least " +
543 a.width + "x" + a.height + " but got " +
544 frame.codedWidth + "x" + frame.codedHeight);
545 }
546 const sx = 0;
547 const sy = 0;
548 const sw = a.width;
549 const sh = a.height;
550 const dx = a.x;
551 const dy = a.y;
552 const dw = sw;
553 const dh = sh;
554 this.drawImage(frame, sx, sy, sw, sh, dx, dy, dw, dh);
555 frame.close();
556 } else {
557 let display = this;
558 a.frame.promise.then(() => {
559 display._scanRenderQ();
560 });
561 ready = false;
562 }
563 break;
564 }
565
566 if (ready) {
567 this._renderQ.shift();
568 }
569 }
570
571 if (this._renderQ.length === 0 &&
572 this._flushPromise !== null) {
573 this._flushResolve();
574 this._flushPromise = null;
575 this._flushResolve = null;
576 }
577 }
578}