Author SHA1 Message Date
ksmithandClaude Sonnet 5 c4a62e8fb6 About dialog: fix unreadable dark-mode text and missing version
The subtitle and version line used palette(mid), a role meant for 3D
bevel/shadow decoration, not text -- it has poor contrast against the
window background in dark themes. Replaced with a color blended from
the widget's actual text/window palette colors, so it stays readable
(de-emphasized but never low-contrast) in either theme.

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 16:56:22 -06:00
ksmithandClaude Sonnet 5 1c66adb646 packaging: pin Flathub manifest to v2026.9.8.2
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 16:27:47 -06:00
25 changed files with 790 additions and 21 deletions
+2
View File
@@ -1,3 +1,5 @@
/build/
/dist/
/.flatpak-builder/
/build-doc-tool/
/docs/USER_GUIDE.pdf
+16 -2
View File
@@ -1,6 +1,6 @@
cmake_minimum_required(VERSION 3.21)
project(OrbitHub VERSION 2026.9.8.2 LANGUAGES CXX)
project(OrbitHub VERSION 2026.9.14 LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
@@ -81,6 +81,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 +134,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 +261,14 @@ 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
)
+5 -2
View File
@@ -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.14`
- 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.14](https://git.darksingularity.org/DarkSingularity/orbithub/releases/tag/v2026.9.14)
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
+16
View File
@@ -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
View File
@@ -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
+2
View File
@@ -199,6 +199,8 @@ 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
## Milestone 10 - v1.0 Stabilization
+230
View File
@@ -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).
+5
View File
@@ -0,0 +1,5 @@
<RCC>
<qresource prefix="/docs">
<file>USER_GUIDE.md</file>
</qresource>
</RCC>
+15
View File
@@ -0,0 +1,15 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
BUILD_DIR="${1:-$ROOT_DIR/build-doc-tool}"
OUTPUT_PATH="${2:-$ROOT_DIR/docs/USER_GUIDE.pdf}"
cmake -S "$ROOT_DIR/tools/user-guide-pdf" -B "$BUILD_DIR" -G Ninja
cmake --build "$BUILD_DIR"
QT_QPA_PLATFORM=offscreen "$BUILD_DIR/user-guide-pdf" \
"$ROOT_DIR/docs/USER_GUIDE.md" \
"$OUTPUT_PATH"
echo "Created $OUTPUT_PATH"
@@ -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.14
commit: e90e9b5abfad585ce2ce6dc54453f110c6ed217f
@@ -34,6 +34,18 @@
</screenshot>
</screenshots>
<releases>
<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>
+27 -2
View File
@@ -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);
+3
View File
@@ -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;
+61 -7
View File
@@ -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) {
+6
View File
@@ -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
+89 -5
View File
@@ -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,34 @@ 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);
// 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 +2117,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."));
+3
View File
@@ -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;
+4
View File
@@ -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);
+9
View File
@@ -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,
+1
View File
@@ -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,
+9
View File
@@ -2,6 +2,7 @@
#include "about_dialog.h"
#include "profiles_window.h"
#include "user_guide_dialog.h"
#include <QApplication>
#include "session_tab.h"
@@ -182,6 +183,14 @@ SessionWindow::SessionWindow(QWidget* parent)
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,
+149
View File
@@ -0,0 +1,149 @@
#include "user_guide_dialog.h"
#include <QApplication>
#include <QDesktopServices>
#include <QDialogButtonBox>
#include <QFile>
#include <QListWidget>
#include <QSplitter>
#include <QTextBrowser>
#include <QTextStream>
#include <QUrl>
#include <QVBoxLayout>
namespace {
QString slugify(const QString& title)
{
QString slug;
slug.reserve(title.size());
bool lastWasHyphen = false;
for (const QChar& ch : title) {
if (ch.isLetterOrNumber()) {
slug += ch.toLower();
lastWasHyphen = false;
} else if (!lastWasHyphen && !slug.isEmpty()) {
slug += QLatin1Char('-');
lastWasHyphen = true;
}
}
while (slug.endsWith(QLatin1Char('-'))) {
slug.chop(1);
}
return slug;
}
}
UserGuideDialog::UserGuideDialog(QWidget* parent)
: QDialog(parent), m_sectionList(nullptr), m_browser(nullptr)
{
setWindowTitle(QStringLiteral("OrbitHub User Guide"));
setWindowIcon(QApplication::windowIcon());
resize(900, 640);
auto* layout = new QVBoxLayout(this);
layout->setContentsMargins(16, 16, 16, 16);
layout->setSpacing(12);
auto* splitter = new QSplitter(Qt::Horizontal, this);
m_sectionList = new QListWidget(splitter);
m_sectionList->setMaximumWidth(220);
m_browser = new QTextBrowser(splitter);
m_browser->setOpenExternalLinks(false);
m_browser->setOpenLinks(false);
splitter->addWidget(m_sectionList);
splitter->addWidget(m_browser);
splitter->setStretchFactor(0, 0);
splitter->setStretchFactor(1, 1);
auto* buttons = new QDialogButtonBox(QDialogButtonBox::Close, this);
connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::reject);
connect(buttons, &QDialogButtonBox::accepted, this, &QDialog::accept);
layout->addWidget(splitter, 1);
layout->addWidget(buttons);
loadSections();
connect(m_sectionList,
&QListWidget::currentRowChanged,
this,
[this](int row) { showSection(row); });
connect(m_browser, &QTextBrowser::anchorClicked, this, &UserGuideDialog::onAnchorClicked);
if (!m_sections.isEmpty()) {
m_sectionList->setCurrentRow(0);
}
}
void UserGuideDialog::loadSections()
{
QFile file(QStringLiteral(":/docs/USER_GUIDE.md"));
if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) {
return;
}
QTextStream stream(&file);
const QString content = stream.readAll();
const QStringList lines = content.split(QLatin1Char('\n'));
QString currentTitle;
QStringList currentLines;
auto flushSection = [this, &currentTitle, &currentLines]() {
if (currentTitle.isEmpty()) {
return;
}
Section section;
section.title = currentTitle;
section.anchor = slugify(currentTitle);
section.markdown = currentLines.join(QLatin1Char('\n'));
m_sections.append(section);
};
for (const QString& line : lines) {
if (line.startsWith(QStringLiteral("## "))) {
flushSection();
currentTitle = line.mid(3).trimmed();
currentLines.clear();
}
currentLines.append(line);
}
flushSection();
for (const Section& section : m_sections) {
m_sectionList->addItem(section.title);
}
}
void UserGuideDialog::showSection(int index)
{
if (index < 0 || index >= m_sections.size()) {
return;
}
m_browser->setMarkdown(m_sections[index].markdown);
}
void UserGuideDialog::onAnchorClicked(const QUrl& url)
{
if (!url.scheme().isEmpty()) {
QDesktopServices::openUrl(url);
return;
}
const QString fragment = url.fragment();
if (fragment.isEmpty()) {
return;
}
for (int i = 0; i < m_sections.size(); ++i) {
if (m_sections[i].anchor == fragment) {
m_sectionList->setCurrentRow(i);
return;
}
}
}
+35
View File
@@ -0,0 +1,35 @@
#ifndef ORBITHUB_USER_GUIDE_DIALOG_H
#define ORBITHUB_USER_GUIDE_DIALOG_H
#include <QDialog>
#include <QString>
#include <QVector>
class QListWidget;
class QTextBrowser;
class QUrl;
class UserGuideDialog : public QDialog
{
Q_OBJECT
public:
explicit UserGuideDialog(QWidget* parent = nullptr);
private:
struct Section {
QString title;
QString anchor;
QString markdown;
};
void loadSections();
void showSection(int index);
void onAnchorClicked(const QUrl& url);
QListWidget* m_sectionList;
QTextBrowser* m_browser;
QVector<Section> m_sections;
};
#endif
+11
View File
@@ -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)
+44
View File
@@ -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;
}