README | 4 +- TODO | 2 +- bitmap.js | 20 +++++---- cursor.js | 2 +- display.js | 119 +++++++++++++++++++++++++++++++++------------------- enums.js | 11 ++++- lz.js | 16 +++++++ main.js | 13 +++++- playback.js | 106 +++++++++++++++++++++++++++++++--------------- port.js | 85 +++++++++++++++++++++++++++++++++++++ resize.js | 20 +++++++-- simulatecursor.js | 6 +-- spice.css | 1 - spice.html | 71 ++++++++++++++++++++++--------- spice_auto.html | 15 ++++++- spiceconn.js | 39 ++++++++++------- spicedataview.js | 8 ++-- spicemsg.js | 24 +++++++++-- spicetype.js | 2 +- thirdparty/jsbn.js | 6 +-- thirdparty/prng4.js | 6 +-- thirdparty/rng.js | 8 ++-- thirdparty/rsa.js | 6 +-- utils.js | 12 ++++++ webm.js | 1 + wire.js | 2 +- 26 files changed, 449 insertions(+), 156 deletions(-) diff --git a/README b/README index 89d3747..6443000 100644 --- a/README +++ b/README @@ -5,7 +5,7 @@ Instructions and status as of August, 2016. Requirements: 1. Modern Firefox or Chrome (IE will work, but badly) - + 2. A WebSocket proxy websockify: @@ -24,7 +24,7 @@ Optional: With firefox, you can just open file:///your-path-to-spice.html-here - With Chrome, you have to set a secret config flag to do that, or + With Chrome, you have to set a secret config flag to do that, or serve the files from a web server. diff --git a/TODO b/TODO index 4d4b115..64fc326 100644 --- a/TODO +++ b/TODO @@ -6,7 +6,7 @@ Medium Tasks: *only* about messages) . Change the message processing to be able to handle - an array of ArrayBuffers so we don't have to + an array of ArrayBuffers so we don't have to use the combine function, which is presumed slow. . Use the 'real' DataView if we have it diff --git a/bitmap.js b/bitmap.js index 03f5127..91278d7 100644 --- a/bitmap.js +++ b/bitmap.js @@ -26,25 +26,31 @@ function convert_spice_bitmap_to_web(context, spice_bitmap) { var ret; - var offset, x; + var offset, x, src_offset = 0, src_dec = 0; var u8 = new Uint8Array(spice_bitmap.data); if (spice_bitmap.format != SPICE_BITMAP_FMT_32BIT && spice_bitmap.format != SPICE_BITMAP_FMT_RGBA) return undefined; + if (!(spice_bitmap.flags & SPICE_BITMAP_FLAGS_TOP_DOWN)) + { + src_offset = (spice_bitmap.y - 1 ) * spice_bitmap.stride; + src_dec = 2 * spice_bitmap.stride; + } + ret = context.createImageData(spice_bitmap.x, spice_bitmap.y); - for (offset = 0; offset < (spice_bitmap.y * spice_bitmap.stride); ) - for (x = 0; x < spice_bitmap.x; x++, offset += 4) + for (offset = 0; offset < (spice_bitmap.y * spice_bitmap.stride); src_offset -= src_dec) + for (x = 0; x < spice_bitmap.x; x++, offset += 4, src_offset += 4) { - ret.data[offset + 0 ] = u8[offset + 2]; - ret.data[offset + 1 ] = u8[offset + 1]; - ret.data[offset + 2 ] = u8[offset + 0]; + ret.data[offset + 0 ] = u8[src_offset + 2]; + ret.data[offset + 1 ] = u8[src_offset + 1]; + ret.data[offset + 2 ] = u8[src_offset + 0]; // FIXME - We effectively treat all images as having SPICE_IMAGE_FLAGS_HIGH_BITS_SET if (spice_bitmap.format == SPICE_BITMAP_FMT_32BIT) ret.data[offset + 3] = 255; else - ret.data[offset + 3] = u8[offset]; + ret.data[offset + 3] = u8[src_offset]; } return ret; diff --git a/cursor.js b/cursor.js index 296fbde..d3f4d55 100644 --- a/cursor.js +++ b/cursor.js @@ -118,7 +118,7 @@ SpiceCursorConn.prototype.process_channel_message = function(msg) SpiceCursorConn.prototype.set_cursor = function(cursor) { var pngstr = create_rgba_png(cursor.header.height, cursor.header.width, cursor.data); - var curstr = 'url(data:image/png,' + pngstr + ') ' + + var curstr = 'url(data:image/png,' + pngstr + ') ' + cursor.header.hot_spot_x + ' ' + cursor.header.hot_spot_y + ", default"; var screen = document.getElementById(this.parent.screen_id); screen.style.cursor = 'auto'; diff --git a/display.js b/display.js index 12fbab0..f6c74f5 100644 --- a/display.js +++ b/display.js @@ -142,7 +142,7 @@ SpiceDisplayConn.prototype.process_channel_message = function(msg) { base: draw_copy.base, src_area: draw_copy.data.src_area, image_data: this.cache[draw_copy.data.src_bitmap.descriptor.id], - tag: "copycache." + draw_copy.data.src_bitmap.descriptor.id, + tag: "copycache." + draw_copy.data.src_bitmap.descriptor.id, has_alpha: true, /* FIXME - may want this to be false... */ descriptor : draw_copy.data.src_bitmap.descriptor }); @@ -200,7 +200,7 @@ SpiceDisplayConn.prototype.process_channel_message = function(msg) tmpstr += qdv[i].toString(16); } - img.o = + img.o = { base: draw_copy.base, tag: "jpeg." + draw_copy.data.src_bitmap.surface_id, descriptor : draw_copy.data.src_bitmap.descriptor, @@ -233,7 +233,7 @@ SpiceDisplayConn.prototype.process_channel_message = function(msg) tmpstr += qdv[i].toString(16); } - img.o = + img.o = { base: draw_copy.base, tag: "jpeg." + draw_copy.data.src_bitmap.surface_id, descriptor : draw_copy.data.src_bitmap.descriptor, @@ -265,7 +265,7 @@ SpiceDisplayConn.prototype.process_channel_message = function(msg) draw_copy.data.src_bitmap.bitmap); if (! source_img) { - this.log_warn("FIXME: Unable to interpret bitmap of format: " + + this.log_warn("FIXME: Unable to interpret bitmap of format: " + draw_copy.data.src_bitmap.bitmap.format); return false; } @@ -288,14 +288,11 @@ SpiceDisplayConn.prototype.process_channel_message = function(msg) return false; } - if (draw_copy.data.src_bitmap.lz_rgb.top_down != 1) - this.log_warn("FIXME: Implement non top down support for lz_rgb"); - var source_img = convert_spice_lz_to_web(canvas.context, draw_copy.data.src_bitmap.lz_rgb); if (! source_img) { - this.log_warn("FIXME: Unable to interpret bitmap of type: " + + this.log_warn("FIXME: Unable to interpret bitmap of type: " + draw_copy.data.src_bitmap.lz_rgb.type); return false; } @@ -359,7 +356,7 @@ SpiceDisplayConn.prototype.process_channel_message = function(msg) draw_fill.base.box.bottom - draw_fill.base.box.top); document.getElementById(this.parent.dump_id).appendChild(debug_canvas); } - + this.surfaces[draw_fill.base.surface_id].draw_count++; } @@ -484,9 +481,9 @@ SpiceDisplayConn.prototype.process_channel_message = function(msg) this.surfaces = []; var m = new SpiceMsgSurfaceCreate(msg.data); - DEBUG > 1 && console.log(this.type + ": MsgSurfaceCreate id " + m.surface.surface_id + DEBUG > 1 && console.log(this.type + ": MsgSurfaceCreate id " + m.surface.surface_id + "; " + m.surface.width + "x" + m.surface.height - + "; format " + m.surface.format + + "; format " + m.surface.format + "; flags " + m.surface.flags); if (m.surface.format != SPICE_SURFACE_FMT_32_xRGB && m.surface.format != SPICE_SURFACE_FMT_32_ARGB) @@ -535,7 +532,10 @@ SpiceDisplayConn.prototype.process_channel_message = function(msg) if (msg.type == SPICE_MSG_DISPLAY_STREAM_CREATE) { var m = new SpiceMsgDisplayStreamCreate(msg.data); - DEBUG > 1 && console.log(this.type + ": MsgStreamCreate id" + m.id); + STREAM_DEBUG > 0 && console.log(this.type + ": MsgStreamCreate id" + m.id + "; type " + m.codec_type + + "; width " + m.stream_width + "; height " + m.stream_height + + "; left " + m.dest.left + "; top " + m.dest.top + ); if (!this.streams) this.streams = new Array(); if (this.streams[m.id]) @@ -567,14 +567,21 @@ SpiceDisplayConn.prototype.process_channel_message = function(msg) media.addEventListener('sourceended', handle_video_source_ended, false); media.addEventListener('sourceclosed', handle_video_source_closed, false); - this.streams[m.id].video = v; - this.streams[m.id].media = media; + var s = this.streams[m.id]; + s.video = v; + s.media = media; + s.queue = new Array(); + s.start_time = 0; + s.cluster_time = 0; + s.append_okay = false; - media.stream = this.streams[m.id]; + media.stream = s; media.spiceconn = this; - v.spice_stream = this.streams[m.id]; + v.spice_stream = s; } - else if (m.codec_type != SPICE_VIDEO_CODEC_TYPE_MJPEG) + else if (m.codec_type == SPICE_VIDEO_CODEC_TYPE_MJPEG) + this.streams[m.id].frames_loading = 0; + else console.log("Unhandled stream codec: "+m.codec_type); return true; } @@ -623,7 +630,7 @@ SpiceDisplayConn.prototype.process_channel_message = function(msg) if (msg.type == SPICE_MSG_DISPLAY_STREAM_CLIP) { var m = new SpiceMsgDisplayStreamClip(msg.data); - DEBUG > 1 && console.log(this.type + ": MsgStreamClip id" + m.id); + STREAM_DEBUG > 1 && console.log(this.type + ": MsgStreamClip id" + m.id); this.streams[m.id].clip = m.clip; return true; } @@ -631,7 +638,7 @@ SpiceDisplayConn.prototype.process_channel_message = function(msg) if (msg.type == SPICE_MSG_DISPLAY_STREAM_DESTROY) { var m = new SpiceMsgDisplayStreamDestroy(msg.data); - DEBUG > 1 && console.log(this.type + ": MsgStreamDestroy id" + m.id); + STREAM_DEBUG > 0 && console.log(this.type + ": MsgStreamDestroy id" + m.id); if (this.streams[m.id].codec_type == SPICE_VIDEO_CODEC_TYPE_VP8) { @@ -745,7 +752,7 @@ SpiceDisplayConn.prototype.draw_copy_helper = function(o) SpiceDisplayConn.prototype.log_draw = function(prefix, draw) { var str = prefix + "." + draw.base.surface_id + "." + this.surfaces[draw.base.surface_id].draw_count + ": "; - str += "base.box " + draw.base.box.left + ", " + draw.base.box.top + " to " + + str += "base.box " + draw.base.box.left + ", " + draw.base.box.top + " to " + draw.base.box.right + ", " + draw.base.box.bottom; str += "; clip.type " + draw.base.clip.type; @@ -762,13 +769,19 @@ SpiceDisplayConn.prototype.log_draw = function(prefix, draw) str += "; src_bitmap type " + draw.data.src_bitmap.descriptor.type + ", flags " + draw.data.src_bitmap.descriptor.flags; if (draw.data.src_bitmap.surface_id !== undefined) str += "; src_bitmap surface_id " + draw.data.src_bitmap.surface_id; + if (draw.data.src_bitmap.bitmap) + str += "; BITMAP format " + draw.data.src_bitmap.bitmap.format + + "; flags " + draw.data.src_bitmap.bitmap.flags + + "; x " + draw.data.src_bitmap.bitmap.x + + "; y " + draw.data.src_bitmap.bitmap.y + + "; stride " + draw.data.src_bitmap.bitmap.stride ; if (draw.data.src_bitmap.quic) - str += "; QUIC type " + draw.data.src_bitmap.quic.type + - "; width " + draw.data.src_bitmap.quic.width + + str += "; QUIC type " + draw.data.src_bitmap.quic.type + + "; width " + draw.data.src_bitmap.quic.width + "; height " + draw.data.src_bitmap.quic.height ; if (draw.data.src_bitmap.lz_rgb) str += "; LZ_RGB length " + draw.data.src_bitmap.lz_rgb.length + - "; magic " + draw.data.src_bitmap.lz_rgb.magic + + "; magic " + draw.data.src_bitmap.lz_rgb.magic + "; version 0x" + draw.data.src_bitmap.lz_rgb.version.toString(16) + "; type " + draw.data.src_bitmap.lz_rgb.type + "; width " + draw.data.src_bitmap.lz_rgb.width + @@ -876,6 +889,9 @@ function handle_draw_jpeg_onload() var temp_canvas = null; var context; + if (this.o.sc.streams[this.o.id]) + this.o.sc.streams[this.o.id].frames_loading--; + /*------------------------------------------------------------ ** FIXME: ** The helper should be extended to be able to handle actual HtmlImageElements @@ -885,7 +901,7 @@ function handle_draw_jpeg_onload() { // This can happen; if the jpeg image loads after our surface // has been destroyed (e.g. open a menu, close it quickly), - // we'll find we have no surface. + // we'll find we have no surface. DEBUG > 2 && this.o.sc.log_info("Discarding jpeg; presumed lost surface " + this.o.base.surface_id); temp_canvas = document.createElement("canvas"); temp_canvas.setAttribute('width', this.o.base.box.right); @@ -904,16 +920,16 @@ function handle_draw_jpeg_onload() t.putImageData(this.alpha_img, 0, 0); t.globalCompositeOperation = 'source-in'; t.drawImage(this, 0, 0); - + context.drawImage(c, this.o.base.box.left, this.o.base.box.top); - if (this.o.descriptor && + if (this.o.descriptor && (this.o.descriptor.flags & SPICE_IMAGE_FLAGS_CACHE_ME)) { if (! ("cache" in this.o.sc)) this.o.sc.cache = {}; - this.o.sc.cache[this.o.descriptor.id] = + this.o.sc.cache[this.o.descriptor.id] = t.getImageData(0, 0, this.alpha_img.width, this.alpha_img.height); @@ -925,15 +941,16 @@ function handle_draw_jpeg_onload() // Give the Garbage collector a clue to recycle this; avoids // fairly massive memory leaks during video playback - this.src = null; + this.onload = undefined; + this.src = EMPTY_GIF_IMAGE; - if (this.o.descriptor && + if (this.o.descriptor && (this.o.descriptor.flags & SPICE_IMAGE_FLAGS_CACHE_ME)) { if (! ("cache" in this.o.sc)) this.o.sc.cache = {}; - this.o.sc.cache[this.o.descriptor.id] = + this.o.sc.cache[this.o.descriptor.id] = context.getImageData(this.o.base.box.left, this.o.base.box.top, this.o.base.box.right - this.o.base.box.left, this.o.base.box.bottom - this.o.base.box.top); @@ -955,13 +972,16 @@ function handle_draw_jpeg_onload() this.o.sc.surfaces[this.o.base.surface_id].draw_count++; } - if ("report" in this.o.sc.streams[this.o.id]) - process_stream_data_report(this.o.sc, this.o.id, this.o.msg_mmtime, this.o.msg_mmtime - this.o.sc.parent.relative_now()) + if (this.o.sc.streams[this.o.id] && "report" in this.o.sc.streams[this.o.id]) + process_stream_data_report(this.o.sc, this.o.id, this.o.msg_mmtime, this.o.msg_mmtime - this.o.sc.parent.relative_now()); } function process_mjpeg_stream_data(sc, m, time_until_due) { - if (time_until_due < 0) + /* If we are currently processing an mjpeg frame when a new one arrives, + and the new one is 'late', drop the new frame. This helps the browsers + keep up, and provides rate control feedback as well */ + if (time_until_due < 0 && sc.streams[m.base.id].frames_loading > 0) { if ("report" in sc.streams[m.base.id]) sc.streams[m.base.id].report.num_drops++; @@ -992,6 +1012,8 @@ function process_mjpeg_stream_data(sc, m, time_until_due) }; img.onload = handle_draw_jpeg_onload; img.src = tmpstr; + + sc.streams[m.base.id].frames_loading++; } function process_stream_data_report(sc, id, msg_mmtime, time_until_due) @@ -1035,10 +1057,6 @@ function handle_video_source_open(e) s.spiceconn = p; s.stream = stream; - stream.queue = new Array(); - stream.start_time = 0; - stream.cluster_time = 0; - listen_for_video_events(stream); var h = new webm_Header(); @@ -1102,6 +1120,24 @@ function handle_append_video_buffer_done(e) { stream.append_okay = true; } + + if (!stream.video) + { + if (STREAM_DEBUG > 0) + console.log("Stream id " + stream.id + " received updateend after video is gone."); + return; + } + + if (stream.video.buffered.length > 0 && + stream.video.currentTime < stream.video.buffered.start(stream.video.buffered.length - 1)) + { + console.log("Video appears to have fallen behind; advancing to " + + stream.video.buffered.start(stream.video.buffered.length - 1)); + stream.video.currentTime = stream.video.buffered.start(stream.video.buffered.length - 1); + } + + if (STREAM_DEBUG > 1) + console.log(stream.video.currentTime + ":id " + stream.id + " updateend " + dump_media_element(stream.video)); } function handle_video_buffer_error(e) @@ -1153,13 +1189,9 @@ function new_video_cluster(stream, msg) function process_video_stream_data(stream, msg) { - if (! stream.source_buffer) - return true; - if (stream.start_time == 0) { stream.start_time = msg.base.multi_media_time; - stream.video.play(); new_video_cluster(stream, msg); } @@ -1185,7 +1217,7 @@ function video_handle_event_debug(e) if (STREAM_DEBUG > 1 && s.source_buffer) console.log(" source_buffer " + dump_source_buffer(s.source_buffer)); - if (STREAM_DEBUG > 0 || s.queue.length > 1) + if (STREAM_DEBUG > 1 || s.queue.length > 1) console.log(' queue len ' + s.queue.length + '; append_okay: ' + s.append_okay); } @@ -1203,10 +1235,11 @@ function listen_for_video_events(stream) var video_1_events = [ "loadstart", "suspend", "emptied", "stalled", "loadedmetadata", "loadeddata", "canplay", "canplaythrough", "playing", "waiting", "seeking", "seeked", "ended", "durationchange", - "timeupdate", "play", "pause", "ratechange" + "play", "pause", "ratechange" ]; var video_2_events = [ + "timeupdate", "progress", "resize", "volumechange" diff --git a/enums.js b/enums.js index 3ef36dc..b6e013c 100644 --- a/enums.js +++ b/enums.js @@ -166,6 +166,15 @@ var SPICE_MSG_PLAYBACK_VOLUME = 105; var SPICE_MSG_PLAYBACK_MUTE = 106; var SPICE_MSG_PLAYBACK_LATENCY = 107; +var SPICE_MSG_SPICEVMC_DATA = 101; +var SPICE_MSG_PORT_INIT = 201; +var SPICE_MSG_PORT_EVENT = 202; +var SPICE_MSG_END_PORT = 203; + +var SPICE_MSGC_SPICEVMC_DATA = 101; +var SPICE_MSGC_PORT_EVENT = 201; +var SPICE_MSGC_END_PORT = 202; + var SPICE_PLAYBACK_CAP_CELT_0_5_1 = 0; var SPICE_PLAYBACK_CAP_VOLUME = 1; var SPICE_PLAYBACK_CAP_LATENCY = 2; @@ -264,7 +273,7 @@ var SPICE_MOUSE_BUTTON_MASK_LEFT = (1 << 0), SPICE_MOUSE_BUTTON_MASK_MIDDLE = (1 << 1), SPICE_MOUSE_BUTTON_MASK_RIGHT = (1 << 2), SPICE_MOUSE_BUTTON_MASK_MASK = 0x7; - + var SPICE_MOUSE_BUTTON_INVALID = 0; var SPICE_MOUSE_BUTTON_LEFT = 1; var SPICE_MOUSE_BUTTON_MIDDLE = 2; diff --git a/lz.js b/lz.js index 4292eac..53c1141 100644 --- a/lz.js +++ b/lz.js @@ -141,6 +141,19 @@ function lz_rgb32_decompress(in_buf, at, out_buf, type, default_alpha) return encoder - 1; } +function flip_image_data(img) +{ + var wb = img.width * 4; + var h = img.height; + var temp_h = h; + var buff = new Uint8Array(img.width * img.height * 4); + while (temp_h--) + { + buff.set(img.data.subarray(temp_h * wb, (temp_h + 1) * wb), (h - temp_h - 1) * wb); + } + img.data.set(buff); +} + function convert_spice_lz_to_web(context, lz_image) { var at; @@ -150,6 +163,9 @@ function convert_spice_lz_to_web(context, lz_image) var ret = context.createImageData(lz_image.width, lz_image.height); at = lz_rgb32_decompress(u8, 0, ret.data, LZ_IMAGE_TYPE_RGB32, lz_image.type != LZ_IMAGE_TYPE_RGBA); + if (!lz_image.top_down) + flip_image_data(ret); + if (lz_image.type == LZ_IMAGE_TYPE_RGBA) lz_rgb32_decompress(u8, at, ret.data, LZ_IMAGE_TYPE_RGBA, false); } diff --git a/main.js b/main.js index afe69bf..6976f9c 100644 --- a/main.js +++ b/main.js @@ -22,7 +22,7 @@ ** SpiceMainConn ** This is the master Javascript class for establishing and ** managing a connection to a Spice Server. -** +** ** Invocation: You must pass an object with properties as follows: ** uri (required) Uri of a WebSocket listener that is ** connected to a spice server. @@ -59,6 +59,7 @@ function SpiceMainConn() this.file_xfer_tasks = {}; this.file_xfer_task_id = 0; this.file_xfer_read_queue = []; + this.ports = []; } SpiceMainConn.prototype = Object.create(SpiceConn.prototype); @@ -144,7 +145,13 @@ SpiceMainConn.prototype.process_channel_message = function(msg) chan_id : chans.channels[i].id }; if (chans.channels[i].type == SPICE_CHANNEL_DISPLAY) - this.display = new SpiceDisplayConn(conn); + { + if (chans.channels[i].id == 0) { + this.display = new SpiceDisplayConn(conn); + } else { + this.log_warn("The spice-html5 client does not handle multiple heads."); + } + } else if (chans.channels[i].type == SPICE_CHANNEL_INPUTS) { this.inputs = new SpiceInputsConn(conn); @@ -154,6 +161,8 @@ SpiceMainConn.prototype.process_channel_message = function(msg) this.cursor = new SpiceCursorConn(conn); else if (chans.channels[i].type == SPICE_CHANNEL_PLAYBACK) this.cursor = new SpicePlaybackConn(conn); + else if (chans.channels[i].type == SPICE_CHANNEL_PORT) + this.ports.push(new SpicePortConn(conn)); else { if (! ("extra_channels" in this)) diff --git a/playback.js b/playback.js index 53b6983..5af9233 100644 --- a/playback.js +++ b/playback.js @@ -29,8 +29,6 @@ function SpicePlaybackConn() this.queue = new Array(); this.append_okay = false; this.start_time = 0; - this.skip_until = 0; - this.gap_time = 0; } SpicePlaybackConn.prototype = Object.create(SpiceConn.prototype); @@ -91,56 +89,59 @@ SpicePlaybackConn.prototype.process_channel_message = function(msg) { var data = new SpiceMsgPlaybackData(msg.data); - // If this packet has the same time as the last, just bump up by one. - if (this.last_data_time && data.time <= this.last_data_time) + if (! this.source_buffer) + return true; + + if (this.audio.readyState >= 3 && this.audio.buffered.length > 1 && + this.audio.currentTime == this.audio.buffered.end(0) && + this.audio.currentTime < this.audio.buffered.start(this.audio.buffered.length - 1)) { - // FIXME - this is arguably wrong. But delaying the transmission was worse, - // in initial testing. Could use more research. - PLAYBACK_DEBUG > 1 && console.log("Hacking time of " + data.time + " to " + this.last_data_time + 1); - data.time = this.last_data_time + 1; + console.log("Audio underrun: we appear to have fallen behind; advancing to " + + this.audio.buffered.start(this.audio.buffered.length - 1)); + this.audio.currentTime = this.audio.buffered.start(this.audio.buffered.length - 1); } - if (! this.source_buffer) - return true; + /* Around version 45, Firefox started being very particular about the + time stamps put into the Opus stream. The time stamps from the Spice server are + somewhat irregular. They mostly arrive every 10 ms, but sometimes it is 11, or sometimes + with two time stamps the same in a row. The previous logic resulted in fuzzy and/or + distorted audio streams in Firefox in a row. - /* Gap detection: If there has been a delay since our last packet, then audio must - have paused. Handling that gets tricky. In Chrome, you can seek forward, - but you cannot in Firefox. And seeking forward in Chrome is nice, as it keeps - Chrome from being overly cautious in it's buffer strategy. + In theory, the sequence mode should be appropriate for us, but as of 09/27/2016, + I was unable to make sequence mode work with Firefox. - So we do two things. First, we seek forward. Second, we compute how much of a gap - there would have been, and essentially eliminate it. + Thus, we end up with an inelegant hack. Essentially, we force every packet to have + a 10ms time delta, unless there is an obvious gap in time stream, in which case we + will resync. */ - if (this.last_data_time && data.time >= (this.last_data_time + GAP_DETECTION_THRESHOLD)) + + if (this.start_time != 0 && data.time != (this.last_data_time + EXPECTED_PACKET_DURATION)) { - this.skip_until = data.time; - this.gap_time = (data.time - this.start_time) - - (this.source_buffer.buffered.end(this.source_buffer.buffered.end.length - 1) * 1000.0).toFixed(0); + if (Math.abs(data.time - (EXPECTED_PACKET_DURATION + this.last_data_time)) < MAX_CLUSTER_TIME) + { + PLAYBACK_DEBUG > 1 && console.log("Hacking time of " + data.time + " to " + + (this.last_data_time + EXPECTED_PACKET_DURATION)); + data.time = this.last_data_time + EXPECTED_PACKET_DURATION; + } + else + { + PLAYBACK_DEBUG > 1 && console.log("Apparent gap in audio time; now is " + data.time + " last was " + this.last_data_time); + } } this.last_data_time = data.time; - PLAYBACK_DEBUG > 1 && console.log("PlaybackData; time " + data.time + "; length " + data.data.byteLength); if (this.start_time == 0) this.start_playback(data); - else if (data.time - this.cluster_time >= MAX_CLUSTER_TIME || this.skip_until > 0) + else if (data.time - this.cluster_time >= MAX_CLUSTER_TIME) this.new_cluster(data); else this.simple_block(data, false); - if (this.skip_until > 0) - { - this.audio.currentTime = (this.skip_until - this.start_time - this.gap_time) / 1000.0; - this.skip_until = 0; - } - - if (this.audio.paused) - this.audio.play(); - return true; } @@ -157,7 +158,22 @@ SpicePlaybackConn.prototype.process_channel_message = function(msg) if (msg.type == SPICE_MSG_PLAYBACK_STOP) { - return true; + PLAYBACK_DEBUG > 0 && console.log("PlaybackStop"); + if (this.source_buffer) + { + document.getElementById(this.parent.screen_id).removeChild(this.audio); + window.URL.revokeObjectURL(this.audio.src); + + delete this.source_buffer; + delete this.media_source; + delete this.audio; + + this.append_okay = false; + this.queue = new Array(); + this.start_time = 0; + + return true; + } } if (msg.type == SPICE_MSG_PLAYBACK_VOLUME) @@ -205,7 +221,7 @@ SpicePlaybackConn.prototype.new_cluster = function(data) { this.cluster_time = data.time; - var c = new webm_Cluster(data.time - this.start_time - this.gap_time); + var c = new webm_Cluster(data.time - this.start_time); var mb = new ArrayBuffer(c.buffer_size()); this.bytes_written += c.to_buffer(mb); @@ -273,6 +289,28 @@ function handle_source_closed(e) p.log_err('Audio source unexpectedly closed.'); } +function condense_playback_queue(queue) +{ + if (queue.length == 1) + return queue.shift(); + + var len = 0; + var i = 0; + for (i = 0; i < queue.length; i++) + len += queue[i].byteLength; + + var mb = new ArrayBuffer(len); + var tmp = new Uint8Array(mb); + len = 0; + for (i = 0; i < queue.length; i++) + { + tmp.set(new Uint8Array(queue[i]), len); + len += queue[i].byteLength; + } + queue.length = 0; + return mb; +} + function handle_append_buffer_done(e) { var p = this.spiceconn; @@ -282,7 +320,7 @@ function handle_append_buffer_done(e) if (p.queue.length > 0) { - var mb = p.queue.shift(); + var mb = condense_playback_queue(p.queue); playback_append_buffer(p, mb); } else diff --git a/port.js b/port.js new file mode 100644 index 0000000..ee22073 --- /dev/null +++ b/port.js @@ -0,0 +1,85 @@ +"use strict"; +/* + Copyright (C) 2016 by Oliver Gutierrez + Miroslav Chodil + + This file is part of spice-html5. + + spice-html5 is free software: you can redistribute it and/or modify + it under the terms of the GNU Lesser General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + spice-html5 is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public License + along with spice-html5. If not, see . +*/ + +/*---------------------------------------------------------------------------- +** SpicePortConn +** Drive the Spice Port Channel +**--------------------------------------------------------------------------*/ +function SpicePortConn() +{ + DEBUG > 0 && console.log('SPICE port: created SPICE port channel. Args:', arguments); + SpiceConn.apply(this, arguments); + this.port_name = null; +} + +SpicePortConn.prototype = Object.create(SpiceConn.prototype); + +SpicePortConn.prototype.process_channel_message = function(msg) +{ + if (msg.type == SPICE_MSG_PORT_INIT) + { + if (this.port_name === null) + { + var m = new SpiceMsgPortInit(msg.data); + this.portName = arraybuffer_to_str(new Uint8Array(m.name)); + this.portOpened = m.opened + DEBUG > 0 && console.log('SPICE port: Port', this.portName, 'initialized'); + return true; + } + + DEBUG > 0 && console.log('SPICE port: Port', this.port_name, 'is already initialized.'); + } + else if (msg.type == SPICE_MSG_PORT_EVENT) + { + DEBUG > 0 && console.log('SPICE port: Port event received for', this.portName, msg); + var event = new CustomEvent('spice-port-event', { + detail: { + channel: this, + spiceEvent: new Uint8Array(msg.data) + }, + bubbles: true, + cancelable: true + }); + + window.dispatchEvent(event); + return true; + } + else if (msg.type == SPICE_MSG_SPICEVMC_DATA) + { + DEBUG > 0 && console.log('SPICE port: Data received in port', this.portName, msg); + var event = new CustomEvent('spice-port-data', { + detail: { + channel: this, + data: msg.data + }, + bubbles: true, + cancelable: true + }); + window.dispatchEvent(event); + return true; + } + else + { + DEBUG > 0 && console.log('SPICE port: SPICE message type not recognized:', msg) + } + + return false; +}; diff --git a/resize.js b/resize.js index f5410d3..51fb1cc 100644 --- a/resize.js +++ b/resize.js @@ -33,17 +33,29 @@ function resize_helper(sc) { var w = document.getElementById(sc.screen_id).clientWidth; - var h = document.getElementById(sc.screen_id).clientHeight; - var m = document.getElementById(sc.message_id); /* Resize vertically; basically we leave a 20 pixel margin at the bottom, and use the position of the message window to figure out how to resize */ - var hd = window.innerHeight - m.offsetHeight - m.offsetTop - 20; + + var h = window.innerHeight - 20; + + /* Screen height based on debug console visibility */ + if (window.getComputedStyle(m).getPropertyValue("display") == 'none') + { + /* Get console height from spice.css .spice-message */ + var mh = parseInt(window.getComputedStyle(m).getPropertyValue("height"), 10); + h = h - mh; + } + else + { + /* Show both div elements - spice-area and message-div */ + h = h - m.offsetHeight - m.clientHeight; + } + /* Xorg requires height be a multiple of 8; round up */ - h = h + hd; if (h % 8 > 0) h += (8 - (h % 8)); diff --git a/simulatecursor.js b/simulatecursor.js index b1fce06..ffd9089 100644 --- a/simulatecursor.js +++ b/simulatecursor.js @@ -71,7 +71,7 @@ simulate_cursor: function (spicecursor, cursor, screen, pngstr) if (window.getComputedStyle(screen, null).cursor == 'auto') { - SpiceSimulateCursor.unknown_cursor(cursor_sha, + SpiceSimulateCursor.unknown_cursor(cursor_sha, SpiceSimulateCursor.create_icondir(cursor.header.width, cursor.header.height, cursor.data.byteLength, cursor.header.hot_spot_x, cursor.header.hot_spot_y) + pngstr); @@ -99,7 +99,7 @@ simulate_cursor: function (spicecursor, cursor, screen, pngstr) spicecursor.spice_simulated_cursor.style.pointerEvents = "none"; } else - { + { if (spicecursor.spice_simulated_cursor) { spicecursor.spice_simulated_cursor.spice_screen.removeChild(spicecursor.spice_simulated_cursor); @@ -162,7 +162,7 @@ create_icondir: function (width, height, bytes, hot_x, hot_y) }; -SpiceSimulateCursor.ICONDIR.prototype = +SpiceSimulateCursor.ICONDIR.prototype = { to_buffer: function(a, at) { diff --git a/spice.css b/spice.css index 5d092ba..ee1b2f3 100644 --- a/spice.css +++ b/spice.css @@ -115,4 +115,3 @@ body .spice-message-error { color: red; } - diff --git a/spice.html b/spice.html index f2f9ed0..7abfcff 100644 --- a/spice.html +++ b/spice.html @@ -28,26 +28,27 @@ Spice Javascript client - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + - + @@ -71,8 +72,8 @@ { var host, port, password, scheme = "ws://", uri; - host = document.getElementById("host").value; - port = document.getElementById("port").value; + host = document.getElementById("host").value; + port = document.getElementById("port").value; password = document.getElementById("password").value; @@ -92,7 +93,7 @@ try { - sc = new SpiceMainConn({uri: uri, screen_id: "spice-screen", dump_id: "debug-div", + sc = new SpiceMainConn({uri: uri, screen_id: "spice-screen", dump_id: "debug-div", message_id: "message-div", password: password, onerror: spice_error, onagent: agent_connected }); } catch (e) @@ -142,6 +143,35 @@ } } + function toggle_console() + { + var checkbox = document.getElementById('show_console'); + var m = document.getElementById('message-div'); + + if (checkbox.checked) + { + m.style.display = 'block'; + } + else + { + m.style.display = 'none'; + } + + window.addEventListener('resize', handle_resize); + resize_helper(sc); + } + /* SPICE port event listeners + window.addEventListener('spice-port-data', function(event) { + // Here we convert data to text, but really we can obtain binary data also + var msg_text = arraybuffer_to_str(new Uint8Array(event.detail.data)); + DEBUG > 0 && console.log('SPICE port', event.detail.channel.portName, 'message text:', msg_text); + }); + + window.addEventListener('spice-port-event', function(event) { + DEBUG > 0 && console.log('SPICE port', event.detail.channel.portName, 'event data:', event.detail.spiceEvent); + }); + */ + @@ -153,6 +183,7 @@ + diff --git a/spice_auto.html b/spice_auto.html index 9aae118..2f04fc9 100644 --- a/spice_auto.html +++ b/spice_auto.html @@ -28,7 +28,7 @@ Spice Javascript client - + @@ -42,6 +42,7 @@ + @@ -182,6 +183,18 @@ } } + /* SPICE port event listeners + window.addEventListener('spice-port-data', function(event) { + // Here we convert data to text, but really we can obtain binary data also + var msg_text = arraybuffer_to_str(new Uint8Array(event.detail.data)); + DEBUG > 0 && console.log('SPICE port', event.detail.channel.portName, 'message text:', msg_text); + }); + + window.addEventListener('spice-port-event', function(event) { + DEBUG > 0 && console.log('SPICE port', event.detail.channel.portName, 'event data:', event.detail.spiceEvent); + }); + */ + connect(); diff --git a/spiceconn.js b/spiceconn.js index 41d2fa3..33e7388 100644 --- a/spiceconn.js +++ b/spiceconn.js @@ -23,7 +23,7 @@ ** This is the base Javascript class for establishing and ** managing a connection to a Spice Server. ** It is used to provide core functionality to the Spice main, -** display, inputs, and cursor channels. See main.js for +** display, inputs, and cursor channels. See main.js for ** usage. **--------------------------------------------------------------------------*/ function SpiceConn(o) @@ -119,28 +119,36 @@ SpiceConn.prototype = msg.connection_id = this.connection_id; msg.channel_type = this.type; - // FIXME - we're not setting a channel_id... + msg.channel_id = this.chan_id; + msg.common_caps.push( (1 << SPICE_COMMON_CAP_PROTOCOL_AUTH_SELECTION) | (1 << SPICE_COMMON_CAP_MINI_HEADER) ); if (msg.channel_type == SPICE_CHANNEL_PLAYBACK) - msg.channel_caps.push( - (1 << SPICE_PLAYBACK_CAP_OPUS) - ); + { + var caps = 0; + if ('MediaSource' in window && MediaSource.isTypeSupported(SPICE_PLAYBACK_CODEC)) + caps |= (1 << SPICE_PLAYBACK_CAP_OPUS); + msg.channel_caps.push(caps); + } else if (msg.channel_type == SPICE_CHANNEL_MAIN) + { msg.channel_caps.push( (1 << SPICE_MAIN_CAP_AGENT_CONNECTED_TOKENS) ); + } else if (msg.channel_type == SPICE_CHANNEL_DISPLAY) - msg.channel_caps.push( - (1 << SPICE_DISPLAY_CAP_SIZED_STREAM) | - (1 << SPICE_DISPLAY_CAP_STREAM_REPORT) | - (1 << SPICE_DISPLAY_CAP_MULTI_CODEC) | - (1 << SPICE_DISPLAY_CAP_CODEC_MJPEG) | - (1 << SPICE_DISPLAY_CAP_CODEC_VP8) - ); + { + var caps = (1 << SPICE_DISPLAY_CAP_SIZED_STREAM) | + (1 << SPICE_DISPLAY_CAP_STREAM_REPORT) | + (1 << SPICE_DISPLAY_CAP_MULTI_CODEC) | + (1 << SPICE_DISPLAY_CAP_CODEC_MJPEG); + if ('MediaSource' in window && MediaSource.isTypeSupported(SPICE_VP8_CODEC)) + caps |= (1 << SPICE_DISPLAY_CAP_CODEC_VP8); + msg.channel_caps.push(caps); + } hdr.size = msg.buffer_size(); @@ -188,8 +196,11 @@ SpiceConn.prototype = if (msg.type > 500) { - alert("Something has gone very wrong; we think we have message of type " + msg.type); - debugger; + if (DEBUG > 0) + { + alert("Something has gone very wrong; we think we have message of type " + msg.type); + debugger; + } } if (msg.size == 0) diff --git a/spicedataview.js b/spicedataview.js index 800df03..719d968 100644 --- a/spicedataview.js +++ b/spicedataview.js @@ -20,10 +20,10 @@ /*---------------------------------------------------------------------------- ** SpiceDataView -** FIXME FIXME +** FIXME FIXME ** This is used because Firefox does not have DataView yet. -** We should use DataView if we have it, because it *has* to -** be faster than this code +** We should use DataView if we have it, because it *has* to +** be faster than this code **--------------------------------------------------------------------------*/ function SpiceDataView(buffer, byteOffset, byteLength) { @@ -63,7 +63,7 @@ SpiceDataView.prototype = { high = 2; } - return (this.getUint16(byteOffset + high, littleEndian) << 16) | + return (this.getUint16(byteOffset + high, littleEndian) << 16) | this.getUint16(byteOffset + low, littleEndian); }, getUint64: function (byteOffset, littleEndian) diff --git a/spicemsg.js b/spicemsg.js index db6625a..3619996 100644 --- a/spicemsg.js +++ b/spicemsg.js @@ -21,7 +21,7 @@ /*---------------------------------------------------------------------------- ** Spice messages ** This file contains classes for passing messages to and from -** a spice server. This file should arguably be generated from +** a spice server. This file should arguably be generated from ** spice.proto, but it was instead put together by hand. **--------------------------------------------------------------------------*/ function SpiceLinkHeader(a, at) @@ -63,7 +63,7 @@ SpiceLinkHeader.prototype = dv.setUint32(at, this.size, true); at += 4; }, buffer_size: function() - { + { return 16; }, } @@ -938,7 +938,7 @@ function SpiceMsgcMousePosition(sc, e) this.x = e.clientX - sc.display.surfaces[sc.display.primary_surface].canvas.offsetLeft + scrollLeft; this.y = e.clientY - sc.display.surfaces[sc.display.primary_surface].canvas.offsetTop + scrollTop; sc.mousex = this.x; - sc.mousey = this.y; + sc.mousey = this.y; } else { @@ -1278,3 +1278,21 @@ SpiceMsgDisplayInvalList.prototype = } }, } + +function SpiceMsgPortInit(a, at) +{ + this.from_buffer(a,at); +}; + +SpiceMsgPortInit.prototype = +{ + from_buffer: function (a, at) + { + at = at || 0; + var dv = new SpiceDataView(a); + var namesize = dv.getUint32(at, true); at += 4; + var offset = dv.getUint32(at, true); at += 4; + this.opened = dv.getUint8(at, true); at += 1; + this.name = a.slice(offset, offset + namesize - 1); + } +} diff --git a/spicetype.js b/spicetype.js index 951b277..e145abc 100644 --- a/spicetype.js +++ b/spicetype.js @@ -469,5 +469,5 @@ SpiceSurface.prototype = }, } -/* FIXME - SpiceImage types lz_plt, jpeg, zlib_glz, and jpeg_alpha are +/* FIXME - SpiceImage types lz_plt, jpeg, zlib_glz, and jpeg_alpha are completely unimplemented */ diff --git a/thirdparty/jsbn.js b/thirdparty/jsbn.js index 9b9476e..d88ec54 100644 --- a/thirdparty/jsbn.js +++ b/thirdparty/jsbn.js @@ -15,9 +15,9 @@ * The above copyright notice and this permission notice shall be * included in all copies or substantial portions of the Software. * - * THE SOFTWARE IS PROVIDED "AS-IS" AND WITHOUT WARRANTY OF ANY KIND, - * EXPRESS, IMPLIED OR OTHERWISE, INCLUDING WITHOUT LIMITATION, ANY - * WARRANTY OF MERCHANTABILITY OR FITNESS FOR A PARTICULAR PURPOSE. + * THE SOFTWARE IS PROVIDED "AS-IS" AND WITHOUT WARRANTY OF ANY KIND, + * EXPRESS, IMPLIED OR OTHERWISE, INCLUDING WITHOUT LIMITATION, ANY + * WARRANTY OF MERCHANTABILITY OR FITNESS FOR A PARTICULAR PURPOSE. * * IN NO EVENT SHALL TOM WU BE LIABLE FOR ANY SPECIAL, INCIDENTAL, * INDIRECT OR CONSEQUENTIAL DAMAGES OF ANY KIND, OR ANY DAMAGES WHATSOEVER diff --git a/thirdparty/prng4.js b/thirdparty/prng4.js index 4715372..ef3efd6 100644 --- a/thirdparty/prng4.js +++ b/thirdparty/prng4.js @@ -15,9 +15,9 @@ * The above copyright notice and this permission notice shall be * included in all copies or substantial portions of the Software. * - * THE SOFTWARE IS PROVIDED "AS-IS" AND WITHOUT WARRANTY OF ANY KIND, - * EXPRESS, IMPLIED OR OTHERWISE, INCLUDING WITHOUT LIMITATION, ANY - * WARRANTY OF MERCHANTABILITY OR FITNESS FOR A PARTICULAR PURPOSE. + * THE SOFTWARE IS PROVIDED "AS-IS" AND WITHOUT WARRANTY OF ANY KIND, + * EXPRESS, IMPLIED OR OTHERWISE, INCLUDING WITHOUT LIMITATION, ANY + * WARRANTY OF MERCHANTABILITY OR FITNESS FOR A PARTICULAR PURPOSE. * * IN NO EVENT SHALL TOM WU BE LIABLE FOR ANY SPECIAL, INCIDENTAL, * INDIRECT OR CONSEQUENTIAL DAMAGES OF ANY KIND, OR ANY DAMAGES WHATSOEVER diff --git a/thirdparty/rng.js b/thirdparty/rng.js index 829a23c..efbf382 100644 --- a/thirdparty/rng.js +++ b/thirdparty/rng.js @@ -15,9 +15,9 @@ * The above copyright notice and this permission notice shall be * included in all copies or substantial portions of the Software. * - * THE SOFTWARE IS PROVIDED "AS-IS" AND WITHOUT WARRANTY OF ANY KIND, - * EXPRESS, IMPLIED OR OTHERWISE, INCLUDING WITHOUT LIMITATION, ANY - * WARRANTY OF MERCHANTABILITY OR FITNESS FOR A PARTICULAR PURPOSE. + * THE SOFTWARE IS PROVIDED "AS-IS" AND WITHOUT WARRANTY OF ANY KIND, + * EXPRESS, IMPLIED OR OTHERWISE, INCLUDING WITHOUT LIMITATION, ANY + * WARRANTY OF MERCHANTABILITY OR FITNESS FOR A PARTICULAR PURPOSE. * * IN NO EVENT SHALL TOM WU BE LIABLE FOR ANY SPECIAL, INCIDENTAL, * INDIRECT OR CONSEQUENTIAL DAMAGES OF ANY KIND, OR ANY DAMAGES WHATSOEVER @@ -66,7 +66,7 @@ if(rng_pool == null) { var z = window.crypto.random(32); for(t = 0; t < z.length; ++t) rng_pool[rng_pptr++] = z.charCodeAt(t) & 255; - } + } while(rng_pptr < rng_psize) { // extract some randomness from Math.random() t = Math.floor(65536 * Math.random()); rng_pool[rng_pptr++] = t >>> 8; diff --git a/thirdparty/rsa.js b/thirdparty/rsa.js index 1bbf249..ea0e45b 100644 --- a/thirdparty/rsa.js +++ b/thirdparty/rsa.js @@ -15,9 +15,9 @@ * The above copyright notice and this permission notice shall be * included in all copies or substantial portions of the Software. * - * THE SOFTWARE IS PROVIDED "AS-IS" AND WITHOUT WARRANTY OF ANY KIND, - * EXPRESS, IMPLIED OR OTHERWISE, INCLUDING WITHOUT LIMITATION, ANY - * WARRANTY OF MERCHANTABILITY OR FITNESS FOR A PARTICULAR PURPOSE. + * THE SOFTWARE IS PROVIDED "AS-IS" AND WITHOUT WARRANTY OF ANY KIND, + * EXPRESS, IMPLIED OR OTHERWISE, INCLUDING WITHOUT LIMITATION, ANY + * WARRANTY OF MERCHANTABILITY OR FITNESS FOR A PARTICULAR PURPOSE. * * IN NO EVENT SHALL TOM WU BE LIABLE FOR ANY SPECIAL, INCIDENTAL, * INDIRECT OR CONSEQUENTIAL DAMAGES OF ANY KIND, OR ANY DAMAGES WHATSOEVER diff --git a/utils.js b/utils.js index 9093a24..7aeefdb 100644 --- a/utils.js +++ b/utils.js @@ -27,6 +27,11 @@ var STREAM_DEBUG = 0; var DUMP_DRAWS = false; var DUMP_CANVASES = false; +/*---------------------------------------------------------------------------- +** We use an Image temporarily, and the image/src does not get garbage +** collected as quickly as we might like. This blank image helps with that. +**--------------------------------------------------------------------------*/ +var EMPTY_GIF_IMAGE = "data:image/gif;base64,R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs="; /*---------------------------------------------------------------------------- ** combine_array_buffers @@ -100,6 +105,13 @@ function hexdump_buffer(a) } /*---------------------------------------------------------------------------- +** Convert arraybuffer to string +**--------------------------------------------------------------------------*/ +function arraybuffer_to_str(buf) { + return String.fromCharCode.apply(null, new Uint16Array(buf)); +} + +/*---------------------------------------------------------------------------- ** Converting keycodes to AT scancodes is very hard. ** luckly there are some resources on the web and in the Xorg driver that help ** us figure out what browser dependent keycodes match to what scancodes. diff --git a/webm.js b/webm.js index 8faa8e7..789da14 100644 --- a/webm.js +++ b/webm.js @@ -84,6 +84,7 @@ var OPUS_CHANNELS = 2; var SPICE_PLAYBACK_CODEC = 'audio/webm; codecs="opus"'; var MAX_CLUSTER_TIME = 1000; +var EXPECTED_PACKET_DURATION = 10; var GAP_DETECTION_THRESHOLD = 50; var SPICE_VP8_CODEC = 'video/webm; codecs="vp8"'; diff --git a/wire.js b/wire.js index 7407ce7..2c7f096 100644 --- a/wire.js +++ b/wire.js @@ -96,7 +96,7 @@ SpiceWireReader.prototype = this.callback.call(this.sc, mb, this.saved_msg_header || undefined); } - + }, request: function(n)