Commit Graph
6 Commits
Author SHA1 Message Date
ksmithandClaude Sonnet 5 6da9dc6ca5 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>
2026-09-15 18:02:49 -06:00
ksmithandClaude Sonnet 5 dbcc20d155 Add File -> Import from mRemoteNG...
Parses mRemoteNG's confCons.xml export format directly -- schema verified
against mRemoteNG's own XmlConnectionsDeserializer.cs source and a real
exported sample, not guessed at. Maps nested <Node Type="Container">
folders and <Node Type="Connection"> entries onto OrbitHub profiles:
RDP stays RDP, SSH1/SSH2 collapse to OrbitHub's single SSH protocol,
anything else (VNC, Telnet, HTTP, PowerShell, ...) is skipped and listed
in the import summary rather than silently dropped.

Passwords are never read, not even for the common case where they're
technically readable without a master password (mRemoteNG only encrypts
the Password attribute itself, everything else -- Hostname, Username,
Domain, Protocol -- is plaintext). A FullFileEncryption="true" export
encrypts the whole node tree instead and genuinely can't be read without
the user's master password; that case is detected and refused with a
clear message rather than failing confusingly.

The parser (src/mremoteng_importer.h/.cpp) is a pure function decoupled
from any file/UI I/O, matching this session's established pattern of
keeping business logic separately testable from the Qt Widgets shell that
calls it (ProfilesWindow::importFromMRemoteNG() is the thin wrapper:
QFileDialog, call the parser, write results via ProfileRepository, show a
summary). 11 test cases against realistic sample XML.

Hit a real moc gotcha along the way: a literal "//" inside a raw string
literal (the xmlns URL) makes moc's lexer think a line comment started
there, silently desyncing its parse so it never finds the QObject-derived
test class at all (no error, just a missing vtable at link time). Fixed
by moving the XML fixtures into a plain non-QObject header moc never
scans, split across two adjacent literals as a second safeguard.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-15 16:08:02 -06:00
ksmithandClaude Sonnet 5 04ce6f7904 Add RdpSessionBackend test coverage (#1)
RdpSessionBackend can't reasonably get the same fixture-driven
state-machine tests SshSessionBackend got: it's driven by FreeRDP's own
event loop and a raw worker thread against a real freerdp_connect(), not
a QProcess we can point at a stand-in binary. What it does have is a
large amount of pure, regression-prone logic -- exactly the kind that
already caused a real historical bug here (the X11-keycode/PC-AT-scancode
mixup fixed in Milestone 7) -- so that's what gets covered instead.

Twelve functions promoted from free functions / private members to
public statics purely so tests can call them without a live connection:
security-mode/performance-profile normalization, the HiDPI scale-value
mapping, desktop-size clamping, both scancode-mapping functions, and the
five FreeRDP error-code interpretation functions. UINT32 is surfaced as
quint32 in the public signatures to keep FreeRDP/WinPR types out of the
header, matching how rdp_freerdp* is already only forward-declared there.

27 test cases, including a couple of direct regression guards: verifying
scancodeFromNativeScanCode() is a faithful passthrough to FreeRDP's X11
table (not a reimplementation), and that it does NOT reproduce the old
"X11 keycode treated as PC/AT scancode" bug for a documented example key.

This closes out #1's originally scoped work (CTest wiring, ProfileRepository,
SshSessionBackend, RdpSessionBackend coverage). Deeper state-machine
coverage for the two session backends remains future work if ever needed,
but isn't blocking here.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-15 15:32:02 -06:00
ksmithandClaude Sonnet 5 9842a44de0 Add SshSessionBackend test coverage (#1)
Adds two kinds of coverage, continuing #1's remaining scope:

1. Pure-function tests for mapSshError() and escapeForShellSingleQuotes(),
   promoted from private members to public statics purely so tests can
   call them without spinning up a process. escapeForShellSingleQuotes()
   is the actual security boundary for password auth (it's what stops a
   password containing a single quote from breaking out of the askpass
   script's quoting), so it gets a real adversarial test, not just a
   happy-path one.

2. State-machine tests (connect -> Connected, auth failure -> Failed with
   the right mapped message, connection refused -> Failed, input
   round-tripping, reconnect) driven against tests/fixtures/fake_ssh.sh,
   a small controllable stand-in for the real ssh binary, instead of a
   real network/SSH server. This needed one small testability seam: a new
   constructor overload that overrides the launched program ("ssh" in
   production, the fixture script in tests).

POSIX-only for now: the fixture is a shell script, so the state-machine
tests QSKIP on Windows until an equivalent fixture exists there; the
pure-function tests run everywhere.

RdpSessionBackend coverage is still open -- it's a bigger lift again
(FreeRDP's own event loop, not just a QProcess), left for a follow-up.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-15 15:16:32 -06:00
ksmithandClaude Sonnet 5 eb63bde870 Add Delete Folder to the profile tree context menu
ProfileRepository::deleteFolder() removes a folder without ever deleting
the profiles or subfolders inside it -- everything directly under the
deleted folder shifts up to take its place (its parent, or the top level
if it had none), exactly as if that one path segment were removed from
each affected path. This is a labels-only operation (a "folder" is just a
grouping string on each profile, not a container that owns them), so
that's the least-surprising behavior versus silently bulk-deleting saved
connections.

Right-clicking a folder in the tree now offers "Delete Folder"; if it
isn't empty, a confirmation states exactly how many profiles/subfolders
will move and to where.

Covered by 7 new unit tests, which caught the same class of bug fixed in
3fab2f9: the new code's own folder-path remap also bound an unguarded
null QString (a folder moving to root) against the `folder_path NOT
NULL` column.

Fixes #20.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-15 15:08:37 -06:00
ksmithandClaude Sonnet 5 3fab2f9de3 Wire up CTest and add unit test coverage for ProfileRepository
Adds a Qt6::Test-based unit test target (tests/test_profile_repository.cpp,
21 cases), gated behind an ORBITHUB_BUILD_TESTS option that no-ops
gracefully if Qt6::Test isn't available, so it can't break app-only
builds. Covers profile CRUD, validation rules, search/sort, tag
normalization, and folder handling, each against an isolated temporary
SQLite file (new ProfileRepository(databasePathOverride) constructor
overload added for exactly this).

Caught and fixed a real bug along the way: normalizedTags()'s result was
bound directly without the nonNullTrimmed() null-guard every other field
already uses, so creating a profile with no tags at all hit the `tags
NOT NULL` constraint and silently failed -- including via the Import
Profiles feature for any export where a profile has no tags key.

Partial progress on #1 (RdpSessionBackend/SshSessionBackend state-machine
coverage still open -- much larger lift, needs a testability pass on
those backends first).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-15 06:39:20 -06:00