46 Commits
Author SHA1 Message Date
ksmithandClaude Sonnet 5 4f8fa3272b packaging: pin vendored FreeRDP's own version, stop git-tag bleed-through
third_party/FreeRDP is vendored in-tree rather than as a separate git
submodule, so its build-time git_get_exact_tag() call (in
cmake/GetProjectVersion.cmake) was resolving against OrbitHub's own git
tags instead of any real FreeRDP release tag. Its version-extraction regex
then greedily matched the last three dot-separated numbers of our tag
(e.g. v2026.9.14.2 -> "9.14.2"), mislabeling FreeRDP's own libraries in
packaged builds (libfreerdp9.so.9.14.2 instead of the real
libfreerdp3.so.3.23.1) -- self-consistent within a build, but misleading
and drifting release to release.

Adding .source_tag makes GetProjectVersion.cmake take its file-based
branch (which it already prefers over the git-tag branch) instead.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 22:00:51 -06:00
ksmithandClaude Sonnet 5 96c8403f3b Bump version to v2026.9.14.2
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 21:57:43 -06:00
ksmithandClaude Sonnet 5 3e621219f1 RDP: fix resize corruption by proactively resizing the local GDI buffer
update->DesktopResize (the only place gdi_resize() was called) only fires
during a full Deactivation-Reactivation sequence or a GFX ResetGraphics
PDU, neither of which our Display Control channel resize path (SendMonitorLayout,
MS-RDPEDISP) triggers. The client's own display buffer was left stuck at
its initial-connect size for the rest of the session, and FreeRDP's surface-bits
handling silently drops updates outside those stale bounds -- producing
the missing/misplaced taskbar and stale composited-looking content.

sendDisplayResize now calls gdi_resize() itself right after a successful
SendMonitorLayout, rather than waiting on a callback that structurally
never fires for this channel/codec configuration.

Fixes #18.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 21:57:08 -06:00
ksmith e0969041a7 packaging: pin Flathub manifest to v2026.9.14 About-dialog-fix commit 2026-09-14 21:32:56 -06:00
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
ksmithandClaude Sonnet 5 4c649f727f Bump version to v2026.9.8.2
Same-day patch release: RDP TLS certificate verification fix and
Flathub submission prep.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 16:27:22 -06:00
ksmithandClaude Sonnet 5 18f234105d packaging: fix Flathub linter findings
Runs Flathub's own flatpak-builder-lint against both manifests and
the metainfo, and fixes what it found:

- only-arches doesn't belong in the manifest itself (linter: manifest
  unknown property); it belongs in a separate flathub.json, which is
  what Flathub's own build infrastructure actually reads it from.
- Bumps the KDE runtime from 6.10 to 6.11 per the linter's outdated-
  runtime warning; verified the app still builds and launches against
  it before committing to the bump.

The one remaining linter finding, finish-args-ssh-filesystem-access,
is a deliberate policy flag rather than a bug — Flathub requires a
written justification for any ~/.ssh access in the submission PR,
which docs/FLATHUB.md now documents with precedent from already-
approved apps in the same situation.

Also confirms (via a real interactive test with xdotool) that the
private-key Browse button correctly triggers the desktop portal
file chooser inside the sandbox, closing the last open verification
item from the previous commit.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 16:26:14 -06:00
ksmithandClaude Sonnet 5 186480dcf5 packaging: prepare Flatpak for Flathub submission
Adds a production manifest (packaging/flatpak/flathub/) using a
pinned git source instead of the local-dir source the dev manifest
uses, so Flathub's build infrastructure can reproduce the build
independently.

