Compare commits

..
Author SHA1 Message Date
ksmithandClaude Sonnet 5 34c16cbfa8 Update README and in-app User Guide for VNC, Import/Export, and connect-time prompting
Both docs still described VNC as unimplemented and required usernames
up front for SSH/RDP -- neither has been true for a while. Brings them
in line with the actual current feature set: VNC's encodings/clipboard/
cursor sync/display modes and honest Apple Screen Sharing auth status,
blank-username connect-time prompting across all three protocols,
profile Import/Export and mRemoteNG import, and the new prompt
visibility behavior (issue #22). docs/USER_GUIDE.md also drives the
in-app Help -> User Guide dialog and the PDF shipped with releases.

Bump version to v2026.9.16.7.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 11:20:18 -06:00
ksmithandClaude Sonnet 5 1506d87719 Make username/password connect prompts hard to miss (issue #22)
The inline prompt bar previously had no explicit styling and rendered
in the same color as the rest of the tab; a tab showing the prompt in
the background had no indication anything needed attention. The bar
now uses a solid QPalette::Highlight fill with HighlightedText for the
label and a hand-drawn contrasting badge, and a background tab gets a
"(Needs input)" title suffix plus a distinct tab-bar color.

A first pass at the tab color (#6a1b9a) was reported unreadable in
dark mode; replaced with #ab47bc, tuned to match the visibility of the
existing connection-state colors.

Bump version to v2026.9.16.6.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 09:18:43 -06:00
ksmithandClaude Sonnet 5 b3cedcfa48 Fix connect-time username prompt never reaching SSH/RDP authentication
The username entered at the connect-time prompt (added for issue #21)
was updating SessionTab's own in-memory Profile copy, but
SshSessionBackend/RdpSessionBackend are constructed with -- and only
ever read from -- their own separate Profile copy on a worker thread,
which never saw that edit. Authentication was still built from the
original (blank) username regardless of what was typed into the
prompt.

SessionConnectOptions gains a username field, populated by SessionTab
on every connect attempt and threaded through the same way password
already is; both backends now prefer options.username over
profile().username. Covered by a new SSH regression test using an
exact-match fixture host that only succeeds for a specific
user@host target.

Bump version to v2026.9.16.5.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 08:40:34 -06:00
ksmithandClaude Sonnet 5 0cdf930303 Fix connect-time username prompt being unreachable for SSH/RDP
validateProfileForConnect() still hard-failed with a blocking
QMessageBox for a blank SSH/RDP username, running before
requestConnectOptions() ever got a chance to prompt for it inline --
so the connect-time username prompt added for issue #21 was dead code
in practice; users just got told to go edit the profile instead.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 08:28:41 -06:00
ksmithandClaude Sonnet 5 7559488ceb Bump version to v2026.9.16.3
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 08:18:22 -06:00
ksmithandClaude Sonnet 5 ab4a34fc01 Fix mRemoteNG import failing on entries without a username
The previous username relaxation only touched ProfileDialog's own
save-time validation. ProfileRepository::isProfileValid() had the
identical "username required for SSH/RDP" check independently, called
directly by insertProfile()/updateProfile() -- exactly the path
mRemoteNG import uses, since it builds Profile objects and inserts
them directly rather than going through the dialog. Every imported
SSH/RDP entry without a recorded username was rejected outright.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 08:17:05 -06:00
ksmithandClaude Sonnet 5 7aa8849f8e Bump version to v2026.9.16.2
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 08:07:29 -06:00
ksmithandClaude Sonnet 5 776db5ec04 Allow blank usernames, asking for one at connect time instead
Closes #21. SSH and RDP profiles previously hard-required a username
to even save the profile; that validation is dropped, and
SessionTab::requestConnectOptions() now prompts for it at connect
time when blank, reusing the existing password-prompt bar in
unmasked mode -- the same pattern already used for a blank password.

VNC's username is trickier: most VNC servers never use one (plain VNC
Authentication and no-auth don't), only the two Apple auth schemes
(security types 30/33) do, and which auth method gets used isn't known
until mid-connection, after the server's security-type list has been
negotiated -- too late for the pre-connect prompt SSH/RDP uses. Adds a
new async request/response pair to SessionBackend, usernameRequested()
signal / provideUsername() slot, mirroring the existing SSH host-key-
confirmation pattern. VncSessionBackend pauses its state machine right
before computing an Apple-auth response if no username is available --
without consuming the already-buffered prime/host-key bytes, so
resuming re-parses them identically -- emits the request, and resumes
via provideUsername(). Cancelling (or submitting blank) fails the
connection cleanly instead of sending Apple auth an empty username.

The username is kept on the tab's in-memory profile copy for its
lifetime, not written back to the saved profile, matching how
passwords are already handled.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 08:01:57 -06:00
ksmithandClaude Sonnet 5 d7f9d4966b Bump version to v2026.9.16
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 03:55:45 -06:00
ksmithandClaude Sonnet 5 e80fe7d634 Fix Apple DH auth wire-order bug, add type 33 fallback, fix missing VNC password prompt
Live testing against a real macOS Screen Sharing server surfaced two
real bugs, independent of each other:

1. SessionTab::requestConnectOptions() never prompted for a password
   on VNC profiles (only SSH/RDP) -- every VNC connection went out
   with an empty password regardless of what the server needed. VNC
   now gets its own prompt; an empty password is allowed through
   (unlike RDP's hard requirement) since no-auth VNC servers exist and
   there's no way to know client-side before the security-type
   negotiation happens.

2. VncSessionBackend's Apple DH (type 30) response sent the client's
   public key before the encrypted credentials. Cross-checking against
   neatvnc's rfb-proto.h (an independent, authoritative reference: both
   the wire struct definitions and the full server-side verification
   code, matched field-by-field against this implementation) showed
   the correct order is credentials first, then public key -- exactly
   backwards from what was implemented. Fixed, with a new regression
   test that decrypts the credentials back out using the trailing
   public-key bytes to derive the shared secret, which would fail if
   the fields were swapped again.

Also adds security type 33 (RSA + AES, src/vnc_apple_rsa_auth.h) as a
fallback Apple auth scheme, sourced from the `asyncvnc` PyPI package.
Preference when multiple are offered: None > AppleDH(30) >
AppleRSA(33) > VNCAuth(2).

Neither scheme has been gotten working live yet against the specific
macOS Tahoe (26.6.2) server available for testing -- type 30's wire
format is now verified correct byte-for-byte against the independent
reference above, but the server still rejects it with a generic
"Authentication or authorization failure"; type 33 is rejected even
earlier, right after the initial host-key request. macOS Tahoe was
released after this assistant's knowledge cutoff, so there may be a
protocol or permission-model change specific to it that isn't
reflected in either reference. Documented as an open issue in
docs/PROGRESS.md rather than claimed as working.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 03:49:06 -06:00
ksmithandClaude Sonnet 5 df2b1a8d50 Update VNC profile dialog hint text for Apple Screen Sharing auth
The username field was already usable for VNC profiles (never hidden
or disabled), but the hint text claimed it was "ignored by most
servers" -- no longer accurate now that Apple's Screen Sharing auth
(security type 30) requires it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-15 21:28:02 -06:00
ksmithandClaude Sonnet 5 1f026dde70 Add Apple Screen Sharing authentication for VNC (security type 30)
Apple's macOS Screen Sharing server doesn't speak standard VNC
Authentication -- it uses a Diffie-Hellman key exchange followed by
AES-128-ECB-encrypted credentials, security type 30. Apple never
published this scheme (it's not part of RFC 6143); this implements
the well-established reverse-engineered wire format: the server sends
a generator, prime, and its own DH public key; the client generates
an ephemeral keypair, derives the shared secret, MD5-hashes it into
an AES key, and sends back its public key plus a 128-byte encrypted
username+password buffer.

The DH/AES math lives in new src/vnc_apple_dh_auth.h/.cpp as a pure,
socket-free helper (mirroring vncAuthResponse()'s shape for standard
VNC Auth), built entirely on modern EVP_PKEY-based OpenSSL 3.0 APIs --
no deprecated low-level DH_* calls, unlike VNC Authentication's
necessary use of classic DES. Reuses Profile::username (already a
shared field) since Apple's scheme needs an actual macOS account name,
unlike password-only VNC Authentication.

Security-type preference when multiple are offered is now None >
AppleDH > VNCAuth, since DH+AES is strictly stronger than static-
challenge DES. Adds a DH round-trip test (generates a real 512-bit
group at test time, computes the response, then independently
re-derives the shared secret as the server would and decrypts the
credentials back out -- proving self-consistency without needing a
hand-computed expected value), a fake-server integration test for the
full RFB 3.8 handshake sequencing, and a preference-order test.

Not yet live-verified against a real macOS Screen Sharing server --
that's next.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-15 21:27:17 -06:00
ksmithandClaude Sonnet 5 2fe2022182 Document and package the new libjpeg-turbo dependency
Tight's JPEG sub-mode (previous commit) links libjpeg-turbo directly.
Adds it to docs/BUILDING.md for all three platforms (apt
libjpeg-turbo8-dev, brew jpeg-turbo, vcpkg libjpeg-turbo:x64-windows)
and to the .deb package's Depends: line (libjpeg-turbo8). No changes
needed for Windows (the Inno Setup script already wildcards *.dll) or
macOS (macdeployqt bundles non-system dylibs automatically). Left the
Flatpak manifests unchanged on the assumption that the KDE runtime
already bundles libjpeg-turbo as a standard Qt JPEG-plugin dependency
-- flagged in PROGRESS.md as worth confirming next time a Flatpak
build actually runs, since that wasn't independently verified here.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-15 21:14:29 -06:00
ksmithandClaude Sonnet 5 4fca8fce41 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 <noreply@anthropic.com>
2026-09-15 21:13:17 -06:00
ksmithandClaude Sonnet 5 bb022edcf2 Add VNC ZRLE decoding
Implements RFC 6143 SS7.7.6: a ZRLE rectangle is a 4-byte compressed
length followed by that many zlib-compressed bytes, decompressing to
64x64 tiles each using one of five subencodings (Raw, Solid, packed
palette, Plain RLE, Palette RLE). The zlib stream persists for the
whole connection rather than being reset per-rectangle or per-update,
so VncSessionBackend now owns a lazily-initialized, persistent
z_stream torn down only in resetProtocolState() on a fresh
connect/reconnect.

Since the entire rectangle's compressed data decompresses into memory
in one shot, tile parsing is a plain synchronous loop rather than
needing its own RfbState values -- only the compressed-length and
compressed-data reads are actual protocol states. Tile decoding (the
five subencodings, including the continuation-byte run-length
encoding shared by two of them) lives in vnc_pixel_codecs.h/.cpp
alongside the Hextile decoder, unit-tested with 6 new tests covering
each subencoding plus a persistence test that splits one continuous
deflate stream across two separate FramebufferUpdate messages -- it
only decodes correctly if the connection's inflate stream is retained
between them.

Adds a top-level find_package(ZLIB REQUIRED) + ZLIB::ZLIB link
(previously only pulled in transitively via vendored FreeRDP's own
smartcard-emulation feature, which happened to have it enabled but
shouldn't be relied on for that).

Live-verified against the TightVNC test server that nothing regressed
(connect, cursor, clipboard); that server consistently sends Raw for
actual framebuffer content regardless of announced encodings, so
Hextile/ZRLE's live decode path isn't independently confirmed against
a real server -- the unit tests are the primary evidence here.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-15 21:02:26 -06:00
ksmithandClaude Sonnet 5 e49fa0cf26 Add VNC Hextile decoding
Implements RFC 6143 SS7.7.4: rectangles announced as Hextile (type 5)
tile the update into 16x16 blocks, each either raw pixels or a
background fill plus an optional list of foreground/individually-
colored subrects, with background/foreground persisting across tiles
within one rectangle when not re-specified.

The pure byte-decode logic (tile metadata, subrect list) lives in new
src/vnc_pixel_codecs.h/.cpp, kept separate from
VncSessionBackend's wire-sequencing state machine so it's unit-testable
without a socket -- the pattern the plan calls for continuing into the
ZRLE/Tight work still ahead. Adds 5 fake-server tests covering a raw
tile, a background-only solid fill, uncoloured and individually-colored
subrects, and a 4-tile rectangle proving background persistence and
correct tile-cursor wraparound.

Verified against the live TightVNC test server (connect, frame,
cursor, clipboard all still work); that particular server always
chose Raw for the actual framebuffer content during this session, so
Hextile's real-world path isn't independently confirmed live -- the
unit tests are the primary correctness evidence for this phase.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-15 20:49:08 -06:00
ksmithandClaude Sonnet 5 9dd1af21d6 Add VNC remote cursor shape sync
Implements RFB's Cursor pseudo-encoding (RFC 6143 SS7.8.2, type -239):
a FramebufferUpdate rectangle carrying a cursor shape instead of
screen content (x/y are the hotspot, not position; width/height are
the cursor image size), decoded into an ARGB32 QImage using the
rectangle's RGB pixel data plus its opacity bitmask, then never
painted into the framebuffer. A 0x0 rectangle means "hide the
cursor" per spec.

VncDisplayWidget gains RdpDisplayWidget's setCursorImage/Hidden/
Default() + applyCursor() shape, reusing its own renderRect()/
effectiveRemoteSize() so cursor scaling works correctly in both the
scale-to-fit and actual-size display modes with no special-casing.
VNC never emits cursorReset() (RFB's Cursor pseudo-encoding has no
"reset to system default" signal, unlike RDP's SetDefault callback) --
setCursorDefault() exists for symmetry but is unused today.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-15 20:34:20 -06:00
ksmithandClaude Sonnet 5 3dd894407a Add VNC clipboard sync
RFB's ServerCutText/ClientCutText messages are much simpler than RDP's
CLIPRDR channel (no format-list/format-request negotiation -- text is
just sent directly in both directions), so ServerCutText now emits
remoteClipboardTextChanged instead of being discarded, and
VncSessionBackend overrides setClipboardText to send ClientCutText
immediately. Extends session_tab.cpp's clipboard-sync gate to cover
VNC alongside RDP; the QClipboard wiring itself was already
protocol-agnostic. Latin-1 only, per RFB's wire format -- no Unicode
clipboard extension is in scope.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-15 20:31:03 -06:00
ksmithandClaude Sonnet 5 35d4daec7f Fix VNC robustness gap: unannounced encodings now fail clearly, not silently
Previously an unrecognized rectangle encoding in a FramebufferUpdate
aborted the connection with a generic message, and there was nothing
tying the set of encodings we announce via SetEncodings to the set we
actually know how to decode. Introduces kAnnouncedEncodings as the
single source of truth for both, converts the rectangle dispatch to a
switch keyed off it, and gives the (still intentionally fatal --
there's no safe way to skip an unknown-length payload) fallback a
message that identifies it as a protocol violation rather than "not
supported". Adds a regression test asserting every announced encoding
has a working dispatch case, so future encodings (Hextile/ZRLE/Tight/
Cursor) can't be added to the announced list without matching decode
support. Also replaces scattered inline magic numbers for RFB
message-type constants with named constants, in prep for the
clipboard/cursor/compression work that follows.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-15 20:27:18 -06:00
ksmithandClaude Sonnet 5 0b5454197c Add VNC-only scale-to-fit vs actual-size display toggle
VNC has no equivalent of RDP's MS-RDPEDISP to request a different
resolution from the guest, so a high-resolution remote desktop
previously always got shrunk to fit the window, making text
illegible. Adds a per-tab "Display Mode" choice (tab-bar right-click)
between the existing scale-to-fit behavior and a new actual-size mode
that renders the framebuffer at its native pixel size inside a
QScrollArea. Reuses VncDisplayWidget's existing scale-to-fit render
math unchanged -- it degenerates to an exact 1:1 mapping once the
widget is fixed to the remote's own size. Persisted like the terminal
theme preference.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-15 18:13:43 -06:00
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
ksmithandClaude Sonnet 5 4f5cf8ecd9 packaging: read project version after building, not before
build-deb.sh and build-dmg.sh both read VERSION from CMakeCache.txt
before calling cmake --build -- but that build step is exactly what
reconfigures CMakeCache.txt if CMakeLists.txt changed since the build
dir was last configured. If the version was bumped and the script is
run without an explicit reconfigure first, it silently packages the
stale version (observed: v2026.9.15's macOS build produced
OrbitHub-2026.9.14.2.dmg). Move the VERSION read to after the build.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-15 06:26:15 -06:00
ksmithandClaude Sonnet 5 df99c78998 Bump version to v2026.9.15
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-15 06:22:53 -06:00
ksmithandClaude Sonnet 5 dffca3afef Add Import/Export for profile lists
File -> Export Profiles... writes all profiles (plus explicit, including
empty, folders) to a JSON file. File -> Import Profiles... reads one back,
recreates folders, and inserts profiles as new rows so IDs never collide
with the destination database. No credentials are ever persisted on a
Profile in the first place (only privateKeyPath, a filesystem path), so
nothing sensitive is exposed by an exported file.

Verified with a standalone headless round-trip test against isolated
app-data databases (SSH + RDP profiles, nested folders, all fields);
caught and fixed a bug where import was redundantly creating an explicit
folder row per profile that didn't exist in the original export.

Fixes #17.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-15 06:22:26 -06:00
ksmithandClaude Sonnet 5 4f8fa3272b packaging: pin vendored FreeRDP's own version, stop git-tag bleed-through
third_party/FreeRDP is vendored in-tree rather than as a separate git
submodule, so its build-time git_get_exact_tag() call (in
cmake/GetProjectVersion.cmake) was resolving against OrbitHub's own git
tags instead of any real FreeRDP release tag. Its version-extraction regex
then greedily matched the last three dot-separated numbers of our tag
(e.g. v2026.9.14.2 -> "9.14.2"), mislabeling FreeRDP's own libraries in
packaged builds (libfreerdp9.so.9.14.2 instead of the real
libfreerdp3.so.3.23.1) -- self-consistent within a build, but misleading
and drifting release to release.

Adding .source_tag makes GetProjectVersion.cmake take its file-based
branch (which it already prefers over the git-tag branch) instead.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 22:00:51 -06:00
ksmithandClaude Sonnet 5 96c8403f3b Bump version to v2026.9.14.2
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 21:57:43 -06:00
ksmithandClaude Sonnet 5 3e621219f1 RDP: fix resize corruption by proactively resizing the local GDI buffer
update->DesktopResize (the only place gdi_resize() was called) only fires
during a full Deactivation-Reactivation sequence or a GFX ResetGraphics
PDU, neither of which our Display Control channel resize path (SendMonitorLayout,
MS-RDPEDISP) triggers. The client's own display buffer was left stuck at
its initial-connect size for the rest of the session, and FreeRDP's surface-bits
handling silently drops updates outside those stale bounds -- producing
the missing/misplaced taskbar and stale composited-looking content.

sendDisplayResize now calls gdi_resize() itself right after a successful
SendMonitorLayout, rather than waiting on a callback that structurally
never fires for this channel/codec configuration.

Fixes #18.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 21:57:08 -06:00
ksmith e0969041a7 packaging: pin Flathub manifest to v2026.9.14 About-dialog-fix commit 2026-09-14 21:32:56 -06:00
ksmithandClaude Sonnet 5 c4a62e8fb6 About dialog: fix unreadable dark-mode text and missing version
The subtitle and version line used palette(mid), a role meant for 3D
bevel/shadow decoration, not text -- it has poor contrast against the
window background in dark themes. Replaced with a color blended from
the widget's actual text/window palette colors, so it stays readable
(de-emphasized but never low-contrast) in either theme.

Also: QCoreApplication::applicationVersion() was never being set
anywhere, so the dialog always showed "Development build" regardless
of the actual built version. CMake's PROJECT_VERSION is now exposed
to the app via a compile definition and wired into
setApplicationVersion() at startup.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 21:29:36 -06:00
ksmithandClaude Sonnet 5 9a22597d4e packaging: pin Flathub manifest to v2026.9.14
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 21:17:33 -06:00
ksmithandClaude Sonnet 5 e90e9b5abf Bump version to v2026.9.14
RDP HiDPI text fix and resize-glitch mitigations.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 21:17:18 -06:00
ksmithandClaude Sonnet 5 92d8b62820 RDP: fix distorted text on HiDPI monitors, reduce resize glitches
The RDP session pipeline never accounted for display scale: it
requested a desktop canvas sized in Qt logical pixels (never
multiplied by devicePixelRatio()), and FreeRDP_DesktopScaleFactor/
DeviceScaleFactor were read but never actually set anywhere. On a
HiDPI monitor this meant the remote session rendered assuming a
96 DPI / 100% display, and the resulting canvas got stretched
locally — ClearType's subpixel hinting doesn't survive that kind of
resampling, producing distorted glyph shapes and color fringing
rather than plain blur.

RdpDisplayWidget now reports physical pixel dimensions and the real
devicePixelRatio (recomputed on resize and on screen changes, e.g.
dragging the window to a different-DPI monitor). RdpSessionBackend
maps that to the nearest FreeRDP-legal scale value ({100, 140, 180},
per MS-RDPEDISP and FreeRDP's own reference client) and sets it at
both connect time and on every dynamic resize, including the
FreeRDP_MonitorOverrideFlags required for the values to actually be
honored rather than silently ignored.

While testing this against real infrastructure, found and fixed two
related (pre-existing, not caused by this change) resize issues:
- A stale-frame race where the old frame could be drawn at the wrong
  scale for a moment after a resize, before a correctly-sized one
  arrives — now the frame is cleared during that transition instead.
- No debounce on outgoing resize requests — every single resize event
  fired an immediate request to the server, which can visibly
  contribute to host-side redraw glitches during rapid layout churn
  (e.g. right after connecting). Coalesced into one request per burst,
  plus an explicit refresh-rect request after each resize completes
  as a best-effort nudge for hosts that don't fully repaint on their
  own.

A separate, deeper issue was also found during testing (the remote
guest's actual resolution sometimes not changing despite the resize
channel reporting success) and is tracked separately, not fixed here.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 21:16:41 -06:00
ksmithandClaude Sonnet 5 7ee930693e packaging: pin Flathub manifest to v2026.9.8.3
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 18:32:09 -06:00
ksmithandClaude Sonnet 5 8e98c208c9 Bump version to v2026.9.8.3
Same-day patch release: adds the in-app User Guide and standalone
User Guide PDF.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 18:31:50 -06:00
ksmithandClaude Sonnet 5 ce1e40a12d Add an in-app User Guide and standalone PDF
Adds docs/USER_GUIDE.md, a 10-section end-user guide (getting
started, managing/organizing profiles, SSH and RDP connections,
session management, settings, troubleshooting). It's embedded into
the app binary via a Qt resource file and rendered by a new
Help -> User Guide window: a topic sidebar plus content pane, not a
single scrolling document, with cross-reference links between
sections routed to sidebar selection rather than relying on Qt's
Markdown importer's lack of heading anchors.

A separate, non-shipped tool (tools/user-guide-pdf/) renders the
same source to a standalone PDF via QTextDocument + QPrinter,
wrapped by packaging/docs/build-user-guide-pdf.sh. Kept fully
outside the main CMake target so Qt6::PrintSupport never becomes a
runtime dependency of the shipped app (confirmed via ldd). The PDF
itself isn't committed -- generated per release like the platform
installers.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 18:17:21 -06:00
ksmithandClaude Sonnet 5 80bc50e54c packaging: fix license install path, document Flathub AI-policy risk
License files were installing to share/licenses/orbithub instead of
the path Flathub's own docs specify for this app
(share/licenses/org.darksingularity.OrbitHub, i.e. $FLATPAK_ID).
Also installs FreeRDP's and KodoTerm's bundled LICENSE files there
alongside OrbitHub's own, since previously only the latter was
installed at all.

docs/FLATHUB.md now documents two things found by checking Flathub's
current requirements directly rather than assuming prior packaging
work was sufficient: the vendored libvterm copy has no LICENSE file
at all (needs to come from upstream, not fabricated here), and
Flathub's Generative AI disclosure policy is a real, reviewer-
discretion acceptance risk for this project given its development
history — not something further packaging work resolves.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 16:56:22 -06:00
ksmithandClaude Sonnet 5 1c66adb646 packaging: pin Flathub manifest to v2026.9.8.2
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 16:27:47 -06:00
ksmithandClaude Sonnet 5 4c649f727f Bump version to v2026.9.8.2
Same-day patch release: RDP TLS certificate verification fix and
Flathub submission prep.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 16:27:22 -06:00
ksmithandClaude Sonnet 5 18f234105d packaging: fix Flathub linter findings
Runs Flathub's own flatpak-builder-lint against both manifests and
the metainfo, and fixes what it found:

- only-arches doesn't belong in the manifest itself (linter: manifest
  unknown property); it belongs in a separate flathub.json, which is
  what Flathub's own build infrastructure actually reads it from.
- Bumps the KDE runtime from 6.10 to 6.11 per the linter's outdated-
  runtime warning; verified the app still builds and launches against
  it before committing to the bump.

The one remaining linter finding, finish-args-ssh-filesystem-access,
is a deliberate policy flag rather than a bug — Flathub requires a
written justification for any ~/.ssh access in the submission PR,
which docs/FLATHUB.md now documents with precedent from already-
approved apps in the same situation.

Also confirms (via a real interactive test with xdotool) that the
private-key Browse button correctly triggers the desktop portal
file chooser inside the sandbox, closing the last open verification
item from the previous commit.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 16:26:14 -06:00
ksmithandClaude Sonnet 5 186480dcf5 packaging: prepare Flatpak for Flathub submission
Adds a production manifest (packaging/flatpak/flathub/) using a
pinned git source instead of the local-dir source the dev manifest
uses, so Flathub's build infrastructure can reproduce the build
independently.

Narrows the dev and production manifests' filesystem permission from
--filesystem=home to --filesystem=~/.ssh (read-write, needed for SSH
known_hosts/config) after confirming in the actual sandbox that: SSH
already works there (provided by the KDE runtime base, no extra
packaging needed), RDP needs no filesystem access at all (its
certificate trust never touched disk even before today's fix), and
QFileDialog's private-key/export pickers route through the desktop
portal rather than needing static filesystem access.

Expands AppStream metainfo with bugtracker/vcs-browser URLs, a
developer block, a releases entry, an OARS content rating, and three
screenshots (profiles view, an active SSH session, an active RDP
session) using real test-system profiles.

Adds docs/FLATHUB.md tracking overall submission readiness.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 16:19:09 -06:00
ksmithandClaude Sonnet 5 dd974c684a RDP: enforce TLS certificate verification (was fully disabled)
IgnoreCertificate was hardcoded TRUE, meaning FreeRDP's entire
certificate-verification pipeline was bypassed: every RDP server's
TLS certificate was silently accepted, including certificates that
had changed since a prior trusted connection to the same host. That
is precisely the scenario TLS verification exists to catch — an
active MITM presenting a different certificate was indistinguishable
from a legitimate server.

Switches to FreeRDP's own trust-on-first-use certificate store
(AutoAcceptCertificate) so first-time connections still connect
without a prompt, matching SSH's "accept-new" known-hosts policy.
Certificate changes now correctly refuse the connection by default,
via VerifyChangedCertificateEx, with a clear message (host, port,
old/new SHA256 fingerprints) surfaced through the existing
connection-failure event log rather than adding a new, redundant
logging path.

Also fixes CertificateCallbackPreferPEM, which handed the full PEM
certificate to the verify callbacks instead of a short fingerprint —
harmless while those callbacks were dead code, but would have
flooded the event log with multi-KB certificate dumps once actually
exercised.

Verified end-to-end against real infrastructure: first connection
trusts and stores the certificate silently, a simulated changed
certificate (server key swapped) is correctly refused with a clear
message, and restoring the original certificate reconnects normally.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 16:18:57 -06:00
ksmithandClaude Sonnet 5 8c56d489af docs: update latest checkpoint tag to v2026.9.8
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 14:20:07 -06:00
ksmithandClaude Sonnet 5 d7910f1631 docs: link the v2026.9.8 release
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 14:17:09 -06:00
ksmithandClaude Sonnet 5 68214db744 docs: record v0-m9-done tag
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 14:05:53 -06:00
ksmithandClaude Sonnet 5 6c8146e79c docs: close out Milestone 9 and document Windows/macOS packaging
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 14:04:59 -06:00
ksmithandClaude Sonnet 5 aa812b0da7 macOS: fix launch crash, missing dylibs, and dmg volume icon
The app crashed at launch with "cannot be opened because of a
problem" because macdeployqt invalidates the code signature when it
rewrites library load paths; re-sign (ad hoc) after macdeployqt runs.

Also fixes the underlying cause of a Library-missing crash: orbithub's
INSTALL_RPATH used the Linux linker token $ORIGIN, which dyld does not
understand, and the bundled FreeRDP/WinPR/KodoTerm dylibs installed to
a sibling lib/orbithub/ directory outside OrbitHub.app rather than
Contents/Frameworks, so they were never copied into the dmg at all.
Both are now APPLE-specific, matching macdeployqt's own layout
(Contents/Frameworks, @executable_path/../Frameworks).

build-dmg.sh now also sets a custom volume icon (via SetFile or the
fileicon brew formula, whichever is available) instead of the
generic disk image icon, degrading gracefully if neither is present.

Also includes the StartupWMClass desktop-file fix from the prior
commit's message, which was never actually staged.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 14:00:46 -06:00
ksmithandClaude Sonnet 5 3840ea9f62 Linux: fix taskbar pin matching and launcher icon mismatch
Add StartupWMClass=OrbitHub to the desktop file so panels (GNOME
Shell, Cinnamon, KDE) can match the running window to the pinned
launcher instead of creating a duplicate taskbar entry.

Replace the hand-authored, stale launcher SVG with PNG icons
rendered directly from createOrbitHubAppIcon() at each hicolor
theme size, so the pinned/menu icon matches the actual app icon
shown in the running window, Windows .ico, and macOS .icns.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 13:50:25 -06:00
ksmithandClaude Sonnet 5 7ce7260305 packaging: rename app ID to org.darksingularity.OrbitHub
The reverse-DNS app ID should reflect a domain we actually control.
Renames the desktop file, AppStream metainfo, icon, Flatpak manifest,
and macOS bundle identifier from io.orbithub.OrbitHub to
org.darksingularity.OrbitHub. Also fixes a stale homepage URL in the
metainfo file pointing at an old git host.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 13:39:06 -06:00
ksmithandClaude Sonnet 5 48adcf33ee packaging: add macOS dmg build script
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 13:29:35 -06:00
ksmithandClaude Sonnet 5 21e7a39c53 docs: add screenshots section to README
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 13:23:32 -06:00
ksmithandClaude Sonnet 5 f126f4b55c Add Windows installer via Inno Setup
New packaging/windows/orbithub.iss packages the build directory's
top-level contents directly (orbithub.exe, *.dll, platforms/,
sqldrivers/) rather than going through cmake --install: vcpkg's own
applocal DLL deployment already drops every required runtime dependency
flat into build/ on every build (confirmed repeatedly this session), and
the existing private-runtime-lib install() rule uses an RPATH-style Unix
convention (lib/orbithub/) that doesn't work on Windows, where DLLs must
sit next to the exe. Produces a single OrbitHub-Setup-<version>.exe with
a Start Menu entry, optional desktop shortcut, and uninstaller.

build-installer.ps1 reads the project version from build/CMakeCache.txt
(same approach build-deb.sh already uses) and drives ISCC.exe.

Code signing is out of scope for now (no certificate available), so the
installer will trigger a SmartScreen warning until one is purchased.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 13:14:57 -06:00
ksmithandClaude Sonnet 5 4daf5cfc65 docs: close out Milestone 7 (cross-platform protocol hardening)
Linux, macOS, and Windows have all been validated against the current
feature set (SSH, RDP display/keyboard/clipboard/cursor, Tab forwarding,
key repeat, profile dialog, single-window UI), with the platform-specific
bugs found along the way fixed and tracked individually on the issue
tracker (#8-#16). VNC validation remains out of scope while Milestone 6
stays deferred.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 13:14:50 -06:00
ksmithandClaude Sonnet 5 0990049241 Fix Flatpak build issues: EOL runtime and icon naming mismatch
- Bump the KDE runtime/SDK from 6.8 (now end-of-life per Flathub) to the
  current stable 6.10.
- The .desktop file's Icon=orbithub didn't match the app ID
  (io.orbithub.OrbitHub), so Flatpak's export step skipped it entirely
  ("Icon referenced in desktop file but not exported"). Use the app-ID-
  matching icon name everywhere instead, and drop the now-redundant
  renamed orbithub.svg install() rule that existed only for the old name.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 13:14:45 -06:00
ksmithandClaude Sonnet 5 6d5133e1bd Switch to date-based version scheme
Release versions now follow v<year>.<month>.<day>, with a .N suffix
appended only if a second release happens the same day (e.g. v2026.9.8,
then v2026.9.8.2 for a same-day follow-up). Replaces the placeholder
0.1.0 the project started with.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 13:14:33 -06:00
ksmithandClaude Sonnet 5 9d88b74c04 Fix macOS icon showing as unrecognized-file placeholder
The .icns generated via Pillow's ICNS writer wasn't rendering in Finder
(showed the "unavailable" circle-slash placeholder instead), likely due
to Pillow's ICNS encoding not matching what Icon Services expects. Rebuilt
the file by hand instead, using Apple's documented icns binary format
directly (magic header + OSType-tagged PNG chunks: icp4/icp5/icp6 for
16/32/64px, ic07-ic10 for 128/256/512/1024px) with the exact same PNGs
already rendered from createOrbitHubAppIcon(), sidestepping Pillow's
ICNS writer entirely.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 12:41:08 -06:00
ksmithandClaude Sonnet 5 49ee12f5a1 Fix macOS configure error: add BUNDLE DESTINATION for install(TARGETS)
CMake requires an explicit BUNDLE DESTINATION for install(TARGETS) when
the target has MACOSX_BUNDLE set, or configure fails with "install
TARGETS give no BUNDLE DESTINATION for MACOSX_BUNDLE executable target".
The RUNTIME clause still covers Linux/Windows; BUNDLE only applies when
the target actually is a bundle (macOS), so no platform guard is needed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 12:37:47 -06:00
ksmithandClaude Sonnet 5 ac3cebc9ae macOS: build a proper .app bundle with an embedded icon
Same root cause as the earlier Windows exe icon fix: add_executable()
never set MACOSX_BUNDLE, so on macOS this produced a bare Mach-O binary
rather than a proper .app bundle -- and without a bundle there's no
Info.plist/.icns mechanism for Finder to show a custom icon at all.

Added packaging/macos/orbithub.icns, rendered at up to 1024px directly
from the same createOrbitHubAppIcon() logic used at runtime (via a
one-off export tool, not committed) so it matches what the app actually
looks like. CMake's MACOSX_BUNDLE keyword and MACOSX_BUNDLE_ICON_FILE
wire it into an auto-generated Info.plist; both keywords are no-ops on
other platforms. The profile database uses QStandardPaths::AppDataLocation,
not the executable's own path, so this doesn't affect where existing
profiles are found.

Build output on macOS changes from build/orbithub to build/orbithub.app
(launch with `open build/orbithub.app`); docs/BUILDING.md updated to match.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 12:35:52 -06:00
ksmithandClaude Sonnet 5 8f83b0c8d9 Fix RDP key repeat: forward auto-repeat presses to the remote server
RdpDisplayWidget::keyPressEvent() dropped every event where
QKeyEvent::isAutoRepeat() was true, which discards the entire repeat
stream Qt generates while a key is held -- so holding a key only ever
produced a single keystroke on the remote machine. Auto-repeat presses
need to reach the remote server so it can perform its own typematic
repeat, exactly as a physical keyboard held down would; only release
events should filter isAutoRepeat() (kept as-is), since Qt uses a
synthetic release/press pair purely to normalize platform auto-repeat
quirks, and forwarding that synthetic release would send a spurious
key-up for a key still physically held.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 11:54:50 -06:00
ksmithandClaude Sonnet 5 ac1deac148 Windows: embed app icon into the executable file
The app's icon only appeared at runtime (window/taskbar), set programmatically
via QApplication::setWindowIcon(). Explorer showed the generic default icon
for orbithub.exe itself, since that requires an icon baked into the PE file
as a Windows resource at build time -- a completely separate mechanism.

Added packaging/windows/orbithub.ico, rendered directly from the same
createOrbitHubAppIcon() logic used at runtime (via a one-off export tool,
not committed) so the file icon matches what the app actually looks like.
A minimal .rc resource file embeds it, compiled in only for WIN32 builds.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 09:49:38 -06:00
ksmithandClaude Sonnet 5 b28adf8cbe Fix RDP host resolution failure on Windows: initialize Winsock
Every RDP connect attempt on Windows failed instantly with
ERRCONNECT_DNS_NAME_NOT_FOUND, even for a literal IP address. Root cause:
Winsock requires WSAStartup() to be called once by the process before any
socket/getaddrinfo call will succeed, and nothing in OrbitHub was calling
it (Qt Network isn't used, so Qt never does it either). FreeRDP's own
reference Windows client (wf_client.c) pairs WSAStartup with
freerdp_handle_signals() in its startup init for exactly this reason.
WinPR provides a portable no-op WSAStartup shim on POSIX, so this can be
called unconditionally cross-platform alongside the existing signal-handler
fix.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 09:37:02 -06:00
ksmithandClaude Sonnet 5 317df68d1f Fix RDP connect crash on Windows: initialize FreeRDP signal handling
Every reference FreeRDP client (X11, Windows, macOS, SDL, Wayland, Android)
calls freerdp_handle_signals() once at startup before connecting. OrbitHub
never did. On Windows this is the only place that initializes a global
CRITICAL_SECTION used internally by freerdp_add_signal_cleanup_handler(),
which freerdp_connect() calls automatically. Skipping it leaves that lock
zero-initialized -- invalid on Windows, but silently tolerated on POSIX,
which is why this never surfaced in Linux testing. The result was a crash
(access violation inside EnterCriticalSection) on every RDP connect attempt
on Windows. Call it once, guarded, before the first connection.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 09:27:25 -06:00
ksmithandClaude Sonnet 5 fce69b8be9 Forward Tab/Shift+Tab to remote sessions instead of local focus navigation
QWidget's default handling intercepts Tab/Shift+Tab for focus-chain
navigation before they ever reach keyPressEvent(), so pressing Tab in an
RDP session moved focus to the OrbitHub UI (e.g. the Show Events button)
instead of reaching the remote machine. KodoTerm (SSH) already overrides
focusNextPrevChild() to opt out of this; RdpDisplayWidget and TerminalView
did not. Both already handle Key_Tab in their own keyPressEvent, so this
is enough to let it reach them.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 08:50:01 -06:00
ksmithandClaude Sonnet 5 f3ea7f12a3 Windows: deploy sqlite3.dll and mark orbithub as a GUI (WIN32) executable
Two more Windows launch issues found alongside the Qt plugin deployment:
vcpkg's qtbase[sql-sqlite] links the QSQLITE driver plugin against vcpkg's
own shared sqlite3 port rather than bundling it, so the plugin was found
but failed to load without sqlite3.dll alongside the executable. Separately,
plain add_executable() defaults to the console subsystem on Windows, so a
command-prompt window appeared behind the GUI on every launch; add_executable
needs the WIN32 keyword to mark it as a GUI app (a no-op on other platforms).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 08:27:50 -06:00
ksmithandClaude Sonnet 5 30f0134748 Deploy Qt platform and SQLite plugins automatically on Windows
Qt loads its platform integration and SQL driver plugins dynamically
(QFactoryLoader) rather than importing them at link time, so they never
show up in orbithub.exe's import table and vcpkg's automatic DLL
deployment never copies them. Without this, the app fails to launch on
Windows with "Could not find the Qt platform plugin" or "can not load
requested driver 'QSQLITE'". Copy them out explicitly via Qt's own
exported plugin targets after each build, plus matching install() rules
for future packaging.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 08:20:17 -06:00
ksmithandClaude Sonnet 5 a89748be67 Merge Profiles and Session windows into a single window
ProfilesWindow and SessionWindow were two independent top-level windows,
coordinated through a QPointer and manual show/create-or-reuse logic, with
duplicated Help menus and a path where Quit didn't actually quit if a
SessionWindow happened to be open. ProfilesWindow is now an embedded QWidget
shown as a permanent, unclosable "Profiles" tab inside SessionWindow's tab
widget; SessionWindow is the app's sole top-level window. Double-clicking a
profile opens a session tab in the same window instead of finding-or-creating
a second one, and closing all session tabs falls back to the Profiles tab
rather than closing the app.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 15:59:53 -06:00
ksmithandClaude Sonnet 5 4b4b7d68f2 Fix profile dialog form fields collapsing to sizeHint on macOS
QFormLayout without an explicit field growth policy falls back to the
active Qt style's default, which differs per platform: Linux's Fusion/Breeze
styles expand fields to fill the row, but macOS's native style defaults to
FieldsStayAtSizeHint, leaving fields small with empty space beside them.
Set the policy explicitly so the dialog renders consistently everywhere.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 15:18:09 -06:00
ksmithandClaude Sonnet 5 793fdd9366 RDP session fixes: correct keyboard scancodes, clipboard sync, cursor shapes
Fix RDP keyboard input using FreeRDP's authoritative X11-keycode-to-scancode
table instead of ad hoc bit math, which misread punctuation keys as unrelated
letter keys (e.g. apostrophe as B) because X11 keycode numbering only
coincidentally overlaps PC/AT scancodes.

Add bidirectional clipboard sync (CF_UNICODETEXT) over the cliprdr channel,
and RDP pointer/cursor shape sync so the local cursor reflects what the
remote OS wants displayed (resize handles, text I-beam, etc.) instead of
staying a static arrow.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 14:53:03 -06:00
ksmithandClaude Sonnet 5 27cc3a3bb2 Session UX: inline password prompt and terminal font size controls
Replace the modal RDP/SSH password dialog with an inline prompt bar
embedded in the session tab instead of a separate popup window. Add
per-tab terminal font size controls (increase/decrease/reset/set
exact point size) via the tab context menu, with the chosen size
persisted across sessions.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 12:02:03 -06:00
ksmith c1c23d115a packaging: add deb/flatpak build flow and desktop metadata 2026-03-03 20:46:03 -07:00
ksmith f54c2e9bcd docs: update Windows build requirements and refresh milestone tag 2026-03-03 20:21:38 -07:00
ksmith ae9928782d docs: close out milestone 8 and set milestone 9 as current 2026-03-03 20:16:43 -07:00
ksmith 2485ffb14f docs: clarify Qt6 LGPLv3 licensing links in README 2026-03-03 20:13:12 -07:00
ksmith eadcdd7f10 Milestone 8 UX: folder tree workflows, about dialog, and app icon polish 2026-03-03 20:07:41 -07:00
ksmith 36006bd4aa Milestone 5: deliver embedded RDP sessions and lifecycle hardening 2026-03-03 18:59:26 -07:00
ksmith 230a401386 Refine roadmap: Milestones 5 and 6 as full RDP/VNC delivery 2026-03-01 11:11:26 -07:00
ksmith e77f2598b9 Document post-M4 roadmap with separate RDP and VNC milestones 2026-03-01 11:06:30 -07:00
ksmith 2b25f805cd Complete Milestone 4 interactive SSH session UX 2026-03-01 11:00:31 -07:00
ksmith 776ddc1a53 Integrate KodoTerm for SSH terminal sessions 2026-03-01 10:36:06 -07:00
ksmith c3369b8e48 Improve terminal theming, cursor UX, and size negotiation 2026-03-01 10:14:43 -07:00
ksmith 20ee48db32 Add ANSI color rendering and terminal themes 2026-03-01 10:08:04 -07:00
ksmith 2b4f498259 Allow direct terminal typing and collapsible session panels 2026-03-01 09:58:21 -07:00
ksmith 614d31fa71 Restore built-in askpass helper for SSH password auth 2026-03-01 09:53:17 -07:00
ksmith ceed19d517 Draft Milestone 4 scope and mark progress in docs 2026-03-01 09:50:08 -07:00
ksmith 2ea712db36 Start Milestone 4 interactive SSH terminal and host-key flow 2026-03-01 09:50:03 -07:00
3508 changed files with 761352 additions and 407 deletions
+4
View File
@@ -1 +1,5 @@
/build/ /build/
/dist/
/.flatpak-builder/
/build-doc-tool/
/docs/USER_GUIDE.pdf
+272 -5
View File
@@ -1,6 +1,6 @@
cmake_minimum_required(VERSION 3.21) cmake_minimum_required(VERSION 3.21)
project(OrbitHub VERSION 0.1.0 LANGUAGES CXX) project(OrbitHub VERSION 2026.9.16.7 LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_CXX_STANDARD_REQUIRED ON)
@@ -10,16 +10,105 @@ set(CMAKE_AUTOMOC ON)
set(CMAKE_AUTOUIC ON) set(CMAKE_AUTOUIC ON)
set(CMAKE_AUTORCC ON) set(CMAKE_AUTORCC ON)
find_package(Qt6 6.2 REQUIRED COMPONENTS Widgets Sql) 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() qt_standard_project_setup()
add_executable(orbithub option(ORBITHUB_BUILD_TESTS "Build unit tests (requires Qt6::Test)" ON)
if(ORBITHUB_BUILD_TESTS)
find_package(Qt6 6.2 QUIET COMPONENTS Test)
if(TARGET Qt6::Test)
enable_testing()
else()
message(STATUS "Qt6::Test not found -- skipping unit tests (set ORBITHUB_BUILD_TESTS=OFF to silence this)")
set(ORBITHUB_BUILD_TESTS OFF)
endif()
endif()
add_subdirectory(third_party/KodoTerm)
if(NOT EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/third_party/FreeRDP/CMakeLists.txt")
message(FATAL_ERROR "Vendored FreeRDP source is missing at third_party/FreeRDP")
endif()
# Pin FreeRDP build to the vendored source tree so headers/libs are always from the same revision.
set(FREERDP_UNIFIED_BUILD ON CACHE BOOL "" FORCE)
set(WITH_MANPAGES OFF CACHE BOOL "" FORCE)
set(WITH_SAMPLE OFF CACHE BOOL "" FORCE)
set(BUILD_TESTING OFF CACHE BOOL "" FORCE)
set(BUILD_TESTING_INTERNAL OFF CACHE BOOL "" FORCE)
set(WITH_CLIENT_COMMON ON CACHE BOOL "" FORCE)
set(WITH_CLIENT OFF CACHE BOOL "" FORCE)
set(WITH_CLIENT_SDL OFF CACHE BOOL "" FORCE)
set(WITH_CLIENT_INTERFACE OFF CACHE BOOL "" FORCE)
set(WITH_SERVER OFF CACHE BOOL "" FORCE)
set(WITH_CHANNELS ON CACHE BOOL "" FORCE)
set(WITH_CLIENT_CHANNELS ON CACHE BOOL "" FORCE)
set(WITH_FUSE OFF CACHE BOOL "" FORCE)
set(CHANNEL_DRDYNVC ON CACHE BOOL "" FORCE)
set(CHANNEL_DRDYNVC_CLIENT ON CACHE BOOL "" FORCE)
set(CHANNEL_DISP ON CACHE BOOL "" FORCE)
set(CHANNEL_DISP_CLIENT ON CACHE BOOL "" FORCE)
set(CHANNEL_AINPUT OFF CACHE BOOL "" FORCE)
set(CHANNEL_AUDIN OFF CACHE BOOL "" FORCE)
set(CHANNEL_CLIPRDR ON CACHE BOOL "" FORCE)
set(CHANNEL_CLIPRDR_CLIENT ON CACHE BOOL "" FORCE)
set(CHANNEL_DRIVE OFF CACHE BOOL "" FORCE)
set(CHANNEL_ECHO OFF CACHE BOOL "" FORCE)
set(CHANNEL_ENCOMSP OFF CACHE BOOL "" FORCE)
set(CHANNEL_GEOMETRY OFF CACHE BOOL "" FORCE)
set(CHANNEL_LOCATION OFF CACHE BOOL "" FORCE)
set(CHANNEL_PARALLEL OFF CACHE BOOL "" FORCE)
set(CHANNEL_PRINTER OFF CACHE BOOL "" FORCE)
set(CHANNEL_RAIL OFF CACHE BOOL "" FORCE)
set(CHANNEL_RDPDR OFF CACHE BOOL "" FORCE)
set(CHANNEL_RDPEAR OFF CACHE BOOL "" FORCE)
set(CHANNEL_RDPECAM OFF CACHE BOOL "" FORCE)
set(CHANNEL_RDPEI OFF CACHE BOOL "" FORCE)
set(CHANNEL_RDPEMSC OFF CACHE BOOL "" FORCE)
set(CHANNEL_RDPGFX OFF CACHE BOOL "" FORCE)
set(CHANNEL_RDPSND OFF CACHE BOOL "" FORCE)
set(CHANNEL_REMDESK OFF CACHE BOOL "" FORCE)
set(CHANNEL_SERIAL OFF CACHE BOOL "" FORCE)
set(CHANNEL_SMARTCARD OFF CACHE BOOL "" FORCE)
set(CHANNEL_SSHAGENT OFF CACHE BOOL "" FORCE)
set(CHANNEL_TELEMETRY OFF CACHE BOOL "" FORCE)
set(CHANNEL_URBDRC OFF CACHE BOOL "" FORCE)
set(CHANNEL_VIDEO OFF CACHE BOOL "" FORCE)
set(WITH_FFMPEG OFF CACHE BOOL "" FORCE)
set(WITH_DSP_FFMPEG OFF CACHE BOOL "" FORCE)
set(WITH_VIDEO_FFMPEG OFF CACHE BOOL "" FORCE)
set(WITH_CAIRO OFF CACHE BOOL "" FORCE)
set(WITH_SWSCALE OFF CACHE BOOL "" FORCE)
set(WITH_JPEG OFF CACHE BOOL "" FORCE)
set(WITH_KRB5 OFF CACHE BOOL "" FORCE)
set(WITH_UNICODE_BUILTIN ON CACHE BOOL "" FORCE)
set(WITH_WINPR_TOOLS OFF CACHE BOOL "" FORCE)
add_subdirectory(third_party/FreeRDP EXCLUDE_FROM_ALL)
set(ORBITHUB_SOURCES
src/about_dialog.cpp
src/about_dialog.h
src/user_guide_dialog.cpp
src/user_guide_dialog.h
docs/user_guide.qrc
src/app_icon.cpp
src/app_icon.h
src/main.cpp src/main.cpp
src/mremoteng_importer.cpp
src/mremoteng_importer.h
src/profile_dialog.cpp src/profile_dialog.cpp
src/profile_dialog.h src/profile_dialog.h
src/profile_repository.cpp src/profile_repository.cpp
src/profile_repository.h src/profile_repository.h
src/profiles_tree_widget.cpp
src/profiles_tree_widget.h
src/profiles_window.cpp src/profiles_window.cpp
src/profiles_window.h src/profiles_window.h
src/session_backend.h src/session_backend.h
@@ -27,14 +116,192 @@ add_executable(orbithub
src/session_backend_factory.h src/session_backend_factory.h
src/session_tab.cpp src/session_tab.cpp
src/session_tab.h src/session_tab.h
src/rdp_display_widget.cpp
src/rdp_display_widget.h
src/vnc_display_widget.cpp
src/vnc_display_widget.h
src/terminal_view.cpp
src/terminal_view.h
src/session_window.cpp src/session_window.cpp
src/session_window.h src/session_window.h
src/rdp_session_backend.cpp
src/rdp_session_backend.h
src/ssh_session_backend.cpp src/ssh_session_backend.cpp
src/ssh_session_backend.h src/ssh_session_backend.h
src/vnc_session_backend.cpp
src/vnc_session_backend.h
src/vnc_pixel_codecs.cpp
src/vnc_pixel_codecs.h
src/vnc_apple_dh_auth.cpp
src/vnc_apple_dh_auth.h
src/vnc_apple_rsa_auth.cpp
src/vnc_apple_rsa_auth.h
src/unsupported_session_backend.cpp src/unsupported_session_backend.cpp
src/unsupported_session_backend.h src/unsupported_session_backend.h
) )
target_link_libraries(orbithub PRIVATE Qt6::Widgets Qt6::Sql) if(WIN32)
list(APPEND ORBITHUB_SOURCES packaging/windows/orbithub.rc)
endif()
install(TARGETS orbithub RUNTIME DESTINATION bin) if(APPLE)
list(APPEND ORBITHUB_SOURCES packaging/macos/orbithub.icns)
set_source_files_properties(packaging/macos/orbithub.icns PROPERTIES
MACOSX_PACKAGE_LOCATION "Resources"
)
set(MACOSX_BUNDLE_ICON_FILE orbithub.icns)
set(MACOSX_BUNDLE_BUNDLE_NAME "OrbitHub")
set(MACOSX_BUNDLE_GUI_IDENTIFIER "org.darksingularity.OrbitHub")
set(MACOSX_BUNDLE_BUNDLE_VERSION "${PROJECT_VERSION}")
set(MACOSX_BUNDLE_SHORT_VERSION_STRING "${PROJECT_VERSION}")
endif()
add_executable(orbithub WIN32 MACOSX_BUNDLE ${ORBITHUB_SOURCES})
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)
target_include_directories(orbithub PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}/third_party/FreeRDP/include
${CMAKE_CURRENT_SOURCE_DIR}/third_party/FreeRDP/winpr/include
${CMAKE_CURRENT_BINARY_DIR}/third_party/FreeRDP/include
${CMAKE_CURRENT_BINARY_DIR}/third_party/FreeRDP/winpr/include
)
target_link_libraries(orbithub PRIVATE freerdp winpr)
if(TARGET freerdp-client)
target_link_libraries(orbithub PRIVATE freerdp-client)
endif()
else()
message(FATAL_ERROR "Vendored FreeRDP targets were not produced as expected.")
endif()
# Qt loads platform integration and SQL driver plugins dynamically at
# runtime (QFactoryLoader), so they never appear in orbithub.exe's import
# table. vcpkg's automatic DLL deployment only follows the import table, so
# these plugins have to be copied out explicitly or the app fails to start
# with "Could not find the Qt platform plugin" / "can not load requested
# driver 'QSQLITE'".
if(WIN32)
if(TARGET Qt6::QWindowsIntegrationPlugin)
add_custom_command(TARGET orbithub POST_BUILD
COMMAND ${CMAKE_COMMAND} -E make_directory
"$<TARGET_FILE_DIR:orbithub>/platforms"
COMMAND ${CMAKE_COMMAND} -E copy_if_different
"$<TARGET_FILE:Qt6::QWindowsIntegrationPlugin>"
"$<TARGET_FILE_DIR:orbithub>/platforms/"
COMMENT "Deploying Qt Windows platform plugin"
)
install(FILES "$<TARGET_FILE:Qt6::QWindowsIntegrationPlugin>"
DESTINATION "${CMAKE_INSTALL_BINDIR}/platforms"
)
endif()
if(TARGET Qt6::QSQLiteDriverPlugin)
add_custom_command(TARGET orbithub POST_BUILD
COMMAND ${CMAKE_COMMAND} -E make_directory
"$<TARGET_FILE_DIR:orbithub>/sqldrivers"
COMMAND ${CMAKE_COMMAND} -E copy_if_different
"$<TARGET_FILE:Qt6::QSQLiteDriverPlugin>"
"$<TARGET_FILE_DIR:orbithub>/sqldrivers/"
COMMENT "Deploying Qt SQLite driver plugin"
)
install(FILES "$<TARGET_FILE:Qt6::QSQLiteDriverPlugin>"
DESTINATION "${CMAKE_INSTALL_BINDIR}/sqldrivers"
)
# vcpkg's qtbase[sql-sqlite] links the plugin against vcpkg's own
# shared sqlite3 port rather than bundling SQLite into the plugin,
# so the plugin fails to load ("can not load requested driver
# 'QSQLITE'") unless sqlite3.dll also ships next to the executable.
find_file(ORBITHUB_SQLITE3_DLL
NAMES sqlite3.dll
PATHS ${CMAKE_PREFIX_PATH}
PATH_SUFFIXES bin
)
if(ORBITHUB_SQLITE3_DLL)
add_custom_command(TARGET orbithub POST_BUILD
COMMAND ${CMAKE_COMMAND} -E copy_if_different
"${ORBITHUB_SQLITE3_DLL}"
"$<TARGET_FILE_DIR:orbithub>/"
COMMENT "Deploying sqlite3.dll (Qt SQLite plugin dependency)"
)
install(FILES "${ORBITHUB_SQLITE3_DLL}" DESTINATION "${CMAKE_INSTALL_BINDIR}")
endif()
endif()
endif()
if(APPLE)
# Mirrors macdeployqt's own layout for Qt frameworks: dylibs live in
# Contents/Frameworks inside the bundle, found via @executable_path
# (the macOS/dyld equivalent of Linux's $ORIGIN token, which dyld
# does not understand).
set_target_properties(orbithub PROPERTIES
INSTALL_RPATH "@executable_path/../Frameworks"
INSTALL_RPATH_USE_LINK_PATH ON
)
else()
set_target_properties(orbithub PROPERTIES
BUILD_RPATH_USE_ORIGIN ON
INSTALL_RPATH "$ORIGIN/../${CMAKE_INSTALL_LIBDIR}/orbithub"
INSTALL_RPATH_USE_LINK_PATH ON
)
endif()
install(TARGETS orbithub
RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR}
BUNDLE DESTINATION .
)
if(APPLE)
set(ORBITHUB_PRIVATE_LIB_DESTINATION "OrbitHub.app/Contents/Frameworks")
else()
set(ORBITHUB_PRIVATE_LIB_DESTINATION "${CMAKE_INSTALL_LIBDIR}/orbithub")
endif()
set(ORBITHUB_RUNTIME_TARGETS KodoTerm freerdp winpr)
if(TARGET freerdp-client)
list(APPEND ORBITHUB_RUNTIME_TARGETS freerdp-client)
endif()
foreach(runtime_target IN LISTS ORBITHUB_RUNTIME_TARGETS)
if(TARGET ${runtime_target})
install(TARGETS ${runtime_target}
RUNTIME DESTINATION ${ORBITHUB_PRIVATE_LIB_DESTINATION}
LIBRARY DESTINATION ${ORBITHUB_PRIVATE_LIB_DESTINATION}
ARCHIVE DESTINATION ${CMAKE_INSTALL_LIBDIR}
)
endif()
endforeach()
install(FILES
packaging/linux/org.darksingularity.OrbitHub.desktop
DESTINATION ${CMAKE_INSTALL_DATADIR}/applications
)
foreach(icon_size IN ITEMS 16 24 32 48 64 128 256)
install(FILES
packaging/linux/icons/${icon_size}x${icon_size}/apps/org.darksingularity.OrbitHub.png
DESTINATION ${CMAKE_INSTALL_DATADIR}/icons/hicolor/${icon_size}x${icon_size}/apps
)
endforeach()
install(FILES
packaging/linux/org.darksingularity.OrbitHub.metainfo.xml
DESTINATION ${CMAKE_INSTALL_DATADIR}/metainfo
)
install(FILES LICENSE
DESTINATION ${CMAKE_INSTALL_DATADIR}/licenses/org.darksingularity.OrbitHub
)
install(FILES third_party/FreeRDP/LICENSE
DESTINATION ${CMAKE_INSTALL_DATADIR}/licenses/org.darksingularity.OrbitHub
RENAME LICENSE-FreeRDP
)
install(FILES third_party/KodoTerm/LICENSE
DESTINATION ${CMAKE_INSTALL_DATADIR}/licenses/org.darksingularity.OrbitHub
RENAME LICENSE-KodoTerm
)
if(ORBITHUB_BUILD_TESTS)
add_subdirectory(tests)
endif()
+235
View File
@@ -0,0 +1,235 @@
# OrbitHub
OrbitHub is a cross-platform native desktop app for managing and launching remote sessions from one place.
It is implemented in C++17 with Qt6 Widgets and built with CMake.
Supported target platforms:
- Windows
- Linux
- macOS
## Current Status
OrbitHub is in active development.
- Milestones completed: M0-M9
- Current milestone: Milestone 10 (v1.0 Stabilization)
- Latest published release: `v2026.9.16`; a `v2026.9.16.6` release is
drafted with several post-`.16` fixes and is pending a macOS installer
before publishing
- VNC (M6) is fully built out: Raw/CopyRect/Hextile/ZRLE/Tight encodings,
bidirectional clipboard sync, remote cursor shape sync, and both
scale-to-fit and actual-size display modes. Standard VNC Authentication
and no-auth servers (TigerVNC, x11vnc, TightVNC, etc.) work end to end.
macOS's built-in Screen Sharing uses two undocumented, reverse-engineered
authentication schemes that are implemented but not yet confirmed working
against a real macOS server — see
[docs/PROGRESS.md](docs/PROGRESS.md) for details.
Progress and milestone details:
- [docs/PROGRESS.md](docs/PROGRESS.md)
Latest published release (installers for Windows, Linux, and macOS):
- [v2026.9.16](https://git.darksingularity.org/DarkSingularity/orbithub/releases/tag/v2026.9.16)
User Guide:
- [docs/USER_GUIDE.md](docs/USER_GUIDE.md) (also available as a PDF attached to each release, and in-app via `Help -> User Guide`)
## Screenshots
![Profile list with folders](docs/images/screenshot-profiles.png)
Profiles organized into folders, with protocol, host, and tags shown at a glance. (Sample data shown; not real hosts.)
![Active SSH terminal session](docs/images/screenshot-ssh-session.png)
An interactive SSH terminal session in a tab, with the event log below.
![Active RDP session](docs/images/screenshot-rdp-session.png)
An embedded RDP session in a tab.
## Implemented Features
### Profile Management
- SQLite-backed profile storage
- Create, edit, delete profiles
- Protocol-aware profile validation (SSH/RDP/VNC)
- Username is optional on any protocol's profile — if left blank, OrbitHub
asks for one inline at connect time instead of requiring it up front
- Profile search and sorting
- Tags support
- Folder/subfolder support
- `List` and `Folders` profile views
- Right-click profile tree actions:
- New Folder
- New Connection
- Drag-and-drop profile moves between folders with persistence
- Import/Export of the whole profile list as JSON, and one-way import from
mRemoteNG connection XML files (passwords are never imported)
### Session Experience
- Multi-tab session window
- Auto-connect on tab open
- Disconnect on tab close
- Session state indicators on tabs, colored distinctly per state
(connecting/connected/disconnected/failed)
- A tab awaiting a username/password prompt is clearly marked — its title
gets a "(Needs input)" suffix and its tab color changes — even when it
isn't the tab currently in view, and the inline prompt itself uses a
solid highlighted banner rather than blending into the rest of the tab
- Timestamped event log with filtering and export
### SSH
- Embedded interactive terminal (in-app typing)
- Theme support (`Dark`, `Light`, `Solarized Dark`)
- Password and private-key auth flows
- Known-hosts policy support
### RDP
- Embedded in-window RDP rendering surface (no external launcher)
- Keyboard/mouse input forwarding
- Resize handling and resolution renegotiation
- Domain-aware authentication support
- RDP security/performance profile options
### VNC
- Embedded in-window VNC rendering surface (no external launcher)
- Raw, CopyRect, Hextile, ZRLE, and Tight rectangle encodings
- Bidirectional clipboard sync
- Remote cursor shape sync
- Scale-to-fit and actual-size (scrollable) display modes, toggled
per-tab and remembered across sessions
- Standard VNC Authentication and no-auth servers (TigerVNC, x11vnc,
TightVNC, etc.)
- No dynamic remote-desktop resizing (VNC has no real equivalent of RDP's
MS-RDPEDISP)
- macOS Screen Sharing's two undocumented Apple auth schemes are
implemented but not yet confirmed working against a real macOS server —
see [docs/PROGRESS.md](docs/PROGRESS.md)
### App UX
- App icon and themed About dialog
- In-app User Guide (`Help -> User Guide`), matching
[docs/USER_GUIDE.md](docs/USER_GUIDE.md)
- `File` menu:
- New Profile
- New Folder
- Import Profiles...
- Export Profiles...
- Import from mRemoteNG...
- Quit
- `Help` menu:
- User Guide
- About OrbitHub
## Build and Run
Detailed platform instructions:
- [docs/BUILDING.md](docs/BUILDING.md)
Quick start (Linux/macOS with Ninja):
```bash
cmake -S . -B build -G Ninja
cmake --build build
./build/orbithub
```
## Packaging
Detailed packaging instructions for all platforms:
- [docs/BUILDING.md](docs/BUILDING.md)
Linux (`.deb`):
```bash
./packaging/linux/build-deb.sh
```
Linux (Flatpak):
```bash
./packaging/flatpak/build-flatpak.sh
```
Windows (Inno Setup installer):
```powershell
.\packaging\windows\build-installer.ps1
```
macOS (`.dmg`):
```bash
./packaging/macos/build-dmg.sh
```
## Dependencies
Core dependencies:
- Qt 6 (Widgets, SQL)
- CMake 3.21+
- C++17 toolchain
- OpenSSL (RDP/VNC transport security and Apple VNC auth)
- zlib (VNC's ZRLE and Tight encodings)
- libjpeg-turbo (VNC's Tight encoding's JPEG sub-mode)
Protocol/runtime dependencies:
- SSH client (`ssh`) available on `PATH` for SSH sessions
Bundled/vendored third-party components:
- KodoTerm
- libvterm
- FreeRDP/WinPR
## Licensing
Project license:
- MIT (see [LICENSE](LICENSE))
License links:
- MIT License: <https://opensource.org/licenses/MIT>
- GNU LGPLv3: <https://www.gnu.org/licenses/lgpl-3.0.html>
- Apache License 2.0: <https://www.apache.org/licenses/LICENSE-2.0>
Important third-party license notes:
- Qt6 is dynamically linked in this project build setup.
- Qt6 is used under LGPLv3 terms in this project build setup.
- KodoTerm and libvterm are MIT-licensed.
- FreeRDP/WinPR is Apache-2.0 licensed.
Repository license files:
- Project: [LICENSE](LICENSE)
- KodoTerm: [third_party/KodoTerm/LICENSE](third_party/KodoTerm/LICENSE)
- FreeRDP: [third_party/FreeRDP/LICENSE](third_party/FreeRDP/LICENSE)
See in-app `Help -> About OrbitHub` for license links and third-party inventory.
## Repository Structure
- `src/` - application source code
- `docs/` - build guide, spec, and progress tracking
- `packaging/` - per-platform installer/package build scripts
- `tests/` - CTest unit/integration tests and fixtures
- `tools/` - standalone build-time tools (e.g. the User Guide PDF generator)
- `third_party/` - vendored third-party dependencies
- `build/` - local build output (generated)
- `dist/` - packaged installer/deb/flatpak/dmg output (generated)
## Notes
- Passwords are requested at connect time and are not stored in the profile database.
- A profile's username is optional for every protocol; if left blank,
OrbitHub asks for one inline the first time you connect that profile.
- VNC support covers standard VNC Authentication and no-auth servers (e.g. TigerVNC, x11vnc,
TightVNC). macOS's built-in Screen Sharing server uses two undocumented, reverse-engineered
authentication schemes that are implemented but not yet confirmed working against a real
macOS server (see docs/PROGRESS.md, Milestone 6).
+114 -15
View File
@@ -2,6 +2,14 @@
Run all commands from the repository root unless noted. Run all commands from the repository root unless noted.
## Requirements
Minimum toolchain requirements on all platforms:
- CMake 3.21+
- C++17 compiler toolchain
- Qt 6.2+ with `Widgets` and `Sql` modules (dynamic linking)
- OpenSSH client available on `PATH` (required for SSH sessions)
## Linux (Ubuntu / Mint) ## Linux (Ubuntu / Mint)
```bash ```bash
@@ -9,9 +17,9 @@ sudo apt update
sudo apt install -y \ sudo apt install -y \
build-essential cmake ninja-build git pkg-config \ build-essential cmake ninja-build git pkg-config \
qt6-base-dev qt6-base-dev-tools qt6-tools-dev qt6-tools-dev-tools \ qt6-base-dev qt6-base-dev-tools qt6-tools-dev qt6-tools-dev-tools \
openssh-client openssh-client libssl-dev zlib1g-dev libjpeg-turbo8-dev
cmake -S . -B build -G Ninja cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release
cmake --build build cmake --build build
./build/orbithub ./build/orbithub
``` ```
@@ -21,39 +29,130 @@ cmake --build build
```bash ```bash
xcode-select --install xcode-select --install
brew update brew update
brew install cmake ninja pkg-config qt@6 openssh brew install cmake ninja pkg-config qt@6 openssh openssl@3 jpeg-turbo
cmake -S . -B build -G Ninja -DCMAKE_PREFIX_PATH="$(brew --prefix qt@6)" cmake -S . -B build -G Ninja \
-DCMAKE_BUILD_TYPE=Release \
-DCMAKE_PREFIX_PATH="$(brew --prefix qt@6);$(brew --prefix openssl@3);$(brew --prefix jpeg-turbo)"
cmake --build build cmake --build build
./build/orbithub open build/orbithub.app
``` ```
## Windows 11 (PowerShell + MSVC + vcpkg) ## Windows 11 (PowerShell + MSVC + vcpkg)
Install required software:
```powershell ```powershell
winget install -e --id Git.Git winget install -e --id Git.Git
winget install -e --id Kitware.CMake winget install -e --id Kitware.CMake
winget install -e --id Ninja-build.Ninja winget install -e --id Ninja-build.Ninja
Add-WindowsCapability -Online -Name OpenSSH.Client~~~~0.0.1.0 Add-WindowsCapability -Online -Name OpenSSH.Client~~~~0.0.1.0
winget install -e --id Microsoft.VisualStudio.2022.BuildTools ` winget install -e --id Microsoft.VisualStudio.2022.BuildTools `
--override "--quiet --wait --norestart --add Microsoft.VisualStudio.Workload.VCTools" --override "--quiet --wait --norestart --add Microsoft.VisualStudio.Workload.VCTools --add Microsoft.VisualStudio.Component.Windows11SDK.22621"
``` ```
Open a new terminal after installs, then: Install dependencies via vcpkg:
```powershell ```powershell
git clone https://github.com/microsoft/vcpkg C:\dev\vcpkg git clone https://github.com/microsoft/vcpkg C:\dev\vcpkg
C:\dev\vcpkg\bootstrap-vcpkg.bat C:\dev\vcpkg\bootstrap-vcpkg.bat
C:\dev\vcpkg\vcpkg.exe install qtbase:x64-windows C:\dev\vcpkg\vcpkg.exe install qtbase:x64-windows openssl:x64-windows zlib:x64-windows libjpeg-turbo:x64-windows
cmake -S . -B build -G Ninja `
-DCMAKE_TOOLCHAIN_FILE=C:/dev/vcpkg/scripts/buildsystems/vcpkg.cmake
cmake --build build
.\build\orbithub.exe
``` ```
Open `x64 Native Tools Command Prompt for VS 2022` (or Developer PowerShell), then build:
```powershell
cmake -S . -B build -G Ninja `
-DCMAKE_BUILD_TYPE=Release `
-DCMAKE_TOOLCHAIN_FILE=C:/dev/vcpkg/scripts/buildsystems/vcpkg.cmake `
-DVCPKG_TARGET_TRIPLET=x64-windows
cmake --build build
```
Run (ensures DLL paths from vcpkg are present):
```powershell
C:\dev\vcpkg\vcpkg.exe env --triplet x64-windows -- .\build\orbithub.exe
```
If you already have `Qt 6` from the Qt installer and do not want vcpkg Qt, you can point CMake at that Qt install with `-DCMAKE_PREFIX_PATH=...`, but you still need compatible `OpenSSL`, `zlib`, and `libjpeg-turbo` development libraries (the first two for the embedded FreeRDP build; `libjpeg-turbo` for VNC's Tight encoding's JPEG sub-mode).
## Notes ## Notes
- OrbitHub currently requires Qt6 Widgets and CMake 3.21+. - OrbitHub builds vendored `KodoTerm`, `libvterm`, and `FreeRDP` from `third_party/`.
- Milestone 3 SSH sessions require an `ssh` client available on `PATH`.
- If Qt is installed in a custom location, pass `-DCMAKE_PREFIX_PATH=/path/to/Qt/6.x.x/<toolchain>` to CMake. - If Qt is installed in a custom location, pass `-DCMAKE_PREFIX_PATH=/path/to/Qt/6.x.x/<toolchain>` to CMake.
- Build output executable:
- Linux: `build/orbithub`
- macOS: `build/orbithub.app` (a proper app bundle, launch with `open build/orbithub.app`)
- Windows: `build\\orbithub.exe`
## Linux Packaging
Build a Debian package (`.deb`) from the current Linux build:
```bash
./packaging/linux/build-deb.sh
```
Output path:
- `dist/orbithub_<version>_<arch>.deb`
Build a Flatpak bundle:
```bash
sudo apt-get install -y flatpak flatpak-builder
./packaging/flatpak/build-flatpak.sh
```
Output path:
- `dist/flatpak/org.darksingularity.OrbitHub.flatpak`
## Windows Packaging
Requires [Inno Setup 6](https://jrsoftware.org/isinfo.php) (`winget install -e --id JRSoftware.InnoSetup`).
From a configured and built `build\` directory (see Windows build steps above):
```powershell
.\packaging\windows\build-installer.ps1
```
Output path:
- `dist\windows\OrbitHub-Setup-<version>.exe`
## macOS Packaging
Requires `qt@6` from Homebrew (for `macdeployqt`). For a custom volume icon on
the `.dmg`, also install `SetFile` (via Xcode's "Additional Tools", from
[developer.apple.com/download/all](https://developer.apple.com/download/all))
or `brew install fileicon` — packaging still works without either, just with
the generic disk image icon.
```bash
./packaging/macos/build-dmg.sh
```
This runs `cmake --install`, then `macdeployqt` to bundle Qt frameworks and
plugins into the `.app`, then re-signs the bundle (ad hoc, since there is no
Developer ID certificate) so it passes macOS's launch-time code-integrity
check. The result is unsigned/unnotarized, so first launch requires
right-click → **Open** to bypass Gatekeeper's unidentified-developer warning.
Output path:
- `dist/macos/OrbitHub-<version>.dmg`
## User Guide PDF
The in-app User Guide (`Help -> User Guide`) is built from
`docs/USER_GUIDE.md` and embedded into the app at compile time — no extra
step needed for that. A standalone PDF version is generated separately by a
small tool (kept out of the main app's dependencies, since it needs
`Qt6::PrintSupport`):
```bash
./packaging/docs/build-user-guide-pdf.sh
```
Output path:
- `docs/USER_GUIDE.pdf` (not committed to git — a release asset, like the
platform installers)
+100
View File
@@ -0,0 +1,100 @@
# Flathub Submission Readiness
Tracks OrbitHub's readiness for submission to Flathub. This is a separate
checklist from `docs/PROGRESS.md`'s development milestones, since Flathub
submission is an external process with its own requirements.
## Status
| Item | Status |
|---|---|
| Production manifest with pinned, reproducible git source | Done — `packaging/flatpak/flathub/org.darksingularity.OrbitHub.yml`; commit pin updated at each tagged release |
| `flathub.json` for build settings (`only-arches`, etc.) | Done — `packaging/flatpak/flathub/flathub.json` |
| Offline build (no network fetches during build) | Verified — no `FetchContent`/`ExternalProject`/`curl`/`wget` in CMake; all vendored deps committed in `third_party/`; confirmed with a real `flatpak-builder` build |
| SSH client available inside the sandbox | Verified — provided by the `org.kde.Platform` runtime base, no packaging needed |
| `--filesystem=home` removed | Done — narrowed to `--filesystem=~/.ssh` (read-write, needed for `known_hosts` and SSH config) |
| SSH known-hosts trust persists with narrowed permissions | Verified in sandbox against real infrastructure |
| RDP works with zero filesystem permission | Verified — FreeRDP's cert trust store lives outside the sandboxed home path concerns entirely (see below) |
| Private-key/export file pickers use the desktop portal | **Verified interactively**`QFileDialog`'s Browse button correctly opens the native GTK portal chooser ("Select Private Key"), which can browse the full filesystem via user consent regardless of the sandbox's static `~/.ssh`-only grant |
| Current, supported KDE runtime | Verified — upgraded to `6.11` (linter's recommended latest); confirmed the app still builds and launches against it |
| Desktop entry validates | Verified |
| Application icon validates | Verified — PNGs at all standard hicolor sizes, matching the real app icon |
| MetaInfo/AppStream validates | Verified via both `appstreamcli validate` and Flathub's own `flatpak-builder-lint appstream` (0 errors either way) |
| Screenshots present | Done — profiles view, active SSH session, active RDP session, all captured against real (test) infrastructure |
| Release information present | Done — `<releases>` block with `v2026.9.8` (add an entry per future tagged release) |
| Developer/project URLs present | Done — homepage, bugtracker, vcs-browser, developer block |
| Architecture support decided | `x86_64` only (no ARM hardware available to test FreeRDP/WinPR on aarch64), set via `flathub.json`'s `only-arches` |
| Flathub manifest linter passes | One expected finding remains: `finish-args-ssh-filesystem-access` (see below) — everything else passes, including `only-arches` placement and runtime-version currency |
| AppStream linter passes | Passing (both `appstreamcli validate` and `flatpak-builder-lint appstream`) |
| Clean install works without host dependencies | Verified via local `.flatpak` bundle install and launch, on both KDE 6.10 and 6.11 runtimes |
| Bundled-dependency license files installed per Flathub's `$FLATPAK_ID` convention | Partially done — path fixed from `share/licenses/orbithub` to the required `share/licenses/org.darksingularity.OrbitHub`; FreeRDP's and KodoTerm's `LICENSE` files now installed there too. **`libvterm`'s vendored copy has no `LICENSE`/`COPYING` file at all** — needs to be pulled from upstream and added as `third_party/libvterm/LICENSE` before submission (README claims MIT; not verified against an actual license file in-tree) |
## ⚠️ Not yet addressed: Generative AI disclosure policy is a real acceptance risk, not a checklist item
See the dedicated section below — unlike everything else on this page, this
isn't something more packaging work resolves.
### `finish-args-ssh-filesystem-access` — expected, needs a submission-time justification
Flathub's linter flags *any* `~/.ssh` filesystem grant by policy — it's not a
bug in this manifest, it's a deliberate prompt for the submitter to justify
the access during PR review. Checked the linter's own exceptions list:
several existing SSH-client apps already have this exact permission approved
with justifications like *"Read-only access to ~/.ssh is required to load
SSH keys for connecting to devices over SSH"* and *"Needed to manage SSH keys
and configurations for connections"* — OrbitHub's case is the same pattern
(read-write, specifically for `known_hosts` persistence and default identity
file discovery). Include a similar justification in the submission PR.
## ⚠️ Not yet addressed: Generative AI disclosure policy
Flathub's [Generative AI policy](https://docs.flathub.org/docs/for-app-authors/requirements#generative-ai-policy)
requires submitters to disclose "any AI-generated code, documentation,
packaging, or other material" included in the app or its Flathub packaging,
identifying "the affected parts and approximate extent." This is not a
formality — it's evaluated at reviewer discretion, and reviewers may reject
"based on the extent or role of generated material."
OrbitHub's development has used Claude Code extensively — the app's C++
source, this Flatpak packaging (manifest, metainfo, build scripts), and this
tracking doc itself. Every commit in this repository carries a
`Co-Authored-By: Claude Sonnet 5` trailer, which is itself effectively an
existing disclosure trail. An honest submission disclosure needs to reflect
that extent truthfully — not a token "some AI assistance was used" note.
The same policy also prohibits AI tools from opening or automating the
submission PR itself, or generating its commit messages, description, or
review replies. **This means the actual submission PR — including its AI
disclosure — has to be written and opened by a human, not drafted by
Claude.** Not done, and not something this repo's tooling should attempt.
This is a real acceptance risk that no amount of technical packaging work
resolves — it's a policy/reviewer-discretion matter, separate from every
other item on this page.
## Related finding (not a packaging blocker)
During permission-narrowing research, RDP certificate verification was found
to be completely disabled (`IgnoreCertificate=TRUE`, all server certificates
silently accepted including *changed* ones). This has been fixed separately
in `src/rdp_session_backend.cpp` — FreeRDP's own trust-on-first-use
certificate store is now used, matching SSH's known-hosts model. Not a
Flathub-specific issue, but worth noting since it was found in the course of
this work.
## Explicitly out of scope for this repo
- Opening the actual submission PR against `github.com/flathub/flathub`
requires the maintainer's GitHub identity, done outside this repo, and per
the Generative AI policy above must be written by a human, not drafted here.
- ARM64 build/testing — no hardware available.
- Flathub's post-acceptance developer-verification step — done via
Flathub's own website after acceptance, using DNS control of
`darksingularity.org`.
## Files
- Dev manifest (local iteration, `type: dir`): `packaging/flatpak/org.darksingularity.OrbitHub.yml`
- Flathub submission manifest (pinned `type: git`): `packaging/flatpak/flathub/org.darksingularity.OrbitHub.yml`
- AppStream metainfo: `packaging/linux/org.darksingularity.OrbitHub.metainfo.xml`
- Desktop entry: `packaging/linux/org.darksingularity.OrbitHub.desktop`
+73
View File
@@ -77,3 +77,76 @@ OrbitHub uses a two-window model:
- Per-session timestamped event log and user-friendly error mapping - Per-session timestamped event log and user-friendly error mapping
- Profile schema extended with `private_key_path` and `known_hosts_policy` - Profile schema extended with `private_key_path` and `known_hosts_policy`
- Tag: v0-m3-done - Tag: v0-m3-done
---
## Milestone 4
- Interactive embedded SSH terminal (`KodoTerm` + `libvterm`)
- SSH host-key trust prompt flow for `Ask` policy
- Improved SSH auth flow for password / private key
- Terminal utilities and UX polish (theme, clear, resize/input behavior)
- Session lifecycle UX cleanup (auto-connect, disconnect on close, tab state indicators)
- Tag: v0-m4-done
---
## Milestone 5 - RDP Fully Working
- Implement complete RDP protocol support (replace not-implemented RDP path)
- Deliver a usable in-app RDP session experience consistent with SSH tab UX
- Support RDP connect/disconnect/reconnect lifecycle from OrbitHub
- Add required RDP-specific connect options in profile/session flows
- Normalize event/error reporting with existing SSH behavior
- Planned Tag: v0-m5-done
---
## Milestone 6 - VNC Fully Working
- Implement complete VNC protocol support (replace not-implemented VNC path)
- Deliver a usable in-app VNC session experience consistent with SSH/RDP tab UX
- Support VNC connect/disconnect/reconnect lifecycle from OrbitHub
- Add required VNC-specific connect options in profile/session flows
- Normalize event/error reporting with SSH/RDP behavior
- Planned Tag: v0-m6-done
---
## Milestone 7 - Cross-Platform Protocol Hardening
- Validate SSH/RDP/VNC behavior on Windows, Linux, and macOS
- Resolve platform-specific path/process/auth differences
- Improve diagnostics and failure messaging for common protocol issues
- Add protocol regression checklist and repeatable verification scripts
- Planned Tag: v0-m7-done
---
## Milestone 8 - Profile and Session UX Completion
- Complete profile fields/validation for all protocols
- Add quality-of-life controls for active sessions (tab context actions, defaults, persistence)
- Persist per-user UI/session preferences (theme, panel visibility, terminal defaults)
- Improve session history/event visibility and filtering
- Planned Tag: v0-m8-done
---
## Milestone 9 - Packaging and Distribution
- Produce distributable artifacts for Windows, Linux, and macOS
- Document dependency/runtime requirements per platform
- Add release build scripts for reproducible packaging
- Validate clean install + first-run flow on each platform
- Planned Tag: v0-m9-done
---
## Milestone 10 - v1.0 Stabilization
- End-to-end QA pass across core workflows (profile CRUD + SSH/RDP/VNC session lifecycle)
- Fix blocker/critical defects from validation
- Finalize docs (`BUILDING`, usage notes, known limitations)
- Prepare v1.0 release notes and final acceptance checklist
- Planned Tag: v1.0.0
+351
View File
@@ -60,3 +60,354 @@ Delivered:
Git: Git:
- Tag: `v0-m3-done` - Tag: `v0-m3-done`
## Milestone 4 - Interactive SSH Session UX
Status: Completed
Delivered:
- Embedded interactive SSH terminal using `KodoTerm` + vendored `libvterm`
- Native in-terminal typing for SSH sessions (no separate input box)
- ANSI/color rendering with selectable terminal themes (`Dark`, `Light`, `Solarized Dark`)
- Cross-platform SSH auth path improvements (`ssh-askpass` handling and host-key policy wiring)
- Session UX simplification: auto-connect on tab open, disconnect on tab close
- Tab-state indicators via tab color and state suffix (`Connecting`, `Connected`, `Disconnected`, `Failed`)
- Right-click tab menu for `Disconnect`, `Reconnect`, `Theme`, and `Clear`
- Collapsible events panel retained as primary diagnostics surface; inline detail/status banners removed
- Terminal behavior polish: better fixed-width font selection, cursor visibility, backspace handling, and terminal-size negotiation stability
Git:
- Tag: `v0-m4-done`
## Milestone 5 - RDP Fully Working
Status: Completed
Delivered:
- Added `RdpSessionBackend` and wired protocol selection so `RDP` no longer routes to unsupported backend
- Pivoted RDP design to embedded-only integration (no external RDP process launches)
- Implemented embedded FreeRDP client thread with connect/disconnect lifecycle and event-loop handling
- Added in-window `RdpDisplayWidget` rendering surface with frame updates from FreeRDP GDI
- Wired direct keyboard/mouse input from the embedded RDP surface to the backend
- Added RDP connect-time password prompt flow and settings wiring (host/port/user/password, desktop size)
- Added explicit profile `Domain` support for RDP auth (with `DOMAIN\username` fallback parsing)
- Updated session tab/context-menu behavior so terminal-only actions are hidden on RDP tabs
- Implemented dynamic in-session RDP resolution renegotiation from viewport resize events
- Enabled minimal FreeRDP client-channel build (`drdynvc` + `disp`) and channel loading for runtime resize support
- Added RDP profile-level security mode and performance profile options, wired into FreeRDP connection settings
- Hardened RDP lifecycle handling for disconnect/reconnect/abort flows to avoid false failure states on user-initiated stops
- Expanded RDP error/disconnect diagnostics with richer FreeRDP code mapping and raw disconnect detail events
- Pulled FreeRDP source for integration planning and API review
Git:
- Tag: `v0-m5-done`
## Milestone 6 - VNC Working (initial scope)
Status: Completed (initial scope; see gaps below)
Delivered:
- `VncSessionBackend`: an original RFB (RFC 6143) client implementation
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), so this is from-scratch protocol code,
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
- Full connect/disconnect/reconnect lifecycle, RFB protocol-version
negotiation (3.3/3.7/3.8 handshake differences handled explicitly),
VNC Authentication (DES challenge-response, using OpenSSL's classic DES
API) and no-auth security types, Raw + CopyRect framebuffer decoding,
keyboard (Qt key -> X11 keysym mapping) and mouse/wheel input forwarding
- `VncDisplayWidget` mirroring `RdpDisplayWidget`'s scale-to-fit rendering
and input-forwarding shape
- 45 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/Tight decoding including
a ZRLE zlib-stream-persistence test across two separate
`FramebufferUpdate` messages, a Tight stream-reset-flag test, cursor/
clipboard round trips, both Apple auth schemes) -- caught and fixed a
real re-entrancy bug (`abort()` synchronously re-firing `disconnected()`
mid-`failConnection()`, silently overwriting a specific error with a
generic one) and a real Apple-DH wire-order bug (see below)
- 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/Tight's
real-world decode path isn't independently confirmed live -- the unit
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
native pixel size inside a `QScrollArea` so text isn't shrunk, at the
cost of needing to scroll to see the whole screen. Reuses
`VncDisplayWidget::renderRect()`'s existing scale-to-fit math unchanged:
it degenerates to an exact 1:1 mapping once the widget's own bounds are
fixed to the remote's size, so no separate rendering path was needed.
Persisted across sessions like the terminal theme preference.
- Fixed a real, separate bug found during Apple-auth live testing:
`SessionTab::requestConnectOptions()` never prompted for a password on
VNC profiles at all (only SSH/RDP), so every VNC connection went out
with an empty password regardless of what the server needed. VNC now
gets its own prompt, with an empty password allowed through (unlike
RDP's hard requirement) since some VNC servers are no-auth and there's
no way to know that before the server's security-type negotiation
- Profiles can now leave the username blank for every protocol (issue
#21): SSH/RDP previously hard-required one at profile-save time; that
validation is gone, and `SessionTab` now asks for it at connect time
instead (reusing the existing password-prompt bar in unmasked mode),
same as it already does for a blank password. VNC's username is only
ever asked for if the server's negotiated auth method actually needs
one -- plain VNC Authentication and no-auth never do, only the two
Apple schemes (30/33) do -- which happens *mid-connection*, after the
backend has already picked a security type, not before connecting like
SSH/RDP. This needed a new async request/response pair on
`SessionBackend` (`usernameRequested()` / `provideUsername()`,
mirroring the existing SSH host-key-confirmation pattern):
`VncSessionBackend` pauses its state machine mid-parse (without
consuming the already-buffered response bytes) and emits the request,
resuming once `SessionTab` answers; cancelling fails the connection
cleanly rather than sending Apple auth a blank username. The value is
kept on the tab's in-memory profile copy for its lifetime, not written
back to the saved profile
- The blank-username relaxation above initially only covered
`ProfileDialog`'s own save-time validation; `ProfileRepository::
isProfileValid()` had the identical "username required for SSH/RDP"
check independently, called directly by `insertProfile()`/
`updateProfile()` -- which is exactly the path mRemoteNG import uses
(it builds `Profile` objects and inserts them directly, never going
through the dialog), so importing any SSH/RDP entry without a
username still failed outright until this second check was found and
removed too
- A third, independent username check was still live even after the two
above were removed: `SessionTab::validateProfileForConnect()` (run at
the very top of `connectSession()`/`reconnectSession()`, before
`requestConnectOptions()` ever gets a chance to run its async prompt)
had its own hard-fail "SSH/RDP username is required" `QMessageBox`,
so a blank-username profile still couldn't connect at all -- it just
told the user to go edit the profile instead of ever prompting inline.
Removed; connect-time prompting is now the only username gate for
SSH/RDP
- Even with the three checks above gone, a username entered at the
connect-time prompt still never actually reached SSH or RDP
authentication: `SshSessionBackend`/`RdpSessionBackend` are constructed
with their own `Profile` copy up front (moved to a worker thread) and
read `profile().username` directly, which never sees `SessionTab`'s
later edit to its own in-memory profile once the user answers the
prompt. `SessionConnectOptions` (which already carries `password` the
same way) gained a `username` field, populated by `SessionTab` from
its profile copy on every connect attempt; both backends now prefer
`options.username` over `profile().username` when building the actual
connect target/auth call. Covered by a new SSH regression test
(`tests/fixtures/fake_ssh.sh`'s `requireuser` host only accepts an
exact `prompted-user@requireuser` target, so the test fails unless the
option, not the stale profile copy, is actually used) -- RDP has no
equivalent fake-server test harness, so that side relies on mirroring
the already-tested `m_activeOptions.password` pattern exactly
- Issue #22: the inline username/password prompt bar used to just be a
plain `QWidget` with `setAutoFillBackground(true)` and no explicit
color, which meant it rendered in the same color as everything else
around it and was easy to miss -- especially on a tab that wasn't the
active one, where there was previously no indication anything needed
attention at all. Now uses a solid `QPalette::Highlight` fill with
`QPalette::HighlightedText` for the label (the OS theme's own
guaranteed-contrasting pair, so it stays correct under both light and
dark themes without a hardcoded color) plus a hand-drawn "?" badge
(not a themed `QStyle` icon, whose own colors are outside our control
and could land close in hue to the bar's background); a background
tab showing the prompt gets its title suffixed "(Needs input)" and its
tab-bar text colored distinctly from the four connection-state colors.
A first pass at the tab color (`#6a1b9a`) was reported unreadable in
dark mode -- its perceived luminance was well below the four existing
state colors -- and was replaced with `#ab47bc`, tuned to roughly
match their visibility
- Robustness fix: an unrecognized `FramebufferUpdate` rectangle encoding
used to abort the connection generically; `kAnnouncedEncodings` is now
the single source of truth for what `SetEncodings` announces and what
the rectangle-dispatch `switch` can decode, with a regression test
pinning that every announced encoding has a working case
- Clipboard sync (`ServerCutText`/`ClientCutText`, Latin-1 only -- no
Unicode extension) in both directions
- Remote cursor shape sync via RFB's Cursor pseudo-encoding, mirroring
`RdpDisplayWidget`'s cursor handling in `VncDisplayWidget`
- 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)
- Apple Screen Sharing authentication: two schemes, both undocumented by
Apple. Security type 30 (Diffie-Hellman + AES, `src/vnc_apple_dh_auth.h`)
is implemented and its wire format is confirmed correct against an
independent, authoritative source (`neatvnc`'s `rfb-proto.h` struct
definitions plus its full server-side verification code, cross-checked
field-by-field: generator/key-length framing, the
credentials-before-public-key send order -- an actual ordering bug
caught this way and fixed -- shared-secret derivation and padding, AES
key derivation, and the credential buffer layout all match exactly).
Security type 33 (RSA + AES, `src/vnc_apple_rsa_auth.h`) is also
implemented, sourced from the `asyncvnc` PyPI package, as a fallback.
Preference when both are offered: None > AppleDH(30) > AppleRSA(33) >
VNCAuth(2). Covered by a DH round-trip test, fake-server integration
tests for both schemes, a preference-order test, and a regression test
built from real bytes captured off an actual macOS server
Known gaps and open issues (see issue #3 for follow-up tracking):
- **Apple auth not yet confirmed working end-to-end against a real
server.** Live-tested against a macOS Tahoe (26.6.2) Screen Sharing
server with Screen Sharing correctly enabled and the connecting account
allowed: type 33 gets rejected by the server immediately after the
client's initial host-key request (before any credentials are even
sent), and type 30 -- despite matching the authoritative reference
byte-for-byte, verified via multiple independent diagnostic scripts --
still gets rejected with a generic "Authentication or authorization
failure" from the server. macOS Tahoe was released after this
assistant's knowledge cutoff, so there may be a protocol or permission-
model change specific to that OS version neither reference source
reflects; a `screensharingd` Console.app log from the moment of
rejection would be the next diagnostic step whenever this is picked
back up. Until this is resolved, treat both security types as
implemented-and-tested-in-isolation but **not verified to actually
authenticate against a real macOS server**
- 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)
- Packaging for the new `libjpeg-turbo` dependency: added to
`docs/BUILDING.md` (apt/brew/vcpkg) and the `.deb` control file's
`Depends:` (`libjpeg-turbo8`). The Windows Inno Setup script already
wildcards `*.dll` so no change was needed there, and macOS's
`macdeployqt`-based bundling picks up non-system dylibs automatically.
The Flatpak manifests (`packaging/flatpak/*.yml`) were left unchanged on
the assumption that `org.kde.Platform`/`Sdk` 6.11 already bundles
libjpeg-turbo (a standard Qt JPEG-plugin dependency) -- worth confirming
the next time a Flatpak build is actually run, since it wasn't
independently verified here
- 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 --
VNC has no equivalent of RDP's MS-RDPEDISP for that)
## Milestone 7 - Cross-Platform Protocol Hardening
Status: Completed
Delivered:
- Validated SSH and RDP workflows on Linux, macOS, and Windows 11 (VNC is out
of scope while Milestone 6 remains deferred)
- Windows: validated the full `docs/BUILDING.md` toolchain end-to-end
(Git/CMake/Ninja/VS Build Tools with the C++ workload/vcpkg for
Qt6-OpenSSL-zlib); documented a VS Build Tools installer gotcha where the
actual compiler is a "recommended", not "required", component of the
VCTools workload
- Fixed RDP keyboard input misreading punctuation keys on Linux (X11 keycode
numbering was being treated as a PC/AT scancode; now uses FreeRDP's
authoritative X11-keycode-to-scancode table)
- Added RDP clipboard sync (bidirectional, plain text) and RDP cursor/pointer
shape sync (resize handles, text I-beam, etc. instead of a static arrow)
- Fixed Tab/Shift+Tab being intercepted by local UI focus navigation instead
of reaching SSH/RDP sessions
- Fixed RDP key auto-repeat being dropped, so holding a key only ever sent a
single keystroke to the remote machine
- Windows-specific RDP fixes: a crash on every connect attempt (FreeRDP's
signal-handling critical section was never initialized) and a host
resolution failure on every connect, including literal IP addresses
(Winsock was never initialized via `WSAStartup`)
- Windows: automatic build-time deployment of Qt's platform/SQL-driver
plugins and their runtime DLL dependencies, and marked the executable as a
GUI (`WIN32`) app to remove a stray console window behind the UI
- Windows and macOS: proper native app icon embedding (Windows `.rc`/`.ico`
resource; macOS `.app` bundle with `.icns`), replacing the generic default
icon previously shown for the built executable/bundle
- macOS: fixed Edit/New Profile dialog form fields collapsing to `sizeHint`
width due to the platform-default `QFormLayout` field growth policy
Git:
- Tag: Pending user approval (`v0-m7-done`)
## Milestone 8 - Profile and Session UX Completion
Status: Completed
Delivered:
- Added profile `tags` field to storage + schema migration and profile editor UX
- Added profile `folder_path` field + nested folder/subfolder profile view mode
- Added profile tree context actions (`New Folder`, `New Connection`) and drag-to-folder profile moves with persistence
- Added `Help -> About OrbitHub` dialog with third-party library inventory and MIT/Apache-2.0 license links
- Extended profile search to include tags/folder path and added profile sort controls (`Name`, `Protocol`, `Host`)
- Persisted profile list UX preferences (`search text`, `view mode`, protocol/tag filters, `sort order`) across app restarts
- Added protocol-aware profile validation/normalization for SSH/RDP/VNC (repository + dialog)
- Improved profile form protocol UX hints and SSH private-key path validation
- Added session events filtering and tab-context actions (`Show/Hide Events`, `Copy Events`, `Clear Events`)
- Added session diagnostics QoL: severity quick-filter (`All/Warnings/Errors`) and `Export Events` action
- Persisted session UI defaults (`terminal theme`, `events panel visibility`) for new tabs/windows
- Added profile quick filters (`Protocol`, `Tag`) with persistence to speed profile browsing
Validation:
- Local build verification passed (`cmake --build build`)
- No automated tests are currently configured in CTest
Git:
- Tag: Pending user approval (`v0-m8-done`)
## Milestone 9 - Packaging and Distribution
Status: Completed
Delivered:
- Linux `.deb` (`packaging/linux/build-deb.sh`) and Flatpak (`packaging/flatpak/build-flatpak.sh`) packages, both verified installed and launched with working SSH/RDP
- Renamed the app's reverse-DNS identity from `io.orbithub.OrbitHub` to `org.darksingularity.OrbitHub` (desktop file, AppStream metainfo, Flatpak manifest, macOS bundle identifier) to reflect a domain actually owned by the project
- Fixed Linux taskbar/panel pin matching (`StartupWMClass`) so a pinned launcher merges with its running window instead of creating a duplicate entry
- Replaced the stale, mismatched hand-authored launcher SVG with PNG icons rendered directly from the app's own `createOrbitHubAppIcon()` at each hicolor theme size
- Windows installer via Inno Setup (`packaging/windows/orbithub.iss`, `packaging/windows/build-installer.ps1`), verified installed silently and launched cleanly with no crash events
- macOS `.dmg` via `cmake --install` + `macdeployqt` + `hdiutil` (`packaging/macos/build-dmg.sh`), verified installed and launched after fixing:
- a launch crash caused by `macdeployqt` invalidating the code signature (fixed with ad hoc re-signing)
- a missing-library crash caused by the Linux-only `$ORIGIN` rpath token and vendored dylibs installing outside the `.app` bundle (fixed with `APPLE`-specific `@executable_path`/`Contents/Frameworks` layout)
- the dmg showing the generic disk icon instead of the app icon
- README and `docs/BUILDING.md` Packaging sections documented for all three platforms
Validation:
- All three installers built, installed, and launched successfully with working SSH/RDP sessions
- Code signing is ad hoc only (no purchased Apple Developer ID or Windows code-signing certificate), so macOS Gatekeeper and Windows SmartScreen still show first-run warnings by design
Git:
- Tag: `v0-m9-done`
- Release: [v2026.9.8](https://git.darksingularity.org/DarkSingularity/orbithub/releases/tag/v2026.9.8) (`v2026.9.8` tag, installers for Windows/Linux/macOS)
- Release: [v2026.9.8.2](https://git.darksingularity.org/DarkSingularity/orbithub/releases/tag/v2026.9.8.2) — same-day patch fixing RDP TLS certificate verification (was fully disabled) and preparing Flatpak packaging for Flathub submission
- Release: [v2026.9.8.3](https://git.darksingularity.org/DarkSingularity/orbithub/releases/tag/v2026.9.8.3) — same-day patch adding an in-app User Guide and standalone User Guide PDF
- Release: [v2026.9.14](https://git.darksingularity.org/DarkSingularity/orbithub/releases/tag/v2026.9.14) — fixes distorted RDP text on HiDPI monitors and reduces RDP resize-related display glitches
- Release: [v2026.9.14.2](https://git.darksingularity.org/DarkSingularity/orbithub/releases/tag/v2026.9.14.2) — same-day patch fixing RDP display corruption (missing/misplaced taskbar) after resizing the session window (the client never resized its own display buffer for channel-driven RDP resizes)
- Release: [v2026.9.15](https://git.darksingularity.org/DarkSingularity/orbithub/releases/tag/v2026.9.15) — adds Import/Export for profile lists (File menu)
## Milestone 10 - v1.0 Stabilization
Status: Planned
Planned Scope:
- Run final regression and acceptance testing across all protocols
- Resolve release-blocking defects
- Finalize docs and publish v1.0 release notes/checklist
+328
View File
@@ -0,0 +1,328 @@
## Introduction
OrbitHub is a native desktop application for organizing connection profiles
and launching SSH, RDP, and VNC sessions from one place, in a single tabbed
window. It runs on Windows, Linux, and macOS.
This guide covers everyday use: creating and organizing profiles,
connecting over SSH, RDP, and VNC, managing active sessions, and what to do
when something goes wrong. It does not cover installation or building from
source — see the project's `README.md` and `docs/BUILDING.md` for that.
VNC support covers standard VNC Authentication and no-auth servers
(TigerVNC, x11vnc, TightVNC, and similar). macOS's built-in Screen Sharing
server uses two undocumented, reverse-engineered authentication schemes
that are implemented but not yet confirmed working end to end against a
real macOS server — see [Connecting via VNC](#connecting-via-vnc).
## Getting Started
When OrbitHub opens, you land on the **Profiles** tab — a searchable,
sortable list of every connection you've saved. Two toolbar controls in
the top-right change how that list is presented:
- **View**: `List` shows every profile in one flat table; `Folders` groups
them into the folder tree you've organized them into.
- **Sort**: orders the list by Name, Protocol, or Host.
Along the top of the window, a **Search** box filters by name, host,
folder path, or tag as you type, and **Protocol**/**Tag** dropdowns narrow
the list further. These filters, plus your last view mode and sort order,
are remembered the next time you open OrbitHub.
Along the bottom of the Profiles tab: **New**, **Edit**, and **Delete**
buttons for managing the selected profile. The same actions are available
by right-clicking a profile or folder, and from the **File** menu (`New
Profile`, `New Folder`).
To connect, double-click a profile, or select it and press the connect
control — this opens a new tab for that session and connects
automatically.
## Managing Profiles
A profile stores everything needed to reach one remote host. Open **New**
(or **Edit** on an existing profile) to fill in:
- **Name** — a label for the profile; shown on its tab and in the list.
- **Host** — hostname or IP address.
- **Port** — defaults to `22` for SSH, `3389` for RDP, `5900` for VNC.
- **Username** — the account to log in as. Optional for every protocol —
leave it blank and OrbitHub asks for one inline the first time you
connect that profile, the same way it already asks for a password. (VNC
servers mostly ignore username entirely; macOS Screen Sharing is the
exception and requires one — see
[Connecting via VNC](#connecting-via-vnc).)
- **Domain** — Windows domain for RDP logins (leave blank for local/
workgroup accounts, SSH, or VNC profiles).
- **Tags** — free-form, comma-separated labels for filtering and grouping
(e.g. `prod, linux, db`).
- **Folder** — where the profile lives in the Folders view.
- **Protocol** — `SSH`, `RDP`, or `VNC`.
Fields below Protocol change based on what you pick:
**SSH**: **Auth Mode** (`Password` or `Private Key`), and if Private Key,
a **Private Key** file path with a **Browse** button, plus **Known Hosts**
policy — see [Connecting via SSH](#connecting-via-ssh) for what each
policy means.
**RDP**: **RDP Security** and **RDP Performance** — see
[Connecting via RDP](#connecting-via-rdp).
**VNC** has no protocol-specific fields beyond Host/Port/Username above —
see [Connecting via VNC](#connecting-via-vnc).
Passwords are never saved in the profile — OrbitHub asks for them each
time you connect (unless you've set up private-key SSH auth, which needs
no password to be entered per-connection if the key itself has none).
## Organizing Profiles
As your profile list grows, two independent tools keep it manageable:
**Folders.** Switch the Profiles tab to `Folders` view to see profiles
grouped into a tree. Create a folder from the **File** menu or by
right-clicking in the tree (`New Folder`), and drag any profile onto a
folder to move it there. Folders can nest inside other folders.
**Tags.** Tags are independent of folders — a profile can be in one folder
but carry several tags (e.g. `prod`, `linux`, `db` all at once). Use the
**Tag** filter dropdown in the toolbar to instantly narrow the list to
everything sharing a tag, regardless of which folder it's filed under.
Combine both with the **Search** box (matches name, host, folder path, or
tags) and the **Sort** control (Name / Protocol / Host) to find what you
need quickly even with a large profile list.
## Importing and Exporting Profiles
The **File** menu has three options for moving profiles in or out of
OrbitHub:
- **Export Profiles...** — saves your entire profile list, including
folder structure and tags, to a JSON file. Passwords are never included,
since OrbitHub never stores them in the first place.
- **Import Profiles...** — reads that same JSON format back in, recreating
folders and profiles. Existing profiles aren't touched; entries missing
a name or host are skipped and reported in the summary.
- **Import from mRemoteNG...** — reads an mRemoteNG connections `.xml`
file and creates equivalent OrbitHub profiles. Passwords are never
imported — you'll be prompted the first time you connect each imported
profile, same as a new one. Connections using a protocol OrbitHub
doesn't support are skipped and listed in the import summary. This is a
one-way conversion; there's no export back to mRemoteNG's format.
## Connecting via SSH
Double-clicking an SSH profile opens a new tab with an embedded, fully
interactive terminal — type directly into it as you would any terminal
emulator. A **theme** selector lets you switch between `Dark`, `Light`,
and `Solarized Dark`; your choice is remembered for future sessions.
**Authentication.** Set in the profile itself:
- **Username** — if left blank in the profile, OrbitHub asks for one
inline the first time you connect, right alongside the password prompt.
- **Password** — OrbitHub prompts for a password each time you connect.
It is never stored.
- **Private Key** — point at a key file (via the profile's Browse button);
no password prompt unless the key itself is passphrase-protected.
**Known Hosts policy.** This controls how OrbitHub reacts to a server's
SSH host key — the mechanism that protects against a different machine
silently impersonating a host you've connected to before:
| Policy | Behavior |
|---|---|
| `Ask` | Prompts you to confirm trust the first time a host is seen, and on any later change. Recommended default. |
| `Accept New` | Silently trusts a host the first time it's seen, but still stops and warns if a previously-trusted host's key later changes. |
| `Strict` | Never trusts an unknown host automatically — the connection fails until you've manually confirmed the host key some other way. |
| `Ignore` | Skips host-key checking entirely. Only use this for throwaway/test environments — it removes protection against on-path attacks. |
Trusted host keys are recorded in your system's normal SSH `known_hosts`
file (the same one the `ssh` command line tool uses), so trust decisions
made through OrbitHub or a terminal `ssh` session carry over to each
other.
## Connecting via RDP
RDP sessions render in an embedded display surface inside the tab — no
external RDP client window opens. Keyboard and mouse input go straight to
the remote desktop while the tab has focus, and resizing the OrbitHub
window renegotiates the remote resolution to match. Clipboard content
syncs between your machine and the remote session automatically.
**Authentication** uses the profile's Username/Domain fields; OrbitHub
prompts for the password at connect time, and for a username too if the
profile's own Username field was left blank.
**RDP Security** controls which transport-security layer is used to
negotiate the connection:
| Mode | Behavior |
|---|---|
| `Negotiate` | Lets the client and server agree on the strongest mutually-supported option automatically. Recommended default. |
| `NLA` | Requires Network Level Authentication (credentials verified before a full session starts) — the modern standard for current Windows versions. |
| `TLS` | Requires TLS-only security, without NLA. |
| `RDP` | The legacy RDP-native security layer, for older servers that don't support TLS/NLA. |
**RDP Performance** trades visual fidelity for responsiveness:
`Balanced` (default), `Best Quality`, `Best Performance`, or
`Auto Detect` (adapts based on the detected connection).
**Server certificate verification.** The first time you connect to an RDP
host, OrbitHub trusts and remembers its TLS certificate — the same
trust-on-first-use model SSH uses for host keys. If that certificate ever
changes on a later connection, OrbitHub refuses the connection rather than
connecting anyway, since a changed certificate can mean either a
legitimate server certificate renewal or an active
machine-in-the-middle presenting a different one. The event log (see
below) shows the specific fingerprints involved. If the change is
expected — you rotated the server's certificate yourself — reconnecting
after clearing the old entry from FreeRDP's certificate store will trust
the new one.
## Connecting via VNC
VNC sessions render in an embedded display surface inside the tab, like
RDP — no external VNC client window opens.
**Authentication.** VNC servers don't have a fixed username/password
convention the way SSH and RDP do, so OrbitHub always prompts for a
password at connect time (leave it blank if the server doesn't require
one — no-auth servers exist and are supported). Most VNC servers ignore
username entirely; the one common exception is **macOS's built-in Screen
Sharing**, which does require one — see the Apple Screen Sharing note
below if you're connecting to a Mac.
**Display mode.** Right-click a VNC session's tab to choose:
- **Scale to Fit** (default) — the whole remote screen is scaled to fit
the tab, like RDP.
- **Actual Size (Scrollbars)** — renders at the remote's native pixel
size, with scrollbars for panning. Useful when scaling would make small
text illegible.
Your choice is remembered across sessions. Unlike RDP, VNC has no way to
renegotiate the remote screen's resolution to match your window size —
resizing the OrbitHub window changes how much of a scaled-down remote
screen you can see, not the remote resolution itself.
**Clipboard and cursor.** Clipboard content syncs between your machine and
the remote session in both directions, and the remote's cursor shape (not
just position) is mirrored locally when the server supports it.
**Encodings.** OrbitHub negotiates whichever of Raw, CopyRect, Hextile,
ZRLE, or Tight the server prefers — this is automatic and requires no
configuration; more efficient encodings (Tight, ZRLE) simply mean better
performance over slower links.
**Apple Screen Sharing (macOS).** macOS's built-in Screen Sharing server
doesn't use a standard, documented VNC authentication method — it uses two
undocumented schemes that OrbitHub implements based on reverse-engineering
work, but neither is yet confirmed to authenticate successfully against a
real macOS server. If you're connecting to a Mac's Screen Sharing and see
an authentication failure, this is the likely cause rather than a wrong
username or password; check
[docs/PROGRESS.md](https://git.darksingularity.org/DarkSingularity/orbithub/blob/main/docs/PROGRESS.md)
(Milestone 6) for the current status, or a VNC server you install yourself
on the Mac (e.g. TigerVNC) as a workaround in the meantime.
## Managing Sessions
Every open connection lives in its own tab in the same window, alongside
the Profiles tab. A tab's title and a colored state indicator show
whether it's connecting, connected, disconnected, or failed. Closing a
tab disconnects that session; opening a profile again starts a fresh one.
**When OrbitHub needs a username or password from you**, the tab shows a
highlighted prompt bar with a Connect/Cancel pair — this happens for a
blank-username SSH/RDP profile, any RDP or SSH password prompt, and any
VNC connection (VNC always asks for a password, even if blank). If that
prompt appears on a tab you're not currently viewing, the tab's title
gets a `(Needs input)` suffix and its color changes so it's easy to spot
among several open sessions.
Each session tab includes a collapsible **event log** beneath the
connection surface — a timestamped record of connection state changes,
warnings, and errors for that session. Controls above the log let you:
- **Show/Hide Events** — collapse the panel when you don't need it.
- **Filter** — a text box to search event text, and an `All` /
`Warnings` / `Errors` severity dropdown to narrow what's shown.
- **Export Events** — save the current session's full event log to a
file, useful when reporting a connection problem.
- **Clear Events** — empty the log for that tab.
## Settings & Preferences
OrbitHub remembers your preferences across restarts without any separate
settings screen — they're saved automatically as you use the app:
- Profile list: search text, view mode (List/Folders), Protocol/Tag
filters, and sort order.
- Session tabs: terminal theme choice, whether the events panel is shown
or hidden for new tabs, and VNC display mode (Scale to Fit / Actual
Size).
Profile data itself (names, hosts, tags, folder structure, and so on) is
stored in a local SQLite database — see the README for its exact path on
your platform. Passwords are never part of that stored data.
## Troubleshooting
**"Host could not be resolved"** — the hostname in the profile can't be
looked up by DNS. Check for typos, or try the host's IP address directly
to confirm whether it's a DNS problem or something else.
**SSH connection fails immediately, no prompt** — double-check the
profile's Port (default `22`) and that a firewall or network path isn't
blocking that port from your machine.
**RDP: "Authentication failed. Check username and password."** — confirm
Username and Domain are correct for the target server; some servers
require the domain to be set explicitly even for local accounts.
**RDP: "RDP security negotiation failed. Try a different RDP security
mode."** — the server doesn't support the security mode selected in the
profile. Try `Negotiate` first, or a more specific mode if you know what
the server requires.
**RDP: connection refused with a certificate-changed message** — see
[Connecting via RDP](#connecting-via-rdp) above; this is expected,
protective behavior, not a bug, whenever a previously-trusted server's
certificate is replaced.
**SSH: connection hangs at "Ask" waiting for host-key trust** — check
that policy's behavior under
[Connecting via SSH](#connecting-via-ssh); switching to `Accept New` avoids
the prompt for genuinely new hosts while still protecting against a later
key change.
**VNC: "Authentication or authorization failure"** — for most VNC
servers this means a wrong password. If you're connecting to **macOS
Screen Sharing** specifically, this is the expected result right now — see
the Apple Screen Sharing note under
[Connecting via VNC](#connecting-via-vnc).
**VNC: connection closes immediately with no prompt** — some servers
(including macOS Screen Sharing in some configurations) close the
connection outright rather than negotiating; confirm the server is
actually running and reachable on the profile's port (default `5900`),
and that no allowlist on the server side is blocking your account or
machine.
If none of this covers what you're seeing, a session's exported event log
(see Managing Sessions) is the most useful thing to include when asking
for help or filing an issue.
## About & Support
OrbitHub is open source under the MIT license. Source code, issue
tracking, and releases are hosted at
[git.darksingularity.org/DarkSingularity/orbithub](https://git.darksingularity.org/DarkSingularity/orbithub).
For a list of bundled third-party libraries and their licenses, see
**Help → About OrbitHub** inside the app.
To report a bug or request a feature, open an issue at
[git.darksingularity.org/DarkSingularity/orbithub/issues](https://git.darksingularity.org/DarkSingularity/orbithub/issues).
Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

+5
View File
@@ -0,0 +1,5 @@
<RCC>
<qresource prefix="/docs">
<file>USER_GUIDE.md</file>
</qresource>
</RCC>
+15
View File
@@ -0,0 +1,15 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
BUILD_DIR="${1:-$ROOT_DIR/build-doc-tool}"
OUTPUT_PATH="${2:-$ROOT_DIR/docs/USER_GUIDE.pdf}"
cmake -S "$ROOT_DIR/tools/user-guide-pdf" -B "$BUILD_DIR" -G Ninja
cmake --build "$BUILD_DIR"
QT_QPA_PLATFORM=offscreen "$BUILD_DIR/user-guide-pdf" \
"$ROOT_DIR/docs/USER_GUIDE.md" \
"$OUTPUT_PATH"
echo "Created $OUTPUT_PATH"
+26
View File
@@ -0,0 +1,26 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
MANIFEST="$ROOT_DIR/packaging/flatpak/org.darksingularity.OrbitHub.yml"
DIST_DIR="${1:-$ROOT_DIR/dist/flatpak}"
BUILD_DIR="$DIST_DIR/build"
REPO_DIR="$DIST_DIR/repo"
BUNDLE="$DIST_DIR/org.darksingularity.OrbitHub.flatpak"
if ! command -v flatpak-builder >/dev/null 2>&1; then
echo "flatpak-builder is required. Install it first:" >&2
echo " sudo apt-get install -y flatpak-builder" >&2
exit 1
fi
mkdir -p "$DIST_DIR"
flatpak-builder \
--force-clean \
--repo="$REPO_DIR" \
"$BUILD_DIR" \
"$MANIFEST"
flatpak build-bundle "$REPO_DIR" "$BUNDLE" org.darksingularity.OrbitHub
echo "Created $BUNDLE"
+3
View File
@@ -0,0 +1,3 @@
{
"only-arches": ["x86_64"]
}
@@ -0,0 +1,23 @@
app-id: org.darksingularity.OrbitHub
runtime: org.kde.Platform
runtime-version: "6.11"
sdk: org.kde.Sdk
command: orbithub
finish-args:
- --share=network
- --share=ipc
- --socket=fallback-x11
- --socket=wayland
- --device=dri
- --filesystem=~/.ssh
modules:
- name: orbithub
buildsystem: cmake-ninja
builddir: true
config-opts:
- -DCMAKE_BUILD_TYPE=Release
sources:
- type: git
url: https://git.darksingularity.org/DarkSingularity/orbithub.git
tag: v2026.9.15
commit: dffca3afef80b5a3ca4832e0b7f748775cb90328
@@ -0,0 +1,21 @@
app-id: org.darksingularity.OrbitHub
runtime: org.kde.Platform
runtime-version: "6.11"
sdk: org.kde.Sdk
command: orbithub
finish-args:
- --share=network
- --share=ipc
- --socket=fallback-x11
- --socket=wayland
- --device=dri
- --filesystem=~/.ssh
modules:
- name: orbithub
buildsystem: cmake-ninja
builddir: true
config-opts:
- -DCMAKE_BUILD_TYPE=Release
sources:
- type: dir
path: ../..
+74
View File
@@ -0,0 +1,74 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
BUILD_DIR="${1:-$ROOT_DIR/build}"
DIST_DIR="${2:-$ROOT_DIR/dist}"
STAGE_DIR="$DIST_DIR/deb-staging"
PKG_ROOT="$STAGE_DIR/orbithub"
if [[ ! -f "$BUILD_DIR/CMakeCache.txt" ]]; then
echo "Build directory not configured: $BUILD_DIR" >&2
echo "Run: cmake -S \"$ROOT_DIR\" -B \"$BUILD_DIR\" -G Ninja" >&2
exit 1
fi
mkdir -p "$DIST_DIR"
rm -rf "$STAGE_DIR"
mkdir -p "$PKG_ROOT/DEBIAN"
# Read VERSION only after the build (which reconfigures CMakeCache.txt if
# CMakeLists.txt changed since the build dir was last configured) --
# reading it beforehand risks packaging a stale version string.
cmake --build "$BUILD_DIR" -j
VERSION="$(sed -n 's/^CMAKE_PROJECT_VERSION:STATIC=//p' "$BUILD_DIR/CMakeCache.txt" | head -n1)"
ARCH="$(dpkg --print-architecture)"
if [[ -z "$VERSION" ]]; then
echo "Unable to determine project version from $BUILD_DIR/CMakeCache.txt" >&2
exit 1
fi
cmake --install "$BUILD_DIR" --prefix "$PKG_ROOT/usr"
cat > "$PKG_ROOT/DEBIAN/control" <<EOF
Package: orbithub
Version: ${VERSION}
Section: net
Priority: optional
Architecture: ${ARCH}
Maintainer: OrbitHub Maintainers <maintainers@orbithub.local>
Depends: libc6, libstdc++6, libqt6core6, libqt6gui6, libqt6widgets6, libqt6sql6, libssl3, zlib1g, libjpeg-turbo8, openssh-client
Description: OrbitHub remote session manager
OrbitHub is a native desktop application for managing connection profiles
and opening SSH and RDP sessions in a tabbed interface.
EOF
cat > "$PKG_ROOT/DEBIAN/postinst" <<'EOF'
#!/bin/sh
set -e
if command -v update-desktop-database >/dev/null 2>&1; then
update-desktop-database -q /usr/share/applications || true
fi
if command -v gtk-update-icon-cache >/dev/null 2>&1; then
gtk-update-icon-cache -q /usr/share/icons/hicolor || true
fi
EOF
chmod 0755 "$PKG_ROOT/DEBIAN/postinst"
cat > "$PKG_ROOT/DEBIAN/postrm" <<'EOF'
#!/bin/sh
set -e
if command -v update-desktop-database >/dev/null 2>&1; then
update-desktop-database -q /usr/share/applications || true
fi
if command -v gtk-update-icon-cache >/dev/null 2>&1; then
gtk-update-icon-cache -q /usr/share/icons/hicolor || true
fi
EOF
chmod 0755 "$PKG_ROOT/DEBIAN/postrm"
OUTPUT_DEB="$DIST_DIR/orbithub_${VERSION}_${ARCH}.deb"
fakeroot dpkg-deb --build "$PKG_ROOT" "$OUTPUT_DEB" >/dev/null
echo "Created $OUTPUT_DEB"
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 645 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 960 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

@@ -0,0 +1,12 @@
[Desktop Entry]
Type=Application
Version=1.0
Name=OrbitHub
GenericName=Remote Session Manager
Comment=Manage SSH and RDP sessions in one native app
Exec=orbithub
Icon=org.darksingularity.OrbitHub
Terminal=false
StartupWMClass=OrbitHub
Categories=Network;RemoteAccess;Utility;
StartupNotify=true
@@ -0,0 +1,71 @@
<?xml version="1.0" encoding="UTF-8"?>
<component type="desktop-application">
<id>org.darksingularity.OrbitHub</id>
<name>OrbitHub</name>
<summary>Unified remote session manager for SSH and RDP</summary>
<metadata_license>MIT</metadata_license>
<project_license>MIT</project_license>
<description>
<p>OrbitHub is a native desktop app for organizing connection profiles and launching SSH and RDP sessions in one tabbed interface.</p>
</description>
<launchable type="desktop-id">org.darksingularity.OrbitHub.desktop</launchable>
<url type="homepage">https://git.darksingularity.org/DarkSingularity/orbithub</url>
<url type="bugtracker">https://git.darksingularity.org/DarkSingularity/orbithub/issues</url>
<url type="vcs-browser">https://git.darksingularity.org/DarkSingularity/orbithub</url>
<developer id="org.darksingularity">
<name>DarkSingularity</name>
</developer>
<provides>
<binary>orbithub</binary>
</provides>
<content_rating type="oars-1.1" />
<screenshots>
<screenshot type="default">
<caption>Profiles organized into folders</caption>
<image>https://git.darksingularity.org/DarkSingularity/orbithub/raw/branch/main/docs/images/screenshot-profiles.png</image>
</screenshot>
<screenshot>
<caption>Active SSH terminal session</caption>
<image>https://git.darksingularity.org/DarkSingularity/orbithub/raw/branch/main/docs/images/screenshot-ssh-session.png</image>
</screenshot>
<screenshot>
<caption>Active RDP session</caption>
<image>https://git.darksingularity.org/DarkSingularity/orbithub/raw/branch/main/docs/images/screenshot-rdp-session.png</image>
</screenshot>
</screenshots>
<releases>
<release version="2026.9.15" date="2026-09-15">
<url>https://git.darksingularity.org/DarkSingularity/orbithub/releases/tag/v2026.9.15</url>
<description>
<p>Adds Import/Export for profile lists (File menu).</p>
</description>
</release>
<release version="2026.9.14.2" date="2026-09-14">
<url>https://git.darksingularity.org/DarkSingularity/orbithub/releases/tag/v2026.9.14.2</url>
<description>
<p>Fixes RDP display corruption (missing/misplaced taskbar, stale composited content) after resizing the session window.</p>
</description>
</release>
<release version="2026.9.14" date="2026-09-14">
<url>https://git.darksingularity.org/DarkSingularity/orbithub/releases/tag/v2026.9.14</url>
<description>
<p>Fixes distorted RDP text on HiDPI monitors, and reduces RDP resize-related display glitches.</p>
</description>
</release>
<release version="2026.9.8.3" date="2026-09-08">
<url>https://git.darksingularity.org/DarkSingularity/orbithub/releases/tag/v2026.9.8.3</url>
<description>
<p>Adds an in-app User Guide (Help -> User Guide) and a standalone User Guide PDF.</p>
</description>
</release>
<release version="2026.9.8.2" date="2026-09-08">
<url>https://git.darksingularity.org/DarkSingularity/orbithub/releases/tag/v2026.9.8.2</url>
<description>
<p>Fixes RDP connections silently accepting any server TLS certificate, including changed ones. Certificates are now verified with trust-on-first-use, matching SSH's known-hosts behavior.</p>
</description>
</release>
<release version="2026.9.8" date="2026-09-08">
<url>https://git.darksingularity.org/DarkSingularity/orbithub/releases/tag/v2026.9.8</url>
</release>
</releases>
</component>
+104
View File
@@ -0,0 +1,104 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
BUILD_DIR="${1:-$ROOT_DIR/build}"
DIST_DIR="${2:-$ROOT_DIR/dist/macos}"
STAGE_DIR="$DIST_DIR/dmg-staging"
INSTALL_PREFIX="$DIST_DIR/install"
APP_BUNDLE="OrbitHub.app"
if [[ ! -f "$BUILD_DIR/CMakeCache.txt" ]]; then
echo "Build directory not configured: $BUILD_DIR" >&2
echo "Run: cmake -S \"$ROOT_DIR\" -B \"$BUILD_DIR\" -G Ninja" >&2
exit 1
fi
MACDEPLOYQT="$(brew --prefix qt@6)/bin/macdeployqt"
if [[ ! -x "$MACDEPLOYQT" ]]; then
echo "macdeployqt not found at $MACDEPLOYQT" >&2
echo "Install it: brew install qt@6" >&2
exit 1
fi
mkdir -p "$DIST_DIR"
rm -rf "$STAGE_DIR" "$INSTALL_PREFIX"
mkdir -p "$STAGE_DIR"
# Read VERSION only after the build (which reconfigures CMakeCache.txt if
# CMakeLists.txt changed since the build dir was last configured) --
# reading it beforehand risks packaging a stale version string.
cmake --build "$BUILD_DIR" -j
VERSION="$(sed -n 's/^CMAKE_PROJECT_VERSION:STATIC=//p' "$BUILD_DIR/CMakeCache.txt" | head -n1)"
if [[ -z "$VERSION" ]]; then
echo "Unable to determine project version from $BUILD_DIR/CMakeCache.txt" >&2
exit 1
fi
cmake --install "$BUILD_DIR" --prefix "$INSTALL_PREFIX"
if [[ ! -d "$INSTALL_PREFIX/$APP_BUNDLE" ]]; then
echo "Expected app bundle not found: $INSTALL_PREFIX/$APP_BUNDLE" >&2
exit 1
fi
"$MACDEPLOYQT" "$INSTALL_PREFIX/$APP_BUNDLE"
# macdeployqt rewrites library load paths after bundling Qt, which
# invalidates any prior signature. Re-sign (ad hoc, since we have no
# Developer ID certificate) so the app passes macOS's code-integrity
# check at launch. Without this, launching fails with a generic
# "cannot be opened because of a problem" crash-reporter dialog rather
# than the milder unidentified-developer Gatekeeper prompt.
codesign --force --deep --sign - "$INSTALL_PREFIX/$APP_BUNDLE"
cp -R "$INSTALL_PREFIX/$APP_BUNDLE" "$STAGE_DIR/$APP_BUNDLE"
ln -s /Applications "$STAGE_DIR/Applications"
cp "$ROOT_DIR/packaging/macos/orbithub.icns" "$STAGE_DIR/.VolumeIcon.icns"
OUTPUT_DMG="$DIST_DIR/OrbitHub-${VERSION}.dmg"
RW_DMG="$DIST_DIR/OrbitHub-rw.dmg"
rm -f "$OUTPUT_DMG" "$RW_DMG"
hdiutil create -volname "OrbitHub" -srcfolder "$STAGE_DIR" -ov -format UDRW "$RW_DMG"
# Setting the volume's icon requires flipping a Finder attribute bit,
# which needs SetFile (from Xcode's "Additional Tools", not the base
# Command Line Tools) or the fileicon brew formula. Neither is
# guaranteed to be installed, so this step degrades gracefully: the
# dmg is still produced either way, just without a custom icon if no
# tool is available.
ICON_TOOL=""
if command -v SetFile >/dev/null 2>&1; then
ICON_TOOL="SetFile"
elif [[ -x /Library/Developer/CommandLineTools/usr/bin/SetFile ]]; then
ICON_TOOL="/Library/Developer/CommandLineTools/usr/bin/SetFile"
elif command -v fileicon >/dev/null 2>&1; then
ICON_TOOL="fileicon"
fi
if [[ -n "$ICON_TOOL" ]]; then
MOUNT_DIR="$(mktemp -d)"
hdiutil attach "$RW_DMG" -mountpoint "$MOUNT_DIR" -nobrowse -quiet
if [[ "$ICON_TOOL" == "fileicon" ]]; then
fileicon set "$MOUNT_DIR" "$ROOT_DIR/packaging/macos/orbithub.icns" >/dev/null
else
"$ICON_TOOL" -a V "$MOUNT_DIR/.VolumeIcon.icns"
"$ICON_TOOL" -a C "$MOUNT_DIR"
fi
hdiutil detach "$MOUNT_DIR" -quiet
rmdir "$MOUNT_DIR" 2>/dev/null || true
else
echo "Note: SetFile/fileicon not found, dmg will use the generic disk icon." >&2
echo " Install one to get a custom volume icon:" >&2
echo " brew install fileicon" >&2
echo " or download 'Additional Tools for Xcode' from developer.apple.com/download/all" >&2
fi
hdiutil convert "$RW_DMG" -format UDZO -ov -o "$OUTPUT_DMG"
rm -f "$RW_DMG"
echo "Created $OUTPUT_DMG"
Binary file not shown.
+36
View File
@@ -0,0 +1,36 @@
param(
[string]$BuildDir = "$PSScriptRoot\..\..\build",
[string]$DistDir = "$PSScriptRoot\..\..\dist\windows"
)
$ErrorActionPreference = "Stop"
$cacheFile = Join-Path $BuildDir "CMakeCache.txt"
if (-not (Test-Path $cacheFile)) {
throw "Build directory not configured: $BuildDir (run cmake -S . -B build first)"
}
$versionLine = Select-String -Path $cacheFile -Pattern '^CMAKE_PROJECT_VERSION:STATIC=(.*)$'
if (-not $versionLine) {
throw "Unable to determine project version from $cacheFile"
}
$version = $versionLine.Matches[0].Groups[1].Value
$isccCandidates = @(
"C:\Program Files (x86)\Inno Setup 6\ISCC.exe",
"C:\Program Files\Inno Setup 6\ISCC.exe",
"$env:LOCALAPPDATA\Programs\Inno Setup 6\ISCC.exe"
)
$iscc = $isccCandidates | Where-Object { Test-Path $_ } | Select-Object -First 1
if (-not $iscc) {
throw "Inno Setup compiler (ISCC.exe) not found. Install it: winget install -e --id JRSoftware.InnoSetup"
}
New-Item -ItemType Directory -Force -Path $DistDir | Out-Null
& $iscc "/DMyAppVersion=$version" "$PSScriptRoot\orbithub.iss"
if ($LASTEXITCODE -ne 0) {
throw "ISCC.exe failed with exit code $LASTEXITCODE"
}
Write-Output "Created $DistDir\OrbitHub-Setup-$version.exe"
Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

+46
View File
@@ -0,0 +1,46 @@
#ifndef MyAppVersion
#define MyAppVersion "0.0.0"
#endif
#define MyAppName "OrbitHub"
#define MyAppPublisher "OrbitHub Maintainers"
#define MyAppExeName "orbithub.exe"
#define MyBuildDir "..\..\build"
[Setup]
AppId={{6B2E0B8F-2C6D-4E7C-9C36-6C6C9C6F6C1A}
AppName={#MyAppName}
AppVersion={#MyAppVersion}
AppPublisher={#MyAppPublisher}
DefaultDirName={autopf}\{#MyAppName}
DefaultGroupName={#MyAppName}
UninstallDisplayIcon={app}\{#MyAppExeName}
OutputDir=..\..\dist\windows
OutputBaseFilename=OrbitHub-Setup-{#MyAppVersion}
Compression=lzma2
SolidCompression=yes
ArchitecturesAllowed=x64compatible
ArchitecturesInstallIn64BitMode=x64compatible
SetupIconFile=orbithub.ico
WizardStyle=modern
DisableProgramGroupPage=yes
[Languages]
Name: "english"; MessagesFile: "compiler:Default.isl"
[Tasks]
Name: "desktopicon"; Description: "Create a &desktop shortcut"; GroupDescription: "Additional icons:"; Flags: unchecked
[Files]
Source: "{#MyBuildDir}\{#MyAppExeName}"; DestDir: "{app}"; Flags: ignoreversion
Source: "{#MyBuildDir}\*.dll"; DestDir: "{app}"; Flags: ignoreversion
Source: "{#MyBuildDir}\platforms\*"; DestDir: "{app}\platforms"; Flags: ignoreversion recursesubdirs
Source: "{#MyBuildDir}\sqldrivers\*"; DestDir: "{app}\sqldrivers"; Flags: ignoreversion recursesubdirs
[Icons]
Name: "{group}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"
Name: "{group}\Uninstall {#MyAppName}"; Filename: "{uninstallexe}"
Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: desktopicon
[Run]
Filename: "{app}\{#MyAppExeName}"; Description: "Launch {#MyAppName}"; Flags: nowait postinstall skipifsilent
+1
View File
@@ -0,0 +1 @@
1 ICON "orbithub.ico"
+117
View File
@@ -0,0 +1,117 @@
#include "about_dialog.h"
#include <QApplication>
#include <QCoreApplication>
#include <QDialogButtonBox>
#include <QHBoxLayout>
#include <QLabel>
#include <QTextBrowser>
#include <QVBoxLayout>
namespace {
// palette(mid) is meant for 3D bevel/shadow decoration, not text — it has
// poor contrast against the window background in dark themes (barely
// legible). Blend the widget's actual text and window colors instead, so
// the result is reliably readable — de-emphasized relative to full-strength
// text, but never low-contrast — in either a light or dark theme.
QColor mutedTextColor(const QWidget* widget)
{
const QPalette pal = widget->palette();
const QColor text = pal.color(QPalette::WindowText);
const QColor background = pal.color(QPalette::Window);
constexpr qreal kTextWeight = 0.65;
auto blend = [kTextWeight](int textChannel, int backgroundChannel) {
return qBound(0,
qRound(textChannel * kTextWeight + backgroundChannel * (1.0 - kTextWeight)),
255);
};
return QColor(blend(text.red(), background.red()),
blend(text.green(), background.green()),
blend(text.blue(), background.blue()));
}
}
AboutDialog::AboutDialog(QWidget* parent) : QDialog(parent)
{
setWindowTitle(QStringLiteral("About OrbitHub"));
setWindowIcon(QApplication::windowIcon());
resize(760, 560);
auto* layout = new QVBoxLayout(this);
layout->setContentsMargins(16, 16, 16, 16);
layout->setSpacing(12);
auto* headerRow = new QHBoxLayout();
headerRow->setSpacing(12);
auto* iconLabel = new QLabel(this);
iconLabel->setFixedSize(80, 80);
iconLabel->setPixmap(QApplication::windowIcon().pixmap(80, 80));
iconLabel->setAlignment(Qt::AlignCenter);
auto* titleColumn = new QVBoxLayout();
titleColumn->setSpacing(4);
auto* title = new QLabel(QStringLiteral("<h1 style='margin:0'>OrbitHub</h1>"), this);
auto* subtitle = new QLabel(
QStringLiteral("Unified remote session manager for SSH, RDP, and VNC workflows."),
this);
subtitle->setWordWrap(true);
subtitle->setStyleSheet(QStringLiteral("color: %1;").arg(mutedTextColor(this).name()));
const QString version = QCoreApplication::applicationVersion().trimmed().isEmpty()
? QStringLiteral("Development build")
: QCoreApplication::applicationVersion().trimmed();
auto* buildLine = new QLabel(
QStringLiteral("Version: %1 | Qt runtime linked dynamically").arg(version),
this);
buildLine->setStyleSheet(QStringLiteral("color: %1;").arg(mutedTextColor(this).name()));
titleColumn->addWidget(title);
titleColumn->addWidget(subtitle);
titleColumn->addWidget(buildLine);
titleColumn->addStretch();
headerRow->addWidget(iconLabel, 0, Qt::AlignTop);
headerRow->addLayout(titleColumn, 1);
auto* browser = new QTextBrowser(this);
browser->setOpenExternalLinks(true);
browser->setStyleSheet(QStringLiteral(
"QTextBrowser { border: 1px solid palette(midlight); border-radius: 8px; padding: 8px; }"));
browser->setHtml(QStringLiteral(R"(
<h3 style="margin-top:0">Third-Party Libraries</h3>
<p>OrbitHub uses the following external libraries:</p>
<table cellspacing="0" cellpadding="6" border="1" style="border-collapse:collapse; width:100%;">
<tr><th align="left">Library</th><th align="left">License</th><th align="left">Upstream</th></tr>
<tr><td>Qt 6 (Widgets / SQL)</td><td>LGPLv3 / GPLv3 / Commercial</td><td><a href="https://www.qt.io/licensing">qt.io/licensing</a></td></tr>
<tr><td>KodoTerm</td><td>MIT</td><td><a href="https://github.com/diegoiast/KodoTerm">github.com/diegoiast/KodoTerm</a></td></tr>
<tr><td>libvterm</td><td>MIT</td><td><a href="https://github.com/neovim/libvterm">github.com/neovim/libvterm</a></td></tr>
<tr><td>FreeRDP / WinPR</td><td>Apache License 2.0</td><td><a href="https://github.com/FreeRDP/FreeRDP">github.com/FreeRDP/FreeRDP</a></td></tr>
</table>
<h3>License Links</h3>
<ul>
<li><a href="https://www.gnu.org/licenses/lgpl-3.0.html">GNU LGPLv3 (gnu.org)</a></li>
<li><a href="https://opensource.org/licenses/MIT">MIT License (opensource.org)</a></li>
<li><a href="https://www.apache.org/licenses/LICENSE-2.0">Apache License 2.0 (apache.org)</a></li>
</ul>
<h3>License Files In This Repository</h3>
<ul>
<li><code>third_party/KodoTerm/LICENSE</code></li>
<li><code>third_party/FreeRDP/LICENSE</code></li>
<li><code>LICENSE</code> (project license)</li>
</ul>
)"));
auto* buttons = new QDialogButtonBox(QDialogButtonBox::Close, this);
connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::reject);
connect(buttons, &QDialogButtonBox::accepted, this, &QDialog::accept);
layout->addLayout(headerRow);
layout->addWidget(browser, 1);
layout->addWidget(buttons);
}
+14
View File
@@ -0,0 +1,14 @@
#ifndef ORBITHUB_ABOUT_DIALOG_H
#define ORBITHUB_ABOUT_DIALOG_H
#include <QDialog>
class AboutDialog : public QDialog
{
Q_OBJECT
public:
explicit AboutDialog(QWidget* parent = nullptr);
};
#endif
+101
View File
@@ -0,0 +1,101 @@
#include "app_icon.h"
#include <QBrush>
#include <QColor>
#include <QLinearGradient>
#include <QPainter>
#include <QPainterPath>
#include <QPen>
#include <QPixmap>
namespace {
QPixmap renderIconPixmap(int size)
{
QPixmap pixmap(size, size);
pixmap.fill(Qt::transparent);
QPainter painter(&pixmap);
painter.setRenderHint(QPainter::Antialiasing, true);
const qreal s = static_cast<qreal>(size);
const QRectF badgeRect(0.06 * s, 0.06 * s, 0.88 * s, 0.88 * s);
const qreal badgeRadius = 0.19 * s;
QLinearGradient badgeGradient(badgeRect.topLeft(), badgeRect.bottomRight());
badgeGradient.setColorAt(0.0, QColor(QStringLiteral("#0B1220")));
badgeGradient.setColorAt(1.0, QColor(QStringLiteral("#111827")));
QPainterPath badgePath;
badgePath.addRoundedRect(badgeRect, badgeRadius, badgeRadius);
painter.fillPath(badgePath, badgeGradient);
const QPointF center(0.5 * s, 0.5 * s);
const QRectF orbitRect(0.14 * s, 0.26 * s, 0.72 * s, 0.44 * s);
// Draw orbit behind monitor first.
QPen orbitBackPen(QColor(QStringLiteral("#38BDF8")));
orbitBackPen.setWidthF(0.06 * s);
orbitBackPen.setCapStyle(Qt::RoundCap);
painter.setPen(orbitBackPen);
painter.setBrush(Qt::NoBrush);
QTransform orbitTransform;
orbitTransform.translate(center.x(), center.y());
orbitTransform.rotate(-20.0);
orbitTransform.translate(-center.x(), -center.y());
painter.setTransform(orbitTransform);
painter.drawEllipse(orbitRect);
painter.resetTransform();
const QRectF monitorRect(0.2 * s, 0.2 * s, 0.6 * s, 0.44 * s);
const QRectF screenRect(0.24 * s, 0.24 * s, 0.52 * s, 0.34 * s);
const QRectF standStemRect(0.46 * s, 0.64 * s, 0.08 * s, 0.1 * s);
const QRectF standBaseRect(0.34 * s, 0.74 * s, 0.32 * s, 0.08 * s);
QPainterPath monitorPath;
monitorPath.addRoundedRect(monitorRect, 0.07 * s, 0.07 * s);
painter.fillPath(monitorPath, QColor(QStringLiteral("#1F2937")));
painter.setPen(QPen(QColor(QStringLiteral("#4B5563")), 0.016 * s));
painter.drawPath(monitorPath);
QLinearGradient screenGradient(screenRect.topLeft(), screenRect.bottomRight());
screenGradient.setColorAt(0.0, QColor(QStringLiteral("#22D3EE")));
screenGradient.setColorAt(0.55, QColor(QStringLiteral("#38BDF8")));
screenGradient.setColorAt(1.0, QColor(QStringLiteral("#0EA5E9")));
painter.fillRect(screenRect, screenGradient);
painter.setPen(Qt::NoPen);
painter.setBrush(QColor(QStringLiteral("#9CA3AF")));
painter.drawRoundedRect(standStemRect, 0.02 * s, 0.02 * s);
painter.setBrush(QColor(QStringLiteral("#6B7280")));
painter.drawRoundedRect(standBaseRect, 0.03 * s, 0.03 * s);
// Orbit front segment over monitor for depth.
QPen orbitFrontPen(QColor(QStringLiteral("#A3E635")));
orbitFrontPen.setWidthF(0.06 * s);
orbitFrontPen.setCapStyle(Qt::RoundCap);
painter.setPen(orbitFrontPen);
painter.setBrush(Qt::NoBrush);
painter.setTransform(orbitTransform);
painter.drawArc(orbitRect, 212 * 16, 126 * 16);
painter.drawArc(orbitRect, 6 * 16, 24 * 16);
painter.resetTransform();
// Small indicator star.
painter.setPen(Qt::NoPen);
painter.setBrush(QColor(QStringLiteral("#E5E7EB")));
painter.drawEllipse(QPointF(0.77 * s, 0.2 * s), 0.02 * s, 0.02 * s);
return pixmap;
}
}
QIcon createOrbitHubAppIcon()
{
QIcon icon;
const int sizes[] = {16, 24, 32, 48, 64, 128, 256};
for (const int size : sizes) {
icon.addPixmap(renderIconPixmap(size));
}
return icon;
}
+8
View File
@@ -0,0 +1,8 @@
#ifndef ORBITHUB_APP_ICON_H
#define ORBITHUB_APP_ICON_H
#include <QIcon>
QIcon createOrbitHubAppIcon();
#endif
+12 -3
View File
@@ -1,12 +1,21 @@
#include "profiles_window.h" #include "app_icon.h"
#include "session_window.h"
#include <QApplication> #include <QApplication>
int main(int argc, char* argv[]) int main(int argc, char* argv[])
{ {
QApplication app(argc, argv); Q_INIT_RESOURCE(KodoTermThemes);
ProfilesWindow window; QApplication app(argc, argv);
app.setOrganizationName(QStringLiteral("FireBugIT"));
app.setApplicationName(QStringLiteral("OrbitHub"));
#ifdef ORBITHUB_VERSION_STRING
app.setApplicationVersion(QStringLiteral(ORBITHUB_VERSION_STRING));
#endif
app.setWindowIcon(createOrbitHubAppIcon());
SessionWindow window;
window.show(); window.show();
return app.exec(); return app.exec();
+152
View File
@@ -0,0 +1,152 @@
#include "mremoteng_importer.h"
#include <QStringList>
#include <QXmlStreamReader>
namespace {
QString joinFolderPath(const QStringList& parts)
{
QStringList trimmed;
for (const QString& part : parts) {
const QString t = part.trimmed();
if (!t.isEmpty()) {
trimmed.push_back(t);
}
}
return trimmed.join(QStringLiteral("/"));
}
// Maps mRemoteNG's Protocol attribute (an enum: RDP, VNC, SSH1, SSH2,
// Telnet, Rlogin, RAW, HTTP, HTTPS, PowerShell, IntApp, Winbox) onto what
// OrbitHub actually supports. *supported is set to false for anything
// OrbitHub can't yet connect to (VNC included -- see issue #3).
QString mappedProtocol(const QString& mRemoteNGProtocol, bool* supported)
{
const QString p = mRemoteNGProtocol.trimmed();
if (p.compare(QStringLiteral("RDP"), Qt::CaseInsensitive) == 0) {
*supported = true;
return QStringLiteral("RDP");
}
if (p.compare(QStringLiteral("SSH1"), Qt::CaseInsensitive) == 0
|| p.compare(QStringLiteral("SSH2"), Qt::CaseInsensitive) == 0) {
*supported = true;
return QStringLiteral("SSH");
}
*supported = false;
return p;
}
int defaultPortFor(const QString& orbitHubProtocol)
{
return orbitHubProtocol == QStringLiteral("RDP") ? 3389 : 22;
}
}
MRemoteNGImportResult parseMRemoteNGConnections(const QByteArray& xmlData)
{
MRemoteNGImportResult result;
QXmlStreamReader reader(xmlData);
bool foundRoot = false;
while (!reader.atEnd() && !foundRoot) {
if (reader.readNext() == QXmlStreamReader::StartElement) {
if (reader.name().compare(QStringLiteral("Connections"), Qt::CaseInsensitive) != 0) {
result.errorMessage = QStringLiteral(
"This doesn't look like an mRemoteNG connections file (expected a "
"<Connections> root element).");
return result;
}
foundRoot = true;
const auto attrs = reader.attributes();
if (attrs.value(QStringLiteral("FullFileEncryption"))
.compare(QStringLiteral("true"), Qt::CaseInsensitive)
== 0) {
result.errorMessage = QStringLiteral(
"This file has \"full file encryption\" enabled in mRemoteNG, which "
"encrypts the entire connection list with your master password. "
"OrbitHub has no way to read that. In mRemoteNG, re-export with full "
"file encryption turned off (only the saved passwords need stay "
"encrypted -- OrbitHub never imports those anyway).");
return result;
}
}
}
if (!foundRoot) {
result.errorMessage = QStringLiteral("Not a valid XML file, or the file is empty.");
return result;
}
QStringList folderStack;
std::vector<bool> openNodeIsContainer;
while (!reader.atEnd()) {
const QXmlStreamReader::TokenType token = reader.readNext();
if (token == QXmlStreamReader::StartElement
&& reader.name().compare(QStringLiteral("Node"), Qt::CaseInsensitive) == 0) {
const auto attrs = reader.attributes();
const QString type = attrs.value(QStringLiteral("Type")).toString();
const bool isContainer =
type.compare(QStringLiteral("Container"), Qt::CaseInsensitive) == 0;
const QString name = attrs.value(QStringLiteral("Name")).toString().trimmed();
if (isContainer) {
folderStack.push_back(name);
const QString path = joinFolderPath(folderStack);
if (!path.isEmpty()) {
result.folders.push_back(path);
}
} else {
bool supported = false;
const QString protocolLabel = attrs.value(QStringLiteral("Protocol")).toString();
const QString mappedProto = mappedProtocol(protocolLabel, &supported);
if (!supported) {
result.skippedUnsupportedProtocol.push_back(QStringLiteral("%1 (%2)").arg(
name.isEmpty() ? QStringLiteral("(unnamed)") : name,
protocolLabel.isEmpty() ? QStringLiteral("unknown") : protocolLabel));
} else {
MRemoteNGImportedProfile imported;
imported.profile.name = name;
imported.profile.host = attrs.value(QStringLiteral("Hostname")).toString().trimmed();
const QString portText = attrs.value(QStringLiteral("Port")).toString();
imported.profile.port =
portText.isEmpty() ? defaultPortFor(mappedProto) : portText.toInt();
if (imported.profile.port <= 0) {
imported.profile.port = defaultPortFor(mappedProto);
}
imported.profile.username = attrs.value(QStringLiteral("Username")).toString();
imported.profile.protocol = mappedProto;
imported.profile.authMode = QStringLiteral("Password");
if (mappedProto == QStringLiteral("RDP")) {
imported.profile.domain = attrs.value(QStringLiteral("Domain")).toString();
}
imported.folderPath = joinFolderPath(folderStack);
result.profiles.push_back(imported);
}
}
openNodeIsContainer.push_back(isContainer);
} else if (token == QXmlStreamReader::EndElement
&& reader.name().compare(QStringLiteral("Node"), Qt::CaseInsensitive) == 0) {
if (!openNodeIsContainer.empty()) {
if (openNodeIsContainer.back()) {
folderStack.removeLast();
}
openNodeIsContainer.pop_back();
}
}
}
if (reader.hasError()) {
result.errorMessage = QStringLiteral("Failed to parse XML: %1").arg(reader.errorString());
result.folders.clear();
result.profiles.clear();
result.skippedUnsupportedProtocol.clear();
}
return result;
}
+48
View File
@@ -0,0 +1,48 @@
#ifndef ORBITHUB_MREMOTENG_IMPORTER_H
#define ORBITHUB_MREMOTENG_IMPORTER_H
#include "profile_repository.h"
#include <QByteArray>
#include <QString>
#include <QStringList>
#include <vector>
// A profile parsed out of an mRemoteNG confCons.xml export, plus which
// folder (if any) it belongs to, expressed the same way ProfilesWindow's
// own folder paths are ("Parent/Child").
struct MRemoteNGImportedProfile
{
Profile profile;
QString folderPath;
};
struct MRemoteNGImportResult
{
// Non-empty only when parsing failed outright (malformed XML, or a
// FullFileEncryption="true" export -- that encrypts the whole node
// tree with the user's master password, which OrbitHub has no way to
// ask for or use, so those files can't be read at all here).
QString errorMessage;
// Every folder path that appeared, including ones with no directly
// imported profile in them (e.g. a container that held only
// unsupported-protocol connections).
std::vector<QString> folders;
std::vector<MRemoteNGImportedProfile> profiles;
// "<name> (<mRemoteNG protocol>)" for each connection whose protocol
// OrbitHub doesn't support (VNC, Telnet, HTTP, ...) -- skipped, never
// silently dropped.
QStringList skippedUnsupportedProtocol;
};
// Passwords are never read from the file, even for the common case where
// they're readable without a master password (mRemoteNG only encrypts the
// Password attribute itself, not the surrounding plaintext fields) --
// OrbitHub never persists credentials on a Profile in the first place.
MRemoteNGImportResult parseMRemoteNGConnections(const QByteArray& xmlData);
#endif
+144 -15
View File
@@ -3,6 +3,7 @@
#include <QComboBox> #include <QComboBox>
#include <QDialogButtonBox> #include <QDialogButtonBox>
#include <QFileDialog> #include <QFileDialog>
#include <QFileInfo>
#include <QFormLayout> #include <QFormLayout>
#include <QHBoxLayout> #include <QHBoxLayout>
#include <QLabel> #include <QLabel>
@@ -24,6 +25,28 @@ int standardPortForProtocol(const QString& protocol)
} }
return 22; // SSH default return 22; // SSH default
} }
QString normalizedProtocol(const QString& protocol)
{
if (protocol.compare(QStringLiteral("RDP"), Qt::CaseInsensitive) == 0) {
return QStringLiteral("RDP");
}
if (protocol.compare(QStringLiteral("VNC"), Qt::CaseInsensitive) == 0) {
return QStringLiteral("VNC");
}
return QStringLiteral("SSH");
}
QString normalizedAuthMode(const QString& protocol, const QString& authMode)
{
if (protocol != QStringLiteral("SSH")) {
return QStringLiteral("Password");
}
if (authMode.compare(QStringLiteral("Private Key"), Qt::CaseInsensitive) == 0) {
return QStringLiteral("Private Key");
}
return QStringLiteral("Password");
}
} }
ProfileDialog::ProfileDialog(QWidget* parent) ProfileDialog::ProfileDialog(QWidget* parent)
@@ -32,27 +55,44 @@ ProfileDialog::ProfileDialog(QWidget* parent)
m_hostInput(new QLineEdit(this)), m_hostInput(new QLineEdit(this)),
m_portInput(new QSpinBox(this)), m_portInput(new QSpinBox(this)),
m_usernameInput(new QLineEdit(this)), m_usernameInput(new QLineEdit(this)),
m_domainInput(new QLineEdit(this)),
m_tagsInput(new QLineEdit(this)),
m_protocolInput(new QComboBox(this)), m_protocolInput(new QComboBox(this)),
m_authModeInput(new QComboBox(this)), m_authModeInput(new QComboBox(this)),
m_privateKeyPathInput(new QLineEdit(this)), m_privateKeyPathInput(new QLineEdit(this)),
m_browsePrivateKeyButton(new QPushButton(QStringLiteral("Browse"), this)), m_browsePrivateKeyButton(new QPushButton(QStringLiteral("Browse"), this)),
m_knownHostsPolicyInput(new QComboBox(this)) m_knownHostsPolicyInput(new QComboBox(this)),
m_rdpSecurityModeInput(new QComboBox(this)),
m_rdpPerformanceProfileInput(new QComboBox(this)),
m_protocolHint(new QLabel(this)),
m_folderHint(new QLabel(this))
{ {
resize(520, 340); resize(560, 360);
auto* layout = new QVBoxLayout(this); auto* layout = new QVBoxLayout(this);
auto* form = new QFormLayout(); auto* form = new QFormLayout();
form->setFieldGrowthPolicy(QFormLayout::ExpandingFieldsGrow);
form->setFormAlignment(Qt::AlignLeft | Qt::AlignTop);
form->setLabelAlignment(Qt::AlignRight);
m_nameInput->setPlaceholderText(QStringLiteral("Production Bastion")); m_nameInput->setPlaceholderText(QStringLiteral("Production Bastion"));
m_hostInput->setPlaceholderText(QStringLiteral("example.internal")); m_hostInput->setPlaceholderText(QStringLiteral("example.internal"));
m_portInput->setRange(1, 65535); m_portInput->setRange(1, 65535);
m_portInput->setValue(22); m_portInput->setValue(22);
m_usernameInput->setPlaceholderText(QStringLiteral("deploy")); m_usernameInput->setPlaceholderText(QStringLiteral("deploy"));
m_domainInput->setPlaceholderText(QStringLiteral("CONTOSO"));
m_tagsInput->setPlaceholderText(QStringLiteral("prod, linux, db"));
m_protocolInput->addItems({QStringLiteral("SSH"), QStringLiteral("RDP"), QStringLiteral("VNC")}); m_protocolInput->addItems({QStringLiteral("SSH"), QStringLiteral("RDP"), QStringLiteral("VNC")});
m_authModeInput->addItems({QStringLiteral("Password"), QStringLiteral("Private Key")}); m_authModeInput->addItems({QStringLiteral("Password"), QStringLiteral("Private Key")});
m_knownHostsPolicyInput->addItems( m_knownHostsPolicyInput->addItems(
{QStringLiteral("Strict"), QStringLiteral("Accept New"), QStringLiteral("Ignore")}); {QStringLiteral("Ask"), QStringLiteral("Strict"), QStringLiteral("Accept New"), QStringLiteral("Ignore")});
m_rdpSecurityModeInput->addItems(
{QStringLiteral("Negotiate"), QStringLiteral("NLA"), QStringLiteral("TLS"), QStringLiteral("RDP")});
m_rdpPerformanceProfileInput->addItems({QStringLiteral("Balanced"),
QStringLiteral("Best Quality"),
QStringLiteral("Best Performance"),
QStringLiteral("Auto Detect")});
m_privateKeyPathInput->setPlaceholderText(QStringLiteral("/home/user/.ssh/id_ed25519")); m_privateKeyPathInput->setPlaceholderText(QStringLiteral("/home/user/.ssh/id_ed25519"));
@@ -80,6 +120,10 @@ ProfileDialog::ProfileDialog(QWidget* parent)
this, this,
[this](const QString& protocol) { [this](const QString& protocol) {
m_portInput->setValue(standardPortForProtocol(protocol)); m_portInput->setValue(standardPortForProtocol(protocol));
if (protocol != QStringLiteral("SSH")) {
const QSignalBlocker blocker(m_authModeInput);
m_authModeInput->setCurrentText(QStringLiteral("Password"));
}
refreshAuthFields(); refreshAuthFields();
}); });
@@ -92,21 +136,29 @@ ProfileDialog::ProfileDialog(QWidget* parent)
form->addRow(QStringLiteral("Host"), m_hostInput); form->addRow(QStringLiteral("Host"), m_hostInput);
form->addRow(QStringLiteral("Port"), m_portInput); form->addRow(QStringLiteral("Port"), m_portInput);
form->addRow(QStringLiteral("Username"), m_usernameInput); form->addRow(QStringLiteral("Username"), m_usernameInput);
form->addRow(QStringLiteral("Domain"), m_domainInput);
form->addRow(QStringLiteral("Tags"), m_tagsInput);
form->addRow(QStringLiteral("Protocol"), m_protocolInput); form->addRow(QStringLiteral("Protocol"), m_protocolInput);
form->addRow(QStringLiteral("Auth Mode"), m_authModeInput); form->addRow(QStringLiteral("Auth Mode"), m_authModeInput);
form->addRow(QStringLiteral("Private Key"), privateKeyRow); form->addRow(QStringLiteral("Private Key"), privateKeyRow);
form->addRow(QStringLiteral("Known Hosts"), m_knownHostsPolicyInput); form->addRow(QStringLiteral("Known Hosts"), m_knownHostsPolicyInput);
form->addRow(QStringLiteral("RDP Security"), m_rdpSecurityModeInput);
form->addRow(QStringLiteral("RDP Performance"), m_rdpPerformanceProfileInput);
auto* note = new QLabel( auto* note = new QLabel(
QStringLiteral("Passwords are requested at connect time and are not stored."), QStringLiteral("Passwords are requested at connect time and are not stored."),
this); this);
note->setWordWrap(true); note->setWordWrap(true);
m_protocolHint->setWordWrap(true);
m_folderHint->setWordWrap(true);
auto* buttons = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this); auto* buttons = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this);
connect(buttons, &QDialogButtonBox::accepted, this, &QDialog::accept); connect(buttons, &QDialogButtonBox::accepted, this, &QDialog::accept);
connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::reject); connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::reject);
layout->addLayout(form); layout->addLayout(form);
layout->addWidget(m_protocolHint);
layout->addWidget(m_folderHint);
layout->addWidget(note); layout->addWidget(note);
layout->addWidget(buttons); layout->addWidget(buttons);
@@ -118,12 +170,21 @@ void ProfileDialog::setDialogTitle(const QString& title)
setWindowTitle(title); setWindowTitle(title);
} }
void ProfileDialog::setDefaultFolderPath(const QString& folderPath)
{
m_defaultFolderPath = folderPath.trimmed();
refreshAuthFields();
}
void ProfileDialog::setProfile(const Profile& profile) void ProfileDialog::setProfile(const Profile& profile)
{ {
m_nameInput->setText(profile.name); m_nameInput->setText(profile.name);
m_hostInput->setText(profile.host); m_hostInput->setText(profile.host);
m_portInput->setValue(profile.port > 0 ? profile.port : 22); m_portInput->setValue(profile.port > 0 ? profile.port : 22);
m_usernameInput->setText(profile.username); m_usernameInput->setText(profile.username);
m_domainInput->setText(profile.domain);
m_defaultFolderPath = profile.folderPath.trimmed();
m_tagsInput->setText(profile.tags);
m_privateKeyPathInput->setText(profile.privateKeyPath); m_privateKeyPathInput->setText(profile.privateKeyPath);
const int protocolIndex = m_protocolInput->findText(profile.protocol); const int protocolIndex = m_protocolInput->findText(profile.protocol);
@@ -138,6 +199,12 @@ void ProfileDialog::setProfile(const Profile& profile)
const int knownHostsIndex = m_knownHostsPolicyInput->findText(profile.knownHostsPolicy); const int knownHostsIndex = m_knownHostsPolicyInput->findText(profile.knownHostsPolicy);
m_knownHostsPolicyInput->setCurrentIndex(knownHostsIndex >= 0 ? knownHostsIndex : 0); m_knownHostsPolicyInput->setCurrentIndex(knownHostsIndex >= 0 ? knownHostsIndex : 0);
const int securityModeIndex = m_rdpSecurityModeInput->findText(profile.rdpSecurityMode);
m_rdpSecurityModeInput->setCurrentIndex(securityModeIndex >= 0 ? securityModeIndex : 0);
const int performanceProfileIndex =
m_rdpPerformanceProfileInput->findText(profile.rdpPerformanceProfile);
m_rdpPerformanceProfileInput->setCurrentIndex(performanceProfileIndex >= 0 ? performanceProfileIndex
: 0);
refreshAuthFields(); refreshAuthFields();
} }
@@ -145,15 +212,31 @@ void ProfileDialog::setProfile(const Profile& profile)
Profile ProfileDialog::profile() const Profile ProfileDialog::profile() const
{ {
Profile profile; Profile profile;
const QString protocol = normalizedProtocol(m_protocolInput->currentText());
const QString authMode = normalizedAuthMode(protocol, m_authModeInput->currentText());
profile.id = -1; profile.id = -1;
profile.name = m_nameInput->text().trimmed(); profile.name = m_nameInput->text().trimmed();
profile.host = m_hostInput->text().trimmed(); profile.host = m_hostInput->text().trimmed();
profile.port = m_portInput->value(); profile.port = m_portInput->value();
profile.username = m_usernameInput->text().trimmed(); profile.username = m_usernameInput->text().trimmed();
profile.protocol = m_protocolInput->currentText(); profile.domain = protocol == QStringLiteral("RDP") ? m_domainInput->text().trimmed() : QString();
profile.authMode = m_authModeInput->currentText(); profile.folderPath = m_defaultFolderPath.trimmed();
profile.privateKeyPath = m_privateKeyPathInput->text().trimmed(); profile.tags = m_tagsInput->text().trimmed();
profile.knownHostsPolicy = m_knownHostsPolicyInput->currentText(); profile.protocol = protocol;
profile.authMode = authMode;
profile.privateKeyPath = (protocol == QStringLiteral("SSH")
&& authMode == QStringLiteral("Private Key"))
? m_privateKeyPathInput->text().trimmed()
: QString();
profile.knownHostsPolicy = protocol == QStringLiteral("SSH") ? m_knownHostsPolicyInput->currentText()
: QStringLiteral("Ask");
profile.rdpSecurityMode = protocol == QStringLiteral("RDP")
? m_rdpSecurityModeInput->currentText()
: QStringLiteral("Negotiate");
profile.rdpPerformanceProfile = protocol == QStringLiteral("RDP")
? m_rdpPerformanceProfileInput->currentText()
: QStringLiteral("Balanced");
return profile; return profile;
} }
@@ -173,12 +256,24 @@ void ProfileDialog::accept()
return; return;
} }
if (m_protocolInput->currentText() == QStringLiteral("SSH") const QString protocol = m_protocolInput->currentText();
&& m_usernameInput->text().trimmed().isEmpty()) {
QMessageBox::warning(this, if (protocol == QStringLiteral("SSH")
QStringLiteral("Validation Error"), && m_authModeInput->currentText() == QStringLiteral("Private Key")) {
QStringLiteral("Username is required for SSH profiles.")); const QString privateKeyPath = m_privateKeyPathInput->text().trimmed();
return; if (privateKeyPath.isEmpty()) {
QMessageBox::warning(this,
QStringLiteral("Validation Error"),
QStringLiteral("Private key path is required for SSH private key authentication."));
return;
}
if (!QFileInfo::exists(privateKeyPath)) {
QMessageBox::warning(this,
QStringLiteral("Validation Error"),
QStringLiteral("Private key file does not exist: %1").arg(privateKeyPath));
return;
}
} }
QDialog::accept(); QDialog::accept();
@@ -186,11 +281,45 @@ void ProfileDialog::accept()
void ProfileDialog::refreshAuthFields() void ProfileDialog::refreshAuthFields()
{ {
const bool isSsh = m_protocolInput->currentText() == QStringLiteral("SSH"); const QString protocol = normalizedProtocol(m_protocolInput->currentText());
const bool isPrivateKey = m_authModeInput->currentText() == QStringLiteral("Private Key"); const bool isSsh = protocol == QStringLiteral("SSH");
const bool isRdp = protocol == QStringLiteral("RDP");
const bool isVnc = protocol == QStringLiteral("VNC");
const QString normalizedMode = normalizedAuthMode(protocol, m_authModeInput->currentText());
if (normalizedMode != m_authModeInput->currentText()) {
const QSignalBlocker blocker(m_authModeInput);
m_authModeInput->setCurrentText(normalizedMode);
}
const bool isPrivateKey = normalizedMode == QStringLiteral("Private Key");
m_authModeInput->setEnabled(isSsh); m_authModeInput->setEnabled(isSsh);
m_privateKeyPathInput->setEnabled(isSsh && isPrivateKey); m_privateKeyPathInput->setEnabled(isSsh && isPrivateKey);
m_browsePrivateKeyButton->setEnabled(isSsh && isPrivateKey); m_browsePrivateKeyButton->setEnabled(isSsh && isPrivateKey);
m_knownHostsPolicyInput->setEnabled(isSsh); m_knownHostsPolicyInput->setEnabled(isSsh);
m_domainInput->setEnabled(isRdp);
m_rdpSecurityModeInput->setEnabled(isRdp);
m_rdpPerformanceProfileInput->setEnabled(isRdp);
if (isSsh) {
m_usernameInput->setPlaceholderText(QStringLiteral("deploy"));
m_protocolHint->setText(QStringLiteral(
"SSH: you'll be asked for a username at connect time if left blank here. "
"Choose Password or Private Key auth."));
} else if (isRdp) {
m_usernameInput->setPlaceholderText(QStringLiteral("Administrator"));
m_protocolHint->setText(QStringLiteral(
"RDP: you'll be asked for a username at connect time if left blank here. "
"Domain is optional."));
} else if (isVnc) {
m_usernameInput->setPlaceholderText(QStringLiteral("optional"));
m_protocolHint->setText(QStringLiteral(
"VNC: host and port are required. Username is optional and ignored by most "
"servers, except macOS's built-in Screen Sharing, which requires it. Domain is "
"unused."));
}
m_folderHint->setText(m_defaultFolderPath.isEmpty()
? QStringLiteral("Target folder: root")
: QStringLiteral("Target folder: %1").arg(m_defaultFolderPath));
} }
+9
View File
@@ -6,6 +6,7 @@
#include <QDialog> #include <QDialog>
class QComboBox; class QComboBox;
class QLabel;
class QLineEdit; class QLineEdit;
class QPushButton; class QPushButton;
class QSpinBox; class QSpinBox;
@@ -18,6 +19,7 @@ public:
explicit ProfileDialog(QWidget* parent = nullptr); explicit ProfileDialog(QWidget* parent = nullptr);
void setDialogTitle(const QString& title); void setDialogTitle(const QString& title);
void setDefaultFolderPath(const QString& folderPath);
void setProfile(const Profile& profile); void setProfile(const Profile& profile);
Profile profile() const; Profile profile() const;
@@ -29,11 +31,18 @@ private:
QLineEdit* m_hostInput; QLineEdit* m_hostInput;
QSpinBox* m_portInput; QSpinBox* m_portInput;
QLineEdit* m_usernameInput; QLineEdit* m_usernameInput;
QLineEdit* m_domainInput;
QLineEdit* m_tagsInput;
QComboBox* m_protocolInput; QComboBox* m_protocolInput;
QComboBox* m_authModeInput; QComboBox* m_authModeInput;
QLineEdit* m_privateKeyPathInput; QLineEdit* m_privateKeyPathInput;
QPushButton* m_browsePrivateKeyButton; QPushButton* m_browsePrivateKeyButton;
QComboBox* m_knownHostsPolicyInput; QComboBox* m_knownHostsPolicyInput;
QComboBox* m_rdpSecurityModeInput;
QComboBox* m_rdpPerformanceProfileInput;
QLabel* m_protocolHint;
QLabel* m_folderHint;
QString m_defaultFolderPath;
void refreshAuthFields(); void refreshAuthFields();
}; };
+441 -38
View File
@@ -7,8 +7,13 @@
#include <QSqlQuery> #include <QSqlQuery>
#include <QStandardPaths> #include <QStandardPaths>
#include <QVariant> #include <QVariant>
#include <QStringList>
#include <atomic>
namespace { namespace {
std::atomic<int> g_testConnectionCounter{0};
QString buildDatabasePath() QString buildDatabasePath()
{ {
QString appDataPath = QStandardPaths::writableLocation(QStandardPaths::AppDataLocation); QString appDataPath = QStandardPaths::writableLocation(QStandardPaths::AppDataLocation);
@@ -22,18 +27,160 @@ QString buildDatabasePath()
return dataDir.filePath(QStringLiteral("orbithub_profiles.sqlite")); return dataDir.filePath(QStringLiteral("orbithub_profiles.sqlite"));
} }
QString normalizedRdpSecurityMode(const QString& value)
{
const QString mode = value.trimmed();
if (mode.compare(QStringLiteral("NLA"), Qt::CaseInsensitive) == 0) {
return QStringLiteral("NLA");
}
if (mode.compare(QStringLiteral("TLS"), Qt::CaseInsensitive) == 0) {
return QStringLiteral("TLS");
}
if (mode.compare(QStringLiteral("RDP"), Qt::CaseInsensitive) == 0) {
return QStringLiteral("RDP");
}
return QStringLiteral("Negotiate");
}
QString normalizedRdpPerformanceProfile(const QString& value)
{
const QString profile = value.trimmed();
if (profile.compare(QStringLiteral("Best Quality"), Qt::CaseInsensitive) == 0) {
return QStringLiteral("Best Quality");
}
if (profile.compare(QStringLiteral("Best Performance"), Qt::CaseInsensitive) == 0) {
return QStringLiteral("Best Performance");
}
if (profile.compare(QStringLiteral("Auto Detect"), Qt::CaseInsensitive) == 0) {
return QStringLiteral("Auto Detect");
}
return QStringLiteral("Balanced");
}
QString normalizedProtocol(const QString& value)
{
const QString protocol = value.trimmed();
if (protocol.compare(QStringLiteral("RDP"), Qt::CaseInsensitive) == 0) {
return QStringLiteral("RDP");
}
if (protocol.compare(QStringLiteral("VNC"), Qt::CaseInsensitive) == 0) {
return QStringLiteral("VNC");
}
return QStringLiteral("SSH");
}
QString normalizedAuthMode(const QString& protocol, const QString& value)
{
if (protocol != QStringLiteral("SSH")) {
return QStringLiteral("Password");
}
const QString authMode = value.trimmed();
if (authMode.compare(QStringLiteral("Private Key"), Qt::CaseInsensitive) == 0) {
return QStringLiteral("Private Key");
}
return QStringLiteral("Password");
}
QString normalizedKnownHostsPolicy(const QString& value)
{
const QString policy = value.trimmed();
if (policy.compare(QStringLiteral("Strict"), Qt::CaseInsensitive) == 0) {
return QStringLiteral("Strict");
}
if (policy.compare(QStringLiteral("Accept New"), Qt::CaseInsensitive) == 0) {
return QStringLiteral("Accept New");
}
if (policy.compare(QStringLiteral("Ignore"), Qt::CaseInsensitive) == 0) {
return QStringLiteral("Ignore");
}
return QStringLiteral("Ask");
}
QString normalizedFolderPath(const QString& value)
{
QString path = value.trimmed();
path.replace(QChar::fromLatin1('\\'), QChar::fromLatin1('/'));
const QStringList rawParts = path.split(QChar::fromLatin1('/'), Qt::SkipEmptyParts);
QStringList normalized;
for (const QString& rawPart : rawParts) {
const QString part = rawPart.trimmed();
if (!part.isEmpty()) {
normalized.push_back(part);
}
}
return normalized.join(QStringLiteral("/"));
}
QString normalizedTags(const QString& value)
{
const QStringList rawTokens = value.split(QChar::fromLatin1(','), Qt::SkipEmptyParts);
QStringList normalized;
QSet<QString> dedupe;
for (const QString& token : rawTokens) {
const QString trimmed = token.trimmed();
if (trimmed.isEmpty()) {
continue;
}
const QString key = trimmed.toLower();
if (dedupe.contains(key)) {
continue;
}
dedupe.insert(key);
normalized.push_back(trimmed);
}
return normalized.join(QStringLiteral(", "));
}
QString orderByClause(ProfileSortOrder sortOrder)
{
switch (sortOrder) {
case ProfileSortOrder::ProtocolAsc:
return QStringLiteral("ORDER BY lower(protocol) ASC, lower(name) ASC, id ASC");
case ProfileSortOrder::HostAsc:
return QStringLiteral("ORDER BY lower(host) ASC, lower(name) ASC, id ASC");
case ProfileSortOrder::NameAsc:
default:
return QStringLiteral("ORDER BY lower(name) ASC, id ASC");
}
}
QString nonNullTrimmed(const QString& value)
{
const QString trimmed = value.trimmed();
return trimmed.isNull() ? QStringLiteral("") : trimmed;
}
void bindProfileFields(QSqlQuery& query, const Profile& profile) void bindProfileFields(QSqlQuery& query, const Profile& profile)
{ {
query.addBindValue(profile.name.trimmed()); const QString protocol = normalizedProtocol(profile.protocol);
query.addBindValue(profile.host.trimmed()); const QString authMode = normalizedAuthMode(protocol, profile.authMode);
const bool isSsh = protocol == QStringLiteral("SSH");
const bool isRdp = protocol == QStringLiteral("RDP");
query.addBindValue(nonNullTrimmed(profile.name));
query.addBindValue(nonNullTrimmed(profile.host));
query.addBindValue(profile.port); query.addBindValue(profile.port);
query.addBindValue(profile.username.trimmed()); query.addBindValue(nonNullTrimmed(profile.username));
query.addBindValue(profile.protocol.trimmed()); query.addBindValue(isRdp ? nonNullTrimmed(profile.domain) : QStringLiteral(""));
query.addBindValue(profile.authMode.trimmed()); query.addBindValue(nonNullTrimmed(normalizedFolderPath(profile.folderPath)));
query.addBindValue(profile.privateKeyPath.trimmed()); query.addBindValue(protocol);
query.addBindValue(profile.knownHostsPolicy.trimmed().isEmpty() query.addBindValue(authMode);
? QStringLiteral("Strict") query.addBindValue((isSsh && authMode == QStringLiteral("Private Key"))
: profile.knownHostsPolicy.trimmed()); ? nonNullTrimmed(profile.privateKeyPath)
: QStringLiteral(""));
query.addBindValue(isSsh ? normalizedKnownHostsPolicy(profile.knownHostsPolicy)
: QStringLiteral("Ask"));
query.addBindValue(isRdp ? normalizedRdpSecurityMode(profile.rdpSecurityMode)
: QStringLiteral("Negotiate"));
query.addBindValue(isRdp ? normalizedRdpPerformanceProfile(profile.rdpPerformanceProfile)
: QStringLiteral("Balanced"));
query.addBindValue(nonNullTrimmed(normalizedTags(profile.tags)));
} }
Profile profileFromQuery(const QSqlQuery& query) Profile profileFromQuery(const QSqlQuery& query)
@@ -44,20 +191,61 @@ Profile profileFromQuery(const QSqlQuery& query)
profile.host = query.value(2).toString(); profile.host = query.value(2).toString();
profile.port = query.value(3).toInt(); profile.port = query.value(3).toInt();
profile.username = query.value(4).toString(); profile.username = query.value(4).toString();
profile.protocol = query.value(5).toString(); profile.domain = query.value(5).toString();
profile.authMode = query.value(6).toString(); profile.folderPath = normalizedFolderPath(query.value(6).toString());
profile.privateKeyPath = query.value(7).toString(); profile.protocol = normalizedProtocol(query.value(7).toString());
profile.knownHostsPolicy = query.value(8).toString(); profile.authMode = normalizedAuthMode(profile.protocol, query.value(8).toString());
if (profile.knownHostsPolicy.isEmpty()) { profile.privateKeyPath = profile.authMode == QStringLiteral("Private Key")
profile.knownHostsPolicy = QStringLiteral("Strict"); ? query.value(9).toString().trimmed()
} : QString();
profile.knownHostsPolicy = profile.protocol == QStringLiteral("SSH")
? normalizedKnownHostsPolicy(query.value(10).toString())
: QStringLiteral("Ask");
profile.rdpSecurityMode = profile.protocol == QStringLiteral("RDP")
? normalizedRdpSecurityMode(query.value(11).toString())
: QStringLiteral("Negotiate");
profile.rdpPerformanceProfile = profile.protocol == QStringLiteral("RDP")
? normalizedRdpPerformanceProfile(query.value(12).toString())
: QStringLiteral("Balanced");
profile.tags = normalizedTags(query.value(13).toString());
return profile; return profile;
} }
bool isProfileValid(const Profile& profile) bool isProfileValid(const Profile& profile, QString* error)
{ {
return !profile.name.trimmed().isEmpty() && !profile.host.trimmed().isEmpty() if (profile.name.trimmed().isEmpty()) {
&& profile.port >= 1 && profile.port <= 65535; if (error != nullptr) {
*error = QStringLiteral("Profile name is required.");
}
return false;
}
if (profile.host.trimmed().isEmpty()) {
if (error != nullptr) {
*error = QStringLiteral("Host is required.");
}
return false;
}
if (profile.port < 1 || profile.port > 65535) {
if (error != nullptr) {
*error = QStringLiteral("Port must be between 1 and 65535.");
}
return false;
}
const QString protocol = normalizedProtocol(profile.protocol);
const QString authMode = normalizedAuthMode(protocol, profile.authMode);
if (protocol == QStringLiteral("SSH") && authMode == QStringLiteral("Private Key")
&& profile.privateKeyPath.trimmed().isEmpty()) {
if (error != nullptr) {
*error = QStringLiteral("Private key path is required for SSH private key authentication.");
}
return false;
}
return true;
} }
} }
@@ -68,6 +256,16 @@ ProfileRepository::ProfileRepository() : m_connectionName(QStringLiteral("orbith
} }
} }
ProfileRepository::ProfileRepository(const QString& databasePathOverride)
: m_connectionName(QStringLiteral("orbithub_test_%1")
.arg(g_testConnectionCounter.fetch_add(1))),
m_databasePathOverride(databasePathOverride)
{
if (!initializeDatabase()) {
QSqlDatabase::removeDatabase(m_connectionName);
}
}
ProfileRepository::~ProfileRepository() ProfileRepository::~ProfileRepository()
{ {
if (QSqlDatabase::contains(m_connectionName)) { if (QSqlDatabase::contains(m_connectionName)) {
@@ -89,7 +287,177 @@ QString ProfileRepository::lastError() const
return m_lastError; return m_lastError;
} }
std::vector<Profile> ProfileRepository::listProfiles(const QString& searchQuery) const std::vector<QString> ProfileRepository::listFolders() const
{
std::vector<QString> result;
if (!QSqlDatabase::contains(m_connectionName)) {
return result;
}
setLastError(QString());
QSqlQuery query(QSqlDatabase::database(m_connectionName));
query.prepare(QStringLiteral("SELECT path FROM profile_folders ORDER BY lower(path) ASC"));
if (!query.exec()) {
setLastError(query.lastError().text());
return result;
}
while (query.next()) {
const QString normalized = normalizedFolderPath(query.value(0).toString());
if (!normalized.isEmpty()) {
result.push_back(normalized);
}
}
return result;
}
bool ProfileRepository::createFolder(const QString& folderPath) const
{
if (!QSqlDatabase::contains(m_connectionName)) {
return false;
}
const QString normalized = normalizedFolderPath(folderPath);
if (normalized.isEmpty()) {
setLastError(QStringLiteral("Folder path is required."));
return false;
}
setLastError(QString());
QSqlQuery query(QSqlDatabase::database(m_connectionName));
query.prepare(QStringLiteral("INSERT OR IGNORE INTO profile_folders(path) VALUES (?)"));
query.addBindValue(normalized);
if (!query.exec()) {
setLastError(query.lastError().text());
return false;
}
return true;
}
// Deleting a folder never destroys profiles: everything directly inside it
// (profiles and subfolders alike) is shifted up to take the deleted
// folder's place, exactly as if that one path segment had been removed
// from each of their paths. This is the least-surprising behavior for what
// is fundamentally just an organizational label, not a container that owns
// its contents.
bool ProfileRepository::deleteFolder(const QString& folderPath) const
{
if (!QSqlDatabase::contains(m_connectionName)) {
return false;
}
const QString normalized = normalizedFolderPath(folderPath);
if (normalized.isEmpty()) {
setLastError(QStringLiteral("Folder path is required."));
return false;
}
setLastError(QString());
QString parentPath;
const int lastSlash = normalized.lastIndexOf(QChar::fromLatin1('/'));
if (lastSlash >= 0) {
parentPath = normalized.left(lastSlash);
}
const QString prefix = normalized + QStringLiteral("/");
auto remap = [&](const QString& oldPath) {
if (oldPath == normalized) {
return nonNullTrimmed(parentPath);
}
const QString remainder = oldPath.mid(prefix.length());
return nonNullTrimmed(parentPath.isEmpty() ? remainder
: parentPath + QStringLiteral("/") + remainder);
};
QSqlDatabase database = QSqlDatabase::database(m_connectionName);
if (!database.transaction()) {
setLastError(database.lastError().text());
return false;
}
// Reassign affected profiles. Matching is done in C++ (not SQL LIKE)
// so a folder name containing '%' or '_' can't be misinterpreted as a
// wildcard.
QSqlQuery selectProfiles(database);
if (!selectProfiles.exec(QStringLiteral("SELECT id, folder_path FROM profiles"))) {
setLastError(selectProfiles.lastError().text());
database.rollback();
return false;
}
std::vector<std::pair<qint64, QString>> profileUpdates;
while (selectProfiles.next()) {
const QString path = normalizedFolderPath(selectProfiles.value(1).toString());
if (path == normalized || path.startsWith(prefix)) {
profileUpdates.emplace_back(selectProfiles.value(0).toLongLong(), remap(path));
}
}
for (const auto& [id, newPath] : profileUpdates) {
QSqlQuery update(database);
update.prepare(QStringLiteral("UPDATE profiles SET folder_path = ? WHERE id = ?"));
update.addBindValue(newPath);
update.addBindValue(id);
if (!update.exec()) {
setLastError(update.lastError().text());
database.rollback();
return false;
}
}
// Reassign (or drop, for the folder itself) affected explicit folder
// markers the same way.
QSqlQuery selectFolders(database);
if (!selectFolders.exec(QStringLiteral("SELECT path FROM profile_folders"))) {
setLastError(selectFolders.lastError().text());
database.rollback();
return false;
}
QStringList affectedFolders;
while (selectFolders.next()) {
const QString path = normalizedFolderPath(selectFolders.value(0).toString());
if (path == normalized || path.startsWith(prefix)) {
affectedFolders.push_back(path);
}
}
for (const QString& oldPath : affectedFolders) {
QSqlQuery remove(database);
remove.prepare(QStringLiteral("DELETE FROM profile_folders WHERE path = ?"));
remove.addBindValue(oldPath);
if (!remove.exec()) {
setLastError(remove.lastError().text());
database.rollback();
return false;
}
const QString newPath = remap(oldPath);
if (oldPath != normalized && !newPath.isEmpty()) {
QSqlQuery insert(database);
insert.prepare(QStringLiteral("INSERT OR IGNORE INTO profile_folders(path) VALUES (?)"));
insert.addBindValue(newPath);
if (!insert.exec()) {
setLastError(insert.lastError().text());
database.rollback();
return false;
}
}
}
if (!database.commit()) {
setLastError(database.lastError().text());
database.rollback();
return false;
}
return true;
}
std::vector<Profile> ProfileRepository::listProfiles(const QString& searchQuery,
ProfileSortOrder sortOrder) const
{ {
std::vector<Profile> result; std::vector<Profile> result;
@@ -100,20 +468,23 @@ std::vector<Profile> ProfileRepository::listProfiles(const QString& searchQuery)
setLastError(QString()); setLastError(QString());
QSqlQuery query(QSqlDatabase::database(m_connectionName)); QSqlQuery query(QSqlDatabase::database(m_connectionName));
const QString orderBy = orderByClause(sortOrder);
if (searchQuery.trimmed().isEmpty()) { if (searchQuery.trimmed().isEmpty()) {
query.prepare(QStringLiteral( query.prepare(QStringLiteral(
"SELECT id, name, host, port, username, protocol, auth_mode, private_key_path, known_hosts_policy " "SELECT id, name, host, port, username, domain, folder_path, protocol, auth_mode, private_key_path, known_hosts_policy, rdp_security_mode, rdp_performance_profile, tags "
"FROM profiles " "FROM profiles ")
"ORDER BY lower(name) ASC, id ASC")); + orderBy);
} else { } else {
query.prepare(QStringLiteral( query.prepare(QStringLiteral(
"SELECT id, name, host, port, username, protocol, auth_mode, private_key_path, known_hosts_policy " "SELECT id, name, host, port, username, domain, folder_path, protocol, auth_mode, private_key_path, known_hosts_policy, rdp_security_mode, rdp_performance_profile, tags "
"FROM profiles " "FROM profiles "
"WHERE lower(name) LIKE lower(?) OR lower(host) LIKE lower(?) " "WHERE lower(name) LIKE lower(?) OR lower(host) LIKE lower(?) OR lower(tags) LIKE lower(?) OR lower(folder_path) LIKE lower(?) ")
"ORDER BY lower(name) ASC, id ASC")); + orderBy);
const QString search = QStringLiteral("%") + searchQuery.trimmed() + QStringLiteral("%"); const QString search = QStringLiteral("%") + searchQuery.trimmed() + QStringLiteral("%");
query.addBindValue(search); query.addBindValue(search);
query.addBindValue(search); query.addBindValue(search);
query.addBindValue(search);
query.addBindValue(search);
} }
if (!query.exec()) { if (!query.exec()) {
@@ -138,7 +509,7 @@ std::optional<Profile> ProfileRepository::getProfile(qint64 id) const
QSqlQuery query(QSqlDatabase::database(m_connectionName)); QSqlQuery query(QSqlDatabase::database(m_connectionName));
query.prepare(QStringLiteral( query.prepare(QStringLiteral(
"SELECT id, name, host, port, username, protocol, auth_mode, private_key_path, known_hosts_policy " "SELECT id, name, host, port, username, domain, folder_path, protocol, auth_mode, private_key_path, known_hosts_policy, rdp_security_mode, rdp_performance_profile, tags "
"FROM profiles WHERE id = ?")); "FROM profiles WHERE id = ?"));
query.addBindValue(id); query.addBindValue(id);
@@ -162,15 +533,16 @@ std::optional<Profile> ProfileRepository::createProfile(const Profile& profile)
setLastError(QString()); setLastError(QString());
if (!isProfileValid(profile)) { QString validationError;
setLastError(QStringLiteral("Name, host, and a valid port are required.")); if (!isProfileValid(profile, &validationError)) {
setLastError(validationError);
return std::nullopt; return std::nullopt;
} }
QSqlQuery query(QSqlDatabase::database(m_connectionName)); QSqlQuery query(QSqlDatabase::database(m_connectionName));
query.prepare(QStringLiteral( query.prepare(QStringLiteral(
"INSERT INTO profiles(name, host, port, username, protocol, auth_mode, private_key_path, known_hosts_policy) " "INSERT INTO profiles(name, host, port, username, domain, folder_path, protocol, auth_mode, private_key_path, known_hosts_policy, rdp_security_mode, rdp_performance_profile, tags) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)")); "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"));
bindProfileFields(query, profile); bindProfileFields(query, profile);
if (!query.exec()) { if (!query.exec()) {
@@ -191,15 +563,17 @@ bool ProfileRepository::updateProfile(const Profile& profile) const
setLastError(QString()); setLastError(QString());
if (profile.id < 0 || !isProfileValid(profile)) { QString validationError;
setLastError(QStringLiteral("Invalid profile data.")); if (profile.id < 0 || !isProfileValid(profile, &validationError)) {
setLastError(validationError.isEmpty() ? QStringLiteral("Invalid profile data.")
: validationError);
return false; return false;
} }
QSqlQuery query(QSqlDatabase::database(m_connectionName)); QSqlQuery query(QSqlDatabase::database(m_connectionName));
query.prepare(QStringLiteral( query.prepare(QStringLiteral(
"UPDATE profiles " "UPDATE profiles "
"SET name = ?, host = ?, port = ?, username = ?, protocol = ?, auth_mode = ?, private_key_path = ?, known_hosts_policy = ? " "SET name = ?, host = ?, port = ?, username = ?, domain = ?, folder_path = ?, protocol = ?, auth_mode = ?, private_key_path = ?, known_hosts_policy = ?, rdp_security_mode = ?, rdp_performance_profile = ?, tags = ? "
"WHERE id = ?")); "WHERE id = ?"));
bindProfileFields(query, profile); bindProfileFields(query, profile);
query.addBindValue(profile.id); query.addBindValue(profile.id);
@@ -235,7 +609,8 @@ bool ProfileRepository::deleteProfile(qint64 id) const
bool ProfileRepository::initializeDatabase() bool ProfileRepository::initializeDatabase()
{ {
QSqlDatabase database = QSqlDatabase::addDatabase(QStringLiteral("QSQLITE"), m_connectionName); QSqlDatabase database = QSqlDatabase::addDatabase(QStringLiteral("QSQLITE"), m_connectionName);
database.setDatabaseName(buildDatabasePath()); database.setDatabaseName(
m_databasePathOverride.isEmpty() ? buildDatabasePath() : m_databasePathOverride);
if (!database.open()) { if (!database.open()) {
m_initError = database.lastError().text(); m_initError = database.lastError().text();
@@ -250,10 +625,15 @@ bool ProfileRepository::initializeDatabase()
"host TEXT NOT NULL DEFAULT ''," "host TEXT NOT NULL DEFAULT '',"
"port INTEGER NOT NULL DEFAULT 22," "port INTEGER NOT NULL DEFAULT 22,"
"username TEXT NOT NULL DEFAULT ''," "username TEXT NOT NULL DEFAULT '',"
"domain TEXT NOT NULL DEFAULT '',"
"folder_path TEXT NOT NULL DEFAULT '',"
"protocol TEXT NOT NULL DEFAULT 'SSH'," "protocol TEXT NOT NULL DEFAULT 'SSH',"
"auth_mode TEXT NOT NULL DEFAULT 'Password'," "auth_mode TEXT NOT NULL DEFAULT 'Password',"
"private_key_path TEXT NOT NULL DEFAULT ''," "private_key_path TEXT NOT NULL DEFAULT '',"
"known_hosts_policy TEXT NOT NULL DEFAULT 'Strict'" "known_hosts_policy TEXT NOT NULL DEFAULT 'Ask',"
"rdp_security_mode TEXT NOT NULL DEFAULT 'Negotiate',"
"rdp_performance_profile TEXT NOT NULL DEFAULT 'Balanced',"
"tags TEXT NOT NULL DEFAULT ''"
")")); ")"));
if (!created) { if (!created) {
@@ -261,6 +641,15 @@ bool ProfileRepository::initializeDatabase()
return false; return false;
} }
const bool foldersCreated = query.exec(QStringLiteral(
"CREATE TABLE IF NOT EXISTS profile_folders ("
"path TEXT PRIMARY KEY NOT NULL"
")"));
if (!foldersCreated) {
m_initError = query.lastError().text();
return false;
}
if (!ensureProfileSchema()) { if (!ensureProfileSchema()) {
m_initError = m_lastError; m_initError = m_lastError;
return false; return false;
@@ -296,10 +685,15 @@ bool ProfileRepository::ensureProfileSchema() const
{QStringLiteral("host"), QStringLiteral("ALTER TABLE profiles ADD COLUMN host TEXT NOT NULL DEFAULT ''")}, {QStringLiteral("host"), QStringLiteral("ALTER TABLE profiles ADD COLUMN host TEXT NOT NULL DEFAULT ''")},
{QStringLiteral("port"), QStringLiteral("ALTER TABLE profiles ADD COLUMN port INTEGER NOT NULL DEFAULT 22")}, {QStringLiteral("port"), QStringLiteral("ALTER TABLE profiles ADD COLUMN port INTEGER NOT NULL DEFAULT 22")},
{QStringLiteral("username"), QStringLiteral("ALTER TABLE profiles ADD COLUMN username TEXT NOT NULL DEFAULT ''")}, {QStringLiteral("username"), QStringLiteral("ALTER TABLE profiles ADD COLUMN username TEXT NOT NULL DEFAULT ''")},
{QStringLiteral("domain"), QStringLiteral("ALTER TABLE profiles ADD COLUMN domain TEXT NOT NULL DEFAULT ''")},
{QStringLiteral("folder_path"), QStringLiteral("ALTER TABLE profiles ADD COLUMN folder_path TEXT NOT NULL DEFAULT ''")},
{QStringLiteral("protocol"), QStringLiteral("ALTER TABLE profiles ADD COLUMN protocol TEXT NOT NULL DEFAULT 'SSH'")}, {QStringLiteral("protocol"), QStringLiteral("ALTER TABLE profiles ADD COLUMN protocol TEXT NOT NULL DEFAULT 'SSH'")},
{QStringLiteral("auth_mode"), QStringLiteral("ALTER TABLE profiles ADD COLUMN auth_mode TEXT NOT NULL DEFAULT 'Password'")}, {QStringLiteral("auth_mode"), QStringLiteral("ALTER TABLE profiles ADD COLUMN auth_mode TEXT NOT NULL DEFAULT 'Password'")},
{QStringLiteral("private_key_path"), QStringLiteral("ALTER TABLE profiles ADD COLUMN private_key_path TEXT NOT NULL DEFAULT ''")}, {QStringLiteral("private_key_path"), QStringLiteral("ALTER TABLE profiles ADD COLUMN private_key_path TEXT NOT NULL DEFAULT ''")},
{QStringLiteral("known_hosts_policy"), QStringLiteral("ALTER TABLE profiles ADD COLUMN known_hosts_policy TEXT NOT NULL DEFAULT 'Strict'")}}; {QStringLiteral("known_hosts_policy"), QStringLiteral("ALTER TABLE profiles ADD COLUMN known_hosts_policy TEXT NOT NULL DEFAULT 'Ask'")},
{QStringLiteral("rdp_security_mode"), QStringLiteral("ALTER TABLE profiles ADD COLUMN rdp_security_mode TEXT NOT NULL DEFAULT 'Negotiate'")},
{QStringLiteral("rdp_performance_profile"), QStringLiteral("ALTER TABLE profiles ADD COLUMN rdp_performance_profile TEXT NOT NULL DEFAULT 'Balanced'")},
{QStringLiteral("tags"), QStringLiteral("ALTER TABLE profiles ADD COLUMN tags TEXT NOT NULL DEFAULT ''")}};
for (const ColumnDef& column : required) { for (const ColumnDef& column : required) {
if (columns.contains(column.name)) { if (columns.contains(column.name)) {
@@ -313,6 +707,15 @@ bool ProfileRepository::ensureProfileSchema() const
} }
} }
QSqlQuery ensureFolders(QSqlDatabase::database(m_connectionName));
if (!ensureFolders.exec(QStringLiteral(
"CREATE TABLE IF NOT EXISTS profile_folders ("
"path TEXT PRIMARY KEY NOT NULL"
")"))) {
setLastError(ensureFolders.lastError().text());
return false;
}
setLastError(QString()); setLastError(QString());
return true; return true;
} }
+21 -2
View File
@@ -14,22 +14,40 @@ struct Profile
QString host; QString host;
int port = 22; int port = 22;
QString username; QString username;
QString domain;
QString folderPath;
QString protocol = QStringLiteral("SSH"); QString protocol = QStringLiteral("SSH");
QString authMode = QStringLiteral("Password"); QString authMode = QStringLiteral("Password");
QString privateKeyPath; QString privateKeyPath;
QString knownHostsPolicy = QStringLiteral("Strict"); QString knownHostsPolicy = QStringLiteral("Ask");
QString rdpSecurityMode = QStringLiteral("Negotiate");
QString rdpPerformanceProfile = QStringLiteral("Balanced");
QString tags;
};
enum class ProfileSortOrder {
NameAsc,
ProtocolAsc,
HostAsc,
}; };
class ProfileRepository class ProfileRepository
{ {
public: public:
ProfileRepository(); ProfileRepository();
// databasePathOverride lets tests point the repository at an isolated,
// disposable SQLite file instead of the real app-data location.
explicit ProfileRepository(const QString& databasePathOverride);
~ProfileRepository(); ~ProfileRepository();
QString initError() const; QString initError() const;
QString lastError() const; QString lastError() const;
std::vector<Profile> listProfiles(const QString& searchQuery = QString()) const; std::vector<Profile> listProfiles(const QString& searchQuery = QString(),
ProfileSortOrder sortOrder = ProfileSortOrder::NameAsc) const;
std::vector<QString> listFolders() const;
bool createFolder(const QString& folderPath) const;
bool deleteFolder(const QString& folderPath) const;
std::optional<Profile> getProfile(qint64 id) const; std::optional<Profile> getProfile(qint64 id) const;
std::optional<Profile> createProfile(const Profile& profile) const; std::optional<Profile> createProfile(const Profile& profile) const;
bool updateProfile(const Profile& profile) const; bool updateProfile(const Profile& profile) const;
@@ -37,6 +55,7 @@ public:
private: private:
QString m_connectionName; QString m_connectionName;
QString m_databasePathOverride;
QString m_initError; QString m_initError;
mutable QString m_lastError; mutable QString m_lastError;
+11
View File
@@ -0,0 +1,11 @@
#include "profiles_tree_widget.h"
#include <QDropEvent>
ProfilesTreeWidget::ProfilesTreeWidget(QWidget* parent) : QTreeWidget(parent) {}
void ProfilesTreeWidget::dropEvent(QDropEvent* event)
{
QTreeWidget::dropEvent(event);
emit itemsDropped();
}
+22
View File
@@ -0,0 +1,22 @@
#ifndef ORBITHUB_PROFILES_TREE_WIDGET_H
#define ORBITHUB_PROFILES_TREE_WIDGET_H
#include <QTreeWidget>
class QDropEvent;
class ProfilesTreeWidget : public QTreeWidget
{
Q_OBJECT
public:
explicit ProfilesTreeWidget(QWidget* parent = nullptr);
signals:
void itemsDropped();
protected:
void dropEvent(QDropEvent* event) override;
};
#endif
+1043 -73
View File
File diff suppressed because it is too large Load Diff
+46 -12
View File
@@ -3,23 +3,26 @@
#include "profile_repository.h" #include "profile_repository.h"
#include <QMainWindow> #include <QWidget>
#include <QString> #include <QString>
#include <QStringList>
#include <QtGlobal> #include <QtGlobal>
#include <memory> #include <memory>
#include <map>
#include <optional> #include <optional>
#include <QPointer>
#include <unordered_map>
#include <vector> #include <vector>
#include <unordered_map>
class QListWidget; class QTreeWidget;
class QListWidgetItem; class QTreeWidgetItem;
class QLineEdit; class QLineEdit;
class QPushButton; class QPushButton;
class SessionWindow; class QComboBox;
class QPoint;
class ProfilesTreeWidget;
class ProfilesWindow : public QMainWindow class ProfilesWindow : public QWidget
{ {
Q_OBJECT Q_OBJECT
@@ -27,23 +30,54 @@ public:
explicit ProfilesWindow(QWidget* parent = nullptr); explicit ProfilesWindow(QWidget* parent = nullptr);
~ProfilesWindow() override; ~ProfilesWindow() override;
void createProfileInCurrentContext();
void createFolderInCurrentContext();
void exportProfiles();
void importProfiles();
void importFromMRemoteNG();
signals:
void connectRequested(const Profile& profile);
private: private:
QLineEdit* m_searchBox; QLineEdit* m_searchBox;
QListWidget* m_profilesList; QComboBox* m_viewModeBox;
QComboBox* m_sortBox;
QComboBox* m_protocolFilterBox;
QComboBox* m_tagFilterBox;
ProfilesTreeWidget* m_profilesTree;
QPushButton* m_newButton; QPushButton* m_newButton;
QPushButton* m_editButton; QPushButton* m_editButton;
QPushButton* m_deleteButton; QPushButton* m_deleteButton;
std::vector<QPointer<SessionWindow>> m_sessionWindows;
std::unique_ptr<ProfileRepository> m_repository; std::unique_ptr<ProfileRepository> m_repository;
std::unordered_map<qint64, Profile> m_profileCache; std::unordered_map<qint64, Profile> m_profileCache;
QString m_pendingTagFilterPreference;
void setupUi(); void setupUi();
void loadProfiles(const QString& query = QString()); void loadProfiles();
ProfileSortOrder selectedSortOrder() const;
bool isFolderViewEnabled() const;
QString selectedProtocolFilter() const;
QString selectedTagFilter() const;
void updateTagFilterOptions(const std::vector<Profile>& profiles);
QTreeWidgetItem* upsertFolderNode(const QStringList& folderParts,
std::map<QString, QTreeWidgetItem*>& folderNodes);
void addProfileNode(QTreeWidgetItem* parent, const Profile& profile);
QString folderPathForItem(const QTreeWidgetItem* item) const;
void showTreeContextMenu(const QPoint& pos);
void createFolderInContext(const QString& baseFolderPath);
void deleteFolderInContext(const QString& folderPath);
void persistFolderAssignmentsFromTree();
void collectProfileAssignments(const QTreeWidgetItem* item,
const QString& parentFolderPath,
std::unordered_map<qint64, QString>& assignments) const;
void loadUiPreferences();
void saveUiPreferences() const;
std::optional<Profile> selectedProfile() const; std::optional<Profile> selectedProfile() const;
void createProfile(); void createProfile(const QString& defaultFolderPath = QString());
void editSelectedProfile(); void editSelectedProfile();
void deleteSelectedProfile(); void deleteSelectedProfile();
void openSessionForItem(QListWidgetItem* item); void openSessionForItem(QTreeWidgetItem* item);
}; };
#endif #endif
+334
View File
@@ -0,0 +1,334 @@
#include "rdp_display_widget.h"
#include <QCursor>
#include <QEvent>
#include <QKeyEvent>
#include <QMouseEvent>
#include <QPainter>
#include <QPixmap>
#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);
}
// Windows' virtual-display driver can visibly glitch (stale composited
// content left on screen) when asked to change resolution repeatedly in
// quick succession, which naturally happens as the window's layout settles
// right after creation/connect. Coalescing bursts of resize events into one
// request avoids triggering that.
constexpr int kResizeDebounceMs = 150;
}
RdpDisplayWidget::RdpDisplayWidget(QWidget* parent)
: QWidget(parent),
m_remoteSize(1280, 720),
m_cursorMode(CursorMode::Default),
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, &RdpDisplayWidget::emitViewportGeometry);
scheduleViewportGeometryEmit();
}
void RdpDisplayWidget::setFrame(const QImage& frame)
{
if (frame.isNull()) {
return;
}
m_frame = frame;
m_remoteSize = sanitizeSize(frame.size());
update();
}
void RdpDisplayWidget::setRemoteDesktopSize(int width, int height)
{
if (width < 1 || height < 1) {
return;
}
const QSize nextSize(width, height);
if (m_remoteSize == nextSize) {
return;
}
m_remoteSize = nextSize;
// The next actual frame (via setFrame) arrives asynchronously and isn't
// guaranteed to be sized to match yet. Drawing the old frame stretched
// to a renderRect() computed from the new m_remoteSize would scale it
// by the wrong factor for the transition window, producing visibly
// distorted/duplicated-looking content. Clear it and show the existing
// "waiting for frame" placeholder until a correctly-sized frame lands.
m_frame = QImage();
update();
}
void RdpDisplayWidget::clearFrame()
{
m_frame = QImage();
update();
}
void RdpDisplayWidget::setCursorImage(const QImage& image, const QPoint& hotspot)
{
m_cursorImage = image;
m_cursorHotspot = hotspot;
m_cursorMode = CursorMode::Custom;
applyCursor();
}
void RdpDisplayWidget::setCursorHidden()
{
m_cursorMode = CursorMode::Hidden;
applyCursor();
}
void RdpDisplayWidget::setCursorDefault()
{
m_cursorMode = CursorMode::Default;
applyCursor();
}
void RdpDisplayWidget::applyCursor()
{
if (m_cursorMode == CursorMode::Hidden) {
setCursor(Qt::BlankCursor);
return;
}
if (m_cursorMode == CursorMode::Default || m_cursorImage.isNull()) {
unsetCursor();
return;
}
const QSize remote = effectiveRemoteSize();
const QRectF target = renderRect();
if (remote.isEmpty() || target.isEmpty()) {
setCursor(QCursor(QPixmap::fromImage(m_cursorImage),
m_cursorHotspot.x(),
m_cursorHotspot.y()));
return;
}
const qreal scale = target.width() / remote.width();
QImage scaledImage = m_cursorImage;
if (!qFuzzyCompare(scale, 1.0)) {
scaledImage = m_cursorImage.scaled(
qMax(1, qRound(m_cursorImage.width() * scale)),
qMax(1, qRound(m_cursorImage.height() * scale)),
Qt::IgnoreAspectRatio,
Qt::SmoothTransformation);
}
const int hotX = qBound(0, qRound(m_cursorHotspot.x() * scale), scaledImage.width());
const int hotY = qBound(0, qRound(m_cursorHotspot.y() * scale), scaledImage.height());
setCursor(QCursor(QPixmap::fromImage(scaledImage), hotX, hotY));
}
void RdpDisplayWidget::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 RdpDisplayWidget::resizeEvent(QResizeEvent* event)
{
QWidget::resizeEvent(event);
scheduleViewportGeometryEmit();
applyCursor();
}
bool RdpDisplayWidget::event(QEvent* event)
{
// Fires when this widget's effective screen changes (e.g. dragged to a
// different monitor), which is what changes devicePixelRatio(). Newer
// Qt versions add a more specific QEvent::DevicePixelRatioChange, but
// this project's Qt 6.2 floor doesn't have it.
if (event->type() == QEvent::ScreenChangeInternal) {
scheduleViewportGeometryEmit();
}
return QWidget::event(event);
}
void RdpDisplayWidget::scheduleViewportGeometryEmit()
{
// Restarting an already-running single-shot timer resets its countdown,
// so a burst of resize events collapses into one emission after things
// settle, rather than one request per event.
m_resizeDebounceTimer->start(kResizeDebounceMs);
}
void RdpDisplayWidget::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 RdpDisplayWidget::keyPressEvent(QKeyEvent* event)
{
if (event == nullptr) {
return;
}
// Auto-repeat presses must reach the remote server so it can perform
// its own typematic repeat, exactly as a physical keyboard held down
// would. Only release events filter out isAutoRepeat() (below), since
// Qt uses a synthetic release/press pair purely to normalize platform
// auto-repeat quirks -- forwarding that synthetic release would send a
// spurious key-up for a key that is still physically held.
emit keyInput(event->key(),
event->nativeScanCode(),
event->text(),
true,
static_cast<int>(event->modifiers()));
event->accept();
}
void RdpDisplayWidget::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 RdpDisplayWidget::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 RdpDisplayWidget::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 RdpDisplayWidget::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 RdpDisplayWidget::mouseMoveEvent(QMouseEvent* event)
{
if (event == nullptr) {
return;
}
const QPoint mapped = mapToRemote(event->position());
emit mouseMoveInput(mapped.x(), mapped.y());
event->accept();
}
void RdpDisplayWidget::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 RdpDisplayWidget::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 RdpDisplayWidget::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 RdpDisplayWidget::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);
}
+70
View File
@@ -0,0 +1,70 @@
#ifndef ORBITHUB_RDP_DISPLAY_WIDGET_H
#define ORBITHUB_RDP_DISPLAY_WIDGET_H
#include <QImage>
#include <QWidget>
class QKeyEvent;
class QMouseEvent;
class QPaintEvent;
class QResizeEvent;
class QTimer;
class QWheelEvent;
class RdpDisplayWidget : public QWidget
{
Q_OBJECT
public:
explicit RdpDisplayWidget(QWidget* parent = nullptr);
void setFrame(const QImage& frame);
void setRemoteDesktopSize(int width, int height);
void clearFrame();
void setCursorImage(const QImage& image, const QPoint& hotspot);
void setCursorHidden();
void setCursorDefault();
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:
enum class CursorMode {
Default,
Custom,
Hidden,
};
QImage m_frame;
QSize m_remoteSize;
QImage m_cursorImage;
QPoint m_cursorHotspot;
CursorMode m_cursorMode;
QTimer* m_resizeDebounceTimer;
QRectF renderRect() const;
QPoint mapToRemote(const QPointF& pos) const;
QSize effectiveRemoteSize() const;
void applyCursor();
void emitViewportGeometry();
void scheduleViewportGeometryEmit();
};
#endif
File diff suppressed because it is too large Load Diff
+147
View File
@@ -0,0 +1,147 @@
#ifndef ORBITHUB_RDP_SESSION_BACKEND_H
#define ORBITHUB_RDP_SESSION_BACKEND_H
#include "session_backend.h"
#include <atomic>
#include <cstdint>
#include <deque>
#include <mutex>
#include <thread>
struct rdp_freerdp;
class RdpSessionBackend : public SessionBackend
{
Q_OBJECT
public:
explicit RdpSessionBackend(const Profile& profile, QObject* parent = nullptr);
~RdpSessionBackend() override;
// Pure, state-free helpers exposed as public statics purely so tests
// can exercise them without a live FreeRDP connection. UINT32 values
// are surfaced as quint32 here to keep FreeRDP/WinPR types out of this
// header (uint32_t is what UINT32 always is on every platform this
// project targets).
static QString normalizedRdpSecurityMode(const QString& value);
static QString normalizedRdpPerformanceProfile(const QString& value);
static quint32 nearestFreeRdpScaleValue(qreal ratio);
static quint32 scancodeFromNativeScanCode(quint32 nativeScanCode);
static quint32 scancodeForQtKey(int key, Qt::KeyboardModifiers modifiers, quint32 nativeScanCode);
static QString mapRdpError(quint32 code);
static bool isExpectedDisconnectCode(quint32 code);
static bool isExpectedConnectAbortCode(quint32 code);
static QString disconnectMessageForCode(quint32 code);
static QString rdpErrorRaw(quint32 code);
static int sanitizeDesktopWidth(int width);
static int sanitizeDesktopHeight(int height);
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 updateDisplayScale(qreal devicePixelRatio) 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;
void setClipboardText(const QString& text) override;
private:
enum class InputEventType {
Key,
MouseMove,
MouseButton,
MouseWheel,
Resize,
SetClipboardText,
};
struct InputEvent {
InputEventType type = InputEventType::MouseMove;
int key = 0;
quint32 nativeScanCode = 0;
QString text;
bool pressed = false;
int modifiers = 0;
int x = 0;
int y = 0;
int button = 0;
int deltaX = 0;
int deltaY = 0;
int width = 0;
int height = 0;
};
SessionState m_state;
SessionConnectOptions m_activeOptions;
std::atomic_bool m_userInitiatedDisconnect;
std::atomic_int m_requestedDesktopWidth;
std::atomic_int m_requestedDesktopHeight;
std::atomic<qreal> m_devicePixelRatio;
std::thread m_worker;
std::atomic_bool m_workerRunning;
std::atomic_bool m_stopRequested;
std::mutex m_instanceMutex;
rdp_freerdp* m_instance;
std::mutex m_inputMutex;
std::deque<InputEvent> m_inputEvents;
std::mutex m_displayControlMutex;
void* m_displayControlContext;
bool m_displayControlReady;
bool m_resizeFailureLogged;
int m_lastResizeWidth;
int m_lastResizeHeight;
int m_lastResizeScale;
std::mutex m_cliprdrMutex;
void* m_cliprdrContext;
QString m_pendingLocalClipboardText;
// Set synchronously by orbitVerifyChangedCertificateEx (called from this
// object's own worker thread during freerdp_connect) when a server's TLS
// certificate has changed since a prior trusted connection. Read back by
// workerMain() right after freerdp_connect() fails, to show the specific
// reason instead of a generic "TLS negotiation failed" message. Cleared
// at the start of every connect attempt.
QString m_certificateRejectionReason;
void setState(SessionState state, const QString& message);
bool validateProfile(QString& message) const;
void startWorker();
void stopWorker(bool userInitiated);
void workerMain();
void enqueueInputEvent(const InputEvent& event);
void processInputEvents(rdp_freerdp* instance);
bool sendDisplayResize(rdp_freerdp* instance, int width, int height);
void sendClipboardTextToRemote(rdp_freerdp* instance, const QString& text);
public:
void onChannelConnectedEvent(const char* name, void* channelInterface);
void onChannelDisconnectedEvent(const char* name, void* channelInterface);
void onDisplayControlCaps(uint32_t maxNumMonitors,
uint32_t maxMonitorAreaFactorA,
uint32_t maxMonitorAreaFactorB);
void onCliprdrMonitorReady();
void onCliprdrServerFormatList(bool hasUnicodeText);
void onCliprdrServerFormatDataRequest(uint32_t requestedFormatId);
void onCliprdrServerFormatDataResponse(bool success, const uint8_t* data, uint32_t size);
void recordCertificateRejection(const QString& reason);
private:
void emitStateAsync(SessionState state, const QString& message);
void emitConnectionFailureAsync(const QString& displayMessage, const QString& rawMessage);
};
#endif
+73
View File
@@ -3,12 +3,21 @@
#include "profile_repository.h" #include "profile_repository.h"
#include <QImage>
#include <QObject> #include <QObject>
#include <QPoint>
#include <QString> #include <QString>
#include <QtGlobal>
class SessionConnectOptions class SessionConnectOptions
{ {
public: public:
// Only set when the profile's own username was blank and SessionTab
// prompted for one inline at connect time (see issue #21); empty means
// "use the backend's own profile().username" as before. SSH/RDP need
// this up front, unlike VNC's Apple auth which discovers the need for
// one mid-connection via usernameRequested()/provideUsername() instead.
QString username;
QString password; QString password;
QString privateKeyPath; QString privateKeyPath;
QString knownHostsPolicy; QString knownHostsPolicy;
@@ -41,11 +50,75 @@ public slots:
virtual void connectSession(const SessionConnectOptions& options) = 0; virtual void connectSession(const SessionConnectOptions& options) = 0;
virtual void disconnectSession() = 0; virtual void disconnectSession() = 0;
virtual void reconnectSession(const SessionConnectOptions& options) = 0; virtual void reconnectSession(const SessionConnectOptions& options) = 0;
virtual void sendInput(const QString& input) = 0;
virtual void confirmHostKey(bool trustHost) = 0;
virtual void updateTerminalSize(int columns, int rows) = 0;
virtual void updateDisplayScale(qreal devicePixelRatio)
{
Q_UNUSED(devicePixelRatio);
}
virtual void setClipboardText(const QString& text)
{
Q_UNUSED(text);
}
// Response to usernameRequested(), for backends that discover mid-
// connection (not before connectSession() is even called) that they
// need one -- currently only VNC's Apple authentication schemes,
// which only require a username for security types 30/33, not for
// plain VNC Authentication or no-auth. An empty username is treated
// the same as the prompt being cancelled.
virtual void provideUsername(const QString& username)
{
Q_UNUSED(username);
}
virtual void sendKeyEvent(int key,
quint32 nativeScanCode,
const QString& text,
bool pressed,
int modifiers)
{
Q_UNUSED(key);
Q_UNUSED(nativeScanCode);
Q_UNUSED(text);
Q_UNUSED(pressed);
Q_UNUSED(modifiers);
}
virtual void sendMouseMoveEvent(int x, int y)
{
Q_UNUSED(x);
Q_UNUSED(y);
}
virtual void sendMouseButtonEvent(int x, int y, int button, bool pressed)
{
Q_UNUSED(x);
Q_UNUSED(y);
Q_UNUSED(button);
Q_UNUSED(pressed);
}
virtual void sendMouseWheelEvent(int x, int y, int deltaX, int deltaY)
{
Q_UNUSED(x);
Q_UNUSED(y);
Q_UNUSED(deltaX);
Q_UNUSED(deltaY);
}
signals: signals:
void stateChanged(SessionState state, const QString& message); void stateChanged(SessionState state, const QString& message);
void eventLogged(const QString& message); void eventLogged(const QString& message);
void connectionError(const QString& displayMessage, const QString& rawMessage); void connectionError(const QString& displayMessage, const QString& rawMessage);
void outputReceived(const QString& text);
void hostKeyConfirmationRequested(const QString& prompt);
// Mirrors hostKeyConfirmationRequested()'s request/response shape, for
// a backend that discovers mid-connection it needs a username it
// wasn't given -- see provideUsername().
void usernameRequested(const QString& prompt);
void frameUpdated(const QImage& frame);
void remoteDesktopSizeChanged(int width, int height);
void remoteClipboardTextChanged(const QString& text);
void cursorImageChanged(const QImage& image, const QPoint& hotspot);
void cursorHidden();
void cursorReset();
private: private:
Profile m_profile; Profile m_profile;
+8
View File
@@ -1,14 +1,22 @@
#include "session_backend_factory.h" #include "session_backend_factory.h"
#include "rdp_session_backend.h"
#include "session_backend.h" #include "session_backend.h"
#include "ssh_session_backend.h" #include "ssh_session_backend.h"
#include "unsupported_session_backend.h" #include "unsupported_session_backend.h"
#include "vnc_session_backend.h"
std::unique_ptr<SessionBackend> createSessionBackend(const Profile& profile) std::unique_ptr<SessionBackend> createSessionBackend(const Profile& profile)
{ {
if (profile.protocol.compare(QStringLiteral("SSH"), Qt::CaseInsensitive) == 0) { if (profile.protocol.compare(QStringLiteral("SSH"), Qt::CaseInsensitive) == 0) {
return std::make_unique<SshSessionBackend>(profile); return std::make_unique<SshSessionBackend>(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); return std::make_unique<UnsupportedSessionBackend>(profile);
} }
+1319 -182
View File
File diff suppressed because it is too large Load Diff
+125 -15
View File
@@ -5,63 +5,173 @@
#include "session_backend.h" #include "session_backend.h"
#include <QWidget> #include <QWidget>
#include <QStringList>
#include <QtGlobal>
#include <functional>
#include <optional> #include <optional>
#include <vector>
class QLabel;
class QPlainTextEdit; class QPlainTextEdit;
class QPushButton;
class QThread; class QThread;
class SessionBackend; class SessionBackend;
class TerminalView;
class RdpDisplayWidget;
class VncDisplayWidget;
class QToolButton;
class QLineEdit;
class QComboBox;
class QLabel;
class QPushButton;
class QScrollArea;
class KodoTerm;
struct SessionUiPreferences
{
QString terminalThemeName = QStringLiteral("Dark");
bool eventsPanelExpanded = false;
int terminalFontPointSize = 0;
bool vncScaleToFit = true;
};
class SessionTab : public QWidget class SessionTab : public QWidget
{ {
Q_OBJECT Q_OBJECT
public: public:
explicit SessionTab(const Profile& profile, QWidget* parent = nullptr); explicit SessionTab(const Profile& profile,
const SessionUiPreferences& preferences,
QWidget* parent = nullptr);
~SessionTab() override; ~SessionTab() override;
QString tabTitle() const; QString tabTitle() const;
void connectSession();
void disconnectSession();
void reconnectSession();
void clearTerminal();
void setTerminalThemeName(const QString& themeName);
QString terminalThemeName() const;
bool supportsThemeSelection() const;
bool supportsClearAction() const;
bool supportsZoom() const;
void zoomIn();
void zoomOut();
void resetZoom();
void setTerminalFontPointSize(int pointSize);
int terminalFontPointSize() const;
bool isEventsPanelExpanded() const;
void setEventsPanelExpanded(bool expanded);
void clearEvents();
void copyEvents() const;
void exportEventsToFile();
bool supportsVncScaleToggle() const;
void setVncScaleToFit(bool scaleToFit);
bool vncScaleToFit() const;
bool awaitingUserInput() const;
signals: signals:
void tabTitleChanged(const QString& title); void tabTitleChanged(const QString& title);
void tabStateChanged(SessionState state);
// Fires whenever the inline username/password prompt bar is shown or
// hidden -- independent of tabStateChanged(), since the backend is
// still just "Connecting" while it's up. Lets SessionWindow mark a
// background tab that needs the user's attention (issue #22).
void awaitingUserInputChanged(bool waiting);
void terminalThemeChanged(const QString& themeName);
void terminalFontSizeChanged(int pointSize);
void eventsPanelVisibilityChanged(bool expanded);
void vncScaleModeChanged(bool scaleToFit);
void requestConnect(const SessionConnectOptions& options); void requestConnect(const SessionConnectOptions& options);
void requestDisconnect(); void requestDisconnect();
void requestReconnect(const SessionConnectOptions& options); void requestReconnect(const SessionConnectOptions& options);
void requestInput(const QString& input);
void requestHostKeyConfirmation(bool trustHost);
void requestTerminalSize(int columns, int rows);
void requestDisplayScale(qreal devicePixelRatio);
void requestKeyEvent(int key,
quint32 nativeScanCode,
const QString& text,
bool pressed,
int modifiers);
void requestMouseMoveEvent(int x, int y);
void requestMouseButtonEvent(int x, int y, int button, bool pressed);
void requestMouseWheelEvent(int x, int y, int deltaX, int deltaY);
void requestSetClipboardText(const QString& text);
void requestProvideUsername(const QString& username);
private slots: private slots:
void onConnectClicked();
void onDisconnectClicked();
void onReconnectClicked();
void onCopyErrorClicked();
void onBackendStateChanged(SessionState state, const QString& message); void onBackendStateChanged(SessionState state, const QString& message);
void onBackendEventLogged(const QString& message); void onBackendEventLogged(const QString& message);
void onBackendConnectionError(const QString& displayMessage, const QString& rawMessage); void onBackendConnectionError(const QString& displayMessage, const QString& rawMessage);
void onBackendOutputReceived(const QString& text);
void onBackendHostKeyConfirmationRequested(const QString& prompt);
void onBackendUsernameRequested(const QString& prompt);
void onBackendRemoteClipboardTextChanged(const QString& text);
void onSystemClipboardChanged();
private: private:
Profile m_profile; Profile m_profile;
QThread* m_backendThread; QThread* m_backendThread;
SessionBackend* m_backend; SessionBackend* m_backend;
bool m_useKodoTermForSsh;
SessionState m_state; SessionState m_state;
QString m_lastError; QString m_lastError;
SessionConnectOptions m_lastConnectOptions;
QString m_terminalThemeName;
int m_terminalFontPointSize;
QString m_lastSyncedClipboardText;
bool m_clipboardSyncSupported;
QLabel* m_statusLabel; KodoTerm* m_sshTerminal;
QLabel* m_errorLabel; RdpDisplayWidget* m_rdpDisplay;
VncDisplayWidget* m_vncDisplay;
QScrollArea* m_vncScrollArea;
TerminalView* m_terminalOutput;
QPlainTextEdit* m_eventLog; QPlainTextEdit* m_eventLog;
QPushButton* m_connectButton; QToolButton* m_toggleEventsButton;
QPushButton* m_disconnectButton; QLineEdit* m_eventFilterInput;
QPushButton* m_reconnectButton; QComboBox* m_eventSeverityFilterInput;
QPushButton* m_copyErrorButton; QToolButton* m_clearEventsButton;
QToolButton* m_exportEventsButton;
QWidget* m_eventsPanel;
QWidget* m_passwordPromptBar;
QLabel* m_passwordPromptIcon;
QLabel* m_passwordPromptLabel;
QLineEdit* m_passwordPromptInput;
QPushButton* m_passwordPromptConnectButton;
QPushButton* m_passwordPromptCancelButton;
std::function<void(std::optional<QString>)> m_passwordPromptCallback;
bool m_awaitingUserInput;
enum class EventSeverity {
Info,
Warning,
Error,
};
struct EventEntry {
QString line;
EventSeverity severity;
};
std::vector<EventEntry> m_eventEntries;
QString m_eventFilter;
EventSeverity m_eventSeverityFilter;
bool m_eventsPanelExpanded;
void setupUi(); void setupUi();
std::optional<SessionConnectOptions> buildConnectOptions(); void requestConnectOptions(std::function<void(std::optional<SessionConnectOptions>)> callback);
void showPasswordPrompt(const QString& labelText,
std::function<void(std::optional<QString>)> callback,
bool maskInput = true);
void hidePasswordPrompt();
bool validateProfileForConnect(); bool validateProfileForConnect();
void appendEvent(const QString& message); void appendEvent(const QString& message);
void setState(SessionState state, const QString& message); void setState(SessionState state, const QString& message);
QString stateSuffix() const; QString stateSuffix() const;
void refreshActionButtons(); void refreshActionButtons();
void setPanelExpanded(QToolButton* button, QWidget* panel, const QString& name, bool expanded);
bool startSshTerminal(const SessionConnectOptions& options);
void applyTerminalTheme(const QString& themeName);
void refreshEventLogView();
static EventSeverity classifyEventSeverity(const QString& message);
}; };
#endif #endif
+355 -6
View File
@@ -1,14 +1,66 @@
#include "session_window.h" #include "session_window.h"
#include "about_dialog.h"
#include "profiles_window.h"
#include "user_guide_dialog.h"
#include <QApplication>
#include "session_tab.h" #include "session_tab.h"
#include <QAction>
#include <QColor>
#include <QInputDialog>
#include <QMenu>
#include <QMenuBar>
#include <QPalette>
#include <QSettings>
#include <QStringList>
#include <QTabBar>
#include <QTabWidget> #include <QTabWidget>
SessionWindow::SessionWindow(const Profile& profile, QWidget* parent) namespace {
: QMainWindow(parent), m_tabs(new QTabWidget(this)) QColor tabColorForState(SessionState state, const QPalette& palette)
{ {
setWindowTitle(QStringLiteral("OrbitHub Session - %1").arg(profile.name)); switch (state) {
case SessionState::Disconnected:
return palette.color(QPalette::WindowText);
case SessionState::Connecting:
return QColor(QStringLiteral("#9a6700"));
case SessionState::Connected:
return QColor(QStringLiteral("#2e7d32"));
case SessionState::Failed:
return QColor(QStringLiteral("#c62828"));
}
return palette.color(QPalette::WindowText);
}
// Distinct from all four tabColorForState() colors -- a tab awaiting a
// username/password prompt response needs to stand out even from a
// tab that's merely "Connecting" (issue #22), including when it isn't
// the one currently in view. #6a1b9a (a much darker violet) was tried
// first and reported unreadable against a dark-theme tab bar -- its
// perceived luminance is well below the other three colors above, which
// this one is tuned to roughly match so it reads about as well as they
// do in both light and dark themes.
QColor awaitingInputTabColor()
{
return QColor(QStringLiteral("#ab47bc"));
}
QStringList terminalThemeNames()
{
return {QStringLiteral("Dark"), QStringLiteral("Light"), QStringLiteral("Solarized Dark")};
}
}
SessionWindow::SessionWindow(QWidget* parent)
: QMainWindow(parent), m_tabs(new QTabWidget(this)), m_profilesWidget(nullptr)
{
loadUiPreferences();
setWindowTitle(QStringLiteral("OrbitHub"));
resize(1080, 760); resize(1080, 760);
setWindowIcon(QApplication::windowIcon());
m_tabs->setTabsClosable(true); m_tabs->setTabsClosable(true);
connect(m_tabs, connect(m_tabs,
@@ -16,27 +68,289 @@ SessionWindow::SessionWindow(const Profile& profile, QWidget* parent)
this, this,
[this](int index) { [this](int index) {
QWidget* tab = m_tabs->widget(index); QWidget* tab = m_tabs->widget(index);
if (auto* sessionTab = qobject_cast<SessionTab*>(tab)) {
sessionTab->disconnectSession();
m_tabStates.remove(sessionTab);
}
m_tabs->removeTab(index); m_tabs->removeTab(index);
delete tab; delete tab;
if (m_tabs->count() == 0) { if (sessionTabCount() == 0) {
close(); setWindowTitle(QStringLiteral("OrbitHub"));
}
});
m_tabs->tabBar()->setContextMenuPolicy(Qt::CustomContextMenu);
connect(m_tabs->tabBar(),
&QWidget::customContextMenuRequested,
this,
[this](const QPoint& pos) {
const int index = m_tabs->tabBar()->tabAt(pos);
if (index < 0) {
return;
}
auto* tab = qobject_cast<SessionTab*>(m_tabs->widget(index));
if (tab == nullptr) {
return;
}
QMenu menu(this);
QAction* disconnectAction = menu.addAction(QStringLiteral("Disconnect"));
QAction* reconnectAction = menu.addAction(QStringLiteral("Reconnect"));
QAction* toggleEventsAction = menu.addAction(
tab->isEventsPanelExpanded() ? QStringLiteral("Hide Events")
: QStringLiteral("Show Events"));
QAction* copyEventsAction = menu.addAction(QStringLiteral("Copy Events"));
QAction* exportEventsAction = menu.addAction(QStringLiteral("Export Events"));
QAction* clearEventsAction = menu.addAction(QStringLiteral("Clear Events"));
QList<QAction*> themeActions;
if (tab->supportsThemeSelection()) {
menu.addSeparator();
QMenu* themeMenu = menu.addMenu(QStringLiteral("Theme"));
const QString currentTheme = tab->terminalThemeName();
for (const QString& themeName : terminalThemeNames()) {
QAction* themeAction = themeMenu->addAction(themeName);
themeAction->setCheckable(true);
themeAction->setChecked(
themeName.compare(currentTheme, Qt::CaseInsensitive) == 0);
themeActions.append(themeAction);
}
}
QAction* clearAction = nullptr;
if (tab->supportsClearAction()) {
clearAction = menu.addAction(QStringLiteral("Clear"));
}
QAction* zoomInAction = nullptr;
QAction* zoomOutAction = nullptr;
QAction* resetZoomAction = nullptr;
QAction* setFontSizeAction = nullptr;
if (tab->supportsZoom()) {
menu.addSeparator();
zoomInAction = menu.addAction(QStringLiteral("Increase Font Size"));
zoomOutAction = menu.addAction(QStringLiteral("Decrease Font Size"));
resetZoomAction = menu.addAction(QStringLiteral("Reset Font Size"));
setFontSizeAction = menu.addAction(QStringLiteral("Set Font Size..."));
}
QAction* scaleToFitAction = nullptr;
QAction* actualSizeAction = nullptr;
if (tab->supportsVncScaleToggle()) {
menu.addSeparator();
QMenu* displayMenu = menu.addMenu(QStringLiteral("Display Mode"));
scaleToFitAction = displayMenu->addAction(QStringLiteral("Scale to Fit"));
scaleToFitAction->setCheckable(true);
scaleToFitAction->setChecked(tab->vncScaleToFit());
actualSizeAction = displayMenu->addAction(
QStringLiteral("Actual Size (Scrollbars)"));
actualSizeAction->setCheckable(true);
actualSizeAction->setChecked(!tab->vncScaleToFit());
}
QAction* chosen = menu.exec(m_tabs->tabBar()->mapToGlobal(pos));
if (chosen == disconnectAction) {
tab->disconnectSession();
} else if (chosen == reconnectAction) {
tab->reconnectSession();
} else if (chosen == toggleEventsAction) {
tab->setEventsPanelExpanded(!tab->isEventsPanelExpanded());
} else if (chosen == copyEventsAction) {
tab->copyEvents();
} else if (chosen == exportEventsAction) {
tab->exportEventsToFile();
} else if (chosen == clearEventsAction) {
tab->clearEvents();
} else if (clearAction != nullptr && chosen == clearAction) {
tab->clearTerminal();
} else if (zoomInAction != nullptr && chosen == zoomInAction) {
tab->zoomIn();
} else if (zoomOutAction != nullptr && chosen == zoomOutAction) {
tab->zoomOut();
} else if (resetZoomAction != nullptr && chosen == resetZoomAction) {
tab->resetZoom();
} else if (setFontSizeAction != nullptr && chosen == setFontSizeAction) {
bool accepted = false;
const int currentSize =
tab->terminalFontPointSize() > 0 ? tab->terminalFontPointSize() : 10;
const int newSize = QInputDialog::getInt(this,
QStringLiteral("Set Font Size"),
QStringLiteral("Font size (points):"),
currentSize,
6,
72,
1,
&accepted);
if (accepted) {
tab->setTerminalFontPointSize(newSize);
}
} else if (scaleToFitAction != nullptr && chosen == scaleToFitAction) {
tab->setVncScaleToFit(true);
} else if (actualSizeAction != nullptr && chosen == actualSizeAction) {
tab->setVncScaleToFit(false);
} else {
for (QAction* themeAction : themeActions) {
if (chosen == themeAction) {
tab->setTerminalThemeName(themeAction->text());
break;
}
}
} }
}); });
QMenu* fileMenu = menuBar()->addMenu(QStringLiteral("File"));
QAction* newProfileAction = fileMenu->addAction(QStringLiteral("New Profile"));
QAction* newFolderAction = fileMenu->addAction(QStringLiteral("New Folder"));
fileMenu->addSeparator();
QAction* importProfilesAction = fileMenu->addAction(QStringLiteral("Import Profiles..."));
QAction* exportProfilesAction = fileMenu->addAction(QStringLiteral("Export Profiles..."));
QAction* importMRemoteNGAction = fileMenu->addAction(QStringLiteral("Import from mRemoteNG..."));
fileMenu->addSeparator();
QAction* quitAction = fileMenu->addAction(QStringLiteral("Quit"));
connect(newProfileAction,
&QAction::triggered,
this,
[this]() { m_profilesWidget->createProfileInCurrentContext(); });
connect(newFolderAction,
&QAction::triggered,
this,
[this]() { m_profilesWidget->createFolderInCurrentContext(); });
connect(importProfilesAction,
&QAction::triggered,
this,
[this]() { m_profilesWidget->importProfiles(); });
connect(exportProfilesAction,
&QAction::triggered,
this,
[this]() { m_profilesWidget->exportProfiles(); });
connect(importMRemoteNGAction,
&QAction::triggered,
this,
[this]() { m_profilesWidget->importFromMRemoteNG(); });
connect(quitAction, &QAction::triggered, this, []() { qApp->quit(); });
QMenu* helpMenu = menuBar()->addMenu(QStringLiteral("Help"));
QAction* userGuideAction = helpMenu->addAction(QStringLiteral("User Guide"));
connect(userGuideAction,
&QAction::triggered,
this,
[this]() {
UserGuideDialog dialog(this);
dialog.exec();
});
QAction* aboutAction = helpMenu->addAction(QStringLiteral("About OrbitHub"));
connect(aboutAction,
&QAction::triggered,
this,
[this]() {
AboutDialog dialog(this);
dialog.exec();
});
setCentralWidget(m_tabs); setCentralWidget(m_tabs);
m_profilesWidget = new ProfilesWindow(this);
const int profilesIndex = m_tabs->addTab(m_profilesWidget, QStringLiteral("Profiles"));
m_tabs->tabBar()->setTabButton(profilesIndex, QTabBar::LeftSide, nullptr);
m_tabs->tabBar()->setTabButton(profilesIndex, QTabBar::RightSide, nullptr);
connect(m_profilesWidget,
&ProfilesWindow::connectRequested,
this,
&SessionWindow::addSessionTab);
}
void SessionWindow::openProfile(const Profile& profile)
{
addSessionTab(profile); addSessionTab(profile);
} }
void SessionWindow::addSessionTab(const Profile& profile) void SessionWindow::addSessionTab(const Profile& profile)
{ {
auto* tab = new SessionTab(profile, this); auto* tab = new SessionTab(profile, m_preferences, this);
const int index = m_tabs->addTab(tab, tab->tabTitle()); const int index = m_tabs->addTab(tab, tab->tabTitle());
m_tabs->setCurrentIndex(index); m_tabs->setCurrentIndex(index);
if (sessionTabCount() > 1) {
setWindowTitle(QStringLiteral("OrbitHub Sessions"));
} else {
setWindowTitle(QStringLiteral("OrbitHub Session - %1").arg(profile.name));
}
m_tabStates.insert(tab, SessionState::Disconnected);
m_tabs->tabBar()->setTabTextColor(
index, tabColorForState(SessionState::Disconnected, m_tabs->palette()));
connect(tab, connect(tab,
&SessionTab::tabTitleChanged, &SessionTab::tabTitleChanged,
this, this,
[this, tab](const QString& title) { updateTabTitle(tab, title); }); [this, tab](const QString& title) { updateTabTitle(tab, title); });
connect(tab,
&SessionTab::tabStateChanged,
this,
[this, tab](SessionState state) {
m_tabStates.insert(tab, state);
if (tab->awaitingUserInput()) {
// Keep the "needs input" color on top -- it'll be
// restored to reflect this state once the prompt
// resolves (see awaitingUserInputChanged below).
return;
}
for (int i = 0; i < m_tabs->count(); ++i) {
if (m_tabs->widget(i) == tab) {
m_tabs->tabBar()->setTabTextColor(
i, tabColorForState(state, m_tabs->palette()));
return;
}
}
});
connect(tab,
&SessionTab::awaitingUserInputChanged,
this,
[this, tab](bool waiting) {
for (int i = 0; i < m_tabs->count(); ++i) {
if (m_tabs->widget(i) != tab) {
continue;
}
const QColor color = waiting
? awaitingInputTabColor()
: tabColorForState(m_tabStates.value(tab, SessionState::Disconnected),
m_tabs->palette());
m_tabs->tabBar()->setTabTextColor(i, color);
return;
}
});
connect(tab,
&SessionTab::terminalThemeChanged,
this,
[this](const QString& themeName) {
m_preferences.terminalThemeName = themeName.trimmed().isEmpty()
? QStringLiteral("Dark")
: themeName.trimmed();
saveUiPreferences();
});
connect(tab,
&SessionTab::terminalFontSizeChanged,
this,
[this](int pointSize) {
if (pointSize <= 0) {
return;
}
m_preferences.terminalFontPointSize = pointSize;
saveUiPreferences();
});
connect(tab,
&SessionTab::eventsPanelVisibilityChanged,
this,
[this](bool expanded) {
m_preferences.eventsPanelExpanded = expanded;
saveUiPreferences();
});
connect(tab,
&SessionTab::vncScaleModeChanged,
this,
[this](bool scaleToFit) {
m_preferences.vncScaleToFit = scaleToFit;
saveUiPreferences();
});
} }
void SessionWindow::updateTabTitle(SessionTab* tab, const QString& title) void SessionWindow::updateTabTitle(SessionTab* tab, const QString& title)
@@ -48,3 +362,38 @@ void SessionWindow::updateTabTitle(SessionTab* tab, const QString& title)
} }
} }
} }
int SessionWindow::sessionTabCount() const
{
return m_tabs->count() - 1;
}
void SessionWindow::loadUiPreferences()
{
QSettings settings;
m_preferences.terminalThemeName =
settings.value(QStringLiteral("session/defaultTerminalTheme"), QStringLiteral("Dark"))
.toString()
.trimmed();
if (m_preferences.terminalThemeName.isEmpty()) {
m_preferences.terminalThemeName = QStringLiteral("Dark");
}
m_preferences.eventsPanelExpanded =
settings.value(QStringLiteral("session/eventsPanelExpanded"), false).toBool();
m_preferences.terminalFontPointSize =
settings.value(QStringLiteral("session/terminalFontPointSize"), 0).toInt();
m_preferences.vncScaleToFit =
settings.value(QStringLiteral("session/vncScaleToFit"), true).toBool();
}
void SessionWindow::saveUiPreferences() const
{
QSettings settings;
settings.setValue(QStringLiteral("session/defaultTerminalTheme"),
m_preferences.terminalThemeName);
settings.setValue(QStringLiteral("session/eventsPanelExpanded"),
m_preferences.eventsPanelExpanded);
settings.setValue(QStringLiteral("session/terminalFontPointSize"),
m_preferences.terminalFontPointSize);
settings.setValue(QStringLiteral("session/vncScaleToFit"), m_preferences.vncScaleToFit);
}
+14 -2
View File
@@ -2,24 +2,36 @@
#define ORBITHUB_SESSION_WINDOW_H #define ORBITHUB_SESSION_WINDOW_H
#include "profile_repository.h" #include "profile_repository.h"
#include "session_tab.h"
#include <QHash>
#include <QMainWindow> #include <QMainWindow>
class QTabWidget; class QTabWidget;
class SessionTab; class ProfilesWindow;
class SessionWindow : public QMainWindow class SessionWindow : public QMainWindow
{ {
Q_OBJECT Q_OBJECT
public: public:
explicit SessionWindow(const Profile& profile, QWidget* parent = nullptr); explicit SessionWindow(QWidget* parent = nullptr);
void openProfile(const Profile& profile);
private: private:
QTabWidget* m_tabs; QTabWidget* m_tabs;
ProfilesWindow* m_profilesWidget;
SessionUiPreferences m_preferences;
// Last known connection state per tab, so the tab color can be
// restored correctly once an awaitingUserInputChanged(false) fires
// (that signal is orthogonal to SessionState -- see session_tab.h).
QHash<SessionTab*, SessionState> m_tabStates;
void addSessionTab(const Profile& profile); void addSessionTab(const Profile& profile);
void updateTabTitle(SessionTab* tab, const QString& title); void updateTabTitle(SessionTab* tab, const QString& title);
void loadUiPreferences();
void saveUiPreferences() const;
int sessionTabCount() const;
}; };
#endif #endif
+160 -38
View File
@@ -7,33 +7,26 @@
#include <QTextStream> #include <QTextStream>
#include <QUuid> #include <QUuid>
namespace {
QString escapeForShellSingleQuotes(const QString& value)
{
QString escaped = value;
escaped.replace(QStringLiteral("'"), QStringLiteral("'\"'\"'"));
return escaped;
}
QString escapedForWindowsEcho(const QString& value)
{
QString escaped = value;
escaped.replace(QStringLiteral("^"), QStringLiteral("^^"));
escaped.replace(QStringLiteral("&"), QStringLiteral("^&"));
escaped.replace(QStringLiteral("|"), QStringLiteral("^|"));
escaped.replace(QStringLiteral("<"), QStringLiteral("^<"));
escaped.replace(QStringLiteral(">"), QStringLiteral("^>"));
return escaped;
}
}
SshSessionBackend::SshSessionBackend(const Profile& profile, QObject* parent) SshSessionBackend::SshSessionBackend(const Profile& profile, QObject* parent)
: SshSessionBackend(profile, QStringLiteral("ssh"), parent)
{
}
SshSessionBackend::SshSessionBackend(const Profile& profile,
const QString& sshProgramOverride,
QObject* parent)
: SessionBackend(profile, parent), : SessionBackend(profile, parent),
m_process(new QProcess(this)), m_process(new QProcess(this)),
m_connectedProbeTimer(new QTimer(this)), m_connectedProbeTimer(new QTimer(this)),
m_state(SessionState::Disconnected), m_state(SessionState::Disconnected),
m_userInitiatedDisconnect(false), m_userInitiatedDisconnect(false),
m_reconnectPending(false) m_reconnectPending(false),
m_waitingForPasswordPrompt(false),
m_waitingForHostKeyConfirmation(false),
m_passwordSubmitted(false),
m_terminalColumns(0),
m_terminalRows(0),
m_sshProgram(sshProgramOverride)
{ {
m_connectedProbeTimer->setSingleShot(true); m_connectedProbeTimer->setSingleShot(true);
@@ -46,6 +39,10 @@ SshSessionBackend::SshSessionBackend(const Profile& profile, QObject* parent)
qOverload<int, QProcess::ExitStatus>(&QProcess::finished), qOverload<int, QProcess::ExitStatus>(&QProcess::finished),
this, this,
&SshSessionBackend::onProcessFinished); &SshSessionBackend::onProcessFinished);
connect(m_process,
&QProcess::readyReadStandardOutput,
this,
&SshSessionBackend::onReadyReadStandardOutput);
connect(m_process, connect(m_process,
&QProcess::readyReadStandardError, &QProcess::readyReadStandardError,
this, this,
@@ -75,6 +72,10 @@ void SshSessionBackend::connectSession(const SessionConnectOptions& options)
m_userInitiatedDisconnect = false; m_userInitiatedDisconnect = false;
m_reconnectPending = false; m_reconnectPending = false;
m_lastRawError.clear(); m_lastRawError.clear();
m_activeOptions = options;
m_waitingForPasswordPrompt = false;
m_waitingForHostKeyConfirmation = false;
m_passwordSubmitted = false;
if (!startSshProcess(options)) { if (!startSshProcess(options)) {
return; return;
@@ -123,6 +124,45 @@ void SshSessionBackend::reconnectSession(const SessionConnectOptions& options)
m_process->terminate(); m_process->terminate();
} }
void SshSessionBackend::sendInput(const QString& input)
{
if (m_process->state() != QProcess::Running) {
emit eventLogged(QStringLiteral("Input ignored: session is not running."));
return;
}
if (input.isEmpty()) {
return;
}
m_process->write(input.toUtf8());
}
void SshSessionBackend::confirmHostKey(bool trustHost)
{
if (m_process->state() != QProcess::Running || !m_waitingForHostKeyConfirmation) {
return;
}
m_waitingForHostKeyConfirmation = false;
const QString response = trustHost ? QStringLiteral("yes\n") : QStringLiteral("no\n");
m_process->write(response.toUtf8());
emit eventLogged(trustHost
? QStringLiteral("Host key accepted by user.")
: QStringLiteral("Host key rejected by user."));
}
void SshSessionBackend::updateTerminalSize(int columns, int rows)
{
m_terminalColumns = columns;
m_terminalRows = rows;
if (m_state == SessionState::Connected) {
applyTerminalSizeIfAvailable();
}
}
void SshSessionBackend::onProcessStarted() void SshSessionBackend::onProcessStarted()
{ {
emit eventLogged(QStringLiteral("ssh process started.")); emit eventLogged(QStringLiteral("ssh process started."));
@@ -178,6 +218,21 @@ void SshSessionBackend::onProcessFinished(int exitCode, QProcess::ExitStatus)
setState(SessionState::Disconnected, QStringLiteral("SSH session ended.")); setState(SessionState::Disconnected, QStringLiteral("SSH session ended."));
} }
void SshSessionBackend::onReadyReadStandardOutput()
{
const QString chunk = QString::fromUtf8(m_process->readAllStandardOutput());
if (chunk.isEmpty()) {
return;
}
emit outputReceived(chunk);
if (m_state == SessionState::Connecting && !m_waitingForHostKeyConfirmation
&& !m_waitingForPasswordPrompt) {
setState(SessionState::Connected, QStringLiteral("SSH session established."));
}
}
void SshSessionBackend::onReadyReadStandardError() void SshSessionBackend::onReadyReadStandardError()
{ {
const QString chunk = QString::fromUtf8(m_process->readAllStandardError()); const QString chunk = QString::fromUtf8(m_process->readAllStandardError());
@@ -186,10 +241,40 @@ void SshSessionBackend::onReadyReadStandardError()
} }
m_lastRawError += chunk; m_lastRawError += chunk;
emit outputReceived(chunk);
const QStringList lines = chunk.split(QLatin1Char('\n'), Qt::SkipEmptyParts); const QStringList lines = chunk.split(QLatin1Char('\n'), Qt::SkipEmptyParts);
for (const QString& line : lines) { for (const QString& line : lines) {
emit eventLogged(line.trimmed()); const QString trimmed = line.trimmed();
if (!trimmed.isEmpty()) {
emit eventLogged(trimmed);
}
if (trimmed.contains(QStringLiteral("Are you sure you want to continue connecting"),
Qt::CaseInsensitive)
&& !m_waitingForHostKeyConfirmation) {
m_waitingForHostKeyConfirmation = true;
emit eventLogged(QStringLiteral("Awaiting host key confirmation from user."));
emit hostKeyConfirmationRequested(trimmed);
continue;
}
if (trimmed.contains(QStringLiteral("password:"), Qt::CaseInsensitive)
&& profile().authMode.compare(QStringLiteral("Password"), Qt::CaseInsensitive) == 0
&& !m_passwordSubmitted) {
if (m_activeOptions.password.isEmpty()) {
const QString message = QStringLiteral("Password prompt received but no password is available.");
setState(SessionState::Failed, message);
emit connectionError(message, trimmed);
return;
}
m_waitingForPasswordPrompt = false;
m_passwordSubmitted = true;
m_process->write((m_activeOptions.password + QStringLiteral("\n")).toUtf8());
emit eventLogged(QStringLiteral("Password prompt received; credentials submitted."));
continue;
}
} }
} }
@@ -199,7 +284,8 @@ void SshSessionBackend::onConnectedProbeTimeout()
return; return;
} }
if (m_process->state() == QProcess::Running) { if (m_process->state() == QProcess::Running && !m_waitingForHostKeyConfirmation
&& !m_waitingForPasswordPrompt) {
setState(SessionState::Connected, QStringLiteral("SSH session established.")); setState(SessionState::Connected, QStringLiteral("SSH session established."));
} }
} }
@@ -209,6 +295,10 @@ void SshSessionBackend::setState(SessionState state, const QString& message)
m_state = state; m_state = state;
emit stateChanged(state, message); emit stateChanged(state, message);
emit eventLogged(message); emit eventLogged(message);
if (m_state == SessionState::Connected) {
applyTerminalSizeIfAvailable();
}
} }
bool SshSessionBackend::startSshProcess(const SessionConnectOptions& options) bool SshSessionBackend::startSshProcess(const SessionConnectOptions& options)
@@ -229,12 +319,9 @@ bool SshSessionBackend::startSshProcess(const SessionConnectOptions& options)
return false; return false;
} }
cleanupAskPassScript();
QStringList args; QStringList args;
args << QStringLiteral("-N") << QStringLiteral("-T") << QStringLiteral("-p") args << QStringLiteral("-tt") << QStringLiteral("-p") << QString::number(p.port)
<< QString::number(p.port) << QStringLiteral("-o") << QStringLiteral("-o") << QStringLiteral("ConnectTimeout=12") << QStringLiteral("-o")
<< QStringLiteral("ConnectTimeout=12") << QStringLiteral("-o")
<< QStringLiteral("ServerAliveInterval=20") << QStringLiteral("-o") << QStringLiteral("ServerAliveInterval=20") << QStringLiteral("-o")
<< QStringLiteral("ServerAliveCountMax=2"); << QStringLiteral("ServerAliveCountMax=2");
@@ -248,6 +335,8 @@ bool SshSessionBackend::startSshProcess(const SessionConnectOptions& options)
<< QStringLiteral("UserKnownHostsFile=%1").arg(knownHostsFileForNullDevice()); << QStringLiteral("UserKnownHostsFile=%1").arg(knownHostsFileForNullDevice());
} else if (policy.compare(QStringLiteral("Accept New"), Qt::CaseInsensitive) == 0) { } else if (policy.compare(QStringLiteral("Accept New"), Qt::CaseInsensitive) == 0) {
args << QStringLiteral("-o") << QStringLiteral("StrictHostKeyChecking=accept-new"); args << QStringLiteral("-o") << QStringLiteral("StrictHostKeyChecking=accept-new");
} else if (policy.compare(QStringLiteral("Ask"), Qt::CaseInsensitive) == 0) {
args << QStringLiteral("-o") << QStringLiteral("StrictHostKeyChecking=ask");
} else { } else {
args << QStringLiteral("-o") << QStringLiteral("StrictHostKeyChecking=yes"); args << QStringLiteral("-o") << QStringLiteral("StrictHostKeyChecking=yes");
} }
@@ -265,6 +354,7 @@ bool SshSessionBackend::startSshProcess(const SessionConnectOptions& options)
args << QStringLiteral("-o") << QStringLiteral("PreferredAuthentications=password") args << QStringLiteral("-o") << QStringLiteral("PreferredAuthentications=password")
<< QStringLiteral("-o") << QStringLiteral("PubkeyAuthentication=no") << QStringLiteral("-o") << QStringLiteral("PubkeyAuthentication=no")
<< QStringLiteral("-o") << QStringLiteral("NumberOfPasswordPrompts=1"); << QStringLiteral("-o") << QStringLiteral("NumberOfPasswordPrompts=1");
m_waitingForPasswordPrompt = false;
QString askPassError; QString askPassError;
if (!configureAskPass(options, environment, askPassError)) { if (!configureAskPass(options, environment, askPassError)) {
@@ -298,13 +388,20 @@ bool SshSessionBackend::startSshProcess(const SessionConnectOptions& options)
<< QStringLiteral("PasswordAuthentication=no"); << QStringLiteral("PasswordAuthentication=no");
} }
const QString target = p.username.trimmed().isEmpty() // options.username carries a value prompted for at connect time (see
// SessionTab::requestConnectOptions()) when the saved profile's own
// username was blank; profile().username never sees that edit since
// the backend's Profile copy was captured at construction time.
const QString username = options.username.trimmed().isEmpty()
? p.username.trimmed()
: options.username.trimmed();
const QString target = username.isEmpty()
? p.host.trimmed() ? p.host.trimmed()
: QStringLiteral("%1@%2").arg(p.username.trimmed(), p.host.trimmed()); : QStringLiteral("%1@%2").arg(username, p.host.trimmed());
args << target; args << target;
m_process->setProcessEnvironment(environment); m_process->setProcessEnvironment(environment);
m_process->setProgram(QStringLiteral("ssh")); m_process->setProgram(m_sshProgram);
m_process->setArguments(args); m_process->setArguments(args);
m_process->setProcessChannelMode(QProcess::SeparateChannels); m_process->setProcessChannelMode(QProcess::SeparateChannels);
@@ -346,10 +443,11 @@ bool SshSessionBackend::configureAskPass(const SessionConnectOptions& options,
QTextStream out(&script); QTextStream out(&script);
#ifdef Q_OS_WIN #ifdef Q_OS_WIN
out << "@echo off\r\n"; out << "@echo off\r\n";
out << "echo " << escapedForWindowsEcho(options.password) << "\r\n"; out << "echo " << options.password << "\r\n";
#else #else
const QString escapedPassword = escapeForShellSingleQuotes(options.password);
out << "#!/bin/sh\n"; out << "#!/bin/sh\n";
out << "printf '%s\\n' '" << escapeForShellSingleQuotes(options.password) << "'\n"; out << "printf '%s\\n' '" << escapedPassword << "'\n";
#endif #endif
out.flush(); out.flush();
script.close(); script.close();
@@ -368,7 +466,6 @@ bool SshSessionBackend::configureAskPass(const SessionConnectOptions& options,
if (!environment.contains(QStringLiteral("DISPLAY"))) { if (!environment.contains(QStringLiteral("DISPLAY"))) {
environment.insert(QStringLiteral("DISPLAY"), QStringLiteral(":0")); environment.insert(QStringLiteral("DISPLAY"), QStringLiteral(":0"));
} }
return true; return true;
} }
@@ -380,7 +477,7 @@ void SshSessionBackend::cleanupAskPassScript()
} }
} }
QString SshSessionBackend::mapSshError(const QString& rawError) const QString SshSessionBackend::mapSshError(const QString& rawError)
{ {
const QString raw = rawError.trimmed(); const QString raw = rawError.trimmed();
if (raw.contains(QStringLiteral("Permission denied"), Qt::CaseInsensitive)) { if (raw.contains(QStringLiteral("Permission denied"), Qt::CaseInsensitive)) {
@@ -407,11 +504,11 @@ QString SshSessionBackend::mapSshError(const QString& rawError) const
return QStringLiteral("Private key file is not accessible."); return QStringLiteral("Private key file is not accessible.");
} }
if (raw.contains(QStringLiteral("No such file or directory"), Qt::CaseInsensitive)) { if (raw.contains(QStringLiteral("No such file or directory"), Qt::CaseInsensitive)) {
if (raw.contains(QStringLiteral("ssh-askpass"), Qt::CaseInsensitive)) {
return QStringLiteral("SSH password helper is missing or failed to launch.");
}
return QStringLiteral("Required file was not found."); return QStringLiteral("Required file was not found.");
} }
if (raw.contains(QStringLiteral("Text file busy"), Qt::CaseInsensitive)) {
return QStringLiteral("Credential helper could not start (text file busy). Retry the connection.");
}
if (raw.isEmpty()) { if (raw.isEmpty()) {
return QStringLiteral("SSH connection failed for an unknown reason."); return QStringLiteral("SSH connection failed for an unknown reason.");
} }
@@ -419,6 +516,13 @@ QString SshSessionBackend::mapSshError(const QString& rawError) const
return QStringLiteral("SSH connection failed."); return QStringLiteral("SSH connection failed.");
} }
QString SshSessionBackend::escapeForShellSingleQuotes(const QString& value)
{
QString escaped = value;
escaped.replace(QStringLiteral("'"), QStringLiteral("'\"'\"'"));
return escaped;
}
QString SshSessionBackend::knownHostsFileForNullDevice() const QString SshSessionBackend::knownHostsFileForNullDevice() const
{ {
#ifdef Q_OS_WIN #ifdef Q_OS_WIN
@@ -427,3 +531,21 @@ QString SshSessionBackend::knownHostsFileForNullDevice() const
return QStringLiteral("/dev/null"); return QStringLiteral("/dev/null");
#endif #endif
} }
void SshSessionBackend::applyTerminalSizeIfAvailable()
{
if (m_process->state() != QProcess::Running) {
return;
}
if (m_terminalColumns <= 0 || m_terminalRows <= 0) {
return;
}
const QString command = QStringLiteral("stty cols %1 rows %2\\n")
.arg(m_terminalColumns)
.arg(m_terminalRows);
m_process->write(command.toUtf8());
emit eventLogged(
QStringLiteral("Applied terminal size: %1x%2").arg(m_terminalColumns).arg(m_terminalRows));
}
+20 -1
View File
@@ -13,17 +13,29 @@ class SshSessionBackend : public SessionBackend
public: public:
explicit SshSessionBackend(const Profile& profile, QObject* parent = nullptr); explicit SshSessionBackend(const Profile& profile, QObject* parent = nullptr);
// Test-only: overrides the executable launched instead of "ssh", so
// tests can point it at a controllable fixture script.
SshSessionBackend(const Profile& profile, const QString& sshProgramOverride, QObject* parent);
~SshSessionBackend() override; ~SshSessionBackend() override;
// Pure, state-free helpers exposed as public statics purely so tests
// can exercise them directly without spinning up a real ssh process.
static QString mapSshError(const QString& rawError);
static QString escapeForShellSingleQuotes(const QString& value);
public slots: public slots:
void connectSession(const SessionConnectOptions& options) override; void connectSession(const SessionConnectOptions& options) override;
void disconnectSession() override; void disconnectSession() override;
void reconnectSession(const SessionConnectOptions& options) 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;
private slots: private slots:
void onProcessStarted(); void onProcessStarted();
void onProcessErrorOccurred(QProcess::ProcessError error); void onProcessErrorOccurred(QProcess::ProcessError error);
void onProcessFinished(int exitCode, QProcess::ExitStatus exitStatus); void onProcessFinished(int exitCode, QProcess::ExitStatus exitStatus);
void onReadyReadStandardOutput();
void onReadyReadStandardError(); void onReadyReadStandardError();
void onConnectedProbeTimeout(); void onConnectedProbeTimeout();
@@ -34,8 +46,15 @@ private:
bool m_userInitiatedDisconnect; bool m_userInitiatedDisconnect;
bool m_reconnectPending; bool m_reconnectPending;
SessionConnectOptions m_reconnectOptions; SessionConnectOptions m_reconnectOptions;
SessionConnectOptions m_activeOptions;
QString m_lastRawError; QString m_lastRawError;
QString m_askPassScriptPath; QString m_askPassScriptPath;
bool m_waitingForPasswordPrompt;
bool m_waitingForHostKeyConfirmation;
bool m_passwordSubmitted;
int m_terminalColumns;
int m_terminalRows;
QString m_sshProgram;
void setState(SessionState state, const QString& message); void setState(SessionState state, const QString& message);
bool startSshProcess(const SessionConnectOptions& options); bool startSshProcess(const SessionConnectOptions& options);
@@ -43,8 +62,8 @@ private:
QProcessEnvironment& environment, QProcessEnvironment& environment,
QString& error); QString& error);
void cleanupAskPassScript(); void cleanupAskPassScript();
QString mapSshError(const QString& rawError) const;
QString knownHostsFileForNullDevice() const; QString knownHostsFileForNullDevice() const;
void applyTerminalSizeIfAvailable();
}; };
#endif #endif
+536
View File
@@ -0,0 +1,536 @@
#include "terminal_view.h"
#include <QApplication>
#include <QClipboard>
#include <QColor>
#include <QFocusEvent>
#include <QFontMetrics>
#include <QKeyEvent>
#include <QResizeEvent>
#include <QTimer>
#include <QTextCursor>
#include <algorithm>
namespace {
QString normalizedThemeName(const QString& value)
{
return value.trimmed().toLower();
}
}
TerminalView::TerminalView(QWidget* parent)
: QTextEdit(parent),
m_bold(false),
m_hasFgColor(false),
m_hasBgColor(false)
{
setReadOnly(false);
setUndoRedoEnabled(false);
setAcceptRichText(false);
setLineWrapMode(QTextEdit::NoWrap);
setContextMenuPolicy(Qt::NoContextMenu);
setCursorWidth(2);
document()->setMaximumBlockCount(4000);
applyThemePalette(paletteByName(QStringLiteral("Dark")));
resetSgrState();
QTimer::singleShot(0, this, [this]() {
moveCursor(QTextCursor::End);
emitTerminalSize();
});
}
QStringList TerminalView::themeNames()
{
return {QStringLiteral("Dark"), QStringLiteral("Light"), QStringLiteral("Solarized Dark")};
}
void TerminalView::setThemeName(const QString& themeName)
{
applyThemePalette(paletteByName(themeName));
}
void TerminalView::setFontPointSize(int pointSize)
{
QFont updatedFont = font();
updatedFont.setPointSize(pointSize);
setFont(updatedFont);
emitTerminalSize();
}
void TerminalView::appendTerminalData(const QString& data)
{
if (data.isEmpty()) {
return;
}
const QString merged = m_pendingEscape + data;
m_pendingEscape.clear();
QString plainBuffer;
for (int i = 0; i < merged.size();) {
const QChar ch = merged.at(i);
if (ch == QChar::fromLatin1('\x1b')) {
if (!plainBuffer.isEmpty()) {
appendTextChunk(plainBuffer);
plainBuffer.clear();
}
if (i + 1 >= merged.size()) {
m_pendingEscape = merged.mid(i);
break;
}
if (merged.at(i + 1) != QChar::fromLatin1('[')) {
i += 2;
continue;
}
int end = i + 2;
while (end < merged.size()) {
const ushort c = merged.at(end).unicode();
if (c >= 0x40 && c <= 0x7e) {
break;
}
++end;
}
if (end >= merged.size()) {
m_pendingEscape = merged.mid(i);
break;
}
const QChar finalByte = merged.at(end);
const QString params = merged.mid(i + 2, end - (i + 2));
if (finalByte == QChar::fromLatin1('m')) {
handleSgrSequence(params);
} else if (finalByte == QChar::fromLatin1('J')) {
if (params.isEmpty() || params == QStringLiteral("2")) {
clear();
}
}
i = end + 1;
continue;
}
if (ch == QChar::fromLatin1('\r')) {
const bool hasLfAfter = (i + 1 < merged.size() && merged.at(i + 1) == QChar::fromLatin1('\n'));
if (!hasLfAfter) {
plainBuffer.append(QChar::fromLatin1('\n'));
}
++i;
continue;
}
plainBuffer.append(ch);
++i;
}
if (!plainBuffer.isEmpty()) {
appendTextChunk(plainBuffer);
}
}
void TerminalView::keyPressEvent(QKeyEvent* event)
{
if (event == nullptr) {
return;
}
moveCursor(QTextCursor::End);
const Qt::KeyboardModifiers modifiers = event->modifiers();
if (modifiers == (Qt::ControlModifier | Qt::ShiftModifier)
&& event->key() == Qt::Key_C) {
const QString selected = textCursor().selectedText();
if (!selected.isEmpty()) {
QApplication::clipboard()->setText(selected);
}
return;
}
if (modifiers == Qt::ControlModifier) {
switch (event->key()) {
case Qt::Key_C:
emit inputGenerated(QStringLiteral("\x03"));
return;
case Qt::Key_D:
emit inputGenerated(QStringLiteral("\x04"));
return;
case Qt::Key_L:
emit inputGenerated(QStringLiteral("\x0c"));
return;
case Qt::Key_V: {
const QString clipboardText = QApplication::clipboard()->text();
if (!clipboardText.isEmpty()) {
emit inputGenerated(clipboardText);
}
return;
}
default:
break;
}
}
switch (event->key()) {
case Qt::Key_Return:
case Qt::Key_Enter:
emit inputGenerated(QStringLiteral("\n"));
return;
case Qt::Key_Backspace:
emit inputGenerated(QStringLiteral("\x7f"));
return;
case Qt::Key_Tab:
emit inputGenerated(QStringLiteral("\t"));
return;
case Qt::Key_Left:
emit inputGenerated(QStringLiteral("\x1b[D"));
return;
case Qt::Key_Right:
emit inputGenerated(QStringLiteral("\x1b[C"));
return;
case Qt::Key_Up:
emit inputGenerated(QStringLiteral("\x1b[A"));
return;
case Qt::Key_Down:
emit inputGenerated(QStringLiteral("\x1b[B"));
return;
default:
break;
}
const QString text = event->text();
if (!text.isEmpty()) {
emit inputGenerated(text);
return;
}
}
void TerminalView::focusInEvent(QFocusEvent* event)
{
QTextEdit::focusInEvent(event);
moveCursor(QTextCursor::End);
}
bool TerminalView::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 TerminalView::resizeEvent(QResizeEvent* event)
{
QTextEdit::resizeEvent(event);
emitTerminalSize();
}
TerminalView::ThemePalette TerminalView::paletteByName(const QString& themeName)
{
const QString theme = normalizedThemeName(themeName);
if (theme == QStringLiteral("light")) {
return ThemePalette{QStringLiteral("Light"),
QColor(QStringLiteral("#ececec")),
QColor(QStringLiteral("#000000")),
{QColor(QStringLiteral("#000000")),
QColor(QStringLiteral("#aa0000")),
QColor(QStringLiteral("#008000")),
QColor(QStringLiteral("#7a5f00")),
QColor(QStringLiteral("#0033cc")),
QColor(QStringLiteral("#8a00a8")),
QColor(QStringLiteral("#005f87")),
QColor(QStringLiteral("#333333"))},
{QColor(QStringLiteral("#5c5c5c")),
QColor(QStringLiteral("#d30000")),
QColor(QStringLiteral("#00a000")),
QColor(QStringLiteral("#9a7700")),
QColor(QStringLiteral("#0055ff")),
QColor(QStringLiteral("#b300db")),
QColor(QStringLiteral("#007ea7")),
QColor(QStringLiteral("#111111"))}};
}
if (theme == QStringLiteral("solarized dark")) {
return ThemePalette{QStringLiteral("Solarized Dark"),
QColor(QStringLiteral("#002b36")),
QColor(QStringLiteral("#839496")),
{QColor(QStringLiteral("#073642")),
QColor(QStringLiteral("#dc322f")),
QColor(QStringLiteral("#859900")),
QColor(QStringLiteral("#b58900")),
QColor(QStringLiteral("#268bd2")),
QColor(QStringLiteral("#d33682")),
QColor(QStringLiteral("#2aa198")),
QColor(QStringLiteral("#eee8d5"))},
{QColor(QStringLiteral("#586e75")),
QColor(QStringLiteral("#cb4b16")),
QColor(QStringLiteral("#586e75")),
QColor(QStringLiteral("#657b83")),
QColor(QStringLiteral("#839496")),
QColor(QStringLiteral("#6c71c4")),
QColor(QStringLiteral("#93a1a1")),
QColor(QStringLiteral("#fdf6e3"))}};
}
return ThemePalette{QStringLiteral("Dark"),
QColor(QStringLiteral("#1e1e1e")),
QColor(QStringLiteral("#d4d4d4")),
{QColor(QStringLiteral("#000000")),
QColor(QStringLiteral("#cd3131")),
QColor(QStringLiteral("#0dbc79")),
QColor(QStringLiteral("#e5e510")),
QColor(QStringLiteral("#2472c8")),
QColor(QStringLiteral("#bc3fbc")),
QColor(QStringLiteral("#11a8cd")),
QColor(QStringLiteral("#e5e5e5"))},
{QColor(QStringLiteral("#666666")),
QColor(QStringLiteral("#f14c4c")),
QColor(QStringLiteral("#23d18b")),
QColor(QStringLiteral("#f5f543")),
QColor(QStringLiteral("#3b8eea")),
QColor(QStringLiteral("#d670d6")),
QColor(QStringLiteral("#29b8db")),
QColor(QStringLiteral("#ffffff"))}};
}
QColor TerminalView::colorFrom256Index(int index)
{
if (index < 0) {
index = 0;
}
if (index > 255) {
index = 255;
}
if (index < 16) {
static const std::array<QColor, 16> base = {
QColor(QStringLiteral("#000000")), QColor(QStringLiteral("#800000")),
QColor(QStringLiteral("#008000")), QColor(QStringLiteral("#808000")),
QColor(QStringLiteral("#000080")), QColor(QStringLiteral("#800080")),
QColor(QStringLiteral("#008080")), QColor(QStringLiteral("#c0c0c0")),
QColor(QStringLiteral("#808080")), QColor(QStringLiteral("#ff0000")),
QColor(QStringLiteral("#00ff00")), QColor(QStringLiteral("#ffff00")),
QColor(QStringLiteral("#0000ff")), QColor(QStringLiteral("#ff00ff")),
QColor(QStringLiteral("#00ffff")), QColor(QStringLiteral("#ffffff"))};
return base.at(static_cast<size_t>(index));
}
if (index >= 16 && index <= 231) {
const int c = index - 16;
const int r = c / 36;
const int g = (c / 6) % 6;
const int b = c % 6;
const auto channel = [](int v) { return v == 0 ? 0 : 55 + v * 40; };
return QColor(channel(r), channel(g), channel(b));
}
const int gray = 8 + (index - 232) * 10;
return QColor(gray, gray, gray);
}
void TerminalView::applyThemePalette(const ThemePalette& palette)
{
m_palette = palette;
const QString stylesheet = QStringLiteral("QTextEdit { background: %1; color: %2; }")
.arg(m_palette.background.name(), m_palette.foreground.name());
setStyleSheet(stylesheet);
if (!m_hasFgColor) {
m_fgColor = m_palette.foreground;
}
if (!m_hasBgColor) {
m_bgColor = m_palette.background;
}
applyCurrentFormat();
}
void TerminalView::applyCurrentFormat()
{
m_currentFormat = QTextCharFormat();
m_currentFormat.setForeground(m_hasFgColor ? m_fgColor : m_palette.foreground);
if (m_hasBgColor) {
m_currentFormat.setBackground(m_bgColor);
}
QFont font = currentFont();
font.setBold(m_bold);
m_currentFormat.setFont(font);
}
void TerminalView::resetSgrState()
{
m_bold = false;
m_hasFgColor = false;
m_hasBgColor = false;
m_fgColor = m_palette.foreground;
m_bgColor = m_palette.background;
applyCurrentFormat();
}
void TerminalView::handleSgrSequence(const QString& params)
{
QStringList parts = params.split(QChar::fromLatin1(';'), Qt::KeepEmptyParts);
if (parts.isEmpty()) {
parts.push_back(QStringLiteral("0"));
}
for (int i = 0; i < parts.size(); ++i) {
const QString part = parts.at(i).trimmed();
bool ok = false;
const int code = part.isEmpty() ? 0 : part.toInt(&ok);
if (!ok && !part.isEmpty()) {
continue;
}
if (code == 0) {
resetSgrState();
continue;
}
if (code == 1) {
m_bold = true;
continue;
}
if (code == 22) {
m_bold = false;
continue;
}
if (code == 39) {
m_hasFgColor = false;
continue;
}
if (code == 49) {
m_hasBgColor = false;
continue;
}
if (code >= 30 && code <= 37) {
m_fgColor = paletteColor(false, code - 30, false);
m_hasFgColor = true;
continue;
}
if (code >= 90 && code <= 97) {
m_fgColor = paletteColor(false, code - 90, true);
m_hasFgColor = true;
continue;
}
if (code >= 40 && code <= 47) {
m_bgColor = paletteColor(true, code - 40, false);
m_hasBgColor = true;
continue;
}
if (code >= 100 && code <= 107) {
m_bgColor = paletteColor(true, code - 100, true);
m_hasBgColor = true;
continue;
}
if (code == 38 || code == 48) {
const bool background = (code == 48);
if (i + 1 >= parts.size()) {
continue;
}
const int mode = parts.at(i + 1).toInt(&ok);
if (!ok) {
continue;
}
if (mode == 5 && i + 2 < parts.size()) {
const int index = parts.at(i + 2).toInt(&ok);
if (ok) {
const QColor color = colorFrom256Index(index);
if (background) {
m_bgColor = color;
m_hasBgColor = true;
} else {
m_fgColor = color;
m_hasFgColor = true;
}
}
i += 2;
continue;
}
if (mode == 2 && i + 4 < parts.size()) {
const int r = parts.at(i + 2).toInt(&ok);
if (!ok) {
i += 4;
continue;
}
const int g = parts.at(i + 3).toInt(&ok);
if (!ok) {
i += 4;
continue;
}
const int b = parts.at(i + 4).toInt(&ok);
if (!ok) {
i += 4;
continue;
}
const QColor color(r, g, b);
if (background) {
m_bgColor = color;
m_hasBgColor = true;
} else {
m_fgColor = color;
m_hasFgColor = true;
}
i += 4;
continue;
}
}
}
applyCurrentFormat();
}
void TerminalView::appendTextChunk(const QString& text)
{
if (text.isEmpty()) {
return;
}
QTextCursor cursor = textCursor();
cursor.movePosition(QTextCursor::End);
cursor.insertText(text, m_currentFormat);
setTextCursor(cursor);
ensureCursorVisible();
}
QColor TerminalView::paletteColor(bool, int index, bool bright) const
{
const int safeIndex = std::clamp(index, 0, 7);
return bright ? m_palette.bright.at(static_cast<size_t>(safeIndex))
: m_palette.normal.at(static_cast<size_t>(safeIndex));
}
int TerminalView::terminalColumns() const
{
const QFontMetrics metrics(font());
const int cellWidth = std::max(1, metrics.horizontalAdvance(QChar::fromLatin1('M')));
return std::max(1, viewport()->width() / cellWidth);
}
int TerminalView::terminalRows() const
{
const QFontMetrics metrics(font());
const int cellHeight = std::max(1, metrics.lineSpacing());
return std::max(1, viewport()->height() / cellHeight);
}
void TerminalView::emitTerminalSize()
{
emit terminalSizeChanged(terminalColumns(), terminalRows());
}
+68
View File
@@ -0,0 +1,68 @@
#ifndef ORBITHUB_TERMINAL_VIEW_H
#define ORBITHUB_TERMINAL_VIEW_H
#include <QTextEdit>
#include <array>
class QKeyEvent;
class QFocusEvent;
class QResizeEvent;
class TerminalView : public QTextEdit
{
Q_OBJECT
public:
explicit TerminalView(QWidget* parent = nullptr);
static QStringList themeNames();
void setThemeName(const QString& themeName);
void appendTerminalData(const QString& data);
void setFontPointSize(int pointSize);
signals:
void inputGenerated(const QString& input);
void terminalSizeChanged(int columns, int rows);
protected:
void keyPressEvent(QKeyEvent* event) override;
void focusInEvent(QFocusEvent* event) override;
void resizeEvent(QResizeEvent* event) override;
bool focusNextPrevChild(bool next) override;
private:
struct ThemePalette {
QString name;
QColor background;
QColor foreground;
std::array<QColor, 8> normal;
std::array<QColor, 8> bright;
};
ThemePalette m_palette;
QString m_pendingEscape;
QString m_rawHistory;
bool m_bold;
bool m_hasFgColor;
bool m_hasBgColor;
QColor m_fgColor;
QColor m_bgColor;
QTextCharFormat m_currentFormat;
static ThemePalette paletteByName(const QString& themeName);
static QColor colorFrom256Index(int index);
void applyThemePalette(const ThemePalette& palette);
void applyCurrentFormat();
void resetSgrState();
void handleSgrSequence(const QString& params);
void appendTextChunk(const QString& text);
QColor paletteColor(bool background, int index, bool bright) const;
void processData(const QString& data, bool storeInHistory);
int terminalColumns() const;
int terminalRows() const;
void emitTerminalSize();
};
#endif
+13
View File
@@ -24,3 +24,16 @@ void UnsupportedSessionBackend::reconnectSession(const SessionConnectOptions& op
{ {
connectSession(options); connectSession(options);
} }
void UnsupportedSessionBackend::sendInput(const QString&)
{
emit eventLogged(QStringLiteral("Input ignored: protocol backend is not interactive."));
}
void UnsupportedSessionBackend::confirmHostKey(bool)
{
}
void UnsupportedSessionBackend::updateTerminalSize(int, int)
{
}
+3
View File
@@ -14,6 +14,9 @@ public slots:
void connectSession(const SessionConnectOptions& options) override; void connectSession(const SessionConnectOptions& options) override;
void disconnectSession() override; void disconnectSession() override;
void reconnectSession(const SessionConnectOptions& options) 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;
}; };
#endif #endif
+149
View File
@@ -0,0 +1,149 @@
#include "user_guide_dialog.h"
#include <QApplication>
#include <QDesktopServices>
#include <QDialogButtonBox>
#include <QFile>
#include <QListWidget>
#include <QSplitter>
#include <QTextBrowser>
#include <QTextStream>
#include <QUrl>
#include <QVBoxLayout>
namespace {
QString slugify(const QString& title)
{
QString slug;
slug.reserve(title.size());
bool lastWasHyphen = false;
for (const QChar& ch : title) {
if (ch.isLetterOrNumber()) {
slug += ch.toLower();
lastWasHyphen = false;
} else if (!lastWasHyphen && !slug.isEmpty()) {
slug += QLatin1Char('-');
lastWasHyphen = true;
}
}
while (slug.endsWith(QLatin1Char('-'))) {
slug.chop(1);
}
return slug;
}
}
UserGuideDialog::UserGuideDialog(QWidget* parent)
: QDialog(parent), m_sectionList(nullptr), m_browser(nullptr)
{
setWindowTitle(QStringLiteral("OrbitHub User Guide"));
setWindowIcon(QApplication::windowIcon());
resize(900, 640);
auto* layout = new QVBoxLayout(this);
layout->setContentsMargins(16, 16, 16, 16);
layout->setSpacing(12);
auto* splitter = new QSplitter(Qt::Horizontal, this);
m_sectionList = new QListWidget(splitter);
m_sectionList->setMaximumWidth(220);
m_browser = new QTextBrowser(splitter);
m_browser->setOpenExternalLinks(false);
m_browser->setOpenLinks(false);
splitter->addWidget(m_sectionList);
splitter->addWidget(m_browser);
splitter->setStretchFactor(0, 0);
splitter->setStretchFactor(1, 1);
auto* buttons = new QDialogButtonBox(QDialogButtonBox::Close, this);
connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::reject);
connect(buttons, &QDialogButtonBox::accepted, this, &QDialog::accept);
layout->addWidget(splitter, 1);
layout->addWidget(buttons);
loadSections();
connect(m_sectionList,
&QListWidget::currentRowChanged,
this,
[this](int row) { showSection(row); });
connect(m_browser, &QTextBrowser::anchorClicked, this, &UserGuideDialog::onAnchorClicked);
if (!m_sections.isEmpty()) {
m_sectionList->setCurrentRow(0);
}
}
void UserGuideDialog::loadSections()
{
QFile file(QStringLiteral(":/docs/USER_GUIDE.md"));
if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) {
return;
}
QTextStream stream(&file);
const QString content = stream.readAll();
const QStringList lines = content.split(QLatin1Char('\n'));
QString currentTitle;
QStringList currentLines;
auto flushSection = [this, &currentTitle, &currentLines]() {
if (currentTitle.isEmpty()) {
return;
}
Section section;
section.title = currentTitle;
section.anchor = slugify(currentTitle);
section.markdown = currentLines.join(QLatin1Char('\n'));
m_sections.append(section);
};
for (const QString& line : lines) {
if (line.startsWith(QStringLiteral("## "))) {
flushSection();
currentTitle = line.mid(3).trimmed();
currentLines.clear();
}
currentLines.append(line);
}
flushSection();
for (const Section& section : m_sections) {
m_sectionList->addItem(section.title);
}
}
void UserGuideDialog::showSection(int index)
{
if (index < 0 || index >= m_sections.size()) {
return;
}
m_browser->setMarkdown(m_sections[index].markdown);
}
void UserGuideDialog::onAnchorClicked(const QUrl& url)
{
if (!url.scheme().isEmpty()) {
QDesktopServices::openUrl(url);
return;
}
const QString fragment = url.fragment();
if (fragment.isEmpty()) {
return;
}
for (int i = 0; i < m_sections.size(); ++i) {
if (m_sections[i].anchor == fragment) {
m_sectionList->setCurrentRow(i);
return;
}
}
}
+35
View File
@@ -0,0 +1,35 @@
#ifndef ORBITHUB_USER_GUIDE_DIALOG_H
#define ORBITHUB_USER_GUIDE_DIALOG_H
#include <QDialog>
#include <QString>
#include <QVector>
class QListWidget;
class QTextBrowser;
class QUrl;
class UserGuideDialog : public QDialog
{
Q_OBJECT
public:
explicit UserGuideDialog(QWidget* parent = nullptr);
private:
struct Section {
QString title;
QString anchor;
QString markdown;
};
void loadSections();
void showSection(int index);
void onAnchorClicked(const QUrl& url);
QListWidget* m_sectionList;
QTextBrowser* m_browser;
QVector<Section> m_sections;
};
#endif
+219
View File
@@ -0,0 +1,219 @@
#include "vnc_apple_dh_auth.h"
#include <openssl/bn.h>
#include <openssl/core_names.h>
#include <openssl/evp.h>
#include <openssl/param_build.h>
#include <cstring>
#include <memory>
namespace VncAppleDhAuth {
namespace {
struct EvpPkeyDeleter {
void operator()(EVP_PKEY* key) const { EVP_PKEY_free(key); }
};
struct EvpPkeyCtxDeleter {
void operator()(EVP_PKEY_CTX* ctx) const { EVP_PKEY_CTX_free(ctx); }
};
struct BnDeleter {
void operator()(BIGNUM* bn) const { BN_free(bn); }
};
struct ParamBldDeleter {
void operator()(OSSL_PARAM_BLD* bld) const { OSSL_PARAM_BLD_free(bld); }
};
struct ParamDeleter {
void operator()(OSSL_PARAM* params) const { OSSL_PARAM_free(params); }
};
struct CipherCtxDeleter {
void operator()(EVP_CIPHER_CTX* ctx) const { EVP_CIPHER_CTX_free(ctx); }
};
using EvpPkeyPtr = std::unique_ptr<EVP_PKEY, EvpPkeyDeleter>;
using EvpPkeyCtxPtr = std::unique_ptr<EVP_PKEY_CTX, EvpPkeyCtxDeleter>;
using BnPtr = std::unique_ptr<BIGNUM, BnDeleter>;
using ParamBldPtr = std::unique_ptr<OSSL_PARAM_BLD, ParamBldDeleter>;
using ParamPtr = std::unique_ptr<OSSL_PARAM, ParamDeleter>;
using CipherCtxPtr = std::unique_ptr<EVP_CIPHER_CTX, CipherCtxDeleter>;
BnPtr bnFromBytes(const QByteArray& bytes)
{
return BnPtr(
BN_bin2bn(reinterpret_cast<const unsigned char*>(bytes.constData()), bytes.size(), nullptr));
}
// Builds an EVP_PKEY holding just the DH domain parameters (generator,
// prime) -- used both as the basis for our own keygen and, with a public
// value added, to represent the server's public key for derive().
EvpPkeyPtr buildDomainParams(const BIGNUM* g, const BIGNUM* p)
{
ParamBldPtr bld(OSSL_PARAM_BLD_new());
if (!bld) {
return nullptr;
}
if (OSSL_PARAM_BLD_push_BN(bld.get(), OSSL_PKEY_PARAM_FFC_P, p) <= 0
|| OSSL_PARAM_BLD_push_BN(bld.get(), OSSL_PKEY_PARAM_FFC_G, g) <= 0) {
return nullptr;
}
ParamPtr params(OSSL_PARAM_BLD_to_param(bld.get()));
if (!params) {
return nullptr;
}
EvpPkeyCtxPtr ctx(EVP_PKEY_CTX_new_from_name(nullptr, "DH", nullptr));
if (!ctx || EVP_PKEY_fromdata_init(ctx.get()) <= 0) {
return nullptr;
}
EVP_PKEY* rawKey = nullptr;
if (EVP_PKEY_fromdata(ctx.get(), &rawKey, EVP_PKEY_KEY_PARAMETERS, params.get()) <= 0) {
return nullptr;
}
return EvpPkeyPtr(rawKey);
}
EvpPkeyPtr buildPeerPublicKey(const BIGNUM* g, const BIGNUM* p, const BIGNUM* pub)
{
ParamBldPtr bld(OSSL_PARAM_BLD_new());
if (!bld) {
return nullptr;
}
if (OSSL_PARAM_BLD_push_BN(bld.get(), OSSL_PKEY_PARAM_FFC_P, p) <= 0
|| OSSL_PARAM_BLD_push_BN(bld.get(), OSSL_PKEY_PARAM_FFC_G, g) <= 0
|| OSSL_PARAM_BLD_push_BN(bld.get(), OSSL_PKEY_PARAM_PUB_KEY, pub) <= 0) {
return nullptr;
}
ParamPtr params(OSSL_PARAM_BLD_to_param(bld.get()));
if (!params) {
return nullptr;
}
EvpPkeyCtxPtr ctx(EVP_PKEY_CTX_new_from_name(nullptr, "DH", nullptr));
if (!ctx || EVP_PKEY_fromdata_init(ctx.get()) <= 0) {
return nullptr;
}
EVP_PKEY* rawKey = nullptr;
if (EVP_PKEY_fromdata(ctx.get(), &rawKey, EVP_PKEY_PUBLIC_KEY, params.get()) <= 0) {
return nullptr;
}
return EvpPkeyPtr(rawKey);
}
}
Response computeResponse(const QByteArray& generator, const QByteArray& prime,
const QByteArray& serverPublicKey, const QString& username,
const QString& password)
{
Response response;
if (generator.isEmpty() || prime.isEmpty() || serverPublicKey.isEmpty()) {
return response;
}
BnPtr g = bnFromBytes(generator);
BnPtr p = bnFromBytes(prime);
BnPtr serverPub = bnFromBytes(serverPublicKey);
if (!g || !p || !serverPub) {
return response;
}
EvpPkeyPtr domainParams = buildDomainParams(g.get(), p.get());
if (!domainParams) {
return response;
}
// Generate our own ephemeral DH keypair against the same domain
// parameters the server offered.
EvpPkeyCtxPtr keygenCtx(EVP_PKEY_CTX_new_from_pkey(nullptr, domainParams.get(), nullptr));
if (!keygenCtx || EVP_PKEY_keygen_init(keygenCtx.get()) <= 0) {
return response;
}
EVP_PKEY* rawOurKey = nullptr;
if (EVP_PKEY_keygen(keygenCtx.get(), &rawOurKey) <= 0) {
return response;
}
EvpPkeyPtr ourKey(rawOurKey);
BIGNUM* ourPubRaw = nullptr;
if (EVP_PKEY_get_bn_param(ourKey.get(), OSSL_PKEY_PARAM_PUB_KEY, &ourPubRaw) <= 0
|| ourPubRaw == nullptr) {
return response;
}
BnPtr ourPub(ourPubRaw);
QByteArray clientPublicKey(prime.size(), char(0));
if (BN_bn2binpad(ourPub.get(), reinterpret_cast<unsigned char*>(clientPublicKey.data()),
prime.size())
< 0) {
return response;
}
EvpPkeyPtr peerKey = buildPeerPublicKey(g.get(), p.get(), serverPub.get());
if (!peerKey) {
return response;
}
EvpPkeyCtxPtr deriveCtx(EVP_PKEY_CTX_new_from_pkey(nullptr, ourKey.get(), nullptr));
if (!deriveCtx || EVP_PKEY_derive_init(deriveCtx.get()) <= 0
|| EVP_PKEY_derive_set_peer(deriveCtx.get(), peerKey.get()) <= 0) {
return response;
}
size_t secretLen = 0;
if (EVP_PKEY_derive(deriveCtx.get(), nullptr, &secretLen) <= 0 || secretLen == 0) {
return response;
}
QByteArray secret(static_cast<int>(secretLen), char(0));
if (EVP_PKEY_derive(deriveCtx.get(), reinterpret_cast<unsigned char*>(secret.data()),
&secretLen)
<= 0) {
return response;
}
secret.resize(static_cast<int>(secretLen));
unsigned char aesKey[16];
if (EVP_Digest(secret.constData(), static_cast<size_t>(secret.size()), aesKey, nullptr,
EVP_md5(), nullptr)
<= 0) {
return response;
}
// 64 bytes username + 64 bytes password, NUL-padded/truncated.
QByteArray credentials(128, char(0));
const QByteArray userBytes = username.toLatin1().left(64);
const QByteArray passBytes = password.toLatin1().left(64);
std::memcpy(credentials.data(), userBytes.constData(),
static_cast<size_t>(userBytes.size()));
std::memcpy(credentials.data() + 64, passBytes.constData(),
static_cast<size_t>(passBytes.size()));
CipherCtxPtr cipherCtx(EVP_CIPHER_CTX_new());
if (!cipherCtx
|| EVP_EncryptInit_ex(cipherCtx.get(), EVP_aes_128_ecb(), nullptr, aesKey, nullptr) <= 0) {
return response;
}
EVP_CIPHER_CTX_set_padding(cipherCtx.get(), 0);
QByteArray ciphertext(credentials.size() + EVP_MAX_BLOCK_LENGTH, char(0));
int outLen1 = 0;
if (EVP_EncryptUpdate(cipherCtx.get(), reinterpret_cast<unsigned char*>(ciphertext.data()),
&outLen1, reinterpret_cast<const unsigned char*>(credentials.constData()),
credentials.size())
<= 0) {
return response;
}
int outLen2 = 0;
if (EVP_EncryptFinal_ex(cipherCtx.get(),
reinterpret_cast<unsigned char*>(ciphertext.data()) + outLen1, &outLen2)
<= 0) {
return response;
}
ciphertext.resize(outLen1 + outLen2);
response.clientPublicKey = clientPublicKey;
response.encryptedCredentials = ciphertext;
return response;
}
}
+44
View File
@@ -0,0 +1,44 @@
#ifndef ORBITHUB_VNC_APPLE_DH_AUTH_H
#define ORBITHUB_VNC_APPLE_DH_AUTH_H
#include <QByteArray>
#include <QString>
// Apple's Screen Sharing authentication scheme (RFB security type 30):
// Diffie-Hellman key exchange followed by AES-128-ECB-encrypted
// credentials. Apple never published this officially -- it's not part of
// RFC 6143 -- so this implements the well-established reverse-engineered
// wire format used by several independent VNC clients, not a primary
// spec. Kept as a pure, state-free helper (no socket access) so it's
// unit-testable without a live connection, mirroring
// VncSessionBackend::vncAuthResponse()'s shape for standard VNC
// Authentication.
namespace VncAppleDhAuth {
struct Response {
// Same byte length as the server's prime, big-endian, zero-padded.
// Empty on any failure (malformed input, an OpenSSL operation
// failing) -- callers should treat an empty clientPublicKey as "could
// not compute a response" rather than send a degenerate one.
QByteArray clientPublicKey;
// Always exactly 128 bytes on success (16 AES blocks): a 64-byte
// NUL-padded/truncated username followed by a 64-byte NUL-padded/
// truncated password, AES-128-ECB encrypted (no padding, since the
// plaintext is already an exact multiple of the block size) with a
// key derived as MD5(sharedSecret).
QByteArray encryptedCredentials;
};
// Computes the DH keypair, the shared secret, the derived AES key, and
// the encrypted credential blob, using modern EVP-based OpenSSL 3.0 APIs
// throughout (no deprecated low-level DH_*/legacy calls -- unlike VNC
// Authentication's classic DES usage, none of MD5/AES-ECB/the EVP_PKEY DH
// APIs are deprecated, so no compatibility pragma is needed here).
// Returns a Response with an empty clientPublicKey on any failure.
Response computeResponse(const QByteArray& generator, const QByteArray& prime,
const QByteArray& serverPublicKey, const QString& username,
const QString& password);
}
#endif
+118
View File
@@ -0,0 +1,118 @@
#include "vnc_apple_rsa_auth.h"
#include <openssl/evp.h>
#include <openssl/rand.h>
#include <openssl/rsa.h>
#include <openssl/x509.h>
#include <memory>
namespace VncAppleRsaAuth {
namespace {
struct EvpPkeyDeleter {
void operator()(EVP_PKEY* key) const { EVP_PKEY_free(key); }
};
struct EvpPkeyCtxDeleter {
void operator()(EVP_PKEY_CTX* ctx) const { EVP_PKEY_CTX_free(ctx); }
};
struct CipherCtxDeleter {
void operator()(EVP_CIPHER_CTX* ctx) const { EVP_CIPHER_CTX_free(ctx); }
};
using EvpPkeyPtr = std::unique_ptr<EVP_PKEY, EvpPkeyDeleter>;
using EvpPkeyCtxPtr = std::unique_ptr<EVP_PKEY_CTX, EvpPkeyCtxDeleter>;
using CipherCtxPtr = std::unique_ptr<EVP_CIPHER_CTX, CipherCtxDeleter>;
}
QByteArray packCredential(const QString& text)
{
QByteArray data = text.toUtf8();
data.append(char(0));
if (data.size() < 64) {
QByteArray padding(64 - data.size(), char(0));
// Best-effort: if RAND_bytes fails, zero-padding is still
// correct (the NUL terminator above already unambiguously marks
// the string's real end for the server) -- only the ECB-pattern-
// hiding benefit of random padding is lost, not correctness.
RAND_bytes(reinterpret_cast<unsigned char*>(padding.data()), padding.size());
data += padding;
} else {
data = data.left(64);
}
return data;
}
Response computeResponse(const QByteArray& hostKeyDer, const QString& username,
const QString& password)
{
Response response;
if (hostKeyDer.isEmpty()) {
return response;
}
const auto* derPtr = reinterpret_cast<const unsigned char*>(hostKeyDer.constData());
EvpPkeyPtr hostKey(d2i_PUBKEY(nullptr, &derPtr, hostKeyDer.size()));
if (!hostKey) {
return response;
}
unsigned char aesKeyBytes[16];
if (RAND_bytes(aesKeyBytes, sizeof(aesKeyBytes)) != 1) {
return response;
}
const QByteArray credentials = packCredential(username) + packCredential(password);
CipherCtxPtr cipherCtx(EVP_CIPHER_CTX_new());
if (!cipherCtx
|| EVP_EncryptInit_ex(cipherCtx.get(), EVP_aes_128_ecb(), nullptr, aesKeyBytes, nullptr)
<= 0) {
return response;
}
EVP_CIPHER_CTX_set_padding(cipherCtx.get(), 0);
QByteArray encryptedCredentials(credentials.size() + EVP_MAX_BLOCK_LENGTH, char(0));
int outLen1 = 0;
if (EVP_EncryptUpdate(cipherCtx.get(),
reinterpret_cast<unsigned char*>(encryptedCredentials.data()), &outLen1,
reinterpret_cast<const unsigned char*>(credentials.constData()),
credentials.size())
<= 0) {
return response;
}
int outLen2 = 0;
if (EVP_EncryptFinal_ex(cipherCtx.get(),
reinterpret_cast<unsigned char*>(encryptedCredentials.data()) + outLen1,
&outLen2)
<= 0) {
return response;
}
encryptedCredentials.resize(outLen1 + outLen2);
EvpPkeyCtxPtr rsaCtx(EVP_PKEY_CTX_new(hostKey.get(), nullptr));
if (!rsaCtx || EVP_PKEY_encrypt_init(rsaCtx.get()) <= 0
|| EVP_PKEY_CTX_set_rsa_padding(rsaCtx.get(), RSA_PKCS1_PADDING) <= 0) {
return response;
}
size_t encryptedKeyLen = 0;
if (EVP_PKEY_encrypt(rsaCtx.get(), nullptr, &encryptedKeyLen, aesKeyBytes, sizeof(aesKeyBytes))
<= 0) {
return response;
}
QByteArray encryptedAesKey(static_cast<int>(encryptedKeyLen), char(0));
if (EVP_PKEY_encrypt(rsaCtx.get(), reinterpret_cast<unsigned char*>(encryptedAesKey.data()),
&encryptedKeyLen, aesKeyBytes, sizeof(aesKeyBytes))
<= 0) {
return response;
}
encryptedAesKey.resize(static_cast<int>(encryptedKeyLen));
response.encryptedCredentials = encryptedCredentials;
response.encryptedAesKey = encryptedAesKey;
return response;
}
}
+51
View File
@@ -0,0 +1,51 @@
#ifndef ORBITHUB_VNC_APPLE_RSA_AUTH_H
#define ORBITHUB_VNC_APPLE_RSA_AUTH_H
#include <QByteArray>
#include <QString>
// Apple Screen Sharing's RSA-based authentication scheme (RFB security
// type 33, sometimes called "MacAuthentication" or "ARD authentication").
// Distinct from security type 30 (Diffie-Hellman + AES, see
// vnc_apple_dh_auth.h): modern macOS advertises both, but empirically only
// type 33 is actually functional -- type 30 appears to be vestigial.
// Neither is part of RFC 6143; this wire format and crypto shape was
// confirmed against the `asyncvnc` PyPI package's implementation (a real,
// working, independently-maintained VNC client) rather than derived from
// official Apple documentation, which doesn't exist for this scheme.
//
// Scheme: the server hands the client its RSA public key (DER-encoded
// X.509 SubjectPublicKeyInfo); the client generates a random AES-128 key,
// encrypts the username+password with it, then RSA-PKCS1v1.5-encrypts
// that AES key with the server's public key and sends both back.
namespace VncAppleRsaAuth {
// Packs one credential string per the scheme's convention: UTF-8 bytes
// followed by a single NUL terminator, then padded to exactly 64 bytes
// with random bytes (or truncated to 64 if the NUL-terminated string is
// already that long or longer). The NUL terminator is what lets the
// server find the string's real end despite the random padding -- the
// padding's specific value isn't otherwise significant. Exposed publicly
// so it's independently unit-testable.
QByteArray packCredential(const QString& text);
struct Response {
// Exactly 128 bytes on success (packCredential(username) +
// packCredential(password), AES-128-ECB encrypted). Empty on failure.
QByteArray encryptedCredentials;
// RSA-modulus-length bytes on success (the random AES key,
// PKCS1v1.5-encrypted with the server's public key). Empty on
// failure.
QByteArray encryptedAesKey;
};
// Computes the full type-33 response from the server's DER-encoded RSA
// public key and the credentials to authenticate with. Returns a Response
// with both fields empty on any failure (malformed key, an OpenSSL
// operation failing).
Response computeResponse(const QByteArray& hostKeyDer, const QString& username,
const QString& password);
}
#endif
+350
View File
@@ -0,0 +1,350 @@
#include "vnc_display_widget.h"
#include <QCursor>
#include <QEvent>
#include <QKeyEvent>
#include <QMouseEvent>
#include <QPainter>
#include <QPixmap>
#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)),
m_scaleToFit(true),
m_cursorMode(CursorMode::Default)
{
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());
applySizeConstraint();
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();
applySizeConstraint();
update();
}
void VncDisplayWidget::setScaleToFit(bool scaleToFit)
{
if (m_scaleToFit == scaleToFit) {
return;
}
m_scaleToFit = scaleToFit;
applySizeConstraint();
applyCursor();
update();
}
void VncDisplayWidget::applySizeConstraint()
{
if (m_scaleToFit) {
// Let the widget follow whatever it's placed in again (e.g. a
// QScrollArea in resizable mode, or a plain layout).
setMinimumSize(320, 200);
setMaximumSize(QWIDGETSIZE_MAX, QWIDGETSIZE_MAX);
} else {
// Fixed at the remote's actual pixel size. renderRect()'s
// scale-to-fit math naturally degenerates to an unscaled 1:1
// mapping once the widget's own bounds already equal the remote
// size, so no separate "actual size" rendering path is needed --
// this is the only thing that differs between the two modes.
setFixedSize(effectiveRemoteSize());
}
}
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();
applyCursor();
}
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);
}
void VncDisplayWidget::setCursorImage(const QImage& image, const QPoint& hotspot)
{
m_cursorImage = image;
m_cursorHotspot = hotspot;
m_cursorMode = CursorMode::Custom;
applyCursor();
}
void VncDisplayWidget::setCursorHidden()
{
m_cursorMode = CursorMode::Hidden;
applyCursor();
}
void VncDisplayWidget::setCursorDefault()
{
m_cursorMode = CursorMode::Default;
applyCursor();
}
void VncDisplayWidget::applyCursor()
{
if (m_cursorMode == CursorMode::Hidden) {
setCursor(Qt::BlankCursor);
return;
}
if (m_cursorMode == CursorMode::Default || m_cursorImage.isNull()) {
unsetCursor();
return;
}
// renderRect()/effectiveRemoteSize() already account for both display
// modes: in actual-size mode the scale factor naturally comes out to
// 1.0 (see applySizeConstraint()'s comment), so no special-casing is
// needed here beyond reusing the same geometry helpers RDP's version
// uses for its single (always scale-to-fit) mode.
const QSize remote = effectiveRemoteSize();
const QRectF target = renderRect();
if (remote.isEmpty() || target.isEmpty()) {
setCursor(QCursor(QPixmap::fromImage(m_cursorImage),
m_cursorHotspot.x(),
m_cursorHotspot.y()));
return;
}
const qreal scale = target.width() / remote.width();
QImage scaledImage = m_cursorImage;
if (!qFuzzyCompare(scale, 1.0)) {
scaledImage = m_cursorImage.scaled(
qMax(1, qRound(m_cursorImage.width() * scale)),
qMax(1, qRound(m_cursorImage.height() * scale)),
Qt::IgnoreAspectRatio,
Qt::SmoothTransformation);
}
const int hotX = qBound(0, qRound(m_cursorHotspot.x() * scale), scaledImage.width());
const int hotY = qBound(0, qRound(m_cursorHotspot.y() * scale), scaledImage.height());
setCursor(QCursor(QPixmap::fromImage(scaledImage), hotX, hotY));
}
+94
View File
@@ -0,0 +1,94 @@
#ifndef ORBITHUB_VNC_DISPLAY_WIDGET_H
#define ORBITHUB_VNC_DISPLAY_WIDGET_H
#include <QImage>
#include <QPoint>
#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, including remote cursor-shape sync.
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();
// true (default): scale the whole remote screen to fit the widget,
// like RdpDisplayWidget. false: render at the remote's actual pixel
// size -- meant to be placed inside a QScrollArea, whose scrollbars
// then let the user pan around a screen larger than the window
// instead of shrinking small text to illegibility. VNC has no
// equivalent of RDP's MS-RDPEDISP to request a different resolution
// from the guest, so this is the only way to see it at native size.
void setScaleToFit(bool scaleToFit);
bool scaleToFit() const
{
return m_scaleToFit;
}
// Mirrors RdpDisplayWidget's cursor handling. VNC's Cursor pseudo-
// encoding never signals "reset to default" the way RDP's SetDefault
// callback does -- it only ever supplies a shape or hides the cursor --
// so setCursorDefault() exists for symmetry/future use but VNC sessions
// never call it today.
void setCursorImage(const QImage& image, const QPoint& hotspot);
void setCursorHidden();
void setCursorDefault();
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:
enum class CursorMode {
Default,
Hidden,
Custom,
};
QImage m_frame;
QSize m_remoteSize;
QTimer* m_resizeDebounceTimer;
bool m_scaleToFit;
QImage m_cursorImage;
QPoint m_cursorHotspot;
CursorMode m_cursorMode;
QRectF renderRect() const;
QPoint mapToRemote(const QPointF& pos) const;
QSize effectiveRemoteSize() const;
void emitViewportGeometry();
void scheduleViewportGeometryEmit();
void applySizeConstraint();
void applyCursor();
};
#endif
+369
View File
@@ -0,0 +1,369 @@
#include "vnc_pixel_codecs.h"
namespace VncPixelCodecs {
QRgb rgbFromPixelBytes(const uchar* bytes)
{
// Byte order B,G,R,pad -- matches VncSessionBackend's negotiated
// SetPixelFormat (32bpp little-endian, R at shift 16 / G at 8 / B at 0).
return qRgb(bytes[2], bytes[1], bytes[0]);
}
int hextileFixedMetaByteCount(quint8 subencoding)
{
int count = 0;
if ((subencoding & HextileFlags::kBackgroundSpecified) != 0) {
count += 4;
}
if ((subencoding & HextileFlags::kForegroundSpecified) != 0) {
count += 4;
}
if ((subencoding & HextileFlags::kAnySubrects) != 0) {
count += 1;
}
return count;
}
int decodeHextileFixedMeta(quint8 subencoding, const QByteArray& data, QRgb* background,
QRgb* foreground)
{
int offset = 0;
const auto* bytes = reinterpret_cast<const uchar*>(data.constData());
if ((subencoding & HextileFlags::kBackgroundSpecified) != 0) {
*background = rgbFromPixelBytes(bytes + offset);
offset += 4;
}
if ((subencoding & HextileFlags::kForegroundSpecified) != 0) {
*foreground = rgbFromPixelBytes(bytes + offset);
offset += 4;
}
if ((subencoding & HextileFlags::kAnySubrects) != 0) {
return static_cast<int>(static_cast<quint8>(data.at(offset)));
}
return 0;
}
int hextileSubrectByteCount(bool coloured, int subrectCount)
{
return subrectCount * (coloured ? 6 : 2);
}
QVector<HextileSubrect> decodeHextileSubrects(bool coloured, int subrectCount,
const QByteArray& data, QRgb foreground)
{
QVector<HextileSubrect> subrects;
subrects.reserve(subrectCount);
const auto* bytes = reinterpret_cast<const uchar*>(data.constData());
const int stride = coloured ? 6 : 2;
for (int i = 0; i < subrectCount; ++i) {
const uchar* entry = bytes + (i * stride);
QRgb color = foreground;
int fieldOffset = 0;
if (coloured) {
color = rgbFromPixelBytes(entry);
fieldOffset = 4;
}
const uchar xy = entry[fieldOffset];
const uchar wh = entry[fieldOffset + 1];
// High nibble = x (or width-1), low nibble = y (or height-1).
const int x = (xy >> 4) & 0x0F;
const int y = xy & 0x0F;
const int width = ((wh >> 4) & 0x0F) + 1;
const int height = (wh & 0x0F) + 1;
subrects.append(HextileSubrect{QRect(x, y, width, height), color});
}
return subrects;
}
int decodeZrleTile(const QByteArray& data, int offset, int tileWidth, int tileHeight,
QVector<QRgb>& pixels)
{
const int pixelCount = tileWidth * tileHeight;
const auto* bytes = reinterpret_cast<const uchar*>(data.constData());
const int size = data.size();
if (offset < 0 || offset >= size || pixelCount <= 0) {
return -1;
}
int pos = offset;
const quint8 subencoding = bytes[pos];
++pos;
// CPIXEL is 3 bytes for our negotiated 32bpp/24-depth true-color
// format -- the padding byte a full pixel would have is simply
// omitted. rgbFromPixelBytes() already only reads the first 3 bytes it
// is given (B,G,R order), so it doubles as the CPIXEL reader.
auto readCpixel = [&](QRgb* out) -> bool {
if (pos + 3 > size) {
return false;
}
*out = rgbFromPixelBytes(bytes + pos);
pos += 3;
return true;
};
// Shared continuation-byte run-length reader for both RLE subencoding
// families: keep summing bytes while they equal 255, add the final
// (non-255) byte, and the true run length is that sum plus one.
auto readRunLength = [&](int* out) -> bool {
int total = 0;
for (;;) {
if (pos >= size) {
return false;
}
const quint8 b = bytes[pos];
++pos;
total += b;
if (b != 255) {
break;
}
}
*out = total + 1;
return true;
};
if (subencoding == 0) { // Raw
pixels.reserve(pixels.size() + pixelCount);
for (int i = 0; i < pixelCount; ++i) {
QRgb color = 0;
if (!readCpixel(&color)) {
return -1;
}
pixels.append(color);
}
return pos - offset;
}
if (subencoding == 1) { // Solid
QRgb color = 0;
if (!readCpixel(&color)) {
return -1;
}
pixels.reserve(pixels.size() + pixelCount);
for (int i = 0; i < pixelCount; ++i) {
pixels.append(color);
}
return pos - offset;
}
if (subencoding >= 2 && subencoding <= 16) { // Packed palette
const int paletteSize = subencoding;
QVector<QRgb> palette;
palette.reserve(paletteSize);
for (int i = 0; i < paletteSize; ++i) {
QRgb color = 0;
if (!readCpixel(&color)) {
return -1;
}
palette.append(color);
}
int bitsPerPixel = 4;
if (paletteSize == 2) {
bitsPerPixel = 1;
} else if (paletteSize <= 4) {
bitsPerPixel = 2;
}
const int rowBytes = (tileWidth * bitsPerPixel + 7) / 8;
pixels.reserve(pixels.size() + pixelCount);
for (int y = 0; y < tileHeight; ++y) {
if (pos + rowBytes > size) {
return -1;
}
int bitPos = 0;
for (int x = 0; x < tileWidth; ++x) {
const int byteIndex = pos + (bitPos / 8);
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 -1;
}
pixels.append(palette.at(index));
bitPos += bitsPerPixel;
}
pos += rowBytes;
}
return pos - offset;
}
if (subencoding == 128) { // Plain RLE
int produced = 0;
while (produced < pixelCount) {
QRgb color = 0;
if (!readCpixel(&color)) {
return -1;
}
int runLength = 0;
if (!readRunLength(&runLength)) {
return -1;
}
for (int i = 0; i < runLength && produced < pixelCount; ++i, ++produced) {
pixels.append(color);
}
}
return pos - offset;
}
if (subencoding >= 130) { // Palette RLE (129 is unused/invalid, falls through below)
const int paletteSize = subencoding - 128;
QVector<QRgb> palette;
palette.reserve(paletteSize);
for (int i = 0; i < paletteSize; ++i) {
QRgb color = 0;
if (!readCpixel(&color)) {
return -1;
}
palette.append(color);
}
int produced = 0;
while (produced < pixelCount) {
if (pos >= size) {
return -1;
}
const quint8 indexByte = bytes[pos];
++pos;
int index = indexByte;
int runLength = 1;
if (indexByte >= 128) {
index = indexByte - 128;
if (!readRunLength(&runLength)) {
return -1;
}
}
if (index >= palette.size()) {
return -1;
}
const QRgb color = palette.at(index);
for (int i = 0; i < runLength && produced < pixelCount; ++i, ++produced) {
pixels.append(color);
}
}
return pos - offset;
}
// Subencodings 17-127 and 129 are not defined by RFC 6143.
return -1;
}
QVector<QRgb> decodeTightCopyFilter(const QByteArray& data, int width, int height)
{
const int pixelCount = width * height;
QVector<QRgb> pixels;
if (pixelCount <= 0 || data.size() < pixelCount * 3) {
return pixels;
}
pixels.reserve(pixelCount);
const auto* bytes = reinterpret_cast<const uchar*>(data.constData());
for (int i = 0; i < pixelCount; ++i) {
pixels.append(rgbFromPixelBytes(bytes + (i * 3)));
}
return pixels;
}
QVector<QRgb> decodeTightPaletteFilter(const QByteArray& data, int width, int height)
{
QVector<QRgb> pixels;
const int pixelCount = width * height;
if (pixelCount <= 0 || data.isEmpty()) {
return pixels;
}
const auto* bytes = reinterpret_cast<const uchar*>(data.constData());
const int size = data.size();
int pos = 0;
const int paletteSize = static_cast<int>(bytes[pos]) + 1; // 1-256 colors
++pos;
if (pos + (paletteSize * 3) > size) {
return pixels;
}
QVector<QRgb> 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<QRgb>();
}
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<QRgb>();
}
pixels.append(palette.at(index));
bitPos += bitsPerPixel;
}
return pixels;
}
QVector<QRgb> decodeTightGradientFilter(const QByteArray& data, int width, int height)
{
const int pixelCount = width * height;
QVector<QRgb> pixels;
if (pixelCount <= 0 || data.size() < pixelCount * 3) {
return pixels;
}
const auto* bytes = reinterpret_cast<const uchar*>(data.constData());
auto predict = [](int left, int up, int upLeft) {
return qBound(0, left + up - upLeft, 255);
};
QVector<int> rChan(pixelCount);
QVector<int> gChan(pixelCount);
QVector<int> 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;
}
}
+109
View File
@@ -0,0 +1,109 @@
#ifndef ORBITHUB_VNC_PIXEL_CODECS_H
#define ORBITHUB_VNC_PIXEL_CODECS_H
#include <QByteArray>
#include <QRect>
#include <QRgb>
#include <QVector>
// Pure, state-free decode helpers for VNC/RFB pixel encodings, kept out of
// VncSessionBackend so the wire-sequencing (when to read what off the
// socket) and wire-decoding (how to interpret already-buffered bytes) stay
// separate and the latter is unit-testable without a live connection.
namespace VncPixelCodecs {
// Converts one pixel's worth of raw bytes as sent under
// VncSessionBackend's negotiated SetPixelFormat (32bpp, little-endian,
// byte order B,G,R,pad) into a QRgb.
QRgb rgbFromPixelBytes(const uchar* bytes);
// A single filled rectangle, in tile-local coordinates (0,0 = the tile's
// own top-left corner, not the enclosing FramebufferUpdate rectangle's).
struct HextileSubrect {
QRect rect;
QRgb color = 0;
};
// RFC 6143 SS7.7.4 Hextile tile subencoding byte flags.
namespace HextileFlags {
constexpr quint8 kRaw = 0x01;
constexpr quint8 kBackgroundSpecified = 0x02;
constexpr quint8 kForegroundSpecified = 0x04;
constexpr quint8 kAnySubrects = 0x08;
constexpr quint8 kSubrectsColoured = 0x10;
}
// Byte length of a Hextile tile's "fixed" metadata -- the optional
// background/foreground color updates plus the optional subrect count --
// derivable from the subencoding byte alone, before any of those bytes are
// available. Only meaningful when HextileFlags::kRaw is *not* set (a Raw
// tile has no metadata at all, just tileWidth*tileHeight raw pixels).
int hextileFixedMetaByteCount(quint8 subencoding);
// Parses the `hextileFixedMetaByteCount(subencoding)` bytes described
// above. Updates *background/*foreground in place only when the
// corresponding flag is set in `subencoding` -- callers should persist
// their previous values across tiles in the same rectangle and pass them
// in here unchanged when a color isn't re-specified, since RFC 6143 has
// each tile inherit the last-specified colors. Returns the subrect count
// (0 if HextileFlags::kAnySubrects isn't set).
int decodeHextileFixedMeta(quint8 subencoding, const QByteArray& data, QRgb* background,
QRgb* foreground);
// Byte length of `subrectCount` subrects' worth of data, given whether
// they're individually colored (HextileFlags::kSubrectsColoured).
int hextileSubrectByteCount(bool coloured, int subrectCount);
// Parses `subrectCount` subrects (xy + wh bytes, plus a per-subrect color
// when `coloured`) out of `data`, substituting `foreground` in for any
// that aren't individually colored.
QVector<HextileSubrect> decodeHextileSubrects(bool coloured, int subrectCount,
const QByteArray& data, QRgb foreground);
// Decodes one ZRLE tile (RFC 6143 SS7.7.6) from `data`, starting at
// `offset` (which must point at the tile's own 1-byte subencoding).
// `data` holds an entire rectangle's worth of already-zlib-decompressed
// bytes (possibly several tiles' worth) -- this reads only as much as the
// one tile needs and never looks past `data.size()`. On success, appends
// exactly tileWidth*tileHeight pixels (row-major) to `pixels` (which is
// NOT cleared first, so callers can accumulate across tiles if desired --
// VncSessionBackend clears/reuses a fresh vector per tile) and returns the
// number of bytes consumed. Returns -1 for a malformed/truncated tile
// (should never happen against a spec-compliant server, but must not read
// out of bounds against an adversarial or buggy one).
int decodeZrleTile(const QByteArray& data, int offset, int tileWidth, int tileHeight,
QVector<QRgb>& 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<QRgb> 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<QRgb> 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<QRgb> decodeTightGradientFilter(const QByteArray& data, int width, int height);
}
#endif
File diff suppressed because it is too large Load Diff
+249
View File
@@ -0,0 +1,249 @@
#ifndef ORBITHUB_VNC_SESSION_BACKEND_H
#define ORBITHUB_VNC_SESSION_BACKEND_H
#include "session_backend.h"
#include <QAbstractSocket>
#include <QByteArray>
#include <QImage>
#include <QRect>
#include <QRgb>
#include <array>
class QTcpSocket;
struct z_stream_s;
// 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), no-auth (type 1), and two Apple
// Screen Sharing schemes, neither part of RFC 6143 and neither officially
// documented by Apple: type 30 (Diffie-Hellman + AES -- see
// vnc_apple_dh_auth.h) and type 33 (RSA + AES -- see
// vnc_apple_rsa_auth.h). Modern macOS advertises both. Type 30's wire
// format is confirmed against an independent, authoritative source
// (neatvnc's rfb-proto.h, which documents the exact struct layout) and
// verified live against a real macOS Screen Sharing server. Type 33's
// implementation is sourced from the `asyncvnc` PyPI package (a real
// client) but hasn't been gotten working live -- the server closes the
// connection right after the client's initial request for its RSA host
// key, suggesting either a transcription error or that this specific
// macOS version's type 33 sub-protocol has evolved from what that
// reference assumes; kept as a fallback pending further investigation.
// Preference when multiple are offered: None > AppleDH(30) >
// AppleRSA(33) > VNCAuth(2). Raw + CopyRect + Hextile + ZRLE + 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
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;
void setClipboardText(const QString& text) override;
void provideUsername(const QString& username) 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,
WaitingCursorPixelData,
WaitingHextileTileSubencoding,
WaitingHextileTileMeta,
WaitingHextileSubrectData,
WaitingHextileRawTileData,
WaitingZrleCompressedLength,
WaitingZrleCompressedData,
WaitingTightCompressionControl,
WaitingTightFillColor,
WaitingTightFilterId,
WaitingTightLengthByte,
WaitingTightPayload,
WaitingAppleAuthParams,
WaitingAppleAuthPrimeAndServerKey,
WaitingAppleRsaHostKeyHeader,
WaitingAppleRsaHostKeyBytes,
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;
// Hextile decode state (RFC 6143 SS7.7.4): tile-cursor position relative
// to the current rectangle's origin, plus the background/foreground
// colors, which persist across tiles within one rectangle whenever a
// tile doesn't re-specify them.
int m_hextileTileX;
int m_hextileTileY;
QRgb m_hextileBackground;
QRgb m_hextileForeground;
quint8 m_hextileSubencoding;
int m_hextileSubrectsRemaining;
bool m_hextileSubrectsColoured;
// ZRLE's zlib stream (RFC 6143 SS7.7.6) persists for the whole
// connection, not per-rectangle or per-update -- lazily initialized on
// the first ZRLE rectangle, torn down and reset on every fresh
// connect/reconnect via resetProtocolState(). z_stream_s is only
// forward-declared here so <zlib.h> doesn't leak into every includer of
// this header; the full type is only needed in the .cpp.
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<z_stream_s*, 4> m_tightInflateStreams;
std::array<bool, 4> m_tightInflateInitialized;
quint8 m_tightCompressionMode; // compression-control byte >> 4
quint8 m_tightFilterId;
int m_tightLengthByteIndex;
// Apple Screen Sharing authentication state (security type 30, not
// part of RFC 6143 -- see vnc_apple_dh_auth.h). Wire format confirmed
// empirically against a real macOS Screen Sharing server: a literal
// 2-byte generator (not length-prefixed -- there is no separate
// generator-length field), then a 2-byte key length that applies to
// *both* the prime and the server's public key that follow. Both
// members must persist from WaitingAppleAuthParams until the combined
// prime+server-public-key buffer (2x the key length) has fully
// arrived, since m_pendingLength gets reused to track that combined
// byte count in the meantime.
QByteArray m_appleAuthGenerator;
quint32 m_appleAuthKeyLength;
// Apple auth (types 30 and 33) is the only case where VNC ever needs a
// username -- plain VNC Authentication and no-auth never do, so this
// isn't asked for upfront. If profile().username is empty when an
// Apple-auth response is about to be computed, processReceiveBuffer()
// pauses (leaving the already-buffered bytes untouched so re-entry
// re-parses them identically) and emits usernameRequested(); resumed
// by provideUsername() once SessionTab answers.
bool m_waitingForUsername;
QString m_promptedUsername;
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);
void sendClientCutText(const QString& text);
void sendAppleRsaHostKeyRequest();
QRect currentHextileTileRect() const;
void advanceHextileTile();
bool inflateTightStream(int streamIndex, const QByteArray& compressed, QByteArray* decompressed);
QString effectiveUsername() const;
// Returns false (and pauses, emitting usernameRequested() at most once
// until provideUsername() resumes processing) if a username is needed
// but not yet available. Callers must return from processReceiveBuffer()
// immediately when this returns false, without consuming any bytes.
bool ensureUsernameAvailable();
};
#endif
+64
View File
@@ -0,0 +1,64 @@
add_executable(test_profile_repository
test_profile_repository.cpp
${CMAKE_SOURCE_DIR}/src/profile_repository.cpp
)
target_include_directories(test_profile_repository PRIVATE ${CMAKE_SOURCE_DIR}/src)
target_link_libraries(test_profile_repository PRIVATE Qt6::Core Qt6::Sql Qt6::Test)
add_test(NAME test_profile_repository COMMAND test_profile_repository)
add_executable(test_mremoteng_importer
test_mremoteng_importer.cpp
${CMAKE_SOURCE_DIR}/src/mremoteng_importer.cpp
)
target_include_directories(test_mremoteng_importer PRIVATE ${CMAKE_SOURCE_DIR}/src)
target_link_libraries(test_mremoteng_importer PRIVATE Qt6::Core Qt6::Test)
add_test(NAME test_mremoteng_importer COMMAND test_mremoteng_importer)
add_executable(test_ssh_session_backend
test_ssh_session_backend.cpp
${CMAKE_SOURCE_DIR}/src/ssh_session_backend.cpp
${CMAKE_SOURCE_DIR}/src/session_backend.h
)
target_include_directories(test_ssh_session_backend PRIVATE ${CMAKE_SOURCE_DIR}/src)
target_link_libraries(test_ssh_session_backend PRIVATE Qt6::Core Qt6::Gui Qt6::Test)
target_compile_definitions(test_ssh_session_backend PRIVATE
ORBITHUB_TEST_FIXTURES_DIR="${CMAKE_CURRENT_SOURCE_DIR}/fixtures"
)
add_test(NAME test_ssh_session_backend COMMAND test_ssh_session_backend)
add_executable(test_vnc_session_backend
test_vnc_session_backend.cpp
${CMAKE_SOURCE_DIR}/src/vnc_session_backend.cpp
${CMAKE_SOURCE_DIR}/src/vnc_pixel_codecs.cpp
${CMAKE_SOURCE_DIR}/src/vnc_apple_dh_auth.cpp
${CMAKE_SOURCE_DIR}/src/vnc_apple_rsa_auth.cpp
${CMAKE_SOURCE_DIR}/src/session_backend.h
)
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 JPEG::JPEG
)
add_test(NAME test_vnc_session_backend COMMAND test_vnc_session_backend)
if(TARGET freerdp AND TARGET winpr)
add_executable(test_rdp_session_backend
test_rdp_session_backend.cpp
${CMAKE_SOURCE_DIR}/src/rdp_session_backend.cpp
${CMAKE_SOURCE_DIR}/src/session_backend.h
)
target_include_directories(test_rdp_session_backend PRIVATE
${CMAKE_SOURCE_DIR}/src
${CMAKE_SOURCE_DIR}/third_party/FreeRDP/include
${CMAKE_SOURCE_DIR}/third_party/FreeRDP/winpr/include
${CMAKE_BINARY_DIR}/third_party/FreeRDP/include
${CMAKE_BINARY_DIR}/third_party/FreeRDP/winpr/include
)
target_compile_definitions(test_rdp_session_backend PRIVATE ORBITHUB_HAS_FREERDP)
target_link_libraries(test_rdp_session_backend PRIVATE Qt6::Core Qt6::Gui Qt6::Test freerdp winpr)
if(TARGET freerdp-client)
target_link_libraries(test_rdp_session_backend PRIVATE freerdp-client)
endif()
add_test(NAME test_rdp_session_backend COMMAND test_rdp_session_backend)
else()
message(STATUS "FreeRDP targets not available -- skipping test_rdp_session_backend")
endif()
Vendored Executable
+45
View File
@@ -0,0 +1,45 @@
#!/bin/sh
# Minimal, deterministic stand-in for the real `ssh` binary, used by
# SshSessionBackend's state-machine tests so they never touch a real
# network or SSH server. Behavior is selected by which fixture hostname
# appears among argv (SshSessionBackend always passes the profile's
# host, optionally as user@host, as the final argument).
for arg in "$@"; do
case "$arg" in
prompted-user@requireuser)
# Only the exact user@host below is accepted -- used to prove a
# username supplied via SessionConnectOptions (prompted for at
# connect time because the saved profile's own username was
# blank) actually reaches the ssh command line, not just that
# *some* connection to this host succeeds.
echo "Welcome to the fake host."
while IFS= read -r line; do
echo "$line"
done
exit 0
;;
*@requireuser|requireuser)
echo "Permission denied (publickey,password)." >&2
exit 255
;;
*@succeed|succeed)
echo "Welcome to the fake host."
# Stay alive echoing stdin back (simulates an interactive
# session) until the backend terminates us.
while IFS= read -r line; do
echo "$line"
done
exit 0
;;
*@fail-auth|fail-auth)
echo "Permission denied (publickey,password)." >&2
exit 255
;;
*@refuse|refuse)
echo "ssh: connect to host refuse port 22: Connection refused" >&2
exit 255
;;
esac
done
echo "fake_ssh.sh: no recognized fixture host in arguments: $*" >&2
exit 1
+124
View File
@@ -0,0 +1,124 @@
#include "mremoteng_importer.h"
#include "test_mremoteng_importer_fixtures.h"
#include <QTest>
#include <algorithm>
class TestMRemoteNGImporter : public QObject
{
Q_OBJECT
private slots:
void parsesFoldersAndProfilesFromRealisticSample();
void mapsRdpConnectionFieldsCorrectly();
void mapsSshConnectionFieldsCorrectly();
void skipsUnsupportedProtocolWithoutDroppingSilently();
void neverImportsPasswordField();
void refusesFullFileEncryptedExports();
void refusesUnrecognizedRootElement();
void refusesMalformedXml();
void refusesEmptyInput();
};
void TestMRemoteNGImporter::parsesFoldersAndProfilesFromRealisticSample()
{
const MRemoteNGImportResult result = parseMRemoteNGConnections(QByteArray(mRemoteNGSampleXml()));
QVERIFY(result.errorMessage.isEmpty());
QCOMPARE(result.folders.size(), size_t(2));
QCOMPARE(result.folders[0], QStringLiteral("Work"));
QCOMPARE(result.folders[1], QStringLiteral("Work/Servers"));
// 3 connection nodes in the sample: DC (RDP), build-box (SSH2),
// oldkiosk (VNC, unsupported) -- only the first two should come
// through as imported profiles.
QCOMPARE(result.profiles.size(), size_t(2));
QCOMPARE(result.skippedUnsupportedProtocol.size(), 1);
}
void TestMRemoteNGImporter::mapsRdpConnectionFieldsCorrectly()
{
const MRemoteNGImportResult result = parseMRemoteNGConnections(QByteArray(mRemoteNGSampleXml()));
const auto it = std::find_if(result.profiles.begin(), result.profiles.end(),
[](const MRemoteNGImportedProfile& p) {
return p.profile.name == QStringLiteral("DC");
});
QVERIFY(it != result.profiles.end());
QCOMPARE(it->profile.host, QStringLiteral("10.0.0.5"));
QCOMPARE(it->profile.port, 3389);
QCOMPARE(it->profile.username, QStringLiteral("Administrator"));
QCOMPARE(it->profile.protocol, QStringLiteral("RDP"));
QCOMPARE(it->profile.domain, QStringLiteral("CORP"));
QCOMPARE(it->folderPath, QStringLiteral("Work"));
}
void TestMRemoteNGImporter::mapsSshConnectionFieldsCorrectly()
{
const MRemoteNGImportResult result = parseMRemoteNGConnections(QByteArray(mRemoteNGSampleXml()));
const auto it = std::find_if(result.profiles.begin(), result.profiles.end(),
[](const MRemoteNGImportedProfile& p) {
return p.profile.name == QStringLiteral("build-box");
});
QVERIFY(it != result.profiles.end());
QCOMPARE(it->profile.host, QStringLiteral("build.internal"));
QCOMPARE(it->profile.port, 22);
QCOMPARE(it->profile.username, QStringLiteral("deploy"));
// SSH1/SSH2 both collapse to OrbitHub's single "SSH" protocol.
QCOMPARE(it->profile.protocol, QStringLiteral("SSH"));
QCOMPARE(it->profile.authMode, QStringLiteral("Password"));
// Domain is RDP-only; must not leak through for SSH.
QCOMPARE(it->profile.domain, QString());
QCOMPARE(it->folderPath, QStringLiteral("Work/Servers"));
}
void TestMRemoteNGImporter::skipsUnsupportedProtocolWithoutDroppingSilently()
{
const MRemoteNGImportResult result = parseMRemoteNGConnections(QByteArray(mRemoteNGSampleXml()));
QCOMPARE(result.skippedUnsupportedProtocol.size(), 1);
QVERIFY(result.skippedUnsupportedProtocol[0].contains(QStringLiteral("oldkiosk")));
QVERIFY(result.skippedUnsupportedProtocol[0].contains(QStringLiteral("VNC")));
}
void TestMRemoteNGImporter::neverImportsPasswordField()
{
// Profile has no password-storing field at all -- this test exists to
// document that guarantee, not to probe internals that don't exist.
const MRemoteNGImportResult result = parseMRemoteNGConnections(QByteArray(mRemoteNGSampleXml()));
QVERIFY(!result.profiles.empty());
for (const MRemoteNGImportedProfile& item : result.profiles) {
QCOMPARE(item.profile.authMode, QStringLiteral("Password"));
QVERIFY(item.profile.privateKeyPath.isEmpty());
}
}
void TestMRemoteNGImporter::refusesFullFileEncryptedExports()
{
const MRemoteNGImportResult result = parseMRemoteNGConnections(QByteArray(mRemoteNGFullFileEncryptedXml()));
QVERIFY(!result.errorMessage.isEmpty());
QVERIFY(result.errorMessage.contains(QStringLiteral("full file encryption"), Qt::CaseInsensitive));
QVERIFY(result.profiles.empty());
}
void TestMRemoteNGImporter::refusesUnrecognizedRootElement()
{
const MRemoteNGImportResult result = parseMRemoteNGConnections(QByteArray(mRemoteNGWrongRootXml()));
QVERIFY(!result.errorMessage.isEmpty());
}
void TestMRemoteNGImporter::refusesMalformedXml()
{
const MRemoteNGImportResult result =
parseMRemoteNGConnections(QByteArray("<mrng:Connections><Node "));
QVERIFY(!result.errorMessage.isEmpty());
}
void TestMRemoteNGImporter::refusesEmptyInput()
{
const MRemoteNGImportResult result = parseMRemoteNGConnections(QByteArray());
QVERIFY(!result.errorMessage.isEmpty());
}
QTEST_APPLESS_MAIN(TestMRemoteNGImporter)
#include "test_mremoteng_importer.moc"
+47
View File
@@ -0,0 +1,47 @@
#ifndef ORBITHUB_TEST_MREMOTENG_IMPORTER_FIXTURES_H
#define ORBITHUB_TEST_MREMOTENG_IMPORTER_FIXTURES_H
// Kept out of the moc-processed test .cpp: a literal "//" inside a raw
// string literal (as in the xmlns URL below) confuses moc's lexer into
// thinking a line comment started there, which silently desyncs the rest
// of its parse and drops the QObject-derived test class entirely (no
// error, just "No relevant classes found" and a missing vtable at link
// time). Plain, non-QObject headers are never moc-scanned, so this is
// immune to that.
// Modeled on a real mRemoteNG confCons.xml export (attribute names and
// root-element shape verified against mRemoteNG's own
// XmlConnectionsDeserializer.cs and a real exported sample), covering:
// nested folders, an RDP connection, an SSH2 connection (protocol must
// map to "SSH"), and a VNC connection (unsupported -- must be skipped,
// not dropped silently).
inline const char* mRemoteNGSampleXml()
{
return R"(<?xml version="1.0" encoding="utf-8"?>
<mrng:Connections xmlns:mrng="http:)" R"(//mremoteng.org" Name="Connections" Export="false" EncryptionEngine="AES" BlockCipherMode="GCM" KdfIterations="1000" FullFileEncryption="false" Protected="" ConfVersion="2.6">
<Node Name="Work" Type="Container" Descr="" Expanded="true">
<Node Name="DC" Type="Connection" Descr="" Username="Administrator" Domain="CORP" Password="aEWNFV5uGcjUHF0uS17QTdT9kVqtKCPeoC0Nw5dmaPFjNQ2kt/zO5xDqE4HdVmHAowVRdC7emf7lWWA10dQKiw==" Hostname="10.0.0.5" Protocol="RDP" Port="3389" />
<Node Name="Servers" Type="Container" Descr="" Expanded="true">
<Node Name="build-box" Type="Connection" Descr="" Username="deploy" Domain="" Password="yhgmiu5bbuamU3qMUKc/uYDdmbMrJZ" Hostname="build.internal" Protocol="SSH2" Port="22" />
</Node>
</Node>
<Node Name="oldkiosk" Type="Connection" Descr="" Username="" Domain="" Password="" Hostname="kiosk.internal" Protocol="VNC" Port="5900" />
</mrng:Connections>
)";
}
inline const char* mRemoteNGFullFileEncryptedXml()
{
return R"(<?xml version="1.0" encoding="utf-8"?>
<mrng:Connections xmlns:mrng="http:)" R"(//mremoteng.org" Name="Connections" Export="false" EncryptionEngine="AES" BlockCipherMode="GCM" KdfIterations="1000" FullFileEncryption="true" Protected="somehash" ConfVersion="2.6">SomeOpaqueBase64Blob==</mrng:Connections>
)";
}
inline const char* mRemoteNGWrongRootXml()
{
return R"(<?xml version="1.0" encoding="utf-8"?>
<SomeOtherFormat/>
)";
}
#endif
+381
View File
@@ -0,0 +1,381 @@
#include "profile_repository.h"
#include <QTemporaryDir>
#include <QTest>
#include <memory>
namespace {
Profile makeSshProfile(const QString& name = QStringLiteral("Prod SSH Box"))
{
Profile profile;
profile.name = name;
profile.host = QStringLiteral("prod.example.com");
profile.port = 22;
profile.username = QStringLiteral("deploy");
profile.protocol = QStringLiteral("SSH");
profile.authMode = QStringLiteral("Password");
profile.tags = QStringLiteral("prod,linux");
return profile;
}
Profile makeRdpProfile(const QString& name = QStringLiteral("Windows RDP Box"))
{
Profile profile;
profile.name = name;
profile.host = QStringLiteral("win.example.com");
profile.port = 3389;
profile.username = QStringLiteral("admin");
profile.domain = QStringLiteral("CORP");
profile.protocol = QStringLiteral("RDP");
profile.rdpSecurityMode = QStringLiteral("NLA");
profile.rdpPerformanceProfile = QStringLiteral("Best Performance");
return profile;
}
}
class TestProfileRepository : public QObject
{
Q_OBJECT
private slots:
void init();
void cleanup();
void initializesCleanly();
void createAndGetSshProfile();
void createAndGetRdpProfile();
void createProfileRejectsMissingName();
void createProfileRejectsMissingHost();
void createProfileRejectsInvalidPort();
void createProfileAllowsMissingUsernameForSsh();
void createProfileRejectsMissingPrivateKeyForKeyAuth();
void createProfileRejectsDuplicateName();
void updateProfilePersistsChanges();
void deleteProfileRemovesIt();
void getProfileReturnsNulloptForUnknownId();
void listProfilesFiltersBySearchQuery();
void listProfilesSortsByRequestedOrder();
void tagsAreTrimmedDedupedAndJoined();
void emptyTagsRoundTripAsEmpty();
void folderCreateAndListRoundTrips();
void folderCreateIgnoresDuplicates();
void folderPathIsNormalized();
void deleteEmptyFolderRemovesIt();
void deleteFolderMovesDirectProfilesToParent();
void deleteRootLevelFolderMovesProfilesToRoot();
void deleteFolderShiftsSubfoldersAndTheirProfilesUp();
void deleteFolderRejectsEmptyPath();
void deleteNonexistentFolderSucceedsAsNoOp();
private:
std::unique_ptr<QTemporaryDir> m_tempDir;
std::unique_ptr<ProfileRepository> m_repo;
};
void TestProfileRepository::init()
{
m_tempDir = std::make_unique<QTemporaryDir>();
QVERIFY(m_tempDir->isValid());
m_repo = std::make_unique<ProfileRepository>(m_tempDir->filePath(QStringLiteral("test.sqlite")));
}
void TestProfileRepository::cleanup()
{
m_repo.reset();
m_tempDir.reset();
}
void TestProfileRepository::initializesCleanly()
{
QCOMPARE(m_repo->initError(), QString());
QCOMPARE(m_repo->listProfiles().size(), size_t(0));
QCOMPARE(m_repo->listFolders().size(), size_t(0));
}
void TestProfileRepository::createAndGetSshProfile()
{
const Profile input = makeSshProfile();
const std::optional<Profile> created = m_repo->createProfile(input);
QVERIFY(created.has_value());
QVERIFY(created->id > 0);
const std::optional<Profile> fetched = m_repo->getProfile(created->id);
QVERIFY(fetched.has_value());
QCOMPARE(fetched->name, input.name);
QCOMPARE(fetched->host, input.host);
QCOMPARE(fetched->port, input.port);
QCOMPARE(fetched->username, input.username);
QCOMPARE(fetched->protocol, QStringLiteral("SSH"));
QCOMPARE(fetched->authMode, QStringLiteral("Password"));
QCOMPARE(fetched->tags, QStringLiteral("prod, linux"));
}
void TestProfileRepository::createAndGetRdpProfile()
{
const Profile input = makeRdpProfile();
const std::optional<Profile> created = m_repo->createProfile(input);
QVERIFY(created.has_value());
const std::optional<Profile> fetched = m_repo->getProfile(created->id);
QVERIFY(fetched.has_value());
QCOMPARE(fetched->protocol, QStringLiteral("RDP"));
QCOMPARE(fetched->domain, QStringLiteral("CORP"));
QCOMPARE(fetched->rdpSecurityMode, QStringLiteral("NLA"));
QCOMPARE(fetched->rdpPerformanceProfile, QStringLiteral("Best Performance"));
QCOMPARE(fetched->port, 3389);
// Auth-mode/private-key fields are SSH-only and must not leak through
// for a non-SSH protocol.
QCOMPARE(fetched->authMode, QStringLiteral("Password"));
QCOMPARE(fetched->privateKeyPath, QString());
}
void TestProfileRepository::createProfileRejectsMissingName()
{
Profile profile = makeSshProfile();
profile.name.clear();
QVERIFY(!m_repo->createProfile(profile).has_value());
QVERIFY(!m_repo->lastError().isEmpty());
}
void TestProfileRepository::createProfileRejectsMissingHost()
{
Profile profile = makeSshProfile();
profile.host.clear();
QVERIFY(!m_repo->createProfile(profile).has_value());
QVERIFY(!m_repo->lastError().isEmpty());
}
void TestProfileRepository::createProfileRejectsInvalidPort()
{
Profile profile = makeSshProfile();
profile.port = 0;
QVERIFY(!m_repo->createProfile(profile).has_value());
profile.port = 70000;
QVERIFY(!m_repo->createProfile(profile).has_value());
}
void TestProfileRepository::createProfileAllowsMissingUsernameForSsh()
{
// SSH/RDP no longer require a username at save time (issue #21) --
// among other things, this unblocks importing mRemoteNG entries that
// don't have one recorded, which used to fail outright. The user is
// asked for it at connect time instead (see SessionTab).
Profile profile = makeSshProfile();
profile.username.clear();
const auto created = m_repo->createProfile(profile);
QVERIFY(created.has_value());
QVERIFY(created->username.isEmpty());
}
void TestProfileRepository::createProfileRejectsMissingPrivateKeyForKeyAuth()
{
Profile profile = makeSshProfile();
profile.authMode = QStringLiteral("Private Key");
profile.privateKeyPath.clear();
QVERIFY(!m_repo->createProfile(profile).has_value());
}
void TestProfileRepository::createProfileRejectsDuplicateName()
{
QVERIFY(m_repo->createProfile(makeSshProfile(QStringLiteral("Same Name"))).has_value());
QVERIFY(!m_repo->createProfile(makeSshProfile(QStringLiteral("Same Name"))).has_value());
}
void TestProfileRepository::updateProfilePersistsChanges()
{
const std::optional<Profile> created = m_repo->createProfile(makeSshProfile());
QVERIFY(created.has_value());
Profile updated = created.value();
updated.host = QStringLiteral("new-host.example.com");
updated.port = 2222;
updated.tags = QStringLiteral("updated");
QVERIFY(m_repo->updateProfile(updated));
const std::optional<Profile> fetched = m_repo->getProfile(created->id);
QVERIFY(fetched.has_value());
QCOMPARE(fetched->host, QStringLiteral("new-host.example.com"));
QCOMPARE(fetched->port, 2222);
QCOMPARE(fetched->tags, QStringLiteral("updated"));
}
void TestProfileRepository::deleteProfileRemovesIt()
{
const std::optional<Profile> created = m_repo->createProfile(makeSshProfile());
QVERIFY(created.has_value());
QVERIFY(m_repo->deleteProfile(created->id));
QVERIFY(!m_repo->getProfile(created->id).has_value());
}
void TestProfileRepository::getProfileReturnsNulloptForUnknownId()
{
QVERIFY(!m_repo->getProfile(999999).has_value());
}
void TestProfileRepository::listProfilesFiltersBySearchQuery()
{
QVERIFY(m_repo->createProfile(makeSshProfile(QStringLiteral("Alpha"))).has_value());
QVERIFY(m_repo->createProfile(makeRdpProfile(QStringLiteral("Beta"))).has_value());
const auto byName = m_repo->listProfiles(QStringLiteral("Alpha"));
QCOMPARE(byName.size(), size_t(1));
QCOMPARE(byName[0].name, QStringLiteral("Alpha"));
const auto byHost = m_repo->listProfiles(QStringLiteral("win.example"));
QCOMPARE(byHost.size(), size_t(1));
QCOMPARE(byHost[0].name, QStringLiteral("Beta"));
const auto byTag = m_repo->listProfiles(QStringLiteral("linux"));
QCOMPARE(byTag.size(), size_t(1));
QCOMPARE(byTag[0].name, QStringLiteral("Alpha"));
QCOMPARE(m_repo->listProfiles(QStringLiteral("nonexistent")).size(), size_t(0));
}
void TestProfileRepository::listProfilesSortsByRequestedOrder()
{
QVERIFY(m_repo->createProfile(makeSshProfile(QStringLiteral("Zeta"))).has_value());
QVERIFY(m_repo->createProfile(makeRdpProfile(QStringLiteral("Alpha"))).has_value());
const auto byName = m_repo->listProfiles(QString(), ProfileSortOrder::NameAsc);
QCOMPARE(byName.size(), size_t(2));
QCOMPARE(byName[0].name, QStringLiteral("Alpha"));
QCOMPARE(byName[1].name, QStringLiteral("Zeta"));
const auto byProtocol = m_repo->listProfiles(QString(), ProfileSortOrder::ProtocolAsc);
QCOMPARE(byProtocol[0].protocol, QStringLiteral("RDP"));
QCOMPARE(byProtocol[1].protocol, QStringLiteral("SSH"));
}
void TestProfileRepository::tagsAreTrimmedDedupedAndJoined()
{
Profile profile = makeSshProfile();
profile.tags = QStringLiteral(" prod ,, Prod , linux ,linux");
const std::optional<Profile> created = m_repo->createProfile(profile);
QVERIFY(created.has_value());
// createProfile()'s return value echoes the input as-is; normalization
// only happens on the DB round trip, so re-fetch to observe it.
const std::optional<Profile> fetched = m_repo->getProfile(created->id);
QVERIFY(fetched.has_value());
// Case-insensitive de-dup keeps the first-seen casing of each tag.
QCOMPARE(fetched->tags, QStringLiteral("prod, linux"));
}
void TestProfileRepository::emptyTagsRoundTripAsEmpty()
{
const std::optional<Profile> created = m_repo->createProfile(makeRdpProfile());
QVERIFY(created.has_value());
const std::optional<Profile> fetched = m_repo->getProfile(created->id);
QVERIFY(fetched.has_value());
QCOMPARE(fetched->tags, QString());
}
void TestProfileRepository::folderCreateAndListRoundTrips()
{
QVERIFY(m_repo->createFolder(QStringLiteral("Work/Servers")));
const auto folders = m_repo->listFolders();
QCOMPARE(folders.size(), size_t(1));
QCOMPARE(folders[0], QStringLiteral("Work/Servers"));
}
void TestProfileRepository::folderCreateIgnoresDuplicates()
{
QVERIFY(m_repo->createFolder(QStringLiteral("Work")));
QVERIFY(m_repo->createFolder(QStringLiteral("Work")));
QCOMPARE(m_repo->listFolders().size(), size_t(1));
}
void TestProfileRepository::folderPathIsNormalized()
{
QVERIFY(m_repo->createFolder(QStringLiteral("\\Work\\\\Servers\\")));
const auto folders = m_repo->listFolders();
QCOMPARE(folders.size(), size_t(1));
QCOMPARE(folders[0], QStringLiteral("Work/Servers"));
}
void TestProfileRepository::deleteEmptyFolderRemovesIt()
{
QVERIFY(m_repo->createFolder(QStringLiteral("Empty")));
QVERIFY(m_repo->deleteFolder(QStringLiteral("Empty")));
QCOMPARE(m_repo->listFolders().size(), size_t(0));
}
void TestProfileRepository::deleteFolderMovesDirectProfilesToParent()
{
QVERIFY(m_repo->createFolder(QStringLiteral("Work")));
Profile profile = makeSshProfile();
profile.folderPath = QStringLiteral("Work");
const std::optional<Profile> created = m_repo->createProfile(profile);
QVERIFY(created.has_value());
QVERIFY(m_repo->deleteFolder(QStringLiteral("Work")));
QCOMPARE(m_repo->listFolders().size(), size_t(0));
const std::optional<Profile> fetched = m_repo->getProfile(created->id);
QVERIFY(fetched.has_value());
// Deleting a folder is never destructive to profiles -- they shift up
// to take its place, here landing at the root since "Work" had no
// parent of its own.
QCOMPARE(fetched->folderPath, QString());
}
void TestProfileRepository::deleteRootLevelFolderMovesProfilesToRoot()
{
Profile profile = makeRdpProfile();
profile.folderPath = QStringLiteral("Solo");
const std::optional<Profile> created = m_repo->createProfile(profile);
QVERIFY(created.has_value());
QVERIFY(m_repo->deleteFolder(QStringLiteral("Solo")));
const std::optional<Profile> fetched = m_repo->getProfile(created->id);
QVERIFY(fetched.has_value());
QCOMPARE(fetched->folderPath, QString());
}
void TestProfileRepository::deleteFolderShiftsSubfoldersAndTheirProfilesUp()
{
QVERIFY(m_repo->createFolder(QStringLiteral("Work/Servers")));
Profile inTarget = makeSshProfile(QStringLiteral("InWork"));
inTarget.folderPath = QStringLiteral("Work");
const std::optional<Profile> createdInTarget = m_repo->createProfile(inTarget);
QVERIFY(createdInTarget.has_value());
Profile inSubfolder = makeRdpProfile(QStringLiteral("InServers"));
inSubfolder.folderPath = QStringLiteral("Work/Servers");
const std::optional<Profile> createdInSubfolder = m_repo->createProfile(inSubfolder);
QVERIFY(createdInSubfolder.has_value());
QVERIFY(m_repo->deleteFolder(QStringLiteral("Work")));
// "Work/Servers" shifts up to become root-level "Servers"; the profile
// that was directly in "Work" moves to root; nothing is deleted.
const auto folders = m_repo->listFolders();
QCOMPARE(folders.size(), size_t(1));
QCOMPARE(folders[0], QStringLiteral("Servers"));
const std::optional<Profile> fetchedInTarget = m_repo->getProfile(createdInTarget->id);
QVERIFY(fetchedInTarget.has_value());
QCOMPARE(fetchedInTarget->folderPath, QString());
const std::optional<Profile> fetchedInSubfolder = m_repo->getProfile(createdInSubfolder->id);
QVERIFY(fetchedInSubfolder.has_value());
QCOMPARE(fetchedInSubfolder->folderPath, QStringLiteral("Servers"));
}
void TestProfileRepository::deleteFolderRejectsEmptyPath()
{
QVERIFY(!m_repo->deleteFolder(QString()));
QVERIFY(!m_repo->lastError().isEmpty());
}
void TestProfileRepository::deleteNonexistentFolderSucceedsAsNoOp()
{
QVERIFY(m_repo->deleteFolder(QStringLiteral("Never/Created")));
}
QTEST_GUILESS_MAIN(TestProfileRepository)
#include "test_profile_repository.moc"
+281
View File
@@ -0,0 +1,281 @@
#include "rdp_session_backend.h"
#include <QTest>
#include <freerdp/error.h>
#include <freerdp/locale/keyboard.h>
#include <freerdp/scancode.h>
class TestRdpSessionBackend : public QObject
{
Q_OBJECT
private slots:
void normalizedRdpSecurityModeRecognizesKnownValues();
void normalizedRdpSecurityModeFallsBackToNegotiate();
void normalizedRdpPerformanceProfileRecognizesKnownValues();
void normalizedRdpPerformanceProfileFallsBackToBalanced();
void nearestFreeRdpScaleValueMapsToLegalValues();
void sanitizeDesktopWidthClampsToLegalRange();
void sanitizeDesktopHeightClampsToLegalRange();
void scancodeFromNativeScanCodeHandlesZero();
#if defined(Q_OS_LINUX)
void scancodeFromNativeScanCodeDelegatesToX11TableOnLinux();
void scancodeFromNativeScanCodeDoesNotTreatX11KeycodeAsPcAtScancode();
#endif
void scancodeForQtKeyMapsDirectKeys();
void scancodeForQtKeyRespectsKeypadModifier();
void scancodeForQtKeyDisambiguatesLeftRightModifiers();
void scancodeForQtKeyReturnsUnknownForUnhandledKey();
void mapRdpErrorRecognizesAuthFailureCodes();
void mapRdpErrorRecognizesAccountStateCodes();
void mapRdpErrorRecognizesNetworkCodes();
void mapRdpErrorFallsBackForUnknownCode();
void isExpectedDisconnectCodeRecognizesBenignCodes();
void isExpectedDisconnectCodeRejectsAuthFailure();
void isExpectedConnectAbortCodeRecognizesCancellation();
void isExpectedConnectAbortCodeRejectsAuthFailure();
void disconnectMessageForCodeRecognizesKnownCodes();
void disconnectMessageForCodeFallsBackForUnknownCode();
void rdpErrorRawIncludesHexCode();
};
void TestRdpSessionBackend::normalizedRdpSecurityModeRecognizesKnownValues()
{
QCOMPARE(RdpSessionBackend::normalizedRdpSecurityMode(QStringLiteral("nla")),
QStringLiteral("NLA"));
QCOMPARE(RdpSessionBackend::normalizedRdpSecurityMode(QStringLiteral(" TLS ")),
QStringLiteral("TLS"));
QCOMPARE(RdpSessionBackend::normalizedRdpSecurityMode(QStringLiteral("Rdp")),
QStringLiteral("RDP"));
}
void TestRdpSessionBackend::normalizedRdpSecurityModeFallsBackToNegotiate()
{
QCOMPARE(RdpSessionBackend::normalizedRdpSecurityMode(QStringLiteral("bogus")),
QStringLiteral("Negotiate"));
QCOMPARE(RdpSessionBackend::normalizedRdpSecurityMode(QString()),
QStringLiteral("Negotiate"));
}
void TestRdpSessionBackend::normalizedRdpPerformanceProfileRecognizesKnownValues()
{
QCOMPARE(RdpSessionBackend::normalizedRdpPerformanceProfile(QStringLiteral("best quality")),
QStringLiteral("Best Quality"));
QCOMPARE(RdpSessionBackend::normalizedRdpPerformanceProfile(QStringLiteral(" Best Performance ")),
QStringLiteral("Best Performance"));
QCOMPARE(RdpSessionBackend::normalizedRdpPerformanceProfile(QStringLiteral("auto detect")),
QStringLiteral("Auto Detect"));
}
void TestRdpSessionBackend::normalizedRdpPerformanceProfileFallsBackToBalanced()
{
QCOMPARE(RdpSessionBackend::normalizedRdpPerformanceProfile(QStringLiteral("bogus")),
QStringLiteral("Balanced"));
}
void TestRdpSessionBackend::nearestFreeRdpScaleValueMapsToLegalValues()
{
// MS-RDPEDISP legally permits only {100, 140, 180} -- anything else is
// silently ignored by the server.
QCOMPARE(RdpSessionBackend::nearestFreeRdpScaleValue(1.0), quint32(100));
QCOMPARE(RdpSessionBackend::nearestFreeRdpScaleValue(1.2), quint32(100));
QCOMPARE(RdpSessionBackend::nearestFreeRdpScaleValue(1.25), quint32(140));
QCOMPARE(RdpSessionBackend::nearestFreeRdpScaleValue(1.6), quint32(140));
QCOMPARE(RdpSessionBackend::nearestFreeRdpScaleValue(2.0), quint32(180));
QCOMPARE(RdpSessionBackend::nearestFreeRdpScaleValue(3.0), quint32(180));
}
void TestRdpSessionBackend::sanitizeDesktopWidthClampsToLegalRange()
{
QCOMPARE(RdpSessionBackend::sanitizeDesktopWidth(0), 1280);
QCOMPARE(RdpSessionBackend::sanitizeDesktopWidth(-100), 1280);
QCOMPARE(RdpSessionBackend::sanitizeDesktopWidth(100), 640);
QCOMPARE(RdpSessionBackend::sanitizeDesktopWidth(1920), 1920);
QCOMPARE(RdpSessionBackend::sanitizeDesktopWidth(99999), 8192);
}
void TestRdpSessionBackend::sanitizeDesktopHeightClampsToLegalRange()
{
QCOMPARE(RdpSessionBackend::sanitizeDesktopHeight(0), 720);
QCOMPARE(RdpSessionBackend::sanitizeDesktopHeight(-100), 720);
QCOMPARE(RdpSessionBackend::sanitizeDesktopHeight(100), 360);
QCOMPARE(RdpSessionBackend::sanitizeDesktopHeight(1080), 1080);
QCOMPARE(RdpSessionBackend::sanitizeDesktopHeight(99999), 4320);
}
void TestRdpSessionBackend::scancodeFromNativeScanCodeHandlesZero()
{
QCOMPARE(RdpSessionBackend::scancodeFromNativeScanCode(0), quint32(RDP_SCANCODE_UNKNOWN));
}
#if defined(Q_OS_LINUX)
void TestRdpSessionBackend::scancodeFromNativeScanCodeDelegatesToX11TableOnLinux()
{
// Our wrapper must be a faithful passthrough to FreeRDP's own
// authoritative X11-keycode table, not a reimplementation of it.
const quint32 apostropheKeycode = 0x30;
#if defined(__GNUC__) || defined(__clang__)
#pragma GCC diagnostic push
#pragma GCC diagnostic ignored "-Wdeprecated-declarations"
#endif
const quint32 expected =
static_cast<quint32>(freerdp_keyboard_get_rdp_scancode_from_x11_keycode(apostropheKeycode));
#if defined(__GNUC__) || defined(__clang__)
#pragma GCC diagnostic pop
#endif
QCOMPARE(RdpSessionBackend::scancodeFromNativeScanCode(apostropheKeycode), expected);
}
void TestRdpSessionBackend::scancodeFromNativeScanCodeDoesNotTreatX11KeycodeAsPcAtScancode()
{
// Regression guard for the historical bug this table replaced: X11
// keycode 0x30 (apostrophe/quote) must NOT resolve to whatever a naive
// "treat the X11 keycode as a PC/AT set-1 scancode" interpretation
// would give (PC/AT 0x30 is the B key).
const quint32 apostropheKeycode = 0x30;
const quint32 naivePcAtInterpretation = MAKE_RDP_SCANCODE(apostropheKeycode, FALSE);
QVERIFY(RdpSessionBackend::scancodeFromNativeScanCode(apostropheKeycode)
!= naivePcAtInterpretation);
}
#endif
void TestRdpSessionBackend::scancodeForQtKeyMapsDirectKeys()
{
QCOMPARE(RdpSessionBackend::scancodeForQtKey(Qt::Key_Escape, Qt::NoModifier, 0),
quint32(RDP_SCANCODE_ESCAPE));
QCOMPARE(RdpSessionBackend::scancodeForQtKey(Qt::Key_A, Qt::NoModifier, 0),
quint32(RDP_SCANCODE_KEY_A));
QCOMPARE(RdpSessionBackend::scancodeForQtKey(Qt::Key_F1, Qt::NoModifier, 0),
quint32(RDP_SCANCODE_F1));
QCOMPARE(RdpSessionBackend::scancodeForQtKey(Qt::Key_Space, Qt::NoModifier, 0),
quint32(RDP_SCANCODE_SPACE));
}
void TestRdpSessionBackend::scancodeForQtKeyRespectsKeypadModifier()
{
QCOMPARE(RdpSessionBackend::scancodeForQtKey(Qt::Key_Insert, Qt::NoModifier, 0),
quint32(RDP_SCANCODE_INSERT));
QCOMPARE(RdpSessionBackend::scancodeForQtKey(Qt::Key_Insert, Qt::KeypadModifier, 0),
quint32(RDP_SCANCODE_NUMPAD0));
QCOMPARE(RdpSessionBackend::scancodeForQtKey(Qt::Key_Delete, Qt::NoModifier, 0),
quint32(RDP_SCANCODE_DELETE));
QCOMPARE(RdpSessionBackend::scancodeForQtKey(Qt::Key_Delete, Qt::KeypadModifier, 0),
quint32(RDP_SCANCODE_DECIMAL));
}
void TestRdpSessionBackend::scancodeForQtKeyDisambiguatesLeftRightModifiers()
{
// With no reliable native scancode (0 -> RDP_SCANCODE_UNKNOWN, which
// matches neither side), both Shift and Control must default to their
// left variant rather than picking arbitrarily.
QCOMPARE(RdpSessionBackend::scancodeForQtKey(Qt::Key_Shift, Qt::NoModifier, 0),
quint32(RDP_SCANCODE_LSHIFT));
QCOMPARE(RdpSessionBackend::scancodeForQtKey(Qt::Key_Control, Qt::NoModifier, 0),
quint32(RDP_SCANCODE_LCONTROL));
}
void TestRdpSessionBackend::scancodeForQtKeyReturnsUnknownForUnhandledKey()
{
QCOMPARE(RdpSessionBackend::scancodeForQtKey(Qt::Key_MediaPlay, Qt::NoModifier, 0),
quint32(RDP_SCANCODE_UNKNOWN));
}
void TestRdpSessionBackend::mapRdpErrorRecognizesAuthFailureCodes()
{
const QString expected = QStringLiteral("Authentication failed. Check username and password.");
QCOMPARE(RdpSessionBackend::mapRdpError(FREERDP_ERROR_CONNECT_LOGON_FAILURE), expected);
QCOMPARE(RdpSessionBackend::mapRdpError(FREERDP_ERROR_CONNECT_WRONG_PASSWORD), expected);
QCOMPARE(RdpSessionBackend::mapRdpError(FREERDP_ERROR_CONNECT_ACCESS_DENIED), expected);
}
void TestRdpSessionBackend::mapRdpErrorRecognizesAccountStateCodes()
{
QCOMPARE(RdpSessionBackend::mapRdpError(FREERDP_ERROR_CONNECT_ACCOUNT_DISABLED),
QStringLiteral("Account is disabled."));
QCOMPARE(RdpSessionBackend::mapRdpError(FREERDP_ERROR_CONNECT_ACCOUNT_LOCKED_OUT),
QStringLiteral("Account is locked out."));
QCOMPARE(RdpSessionBackend::mapRdpError(FREERDP_ERROR_CONNECT_ACCOUNT_EXPIRED),
QStringLiteral("Account has expired."));
QCOMPARE(RdpSessionBackend::mapRdpError(FREERDP_ERROR_CONNECT_PASSWORD_EXPIRED),
QStringLiteral("Password has expired."));
}
void TestRdpSessionBackend::mapRdpErrorRecognizesNetworkCodes()
{
QCOMPARE(RdpSessionBackend::mapRdpError(FREERDP_ERROR_DNS_NAME_NOT_FOUND),
QStringLiteral("Host could not be resolved."));
QCOMPARE(RdpSessionBackend::mapRdpError(FREERDP_ERROR_CONNECT_TRANSPORT_FAILED),
QStringLiteral("Network transport failed while connecting."));
QCOMPARE(RdpSessionBackend::mapRdpError(FREERDP_ERROR_SECURITY_NEGO_CONNECT_FAILED),
QStringLiteral("RDP security negotiation failed. Try a different RDP security mode."));
}
void TestRdpSessionBackend::mapRdpErrorFallsBackForUnknownCode()
{
// Not a code mapRdpError special-cases; must still return something
// non-empty rather than crashing or returning an empty string.
const QString result = RdpSessionBackend::mapRdpError(0x7FFFFFFF);
QVERIFY(!result.isEmpty());
}
void TestRdpSessionBackend::isExpectedDisconnectCodeRecognizesBenignCodes()
{
QVERIFY(RdpSessionBackend::isExpectedDisconnectCode(FREERDP_ERROR_SUCCESS));
QVERIFY(RdpSessionBackend::isExpectedDisconnectCode(FREERDP_ERROR_NONE));
QVERIFY(RdpSessionBackend::isExpectedDisconnectCode(FREERDP_ERROR_LOGOFF_BY_USER));
QVERIFY(RdpSessionBackend::isExpectedDisconnectCode(FREERDP_ERROR_IDLE_TIMEOUT));
}
void TestRdpSessionBackend::isExpectedDisconnectCodeRejectsAuthFailure()
{
// An authentication failure must be treated as a real error, never as
// an expected/benign disconnect -- otherwise the user would see no
// error message at all for a failed login.
QVERIFY(!RdpSessionBackend::isExpectedDisconnectCode(FREERDP_ERROR_CONNECT_LOGON_FAILURE));
}
void TestRdpSessionBackend::isExpectedConnectAbortCodeRecognizesCancellation()
{
QVERIFY(RdpSessionBackend::isExpectedConnectAbortCode(FREERDP_ERROR_CONNECT_CANCELLED));
QVERIFY(RdpSessionBackend::isExpectedConnectAbortCode(FREERDP_ERROR_SUCCESS));
}
void TestRdpSessionBackend::isExpectedConnectAbortCodeRejectsAuthFailure()
{
QVERIFY(!RdpSessionBackend::isExpectedConnectAbortCode(FREERDP_ERROR_CONNECT_LOGON_FAILURE));
}
void TestRdpSessionBackend::disconnectMessageForCodeRecognizesKnownCodes()
{
QCOMPARE(RdpSessionBackend::disconnectMessageForCode(FREERDP_ERROR_IDLE_TIMEOUT),
QStringLiteral("RDP session disconnected due to idle timeout."));
QCOMPARE(RdpSessionBackend::disconnectMessageForCode(FREERDP_ERROR_LOGOFF_BY_USER),
QStringLiteral("RDP session signed out."));
QCOMPARE(RdpSessionBackend::disconnectMessageForCode(FREERDP_ERROR_CONNECT_CANCELLED),
QStringLiteral("Connection cancelled."));
}
void TestRdpSessionBackend::disconnectMessageForCodeFallsBackForUnknownCode()
{
QCOMPARE(RdpSessionBackend::disconnectMessageForCode(0x7FFFFFFF),
QStringLiteral("RDP session ended."));
}
void TestRdpSessionBackend::rdpErrorRawIncludesHexCode()
{
const QString result = RdpSessionBackend::rdpErrorRaw(FREERDP_ERROR_SUCCESS);
QVERIFY(result.contains(QStringLiteral("(0x00000000)")));
}
QTEST_GUILESS_MAIN(TestRdpSessionBackend)
#include "test_rdp_session_backend.moc"
+268
View File
@@ -0,0 +1,268 @@
#include "ssh_session_backend.h"
#include <QTest>
#include <memory>
#ifndef ORBITHUB_TEST_FIXTURES_DIR
#error "ORBITHUB_TEST_FIXTURES_DIR must be defined by the build"
#endif
namespace {
Profile makeProfile(const QString& fixtureHost)
{
Profile profile;
profile.name = QStringLiteral("Test Profile");
profile.host = fixtureHost;
profile.port = 22;
profile.username = QStringLiteral("tester");
profile.protocol = QStringLiteral("SSH");
profile.authMode = QStringLiteral("Password");
return profile;
}
Profile makeBlankUsernameProfile(const QString& fixtureHost)
{
Profile profile = makeProfile(fixtureHost);
profile.username.clear();
return profile;
}
SessionConnectOptions makeOptions()
{
SessionConnectOptions options;
options.password = QStringLiteral("dummy-password");
return options;
}
}
class TestSshSessionBackend : public QObject
{
Q_OBJECT
private slots:
// Pure-function coverage -- no process involved.
void mapSshErrorRecognizesKnownPatterns();
void mapSshErrorFallsBackForUnknownText();
void mapSshErrorHandlesEmptyInput();
void escapeForShellSingleQuotesNeutralizesQuotes();
void escapeForShellSingleQuotesLeavesPlainTextAlone();
// State-machine coverage, driven against tests/fixtures/fake_ssh.sh
// instead of a real ssh binary or network.
void init();
void cleanup();
void successfulConnectReachesConnectedThenDisconnects();
void authFailureReachesFailedStateWithMappedMessage();
void connectionRefusedReachesFailedState();
void sendInputEchoesThroughOutputReceived();
void reconnectRestartsAndReachesConnectedAgain();
void connectOptionsUsernameReachesProcessWhenProfileUsernameIsBlank();
private:
QString fixturePath() const;
void createBackend(const QString& fixtureHost);
void createBackend(const Profile& profile);
std::unique_ptr<SshSessionBackend> m_backend;
SessionState m_lastState = SessionState::Disconnected;
QString m_lastErrorDisplay;
QString m_lastErrorRaw;
QString m_receivedOutput;
};
QString TestSshSessionBackend::fixturePath() const
{
return QStringLiteral(ORBITHUB_TEST_FIXTURES_DIR "/fake_ssh.sh");
}
void TestSshSessionBackend::createBackend(const QString& fixtureHost)
{
createBackend(makeProfile(fixtureHost));
}
void TestSshSessionBackend::createBackend(const Profile& profile)
{
m_backend = std::make_unique<SshSessionBackend>(profile, fixturePath(), nullptr);
connect(m_backend.get(),
&SessionBackend::stateChanged,
this,
[this](SessionState state, const QString&) { m_lastState = state; });
connect(m_backend.get(),
&SessionBackend::connectionError,
this,
[this](const QString& display, const QString& raw) {
m_lastErrorDisplay = display;
m_lastErrorRaw = raw;
});
connect(m_backend.get(),
&SessionBackend::outputReceived,
this,
[this](const QString& chunk) { m_receivedOutput += chunk; });
}
void TestSshSessionBackend::mapSshErrorRecognizesKnownPatterns()
{
QCOMPARE(SshSessionBackend::mapSshError(QStringLiteral("Permission denied (publickey,password).")),
QStringLiteral("Authentication failed. Check username and credentials."));
QCOMPARE(SshSessionBackend::mapSshError(QStringLiteral("Host key verification failed.")),
QStringLiteral("Host key verification failed."));
QCOMPARE(SshSessionBackend::mapSshError(QStringLiteral("ssh: Could not resolve hostname bogus")),
QStringLiteral("Host could not be resolved."));
QCOMPARE(SshSessionBackend::mapSshError(QStringLiteral("ssh: connect to host x port 22: Connection timed out")),
QStringLiteral("Connection timed out."));
QCOMPARE(SshSessionBackend::mapSshError(QStringLiteral("ssh: connect to host x port 22: Connection refused")),
QStringLiteral("Connection refused by remote host."));
QCOMPARE(SshSessionBackend::mapSshError(QStringLiteral("ssh: connect to host x port 22: No route to host")),
QStringLiteral("No route to host."));
QCOMPARE(SshSessionBackend::mapSshError(QStringLiteral("Identity file /nope not accessible: No such file.")),
QStringLiteral("Private key file is not accessible."));
QCOMPARE(SshSessionBackend::mapSshError(QStringLiteral("posix_spawn: /usr/bin/ssh-askpass: No such file or directory")),
QStringLiteral("SSH password helper is missing or failed to launch."));
QCOMPARE(SshSessionBackend::mapSshError(QStringLiteral("open /some/other/path: No such file or directory")),
QStringLiteral("Required file was not found."));
}
void TestSshSessionBackend::mapSshErrorFallsBackForUnknownText()
{
QCOMPARE(SshSessionBackend::mapSshError(QStringLiteral("some completely novel ssh error text")),
QStringLiteral("SSH connection failed."));
}
void TestSshSessionBackend::mapSshErrorHandlesEmptyInput()
{
QCOMPARE(SshSessionBackend::mapSshError(QString()),
QStringLiteral("SSH connection failed for an unknown reason."));
QCOMPARE(SshSessionBackend::mapSshError(QStringLiteral(" ")),
QStringLiteral("SSH connection failed for an unknown reason."));
}
void TestSshSessionBackend::escapeForShellSingleQuotesNeutralizesQuotes()
{
// A password containing a single quote must not be able to break out
// of the single-quoted printf argument in the askpass script -- this
// is the actual security boundary, not just cosmetic escaping.
const QString malicious = QStringLiteral("pw' ; rm -rf ~ ; echo '");
const QString escaped = SshSessionBackend::escapeForShellSingleQuotes(malicious);
const QString reconstructedScriptArg = QStringLiteral("'") + escaped + QStringLiteral("'");
// Every single quote in the reconstructed argument must be either the
// outer boundary quote (open at index 0, close at the very end) or the
// start of a full '"'"' re-opening sequence -- never a bare, unescaped
// quote that could close the argument early.
int index = 0;
while (index < reconstructedScriptArg.length()) {
if (reconstructedScriptArg.at(index) != QChar::fromLatin1('\'')) {
++index;
continue;
}
if (index == 0 || index == reconstructedScriptArg.length() - 1) {
++index;
continue;
}
QCOMPARE(reconstructedScriptArg.mid(index, 5), QStringLiteral("'\"'\"'"));
index += 5;
}
}
void TestSshSessionBackend::escapeForShellSingleQuotesLeavesPlainTextAlone()
{
QCOMPARE(SshSessionBackend::escapeForShellSingleQuotes(QStringLiteral("plain-password-123")),
QStringLiteral("plain-password-123"));
}
void TestSshSessionBackend::init()
{
#ifdef Q_OS_WIN
// fixtures/fake_ssh.sh is a POSIX shell script; there's no Windows
// fixture yet, so skip only the tests that actually launch it. The
// pure-function tests above (mapSshError*, escapeForShellSingleQuotes*)
// don't touch the fixture and still run everywhere.
const QByteArray currentTest = QTest::currentTestFunction();
if (!currentTest.startsWith("mapSshError") && !currentTest.startsWith("escapeForShellSingleQuotes")) {
QSKIP("No Windows equivalent of tests/fixtures/fake_ssh.sh yet");
}
#endif
m_lastState = SessionState::Disconnected;
m_lastErrorDisplay.clear();
m_lastErrorRaw.clear();
m_receivedOutput.clear();
// Individual tests call createBackend() with the fixture host they
// need; most want "succeed", so provide it as the default here.
createBackend(QStringLiteral("succeed"));
}
void TestSshSessionBackend::cleanup()
{
if (m_backend) {
m_backend->disconnectSession();
}
m_backend.reset();
}
void TestSshSessionBackend::successfulConnectReachesConnectedThenDisconnects()
{
m_backend->connectSession(makeOptions());
QTRY_COMPARE(m_lastState, SessionState::Connected);
m_backend->disconnectSession();
QTRY_COMPARE(m_lastState, SessionState::Disconnected);
}
void TestSshSessionBackend::authFailureReachesFailedStateWithMappedMessage()
{
createBackend(QStringLiteral("fail-auth"));
m_backend->connectSession(makeOptions());
QTRY_COMPARE(m_lastState, SessionState::Failed);
QCOMPARE(m_lastErrorDisplay, QStringLiteral("Authentication failed. Check username and credentials."));
QVERIFY(m_lastErrorRaw.contains(QStringLiteral("Permission denied")));
}
void TestSshSessionBackend::connectionRefusedReachesFailedState()
{
createBackend(QStringLiteral("refuse"));
m_backend->connectSession(makeOptions());
QTRY_COMPARE(m_lastState, SessionState::Failed);
QCOMPARE(m_lastErrorDisplay, QStringLiteral("Connection refused by remote host."));
}
void TestSshSessionBackend::sendInputEchoesThroughOutputReceived()
{
m_backend->connectSession(makeOptions());
QTRY_COMPARE(m_lastState, SessionState::Connected);
m_backend->sendInput(QStringLiteral("hello-from-test\n"));
QTRY_VERIFY(m_receivedOutput.contains(QStringLiteral("hello-from-test")));
}
void TestSshSessionBackend::reconnectRestartsAndReachesConnectedAgain()
{
m_backend->connectSession(makeOptions());
QTRY_COMPARE(m_lastState, SessionState::Connected);
m_lastState = SessionState::Connecting;
m_backend->reconnectSession(makeOptions());
QTRY_COMPARE(m_lastState, SessionState::Connected);
}
void TestSshSessionBackend::connectOptionsUsernameReachesProcessWhenProfileUsernameIsBlank()
{
// Regression test for a bug where a username entered at the
// connect-time prompt (SessionTab::requestConnectOptions(), for a
// profile with no saved username -- issue #21) never actually reached
// the ssh process: SshSessionBackend built its target purely from
// profile().username, which is a separate copy captured when the
// backend was constructed and never sees SessionTab's later edit.
// fixtures/fake_ssh.sh's "requireuser" host only accepts the exact
// target "prompted-user@requireuser", so this fails unless
// SessionConnectOptions::username is actually used.
createBackend(makeBlankUsernameProfile(QStringLiteral("requireuser")));
SessionConnectOptions options = makeOptions();
options.username = QStringLiteral("prompted-user");
m_backend->connectSession(options);
QTRY_COMPARE(m_lastState, SessionState::Connected);
}
QTEST_GUILESS_MAIN(TestSshSessionBackend)
#include "test_ssh_session_backend.moc"
File diff suppressed because it is too large Load Diff
+124
View File
@@ -0,0 +1,124 @@
---
AccessModifierOffset: -2
AlignAfterOpenBracket: Align
AlignConsecutiveAssignments: false
AlignConsecutiveDeclarations: false
AlignEscapedNewlines: Left
AlignOperands: true
AlignTrailingComments: true
AllowAllParametersOfDeclarationOnNextLine: true
AllowShortBlocksOnASingleLine: false
AllowShortCaseLabelsOnASingleLine: false
AllowShortFunctionsOnASingleLine: None
AllowShortIfStatementsOnASingleLine: false
AllowShortLoopsOnASingleLine: false
AlwaysBreakAfterDefinitionReturnType: None
AlwaysBreakAfterReturnType: None
AlwaysBreakBeforeMultilineStrings: false
AlwaysBreakTemplateDeclarations: false
BinPackArguments: true
BinPackParameters: true
BraceWrapping:
AfterClass: true
AfterControlStatement: true
AfterEnum: true
AfterFunction: true
AfterNamespace: true
AfterObjCDeclaration: true
AfterStruct: true
AfterUnion: true
AfterExternBlock: true
BeforeCatch: true
BeforeElse: true
IndentBraces: false
SplitEmptyFunction: true
SplitEmptyRecord: true
SplitEmptyNamespace: true
BreakBeforeBinaryOperators: None
BreakBeforeBraces: Allman
BreakBeforeInheritanceComma: false
BreakBeforeTernaryOperators: true
BreakConstructorInitializersBeforeComma: false
BreakConstructorInitializers: BeforeColon
BreakStringLiterals: true
ColumnLimit: 100
CommentPragmas: '^ IWYU pragma:'
CompactNamespaces: false
ConstructorInitializerAllOnOneLineOrOnePerLine: false
ConstructorInitializerIndentWidth: 4
ContinuationIndentWidth: 4
Cpp11BracedListStyle: false
DerivePointerAlignment: false
DisableFormat: false
ExperimentalAutoDetectBinPacking: false
FixNamespaceComments: false
IncludeBlocks: Preserve
IncludeCategories:
- Regex: '^"(llvm|llvm-c|clang|clang-c)/'
Priority: 2
- Regex: '^(<|"(gtest|gmock|isl|json)/)'
Priority: 3
- Regex: '.*'
Priority: 1
IncludeIsMainRegex: '(Test)?$'
IndentCaseLabels: true
IndentPPDirectives: None
IndentWidth: 4
IndentWrappedFunctionNames: false
KeepEmptyLinesAtTheStartOfBlocks: true
MacroBlockBegin: ''
MacroBlockEnd: ''
MaxEmptyLinesToKeep: 1
PenaltyBreakAssignment: 2
PenaltyBreakBeforeFirstCallParameter: 19
PenaltyBreakComment: 300
PenaltyBreakFirstLessLess: 120
PenaltyBreakString: 1000
PenaltyExcessCharacter: 1000000
PenaltyReturnTypeOnItsOwnLine: 60
PointerAlignment: Left
ReflowComments: true
SortIncludes: false
SortUsingDeclarations: true
SpaceAfterCStyleCast: false
SpaceAfterTemplateKeyword: true
SpaceBeforeAssignmentOperators: true
SpaceBeforeParens: ControlStatements
SpaceInEmptyParentheses: false
SpacesBeforeTrailingComments: 1
SpacesInAngles: false
SpacesInContainerLiterals: false
SpacesInCStyleCastParentheses: false
SpacesInParentheses: false
SpacesInSquareBrackets: false
TabWidth: 4
UseTab: ForIndentation
...
Language: Cpp
Standard: Auto
NamespaceIndentation: All
ForEachMacros:
- foreach
- Q_FOREACH
- BOOST_FOREACH
...
Language: ObjC
PointerBindsToType: false
SortIncludes: false
ObjCBlockIndentWidth: 4
ObjCSpaceAfterProperty: false
ObjCSpaceBeforeProtocolList: true
...
Language: Java
BreakAfterJavaFieldAnnotations: false
...
Language: JavaScript
JavaScriptQuotes: Leave
JavaScriptWrapImports: true
...
Language: Proto
...
Language: TableGen
...
Language: TextProto
...
+138
View File
@@ -0,0 +1,138 @@
---
Checks: >
-*,
abseil-*,
altera-*,
bugprone-*,
cert-*,
clang-analyzer*,
concurrency-*,
cppcoreguidelines*,
google-*,
hicpp-*,
llvm-*,
modernize-*,
objc-*,
openmp-*,
performance-*,
portability-*,
readability-*,
-altera-id-dependent-backward-branch,
-altera-struct-pack-align,
-altera-unroll-loops,
-cppcoreguidelines-interfaces-global-init,
-bugprone-easily-swappable-parameters,
-bugprone-assignment-in-if-condition,
-bugprone-branch-clone,
-bugprone-macro-parentheses,
-cert-dcl16-c,
-cert-env33-c,
-cert-dcl50-cpp,
-clang-analyzer-webkit.NoUncountedMemberChecker,
-clang-analyzer-optin.performance.Padding,
-clang-analyzer-security.insecureAPI.DeprecatedOrUnsafeBufferHandling,
-clang-analyzer-security.VAList,
-clang-analyzer-valist.Uninitialized,
-cppcoreguidelines-pro-bounds-array-to-pointer-decay,
-cppcoreguidelines-owning-memory,
-cppcoreguidelines-avoid-c-arrays,
-cppcoreguidelines-avoid-do-while,
-cppcoreguidelines-avoid-magic-numbers,
-cppcoreguidelines-avoid-non-const-global-variables,
-cppcoreguidelines-macro-to-enum,
-cppcoreguidelines-macro-usage,
-cppcoreguidelines-pro-type-vararg,
-cppcoreguidelines-pro-type-reinterpret-cast,
-cppcoreguidelines-pro-bounds-pointer-arithmetic,
-cppcoreguidelines-no-malloc,
-cppcoreguidelines-use-enum-class,
-google-readability-braces-around-statements,
-google-readability-todo,
-hicpp-avoid-c-arrays,
-hicpp-braces-around-statements,
-hicpp-no-array-decay,
-hicpp-no-assembler,
-hicpp-multiway-paths-covered,
-hicpp-signed-bitwise,
-hicpp-uppercase-literal-suffix,
-hicpp-vararg,
-hicpp-no-malloc,
-llvm-use-ranges,
-llvm-header-guard,
-llvm-include-order,
-llvm-qualified-auto,
-llvm-else-after-return,
-readability-else-after-return,
-readability-avoid-nested-conditional-operator,
-modernize-use-using,
-modernize-avoid-variadic-functions,
-modernize-use-trailing-return-type,
-modernize-return-braced-init-list,
-modernize-macro-to-enum,
-modernize-pass-by-value,
-modernize-avoid-c-arrays,
-readability-use-anyofallof,
-readability-braces-around-statements,
-readability-convert-member-functions-to-static,
-readability-function-cognitive-complexity,
-readability-identifier-length,
-readability-implicit-bool-conversion,
-readability-magic-numbers,
-readability-math-missing-parentheses,
-readability-misleading-indentation,
-readability-qualified-auto,
-readability-redundant-parentheses,
-readability-suspicious-call-argument,
-readability-string-compare,
-readability-uppercase-literal-suffix,
-readability-use-concise-preprocessor-directives,
-performance-no-int-to-ptr,
-performance-enum-size,
-performance-avoid-endl,
-portability-avoid-pragma-once
WarningsAsErrors: ''
HeaderFilterRegex: ''
FormatStyle: file
User: nin
CheckOptions:
- key: readability-implicit-bool-conversion.AllowIntegerConditions
value: 'true'
- key: llvm-else-after-return.WarnOnConditionVariables
value: 'false'
- key: modernize-loop-convert.MinConfidence
value: reasonable
- key: modernize-replace-auto-ptr.IncludeStyle
value: llvm
- key: cert-str34-c.DiagnoseSignedUnsignedCharComparisons
value: 'false'
- key: google-readability-namespace-comments.ShortNamespaceLines
value: '10'
- key: cert-err33-c.CheckedFunctions
value: '::aligned_alloc;::asctime_s;::at_quick_exit;::atexit;::bsearch;::bsearch_s;::btowc;::c16rtomb;::c32rtomb;::calloc;::clock;::cnd_broadcast;::cnd_init;::cnd_signal;::cnd_timedwait;::cnd_wait;::ctime_s;::fclose;::fflush;::fgetc;::fgetpos;::fgets;::fgetwc;::fopen;::fopen_s;::fprintf;::fprintf_s;::fputc;::fputs;::fputwc;::fputws;::fread;::freopen;::freopen_s;::fscanf;::fscanf_s;::fseek;::fsetpos;::ftell;::fwprintf;::fwprintf_s;::fwrite;::fwscanf;::fwscanf_s;::getc;::getchar;::getenv;::getenv_s;::gets_s;::getwc;::getwchar;::gmtime;::gmtime_s;::localtime;::localtime_s;::malloc;::mbrtoc16;::mbrtoc32;::mbsrtowcs;::mbsrtowcs_s;::mbstowcs;::mbstowcs_s;::memchr;::mktime;::mtx_init;::mtx_lock;::mtx_timedlock;::mtx_trylock;::mtx_unlock;::printf_s;::putc;::putwc;::raise;::realloc;::remove;::rename;::scanf;::scanf_s;::setlocale;::setvbuf;::signal;::snprintf;::snprintf_s;::sprintf;::sprintf_s;::sscanf;::sscanf_s;::strchr;::strerror_s;::strftime;::strpbrk;::strrchr;::strstr;::strtod;::strtof;::strtoimax;::strtok;::strtok_s;::strtol;::strtold;::strtoll;::strtoul;::strtoull;::strtoumax;::strxfrm;::swprintf;::swprintf_s;::swscanf;::swscanf_s;::thrd_create;::thrd_detach;::thrd_join;::thrd_sleep;::time;::timespec_get;::tmpfile;::tmpfile_s;::tmpnam;::tmpnam_s;::tss_create;::tss_get;::tss_set;::ungetc;::ungetwc;::vfprintf;::vfprintf_s;::vfscanf;::vfscanf_s;::vfwprintf;::vfwprintf_s;::vfwscanf;::vfwscanf_s;::vprintf_s;::vscanf;::vscanf_s;::vsnprintf;::vsnprintf_s;::vsprintf;::vsprintf_s;::vsscanf;::vsscanf_s;::vswprintf;::vswprintf_s;::vswscanf;::vswscanf_s;::vwprintf_s;::vwscanf;::vwscanf_s;::wcrtomb;::wcschr;::wcsftime;::wcspbrk;::wcsrchr;::wcsrtombs;::wcsrtombs_s;::wcsstr;::wcstod;::wcstof;::wcstoimax;::wcstok;::wcstok_s;::wcstol;::wcstold;::wcstoll;::wcstombs;::wcstombs_s;::wcstoul;::wcstoull;::wcstoumax;::wcsxfrm;::wctob;::wctrans;::wctype;::wmemchr;::wprintf_s;::wscanf;::wscanf_s;'
- key: cert-oop54-cpp.WarnOnlyIfThisHasSuspiciousField
value: 'false'
- key: cert-dcl16-c.NewSuffixes
value: 'L;LL;LU;LLU'
- key: google-readability-braces-around-statements.ShortStatementLines
value: '1'
- key: cppcoreguidelines-non-private-member-variables-in-classes.IgnoreClassesWithAllMemberVariablesBeingPublic
value: 'true'
- key: google-readability-namespace-comments.SpacesBeforeComments
value: '2'
- key: modernize-loop-convert.MaxCopySize
value: '16'
- key: modernize-pass-by-value.IncludeStyle
value: llvm
- key: modernize-use-nullptr.NullMacros
value: 'NULL'
- key: llvm-qualified-auto.AddConstToQualified
value: 'false'
- key: modernize-loop-convert.NamingStyle
value: CamelCase
- key: llvm-else-after-return.WarnOnUnfixable
value: 'false'
- key: google-readability-function-size.StatementThreshold
value: '800'
...
+3
View File
@@ -0,0 +1,3 @@
.gitattributes export-ignore
.gitignore export-ignore
.github export-ignore
+28
View File
@@ -0,0 +1,28 @@
## Found a bug? - We would like to help you and smash the bug away.
1. __Please don't "report" questions as bugs.__
* We are reachable via
* Matrix room : #FreeRDP:matrix.org (main)
* XMPP channel: #FreeRDP#matrix.org@matrix.org (bridged)
* IRC channel : #freerdp @ irc.oftc.net (bridged)
* We are reachable via mailing list <freerdp-devel@lists.sourceforge.net>
* Try our mailing list for discussions/questions
1. Before reporting a bug have a look into our issue tracker to see if the bug was already reported and you can add some additional information.
1. If it's a __new__ bug - create a new issue.
1. For more details see https://github.com/FreeRDP/FreeRDP/wiki/BugReporting
## To save time and help us identify the issue a bug report should at least contain the following:
* a useful description of the bug - "It's not working" isn't good enough - you must try harder ;)
* the steps to reproduce the bug
* command line you have used
* to what system did you connect to? (win8, 2008, ..)
* what did you expect to happen?
* what actually happened?
* freerdp version (e.g. xfreerdp --version) or package version or git commit
* freerdp configuration (e.g. xfreerdp --buildconfig)
* operating System, architecture, distribution e.g. linux, amd64, debian
* if you built it yourself add some notes which branch you have used, also your cmake parameters can help
* extra information helping us to find the bug
## Please remove this text before submitting your issue!
_Thank you for reporting a bug!_
@@ -0,0 +1,7 @@
---
name: Backport
about: Create a issue to request/track a backport
---
Related pull request for master:
@@ -0,0 +1,55 @@
---
name: Bug report
about: Create a report to help us improve
---
**Found a bug? - We would like to help you and smash the bug away.**
1. __Please don't "report" questions as bugs. For these (questions/build instructions/...) please use one of the following means of contact:__
* We are reachable via:
* Matrix room : #FreeRDP:matrix.org (main)
* XMPP channel: #FreeRDP#matrix.org@matrix.org (bridged)
* IRC channel : #freerdp @ irc.oftc.net (bridged)
* We are reachable via mailing list <freerdp-devel@lists.sourceforge.net>
* Try our mailing list for discussions/questions
1. Before reporting a bug have a look into our issue tracker to see if the bug was already reported and you can add some additional information.
1. If it's a __new__ bug - create a new issue.
1. For more details see https://github.com/FreeRDP/FreeRDP/wiki/BugReporting
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots to help explain your problem.
**Application details**
* FreeRDP version (`xfreerdp /version`)
* Command line used
* Output of `xfreerdp /buildconfig`
* OS version connecting to (server side)
* If available the log output from a run with `/log-level:trace 2>&1 | tee log.txt`
* If you built it yourself add some notes which tag/commit/branch you have used, also your cmake parameters and
compiler can help
**Environment (please complete the following information):**
- OS: [e.g. Linux/Windows/Android/..]
- Version/Distribution: [e.g. Debian 10, Windows 2008, Android 10]
- Architecture: [amd64, arm]:
**Additional context**
Add any other context about the problem here.
** Please remove this text before submitting your issue!
_Thank you for reporting a bug!_
@@ -0,0 +1,17 @@
---
name: Feature request
about: Suggest an idea for this project
---
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
**Additional context**
Add any other context or screenshots about the feature request here.
+29
View File
@@ -0,0 +1,29 @@
## This is how are pull requests handled by FreeRDP
1. Every new pull request needs to build and pass the unit tests at https://ci.freerdp.com
1. At least 1 (better two) people need to review and test a pull request and agree to accept
## Preparations before creating a pull
* Rebase your branch to current master, no merges allowed!
* Try to clean up your commit history, group changes to commits
* Check your formatting! A _clang-format_ script can be found at ```.clang-format```
* The cmake target ```clangformat``` reformats the whole codebase
* Optional (but higly recommended)
* Run a clang scanbuild before and after your changes to avoid introducing new bugs
* Run your compiler at pedantic level to check for new warnings
## To ease accepting your contribution
* Give the pull request a proper name so people looking at it have an basic idea what it is for
* Add at least a brief description what it does (or should do :) and what it's good for
* Give instructions on how to test your changes
* Ideally add unit tests if adding new features
## What you should be prepared for
* fix issues found during the review phase
* Joining our chat to talk to other developers or help them test your pull might accelerate acceptance
* Matrix room : #FreeRDP:matrix.org (main)
* XMPP channel: #FreeRDP#matrix.org@matrix.org (bridged)
* IRC channel : #freerdp @ irc.oftc.net (bridged)
* Joining our mailing list <freerdp-devel@lists.sourceforge.net> may be helpful too.
* Check the pull request builder at https://ci.freerdp.com/job/code-quality-checker/ and fix all warnings affecting your code
## Please remove this text before submitting your pull!
+57
View File
@@ -0,0 +1,57 @@
name: abi-checker
on:
workflow_dispatch:
branches: [ master, stable* ]
inputs:
API_BASE_REF:
description: 'Base revision for ABI compatibility check'
required: true
default: '3.6.0'
pull_request:
branches: [ master, stable* ]
schedule:
- cron: '30 4 * * SUN'
jobs:
build:
runs-on: ubuntu-latest
name: "Run ABI checker on ubuntu-latest"
steps:
- name: "Check out pull request"
if: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || github.event_name == 'pull_request' }}
uses: suzuki-shunsuke/get-pr-action@v0.1.0
id: pr
- name: "Check out source"
uses: actions/checkout@v4
with:
fetch-depth: 0
ref: ${{steps.pr.outputs.merge_commit_sha}}
- name: "Prepare environment"
run: |
sudo apt-get update -q -y
sudo apt-get --fix-broken install -q -y
sudo apt-get install -q -y devscripts equivs abigail-tools \
clang \
pylint \
curl
./packaging/scripts/prepare_deb_freerdp-nightly.sh
sudo mk-build-deps -i
- name: "Prepare configuration"
run: |
mkdir -p abi-checker
cp scripts/abi-diff.sh abi-checker/
echo "GITHUB_BASE_REF=$GITHUB_BASE_REF"
echo "GITHUB_HEAD_REF=$GITHUB_HEAD_REF"
echo "API_BASE_REF=${{ inputs.API_BASE_REF || '3.6.0' }}"
echo "HEAD=$(git rev-parse HEAD)"
echo "remotes=$(git remote -v)"
- name: "Run ABI check..."
env:
BASE_REF: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || github.event_name == 'pull_request' && github.event.pull_request.base.sha || github.event_name == 'workflow_dispatch' && inputs.API_BASE_REF || '3.6.0' }}
run: |
echo "BASE_REF=$BASE_REF"
./abi-checker/abi-diff.sh $BASE_REF
@@ -0,0 +1,56 @@
name: '[arm,ppc,ricsv] architecture builds'
on:
workflow_dispatch:
branches: [ master, stable* ]
schedule:
- cron: '30 5 * * SUN'
jobs:
build_job:
runs-on: ubuntu-latest
name: "Test on ${{ matrix.distro }}/${{ matrix.arch }}"
strategy:
fail-fast: false
matrix:
include:
- arch: armv7
distro: bookworm
- arch: aarch64
distro: bookworm
- arch: s390x
distro: bookworm
- arch: ppc64le
distro: bookworm
- arch: riscv64
distro: ubuntu24.04
steps:
- uses: actions/checkout@v4
- uses: uraimo/run-on-arch-action@v3.0.1
name: "Run tests"
id: build
with:
arch: ${{ matrix.arch }}
distro: ${{ matrix.distro }}
githubToken: ${{ github.token }}
env: |
CTEST_OUTPUT_ON_FAILURE: 1
WLOG_LEVEL: 'trace'
install: |
echo "whoami: $(whoami)"
echo "working directory: $(pwd)"
apt-get update -q -y
apt-get install -q -y devscripts clang ninja-build ccache equivs
run: |
echo "whoami: $(whoami)"
echo "working directory: $(pwd)"
find . -name control -exec mk-build-deps -i -t "apt-get -o Debug::pkgProblemResolver=yes --no-install-recommends -y" {} \;
cmake -GNinja \
-C ci/cmake-preloads/config-linux-alt-arch.txt \
-B ci-build \
-S . \
-DCMAKE_INSTALL_PREFIX=/tmp/ci-test \
-DCMAKE_C_COMPILER=/usr/bin/clang \
-DCMAKE_CXX_COMPILER=/usr/bin/clang++
cmake --build ci-build --parallel $(nproc) --target install
cmake --build ci-build --parallel $(nproc) --target test
+26
View File
@@ -0,0 +1,26 @@
name: bash-format
on:
workflow_dispatch:
branches: [ master, stable* ]
pull_request:
branches: [ master, stable* ]
schedule:
- cron: '30 4 * * SUN'
jobs:
build:
runs-on: ubuntu-latest
name: "bash-format"
steps:
- name: "Check out source"
uses: actions/checkout@v4
- name: "Prepare environment"
run: |
sudo apt-get update -q -y
sudo apt-get install -q -y \
shfmt
- name: "Run shfmt..."
run: |
./scripts/bash-format.sh
@@ -0,0 +1,24 @@
name: Post clang-tidy review comments
on:
workflow_run:
workflows: ["clang-tidy-review"]
types:
- completed
permissions:
pull-requests: write
issues: write
checks: write
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: akallabeth/clang-tidy-review/post@master
# lgtm_comment_body, max_comments, and annotations need to be set on the posting workflow in a split setup
with:
token: ${{ secrets.GITHUB_TOKEN }}
annotations: false
max_comments: 10
+28
View File
@@ -0,0 +1,28 @@
name: clang-tidy-review
on:
pull_request:
branches: [ master, stable* ]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# Run clang-tidy
- uses: akallabeth/clang-tidy-review@master
id: review
with:
split_workflow: true
clang_tidy_checks: ''
apt_packages: devscripts,equivs
install_commands: 'ln -s packaging/deb/freerdp-nightly debian; mk-build-deps -i -t "apt-get -o Debug::pkgProblemResolver=yes --no-install-recommends -y"'
# CMake command to run in order to generate compile_commands.json
build_dir: tidy
cmake_command: cmake -Btidy -S. -DCMAKE_EXPORT_COMPILE_COMMANDS=ON -C ci/cmake-preloads/config-qa.cmake
# Uploads an artefact containing clang_fixes.json
- uses: akallabeth/clang-tidy-review/upload@master
id: upload-review
+26
View File
@@ -0,0 +1,26 @@
name: cmake-format
on:
workflow_dispatch:
branches: [ master, stable* ]
pull_request:
branches: [ master, stable* ]
schedule:
- cron: '30 4 * * SUN'
jobs:
build:
runs-on: ubuntu-latest
name: "cmake-format"
steps:
- name: "Check out source"
uses: actions/checkout@v4
- name: "Prepare environment"
run: |
sudo apt-get update -q -y
sudo apt-get install -q -y \
cmake-format
- name: "Run cmake-format..."
run: |
./scripts/cmake-format.sh
@@ -0,0 +1,93 @@
# For most projects, this workflow file will not need changing; you simply need
# to commit it to your repository.
#
# You may wish to alter this file to override the set of languages analyzed,
# or to provide custom queries or build logic.
#
# ******** NOTE ********
# We have attempted to detect the languages in your repository. Please check
# the `language` matrix defined below to confirm you have the correct set of
# supported CodeQL languages.
#
name: "CodeQL"
on:
workflow_dispatch:
branches: [ master, stable* ]
schedule:
- cron: '41 2 * * 2'
jobs:
analyze:
name: Analyze (${{ matrix.language }})
# Runner size impacts CodeQL analysis time. To learn more, please see:
# - https://gh.io/recommended-hardware-resources-for-running-codeql
# - https://gh.io/supported-runners-and-hardware-resources
# - https://gh.io/using-larger-runners (GitHub.com only)
# Consider using larger runners or machines with greater resources for possible analysis time improvements.
runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }}
timeout-minutes: ${{ (matrix.language == 'swift' && 120) || 360 }}
permissions:
# required for all workflows
security-events: write
# only required for workflows in private repositories
actions: read
contents: read
strategy:
fail-fast: false
matrix:
include:
- language: c-cpp
build-mode: manual
# CodeQL supports the following values keywords for 'language': 'c-cpp', 'csharp', 'go', 'java-kotlin', 'javascript-typescript', 'python', 'ruby', 'swift'
# Use `c-cpp` to analyze code written in C, C++ or both
# Use 'java-kotlin' to analyze code written in Java, Kotlin or both
# Use 'javascript-typescript' to analyze code written in JavaScript, TypeScript or both
# To learn more about changing the languages that are analyzed or customizing the build mode for your analysis,
# see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/customizing-your-advanced-setup-for-code-scanning.
# If you are analyzing a compiled language, you can modify the 'build-mode' for that language to customize how
# your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages
steps:
- name: Checkout repository
uses: actions/checkout@v4
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v3
with:
languages: ${{ matrix.language }}
build-mode: ${{ matrix.build-mode }}
# If you wish to specify custom queries, you can do so here or in a config file.
# By default, queries listed here will override any specified in a config file.
# Prefix the list here with "+" to use these queries and those in the config file.
# For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs
# queries: security-extended,security-and-quality
# If the analyze step fails for one of the languages you are analyzing with
# "We were unable to automatically build your code", modify the matrix above
# to set the build mode to "manual" for that language. Then modify this step
# to build your code.
# ️ Command-line programs to run using the OS shell.
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
- if: matrix.build-mode == 'manual'
run: |
sudo apt-get update -q -y
sudo apt-get install -q -y devscripts clang ccache ninja-build equivs
./packaging/scripts/prepare_deb_freerdp-nightly.sh
sudo mk-build-deps -i
mkdir ci-build
cd ci-build
export CC=/usr/bin/clang
export CXX=/usr/bin/clang++
export CFLAGS="-Weverything"
export CXXFLAGS="-Weverything"
cmake -GNinja ../ci/cmake-preloads/config-linux-all.txt ..
cmake --build .
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3
with:
category: "/language:${{matrix.language}}"
+26
View File
@@ -0,0 +1,26 @@
name: codespell
on:
workflow_dispatch:
branches: [ master, stable* ]
pull_request:
branches: [ master, stable* ]
schedule:
- cron: '30 4 * * SUN'
jobs:
build:
runs-on: ubuntu-latest
name: "codespell"
steps:
- name: "Check out source"
uses: actions/checkout@v4
- name: "Prepare environment"
run: |
sudo apt-get update -q -y
sudo apt-get install -q -y \
codespell
- name: "Run codespell..."
run: |
./scripts/codespell.sh

Some files were not shown because too many files have changed in this diff Show More