From ce1e40a12d9df6c864eed1b297f6a1a56ed9404a Mon Sep 17 00:00:00 2001 From: Keith Smith Date: Tue, 8 Sep 2026 18:17:21 -0600 Subject: [PATCH] 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 --- .gitignore | 2 + CMakeLists.txt | 3 + README.md | 3 + docs/BUILDING.md | 16 ++ docs/USER_GUIDE.md | 230 +++++++++++++++++++++++++ docs/user_guide.qrc | 5 + packaging/docs/build-user-guide-pdf.sh | 15 ++ src/session_window.cpp | 9 + src/user_guide_dialog.cpp | 149 ++++++++++++++++ src/user_guide_dialog.h | 35 ++++ tools/user-guide-pdf/CMakeLists.txt | 11 ++ tools/user-guide-pdf/main.cpp | 44 +++++ 12 files changed, 522 insertions(+) create mode 100644 docs/USER_GUIDE.md create mode 100644 docs/user_guide.qrc create mode 100755 packaging/docs/build-user-guide-pdf.sh create mode 100644 src/user_guide_dialog.cpp create mode 100644 src/user_guide_dialog.h create mode 100644 tools/user-guide-pdf/CMakeLists.txt create mode 100644 tools/user-guide-pdf/main.cpp diff --git a/.gitignore b/.gitignore index 70249de..8afa862 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ /build/ /dist/ /.flatpak-builder/ +/build-doc-tool/ +/docs/USER_GUIDE.pdf diff --git a/CMakeLists.txt b/CMakeLists.txt index 4502026..c44d14b 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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 diff --git a/README.md b/README.md index 07dd815..7cdd4ba 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,9 @@ Progress and milestone details: Latest release (installers for Windows, Linux, and macOS): - [v2026.9.8.2](https://git.darksingularity.org/DarkSingularity/orbithub/releases/tag/v2026.9.8.2) +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 ![Profile list with folders](docs/images/screenshot-profiles.png) diff --git a/docs/BUILDING.md b/docs/BUILDING.md index 6faafd9..87f31e1 100644 --- a/docs/BUILDING.md +++ b/docs/BUILDING.md @@ -140,3 +140,19 @@ right-click → **Open** to bypass Gatekeeper's unidentified-developer warning. Output path: - `dist/macos/OrbitHub-.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) diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md new file mode 100644 index 0000000..8107f0c --- /dev/null +++ b/docs/USER_GUIDE.md @@ -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). diff --git a/docs/user_guide.qrc b/docs/user_guide.qrc new file mode 100644 index 0000000..2a52b68 --- /dev/null +++ b/docs/user_guide.qrc @@ -0,0 +1,5 @@ + + + USER_GUIDE.md + + diff --git a/packaging/docs/build-user-guide-pdf.sh b/packaging/docs/build-user-guide-pdf.sh new file mode 100755 index 0000000..07fc491 --- /dev/null +++ b/packaging/docs/build-user-guide-pdf.sh @@ -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" diff --git a/src/session_window.cpp b/src/session_window.cpp index 7935d1e..79ca042 100644 --- a/src/session_window.cpp +++ b/src/session_window.cpp @@ -2,6 +2,7 @@ #include "about_dialog.h" #include "profiles_window.h" +#include "user_guide_dialog.h" #include #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, diff --git a/src/user_guide_dialog.cpp b/src/user_guide_dialog.cpp new file mode 100644 index 0000000..1a42500 --- /dev/null +++ b/src/user_guide_dialog.cpp @@ -0,0 +1,149 @@ +#include "user_guide_dialog.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +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; + } + } +} diff --git a/src/user_guide_dialog.h b/src/user_guide_dialog.h new file mode 100644 index 0000000..e11999a --- /dev/null +++ b/src/user_guide_dialog.h @@ -0,0 +1,35 @@ +#ifndef ORBITHUB_USER_GUIDE_DIALOG_H +#define ORBITHUB_USER_GUIDE_DIALOG_H + +#include +#include +#include + +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
m_sections; +}; + +#endif diff --git a/tools/user-guide-pdf/CMakeLists.txt b/tools/user-guide-pdf/CMakeLists.txt new file mode 100644 index 0000000..553e0db --- /dev/null +++ b/tools/user-guide-pdf/CMakeLists.txt @@ -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) diff --git a/tools/user-guide-pdf/main.cpp b/tools/user-guide-pdf/main.cpp new file mode 100644 index 0000000..2503a04 --- /dev/null +++ b/tools/user-guide-pdf/main.cpp @@ -0,0 +1,44 @@ +#include +#include +#include +#include +#include +#include + +#include + +int main(int argc, char* argv[]) +{ + QApplication app(argc, argv); + + if (argc != 3) { + std::fprintf(stderr, "Usage: %s \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; +}