From 4fca8fce4117e49e3b5b1ae816ff1345b80762a3 Mon Sep 17 00:00:00 2001 From: Keith Smith Date: Tue, 15 Sep 2026 21:13:17 -0600 Subject: [PATCH] Add VNC Tight decoding Implements RFC 6143's Tight encoding: a compression-control byte (low 4 bits reset one of 4 persistent zlib streams; high nibble selects Fill/JPEG/Basic mode) followed by Fill's 3-byte solid color, JPEG's compact-length-prefixed baseline JPEG covering the whole rectangle (decoded via libjpeg-turbo directly, not QImage's plugin, to avoid a packaging-dependent runtime failure mode), or Basic mode's compact-length-prefixed zlib payload plus a filter (Copy, Palette, or Gradient) applied after decompression. Unlike Hextile/ZRLE, Tight has no internal tiling -- one rectangle is one filtered/compressed unit. The three filters live in vnc_pixel_codecs.h/.cpp alongside the Hextile/ZRLE decoders. Adds find_package(JPEG REQUIRED) + JPEG::JPEG as a new build dependency (confirmed available via libjpeg-turbo on this dev machine). 5 new tests cover Fill, Basic+Copy, Basic+Palette, JPEG (round-tripped through a real libjpeg-turbo-encoded fixture, compared with tolerance since JPEG is lossy), and the stream-reset flag correctly tearing down and reinitializing a targeted stream rather than erroring on stale state. Known, documented gap: this decoder always treats Basic-mode payloads as zlib-compressed; the real protocol allows very small payloads to skip compression, which couldn't be verified with confidence against the RFC text alone and is narrow enough in practice (tiny solid areas are virtually always sent as Fill instead) to leave unhandled for now -- it fails that one rectangle's decode cleanly rather than misinterpreting it silently. The Gradient filter is implemented from the spec description but is the least exercised of the three in this pass. Live-verified against the TightVNC test server that nothing regressed; that server still consistently chose Raw for actual framebuffer content regardless of announced encodings, so Tight's live decode path isn't independently confirmed against a real server here either -- the unit tests are the primary evidence. Co-Authored-By: Claude Sonnet 5 --- CMakeLists.txt | 2 + docs/PROGRESS.md | 63 +++-- src/vnc_pixel_codecs.cpp | 115 ++++++++++ src/vnc_pixel_codecs.h | 30 +++ src/vnc_session_backend.cpp | 324 +++++++++++++++++++++++++- src/vnc_session_backend.h | 35 ++- tests/CMakeLists.txt | 2 +- tests/test_vnc_session_backend.cpp | 356 +++++++++++++++++++++++++++++ 8 files changed, 901 insertions(+), 26 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 0411fee..ab37502 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -15,6 +15,7 @@ include(GNUInstallDirs) find_package(Qt6 6.2 REQUIRED COMPONENTS Widgets Sql Network) find_package(OpenSSL REQUIRED) find_package(ZLIB REQUIRED) +find_package(JPEG REQUIRED) qt_standard_project_setup() @@ -157,6 +158,7 @@ target_link_libraries(orbithub PRIVATE Qt6::Widgets Qt6::Sql Qt6::Network) target_link_libraries(orbithub PRIVATE KodoTerm::KodoTerm) target_link_libraries(orbithub PRIVATE OpenSSL::Crypto) target_link_libraries(orbithub PRIVATE ZLIB::ZLIB) +target_link_libraries(orbithub PRIVATE JPEG::JPEG) target_compile_definitions(orbithub PRIVATE ORBITHUB_VERSION_STRING="${PROJECT_VERSION}") if(TARGET freerdp AND TARGET winpr) target_compile_definitions(orbithub PRIVATE ORBITHUB_HAS_FREERDP) diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index 52c4be5..b5c6c0b 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -121,25 +121,26 @@ Delivered: keyboard (Qt key -> X11 keysym mapping) and mouse/wheel input forwarding - `VncDisplayWidget` mirroring `RdpDisplayWidget`'s scale-to-fit rendering and input-forwarding shape -- 36 unit tests (`tests/test_vnc_session_backend.cpp`): pure-function +- 41 unit tests (`tests/test_vnc_session_backend.cpp`): pure-function coverage (DES key prep verified against an independently documented test vector, keysym mapping, socket-error mapping) plus state-machine coverage against a scripted in-process fake RFB server (all three protocol-version handshake shapes, auth success/failure, unsupported - security types, pixel-accurate Raw/Hextile/ZRLE decoding including a - ZRLE zlib-stream-persistence test across two separate - `FramebufferUpdate` messages, cursor/clipboard round trips) -- caught - and fixed a real re-entrancy bug (`abort()` synchronously re-firing - `disconnected()` mid-`failConnection()`, silently overwriting a specific - error with a generic one) + security types, pixel-accurate Raw/Hextile/ZRLE/Tight decoding including + a ZRLE zlib-stream-persistence test across two separate + `FramebufferUpdate` messages, a Tight stream-reset-flag test, cursor/ + clipboard round trips) -- caught and fixed a real re-entrancy bug + (`abort()` synchronously re-firing `disconnected()` mid- + `failConnection()`, silently overwriting a specific error with a + generic one) - Verified live against a real, independently implemented VNC server (TightVNC on Windows): connect with VNC Authentication, correct framebuffer dimensions and pixel data, clean disconnect, reconnect, live cursor-shape and clipboard-send checks. That particular server consistently chose Raw for actual framebuffer content regardless of - which compression encodings were announced, so Hextile/ZRLE's + which compression encodings were announced, so Hextile/ZRLE/Tight's real-world decode path isn't independently confirmed live -- the unit - tests are the primary correctness evidence for those two + tests are the primary correctness evidence for those - Per-tab VNC-only display mode toggle (tab-bar right-click -> `Display Mode`): `Scale to Fit` (default, matches RDP's behavior) or `Actual Size (Scrollbars)` -- renders the remote framebuffer at its @@ -158,22 +159,46 @@ Delivered: Unicode extension) in both directions - Remote cursor shape sync via RFB's Cursor pseudo-encoding, mirroring `RdpDisplayWidget`'s cursor handling in `VncDisplayWidget` -- Hextile and ZRLE compression encodings, in addition to Raw + CopyRect -- - meaningfully reduces bandwidth over slower links versus Raw alone. Pure - tile/pixel decode logic lives in `src/vnc_pixel_codecs.h/.cpp`, kept - separate from the wire-sequencing state machine so it's unit-testable - without a socket. ZRLE decoding links `ZLIB::ZLIB` (found via a fresh - top-level `find_package(ZLIB REQUIRED)`, independent of whether - vendored FreeRDP's own internal zlib usage stays enabled) +- Hextile, ZRLE, and Tight compression encodings, in addition to Raw + + CopyRect -- meaningfully reduces bandwidth over slower links versus Raw + alone; Tight in particular is the encoding most real VNC servers prefer + when the client offers it. Pure tile/pixel decode logic (including + Tight's three filters -- Copy, Palette, Gradient) lives in + `src/vnc_pixel_codecs.h/.cpp`, kept separate from the wire-sequencing + state machine so it's unit-testable without a socket. ZRLE and Tight's + Basic mode share the same "persistent zlib stream(s), decompress + in-memory, decode synchronously" approach (Tight has 4 independent + streams selected per-rectangle, individually reset via the + compression-control byte's low 4 bits). Tight's JPEG sub-mode decodes + via libjpeg-turbo directly (`find_package(JPEG REQUIRED)` -> + `JPEG::JPEG`), not `QImage`'s own JPEG plugin, to avoid a + packaging-dependent runtime failure mode. ZRLE/Tight link `ZLIB::ZLIB` + (found via a fresh top-level `find_package(ZLIB REQUIRED)`, independent + of whether vendored FreeRDP's own internal zlib usage stays enabled) +- 41 unit tests total, 15 of them for Hextile/ZRLE/Tight specifically, + including a ZRLE zlib-stream-persistence test across two separate + `FramebufferUpdate` messages and a Tight stream-reset-flag test proving + the low 4 control-byte bits actually tear down and reinitialize the + targeted stream rather than erroring out on stale state Known gaps (explicit scope decisions, not oversights -- see issue #3 for follow-up tracking): - Apple's Screen Sharing authentication (Diffie-Hellman + AES, security type 30) isn't implemented, so this can't yet reach macOS's built-in VNC server -- only standard VNC Authentication (type 2) and no-auth (type 1) -- Tight compression isn't implemented yet (Raw + CopyRect + Hextile + ZRLE - are); Tight typically compresses best of all of these, so bandwidth over - very slow links is still not as good as a full VNC client would achieve +- Tight's Basic compression mode always assumes zlib-compressed payloads; + the real protocol permits the server to skip compression for very small + (filtered byte count under ~12) payloads, which this decoder doesn't + special-case (the exact trigger/wire-signaling for that couldn't be + verified with confidence against the RFC text alone). In practice this + only affects rare, tiny rectangles -- solid or near-solid tiny areas are + virtually always sent as Fill instead -- and fails that one rectangle's + decode cleanly (disconnects with a clear error) rather than silently + misinterpreting it +- Tight's Gradient filter is implemented from RFC 6143's description but + is the least exercised/confirmed of the three filters against a real + server in this pass (most real-world Tight traffic uses Copy or + Palette) - No dynamic resize (connects at the server's native resolution; the `Scale to Fit`/`Actual Size` toggle changes how that fixed resolution is displayed locally, not what resolution is requested from the guest -- diff --git a/src/vnc_pixel_codecs.cpp b/src/vnc_pixel_codecs.cpp index ecb9e04..e2e1359 100644 --- a/src/vnc_pixel_codecs.cpp +++ b/src/vnc_pixel_codecs.cpp @@ -251,4 +251,119 @@ int decodeZrleTile(const QByteArray& data, int offset, int tileWidth, int tileHe return -1; } +QVector decodeTightCopyFilter(const QByteArray& data, int width, int height) +{ + const int pixelCount = width * height; + QVector pixels; + if (pixelCount <= 0 || data.size() < pixelCount * 3) { + return pixels; + } + pixels.reserve(pixelCount); + const auto* bytes = reinterpret_cast(data.constData()); + for (int i = 0; i < pixelCount; ++i) { + pixels.append(rgbFromPixelBytes(bytes + (i * 3))); + } + return pixels; +} + +QVector decodeTightPaletteFilter(const QByteArray& data, int width, int height) +{ + QVector pixels; + const int pixelCount = width * height; + if (pixelCount <= 0 || data.isEmpty()) { + return pixels; + } + + const auto* bytes = reinterpret_cast(data.constData()); + const int size = data.size(); + int pos = 0; + const int paletteSize = static_cast(bytes[pos]) + 1; // 1-256 colors + ++pos; + if (pos + (paletteSize * 3) > size) { + return pixels; + } + + QVector palette; + palette.reserve(paletteSize); + for (int i = 0; i < paletteSize; ++i) { + palette.append(rgbFromPixelBytes(bytes + pos)); + pos += 3; + } + + int bitsPerPixel = 8; + if (paletteSize <= 2) { + bitsPerPixel = 1; + } else if (paletteSize <= 4) { + bitsPerPixel = 2; + } else if (paletteSize <= 16) { + bitsPerPixel = 4; + } + + pixels.reserve(pixelCount); + int bitPos = 0; + for (int i = 0; i < pixelCount; ++i) { + const int byteIndex = pos + (bitPos / 8); + if (byteIndex >= size) { + return QVector(); + } + const int shift = 8 - (bitPos % 8) - bitsPerPixel; + const int mask = (1 << bitsPerPixel) - 1; + const int index = (bytes[byteIndex] >> shift) & mask; + if (index >= palette.size()) { + return QVector(); + } + pixels.append(palette.at(index)); + bitPos += bitsPerPixel; + } + return pixels; +} + +QVector decodeTightGradientFilter(const QByteArray& data, int width, int height) +{ + const int pixelCount = width * height; + QVector pixels; + if (pixelCount <= 0 || data.size() < pixelCount * 3) { + return pixels; + } + + const auto* bytes = reinterpret_cast(data.constData()); + auto predict = [](int left, int up, int upLeft) { + return qBound(0, left + up - upLeft, 255); + }; + + QVector rChan(pixelCount); + QVector gChan(pixelCount); + QVector bChan(pixelCount); + for (int y = 0; y < height; ++y) { + for (int x = 0; x < width; ++x) { + const int idx = (y * width) + x; + const uchar* px = bytes + (idx * 3); + // TPIXEL order matches rgbFromPixelBytes: B,G,R. + const int deltaB = px[0]; + const int deltaG = px[1]; + const int deltaR = px[2]; + + const int leftR = (x > 0) ? rChan[idx - 1] : 0; + const int leftG = (x > 0) ? gChan[idx - 1] : 0; + const int leftB = (x > 0) ? bChan[idx - 1] : 0; + const int upR = (y > 0) ? rChan[idx - width] : 0; + const int upG = (y > 0) ? gChan[idx - width] : 0; + const int upB = (y > 0) ? bChan[idx - width] : 0; + const int upLeftR = (x > 0 && y > 0) ? rChan[idx - width - 1] : 0; + const int upLeftG = (x > 0 && y > 0) ? gChan[idx - width - 1] : 0; + const int upLeftB = (x > 0 && y > 0) ? bChan[idx - width - 1] : 0; + + rChan[idx] = (predict(leftR, upR, upLeftR) + deltaR) & 0xFF; + gChan[idx] = (predict(leftG, upG, upLeftG) + deltaG) & 0xFF; + bChan[idx] = (predict(leftB, upB, upLeftB) + deltaB) & 0xFF; + } + } + + pixels.reserve(pixelCount); + for (int i = 0; i < pixelCount; ++i) { + pixels.append(qRgb(rChan.at(i), gChan.at(i), bChan.at(i))); + } + return pixels; +} + } diff --git a/src/vnc_pixel_codecs.h b/src/vnc_pixel_codecs.h index dbd9690..7abc619 100644 --- a/src/vnc_pixel_codecs.h +++ b/src/vnc_pixel_codecs.h @@ -74,6 +74,36 @@ QVector decodeHextileSubrects(bool coloured, int subrectCount, int decodeZrleTile(const QByteArray& data, int offset, int tileWidth, int tileHeight, QVector& pixels); +// Tight encoding (RFC 6143 SS7.7.4) filters. Unlike Hextile/ZRLE, a Tight +// rectangle is never internally tiled -- these operate on the whole +// rectangle's already-decompressed (or, for very small payloads the real +// protocol allows to skip compression entirely, raw -- NOT handled by this +// implementation, see VncSessionBackend's class comment) filtered byte +// stream at once. Each returns exactly width*height pixels on success; a +// short/malformed result (any size other than width*height, including an +// empty vector) signals truncated/invalid input to the caller. + +// "Copy" filter: `data` is exactly width*height TPIXELs (3 bytes each, +// row-major, same B,G,R order as rgbFromPixelBytes/ZRLE's CPIXEL). +QVector decodeTightCopyFilter(const QByteArray& data, int width, int height); + +// "Palette" filter: `data` is a 1-byte (paletteSize-1) count, then +// paletteSize TPIXELs, then a *continuous* (not row-padded, unlike ZRLE's +// packed palette) MSB-first bit-packed index stream covering width*height +// pixels, with bits-per-pixel derived from paletteSize the same way ZRLE's +// packed palette does (<=2 colors: 1 bit; <=4: 2 bits; <=16: 4 bits; +// otherwise 8 bits/1 byte per index, up to 256 colors). +QVector decodeTightPaletteFilter(const QByteArray& data, int width, int height); + +// "Gradient" filter: `data` is exactly width*height TPIXELs, each channel +// (R,G,B independently) carrying a delta from a predicted value computed +// from already-decoded neighbors (predicted = clamp(left + up - upleft, +// 0, 255); treated as 0 past the first row/column). This is the least +// commonly exercised of the three Tight filters in real-world traffic and +// the one this implementation has the lowest confidence in byte-for-byte +// -- flagged for extra scrutiny/testing. +QVector decodeTightGradientFilter(const QByteArray& data, int width, int height); + } #endif diff --git a/src/vnc_session_backend.cpp b/src/vnc_session_backend.cpp index 207ff3d..20f3d90 100644 --- a/src/vnc_session_backend.cpp +++ b/src/vnc_session_backend.cpp @@ -12,6 +12,10 @@ #include +extern "C" { +#include +} + #include #include #include @@ -38,14 +42,26 @@ constexpr qint32 kEncRaw = 0; constexpr qint32 kEncCopyRect = 1; constexpr qint32 kEncHextile = 5; constexpr qint32 kEncZRLE = 16; +constexpr qint32 kEncTight = 7; // RFC 6143 SS7.8.2 "Cursor pseudo-encoding": not a real screen-content // encoding -- a rectangle with this type carries a cursor shape update // instead (hotspot in x/y, image dims in width/height), never painted into // the framebuffer. constexpr qint32 kEncCursor = -239; -constexpr std::array kAnnouncedEncodings = { kEncRaw, kEncCopyRect, kEncHextile, - kEncZRLE, kEncCursor }; +constexpr std::array kAnnouncedEncodings = { kEncRaw, kEncCopyRect, kEncHextile, + kEncZRLE, kEncTight, kEncCursor }; + +// Tight compression-control byte, high nibble (after the low 4 bits' +// stream-reset flags are stripped off). +constexpr quint8 kTightModeFill = 0x08; +constexpr quint8 kTightModeJpeg = 0x09; +// Modes 0-7 are "Basic" compression; bits 0-1 of the mode select which of +// the 4 persistent zlib streams to use, bit 2 flags that an explicit +// filter-id byte follows (Copy is implied when it's clear). +constexpr quint8 kTightBasicModeMax = 0x07; +constexpr quint8 kTightBasicExplicitFilterFlag = 0x04; +constexpr quint8 kTightBasicStreamIndexMask = 0x03; constexpr int kZrleTileSize = 64; @@ -81,6 +97,62 @@ void appendU32BE(QByteArray& buf, quint32 value) buf.append(static_cast((value >> 8) & 0xFF)); buf.append(static_cast(value & 0xFF)); } + +// Decodes a Tight "JPEG" rectangle (a full baseline JPEG image covering +// the whole rectangle, no tiling) via libjpeg-turbo directly -- chosen +// over QImage::fromData(..., "JPEG") specifically to avoid depending on +// Qt's own JPEG plugin being present at runtime across every packaging +// target. Returns false on any decode failure or dimension mismatch +// rather than risk painting a misinterpreted image. +bool decodeJpegRectangle(const QByteArray& jpegBytes, int width, int height, QImage* outImage) +{ + if (jpegBytes.isEmpty() || width <= 0 || height <= 0) { + return false; + } + + jpeg_decompress_struct cinfo; + jpeg_error_mgr jerr; + cinfo.err = jpeg_std_error(&jerr); + jpeg_create_decompress(&cinfo); + + jpeg_mem_src(&cinfo, reinterpret_cast(jpegBytes.constData()), + static_cast(jpegBytes.size())); + + if (jpeg_read_header(&cinfo, TRUE) != JPEG_HEADER_OK) { + jpeg_destroy_decompress(&cinfo); + return false; + } + + cinfo.out_color_space = JCS_RGB; + jpeg_start_decompress(&cinfo); + + if (static_cast(cinfo.output_width) != width + || static_cast(cinfo.output_height) != height) { + jpeg_finish_decompress(&cinfo); + jpeg_destroy_decompress(&cinfo); + return false; + } + + QImage image(width, height, QImage::Format_RGB32); + const int rowStride = static_cast(cinfo.output_width) * cinfo.output_components; + QVector rowBuffer(rowStride); + unsigned char* rowPointer[1]; + while (cinfo.output_scanline < cinfo.output_height) { + rowPointer[0] = rowBuffer.data(); + jpeg_read_scanlines(&cinfo, rowPointer, 1); + const int y = static_cast(cinfo.output_scanline) - 1; + auto* dst = reinterpret_cast(image.scanLine(y)); + for (int x = 0; x < width; ++x) { + const unsigned char* p = rowBuffer.constData() + (x * 3); + dst[x] = qRgb(p[0], p[1], p[2]); + } + } + + jpeg_finish_decompress(&cinfo); + jpeg_destroy_decompress(&cinfo); + *outImage = image; + return true; +} } VncSessionBackend::VncSessionBackend(const Profile& profile, QObject* parent) @@ -106,9 +178,18 @@ VncSessionBackend::VncSessionBackend(const Profile& profile, QObject* parent) m_hextileSubrectsRemaining(0), m_hextileSubrectsColoured(false), m_zrleInflateStream(new z_stream_s()), - m_zrleInflateInitialized(false) + m_zrleInflateInitialized(false), + m_tightInflateStreams{ new z_stream_s(), new z_stream_s(), new z_stream_s(), + new z_stream_s() }, + m_tightInflateInitialized{ false, false, false, false }, + m_tightCompressionMode(0), + m_tightFilterId(0), + m_tightLengthByteIndex(0) { std::memset(m_zrleInflateStream, 0, sizeof(z_stream_s)); + for (z_stream_s* stream : m_tightInflateStreams) { + std::memset(stream, 0, sizeof(z_stream_s)); + } connect(m_socket, &QTcpSocket::connected, this, &VncSessionBackend::onSocketConnected); connect(m_socket, &QTcpSocket::readyRead, this, &VncSessionBackend::onSocketReadyRead); connect(m_socket, &QTcpSocket::disconnected, this, &VncSessionBackend::onSocketDisconnected); @@ -124,6 +205,12 @@ VncSessionBackend::~VncSessionBackend() inflateEnd(m_zrleInflateStream); } delete m_zrleInflateStream; + for (int i = 0; i < 4; ++i) { + if (m_tightInflateInitialized[i]) { + inflateEnd(m_tightInflateStreams[i]); + } + delete m_tightInflateStreams[i]; + } } void VncSessionBackend::connectSession(const SessionConnectOptions& options) @@ -361,6 +448,11 @@ void VncSessionBackend::onSocketDisconnected() && m_rfbState != RfbState::WaitingHextileRawTileData && m_rfbState != RfbState::WaitingZrleCompressedLength && m_rfbState != RfbState::WaitingZrleCompressedData + && m_rfbState != RfbState::WaitingTightCompressionControl + && m_rfbState != RfbState::WaitingTightFillColor + && m_rfbState != RfbState::WaitingTightFilterId + && m_rfbState != RfbState::WaitingTightLengthByte + && m_rfbState != RfbState::WaitingTightPayload && m_rfbState != RfbState::WaitingSetColourMapHeader && m_rfbState != RfbState::WaitingSetColourMapData && m_rfbState != RfbState::WaitingServerCutTextHeader @@ -419,6 +511,12 @@ void VncSessionBackend::resetProtocolState() inflateEnd(m_zrleInflateStream); m_zrleInflateInitialized = false; } + for (int i = 0; i < 4; ++i) { + if (m_tightInflateInitialized[i]) { + inflateEnd(m_tightInflateStreams[i]); + m_tightInflateInitialized[i] = false; + } + } } bool VncSessionBackend::haveBytes(int count) const @@ -588,6 +686,38 @@ void VncSessionBackend::advanceHextileTile() } } +bool VncSessionBackend::inflateTightStream(int streamIndex, const QByteArray& compressed, + QByteArray* decompressed) +{ + z_stream_s* stream = m_tightInflateStreams[streamIndex]; + if (!m_tightInflateInitialized[streamIndex]) { + std::memset(stream, 0, sizeof(z_stream_s)); + if (inflateInit(stream) != Z_OK) { + return false; + } + m_tightInflateInitialized[streamIndex] = true; + } + + constexpr int kChunkSize = 65536; + char outBuffer[kChunkSize]; + stream->next_in = reinterpret_cast(const_cast(compressed.constData())); + stream->avail_in = static_cast(compressed.size()); + int result = Z_OK; + while (stream->avail_in > 0) { + stream->next_out = reinterpret_cast(outBuffer); + stream->avail_out = kChunkSize; + result = inflate(stream, Z_NO_FLUSH); + const int produced = kChunkSize - static_cast(stream->avail_out); + if (produced > 0) { + decompressed->append(outBuffer, produced); + } + if (result != Z_OK) { + break; + } + } + return result == Z_OK || result == Z_STREAM_END; +} + void VncSessionBackend::processReceiveBuffer() { for (;;) { @@ -906,6 +1036,9 @@ void VncSessionBackend::processReceiveBuffer() case kEncZRLE: m_rfbState = RfbState::WaitingZrleCompressedLength; break; + case kEncTight: + m_rfbState = RfbState::WaitingTightCompressionControl; + break; default: { // SetEncodings (see kAnnouncedEncodings) is entirely // client-controlled, so a spec-compliant server will never @@ -1231,6 +1364,191 @@ void VncSessionBackend::processReceiveBuffer() break; } + case RfbState::WaitingTightCompressionControl: { + if (!haveBytes(1)) { + return; + } + const quint8 controlByte = static_cast(m_recvBuffer.at(0)); + m_recvBuffer.remove(0, 1); + + // Low 4 bits: reset flags for the 4 persistent Basic-mode zlib + // streams, independent of whatever mode this particular + // rectangle itself uses. + for (int i = 0; i < 4; ++i) { + if ((controlByte & (1 << i)) != 0 && m_tightInflateInitialized[i]) { + inflateEnd(m_tightInflateStreams[i]); + m_tightInflateInitialized[i] = false; + } + } + + const quint8 mode = controlByte >> 4; + m_tightCompressionMode = mode; + if (mode == kTightModeFill) { + m_rfbState = RfbState::WaitingTightFillColor; + } else if (mode == kTightModeJpeg) { + m_tightLengthByteIndex = 0; + m_pendingLength = 0; + m_rfbState = RfbState::WaitingTightLengthByte; + } else if (mode <= kTightBasicModeMax) { + if ((mode & kTightBasicExplicitFilterFlag) != 0) { + m_rfbState = RfbState::WaitingTightFilterId; + } else { + m_tightFilterId = 0; // Copy implied + m_tightLengthByteIndex = 0; + m_pendingLength = 0; + m_rfbState = RfbState::WaitingTightLengthByte; + } + } else { + failConnection( + QStringLiteral("The VNC server sent an invalid Tight compression-control byte."), + QStringLiteral("Control byte 0x%1").arg(controlByte, 2, 16, QChar('0'))); + return; + } + break; + } + + case RfbState::WaitingTightFillColor: { + if (!haveBytes(3)) { + return; + } + const QRgb color = VncPixelCodecs::rgbFromPixelBytes( + reinterpret_cast(m_recvBuffer.constData())); + m_recvBuffer.remove(0, 3); + + if (m_currentRectangle.width > 0 && m_currentRectangle.height > 0) { + QPainter painter(&m_framebuffer); + painter.fillRect(QRect(m_currentRectangle.x, m_currentRectangle.y, + m_currentRectangle.width, m_currentRectangle.height), + QColor::fromRgb(color)); + } + onRectangleFinished(); + break; + } + + case RfbState::WaitingTightFilterId: { + if (!haveBytes(1)) { + return; + } + m_tightFilterId = static_cast(m_recvBuffer.at(0)); + m_recvBuffer.remove(0, 1); + if (m_tightFilterId > 2) { + failConnection(QStringLiteral("The VNC server sent an unrecognized Tight filter id."), + QStringLiteral("Filter id %1").arg(m_tightFilterId)); + return; + } + m_tightLengthByteIndex = 0; + m_pendingLength = 0; + m_rfbState = RfbState::WaitingTightLengthByte; + break; + } + + case RfbState::WaitingTightLengthByte: { + if (!haveBytes(1)) { + return; + } + const quint8 b = static_cast(m_recvBuffer.at(0)); + m_recvBuffer.remove(0, 1); + + if (m_tightLengthByteIndex == 0) { + m_pendingLength = (b & 0x7F); + } else if (m_tightLengthByteIndex == 1) { + m_pendingLength |= static_cast(b & 0x7F) << 7; + } else { + m_pendingLength |= static_cast(b) << 14; + } + + // Compact-length encoding: up to 3 bytes, continuation-bit + // style (only the first two bytes' high bit can request + // another byte; the third byte's full 8 bits are always used + // as the final contribution, capping the value at ~4MB). + const bool continuation = (m_tightLengthByteIndex < 2) && ((b & 0x80) != 0); + ++m_tightLengthByteIndex; + if (continuation) { + break; + } + m_rfbState = RfbState::WaitingTightPayload; + break; + } + + case RfbState::WaitingTightPayload: { + if (!haveBytes(static_cast(m_pendingLength))) { + return; + } + const QByteArray payload = m_recvBuffer.left(static_cast(m_pendingLength)); + m_recvBuffer.remove(0, static_cast(m_pendingLength)); + + const int rectWidth = m_currentRectangle.width; + const int rectHeight = m_currentRectangle.height; + if (rectWidth <= 0 || rectHeight <= 0) { + onRectangleFinished(); + break; + } + + if (m_tightCompressionMode == kTightModeJpeg) { + QImage jpegImage; + if (!decodeJpegRectangle(payload, rectWidth, rectHeight, &jpegImage)) { + failConnection(QStringLiteral("The VNC server sent malformed Tight JPEG data."), + QStringLiteral("JPEG payload length %1").arg(payload.size())); + return; + } + { + QPainter painter(&m_framebuffer); + painter.drawImage(m_currentRectangle.x, m_currentRectangle.y, jpegImage); + } + onRectangleFinished(); + break; + } + + // Basic compression: always zlib-compressed by this decoder + // (the real protocol allows very small payloads to skip + // compression entirely; not handled here -- see class comment). + const int streamIndex = m_tightCompressionMode & kTightBasicStreamIndexMask; + QByteArray filtered; + if (!inflateTightStream(streamIndex, payload, &filtered)) { + failConnection( + QStringLiteral("The VNC server sent malformed Tight-compressed data."), + QStringLiteral("Stream %1, payload length %2").arg(streamIndex).arg(payload.size())); + return; + } + + QVector pixels; + switch (m_tightFilterId) { + case 0: + pixels = VncPixelCodecs::decodeTightCopyFilter(filtered, rectWidth, rectHeight); + break; + case 1: + pixels = VncPixelCodecs::decodeTightPaletteFilter(filtered, rectWidth, rectHeight); + break; + case 2: + pixels = VncPixelCodecs::decodeTightGradientFilter(filtered, rectWidth, rectHeight); + break; + default: + break; + } + if (pixels.size() != rectWidth * rectHeight) { + failConnection( + QStringLiteral("The VNC server sent a malformed Tight rectangle."), + QStringLiteral("Filter %1 produced %2 pixels, expected %3") + .arg(m_tightFilterId) + .arg(pixels.size()) + .arg(rectWidth * rectHeight)); + return; + } + + { + QImage rectImage(rectWidth, rectHeight, QImage::Format_RGB32); + for (int y = 0; y < rectHeight; ++y) { + for (int x = 0; x < rectWidth; ++x) { + rectImage.setPixel(x, y, pixels.at((y * rectWidth) + x)); + } + } + QPainter painter(&m_framebuffer); + painter.drawImage(m_currentRectangle.x, m_currentRectangle.y, rectImage); + } + onRectangleFinished(); + break; + } + case RfbState::WaitingSetColourMapHeader: { if (!haveBytes(5)) { return; diff --git a/src/vnc_session_backend.h b/src/vnc_session_backend.h index 4e36555..1a52c1e 100644 --- a/src/vnc_session_backend.h +++ b/src/vnc_session_backend.h @@ -9,6 +9,8 @@ #include #include +#include + class QTcpSocket; struct z_stream_s; @@ -24,9 +26,19 @@ struct z_stream_s; // Scope (see plan / issue #3 for the full rationale): standard VNC // Authentication (security type 2) and no-auth (type 1) only -- not // Apple's Screen Sharing scheme (type 30). Raw + CopyRect + Hextile + ZRLE -// encodings (Tight compression not yet implemented). No dynamic resize. -// Clipboard sync (Latin-1 only, per RFB's ServerCutText/ClientCutText) and -// remote cursor shape sync (the Cursor pseudo-encoding) are supported. +// + Tight encodings. No dynamic resize. Clipboard sync (Latin-1 only, per +// RFB's ServerCutText/ClientCutText) and remote cursor shape sync (the +// Cursor pseudo-encoding) are supported. +// +// Tight decoding gap: the real protocol allows the server to skip zlib +// compression entirely for very small Basic-mode payloads; this decoder +// always attempts to zlib-inflate them, so a server that takes that +// shortcut on a given rectangle would have that one rectangle fail rather +// than decode. This is intentionally not special-cased (the exact trigger +// condition/wire signaling for it could not be verified with confidence +// against the RFC text alone, and it only affects rare, tiny rectangles -- +// solid or near-solid tiny areas are virtually always sent as Fill instead +// in practice) -- see docs/PROGRESS.md. class VncSessionBackend : public SessionBackend { Q_OBJECT @@ -92,6 +104,11 @@ private: WaitingHextileRawTileData, WaitingZrleCompressedLength, WaitingZrleCompressedData, + WaitingTightCompressionControl, + WaitingTightFillColor, + WaitingTightFilterId, + WaitingTightLengthByte, + WaitingTightPayload, WaitingSetColourMapHeader, WaitingSetColourMapData, WaitingServerCutTextHeader, @@ -150,6 +167,17 @@ private: z_stream_s* m_zrleInflateStream; bool m_zrleInflateInitialized; + // Tight decode state (RFC 6143 SS7.7.4). Unlike ZRLE, Tight's "Basic" + // compression mode has 4 independent persistent zlib streams (chosen + // per-rectangle by 2 bits of the compression-control byte), each with + // its own lifecycle -- reset individually via the control byte's low 4 + // bits, otherwise persisting like ZRLE's single stream. + std::array m_tightInflateStreams; + std::array m_tightInflateInitialized; + quint8 m_tightCompressionMode; // compression-control byte >> 4 + quint8 m_tightFilterId; + int m_tightLengthByteIndex; + void setState(SessionState state, const QString& message); void resetProtocolState(); void processReceiveBuffer(); @@ -166,6 +194,7 @@ private: void sendClientCutText(const QString& text); QRect currentHextileTileRect() const; void advanceHextileTile(); + bool inflateTightStream(int streamIndex, const QByteArray& compressed, QByteArray* decompressed); }; #endif diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index fbb7757..c4bc562 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -34,7 +34,7 @@ add_executable(test_vnc_session_backend ) target_include_directories(test_vnc_session_backend PRIVATE ${CMAKE_SOURCE_DIR}/src) target_link_libraries(test_vnc_session_backend PRIVATE - Qt6::Core Qt6::Gui Qt6::Network Qt6::Test OpenSSL::Crypto ZLIB::ZLIB + Qt6::Core Qt6::Gui Qt6::Network Qt6::Test OpenSSL::Crypto ZLIB::ZLIB JPEG::JPEG ) add_test(NAME test_vnc_session_backend COMMAND test_vnc_session_backend) diff --git a/tests/test_vnc_session_backend.cpp b/tests/test_vnc_session_backend.cpp index 0eb497d..1f0d317 100644 --- a/tests/test_vnc_session_backend.cpp +++ b/tests/test_vnc_session_backend.cpp @@ -6,6 +6,12 @@ #include +extern "C" { +#include +} + +#include + namespace { // Independently documented bit-reversal example for VNC Authentication's // DES key prep (password "COW"): 'C'=0x43, 'O'=0x4F, 'W'=0x57, each @@ -209,6 +215,88 @@ QByteArray zlibCompressChunk(z_stream& stream, const QByteArray& input, bool fin output.resize(totalOut); return output; } + +// Mirrors VncSessionBackend's compact-length decode in reverse, for +// building test fixtures. +void appendTightCompactLength(QByteArray& buf, quint32 length) +{ + buf.append(static_cast((length & 0x7F) | ((length > 0x7F) ? 0x80 : 0))); + if (length <= 0x7F) { + return; + } + length >>= 7; + buf.append(static_cast((length & 0x7F) | ((length > 0x7F) ? 0x80 : 0))); + if (length <= 0x7F) { + return; + } + length >>= 7; + buf.append(static_cast(length & 0xFF)); +} + +// A FramebufferUpdate message with exactly one Tight-encoded rectangle +// (RFC 6143 encoding type 7), given the already fully-formed byte stream +// to follow the rectangle header (starting with the compression-control +// byte). +QByteArray tightFramebufferUpdate(int x, int y, int width, int height, const QByteArray& tightData) +{ + QByteArray update; + update.append(char(0)); update.append(char(0)); + update.append(char(0)); update.append(char(1)); // 1 rectangle + appendU16(update, static_cast(x)); + appendU16(update, static_cast(y)); + appendU16(update, static_cast(width)); + appendU16(update, static_cast(height)); + update.append(char(0)); update.append(char(0)); update.append(char(0)); + update.append(char(7)); // encoding = Tight + update.append(tightData); + return update; +} + +// Encodes a solid-colored width x height baseline JPEG using libjpeg-turbo +// directly (mirroring the decode side's use of the same library), for +// exercising Tight's JPEG sub-mode against real, valid JPEG bytes. +QByteArray encodeJpegForTest(int width, int height, QRgb color) +{ + jpeg_compress_struct cinfo; + jpeg_error_mgr jerr; + cinfo.err = jpeg_std_error(&jerr); + jpeg_create_compress(&cinfo); + + unsigned char* buffer = nullptr; + unsigned long bufferSize = 0; + jpeg_mem_dest(&cinfo, &buffer, &bufferSize); + + cinfo.image_width = static_cast(width); + cinfo.image_height = static_cast(height); + cinfo.input_components = 3; + cinfo.in_color_space = JCS_RGB; + jpeg_set_defaults(&cinfo); + jpeg_set_quality(&cinfo, 100, TRUE); + // Disable chroma subsampling so a solid color round-trips as exactly + // as JPEG's DCT quantization allows, for a tighter test tolerance. + for (int i = 0; i < cinfo.num_components; ++i) { + cinfo.comp_info[i].h_samp_factor = 1; + cinfo.comp_info[i].v_samp_factor = 1; + } + + jpeg_start_compress(&cinfo, TRUE); + QVector row(width * 3); + for (int x = 0; x < width; ++x) { + row[(x * 3) + 0] = static_cast(qRed(color)); + row[(x * 3) + 1] = static_cast(qGreen(color)); + row[(x * 3) + 2] = static_cast(qBlue(color)); + } + JSAMPROW rowPointer[1] = { row.data() }; + for (int y = 0; y < height; ++y) { + jpeg_write_scanlines(&cinfo, rowPointer, 1); + } + jpeg_finish_compress(&cinfo); + + const QByteArray result(reinterpret_cast(buffer), static_cast(bufferSize)); + jpeg_destroy_compress(&cinfo); + std::free(buffer); + return result; +} } class TestVncSessionBackend : public QObject @@ -255,6 +343,11 @@ private slots: void zrlePlainRleTileProducesExpectedPixels(); void zrlePaletteRleTileProducesExpectedPixels(); void zrleStreamPersistsAcrossTwoFramebufferUpdates(); + void tightFillProducesExpectedPixels(); + void tightBasicCopyFilterProducesExpectedPixels(); + void tightBasicPaletteFilterProducesExpectedPixels(); + void tightJpegProducesExpectedPixels(); + void tightStreamResetFlagAllowsIndependentDecoding(); private: std::unique_ptr m_server; @@ -1506,5 +1599,268 @@ void TestVncSessionBackend::zrleStreamPersistsAcrossTwoFramebufferUpdates() QCOMPARE(frames.at(1).pixelColor(1, 0), QColor(10, 20, 30)); } +void TestVncSessionBackend::tightFillProducesExpectedPixels() +{ + QByteArray tight; + tight.append(char(0x80)); // mode = Fill, no stream-reset bits + tight += cpixelBytes(10, 200, 30); + + connect(m_server.get(), &FakeVncServer::clientConnected, this, [this]() { + m_server->sendWhenConnected(QByteArray("RFB 003.008\n")); + }); + connect(m_server.get(), &FakeVncServer::dataReceived, this, [this, tight]() { + switch (m_server->nextStep()) { + case 0: { + QByteArray securityTypes; + securityTypes.append(char(1)); + securityTypes.append(char(1)); + m_server->sendWhenConnected(securityTypes); + break; + } + case 1: + m_server->sendWhenConnected(QByteArray(4, char(0))); // SecurityResult: OK + break; + case 2: + m_server->sendWhenConnected(serverInitBytes(3, 3)); + break; + case 3: + m_server->sendWhenConnected(tightFramebufferUpdate(0, 0, 3, 3, tight)); + break; + default: + break; + } + }); + + m_backend->connectSession(makeOptions()); + QTRY_VERIFY(m_gotFrame); + QCOMPARE(m_lastFrame.size(), QSize(3, 3)); + for (int y = 0; y < 3; ++y) { + for (int x = 0; x < 3; ++x) { + QCOMPARE(m_lastFrame.pixelColor(x, y), QColor(10, 200, 30)); + } + } +} + +void TestVncSessionBackend::tightBasicCopyFilterProducesExpectedPixels() +{ + QByteArray filtered; + filtered += cpixelBytes(255, 0, 0); + filtered += cpixelBytes(0, 255, 0); + filtered += cpixelBytes(0, 0, 255); + filtered += cpixelBytes(255, 255, 255); + const QByteArray compressed = zlibCompressWhole(filtered); + + QByteArray tight; + tight.append(char(0x00)); // mode = Basic, stream 0, Copy implied + appendTightCompactLength(tight, static_cast(compressed.size())); + tight += compressed; + + connect(m_server.get(), &FakeVncServer::clientConnected, this, [this]() { + m_server->sendWhenConnected(QByteArray("RFB 003.008\n")); + }); + connect(m_server.get(), &FakeVncServer::dataReceived, this, [this, tight]() { + switch (m_server->nextStep()) { + case 0: { + QByteArray securityTypes; + securityTypes.append(char(1)); + securityTypes.append(char(1)); + m_server->sendWhenConnected(securityTypes); + break; + } + case 1: + m_server->sendWhenConnected(QByteArray(4, char(0))); // SecurityResult: OK + break; + case 2: + m_server->sendWhenConnected(serverInitBytes(2, 2)); + break; + case 3: + m_server->sendWhenConnected(tightFramebufferUpdate(0, 0, 2, 2, tight)); + break; + default: + break; + } + }); + + m_backend->connectSession(makeOptions()); + QTRY_VERIFY(m_gotFrame); + QCOMPARE(m_lastFrame.size(), QSize(2, 2)); + QCOMPARE(m_lastFrame.pixelColor(0, 0), QColor(255, 0, 0)); + QCOMPARE(m_lastFrame.pixelColor(1, 0), QColor(0, 255, 0)); + QCOMPARE(m_lastFrame.pixelColor(0, 1), QColor(0, 0, 255)); + QCOMPARE(m_lastFrame.pixelColor(1, 1), QColor(255, 255, 255)); +} + +void TestVncSessionBackend::tightBasicPaletteFilterProducesExpectedPixels() +{ + // 4 pixels, 2-color palette, 1 bit/pixel, packed MSB-first: indices + // [0,1,1,0] -> byte 0b0110_0000 (same packing convention as ZRLE's + // packed palette; with only one row here, continuous vs row-padded + // bit-packing happen to coincide). + QByteArray filtered; + filtered.append(char(0x01)); // paletteSize - 1 = 1 -> paletteSize = 2 + filtered += cpixelBytes(10, 20, 30); // palette[0] + filtered += cpixelBytes(200, 210, 220); // palette[1] + filtered.append(char(0x60)); + const QByteArray compressed = zlibCompressWhole(filtered); + + QByteArray tight; + tight.append(char(0x40)); // mode = Basic, stream 0, explicit filter follows + tight.append(char(1)); // filter id = Palette + appendTightCompactLength(tight, static_cast(compressed.size())); + tight += compressed; + + connect(m_server.get(), &FakeVncServer::clientConnected, this, [this]() { + m_server->sendWhenConnected(QByteArray("RFB 003.008\n")); + }); + connect(m_server.get(), &FakeVncServer::dataReceived, this, [this, tight]() { + switch (m_server->nextStep()) { + case 0: { + QByteArray securityTypes; + securityTypes.append(char(1)); + securityTypes.append(char(1)); + m_server->sendWhenConnected(securityTypes); + break; + } + case 1: + m_server->sendWhenConnected(QByteArray(4, char(0))); // SecurityResult: OK + break; + case 2: + m_server->sendWhenConnected(serverInitBytes(4, 1)); + break; + case 3: + m_server->sendWhenConnected(tightFramebufferUpdate(0, 0, 4, 1, tight)); + break; + default: + break; + } + }); + + m_backend->connectSession(makeOptions()); + QTRY_VERIFY(m_gotFrame); + QCOMPARE(m_lastFrame.size(), QSize(4, 1)); + QCOMPARE(m_lastFrame.pixelColor(0, 0), QColor(10, 20, 30)); + QCOMPARE(m_lastFrame.pixelColor(1, 0), QColor(200, 210, 220)); + QCOMPARE(m_lastFrame.pixelColor(2, 0), QColor(200, 210, 220)); + QCOMPARE(m_lastFrame.pixelColor(3, 0), QColor(10, 20, 30)); +} + +void TestVncSessionBackend::tightJpegProducesExpectedPixels() +{ + // 8x8 (one full JPEG MCU at 1x1 chroma sampling) solid red. + const QByteArray jpegBytes = encodeJpegForTest(8, 8, qRgb(220, 20, 20)); + + QByteArray tight; + tight.append(char(0x90)); // mode = JPEG + appendTightCompactLength(tight, static_cast(jpegBytes.size())); + tight += jpegBytes; + + connect(m_server.get(), &FakeVncServer::clientConnected, this, [this]() { + m_server->sendWhenConnected(QByteArray("RFB 003.008\n")); + }); + connect(m_server.get(), &FakeVncServer::dataReceived, this, [this, tight]() { + switch (m_server->nextStep()) { + case 0: { + QByteArray securityTypes; + securityTypes.append(char(1)); + securityTypes.append(char(1)); + m_server->sendWhenConnected(securityTypes); + break; + } + case 1: + m_server->sendWhenConnected(QByteArray(4, char(0))); // SecurityResult: OK + break; + case 2: + m_server->sendWhenConnected(serverInitBytes(8, 8)); + break; + case 3: + m_server->sendWhenConnected(tightFramebufferUpdate(0, 0, 8, 8, tight)); + break; + default: + break; + } + }); + + m_backend->connectSession(makeOptions()); + QTRY_VERIFY(m_gotFrame); + QCOMPARE(m_lastFrame.size(), QSize(8, 8)); + // JPEG is lossy even at quality 100 -- assert closeness, not equality. + const QColor decoded = m_lastFrame.pixelColor(4, 4); + QVERIFY(qAbs(decoded.red() - 220) <= 8); + QVERIFY(qAbs(decoded.green() - 20) <= 8); + QVERIFY(qAbs(decoded.blue() - 20) <= 8); +} + +void TestVncSessionBackend::tightStreamResetFlagAllowsIndependentDecoding() +{ + QByteArray filteredA; + filteredA += cpixelBytes(10, 20, 30); + filteredA += cpixelBytes(10, 20, 30); + filteredA += cpixelBytes(10, 20, 30); + filteredA += cpixelBytes(10, 20, 30); + QByteArray filteredB; + filteredB += cpixelBytes(200, 210, 220); + filteredB += cpixelBytes(200, 210, 220); + filteredB += cpixelBytes(200, 210, 220); + filteredB += cpixelBytes(200, 210, 220); + + // Each independently a complete, self-contained zlib stream (its own + // deflateInit + Z_FINISH) -- decoding the second one correctly after + // the first, on the same persistent stream slot, requires that + // stream-reset bit 0 in rectangle B's control byte actually tore down + // and re-initialized stream 0 rather than trying to keep feeding an + // already-finished stream more data. + const QByteArray compressedA = zlibCompressWhole(filteredA); + const QByteArray compressedB = zlibCompressWhole(filteredB); + + QByteArray tightA; + tightA.append(char(0x00)); // mode = Basic, stream 0, Copy, no reset + appendTightCompactLength(tightA, static_cast(compressedA.size())); + tightA += compressedA; + + QByteArray tightB; + tightB.append(char(0x01)); // mode = Basic, stream 0, Copy, reset stream 0 + appendTightCompactLength(tightB, static_cast(compressedB.size())); + tightB += compressedB; + + QVector frames; + connect(m_backend.get(), &SessionBackend::frameUpdated, this, + [&frames](const QImage& frame) { frames.append(frame.copy()); }); + + connect(m_server.get(), &FakeVncServer::clientConnected, this, [this]() { + m_server->sendWhenConnected(QByteArray("RFB 003.008\n")); + }); + connect(m_server.get(), &FakeVncServer::dataReceived, this, [this, tightA, tightB]() { + switch (m_server->nextStep()) { + case 0: { + QByteArray securityTypes; + securityTypes.append(char(1)); + securityTypes.append(char(1)); + m_server->sendWhenConnected(securityTypes); + break; + } + case 1: + m_server->sendWhenConnected(QByteArray(4, char(0))); // SecurityResult: OK + break; + case 2: + m_server->sendWhenConnected(serverInitBytes(2, 2)); + break; + case 3: // first (non-incremental) FramebufferUpdateRequest + m_server->sendWhenConnected(tightFramebufferUpdate(0, 0, 2, 2, tightA)); + break; + case 4: // next (incremental) FramebufferUpdateRequest + m_server->sendWhenConnected(tightFramebufferUpdate(0, 0, 2, 2, tightB)); + break; + default: + break; + } + }); + + m_backend->connectSession(makeOptions()); + QTRY_VERIFY(frames.size() >= 2); + QCOMPARE(m_lastState, SessionState::Connected); + QCOMPARE(frames.at(0).pixelColor(0, 0), QColor(10, 20, 30)); + QCOMPARE(frames.at(1).pixelColor(0, 0), QColor(200, 210, 220)); +} + QTEST_GUILESS_MAIN(TestVncSessionBackend) #include "test_vnc_session_backend.moc"