Internal
Public Access
Compare commits
10
Commits
v2026.9.8
...
v2026.9.8.3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8e98c208c9 | ||
|
|
ce1e40a12d | ||
|
|
80bc50e54c | ||
|
|
1c66adb646 | ||
|
|
4c649f727f | ||
|
|
18f234105d | ||
|
|
186480dcf5 | ||
|
|
dd974c684a | ||
|
|
8c56d489af | ||
|
|
d7910f1631 |
@@ -1,3 +1,5 @@
|
|||||||
/build/
|
/build/
|
||||||
/dist/
|
/dist/
|
||||||
/.flatpak-builder/
|
/.flatpak-builder/
|
||||||
|
/build-doc-tool/
|
||||||
|
/docs/USER_GUIDE.pdf
|
||||||
|
|||||||
+15
-2
@@ -1,6 +1,6 @@
|
|||||||
cmake_minimum_required(VERSION 3.21)
|
cmake_minimum_required(VERSION 3.21)
|
||||||
|
|
||||||
project(OrbitHub VERSION 2026.9.8 LANGUAGES CXX)
|
project(OrbitHub VERSION 2026.9.8.3 LANGUAGES CXX)
|
||||||
|
|
||||||
set(CMAKE_CXX_STANDARD 17)
|
set(CMAKE_CXX_STANDARD 17)
|
||||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||||
@@ -81,6 +81,9 @@ add_subdirectory(third_party/FreeRDP EXCLUDE_FROM_ALL)
|
|||||||
set(ORBITHUB_SOURCES
|
set(ORBITHUB_SOURCES
|
||||||
src/about_dialog.cpp
|
src/about_dialog.cpp
|
||||||
src/about_dialog.h
|
src/about_dialog.h
|
||||||
|
src/user_guide_dialog.cpp
|
||||||
|
src/user_guide_dialog.h
|
||||||
|
docs/user_guide.qrc
|
||||||
src/app_icon.cpp
|
src/app_icon.cpp
|
||||||
src/app_icon.h
|
src/app_icon.h
|
||||||
src/main.cpp
|
src/main.cpp
|
||||||
@@ -257,4 +260,14 @@ install(FILES
|
|||||||
packaging/linux/org.darksingularity.OrbitHub.metainfo.xml
|
packaging/linux/org.darksingularity.OrbitHub.metainfo.xml
|
||||||
DESTINATION ${CMAKE_INSTALL_DATADIR}/metainfo
|
DESTINATION ${CMAKE_INSTALL_DATADIR}/metainfo
|
||||||
)
|
)
|
||||||
install(FILES LICENSE DESTINATION ${CMAKE_INSTALL_DATADIR}/licenses/orbithub)
|
install(FILES LICENSE
|
||||||
|
DESTINATION ${CMAKE_INSTALL_DATADIR}/licenses/org.darksingularity.OrbitHub
|
||||||
|
)
|
||||||
|
install(FILES third_party/FreeRDP/LICENSE
|
||||||
|
DESTINATION ${CMAKE_INSTALL_DATADIR}/licenses/org.darksingularity.OrbitHub
|
||||||
|
RENAME LICENSE-FreeRDP
|
||||||
|
)
|
||||||
|
install(FILES third_party/KodoTerm/LICENSE
|
||||||
|
DESTINATION ${CMAKE_INSTALL_DATADIR}/licenses/org.darksingularity.OrbitHub
|
||||||
|
RENAME LICENSE-KodoTerm
|
||||||
|
)
|
||||||
|
|||||||
@@ -16,18 +16,32 @@ OrbitHub is in active development.
|
|||||||
- Milestones completed: M0-M5, and M7-M9
|
- Milestones completed: M0-M5, and M7-M9
|
||||||
- Current milestone: Milestone 10 (v1.0 Stabilization)
|
- Current milestone: Milestone 10 (v1.0 Stabilization)
|
||||||
- Deferred milestone: Milestone 6 (VNC Fully Working)
|
- Deferred milestone: Milestone 6 (VNC Fully Working)
|
||||||
- Latest checkpoint tag: `v0-m9-done`
|
- Latest checkpoint tag: `v2026.9.8.3`
|
||||||
- VNC implementation milestone (M6) is currently deferred
|
- VNC implementation milestone (M6) is currently deferred
|
||||||
|
|
||||||
Progress and milestone details:
|
Progress and milestone details:
|
||||||
- [docs/PROGRESS.md](docs/PROGRESS.md)
|
- [docs/PROGRESS.md](docs/PROGRESS.md)
|
||||||
|
|
||||||
|
Latest release (installers for Windows, Linux, and macOS):
|
||||||
|
- [v2026.9.8.3](https://git.darksingularity.org/DarkSingularity/orbithub/releases/tag/v2026.9.8.3)
|
||||||
|
|
||||||
|
User Guide:
|
||||||
|
- [docs/USER_GUIDE.md](docs/USER_GUIDE.md) (also available as a PDF attached to each release, and in-app via `Help -> User Guide`)
|
||||||
|
|
||||||
## Screenshots
|
## Screenshots
|
||||||
|
|
||||||

|

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

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

|
||||||
|
|
||||||
|
An embedded RDP session in a tab.
|
||||||
|
|
||||||
## Implemented Features
|
## Implemented Features
|
||||||
|
|
||||||
### Profile Management
|
### Profile Management
|
||||||
|
|||||||
@@ -140,3 +140,19 @@ right-click → **Open** to bypass Gatekeeper's unidentified-developer warning.
|
|||||||
|
|
||||||
Output path:
|
Output path:
|
||||||
- `dist/macos/OrbitHub-<version>.dmg`
|
- `dist/macos/OrbitHub-<version>.dmg`
|
||||||
|
|
||||||
|
## User Guide PDF
|
||||||
|
|
||||||
|
The in-app User Guide (`Help -> User Guide`) is built from
|
||||||
|
`docs/USER_GUIDE.md` and embedded into the app at compile time — no extra
|
||||||
|
step needed for that. A standalone PDF version is generated separately by a
|
||||||
|
small tool (kept out of the main app's dependencies, since it needs
|
||||||
|
`Qt6::PrintSupport`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./packaging/docs/build-user-guide-pdf.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Output path:
|
||||||
|
- `docs/USER_GUIDE.pdf` (not committed to git — a release asset, like the
|
||||||
|
platform installers)
|
||||||
|
|||||||
+100
@@ -0,0 +1,100 @@
|
|||||||
|
# Flathub Submission Readiness
|
||||||
|
|
||||||
|
Tracks OrbitHub's readiness for submission to Flathub. This is a separate
|
||||||
|
checklist from `docs/PROGRESS.md`'s development milestones, since Flathub
|
||||||
|
submission is an external process with its own requirements.
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
| Item | Status |
|
||||||
|
|---|---|
|
||||||
|
| Production manifest with pinned, reproducible git source | Done — `packaging/flatpak/flathub/org.darksingularity.OrbitHub.yml`; commit pin updated at each tagged release |
|
||||||
|
| `flathub.json` for build settings (`only-arches`, etc.) | Done — `packaging/flatpak/flathub/flathub.json` |
|
||||||
|
| Offline build (no network fetches during build) | Verified — no `FetchContent`/`ExternalProject`/`curl`/`wget` in CMake; all vendored deps committed in `third_party/`; confirmed with a real `flatpak-builder` build |
|
||||||
|
| SSH client available inside the sandbox | Verified — provided by the `org.kde.Platform` runtime base, no packaging needed |
|
||||||
|
| `--filesystem=home` removed | Done — narrowed to `--filesystem=~/.ssh` (read-write, needed for `known_hosts` and SSH config) |
|
||||||
|
| SSH known-hosts trust persists with narrowed permissions | Verified in sandbox against real infrastructure |
|
||||||
|
| RDP works with zero filesystem permission | Verified — FreeRDP's cert trust store lives outside the sandboxed home path concerns entirely (see below) |
|
||||||
|
| Private-key/export file pickers use the desktop portal | **Verified interactively** — `QFileDialog`'s Browse button correctly opens the native GTK portal chooser ("Select Private Key"), which can browse the full filesystem via user consent regardless of the sandbox's static `~/.ssh`-only grant |
|
||||||
|
| Current, supported KDE runtime | Verified — upgraded to `6.11` (linter's recommended latest); confirmed the app still builds and launches against it |
|
||||||
|
| Desktop entry validates | Verified |
|
||||||
|
| Application icon validates | Verified — PNGs at all standard hicolor sizes, matching the real app icon |
|
||||||
|
| MetaInfo/AppStream validates | Verified via both `appstreamcli validate` and Flathub's own `flatpak-builder-lint appstream` (0 errors either way) |
|
||||||
|
| Screenshots present | Done — profiles view, active SSH session, active RDP session, all captured against real (test) infrastructure |
|
||||||
|
| Release information present | Done — `<releases>` block with `v2026.9.8` (add an entry per future tagged release) |
|
||||||
|
| Developer/project URLs present | Done — homepage, bugtracker, vcs-browser, developer block |
|
||||||
|
| Architecture support decided | `x86_64` only (no ARM hardware available to test FreeRDP/WinPR on aarch64), set via `flathub.json`'s `only-arches` |
|
||||||
|
| Flathub manifest linter passes | One expected finding remains: `finish-args-ssh-filesystem-access` (see below) — everything else passes, including `only-arches` placement and runtime-version currency |
|
||||||
|
| AppStream linter passes | Passing (both `appstreamcli validate` and `flatpak-builder-lint appstream`) |
|
||||||
|
| Clean install works without host dependencies | Verified via local `.flatpak` bundle install and launch, on both KDE 6.10 and 6.11 runtimes |
|
||||||
|
| Bundled-dependency license files installed per Flathub's `$FLATPAK_ID` convention | Partially done — path fixed from `share/licenses/orbithub` to the required `share/licenses/org.darksingularity.OrbitHub`; FreeRDP's and KodoTerm's `LICENSE` files now installed there too. **`libvterm`'s vendored copy has no `LICENSE`/`COPYING` file at all** — needs to be pulled from upstream and added as `third_party/libvterm/LICENSE` before submission (README claims MIT; not verified against an actual license file in-tree) |
|
||||||
|
|
||||||
|
## ⚠️ Not yet addressed: Generative AI disclosure policy is a real acceptance risk, not a checklist item
|
||||||
|
|
||||||
|
See the dedicated section below — unlike everything else on this page, this
|
||||||
|
isn't something more packaging work resolves.
|
||||||
|
|
||||||
|
### `finish-args-ssh-filesystem-access` — expected, needs a submission-time justification
|
||||||
|
|
||||||
|
Flathub's linter flags *any* `~/.ssh` filesystem grant by policy — it's not a
|
||||||
|
bug in this manifest, it's a deliberate prompt for the submitter to justify
|
||||||
|
the access during PR review. Checked the linter's own exceptions list:
|
||||||
|
several existing SSH-client apps already have this exact permission approved
|
||||||
|
with justifications like *"Read-only access to ~/.ssh is required to load
|
||||||
|
SSH keys for connecting to devices over SSH"* and *"Needed to manage SSH keys
|
||||||
|
and configurations for connections"* — OrbitHub's case is the same pattern
|
||||||
|
(read-write, specifically for `known_hosts` persistence and default identity
|
||||||
|
file discovery). Include a similar justification in the submission PR.
|
||||||
|
|
||||||
|
## ⚠️ Not yet addressed: Generative AI disclosure policy
|
||||||
|
|
||||||
|
Flathub's [Generative AI policy](https://docs.flathub.org/docs/for-app-authors/requirements#generative-ai-policy)
|
||||||
|
requires submitters to disclose "any AI-generated code, documentation,
|
||||||
|
packaging, or other material" included in the app or its Flathub packaging,
|
||||||
|
identifying "the affected parts and approximate extent." This is not a
|
||||||
|
formality — it's evaluated at reviewer discretion, and reviewers may reject
|
||||||
|
"based on the extent or role of generated material."
|
||||||
|
|
||||||
|
OrbitHub's development has used Claude Code extensively — the app's C++
|
||||||
|
source, this Flatpak packaging (manifest, metainfo, build scripts), and this
|
||||||
|
tracking doc itself. Every commit in this repository carries a
|
||||||
|
`Co-Authored-By: Claude Sonnet 5` trailer, which is itself effectively an
|
||||||
|
existing disclosure trail. An honest submission disclosure needs to reflect
|
||||||
|
that extent truthfully — not a token "some AI assistance was used" note.
|
||||||
|
|
||||||
|
The same policy also prohibits AI tools from opening or automating the
|
||||||
|
submission PR itself, or generating its commit messages, description, or
|
||||||
|
review replies. **This means the actual submission PR — including its AI
|
||||||
|
disclosure — has to be written and opened by a human, not drafted by
|
||||||
|
Claude.** Not done, and not something this repo's tooling should attempt.
|
||||||
|
|
||||||
|
This is a real acceptance risk that no amount of technical packaging work
|
||||||
|
resolves — it's a policy/reviewer-discretion matter, separate from every
|
||||||
|
other item on this page.
|
||||||
|
|
||||||
|
## Related finding (not a packaging blocker)
|
||||||
|
|
||||||
|
During permission-narrowing research, RDP certificate verification was found
|
||||||
|
to be completely disabled (`IgnoreCertificate=TRUE`, all server certificates
|
||||||
|
silently accepted including *changed* ones). This has been fixed separately
|
||||||
|
in `src/rdp_session_backend.cpp` — FreeRDP's own trust-on-first-use
|
||||||
|
certificate store is now used, matching SSH's known-hosts model. Not a
|
||||||
|
Flathub-specific issue, but worth noting since it was found in the course of
|
||||||
|
this work.
|
||||||
|
|
||||||
|
## Explicitly out of scope for this repo
|
||||||
|
|
||||||
|
- Opening the actual submission PR against `github.com/flathub/flathub` —
|
||||||
|
requires the maintainer's GitHub identity, done outside this repo, and per
|
||||||
|
the Generative AI policy above must be written by a human, not drafted here.
|
||||||
|
- ARM64 build/testing — no hardware available.
|
||||||
|
- Flathub's post-acceptance developer-verification step — done via
|
||||||
|
Flathub's own website after acceptance, using DNS control of
|
||||||
|
`darksingularity.org`.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
- Dev manifest (local iteration, `type: dir`): `packaging/flatpak/org.darksingularity.OrbitHub.yml`
|
||||||
|
- Flathub submission manifest (pinned `type: git`): `packaging/flatpak/flathub/org.darksingularity.OrbitHub.yml`
|
||||||
|
- AppStream metainfo: `packaging/linux/org.darksingularity.OrbitHub.metainfo.xml`
|
||||||
|
- Desktop entry: `packaging/linux/org.darksingularity.OrbitHub.desktop`
|
||||||
@@ -197,6 +197,9 @@ Validation:
|
|||||||
|
|
||||||
Git:
|
Git:
|
||||||
- Tag: `v0-m9-done`
|
- Tag: `v0-m9-done`
|
||||||
|
- Release: [v2026.9.8](https://git.darksingularity.org/DarkSingularity/orbithub/releases/tag/v2026.9.8) (`v2026.9.8` tag, installers for Windows/Linux/macOS)
|
||||||
|
- Release: [v2026.9.8.2](https://git.darksingularity.org/DarkSingularity/orbithub/releases/tag/v2026.9.8.2) — same-day patch fixing RDP TLS certificate verification (was fully disabled) and preparing Flatpak packaging for Flathub submission
|
||||||
|
- Release: [v2026.9.8.3](https://git.darksingularity.org/DarkSingularity/orbithub/releases/tag/v2026.9.8.3) — same-day patch adding an in-app User Guide and standalone User Guide PDF
|
||||||
|
|
||||||
## Milestone 10 - v1.0 Stabilization
|
## Milestone 10 - v1.0 Stabilization
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,230 @@
|
|||||||
|
## Introduction
|
||||||
|
|
||||||
|
OrbitHub is a native desktop application for organizing connection profiles
|
||||||
|
and launching SSH and RDP sessions from one place, in a single tabbed
|
||||||
|
window. It runs on Windows, Linux, and macOS.
|
||||||
|
|
||||||
|
This guide covers everyday use: creating and organizing profiles,
|
||||||
|
connecting over SSH and RDP, managing active sessions, and what to do when
|
||||||
|
something goes wrong. It does not cover installation or building from
|
||||||
|
source — see the project's `README.md` and `docs/BUILDING.md` for that.
|
||||||
|
|
||||||
|
VNC support is planned but not yet implemented; profiles can be tagged for
|
||||||
|
it, but connecting will show an "unsupported protocol" message for now.
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
When OrbitHub opens, you land on the **Profiles** tab — a searchable,
|
||||||
|
sortable list of every connection you've saved. Two toolbar controls in
|
||||||
|
the top-right change how that list is presented:
|
||||||
|
|
||||||
|
- **View**: `List` shows every profile in one flat table; `Folders` groups
|
||||||
|
them into the folder tree you've organized them into.
|
||||||
|
- **Sort**: orders the list by Name, Protocol, or Host.
|
||||||
|
|
||||||
|
Along the top of the window, a **Search** box filters by name, host,
|
||||||
|
folder path, or tag as you type, and **Protocol**/**Tag** dropdowns narrow
|
||||||
|
the list further. These filters, plus your last view mode and sort order,
|
||||||
|
are remembered the next time you open OrbitHub.
|
||||||
|
|
||||||
|
Along the bottom of the Profiles tab: **New**, **Edit**, and **Delete**
|
||||||
|
buttons for managing the selected profile. The same actions are available
|
||||||
|
by right-clicking a profile or folder, and from the **File** menu (`New
|
||||||
|
Profile`, `New Folder`).
|
||||||
|
|
||||||
|
To connect, double-click a profile, or select it and press the connect
|
||||||
|
control — this opens a new tab for that session and connects
|
||||||
|
automatically.
|
||||||
|
|
||||||
|
## Managing Profiles
|
||||||
|
|
||||||
|
A profile stores everything needed to reach one remote host. Open **New**
|
||||||
|
(or **Edit** on an existing profile) to fill in:
|
||||||
|
|
||||||
|
- **Name** — a label for the profile; shown on its tab and in the list.
|
||||||
|
- **Host** — hostname or IP address.
|
||||||
|
- **Port** — defaults to `22` for SSH, `3389` for RDP.
|
||||||
|
- **Username** — the account to log in as.
|
||||||
|
- **Domain** — Windows domain for RDP logins (leave blank for local/
|
||||||
|
workgroup accounts or SSH profiles).
|
||||||
|
- **Tags** — free-form, comma-separated labels for filtering and grouping
|
||||||
|
(e.g. `prod, linux, db`).
|
||||||
|
- **Folder** — where the profile lives in the Folders view.
|
||||||
|
- **Protocol** — `SSH`, `RDP`, or `VNC` (VNC is accepted but not yet
|
||||||
|
connectable — see Introduction).
|
||||||
|
|
||||||
|
Fields below Protocol change based on what you pick:
|
||||||
|
|
||||||
|
**SSH**: **Auth Mode** (`Password` or `Private Key`), and if Private Key,
|
||||||
|
a **Private Key** file path with a **Browse** button, plus **Known Hosts**
|
||||||
|
policy — see [Connecting via SSH](#connecting-via-ssh) for what each
|
||||||
|
policy means.
|
||||||
|
|
||||||
|
**RDP**: **RDP Security** and **RDP Performance** — see
|
||||||
|
[Connecting via RDP](#connecting-via-rdp).
|
||||||
|
|
||||||
|
Passwords are never saved in the profile — OrbitHub asks for them each
|
||||||
|
time you connect (unless you've set up private-key SSH auth, which needs
|
||||||
|
no password to be entered per-connection if the key itself has none).
|
||||||
|
|
||||||
|
## Organizing Profiles
|
||||||
|
|
||||||
|
As your profile list grows, two independent tools keep it manageable:
|
||||||
|
|
||||||
|
**Folders.** Switch the Profiles tab to `Folders` view to see profiles
|
||||||
|
grouped into a tree. Create a folder from the **File** menu or by
|
||||||
|
right-clicking in the tree (`New Folder`), and drag any profile onto a
|
||||||
|
folder to move it there. Folders can nest inside other folders.
|
||||||
|
|
||||||
|
**Tags.** Tags are independent of folders — a profile can be in one folder
|
||||||
|
but carry several tags (e.g. `prod`, `linux`, `db` all at once). Use the
|
||||||
|
**Tag** filter dropdown in the toolbar to instantly narrow the list to
|
||||||
|
everything sharing a tag, regardless of which folder it's filed under.
|
||||||
|
|
||||||
|
Combine both with the **Search** box (matches name, host, folder path, or
|
||||||
|
tags) and the **Sort** control (Name / Protocol / Host) to find what you
|
||||||
|
need quickly even with a large profile list.
|
||||||
|
|
||||||
|
## Connecting via SSH
|
||||||
|
|
||||||
|
Double-clicking an SSH profile opens a new tab with an embedded, fully
|
||||||
|
interactive terminal — type directly into it as you would any terminal
|
||||||
|
emulator. A **theme** selector lets you switch between `Dark`, `Light`,
|
||||||
|
and `Solarized Dark`; your choice is remembered for future sessions.
|
||||||
|
|
||||||
|
**Authentication.** Set in the profile itself:
|
||||||
|
- **Password** — OrbitHub prompts for a password each time you connect.
|
||||||
|
It is never stored.
|
||||||
|
- **Private Key** — point at a key file (via the profile's Browse button);
|
||||||
|
no password prompt unless the key itself is passphrase-protected.
|
||||||
|
|
||||||
|
**Known Hosts policy.** This controls how OrbitHub reacts to a server's
|
||||||
|
SSH host key — the mechanism that protects against a different machine
|
||||||
|
silently impersonating a host you've connected to before:
|
||||||
|
|
||||||
|
| Policy | Behavior |
|
||||||
|
|---|---|
|
||||||
|
| `Ask` | Prompts you to confirm trust the first time a host is seen, and on any later change. Recommended default. |
|
||||||
|
| `Accept-new` | Silently trusts a host the first time it's seen, but still stops and warns if a previously-trusted host's key later changes. |
|
||||||
|
| `Strict` | Never trusts an unknown host automatically — the connection fails until you've manually confirmed the host key some other way. |
|
||||||
|
| `Ignore` | Skips host-key checking entirely. Only use this for throwaway/test environments — it removes protection against on-path attacks. |
|
||||||
|
|
||||||
|
Trusted host keys are recorded in your system's normal SSH `known_hosts`
|
||||||
|
file (the same one the `ssh` command line tool uses), so trust decisions
|
||||||
|
made through OrbitHub or a terminal `ssh` session carry over to each
|
||||||
|
other.
|
||||||
|
|
||||||
|
## Connecting via RDP
|
||||||
|
|
||||||
|
RDP sessions render in an embedded display surface inside the tab — no
|
||||||
|
external RDP client window opens. Keyboard and mouse input go straight to
|
||||||
|
the remote desktop while the tab has focus, and resizing the OrbitHub
|
||||||
|
window renegotiates the remote resolution to match. Clipboard content
|
||||||
|
syncs between your machine and the remote session automatically.
|
||||||
|
|
||||||
|
**Authentication** uses the profile's Username/Domain fields; OrbitHub
|
||||||
|
prompts for the password at connect time.
|
||||||
|
|
||||||
|
**RDP Security** controls which transport-security layer is used to
|
||||||
|
negotiate the connection:
|
||||||
|
|
||||||
|
| Mode | Behavior |
|
||||||
|
|---|---|
|
||||||
|
| `Negotiate` | Lets the client and server agree on the strongest mutually-supported option automatically. Recommended default. |
|
||||||
|
| `NLA` | Requires Network Level Authentication (credentials verified before a full session starts) — the modern standard for current Windows versions. |
|
||||||
|
| `TLS` | Requires TLS-only security, without NLA. |
|
||||||
|
| `RDP` | The legacy RDP-native security layer, for older servers that don't support TLS/NLA. |
|
||||||
|
|
||||||
|
**RDP Performance** trades visual fidelity for responsiveness:
|
||||||
|
`Balanced` (default), `Best Quality`, `Best Performance`, or
|
||||||
|
`Auto Detect` (adapts based on the detected connection).
|
||||||
|
|
||||||
|
**Server certificate verification.** The first time you connect to an RDP
|
||||||
|
host, OrbitHub trusts and remembers its TLS certificate — the same
|
||||||
|
trust-on-first-use model SSH uses for host keys. If that certificate ever
|
||||||
|
changes on a later connection, OrbitHub refuses the connection rather than
|
||||||
|
connecting anyway, since a changed certificate can mean either a
|
||||||
|
legitimate server certificate renewal or an active
|
||||||
|
machine-in-the-middle presenting a different one. The event log (see
|
||||||
|
below) shows the specific fingerprints involved. If the change is
|
||||||
|
expected — you rotated the server's certificate yourself — reconnecting
|
||||||
|
after clearing the old entry from FreeRDP's certificate store will trust
|
||||||
|
the new one.
|
||||||
|
|
||||||
|
## Managing Sessions
|
||||||
|
|
||||||
|
Every open connection lives in its own tab in the same window, alongside
|
||||||
|
the Profiles tab. A tab's title and a colored state indicator show
|
||||||
|
whether it's connecting, connected, disconnected, or failed. Closing a
|
||||||
|
tab disconnects that session; opening a profile again starts a fresh one.
|
||||||
|
|
||||||
|
Each session tab includes a collapsible **event log** beneath the
|
||||||
|
connection surface — a timestamped record of connection state changes,
|
||||||
|
warnings, and errors for that session. Controls above the log let you:
|
||||||
|
|
||||||
|
- **Show/Hide Events** — collapse the panel when you don't need it.
|
||||||
|
- **Filter** — a text box to search event text, and an `All` /
|
||||||
|
`Warnings` / `Errors` severity dropdown to narrow what's shown.
|
||||||
|
- **Export Events** — save the current session's full event log to a
|
||||||
|
file, useful when reporting a connection problem.
|
||||||
|
- **Clear Events** — empty the log for that tab.
|
||||||
|
|
||||||
|
## Settings & Preferences
|
||||||
|
|
||||||
|
OrbitHub remembers your preferences across restarts without any separate
|
||||||
|
settings screen — they're saved automatically as you use the app:
|
||||||
|
|
||||||
|
- Profile list: search text, view mode (List/Folders), Protocol/Tag
|
||||||
|
filters, and sort order.
|
||||||
|
- Session tabs: terminal theme choice, and whether the events panel is
|
||||||
|
shown or hidden for new tabs.
|
||||||
|
|
||||||
|
Profile data itself (names, hosts, tags, folder structure, and so on) is
|
||||||
|
stored in a local SQLite database — see the README for its exact path on
|
||||||
|
your platform. Passwords are never part of that stored data.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
**"Host could not be resolved"** — the hostname in the profile can't be
|
||||||
|
looked up by DNS. Check for typos, or try the host's IP address directly
|
||||||
|
to confirm whether it's a DNS problem or something else.
|
||||||
|
|
||||||
|
**SSH connection fails immediately, no prompt** — double-check the
|
||||||
|
profile's Port (default `22`) and that a firewall or network path isn't
|
||||||
|
blocking that port from your machine.
|
||||||
|
|
||||||
|
**RDP: "Authentication failed. Check username and password."** — confirm
|
||||||
|
Username and Domain are correct for the target server; some servers
|
||||||
|
require the domain to be set explicitly even for local accounts.
|
||||||
|
|
||||||
|
**RDP: "RDP security negotiation failed. Try a different RDP security
|
||||||
|
mode."** — the server doesn't support the security mode selected in the
|
||||||
|
profile. Try `Negotiate` first, or a more specific mode if you know what
|
||||||
|
the server requires.
|
||||||
|
|
||||||
|
**RDP: connection refused with a certificate-changed message** — see
|
||||||
|
[Connecting via RDP](#connecting-via-rdp) above; this is expected,
|
||||||
|
protective behavior, not a bug, whenever a previously-trusted server's
|
||||||
|
certificate is replaced.
|
||||||
|
|
||||||
|
**SSH: connection hangs at "Ask" waiting for host-key trust** — check
|
||||||
|
that policy's behavior under
|
||||||
|
[Connecting via SSH](#connecting-via-ssh); switching to `Accept-new` avoids
|
||||||
|
the prompt for genuinely new hosts while still protecting against a later
|
||||||
|
key change.
|
||||||
|
|
||||||
|
If none of this covers what you're seeing, a session's exported event log
|
||||||
|
(see Managing Sessions) is the most useful thing to include when asking
|
||||||
|
for help or filing an issue.
|
||||||
|
|
||||||
|
## About & Support
|
||||||
|
|
||||||
|
OrbitHub is open source under the MIT license. Source code, issue
|
||||||
|
tracking, and releases are hosted at
|
||||||
|
[git.darksingularity.org/DarkSingularity/orbithub](https://git.darksingularity.org/DarkSingularity/orbithub).
|
||||||
|
|
||||||
|
For a list of bundled third-party libraries and their licenses, see
|
||||||
|
**Help → About OrbitHub** inside the app.
|
||||||
|
|
||||||
|
To report a bug or request a feature, open an issue at
|
||||||
|
[git.darksingularity.org/DarkSingularity/orbithub/issues](https://git.darksingularity.org/DarkSingularity/orbithub/issues).
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 44 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 100 KiB |
@@ -0,0 +1,5 @@
|
|||||||
|
<RCC>
|
||||||
|
<qresource prefix="/docs">
|
||||||
|
<file>USER_GUIDE.md</file>
|
||||||
|
</qresource>
|
||||||
|
</RCC>
|
||||||
Executable
+15
@@ -0,0 +1,15 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||||
|
BUILD_DIR="${1:-$ROOT_DIR/build-doc-tool}"
|
||||||
|
OUTPUT_PATH="${2:-$ROOT_DIR/docs/USER_GUIDE.pdf}"
|
||||||
|
|
||||||
|
cmake -S "$ROOT_DIR/tools/user-guide-pdf" -B "$BUILD_DIR" -G Ninja
|
||||||
|
cmake --build "$BUILD_DIR"
|
||||||
|
|
||||||
|
QT_QPA_PLATFORM=offscreen "$BUILD_DIR/user-guide-pdf" \
|
||||||
|
"$ROOT_DIR/docs/USER_GUIDE.md" \
|
||||||
|
"$OUTPUT_PATH"
|
||||||
|
|
||||||
|
echo "Created $OUTPUT_PATH"
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"only-arches": ["x86_64"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
app-id: org.darksingularity.OrbitHub
|
||||||
|
runtime: org.kde.Platform
|
||||||
|
runtime-version: "6.11"
|
||||||
|
sdk: org.kde.Sdk
|
||||||
|
command: orbithub
|
||||||
|
finish-args:
|
||||||
|
- --share=network
|
||||||
|
- --share=ipc
|
||||||
|
- --socket=fallback-x11
|
||||||
|
- --socket=wayland
|
||||||
|
- --device=dri
|
||||||
|
- --filesystem=~/.ssh
|
||||||
|
modules:
|
||||||
|
- name: orbithub
|
||||||
|
buildsystem: cmake-ninja
|
||||||
|
builddir: true
|
||||||
|
config-opts:
|
||||||
|
- -DCMAKE_BUILD_TYPE=Release
|
||||||
|
sources:
|
||||||
|
- type: git
|
||||||
|
url: https://git.darksingularity.org/DarkSingularity/orbithub.git
|
||||||
|
tag: v2026.9.8.2
|
||||||
|
commit: 4c649f727fb794f4b5afdc3186e545e20ce2f138
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
app-id: org.darksingularity.OrbitHub
|
app-id: org.darksingularity.OrbitHub
|
||||||
runtime: org.kde.Platform
|
runtime: org.kde.Platform
|
||||||
runtime-version: "6.10"
|
runtime-version: "6.11"
|
||||||
sdk: org.kde.Sdk
|
sdk: org.kde.Sdk
|
||||||
command: orbithub
|
command: orbithub
|
||||||
finish-args:
|
finish-args:
|
||||||
@@ -9,7 +9,7 @@ finish-args:
|
|||||||
- --socket=fallback-x11
|
- --socket=fallback-x11
|
||||||
- --socket=wayland
|
- --socket=wayland
|
||||||
- --device=dri
|
- --device=dri
|
||||||
- --filesystem=home
|
- --filesystem=~/.ssh
|
||||||
modules:
|
modules:
|
||||||
- name: orbithub
|
- name: orbithub
|
||||||
buildsystem: cmake-ninja
|
buildsystem: cmake-ninja
|
||||||
|
|||||||
@@ -10,7 +10,44 @@
|
|||||||
</description>
|
</description>
|
||||||
<launchable type="desktop-id">org.darksingularity.OrbitHub.desktop</launchable>
|
<launchable type="desktop-id">org.darksingularity.OrbitHub.desktop</launchable>
|
||||||
<url type="homepage">https://git.darksingularity.org/DarkSingularity/orbithub</url>
|
<url type="homepage">https://git.darksingularity.org/DarkSingularity/orbithub</url>
|
||||||
|
<url type="bugtracker">https://git.darksingularity.org/DarkSingularity/orbithub/issues</url>
|
||||||
|
<url type="vcs-browser">https://git.darksingularity.org/DarkSingularity/orbithub</url>
|
||||||
|
<developer id="org.darksingularity">
|
||||||
|
<name>DarkSingularity</name>
|
||||||
|
</developer>
|
||||||
<provides>
|
<provides>
|
||||||
<binary>orbithub</binary>
|
<binary>orbithub</binary>
|
||||||
</provides>
|
</provides>
|
||||||
|
<content_rating type="oars-1.1" />
|
||||||
|
<screenshots>
|
||||||
|
<screenshot type="default">
|
||||||
|
<caption>Profiles organized into folders</caption>
|
||||||
|
<image>https://git.darksingularity.org/DarkSingularity/orbithub/raw/branch/main/docs/images/screenshot-profiles.png</image>
|
||||||
|
</screenshot>
|
||||||
|
<screenshot>
|
||||||
|
<caption>Active SSH terminal session</caption>
|
||||||
|
<image>https://git.darksingularity.org/DarkSingularity/orbithub/raw/branch/main/docs/images/screenshot-ssh-session.png</image>
|
||||||
|
</screenshot>
|
||||||
|
<screenshot>
|
||||||
|
<caption>Active RDP session</caption>
|
||||||
|
<image>https://git.darksingularity.org/DarkSingularity/orbithub/raw/branch/main/docs/images/screenshot-rdp-session.png</image>
|
||||||
|
</screenshot>
|
||||||
|
</screenshots>
|
||||||
|
<releases>
|
||||||
|
<release version="2026.9.8.3" date="2026-09-08">
|
||||||
|
<url>https://git.darksingularity.org/DarkSingularity/orbithub/releases/tag/v2026.9.8.3</url>
|
||||||
|
<description>
|
||||||
|
<p>Adds an in-app User Guide (Help -> User Guide) and a standalone User Guide PDF.</p>
|
||||||
|
</description>
|
||||||
|
</release>
|
||||||
|
<release version="2026.9.8.2" date="2026-09-08">
|
||||||
|
<url>https://git.darksingularity.org/DarkSingularity/orbithub/releases/tag/v2026.9.8.2</url>
|
||||||
|
<description>
|
||||||
|
<p>Fixes RDP connections silently accepting any server TLS certificate, including changed ones. Certificates are now verified with trust-on-first-use, matching SSH's known-hosts behavior.</p>
|
||||||
|
</description>
|
||||||
|
</release>
|
||||||
|
<release version="2026.9.8" date="2026-09-08">
|
||||||
|
<url>https://git.darksingularity.org/DarkSingularity/orbithub/releases/tag/v2026.9.8</url>
|
||||||
|
</release>
|
||||||
|
</releases>
|
||||||
</component>
|
</component>
|
||||||
|
|||||||
+53
-11
@@ -420,9 +420,19 @@ BOOL orbitPreConnect(freerdp* instance)
|
|||||||
}
|
}
|
||||||
|
|
||||||
rdpSettings* settings = instance->context->settings;
|
rdpSettings* settings = instance->context->settings;
|
||||||
freerdp_settings_set_bool(settings, FreeRDP_CertificateCallbackPreferPEM, TRUE);
|
// FALSE gives the verify callbacks a short SHA256 fingerprint string
|
||||||
|
// (e.g. "ab:cd:12:...") instead of the full PEM certificate blob, which
|
||||||
|
// is what actually belongs in a user-facing changed-certificate message.
|
||||||
|
freerdp_settings_set_bool(settings, FreeRDP_CertificateCallbackPreferPEM, FALSE);
|
||||||
freerdp_settings_set_bool(settings, FreeRDP_DesktopResize, TRUE);
|
freerdp_settings_set_bool(settings, FreeRDP_DesktopResize, TRUE);
|
||||||
freerdp_settings_set_bool(settings, FreeRDP_IgnoreCertificate, TRUE);
|
// Trust servers on first connection (matching SSH's "accept-new" known-hosts
|
||||||
|
// policy) and persist that trust via FreeRDP's own certificate store, but
|
||||||
|
// actually verify it on every subsequent connection: IgnoreCertificate
|
||||||
|
// previously bypassed verification entirely, so a changed certificate
|
||||||
|
// (e.g. an active MITM) was silently accepted, indistinguishable from a
|
||||||
|
// legitimate server. See orbitVerifyChangedCertificateEx below.
|
||||||
|
freerdp_settings_set_bool(settings, FreeRDP_IgnoreCertificate, FALSE);
|
||||||
|
freerdp_settings_set_bool(settings, FreeRDP_AutoAcceptCertificate, TRUE);
|
||||||
return TRUE;
|
return TRUE;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -708,10 +718,13 @@ DWORD orbitVerifyCertificateEx(freerdp* instance,
|
|||||||
const char*,
|
const char*,
|
||||||
DWORD)
|
DWORD)
|
||||||
{
|
{
|
||||||
|
// With FreeRDP_AutoAcceptCertificate set, FreeRDP accepts and stores a
|
||||||
|
// first-seen certificate itself without calling this callback at all;
|
||||||
|
// it is kept as a defensive fallback for any code path that reaches it.
|
||||||
if (instance != nullptr && instance->context != nullptr) {
|
if (instance != nullptr && instance->context != nullptr) {
|
||||||
if (RdpSessionBackend* backend = backendFromContext(instance->context)) {
|
if (RdpSessionBackend* backend = backendFromContext(instance->context)) {
|
||||||
emit backend->eventLogged(
|
emit backend->eventLogged(
|
||||||
QStringLiteral("Accepting server certificate for %1:%2.")
|
QStringLiteral("Trusting new server certificate for %1:%2.")
|
||||||
.arg(QString::fromUtf8(host == nullptr ? "" : host))
|
.arg(QString::fromUtf8(host == nullptr ? "" : host))
|
||||||
.arg(port));
|
.arg(port));
|
||||||
}
|
}
|
||||||
@@ -725,21 +738,42 @@ DWORD orbitVerifyChangedCertificateEx(freerdp* instance,
|
|||||||
const char*,
|
const char*,
|
||||||
const char*,
|
const char*,
|
||||||
const char*,
|
const char*,
|
||||||
|
const char* newFingerprint,
|
||||||
const char*,
|
const char*,
|
||||||
const char*,
|
const char*,
|
||||||
const char*,
|
const char* oldFingerprint,
|
||||||
const char*,
|
|
||||||
DWORD)
|
DWORD)
|
||||||
{
|
{
|
||||||
|
// The server's certificate no longer matches the one trusted on a prior
|
||||||
|
// connection. This is exactly the scenario TLS verification exists to
|
||||||
|
// catch: either the server legitimately rotated its certificate, or an
|
||||||
|
// active man-in-the-middle is presenting a different one. Refuse the
|
||||||
|
// connection by default rather than silently trusting it.
|
||||||
|
const QString hostStr = QString::fromUtf8(host == nullptr ? "" : host);
|
||||||
|
const QString message = QStringLiteral(
|
||||||
|
"Server certificate for %1:%2 has changed since it was last trusted. "
|
||||||
|
"Connection refused for safety — this could mean the server's "
|
||||||
|
"certificate was legitimately renewed, or that a different host is "
|
||||||
|
"impersonating it. Previously trusted fingerprint: %3 — now "
|
||||||
|
"presented: %4. If this change is expected, remove the stored entry "
|
||||||
|
"for this host from FreeRDP's certificate store "
|
||||||
|
"(~/.config/freerdp/server) and reconnect.")
|
||||||
|
.arg(hostStr)
|
||||||
|
.arg(port)
|
||||||
|
.arg(QString::fromUtf8(oldFingerprint == nullptr ? "unknown" : oldFingerprint))
|
||||||
|
.arg(QString::fromUtf8(newFingerprint == nullptr ? "unknown" : newFingerprint));
|
||||||
|
|
||||||
|
// Not logged directly here: recordCertificateRejection() feeds this
|
||||||
|
// message into the generic connect-failure handling in workerMain(),
|
||||||
|
// which already reports it through eventLogged/connectionError/setState
|
||||||
|
// (the same channels every other RDP connection failure uses) — an
|
||||||
|
// extra direct log call here would just duplicate that.
|
||||||
if (instance != nullptr && instance->context != nullptr) {
|
if (instance != nullptr && instance->context != nullptr) {
|
||||||
if (RdpSessionBackend* backend = backendFromContext(instance->context)) {
|
if (RdpSessionBackend* backend = backendFromContext(instance->context)) {
|
||||||
emit backend->eventLogged(
|
backend->recordCertificateRejection(message);
|
||||||
QStringLiteral("Accepting changed server certificate for %1:%2.")
|
|
||||||
.arg(QString::fromUtf8(host == nullptr ? "" : host))
|
|
||||||
.arg(port));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return 1;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
const char* authReasonName(rdp_auth_reason reason)
|
const char* authReasonName(rdp_auth_reason reason)
|
||||||
@@ -1451,6 +1485,11 @@ void RdpSessionBackend::setState(SessionState state, const QString& message)
|
|||||||
emit eventLogged(message);
|
emit eventLogged(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void RdpSessionBackend::recordCertificateRejection(const QString& reason)
|
||||||
|
{
|
||||||
|
m_certificateRejectionReason = reason;
|
||||||
|
}
|
||||||
|
|
||||||
bool RdpSessionBackend::validateProfile(QString& message) const
|
bool RdpSessionBackend::validateProfile(QString& message) const
|
||||||
{
|
{
|
||||||
const Profile& p = profile();
|
const Profile& p = profile();
|
||||||
@@ -1510,6 +1549,7 @@ void RdpSessionBackend::workerMain()
|
|||||||
m_workerRunning.store(false);
|
m_workerRunning.store(false);
|
||||||
return;
|
return;
|
||||||
#else
|
#else
|
||||||
|
m_certificateRejectionReason.clear();
|
||||||
ensureFreeRdpRuntimeInitialized();
|
ensureFreeRdpRuntimeInitialized();
|
||||||
|
|
||||||
freerdp* instance = freerdp_new();
|
freerdp* instance = freerdp_new();
|
||||||
@@ -1671,7 +1711,9 @@ void RdpSessionBackend::workerMain()
|
|||||||
emit eventLogged(QStringLiteral("RDP connect aborted: %1").arg(raw));
|
emit eventLogged(QStringLiteral("RDP connect aborted: %1").arg(raw));
|
||||||
emitStateAsync(SessionState::Disconnected, disconnectMessageForCode(code));
|
emitStateAsync(SessionState::Disconnected, disconnectMessageForCode(code));
|
||||||
} else {
|
} else {
|
||||||
const QString mapped = mapRdpError(code);
|
const QString mapped = m_certificateRejectionReason.isEmpty()
|
||||||
|
? mapRdpError(code)
|
||||||
|
: m_certificateRejectionReason;
|
||||||
emit eventLogged(QStringLiteral("RDP connect failure detail: %1").arg(raw));
|
emit eventLogged(QStringLiteral("RDP connect failure detail: %1").arg(raw));
|
||||||
emitConnectionFailureAsync(mapped, raw);
|
emitConnectionFailureAsync(mapped, raw);
|
||||||
emitStateAsync(SessionState::Failed, mapped);
|
emitStateAsync(SessionState::Failed, mapped);
|
||||||
|
|||||||
@@ -90,6 +90,14 @@ private:
|
|||||||
void* m_cliprdrContext;
|
void* m_cliprdrContext;
|
||||||
QString m_pendingLocalClipboardText;
|
QString m_pendingLocalClipboardText;
|
||||||
|
|
||||||
|
// Set synchronously by orbitVerifyChangedCertificateEx (called from this
|
||||||
|
// object's own worker thread during freerdp_connect) when a server's TLS
|
||||||
|
// certificate has changed since a prior trusted connection. Read back by
|
||||||
|
// workerMain() right after freerdp_connect() fails, to show the specific
|
||||||
|
// reason instead of a generic "TLS negotiation failed" message. Cleared
|
||||||
|
// at the start of every connect attempt.
|
||||||
|
QString m_certificateRejectionReason;
|
||||||
|
|
||||||
void setState(SessionState state, const QString& message);
|
void setState(SessionState state, const QString& message);
|
||||||
bool validateProfile(QString& message) const;
|
bool validateProfile(QString& message) const;
|
||||||
void startWorker();
|
void startWorker();
|
||||||
@@ -109,6 +117,7 @@ public:
|
|||||||
void onCliprdrServerFormatList(bool hasUnicodeText);
|
void onCliprdrServerFormatList(bool hasUnicodeText);
|
||||||
void onCliprdrServerFormatDataRequest(uint32_t requestedFormatId);
|
void onCliprdrServerFormatDataRequest(uint32_t requestedFormatId);
|
||||||
void onCliprdrServerFormatDataResponse(bool success, const uint8_t* data, uint32_t size);
|
void onCliprdrServerFormatDataResponse(bool success, const uint8_t* data, uint32_t size);
|
||||||
|
void recordCertificateRejection(const QString& reason);
|
||||||
private:
|
private:
|
||||||
void emitStateAsync(SessionState state, const QString& message);
|
void emitStateAsync(SessionState state, const QString& message);
|
||||||
void emitConnectionFailureAsync(const QString& displayMessage, const QString& rawMessage);
|
void emitConnectionFailureAsync(const QString& displayMessage, const QString& rawMessage);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
#include "about_dialog.h"
|
#include "about_dialog.h"
|
||||||
#include "profiles_window.h"
|
#include "profiles_window.h"
|
||||||
|
#include "user_guide_dialog.h"
|
||||||
#include <QApplication>
|
#include <QApplication>
|
||||||
#include "session_tab.h"
|
#include "session_tab.h"
|
||||||
|
|
||||||
@@ -182,6 +183,14 @@ SessionWindow::SessionWindow(QWidget* parent)
|
|||||||
connect(quitAction, &QAction::triggered, this, []() { qApp->quit(); });
|
connect(quitAction, &QAction::triggered, this, []() { qApp->quit(); });
|
||||||
|
|
||||||
QMenu* helpMenu = menuBar()->addMenu(QStringLiteral("Help"));
|
QMenu* helpMenu = menuBar()->addMenu(QStringLiteral("Help"));
|
||||||
|
QAction* userGuideAction = helpMenu->addAction(QStringLiteral("User Guide"));
|
||||||
|
connect(userGuideAction,
|
||||||
|
&QAction::triggered,
|
||||||
|
this,
|
||||||
|
[this]() {
|
||||||
|
UserGuideDialog dialog(this);
|
||||||
|
dialog.exec();
|
||||||
|
});
|
||||||
QAction* aboutAction = helpMenu->addAction(QStringLiteral("About OrbitHub"));
|
QAction* aboutAction = helpMenu->addAction(QStringLiteral("About OrbitHub"));
|
||||||
connect(aboutAction,
|
connect(aboutAction,
|
||||||
&QAction::triggered,
|
&QAction::triggered,
|
||||||
|
|||||||
@@ -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,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