Internal
Public Access
Compare commits
38
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
34c16cbfa8 | ||
|
|
1506d87719 | ||
|
|
b3cedcfa48 | ||
|
|
0cdf930303 | ||
|
|
7559488ceb | ||
|
|
ab4a34fc01 | ||
|
|
7aa8849f8e | ||
|
|
776db5ec04 | ||
|
|
d7f9d4966b | ||
|
|
e80fe7d634 | ||
|
|
df2b1a8d50 | ||
|
|
1f026dde70 | ||
|
|
2fe2022182 | ||
|
|
4fca8fce41 | ||
|
|
bb022edcf2 | ||
|
|
e49fa0cf26 | ||
|
|
9dd1af21d6 | ||
|
|
3dd894407a | ||
|
|
35d4daec7f | ||
|
|
0b5454197c | ||
|
|
6da9dc6ca5 | ||
|
|
dbcc20d155 | ||
|
|
04ce6f7904 | ||
|
|
9842a44de0 | ||
|
|
eb63bde870 | ||
|
|
3fab2f9de3 | ||
|
|
4f5cf8ecd9 | ||
|
|
df99c78998 | ||
|
|
dffca3afef | ||
|
|
4f8fa3272b | ||
|
|
96c8403f3b | ||
|
|
3e621219f1 | ||
|
|
e0969041a7 | ||
|
|
c4a62e8fb6 | ||
|
|
9a22597d4e | ||
|
|
e90e9b5abf | ||
|
|
92d8b62820 | ||
|
|
7ee930693e |
+37
-3
@@ -1,6 +1,6 @@
|
||||
cmake_minimum_required(VERSION 3.21)
|
||||
|
||||
project(OrbitHub VERSION 2026.9.8.3 LANGUAGES CXX)
|
||||
project(OrbitHub VERSION 2026.9.16.7 LANGUAGES CXX)
|
||||
|
||||
set(CMAKE_CXX_STANDARD 17)
|
||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||
@@ -12,10 +12,24 @@ set(CMAKE_AUTORCC ON)
|
||||
|
||||
include(GNUInstallDirs)
|
||||
|
||||
find_package(Qt6 6.2 REQUIRED COMPONENTS Widgets Sql)
|
||||
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()
|
||||
|
||||
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")
|
||||
@@ -87,6 +101,8 @@ set(ORBITHUB_SOURCES
|
||||
src/app_icon.cpp
|
||||
src/app_icon.h
|
||||
src/main.cpp
|
||||
src/mremoteng_importer.cpp
|
||||
src/mremoteng_importer.h
|
||||
src/profile_dialog.cpp
|
||||
src/profile_dialog.h
|
||||
src/profile_repository.cpp
|
||||
@@ -102,6 +118,8 @@ set(ORBITHUB_SOURCES
|
||||
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
|
||||
@@ -110,6 +128,14 @@ set(ORBITHUB_SOURCES
|
||||
src/rdp_session_backend.h
|
||||
src/ssh_session_backend.cpp
|
||||
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.h
|
||||
)
|
||||
@@ -132,8 +158,12 @@ endif()
|
||||
|
||||
add_executable(orbithub WIN32 MACOSX_BUNDLE ${ORBITHUB_SOURCES})
|
||||
|
||||
target_link_libraries(orbithub PRIVATE Qt6::Widgets Qt6::Sql)
|
||||
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
|
||||
@@ -271,3 +301,7 @@ 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()
|
||||
|
||||
@@ -13,17 +13,25 @@ Supported target platforms:
|
||||
|
||||
OrbitHub is in active development.
|
||||
|
||||
- Milestones completed: M0-M5, and M7-M9
|
||||
- Milestones completed: M0-M9
|
||||
- Current milestone: Milestone 10 (v1.0 Stabilization)
|
||||
- Deferred milestone: Milestone 6 (VNC Fully Working)
|
||||
- Latest checkpoint tag: `v2026.9.8.3`
|
||||
- VNC implementation milestone (M6) is currently deferred
|
||||
- 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 release (installers for Windows, Linux, and macOS):
|
||||
- [v2026.9.8.3](https://git.darksingularity.org/DarkSingularity/orbithub/releases/tag/v2026.9.8.3)
|
||||
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`)
|
||||
@@ -49,6 +57,8 @@ An embedded RDP session in a tab.
|
||||
- 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
|
||||
@@ -57,13 +67,20 @@ An embedded RDP session in a tab.
|
||||
- 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
|
||||
- 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
|
||||
@@ -81,14 +98,36 @@ An embedded RDP session in a tab.
|
||||
- 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
|
||||
@@ -139,6 +178,9 @@ 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
|
||||
@@ -175,10 +217,19 @@ See in-app `Help -> About OrbitHub` for license links and third-party inventory.
|
||||
|
||||
- `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.
|
||||
- This repository currently prioritizes integrated SSH and RDP workflows while VNC implementation is pending.
|
||||
- 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).
|
||||
|
||||
+5
-5
@@ -17,7 +17,7 @@ sudo apt update
|
||||
sudo apt install -y \
|
||||
build-essential cmake ninja-build git pkg-config \
|
||||
qt6-base-dev qt6-base-dev-tools qt6-tools-dev qt6-tools-dev-tools \
|
||||
openssh-client libssl-dev zlib1g-dev
|
||||
openssh-client libssl-dev zlib1g-dev libjpeg-turbo8-dev
|
||||
|
||||
cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release
|
||||
cmake --build build
|
||||
@@ -29,11 +29,11 @@ cmake --build build
|
||||
```bash
|
||||
xcode-select --install
|
||||
brew update
|
||||
brew install cmake ninja pkg-config qt@6 openssh openssl@3
|
||||
brew install cmake ninja pkg-config qt@6 openssh openssl@3 jpeg-turbo
|
||||
|
||||
cmake -S . -B build -G Ninja \
|
||||
-DCMAKE_BUILD_TYPE=Release \
|
||||
-DCMAKE_PREFIX_PATH="$(brew --prefix qt@6);$(brew --prefix openssl@3)"
|
||||
-DCMAKE_PREFIX_PATH="$(brew --prefix qt@6);$(brew --prefix openssl@3);$(brew --prefix jpeg-turbo)"
|
||||
cmake --build build
|
||||
open build/orbithub.app
|
||||
```
|
||||
@@ -56,7 +56,7 @@ Install dependencies via vcpkg:
|
||||
```powershell
|
||||
git clone https://github.com/microsoft/vcpkg C:\dev\vcpkg
|
||||
C:\dev\vcpkg\bootstrap-vcpkg.bat
|
||||
C:\dev\vcpkg\vcpkg.exe install qtbase:x64-windows openssl:x64-windows zlib:x64-windows
|
||||
C:\dev\vcpkg\vcpkg.exe install qtbase:x64-windows openssl:x64-windows zlib:x64-windows libjpeg-turbo:x64-windows
|
||||
```
|
||||
|
||||
Open `x64 Native Tools Command Prompt for VS 2022` (or Developer PowerShell), then build:
|
||||
@@ -75,7 +75,7 @@ Run (ensures DLL paths from vcpkg are present):
|
||||
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` and `zlib` development libraries for the embedded FreeRDP build.
|
||||
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
|
||||
|
||||
|
||||
+210
-8
@@ -102,16 +102,215 @@ Delivered:
|
||||
Git:
|
||||
- Tag: `v0-m5-done`
|
||||
|
||||
## Milestone 6 - VNC Fully Working
|
||||
## Milestone 6 - VNC Working (initial scope)
|
||||
|
||||
Status: Deferred (temporarily postponed)
|
||||
Status: Completed (initial scope; see gaps below)
|
||||
|
||||
Planned Scope:
|
||||
- Replace current unsupported VNC path with complete VNC implementation
|
||||
- Deliver usable in-app VNC session behavior aligned to SSH/RDP UX
|
||||
- Implement VNC connect/disconnect/reconnect lifecycle handling
|
||||
- Extend profile/session connect options needed by VNC
|
||||
- Standardize event log and error mapping behavior with SSH/RDP
|
||||
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
|
||||
|
||||
@@ -200,6 +399,9 @@ Git:
|
||||
- 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
|
||||
|
||||
|
||||
+113
-15
@@ -1,16 +1,19 @@
|
||||
## Introduction
|
||||
|
||||
OrbitHub is a native desktop application for organizing connection profiles
|
||||
and launching SSH and RDP sessions from one place, in a single tabbed
|
||||
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 and RDP, managing active sessions, and what to do when
|
||||
something goes wrong. It does not cover installation or building from
|
||||
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 is planned but not yet implemented; profiles can be tagged for
|
||||
it, but connecting will show an "unsupported protocol" message for now.
|
||||
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
|
||||
|
||||
@@ -43,15 +46,19 @@ A profile stores everything needed to reach one remote host. Open **New**
|
||||
|
||||
- **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.
|
||||
- **Username** — the account to log in as.
|
||||
- **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 or SSH profiles).
|
||||
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` (VNC is accepted but not yet
|
||||
connectable — see Introduction).
|
||||
- **Protocol** — `SSH`, `RDP`, or `VNC`.
|
||||
|
||||
Fields below Protocol change based on what you pick:
|
||||
|
||||
@@ -63,6 +70,9 @@ 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).
|
||||
@@ -85,6 +95,24 @@ 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
|
||||
@@ -93,6 +121,8 @@ 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);
|
||||
@@ -105,7 +135,7 @@ 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. |
|
||||
| `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. |
|
||||
|
||||
@@ -123,7 +153,8 @@ 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.
|
||||
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:
|
||||
@@ -151,6 +182,51 @@ 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
|
||||
@@ -158,6 +234,14 @@ 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:
|
||||
@@ -176,8 +260,9 @@ 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, and whether the events panel is
|
||||
shown or hidden for new tabs.
|
||||
- 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
|
||||
@@ -209,10 +294,23 @@ 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
|
||||
[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.
|
||||
|
||||
@@ -19,5 +19,5 @@ modules:
|
||||
sources:
|
||||
- type: git
|
||||
url: https://git.darksingularity.org/DarkSingularity/orbithub.git
|
||||
tag: v2026.9.8.2
|
||||
commit: 4c649f727fb794f4b5afdc3186e545e20ce2f138
|
||||
tag: v2026.9.15
|
||||
commit: dffca3afef80b5a3ca4832e0b7f748775cb90328
|
||||
|
||||
@@ -17,6 +17,11 @@ 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)"
|
||||
|
||||
@@ -25,7 +30,6 @@ if [[ -z "$VERSION" ]]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cmake --build "$BUILD_DIR" -j
|
||||
cmake --install "$BUILD_DIR" --prefix "$PKG_ROOT/usr"
|
||||
|
||||
cat > "$PKG_ROOT/DEBIAN/control" <<EOF
|
||||
@@ -35,7 +39,7 @@ Section: net
|
||||
Priority: optional
|
||||
Architecture: ${ARCH}
|
||||
Maintainer: OrbitHub Maintainers <maintainers@orbithub.local>
|
||||
Depends: libc6, libstdc++6, libqt6core6, libqt6gui6, libqt6widgets6, libqt6sql6, libssl3, zlib1g, openssh-client
|
||||
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.
|
||||
|
||||
@@ -34,6 +34,24 @@
|
||||
</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>
|
||||
|
||||
@@ -14,12 +14,6 @@ if [[ ! -f "$BUILD_DIR/CMakeCache.txt" ]]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
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
|
||||
|
||||
MACDEPLOYQT="$(brew --prefix qt@6)/bin/macdeployqt"
|
||||
if [[ ! -x "$MACDEPLOYQT" ]]; then
|
||||
echo "macdeployqt not found at $MACDEPLOYQT" >&2
|
||||
@@ -31,7 +25,17 @@ 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
|
||||
|
||||
+27
-2
@@ -8,6 +8,31 @@
|
||||
#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"));
|
||||
@@ -34,7 +59,7 @@ AboutDialog::AboutDialog(QWidget* parent) : QDialog(parent)
|
||||
QStringLiteral("Unified remote session manager for SSH, RDP, and VNC workflows."),
|
||||
this);
|
||||
subtitle->setWordWrap(true);
|
||||
subtitle->setStyleSheet(QStringLiteral("color: palette(mid);"));
|
||||
subtitle->setStyleSheet(QStringLiteral("color: %1;").arg(mutedTextColor(this).name()));
|
||||
|
||||
const QString version = QCoreApplication::applicationVersion().trimmed().isEmpty()
|
||||
? QStringLiteral("Development build")
|
||||
@@ -42,7 +67,7 @@ AboutDialog::AboutDialog(QWidget* parent) : QDialog(parent)
|
||||
auto* buildLine = new QLabel(
|
||||
QStringLiteral("Version: %1 | Qt runtime linked dynamically").arg(version),
|
||||
this);
|
||||
buildLine->setStyleSheet(QStringLiteral("color: palette(mid);"));
|
||||
buildLine->setStyleSheet(QStringLiteral("color: %1;").arg(mutedTextColor(this).name()));
|
||||
|
||||
titleColumn->addWidget(title);
|
||||
titleColumn->addWidget(subtitle);
|
||||
|
||||
@@ -10,6 +10,9 @@ int main(int argc, char* argv[])
|
||||
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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
+10
-13
@@ -257,13 +257,6 @@ void ProfileDialog::accept()
|
||||
}
|
||||
|
||||
const QString protocol = m_protocolInput->currentText();
|
||||
if ((protocol == QStringLiteral("SSH") || protocol == QStringLiteral("RDP"))
|
||||
&& m_usernameInput->text().trimmed().isEmpty()) {
|
||||
QMessageBox::warning(this,
|
||||
QStringLiteral("Validation Error"),
|
||||
QStringLiteral("Username is required for %1 profiles.").arg(protocol));
|
||||
return;
|
||||
}
|
||||
|
||||
if (protocol == QStringLiteral("SSH")
|
||||
&& m_authModeInput->currentText() == QStringLiteral("Private Key")) {
|
||||
@@ -310,16 +303,20 @@ void ProfileDialog::refreshAuthFields()
|
||||
|
||||
if (isSsh) {
|
||||
m_usernameInput->setPlaceholderText(QStringLiteral("deploy"));
|
||||
m_protocolHint->setText(
|
||||
QStringLiteral("SSH: username is required. Choose Password or Private Key auth."));
|
||||
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: username and password are required. Domain is optional."));
|
||||
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/domain are optional and ignored by most servers."));
|
||||
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()
|
||||
|
||||
+134
-9
@@ -9,7 +9,11 @@
|
||||
#include <QVariant>
|
||||
#include <QStringList>
|
||||
|
||||
#include <atomic>
|
||||
|
||||
namespace {
|
||||
std::atomic<int> g_testConnectionCounter{0};
|
||||
|
||||
QString buildDatabasePath()
|
||||
{
|
||||
QString appDataPath = QStandardPaths::writableLocation(QStandardPaths::AppDataLocation);
|
||||
@@ -176,7 +180,7 @@ void bindProfileFields(QSqlQuery& query, const Profile& profile)
|
||||
: QStringLiteral("Negotiate"));
|
||||
query.addBindValue(isRdp ? normalizedRdpPerformanceProfile(profile.rdpPerformanceProfile)
|
||||
: QStringLiteral("Balanced"));
|
||||
query.addBindValue(normalizedTags(profile.tags));
|
||||
query.addBindValue(nonNullTrimmed(normalizedTags(profile.tags)));
|
||||
}
|
||||
|
||||
Profile profileFromQuery(const QSqlQuery& query)
|
||||
@@ -231,13 +235,6 @@ bool isProfileValid(const Profile& profile, QString* error)
|
||||
}
|
||||
|
||||
const QString protocol = normalizedProtocol(profile.protocol);
|
||||
if ((protocol == QStringLiteral("SSH") || protocol == QStringLiteral("RDP"))
|
||||
&& profile.username.trimmed().isEmpty()) {
|
||||
if (error != nullptr) {
|
||||
*error = QStringLiteral("Username is required for %1 profiles.").arg(protocol);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const QString authMode = normalizedAuthMode(protocol, profile.authMode);
|
||||
if (protocol == QStringLiteral("SSH") && authMode == QStringLiteral("Private Key")
|
||||
@@ -259,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()
|
||||
{
|
||||
if (QSqlDatabase::contains(m_connectionName)) {
|
||||
@@ -332,6 +339,123 @@ bool ProfileRepository::createFolder(const QString& folderPath) const
|
||||
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
|
||||
{
|
||||
@@ -485,7 +609,8 @@ bool ProfileRepository::deleteProfile(qint64 id) const
|
||||
bool ProfileRepository::initializeDatabase()
|
||||
{
|
||||
QSqlDatabase database = QSqlDatabase::addDatabase(QStringLiteral("QSQLITE"), m_connectionName);
|
||||
database.setDatabaseName(buildDatabasePath());
|
||||
database.setDatabaseName(
|
||||
m_databasePathOverride.isEmpty() ? buildDatabasePath() : m_databasePathOverride);
|
||||
|
||||
if (!database.open()) {
|
||||
m_initError = database.lastError().text();
|
||||
|
||||
@@ -35,6 +35,9 @@ class ProfileRepository
|
||||
{
|
||||
public:
|
||||
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();
|
||||
|
||||
QString initError() const;
|
||||
@@ -44,6 +47,7 @@ public:
|
||||
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> createProfile(const Profile& profile) const;
|
||||
bool updateProfile(const Profile& profile) const;
|
||||
@@ -51,6 +55,7 @@ public:
|
||||
|
||||
private:
|
||||
QString m_connectionName;
|
||||
QString m_databasePathOverride;
|
||||
QString m_initError;
|
||||
mutable QString m_lastError;
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#include "profiles_window.h"
|
||||
|
||||
#include "mremoteng_importer.h"
|
||||
#include "profile_dialog.h"
|
||||
#include "profile_repository.h"
|
||||
#include "profiles_tree_widget.h"
|
||||
@@ -7,8 +8,13 @@
|
||||
#include <QAction>
|
||||
#include <QAbstractItemView>
|
||||
#include <QComboBox>
|
||||
#include <QFile>
|
||||
#include <QFileDialog>
|
||||
#include <QHeaderView>
|
||||
#include <QHBoxLayout>
|
||||
#include <QJsonArray>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
#include <QLabel>
|
||||
#include <QLineEdit>
|
||||
#include <QInputDialog>
|
||||
@@ -85,6 +91,53 @@ bool profileHasTag(const Profile& profile, const QString& requestedTag)
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
constexpr int kProfileExportFormatVersion = 1;
|
||||
|
||||
QJsonObject profileToJson(const Profile& profile)
|
||||
{
|
||||
QJsonObject object;
|
||||
object.insert(QStringLiteral("name"), profile.name);
|
||||
object.insert(QStringLiteral("host"), profile.host);
|
||||
object.insert(QStringLiteral("port"), profile.port);
|
||||
object.insert(QStringLiteral("username"), profile.username);
|
||||
object.insert(QStringLiteral("domain"), profile.domain);
|
||||
object.insert(QStringLiteral("folderPath"), profile.folderPath);
|
||||
object.insert(QStringLiteral("protocol"), profile.protocol);
|
||||
object.insert(QStringLiteral("authMode"), profile.authMode);
|
||||
object.insert(QStringLiteral("privateKeyPath"), profile.privateKeyPath);
|
||||
object.insert(QStringLiteral("knownHostsPolicy"), profile.knownHostsPolicy);
|
||||
object.insert(QStringLiteral("rdpSecurityMode"), profile.rdpSecurityMode);
|
||||
object.insert(QStringLiteral("rdpPerformanceProfile"), profile.rdpPerformanceProfile);
|
||||
object.insert(QStringLiteral("tags"), profile.tags);
|
||||
return object;
|
||||
}
|
||||
|
||||
// Deliberately excludes id (import always creates new rows -- an imported
|
||||
// profile's id has no meaning in the destination database) and any
|
||||
// credential material (none is ever persisted on Profile in the first
|
||||
// place, see ProfileRepository).
|
||||
Profile profileFromJson(const QJsonObject& object)
|
||||
{
|
||||
Profile profile;
|
||||
profile.name = object.value(QStringLiteral("name")).toString();
|
||||
profile.host = object.value(QStringLiteral("host")).toString();
|
||||
profile.port = object.value(QStringLiteral("port")).toInt(22);
|
||||
profile.username = object.value(QStringLiteral("username")).toString();
|
||||
profile.domain = object.value(QStringLiteral("domain")).toString();
|
||||
profile.folderPath = object.value(QStringLiteral("folderPath")).toString();
|
||||
profile.protocol = object.value(QStringLiteral("protocol")).toString(QStringLiteral("SSH"));
|
||||
profile.authMode = object.value(QStringLiteral("authMode")).toString(QStringLiteral("Password"));
|
||||
profile.privateKeyPath = object.value(QStringLiteral("privateKeyPath")).toString();
|
||||
profile.knownHostsPolicy =
|
||||
object.value(QStringLiteral("knownHostsPolicy")).toString(QStringLiteral("Ask"));
|
||||
profile.rdpSecurityMode =
|
||||
object.value(QStringLiteral("rdpSecurityMode")).toString(QStringLiteral("Negotiate"));
|
||||
profile.rdpPerformanceProfile =
|
||||
object.value(QStringLiteral("rdpPerformanceProfile")).toString(QStringLiteral("Balanced"));
|
||||
profile.tags = object.value(QStringLiteral("tags")).toString();
|
||||
return profile;
|
||||
}
|
||||
}
|
||||
|
||||
ProfilesWindow::ProfilesWindow(QWidget* parent)
|
||||
@@ -535,6 +588,8 @@ void ProfilesWindow::showTreeContextMenu(const QPoint& pos)
|
||||
|
||||
const QString contextFolder = folderPathForItem(item);
|
||||
const bool isProfileItem = item != nullptr && item->data(0, kProfileIdRole).isValid();
|
||||
const bool isFolderItem =
|
||||
item != nullptr && !isProfileItem && item->data(0, kFolderPathRole).isValid();
|
||||
|
||||
QMenu menu(this);
|
||||
QAction* newConnectionAction = menu.addAction(QStringLiteral("New Connection"));
|
||||
@@ -542,12 +597,16 @@ void ProfilesWindow::showTreeContextMenu(const QPoint& pos)
|
||||
QAction* connectAction = nullptr;
|
||||
QAction* editAction = nullptr;
|
||||
QAction* deleteAction = nullptr;
|
||||
QAction* deleteFolderAction = nullptr;
|
||||
|
||||
if (isProfileItem) {
|
||||
menu.addSeparator();
|
||||
connectAction = menu.addAction(QStringLiteral("Connect"));
|
||||
editAction = menu.addAction(QStringLiteral("Edit"));
|
||||
deleteAction = menu.addAction(QStringLiteral("Delete"));
|
||||
} else if (isFolderItem) {
|
||||
menu.addSeparator();
|
||||
deleteFolderAction = menu.addAction(QStringLiteral("Delete Folder"));
|
||||
}
|
||||
|
||||
QAction* chosen = menu.exec(m_profilesTree->viewport()->mapToGlobal(pos));
|
||||
@@ -577,6 +636,10 @@ void ProfilesWindow::showTreeContextMenu(const QPoint& pos)
|
||||
deleteSelectedProfile();
|
||||
return;
|
||||
}
|
||||
if (isFolderItem && chosen == deleteFolderAction) {
|
||||
deleteFolderInContext(contextFolder);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
void ProfilesWindow::createFolderInContext(const QString& baseFolderPath)
|
||||
@@ -614,6 +677,72 @@ void ProfilesWindow::createFolderInContext(const QString& baseFolderPath)
|
||||
loadProfiles();
|
||||
}
|
||||
|
||||
void ProfilesWindow::deleteFolderInContext(const QString& folderPath)
|
||||
{
|
||||
const QString normalized = normalizeFolderPathForView(folderPath);
|
||||
if (normalized.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Deleting a folder never deletes profiles -- everything inside it
|
||||
// (profiles and subfolders) shifts up to take its place. Count what's
|
||||
// affected so the confirmation is honest about that, rather than
|
||||
// reading like the usual destructive "Delete" action.
|
||||
const QString prefix = normalized + QStringLiteral("/");
|
||||
int profileCount = 0;
|
||||
for (const Profile& profile : m_repository->listProfiles()) {
|
||||
if (profile.folderPath == normalized || profile.folderPath.startsWith(prefix)) {
|
||||
++profileCount;
|
||||
}
|
||||
}
|
||||
int subfolderCount = 0;
|
||||
for (const QString& folder : m_repository->listFolders()) {
|
||||
if (folder != normalized && folder.startsWith(prefix)) {
|
||||
++subfolderCount;
|
||||
}
|
||||
}
|
||||
|
||||
if (profileCount > 0 || subfolderCount > 0) {
|
||||
QStringList parentParts = splitFolderPath(normalized);
|
||||
parentParts.removeLast();
|
||||
const QString destination = parentParts.isEmpty()
|
||||
? QStringLiteral("the top level")
|
||||
: QStringLiteral("'%1'").arg(parentParts.join(QStringLiteral("/")));
|
||||
|
||||
QStringList details;
|
||||
if (profileCount > 0) {
|
||||
details.push_back(QStringLiteral("%1 profile(s)").arg(profileCount));
|
||||
}
|
||||
if (subfolderCount > 0) {
|
||||
details.push_back(QStringLiteral("%1 subfolder(s)").arg(subfolderCount));
|
||||
}
|
||||
|
||||
const QMessageBox::StandardButton confirm = QMessageBox::question(
|
||||
this,
|
||||
QStringLiteral("Delete Folder"),
|
||||
QStringLiteral("Delete folder '%1'?\n\nNothing will be deleted: %2 currently inside "
|
||||
"it will move up to %3.")
|
||||
.arg(normalized, details.join(QStringLiteral(" and ")), destination),
|
||||
QMessageBox::Yes | QMessageBox::No,
|
||||
QMessageBox::No);
|
||||
if (confirm != QMessageBox::Yes) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!m_repository->deleteFolder(normalized)) {
|
||||
QMessageBox::warning(this,
|
||||
QStringLiteral("Delete Folder"),
|
||||
QStringLiteral("Failed to delete folder: %1")
|
||||
.arg(m_repository->lastError().isEmpty()
|
||||
? QStringLiteral("unknown error")
|
||||
: m_repository->lastError()));
|
||||
return;
|
||||
}
|
||||
|
||||
loadProfiles();
|
||||
}
|
||||
|
||||
void ProfilesWindow::persistFolderAssignmentsFromTree()
|
||||
{
|
||||
if (!isFolderViewEnabled() || m_profilesTree == nullptr) {
|
||||
@@ -930,3 +1059,197 @@ void ProfilesWindow::createFolderInCurrentContext()
|
||||
const QString folderPath = folderPathForItem(m_profilesTree->currentItem());
|
||||
createFolderInContext(folderPath);
|
||||
}
|
||||
|
||||
void ProfilesWindow::exportProfiles()
|
||||
{
|
||||
const QString fileName = QFileDialog::getSaveFileName(
|
||||
this, QStringLiteral("Export Profiles"), QStringLiteral("orbithub-profiles.json"),
|
||||
QStringLiteral("JSON Files (*.json)"));
|
||||
if (fileName.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
QJsonArray folders;
|
||||
for (const QString& folderPath : m_repository->listFolders()) {
|
||||
folders.append(folderPath);
|
||||
}
|
||||
|
||||
QJsonArray profiles;
|
||||
for (const Profile& profile : m_repository->listProfiles()) {
|
||||
profiles.append(profileToJson(profile));
|
||||
}
|
||||
|
||||
QJsonObject root;
|
||||
root.insert(QStringLiteral("orbithubProfileExport"), kProfileExportFormatVersion);
|
||||
root.insert(QStringLiteral("folders"), folders);
|
||||
root.insert(QStringLiteral("profiles"), profiles);
|
||||
|
||||
QFile file(fileName);
|
||||
if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate)) {
|
||||
QMessageBox::warning(this,
|
||||
QStringLiteral("Export Profiles"),
|
||||
QStringLiteral("Failed to write %1: %2")
|
||||
.arg(fileName, file.errorString()));
|
||||
return;
|
||||
}
|
||||
file.write(QJsonDocument(root).toJson(QJsonDocument::Indented));
|
||||
file.close();
|
||||
|
||||
QMessageBox::information(this,
|
||||
QStringLiteral("Export Profiles"),
|
||||
QStringLiteral("Exported %1 profile(s) to %2.")
|
||||
.arg(profiles.size())
|
||||
.arg(fileName));
|
||||
}
|
||||
|
||||
void ProfilesWindow::importProfiles()
|
||||
{
|
||||
const QString fileName = QFileDialog::getOpenFileName(
|
||||
this, QStringLiteral("Import Profiles"), QString(), QStringLiteral("JSON Files (*.json)"));
|
||||
if (fileName.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
QFile file(fileName);
|
||||
if (!file.open(QIODevice::ReadOnly)) {
|
||||
QMessageBox::warning(this,
|
||||
QStringLiteral("Import Profiles"),
|
||||
QStringLiteral("Failed to read %1: %2")
|
||||
.arg(fileName, file.errorString()));
|
||||
return;
|
||||
}
|
||||
|
||||
QJsonParseError parseError{};
|
||||
const QJsonDocument doc = QJsonDocument::fromJson(file.readAll(), &parseError);
|
||||
file.close();
|
||||
if (parseError.error != QJsonParseError::NoError || !doc.isObject()) {
|
||||
QMessageBox::warning(this,
|
||||
QStringLiteral("Import Profiles"),
|
||||
QStringLiteral("%1 is not a valid OrbitHub profile export: %2")
|
||||
.arg(fileName, parseError.errorString()));
|
||||
return;
|
||||
}
|
||||
|
||||
const QJsonObject root = doc.object();
|
||||
if (!root.value(QStringLiteral("profiles")).isArray()) {
|
||||
QMessageBox::warning(this,
|
||||
QStringLiteral("Import Profiles"),
|
||||
QStringLiteral("%1 does not contain a profile list.").arg(fileName));
|
||||
return;
|
||||
}
|
||||
|
||||
for (const QJsonValue& folderValue : root.value(QStringLiteral("folders")).toArray()) {
|
||||
const QString folderPath = folderValue.toString();
|
||||
if (!folderPath.isEmpty()) {
|
||||
m_repository->createFolder(folderPath);
|
||||
}
|
||||
}
|
||||
|
||||
const QJsonArray profilesArray = root.value(QStringLiteral("profiles")).toArray();
|
||||
int imported = 0;
|
||||
QStringList failures;
|
||||
for (const QJsonValue& profileValue : profilesArray) {
|
||||
if (!profileValue.isObject()) {
|
||||
continue;
|
||||
}
|
||||
const Profile profile = profileFromJson(profileValue.toObject());
|
||||
if (profile.name.isEmpty() || profile.host.isEmpty()) {
|
||||
failures.push_back(QStringLiteral("(unnamed profile): missing name or host"));
|
||||
continue;
|
||||
}
|
||||
// No need to createFolder(profile.folderPath) here: the tree view
|
||||
// already synthesizes folder nodes from a profile's own folderPath
|
||||
// (see ProfilesWindow::loadProfiles). Explicit profile_folders rows
|
||||
// are only for folders with no profiles in them, and those are
|
||||
// already recreated above from the export's top-level "folders"
|
||||
// list -- doing it again per-profile would just add spurious
|
||||
// entries not present in the original export.
|
||||
if (m_repository->createProfile(profile).has_value()) {
|
||||
++imported;
|
||||
} else {
|
||||
failures.push_back(QStringLiteral("%1: %2").arg(profile.name, m_repository->lastError()));
|
||||
}
|
||||
}
|
||||
|
||||
loadProfiles();
|
||||
|
||||
QString summary = QStringLiteral("Imported %1 of %2 profile(s) from %3.")
|
||||
.arg(imported)
|
||||
.arg(profilesArray.size())
|
||||
.arg(fileName);
|
||||
if (!failures.isEmpty()) {
|
||||
summary += QStringLiteral("\n\nFailed:\n%1").arg(failures.join(QChar::fromLatin1('\n')));
|
||||
QMessageBox::warning(this, QStringLiteral("Import Profiles"), summary);
|
||||
} else {
|
||||
QMessageBox::information(this, QStringLiteral("Import Profiles"), summary);
|
||||
}
|
||||
}
|
||||
|
||||
void ProfilesWindow::importFromMRemoteNG()
|
||||
{
|
||||
const QString fileName = QFileDialog::getOpenFileName(
|
||||
this, QStringLiteral("Import from mRemoteNG"), QString(),
|
||||
QStringLiteral("mRemoteNG Connections (*.xml)"));
|
||||
if (fileName.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
QFile file(fileName);
|
||||
if (!file.open(QIODevice::ReadOnly)) {
|
||||
QMessageBox::warning(this,
|
||||
QStringLiteral("Import from mRemoteNG"),
|
||||
QStringLiteral("Failed to read %1: %2")
|
||||
.arg(fileName, file.errorString()));
|
||||
return;
|
||||
}
|
||||
const QByteArray xmlData = file.readAll();
|
||||
file.close();
|
||||
|
||||
const MRemoteNGImportResult parsed = parseMRemoteNGConnections(xmlData);
|
||||
if (!parsed.errorMessage.isEmpty()) {
|
||||
QMessageBox::warning(this, QStringLiteral("Import from mRemoteNG"), parsed.errorMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const QString& folderPath : parsed.folders) {
|
||||
m_repository->createFolder(folderPath);
|
||||
}
|
||||
|
||||
int imported = 0;
|
||||
QStringList failures;
|
||||
for (const MRemoteNGImportedProfile& item : parsed.profiles) {
|
||||
Profile profile = item.profile;
|
||||
profile.folderPath = item.folderPath;
|
||||
if (profile.name.isEmpty() || profile.host.isEmpty()) {
|
||||
failures.push_back(QStringLiteral("(unnamed connection): missing name or host"));
|
||||
continue;
|
||||
}
|
||||
if (m_repository->createProfile(profile).has_value()) {
|
||||
++imported;
|
||||
} else {
|
||||
failures.push_back(QStringLiteral("%1: %2").arg(profile.name, m_repository->lastError()));
|
||||
}
|
||||
}
|
||||
|
||||
loadProfiles();
|
||||
|
||||
QString summary = QStringLiteral("Imported %1 of %2 connection(s) from %3.\n\n"
|
||||
"Passwords are never imported -- you'll be prompted the "
|
||||
"first time you connect each profile, same as a new one.")
|
||||
.arg(imported)
|
||||
.arg(static_cast<int>(parsed.profiles.size()))
|
||||
.arg(fileName);
|
||||
if (!parsed.skippedUnsupportedProtocol.isEmpty()) {
|
||||
summary += QStringLiteral(
|
||||
"\n\nSkipped %1 connection(s) using a protocol OrbitHub doesn't support "
|
||||
"yet:\n%2")
|
||||
.arg(static_cast<int>(parsed.skippedUnsupportedProtocol.size()))
|
||||
.arg(parsed.skippedUnsupportedProtocol.join(QChar::fromLatin1('\n')));
|
||||
}
|
||||
if (!failures.isEmpty()) {
|
||||
summary += QStringLiteral("\n\nFailed:\n%1").arg(failures.join(QChar::fromLatin1('\n')));
|
||||
QMessageBox::warning(this, QStringLiteral("Import from mRemoteNG"), summary);
|
||||
} else {
|
||||
QMessageBox::information(this, QStringLiteral("Import from mRemoteNG"), summary);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +32,9 @@ public:
|
||||
|
||||
void createProfileInCurrentContext();
|
||||
void createFolderInCurrentContext();
|
||||
void exportProfiles();
|
||||
void importProfiles();
|
||||
void importFromMRemoteNG();
|
||||
|
||||
signals:
|
||||
void connectRequested(const Profile& profile);
|
||||
@@ -63,6 +66,7 @@ private:
|
||||
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,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#include "rdp_display_widget.h"
|
||||
|
||||
#include <QCursor>
|
||||
#include <QEvent>
|
||||
#include <QKeyEvent>
|
||||
#include <QMouseEvent>
|
||||
#include <QPainter>
|
||||
@@ -15,20 +16,38 @@ 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)
|
||||
: 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);
|
||||
|
||||
QTimer::singleShot(0, this, [this]() {
|
||||
const QSize size = sanitizeSize(this->size());
|
||||
emit viewportSizeChanged(size.width(), size.height());
|
||||
});
|
||||
m_resizeDebounceTimer->setSingleShot(true);
|
||||
connect(m_resizeDebounceTimer, &QTimer::timeout, this, &RdpDisplayWidget::emitViewportGeometry);
|
||||
|
||||
scheduleViewportGeometryEmit();
|
||||
}
|
||||
|
||||
void RdpDisplayWidget::setFrame(const QImage& frame)
|
||||
@@ -54,6 +73,13 @@ void RdpDisplayWidget::setRemoteDesktopSize(int width, int height)
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -140,11 +166,39 @@ void RdpDisplayWidget::paintEvent(QPaintEvent* event)
|
||||
void RdpDisplayWidget::resizeEvent(QResizeEvent* event)
|
||||
{
|
||||
QWidget::resizeEvent(event);
|
||||
const QSize size = sanitizeSize(event->size());
|
||||
emit viewportSizeChanged(size.width(), size.height());
|
||||
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) {
|
||||
|
||||
@@ -8,6 +8,7 @@ class QKeyEvent;
|
||||
class QMouseEvent;
|
||||
class QPaintEvent;
|
||||
class QResizeEvent;
|
||||
class QTimer;
|
||||
class QWheelEvent;
|
||||
|
||||
class RdpDisplayWidget : public QWidget
|
||||
@@ -30,10 +31,12 @@ signals:
|
||||
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;
|
||||
@@ -54,11 +57,14 @@ private:
|
||||
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
|
||||
|
||||
+165
-49
@@ -89,43 +89,13 @@ constexpr double kDefaultDpi = 96.0;
|
||||
constexpr double kMillimetersPerInch = 25.4;
|
||||
|
||||
#ifdef ORBITHUB_HAS_FREERDP
|
||||
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");
|
||||
}
|
||||
|
||||
bool applyRdpSecurityMode(rdpSettings* settings, const QString& mode)
|
||||
{
|
||||
if (settings == nullptr) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const QString normalized = normalizedRdpSecurityMode(mode);
|
||||
const QString normalized = RdpSessionBackend::normalizedRdpSecurityMode(mode);
|
||||
|
||||
BOOL rdp = FALSE;
|
||||
BOOL tls = FALSE;
|
||||
@@ -159,7 +129,7 @@ bool applyRdpPerformanceProfile(rdpSettings* settings, const QString& profile)
|
||||
return false;
|
||||
}
|
||||
|
||||
const QString normalized = normalizedRdpPerformanceProfile(profile);
|
||||
const QString normalized = RdpSessionBackend::normalizedRdpPerformanceProfile(profile);
|
||||
UINT32 connectionType = CONNECTION_TYPE_BROADBAND_HIGH;
|
||||
BOOL networkAutoDetect = FALSE;
|
||||
if (normalized == QStringLiteral("Best Quality")) {
|
||||
@@ -859,8 +829,55 @@ BOOL orbitAuthenticateEx(freerdp* instance,
|
||||
|
||||
return TRUE;
|
||||
}
|
||||
}
|
||||
|
||||
UINT32 scancodeFromNativeScanCode(quint32 nativeScanCode)
|
||||
QString RdpSessionBackend::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");
|
||||
}
|
||||
|
||||
// MS-RDPEDISP restricts DesktopScaleFactor/DeviceScaleFactor to exactly
|
||||
// these three values; FreeRDP's own reference client enforces the same
|
||||
// set (client/common/cmdline.c, parse_scale_options). Anything else is
|
||||
// silently ignored by the server, so map the real, continuous
|
||||
// devicePixelRatio down to the nearest one.
|
||||
quint32 RdpSessionBackend::nearestFreeRdpScaleValue(qreal ratio)
|
||||
{
|
||||
if (ratio <= 1.2) {
|
||||
return 100;
|
||||
}
|
||||
if (ratio <= 1.6) {
|
||||
return 140;
|
||||
}
|
||||
return 180;
|
||||
}
|
||||
|
||||
QString RdpSessionBackend::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");
|
||||
}
|
||||
|
||||
quint32 RdpSessionBackend::scancodeFromNativeScanCode(quint32 nativeScanCode)
|
||||
{
|
||||
if (nativeScanCode == 0) {
|
||||
return RDP_SCANCODE_UNKNOWN;
|
||||
@@ -919,10 +936,12 @@ UINT32 scancodeFromNativeScanCode(quint32 nativeScanCode)
|
||||
#endif
|
||||
}
|
||||
|
||||
UINT32 scancodeForQtKey(int key, Qt::KeyboardModifiers modifiers, quint32 nativeScanCode)
|
||||
quint32 RdpSessionBackend::scancodeForQtKey(int key,
|
||||
Qt::KeyboardModifiers modifiers,
|
||||
quint32 nativeScanCode)
|
||||
{
|
||||
const bool keypad = modifiers.testFlag(Qt::KeypadModifier);
|
||||
const UINT32 nativeScancode = scancodeFromNativeScanCode(nativeScanCode);
|
||||
const quint32 nativeScancode = scancodeFromNativeScanCode(nativeScanCode);
|
||||
|
||||
switch (key) {
|
||||
case Qt::Key_Escape:
|
||||
@@ -1147,7 +1166,7 @@ UINT32 scancodeForQtKey(int key, Qt::KeyboardModifiers modifiers, quint32 native
|
||||
}
|
||||
}
|
||||
|
||||
QString mapRdpError(UINT32 code)
|
||||
QString RdpSessionBackend::mapRdpError(quint32 code)
|
||||
{
|
||||
switch (code) {
|
||||
case FREERDP_ERROR_CONNECT_LOGON_FAILURE:
|
||||
@@ -1210,7 +1229,7 @@ QString mapRdpError(UINT32 code)
|
||||
return QStringLiteral("RDP connection failed (0x%1).").arg(code, 8, 16, QChar('0'));
|
||||
}
|
||||
|
||||
bool isExpectedDisconnectCode(UINT32 code)
|
||||
bool RdpSessionBackend::isExpectedDisconnectCode(quint32 code)
|
||||
{
|
||||
switch (code) {
|
||||
case FREERDP_ERROR_SUCCESS:
|
||||
@@ -1240,7 +1259,7 @@ bool isExpectedDisconnectCode(UINT32 code)
|
||||
}
|
||||
}
|
||||
|
||||
bool isExpectedConnectAbortCode(UINT32 code)
|
||||
bool RdpSessionBackend::isExpectedConnectAbortCode(quint32 code)
|
||||
{
|
||||
switch (code) {
|
||||
case FREERDP_ERROR_SUCCESS:
|
||||
@@ -1252,7 +1271,7 @@ bool isExpectedConnectAbortCode(UINT32 code)
|
||||
}
|
||||
}
|
||||
|
||||
QString disconnectMessageForCode(UINT32 code)
|
||||
QString RdpSessionBackend::disconnectMessageForCode(quint32 code)
|
||||
{
|
||||
switch (code) {
|
||||
case FREERDP_ERROR_IDLE_TIMEOUT:
|
||||
@@ -1288,7 +1307,7 @@ QString disconnectMessageForCode(UINT32 code)
|
||||
}
|
||||
}
|
||||
|
||||
QString rdpErrorRaw(UINT32 code)
|
||||
QString RdpSessionBackend::rdpErrorRaw(quint32 code)
|
||||
{
|
||||
const char* name = freerdp_get_last_error_name(code);
|
||||
const QString text = (name != nullptr && name[0] != '\0') ? QString::fromUtf8(name)
|
||||
@@ -1296,7 +1315,6 @@ QString rdpErrorRaw(UINT32 code)
|
||||
return QStringLiteral("%1 (0x%2)").arg(text).arg(code, 8, 16, QChar('0'));
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
RdpSessionBackend::RdpSessionBackend(const Profile& profile, QObject* parent)
|
||||
: SessionBackend(profile, parent),
|
||||
@@ -1304,6 +1322,7 @@ RdpSessionBackend::RdpSessionBackend(const Profile& profile, QObject* parent)
|
||||
m_userInitiatedDisconnect(false),
|
||||
m_requestedDesktopWidth(kDefaultDesktopWidth),
|
||||
m_requestedDesktopHeight(kDefaultDesktopHeight),
|
||||
m_devicePixelRatio(1.0),
|
||||
m_workerRunning(false),
|
||||
m_stopRequested(false),
|
||||
m_instance(nullptr),
|
||||
@@ -1312,6 +1331,7 @@ RdpSessionBackend::RdpSessionBackend(const Profile& profile, QObject* parent)
|
||||
m_resizeFailureLogged(false),
|
||||
m_lastResizeWidth(0),
|
||||
m_lastResizeHeight(0),
|
||||
m_lastResizeScale(0),
|
||||
m_cliprdrContext(nullptr)
|
||||
{
|
||||
}
|
||||
@@ -1403,6 +1423,28 @@ void RdpSessionBackend::updateTerminalSize(int columns, int rows)
|
||||
enqueueInputEvent(event);
|
||||
}
|
||||
|
||||
void RdpSessionBackend::updateDisplayScale(qreal devicePixelRatio)
|
||||
{
|
||||
const qreal clamped = qBound(1.0, devicePixelRatio, 4.0);
|
||||
m_devicePixelRatio.store(clamped);
|
||||
|
||||
if (!m_workerRunning.load()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Reuses the resize input-event path so the worker thread (which owns
|
||||
// m_instance) picks this up safely; processInputEvents' dedup check
|
||||
// also compares the quantized scale value, so this correctly triggers
|
||||
// a fresh SendMonitorLayout even when width/height haven't changed
|
||||
// (e.g. the window moved to a different-DPI monitor at the same
|
||||
// logical size).
|
||||
InputEvent event;
|
||||
event.type = InputEventType::Resize;
|
||||
event.width = sanitizeDesktopWidth(m_requestedDesktopWidth.load());
|
||||
event.height = sanitizeDesktopHeight(m_requestedDesktopHeight.load());
|
||||
enqueueInputEvent(event);
|
||||
}
|
||||
|
||||
void RdpSessionBackend::sendKeyEvent(int key,
|
||||
quint32 nativeScanCode,
|
||||
const QString& text,
|
||||
@@ -1598,7 +1640,13 @@ void RdpSessionBackend::workerMain()
|
||||
const Profile& p = profile();
|
||||
|
||||
const QString host = p.host.trimmed();
|
||||
QString username = p.username.trimmed();
|
||||
// m_activeOptions.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 this backend's Profile copy was captured at construction time.
|
||||
QString username = m_activeOptions.username.trimmed().isEmpty()
|
||||
? p.username.trimmed()
|
||||
: m_activeOptions.username.trimmed();
|
||||
QString domain = p.domain.trimmed();
|
||||
if (domain.isEmpty()) {
|
||||
const int domainSeparator = username.indexOf(QLatin1Char('\\'));
|
||||
@@ -1637,9 +1685,29 @@ void RdpSessionBackend::workerMain()
|
||||
const QString performanceProfile = normalizedRdpPerformanceProfile(p.rdpPerformanceProfile);
|
||||
freerdp_settings_set_bool(settings, FreeRDP_SupportDisplayControl, TRUE);
|
||||
freerdp_settings_set_bool(settings, FreeRDP_DynamicResolutionUpdate, TRUE);
|
||||
// Lets us actively request a full-screen repaint after a resize (see
|
||||
// sendDisplayResize) — some RDP hosts (particularly VMs using a
|
||||
// synthetic/virtual display driver) occasionally fail to fully redraw
|
||||
// their own desktop after a resolution change; requesting a refresh
|
||||
// forces them to resend everything rather than leaving stale content.
|
||||
freerdp_settings_set_bool(settings, FreeRDP_RefreshRect, TRUE);
|
||||
freerdp_settings_set_uint32(settings, FreeRDP_DesktopWidth, static_cast<UINT32>(desktopWidth));
|
||||
freerdp_settings_set_uint32(settings, FreeRDP_DesktopHeight, static_cast<UINT32>(desktopHeight));
|
||||
freerdp_settings_set_uint32(settings, FreeRDP_ColorDepth, 32);
|
||||
// Tell the remote session the real display scale so it renders its own
|
||||
// UI/ClearType text natively at that size, instead of assuming 96 DPI
|
||||
// and having the result stretched (and ClearType's subpixel hinting
|
||||
// distorted) by client-side scaling. The override flags are required —
|
||||
// without them FreeRDP ignores the scale factor values entirely.
|
||||
{
|
||||
const UINT32 scaleValue = nearestFreeRdpScaleValue(m_devicePixelRatio.load());
|
||||
freerdp_settings_set_uint32(settings, FreeRDP_DesktopScaleFactor, scaleValue);
|
||||
freerdp_settings_set_uint32(settings, FreeRDP_DeviceScaleFactor, scaleValue);
|
||||
freerdp_settings_set_uint64(settings,
|
||||
FreeRDP_MonitorOverrideFlags,
|
||||
FREERDP_MONITOR_OVERRIDE_DESKTOP_SCALE
|
||||
| FREERDP_MONITOR_OVERRIDE_DEVICE_SCALE);
|
||||
}
|
||||
freerdp_settings_set_bool(settings, FreeRDP_AuthenticationOnly, FALSE);
|
||||
freerdp_settings_set_bool(settings, FreeRDP_AutoLogonEnabled, TRUE);
|
||||
if (!applyRdpSecurityMode(settings, securityMode)) {
|
||||
@@ -1831,9 +1899,14 @@ bool RdpSessionBackend::sendDisplayResize(rdp_freerdp* instance, int width, int
|
||||
return false;
|
||||
}
|
||||
|
||||
const qreal ratio = m_devicePixelRatio.load();
|
||||
const UINT32 scaleValue = nearestFreeRdpScaleValue(ratio);
|
||||
|
||||
rdpSettings* settings = instance->context->settings;
|
||||
freerdp_settings_set_uint32(settings, FreeRDP_DesktopWidth, static_cast<UINT32>(width));
|
||||
freerdp_settings_set_uint32(settings, FreeRDP_DesktopHeight, static_cast<UINT32>(height));
|
||||
freerdp_settings_set_uint32(settings, FreeRDP_DesktopScaleFactor, scaleValue);
|
||||
freerdp_settings_set_uint32(settings, FreeRDP_DeviceScaleFactor, scaleValue);
|
||||
|
||||
DispClientContext* dispContext = nullptr;
|
||||
bool displayControlReady = false;
|
||||
@@ -1851,17 +1924,58 @@ bool RdpSessionBackend::sendDisplayResize(rdp_freerdp* instance, int width, int
|
||||
layout.Width = static_cast<UINT32>(width);
|
||||
layout.Height = static_cast<UINT32>(height);
|
||||
layout.Orientation = ORIENTATION_LANDSCAPE;
|
||||
layout.DesktopScaleFactor = freerdp_settings_get_uint32(settings, FreeRDP_DesktopScaleFactor);
|
||||
layout.DeviceScaleFactor = freerdp_settings_get_uint32(settings, FreeRDP_DeviceScaleFactor);
|
||||
layout.DesktopScaleFactor = scaleValue;
|
||||
layout.DeviceScaleFactor = scaleValue;
|
||||
// Physical size in mm must reflect the real DPI (96 * ratio), not
|
||||
// the plain baseline, so it stays consistent with the scale factor
|
||||
// above rather than implying a standard-DPI display of this size.
|
||||
const double effectiveDpi = kDefaultDpi * ratio;
|
||||
layout.PhysicalWidth = static_cast<UINT32>(
|
||||
std::lround((static_cast<double>(width) / kDefaultDpi) * kMillimetersPerInch));
|
||||
std::lround((static_cast<double>(width) / effectiveDpi) * kMillimetersPerInch));
|
||||
layout.PhysicalHeight = static_cast<UINT32>(
|
||||
std::lround((static_cast<double>(height) / kDefaultDpi) * kMillimetersPerInch));
|
||||
std::lround((static_cast<double>(height) / effectiveDpi) * kMillimetersPerInch));
|
||||
|
||||
const UINT rc = dispContext->SendMonitorLayout(dispContext, 1, &layout);
|
||||
if (rc == CHANNEL_RC_OK) {
|
||||
m_lastResizeWidth = width;
|
||||
m_lastResizeHeight = height;
|
||||
m_lastResizeScale = static_cast<int>(scaleValue);
|
||||
|
||||
// The Display Control channel (MS-RDPEDISP) has no server
|
||||
// acknowledgment PDU, and real hosts apply the new resolution
|
||||
// without a Deactivation-Reactivation sequence — so
|
||||
// update->DesktopResize (which only fires for that sequence, or
|
||||
// for the GFX/Progressive pipeline we don't use) never runs for
|
||||
// a channel-driven resize. Without a matching gdi_resize() call,
|
||||
// gdi->width/height stay at the old size, and FreeRDP's own
|
||||
// surface-bits handling (intersect_rect in gdi.c) then silently
|
||||
// *drops* any update reaching outside those stale bounds —
|
||||
// which is what produced the missing/misplaced taskbar and
|
||||
// stale composited-looking content: this call is the fix, not
|
||||
// just a best-effort nudge.
|
||||
if (instance->context->gdi != nullptr
|
||||
&& gdi_resize(instance->context->gdi, static_cast<UINT32>(width),
|
||||
static_cast<UINT32>(height))) {
|
||||
emit remoteDesktopSizeChanged(width, height);
|
||||
} else if (instance->context->gdi != nullptr) {
|
||||
emit eventLogged(QStringLiteral(
|
||||
"RDP warning: local resize to %1x%2 failed; display may show stale content "
|
||||
"until the next full repaint.")
|
||||
.arg(width)
|
||||
.arg(height));
|
||||
}
|
||||
|
||||
// Best-effort nudge: some hosts don't fully repaint their own
|
||||
// desktop after a resolution change (observed: taskbar missing
|
||||
// until something else forces a redraw). Explicitly asking for
|
||||
// the whole new area to be resent costs little and helps
|
||||
// recover from that when it happens.
|
||||
if (instance->context->update != nullptr
|
||||
&& instance->context->update->RefreshRect != nullptr) {
|
||||
RECTANGLE_16 fullArea = {0, 0, static_cast<UINT16>(qMin(width, 65535)),
|
||||
static_cast<UINT16>(qMin(height, 65535))};
|
||||
instance->context->update->RefreshRect(instance->context, 1, &fullArea);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -2035,7 +2149,9 @@ void RdpSessionBackend::processInputEvents(rdp_freerdp* instance)
|
||||
if (hasResize) {
|
||||
const int width = sanitizeDesktopWidth(resizeWidth);
|
||||
const int height = sanitizeDesktopHeight(resizeHeight);
|
||||
if (width != m_lastResizeWidth || height != m_lastResizeHeight) {
|
||||
const int scaleValue = static_cast<int>(nearestFreeRdpScaleValue(m_devicePixelRatio.load()));
|
||||
if (width != m_lastResizeWidth || height != m_lastResizeHeight
|
||||
|| scaleValue != m_lastResizeScale) {
|
||||
if (sendDisplayResize(instance, width, height)) {
|
||||
if (m_resizeFailureLogged) {
|
||||
emit eventLogged(QStringLiteral("Dynamic RDP resize recovered."));
|
||||
@@ -2326,7 +2442,7 @@ void RdpSessionBackend::emitConnectionFailureAsync(const QString& displayMessage
|
||||
Qt::QueuedConnection);
|
||||
}
|
||||
|
||||
int RdpSessionBackend::sanitizeDesktopWidth(int width) const
|
||||
int RdpSessionBackend::sanitizeDesktopWidth(int width)
|
||||
{
|
||||
if (width <= 0) {
|
||||
return kDefaultDesktopWidth;
|
||||
@@ -2334,7 +2450,7 @@ int RdpSessionBackend::sanitizeDesktopWidth(int width) const
|
||||
return qBound(kMinDesktopWidth, width, kMaxDesktopWidth);
|
||||
}
|
||||
|
||||
int RdpSessionBackend::sanitizeDesktopHeight(int height) const
|
||||
int RdpSessionBackend::sanitizeDesktopHeight(int height)
|
||||
{
|
||||
if (height <= 0) {
|
||||
return kDefaultDesktopHeight;
|
||||
|
||||
@@ -19,6 +19,24 @@ 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;
|
||||
@@ -26,6 +44,7 @@ public slots:
|
||||
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,
|
||||
@@ -68,6 +87,7 @@ private:
|
||||
|
||||
std::atomic_int m_requestedDesktopWidth;
|
||||
std::atomic_int m_requestedDesktopHeight;
|
||||
std::atomic<qreal> m_devicePixelRatio;
|
||||
|
||||
std::thread m_worker;
|
||||
std::atomic_bool m_workerRunning;
|
||||
@@ -85,6 +105,7 @@ private:
|
||||
bool m_resizeFailureLogged;
|
||||
int m_lastResizeWidth;
|
||||
int m_lastResizeHeight;
|
||||
int m_lastResizeScale;
|
||||
|
||||
std::mutex m_cliprdrMutex;
|
||||
void* m_cliprdrContext;
|
||||
@@ -121,8 +142,6 @@ public:
|
||||
private:
|
||||
void emitStateAsync(SessionState state, const QString& message);
|
||||
void emitConnectionFailureAsync(const QString& displayMessage, const QString& rawMessage);
|
||||
int sanitizeDesktopWidth(int width) const;
|
||||
int sanitizeDesktopHeight(int height) const;
|
||||
};
|
||||
|
||||
#endif
|
||||
|
||||
@@ -12,6 +12,12 @@
|
||||
class SessionConnectOptions
|
||||
{
|
||||
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 privateKeyPath;
|
||||
QString knownHostsPolicy;
|
||||
@@ -47,10 +53,24 @@ public slots:
|
||||
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,
|
||||
@@ -89,6 +109,10 @@ signals:
|
||||
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);
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
#include "session_backend.h"
|
||||
#include "ssh_session_backend.h"
|
||||
#include "unsupported_session_backend.h"
|
||||
#include "vnc_session_backend.h"
|
||||
|
||||
std::unique_ptr<SessionBackend> createSessionBackend(const Profile& profile)
|
||||
{
|
||||
@@ -13,6 +14,9 @@ std::unique_ptr<SessionBackend> createSessionBackend(const Profile& profile)
|
||||
if (profile.protocol.compare(QStringLiteral("RDP"), Qt::CaseInsensitive) == 0) {
|
||||
return std::make_unique<RdpSessionBackend>(profile);
|
||||
}
|
||||
if (profile.protocol.compare(QStringLiteral("VNC"), Qt::CaseInsensitive) == 0) {
|
||||
return std::make_unique<VncSessionBackend>(profile);
|
||||
}
|
||||
|
||||
return std::make_unique<UnsupportedSessionBackend>(profile);
|
||||
}
|
||||
|
||||
+253
-18
@@ -3,6 +3,7 @@
|
||||
#include "rdp_display_widget.h"
|
||||
#include "session_backend_factory.h"
|
||||
#include "terminal_view.h"
|
||||
#include "vnc_display_widget.h"
|
||||
|
||||
#include <KodoTerm/KodoTerm.hpp>
|
||||
|
||||
@@ -16,13 +17,18 @@
|
||||
#include <QLabel>
|
||||
#include <QLineEdit>
|
||||
#include <QMessageBox>
|
||||
#include <QPainter>
|
||||
#include <QPalette>
|
||||
#include <QPixmap>
|
||||
#include <QPlainTextEdit>
|
||||
#include <QApplication>
|
||||
#include <QClipboard>
|
||||
#include <QMimeData>
|
||||
#include <QColor>
|
||||
#include <QComboBox>
|
||||
#include <QProcessEnvironment>
|
||||
#include <QPushButton>
|
||||
#include <QScrollArea>
|
||||
#include <QThread>
|
||||
#include <QTimer>
|
||||
#include <QToolButton>
|
||||
@@ -57,6 +63,34 @@ TerminalTheme themeForName(const QString& themeName)
|
||||
return TerminalTheme::loadKonsoleTheme(
|
||||
QStringLiteral(":/KodoTermThemes/konsole/Breeze.colorscheme"));
|
||||
}
|
||||
|
||||
// A filled circle with a bold "?", used on the username/password prompt
|
||||
// bar (issue #22). Drawn by hand rather than pulled from a QStyle standard
|
||||
// icon because a themed icon's own internal colors are outside our
|
||||
// control and could end up close in hue to the bar's own background,
|
||||
// undermining the contrast the bar is trying to achieve; painting it
|
||||
// ourselves guarantees fillColor/textColor are exactly the same
|
||||
// guaranteed-contrasting pair used for the rest of the bar.
|
||||
QPixmap questionMarkBadgePixmap(const QColor& fillColor, const QColor& textColor, int diameter)
|
||||
{
|
||||
QPixmap pixmap(diameter, diameter);
|
||||
pixmap.fill(Qt::transparent);
|
||||
|
||||
QPainter painter(&pixmap);
|
||||
painter.setRenderHint(QPainter::Antialiasing, true);
|
||||
painter.setPen(Qt::NoPen);
|
||||
painter.setBrush(fillColor);
|
||||
painter.drawEllipse(0, 0, diameter, diameter);
|
||||
|
||||
QFont font = painter.font();
|
||||
font.setBold(true);
|
||||
font.setPixelSize(static_cast<int>(diameter * 0.65));
|
||||
painter.setFont(font);
|
||||
painter.setPen(textColor);
|
||||
painter.drawText(QRect(0, 0, diameter, diameter), Qt::AlignCenter, QStringLiteral("?"));
|
||||
|
||||
return pixmap;
|
||||
}
|
||||
}
|
||||
|
||||
SessionTab::SessionTab(const Profile& profile,
|
||||
@@ -75,10 +109,13 @@ SessionTab::SessionTab(const Profile& profile,
|
||||
m_terminalFontPointSize(preferences.terminalFontPointSize > 0
|
||||
? preferences.terminalFontPointSize
|
||||
: 0),
|
||||
m_clipboardSyncSupported(profile.protocol.compare(QStringLiteral("RDP"), Qt::CaseInsensitive)
|
||||
== 0),
|
||||
m_clipboardSyncSupported(
|
||||
profile.protocol.compare(QStringLiteral("RDP"), Qt::CaseInsensitive) == 0
|
||||
|| profile.protocol.compare(QStringLiteral("VNC"), Qt::CaseInsensitive) == 0),
|
||||
m_sshTerminal(nullptr),
|
||||
m_rdpDisplay(nullptr),
|
||||
m_vncDisplay(nullptr),
|
||||
m_vncScrollArea(nullptr),
|
||||
m_terminalOutput(nullptr),
|
||||
m_eventLog(nullptr),
|
||||
m_toggleEventsButton(nullptr),
|
||||
@@ -88,10 +125,12 @@ SessionTab::SessionTab(const Profile& profile,
|
||||
m_exportEventsButton(nullptr),
|
||||
m_eventsPanel(nullptr),
|
||||
m_passwordPromptBar(nullptr),
|
||||
m_passwordPromptIcon(nullptr),
|
||||
m_passwordPromptLabel(nullptr),
|
||||
m_passwordPromptInput(nullptr),
|
||||
m_passwordPromptConnectButton(nullptr),
|
||||
m_passwordPromptCancelButton(nullptr),
|
||||
m_awaitingUserInput(false),
|
||||
m_eventSeverityFilter(EventSeverity::Info),
|
||||
m_eventsPanelExpanded(preferences.eventsPanelExpanded)
|
||||
{
|
||||
@@ -100,6 +139,10 @@ SessionTab::SessionTab(const Profile& profile,
|
||||
|
||||
setupUi();
|
||||
|
||||
if (m_vncDisplay != nullptr) {
|
||||
m_vncDisplay->setScaleToFit(preferences.vncScaleToFit);
|
||||
}
|
||||
|
||||
if (m_useKodoTermForSsh) {
|
||||
connect(m_sshTerminal,
|
||||
&KodoTerm::finished,
|
||||
@@ -176,6 +219,11 @@ SessionTab::SessionTab(const Profile& profile,
|
||||
m_backend,
|
||||
&SessionBackend::updateTerminalSize,
|
||||
Qt::QueuedConnection);
|
||||
connect(this,
|
||||
&SessionTab::requestDisplayScale,
|
||||
m_backend,
|
||||
&SessionBackend::updateDisplayScale,
|
||||
Qt::QueuedConnection);
|
||||
connect(this,
|
||||
&SessionTab::requestKeyEvent,
|
||||
m_backend,
|
||||
@@ -201,6 +249,11 @@ SessionTab::SessionTab(const Profile& profile,
|
||||
m_backend,
|
||||
&SessionBackend::setClipboardText,
|
||||
Qt::QueuedConnection);
|
||||
connect(this,
|
||||
&SessionTab::requestProvideUsername,
|
||||
m_backend,
|
||||
&SessionBackend::provideUsername,
|
||||
Qt::QueuedConnection);
|
||||
|
||||
connect(m_backend,
|
||||
&SessionBackend::stateChanged,
|
||||
@@ -227,12 +280,19 @@ SessionTab::SessionTab(const Profile& profile,
|
||||
this,
|
||||
&SessionTab::onBackendHostKeyConfirmationRequested,
|
||||
Qt::QueuedConnection);
|
||||
connect(m_backend,
|
||||
&SessionBackend::usernameRequested,
|
||||
this,
|
||||
&SessionTab::onBackendUsernameRequested,
|
||||
Qt::QueuedConnection);
|
||||
connect(m_backend,
|
||||
&SessionBackend::frameUpdated,
|
||||
this,
|
||||
[this](const QImage& frame) {
|
||||
if (m_rdpDisplay != nullptr) {
|
||||
m_rdpDisplay->setFrame(frame);
|
||||
} else if (m_vncDisplay != nullptr) {
|
||||
m_vncDisplay->setFrame(frame);
|
||||
}
|
||||
},
|
||||
Qt::QueuedConnection);
|
||||
@@ -242,6 +302,8 @@ SessionTab::SessionTab(const Profile& profile,
|
||||
[this](int width, int height) {
|
||||
if (m_rdpDisplay != nullptr) {
|
||||
m_rdpDisplay->setRemoteDesktopSize(width, height);
|
||||
} else if (m_vncDisplay != nullptr) {
|
||||
m_vncDisplay->setRemoteDesktopSize(width, height);
|
||||
}
|
||||
},
|
||||
Qt::QueuedConnection);
|
||||
@@ -256,6 +318,8 @@ SessionTab::SessionTab(const Profile& profile,
|
||||
[this](const QImage& image, const QPoint& hotspot) {
|
||||
if (m_rdpDisplay != nullptr) {
|
||||
m_rdpDisplay->setCursorImage(image, hotspot);
|
||||
} else if (m_vncDisplay != nullptr) {
|
||||
m_vncDisplay->setCursorImage(image, hotspot);
|
||||
}
|
||||
},
|
||||
Qt::QueuedConnection);
|
||||
@@ -265,6 +329,8 @@ SessionTab::SessionTab(const Profile& profile,
|
||||
[this]() {
|
||||
if (m_rdpDisplay != nullptr) {
|
||||
m_rdpDisplay->setCursorHidden();
|
||||
} else if (m_vncDisplay != nullptr) {
|
||||
m_vncDisplay->setCursorHidden();
|
||||
}
|
||||
},
|
||||
Qt::QueuedConnection);
|
||||
@@ -274,6 +340,8 @@ SessionTab::SessionTab(const Profile& profile,
|
||||
[this]() {
|
||||
if (m_rdpDisplay != nullptr) {
|
||||
m_rdpDisplay->setCursorDefault();
|
||||
} else if (m_vncDisplay != nullptr) {
|
||||
m_vncDisplay->setCursorDefault();
|
||||
}
|
||||
},
|
||||
Qt::QueuedConnection);
|
||||
@@ -307,9 +375,17 @@ SessionTab::~SessionTab()
|
||||
|
||||
QString SessionTab::tabTitle() const
|
||||
{
|
||||
if (m_awaitingUserInput) {
|
||||
return QStringLiteral("%1 (Needs input)").arg(m_profile.name);
|
||||
}
|
||||
return QStringLiteral("%1 (%2)").arg(m_profile.name, stateSuffix());
|
||||
}
|
||||
|
||||
bool SessionTab::awaitingUserInput() const
|
||||
{
|
||||
return m_awaitingUserInput;
|
||||
}
|
||||
|
||||
void SessionTab::connectSession()
|
||||
{
|
||||
if (m_state == SessionState::Connecting || m_state == SessionState::Connected) {
|
||||
@@ -400,6 +476,12 @@ void SessionTab::clearTerminal()
|
||||
if (m_rdpDisplay != nullptr) {
|
||||
m_rdpDisplay->clearFrame();
|
||||
m_rdpDisplay->setFocus();
|
||||
return;
|
||||
}
|
||||
|
||||
if (m_vncDisplay != nullptr) {
|
||||
m_vncDisplay->clearFrame();
|
||||
m_vncDisplay->setFocus();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -440,6 +522,28 @@ bool SessionTab::supportsZoom() const
|
||||
return m_useKodoTermForSsh || m_terminalOutput != nullptr;
|
||||
}
|
||||
|
||||
bool SessionTab::supportsVncScaleToggle() const
|
||||
{
|
||||
return m_vncDisplay != nullptr;
|
||||
}
|
||||
|
||||
void SessionTab::setVncScaleToFit(bool scaleToFit)
|
||||
{
|
||||
if (m_vncDisplay == nullptr || m_vncDisplay->scaleToFit() == scaleToFit) {
|
||||
return;
|
||||
}
|
||||
|
||||
m_vncDisplay->setScaleToFit(scaleToFit);
|
||||
appendEvent(scaleToFit ? QStringLiteral("Display mode set to scale to fit.")
|
||||
: QStringLiteral("Display mode set to actual size."));
|
||||
emit vncScaleModeChanged(scaleToFit);
|
||||
}
|
||||
|
||||
bool SessionTab::vncScaleToFit() const
|
||||
{
|
||||
return m_vncDisplay != nullptr ? m_vncDisplay->scaleToFit() : true;
|
||||
}
|
||||
|
||||
void SessionTab::zoomIn()
|
||||
{
|
||||
if (m_useKodoTermForSsh && m_sshTerminal != nullptr) {
|
||||
@@ -662,6 +766,16 @@ void SessionTab::onBackendHostKeyConfirmationRequested(const QString& prompt)
|
||||
emit requestHostKeyConfirmation(reply == QMessageBox::Yes);
|
||||
}
|
||||
|
||||
void SessionTab::onBackendUsernameRequested(const QString& prompt)
|
||||
{
|
||||
showPasswordPrompt(
|
||||
prompt.isEmpty() ? QStringLiteral("Username for %1:").arg(m_profile.host) : prompt,
|
||||
[this](std::optional<QString> username) {
|
||||
emit requestProvideUsername(username.value_or(QString()).trimmed());
|
||||
},
|
||||
false);
|
||||
}
|
||||
|
||||
void SessionTab::onBackendRemoteClipboardTextChanged(const QString& text)
|
||||
{
|
||||
if (text == m_lastSyncedClipboardText) {
|
||||
@@ -712,6 +826,13 @@ void SessionTab::setupUi()
|
||||
} else if (m_profile.protocol.compare(QStringLiteral("RDP"), Qt::CaseInsensitive) == 0) {
|
||||
m_rdpDisplay = new RdpDisplayWidget(this);
|
||||
rootLayout->addWidget(m_rdpDisplay, 1);
|
||||
} else if (m_profile.protocol.compare(QStringLiteral("VNC"), Qt::CaseInsensitive) == 0) {
|
||||
m_vncDisplay = new VncDisplayWidget(this);
|
||||
m_vncScrollArea = new QScrollArea(this);
|
||||
m_vncScrollArea->setWidget(m_vncDisplay);
|
||||
m_vncScrollArea->setWidgetResizable(true);
|
||||
m_vncScrollArea->setFrameShape(QFrame::NoFrame);
|
||||
rootLayout->addWidget(m_vncScrollArea, 1);
|
||||
} else {
|
||||
m_terminalOutput = new TerminalView(this);
|
||||
QFont fallbackFont = defaultTerminalFont();
|
||||
@@ -721,33 +842,48 @@ void SessionTab::setupUi()
|
||||
m_terminalOutput->setFont(fallbackFont);
|
||||
m_terminalOutput->setMinimumHeight(260);
|
||||
m_terminalOutput->setReadOnly(true);
|
||||
if (m_profile.protocol.compare(QStringLiteral("VNC"), Qt::CaseInsensitive) == 0) {
|
||||
m_terminalOutput->setPlaceholderText(
|
||||
QStringLiteral("Embedded VNC session output appears here when the backend is available."));
|
||||
} else {
|
||||
m_terminalOutput->setPlaceholderText(
|
||||
QStringLiteral("Session output appears here."));
|
||||
}
|
||||
m_terminalOutput->setPlaceholderText(QStringLiteral("Session output appears here."));
|
||||
rootLayout->addWidget(m_terminalOutput, 1);
|
||||
}
|
||||
|
||||
applyTerminalTheme(m_terminalThemeName);
|
||||
|
||||
// Styled distinctly (issue #22: this bar used to blend straight into
|
||||
// the plain window background and was easy to miss, especially on a
|
||||
// tab you weren't actively looking at). A solid QPalette::Highlight
|
||||
// fill with QPalette::HighlightedText for the label is used rather
|
||||
// than a subtle tint -- a faint tint proved to still be easy to miss,
|
||||
// and Highlight/HighlightedText are the OS theme's own guaranteed-
|
||||
// contrasting pair, so this stays readable under both light and dark
|
||||
// themes without hardcoding a color.
|
||||
auto* passwordPromptLayout = new QHBoxLayout();
|
||||
m_passwordPromptIcon = new QLabel(this);
|
||||
const QColor highlight = palette().color(QPalette::Highlight);
|
||||
const QColor highlightedText = palette().color(QPalette::HighlightedText);
|
||||
m_passwordPromptIcon->setPixmap(questionMarkBadgePixmap(highlightedText, highlight, 22));
|
||||
m_passwordPromptLabel = new QLabel(this);
|
||||
m_passwordPromptLabel->setObjectName(QStringLiteral("passwordPromptLabel"));
|
||||
m_passwordPromptInput = new QLineEdit(this);
|
||||
m_passwordPromptInput->setEchoMode(QLineEdit::Password);
|
||||
m_passwordPromptConnectButton = new QPushButton(QStringLiteral("Connect"), this);
|
||||
m_passwordPromptCancelButton = new QPushButton(QStringLiteral("Cancel"), this);
|
||||
passwordPromptLayout->addWidget(m_passwordPromptIcon);
|
||||
passwordPromptLayout->addWidget(m_passwordPromptLabel);
|
||||
passwordPromptLayout->addWidget(m_passwordPromptInput, 1);
|
||||
passwordPromptLayout->addWidget(m_passwordPromptConnectButton);
|
||||
passwordPromptLayout->addWidget(m_passwordPromptCancelButton);
|
||||
passwordPromptLayout->setContentsMargins(10, 8, 10, 8);
|
||||
|
||||
m_passwordPromptBar = new QWidget(this);
|
||||
m_passwordPromptBar->setObjectName(QStringLiteral("passwordPromptBar"));
|
||||
m_passwordPromptBar->setLayout(passwordPromptLayout);
|
||||
m_passwordPromptBar->setAutoFillBackground(true);
|
||||
m_passwordPromptBar->setVisible(false);
|
||||
m_passwordPromptBar->setStyleSheet(
|
||||
QStringLiteral("QWidget#passwordPromptBar { background-color: %1; }"
|
||||
"QWidget#passwordPromptBar QLabel#passwordPromptLabel "
|
||||
"{ color: %2; font-weight: bold; font-size: 11pt; }")
|
||||
.arg(highlight.name(), highlightedText.name()));
|
||||
rootLayout->addWidget(m_passwordPromptBar);
|
||||
|
||||
connect(m_passwordPromptConnectButton, &QPushButton::clicked, this, [this]() {
|
||||
@@ -860,6 +996,10 @@ void SessionTab::setupUi()
|
||||
&RdpDisplayWidget::viewportSizeChanged,
|
||||
this,
|
||||
[this](int width, int height) { emit requestTerminalSize(width, height); });
|
||||
connect(m_rdpDisplay,
|
||||
&RdpDisplayWidget::displayScaleChanged,
|
||||
this,
|
||||
[this](qreal ratio) { emit requestDisplayScale(ratio); });
|
||||
connect(m_rdpDisplay,
|
||||
&RdpDisplayWidget::keyInput,
|
||||
this,
|
||||
@@ -882,6 +1022,37 @@ void SessionTab::setupUi()
|
||||
[this](int x, int y, int deltaX, int deltaY) {
|
||||
emit requestMouseWheelEvent(x, y, deltaX, deltaY);
|
||||
});
|
||||
} else if (m_vncDisplay != nullptr) {
|
||||
connect(m_vncDisplay,
|
||||
&VncDisplayWidget::viewportSizeChanged,
|
||||
this,
|
||||
[this](int width, int height) { emit requestTerminalSize(width, height); });
|
||||
connect(m_vncDisplay,
|
||||
&VncDisplayWidget::displayScaleChanged,
|
||||
this,
|
||||
[this](qreal ratio) { emit requestDisplayScale(ratio); });
|
||||
connect(m_vncDisplay,
|
||||
&VncDisplayWidget::keyInput,
|
||||
this,
|
||||
[this](int key, quint32 nativeScanCode, const QString& text, bool pressed, int modifiers) {
|
||||
emit requestKeyEvent(key, nativeScanCode, text, pressed, modifiers);
|
||||
});
|
||||
connect(m_vncDisplay,
|
||||
&VncDisplayWidget::mouseMoveInput,
|
||||
this,
|
||||
[this](int x, int y) { emit requestMouseMoveEvent(x, y); });
|
||||
connect(m_vncDisplay,
|
||||
&VncDisplayWidget::mouseButtonInput,
|
||||
this,
|
||||
[this](int x, int y, int button, bool pressed) {
|
||||
emit requestMouseButtonEvent(x, y, button, pressed);
|
||||
});
|
||||
connect(m_vncDisplay,
|
||||
&VncDisplayWidget::mouseWheelInput,
|
||||
this,
|
||||
[this](int x, int y, int deltaX, int deltaY) {
|
||||
emit requestMouseWheelEvent(x, y, deltaX, deltaY);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -890,9 +1061,55 @@ void SessionTab::requestConnectOptions(
|
||||
{
|
||||
SessionConnectOptions baseOptions;
|
||||
baseOptions.knownHostsPolicy = m_profile.knownHostsPolicy;
|
||||
// The backend's own Profile copy was captured when it was constructed
|
||||
// and never sees later edits to m_profile (e.g. the username prompt
|
||||
// below) -- it has to travel through here instead.
|
||||
baseOptions.username = m_profile.username.trimmed();
|
||||
|
||||
const bool isSsh = m_profile.protocol.compare(QStringLiteral("SSH"), Qt::CaseInsensitive) == 0;
|
||||
const bool isRdp = m_profile.protocol.compare(QStringLiteral("RDP"), Qt::CaseInsensitive) == 0;
|
||||
const bool isVnc = m_profile.protocol.compare(QStringLiteral("VNC"), Qt::CaseInsensitive) == 0;
|
||||
|
||||
// SSH and RDP always need a username; a profile is now allowed to
|
||||
// leave it blank (see profile_dialog.cpp) and get asked here instead,
|
||||
// the same way a blank password is already handled below. The value
|
||||
// is kept on this in-memory m_profile copy for the rest of the tab's
|
||||
// lifetime, not written back to the saved profile.
|
||||
if ((isSsh || isRdp) && m_profile.username.trimmed().isEmpty()) {
|
||||
showPasswordPrompt(
|
||||
QStringLiteral("%1 username for %2:").arg(m_profile.protocol, m_profile.host),
|
||||
[this, callback](std::optional<QString> username) {
|
||||
if (!username.has_value() || username->trimmed().isEmpty()) {
|
||||
callback(std::nullopt);
|
||||
return;
|
||||
}
|
||||
m_profile.username = username->trimmed();
|
||||
requestConnectOptions(callback);
|
||||
},
|
||||
false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isVnc) {
|
||||
// Unlike RDP, an empty password is allowed through: some VNC
|
||||
// servers (no-auth) don't need one at all, and there's no
|
||||
// client-side way to know that before the server's security-type
|
||||
// negotiation happens.
|
||||
showPasswordPrompt(
|
||||
QStringLiteral("VNC password for %1 (leave blank if the server doesn't require one):")
|
||||
.arg(m_profile.host),
|
||||
[baseOptions, callback](std::optional<QString> password) {
|
||||
if (!password.has_value()) {
|
||||
callback(std::nullopt);
|
||||
return;
|
||||
}
|
||||
|
||||
SessionConnectOptions options = baseOptions;
|
||||
options.password = password.value();
|
||||
callback(options);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isSsh && !isRdp) {
|
||||
callback(baseOptions);
|
||||
@@ -992,7 +1209,8 @@ void SessionTab::requestConnectOptions(
|
||||
}
|
||||
|
||||
void SessionTab::showPasswordPrompt(const QString& labelText,
|
||||
std::function<void(std::optional<QString>)> callback)
|
||||
std::function<void(std::optional<QString>)> callback,
|
||||
bool maskInput)
|
||||
{
|
||||
if (m_passwordPromptCallback) {
|
||||
const auto previousCallback = m_passwordPromptCallback;
|
||||
@@ -1003,14 +1221,27 @@ void SessionTab::showPasswordPrompt(const QString& labelText,
|
||||
m_passwordPromptCallback = std::move(callback);
|
||||
m_passwordPromptLabel->setText(labelText);
|
||||
m_passwordPromptInput->clear();
|
||||
m_passwordPromptInput->setEchoMode(maskInput ? QLineEdit::Password : QLineEdit::Normal);
|
||||
m_passwordPromptBar->setVisible(true);
|
||||
m_passwordPromptInput->setFocus();
|
||||
|
||||
if (!m_awaitingUserInput) {
|
||||
m_awaitingUserInput = true;
|
||||
emit awaitingUserInputChanged(true);
|
||||
emit tabTitleChanged(tabTitle());
|
||||
}
|
||||
}
|
||||
|
||||
void SessionTab::hidePasswordPrompt()
|
||||
{
|
||||
m_passwordPromptBar->setVisible(false);
|
||||
m_passwordPromptCallback = nullptr;
|
||||
|
||||
if (m_awaitingUserInput) {
|
||||
m_awaitingUserInput = false;
|
||||
emit awaitingUserInputChanged(false);
|
||||
emit tabTitleChanged(tabTitle());
|
||||
}
|
||||
}
|
||||
|
||||
bool SessionTab::validateProfileForConnect()
|
||||
@@ -1029,14 +1260,10 @@ bool SessionTab::validateProfileForConnect()
|
||||
return false;
|
||||
}
|
||||
|
||||
if ((m_profile.protocol.compare(QStringLiteral("SSH"), Qt::CaseInsensitive) == 0
|
||||
|| m_profile.protocol.compare(QStringLiteral("RDP"), Qt::CaseInsensitive) == 0)
|
||||
&& m_profile.username.trimmed().isEmpty()) {
|
||||
QMessageBox::warning(this,
|
||||
QStringLiteral("Connect"),
|
||||
QStringLiteral("%1 username is required.").arg(m_profile.protocol));
|
||||
return false;
|
||||
}
|
||||
// SSH/RDP no longer hard-require a username here -- a blank one is
|
||||
// handled by requestConnectOptions() prompting for it inline at connect
|
||||
// time (see issue #21). Do not re-add a check here without also
|
||||
// updating that flow.
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -1101,6 +1328,14 @@ void SessionTab::refreshActionButtons()
|
||||
if (isConnected) {
|
||||
m_rdpDisplay->setFocus();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (m_vncDisplay != nullptr) {
|
||||
m_vncDisplay->setEnabled(isConnected);
|
||||
if (isConnected) {
|
||||
m_vncDisplay->setFocus();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+22
-1
@@ -17,11 +17,13 @@ class QThread;
|
||||
class SessionBackend;
|
||||
class TerminalView;
|
||||
class RdpDisplayWidget;
|
||||
class VncDisplayWidget;
|
||||
class QToolButton;
|
||||
class QLineEdit;
|
||||
class QComboBox;
|
||||
class QLabel;
|
||||
class QPushButton;
|
||||
class QScrollArea;
|
||||
class KodoTerm;
|
||||
|
||||
struct SessionUiPreferences
|
||||
@@ -29,6 +31,7 @@ struct SessionUiPreferences
|
||||
QString terminalThemeName = QStringLiteral("Dark");
|
||||
bool eventsPanelExpanded = false;
|
||||
int terminalFontPointSize = 0;
|
||||
bool vncScaleToFit = true;
|
||||
};
|
||||
|
||||
class SessionTab : public QWidget
|
||||
@@ -61,19 +64,30 @@ public:
|
||||
void clearEvents();
|
||||
void copyEvents() const;
|
||||
void exportEventsToFile();
|
||||
bool supportsVncScaleToggle() const;
|
||||
void setVncScaleToFit(bool scaleToFit);
|
||||
bool vncScaleToFit() const;
|
||||
bool awaitingUserInput() const;
|
||||
|
||||
signals:
|
||||
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 requestDisconnect();
|
||||
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,
|
||||
@@ -83,6 +97,7 @@ signals:
|
||||
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:
|
||||
void onBackendStateChanged(SessionState state, const QString& message);
|
||||
@@ -90,6 +105,7 @@ private slots:
|
||||
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();
|
||||
|
||||
@@ -108,6 +124,8 @@ private:
|
||||
|
||||
KodoTerm* m_sshTerminal;
|
||||
RdpDisplayWidget* m_rdpDisplay;
|
||||
VncDisplayWidget* m_vncDisplay;
|
||||
QScrollArea* m_vncScrollArea;
|
||||
TerminalView* m_terminalOutput;
|
||||
QPlainTextEdit* m_eventLog;
|
||||
QToolButton* m_toggleEventsButton;
|
||||
@@ -117,11 +135,13 @@ private:
|
||||
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,
|
||||
@@ -139,7 +159,8 @@ private:
|
||||
void setupUi();
|
||||
void requestConnectOptions(std::function<void(std::optional<SessionConnectOptions>)> callback);
|
||||
void showPasswordPrompt(const QString& labelText,
|
||||
std::function<void(std::optional<QString>)> callback);
|
||||
std::function<void(std::optional<QString>)> callback,
|
||||
bool maskInput = true);
|
||||
void hidePasswordPrompt();
|
||||
bool validateProfileForConnect();
|
||||
void appendEvent(const QString& message);
|
||||
|
||||
@@ -34,6 +34,19 @@ QColor tabColorForState(SessionState state, const QPalette& palette)
|
||||
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")};
|
||||
@@ -57,6 +70,7 @@ SessionWindow::SessionWindow(QWidget* parent)
|
||||
QWidget* tab = m_tabs->widget(index);
|
||||
if (auto* sessionTab = qobject_cast<SessionTab*>(tab)) {
|
||||
sessionTab->disconnectSession();
|
||||
m_tabStates.remove(sessionTab);
|
||||
}
|
||||
m_tabs->removeTab(index);
|
||||
delete tab;
|
||||
@@ -120,6 +134,20 @@ SessionWindow::SessionWindow(QWidget* parent)
|
||||
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();
|
||||
@@ -156,6 +184,10 @@ SessionWindow::SessionWindow(QWidget* parent)
|
||||
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) {
|
||||
@@ -170,6 +202,10 @@ SessionWindow::SessionWindow(QWidget* parent)
|
||||
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,
|
||||
@@ -180,6 +216,18 @@ SessionWindow::SessionWindow(QWidget* parent)
|
||||
&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"));
|
||||
@@ -227,6 +275,7 @@ void SessionWindow::addSessionTab(const Profile& profile)
|
||||
} 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()));
|
||||
|
||||
@@ -238,6 +287,13 @@ void SessionWindow::addSessionTab(const Profile& profile)
|
||||
&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(
|
||||
@@ -246,6 +302,22 @@ void SessionWindow::addSessionTab(const Profile& profile)
|
||||
}
|
||||
}
|
||||
});
|
||||
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,
|
||||
@@ -272,6 +344,13 @@ void SessionWindow::addSessionTab(const Profile& profile)
|
||||
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)
|
||||
@@ -303,6 +382,8 @@ void SessionWindow::loadUiPreferences()
|
||||
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
|
||||
@@ -314,4 +395,5 @@ void SessionWindow::saveUiPreferences() const
|
||||
m_preferences.eventsPanelExpanded);
|
||||
settings.setValue(QStringLiteral("session/terminalFontPointSize"),
|
||||
m_preferences.terminalFontPointSize);
|
||||
settings.setValue(QStringLiteral("session/vncScaleToFit"), m_preferences.vncScaleToFit);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
#include "profile_repository.h"
|
||||
#include "session_tab.h"
|
||||
|
||||
#include <QHash>
|
||||
#include <QMainWindow>
|
||||
|
||||
class QTabWidget;
|
||||
@@ -21,6 +22,10 @@ private:
|
||||
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 updateTabTitle(SessionTab* tab, const QString& title);
|
||||
|
||||
+25
-12
@@ -7,16 +7,14 @@
|
||||
#include <QTextStream>
|
||||
#include <QUuid>
|
||||
|
||||
namespace {
|
||||
QString escapeForShellSingleQuotes(const QString& value)
|
||||
SshSessionBackend::SshSessionBackend(const Profile& profile, QObject* parent)
|
||||
: SshSessionBackend(profile, QStringLiteral("ssh"), parent)
|
||||
{
|
||||
QString escaped = value;
|
||||
escaped.replace(QStringLiteral("'"), QStringLiteral("'\"'\"'"));
|
||||
return escaped;
|
||||
}
|
||||
}
|
||||
|
||||
SshSessionBackend::SshSessionBackend(const Profile& profile, QObject* parent)
|
||||
SshSessionBackend::SshSessionBackend(const Profile& profile,
|
||||
const QString& sshProgramOverride,
|
||||
QObject* parent)
|
||||
: SessionBackend(profile, parent),
|
||||
m_process(new QProcess(this)),
|
||||
m_connectedProbeTimer(new QTimer(this)),
|
||||
@@ -27,7 +25,8 @@ SshSessionBackend::SshSessionBackend(const Profile& profile, QObject* parent)
|
||||
m_waitingForHostKeyConfirmation(false),
|
||||
m_passwordSubmitted(false),
|
||||
m_terminalColumns(0),
|
||||
m_terminalRows(0)
|
||||
m_terminalRows(0),
|
||||
m_sshProgram(sshProgramOverride)
|
||||
{
|
||||
m_connectedProbeTimer->setSingleShot(true);
|
||||
|
||||
@@ -389,13 +388,20 @@ bool SshSessionBackend::startSshProcess(const SessionConnectOptions& options)
|
||||
<< 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()
|
||||
: QStringLiteral("%1@%2").arg(p.username.trimmed(), p.host.trimmed());
|
||||
: QStringLiteral("%1@%2").arg(username, p.host.trimmed());
|
||||
args << target;
|
||||
|
||||
m_process->setProcessEnvironment(environment);
|
||||
m_process->setProgram(QStringLiteral("ssh"));
|
||||
m_process->setProgram(m_sshProgram);
|
||||
m_process->setArguments(args);
|
||||
m_process->setProcessChannelMode(QProcess::SeparateChannels);
|
||||
|
||||
@@ -471,7 +477,7 @@ void SshSessionBackend::cleanupAskPassScript()
|
||||
}
|
||||
}
|
||||
|
||||
QString SshSessionBackend::mapSshError(const QString& rawError) const
|
||||
QString SshSessionBackend::mapSshError(const QString& rawError)
|
||||
{
|
||||
const QString raw = rawError.trimmed();
|
||||
if (raw.contains(QStringLiteral("Permission denied"), Qt::CaseInsensitive)) {
|
||||
@@ -510,6 +516,13 @@ QString SshSessionBackend::mapSshError(const QString& rawError) const
|
||||
return QStringLiteral("SSH connection failed.");
|
||||
}
|
||||
|
||||
QString SshSessionBackend::escapeForShellSingleQuotes(const QString& value)
|
||||
{
|
||||
QString escaped = value;
|
||||
escaped.replace(QStringLiteral("'"), QStringLiteral("'\"'\"'"));
|
||||
return escaped;
|
||||
}
|
||||
|
||||
QString SshSessionBackend::knownHostsFileForNullDevice() const
|
||||
{
|
||||
#ifdef Q_OS_WIN
|
||||
|
||||
@@ -13,8 +13,16 @@ class SshSessionBackend : public SessionBackend
|
||||
|
||||
public:
|
||||
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;
|
||||
|
||||
// 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:
|
||||
void connectSession(const SessionConnectOptions& options) override;
|
||||
void disconnectSession() override;
|
||||
@@ -46,6 +54,7 @@ private:
|
||||
bool m_passwordSubmitted;
|
||||
int m_terminalColumns;
|
||||
int m_terminalRows;
|
||||
QString m_sshProgram;
|
||||
|
||||
void setState(SessionState state, const QString& message);
|
||||
bool startSshProcess(const SessionConnectOptions& options);
|
||||
@@ -53,7 +62,6 @@ private:
|
||||
QProcessEnvironment& environment,
|
||||
QString& error);
|
||||
void cleanupAskPassScript();
|
||||
QString mapSshError(const QString& rawError) const;
|
||||
QString knownHostsFileForNullDevice() const;
|
||||
void applyTerminalSizeIfAvailable();
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
@@ -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()
|
||||
+45
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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
Vendored
+1
@@ -0,0 +1 @@
|
||||
3.23.1-dev0
|
||||
Reference in New Issue
Block a user