Internal
Public Access
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dbcc20d155 | ||
|
|
04ce6f7904 | ||
|
|
9842a44de0 | ||
|
|
eb63bde870 | ||
|
|
3fab2f9de3 | ||
|
|
4f5cf8ecd9 | ||
|
|
df99c78998 | ||
|
|
dffca3afef | ||
|
|
4f8fa3272b | ||
|
|
96c8403f3b | ||
|
|
3e621219f1 | ||
|
|
e0969041a7 | ||
|
|
c4a62e8fb6 | ||
|
|
9a22597d4e | ||
|
|
e90e9b5abf | ||
|
|
92d8b62820 | ||
|
|
7ee930693e | ||
|
|
8e98c208c9 | ||
|
|
ce1e40a12d | ||
|
|
80bc50e54c | ||
|
|
1c66adb646 | ||
|
|
4c649f727f | ||
|
|
18f234105d | ||
|
|
186480dcf5 | ||
|
|
dd974c684a | ||
|
|
8c56d489af | ||
|
|
d7910f1631 |
@@ -1,3 +1,5 @@
|
|||||||
/build/
|
/build/
|
||||||
/dist/
|
/dist/
|
||||||
/.flatpak-builder/
|
/.flatpak-builder/
|
||||||
|
/build-doc-tool/
|
||||||
|
/docs/USER_GUIDE.pdf
|
||||||
|
|||||||
+33
-2
@@ -1,6 +1,6 @@
|
|||||||
cmake_minimum_required(VERSION 3.21)
|
cmake_minimum_required(VERSION 3.21)
|
||||||
|
|
||||||
project(OrbitHub VERSION 2026.9.8 LANGUAGES CXX)
|
project(OrbitHub VERSION 2026.9.15 LANGUAGES CXX)
|
||||||
|
|
||||||
set(CMAKE_CXX_STANDARD 17)
|
set(CMAKE_CXX_STANDARD 17)
|
||||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||||
@@ -16,6 +16,17 @@ find_package(Qt6 6.2 REQUIRED COMPONENTS Widgets Sql)
|
|||||||
|
|
||||||
qt_standard_project_setup()
|
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)
|
add_subdirectory(third_party/KodoTerm)
|
||||||
|
|
||||||
if(NOT EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/third_party/FreeRDP/CMakeLists.txt")
|
if(NOT EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/third_party/FreeRDP/CMakeLists.txt")
|
||||||
@@ -81,9 +92,14 @@ add_subdirectory(third_party/FreeRDP EXCLUDE_FROM_ALL)
|
|||||||
set(ORBITHUB_SOURCES
|
set(ORBITHUB_SOURCES
|
||||||
src/about_dialog.cpp
|
src/about_dialog.cpp
|
||||||
src/about_dialog.h
|
src/about_dialog.h
|
||||||
|
src/user_guide_dialog.cpp
|
||||||
|
src/user_guide_dialog.h
|
||||||
|
docs/user_guide.qrc
|
||||||
src/app_icon.cpp
|
src/app_icon.cpp
|
||||||
src/app_icon.h
|
src/app_icon.h
|
||||||
src/main.cpp
|
src/main.cpp
|
||||||
|
src/mremoteng_importer.cpp
|
||||||
|
src/mremoteng_importer.h
|
||||||
src/profile_dialog.cpp
|
src/profile_dialog.cpp
|
||||||
src/profile_dialog.h
|
src/profile_dialog.h
|
||||||
src/profile_repository.cpp
|
src/profile_repository.cpp
|
||||||
@@ -131,6 +147,7 @@ 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)
|
||||||
target_link_libraries(orbithub PRIVATE KodoTerm::KodoTerm)
|
target_link_libraries(orbithub PRIVATE KodoTerm::KodoTerm)
|
||||||
|
target_compile_definitions(orbithub PRIVATE ORBITHUB_VERSION_STRING="${PROJECT_VERSION}")
|
||||||
if(TARGET freerdp AND TARGET winpr)
|
if(TARGET freerdp AND TARGET winpr)
|
||||||
target_compile_definitions(orbithub PRIVATE ORBITHUB_HAS_FREERDP)
|
target_compile_definitions(orbithub PRIVATE ORBITHUB_HAS_FREERDP)
|
||||||
target_include_directories(orbithub PRIVATE
|
target_include_directories(orbithub PRIVATE
|
||||||
@@ -257,4 +274,18 @@ install(FILES
|
|||||||
packaging/linux/org.darksingularity.OrbitHub.metainfo.xml
|
packaging/linux/org.darksingularity.OrbitHub.metainfo.xml
|
||||||
DESTINATION ${CMAKE_INSTALL_DATADIR}/metainfo
|
DESTINATION ${CMAKE_INSTALL_DATADIR}/metainfo
|
||||||
)
|
)
|
||||||
install(FILES LICENSE DESTINATION ${CMAKE_INSTALL_DATADIR}/licenses/orbithub)
|
install(FILES LICENSE
|
||||||
|
DESTINATION ${CMAKE_INSTALL_DATADIR}/licenses/org.darksingularity.OrbitHub
|
||||||
|
)
|
||||||
|
install(FILES third_party/FreeRDP/LICENSE
|
||||||
|
DESTINATION ${CMAKE_INSTALL_DATADIR}/licenses/org.darksingularity.OrbitHub
|
||||||
|
RENAME LICENSE-FreeRDP
|
||||||
|
)
|
||||||
|
install(FILES third_party/KodoTerm/LICENSE
|
||||||
|
DESTINATION ${CMAKE_INSTALL_DATADIR}/licenses/org.darksingularity.OrbitHub
|
||||||
|
RENAME LICENSE-KodoTerm
|
||||||
|
)
|
||||||
|
|
||||||
|
if(ORBITHUB_BUILD_TESTS)
|
||||||
|
add_subdirectory(tests)
|
||||||
|
endif()
|
||||||
|
|||||||
@@ -16,18 +16,32 @@ OrbitHub is in active development.
|
|||||||
- Milestones completed: M0-M5, and M7-M9
|
- Milestones completed: M0-M5, and M7-M9
|
||||||
- Current milestone: Milestone 10 (v1.0 Stabilization)
|
- Current milestone: Milestone 10 (v1.0 Stabilization)
|
||||||
- Deferred milestone: Milestone 6 (VNC Fully Working)
|
- Deferred milestone: Milestone 6 (VNC Fully Working)
|
||||||
- Latest checkpoint tag: `v0-m9-done`
|
- Latest checkpoint tag: `v2026.9.15`
|
||||||
- VNC implementation milestone (M6) is currently deferred
|
- VNC implementation milestone (M6) is currently deferred
|
||||||
|
|
||||||
Progress and milestone details:
|
Progress and milestone details:
|
||||||
- [docs/PROGRESS.md](docs/PROGRESS.md)
|
- [docs/PROGRESS.md](docs/PROGRESS.md)
|
||||||
|
|
||||||
|
Latest release (installers for Windows, Linux, and macOS):
|
||||||
|
- [v2026.9.15](https://git.darksingularity.org/DarkSingularity/orbithub/releases/tag/v2026.9.15)
|
||||||
|
|
||||||
|
User Guide:
|
||||||
|
- [docs/USER_GUIDE.md](docs/USER_GUIDE.md) (also available as a PDF attached to each release, and in-app via `Help -> User Guide`)
|
||||||
|
|
||||||
## Screenshots
|
## Screenshots
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
Profiles organized into folders, with protocol, host, and tags shown at a glance. (Sample data shown; not real hosts.)
|
Profiles organized into folders, with protocol, host, and tags shown at a glance. (Sample data shown; not real hosts.)
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
An interactive SSH terminal session in a tab, with the event log below.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
An embedded RDP session in a tab.
|
||||||
|
|
||||||
## Implemented Features
|
## Implemented Features
|
||||||
|
|
||||||
### Profile Management
|
### Profile Management
|
||||||
|
|||||||
@@ -140,3 +140,19 @@ right-click → **Open** to bypass Gatekeeper's unidentified-developer warning.
|
|||||||
|
|
||||||
Output path:
|
Output path:
|
||||||
- `dist/macos/OrbitHub-<version>.dmg`
|
- `dist/macos/OrbitHub-<version>.dmg`
|
||||||
|
|
||||||
|
## User Guide PDF
|
||||||
|
|
||||||
|
The in-app User Guide (`Help -> User Guide`) is built from
|
||||||
|
`docs/USER_GUIDE.md` and embedded into the app at compile time — no extra
|
||||||
|
step needed for that. A standalone PDF version is generated separately by a
|
||||||
|
small tool (kept out of the main app's dependencies, since it needs
|
||||||
|
`Qt6::PrintSupport`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./packaging/docs/build-user-guide-pdf.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Output path:
|
||||||
|
- `docs/USER_GUIDE.pdf` (not committed to git — a release asset, like the
|
||||||
|
platform installers)
|
||||||
|
|||||||
+100
@@ -0,0 +1,100 @@
|
|||||||
|
# Flathub Submission Readiness
|
||||||
|
|
||||||
|
Tracks OrbitHub's readiness for submission to Flathub. This is a separate
|
||||||
|
checklist from `docs/PROGRESS.md`'s development milestones, since Flathub
|
||||||
|
submission is an external process with its own requirements.
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
| Item | Status |
|
||||||
|
|---|---|
|
||||||
|
| Production manifest with pinned, reproducible git source | Done — `packaging/flatpak/flathub/org.darksingularity.OrbitHub.yml`; commit pin updated at each tagged release |
|
||||||
|
| `flathub.json` for build settings (`only-arches`, etc.) | Done — `packaging/flatpak/flathub/flathub.json` |
|
||||||
|
| Offline build (no network fetches during build) | Verified — no `FetchContent`/`ExternalProject`/`curl`/`wget` in CMake; all vendored deps committed in `third_party/`; confirmed with a real `flatpak-builder` build |
|
||||||
|
| SSH client available inside the sandbox | Verified — provided by the `org.kde.Platform` runtime base, no packaging needed |
|
||||||
|
| `--filesystem=home` removed | Done — narrowed to `--filesystem=~/.ssh` (read-write, needed for `known_hosts` and SSH config) |
|
||||||
|
| SSH known-hosts trust persists with narrowed permissions | Verified in sandbox against real infrastructure |
|
||||||
|
| RDP works with zero filesystem permission | Verified — FreeRDP's cert trust store lives outside the sandboxed home path concerns entirely (see below) |
|
||||||
|
| Private-key/export file pickers use the desktop portal | **Verified interactively** — `QFileDialog`'s Browse button correctly opens the native GTK portal chooser ("Select Private Key"), which can browse the full filesystem via user consent regardless of the sandbox's static `~/.ssh`-only grant |
|
||||||
|
| Current, supported KDE runtime | Verified — upgraded to `6.11` (linter's recommended latest); confirmed the app still builds and launches against it |
|
||||||
|
| Desktop entry validates | Verified |
|
||||||
|
| Application icon validates | Verified — PNGs at all standard hicolor sizes, matching the real app icon |
|
||||||
|
| MetaInfo/AppStream validates | Verified via both `appstreamcli validate` and Flathub's own `flatpak-builder-lint appstream` (0 errors either way) |
|
||||||
|
| Screenshots present | Done — profiles view, active SSH session, active RDP session, all captured against real (test) infrastructure |
|
||||||
|
| Release information present | Done — `<releases>` block with `v2026.9.8` (add an entry per future tagged release) |
|
||||||
|
| Developer/project URLs present | Done — homepage, bugtracker, vcs-browser, developer block |
|
||||||
|
| Architecture support decided | `x86_64` only (no ARM hardware available to test FreeRDP/WinPR on aarch64), set via `flathub.json`'s `only-arches` |
|
||||||
|
| Flathub manifest linter passes | One expected finding remains: `finish-args-ssh-filesystem-access` (see below) — everything else passes, including `only-arches` placement and runtime-version currency |
|
||||||
|
| AppStream linter passes | Passing (both `appstreamcli validate` and `flatpak-builder-lint appstream`) |
|
||||||
|
| Clean install works without host dependencies | Verified via local `.flatpak` bundle install and launch, on both KDE 6.10 and 6.11 runtimes |
|
||||||
|
| Bundled-dependency license files installed per Flathub's `$FLATPAK_ID` convention | Partially done — path fixed from `share/licenses/orbithub` to the required `share/licenses/org.darksingularity.OrbitHub`; FreeRDP's and KodoTerm's `LICENSE` files now installed there too. **`libvterm`'s vendored copy has no `LICENSE`/`COPYING` file at all** — needs to be pulled from upstream and added as `third_party/libvterm/LICENSE` before submission (README claims MIT; not verified against an actual license file in-tree) |
|
||||||
|
|
||||||
|
## ⚠️ Not yet addressed: Generative AI disclosure policy is a real acceptance risk, not a checklist item
|
||||||
|
|
||||||
|
See the dedicated section below — unlike everything else on this page, this
|
||||||
|
isn't something more packaging work resolves.
|
||||||
|
|
||||||
|
### `finish-args-ssh-filesystem-access` — expected, needs a submission-time justification
|
||||||
|
|
||||||
|
Flathub's linter flags *any* `~/.ssh` filesystem grant by policy — it's not a
|
||||||
|
bug in this manifest, it's a deliberate prompt for the submitter to justify
|
||||||
|
the access during PR review. Checked the linter's own exceptions list:
|
||||||
|
several existing SSH-client apps already have this exact permission approved
|
||||||
|
with justifications like *"Read-only access to ~/.ssh is required to load
|
||||||
|
SSH keys for connecting to devices over SSH"* and *"Needed to manage SSH keys
|
||||||
|
and configurations for connections"* — OrbitHub's case is the same pattern
|
||||||
|
(read-write, specifically for `known_hosts` persistence and default identity
|
||||||
|
file discovery). Include a similar justification in the submission PR.
|
||||||
|
|
||||||
|
## ⚠️ Not yet addressed: Generative AI disclosure policy
|
||||||
|
|
||||||
|
Flathub's [Generative AI policy](https://docs.flathub.org/docs/for-app-authors/requirements#generative-ai-policy)
|
||||||
|
requires submitters to disclose "any AI-generated code, documentation,
|
||||||
|
packaging, or other material" included in the app or its Flathub packaging,
|
||||||
|
identifying "the affected parts and approximate extent." This is not a
|
||||||
|
formality — it's evaluated at reviewer discretion, and reviewers may reject
|
||||||
|
"based on the extent or role of generated material."
|
||||||
|
|
||||||
|
OrbitHub's development has used Claude Code extensively — the app's C++
|
||||||
|
source, this Flatpak packaging (manifest, metainfo, build scripts), and this
|
||||||
|
tracking doc itself. Every commit in this repository carries a
|
||||||
|
`Co-Authored-By: Claude Sonnet 5` trailer, which is itself effectively an
|
||||||
|
existing disclosure trail. An honest submission disclosure needs to reflect
|
||||||
|
that extent truthfully — not a token "some AI assistance was used" note.
|
||||||
|
|
||||||
|
The same policy also prohibits AI tools from opening or automating the
|
||||||
|
submission PR itself, or generating its commit messages, description, or
|
||||||
|
review replies. **This means the actual submission PR — including its AI
|
||||||
|
disclosure — has to be written and opened by a human, not drafted by
|
||||||
|
Claude.** Not done, and not something this repo's tooling should attempt.
|
||||||
|
|
||||||
|
This is a real acceptance risk that no amount of technical packaging work
|
||||||
|
resolves — it's a policy/reviewer-discretion matter, separate from every
|
||||||
|
other item on this page.
|
||||||
|
|
||||||
|
## Related finding (not a packaging blocker)
|
||||||
|
|
||||||
|
During permission-narrowing research, RDP certificate verification was found
|
||||||
|
to be completely disabled (`IgnoreCertificate=TRUE`, all server certificates
|
||||||
|
silently accepted including *changed* ones). This has been fixed separately
|
||||||
|
in `src/rdp_session_backend.cpp` — FreeRDP's own trust-on-first-use
|
||||||
|
certificate store is now used, matching SSH's known-hosts model. Not a
|
||||||
|
Flathub-specific issue, but worth noting since it was found in the course of
|
||||||
|
this work.
|
||||||
|
|
||||||
|
## Explicitly out of scope for this repo
|
||||||
|
|
||||||
|
- Opening the actual submission PR against `github.com/flathub/flathub` —
|
||||||
|
requires the maintainer's GitHub identity, done outside this repo, and per
|
||||||
|
the Generative AI policy above must be written by a human, not drafted here.
|
||||||
|
- ARM64 build/testing — no hardware available.
|
||||||
|
- Flathub's post-acceptance developer-verification step — done via
|
||||||
|
Flathub's own website after acceptance, using DNS control of
|
||||||
|
`darksingularity.org`.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
- Dev manifest (local iteration, `type: dir`): `packaging/flatpak/org.darksingularity.OrbitHub.yml`
|
||||||
|
- Flathub submission manifest (pinned `type: git`): `packaging/flatpak/flathub/org.darksingularity.OrbitHub.yml`
|
||||||
|
- AppStream metainfo: `packaging/linux/org.darksingularity.OrbitHub.metainfo.xml`
|
||||||
|
- Desktop entry: `packaging/linux/org.darksingularity.OrbitHub.desktop`
|
||||||
@@ -197,6 +197,12 @@ Validation:
|
|||||||
|
|
||||||
Git:
|
Git:
|
||||||
- Tag: `v0-m9-done`
|
- Tag: `v0-m9-done`
|
||||||
|
- Release: [v2026.9.8](https://git.darksingularity.org/DarkSingularity/orbithub/releases/tag/v2026.9.8) (`v2026.9.8` tag, installers for Windows/Linux/macOS)
|
||||||
|
- Release: [v2026.9.8.2](https://git.darksingularity.org/DarkSingularity/orbithub/releases/tag/v2026.9.8.2) — same-day patch fixing RDP TLS certificate verification (was fully disabled) and preparing Flatpak packaging for Flathub submission
|
||||||
|
- Release: [v2026.9.8.3](https://git.darksingularity.org/DarkSingularity/orbithub/releases/tag/v2026.9.8.3) — same-day patch adding an in-app User Guide and standalone User Guide PDF
|
||||||
|
- Release: [v2026.9.14](https://git.darksingularity.org/DarkSingularity/orbithub/releases/tag/v2026.9.14) — fixes distorted RDP text on HiDPI monitors and reduces RDP resize-related display glitches
|
||||||
|
- Release: [v2026.9.14.2](https://git.darksingularity.org/DarkSingularity/orbithub/releases/tag/v2026.9.14.2) — same-day patch fixing RDP display corruption (missing/misplaced taskbar) after resizing the session window (the client never resized its own display buffer for channel-driven RDP resizes)
|
||||||
|
- Release: [v2026.9.15](https://git.darksingularity.org/DarkSingularity/orbithub/releases/tag/v2026.9.15) — adds Import/Export for profile lists (File menu)
|
||||||
|
|
||||||
## Milestone 10 - v1.0 Stabilization
|
## Milestone 10 - v1.0 Stabilization
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,230 @@
|
|||||||
|
## Introduction
|
||||||
|
|
||||||
|
OrbitHub is a native desktop application for organizing connection profiles
|
||||||
|
and launching SSH and RDP 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
|
||||||
|
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.
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
When OrbitHub opens, you land on the **Profiles** tab — a searchable,
|
||||||
|
sortable list of every connection you've saved. Two toolbar controls in
|
||||||
|
the top-right change how that list is presented:
|
||||||
|
|
||||||
|
- **View**: `List` shows every profile in one flat table; `Folders` groups
|
||||||
|
them into the folder tree you've organized them into.
|
||||||
|
- **Sort**: orders the list by Name, Protocol, or Host.
|
||||||
|
|
||||||
|
Along the top of the window, a **Search** box filters by name, host,
|
||||||
|
folder path, or tag as you type, and **Protocol**/**Tag** dropdowns narrow
|
||||||
|
the list further. These filters, plus your last view mode and sort order,
|
||||||
|
are remembered the next time you open OrbitHub.
|
||||||
|
|
||||||
|
Along the bottom of the Profiles tab: **New**, **Edit**, and **Delete**
|
||||||
|
buttons for managing the selected profile. The same actions are available
|
||||||
|
by right-clicking a profile or folder, and from the **File** menu (`New
|
||||||
|
Profile`, `New Folder`).
|
||||||
|
|
||||||
|
To connect, double-click a profile, or select it and press the connect
|
||||||
|
control — this opens a new tab for that session and connects
|
||||||
|
automatically.
|
||||||
|
|
||||||
|
## Managing Profiles
|
||||||
|
|
||||||
|
A profile stores everything needed to reach one remote host. Open **New**
|
||||||
|
(or **Edit** on an existing profile) to fill in:
|
||||||
|
|
||||||
|
- **Name** — a label for the profile; shown on its tab and in the list.
|
||||||
|
- **Host** — hostname or IP address.
|
||||||
|
- **Port** — defaults to `22` for SSH, `3389` for RDP.
|
||||||
|
- **Username** — the account to log in as.
|
||||||
|
- **Domain** — Windows domain for RDP logins (leave blank for local/
|
||||||
|
workgroup accounts or SSH 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).
|
||||||
|
|
||||||
|
Fields below Protocol change based on what you pick:
|
||||||
|
|
||||||
|
**SSH**: **Auth Mode** (`Password` or `Private Key`), and if Private Key,
|
||||||
|
a **Private Key** file path with a **Browse** button, plus **Known Hosts**
|
||||||
|
policy — see [Connecting via SSH](#connecting-via-ssh) for what each
|
||||||
|
policy means.
|
||||||
|
|
||||||
|
**RDP**: **RDP Security** and **RDP Performance** — see
|
||||||
|
[Connecting via RDP](#connecting-via-rdp).
|
||||||
|
|
||||||
|
Passwords are never saved in the profile — OrbitHub asks for them each
|
||||||
|
time you connect (unless you've set up private-key SSH auth, which needs
|
||||||
|
no password to be entered per-connection if the key itself has none).
|
||||||
|
|
||||||
|
## Organizing Profiles
|
||||||
|
|
||||||
|
As your profile list grows, two independent tools keep it manageable:
|
||||||
|
|
||||||
|
**Folders.** Switch the Profiles tab to `Folders` view to see profiles
|
||||||
|
grouped into a tree. Create a folder from the **File** menu or by
|
||||||
|
right-clicking in the tree (`New Folder`), and drag any profile onto a
|
||||||
|
folder to move it there. Folders can nest inside other folders.
|
||||||
|
|
||||||
|
**Tags.** Tags are independent of folders — a profile can be in one folder
|
||||||
|
but carry several tags (e.g. `prod`, `linux`, `db` all at once). Use the
|
||||||
|
**Tag** filter dropdown in the toolbar to instantly narrow the list to
|
||||||
|
everything sharing a tag, regardless of which folder it's filed under.
|
||||||
|
|
||||||
|
Combine both with the **Search** box (matches name, host, folder path, or
|
||||||
|
tags) and the **Sort** control (Name / Protocol / Host) to find what you
|
||||||
|
need quickly even with a large profile list.
|
||||||
|
|
||||||
|
## Connecting via SSH
|
||||||
|
|
||||||
|
Double-clicking an SSH profile opens a new tab with an embedded, fully
|
||||||
|
interactive terminal — type directly into it as you would any terminal
|
||||||
|
emulator. A **theme** selector lets you switch between `Dark`, `Light`,
|
||||||
|
and `Solarized Dark`; your choice is remembered for future sessions.
|
||||||
|
|
||||||
|
**Authentication.** Set in the profile itself:
|
||||||
|
- **Password** — OrbitHub prompts for a password each time you connect.
|
||||||
|
It is never stored.
|
||||||
|
- **Private Key** — point at a key file (via the profile's Browse button);
|
||||||
|
no password prompt unless the key itself is passphrase-protected.
|
||||||
|
|
||||||
|
**Known Hosts policy.** This controls how OrbitHub reacts to a server's
|
||||||
|
SSH host key — the mechanism that protects against a different machine
|
||||||
|
silently impersonating a host you've connected to before:
|
||||||
|
|
||||||
|
| Policy | Behavior |
|
||||||
|
|---|---|
|
||||||
|
| `Ask` | Prompts you to confirm trust the first time a host is seen, and on any later change. Recommended default. |
|
||||||
|
| `Accept-new` | Silently trusts a host the first time it's seen, but still stops and warns if a previously-trusted host's key later changes. |
|
||||||
|
| `Strict` | Never trusts an unknown host automatically — the connection fails until you've manually confirmed the host key some other way. |
|
||||||
|
| `Ignore` | Skips host-key checking entirely. Only use this for throwaway/test environments — it removes protection against on-path attacks. |
|
||||||
|
|
||||||
|
Trusted host keys are recorded in your system's normal SSH `known_hosts`
|
||||||
|
file (the same one the `ssh` command line tool uses), so trust decisions
|
||||||
|
made through OrbitHub or a terminal `ssh` session carry over to each
|
||||||
|
other.
|
||||||
|
|
||||||
|
## Connecting via RDP
|
||||||
|
|
||||||
|
RDP sessions render in an embedded display surface inside the tab — no
|
||||||
|
external RDP client window opens. Keyboard and mouse input go straight to
|
||||||
|
the remote desktop while the tab has focus, and resizing the OrbitHub
|
||||||
|
window renegotiates the remote resolution to match. Clipboard content
|
||||||
|
syncs between your machine and the remote session automatically.
|
||||||
|
|
||||||
|
**Authentication** uses the profile's Username/Domain fields; OrbitHub
|
||||||
|
prompts for the password at connect time.
|
||||||
|
|
||||||
|
**RDP Security** controls which transport-security layer is used to
|
||||||
|
negotiate the connection:
|
||||||
|
|
||||||
|
| Mode | Behavior |
|
||||||
|
|---|---|
|
||||||
|
| `Negotiate` | Lets the client and server agree on the strongest mutually-supported option automatically. Recommended default. |
|
||||||
|
| `NLA` | Requires Network Level Authentication (credentials verified before a full session starts) — the modern standard for current Windows versions. |
|
||||||
|
| `TLS` | Requires TLS-only security, without NLA. |
|
||||||
|
| `RDP` | The legacy RDP-native security layer, for older servers that don't support TLS/NLA. |
|
||||||
|
|
||||||
|
**RDP Performance** trades visual fidelity for responsiveness:
|
||||||
|
`Balanced` (default), `Best Quality`, `Best Performance`, or
|
||||||
|
`Auto Detect` (adapts based on the detected connection).
|
||||||
|
|
||||||
|
**Server certificate verification.** The first time you connect to an RDP
|
||||||
|
host, OrbitHub trusts and remembers its TLS certificate — the same
|
||||||
|
trust-on-first-use model SSH uses for host keys. If that certificate ever
|
||||||
|
changes on a later connection, OrbitHub refuses the connection rather than
|
||||||
|
connecting anyway, since a changed certificate can mean either a
|
||||||
|
legitimate server certificate renewal or an active
|
||||||
|
machine-in-the-middle presenting a different one. The event log (see
|
||||||
|
below) shows the specific fingerprints involved. If the change is
|
||||||
|
expected — you rotated the server's certificate yourself — reconnecting
|
||||||
|
after clearing the old entry from FreeRDP's certificate store will trust
|
||||||
|
the new one.
|
||||||
|
|
||||||
|
## Managing Sessions
|
||||||
|
|
||||||
|
Every open connection lives in its own tab in the same window, alongside
|
||||||
|
the Profiles tab. A tab's title and a colored state indicator show
|
||||||
|
whether it's connecting, connected, disconnected, or failed. Closing a
|
||||||
|
tab disconnects that session; opening a profile again starts a fresh one.
|
||||||
|
|
||||||
|
Each session tab includes a collapsible **event log** beneath the
|
||||||
|
connection surface — a timestamped record of connection state changes,
|
||||||
|
warnings, and errors for that session. Controls above the log let you:
|
||||||
|
|
||||||
|
- **Show/Hide Events** — collapse the panel when you don't need it.
|
||||||
|
- **Filter** — a text box to search event text, and an `All` /
|
||||||
|
`Warnings` / `Errors` severity dropdown to narrow what's shown.
|
||||||
|
- **Export Events** — save the current session's full event log to a
|
||||||
|
file, useful when reporting a connection problem.
|
||||||
|
- **Clear Events** — empty the log for that tab.
|
||||||
|
|
||||||
|
## Settings & Preferences
|
||||||
|
|
||||||
|
OrbitHub remembers your preferences across restarts without any separate
|
||||||
|
settings screen — they're saved automatically as you use the app:
|
||||||
|
|
||||||
|
- Profile list: search text, view mode (List/Folders), Protocol/Tag
|
||||||
|
filters, and sort order.
|
||||||
|
- Session tabs: terminal theme choice, and whether the events panel is
|
||||||
|
shown or hidden for new tabs.
|
||||||
|
|
||||||
|
Profile data itself (names, hosts, tags, folder structure, and so on) is
|
||||||
|
stored in a local SQLite database — see the README for its exact path on
|
||||||
|
your platform. Passwords are never part of that stored data.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
**"Host could not be resolved"** — the hostname in the profile can't be
|
||||||
|
looked up by DNS. Check for typos, or try the host's IP address directly
|
||||||
|
to confirm whether it's a DNS problem or something else.
|
||||||
|
|
||||||
|
**SSH connection fails immediately, no prompt** — double-check the
|
||||||
|
profile's Port (default `22`) and that a firewall or network path isn't
|
||||||
|
blocking that port from your machine.
|
||||||
|
|
||||||
|
**RDP: "Authentication failed. Check username and password."** — confirm
|
||||||
|
Username and Domain are correct for the target server; some servers
|
||||||
|
require the domain to be set explicitly even for local accounts.
|
||||||
|
|
||||||
|
**RDP: "RDP security negotiation failed. Try a different RDP security
|
||||||
|
mode."** — the server doesn't support the security mode selected in the
|
||||||
|
profile. Try `Negotiate` first, or a more specific mode if you know what
|
||||||
|
the server requires.
|
||||||
|
|
||||||
|
**RDP: connection refused with a certificate-changed message** — see
|
||||||
|
[Connecting via RDP](#connecting-via-rdp) above; this is expected,
|
||||||
|
protective behavior, not a bug, whenever a previously-trusted server's
|
||||||
|
certificate is replaced.
|
||||||
|
|
||||||
|
**SSH: connection hangs at "Ask" waiting for host-key trust** — check
|
||||||
|
that policy's behavior under
|
||||||
|
[Connecting via SSH](#connecting-via-ssh); switching to `Accept-new` avoids
|
||||||
|
the prompt for genuinely new hosts while still protecting against a later
|
||||||
|
key change.
|
||||||
|
|
||||||
|
If none of this covers what you're seeing, a session's exported event log
|
||||||
|
(see Managing Sessions) is the most useful thing to include when asking
|
||||||
|
for help or filing an issue.
|
||||||
|
|
||||||
|
## About & Support
|
||||||
|
|
||||||
|
OrbitHub is open source under the MIT license. Source code, issue
|
||||||
|
tracking, and releases are hosted at
|
||||||
|
[git.darksingularity.org/DarkSingularity/orbithub](https://git.darksingularity.org/DarkSingularity/orbithub).
|
||||||
|
|
||||||
|
For a list of bundled third-party libraries and their licenses, see
|
||||||
|
**Help → About OrbitHub** inside the app.
|
||||||
|
|
||||||
|
To report a bug or request a feature, open an issue at
|
||||||
|
[git.darksingularity.org/DarkSingularity/orbithub/issues](https://git.darksingularity.org/DarkSingularity/orbithub/issues).
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 44 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 100 KiB |
@@ -0,0 +1,5 @@
|
|||||||
|
<RCC>
|
||||||
|
<qresource prefix="/docs">
|
||||||
|
<file>USER_GUIDE.md</file>
|
||||||
|
</qresource>
|
||||||
|
</RCC>
|
||||||
Executable
+15
@@ -0,0 +1,15 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||||
|
BUILD_DIR="${1:-$ROOT_DIR/build-doc-tool}"
|
||||||
|
OUTPUT_PATH="${2:-$ROOT_DIR/docs/USER_GUIDE.pdf}"
|
||||||
|
|
||||||
|
cmake -S "$ROOT_DIR/tools/user-guide-pdf" -B "$BUILD_DIR" -G Ninja
|
||||||
|
cmake --build "$BUILD_DIR"
|
||||||
|
|
||||||
|
QT_QPA_PLATFORM=offscreen "$BUILD_DIR/user-guide-pdf" \
|
||||||
|
"$ROOT_DIR/docs/USER_GUIDE.md" \
|
||||||
|
"$OUTPUT_PATH"
|
||||||
|
|
||||||
|
echo "Created $OUTPUT_PATH"
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"only-arches": ["x86_64"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
app-id: org.darksingularity.OrbitHub
|
||||||
|
runtime: org.kde.Platform
|
||||||
|
runtime-version: "6.11"
|
||||||
|
sdk: org.kde.Sdk
|
||||||
|
command: orbithub
|
||||||
|
finish-args:
|
||||||
|
- --share=network
|
||||||
|
- --share=ipc
|
||||||
|
- --socket=fallback-x11
|
||||||
|
- --socket=wayland
|
||||||
|
- --device=dri
|
||||||
|
- --filesystem=~/.ssh
|
||||||
|
modules:
|
||||||
|
- name: orbithub
|
||||||
|
buildsystem: cmake-ninja
|
||||||
|
builddir: true
|
||||||
|
config-opts:
|
||||||
|
- -DCMAKE_BUILD_TYPE=Release
|
||||||
|
sources:
|
||||||
|
- type: git
|
||||||
|
url: https://git.darksingularity.org/DarkSingularity/orbithub.git
|
||||||
|
tag: v2026.9.15
|
||||||
|
commit: dffca3afef80b5a3ca4832e0b7f748775cb90328
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
app-id: org.darksingularity.OrbitHub
|
app-id: org.darksingularity.OrbitHub
|
||||||
runtime: org.kde.Platform
|
runtime: org.kde.Platform
|
||||||
runtime-version: "6.10"
|
runtime-version: "6.11"
|
||||||
sdk: org.kde.Sdk
|
sdk: org.kde.Sdk
|
||||||
command: orbithub
|
command: orbithub
|
||||||
finish-args:
|
finish-args:
|
||||||
@@ -9,7 +9,7 @@ finish-args:
|
|||||||
- --socket=fallback-x11
|
- --socket=fallback-x11
|
||||||
- --socket=wayland
|
- --socket=wayland
|
||||||
- --device=dri
|
- --device=dri
|
||||||
- --filesystem=home
|
- --filesystem=~/.ssh
|
||||||
modules:
|
modules:
|
||||||
- name: orbithub
|
- name: orbithub
|
||||||
buildsystem: cmake-ninja
|
buildsystem: cmake-ninja
|
||||||
|
|||||||
@@ -17,6 +17,11 @@ mkdir -p "$DIST_DIR"
|
|||||||
rm -rf "$STAGE_DIR"
|
rm -rf "$STAGE_DIR"
|
||||||
mkdir -p "$PKG_ROOT/DEBIAN"
|
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)"
|
VERSION="$(sed -n 's/^CMAKE_PROJECT_VERSION:STATIC=//p' "$BUILD_DIR/CMakeCache.txt" | head -n1)"
|
||||||
ARCH="$(dpkg --print-architecture)"
|
ARCH="$(dpkg --print-architecture)"
|
||||||
|
|
||||||
@@ -25,7 +30,6 @@ if [[ -z "$VERSION" ]]; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
cmake --build "$BUILD_DIR" -j
|
|
||||||
cmake --install "$BUILD_DIR" --prefix "$PKG_ROOT/usr"
|
cmake --install "$BUILD_DIR" --prefix "$PKG_ROOT/usr"
|
||||||
|
|
||||||
cat > "$PKG_ROOT/DEBIAN/control" <<EOF
|
cat > "$PKG_ROOT/DEBIAN/control" <<EOF
|
||||||
|
|||||||
@@ -10,7 +10,62 @@
|
|||||||
</description>
|
</description>
|
||||||
<launchable type="desktop-id">org.darksingularity.OrbitHub.desktop</launchable>
|
<launchable type="desktop-id">org.darksingularity.OrbitHub.desktop</launchable>
|
||||||
<url type="homepage">https://git.darksingularity.org/DarkSingularity/orbithub</url>
|
<url type="homepage">https://git.darksingularity.org/DarkSingularity/orbithub</url>
|
||||||
|
<url type="bugtracker">https://git.darksingularity.org/DarkSingularity/orbithub/issues</url>
|
||||||
|
<url type="vcs-browser">https://git.darksingularity.org/DarkSingularity/orbithub</url>
|
||||||
|
<developer id="org.darksingularity">
|
||||||
|
<name>DarkSingularity</name>
|
||||||
|
</developer>
|
||||||
<provides>
|
<provides>
|
||||||
<binary>orbithub</binary>
|
<binary>orbithub</binary>
|
||||||
</provides>
|
</provides>
|
||||||
|
<content_rating type="oars-1.1" />
|
||||||
|
<screenshots>
|
||||||
|
<screenshot type="default">
|
||||||
|
<caption>Profiles organized into folders</caption>
|
||||||
|
<image>https://git.darksingularity.org/DarkSingularity/orbithub/raw/branch/main/docs/images/screenshot-profiles.png</image>
|
||||||
|
</screenshot>
|
||||||
|
<screenshot>
|
||||||
|
<caption>Active SSH terminal session</caption>
|
||||||
|
<image>https://git.darksingularity.org/DarkSingularity/orbithub/raw/branch/main/docs/images/screenshot-ssh-session.png</image>
|
||||||
|
</screenshot>
|
||||||
|
<screenshot>
|
||||||
|
<caption>Active RDP session</caption>
|
||||||
|
<image>https://git.darksingularity.org/DarkSingularity/orbithub/raw/branch/main/docs/images/screenshot-rdp-session.png</image>
|
||||||
|
</screenshot>
|
||||||
|
</screenshots>
|
||||||
|
<releases>
|
||||||
|
<release version="2026.9.15" date="2026-09-15">
|
||||||
|
<url>https://git.darksingularity.org/DarkSingularity/orbithub/releases/tag/v2026.9.15</url>
|
||||||
|
<description>
|
||||||
|
<p>Adds Import/Export for profile lists (File menu).</p>
|
||||||
|
</description>
|
||||||
|
</release>
|
||||||
|
<release version="2026.9.14.2" date="2026-09-14">
|
||||||
|
<url>https://git.darksingularity.org/DarkSingularity/orbithub/releases/tag/v2026.9.14.2</url>
|
||||||
|
<description>
|
||||||
|
<p>Fixes RDP display corruption (missing/misplaced taskbar, stale composited content) after resizing the session window.</p>
|
||||||
|
</description>
|
||||||
|
</release>
|
||||||
|
<release version="2026.9.14" date="2026-09-14">
|
||||||
|
<url>https://git.darksingularity.org/DarkSingularity/orbithub/releases/tag/v2026.9.14</url>
|
||||||
|
<description>
|
||||||
|
<p>Fixes distorted RDP text on HiDPI monitors, and reduces RDP resize-related display glitches.</p>
|
||||||
|
</description>
|
||||||
|
</release>
|
||||||
|
<release version="2026.9.8.3" date="2026-09-08">
|
||||||
|
<url>https://git.darksingularity.org/DarkSingularity/orbithub/releases/tag/v2026.9.8.3</url>
|
||||||
|
<description>
|
||||||
|
<p>Adds an in-app User Guide (Help -> User Guide) and a standalone User Guide PDF.</p>
|
||||||
|
</description>
|
||||||
|
</release>
|
||||||
|
<release version="2026.9.8.2" date="2026-09-08">
|
||||||
|
<url>https://git.darksingularity.org/DarkSingularity/orbithub/releases/tag/v2026.9.8.2</url>
|
||||||
|
<description>
|
||||||
|
<p>Fixes RDP connections silently accepting any server TLS certificate, including changed ones. Certificates are now verified with trust-on-first-use, matching SSH's known-hosts behavior.</p>
|
||||||
|
</description>
|
||||||
|
</release>
|
||||||
|
<release version="2026.9.8" date="2026-09-08">
|
||||||
|
<url>https://git.darksingularity.org/DarkSingularity/orbithub/releases/tag/v2026.9.8</url>
|
||||||
|
</release>
|
||||||
|
</releases>
|
||||||
</component>
|
</component>
|
||||||
|
|||||||
@@ -14,12 +14,6 @@ if [[ ! -f "$BUILD_DIR/CMakeCache.txt" ]]; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
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"
|
MACDEPLOYQT="$(brew --prefix qt@6)/bin/macdeployqt"
|
||||||
if [[ ! -x "$MACDEPLOYQT" ]]; then
|
if [[ ! -x "$MACDEPLOYQT" ]]; then
|
||||||
echo "macdeployqt not found at $MACDEPLOYQT" >&2
|
echo "macdeployqt not found at $MACDEPLOYQT" >&2
|
||||||
@@ -31,7 +25,17 @@ mkdir -p "$DIST_DIR"
|
|||||||
rm -rf "$STAGE_DIR" "$INSTALL_PREFIX"
|
rm -rf "$STAGE_DIR" "$INSTALL_PREFIX"
|
||||||
mkdir -p "$STAGE_DIR"
|
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
|
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"
|
cmake --install "$BUILD_DIR" --prefix "$INSTALL_PREFIX"
|
||||||
|
|
||||||
if [[ ! -d "$INSTALL_PREFIX/$APP_BUNDLE" ]]; then
|
if [[ ! -d "$INSTALL_PREFIX/$APP_BUNDLE" ]]; then
|
||||||
|
|||||||
+27
-2
@@ -8,6 +8,31 @@
|
|||||||
#include <QTextBrowser>
|
#include <QTextBrowser>
|
||||||
#include <QVBoxLayout>
|
#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)
|
AboutDialog::AboutDialog(QWidget* parent) : QDialog(parent)
|
||||||
{
|
{
|
||||||
setWindowTitle(QStringLiteral("About OrbitHub"));
|
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."),
|
QStringLiteral("Unified remote session manager for SSH, RDP, and VNC workflows."),
|
||||||
this);
|
this);
|
||||||
subtitle->setWordWrap(true);
|
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()
|
const QString version = QCoreApplication::applicationVersion().trimmed().isEmpty()
|
||||||
? QStringLiteral("Development build")
|
? QStringLiteral("Development build")
|
||||||
@@ -42,7 +67,7 @@ AboutDialog::AboutDialog(QWidget* parent) : QDialog(parent)
|
|||||||
auto* buildLine = new QLabel(
|
auto* buildLine = new QLabel(
|
||||||
QStringLiteral("Version: %1 | Qt runtime linked dynamically").arg(version),
|
QStringLiteral("Version: %1 | Qt runtime linked dynamically").arg(version),
|
||||||
this);
|
this);
|
||||||
buildLine->setStyleSheet(QStringLiteral("color: palette(mid);"));
|
buildLine->setStyleSheet(QStringLiteral("color: %1;").arg(mutedTextColor(this).name()));
|
||||||
|
|
||||||
titleColumn->addWidget(title);
|
titleColumn->addWidget(title);
|
||||||
titleColumn->addWidget(subtitle);
|
titleColumn->addWidget(subtitle);
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ int main(int argc, char* argv[])
|
|||||||
QApplication app(argc, argv);
|
QApplication app(argc, argv);
|
||||||
app.setOrganizationName(QStringLiteral("FireBugIT"));
|
app.setOrganizationName(QStringLiteral("FireBugIT"));
|
||||||
app.setApplicationName(QStringLiteral("OrbitHub"));
|
app.setApplicationName(QStringLiteral("OrbitHub"));
|
||||||
|
#ifdef ORBITHUB_VERSION_STRING
|
||||||
|
app.setApplicationVersion(QStringLiteral(ORBITHUB_VERSION_STRING));
|
||||||
|
#endif
|
||||||
app.setWindowIcon(createOrbitHubAppIcon());
|
app.setWindowIcon(createOrbitHubAppIcon());
|
||||||
|
|
||||||
SessionWindow window;
|
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
|
||||||
+134
-2
@@ -9,7 +9,11 @@
|
|||||||
#include <QVariant>
|
#include <QVariant>
|
||||||
#include <QStringList>
|
#include <QStringList>
|
||||||
|
|
||||||
|
#include <atomic>
|
||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
|
std::atomic<int> g_testConnectionCounter{0};
|
||||||
|
|
||||||
QString buildDatabasePath()
|
QString buildDatabasePath()
|
||||||
{
|
{
|
||||||
QString appDataPath = QStandardPaths::writableLocation(QStandardPaths::AppDataLocation);
|
QString appDataPath = QStandardPaths::writableLocation(QStandardPaths::AppDataLocation);
|
||||||
@@ -176,7 +180,7 @@ void bindProfileFields(QSqlQuery& query, const Profile& profile)
|
|||||||
: QStringLiteral("Negotiate"));
|
: QStringLiteral("Negotiate"));
|
||||||
query.addBindValue(isRdp ? normalizedRdpPerformanceProfile(profile.rdpPerformanceProfile)
|
query.addBindValue(isRdp ? normalizedRdpPerformanceProfile(profile.rdpPerformanceProfile)
|
||||||
: QStringLiteral("Balanced"));
|
: QStringLiteral("Balanced"));
|
||||||
query.addBindValue(normalizedTags(profile.tags));
|
query.addBindValue(nonNullTrimmed(normalizedTags(profile.tags)));
|
||||||
}
|
}
|
||||||
|
|
||||||
Profile profileFromQuery(const QSqlQuery& query)
|
Profile profileFromQuery(const QSqlQuery& query)
|
||||||
@@ -259,6 +263,16 @@ ProfileRepository::ProfileRepository() : m_connectionName(QStringLiteral("orbith
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ProfileRepository::ProfileRepository(const QString& databasePathOverride)
|
||||||
|
: m_connectionName(QStringLiteral("orbithub_test_%1")
|
||||||
|
.arg(g_testConnectionCounter.fetch_add(1))),
|
||||||
|
m_databasePathOverride(databasePathOverride)
|
||||||
|
{
|
||||||
|
if (!initializeDatabase()) {
|
||||||
|
QSqlDatabase::removeDatabase(m_connectionName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
ProfileRepository::~ProfileRepository()
|
ProfileRepository::~ProfileRepository()
|
||||||
{
|
{
|
||||||
if (QSqlDatabase::contains(m_connectionName)) {
|
if (QSqlDatabase::contains(m_connectionName)) {
|
||||||
@@ -332,6 +346,123 @@ bool ProfileRepository::createFolder(const QString& folderPath) const
|
|||||||
return true;
|
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,
|
std::vector<Profile> ProfileRepository::listProfiles(const QString& searchQuery,
|
||||||
ProfileSortOrder sortOrder) const
|
ProfileSortOrder sortOrder) const
|
||||||
{
|
{
|
||||||
@@ -485,7 +616,8 @@ bool ProfileRepository::deleteProfile(qint64 id) const
|
|||||||
bool ProfileRepository::initializeDatabase()
|
bool ProfileRepository::initializeDatabase()
|
||||||
{
|
{
|
||||||
QSqlDatabase database = QSqlDatabase::addDatabase(QStringLiteral("QSQLITE"), m_connectionName);
|
QSqlDatabase database = QSqlDatabase::addDatabase(QStringLiteral("QSQLITE"), m_connectionName);
|
||||||
database.setDatabaseName(buildDatabasePath());
|
database.setDatabaseName(
|
||||||
|
m_databasePathOverride.isEmpty() ? buildDatabasePath() : m_databasePathOverride);
|
||||||
|
|
||||||
if (!database.open()) {
|
if (!database.open()) {
|
||||||
m_initError = database.lastError().text();
|
m_initError = database.lastError().text();
|
||||||
|
|||||||
@@ -35,6 +35,9 @@ class ProfileRepository
|
|||||||
{
|
{
|
||||||
public:
|
public:
|
||||||
ProfileRepository();
|
ProfileRepository();
|
||||||
|
// databasePathOverride lets tests point the repository at an isolated,
|
||||||
|
// disposable SQLite file instead of the real app-data location.
|
||||||
|
explicit ProfileRepository(const QString& databasePathOverride);
|
||||||
~ProfileRepository();
|
~ProfileRepository();
|
||||||
|
|
||||||
QString initError() const;
|
QString initError() const;
|
||||||
@@ -44,6 +47,7 @@ public:
|
|||||||
ProfileSortOrder sortOrder = ProfileSortOrder::NameAsc) const;
|
ProfileSortOrder sortOrder = ProfileSortOrder::NameAsc) const;
|
||||||
std::vector<QString> listFolders() const;
|
std::vector<QString> listFolders() const;
|
||||||
bool createFolder(const QString& folderPath) const;
|
bool createFolder(const QString& folderPath) const;
|
||||||
|
bool deleteFolder(const QString& folderPath) const;
|
||||||
std::optional<Profile> getProfile(qint64 id) const;
|
std::optional<Profile> getProfile(qint64 id) const;
|
||||||
std::optional<Profile> createProfile(const Profile& profile) const;
|
std::optional<Profile> createProfile(const Profile& profile) const;
|
||||||
bool updateProfile(const Profile& profile) const;
|
bool updateProfile(const Profile& profile) const;
|
||||||
@@ -51,6 +55,7 @@ public:
|
|||||||
|
|
||||||
private:
|
private:
|
||||||
QString m_connectionName;
|
QString m_connectionName;
|
||||||
|
QString m_databasePathOverride;
|
||||||
QString m_initError;
|
QString m_initError;
|
||||||
mutable QString m_lastError;
|
mutable QString m_lastError;
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
#include "profiles_window.h"
|
#include "profiles_window.h"
|
||||||
|
|
||||||
|
#include "mremoteng_importer.h"
|
||||||
#include "profile_dialog.h"
|
#include "profile_dialog.h"
|
||||||
#include "profile_repository.h"
|
#include "profile_repository.h"
|
||||||
#include "profiles_tree_widget.h"
|
#include "profiles_tree_widget.h"
|
||||||
@@ -7,8 +8,13 @@
|
|||||||
#include <QAction>
|
#include <QAction>
|
||||||
#include <QAbstractItemView>
|
#include <QAbstractItemView>
|
||||||
#include <QComboBox>
|
#include <QComboBox>
|
||||||
|
#include <QFile>
|
||||||
|
#include <QFileDialog>
|
||||||
#include <QHeaderView>
|
#include <QHeaderView>
|
||||||
#include <QHBoxLayout>
|
#include <QHBoxLayout>
|
||||||
|
#include <QJsonArray>
|
||||||
|
#include <QJsonDocument>
|
||||||
|
#include <QJsonObject>
|
||||||
#include <QLabel>
|
#include <QLabel>
|
||||||
#include <QLineEdit>
|
#include <QLineEdit>
|
||||||
#include <QInputDialog>
|
#include <QInputDialog>
|
||||||
@@ -85,6 +91,53 @@ bool profileHasTag(const Profile& profile, const QString& requestedTag)
|
|||||||
|
|
||||||
return false;
|
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)
|
ProfilesWindow::ProfilesWindow(QWidget* parent)
|
||||||
@@ -535,6 +588,8 @@ void ProfilesWindow::showTreeContextMenu(const QPoint& pos)
|
|||||||
|
|
||||||
const QString contextFolder = folderPathForItem(item);
|
const QString contextFolder = folderPathForItem(item);
|
||||||
const bool isProfileItem = item != nullptr && item->data(0, kProfileIdRole).isValid();
|
const bool isProfileItem = item != nullptr && item->data(0, kProfileIdRole).isValid();
|
||||||
|
const bool isFolderItem =
|
||||||
|
item != nullptr && !isProfileItem && item->data(0, kFolderPathRole).isValid();
|
||||||
|
|
||||||
QMenu menu(this);
|
QMenu menu(this);
|
||||||
QAction* newConnectionAction = menu.addAction(QStringLiteral("New Connection"));
|
QAction* newConnectionAction = menu.addAction(QStringLiteral("New Connection"));
|
||||||
@@ -542,12 +597,16 @@ void ProfilesWindow::showTreeContextMenu(const QPoint& pos)
|
|||||||
QAction* connectAction = nullptr;
|
QAction* connectAction = nullptr;
|
||||||
QAction* editAction = nullptr;
|
QAction* editAction = nullptr;
|
||||||
QAction* deleteAction = nullptr;
|
QAction* deleteAction = nullptr;
|
||||||
|
QAction* deleteFolderAction = nullptr;
|
||||||
|
|
||||||
if (isProfileItem) {
|
if (isProfileItem) {
|
||||||
menu.addSeparator();
|
menu.addSeparator();
|
||||||
connectAction = menu.addAction(QStringLiteral("Connect"));
|
connectAction = menu.addAction(QStringLiteral("Connect"));
|
||||||
editAction = menu.addAction(QStringLiteral("Edit"));
|
editAction = menu.addAction(QStringLiteral("Edit"));
|
||||||
deleteAction = menu.addAction(QStringLiteral("Delete"));
|
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));
|
QAction* chosen = menu.exec(m_profilesTree->viewport()->mapToGlobal(pos));
|
||||||
@@ -577,6 +636,10 @@ void ProfilesWindow::showTreeContextMenu(const QPoint& pos)
|
|||||||
deleteSelectedProfile();
|
deleteSelectedProfile();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (isFolderItem && chosen == deleteFolderAction) {
|
||||||
|
deleteFolderInContext(contextFolder);
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void ProfilesWindow::createFolderInContext(const QString& baseFolderPath)
|
void ProfilesWindow::createFolderInContext(const QString& baseFolderPath)
|
||||||
@@ -614,6 +677,72 @@ void ProfilesWindow::createFolderInContext(const QString& baseFolderPath)
|
|||||||
loadProfiles();
|
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()
|
void ProfilesWindow::persistFolderAssignmentsFromTree()
|
||||||
{
|
{
|
||||||
if (!isFolderViewEnabled() || m_profilesTree == nullptr) {
|
if (!isFolderViewEnabled() || m_profilesTree == nullptr) {
|
||||||
@@ -930,3 +1059,197 @@ void ProfilesWindow::createFolderInCurrentContext()
|
|||||||
const QString folderPath = folderPathForItem(m_profilesTree->currentItem());
|
const QString folderPath = folderPathForItem(m_profilesTree->currentItem());
|
||||||
createFolderInContext(folderPath);
|
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 createProfileInCurrentContext();
|
||||||
void createFolderInCurrentContext();
|
void createFolderInCurrentContext();
|
||||||
|
void exportProfiles();
|
||||||
|
void importProfiles();
|
||||||
|
void importFromMRemoteNG();
|
||||||
|
|
||||||
signals:
|
signals:
|
||||||
void connectRequested(const Profile& profile);
|
void connectRequested(const Profile& profile);
|
||||||
@@ -63,6 +66,7 @@ private:
|
|||||||
QString folderPathForItem(const QTreeWidgetItem* item) const;
|
QString folderPathForItem(const QTreeWidgetItem* item) const;
|
||||||
void showTreeContextMenu(const QPoint& pos);
|
void showTreeContextMenu(const QPoint& pos);
|
||||||
void createFolderInContext(const QString& baseFolderPath);
|
void createFolderInContext(const QString& baseFolderPath);
|
||||||
|
void deleteFolderInContext(const QString& folderPath);
|
||||||
void persistFolderAssignmentsFromTree();
|
void persistFolderAssignmentsFromTree();
|
||||||
void collectProfileAssignments(const QTreeWidgetItem* item,
|
void collectProfileAssignments(const QTreeWidgetItem* item,
|
||||||
const QString& parentFolderPath,
|
const QString& parentFolderPath,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
#include "rdp_display_widget.h"
|
#include "rdp_display_widget.h"
|
||||||
|
|
||||||
#include <QCursor>
|
#include <QCursor>
|
||||||
|
#include <QEvent>
|
||||||
#include <QKeyEvent>
|
#include <QKeyEvent>
|
||||||
#include <QMouseEvent>
|
#include <QMouseEvent>
|
||||||
#include <QPainter>
|
#include <QPainter>
|
||||||
@@ -15,20 +16,38 @@ QSize sanitizeSize(const QSize& size)
|
|||||||
{
|
{
|
||||||
return QSize(qMax(1, size.width()), qMax(1, size.height()));
|
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)
|
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);
|
setFocusPolicy(Qt::StrongFocus);
|
||||||
setMouseTracking(true);
|
setMouseTracking(true);
|
||||||
setAutoFillBackground(false);
|
setAutoFillBackground(false);
|
||||||
setMinimumSize(320, 200);
|
setMinimumSize(320, 200);
|
||||||
|
|
||||||
QTimer::singleShot(0, this, [this]() {
|
m_resizeDebounceTimer->setSingleShot(true);
|
||||||
const QSize size = sanitizeSize(this->size());
|
connect(m_resizeDebounceTimer, &QTimer::timeout, this, &RdpDisplayWidget::emitViewportGeometry);
|
||||||
emit viewportSizeChanged(size.width(), size.height());
|
|
||||||
});
|
scheduleViewportGeometryEmit();
|
||||||
}
|
}
|
||||||
|
|
||||||
void RdpDisplayWidget::setFrame(const QImage& frame)
|
void RdpDisplayWidget::setFrame(const QImage& frame)
|
||||||
@@ -54,6 +73,13 @@ void RdpDisplayWidget::setRemoteDesktopSize(int width, int height)
|
|||||||
}
|
}
|
||||||
|
|
||||||
m_remoteSize = nextSize;
|
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();
|
update();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,11 +166,39 @@ void RdpDisplayWidget::paintEvent(QPaintEvent* event)
|
|||||||
void RdpDisplayWidget::resizeEvent(QResizeEvent* event)
|
void RdpDisplayWidget::resizeEvent(QResizeEvent* event)
|
||||||
{
|
{
|
||||||
QWidget::resizeEvent(event);
|
QWidget::resizeEvent(event);
|
||||||
const QSize size = sanitizeSize(event->size());
|
scheduleViewportGeometryEmit();
|
||||||
emit viewportSizeChanged(size.width(), size.height());
|
|
||||||
applyCursor();
|
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)
|
void RdpDisplayWidget::keyPressEvent(QKeyEvent* event)
|
||||||
{
|
{
|
||||||
if (event == nullptr) {
|
if (event == nullptr) {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ class QKeyEvent;
|
|||||||
class QMouseEvent;
|
class QMouseEvent;
|
||||||
class QPaintEvent;
|
class QPaintEvent;
|
||||||
class QResizeEvent;
|
class QResizeEvent;
|
||||||
|
class QTimer;
|
||||||
class QWheelEvent;
|
class QWheelEvent;
|
||||||
|
|
||||||
class RdpDisplayWidget : public QWidget
|
class RdpDisplayWidget : public QWidget
|
||||||
@@ -30,10 +31,12 @@ signals:
|
|||||||
void mouseButtonInput(int x, int y, int button, bool pressed);
|
void mouseButtonInput(int x, int y, int button, bool pressed);
|
||||||
void mouseWheelInput(int x, int y, int deltaX, int deltaY);
|
void mouseWheelInput(int x, int y, int deltaX, int deltaY);
|
||||||
void viewportSizeChanged(int width, int height);
|
void viewportSizeChanged(int width, int height);
|
||||||
|
void displayScaleChanged(qreal devicePixelRatio);
|
||||||
|
|
||||||
protected:
|
protected:
|
||||||
void paintEvent(QPaintEvent* event) override;
|
void paintEvent(QPaintEvent* event) override;
|
||||||
void resizeEvent(QResizeEvent* event) override;
|
void resizeEvent(QResizeEvent* event) override;
|
||||||
|
bool event(QEvent* event) override;
|
||||||
void keyPressEvent(QKeyEvent* event) override;
|
void keyPressEvent(QKeyEvent* event) override;
|
||||||
void keyReleaseEvent(QKeyEvent* event) override;
|
void keyReleaseEvent(QKeyEvent* event) override;
|
||||||
void mousePressEvent(QMouseEvent* event) override;
|
void mousePressEvent(QMouseEvent* event) override;
|
||||||
@@ -54,11 +57,14 @@ private:
|
|||||||
QImage m_cursorImage;
|
QImage m_cursorImage;
|
||||||
QPoint m_cursorHotspot;
|
QPoint m_cursorHotspot;
|
||||||
CursorMode m_cursorMode;
|
CursorMode m_cursorMode;
|
||||||
|
QTimer* m_resizeDebounceTimer;
|
||||||
|
|
||||||
QRectF renderRect() const;
|
QRectF renderRect() const;
|
||||||
QPoint mapToRemote(const QPointF& pos) const;
|
QPoint mapToRemote(const QPointF& pos) const;
|
||||||
QSize effectiveRemoteSize() const;
|
QSize effectiveRemoteSize() const;
|
||||||
void applyCursor();
|
void applyCursor();
|
||||||
|
void emitViewportGeometry();
|
||||||
|
void scheduleViewportGeometryEmit();
|
||||||
};
|
};
|
||||||
|
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
+211
-59
@@ -89,43 +89,13 @@ constexpr double kDefaultDpi = 96.0;
|
|||||||
constexpr double kMillimetersPerInch = 25.4;
|
constexpr double kMillimetersPerInch = 25.4;
|
||||||
|
|
||||||
#ifdef ORBITHUB_HAS_FREERDP
|
#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)
|
bool applyRdpSecurityMode(rdpSettings* settings, const QString& mode)
|
||||||
{
|
{
|
||||||
if (settings == nullptr) {
|
if (settings == nullptr) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const QString normalized = normalizedRdpSecurityMode(mode);
|
const QString normalized = RdpSessionBackend::normalizedRdpSecurityMode(mode);
|
||||||
|
|
||||||
BOOL rdp = FALSE;
|
BOOL rdp = FALSE;
|
||||||
BOOL tls = FALSE;
|
BOOL tls = FALSE;
|
||||||
@@ -159,7 +129,7 @@ bool applyRdpPerformanceProfile(rdpSettings* settings, const QString& profile)
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const QString normalized = normalizedRdpPerformanceProfile(profile);
|
const QString normalized = RdpSessionBackend::normalizedRdpPerformanceProfile(profile);
|
||||||
UINT32 connectionType = CONNECTION_TYPE_BROADBAND_HIGH;
|
UINT32 connectionType = CONNECTION_TYPE_BROADBAND_HIGH;
|
||||||
BOOL networkAutoDetect = FALSE;
|
BOOL networkAutoDetect = FALSE;
|
||||||
if (normalized == QStringLiteral("Best Quality")) {
|
if (normalized == QStringLiteral("Best Quality")) {
|
||||||
@@ -420,9 +390,19 @@ BOOL orbitPreConnect(freerdp* instance)
|
|||||||
}
|
}
|
||||||
|
|
||||||
rdpSettings* settings = instance->context->settings;
|
rdpSettings* settings = instance->context->settings;
|
||||||
freerdp_settings_set_bool(settings, FreeRDP_CertificateCallbackPreferPEM, TRUE);
|
// FALSE gives the verify callbacks a short SHA256 fingerprint string
|
||||||
|
// (e.g. "ab:cd:12:...") instead of the full PEM certificate blob, which
|
||||||
|
// is what actually belongs in a user-facing changed-certificate message.
|
||||||
|
freerdp_settings_set_bool(settings, FreeRDP_CertificateCallbackPreferPEM, FALSE);
|
||||||
freerdp_settings_set_bool(settings, FreeRDP_DesktopResize, TRUE);
|
freerdp_settings_set_bool(settings, FreeRDP_DesktopResize, TRUE);
|
||||||
freerdp_settings_set_bool(settings, FreeRDP_IgnoreCertificate, TRUE);
|
// Trust servers on first connection (matching SSH's "accept-new" known-hosts
|
||||||
|
// policy) and persist that trust via FreeRDP's own certificate store, but
|
||||||
|
// actually verify it on every subsequent connection: IgnoreCertificate
|
||||||
|
// previously bypassed verification entirely, so a changed certificate
|
||||||
|
// (e.g. an active MITM) was silently accepted, indistinguishable from a
|
||||||
|
// legitimate server. See orbitVerifyChangedCertificateEx below.
|
||||||
|
freerdp_settings_set_bool(settings, FreeRDP_IgnoreCertificate, FALSE);
|
||||||
|
freerdp_settings_set_bool(settings, FreeRDP_AutoAcceptCertificate, TRUE);
|
||||||
return TRUE;
|
return TRUE;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -708,10 +688,13 @@ DWORD orbitVerifyCertificateEx(freerdp* instance,
|
|||||||
const char*,
|
const char*,
|
||||||
DWORD)
|
DWORD)
|
||||||
{
|
{
|
||||||
|
// With FreeRDP_AutoAcceptCertificate set, FreeRDP accepts and stores a
|
||||||
|
// first-seen certificate itself without calling this callback at all;
|
||||||
|
// it is kept as a defensive fallback for any code path that reaches it.
|
||||||
if (instance != nullptr && instance->context != nullptr) {
|
if (instance != nullptr && instance->context != nullptr) {
|
||||||
if (RdpSessionBackend* backend = backendFromContext(instance->context)) {
|
if (RdpSessionBackend* backend = backendFromContext(instance->context)) {
|
||||||
emit backend->eventLogged(
|
emit backend->eventLogged(
|
||||||
QStringLiteral("Accepting server certificate for %1:%2.")
|
QStringLiteral("Trusting new server certificate for %1:%2.")
|
||||||
.arg(QString::fromUtf8(host == nullptr ? "" : host))
|
.arg(QString::fromUtf8(host == nullptr ? "" : host))
|
||||||
.arg(port));
|
.arg(port));
|
||||||
}
|
}
|
||||||
@@ -725,21 +708,42 @@ DWORD orbitVerifyChangedCertificateEx(freerdp* instance,
|
|||||||
const char*,
|
const char*,
|
||||||
const char*,
|
const char*,
|
||||||
const char*,
|
const char*,
|
||||||
|
const char* newFingerprint,
|
||||||
const char*,
|
const char*,
|
||||||
const char*,
|
const char*,
|
||||||
const char*,
|
const char* oldFingerprint,
|
||||||
const char*,
|
|
||||||
DWORD)
|
DWORD)
|
||||||
{
|
{
|
||||||
|
// The server's certificate no longer matches the one trusted on a prior
|
||||||
|
// connection. This is exactly the scenario TLS verification exists to
|
||||||
|
// catch: either the server legitimately rotated its certificate, or an
|
||||||
|
// active man-in-the-middle is presenting a different one. Refuse the
|
||||||
|
// connection by default rather than silently trusting it.
|
||||||
|
const QString hostStr = QString::fromUtf8(host == nullptr ? "" : host);
|
||||||
|
const QString message = QStringLiteral(
|
||||||
|
"Server certificate for %1:%2 has changed since it was last trusted. "
|
||||||
|
"Connection refused for safety — this could mean the server's "
|
||||||
|
"certificate was legitimately renewed, or that a different host is "
|
||||||
|
"impersonating it. Previously trusted fingerprint: %3 — now "
|
||||||
|
"presented: %4. If this change is expected, remove the stored entry "
|
||||||
|
"for this host from FreeRDP's certificate store "
|
||||||
|
"(~/.config/freerdp/server) and reconnect.")
|
||||||
|
.arg(hostStr)
|
||||||
|
.arg(port)
|
||||||
|
.arg(QString::fromUtf8(oldFingerprint == nullptr ? "unknown" : oldFingerprint))
|
||||||
|
.arg(QString::fromUtf8(newFingerprint == nullptr ? "unknown" : newFingerprint));
|
||||||
|
|
||||||
|
// Not logged directly here: recordCertificateRejection() feeds this
|
||||||
|
// message into the generic connect-failure handling in workerMain(),
|
||||||
|
// which already reports it through eventLogged/connectionError/setState
|
||||||
|
// (the same channels every other RDP connection failure uses) — an
|
||||||
|
// extra direct log call here would just duplicate that.
|
||||||
if (instance != nullptr && instance->context != nullptr) {
|
if (instance != nullptr && instance->context != nullptr) {
|
||||||
if (RdpSessionBackend* backend = backendFromContext(instance->context)) {
|
if (RdpSessionBackend* backend = backendFromContext(instance->context)) {
|
||||||
emit backend->eventLogged(
|
backend->recordCertificateRejection(message);
|
||||||
QStringLiteral("Accepting changed server certificate for %1:%2.")
|
|
||||||
.arg(QString::fromUtf8(host == nullptr ? "" : host))
|
|
||||||
.arg(port));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return 1;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
const char* authReasonName(rdp_auth_reason reason)
|
const char* authReasonName(rdp_auth_reason reason)
|
||||||
@@ -825,8 +829,55 @@ BOOL orbitAuthenticateEx(freerdp* instance,
|
|||||||
|
|
||||||
return TRUE;
|
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) {
|
if (nativeScanCode == 0) {
|
||||||
return RDP_SCANCODE_UNKNOWN;
|
return RDP_SCANCODE_UNKNOWN;
|
||||||
@@ -885,10 +936,12 @@ UINT32 scancodeFromNativeScanCode(quint32 nativeScanCode)
|
|||||||
#endif
|
#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 bool keypad = modifiers.testFlag(Qt::KeypadModifier);
|
||||||
const UINT32 nativeScancode = scancodeFromNativeScanCode(nativeScanCode);
|
const quint32 nativeScancode = scancodeFromNativeScanCode(nativeScanCode);
|
||||||
|
|
||||||
switch (key) {
|
switch (key) {
|
||||||
case Qt::Key_Escape:
|
case Qt::Key_Escape:
|
||||||
@@ -1113,7 +1166,7 @@ UINT32 scancodeForQtKey(int key, Qt::KeyboardModifiers modifiers, quint32 native
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
QString mapRdpError(UINT32 code)
|
QString RdpSessionBackend::mapRdpError(quint32 code)
|
||||||
{
|
{
|
||||||
switch (code) {
|
switch (code) {
|
||||||
case FREERDP_ERROR_CONNECT_LOGON_FAILURE:
|
case FREERDP_ERROR_CONNECT_LOGON_FAILURE:
|
||||||
@@ -1176,7 +1229,7 @@ QString mapRdpError(UINT32 code)
|
|||||||
return QStringLiteral("RDP connection failed (0x%1).").arg(code, 8, 16, QChar('0'));
|
return QStringLiteral("RDP connection failed (0x%1).").arg(code, 8, 16, QChar('0'));
|
||||||
}
|
}
|
||||||
|
|
||||||
bool isExpectedDisconnectCode(UINT32 code)
|
bool RdpSessionBackend::isExpectedDisconnectCode(quint32 code)
|
||||||
{
|
{
|
||||||
switch (code) {
|
switch (code) {
|
||||||
case FREERDP_ERROR_SUCCESS:
|
case FREERDP_ERROR_SUCCESS:
|
||||||
@@ -1206,7 +1259,7 @@ bool isExpectedDisconnectCode(UINT32 code)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
bool isExpectedConnectAbortCode(UINT32 code)
|
bool RdpSessionBackend::isExpectedConnectAbortCode(quint32 code)
|
||||||
{
|
{
|
||||||
switch (code) {
|
switch (code) {
|
||||||
case FREERDP_ERROR_SUCCESS:
|
case FREERDP_ERROR_SUCCESS:
|
||||||
@@ -1218,7 +1271,7 @@ bool isExpectedConnectAbortCode(UINT32 code)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
QString disconnectMessageForCode(UINT32 code)
|
QString RdpSessionBackend::disconnectMessageForCode(quint32 code)
|
||||||
{
|
{
|
||||||
switch (code) {
|
switch (code) {
|
||||||
case FREERDP_ERROR_IDLE_TIMEOUT:
|
case FREERDP_ERROR_IDLE_TIMEOUT:
|
||||||
@@ -1254,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 char* name = freerdp_get_last_error_name(code);
|
||||||
const QString text = (name != nullptr && name[0] != '\0') ? QString::fromUtf8(name)
|
const QString text = (name != nullptr && name[0] != '\0') ? QString::fromUtf8(name)
|
||||||
@@ -1262,7 +1315,6 @@ QString rdpErrorRaw(UINT32 code)
|
|||||||
return QStringLiteral("%1 (0x%2)").arg(text).arg(code, 8, 16, QChar('0'));
|
return QStringLiteral("%1 (0x%2)").arg(text).arg(code, 8, 16, QChar('0'));
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
}
|
|
||||||
|
|
||||||
RdpSessionBackend::RdpSessionBackend(const Profile& profile, QObject* parent)
|
RdpSessionBackend::RdpSessionBackend(const Profile& profile, QObject* parent)
|
||||||
: SessionBackend(profile, parent),
|
: SessionBackend(profile, parent),
|
||||||
@@ -1270,6 +1322,7 @@ RdpSessionBackend::RdpSessionBackend(const Profile& profile, QObject* parent)
|
|||||||
m_userInitiatedDisconnect(false),
|
m_userInitiatedDisconnect(false),
|
||||||
m_requestedDesktopWidth(kDefaultDesktopWidth),
|
m_requestedDesktopWidth(kDefaultDesktopWidth),
|
||||||
m_requestedDesktopHeight(kDefaultDesktopHeight),
|
m_requestedDesktopHeight(kDefaultDesktopHeight),
|
||||||
|
m_devicePixelRatio(1.0),
|
||||||
m_workerRunning(false),
|
m_workerRunning(false),
|
||||||
m_stopRequested(false),
|
m_stopRequested(false),
|
||||||
m_instance(nullptr),
|
m_instance(nullptr),
|
||||||
@@ -1278,6 +1331,7 @@ RdpSessionBackend::RdpSessionBackend(const Profile& profile, QObject* parent)
|
|||||||
m_resizeFailureLogged(false),
|
m_resizeFailureLogged(false),
|
||||||
m_lastResizeWidth(0),
|
m_lastResizeWidth(0),
|
||||||
m_lastResizeHeight(0),
|
m_lastResizeHeight(0),
|
||||||
|
m_lastResizeScale(0),
|
||||||
m_cliprdrContext(nullptr)
|
m_cliprdrContext(nullptr)
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
@@ -1369,6 +1423,28 @@ void RdpSessionBackend::updateTerminalSize(int columns, int rows)
|
|||||||
enqueueInputEvent(event);
|
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,
|
void RdpSessionBackend::sendKeyEvent(int key,
|
||||||
quint32 nativeScanCode,
|
quint32 nativeScanCode,
|
||||||
const QString& text,
|
const QString& text,
|
||||||
@@ -1451,6 +1527,11 @@ void RdpSessionBackend::setState(SessionState state, const QString& message)
|
|||||||
emit eventLogged(message);
|
emit eventLogged(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void RdpSessionBackend::recordCertificateRejection(const QString& reason)
|
||||||
|
{
|
||||||
|
m_certificateRejectionReason = reason;
|
||||||
|
}
|
||||||
|
|
||||||
bool RdpSessionBackend::validateProfile(QString& message) const
|
bool RdpSessionBackend::validateProfile(QString& message) const
|
||||||
{
|
{
|
||||||
const Profile& p = profile();
|
const Profile& p = profile();
|
||||||
@@ -1510,6 +1591,7 @@ void RdpSessionBackend::workerMain()
|
|||||||
m_workerRunning.store(false);
|
m_workerRunning.store(false);
|
||||||
return;
|
return;
|
||||||
#else
|
#else
|
||||||
|
m_certificateRejectionReason.clear();
|
||||||
ensureFreeRdpRuntimeInitialized();
|
ensureFreeRdpRuntimeInitialized();
|
||||||
|
|
||||||
freerdp* instance = freerdp_new();
|
freerdp* instance = freerdp_new();
|
||||||
@@ -1597,9 +1679,29 @@ void RdpSessionBackend::workerMain()
|
|||||||
const QString performanceProfile = normalizedRdpPerformanceProfile(p.rdpPerformanceProfile);
|
const QString performanceProfile = normalizedRdpPerformanceProfile(p.rdpPerformanceProfile);
|
||||||
freerdp_settings_set_bool(settings, FreeRDP_SupportDisplayControl, TRUE);
|
freerdp_settings_set_bool(settings, FreeRDP_SupportDisplayControl, TRUE);
|
||||||
freerdp_settings_set_bool(settings, FreeRDP_DynamicResolutionUpdate, 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_DesktopWidth, static_cast<UINT32>(desktopWidth));
|
||||||
freerdp_settings_set_uint32(settings, FreeRDP_DesktopHeight, static_cast<UINT32>(desktopHeight));
|
freerdp_settings_set_uint32(settings, FreeRDP_DesktopHeight, static_cast<UINT32>(desktopHeight));
|
||||||
freerdp_settings_set_uint32(settings, FreeRDP_ColorDepth, 32);
|
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_AuthenticationOnly, FALSE);
|
||||||
freerdp_settings_set_bool(settings, FreeRDP_AutoLogonEnabled, TRUE);
|
freerdp_settings_set_bool(settings, FreeRDP_AutoLogonEnabled, TRUE);
|
||||||
if (!applyRdpSecurityMode(settings, securityMode)) {
|
if (!applyRdpSecurityMode(settings, securityMode)) {
|
||||||
@@ -1671,7 +1773,9 @@ void RdpSessionBackend::workerMain()
|
|||||||
emit eventLogged(QStringLiteral("RDP connect aborted: %1").arg(raw));
|
emit eventLogged(QStringLiteral("RDP connect aborted: %1").arg(raw));
|
||||||
emitStateAsync(SessionState::Disconnected, disconnectMessageForCode(code));
|
emitStateAsync(SessionState::Disconnected, disconnectMessageForCode(code));
|
||||||
} else {
|
} else {
|
||||||
const QString mapped = mapRdpError(code);
|
const QString mapped = m_certificateRejectionReason.isEmpty()
|
||||||
|
? mapRdpError(code)
|
||||||
|
: m_certificateRejectionReason;
|
||||||
emit eventLogged(QStringLiteral("RDP connect failure detail: %1").arg(raw));
|
emit eventLogged(QStringLiteral("RDP connect failure detail: %1").arg(raw));
|
||||||
emitConnectionFailureAsync(mapped, raw);
|
emitConnectionFailureAsync(mapped, raw);
|
||||||
emitStateAsync(SessionState::Failed, mapped);
|
emitStateAsync(SessionState::Failed, mapped);
|
||||||
@@ -1789,9 +1893,14 @@ bool RdpSessionBackend::sendDisplayResize(rdp_freerdp* instance, int width, int
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const qreal ratio = m_devicePixelRatio.load();
|
||||||
|
const UINT32 scaleValue = nearestFreeRdpScaleValue(ratio);
|
||||||
|
|
||||||
rdpSettings* settings = instance->context->settings;
|
rdpSettings* settings = instance->context->settings;
|
||||||
freerdp_settings_set_uint32(settings, FreeRDP_DesktopWidth, static_cast<UINT32>(width));
|
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_DesktopHeight, static_cast<UINT32>(height));
|
||||||
|
freerdp_settings_set_uint32(settings, FreeRDP_DesktopScaleFactor, scaleValue);
|
||||||
|
freerdp_settings_set_uint32(settings, FreeRDP_DeviceScaleFactor, scaleValue);
|
||||||
|
|
||||||
DispClientContext* dispContext = nullptr;
|
DispClientContext* dispContext = nullptr;
|
||||||
bool displayControlReady = false;
|
bool displayControlReady = false;
|
||||||
@@ -1809,17 +1918,58 @@ bool RdpSessionBackend::sendDisplayResize(rdp_freerdp* instance, int width, int
|
|||||||
layout.Width = static_cast<UINT32>(width);
|
layout.Width = static_cast<UINT32>(width);
|
||||||
layout.Height = static_cast<UINT32>(height);
|
layout.Height = static_cast<UINT32>(height);
|
||||||
layout.Orientation = ORIENTATION_LANDSCAPE;
|
layout.Orientation = ORIENTATION_LANDSCAPE;
|
||||||
layout.DesktopScaleFactor = freerdp_settings_get_uint32(settings, FreeRDP_DesktopScaleFactor);
|
layout.DesktopScaleFactor = scaleValue;
|
||||||
layout.DeviceScaleFactor = freerdp_settings_get_uint32(settings, FreeRDP_DeviceScaleFactor);
|
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>(
|
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>(
|
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);
|
const UINT rc = dispContext->SendMonitorLayout(dispContext, 1, &layout);
|
||||||
if (rc == CHANNEL_RC_OK) {
|
if (rc == CHANNEL_RC_OK) {
|
||||||
m_lastResizeWidth = width;
|
m_lastResizeWidth = width;
|
||||||
m_lastResizeHeight = height;
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1993,7 +2143,9 @@ void RdpSessionBackend::processInputEvents(rdp_freerdp* instance)
|
|||||||
if (hasResize) {
|
if (hasResize) {
|
||||||
const int width = sanitizeDesktopWidth(resizeWidth);
|
const int width = sanitizeDesktopWidth(resizeWidth);
|
||||||
const int height = sanitizeDesktopHeight(resizeHeight);
|
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 (sendDisplayResize(instance, width, height)) {
|
||||||
if (m_resizeFailureLogged) {
|
if (m_resizeFailureLogged) {
|
||||||
emit eventLogged(QStringLiteral("Dynamic RDP resize recovered."));
|
emit eventLogged(QStringLiteral("Dynamic RDP resize recovered."));
|
||||||
@@ -2284,7 +2436,7 @@ void RdpSessionBackend::emitConnectionFailureAsync(const QString& displayMessage
|
|||||||
Qt::QueuedConnection);
|
Qt::QueuedConnection);
|
||||||
}
|
}
|
||||||
|
|
||||||
int RdpSessionBackend::sanitizeDesktopWidth(int width) const
|
int RdpSessionBackend::sanitizeDesktopWidth(int width)
|
||||||
{
|
{
|
||||||
if (width <= 0) {
|
if (width <= 0) {
|
||||||
return kDefaultDesktopWidth;
|
return kDefaultDesktopWidth;
|
||||||
@@ -2292,7 +2444,7 @@ int RdpSessionBackend::sanitizeDesktopWidth(int width) const
|
|||||||
return qBound(kMinDesktopWidth, width, kMaxDesktopWidth);
|
return qBound(kMinDesktopWidth, width, kMaxDesktopWidth);
|
||||||
}
|
}
|
||||||
|
|
||||||
int RdpSessionBackend::sanitizeDesktopHeight(int height) const
|
int RdpSessionBackend::sanitizeDesktopHeight(int height)
|
||||||
{
|
{
|
||||||
if (height <= 0) {
|
if (height <= 0) {
|
||||||
return kDefaultDesktopHeight;
|
return kDefaultDesktopHeight;
|
||||||
|
|||||||
@@ -19,6 +19,24 @@ public:
|
|||||||
explicit RdpSessionBackend(const Profile& profile, QObject* parent = nullptr);
|
explicit RdpSessionBackend(const Profile& profile, QObject* parent = nullptr);
|
||||||
~RdpSessionBackend() override;
|
~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:
|
public slots:
|
||||||
void connectSession(const SessionConnectOptions& options) override;
|
void connectSession(const SessionConnectOptions& options) override;
|
||||||
void disconnectSession() override;
|
void disconnectSession() override;
|
||||||
@@ -26,6 +44,7 @@ public slots:
|
|||||||
void sendInput(const QString& input) override;
|
void sendInput(const QString& input) override;
|
||||||
void confirmHostKey(bool trustHost) override;
|
void confirmHostKey(bool trustHost) override;
|
||||||
void updateTerminalSize(int columns, int rows) override;
|
void updateTerminalSize(int columns, int rows) override;
|
||||||
|
void updateDisplayScale(qreal devicePixelRatio) override;
|
||||||
void sendKeyEvent(int key,
|
void sendKeyEvent(int key,
|
||||||
quint32 nativeScanCode,
|
quint32 nativeScanCode,
|
||||||
const QString& text,
|
const QString& text,
|
||||||
@@ -68,6 +87,7 @@ private:
|
|||||||
|
|
||||||
std::atomic_int m_requestedDesktopWidth;
|
std::atomic_int m_requestedDesktopWidth;
|
||||||
std::atomic_int m_requestedDesktopHeight;
|
std::atomic_int m_requestedDesktopHeight;
|
||||||
|
std::atomic<qreal> m_devicePixelRatio;
|
||||||
|
|
||||||
std::thread m_worker;
|
std::thread m_worker;
|
||||||
std::atomic_bool m_workerRunning;
|
std::atomic_bool m_workerRunning;
|
||||||
@@ -85,11 +105,20 @@ private:
|
|||||||
bool m_resizeFailureLogged;
|
bool m_resizeFailureLogged;
|
||||||
int m_lastResizeWidth;
|
int m_lastResizeWidth;
|
||||||
int m_lastResizeHeight;
|
int m_lastResizeHeight;
|
||||||
|
int m_lastResizeScale;
|
||||||
|
|
||||||
std::mutex m_cliprdrMutex;
|
std::mutex m_cliprdrMutex;
|
||||||
void* m_cliprdrContext;
|
void* m_cliprdrContext;
|
||||||
QString m_pendingLocalClipboardText;
|
QString m_pendingLocalClipboardText;
|
||||||
|
|
||||||
|
// Set synchronously by orbitVerifyChangedCertificateEx (called from this
|
||||||
|
// object's own worker thread during freerdp_connect) when a server's TLS
|
||||||
|
// certificate has changed since a prior trusted connection. Read back by
|
||||||
|
// workerMain() right after freerdp_connect() fails, to show the specific
|
||||||
|
// reason instead of a generic "TLS negotiation failed" message. Cleared
|
||||||
|
// at the start of every connect attempt.
|
||||||
|
QString m_certificateRejectionReason;
|
||||||
|
|
||||||
void setState(SessionState state, const QString& message);
|
void setState(SessionState state, const QString& message);
|
||||||
bool validateProfile(QString& message) const;
|
bool validateProfile(QString& message) const;
|
||||||
void startWorker();
|
void startWorker();
|
||||||
@@ -109,11 +138,10 @@ public:
|
|||||||
void onCliprdrServerFormatList(bool hasUnicodeText);
|
void onCliprdrServerFormatList(bool hasUnicodeText);
|
||||||
void onCliprdrServerFormatDataRequest(uint32_t requestedFormatId);
|
void onCliprdrServerFormatDataRequest(uint32_t requestedFormatId);
|
||||||
void onCliprdrServerFormatDataResponse(bool success, const uint8_t* data, uint32_t size);
|
void onCliprdrServerFormatDataResponse(bool success, const uint8_t* data, uint32_t size);
|
||||||
|
void recordCertificateRejection(const QString& reason);
|
||||||
private:
|
private:
|
||||||
void emitStateAsync(SessionState state, const QString& message);
|
void emitStateAsync(SessionState state, const QString& message);
|
||||||
void emitConnectionFailureAsync(const QString& displayMessage, const QString& rawMessage);
|
void emitConnectionFailureAsync(const QString& displayMessage, const QString& rawMessage);
|
||||||
int sanitizeDesktopWidth(int width) const;
|
|
||||||
int sanitizeDesktopHeight(int height) const;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -47,6 +47,10 @@ public slots:
|
|||||||
virtual void sendInput(const QString& input) = 0;
|
virtual void sendInput(const QString& input) = 0;
|
||||||
virtual void confirmHostKey(bool trustHost) = 0;
|
virtual void confirmHostKey(bool trustHost) = 0;
|
||||||
virtual void updateTerminalSize(int columns, int rows) = 0;
|
virtual void updateTerminalSize(int columns, int rows) = 0;
|
||||||
|
virtual void updateDisplayScale(qreal devicePixelRatio)
|
||||||
|
{
|
||||||
|
Q_UNUSED(devicePixelRatio);
|
||||||
|
}
|
||||||
virtual void setClipboardText(const QString& text)
|
virtual void setClipboardText(const QString& text)
|
||||||
{
|
{
|
||||||
Q_UNUSED(text);
|
Q_UNUSED(text);
|
||||||
|
|||||||
@@ -176,6 +176,11 @@ SessionTab::SessionTab(const Profile& profile,
|
|||||||
m_backend,
|
m_backend,
|
||||||
&SessionBackend::updateTerminalSize,
|
&SessionBackend::updateTerminalSize,
|
||||||
Qt::QueuedConnection);
|
Qt::QueuedConnection);
|
||||||
|
connect(this,
|
||||||
|
&SessionTab::requestDisplayScale,
|
||||||
|
m_backend,
|
||||||
|
&SessionBackend::updateDisplayScale,
|
||||||
|
Qt::QueuedConnection);
|
||||||
connect(this,
|
connect(this,
|
||||||
&SessionTab::requestKeyEvent,
|
&SessionTab::requestKeyEvent,
|
||||||
m_backend,
|
m_backend,
|
||||||
@@ -860,6 +865,10 @@ void SessionTab::setupUi()
|
|||||||
&RdpDisplayWidget::viewportSizeChanged,
|
&RdpDisplayWidget::viewportSizeChanged,
|
||||||
this,
|
this,
|
||||||
[this](int width, int height) { emit requestTerminalSize(width, height); });
|
[this](int width, int height) { emit requestTerminalSize(width, height); });
|
||||||
|
connect(m_rdpDisplay,
|
||||||
|
&RdpDisplayWidget::displayScaleChanged,
|
||||||
|
this,
|
||||||
|
[this](qreal ratio) { emit requestDisplayScale(ratio); });
|
||||||
connect(m_rdpDisplay,
|
connect(m_rdpDisplay,
|
||||||
&RdpDisplayWidget::keyInput,
|
&RdpDisplayWidget::keyInput,
|
||||||
this,
|
this,
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ signals:
|
|||||||
void requestInput(const QString& input);
|
void requestInput(const QString& input);
|
||||||
void requestHostKeyConfirmation(bool trustHost);
|
void requestHostKeyConfirmation(bool trustHost);
|
||||||
void requestTerminalSize(int columns, int rows);
|
void requestTerminalSize(int columns, int rows);
|
||||||
|
void requestDisplayScale(qreal devicePixelRatio);
|
||||||
void requestKeyEvent(int key,
|
void requestKeyEvent(int key,
|
||||||
quint32 nativeScanCode,
|
quint32 nativeScanCode,
|
||||||
const QString& text,
|
const QString& text,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
#include "about_dialog.h"
|
#include "about_dialog.h"
|
||||||
#include "profiles_window.h"
|
#include "profiles_window.h"
|
||||||
|
#include "user_guide_dialog.h"
|
||||||
#include <QApplication>
|
#include <QApplication>
|
||||||
#include "session_tab.h"
|
#include "session_tab.h"
|
||||||
|
|
||||||
@@ -169,6 +170,10 @@ SessionWindow::SessionWindow(QWidget* parent)
|
|||||||
QAction* newProfileAction = fileMenu->addAction(QStringLiteral("New Profile"));
|
QAction* newProfileAction = fileMenu->addAction(QStringLiteral("New Profile"));
|
||||||
QAction* newFolderAction = fileMenu->addAction(QStringLiteral("New Folder"));
|
QAction* newFolderAction = fileMenu->addAction(QStringLiteral("New Folder"));
|
||||||
fileMenu->addSeparator();
|
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"));
|
QAction* quitAction = fileMenu->addAction(QStringLiteral("Quit"));
|
||||||
|
|
||||||
connect(newProfileAction,
|
connect(newProfileAction,
|
||||||
@@ -179,9 +184,29 @@ SessionWindow::SessionWindow(QWidget* parent)
|
|||||||
&QAction::triggered,
|
&QAction::triggered,
|
||||||
this,
|
this,
|
||||||
[this]() { m_profilesWidget->createFolderInCurrentContext(); });
|
[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(); });
|
connect(quitAction, &QAction::triggered, this, []() { qApp->quit(); });
|
||||||
|
|
||||||
QMenu* helpMenu = menuBar()->addMenu(QStringLiteral("Help"));
|
QMenu* helpMenu = menuBar()->addMenu(QStringLiteral("Help"));
|
||||||
|
QAction* userGuideAction = helpMenu->addAction(QStringLiteral("User Guide"));
|
||||||
|
connect(userGuideAction,
|
||||||
|
&QAction::triggered,
|
||||||
|
this,
|
||||||
|
[this]() {
|
||||||
|
UserGuideDialog dialog(this);
|
||||||
|
dialog.exec();
|
||||||
|
});
|
||||||
QAction* aboutAction = helpMenu->addAction(QStringLiteral("About OrbitHub"));
|
QAction* aboutAction = helpMenu->addAction(QStringLiteral("About OrbitHub"));
|
||||||
connect(aboutAction,
|
connect(aboutAction,
|
||||||
&QAction::triggered,
|
&QAction::triggered,
|
||||||
|
|||||||
+16
-10
@@ -7,16 +7,14 @@
|
|||||||
#include <QTextStream>
|
#include <QTextStream>
|
||||||
#include <QUuid>
|
#include <QUuid>
|
||||||
|
|
||||||
namespace {
|
SshSessionBackend::SshSessionBackend(const Profile& profile, QObject* parent)
|
||||||
QString escapeForShellSingleQuotes(const QString& value)
|
: 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),
|
: SessionBackend(profile, parent),
|
||||||
m_process(new QProcess(this)),
|
m_process(new QProcess(this)),
|
||||||
m_connectedProbeTimer(new QTimer(this)),
|
m_connectedProbeTimer(new QTimer(this)),
|
||||||
@@ -27,7 +25,8 @@ SshSessionBackend::SshSessionBackend(const Profile& profile, QObject* parent)
|
|||||||
m_waitingForHostKeyConfirmation(false),
|
m_waitingForHostKeyConfirmation(false),
|
||||||
m_passwordSubmitted(false),
|
m_passwordSubmitted(false),
|
||||||
m_terminalColumns(0),
|
m_terminalColumns(0),
|
||||||
m_terminalRows(0)
|
m_terminalRows(0),
|
||||||
|
m_sshProgram(sshProgramOverride)
|
||||||
{
|
{
|
||||||
m_connectedProbeTimer->setSingleShot(true);
|
m_connectedProbeTimer->setSingleShot(true);
|
||||||
|
|
||||||
@@ -395,7 +394,7 @@ bool SshSessionBackend::startSshProcess(const SessionConnectOptions& options)
|
|||||||
args << target;
|
args << target;
|
||||||
|
|
||||||
m_process->setProcessEnvironment(environment);
|
m_process->setProcessEnvironment(environment);
|
||||||
m_process->setProgram(QStringLiteral("ssh"));
|
m_process->setProgram(m_sshProgram);
|
||||||
m_process->setArguments(args);
|
m_process->setArguments(args);
|
||||||
m_process->setProcessChannelMode(QProcess::SeparateChannels);
|
m_process->setProcessChannelMode(QProcess::SeparateChannels);
|
||||||
|
|
||||||
@@ -471,7 +470,7 @@ void SshSessionBackend::cleanupAskPassScript()
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
QString SshSessionBackend::mapSshError(const QString& rawError) const
|
QString SshSessionBackend::mapSshError(const QString& rawError)
|
||||||
{
|
{
|
||||||
const QString raw = rawError.trimmed();
|
const QString raw = rawError.trimmed();
|
||||||
if (raw.contains(QStringLiteral("Permission denied"), Qt::CaseInsensitive)) {
|
if (raw.contains(QStringLiteral("Permission denied"), Qt::CaseInsensitive)) {
|
||||||
@@ -510,6 +509,13 @@ QString SshSessionBackend::mapSshError(const QString& rawError) const
|
|||||||
return QStringLiteral("SSH connection failed.");
|
return QStringLiteral("SSH connection failed.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
QString SshSessionBackend::escapeForShellSingleQuotes(const QString& value)
|
||||||
|
{
|
||||||
|
QString escaped = value;
|
||||||
|
escaped.replace(QStringLiteral("'"), QStringLiteral("'\"'\"'"));
|
||||||
|
return escaped;
|
||||||
|
}
|
||||||
|
|
||||||
QString SshSessionBackend::knownHostsFileForNullDevice() const
|
QString SshSessionBackend::knownHostsFileForNullDevice() const
|
||||||
{
|
{
|
||||||
#ifdef Q_OS_WIN
|
#ifdef Q_OS_WIN
|
||||||
|
|||||||
@@ -13,8 +13,16 @@ class SshSessionBackend : public SessionBackend
|
|||||||
|
|
||||||
public:
|
public:
|
||||||
explicit SshSessionBackend(const Profile& profile, QObject* parent = nullptr);
|
explicit SshSessionBackend(const Profile& profile, QObject* parent = nullptr);
|
||||||
|
// Test-only: overrides the executable launched instead of "ssh", so
|
||||||
|
// tests can point it at a controllable fixture script.
|
||||||
|
SshSessionBackend(const Profile& profile, const QString& sshProgramOverride, QObject* parent);
|
||||||
~SshSessionBackend() override;
|
~SshSessionBackend() override;
|
||||||
|
|
||||||
|
// Pure, state-free helpers exposed as public statics purely so tests
|
||||||
|
// can exercise them directly without spinning up a real ssh process.
|
||||||
|
static QString mapSshError(const QString& rawError);
|
||||||
|
static QString escapeForShellSingleQuotes(const QString& value);
|
||||||
|
|
||||||
public slots:
|
public slots:
|
||||||
void connectSession(const SessionConnectOptions& options) override;
|
void connectSession(const SessionConnectOptions& options) override;
|
||||||
void disconnectSession() override;
|
void disconnectSession() override;
|
||||||
@@ -46,6 +54,7 @@ private:
|
|||||||
bool m_passwordSubmitted;
|
bool m_passwordSubmitted;
|
||||||
int m_terminalColumns;
|
int m_terminalColumns;
|
||||||
int m_terminalRows;
|
int m_terminalRows;
|
||||||
|
QString m_sshProgram;
|
||||||
|
|
||||||
void setState(SessionState state, const QString& message);
|
void setState(SessionState state, const QString& message);
|
||||||
bool startSshProcess(const SessionConnectOptions& options);
|
bool startSshProcess(const SessionConnectOptions& options);
|
||||||
@@ -53,7 +62,6 @@ private:
|
|||||||
QProcessEnvironment& environment,
|
QProcessEnvironment& environment,
|
||||||
QString& error);
|
QString& error);
|
||||||
void cleanupAskPassScript();
|
void cleanupAskPassScript();
|
||||||
QString mapSshError(const QString& rawError) const;
|
|
||||||
QString knownHostsFileForNullDevice() const;
|
QString knownHostsFileForNullDevice() const;
|
||||||
void applyTerminalSizeIfAvailable();
|
void applyTerminalSizeIfAvailable();
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
#include "user_guide_dialog.h"
|
||||||
|
|
||||||
|
#include <QApplication>
|
||||||
|
#include <QDesktopServices>
|
||||||
|
#include <QDialogButtonBox>
|
||||||
|
#include <QFile>
|
||||||
|
#include <QListWidget>
|
||||||
|
#include <QSplitter>
|
||||||
|
#include <QTextBrowser>
|
||||||
|
#include <QTextStream>
|
||||||
|
#include <QUrl>
|
||||||
|
#include <QVBoxLayout>
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
QString slugify(const QString& title)
|
||||||
|
{
|
||||||
|
QString slug;
|
||||||
|
slug.reserve(title.size());
|
||||||
|
bool lastWasHyphen = false;
|
||||||
|
for (const QChar& ch : title) {
|
||||||
|
if (ch.isLetterOrNumber()) {
|
||||||
|
slug += ch.toLower();
|
||||||
|
lastWasHyphen = false;
|
||||||
|
} else if (!lastWasHyphen && !slug.isEmpty()) {
|
||||||
|
slug += QLatin1Char('-');
|
||||||
|
lastWasHyphen = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
while (slug.endsWith(QLatin1Char('-'))) {
|
||||||
|
slug.chop(1);
|
||||||
|
}
|
||||||
|
return slug;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
UserGuideDialog::UserGuideDialog(QWidget* parent)
|
||||||
|
: QDialog(parent), m_sectionList(nullptr), m_browser(nullptr)
|
||||||
|
{
|
||||||
|
setWindowTitle(QStringLiteral("OrbitHub User Guide"));
|
||||||
|
setWindowIcon(QApplication::windowIcon());
|
||||||
|
resize(900, 640);
|
||||||
|
|
||||||
|
auto* layout = new QVBoxLayout(this);
|
||||||
|
layout->setContentsMargins(16, 16, 16, 16);
|
||||||
|
layout->setSpacing(12);
|
||||||
|
|
||||||
|
auto* splitter = new QSplitter(Qt::Horizontal, this);
|
||||||
|
|
||||||
|
m_sectionList = new QListWidget(splitter);
|
||||||
|
m_sectionList->setMaximumWidth(220);
|
||||||
|
|
||||||
|
m_browser = new QTextBrowser(splitter);
|
||||||
|
m_browser->setOpenExternalLinks(false);
|
||||||
|
m_browser->setOpenLinks(false);
|
||||||
|
|
||||||
|
splitter->addWidget(m_sectionList);
|
||||||
|
splitter->addWidget(m_browser);
|
||||||
|
splitter->setStretchFactor(0, 0);
|
||||||
|
splitter->setStretchFactor(1, 1);
|
||||||
|
|
||||||
|
auto* buttons = new QDialogButtonBox(QDialogButtonBox::Close, this);
|
||||||
|
connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::reject);
|
||||||
|
connect(buttons, &QDialogButtonBox::accepted, this, &QDialog::accept);
|
||||||
|
|
||||||
|
layout->addWidget(splitter, 1);
|
||||||
|
layout->addWidget(buttons);
|
||||||
|
|
||||||
|
loadSections();
|
||||||
|
|
||||||
|
connect(m_sectionList,
|
||||||
|
&QListWidget::currentRowChanged,
|
||||||
|
this,
|
||||||
|
[this](int row) { showSection(row); });
|
||||||
|
connect(m_browser, &QTextBrowser::anchorClicked, this, &UserGuideDialog::onAnchorClicked);
|
||||||
|
|
||||||
|
if (!m_sections.isEmpty()) {
|
||||||
|
m_sectionList->setCurrentRow(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void UserGuideDialog::loadSections()
|
||||||
|
{
|
||||||
|
QFile file(QStringLiteral(":/docs/USER_GUIDE.md"));
|
||||||
|
if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
QTextStream stream(&file);
|
||||||
|
const QString content = stream.readAll();
|
||||||
|
const QStringList lines = content.split(QLatin1Char('\n'));
|
||||||
|
|
||||||
|
QString currentTitle;
|
||||||
|
QStringList currentLines;
|
||||||
|
|
||||||
|
auto flushSection = [this, ¤tTitle, ¤tLines]() {
|
||||||
|
if (currentTitle.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Section section;
|
||||||
|
section.title = currentTitle;
|
||||||
|
section.anchor = slugify(currentTitle);
|
||||||
|
section.markdown = currentLines.join(QLatin1Char('\n'));
|
||||||
|
m_sections.append(section);
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const QString& line : lines) {
|
||||||
|
if (line.startsWith(QStringLiteral("## "))) {
|
||||||
|
flushSection();
|
||||||
|
currentTitle = line.mid(3).trimmed();
|
||||||
|
currentLines.clear();
|
||||||
|
}
|
||||||
|
currentLines.append(line);
|
||||||
|
}
|
||||||
|
flushSection();
|
||||||
|
|
||||||
|
for (const Section& section : m_sections) {
|
||||||
|
m_sectionList->addItem(section.title);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void UserGuideDialog::showSection(int index)
|
||||||
|
{
|
||||||
|
if (index < 0 || index >= m_sections.size()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
m_browser->setMarkdown(m_sections[index].markdown);
|
||||||
|
}
|
||||||
|
|
||||||
|
void UserGuideDialog::onAnchorClicked(const QUrl& url)
|
||||||
|
{
|
||||||
|
if (!url.scheme().isEmpty()) {
|
||||||
|
QDesktopServices::openUrl(url);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const QString fragment = url.fragment();
|
||||||
|
if (fragment.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int i = 0; i < m_sections.size(); ++i) {
|
||||||
|
if (m_sections[i].anchor == fragment) {
|
||||||
|
m_sectionList->setCurrentRow(i);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
#ifndef ORBITHUB_USER_GUIDE_DIALOG_H
|
||||||
|
#define ORBITHUB_USER_GUIDE_DIALOG_H
|
||||||
|
|
||||||
|
#include <QDialog>
|
||||||
|
#include <QString>
|
||||||
|
#include <QVector>
|
||||||
|
|
||||||
|
class QListWidget;
|
||||||
|
class QTextBrowser;
|
||||||
|
class QUrl;
|
||||||
|
|
||||||
|
class UserGuideDialog : public QDialog
|
||||||
|
{
|
||||||
|
Q_OBJECT
|
||||||
|
|
||||||
|
public:
|
||||||
|
explicit UserGuideDialog(QWidget* parent = nullptr);
|
||||||
|
|
||||||
|
private:
|
||||||
|
struct Section {
|
||||||
|
QString title;
|
||||||
|
QString anchor;
|
||||||
|
QString markdown;
|
||||||
|
};
|
||||||
|
|
||||||
|
void loadSections();
|
||||||
|
void showSection(int index);
|
||||||
|
void onAnchorClicked(const QUrl& url);
|
||||||
|
|
||||||
|
QListWidget* m_sectionList;
|
||||||
|
QTextBrowser* m_browser;
|
||||||
|
QVector<Section> m_sections;
|
||||||
|
};
|
||||||
|
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
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)
|
||||||
|
|
||||||
|
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()
|
||||||
+29
@@ -0,0 +1,29 @@
|
|||||||
|
#!/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
|
||||||
|
*@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,375 @@
|
|||||||
|
#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 createProfileRejectsMissingUsernameForSsh();
|
||||||
|
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::createProfileRejectsMissingUsernameForSsh()
|
||||||
|
{
|
||||||
|
Profile profile = makeSshProfile();
|
||||||
|
profile.username.clear();
|
||||||
|
QVERIFY(!m_repo->createProfile(profile).has_value());
|
||||||
|
}
|
||||||
|
|
||||||
|
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,236 @@
|
|||||||
|
#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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
|
||||||
|
private:
|
||||||
|
QString fixturePath() const;
|
||||||
|
void createBackend(const QString& fixtureHost);
|
||||||
|
|
||||||
|
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)
|
||||||
|
{
|
||||||
|
m_backend =
|
||||||
|
std::make_unique<SshSessionBackend>(makeProfile(fixtureHost), 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
QTEST_GUILESS_MAIN(TestSshSessionBackend)
|
||||||
|
#include "test_ssh_session_backend.moc"
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
3.23.1-dev0
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
cmake_minimum_required(VERSION 3.21)
|
||||||
|
|
||||||
|
project(OrbitHubUserGuidePdf LANGUAGES CXX)
|
||||||
|
|
||||||
|
set(CMAKE_CXX_STANDARD 17)
|
||||||
|
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||||
|
|
||||||
|
find_package(Qt6 6.2 REQUIRED COMPONENTS Widgets PrintSupport)
|
||||||
|
|
||||||
|
add_executable(user-guide-pdf main.cpp)
|
||||||
|
target_link_libraries(user-guide-pdf PRIVATE Qt6::Widgets Qt6::PrintSupport)
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
#include <QApplication>
|
||||||
|
#include <QFile>
|
||||||
|
#include <QPageSize>
|
||||||
|
#include <QPrinter>
|
||||||
|
#include <QTextDocument>
|
||||||
|
#include <QTextStream>
|
||||||
|
|
||||||
|
#include <cstdio>
|
||||||
|
|
||||||
|
int main(int argc, char* argv[])
|
||||||
|
{
|
||||||
|
QApplication app(argc, argv);
|
||||||
|
|
||||||
|
if (argc != 3) {
|
||||||
|
std::fprintf(stderr, "Usage: %s <input.md> <output.pdf>\n", argv[0]);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const QString inputPath = QString::fromLocal8Bit(argv[1]);
|
||||||
|
const QString outputPath = QString::fromLocal8Bit(argv[2]);
|
||||||
|
|
||||||
|
QFile input(inputPath);
|
||||||
|
if (!input.open(QIODevice::ReadOnly | QIODevice::Text)) {
|
||||||
|
std::fprintf(stderr, "Could not open %s\n", qPrintable(inputPath));
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
QTextStream stream(&input);
|
||||||
|
const QString markdown = stream.readAll();
|
||||||
|
|
||||||
|
QTextDocument document;
|
||||||
|
document.setMarkdown(markdown);
|
||||||
|
|
||||||
|
QPrinter printer(QPrinter::HighResolution);
|
||||||
|
printer.setOutputFormat(QPrinter::PdfFormat);
|
||||||
|
printer.setPageSize(QPageSize(QPageSize::Letter));
|
||||||
|
printer.setPageMargins(QMarginsF(50, 50, 50, 50), QPageLayout::Point);
|
||||||
|
printer.setOutputFileName(outputPath);
|
||||||
|
|
||||||
|
document.print(&printer);
|
||||||
|
|
||||||
|
std::printf("Wrote %s\n", qPrintable(outputPath));
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user