Narrows the dev and production manifests' filesystem permission from
--filesystem=home to --filesystem=~/.ssh (read-write, needed for SSH
known_hosts/config) after confirming in the actual sandbox that: SSH
already works there (provided by the KDE runtime base, no extra
packaging needed), RDP needs no filesystem access at all (its
certificate trust never touched disk even before today's fix), and
QFileDialog's private-key/export pickers route through the desktop
portal rather than needing static filesystem access.

Expands AppStream metainfo with bugtracker/vcs-browser URLs, a
developer block, a releases entry, an OARS content rating, and three
screenshots (profiles view, an active SSH session, an active RDP
session) using real test-system profiles.

Adds docs/FLATHUB.md tracking overall submission readiness.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 16:19:09 -06:00
ksmithandClaude Sonnet 5 dd974c684a RDP: enforce TLS certificate verification (was fully disabled)
IgnoreCertificate was hardcoded TRUE, meaning FreeRDP's entire
certificate-verification pipeline was bypassed: every RDP server's
TLS certificate was silently accepted, including certificates that
had changed since a prior trusted connection to the same host. That
is precisely the scenario TLS verification exists to catch — an
active MITM presenting a different certificate was indistinguishable
from a legitimate server.

Switches to FreeRDP's own trust-on-first-use certificate store
(AutoAcceptCertificate) so first-time connections still connect
without a prompt, matching SSH's "accept-new" known-hosts policy.
Certificate changes now correctly refuse the connection by default,
via VerifyChangedCertificateEx, with a clear message (host, port,
old/new SHA256 fingerprints) surfaced through the existing
connection-failure event log rather than adding a new, redundant
logging path.

Also fixes CertificateCallbackPreferPEM, which handed the full PEM
certificate to the verify callbacks instead of a short fingerprint —
harmless while those callbacks were dead code, but would have
flooded the event log with multi-KB certificate dumps once actually
exercised.

Verified end-to-end against real infrastructure: first connection
trusts and stores the certificate silently, a simulated changed
certificate (server key swapped) is correctly refused with a clear
message, and restoring the original certificate reconnects normally.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 16:18:57 -06:00
ksmithandClaude Sonnet 5 8c56d489af docs: update latest checkpoint tag to v2026.9.8
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 14:20:07 -06:00
ksmithandClaude Sonnet 5 d7910f1631 docs: link the v2026.9.8 release
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 14:17:09 -06:00
ksmithandClaude Sonnet 5 68214db744 docs: record v0-m9-done tag
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 14:05:53 -06:00
ksmithandClaude Sonnet 5 6c8146e79c docs: close out Milestone 9 and document Windows/macOS packaging
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 14:04:59 -06:00
ksmithandClaude Sonnet 5 aa812b0da7 macOS: fix launch crash, missing dylibs, and dmg volume icon
The app crashed at launch with "cannot be opened because of a
problem" because macdeployqt invalidates the code signature when it
rewrites library load paths; re-sign (ad hoc) after macdeployqt runs.

Also fixes the underlying cause of a Library-missing crash: orbithub's
INSTALL_RPATH used the Linux linker token $ORIGIN, which dyld does not
understand, and the bundled FreeRDP/WinPR/KodoTerm dylibs installed to
a sibling lib/orbithub/ directory outside OrbitHub.app rather than
Contents/Frameworks, so they were never copied into the dmg at all.
Both are now APPLE-specific, matching macdeployqt's own layout
(Contents/Frameworks, @executable_path/../Frameworks).

build-dmg.sh now also sets a custom volume icon (via SetFile or the
fileicon brew formula, whichever is available) instead of the
generic disk image icon, degrading gracefully if neither is present.

Also includes the StartupWMClass desktop-file fix from the prior
commit's message, which was never actually staged.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 14:00:46 -06:00
ksmithandClaude Sonnet 5 3840ea9f62 Linux: fix taskbar pin matching and launcher icon mismatch
Add StartupWMClass=OrbitHub to the desktop file so panels (GNOME
Shell, Cinnamon, KDE) can match the running window to the pinned
launcher instead of creating a duplicate taskbar entry.

Replace the hand-authored, stale launcher SVG with PNG icons
rendered directly from createOrbitHubAppIcon() at each hicolor
theme size, so the pinned/menu icon matches the actual app icon
shown in the running window, Windows .ico, and macOS .icns.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 13:50:25 -06:00
ksmithandClaude Sonnet 5 7ce7260305 packaging: rename app ID to org.darksingularity.OrbitHub
The reverse-DNS app ID should reflect a domain we actually control.
Renames the desktop file, AppStream metainfo, icon, Flatpak manifest,
and macOS bundle identifier from io.orbithub.OrbitHub to
org.darksingularity.OrbitHub. Also fixes a stale homepage URL in the
metainfo file pointing at an old git host.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 13:39:06 -06:00
ksmithandClaude Sonnet 5 48adcf33ee packaging: add macOS dmg build script
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 13:29:35 -06:00
ksmithandClaude Sonnet 5 21e7a39c53 docs: add screenshots section to README
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 13:23:32 -06:00
ksmithandClaude Sonnet 5 f126f4b55c Add Windows installer via Inno Setup
New packaging/windows/orbithub.iss packages the build directory's
top-level contents directly (orbithub.exe, *.dll, platforms/,
sqldrivers/) rather than going through cmake --install: vcpkg's own
applocal DLL deployment already drops every required runtime dependency
flat into build/ on every build (confirmed repeatedly this session), and
the existing private-runtime-lib install() rule uses an RPATH-style Unix
convention (lib/orbithub/) that doesn't work on Windows, where DLLs must
sit next to the exe. Produces a single OrbitHub-Setup-<version>.exe with
a Start Menu entry, optional desktop shortcut, and uninstaller.

build-installer.ps1 reads the project version from build/CMakeCache.txt
(same approach build-deb.sh already uses) and drives ISCC.exe.

Code signing is out of scope for now (no certificate available), so the
installer will trigger a SmartScreen warning until one is purchased.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 13:14:57 -06:00
ksmithandClaude Sonnet 5 4daf5cfc65 docs: close out Milestone 7 (cross-platform protocol hardening)
Linux, macOS, and Windows have all been validated against the current
feature set (SSH, RDP display/keyboard/clipboard/cursor, Tab forwarding,
key repeat, profile dialog, single-window UI), with the platform-specific
bugs found along the way fixed and tracked individually on the issue
tracker (#8-#16). VNC validation remains out of scope while Milestone 6
stays deferred.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 13:14:50 -06:00
ksmithandClaude Sonnet 5 0990049241 Fix Flatpak build issues: EOL runtime and icon naming mismatch
- Bump the KDE runtime/SDK from 6.8 (now end-of-life per Flathub) to the
  current stable 6.10.
- The .desktop file's Icon=orbithub didn't match the app ID
  (io.orbithub.OrbitHub), so Flatpak's export step skipped it entirely
  ("Icon referenced in desktop file but not exported"). Use the app-ID-
  matching icon name everywhere instead, and drop the now-redundant
  renamed orbithub.svg install() rule that existed only for the old name.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 13:14:45 -06:00
ksmithandClaude Sonnet 5 6d5133e1bd Switch to date-based version scheme
Release versions now follow v<year>.<month>.<day>, with a .N suffix
appended only if a second release happens the same day (e.g. v2026.9.8,
then v2026.9.8.2 for a same-day follow-up). Replaces the placeholder
0.1.0 the project started with.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 13:14:33 -06:00
ksmithandClaude Sonnet 5 9d88b74c04 Fix macOS icon showing as unrecognized-file placeholder
The .icns generated via Pillow's ICNS writer wasn't rendering in Finder
(showed the "unavailable" circle-slash placeholder instead), likely due
to Pillow's ICNS encoding not matching what Icon Services expects. Rebuilt
the file by hand instead, using Apple's documented icns binary format
directly (magic header + OSType-tagged PNG chunks: icp4/icp5/icp6 for
16/32/64px, ic07-ic10 for 128/256/512/1024px) with the exact same PNGs
already rendered from createOrbitHubAppIcon(), sidestepping Pillow's
ICNS writer entirely.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 12:41:08 -06:00
ksmithandClaude Sonnet 5 49ee12f5a1 Fix macOS configure error: add BUNDLE DESTINATION for install(TARGETS)
CMake requires an explicit BUNDLE DESTINATION for install(TARGETS) when
the target has MACOSX_BUNDLE set, or configure fails with "install
TARGETS give no BUNDLE DESTINATION for MACOSX_BUNDLE executable target".
The RUNTIME clause still covers Linux/Windows; BUNDLE only applies when
the target actually is a bundle (macOS), so no platform guard is needed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 12:37:47 -06:00
ksmithandClaude Sonnet 5 ac3cebc9ae macOS: build a proper .app bundle with an embedded icon
Same root cause as the earlier Windows exe icon fix: add_executable()
never set MACOSX_BUNDLE, so on macOS this produced a bare Mach-O binary
rather than a proper .app bundle -- and without a bundle there's no
Info.plist/.icns mechanism for Finder to show a custom icon at all.

Added packaging/macos/orbithub.icns, rendered at up to 1024px directly
from the same createOrbitHubAppIcon() logic used at runtime (via a
one-off export tool, not committed) so it matches what the app actually
looks like. CMake's MACOSX_BUNDLE keyword and MACOSX_BUNDLE_ICON_FILE
wire it into an auto-generated Info.plist; both keywords are no-ops on
other platforms. The profile database uses QStandardPaths::AppDataLocation,
not the executable's own path, so this doesn't affect where existing
profiles are found.

Build output on macOS changes from build/orbithub to build/orbithub.app
(launch with `open build/orbithub.app`); docs/BUILDING.md updated to match.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 12:35:52 -06:00
ksmithandClaude Sonnet 5 8f83b0c8d9 Fix RDP key repeat: forward auto-repeat presses to the remote server
RdpDisplayWidget::keyPressEvent() dropped every event where
QKeyEvent::isAutoRepeat() was true, which discards the entire repeat
stream Qt generates while a key is held -- so holding a key only ever
produced a single keystroke on the remote machine. Auto-repeat presses
need to reach the remote server so it can perform its own typematic
repeat, exactly as a physical keyboard held down would; only release
events should filter isAutoRepeat() (kept as-is), since Qt uses a
synthetic release/press pair purely to normalize platform auto-repeat
quirks, and forwarding that synthetic release would send a spurious
key-up for a key still physically held.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 11:54:50 -06:00
ksmithandClaude Sonnet 5 ac1deac148 Windows: embed app icon into the executable file
The app's icon only appeared at runtime (window/taskbar), set programmatically
via QApplication::setWindowIcon(). Explorer showed the generic default icon
for orbithub.exe itself, since that requires an icon baked into the PE file
as a Windows resource at build time -- a completely separate mechanism.

Added packaging/windows/orbithub.ico, rendered directly from the same
createOrbitHubAppIcon() logic used at runtime (via a one-off export tool,
not committed) so the file icon matches what the app actually looks like.
A minimal .rc resource file embeds it, compiled in only for WIN32 builds.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 09:49:38 -06:00
ksmithandClaude Sonnet 5 b28adf8cbe Fix RDP host resolution failure on Windows: initialize Winsock
Every RDP connect attempt on Windows failed instantly with
ERRCONNECT_DNS_NAME_NOT_FOUND, even for a literal IP address. Root cause:
Winsock requires WSAStartup() to be called once by the process before any
socket/getaddrinfo call will succeed, and nothing in OrbitHub was calling
it (Qt Network isn't used, so Qt never does it either). FreeRDP's own
reference Windows client (wf_client.c) pairs WSAStartup with
freerdp_handle_signals() in its startup init for exactly this reason.
WinPR provides a portable no-op WSAStartup shim on POSIX, so this can be
called unconditionally cross-platform alongside the existing signal-handler
fix.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 09:37:02 -06:00
ksmithandClaude Sonnet 5 317df68d1f Fix RDP connect crash on Windows: initialize FreeRDP signal handling
Every reference FreeRDP client (X11, Windows, macOS, SDL, Wayland, Android)
calls freerdp_handle_signals() once at startup before connecting. OrbitHub
never did. On Windows this is the only place that initializes a global
CRITICAL_SECTION used internally by freerdp_add_signal_cleanup_handler(),
which freerdp_connect() calls automatically. Skipping it leaves that lock
zero-initialized -- invalid on Windows, but silently tolerated on POSIX,
which is why this never surfaced in Linux testing. The result was a crash
(access violation inside EnterCriticalSection) on every RDP connect attempt
on Windows. Call it once, guarded, before the first connection.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 09:27:25 -06:00
ksmithandClaude Sonnet 5 fce69b8be9 Forward Tab/Shift+Tab to remote sessions instead of local focus navigation
QWidget's default handling intercepts Tab/Shift+Tab for focus-chain
navigation before they ever reach keyPressEvent(), so pressing Tab in an
RDP session moved focus to the OrbitHub UI (e.g. the Show Events button)
instead of reaching the remote machine. KodoTerm (SSH) already overrides
focusNextPrevChild() to opt out of this; RdpDisplayWidget and TerminalView
did not. Both already handle Key_Tab in their own keyPressEvent, so this
is enough to let it reach them.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 08:50:01 -06:00
ksmithandClaude Sonnet 5 f3ea7f12a3 Windows: deploy sqlite3.dll and mark orbithub as a GUI (WIN32) executable
Two more Windows launch issues found alongside the Qt plugin deployment:
vcpkg's qtbase[sql-sqlite] links the QSQLITE driver plugin against vcpkg's
own shared sqlite3 port rather than bundling it, so the plugin was found
but failed to load without sqlite3.dll alongside the executable. Separately,
plain add_executable() defaults to the console subsystem on Windows, so a
command-prompt window appeared behind the GUI on every launch; add_executable
needs the WIN32 keyword to mark it as a GUI app (a no-op on other platforms).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 08:27:50 -06:00
ksmithandClaude Sonnet 5 30f0134748 Deploy Qt platform and SQLite plugins automatically on Windows
Qt loads its platform integration and SQL driver plugins dynamically
(QFactoryLoader) rather than importing them at link time, so they never
show up in orbithub.exe's import table and vcpkg's automatic DLL
deployment never copies them. Without this, the app fails to launch on
Windows with "Could not find the Qt platform plugin" or "can not load
requested driver 'QSQLITE'". Copy them out explicitly via Qt's own
exported plugin targets after each build, plus matching install() rules
for future packaging.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 08:20:17 -06:00
ksmithandClaude Sonnet 5 a89748be67 Merge Profiles and Session windows into a single window
ProfilesWindow and SessionWindow were two independent top-level windows,
coordinated through a QPointer and manual show/create-or-reuse logic, with
duplicated Help menus and a path where Quit didn't actually quit if a
SessionWindow happened to be open. ProfilesWindow is now an embedded QWidget
shown as a permanent, unclosable "Profiles" tab inside SessionWindow's tab
widget; SessionWindow is the app's sole top-level window. Double-clicking a
profile opens a session tab in the same window instead of finding-or-creating
a second one, and closing all session tabs falls back to the Profiles tab
rather than closing the app.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 15:59:53 -06:00
ksmithandClaude Sonnet 5 4b4b7d68f2 Fix profile dialog form fields collapsing to sizeHint on macOS
QFormLayout without an explicit field growth policy falls back to the
active Qt style's default, which differs per platform: Linux's Fusion/Breeze
styles expand fields to fill the row, but macOS's native style defaults to
FieldsStayAtSizeHint, leaving fields small with empty space beside them.
Set the policy explicitly so the dialog renders consistently everywhere.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 15:18:09 -06:00
ksmithandClaude Sonnet 5 793fdd9366 RDP session fixes: correct keyboard scancodes, clipboard sync, cursor shapes
Fix RDP keyboard input using FreeRDP's authoritative X11-keycode-to-scancode
table instead of ad hoc bit math, which misread punctuation keys as unrelated
letter keys (e.g. apostrophe as B) because X11 keycode numbering only
coincidentally overlaps PC/AT scancodes.

Add bidirectional clipboard sync (CF_UNICODETEXT) over the cliprdr channel,
and RDP pointer/cursor shape sync so the local cursor reflects what the
remote OS wants displayed (resize handles, text I-beam, etc.) instead of
staying a static arrow.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 14:53:03 -06:00
ksmithandClaude Sonnet 5 27cc3a3bb2 Session UX: inline password prompt and terminal font size controls
Replace the modal RDP/SSH password dialog with an inline prompt bar
embedded in the session tab instead of a separate popup window. Add
per-tab terminal font size controls (increase/decrease/reset/set
exact point size) via the tab context menu, with the chosen size
persisted across sessions.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 12:02:03 -06:00
ksmith c1c23d115a packaging: add deb/flatpak build flow and desktop metadata 2026-03-03 20:46:03 -07:00
ksmith f54c2e9bcd docs: update Windows build requirements and refresh milestone tag 2026-03-03 20:21:38 -07:00
53 changed files with 2793 additions and 251 deletions
+4
View File
@@ -1 +1,5 @@
/build/
/dist/
/.flatpak-builder/
/build-doc-tool/
/docs/USER_GUIDE.pdf
+149 -4
View File
@@ -1,6 +1,6 @@
cmake_minimum_required(VERSION 3.21)
project(OrbitHub VERSION 0.1.0 LANGUAGES CXX)
project(OrbitHub VERSION 2026.9.14.2 LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
@@ -10,6 +10,8 @@ set(CMAKE_AUTOMOC ON)
set(CMAKE_AUTOUIC ON)
set(CMAKE_AUTORCC ON)
include(GNUInstallDirs)
find_package(Qt6 6.2 REQUIRED COMPONENTS Widgets Sql)
qt_standard_project_setup()
@@ -40,7 +42,8 @@ set(CHANNEL_DISP ON CACHE BOOL "" FORCE)
set(CHANNEL_DISP_CLIENT ON CACHE BOOL "" FORCE)
set(CHANNEL_AINPUT OFF CACHE BOOL "" FORCE)
set(CHANNEL_AUDIN OFF CACHE BOOL "" FORCE)
set(CHANNEL_CLIPRDR OFF CACHE BOOL "" FORCE)
set(CHANNEL_CLIPRDR ON CACHE BOOL "" FORCE)
set(CHANNEL_CLIPRDR_CLIENT ON CACHE BOOL "" FORCE)
set(CHANNEL_DRIVE OFF CACHE BOOL "" FORCE)
set(CHANNEL_ECHO OFF CACHE BOOL "" FORCE)
set(CHANNEL_ENCOMSP OFF CACHE BOOL "" FORCE)
@@ -75,9 +78,12 @@ set(WITH_WINPR_TOOLS OFF CACHE BOOL "" FORCE)
add_subdirectory(third_party/FreeRDP EXCLUDE_FROM_ALL)
add_executable(orbithub
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
@@ -108,8 +114,27 @@ add_executable(orbithub
src/unsupported_session_backend.h
)
if(WIN32)
list(APPEND ORBITHUB_SOURCES packaging/windows/orbithub.rc)
endif()
if(APPLE)
list(APPEND ORBITHUB_SOURCES packaging/macos/orbithub.icns)
set_source_files_properties(packaging/macos/orbithub.icns PROPERTIES
MACOSX_PACKAGE_LOCATION "Resources"
)
set(MACOSX_BUNDLE_ICON_FILE orbithub.icns)
set(MACOSX_BUNDLE_BUNDLE_NAME "OrbitHub")
set(MACOSX_BUNDLE_GUI_IDENTIFIER "org.darksingularity.OrbitHub")
set(MACOSX_BUNDLE_BUNDLE_VERSION "${PROJECT_VERSION}")
set(MACOSX_BUNDLE_SHORT_VERSION_STRING "${PROJECT_VERSION}")
endif()
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
@@ -126,4 +151,124 @@ else()
message(FATAL_ERROR "Vendored FreeRDP targets were not produced as expected.")
endif()
install(TARGETS orbithub RUNTIME DESTINATION bin)
# Qt loads platform integration and SQL driver plugins dynamically at
# runtime (QFactoryLoader), so they never appear in orbithub.exe's import
# table. vcpkg's automatic DLL deployment only follows the import table, so
# these plugins have to be copied out explicitly or the app fails to start
# with "Could not find the Qt platform plugin" / "can not load requested
# driver 'QSQLITE'".
if(WIN32)
if(TARGET Qt6::QWindowsIntegrationPlugin)
add_custom_command(TARGET orbithub POST_BUILD
COMMAND ${CMAKE_COMMAND} -E make_directory
"$<TARGET_FILE_DIR:orbithub>/platforms"
COMMAND ${CMAKE_COMMAND} -E copy_if_different
"$<TARGET_FILE:Qt6::QWindowsIntegrationPlugin>"
"$<TARGET_FILE_DIR:orbithub>/platforms/"
COMMENT "Deploying Qt Windows platform plugin"
)
install(FILES "$<TARGET_FILE:Qt6::QWindowsIntegrationPlugin>"
DESTINATION "${CMAKE_INSTALL_BINDIR}/platforms"
)
endif()
if(TARGET Qt6::QSQLiteDriverPlugin)
add_custom_command(TARGET orbithub POST_BUILD
COMMAND ${CMAKE_COMMAND} -E make_directory
"$<TARGET_FILE_DIR:orbithub>/sqldrivers"
COMMAND ${CMAKE_COMMAND} -E copy_if_different
"$<TARGET_FILE:Qt6::QSQLiteDriverPlugin>"
"$<TARGET_FILE_DIR:orbithub>/sqldrivers/"
COMMENT "Deploying Qt SQLite driver plugin"
)
install(FILES "$<TARGET_FILE:Qt6::QSQLiteDriverPlugin>"
DESTINATION "${CMAKE_INSTALL_BINDIR}/sqldrivers"
)
# vcpkg's qtbase[sql-sqlite] links the plugin against vcpkg's own
# shared sqlite3 port rather than bundling SQLite into the plugin,
# so the plugin fails to load ("can not load requested driver
# 'QSQLITE'") unless sqlite3.dll also ships next to the executable.
find_file(ORBITHUB_SQLITE3_DLL
NAMES sqlite3.dll
PATHS ${CMAKE_PREFIX_PATH}
PATH_SUFFIXES bin
)
if(ORBITHUB_SQLITE3_DLL)
add_custom_command(TARGET orbithub POST_BUILD
COMMAND ${CMAKE_COMMAND} -E copy_if_different
"${ORBITHUB_SQLITE3_DLL}"
"$<TARGET_FILE_DIR:orbithub>/"
COMMENT "Deploying sqlite3.dll (Qt SQLite plugin dependency)"
)
install(FILES "${ORBITHUB_SQLITE3_DLL}" DESTINATION "${CMAKE_INSTALL_BINDIR}")
endif()
endif()
endif()
if(APPLE)
# Mirrors macdeployqt's own layout for Qt frameworks: dylibs live in
# Contents/Frameworks inside the bundle, found via @executable_path
# (the macOS/dyld equivalent of Linux's $ORIGIN token, which dyld
# does not understand).
set_target_properties(orbithub PROPERTIES
INSTALL_RPATH "@executable_path/../Frameworks"
INSTALL_RPATH_USE_LINK_PATH ON
)
else()
set_target_properties(orbithub PROPERTIES
BUILD_RPATH_USE_ORIGIN ON
INSTALL_RPATH "$ORIGIN/../${CMAKE_INSTALL_LIBDIR}/orbithub"
INSTALL_RPATH_USE_LINK_PATH ON
)
endif()
install(TARGETS orbithub
RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR}
BUNDLE DESTINATION .
)
if(APPLE)
set(ORBITHUB_PRIVATE_LIB_DESTINATION "OrbitHub.app/Contents/Frameworks")
else()
set(ORBITHUB_PRIVATE_LIB_DESTINATION "${CMAKE_INSTALL_LIBDIR}/orbithub")
endif()
set(ORBITHUB_RUNTIME_TARGETS KodoTerm freerdp winpr)
if(TARGET freerdp-client)
list(APPEND ORBITHUB_RUNTIME_TARGETS freerdp-client)
endif()
foreach(runtime_target IN LISTS ORBITHUB_RUNTIME_TARGETS)
if(TARGET ${runtime_target})
install(TARGETS ${runtime_target}
RUNTIME DESTINATION ${ORBITHUB_PRIVATE_LIB_DESTINATION}
LIBRARY DESTINATION ${ORBITHUB_PRIVATE_LIB_DESTINATION}
ARCHIVE DESTINATION ${CMAKE_INSTALL_LIBDIR}
)
endif()
endforeach()
install(FILES
packaging/linux/org.darksingularity.OrbitHub.desktop
DESTINATION ${CMAKE_INSTALL_DATADIR}/applications
)
foreach(icon_size IN ITEMS 16 24 32 48 64 128 256)
install(FILES
packaging/linux/icons/${icon_size}x${icon_size}/apps/org.darksingularity.OrbitHub.png
DESTINATION ${CMAKE_INSTALL_DATADIR}/icons/hicolor/${icon_size}x${icon_size}/apps
)
endforeach()
install(FILES
packaging/linux/org.darksingularity.OrbitHub.metainfo.xml
DESTINATION ${CMAKE_INSTALL_DATADIR}/metainfo
)
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
)
+52 -3
View File
@@ -13,15 +13,35 @@ Supported target platforms:
OrbitHub is in active development.
- Milestones completed: M0-M5 and M8
- Current milestone: Milestone 9 (Packaging and Distribution)
- Milestones completed: M0-M5, and M7-M9
- Current milestone: Milestone 10 (v1.0 Stabilization)
- Deferred milestone: Milestone 6 (VNC Fully Working)
- Latest checkpoint tag: `v0-m8-wip1`
- Latest checkpoint tag: `v2026.9.14.2`
- 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.14.2](https://git.darksingularity.org/DarkSingularity/orbithub/releases/tag/v2026.9.14.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)
Profiles organized into folders, with protocol, host, and tags shown at a glance. (Sample data shown; not real hosts.)
![Active SSH terminal session](docs/images/screenshot-ssh-session.png)
An interactive SSH terminal session in a tab, with the event log below.
![Active RDP session](docs/images/screenshot-rdp-session.png)
An embedded RDP session in a tab.
## Implemented Features
### Profile Management
@@ -84,6 +104,35 @@ cmake --build build
./build/orbithub
```
## Packaging
Detailed packaging instructions for all platforms:
- [docs/BUILDING.md](docs/BUILDING.md)
Linux (`.deb`):
```bash
./packaging/linux/build-deb.sh
```
Linux (Flatpak):
```bash
./packaging/flatpak/build-flatpak.sh
```
Windows (Inno Setup installer):
```powershell
.\packaging\windows\build-installer.ps1
```
macOS (`.dmg`):
```bash
./packaging/macos/build-dmg.sh
```
## Dependencies
Core dependencies:
+114 -15
View File
@@ -2,6 +2,14 @@
Run all commands from the repository root unless noted.
## Requirements
Minimum toolchain requirements on all platforms:
- CMake 3.21+
- C++17 compiler toolchain
- Qt 6.2+ with `Widgets` and `Sql` modules (dynamic linking)
- OpenSSH client available on `PATH` (required for SSH sessions)
## Linux (Ubuntu / Mint)
```bash
@@ -9,9 +17,9 @@ sudo apt update
sudo apt install -y \
build-essential cmake ninja-build git pkg-config \
qt6-base-dev qt6-base-dev-tools qt6-tools-dev qt6-tools-dev-tools \
openssh-client
openssh-client libssl-dev zlib1g-dev
cmake -S . -B build -G Ninja
cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release
cmake --build build
./build/orbithub
```
@@ -21,39 +29,130 @@ cmake --build build
```bash
xcode-select --install
brew update
brew install cmake ninja pkg-config qt@6 openssh
brew install cmake ninja pkg-config qt@6 openssh openssl@3
cmake -S . -B build -G Ninja -DCMAKE_PREFIX_PATH="$(brew --prefix qt@6)"
cmake -S . -B build -G Ninja \
-DCMAKE_BUILD_TYPE=Release \
-DCMAKE_PREFIX_PATH="$(brew --prefix qt@6);$(brew --prefix openssl@3)"
cmake --build build
./build/orbithub
open build/orbithub.app
```
## Windows 11 (PowerShell + MSVC + vcpkg)
Install required software:
```powershell
winget install -e --id Git.Git
winget install -e --id Kitware.CMake
winget install -e --id Ninja-build.Ninja
Add-WindowsCapability -Online -Name OpenSSH.Client~~~~0.0.1.0
winget install -e --id Microsoft.VisualStudio.2022.BuildTools `
--override "--quiet --wait --norestart --add Microsoft.VisualStudio.Workload.VCTools"
--override "--quiet --wait --norestart --add Microsoft.VisualStudio.Workload.VCTools --add Microsoft.VisualStudio.Component.Windows11SDK.22621"
```
Open a new terminal after installs, then:
Install dependencies via vcpkg:
```powershell
git clone https://github.com/microsoft/vcpkg C:\dev\vcpkg
C:\dev\vcpkg\bootstrap-vcpkg.bat
C:\dev\vcpkg\vcpkg.exe install qtbase:x64-windows
cmake -S . -B build -G Ninja `
-DCMAKE_TOOLCHAIN_FILE=C:/dev/vcpkg/scripts/buildsystems/vcpkg.cmake
cmake --build build
.\build\orbithub.exe
C:\dev\vcpkg\vcpkg.exe install qtbase:x64-windows openssl:x64-windows zlib:x64-windows
```
Open `x64 Native Tools Command Prompt for VS 2022` (or Developer PowerShell), then build:
```powershell
cmake -S . -B build -G Ninja `
-DCMAKE_BUILD_TYPE=Release `
-DCMAKE_TOOLCHAIN_FILE=C:/dev/vcpkg/scripts/buildsystems/vcpkg.cmake `
-DVCPKG_TARGET_TRIPLET=x64-windows
cmake --build build
```
Run (ensures DLL paths from vcpkg are present):
```powershell
C:\dev\vcpkg\vcpkg.exe env --triplet x64-windows -- .\build\orbithub.exe
```
If you already have `Qt 6` from the Qt installer and do not want vcpkg Qt, you can point CMake at that Qt install with `-DCMAKE_PREFIX_PATH=...`, but you still need compatible `OpenSSL` and `zlib` development libraries for the embedded FreeRDP build.
## Notes
- OrbitHub currently requires Qt6 Widgets and CMake 3.21+.
- Milestone 3 SSH sessions require an `ssh` client available on `PATH`.
- OrbitHub builds vendored `KodoTerm`, `libvterm`, and `FreeRDP` from `third_party/`.
- If Qt is installed in a custom location, pass `-DCMAKE_PREFIX_PATH=/path/to/Qt/6.x.x/<toolchain>` to CMake.
- Build output executable:
- Linux: `build/orbithub`
- macOS: `build/orbithub.app` (a proper app bundle, launch with `open build/orbithub.app`)
- Windows: `build\\orbithub.exe`
## Linux Packaging
Build a Debian package (`.deb`) from the current Linux build:
```bash
./packaging/linux/build-deb.sh
```
Output path:
- `dist/orbithub_<version>_<arch>.deb`
Build a Flatpak bundle:
```bash
sudo apt-get install -y flatpak flatpak-builder
./packaging/flatpak/build-flatpak.sh
```
Output path:
- `dist/flatpak/org.darksingularity.OrbitHub.flatpak`
## Windows Packaging
Requires [Inno Setup 6](https://jrsoftware.org/isinfo.php) (`winget install -e --id JRSoftware.InnoSetup`).
From a configured and built `build\` directory (see Windows build steps above):
```powershell
.\packaging\windows\build-installer.ps1
```
Output path:
- `dist\windows\OrbitHub-Setup-<version>.exe`
## macOS Packaging
Requires `qt@6` from Homebrew (for `macdeployqt`). For a custom volume icon on
the `.dmg`, also install `SetFile` (via Xcode's "Additional Tools", from
[developer.apple.com/download/all](https://developer.apple.com/download/all))
or `brew install fileicon` — packaging still works without either, just with
the generic disk image icon.
```bash
./packaging/macos/build-dmg.sh
```
This runs `cmake --install`, then `macdeployqt` to bundle Qt frameworks and
plugins into the `.app`, then re-signs the bundle (ad hoc, since there is no
Developer ID certificate) so it passes macOS's launch-time code-integrity
check. The result is unsigned/unnotarized, so first launch requires
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)
+100
View File
@@ -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`
+57 -10
View File
@@ -115,12 +115,40 @@ Planned Scope:
## Milestone 7 - Cross-Platform Protocol Hardening
Status: Planned
Status: Completed
Planned Scope:
- Validate SSH/RDP/VNC workflows on Windows, Linux, and macOS
- Fix platform-specific runtime/process/auth issues
- Add repeatable protocol validation checklist/scripts
Delivered:
- Validated SSH and RDP workflows on Linux, macOS, and Windows 11 (VNC is out
of scope while Milestone 6 remains deferred)
- Windows: validated the full `docs/BUILDING.md` toolchain end-to-end
(Git/CMake/Ninja/VS Build Tools with the C++ workload/vcpkg for
Qt6-OpenSSL-zlib); documented a VS Build Tools installer gotcha where the
actual compiler is a "recommended", not "required", component of the
VCTools workload
- Fixed RDP keyboard input misreading punctuation keys on Linux (X11 keycode
numbering was being treated as a PC/AT scancode; now uses FreeRDP's
authoritative X11-keycode-to-scancode table)
- Added RDP clipboard sync (bidirectional, plain text) and RDP cursor/pointer
shape sync (resize handles, text I-beam, etc. instead of a static arrow)
- Fixed Tab/Shift+Tab being intercepted by local UI focus navigation instead
of reaching SSH/RDP sessions
- Fixed RDP key auto-repeat being dropped, so holding a key only ever sent a
single keystroke to the remote machine
- Windows-specific RDP fixes: a crash on every connect attempt (FreeRDP's
signal-handling critical section was never initialized) and a host
resolution failure on every connect, including literal IP addresses
(Winsock was never initialized via `WSAStartup`)
- Windows: automatic build-time deployment of Qt's platform/SQL-driver
plugins and their runtime DLL dependencies, and marked the executable as a
GUI (`WIN32`) app to remove a stray console window behind the UI
- Windows and macOS: proper native app icon embedding (Windows `.rc`/`.ico`
resource; macOS `.app` bundle with `.icns`), replacing the generic default
icon previously shown for the built executable/bundle
- macOS: fixed Edit/New Profile dialog form fields collapsing to `sizeHint`
width due to the platform-default `QFormLayout` field growth policy
Git:
- Tag: Pending user approval (`v0-m7-done`)
## Milestone 8 - Profile and Session UX Completion
@@ -149,12 +177,31 @@ Git:
## Milestone 9 - Packaging and Distribution
Status: Planned
Status: Completed
Planned Scope:
- Build distributable artifacts for Windows/Linux/macOS
- Document runtime dependencies and install prerequisites
- Add reproducible release packaging steps/scripts
Delivered:
- Linux `.deb` (`packaging/linux/build-deb.sh`) and Flatpak (`packaging/flatpak/build-flatpak.sh`) packages, both verified installed and launched with working SSH/RDP
- Renamed the app's reverse-DNS identity from `io.orbithub.OrbitHub` to `org.darksingularity.OrbitHub` (desktop file, AppStream metainfo, Flatpak manifest, macOS bundle identifier) to reflect a domain actually owned by the project
- Fixed Linux taskbar/panel pin matching (`StartupWMClass`) so a pinned launcher merges with its running window instead of creating a duplicate entry
- Replaced the stale, mismatched hand-authored launcher SVG with PNG icons rendered directly from the app's own `createOrbitHubAppIcon()` at each hicolor theme size
- Windows installer via Inno Setup (`packaging/windows/orbithub.iss`, `packaging/windows/build-installer.ps1`), verified installed silently and launched cleanly with no crash events
- macOS `.dmg` via `cmake --install` + `macdeployqt` + `hdiutil` (`packaging/macos/build-dmg.sh`), verified installed and launched after fixing:
- a launch crash caused by `macdeployqt` invalidating the code signature (fixed with ad hoc re-signing)
- a missing-library crash caused by the Linux-only `$ORIGIN` rpath token and vendored dylibs installing outside the `.app` bundle (fixed with `APPLE`-specific `@executable_path`/`Contents/Frameworks` layout)
- the dmg showing the generic disk icon instead of the app icon
- README and `docs/BUILDING.md` Packaging sections documented for all three platforms
Validation:
- All three installers built, installed, and launched successfully with working SSH/RDP sessions
- Code signing is ad hoc only (no purchased Apple Developer ID or Windows code-signing certificate), so macOS Gatekeeper and Windows SmartScreen still show first-run warnings by design
Git:
- Tag: `v0-m9-done`
- Release: [v2026.9.8](https://git.darksingularity.org/DarkSingularity/orbithub/releases/tag/v2026.9.8) (`v2026.9.8` tag, installers for Windows/Linux/macOS)
- Release: [v2026.9.8.2](https://git.darksingularity.org/DarkSingularity/orbithub/releases/tag/v2026.9.8.2) — same-day patch fixing RDP TLS certificate verification (was fully disabled) and preparing Flatpak packaging for Flathub submission
- Release: [v2026.9.8.3](https://git.darksingularity.org/DarkSingularity/orbithub/releases/tag/v2026.9.8.3) — same-day patch adding an in-app User Guide and standalone User Guide PDF
- Release: [v2026.9.14](https://git.darksingularity.org/DarkSingularity/orbithub/releases/tag/v2026.9.14) — fixes distorted RDP text on HiDPI monitors and reduces RDP resize-related display glitches
- Release: [v2026.9.14.2](https://git.darksingularity.org/DarkSingularity/orbithub/releases/tag/v2026.9.14.2) — same-day patch fixing RDP display corruption (missing/misplaced taskbar) after resizing the session window (the client never resized its own display buffer for channel-driven RDP resizes)
## 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).
Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

+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"
+26
View File
@@ -0,0 +1,26 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
MANIFEST="$ROOT_DIR/packaging/flatpak/org.darksingularity.OrbitHub.yml"
DIST_DIR="${1:-$ROOT_DIR/dist/flatpak}"
BUILD_DIR="$DIST_DIR/build"
REPO_DIR="$DIST_DIR/repo"
BUNDLE="$DIST_DIR/org.darksingularity.OrbitHub.flatpak"
if ! command -v flatpak-builder >/dev/null 2>&1; then
echo "flatpak-builder is required. Install it first:" >&2
echo " sudo apt-get install -y flatpak-builder" >&2
exit 1
fi
mkdir -p "$DIST_DIR"
flatpak-builder \
--force-clean \
--repo="$REPO_DIR" \
"$BUILD_DIR" \
"$MANIFEST"
flatpak build-bundle "$REPO_DIR" "$BUNDLE" org.darksingularity.OrbitHub
echo "Created $BUNDLE"
+3
View File
@@ -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.14.2
commit: 3e621219f12e9168b152140d927d672f7877b379
@@ -0,0 +1,21 @@
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: dir
path: ../..
+70
View File
@@ -0,0 +1,70 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
BUILD_DIR="${1:-$ROOT_DIR/build}"
DIST_DIR="${2:-$ROOT_DIR/dist}"
STAGE_DIR="$DIST_DIR/deb-staging"
PKG_ROOT="$STAGE_DIR/orbithub"
if [[ ! -f "$BUILD_DIR/CMakeCache.txt" ]]; then
echo "Build directory not configured: $BUILD_DIR" >&2
echo "Run: cmake -S \"$ROOT_DIR\" -B \"$BUILD_DIR\" -G Ninja" >&2
exit 1
fi
mkdir -p "$DIST_DIR"
rm -rf "$STAGE_DIR"
mkdir -p "$PKG_ROOT/DEBIAN"
VERSION="$(sed -n 's/^CMAKE_PROJECT_VERSION:STATIC=//p' "$BUILD_DIR/CMakeCache.txt" | head -n1)"
ARCH="$(dpkg --print-architecture)"
if [[ -z "$VERSION" ]]; then
echo "Unable to determine project version from $BUILD_DIR/CMakeCache.txt" >&2
exit 1
fi
cmake --build "$BUILD_DIR" -j
cmake --install "$BUILD_DIR" --prefix "$PKG_ROOT/usr"
cat > "$PKG_ROOT/DEBIAN/control" <<EOF
Package: orbithub
Version: ${VERSION}
Section: net
Priority: optional
Architecture: ${ARCH}
Maintainer: OrbitHub Maintainers <maintainers@orbithub.local>
Depends: libc6, libstdc++6, libqt6core6, libqt6gui6, libqt6widgets6, libqt6sql6, libssl3, zlib1g, openssh-client
Description: OrbitHub remote session manager
OrbitHub is a native desktop application for managing connection profiles
and opening SSH and RDP sessions in a tabbed interface.
EOF
cat > "$PKG_ROOT/DEBIAN/postinst" <<'EOF'
#!/bin/sh
set -e
if command -v update-desktop-database >/dev/null 2>&1; then
update-desktop-database -q /usr/share/applications || true
fi
if command -v gtk-update-icon-cache >/dev/null 2>&1; then
gtk-update-icon-cache -q /usr/share/icons/hicolor || true
fi
EOF
chmod 0755 "$PKG_ROOT/DEBIAN/postinst"
cat > "$PKG_ROOT/DEBIAN/postrm" <<'EOF'
#!/bin/sh
set -e
if command -v update-desktop-database >/dev/null 2>&1; then
update-desktop-database -q /usr/share/applications || true
fi
if command -v gtk-update-icon-cache >/dev/null 2>&1; then
gtk-update-icon-cache -q /usr/share/icons/hicolor || true
fi
EOF
chmod 0755 "$PKG_ROOT/DEBIAN/postrm"
OUTPUT_DEB="$DIST_DIR/orbithub_${VERSION}_${ARCH}.deb"
fakeroot dpkg-deb --build "$PKG_ROOT" "$OUTPUT_DEB" >/dev/null
echo "Created $OUTPUT_DEB"
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 645 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 960 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

@@ -0,0 +1,12 @@
[Desktop Entry]
Type=Application
Version=1.0
Name=OrbitHub
GenericName=Remote Session Manager
Comment=Manage SSH and RDP sessions in one native app
Exec=orbithub
Icon=org.darksingularity.OrbitHub
Terminal=false
StartupWMClass=OrbitHub
Categories=Network;RemoteAccess;Utility;
StartupNotify=true
@@ -0,0 +1,65 @@
<?xml version="1.0" encoding="UTF-8"?>
<component type="desktop-application">
<id>org.darksingularity.OrbitHub</id>
<name>OrbitHub</name>
<summary>Unified remote session manager for SSH and RDP</summary>
<metadata_license>MIT</metadata_license>
<project_license>MIT</project_license>
<description>
<p>OrbitHub is a native desktop app for organizing connection profiles and launching SSH and RDP sessions in one tabbed interface.</p>
</description>
<launchable type="desktop-id">org.darksingularity.OrbitHub.desktop</launchable>
<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>
<binary>orbithub</binary>
</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.14.2" date="2026-09-14">
<url>https://git.darksingularity.org/DarkSingularity/orbithub/releases/tag/v2026.9.14.2</url>
<description>
<p>Fixes RDP display corruption (missing/misplaced taskbar, stale composited content) after resizing the session window.</p>
</description>
</release>
<release version="2026.9.14" date="2026-09-14">
<url>https://git.darksingularity.org/DarkSingularity/orbithub/releases/tag/v2026.9.14</url>
<description>
<p>Fixes distorted RDP text on HiDPI monitors, and reduces RDP resize-related display glitches.</p>
</description>
</release>
<release version="2026.9.8.3" date="2026-09-08">
<url>https://git.darksingularity.org/DarkSingularity/orbithub/releases/tag/v2026.9.8.3</url>
<description>
<p>Adds an in-app User Guide (Help -> User Guide) and a standalone User Guide PDF.</p>
</description>
</release>
<release version="2026.9.8.2" date="2026-09-08">
<url>https://git.darksingularity.org/DarkSingularity/orbithub/releases/tag/v2026.9.8.2</url>
<description>
<p>Fixes RDP connections silently accepting any server TLS certificate, including changed ones. Certificates are now verified with trust-on-first-use, matching SSH's known-hosts behavior.</p>
</description>
</release>
<release version="2026.9.8" date="2026-09-08">
<url>https://git.darksingularity.org/DarkSingularity/orbithub/releases/tag/v2026.9.8</url>
</release>
</releases>
</component>
+100
View File
@@ -0,0 +1,100 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
BUILD_DIR="${1:-$ROOT_DIR/build}"
DIST_DIR="${2:-$ROOT_DIR/dist/macos}"
STAGE_DIR="$DIST_DIR/dmg-staging"
INSTALL_PREFIX="$DIST_DIR/install"
APP_BUNDLE="OrbitHub.app"
if [[ ! -f "$BUILD_DIR/CMakeCache.txt" ]]; then
echo "Build directory not configured: $BUILD_DIR" >&2
echo "Run: cmake -S \"$ROOT_DIR\" -B \"$BUILD_DIR\" -G Ninja" >&2
exit 1
fi
VERSION="$(sed -n 's/^CMAKE_PROJECT_VERSION:STATIC=//p' "$BUILD_DIR/CMakeCache.txt" | head -n1)"
if [[ -z "$VERSION" ]]; then
echo "Unable to determine project version from $BUILD_DIR/CMakeCache.txt" >&2
exit 1
fi
MACDEPLOYQT="$(brew --prefix qt@6)/bin/macdeployqt"
if [[ ! -x "$MACDEPLOYQT" ]]; then
echo "macdeployqt not found at $MACDEPLOYQT" >&2
echo "Install it: brew install qt@6" >&2
exit 1
fi
mkdir -p "$DIST_DIR"
rm -rf "$STAGE_DIR" "$INSTALL_PREFIX"
mkdir -p "$STAGE_DIR"
cmake --build "$BUILD_DIR" -j
cmake --install "$BUILD_DIR" --prefix "$INSTALL_PREFIX"
if [[ ! -d "$INSTALL_PREFIX/$APP_BUNDLE" ]]; then
echo "Expected app bundle not found: $INSTALL_PREFIX/$APP_BUNDLE" >&2
exit 1
fi
"$MACDEPLOYQT" "$INSTALL_PREFIX/$APP_BUNDLE"
# macdeployqt rewrites library load paths after bundling Qt, which
# invalidates any prior signature. Re-sign (ad hoc, since we have no
# Developer ID certificate) so the app passes macOS's code-integrity
# check at launch. Without this, launching fails with a generic
# "cannot be opened because of a problem" crash-reporter dialog rather
# than the milder unidentified-developer Gatekeeper prompt.
codesign --force --deep --sign - "$INSTALL_PREFIX/$APP_BUNDLE"
cp -R "$INSTALL_PREFIX/$APP_BUNDLE" "$STAGE_DIR/$APP_BUNDLE"
ln -s /Applications "$STAGE_DIR/Applications"
cp "$ROOT_DIR/packaging/macos/orbithub.icns" "$STAGE_DIR/.VolumeIcon.icns"
OUTPUT_DMG="$DIST_DIR/OrbitHub-${VERSION}.dmg"
RW_DMG="$DIST_DIR/OrbitHub-rw.dmg"
rm -f "$OUTPUT_DMG" "$RW_DMG"
hdiutil create -volname "OrbitHub" -srcfolder "$STAGE_DIR" -ov -format UDRW "$RW_DMG"
# Setting the volume's icon requires flipping a Finder attribute bit,
# which needs SetFile (from Xcode's "Additional Tools", not the base
# Command Line Tools) or the fileicon brew formula. Neither is
# guaranteed to be installed, so this step degrades gracefully: the
# dmg is still produced either way, just without a custom icon if no
# tool is available.
ICON_TOOL=""
if command -v SetFile >/dev/null 2>&1; then
ICON_TOOL="SetFile"
elif [[ -x /Library/Developer/CommandLineTools/usr/bin/SetFile ]]; then
ICON_TOOL="/Library/Developer/CommandLineTools/usr/bin/SetFile"
elif command -v fileicon >/dev/null 2>&1; then
ICON_TOOL="fileicon"
fi
if [[ -n "$ICON_TOOL" ]]; then
MOUNT_DIR="$(mktemp -d)"
hdiutil attach "$RW_DMG" -mountpoint "$MOUNT_DIR" -nobrowse -quiet
if [[ "$ICON_TOOL" == "fileicon" ]]; then
fileicon set "$MOUNT_DIR" "$ROOT_DIR/packaging/macos/orbithub.icns" >/dev/null
else
"$ICON_TOOL" -a V "$MOUNT_DIR/.VolumeIcon.icns"
"$ICON_TOOL" -a C "$MOUNT_DIR"
fi
hdiutil detach "$MOUNT_DIR" -quiet
rmdir "$MOUNT_DIR" 2>/dev/null || true
else
echo "Note: SetFile/fileicon not found, dmg will use the generic disk icon." >&2
echo " Install one to get a custom volume icon:" >&2
echo " brew install fileicon" >&2
echo " or download 'Additional Tools for Xcode' from developer.apple.com/download/all" >&2
fi
hdiutil convert "$RW_DMG" -format UDZO -ov -o "$OUTPUT_DMG"
rm -f "$RW_DMG"
echo "Created $OUTPUT_DMG"
Binary file not shown.
+36
View File
@@ -0,0 +1,36 @@
param(
[string]$BuildDir = "$PSScriptRoot\..\..\build",
[string]$DistDir = "$PSScriptRoot\..\..\dist\windows"
)
$ErrorActionPreference = "Stop"
$cacheFile = Join-Path $BuildDir "CMakeCache.txt"
if (-not (Test-Path $cacheFile)) {
throw "Build directory not configured: $BuildDir (run cmake -S . -B build first)"
}
$versionLine = Select-String -Path $cacheFile -Pattern '^CMAKE_PROJECT_VERSION:STATIC=(.*)$'
if (-not $versionLine) {
throw "Unable to determine project version from $cacheFile"
}
$version = $versionLine.Matches[0].Groups[1].Value
$isccCandidates = @(
"C:\Program Files (x86)\Inno Setup 6\ISCC.exe",
"C:\Program Files\Inno Setup 6\ISCC.exe",
"$env:LOCALAPPDATA\Programs\Inno Setup 6\ISCC.exe"
)
$iscc = $isccCandidates | Where-Object { Test-Path $_ } | Select-Object -First 1
if (-not $iscc) {
throw "Inno Setup compiler (ISCC.exe) not found. Install it: winget install -e --id JRSoftware.InnoSetup"
}
New-Item -ItemType Directory -Force -Path $DistDir | Out-Null
& $iscc "/DMyAppVersion=$version" "$PSScriptRoot\orbithub.iss"
if ($LASTEXITCODE -ne 0) {
throw "ISCC.exe failed with exit code $LASTEXITCODE"
}
Write-Output "Created $DistDir\OrbitHub-Setup-$version.exe"
Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

+46
View File
@@ -0,0 +1,46 @@
#ifndef MyAppVersion
#define MyAppVersion "0.0.0"
#endif
#define MyAppName "OrbitHub"
#define MyAppPublisher "OrbitHub Maintainers"
#define MyAppExeName "orbithub.exe"
#define MyBuildDir "..\..\build"
[Setup]
AppId={{6B2E0B8F-2C6D-4E7C-9C36-6C6C9C6F6C1A}
AppName={#MyAppName}
AppVersion={#MyAppVersion}
AppPublisher={#MyAppPublisher}
DefaultDirName={autopf}\{#MyAppName}
DefaultGroupName={#MyAppName}
UninstallDisplayIcon={app}\{#MyAppExeName}
OutputDir=..\..\dist\windows
OutputBaseFilename=OrbitHub-Setup-{#MyAppVersion}
Compression=lzma2
SolidCompression=yes
ArchitecturesAllowed=x64compatible
ArchitecturesInstallIn64BitMode=x64compatible
SetupIconFile=orbithub.ico
WizardStyle=modern
DisableProgramGroupPage=yes
[Languages]
Name: "english"; MessagesFile: "compiler:Default.isl"
[Tasks]
Name: "desktopicon"; Description: "Create a &desktop shortcut"; GroupDescription: "Additional icons:"; Flags: unchecked
[Files]
Source: "{#MyBuildDir}\{#MyAppExeName}"; DestDir: "{app}"; Flags: ignoreversion
Source: "{#MyBuildDir}\*.dll"; DestDir: "{app}"; Flags: ignoreversion
Source: "{#MyBuildDir}\platforms\*"; DestDir: "{app}\platforms"; Flags: ignoreversion recursesubdirs
Source: "{#MyBuildDir}\sqldrivers\*"; DestDir: "{app}\sqldrivers"; Flags: ignoreversion recursesubdirs
[Icons]
Name: "{group}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"
Name: "{group}\Uninstall {#MyAppName}"; Filename: "{uninstallexe}"
Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: desktopicon
[Run]
Filename: "{app}\{#MyAppExeName}"; Description: "Launch {#MyAppName}"; Flags: nowait postinstall skipifsilent
+1
View File
@@ -0,0 +1 @@
1 ICON "orbithub.ico"
+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);
+5 -2
View File
@@ -1,5 +1,5 @@
#include "app_icon.h"
#include "profiles_window.h"
#include "session_window.h"
#include <QApplication>
@@ -10,9 +10,12 @@ 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());
ProfilesWindow window;
SessionWindow window;
window.show();
return app.exec();
+3
View File
@@ -71,6 +71,9 @@ ProfileDialog::ProfileDialog(QWidget* parent)
auto* layout = new QVBoxLayout(this);
auto* form = new QFormLayout();
form->setFieldGrowthPolicy(QFormLayout::ExpandingFieldsGrow);
form->setFormAlignment(Qt::AlignLeft | Qt::AlignTop);
form->setLabelAlignment(Qt::AlignRight);
m_nameInput->setPlaceholderText(QStringLiteral("Production Bastion"));
m_hostInput->setPlaceholderText(QStringLiteral("example.internal"));
+29 -71
View File
@@ -1,10 +1,8 @@
#include "profiles_window.h"
#include "about_dialog.h"
#include "profile_dialog.h"
#include "profile_repository.h"
#include "profiles_tree_widget.h"
#include "session_window.h"
#include <QAction>
#include <QAbstractItemView>
@@ -15,13 +13,11 @@
#include <QLineEdit>
#include <QInputDialog>
#include <QMenu>
#include <QMenuBar>
#include <QMessageBox>
#include <QPushButton>
#include <QSet>
#include <QSettings>
#include <QSignalBlocker>
#include <QApplication>
#include <QStringList>
#include <QStyle>
#include <QTreeWidget>
@@ -92,7 +88,7 @@ bool profileHasTag(const Profile& profile, const QString& requestedTag)
}
ProfilesWindow::ProfilesWindow(QWidget* parent)
: QMainWindow(parent),
: QWidget(parent),
m_searchBox(nullptr),
m_viewModeBox(nullptr),
m_sortBox(nullptr),
@@ -104,10 +100,6 @@ ProfilesWindow::ProfilesWindow(QWidget* parent)
m_deleteButton(nullptr),
m_repository(std::make_unique<ProfileRepository>())
{
setWindowTitle(QStringLiteral("OrbitHub Profiles"));
resize(860, 640);
setWindowIcon(QApplication::windowIcon());
setupUi();
if (!m_repository->initError().isEmpty()) {
@@ -131,36 +123,35 @@ ProfilesWindow::~ProfilesWindow() = default;
void ProfilesWindow::setupUi()
{
auto* central = new QWidget(this);
auto* rootLayout = new QVBoxLayout(central);
auto* rootLayout = new QVBoxLayout(this);
auto* searchLabel = new QLabel(QStringLiteral("Search"), central);
m_searchBox = new QLineEdit(central);
auto* searchLabel = new QLabel(QStringLiteral("Search"), this);
m_searchBox = new QLineEdit(this);
m_searchBox->setPlaceholderText(QStringLiteral("Filter by name, host, folder, or tags..."));
auto* viewModeLabel = new QLabel(QStringLiteral("View"), central);
m_viewModeBox = new QComboBox(central);
auto* viewModeLabel = new QLabel(QStringLiteral("View"), this);
m_viewModeBox = new QComboBox(this);
m_viewModeBox->addItem(QStringLiteral("List"));
m_viewModeBox->addItem(QStringLiteral("Folders"));
auto* sortLabel = new QLabel(QStringLiteral("Sort"), central);
m_sortBox = new QComboBox(central);
auto* sortLabel = new QLabel(QStringLiteral("Sort"), this);
m_sortBox = new QComboBox(this);
m_sortBox->addItem(QStringLiteral("Name"), static_cast<int>(ProfileSortOrder::NameAsc));
m_sortBox->addItem(QStringLiteral("Protocol"), static_cast<int>(ProfileSortOrder::ProtocolAsc));
m_sortBox->addItem(QStringLiteral("Host"), static_cast<int>(ProfileSortOrder::HostAsc));
auto* protocolFilterLabel = new QLabel(QStringLiteral("Protocol"), central);
m_protocolFilterBox = new QComboBox(central);
auto* protocolFilterLabel = new QLabel(QStringLiteral("Protocol"), this);
m_protocolFilterBox = new QComboBox(this);
m_protocolFilterBox->addItem(QStringLiteral("All"));
m_protocolFilterBox->addItem(QStringLiteral("SSH"));
m_protocolFilterBox->addItem(QStringLiteral("RDP"));
m_protocolFilterBox->addItem(QStringLiteral("VNC"));
auto* tagFilterLabel = new QLabel(QStringLiteral("Tag"), central);
m_tagFilterBox = new QComboBox(central);
auto* tagFilterLabel = new QLabel(QStringLiteral("Tag"), this);
m_tagFilterBox = new QComboBox(this);
m_tagFilterBox->addItem(QStringLiteral("All"));
m_profilesTree = new ProfilesTreeWidget(central);
m_profilesTree = new ProfilesTreeWidget(this);
m_profilesTree->setSelectionMode(QAbstractItemView::SingleSelection);
m_profilesTree->setColumnCount(4);
m_profilesTree->setHeaderLabels(
@@ -179,9 +170,9 @@ void ProfilesWindow::setupUi()
m_profilesTree->header()->setSectionResizeMode(3, QHeaderView::Stretch);
auto* buttonRow = new QHBoxLayout();
m_newButton = new QPushButton(QStringLiteral("New"), central);
m_editButton = new QPushButton(QStringLiteral("Edit"), central);
m_deleteButton = new QPushButton(QStringLiteral("Delete"), central);
m_newButton = new QPushButton(QStringLiteral("New"), this);
m_editButton = new QPushButton(QStringLiteral("Edit"), this);
m_deleteButton = new QPushButton(QStringLiteral("Delete"), this);
buttonRow->addWidget(m_newButton);
buttonRow->addWidget(m_editButton);
@@ -204,40 +195,6 @@ void ProfilesWindow::setupUi()
rootLayout->addWidget(m_profilesTree, 1);
rootLayout->addLayout(buttonRow);
setCentralWidget(central);
QMenu* fileMenu = menuBar()->addMenu(QStringLiteral("File"));
QAction* newProfileAction = fileMenu->addAction(QStringLiteral("New Profile"));
QAction* newFolderAction = fileMenu->addAction(QStringLiteral("New Folder"));
fileMenu->addSeparator();
QAction* quitAction = fileMenu->addAction(QStringLiteral("Quit"));
connect(newProfileAction,
&QAction::triggered,
this,
[this]() {
const QString folderPath = folderPathForItem(m_profilesTree->currentItem());
createProfile(folderPath);
});
connect(newFolderAction,
&QAction::triggered,
this,
[this]() {
const QString folderPath = folderPathForItem(m_profilesTree->currentItem());
createFolderInContext(folderPath);
});
connect(quitAction, &QAction::triggered, this, [this]() { close(); });
QMenu* helpMenu = menuBar()->addMenu(QStringLiteral("Help"));
QAction* aboutAction = helpMenu->addAction(QStringLiteral("About OrbitHub"));
connect(aboutAction,
&QAction::triggered,
this,
[this]() {
AboutDialog dialog(this);
dialog.exec();
});
connect(m_searchBox,
&QLineEdit::textChanged,
this,
@@ -959,16 +916,17 @@ void ProfilesWindow::openSessionForItem(QTreeWidgetItem* item)
return;
}
if (m_sessionWindow.isNull()) {
m_sessionWindow = new SessionWindow(profile.value());
m_sessionWindow->setAttribute(Qt::WA_DeleteOnClose);
connect(m_sessionWindow, &QObject::destroyed, this, [this]() { m_sessionWindow = nullptr; });
} else {
m_sessionWindow->openProfile(profile.value());
}
m_sessionWindow->setWindowState(m_sessionWindow->windowState() & ~Qt::WindowMinimized);
m_sessionWindow->show();
m_sessionWindow->raise();
m_sessionWindow->activateWindow();
emit connectRequested(profile.value());
}
void ProfilesWindow::createProfileInCurrentContext()
{
const QString folderPath = folderPathForItem(m_profilesTree->currentItem());
createProfile(folderPath);
}
void ProfilesWindow::createFolderInCurrentContext()
{
const QString folderPath = folderPathForItem(m_profilesTree->currentItem());
createFolderInContext(folderPath);
}
+8 -5
View File
@@ -3,7 +3,7 @@
#include "profile_repository.h"
#include <QMainWindow>
#include <QWidget>
#include <QString>
#include <QStringList>
#include <QtGlobal>
@@ -11,7 +11,6 @@
#include <memory>
#include <map>
#include <optional>
#include <QPointer>
#include <vector>
#include <unordered_map>
@@ -21,10 +20,9 @@ class QLineEdit;
class QPushButton;
class QComboBox;
class QPoint;
class SessionWindow;
class ProfilesTreeWidget;
class ProfilesWindow : public QMainWindow
class ProfilesWindow : public QWidget
{
Q_OBJECT
@@ -32,6 +30,12 @@ public:
explicit ProfilesWindow(QWidget* parent = nullptr);
~ProfilesWindow() override;
void createProfileInCurrentContext();
void createFolderInCurrentContext();
signals:
void connectRequested(const Profile& profile);
private:
QLineEdit* m_searchBox;
QComboBox* m_viewModeBox;
@@ -42,7 +46,6 @@ private:
QPushButton* m_newButton;
QPushButton* m_editButton;
QPushButton* m_deleteButton;
QPointer<SessionWindow> m_sessionWindow;
std::unique_ptr<ProfileRepository> m_repository;
std::unordered_map<qint64, Profile> m_profileCache;
QString m_pendingTagFilterPreference;
+135 -8
View File
@@ -1,8 +1,11 @@
#include "rdp_display_widget.h"
#include <QCursor>
#include <QEvent>
#include <QKeyEvent>
#include <QMouseEvent>
#include <QPainter>
#include <QPixmap>
#include <QResizeEvent>
#include <QTimer>
#include <QWheelEvent>
@@ -13,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)
: 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)
@@ -52,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();
}
@@ -61,6 +89,62 @@ void RdpDisplayWidget::clearFrame()
update();
}
void RdpDisplayWidget::setCursorImage(const QImage& image, const QPoint& hotspot)
{
m_cursorImage = image;
m_cursorHotspot = hotspot;
m_cursorMode = CursorMode::Custom;
applyCursor();
}
void RdpDisplayWidget::setCursorHidden()
{
m_cursorMode = CursorMode::Hidden;
applyCursor();
}
void RdpDisplayWidget::setCursorDefault()
{
m_cursorMode = CursorMode::Default;
applyCursor();
}
void RdpDisplayWidget::applyCursor()
{
if (m_cursorMode == CursorMode::Hidden) {
setCursor(Qt::BlankCursor);
return;
}
if (m_cursorMode == CursorMode::Default || m_cursorImage.isNull()) {
unsetCursor();
return;
}
const QSize remote = effectiveRemoteSize();
const QRectF target = renderRect();
if (remote.isEmpty() || target.isEmpty()) {
setCursor(QCursor(QPixmap::fromImage(m_cursorImage),
m_cursorHotspot.x(),
m_cursorHotspot.y()));
return;
}
const qreal scale = target.width() / remote.width();
QImage scaledImage = m_cursorImage;
if (!qFuzzyCompare(scale, 1.0)) {
scaledImage = m_cursorImage.scaled(
qMax(1, qRound(m_cursorImage.width() * scale)),
qMax(1, qRound(m_cursorImage.height() * scale)),
Qt::IgnoreAspectRatio,
Qt::SmoothTransformation);
}
const int hotX = qBound(0, qRound(m_cursorHotspot.x() * scale), scaledImage.width());
const int hotY = qBound(0, qRound(m_cursorHotspot.y() * scale), scaledImage.height());
setCursor(QCursor(QPixmap::fromImage(scaledImage), hotX, hotY));
}
void RdpDisplayWidget::paintEvent(QPaintEvent* event)
{
Q_UNUSED(event);
@@ -82,16 +166,51 @@ 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 || event->isAutoRepeat()) {
if (event == nullptr) {
return;
}
// Auto-repeat presses must reach the remote server so it can perform
// its own typematic repeat, exactly as a physical keyboard held down
// would. Only release events filter out isAutoRepeat() (below), since
// Qt uses a synthetic release/press pair purely to normalize platform
// auto-repeat quirks -- forwarding that synthetic release would send a
// spurious key-up for a key that is still physically held.
emit keyInput(event->key(),
event->nativeScanCode(),
event->text(),
@@ -114,6 +233,14 @@ void RdpDisplayWidget::keyReleaseEvent(QKeyEvent* event)
event->accept();
}
bool RdpDisplayWidget::focusNextPrevChild(bool next)
{
Q_UNUSED(next);
// Tab/Shift+Tab must reach keyPressEvent() and be forwarded to the
// remote session instead of moving focus to the next local widget.
return false;
}
void RdpDisplayWidget::mousePressEvent(QMouseEvent* event)
{
if (event == nullptr) {
+20
View File
@@ -8,6 +8,7 @@ class QKeyEvent;
class QMouseEvent;
class QPaintEvent;
class QResizeEvent;
class QTimer;
class QWheelEvent;
class RdpDisplayWidget : public QWidget
@@ -20,6 +21,9 @@ public:
void setFrame(const QImage& frame);
void setRemoteDesktopSize(int width, int height);
void clearFrame();
void setCursorImage(const QImage& image, const QPoint& hotspot);
void setCursorHidden();
void setCursorDefault();
signals:
void keyInput(int key, quint32 nativeScanCode, const QString& text, bool pressed, int modifiers);
@@ -27,24 +31,40 @@ 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;
void mouseReleaseEvent(QMouseEvent* event) override;
void mouseMoveEvent(QMouseEvent* event) override;
void wheelEvent(QWheelEvent* event) override;
bool focusNextPrevChild(bool next) override;
private:
enum class CursorMode {
Default,
Custom,
Hidden,
};
QImage m_frame;
QSize m_remoteSize;
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
+683 -41
View File
@@ -7,29 +7,74 @@
#include <cmath>
#include <cstring>
#include <mutex>
#include <new>
#include <utility>
#ifdef ORBITHUB_HAS_FREERDP
#include <freerdp/channels/channels.h>
#include <freerdp/channels/cliprdr.h>
#include <freerdp/channels/drdynvc.h>
#include <freerdp/channels/disp.h>
#include <freerdp/client/cliprdr.h>
#include <freerdp/addin.h>
#include <freerdp/client/channels.h>
#include <freerdp/client/cmdline.h>
#include <freerdp/client/disp.h>
#include <freerdp/codec/color.h>
#include <freerdp/display.h>
#include <freerdp/error.h>
#include <freerdp/event.h>
#include <freerdp/freerdp.h>
#include <freerdp/gdi/gdi.h>
#include <freerdp/graphics.h>
#include <freerdp/input.h>
#include <freerdp/locale/keyboard.h>
#include <freerdp/scancode.h>
#include <freerdp/settings.h>
#include <freerdp/utils/signal.h>
#include <winpr/winsock.h>
#include <winpr/crt.h>
#include <winpr/synch.h>
#include <winpr/user.h>
#endif
namespace {
#ifdef ORBITHUB_HAS_FREERDP
// Mirrors wfreerdp_client_global_init() in FreeRDP's own reference Windows
// client (client/Windows/wf_client.c): every reference client calls
// WSAStartup + freerdp_handle_signals once at startup before connecting.
//
// WSAStartup: on Windows, sockets (getaddrinfo et al.) fail immediately
// with WSANOTINITIALISED until this has been called by *someone* in the
// process; OrbitHub never used Qt Network, so nothing else on Windows was
// calling it, and every connect failed instantly with
// ERRCONNECT_DNS_NAME_NOT_FOUND even for a literal IP address. WinPR
// provides a portable no-op WSAStartup shim on POSIX, so this is safe to
// call unconditionally cross-platform.
//
// freerdp_handle_signals: on Windows it's the only place that initializes
// a global CRITICAL_SECTION used by freerdp_add_signal_cleanup_handler();
// skipping it leaves that lock zero-initialized (invalid on Windows,
// tolerated on POSIX), so the first freerdp_connect() crashed with a
// null-pointer dereference inside EnterCriticalSection.
//
// Guarded so it only ever runs once.
void ensureFreeRdpRuntimeInitialized()
{
static std::once_flag onceFlag;
std::call_once(onceFlag, []() {
WSADATA wsaData;
const int wsaRc = WSAStartup(0x101, &wsaData);
Q_UNUSED(wsaRc);
const int signalRc = freerdp_handle_signals();
Q_UNUSED(signalRc);
});
}
#endif
constexpr int kDefaultDesktopWidth = 1280;
constexpr int kDefaultDesktopHeight = 720;
constexpr int kMinDesktopWidth = 640;
@@ -59,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();
@@ -146,6 +207,16 @@ UINT orbitDisplayControlCaps(DispClientContext* context,
UINT32 maxNumMonitors,
UINT32 maxMonitorAreaFactorA,
UINT32 maxMonitorAreaFactorB);
UINT orbitCliprdrMonitorReady(CliprdrClientContext* context,
const CLIPRDR_MONITOR_READY* monitorReady);
UINT orbitCliprdrServerFormatList(CliprdrClientContext* context,
const CLIPRDR_FORMAT_LIST* formatList);
UINT orbitCliprdrServerFormatListResponse(
CliprdrClientContext* context, const CLIPRDR_FORMAT_LIST_RESPONSE* formatListResponse);
UINT orbitCliprdrServerFormatDataRequest(
CliprdrClientContext* context, const CLIPRDR_FORMAT_DATA_REQUEST* formatDataRequest);
UINT orbitCliprdrServerFormatDataResponse(
CliprdrClientContext* context, const CLIPRDR_FORMAT_DATA_RESPONSE* formatDataResponse);
BOOL orbitLoadChannels(freerdp* instance);
bool orbitLoadStaticChannel(rdpChannels* channels,
rdpSettings* settings,
@@ -246,6 +317,118 @@ BOOL orbitDesktopResize(rdpContext* context)
return TRUE;
}
// Custom rdpPointer subclass: FreeRDP allocates this with calloc() and only
// ever touches the base rdpPointer fields itself, so the trailing fields here
// must stay plain-old-data and be managed manually in New()/Free() (mirrors
// FreeRDP's own X11 client, client/X11/xf_graphics.c).
struct OrbitRdpPointer
{
rdpPointer pointer;
uchar* rgbaData;
UINT32 rgbaWidth;
UINT32 rgbaHeight;
};
BOOL orbitPointerNew(rdpContext* context, rdpPointer* pointer)
{
if (context == nullptr || pointer == nullptr || context->gdi == nullptr) {
return FALSE;
}
auto* orbitPointer = reinterpret_cast<OrbitRdpPointer*>(pointer);
orbitPointer->rgbaData = nullptr;
orbitPointer->rgbaWidth = 0;
orbitPointer->rgbaHeight = 0;
if (pointer->width == 0 || pointer->height == 0) {
return TRUE;
}
const size_t size = static_cast<size_t>(pointer->width) * pointer->height * 4;
auto* buffer = new (std::nothrow) uchar[size];
if (buffer == nullptr) {
return FALSE;
}
if (!freerdp_image_copy_from_pointer_data(buffer,
PIXEL_FORMAT_BGRA32,
0,
0,
0,
pointer->width,
pointer->height,
pointer->xorMaskData,
pointer->lengthXorMask,
pointer->andMaskData,
pointer->lengthAndMask,
pointer->xorBpp,
&context->gdi->palette)) {
delete[] buffer;
return FALSE;
}
orbitPointer->rgbaData = buffer;
orbitPointer->rgbaWidth = pointer->width;
orbitPointer->rgbaHeight = pointer->height;
return TRUE;
}
void orbitPointerFree(rdpContext* context, rdpPointer* pointer)
{
Q_UNUSED(context);
if (pointer == nullptr) {
return;
}
auto* orbitPointer = reinterpret_cast<OrbitRdpPointer*>(pointer);
delete[] orbitPointer->rgbaData;
orbitPointer->rgbaData = nullptr;
}
BOOL orbitPointerSet(rdpContext* context, rdpPointer* pointer)
{
if (context == nullptr || pointer == nullptr) {
return FALSE;
}
RdpSessionBackend* backend = backendFromContext(context);
if (backend == nullptr) {
return TRUE;
}
auto* orbitPointer = reinterpret_cast<OrbitRdpPointer*>(pointer);
if (orbitPointer->rgbaData == nullptr || orbitPointer->rgbaWidth == 0
|| orbitPointer->rgbaHeight == 0) {
return TRUE;
}
const QImage image(orbitPointer->rgbaData,
static_cast<int>(orbitPointer->rgbaWidth),
static_cast<int>(orbitPointer->rgbaHeight),
QImage::Format_ARGB32);
emit backend->cursorImageChanged(
image.copy(),
QPoint(static_cast<int>(pointer->xPos), static_cast<int>(pointer->yPos)));
return TRUE;
}
BOOL orbitPointerSetNull(rdpContext* context)
{
if (RdpSessionBackend* backend = backendFromContext(context)) {
emit backend->cursorHidden();
}
return TRUE;
}
BOOL orbitPointerSetDefault(rdpContext* context)
{
if (RdpSessionBackend* backend = backendFromContext(context)) {
emit backend->cursorReset();
}
return TRUE;
}
BOOL orbitPreConnect(freerdp* instance)
{
if (instance == nullptr || instance->context == nullptr || instance->context->settings == nullptr) {
@@ -253,9 +436,19 @@ BOOL orbitPreConnect(freerdp* instance)
}
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_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;
}
@@ -274,6 +467,17 @@ BOOL orbitPostConnect(freerdp* instance)
context->update->EndPaint = orbitEndPaint;
context->update->DesktopResize = orbitDesktopResize;
if (context->graphics != nullptr) {
rdpPointer pointerCallbacks = {};
pointerCallbacks.size = sizeof(OrbitRdpPointer);
pointerCallbacks.New = orbitPointerNew;
pointerCallbacks.Free = orbitPointerFree;
pointerCallbacks.Set = orbitPointerSet;
pointerCallbacks.SetNull = orbitPointerSetNull;
pointerCallbacks.SetDefault = orbitPointerSetDefault;
graphics_register_pointer(context->graphics, &pointerCallbacks);
}
if (RdpSessionBackend* backend = backendFromContext(context)) {
const int width = static_cast<int>(
freerdp_settings_get_uint32(context->settings, FreeRDP_DesktopWidth));
@@ -291,6 +495,7 @@ void orbitPostDisconnect(freerdp* instance)
if (instance->context != nullptr) {
if (RdpSessionBackend* backend = backendFromContext(instance->context)) {
backend->onChannelDisconnectedEvent(DISP_DVC_CHANNEL_NAME, nullptr);
backend->onChannelDisconnectedEvent(CLIPRDR_SVC_CHANNEL_NAME, nullptr);
}
PubSub_UnsubscribeChannelConnected(instance->context->pubSub,
orbitOnChannelConnectedEventHandler);
@@ -337,6 +542,75 @@ UINT orbitDisplayControlCaps(DispClientContext* context,
return CHANNEL_RC_OK;
}
UINT orbitCliprdrMonitorReady(CliprdrClientContext* context,
const CLIPRDR_MONITOR_READY* monitorReady)
{
Q_UNUSED(monitorReady);
if (context == nullptr || context->custom == nullptr) {
return CHANNEL_RC_OK;
}
auto* backend = reinterpret_cast<RdpSessionBackend*>(context->custom);
backend->onCliprdrMonitorReady();
return CHANNEL_RC_OK;
}
UINT orbitCliprdrServerFormatList(CliprdrClientContext* context,
const CLIPRDR_FORMAT_LIST* formatList)
{
if (context == nullptr || context->custom == nullptr || formatList == nullptr) {
return CHANNEL_RC_OK;
}
bool hasUnicodeText = false;
for (UINT32 i = 0; i < formatList->numFormats; ++i) {
if (formatList->formats[i].formatId == CF_UNICODETEXT) {
hasUnicodeText = true;
break;
}
}
auto* backend = reinterpret_cast<RdpSessionBackend*>(context->custom);
backend->onCliprdrServerFormatList(hasUnicodeText);
return CHANNEL_RC_OK;
}
UINT orbitCliprdrServerFormatListResponse(
CliprdrClientContext* context, const CLIPRDR_FORMAT_LIST_RESPONSE* formatListResponse)
{
Q_UNUSED(context);
Q_UNUSED(formatListResponse);
return CHANNEL_RC_OK;
}
UINT orbitCliprdrServerFormatDataRequest(
CliprdrClientContext* context, const CLIPRDR_FORMAT_DATA_REQUEST* formatDataRequest)
{
if (context == nullptr || context->custom == nullptr || formatDataRequest == nullptr) {
return CHANNEL_RC_OK;
}
auto* backend = reinterpret_cast<RdpSessionBackend*>(context->custom);
backend->onCliprdrServerFormatDataRequest(formatDataRequest->requestedFormatId);
return CHANNEL_RC_OK;
}
UINT orbitCliprdrServerFormatDataResponse(
CliprdrClientContext* context, const CLIPRDR_FORMAT_DATA_RESPONSE* formatDataResponse)
{
if (context == nullptr || context->custom == nullptr || formatDataResponse == nullptr) {
return CHANNEL_RC_OK;
}
auto* backend = reinterpret_cast<RdpSessionBackend*>(context->custom);
const bool success = (formatDataResponse->common.msgFlags & CB_RESPONSE_OK) != 0;
backend->onCliprdrServerFormatDataResponse(
success,
reinterpret_cast<const uint8_t*>(formatDataResponse->requestedFormatData),
formatDataResponse->common.dataLen);
return CHANNEL_RC_OK;
}
BOOL orbitLoadChannels(freerdp* instance)
{
if (instance == nullptr || instance->context == nullptr || instance->context->settings == nullptr
@@ -383,6 +657,14 @@ BOOL orbitLoadChannels(freerdp* instance)
return TRUE;
}
if (!orbitLoadStaticChannel(channels, settings, CLIPRDR_SVC_CHANNEL_NAME, settings)) {
if (backend != nullptr) {
emit backend->eventLogged(
QStringLiteral("RDP warning: failed to load static '%1' channel; clipboard sync unavailable.")
.arg(QString::fromUtf8(CLIPRDR_SVC_CHANNEL_NAME)));
}
}
if (backend != nullptr) {
emit backend->eventLogged(QStringLiteral("RDP channel loader: display-control channels initialized."));
}
@@ -452,10 +734,13 @@ DWORD orbitVerifyCertificateEx(freerdp* instance,
const char*,
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 (RdpSessionBackend* backend = backendFromContext(instance->context)) {
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(port));
}
@@ -469,21 +754,42 @@ DWORD orbitVerifyChangedCertificateEx(freerdp* instance,
const char*,
const char*,
const char*,
const char* newFingerprint,
const char*,
const char*,
const char*,
const char*,
const char* oldFingerprint,
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 (RdpSessionBackend* backend = backendFromContext(instance->context)) {
emit backend->eventLogged(
QStringLiteral("Accepting changed server certificate for %1:%2.")
.arg(QString::fromUtf8(host == nullptr ? "" : host))
.arg(port));
backend->recordCertificateRejection(message);
}
}
return 1;
return 0;
}
const char* authReasonName(rdp_auth_reason reason)
@@ -576,6 +882,25 @@ UINT32 scancodeFromNativeScanCode(quint32 nativeScanCode)
return RDP_SCANCODE_UNKNOWN;
}
#if defined(Q_OS_LINUX)
// On X11, Qt's nativeScanCode() is the raw X11 KeyCode, not a PC/AT set-1
// scancode. X11 keycode numbering only coincidentally overlaps with PC/AT
// scancodes for some keys and diverges for others (e.g. X11 keycode 0x30
// is the apostrophe/quote key, while PC/AT scancode 0x30 is the B key), so
// treating one as the other silently sends the wrong key. FreeRDP ships an
// authoritative X11-keycode -> RDP-scancode table; use it directly.
// The function is marked deprecated upstream ("implement yourself in
// client") but remains present and correct in this pinned FreeRDP build.
#if defined(__GNUC__) || defined(__clang__)
#pragma GCC diagnostic push
#pragma GCC diagnostic ignored "-Wdeprecated-declarations"
#endif
const DWORD mapped = freerdp_keyboard_get_rdp_scancode_from_x11_keycode(nativeScanCode);
#if defined(__GNUC__) || defined(__clang__)
#pragma GCC diagnostic pop
#endif
return mapped != 0 ? mapped : RDP_SCANCODE_UNKNOWN;
#else
quint32 code = 0;
bool extended = false;
if ((nativeScanCode & 0xFF000000u) == 0xE0000000u) {
@@ -607,6 +932,7 @@ UINT32 scancodeFromNativeScanCode(quint32 nativeScanCode)
}
return scancode;
#endif
}
UINT32 scancodeForQtKey(int key, Qt::KeyboardModifiers modifiers, quint32 nativeScanCode)
@@ -994,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),
@@ -1001,7 +1328,9 @@ RdpSessionBackend::RdpSessionBackend(const Profile& profile, QObject* parent)
m_displayControlReady(false),
m_resizeFailureLogged(false),
m_lastResizeWidth(0),
m_lastResizeHeight(0)
m_lastResizeHeight(0),
m_lastResizeScale(0),
m_cliprdrContext(nullptr)
{
}
@@ -1092,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,
@@ -1112,6 +1463,18 @@ void RdpSessionBackend::sendKeyEvent(int key,
enqueueInputEvent(event);
}
void RdpSessionBackend::setClipboardText(const QString& text)
{
if (!m_workerRunning.load()) {
return;
}
InputEvent event;
event.type = InputEventType::SetClipboardText;
event.text = text;
enqueueInputEvent(event);
}
void RdpSessionBackend::sendMouseMoveEvent(int x, int y)
{
if (!m_workerRunning.load()) {
@@ -1162,6 +1525,11 @@ void RdpSessionBackend::setState(SessionState state, const QString& message)
emit eventLogged(message);
}
void RdpSessionBackend::recordCertificateRejection(const QString& reason)
{
m_certificateRejectionReason = reason;
}
bool RdpSessionBackend::validateProfile(QString& message) const
{
const Profile& p = profile();
@@ -1221,6 +1589,9 @@ void RdpSessionBackend::workerMain()
m_workerRunning.store(false);
return;
#else
m_certificateRejectionReason.clear();
ensureFreeRdpRuntimeInitialized();
freerdp* instance = freerdp_new();
if (instance == nullptr) {
emitConnectionFailureAsync(QStringLiteral("Failed to initialize FreeRDP runtime."),
@@ -1306,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)) {
@@ -1380,7 +1771,9 @@ void RdpSessionBackend::workerMain()
emit eventLogged(QStringLiteral("RDP connect aborted: %1").arg(raw));
emitStateAsync(SessionState::Disconnected, disconnectMessageForCode(code));
} 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));
emitConnectionFailureAsync(mapped, raw);
emitStateAsync(SessionState::Failed, mapped);
@@ -1498,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;
@@ -1518,17 +1916,58 @@ bool RdpSessionBackend::sendDisplayResize(rdp_freerdp* instance, int width, int
layout.Width = static_cast<UINT32>(width);
layout.Height = static_cast<UINT32>(height);
layout.Orientation = ORIENTATION_LANDSCAPE;
layout.DesktopScaleFactor = freerdp_settings_get_uint32(settings, FreeRDP_DesktopScaleFactor);
layout.DeviceScaleFactor = freerdp_settings_get_uint32(settings, FreeRDP_DeviceScaleFactor);
layout.DesktopScaleFactor = scaleValue;
layout.DeviceScaleFactor = scaleValue;
// Physical size in mm must reflect the real DPI (96 * ratio), not
// the plain baseline, so it stays consistent with the scale factor
// above rather than implying a standard-DPI display of this size.
const double effectiveDpi = kDefaultDpi * ratio;
layout.PhysicalWidth = static_cast<UINT32>(
std::lround((static_cast<double>(width) / kDefaultDpi) * kMillimetersPerInch));
std::lround((static_cast<double>(width) / effectiveDpi) * kMillimetersPerInch));
layout.PhysicalHeight = static_cast<UINT32>(
std::lround((static_cast<double>(height) / kDefaultDpi) * kMillimetersPerInch));
std::lround((static_cast<double>(height) / effectiveDpi) * kMillimetersPerInch));
const UINT rc = dispContext->SendMonitorLayout(dispContext, 1, &layout);
if (rc == CHANNEL_RC_OK) {
m_lastResizeWidth = width;
m_lastResizeHeight = height;
m_lastResizeScale = static_cast<int>(scaleValue);
// The Display Control channel (MS-RDPEDISP) has no server
// acknowledgment PDU, and real hosts apply the new resolution
// without a Deactivation-Reactivation sequence — so
// update->DesktopResize (which only fires for that sequence, or
// for the GFX/Progressive pipeline we don't use) never runs for
// a channel-driven resize. Without a matching gdi_resize() call,
// gdi->width/height stay at the old size, and FreeRDP's own
// surface-bits handling (intersect_rect in gdi.c) then silently
// *drops* any update reaching outside those stale bounds —
// which is what produced the missing/misplaced taskbar and
// stale composited-looking content: this call is the fix, not
// just a best-effort nudge.
if (instance->context->gdi != nullptr
&& gdi_resize(instance->context->gdi, static_cast<UINT32>(width),
static_cast<UINT32>(height))) {
emit remoteDesktopSizeChanged(width, height);
} else if (instance->context->gdi != nullptr) {
emit eventLogged(QStringLiteral(
"RDP warning: local resize to %1x%2 failed; display may show stale content "
"until the next full repaint.")
.arg(width)
.arg(height));
}
// Best-effort nudge: some hosts don't fully repaint their own
// desktop after a resolution change (observed: taskbar missing
// until something else forces a redraw). Explicitly asking for
// the whole new area to be resent costs little and helps
// recover from that when it happens.
if (instance->context->update != nullptr
&& instance->context->update->RefreshRect != nullptr) {
RECTANGLE_16 fullArea = {0, 0, static_cast<UINT16>(qMin(width, 65535)),
static_cast<UINT16>(qMin(height, 65535))};
instance->context->update->RefreshRect(instance->context, 1, &fullArea);
}
return true;
}
}
@@ -1537,6 +1976,38 @@ bool RdpSessionBackend::sendDisplayResize(rdp_freerdp* instance, int width, int
#endif
}
void RdpSessionBackend::sendClipboardTextToRemote(rdp_freerdp* instance, const QString& text)
{
#ifndef ORBITHUB_HAS_FREERDP
Q_UNUSED(instance);
Q_UNUSED(text);
#else
Q_UNUSED(instance);
CliprdrClientContext* cliprdrContext = nullptr;
{
std::lock_guard<std::mutex> guard(m_cliprdrMutex);
m_pendingLocalClipboardText = text;
cliprdrContext = reinterpret_cast<CliprdrClientContext*>(m_cliprdrContext);
}
if (cliprdrContext == nullptr || cliprdrContext->ClientFormatList == nullptr) {
return;
}
CLIPRDR_FORMAT format = {};
format.formatId = CF_UNICODETEXT;
format.formatName = nullptr;
CLIPRDR_FORMAT_LIST formatList = {};
formatList.numFormats = 1;
formatList.formats = &format;
const UINT rc = cliprdrContext->ClientFormatList(cliprdrContext, &formatList);
Q_UNUSED(rc);
#endif
}
void RdpSessionBackend::processInputEvents(rdp_freerdp* instance)
{
#ifndef ORBITHUB_HAS_FREERDP
@@ -1661,13 +2132,18 @@ void RdpSessionBackend::processInputEvents(rdp_freerdp* instance)
}
break;
}
case InputEventType::SetClipboardText:
sendClipboardTextToRemote(instance, event.text);
break;
}
}
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."));
@@ -1686,24 +2162,47 @@ void RdpSessionBackend::processInputEvents(rdp_freerdp* instance)
void RdpSessionBackend::onChannelConnectedEvent(const char* name, void* channelInterface)
{
#ifdef ORBITHUB_HAS_FREERDP
if (name == nullptr || std::strcmp(name, DISP_DVC_CHANNEL_NAME) != 0) {
if (name == nullptr) {
return;
}
auto* dispContext = reinterpret_cast<DispClientContext*>(channelInterface);
if (dispContext == nullptr) {
if (std::strcmp(name, DISP_DVC_CHANNEL_NAME) == 0) {
auto* dispContext = reinterpret_cast<DispClientContext*>(channelInterface);
if (dispContext == nullptr) {
return;
}
dispContext->custom = this;
dispContext->DisplayControlCaps = orbitDisplayControlCaps;
{
std::lock_guard<std::mutex> guard(m_displayControlMutex);
m_displayControlContext = dispContext;
m_displayControlReady = false;
}
emit eventLogged(QStringLiteral("RDP display-control channel connected."));
return;
}
dispContext->custom = this;
dispContext->DisplayControlCaps = orbitDisplayControlCaps;
if (std::strcmp(name, CLIPRDR_SVC_CHANNEL_NAME) == 0) {
auto* cliprdrContext = reinterpret_cast<CliprdrClientContext*>(channelInterface);
if (cliprdrContext == nullptr) {
return;
}
{
std::lock_guard<std::mutex> guard(m_displayControlMutex);
m_displayControlContext = dispContext;
m_displayControlReady = false;
cliprdrContext->custom = this;
cliprdrContext->MonitorReady = orbitCliprdrMonitorReady;
cliprdrContext->ServerFormatList = orbitCliprdrServerFormatList;
cliprdrContext->ServerFormatListResponse = orbitCliprdrServerFormatListResponse;
cliprdrContext->ServerFormatDataRequest = orbitCliprdrServerFormatDataRequest;
cliprdrContext->ServerFormatDataResponse = orbitCliprdrServerFormatDataResponse;
{
std::lock_guard<std::mutex> guard(m_cliprdrMutex);
m_cliprdrContext = cliprdrContext;
}
emit eventLogged(QStringLiteral("RDP clipboard channel connected."));
}
emit eventLogged(QStringLiteral("RDP display-control channel connected."));
#else
Q_UNUSED(name);
Q_UNUSED(channelInterface);
@@ -1713,24 +2212,43 @@ void RdpSessionBackend::onChannelConnectedEvent(const char* name, void* channelI
void RdpSessionBackend::onChannelDisconnectedEvent(const char* name, void* channelInterface)
{
#ifdef ORBITHUB_HAS_FREERDP
if (name != nullptr && std::strcmp(name, DISP_DVC_CHANNEL_NAME) != 0) {
return;
}
const bool matchesDisp = name == nullptr || std::strcmp(name, DISP_DVC_CHANNEL_NAME) == 0;
const bool matchesCliprdr =
name == nullptr || std::strcmp(name, CLIPRDR_SVC_CHANNEL_NAME) == 0;
bool cleared = false;
bool hadDisplayControl = false;
{
std::lock_guard<std::mutex> guard(m_displayControlMutex);
hadDisplayControl = (m_displayControlContext != nullptr) || m_displayControlReady;
if (channelInterface == nullptr || m_displayControlContext == channelInterface) {
m_displayControlContext = nullptr;
m_displayControlReady = false;
cleared = true;
if (matchesDisp) {
bool cleared = false;
bool hadDisplayControl = false;
{
std::lock_guard<std::mutex> guard(m_displayControlMutex);
hadDisplayControl = (m_displayControlContext != nullptr) || m_displayControlReady;
if (channelInterface == nullptr || m_displayControlContext == channelInterface) {
m_displayControlContext = nullptr;
m_displayControlReady = false;
cleared = true;
}
}
if (cleared && hadDisplayControl) {
emit eventLogged(QStringLiteral("RDP display-control channel disconnected."));
}
}
if (cleared && hadDisplayControl) {
emit eventLogged(QStringLiteral("RDP display-control channel disconnected."));
if (matchesCliprdr) {
bool cleared = false;
bool hadCliprdr = false;
{
std::lock_guard<std::mutex> guard(m_cliprdrMutex);
hadCliprdr = m_cliprdrContext != nullptr;
if (channelInterface == nullptr || m_cliprdrContext == channelInterface) {
m_cliprdrContext = nullptr;
cleared = true;
}
}
if (cleared && hadCliprdr) {
emit eventLogged(QStringLiteral("RDP clipboard channel disconnected."));
}
}
#else
Q_UNUSED(name);
@@ -1769,6 +2287,130 @@ void RdpSessionBackend::onDisplayControlCaps(uint32_t maxNumMonitors,
#endif
}
void RdpSessionBackend::onCliprdrMonitorReady()
{
#ifdef ORBITHUB_HAS_FREERDP
CliprdrClientContext* cliprdrContext = nullptr;
{
std::lock_guard<std::mutex> guard(m_cliprdrMutex);
cliprdrContext = reinterpret_cast<CliprdrClientContext*>(m_cliprdrContext);
}
if (cliprdrContext == nullptr) {
return;
}
CLIPRDR_GENERAL_CAPABILITY_SET generalCapabilitySet = {};
generalCapabilitySet.capabilitySetType = CB_CAPSTYPE_GENERAL;
generalCapabilitySet.capabilitySetLength = CB_CAPSTYPE_GENERAL_LEN;
generalCapabilitySet.version = CB_CAPS_VERSION_2;
generalCapabilitySet.generalFlags = CB_USE_LONG_FORMAT_NAMES;
CLIPRDR_CAPABILITIES capabilities = {};
capabilities.cCapabilitiesSets = 1;
capabilities.capabilitySets = reinterpret_cast<CLIPRDR_CAPABILITY_SET*>(&generalCapabilitySet);
if (cliprdrContext->ClientCapabilities != nullptr) {
const UINT rc = cliprdrContext->ClientCapabilities(cliprdrContext, &capabilities);
Q_UNUSED(rc);
}
emit eventLogged(QStringLiteral("RDP clipboard channel ready."));
#endif
}
void RdpSessionBackend::onCliprdrServerFormatList(bool hasUnicodeText)
{
#ifdef ORBITHUB_HAS_FREERDP
CliprdrClientContext* cliprdrContext = nullptr;
{
std::lock_guard<std::mutex> guard(m_cliprdrMutex);
cliprdrContext = reinterpret_cast<CliprdrClientContext*>(m_cliprdrContext);
}
if (cliprdrContext == nullptr) {
return;
}
if (cliprdrContext->ClientFormatListResponse != nullptr) {
CLIPRDR_FORMAT_LIST_RESPONSE response = {};
response.common.msgFlags = CB_RESPONSE_OK;
const UINT rc = cliprdrContext->ClientFormatListResponse(cliprdrContext, &response);
Q_UNUSED(rc);
}
if (hasUnicodeText && cliprdrContext->ClientFormatDataRequest != nullptr) {
CLIPRDR_FORMAT_DATA_REQUEST request = {};
request.requestedFormatId = CF_UNICODETEXT;
const UINT rc = cliprdrContext->ClientFormatDataRequest(cliprdrContext, &request);
Q_UNUSED(rc);
}
#else
Q_UNUSED(hasUnicodeText);
#endif
}
void RdpSessionBackend::onCliprdrServerFormatDataRequest(uint32_t requestedFormatId)
{
#ifdef ORBITHUB_HAS_FREERDP
CliprdrClientContext* cliprdrContext = nullptr;
QString pendingText;
{
std::lock_guard<std::mutex> guard(m_cliprdrMutex);
cliprdrContext = reinterpret_cast<CliprdrClientContext*>(m_cliprdrContext);
pendingText = m_pendingLocalClipboardText;
}
if (cliprdrContext == nullptr || cliprdrContext->ClientFormatDataResponse == nullptr) {
return;
}
CLIPRDR_FORMAT_DATA_RESPONSE response = {};
QByteArray encoded;
if (requestedFormatId == CF_UNICODETEXT) {
const int byteLength = (pendingText.size() + 1) * static_cast<int>(sizeof(ushort));
encoded = QByteArray(reinterpret_cast<const char*>(pendingText.utf16()), byteLength);
}
response.common.msgFlags = encoded.isEmpty() ? CB_RESPONSE_FAIL : CB_RESPONSE_OK;
response.common.dataLen = static_cast<UINT32>(encoded.size());
response.requestedFormatData =
encoded.isEmpty() ? nullptr : reinterpret_cast<const BYTE*>(encoded.constData());
const UINT rc = cliprdrContext->ClientFormatDataResponse(cliprdrContext, &response);
Q_UNUSED(rc);
#else
Q_UNUSED(requestedFormatId);
#endif
}
void RdpSessionBackend::onCliprdrServerFormatDataResponse(bool success,
const uint8_t* data,
uint32_t size)
{
#ifdef ORBITHUB_HAS_FREERDP
if (!success || data == nullptr || size == 0) {
return;
}
const auto* utf16Data = reinterpret_cast<const char16_t*>(data);
const size_t charCount = size / sizeof(char16_t);
// CF_UNICODETEXT is NUL-terminated per MS-RDPECLIP; trim at the first NUL
// rather than trusting dataLen to exclude it.
size_t length = 0;
while (length < charCount && utf16Data[length] != 0) {
++length;
}
const QString text = QString::fromUtf16(utf16Data, static_cast<int>(length));
emit remoteClipboardTextChanged(text);
#else
Q_UNUSED(success);
Q_UNUSED(data);
Q_UNUSED(size);
#endif
}
void RdpSessionBackend::emitStateAsync(SessionState state, const QString& message)
{
QMetaObject::invokeMethod(
+23
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,
@@ -34,6 +35,7 @@ public slots:
void sendMouseMoveEvent(int x, int y) override;
void sendMouseButtonEvent(int x, int y, int button, bool pressed) override;
void sendMouseWheelEvent(int x, int y, int deltaX, int deltaY) override;
void setClipboardText(const QString& text) override;
private:
enum class InputEventType {
@@ -42,6 +44,7 @@ private:
MouseButton,
MouseWheel,
Resize,
SetClipboardText,
};
struct InputEvent {
@@ -66,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;
@@ -83,6 +87,19 @@ private:
bool m_resizeFailureLogged;
int m_lastResizeWidth;
int m_lastResizeHeight;
int m_lastResizeScale;
std::mutex m_cliprdrMutex;
void* m_cliprdrContext;
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);
bool validateProfile(QString& message) const;
@@ -92,12 +109,18 @@ private:
void enqueueInputEvent(const InputEvent& event);
void processInputEvents(rdp_freerdp* instance);
bool sendDisplayResize(rdp_freerdp* instance, int width, int height);
void sendClipboardTextToRemote(rdp_freerdp* instance, const QString& text);
public:
void onChannelConnectedEvent(const char* name, void* channelInterface);
void onChannelDisconnectedEvent(const char* name, void* channelInterface);
void onDisplayControlCaps(uint32_t maxNumMonitors,
uint32_t maxMonitorAreaFactorA,
uint32_t maxMonitorAreaFactorB);
void onCliprdrMonitorReady();
void onCliprdrServerFormatList(bool hasUnicodeText);
void onCliprdrServerFormatDataRequest(uint32_t requestedFormatId);
void onCliprdrServerFormatDataResponse(bool success, const uint8_t* data, uint32_t size);
void recordCertificateRejection(const QString& reason);
private:
void emitStateAsync(SessionState state, const QString& message);
void emitConnectionFailureAsync(const QString& displayMessage, const QString& rawMessage);
+13
View File
@@ -5,6 +5,7 @@
#include <QImage>
#include <QObject>
#include <QPoint>
#include <QString>
#include <QtGlobal>
@@ -46,6 +47,14 @@ 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);
}
virtual void sendKeyEvent(int key,
quint32 nativeScanCode,
const QString& text,
@@ -86,6 +95,10 @@ signals:
void hostKeyConfirmationRequested(const QString& prompt);
void frameUpdated(const QImage& frame);
void remoteDesktopSizeChanged(int width, int height);
void remoteClipboardTextChanged(const QString& text);
void cursorImageChanged(const QImage& image, const QPoint& hotspot);
void cursorHidden();
void cursorReset();
private:
Profile m_profile;
+332 -81
View File
@@ -13,15 +13,16 @@
#include <QFont>
#include <QFontDatabase>
#include <QHBoxLayout>
#include <QInputDialog>
#include <QLabel>
#include <QLineEdit>
#include <QMessageBox>
#include <QPlainTextEdit>
#include <QApplication>
#include <QClipboard>
#include <QMimeData>
#include <QComboBox>
#include <QProcessEnvironment>
#include <QPushButton>
#include <QThread>
#include <QTimer>
#include <QToolButton>
@@ -71,6 +72,11 @@ SessionTab::SessionTab(const Profile& profile,
m_terminalThemeName(preferences.terminalThemeName.trimmed().isEmpty()
? QStringLiteral("Dark")
: preferences.terminalThemeName.trimmed()),
m_terminalFontPointSize(preferences.terminalFontPointSize > 0
? preferences.terminalFontPointSize
: 0),
m_clipboardSyncSupported(profile.protocol.compare(QStringLiteral("RDP"), Qt::CaseInsensitive)
== 0),
m_sshTerminal(nullptr),
m_rdpDisplay(nullptr),
m_terminalOutput(nullptr),
@@ -81,6 +87,11 @@ SessionTab::SessionTab(const Profile& profile,
m_clearEventsButton(nullptr),
m_exportEventsButton(nullptr),
m_eventsPanel(nullptr),
m_passwordPromptBar(nullptr),
m_passwordPromptLabel(nullptr),
m_passwordPromptInput(nullptr),
m_passwordPromptConnectButton(nullptr),
m_passwordPromptCancelButton(nullptr),
m_eventSeverityFilter(EventSeverity::Info),
m_eventsPanelExpanded(preferences.eventsPanelExpanded)
{
@@ -165,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,
@@ -185,6 +201,11 @@ SessionTab::SessionTab(const Profile& profile,
m_backend,
&SessionBackend::sendMouseWheelEvent,
Qt::QueuedConnection);
connect(this,
&SessionTab::requestSetClipboardText,
m_backend,
&SessionBackend::setClipboardText,
Qt::QueuedConnection);
connect(m_backend,
&SessionBackend::stateChanged,
@@ -229,10 +250,49 @@ SessionTab::SessionTab(const Profile& profile,
}
},
Qt::QueuedConnection);
connect(m_backend,
&SessionBackend::remoteClipboardTextChanged,
this,
&SessionTab::onBackendRemoteClipboardTextChanged,
Qt::QueuedConnection);
connect(m_backend,
&SessionBackend::cursorImageChanged,
this,
[this](const QImage& image, const QPoint& hotspot) {
if (m_rdpDisplay != nullptr) {
m_rdpDisplay->setCursorImage(image, hotspot);
}
},
Qt::QueuedConnection);
connect(m_backend,
&SessionBackend::cursorHidden,
this,
[this]() {
if (m_rdpDisplay != nullptr) {
m_rdpDisplay->setCursorHidden();
}
},
Qt::QueuedConnection);
connect(m_backend,
&SessionBackend::cursorReset,
this,
[this]() {
if (m_rdpDisplay != nullptr) {
m_rdpDisplay->setCursorDefault();
}
},
Qt::QueuedConnection);
m_backendThread->start();
}
if (m_clipboardSyncSupported) {
connect(QApplication::clipboard(),
&QClipboard::dataChanged,
this,
&SessionTab::onSystemClipboardChanged);
}
setState(SessionState::Disconnected, QStringLiteral("Ready to connect."));
QTimer::singleShot(0, this, &SessionTab::connectSession);
}
@@ -265,21 +325,20 @@ void SessionTab::connectSession()
return;
}
const std::optional<SessionConnectOptions> options = buildConnectOptions();
if (!options.has_value()) {
return;
}
m_lastConnectOptions = options.value();
if (m_useKodoTermForSsh) {
if (!startSshTerminal(options.value())) {
requestConnectOptions([this](std::optional<SessionConnectOptions> options) {
if (!options.has_value()) {
return;
}
return;
}
emit requestConnect(options.value());
m_lastConnectOptions = options.value();
if (m_useKodoTermForSsh) {
startSshTerminal(options.value());
return;
}
emit requestConnect(options.value());
});
}
void SessionTab::disconnectSession()
@@ -305,24 +364,25 @@ void SessionTab::reconnectSession()
return;
}
const std::optional<SessionConnectOptions> options = buildConnectOptions();
if (!options.has_value()) {
return;
}
m_lastConnectOptions = options.value();
if (m_useKodoTermForSsh) {
if (m_sshTerminal != nullptr) {
m_sshTerminal->kill();
requestConnectOptions([this](std::optional<SessionConnectOptions> options) {
if (!options.has_value()) {
return;
}
QTimer::singleShot(50,
this,
[this, options]() { startSshTerminal(options.value()); });
return;
}
emit requestReconnect(options.value());
m_lastConnectOptions = options.value();
if (m_useKodoTermForSsh) {
if (m_sshTerminal != nullptr) {
m_sshTerminal->kill();
}
QTimer::singleShot(50,
this,
[this, options]() { startSshTerminal(options.value()); });
return;
}
emit requestReconnect(options.value());
});
}
void SessionTab::clearTerminal()
@@ -380,6 +440,84 @@ bool SessionTab::supportsClearAction() const
return m_useKodoTermForSsh || m_terminalOutput != nullptr;
}
bool SessionTab::supportsZoom() const
{
return m_useKodoTermForSsh || m_terminalOutput != nullptr;
}
void SessionTab::zoomIn()
{
if (m_useKodoTermForSsh && m_sshTerminal != nullptr) {
m_sshTerminal->zoomIn();
m_terminalFontPointSize = m_sshTerminal->getConfig().font.pointSize();
} else if (m_terminalOutput != nullptr) {
m_terminalFontPointSize = m_terminalOutput->font().pointSize() + 1;
m_terminalOutput->setFontPointSize(m_terminalFontPointSize);
} else {
return;
}
emit terminalFontSizeChanged(m_terminalFontPointSize);
}
void SessionTab::zoomOut()
{
if (m_useKodoTermForSsh && m_sshTerminal != nullptr) {
m_sshTerminal->zoomOut();
m_terminalFontPointSize = m_sshTerminal->getConfig().font.pointSize();
} else if (m_terminalOutput != nullptr) {
const int newSize = m_terminalOutput->font().pointSize() - 1;
if (newSize < 6) {
return;
}
m_terminalFontPointSize = newSize;
m_terminalOutput->setFontPointSize(m_terminalFontPointSize);
} else {
return;
}
emit terminalFontSizeChanged(m_terminalFontPointSize);
}
void SessionTab::resetZoom()
{
if (m_useKodoTermForSsh && m_sshTerminal != nullptr) {
m_sshTerminal->resetZoom();
m_terminalFontPointSize = m_sshTerminal->getConfig().font.pointSize();
} else if (m_terminalOutput != nullptr) {
m_terminalFontPointSize = defaultTerminalFont().pointSize();
m_terminalOutput->setFontPointSize(m_terminalFontPointSize);
} else {
return;
}
emit terminalFontSizeChanged(m_terminalFontPointSize);
}
void SessionTab::setTerminalFontPointSize(int pointSize)
{
const int clamped = qBound(6, pointSize, 72);
if (m_useKodoTermForSsh && m_sshTerminal != nullptr) {
KodoTermConfig config = m_sshTerminal->getConfig();
config.font.setPointSize(clamped);
m_sshTerminal->setConfig(config);
m_terminalFontPointSize = clamped;
} else if (m_terminalOutput != nullptr) {
m_terminalFontPointSize = clamped;
m_terminalOutput->setFontPointSize(clamped);
} else {
return;
}
emit terminalFontSizeChanged(m_terminalFontPointSize);
}
int SessionTab::terminalFontPointSize() const
{
return m_terminalFontPointSize;
}
bool SessionTab::isEventsPanelExpanded() const
{
return m_eventsPanelExpanded;
@@ -529,13 +667,46 @@ void SessionTab::onBackendHostKeyConfirmationRequested(const QString& prompt)
emit requestHostKeyConfirmation(reply == QMessageBox::Yes);
}
void SessionTab::onBackendRemoteClipboardTextChanged(const QString& text)
{
if (text == m_lastSyncedClipboardText) {
return;
}
m_lastSyncedClipboardText = text;
QApplication::clipboard()->setText(text);
}
void SessionTab::onSystemClipboardChanged()
{
if (!m_clipboardSyncSupported || m_state != SessionState::Connected) {
return;
}
const QClipboard* clipboard = QApplication::clipboard();
if (!clipboard->mimeData()->hasText()) {
return;
}
const QString text = clipboard->text();
if (text == m_lastSyncedClipboardText) {
return;
}
m_lastSyncedClipboardText = text;
emit requestSetClipboardText(text);
}
void SessionTab::setupUi()
{
auto* rootLayout = new QVBoxLayout(this);
if (m_useKodoTermForSsh) {
m_sshTerminal = new KodoTerm(this);
const QFont terminalFont = defaultTerminalFont();
QFont terminalFont = defaultTerminalFont();
if (m_terminalFontPointSize > 0) {
terminalFont.setPointSize(m_terminalFontPointSize);
}
KodoTermConfig config = m_sshTerminal->getConfig();
config.font = terminalFont;
@@ -548,7 +719,11 @@ void SessionTab::setupUi()
rootLayout->addWidget(m_rdpDisplay, 1);
} else {
m_terminalOutput = new TerminalView(this);
m_terminalOutput->setFont(defaultTerminalFont());
QFont fallbackFont = defaultTerminalFont();
if (m_terminalFontPointSize > 0) {
fallbackFont.setPointSize(m_terminalFontPointSize);
}
m_terminalOutput->setFont(fallbackFont);
m_terminalOutput->setMinimumHeight(260);
m_terminalOutput->setReadOnly(true);
if (m_profile.protocol.compare(QStringLiteral("VNC"), Qt::CaseInsensitive) == 0) {
@@ -563,6 +738,45 @@ void SessionTab::setupUi()
applyTerminalTheme(m_terminalThemeName);
auto* passwordPromptLayout = new QHBoxLayout();
m_passwordPromptLabel = new QLabel(this);
m_passwordPromptInput = new QLineEdit(this);
m_passwordPromptInput->setEchoMode(QLineEdit::Password);
m_passwordPromptConnectButton = new QPushButton(QStringLiteral("Connect"), this);
m_passwordPromptCancelButton = new QPushButton(QStringLiteral("Cancel"), this);
passwordPromptLayout->addWidget(m_passwordPromptLabel);
passwordPromptLayout->addWidget(m_passwordPromptInput, 1);
passwordPromptLayout->addWidget(m_passwordPromptConnectButton);
passwordPromptLayout->addWidget(m_passwordPromptCancelButton);
m_passwordPromptBar = new QWidget(this);
m_passwordPromptBar->setLayout(passwordPromptLayout);
m_passwordPromptBar->setAutoFillBackground(true);
m_passwordPromptBar->setVisible(false);
rootLayout->addWidget(m_passwordPromptBar);
connect(m_passwordPromptConnectButton, &QPushButton::clicked, this, [this]() {
if (!m_passwordPromptCallback) {
return;
}
const QString password = m_passwordPromptInput->text();
const auto callback = m_passwordPromptCallback;
hidePasswordPrompt();
callback(password);
});
connect(m_passwordPromptCancelButton, &QPushButton::clicked, this, [this]() {
if (!m_passwordPromptCallback) {
return;
}
const auto callback = m_passwordPromptCallback;
hidePasswordPrompt();
callback(std::nullopt);
});
connect(m_passwordPromptInput,
&QLineEdit::returnPressed,
m_passwordPromptConnectButton,
&QPushButton::click);
auto* eventsHeader = new QHBoxLayout();
m_toggleEventsButton = new QToolButton(this);
m_toggleEventsButton->setCheckable(true);
@@ -651,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,
@@ -676,77 +894,85 @@ void SessionTab::setupUi()
}
}
std::optional<SessionConnectOptions> SessionTab::buildConnectOptions()
void SessionTab::requestConnectOptions(
std::function<void(std::optional<SessionConnectOptions>)> callback)
{
SessionConnectOptions options;
options.knownHostsPolicy = m_profile.knownHostsPolicy;
SessionConnectOptions baseOptions;
baseOptions.knownHostsPolicy = m_profile.knownHostsPolicy;
const bool isSsh = m_profile.protocol.compare(QStringLiteral("SSH"), Qt::CaseInsensitive) == 0;
const bool isRdp = m_profile.protocol.compare(QStringLiteral("RDP"), Qt::CaseInsensitive) == 0;
if (!isSsh && !isRdp) {
return options;
callback(baseOptions);
return;
}
if (isRdp) {
if (m_profile.authMode.compare(QStringLiteral("Password"), Qt::CaseInsensitive) != 0) {
return options;
callback(baseOptions);
return;
}
bool accepted = false;
const QString password = QInputDialog::getText(
this,
QStringLiteral("RDP Password"),
QStringLiteral("Password for %1:")
.arg(m_profile.username.trimmed().isEmpty()
? m_profile.host
: QStringLiteral("%1@%2").arg(m_profile.username, m_profile.host)),
QLineEdit::Password,
QString(),
&accepted);
if (!accepted) {
return std::nullopt;
}
const QString label = QStringLiteral("RDP password for %1:")
.arg(m_profile.username.trimmed().isEmpty()
? m_profile.host
: QStringLiteral("%1@%2").arg(m_profile.username, m_profile.host));
if (password.isEmpty()) {
QMessageBox::warning(this,
QStringLiteral("Connect"),
QStringLiteral("Password is required for password authentication."));
return std::nullopt;
}
showPasswordPrompt(
label,
[this, baseOptions, callback](std::optional<QString> password) {
if (!password.has_value()) {
callback(std::nullopt);
return;
}
options.password = password;
return options;
if (password->isEmpty()) {
QMessageBox::warning(
this,
QStringLiteral("Connect"),
QStringLiteral("Password is required for password authentication."));
callback(std::nullopt);
return;
}
SessionConnectOptions options = baseOptions;
options.password = password.value();
callback(options);
});
return;
}
if (m_useKodoTermForSsh
&& m_profile.authMode.compare(QStringLiteral("Password"), Qt::CaseInsensitive) == 0) {
// Password is entered directly in terminal prompt.
return options;
callback(baseOptions);
return;
}
if (m_profile.authMode.compare(QStringLiteral("Password"), Qt::CaseInsensitive) == 0) {
bool accepted = false;
const QString password = QInputDialog::getText(this,
QStringLiteral("SSH Password"),
QStringLiteral("Password for %1@%2:")
.arg(m_profile.username, m_profile.host),
QLineEdit::Password,
QString(),
&accepted);
if (!accepted) {
return std::nullopt;
}
showPasswordPrompt(
QStringLiteral("SSH password for %1@%2:").arg(m_profile.username, m_profile.host),
[this, baseOptions, callback](std::optional<QString> password) {
if (!password.has_value()) {
callback(std::nullopt);
return;
}
if (password.isEmpty()) {
QMessageBox::warning(this,
QStringLiteral("Connect"),
QStringLiteral("Password is required for password authentication."));
return std::nullopt;
}
if (password->isEmpty()) {
QMessageBox::warning(
this,
QStringLiteral("Connect"),
QStringLiteral("Password is required for password authentication."));
callback(std::nullopt);
return;
}
options.password = password;
return options;
SessionConnectOptions options = baseOptions;
options.password = password.value();
callback(options);
});
return;
}
QString keyPath = m_profile.privateKeyPath.trimmed();
@@ -756,7 +982,8 @@ std::optional<SessionConnectOptions> SessionTab::buildConnectOptions()
QString(),
QStringLiteral("All Files (*)"));
if (keyPath.isEmpty()) {
return std::nullopt;
callback(std::nullopt);
return;
}
}
@@ -764,11 +991,35 @@ std::optional<SessionConnectOptions> SessionTab::buildConnectOptions()
QMessageBox::warning(this,
QStringLiteral("Connect"),
QStringLiteral("Private key file not found: %1").arg(keyPath));
return std::nullopt;
callback(std::nullopt);
return;
}
SessionConnectOptions options = baseOptions;
options.privateKeyPath = keyPath;
return options;
callback(options);
}
void SessionTab::showPasswordPrompt(const QString& labelText,
std::function<void(std::optional<QString>)> callback)
{
if (m_passwordPromptCallback) {
const auto previousCallback = m_passwordPromptCallback;
m_passwordPromptCallback = nullptr;
previousCallback(std::nullopt);
}
m_passwordPromptCallback = std::move(callback);
m_passwordPromptLabel->setText(labelText);
m_passwordPromptInput->clear();
m_passwordPromptBar->setVisible(true);
m_passwordPromptInput->setFocus();
}
void SessionTab::hidePasswordPrompt()
{
m_passwordPromptBar->setVisible(false);
m_passwordPromptCallback = nullptr;
}
bool SessionTab::validateProfileForConnect()
+28 -1
View File
@@ -8,6 +8,7 @@
#include <QStringList>
#include <QtGlobal>
#include <functional>
#include <optional>
#include <vector>
@@ -19,12 +20,15 @@ class RdpDisplayWidget;
class QToolButton;
class QLineEdit;
class QComboBox;
class QLabel;
class QPushButton;
class KodoTerm;
struct SessionUiPreferences
{
QString terminalThemeName = QStringLiteral("Dark");
bool eventsPanelExpanded = false;
int terminalFontPointSize = 0;
};
class SessionTab : public QWidget
@@ -46,6 +50,12 @@ public:
QString terminalThemeName() const;
bool supportsThemeSelection() const;
bool supportsClearAction() const;
bool supportsZoom() const;
void zoomIn();
void zoomOut();
void resetZoom();
void setTerminalFontPointSize(int pointSize);
int terminalFontPointSize() const;
bool isEventsPanelExpanded() const;
void setEventsPanelExpanded(bool expanded);
void clearEvents();
@@ -56,6 +66,7 @@ signals:
void tabTitleChanged(const QString& title);
void tabStateChanged(SessionState state);
void terminalThemeChanged(const QString& themeName);
void terminalFontSizeChanged(int pointSize);
void eventsPanelVisibilityChanged(bool expanded);
void requestConnect(const SessionConnectOptions& options);
void requestDisconnect();
@@ -63,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,
@@ -71,6 +83,7 @@ signals:
void requestMouseMoveEvent(int x, int y);
void requestMouseButtonEvent(int x, int y, int button, bool pressed);
void requestMouseWheelEvent(int x, int y, int deltaX, int deltaY);
void requestSetClipboardText(const QString& text);
private slots:
void onBackendStateChanged(SessionState state, const QString& message);
@@ -78,6 +91,8 @@ private slots:
void onBackendConnectionError(const QString& displayMessage, const QString& rawMessage);
void onBackendOutputReceived(const QString& text);
void onBackendHostKeyConfirmationRequested(const QString& prompt);
void onBackendRemoteClipboardTextChanged(const QString& text);
void onSystemClipboardChanged();
private:
Profile m_profile;
@@ -88,6 +103,9 @@ private:
QString m_lastError;
SessionConnectOptions m_lastConnectOptions;
QString m_terminalThemeName;
int m_terminalFontPointSize;
QString m_lastSyncedClipboardText;
bool m_clipboardSyncSupported;
KodoTerm* m_sshTerminal;
RdpDisplayWidget* m_rdpDisplay;
@@ -99,6 +117,12 @@ private:
QToolButton* m_clearEventsButton;
QToolButton* m_exportEventsButton;
QWidget* m_eventsPanel;
QWidget* m_passwordPromptBar;
QLabel* m_passwordPromptLabel;
QLineEdit* m_passwordPromptInput;
QPushButton* m_passwordPromptConnectButton;
QPushButton* m_passwordPromptCancelButton;
std::function<void(std::optional<QString>)> m_passwordPromptCallback;
enum class EventSeverity {
Info,
Warning,
@@ -114,7 +138,10 @@ private:
bool m_eventsPanelExpanded;
void setupUi();
std::optional<SessionConnectOptions> buildConnectOptions();
void requestConnectOptions(std::function<void(std::optional<SessionConnectOptions>)> callback);
void showPasswordPrompt(const QString& labelText,
std::function<void(std::optional<QString>)> callback);
void hidePasswordPrompt();
bool validateProfileForConnect();
void appendEvent(const QString& message);
void setState(SessionState state, const QString& message);
+96 -7
View File
@@ -1,11 +1,14 @@
#include "session_window.h"
#include "about_dialog.h"
#include "profiles_window.h"
#include "user_guide_dialog.h"
#include <QApplication>
#include "session_tab.h"
#include <QAction>
#include <QColor>
#include <QInputDialog>
#include <QMenu>
#include <QMenuBar>
#include <QPalette>
@@ -37,12 +40,12 @@ QStringList terminalThemeNames()
}
}
SessionWindow::SessionWindow(const Profile& profile, QWidget* parent)
: QMainWindow(parent), m_tabs(new QTabWidget(this))
SessionWindow::SessionWindow(QWidget* parent)
: QMainWindow(parent), m_tabs(new QTabWidget(this)), m_profilesWidget(nullptr)
{
loadUiPreferences();
setWindowTitle(QStringLiteral("OrbitHub Session - %1").arg(profile.name));
setWindowTitle(QStringLiteral("OrbitHub"));
resize(1080, 760);
setWindowIcon(QApplication::windowIcon());
@@ -57,8 +60,8 @@ SessionWindow::SessionWindow(const Profile& profile, QWidget* parent)
}
m_tabs->removeTab(index);
delete tab;
if (m_tabs->count() == 0) {
close();
if (sessionTabCount() == 0) {
setWindowTitle(QStringLiteral("OrbitHub"));
}
});
m_tabs->tabBar()->setContextMenuPolicy(Qt::CustomContextMenu);
@@ -105,6 +108,18 @@ SessionWindow::SessionWindow(const Profile& profile, QWidget* parent)
clearAction = menu.addAction(QStringLiteral("Clear"));
}
QAction* zoomInAction = nullptr;
QAction* zoomOutAction = nullptr;
QAction* resetZoomAction = nullptr;
QAction* setFontSizeAction = nullptr;
if (tab->supportsZoom()) {
menu.addSeparator();
zoomInAction = menu.addAction(QStringLiteral("Increase Font Size"));
zoomOutAction = menu.addAction(QStringLiteral("Decrease Font Size"));
resetZoomAction = menu.addAction(QStringLiteral("Reset Font Size"));
setFontSizeAction = menu.addAction(QStringLiteral("Set Font Size..."));
}
QAction* chosen = menu.exec(m_tabs->tabBar()->mapToGlobal(pos));
if (chosen == disconnectAction) {
tab->disconnectSession();
@@ -120,6 +135,27 @@ SessionWindow::SessionWindow(const Profile& profile, QWidget* parent)
tab->clearEvents();
} else if (clearAction != nullptr && chosen == clearAction) {
tab->clearTerminal();
} else if (zoomInAction != nullptr && chosen == zoomInAction) {
tab->zoomIn();
} else if (zoomOutAction != nullptr && chosen == zoomOutAction) {
tab->zoomOut();
} else if (resetZoomAction != nullptr && chosen == resetZoomAction) {
tab->resetZoom();
} else if (setFontSizeAction != nullptr && chosen == setFontSizeAction) {
bool accepted = false;
const int currentSize =
tab->terminalFontPointSize() > 0 ? tab->terminalFontPointSize() : 10;
const int newSize = QInputDialog::getInt(this,
QStringLiteral("Set Font Size"),
QStringLiteral("Font size (points):"),
currentSize,
6,
72,
1,
&accepted);
if (accepted) {
tab->setTerminalFontPointSize(newSize);
}
} else {
for (QAction* themeAction : themeActions) {
if (chosen == themeAction) {
@@ -130,7 +166,31 @@ SessionWindow::SessionWindow(const Profile& profile, QWidget* parent)
}
});
QMenu* fileMenu = menuBar()->addMenu(QStringLiteral("File"));
QAction* newProfileAction = fileMenu->addAction(QStringLiteral("New Profile"));
QAction* newFolderAction = fileMenu->addAction(QStringLiteral("New Folder"));
fileMenu->addSeparator();
QAction* quitAction = fileMenu->addAction(QStringLiteral("Quit"));
connect(newProfileAction,
&QAction::triggered,
this,
[this]() { m_profilesWidget->createProfileInCurrentContext(); });
connect(newFolderAction,
&QAction::triggered,
this,
[this]() { m_profilesWidget->createFolderInCurrentContext(); });
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,
@@ -141,7 +201,15 @@ SessionWindow::SessionWindow(const Profile& profile, QWidget* parent)
});
setCentralWidget(m_tabs);
addSessionTab(profile);
m_profilesWidget = new ProfilesWindow(this);
const int profilesIndex = m_tabs->addTab(m_profilesWidget, QStringLiteral("Profiles"));
m_tabs->tabBar()->setTabButton(profilesIndex, QTabBar::LeftSide, nullptr);
m_tabs->tabBar()->setTabButton(profilesIndex, QTabBar::RightSide, nullptr);
connect(m_profilesWidget,
&ProfilesWindow::connectRequested,
this,
&SessionWindow::addSessionTab);
}
void SessionWindow::openProfile(const Profile& profile)
@@ -154,8 +222,10 @@ void SessionWindow::addSessionTab(const Profile& profile)
auto* tab = new SessionTab(profile, m_preferences, this);
const int index = m_tabs->addTab(tab, tab->tabTitle());
m_tabs->setCurrentIndex(index);
if (m_tabs->count() > 1) {
if (sessionTabCount() > 1) {
setWindowTitle(QStringLiteral("OrbitHub Sessions"));
} else {
setWindowTitle(QStringLiteral("OrbitHub Session - %1").arg(profile.name));
}
m_tabs->tabBar()->setTabTextColor(
index, tabColorForState(SessionState::Disconnected, m_tabs->palette()));
@@ -185,6 +255,16 @@ void SessionWindow::addSessionTab(const Profile& profile)
: themeName.trimmed();
saveUiPreferences();
});
connect(tab,
&SessionTab::terminalFontSizeChanged,
this,
[this](int pointSize) {
if (pointSize <= 0) {
return;
}
m_preferences.terminalFontPointSize = pointSize;
saveUiPreferences();
});
connect(tab,
&SessionTab::eventsPanelVisibilityChanged,
this,
@@ -204,6 +284,11 @@ void SessionWindow::updateTabTitle(SessionTab* tab, const QString& title)
}
}
int SessionWindow::sessionTabCount() const
{
return m_tabs->count() - 1;
}
void SessionWindow::loadUiPreferences()
{
QSettings settings;
@@ -216,6 +301,8 @@ void SessionWindow::loadUiPreferences()
}
m_preferences.eventsPanelExpanded =
settings.value(QStringLiteral("session/eventsPanelExpanded"), false).toBool();
m_preferences.terminalFontPointSize =
settings.value(QStringLiteral("session/terminalFontPointSize"), 0).toInt();
}
void SessionWindow::saveUiPreferences() const
@@ -225,4 +312,6 @@ void SessionWindow::saveUiPreferences() const
m_preferences.terminalThemeName);
settings.setValue(QStringLiteral("session/eventsPanelExpanded"),
m_preferences.eventsPanelExpanded);
settings.setValue(QStringLiteral("session/terminalFontPointSize"),
m_preferences.terminalFontPointSize);
}
+4 -1
View File
@@ -7,23 +7,26 @@
#include <QMainWindow>
class QTabWidget;
class ProfilesWindow;
class SessionWindow : public QMainWindow
{
Q_OBJECT
public:
explicit SessionWindow(const Profile& profile, QWidget* parent = nullptr);
explicit SessionWindow(QWidget* parent = nullptr);
void openProfile(const Profile& profile);
private:
QTabWidget* m_tabs;
ProfilesWindow* m_profilesWidget;
SessionUiPreferences m_preferences;
void addSessionTab(const Profile& profile);
void updateTabTitle(SessionTab* tab, const QString& title);
void loadUiPreferences();
void saveUiPreferences() const;
int sessionTabCount() const;
};
#endif
+16
View File
@@ -52,6 +52,14 @@ void TerminalView::setThemeName(const QString& themeName)
applyThemePalette(paletteByName(themeName));
}
void TerminalView::setFontPointSize(int pointSize)
{
QFont updatedFont = font();
updatedFont.setPointSize(pointSize);
setFont(updatedFont);
emitTerminalSize();
}
void TerminalView::appendTerminalData(const QString& data)
{
if (data.isEmpty()) {
@@ -211,6 +219,14 @@ void TerminalView::focusInEvent(QFocusEvent* event)
moveCursor(QTextCursor::End);
}
bool TerminalView::focusNextPrevChild(bool next)
{
Q_UNUSED(next);
// Tab/Shift+Tab must reach keyPressEvent() and be forwarded to the
// remote session instead of moving focus to the next local widget.
return false;
}
void TerminalView::resizeEvent(QResizeEvent* event)
{
QTextEdit::resizeEvent(event);
+2
View File
@@ -19,6 +19,7 @@ public:
static QStringList themeNames();
void setThemeName(const QString& themeName);
void appendTerminalData(const QString& data);
void setFontPointSize(int pointSize);
signals:
void inputGenerated(const QString& input);
@@ -28,6 +29,7 @@ protected:
void keyPressEvent(QKeyEvent* event) override;
void focusInEvent(QFocusEvent* event) override;
void resizeEvent(QResizeEvent* event) override;
bool focusNextPrevChild(bool next) override;
private:
struct ThemePalette {
+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
+1
View File
@@ -0,0 +1 @@
3.23.1-dev0
+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;
}