Internal
Public Access
Compare commits
17
Commits
v2026.9.8.2
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3fab2f9de3 | ||
|
|
4f5cf8ecd9 | ||
|
|
df99c78998 | ||
|
|
dffca3afef | ||
|
|
4f8fa3272b | ||
|
|
96c8403f3b | ||
|
|
3e621219f1 | ||
|
|
e0969041a7 | ||
|
|
c4a62e8fb6 | ||
|
|
9a22597d4e | ||
|
|
e90e9b5abf | ||
|
|
92d8b62820 | ||
|
|
7ee930693e | ||
|
|
8e98c208c9 | ||
|
|
ce1e40a12d | ||
|
|
80bc50e54c | ||
|
|
1c66adb646 |
@@ -1,3 +1,5 @@
|
||||
/build/
|
||||
/dist/
|
||||
/.flatpak-builder/
|
||||
/build-doc-tool/
|
||||
/docs/USER_GUIDE.pdf
|
||||
|
||||
+31
-2
@@ -1,6 +1,6 @@
|
||||
cmake_minimum_required(VERSION 3.21)
|
||||
|
||||
project(OrbitHub VERSION 2026.9.8.2 LANGUAGES CXX)
|
||||
project(OrbitHub VERSION 2026.9.15 LANGUAGES CXX)
|
||||
|
||||
set(CMAKE_CXX_STANDARD 17)
|
||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||
@@ -16,6 +16,17 @@ find_package(Qt6 6.2 REQUIRED COMPONENTS Widgets Sql)
|
||||
|
||||
qt_standard_project_setup()
|
||||
|
||||
option(ORBITHUB_BUILD_TESTS "Build unit tests (requires Qt6::Test)" ON)
|
||||
if(ORBITHUB_BUILD_TESTS)
|
||||
find_package(Qt6 6.2 QUIET COMPONENTS Test)
|
||||
if(TARGET Qt6::Test)
|
||||
enable_testing()
|
||||
else()
|
||||
message(STATUS "Qt6::Test not found -- skipping unit tests (set ORBITHUB_BUILD_TESTS=OFF to silence this)")
|
||||
set(ORBITHUB_BUILD_TESTS OFF)
|
||||
endif()
|
||||
endif()
|
||||
|
||||
add_subdirectory(third_party/KodoTerm)
|
||||
|
||||
if(NOT EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/third_party/FreeRDP/CMakeLists.txt")
|
||||
@@ -81,6 +92,9 @@ add_subdirectory(third_party/FreeRDP EXCLUDE_FROM_ALL)
|
||||
set(ORBITHUB_SOURCES
|
||||
src/about_dialog.cpp
|
||||
src/about_dialog.h
|
||||
src/user_guide_dialog.cpp
|
||||
src/user_guide_dialog.h
|
||||
docs/user_guide.qrc
|
||||
src/app_icon.cpp
|
||||
src/app_icon.h
|
||||
src/main.cpp
|
||||
@@ -131,6 +145,7 @@ add_executable(orbithub WIN32 MACOSX_BUNDLE ${ORBITHUB_SOURCES})
|
||||
|
||||
target_link_libraries(orbithub PRIVATE Qt6::Widgets Qt6::Sql)
|
||||
target_link_libraries(orbithub PRIVATE KodoTerm::KodoTerm)
|
||||
target_compile_definitions(orbithub PRIVATE ORBITHUB_VERSION_STRING="${PROJECT_VERSION}")
|
||||
if(TARGET freerdp AND TARGET winpr)
|
||||
target_compile_definitions(orbithub PRIVATE ORBITHUB_HAS_FREERDP)
|
||||
target_include_directories(orbithub PRIVATE
|
||||
@@ -257,4 +272,18 @@ install(FILES
|
||||
packaging/linux/org.darksingularity.OrbitHub.metainfo.xml
|
||||
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,14 +16,17 @@ OrbitHub is in active development.
|
||||
- Milestones completed: M0-M5, and M7-M9
|
||||
- Current milestone: Milestone 10 (v1.0 Stabilization)
|
||||
- Deferred milestone: Milestone 6 (VNC Fully Working)
|
||||
- Latest checkpoint tag: `v2026.9.8.2`
|
||||
- Latest checkpoint tag: `v2026.9.15`
|
||||
- VNC implementation milestone (M6) is currently deferred
|
||||
|
||||
Progress and milestone details:
|
||||
- [docs/PROGRESS.md](docs/PROGRESS.md)
|
||||
|
||||
Latest release (installers for Windows, Linux, and macOS):
|
||||
- [v2026.9.8.2](https://git.darksingularity.org/DarkSingularity/orbithub/releases/tag/v2026.9.8.2)
|
||||
- [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
|
||||
|
||||
|
||||
@@ -140,3 +140,19 @@ right-click → **Open** to bypass Gatekeeper's unidentified-developer warning.
|
||||
|
||||
Output path:
|
||||
- `dist/macos/OrbitHub-<version>.dmg`
|
||||
|
||||
## User Guide PDF
|
||||
|
||||
The in-app User Guide (`Help -> User Guide`) is built from
|
||||
`docs/USER_GUIDE.md` and embedded into the app at compile time — no extra
|
||||
step needed for that. A standalone PDF version is generated separately by a
|
||||
small tool (kept out of the main app's dependencies, since it needs
|
||||
`Qt6::PrintSupport`):
|
||||
|
||||
```bash
|
||||
./packaging/docs/build-user-guide-pdf.sh
|
||||
```
|
||||
|
||||
Output path:
|
||||
- `docs/USER_GUIDE.pdf` (not committed to git — a release asset, like the
|
||||
platform installers)
|
||||
|
||||
+34
-1
@@ -27,6 +27,12 @@ submission is an external process with its own requirements.
|
||||
| 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
|
||||
|
||||
@@ -40,6 +46,32 @@ 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
|
||||
@@ -53,7 +85,8 @@ 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.
|
||||
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
|
||||
|
||||
@@ -199,6 +199,10 @@ Git:
|
||||
- Tag: `v0-m9-done`
|
||||
- Release: [v2026.9.8](https://git.darksingularity.org/DarkSingularity/orbithub/releases/tag/v2026.9.8) (`v2026.9.8` tag, installers for Windows/Linux/macOS)
|
||||
- Release: [v2026.9.8.2](https://git.darksingularity.org/DarkSingularity/orbithub/releases/tag/v2026.9.8.2) — same-day patch fixing RDP TLS certificate verification (was fully disabled) and preparing Flatpak packaging for Flathub submission
|
||||
- Release: [v2026.9.8.3](https://git.darksingularity.org/DarkSingularity/orbithub/releases/tag/v2026.9.8.3) — same-day patch adding an in-app User Guide and standalone User Guide PDF
|
||||
- Release: [v2026.9.14](https://git.darksingularity.org/DarkSingularity/orbithub/releases/tag/v2026.9.14) — fixes distorted RDP text on HiDPI monitors and reduces RDP resize-related display glitches
|
||||
- Release: [v2026.9.14.2](https://git.darksingularity.org/DarkSingularity/orbithub/releases/tag/v2026.9.14.2) — same-day patch fixing RDP display corruption (missing/misplaced taskbar) after resizing the session window (the client never resized its own display buffer for channel-driven RDP resizes)
|
||||
- Release: [v2026.9.15](https://git.darksingularity.org/DarkSingularity/orbithub/releases/tag/v2026.9.15) — adds Import/Export for profile lists (File menu)
|
||||
|
||||
## Milestone 10 - v1.0 Stabilization
|
||||
|
||||
|
||||
@@ -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).
|
||||
@@ -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"
|
||||
@@ -19,5 +19,5 @@ modules:
|
||||
sources:
|
||||
- type: git
|
||||
url: https://git.darksingularity.org/DarkSingularity/orbithub.git
|
||||
tag: v2026.9.8
|
||||
commit: 68214db744714f19e0dcf01c1e535afed2dab1e7
|
||||
tag: v2026.9.15
|
||||
commit: dffca3afef80b5a3ca4832e0b7f748775cb90328
|
||||
|
||||
@@ -17,6 +17,11 @@ mkdir -p "$DIST_DIR"
|
||||
rm -rf "$STAGE_DIR"
|
||||
mkdir -p "$PKG_ROOT/DEBIAN"
|
||||
|
||||
# Read VERSION only after the build (which reconfigures CMakeCache.txt if
|
||||
# CMakeLists.txt changed since the build dir was last configured) --
|
||||
# reading it beforehand risks packaging a stale version string.
|
||||
cmake --build "$BUILD_DIR" -j
|
||||
|
||||
VERSION="$(sed -n 's/^CMAKE_PROJECT_VERSION:STATIC=//p' "$BUILD_DIR/CMakeCache.txt" | head -n1)"
|
||||
ARCH="$(dpkg --print-architecture)"
|
||||
|
||||
@@ -25,7 +30,6 @@ if [[ -z "$VERSION" ]]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cmake --build "$BUILD_DIR" -j
|
||||
cmake --install "$BUILD_DIR" --prefix "$PKG_ROOT/usr"
|
||||
|
||||
cat > "$PKG_ROOT/DEBIAN/control" <<EOF
|
||||
|
||||
@@ -34,6 +34,30 @@
|
||||
</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>
|
||||
|
||||
@@ -14,12 +14,6 @@ if [[ ! -f "$BUILD_DIR/CMakeCache.txt" ]]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
VERSION="$(sed -n 's/^CMAKE_PROJECT_VERSION:STATIC=//p' "$BUILD_DIR/CMakeCache.txt" | head -n1)"
|
||||
if [[ -z "$VERSION" ]]; then
|
||||
echo "Unable to determine project version from $BUILD_DIR/CMakeCache.txt" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
MACDEPLOYQT="$(brew --prefix qt@6)/bin/macdeployqt"
|
||||
if [[ ! -x "$MACDEPLOYQT" ]]; then
|
||||
echo "macdeployqt not found at $MACDEPLOYQT" >&2
|
||||
@@ -31,7 +25,17 @@ mkdir -p "$DIST_DIR"
|
||||
rm -rf "$STAGE_DIR" "$INSTALL_PREFIX"
|
||||
mkdir -p "$STAGE_DIR"
|
||||
|
||||
# Read VERSION only after the build (which reconfigures CMakeCache.txt if
|
||||
# CMakeLists.txt changed since the build dir was last configured) --
|
||||
# reading it beforehand risks packaging a stale version string.
|
||||
cmake --build "$BUILD_DIR" -j
|
||||
|
||||
VERSION="$(sed -n 's/^CMAKE_PROJECT_VERSION:STATIC=//p' "$BUILD_DIR/CMakeCache.txt" | head -n1)"
|
||||
if [[ -z "$VERSION" ]]; then
|
||||
echo "Unable to determine project version from $BUILD_DIR/CMakeCache.txt" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cmake --install "$BUILD_DIR" --prefix "$INSTALL_PREFIX"
|
||||
|
||||
if [[ ! -d "$INSTALL_PREFIX/$APP_BUNDLE" ]]; then
|
||||
|
||||
+27
-2
@@ -8,6 +8,31 @@
|
||||
#include <QTextBrowser>
|
||||
#include <QVBoxLayout>
|
||||
|
||||
namespace {
|
||||
// palette(mid) is meant for 3D bevel/shadow decoration, not text — it has
|
||||
// poor contrast against the window background in dark themes (barely
|
||||
// legible). Blend the widget's actual text and window colors instead, so
|
||||
// the result is reliably readable — de-emphasized relative to full-strength
|
||||
// text, but never low-contrast — in either a light or dark theme.
|
||||
QColor mutedTextColor(const QWidget* widget)
|
||||
{
|
||||
const QPalette pal = widget->palette();
|
||||
const QColor text = pal.color(QPalette::WindowText);
|
||||
const QColor background = pal.color(QPalette::Window);
|
||||
constexpr qreal kTextWeight = 0.65;
|
||||
|
||||
auto blend = [kTextWeight](int textChannel, int backgroundChannel) {
|
||||
return qBound(0,
|
||||
qRound(textChannel * kTextWeight + backgroundChannel * (1.0 - kTextWeight)),
|
||||
255);
|
||||
};
|
||||
|
||||
return QColor(blend(text.red(), background.red()),
|
||||
blend(text.green(), background.green()),
|
||||
blend(text.blue(), background.blue()));
|
||||
}
|
||||
}
|
||||
|
||||
AboutDialog::AboutDialog(QWidget* parent) : QDialog(parent)
|
||||
{
|
||||
setWindowTitle(QStringLiteral("About OrbitHub"));
|
||||
@@ -34,7 +59,7 @@ AboutDialog::AboutDialog(QWidget* parent) : QDialog(parent)
|
||||
QStringLiteral("Unified remote session manager for SSH, RDP, and VNC workflows."),
|
||||
this);
|
||||
subtitle->setWordWrap(true);
|
||||
subtitle->setStyleSheet(QStringLiteral("color: palette(mid);"));
|
||||
subtitle->setStyleSheet(QStringLiteral("color: %1;").arg(mutedTextColor(this).name()));
|
||||
|
||||
const QString version = QCoreApplication::applicationVersion().trimmed().isEmpty()
|
||||
? QStringLiteral("Development build")
|
||||
@@ -42,7 +67,7 @@ AboutDialog::AboutDialog(QWidget* parent) : QDialog(parent)
|
||||
auto* buildLine = new QLabel(
|
||||
QStringLiteral("Version: %1 | Qt runtime linked dynamically").arg(version),
|
||||
this);
|
||||
buildLine->setStyleSheet(QStringLiteral("color: palette(mid);"));
|
||||
buildLine->setStyleSheet(QStringLiteral("color: %1;").arg(mutedTextColor(this).name()));
|
||||
|
||||
titleColumn->addWidget(title);
|
||||
titleColumn->addWidget(subtitle);
|
||||
|
||||
@@ -10,6 +10,9 @@ int main(int argc, char* argv[])
|
||||
QApplication app(argc, argv);
|
||||
app.setOrganizationName(QStringLiteral("FireBugIT"));
|
||||
app.setApplicationName(QStringLiteral("OrbitHub"));
|
||||
#ifdef ORBITHUB_VERSION_STRING
|
||||
app.setApplicationVersion(QStringLiteral(ORBITHUB_VERSION_STRING));
|
||||
#endif
|
||||
app.setWindowIcon(createOrbitHubAppIcon());
|
||||
|
||||
SessionWindow window;
|
||||
|
||||
@@ -9,7 +9,11 @@
|
||||
#include <QVariant>
|
||||
#include <QStringList>
|
||||
|
||||
#include <atomic>
|
||||
|
||||
namespace {
|
||||
std::atomic<int> g_testConnectionCounter{0};
|
||||
|
||||
QString buildDatabasePath()
|
||||
{
|
||||
QString appDataPath = QStandardPaths::writableLocation(QStandardPaths::AppDataLocation);
|
||||
@@ -176,7 +180,7 @@ void bindProfileFields(QSqlQuery& query, const Profile& profile)
|
||||
: QStringLiteral("Negotiate"));
|
||||
query.addBindValue(isRdp ? normalizedRdpPerformanceProfile(profile.rdpPerformanceProfile)
|
||||
: QStringLiteral("Balanced"));
|
||||
query.addBindValue(normalizedTags(profile.tags));
|
||||
query.addBindValue(nonNullTrimmed(normalizedTags(profile.tags)));
|
||||
}
|
||||
|
||||
Profile profileFromQuery(const QSqlQuery& query)
|
||||
@@ -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()
|
||||
{
|
||||
if (QSqlDatabase::contains(m_connectionName)) {
|
||||
@@ -485,7 +499,8 @@ bool ProfileRepository::deleteProfile(qint64 id) const
|
||||
bool ProfileRepository::initializeDatabase()
|
||||
{
|
||||
QSqlDatabase database = QSqlDatabase::addDatabase(QStringLiteral("QSQLITE"), m_connectionName);
|
||||
database.setDatabaseName(buildDatabasePath());
|
||||
database.setDatabaseName(
|
||||
m_databasePathOverride.isEmpty() ? buildDatabasePath() : m_databasePathOverride);
|
||||
|
||||
if (!database.open()) {
|
||||
m_initError = database.lastError().text();
|
||||
|
||||
@@ -35,6 +35,9 @@ class ProfileRepository
|
||||
{
|
||||
public:
|
||||
ProfileRepository();
|
||||
// databasePathOverride lets tests point the repository at an isolated,
|
||||
// disposable SQLite file instead of the real app-data location.
|
||||
explicit ProfileRepository(const QString& databasePathOverride);
|
||||
~ProfileRepository();
|
||||
|
||||
QString initError() const;
|
||||
@@ -51,6 +54,7 @@ public:
|
||||
|
||||
private:
|
||||
QString m_connectionName;
|
||||
QString m_databasePathOverride;
|
||||
QString m_initError;
|
||||
mutable QString m_lastError;
|
||||
|
||||
|
||||
@@ -7,8 +7,13 @@
|
||||
#include <QAction>
|
||||
#include <QAbstractItemView>
|
||||
#include <QComboBox>
|
||||
#include <QFile>
|
||||
#include <QFileDialog>
|
||||
#include <QHeaderView>
|
||||
#include <QHBoxLayout>
|
||||
#include <QJsonArray>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
#include <QLabel>
|
||||
#include <QLineEdit>
|
||||
#include <QInputDialog>
|
||||
@@ -85,6 +90,53 @@ bool profileHasTag(const Profile& profile, const QString& requestedTag)
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
constexpr int kProfileExportFormatVersion = 1;
|
||||
|
||||
QJsonObject profileToJson(const Profile& profile)
|
||||
{
|
||||
QJsonObject object;
|
||||
object.insert(QStringLiteral("name"), profile.name);
|
||||
object.insert(QStringLiteral("host"), profile.host);
|
||||
object.insert(QStringLiteral("port"), profile.port);
|
||||
object.insert(QStringLiteral("username"), profile.username);
|
||||
object.insert(QStringLiteral("domain"), profile.domain);
|
||||
object.insert(QStringLiteral("folderPath"), profile.folderPath);
|
||||
object.insert(QStringLiteral("protocol"), profile.protocol);
|
||||
object.insert(QStringLiteral("authMode"), profile.authMode);
|
||||
object.insert(QStringLiteral("privateKeyPath"), profile.privateKeyPath);
|
||||
object.insert(QStringLiteral("knownHostsPolicy"), profile.knownHostsPolicy);
|
||||
object.insert(QStringLiteral("rdpSecurityMode"), profile.rdpSecurityMode);
|
||||
object.insert(QStringLiteral("rdpPerformanceProfile"), profile.rdpPerformanceProfile);
|
||||
object.insert(QStringLiteral("tags"), profile.tags);
|
||||
return object;
|
||||
}
|
||||
|
||||
// Deliberately excludes id (import always creates new rows -- an imported
|
||||
// profile's id has no meaning in the destination database) and any
|
||||
// credential material (none is ever persisted on Profile in the first
|
||||
// place, see ProfileRepository).
|
||||
Profile profileFromJson(const QJsonObject& object)
|
||||
{
|
||||
Profile profile;
|
||||
profile.name = object.value(QStringLiteral("name")).toString();
|
||||
profile.host = object.value(QStringLiteral("host")).toString();
|
||||
profile.port = object.value(QStringLiteral("port")).toInt(22);
|
||||
profile.username = object.value(QStringLiteral("username")).toString();
|
||||
profile.domain = object.value(QStringLiteral("domain")).toString();
|
||||
profile.folderPath = object.value(QStringLiteral("folderPath")).toString();
|
||||
profile.protocol = object.value(QStringLiteral("protocol")).toString(QStringLiteral("SSH"));
|
||||
profile.authMode = object.value(QStringLiteral("authMode")).toString(QStringLiteral("Password"));
|
||||
profile.privateKeyPath = object.value(QStringLiteral("privateKeyPath")).toString();
|
||||
profile.knownHostsPolicy =
|
||||
object.value(QStringLiteral("knownHostsPolicy")).toString(QStringLiteral("Ask"));
|
||||
profile.rdpSecurityMode =
|
||||
object.value(QStringLiteral("rdpSecurityMode")).toString(QStringLiteral("Negotiate"));
|
||||
profile.rdpPerformanceProfile =
|
||||
object.value(QStringLiteral("rdpPerformanceProfile")).toString(QStringLiteral("Balanced"));
|
||||
profile.tags = object.value(QStringLiteral("tags")).toString();
|
||||
return profile;
|
||||
}
|
||||
}
|
||||
|
||||
ProfilesWindow::ProfilesWindow(QWidget* parent)
|
||||
@@ -930,3 +982,128 @@ void ProfilesWindow::createFolderInCurrentContext()
|
||||
const QString folderPath = folderPathForItem(m_profilesTree->currentItem());
|
||||
createFolderInContext(folderPath);
|
||||
}
|
||||
|
||||
void ProfilesWindow::exportProfiles()
|
||||
{
|
||||
const QString fileName = QFileDialog::getSaveFileName(
|
||||
this, QStringLiteral("Export Profiles"), QStringLiteral("orbithub-profiles.json"),
|
||||
QStringLiteral("JSON Files (*.json)"));
|
||||
if (fileName.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
QJsonArray folders;
|
||||
for (const QString& folderPath : m_repository->listFolders()) {
|
||||
folders.append(folderPath);
|
||||
}
|
||||
|
||||
QJsonArray profiles;
|
||||
for (const Profile& profile : m_repository->listProfiles()) {
|
||||
profiles.append(profileToJson(profile));
|
||||
}
|
||||
|
||||
QJsonObject root;
|
||||
root.insert(QStringLiteral("orbithubProfileExport"), kProfileExportFormatVersion);
|
||||
root.insert(QStringLiteral("folders"), folders);
|
||||
root.insert(QStringLiteral("profiles"), profiles);
|
||||
|
||||
QFile file(fileName);
|
||||
if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate)) {
|
||||
QMessageBox::warning(this,
|
||||
QStringLiteral("Export Profiles"),
|
||||
QStringLiteral("Failed to write %1: %2")
|
||||
.arg(fileName, file.errorString()));
|
||||
return;
|
||||
}
|
||||
file.write(QJsonDocument(root).toJson(QJsonDocument::Indented));
|
||||
file.close();
|
||||
|
||||
QMessageBox::information(this,
|
||||
QStringLiteral("Export Profiles"),
|
||||
QStringLiteral("Exported %1 profile(s) to %2.")
|
||||
.arg(profiles.size())
|
||||
.arg(fileName));
|
||||
}
|
||||
|
||||
void ProfilesWindow::importProfiles()
|
||||
{
|
||||
const QString fileName = QFileDialog::getOpenFileName(
|
||||
this, QStringLiteral("Import Profiles"), QString(), QStringLiteral("JSON Files (*.json)"));
|
||||
if (fileName.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
QFile file(fileName);
|
||||
if (!file.open(QIODevice::ReadOnly)) {
|
||||
QMessageBox::warning(this,
|
||||
QStringLiteral("Import Profiles"),
|
||||
QStringLiteral("Failed to read %1: %2")
|
||||
.arg(fileName, file.errorString()));
|
||||
return;
|
||||
}
|
||||
|
||||
QJsonParseError parseError{};
|
||||
const QJsonDocument doc = QJsonDocument::fromJson(file.readAll(), &parseError);
|
||||
file.close();
|
||||
if (parseError.error != QJsonParseError::NoError || !doc.isObject()) {
|
||||
QMessageBox::warning(this,
|
||||
QStringLiteral("Import Profiles"),
|
||||
QStringLiteral("%1 is not a valid OrbitHub profile export: %2")
|
||||
.arg(fileName, parseError.errorString()));
|
||||
return;
|
||||
}
|
||||
|
||||
const QJsonObject root = doc.object();
|
||||
if (!root.value(QStringLiteral("profiles")).isArray()) {
|
||||
QMessageBox::warning(this,
|
||||
QStringLiteral("Import Profiles"),
|
||||
QStringLiteral("%1 does not contain a profile list.").arg(fileName));
|
||||
return;
|
||||
}
|
||||
|
||||
for (const QJsonValue& folderValue : root.value(QStringLiteral("folders")).toArray()) {
|
||||
const QString folderPath = folderValue.toString();
|
||||
if (!folderPath.isEmpty()) {
|
||||
m_repository->createFolder(folderPath);
|
||||
}
|
||||
}
|
||||
|
||||
const QJsonArray profilesArray = root.value(QStringLiteral("profiles")).toArray();
|
||||
int imported = 0;
|
||||
QStringList failures;
|
||||
for (const QJsonValue& profileValue : profilesArray) {
|
||||
if (!profileValue.isObject()) {
|
||||
continue;
|
||||
}
|
||||
const Profile profile = profileFromJson(profileValue.toObject());
|
||||
if (profile.name.isEmpty() || profile.host.isEmpty()) {
|
||||
failures.push_back(QStringLiteral("(unnamed profile): missing name or host"));
|
||||
continue;
|
||||
}
|
||||
// No need to createFolder(profile.folderPath) here: the tree view
|
||||
// already synthesizes folder nodes from a profile's own folderPath
|
||||
// (see ProfilesWindow::loadProfiles). Explicit profile_folders rows
|
||||
// are only for folders with no profiles in them, and those are
|
||||
// already recreated above from the export's top-level "folders"
|
||||
// list -- doing it again per-profile would just add spurious
|
||||
// entries not present in the original export.
|
||||
if (m_repository->createProfile(profile).has_value()) {
|
||||
++imported;
|
||||
} else {
|
||||
failures.push_back(QStringLiteral("%1: %2").arg(profile.name, m_repository->lastError()));
|
||||
}
|
||||
}
|
||||
|
||||
loadProfiles();
|
||||
|
||||
QString summary = QStringLiteral("Imported %1 of %2 profile(s) from %3.")
|
||||
.arg(imported)
|
||||
.arg(profilesArray.size())
|
||||
.arg(fileName);
|
||||
if (!failures.isEmpty()) {
|
||||
summary += QStringLiteral("\n\nFailed:\n%1").arg(failures.join(QChar::fromLatin1('\n')));
|
||||
QMessageBox::warning(this, QStringLiteral("Import Profiles"), summary);
|
||||
} else {
|
||||
QMessageBox::information(this, QStringLiteral("Import Profiles"), summary);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +32,8 @@ public:
|
||||
|
||||
void createProfileInCurrentContext();
|
||||
void createFolderInCurrentContext();
|
||||
void exportProfiles();
|
||||
void importProfiles();
|
||||
|
||||
signals:
|
||||
void connectRequested(const Profile& profile);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#include "rdp_display_widget.h"
|
||||
|
||||
#include <QCursor>
|
||||
#include <QEvent>
|
||||
#include <QKeyEvent>
|
||||
#include <QMouseEvent>
|
||||
#include <QPainter>
|
||||
@@ -15,20 +16,38 @@ QSize sanitizeSize(const QSize& size)
|
||||
{
|
||||
return QSize(qMax(1, size.width()), qMax(1, size.height()));
|
||||
}
|
||||
|
||||
qreal sanitizeDevicePixelRatio(qreal ratio)
|
||||
{
|
||||
if (!(ratio > 0.0)) {
|
||||
return 1.0;
|
||||
}
|
||||
return qBound(1.0, ratio, 4.0);
|
||||
}
|
||||
|
||||
// Windows' virtual-display driver can visibly glitch (stale composited
|
||||
// content left on screen) when asked to change resolution repeatedly in
|
||||
// quick succession, which naturally happens as the window's layout settles
|
||||
// right after creation/connect. Coalescing bursts of resize events into one
|
||||
// request avoids triggering that.
|
||||
constexpr int kResizeDebounceMs = 150;
|
||||
}
|
||||
|
||||
RdpDisplayWidget::RdpDisplayWidget(QWidget* parent)
|
||||
: QWidget(parent), m_remoteSize(1280, 720), m_cursorMode(CursorMode::Default)
|
||||
: QWidget(parent),
|
||||
m_remoteSize(1280, 720),
|
||||
m_cursorMode(CursorMode::Default),
|
||||
m_resizeDebounceTimer(new QTimer(this))
|
||||
{
|
||||
setFocusPolicy(Qt::StrongFocus);
|
||||
setMouseTracking(true);
|
||||
setAutoFillBackground(false);
|
||||
setMinimumSize(320, 200);
|
||||
|
||||
QTimer::singleShot(0, this, [this]() {
|
||||
const QSize size = sanitizeSize(this->size());
|
||||
emit viewportSizeChanged(size.width(), size.height());
|
||||
});
|
||||
m_resizeDebounceTimer->setSingleShot(true);
|
||||
connect(m_resizeDebounceTimer, &QTimer::timeout, this, &RdpDisplayWidget::emitViewportGeometry);
|
||||
|
||||
scheduleViewportGeometryEmit();
|
||||
}
|
||||
|
||||
void RdpDisplayWidget::setFrame(const QImage& frame)
|
||||
@@ -54,6 +73,13 @@ void RdpDisplayWidget::setRemoteDesktopSize(int width, int height)
|
||||
}
|
||||
|
||||
m_remoteSize = nextSize;
|
||||
// The next actual frame (via setFrame) arrives asynchronously and isn't
|
||||
// guaranteed to be sized to match yet. Drawing the old frame stretched
|
||||
// to a renderRect() computed from the new m_remoteSize would scale it
|
||||
// by the wrong factor for the transition window, producing visibly
|
||||
// distorted/duplicated-looking content. Clear it and show the existing
|
||||
// "waiting for frame" placeholder until a correctly-sized frame lands.
|
||||
m_frame = QImage();
|
||||
update();
|
||||
}
|
||||
|
||||
@@ -140,11 +166,39 @@ void RdpDisplayWidget::paintEvent(QPaintEvent* event)
|
||||
void RdpDisplayWidget::resizeEvent(QResizeEvent* event)
|
||||
{
|
||||
QWidget::resizeEvent(event);
|
||||
const QSize size = sanitizeSize(event->size());
|
||||
emit viewportSizeChanged(size.width(), size.height());
|
||||
scheduleViewportGeometryEmit();
|
||||
applyCursor();
|
||||
}
|
||||
|
||||
bool RdpDisplayWidget::event(QEvent* event)
|
||||
{
|
||||
// Fires when this widget's effective screen changes (e.g. dragged to a
|
||||
// different monitor), which is what changes devicePixelRatio(). Newer
|
||||
// Qt versions add a more specific QEvent::DevicePixelRatioChange, but
|
||||
// this project's Qt 6.2 floor doesn't have it.
|
||||
if (event->type() == QEvent::ScreenChangeInternal) {
|
||||
scheduleViewportGeometryEmit();
|
||||
}
|
||||
return QWidget::event(event);
|
||||
}
|
||||
|
||||
void RdpDisplayWidget::scheduleViewportGeometryEmit()
|
||||
{
|
||||
// Restarting an already-running single-shot timer resets its countdown,
|
||||
// so a burst of resize events collapses into one emission after things
|
||||
// settle, rather than one request per event.
|
||||
m_resizeDebounceTimer->start(kResizeDebounceMs);
|
||||
}
|
||||
|
||||
void RdpDisplayWidget::emitViewportGeometry()
|
||||
{
|
||||
const QSize logicalSize = sanitizeSize(this->size());
|
||||
const qreal ratio = sanitizeDevicePixelRatio(this->devicePixelRatioF());
|
||||
const QSize physicalSize(qRound(logicalSize.width() * ratio), qRound(logicalSize.height() * ratio));
|
||||
emit viewportSizeChanged(physicalSize.width(), physicalSize.height());
|
||||
emit displayScaleChanged(ratio);
|
||||
}
|
||||
|
||||
void RdpDisplayWidget::keyPressEvent(QKeyEvent* event)
|
||||
{
|
||||
if (event == nullptr) {
|
||||
|
||||
@@ -8,6 +8,7 @@ class QKeyEvent;
|
||||
class QMouseEvent;
|
||||
class QPaintEvent;
|
||||
class QResizeEvent;
|
||||
class QTimer;
|
||||
class QWheelEvent;
|
||||
|
||||
class RdpDisplayWidget : public QWidget
|
||||
@@ -30,10 +31,12 @@ signals:
|
||||
void mouseButtonInput(int x, int y, int button, bool pressed);
|
||||
void mouseWheelInput(int x, int y, int deltaX, int deltaY);
|
||||
void viewportSizeChanged(int width, int height);
|
||||
void displayScaleChanged(qreal devicePixelRatio);
|
||||
|
||||
protected:
|
||||
void paintEvent(QPaintEvent* event) override;
|
||||
void resizeEvent(QResizeEvent* event) override;
|
||||
bool event(QEvent* event) override;
|
||||
void keyPressEvent(QKeyEvent* event) override;
|
||||
void keyReleaseEvent(QKeyEvent* event) override;
|
||||
void mousePressEvent(QMouseEvent* event) override;
|
||||
@@ -54,11 +57,14 @@ private:
|
||||
QImage m_cursorImage;
|
||||
QPoint m_cursorHotspot;
|
||||
CursorMode m_cursorMode;
|
||||
QTimer* m_resizeDebounceTimer;
|
||||
|
||||
QRectF renderRect() const;
|
||||
QPoint mapToRemote(const QPointF& pos) const;
|
||||
QSize effectiveRemoteSize() const;
|
||||
void applyCursor();
|
||||
void emitViewportGeometry();
|
||||
void scheduleViewportGeometryEmit();
|
||||
};
|
||||
|
||||
#endif
|
||||
|
||||
+113
-5
@@ -104,6 +104,22 @@ QString normalizedRdpSecurityMode(const QString& value)
|
||||
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.
|
||||
UINT32 nearestFreeRdpScaleValue(qreal ratio)
|
||||
{
|
||||
if (ratio <= 1.2) {
|
||||
return 100;
|
||||
}
|
||||
if (ratio <= 1.6) {
|
||||
return 140;
|
||||
}
|
||||
return 180;
|
||||
}
|
||||
|
||||
QString normalizedRdpPerformanceProfile(const QString& value)
|
||||
{
|
||||
const QString profile = value.trimmed();
|
||||
@@ -1304,6 +1320,7 @@ RdpSessionBackend::RdpSessionBackend(const Profile& profile, QObject* parent)
|
||||
m_userInitiatedDisconnect(false),
|
||||
m_requestedDesktopWidth(kDefaultDesktopWidth),
|
||||
m_requestedDesktopHeight(kDefaultDesktopHeight),
|
||||
m_devicePixelRatio(1.0),
|
||||
m_workerRunning(false),
|
||||
m_stopRequested(false),
|
||||
m_instance(nullptr),
|
||||
@@ -1312,6 +1329,7 @@ RdpSessionBackend::RdpSessionBackend(const Profile& profile, QObject* parent)
|
||||
m_resizeFailureLogged(false),
|
||||
m_lastResizeWidth(0),
|
||||
m_lastResizeHeight(0),
|
||||
m_lastResizeScale(0),
|
||||
m_cliprdrContext(nullptr)
|
||||
{
|
||||
}
|
||||
@@ -1403,6 +1421,28 @@ void RdpSessionBackend::updateTerminalSize(int columns, int rows)
|
||||
enqueueInputEvent(event);
|
||||
}
|
||||
|
||||
void RdpSessionBackend::updateDisplayScale(qreal devicePixelRatio)
|
||||
{
|
||||
const qreal clamped = qBound(1.0, devicePixelRatio, 4.0);
|
||||
m_devicePixelRatio.store(clamped);
|
||||
|
||||
if (!m_workerRunning.load()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Reuses the resize input-event path so the worker thread (which owns
|
||||
// m_instance) picks this up safely; processInputEvents' dedup check
|
||||
// also compares the quantized scale value, so this correctly triggers
|
||||
// a fresh SendMonitorLayout even when width/height haven't changed
|
||||
// (e.g. the window moved to a different-DPI monitor at the same
|
||||
// logical size).
|
||||
InputEvent event;
|
||||
event.type = InputEventType::Resize;
|
||||
event.width = sanitizeDesktopWidth(m_requestedDesktopWidth.load());
|
||||
event.height = sanitizeDesktopHeight(m_requestedDesktopHeight.load());
|
||||
enqueueInputEvent(event);
|
||||
}
|
||||
|
||||
void RdpSessionBackend::sendKeyEvent(int key,
|
||||
quint32 nativeScanCode,
|
||||
const QString& text,
|
||||
@@ -1637,9 +1677,29 @@ void RdpSessionBackend::workerMain()
|
||||
const QString performanceProfile = normalizedRdpPerformanceProfile(p.rdpPerformanceProfile);
|
||||
freerdp_settings_set_bool(settings, FreeRDP_SupportDisplayControl, TRUE);
|
||||
freerdp_settings_set_bool(settings, FreeRDP_DynamicResolutionUpdate, TRUE);
|
||||
// Lets us actively request a full-screen repaint after a resize (see
|
||||
// sendDisplayResize) — some RDP hosts (particularly VMs using a
|
||||
// synthetic/virtual display driver) occasionally fail to fully redraw
|
||||
// their own desktop after a resolution change; requesting a refresh
|
||||
// forces them to resend everything rather than leaving stale content.
|
||||
freerdp_settings_set_bool(settings, FreeRDP_RefreshRect, TRUE);
|
||||
freerdp_settings_set_uint32(settings, FreeRDP_DesktopWidth, static_cast<UINT32>(desktopWidth));
|
||||
freerdp_settings_set_uint32(settings, FreeRDP_DesktopHeight, static_cast<UINT32>(desktopHeight));
|
||||
freerdp_settings_set_uint32(settings, FreeRDP_ColorDepth, 32);
|
||||
// Tell the remote session the real display scale so it renders its own
|
||||
// UI/ClearType text natively at that size, instead of assuming 96 DPI
|
||||
// and having the result stretched (and ClearType's subpixel hinting
|
||||
// distorted) by client-side scaling. The override flags are required —
|
||||
// without them FreeRDP ignores the scale factor values entirely.
|
||||
{
|
||||
const UINT32 scaleValue = nearestFreeRdpScaleValue(m_devicePixelRatio.load());
|
||||
freerdp_settings_set_uint32(settings, FreeRDP_DesktopScaleFactor, scaleValue);
|
||||
freerdp_settings_set_uint32(settings, FreeRDP_DeviceScaleFactor, scaleValue);
|
||||
freerdp_settings_set_uint64(settings,
|
||||
FreeRDP_MonitorOverrideFlags,
|
||||
FREERDP_MONITOR_OVERRIDE_DESKTOP_SCALE
|
||||
| FREERDP_MONITOR_OVERRIDE_DEVICE_SCALE);
|
||||
}
|
||||
freerdp_settings_set_bool(settings, FreeRDP_AuthenticationOnly, FALSE);
|
||||
freerdp_settings_set_bool(settings, FreeRDP_AutoLogonEnabled, TRUE);
|
||||
if (!applyRdpSecurityMode(settings, securityMode)) {
|
||||
@@ -1831,9 +1891,14 @@ bool RdpSessionBackend::sendDisplayResize(rdp_freerdp* instance, int width, int
|
||||
return false;
|
||||
}
|
||||
|
||||
const qreal ratio = m_devicePixelRatio.load();
|
||||
const UINT32 scaleValue = nearestFreeRdpScaleValue(ratio);
|
||||
|
||||
rdpSettings* settings = instance->context->settings;
|
||||
freerdp_settings_set_uint32(settings, FreeRDP_DesktopWidth, static_cast<UINT32>(width));
|
||||
freerdp_settings_set_uint32(settings, FreeRDP_DesktopHeight, static_cast<UINT32>(height));
|
||||
freerdp_settings_set_uint32(settings, FreeRDP_DesktopScaleFactor, scaleValue);
|
||||
freerdp_settings_set_uint32(settings, FreeRDP_DeviceScaleFactor, scaleValue);
|
||||
|
||||
DispClientContext* dispContext = nullptr;
|
||||
bool displayControlReady = false;
|
||||
@@ -1851,17 +1916,58 @@ bool RdpSessionBackend::sendDisplayResize(rdp_freerdp* instance, int width, int
|
||||
layout.Width = static_cast<UINT32>(width);
|
||||
layout.Height = static_cast<UINT32>(height);
|
||||
layout.Orientation = ORIENTATION_LANDSCAPE;
|
||||
layout.DesktopScaleFactor = freerdp_settings_get_uint32(settings, FreeRDP_DesktopScaleFactor);
|
||||
layout.DeviceScaleFactor = freerdp_settings_get_uint32(settings, FreeRDP_DeviceScaleFactor);
|
||||
layout.DesktopScaleFactor = scaleValue;
|
||||
layout.DeviceScaleFactor = scaleValue;
|
||||
// Physical size in mm must reflect the real DPI (96 * ratio), not
|
||||
// the plain baseline, so it stays consistent with the scale factor
|
||||
// above rather than implying a standard-DPI display of this size.
|
||||
const double effectiveDpi = kDefaultDpi * ratio;
|
||||
layout.PhysicalWidth = static_cast<UINT32>(
|
||||
std::lround((static_cast<double>(width) / kDefaultDpi) * kMillimetersPerInch));
|
||||
std::lround((static_cast<double>(width) / effectiveDpi) * kMillimetersPerInch));
|
||||
layout.PhysicalHeight = static_cast<UINT32>(
|
||||
std::lround((static_cast<double>(height) / kDefaultDpi) * kMillimetersPerInch));
|
||||
std::lround((static_cast<double>(height) / effectiveDpi) * kMillimetersPerInch));
|
||||
|
||||
const UINT rc = dispContext->SendMonitorLayout(dispContext, 1, &layout);
|
||||
if (rc == CHANNEL_RC_OK) {
|
||||
m_lastResizeWidth = width;
|
||||
m_lastResizeHeight = height;
|
||||
m_lastResizeScale = static_cast<int>(scaleValue);
|
||||
|
||||
// The Display Control channel (MS-RDPEDISP) has no server
|
||||
// acknowledgment PDU, and real hosts apply the new resolution
|
||||
// without a Deactivation-Reactivation sequence — so
|
||||
// update->DesktopResize (which only fires for that sequence, or
|
||||
// for the GFX/Progressive pipeline we don't use) never runs for
|
||||
// a channel-driven resize. Without a matching gdi_resize() call,
|
||||
// gdi->width/height stay at the old size, and FreeRDP's own
|
||||
// surface-bits handling (intersect_rect in gdi.c) then silently
|
||||
// *drops* any update reaching outside those stale bounds —
|
||||
// which is what produced the missing/misplaced taskbar and
|
||||
// stale composited-looking content: this call is the fix, not
|
||||
// just a best-effort nudge.
|
||||
if (instance->context->gdi != nullptr
|
||||
&& gdi_resize(instance->context->gdi, static_cast<UINT32>(width),
|
||||
static_cast<UINT32>(height))) {
|
||||
emit remoteDesktopSizeChanged(width, height);
|
||||
} else if (instance->context->gdi != nullptr) {
|
||||
emit eventLogged(QStringLiteral(
|
||||
"RDP warning: local resize to %1x%2 failed; display may show stale content "
|
||||
"until the next full repaint.")
|
||||
.arg(width)
|
||||
.arg(height));
|
||||
}
|
||||
|
||||
// Best-effort nudge: some hosts don't fully repaint their own
|
||||
// desktop after a resolution change (observed: taskbar missing
|
||||
// until something else forces a redraw). Explicitly asking for
|
||||
// the whole new area to be resent costs little and helps
|
||||
// recover from that when it happens.
|
||||
if (instance->context->update != nullptr
|
||||
&& instance->context->update->RefreshRect != nullptr) {
|
||||
RECTANGLE_16 fullArea = {0, 0, static_cast<UINT16>(qMin(width, 65535)),
|
||||
static_cast<UINT16>(qMin(height, 65535))};
|
||||
instance->context->update->RefreshRect(instance->context, 1, &fullArea);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -2035,7 +2141,9 @@ void RdpSessionBackend::processInputEvents(rdp_freerdp* instance)
|
||||
if (hasResize) {
|
||||
const int width = sanitizeDesktopWidth(resizeWidth);
|
||||
const int height = sanitizeDesktopHeight(resizeHeight);
|
||||
if (width != m_lastResizeWidth || height != m_lastResizeHeight) {
|
||||
const int scaleValue = static_cast<int>(nearestFreeRdpScaleValue(m_devicePixelRatio.load()));
|
||||
if (width != m_lastResizeWidth || height != m_lastResizeHeight
|
||||
|| scaleValue != m_lastResizeScale) {
|
||||
if (sendDisplayResize(instance, width, height)) {
|
||||
if (m_resizeFailureLogged) {
|
||||
emit eventLogged(QStringLiteral("Dynamic RDP resize recovered."));
|
||||
|
||||
@@ -26,6 +26,7 @@ public slots:
|
||||
void sendInput(const QString& input) override;
|
||||
void confirmHostKey(bool trustHost) override;
|
||||
void updateTerminalSize(int columns, int rows) override;
|
||||
void updateDisplayScale(qreal devicePixelRatio) override;
|
||||
void sendKeyEvent(int key,
|
||||
quint32 nativeScanCode,
|
||||
const QString& text,
|
||||
@@ -68,6 +69,7 @@ private:
|
||||
|
||||
std::atomic_int m_requestedDesktopWidth;
|
||||
std::atomic_int m_requestedDesktopHeight;
|
||||
std::atomic<qreal> m_devicePixelRatio;
|
||||
|
||||
std::thread m_worker;
|
||||
std::atomic_bool m_workerRunning;
|
||||
@@ -85,6 +87,7 @@ private:
|
||||
bool m_resizeFailureLogged;
|
||||
int m_lastResizeWidth;
|
||||
int m_lastResizeHeight;
|
||||
int m_lastResizeScale;
|
||||
|
||||
std::mutex m_cliprdrMutex;
|
||||
void* m_cliprdrContext;
|
||||
|
||||
@@ -47,6 +47,10 @@ public slots:
|
||||
virtual void sendInput(const QString& input) = 0;
|
||||
virtual void confirmHostKey(bool trustHost) = 0;
|
||||
virtual void updateTerminalSize(int columns, int rows) = 0;
|
||||
virtual void updateDisplayScale(qreal devicePixelRatio)
|
||||
{
|
||||
Q_UNUSED(devicePixelRatio);
|
||||
}
|
||||
virtual void setClipboardText(const QString& text)
|
||||
{
|
||||
Q_UNUSED(text);
|
||||
|
||||
@@ -176,6 +176,11 @@ SessionTab::SessionTab(const Profile& profile,
|
||||
m_backend,
|
||||
&SessionBackend::updateTerminalSize,
|
||||
Qt::QueuedConnection);
|
||||
connect(this,
|
||||
&SessionTab::requestDisplayScale,
|
||||
m_backend,
|
||||
&SessionBackend::updateDisplayScale,
|
||||
Qt::QueuedConnection);
|
||||
connect(this,
|
||||
&SessionTab::requestKeyEvent,
|
||||
m_backend,
|
||||
@@ -860,6 +865,10 @@ void SessionTab::setupUi()
|
||||
&RdpDisplayWidget::viewportSizeChanged,
|
||||
this,
|
||||
[this](int width, int height) { emit requestTerminalSize(width, height); });
|
||||
connect(m_rdpDisplay,
|
||||
&RdpDisplayWidget::displayScaleChanged,
|
||||
this,
|
||||
[this](qreal ratio) { emit requestDisplayScale(ratio); });
|
||||
connect(m_rdpDisplay,
|
||||
&RdpDisplayWidget::keyInput,
|
||||
this,
|
||||
|
||||
@@ -74,6 +74,7 @@ signals:
|
||||
void requestInput(const QString& input);
|
||||
void requestHostKeyConfirmation(bool trustHost);
|
||||
void requestTerminalSize(int columns, int rows);
|
||||
void requestDisplayScale(qreal devicePixelRatio);
|
||||
void requestKeyEvent(int key,
|
||||
quint32 nativeScanCode,
|
||||
const QString& text,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
#include "about_dialog.h"
|
||||
#include "profiles_window.h"
|
||||
#include "user_guide_dialog.h"
|
||||
#include <QApplication>
|
||||
#include "session_tab.h"
|
||||
|
||||
@@ -169,6 +170,9 @@ SessionWindow::SessionWindow(QWidget* parent)
|
||||
QAction* newProfileAction = fileMenu->addAction(QStringLiteral("New Profile"));
|
||||
QAction* newFolderAction = fileMenu->addAction(QStringLiteral("New Folder"));
|
||||
fileMenu->addSeparator();
|
||||
QAction* importProfilesAction = fileMenu->addAction(QStringLiteral("Import Profiles..."));
|
||||
QAction* exportProfilesAction = fileMenu->addAction(QStringLiteral("Export Profiles..."));
|
||||
fileMenu->addSeparator();
|
||||
QAction* quitAction = fileMenu->addAction(QStringLiteral("Quit"));
|
||||
|
||||
connect(newProfileAction,
|
||||
@@ -179,9 +183,25 @@ SessionWindow::SessionWindow(QWidget* parent)
|
||||
&QAction::triggered,
|
||||
this,
|
||||
[this]() { m_profilesWidget->createFolderInCurrentContext(); });
|
||||
connect(importProfilesAction,
|
||||
&QAction::triggered,
|
||||
this,
|
||||
[this]() { m_profilesWidget->importProfiles(); });
|
||||
connect(exportProfilesAction,
|
||||
&QAction::triggered,
|
||||
this,
|
||||
[this]() { m_profilesWidget->exportProfiles(); });
|
||||
connect(quitAction, &QAction::triggered, this, []() { qApp->quit(); });
|
||||
|
||||
QMenu* helpMenu = menuBar()->addMenu(QStringLiteral("Help"));
|
||||
QAction* userGuideAction = helpMenu->addAction(QStringLiteral("User Guide"));
|
||||
connect(userGuideAction,
|
||||
&QAction::triggered,
|
||||
this,
|
||||
[this]() {
|
||||
UserGuideDialog dialog(this);
|
||||
dialog.exec();
|
||||
});
|
||||
QAction* aboutAction = helpMenu->addAction(QStringLiteral("About OrbitHub"));
|
||||
connect(aboutAction,
|
||||
&QAction::triggered,
|
||||
|
||||
@@ -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,7 @@
|
||||
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)
|
||||
@@ -0,0 +1,288 @@
|
||||
#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();
|
||||
|
||||
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"));
|
||||
}
|
||||
|
||||
QTEST_GUILESS_MAIN(TestProfileRepository)
|
||||
#include "test_profile_repository.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