Add VNC support (Milestone 6, issue #3)

Implements RFB (RFC 6143) directly against QTcpSocket. No permissively
licensed VNC client library exists to vendor the way FreeRDP was for RDP:
LibVNCClient is GPLv2, gtk-vnc is LGPL but GTK-tied, and vendoring either
would force a licensing decision on the whole (MIT) project. This is
from-scratch protocol code instead, threaded like SshSessionBackend (a
QObject on its own QThread driven by Qt's own async socket signals)
rather than RdpSessionBackend's manual worker-thread/blocking-loop
pattern, since QTcpSocket is already async.

Scope, matching SessionTab's existing SSH/RDP dispatch pattern
(session_backend_factory.cpp, session_tab.cpp's widget construction and
signal wiring) and VncDisplayWidget mirroring RdpDisplayWidget's
scale-to-fit rendering:
- Protocol handshake: RFB 3.3/3.7/3.8 negotiated explicitly (the
  SecurityResult message only exists in 3.8; pre-3.8 servers signal auth
  failure by closing the socket, which the disconnect handler accounts
  for)
- VNC Authentication (DES challenge-response, via OpenSSL's classic DES
  API) and no-auth security types
- Raw + CopyRect framebuffer decoding into a persistent QImage, requesting
  a fixed 32bpp format whose byte layout matches QImage::Format_RGB32
  directly (same zero-conversion trick RdpSessionBackend uses for
  FreeRDP's GDI buffer)
- Keyboard (Qt key -> X11 keysym, including the Unicode-beyond-Latin-1
  keysym convention) and mouse/wheel input forwarding

Explicit non-goals for this pass (see docs/PROGRESS.md for the full
list): Apple's Screen Sharing auth (so this can't yet reach macOS's
built-in VNC server), compression encodings beyond Raw/CopyRect, dynamic
resize, remote cursor shape sync, clipboard sync.

19 unit tests (tests/test_vnc_session_backend.cpp): pure-function
coverage (DES key prep verified against an independently documented test
vector for password "COW", X11 keysym mapping, socket-error mapping) plus
state-machine coverage against a scripted in-process fake RFB server
covering all three protocol-version handshake shapes, auth success/
failure, unsupported security types, and pixel-accurate Raw decoding.
That harness caught a real re-entrancy bug: QAbstractSocket::abort()
synchronously re-emits disconnected() before returning, so
failConnection() calling it was silently letting a second, generic
disconnected-socket handler overwrite an already-correct, specific error
message.

Also 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.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-15 18:02:49 -06:00
co-authored by Claude Sonnet 5
parent dbcc20d155
commit 6da9dc6ca5
12 changed files with 2097 additions and 21 deletions
+4
View File
@@ -4,6 +4,7 @@
#include "session_backend.h"
#include "ssh_session_backend.h"
#include "unsupported_session_backend.h"
#include "vnc_session_backend.h"
std::unique_ptr<SessionBackend> createSessionBackend(const Profile& profile)
{
@@ -13,6 +14,9 @@ std::unique_ptr<SessionBackend> createSessionBackend(const Profile& profile)
if (profile.protocol.compare(QStringLiteral("RDP"), Qt::CaseInsensitive) == 0) {
return std::make_unique<RdpSessionBackend>(profile);
}
if (profile.protocol.compare(QStringLiteral("VNC"), Qt::CaseInsensitive) == 0) {
return std::make_unique<VncSessionBackend>(profile);
}
return std::make_unique<UnsupportedSessionBackend>(profile);
}
+55 -7
View File
@@ -3,6 +3,7 @@
#include "rdp_display_widget.h"
#include "session_backend_factory.h"
#include "terminal_view.h"
#include "vnc_display_widget.h"
#include <KodoTerm/KodoTerm.hpp>
@@ -79,6 +80,7 @@ SessionTab::SessionTab(const Profile& profile,
== 0),
m_sshTerminal(nullptr),
m_rdpDisplay(nullptr),
m_vncDisplay(nullptr),
m_terminalOutput(nullptr),
m_eventLog(nullptr),
m_toggleEventsButton(nullptr),
@@ -238,6 +240,8 @@ SessionTab::SessionTab(const Profile& profile,
[this](const QImage& frame) {
if (m_rdpDisplay != nullptr) {
m_rdpDisplay->setFrame(frame);
} else if (m_vncDisplay != nullptr) {
m_vncDisplay->setFrame(frame);
}
},
Qt::QueuedConnection);
@@ -247,6 +251,8 @@ SessionTab::SessionTab(const Profile& profile,
[this](int width, int height) {
if (m_rdpDisplay != nullptr) {
m_rdpDisplay->setRemoteDesktopSize(width, height);
} else if (m_vncDisplay != nullptr) {
m_vncDisplay->setRemoteDesktopSize(width, height);
}
},
Qt::QueuedConnection);
@@ -405,6 +411,12 @@ void SessionTab::clearTerminal()
if (m_rdpDisplay != nullptr) {
m_rdpDisplay->clearFrame();
m_rdpDisplay->setFocus();
return;
}
if (m_vncDisplay != nullptr) {
m_vncDisplay->clearFrame();
m_vncDisplay->setFocus();
}
}
@@ -717,6 +729,9 @@ void SessionTab::setupUi()
} else if (m_profile.protocol.compare(QStringLiteral("RDP"), Qt::CaseInsensitive) == 0) {
m_rdpDisplay = new RdpDisplayWidget(this);
rootLayout->addWidget(m_rdpDisplay, 1);
} else if (m_profile.protocol.compare(QStringLiteral("VNC"), Qt::CaseInsensitive) == 0) {
m_vncDisplay = new VncDisplayWidget(this);
rootLayout->addWidget(m_vncDisplay, 1);
} else {
m_terminalOutput = new TerminalView(this);
QFont fallbackFont = defaultTerminalFont();
@@ -726,13 +741,7 @@ void SessionTab::setupUi()
m_terminalOutput->setFont(fallbackFont);
m_terminalOutput->setMinimumHeight(260);
m_terminalOutput->setReadOnly(true);
if (m_profile.protocol.compare(QStringLiteral("VNC"), Qt::CaseInsensitive) == 0) {
m_terminalOutput->setPlaceholderText(
QStringLiteral("Embedded VNC session output appears here when the backend is available."));
} else {
m_terminalOutput->setPlaceholderText(
QStringLiteral("Session output appears here."));
}
m_terminalOutput->setPlaceholderText(QStringLiteral("Session output appears here."));
rootLayout->addWidget(m_terminalOutput, 1);
}
@@ -891,6 +900,37 @@ void SessionTab::setupUi()
[this](int x, int y, int deltaX, int deltaY) {
emit requestMouseWheelEvent(x, y, deltaX, deltaY);
});
} else if (m_vncDisplay != nullptr) {
connect(m_vncDisplay,
&VncDisplayWidget::viewportSizeChanged,
this,
[this](int width, int height) { emit requestTerminalSize(width, height); });
connect(m_vncDisplay,
&VncDisplayWidget::displayScaleChanged,
this,
[this](qreal ratio) { emit requestDisplayScale(ratio); });
connect(m_vncDisplay,
&VncDisplayWidget::keyInput,
this,
[this](int key, quint32 nativeScanCode, const QString& text, bool pressed, int modifiers) {
emit requestKeyEvent(key, nativeScanCode, text, pressed, modifiers);
});
connect(m_vncDisplay,
&VncDisplayWidget::mouseMoveInput,
this,
[this](int x, int y) { emit requestMouseMoveEvent(x, y); });
connect(m_vncDisplay,
&VncDisplayWidget::mouseButtonInput,
this,
[this](int x, int y, int button, bool pressed) {
emit requestMouseButtonEvent(x, y, button, pressed);
});
connect(m_vncDisplay,
&VncDisplayWidget::mouseWheelInput,
this,
[this](int x, int y, int deltaX, int deltaY) {
emit requestMouseWheelEvent(x, y, deltaX, deltaY);
});
}
}
@@ -1110,6 +1150,14 @@ void SessionTab::refreshActionButtons()
if (isConnected) {
m_rdpDisplay->setFocus();
}
return;
}
if (m_vncDisplay != nullptr) {
m_vncDisplay->setEnabled(isConnected);
if (isConnected) {
m_vncDisplay->setFocus();
}
}
}
+2
View File
@@ -17,6 +17,7 @@ class QThread;
class SessionBackend;
class TerminalView;
class RdpDisplayWidget;
class VncDisplayWidget;
class QToolButton;
class QLineEdit;
class QComboBox;
@@ -109,6 +110,7 @@ private:
KodoTerm* m_sshTerminal;
RdpDisplayWidget* m_rdpDisplay;
VncDisplayWidget* m_vncDisplay;
TerminalView* m_terminalOutput;
QPlainTextEdit* m_eventLog;
QToolButton* m_toggleEventsButton;
+252
View File
@@ -0,0 +1,252 @@
#include "vnc_display_widget.h"
#include <QEvent>
#include <QKeyEvent>
#include <QMouseEvent>
#include <QPainter>
#include <QResizeEvent>
#include <QTimer>
#include <QWheelEvent>
#include <QtGlobal>
namespace {
QSize sanitizeSize(const QSize& size)
{
return QSize(qMax(1, size.width()), qMax(1, size.height()));
}
qreal sanitizeDevicePixelRatio(qreal ratio)
{
if (!(ratio > 0.0)) {
return 1.0;
}
return qBound(1.0, ratio, 4.0);
}
constexpr int kResizeDebounceMs = 150;
}
VncDisplayWidget::VncDisplayWidget(QWidget* parent)
: QWidget(parent), m_remoteSize(1280, 720), m_resizeDebounceTimer(new QTimer(this))
{
setFocusPolicy(Qt::StrongFocus);
setMouseTracking(true);
setAutoFillBackground(false);
setMinimumSize(320, 200);
m_resizeDebounceTimer->setSingleShot(true);
connect(m_resizeDebounceTimer, &QTimer::timeout, this, &VncDisplayWidget::emitViewportGeometry);
scheduleViewportGeometryEmit();
}
void VncDisplayWidget::setFrame(const QImage& frame)
{
if (frame.isNull()) {
return;
}
m_frame = frame;
m_remoteSize = sanitizeSize(frame.size());
update();
}
void VncDisplayWidget::setRemoteDesktopSize(int width, int height)
{
if (width < 1 || height < 1) {
return;
}
const QSize nextSize(width, height);
if (m_remoteSize == nextSize) {
return;
}
m_remoteSize = nextSize;
// Same race guarded against as RdpDisplayWidget: the next actual frame
// arrives asynchronously and isn't guaranteed to already match this
// size, so drop the stale one rather than stretch it by the wrong
// factor until a correctly-sized frame lands.
m_frame = QImage();
update();
}
void VncDisplayWidget::clearFrame()
{
m_frame = QImage();
update();
}
void VncDisplayWidget::paintEvent(QPaintEvent* event)
{
Q_UNUSED(event);
QPainter painter(this);
painter.fillRect(rect(), QColor(QStringLiteral("#101214")));
const QRectF target = renderRect();
if (!m_frame.isNull()) {
painter.drawImage(target, m_frame);
} else {
painter.setPen(QColor(QStringLiteral("#b0bec5")));
painter.drawText(rect(),
Qt::AlignCenter,
QStringLiteral("Waiting for remote desktop frame..."));
}
}
void VncDisplayWidget::resizeEvent(QResizeEvent* event)
{
QWidget::resizeEvent(event);
scheduleViewportGeometryEmit();
}
bool VncDisplayWidget::event(QEvent* event)
{
if (event->type() == QEvent::ScreenChangeInternal) {
scheduleViewportGeometryEmit();
}
return QWidget::event(event);
}
void VncDisplayWidget::scheduleViewportGeometryEmit()
{
m_resizeDebounceTimer->start(kResizeDebounceMs);
}
void VncDisplayWidget::emitViewportGeometry()
{
const QSize logicalSize = sanitizeSize(this->size());
const qreal ratio = sanitizeDevicePixelRatio(this->devicePixelRatioF());
const QSize physicalSize(qRound(logicalSize.width() * ratio), qRound(logicalSize.height() * ratio));
emit viewportSizeChanged(physicalSize.width(), physicalSize.height());
emit displayScaleChanged(ratio);
}
void VncDisplayWidget::keyPressEvent(QKeyEvent* event)
{
if (event == nullptr) {
return;
}
emit keyInput(event->key(),
event->nativeScanCode(),
event->text(),
true,
static_cast<int>(event->modifiers()));
event->accept();
}
void VncDisplayWidget::keyReleaseEvent(QKeyEvent* event)
{
if (event == nullptr || event->isAutoRepeat()) {
return;
}
emit keyInput(event->key(),
event->nativeScanCode(),
event->text(),
false,
static_cast<int>(event->modifiers()));
event->accept();
}
bool VncDisplayWidget::focusNextPrevChild(bool next)
{
Q_UNUSED(next);
// Tab/Shift+Tab must reach keyPressEvent() and be forwarded to the
// remote session instead of moving focus to the next local widget.
return false;
}
void VncDisplayWidget::mousePressEvent(QMouseEvent* event)
{
if (event == nullptr) {
return;
}
setFocus(Qt::MouseFocusReason);
const QPoint mapped = mapToRemote(event->position());
emit mouseButtonInput(mapped.x(), mapped.y(), static_cast<int>(event->button()), true);
event->accept();
}
void VncDisplayWidget::mouseReleaseEvent(QMouseEvent* event)
{
if (event == nullptr) {
return;
}
const QPoint mapped = mapToRemote(event->position());
emit mouseButtonInput(mapped.x(), mapped.y(), static_cast<int>(event->button()), false);
event->accept();
}
void VncDisplayWidget::mouseMoveEvent(QMouseEvent* event)
{
if (event == nullptr) {
return;
}
const QPoint mapped = mapToRemote(event->position());
emit mouseMoveInput(mapped.x(), mapped.y());
event->accept();
}
void VncDisplayWidget::wheelEvent(QWheelEvent* event)
{
if (event == nullptr) {
return;
}
const QPoint mapped = mapToRemote(event->position());
const QPoint angle = event->angleDelta();
emit mouseWheelInput(mapped.x(), mapped.y(), angle.x(), angle.y());
event->accept();
}
QRectF VncDisplayWidget::renderRect() const
{
const QSize remote = effectiveRemoteSize();
const QRectF area = rect();
if (area.isEmpty()) {
return QRectF();
}
const qreal scale = qMin(area.width() / remote.width(), area.height() / remote.height());
const qreal drawWidth = remote.width() * scale;
const qreal drawHeight = remote.height() * scale;
const qreal x = area.x() + ((area.width() - drawWidth) * 0.5);
const qreal y = area.y() + ((area.height() - drawHeight) * 0.5);
return QRectF(x, y, drawWidth, drawHeight);
}
QPoint VncDisplayWidget::mapToRemote(const QPointF& pos) const
{
const QSize remote = effectiveRemoteSize();
const QRectF target = renderRect();
if (target.isEmpty()) {
return QPoint(0, 0);
}
const qreal clampedX = qBound(target.left(), pos.x(), target.right());
const qreal clampedY = qBound(target.top(), pos.y(), target.bottom());
const qreal normalizedX = (clampedX - target.left()) / qMax(1.0, target.width());
const qreal normalizedY = (clampedY - target.top()) / qMax(1.0, target.height());
const int remoteX = qBound(0, static_cast<int>(normalizedX * remote.width()), remote.width() - 1);
const int remoteY = qBound(0, static_cast<int>(normalizedY * remote.height()), remote.height() - 1);
return QPoint(remoteX, remoteY);
}
QSize VncDisplayWidget::effectiveRemoteSize() const
{
if (m_remoteSize.width() > 0 && m_remoteSize.height() > 0) {
return m_remoteSize;
}
if (!m_frame.isNull()) {
return sanitizeSize(m_frame.size());
}
return QSize(1280, 720);
}
+60
View File
@@ -0,0 +1,60 @@
#ifndef ORBITHUB_VNC_DISPLAY_WIDGET_H
#define ORBITHUB_VNC_DISPLAY_WIDGET_H
#include <QImage>
#include <QWidget>
class QKeyEvent;
class QMouseEvent;
class QPaintEvent;
class QResizeEvent;
class QTimer;
class QWheelEvent;
// Renders a VNC framebuffer and forwards local input, scaled-to-fit --
// same shape as RdpDisplayWidget, minus cursor-shape sync (out of scope for
// the initial VNC implementation; the system cursor is left alone).
class VncDisplayWidget : public QWidget
{
Q_OBJECT
public:
explicit VncDisplayWidget(QWidget* parent = nullptr);
void setFrame(const QImage& frame);
void setRemoteDesktopSize(int width, int height);
void clearFrame();
signals:
void keyInput(int key, quint32 nativeScanCode, const QString& text, bool pressed, int modifiers);
void mouseMoveInput(int x, int y);
void mouseButtonInput(int x, int y, int button, bool pressed);
void mouseWheelInput(int x, int y, int deltaX, int deltaY);
void viewportSizeChanged(int width, int height);
void displayScaleChanged(qreal devicePixelRatio);
protected:
void paintEvent(QPaintEvent* event) override;
void resizeEvent(QResizeEvent* event) override;
bool event(QEvent* event) override;
void keyPressEvent(QKeyEvent* event) override;
void keyReleaseEvent(QKeyEvent* event) override;
void mousePressEvent(QMouseEvent* event) override;
void mouseReleaseEvent(QMouseEvent* event) override;
void mouseMoveEvent(QMouseEvent* event) override;
void wheelEvent(QWheelEvent* event) override;
bool focusNextPrevChild(bool next) override;
private:
QImage m_frame;
QSize m_remoteSize;
QTimer* m_resizeDebounceTimer;
QRectF renderRect() const;
QPoint mapToRemote(const QPointF& pos) const;
QSize effectiveRemoteSize() const;
void emitViewportGeometry();
void scheduleViewportGeometryEmit();
};
#endif
File diff suppressed because it is too large Load Diff
+134
View File
@@ -0,0 +1,134 @@
#ifndef ORBITHUB_VNC_SESSION_BACKEND_H
#define ORBITHUB_VNC_SESSION_BACKEND_H
#include "session_backend.h"
#include <QAbstractSocket>
#include <QByteArray>
#include <QImage>
class QTcpSocket;
// Implements RFB (RFC 6143) directly against QTcpSocket -- there is no
// permissively licensed VNC client library to vendor the way FreeRDP was
// for RDP (LibVNCClient is GPLv2, gtk-vnc is LGPL but GTK-tied), so this is
// an original implementation. Threading follows SshSessionBackend's model
// (a QObject moved to its own QThread, driven by Qt's own async socket
// signals) rather than RdpSessionBackend's manual worker-thread/blocking
// loop, since QTcpSocket is already async -- there's no legacy synchronous
// C API to wrangle here.
//
// 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 encodings only.
// No dynamic resize, no remote cursor shape sync, no clipboard sync.
class VncSessionBackend : public SessionBackend
{
Q_OBJECT
public:
explicit VncSessionBackend(const Profile& profile, QObject* parent = nullptr);
~VncSessionBackend() override;
// Pure, state-free helpers exposed as public statics purely so tests
// can exercise them without a live connection.
static QByteArray vncAuthResponse(const QByteArray& challenge, const QString& password);
static QByteArray desKeyFromPassword(const QString& password);
static QString mapSocketError(QAbstractSocket::SocketError error, const QString& rawDetail);
static quint32 keysymForQtKey(int key, const QString& text);
public slots:
void connectSession(const SessionConnectOptions& options) override;
void disconnectSession() override;
void reconnectSession(const SessionConnectOptions& options) override;
void sendInput(const QString& input) override;
void confirmHostKey(bool trustHost) override;
void updateTerminalSize(int columns, int rows) override;
void sendKeyEvent(int key,
quint32 nativeScanCode,
const QString& text,
bool pressed,
int modifiers) override;
void sendMouseMoveEvent(int x, int y) override;
void sendMouseButtonEvent(int x, int y, int button, bool pressed) override;
void sendMouseWheelEvent(int x, int y, int deltaX, int deltaY) override;
private slots:
void onSocketConnected();
void onSocketReadyRead();
void onSocketDisconnected();
void onSocketErrorOccurred(QAbstractSocket::SocketError error);
private:
enum class RfbState {
Idle,
WaitingProtocolVersion,
WaitingSecurityTypeCount,
WaitingSecurityTypeList,
WaitingSecurityTypeV33,
WaitingSecurityFailureReasonLength,
WaitingSecurityFailureReason,
WaitingVncAuthChallenge,
WaitingSecurityResult,
WaitingSecurityResultReasonLength,
WaitingSecurityResultReason,
WaitingServerInitHeader,
WaitingServerName,
WaitingServerMessageType,
WaitingFramebufferUpdateHeader,
WaitingRectangleHeader,
WaitingRawPixelData,
WaitingCopyRectSource,
WaitingSetColourMapHeader,
WaitingSetColourMapData,
WaitingServerCutTextHeader,
WaitingServerCutTextData,
};
struct PendingRectangle {
int x = 0;
int y = 0;
int width = 0;
int height = 0;
qint32 encoding = 0;
};
QTcpSocket* m_socket;
SessionState m_state;
RfbState m_rfbState;
QByteArray m_recvBuffer;
bool m_userInitiatedDisconnect;
bool m_reconnectPending;
SessionConnectOptions m_reconnectOptions;
SessionConnectOptions m_activeOptions;
int m_negotiatedMinorVersion;
int m_securityTypeCount;
QByteArray m_offeredSecurityTypes;
quint8 m_chosenSecurityType;
quint32 m_pendingLength;
QImage m_framebuffer;
int m_pendingRectanglesRemaining;
PendingRectangle m_currentRectangle;
quint8 m_pointerButtonMask;
int m_lastPointerX;
int m_lastPointerY;
void setState(SessionState state, const QString& message);
void resetProtocolState();
void processReceiveBuffer();
bool haveBytes(int count) const;
void sendVersionReply();
void sendClientInit();
void sendSetPixelFormatAndEncodings();
void requestFramebufferUpdate(bool incremental);
void failConnection(const QString& displayMessage, const QString& rawMessage);
void finishHandshakeIntoRunningState();
void onRectangleFinished();
void sendPointerEvent();
void sendWheelClick(quint8 wheelBit);
};
#endif