30 Commits
Author SHA1 Message Date
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
ksmith ae9928782d docs: close out milestone 8 and set milestone 9 as current 2026-03-03 20:16:43 -07:00
ksmith 2485ffb14f docs: clarify Qt6 LGPLv3 licensing links in README 2026-03-03 20:13:12 -07:00
ksmith eadcdd7f10 Milestone 8 UX: folder tree workflows, about dialog, and app icon polish 2026-03-03 20:07:41 -07:00
48 changed files with 3661 additions and 311 deletions
+2
View File
@@ -1 +1,3 @@
/build/ /build/
/dist/
/.flatpak-builder/
+141 -4
View File
@@ -1,6 +1,6 @@
cmake_minimum_required(VERSION 3.21) cmake_minimum_required(VERSION 3.21)
project(OrbitHub VERSION 0.1.0 LANGUAGES CXX) project(OrbitHub VERSION 2026.9.8 LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_CXX_STANDARD_REQUIRED ON)
@@ -10,6 +10,8 @@ set(CMAKE_AUTOMOC ON)
set(CMAKE_AUTOUIC ON) set(CMAKE_AUTOUIC ON)
set(CMAKE_AUTORCC ON) set(CMAKE_AUTORCC ON)
include(GNUInstallDirs)
find_package(Qt6 6.2 REQUIRED COMPONENTS Widgets Sql) find_package(Qt6 6.2 REQUIRED COMPONENTS Widgets Sql)
qt_standard_project_setup() 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_DISP_CLIENT ON CACHE BOOL "" FORCE)
set(CHANNEL_AINPUT OFF CACHE BOOL "" FORCE) set(CHANNEL_AINPUT OFF CACHE BOOL "" FORCE)
set(CHANNEL_AUDIN 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_DRIVE OFF CACHE BOOL "" FORCE)
set(CHANNEL_ECHO OFF CACHE BOOL "" FORCE) set(CHANNEL_ECHO OFF CACHE BOOL "" FORCE)
set(CHANNEL_ENCOMSP OFF CACHE BOOL "" FORCE) set(CHANNEL_ENCOMSP OFF CACHE BOOL "" FORCE)
@@ -75,12 +78,18 @@ set(WITH_WINPR_TOOLS OFF CACHE BOOL "" FORCE)
add_subdirectory(third_party/FreeRDP EXCLUDE_FROM_ALL) add_subdirectory(third_party/FreeRDP EXCLUDE_FROM_ALL)
add_executable(orbithub set(ORBITHUB_SOURCES
src/about_dialog.cpp
src/about_dialog.h
src/app_icon.cpp
src/app_icon.h
src/main.cpp src/main.cpp
src/profile_dialog.cpp src/profile_dialog.cpp
src/profile_dialog.h src/profile_dialog.h
src/profile_repository.cpp src/profile_repository.cpp
src/profile_repository.h src/profile_repository.h
src/profiles_tree_widget.cpp
src/profiles_tree_widget.h
src/profiles_window.cpp src/profiles_window.cpp
src/profiles_window.h src/profiles_window.h
src/session_backend.h src/session_backend.h
@@ -102,6 +111,24 @@ add_executable(orbithub
src/unsupported_session_backend.h 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 Qt6::Widgets Qt6::Sql)
target_link_libraries(orbithub PRIVATE KodoTerm::KodoTerm) target_link_libraries(orbithub PRIVATE KodoTerm::KodoTerm)
if(TARGET freerdp AND TARGET winpr) if(TARGET freerdp AND TARGET winpr)
@@ -120,4 +147,114 @@ else()
message(FATAL_ERROR "Vendored FreeRDP targets were not produced as expected.") message(FATAL_ERROR "Vendored FreeRDP targets were not produced as expected.")
endif() 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/orbithub)
+170
View File
@@ -0,0 +1,170 @@
# OrbitHub
OrbitHub is a cross-platform native desktop app for managing and launching remote sessions from one place.
It is implemented in C++17 with Qt6 Widgets and built with CMake.
Supported target platforms:
- Windows
- Linux
- macOS
## Current Status
OrbitHub is in active development.
- Milestones completed: M0-M5, and M7-M9
- Current milestone: Milestone 10 (v1.0 Stabilization)
- Deferred milestone: Milestone 6 (VNC Fully Working)
- Latest checkpoint tag: `v0-m9-done`
- VNC implementation milestone (M6) is currently deferred
Progress and milestone details:
- [docs/PROGRESS.md](docs/PROGRESS.md)
## 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.)
## Implemented Features
### Profile Management
- SQLite-backed profile storage
- Create, edit, delete profiles
- Protocol-aware profile validation (SSH/RDP/VNC)
- Profile search and sorting
- Tags support
- Folder/subfolder support
- `List` and `Folders` profile views
- Right-click profile tree actions:
- New Folder
- New Connection
- Drag-and-drop profile moves between folders with persistence
### Session Experience
- Multi-tab session window
- Auto-connect on tab open
- Disconnect on tab close
- Session state indicators on tabs
- Timestamped event log with filtering and export
### SSH
- Embedded interactive terminal (in-app typing)
- Theme support (`Dark`, `Light`, `Solarized Dark`)
- Password and private-key auth flows
- Known-hosts policy support
### RDP
- Embedded in-window RDP rendering surface (no external launcher)
- Keyboard/mouse input forwarding
- Resize handling and resolution renegotiation
- Domain-aware authentication support
- RDP security/performance profile options
### App UX
- App icon and themed About dialog
- `File` menu:
- New Profile
- New Folder
- Quit
- `Help` menu:
- About OrbitHub
## Build and Run
Detailed platform instructions:
- [docs/BUILDING.md](docs/BUILDING.md)
Quick start (Linux/macOS with Ninja):
```bash
cmake -S . -B build -G Ninja
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:
- Qt 6 (Widgets, SQL)
- CMake 3.21+
- C++17 toolchain
Protocol/runtime dependencies:
- SSH client (`ssh`) available on `PATH` for SSH sessions
Bundled/vendored third-party components:
- KodoTerm
- libvterm
- FreeRDP/WinPR
## Licensing
Project license:
- MIT (see [LICENSE](LICENSE))
License links:
- MIT License: <https://opensource.org/licenses/MIT>
- GNU LGPLv3: <https://www.gnu.org/licenses/lgpl-3.0.html>
- Apache License 2.0: <https://www.apache.org/licenses/LICENSE-2.0>
Important third-party license notes:
- Qt6 is dynamically linked in this project build setup.
- Qt6 is used under LGPLv3 terms in this project build setup.
- KodoTerm and libvterm are MIT-licensed.
- FreeRDP/WinPR is Apache-2.0 licensed.
Repository license files:
- Project: [LICENSE](LICENSE)
- KodoTerm: [third_party/KodoTerm/LICENSE](third_party/KodoTerm/LICENSE)
- FreeRDP: [third_party/FreeRDP/LICENSE](third_party/FreeRDP/LICENSE)
See in-app `Help -> About OrbitHub` for license links and third-party inventory.
## Repository Structure
- `src/` - application source code
- `docs/` - build guide, spec, and progress tracking
- `third_party/` - vendored third-party dependencies
- `build/` - local build output (generated)
## Notes
- Passwords are requested at connect time and are not stored in the profile database.
- This repository currently prioritizes integrated SSH and RDP workflows while VNC implementation is pending.
+98 -15
View File
@@ -2,6 +2,14 @@
Run all commands from the repository root unless noted. 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) ## Linux (Ubuntu / Mint)
```bash ```bash
@@ -9,9 +17,9 @@ sudo apt update
sudo apt install -y \ sudo apt install -y \
build-essential cmake ninja-build git pkg-config \ build-essential cmake ninja-build git pkg-config \
qt6-base-dev qt6-base-dev-tools qt6-tools-dev qt6-tools-dev-tools \ 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 cmake --build build
./build/orbithub ./build/orbithub
``` ```
@@ -21,39 +29,114 @@ cmake --build build
```bash ```bash
xcode-select --install xcode-select --install
brew update 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 cmake --build build
./build/orbithub open build/orbithub.app
``` ```
## Windows 11 (PowerShell + MSVC + vcpkg) ## Windows 11 (PowerShell + MSVC + vcpkg)
Install required software:
```powershell ```powershell
winget install -e --id Git.Git winget install -e --id Git.Git
winget install -e --id Kitware.CMake winget install -e --id Kitware.CMake
winget install -e --id Ninja-build.Ninja winget install -e --id Ninja-build.Ninja
Add-WindowsCapability -Online -Name OpenSSH.Client~~~~0.0.1.0 Add-WindowsCapability -Online -Name OpenSSH.Client~~~~0.0.1.0
winget install -e --id Microsoft.VisualStudio.2022.BuildTools ` 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 ```powershell
git clone https://github.com/microsoft/vcpkg C:\dev\vcpkg git clone https://github.com/microsoft/vcpkg C:\dev\vcpkg
C:\dev\vcpkg\bootstrap-vcpkg.bat C:\dev\vcpkg\bootstrap-vcpkg.bat
C:\dev\vcpkg\vcpkg.exe install qtbase:x64-windows C:\dev\vcpkg\vcpkg.exe install qtbase:x64-windows openssl:x64-windows zlib:x64-windows
cmake -S . -B build -G Ninja `
-DCMAKE_TOOLCHAIN_FILE=C:/dev/vcpkg/scripts/buildsystems/vcpkg.cmake
cmake --build build
.\build\orbithub.exe
``` ```
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 ## Notes
- OrbitHub currently requires Qt6 Widgets and CMake 3.21+. - OrbitHub builds vendored `KodoTerm`, `libvterm`, and `FreeRDP` from `third_party/`.
- Milestone 3 SSH sessions require an `ssh` client available on `PATH`.
- If Qt is installed in a custom location, pass `-DCMAKE_PREFIX_PATH=/path/to/Qt/6.x.x/<toolchain>` to CMake. - 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`
+75 -17
View File
@@ -100,11 +100,11 @@ Delivered:
- Pulled FreeRDP source for integration planning and API review - Pulled FreeRDP source for integration planning and API review
Git: Git:
- Tag: pending (awaiting explicit approval before tagging/pushing) - Tag: `v0-m5-done`
## Milestone 6 - VNC Fully Working ## Milestone 6 - VNC Fully Working
Status: Planned Status: Deferred (temporarily postponed)
Planned Scope: Planned Scope:
- Replace current unsupported VNC path with complete VNC implementation - Replace current unsupported VNC path with complete VNC implementation
@@ -115,30 +115,88 @@ Planned Scope:
## Milestone 7 - Cross-Platform Protocol Hardening ## Milestone 7 - Cross-Platform Protocol Hardening
Status: Planned Status: Completed
Planned Scope: Delivered:
- Validate SSH/RDP/VNC workflows on Windows, Linux, and macOS - Validated SSH and RDP workflows on Linux, macOS, and Windows 11 (VNC is out
- Fix platform-specific runtime/process/auth issues of scope while Milestone 6 remains deferred)
- Add repeatable protocol validation checklist/scripts - 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 ## Milestone 8 - Profile and Session UX Completion
Status: Planned Status: Completed
Planned Scope: Delivered:
- Complete protocol-aware profile validation and UX polish - Added profile `tags` field to storage + schema migration and profile editor UX
- Add/persist session UI preferences and default behaviors - Added profile `folder_path` field + nested folder/subfolder profile view mode
- Improve events/diagnostics visibility for long-running session usage - Added profile tree context actions (`New Folder`, `New Connection`) and drag-to-folder profile moves with persistence
- Added `Help -> About OrbitHub` dialog with third-party library inventory and MIT/Apache-2.0 license links
- Extended profile search to include tags/folder path and added profile sort controls (`Name`, `Protocol`, `Host`)
- Persisted profile list UX preferences (`search text`, `view mode`, protocol/tag filters, `sort order`) across app restarts
- Added protocol-aware profile validation/normalization for SSH/RDP/VNC (repository + dialog)
- Improved profile form protocol UX hints and SSH private-key path validation
- Added session events filtering and tab-context actions (`Show/Hide Events`, `Copy Events`, `Clear Events`)
- Added session diagnostics QoL: severity quick-filter (`All/Warnings/Errors`) and `Export Events` action
- Persisted session UI defaults (`terminal theme`, `events panel visibility`) for new tabs/windows
- Added profile quick filters (`Protocol`, `Tag`) with persistence to speed profile browsing
Validation:
- Local build verification passed (`cmake --build build`)
- No automated tests are currently configured in CTest
Git:
- Tag: Pending user approval (`v0-m8-done`)
## Milestone 9 - Packaging and Distribution ## Milestone 9 - Packaging and Distribution
Status: Planned Status: Completed
Planned Scope: Delivered:
- Build distributable artifacts for Windows/Linux/macOS - Linux `.deb` (`packaging/linux/build-deb.sh`) and Flatpak (`packaging/flatpak/build-flatpak.sh`) packages, both verified installed and launched with working SSH/RDP
- Document runtime dependencies and install prerequisites - 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
- Add reproducible release packaging steps/scripts - 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`
## Milestone 10 - v1.0 Stabilization ## Milestone 10 - v1.0 Stabilization
Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

+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"
@@ -0,0 +1,21 @@
app-id: org.darksingularity.OrbitHub
runtime: org.kde.Platform
runtime-version: "6.10"
sdk: org.kde.Sdk
command: orbithub
finish-args:
- --share=network
- --share=ipc
- --socket=fallback-x11
- --socket=wayland
- --device=dri
- --filesystem=home
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,16 @@
<?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>
<provides>
<binary>orbithub</binary>
</provides>
</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"
+92
View File
@@ -0,0 +1,92 @@
#include "about_dialog.h"
#include <QApplication>
#include <QCoreApplication>
#include <QDialogButtonBox>
#include <QHBoxLayout>
#include <QLabel>
#include <QTextBrowser>
#include <QVBoxLayout>
AboutDialog::AboutDialog(QWidget* parent) : QDialog(parent)
{
setWindowTitle(QStringLiteral("About OrbitHub"));
setWindowIcon(QApplication::windowIcon());
resize(760, 560);
auto* layout = new QVBoxLayout(this);
layout->setContentsMargins(16, 16, 16, 16);
layout->setSpacing(12);
auto* headerRow = new QHBoxLayout();
headerRow->setSpacing(12);
auto* iconLabel = new QLabel(this);
iconLabel->setFixedSize(80, 80);
iconLabel->setPixmap(QApplication::windowIcon().pixmap(80, 80));
iconLabel->setAlignment(Qt::AlignCenter);
auto* titleColumn = new QVBoxLayout();
titleColumn->setSpacing(4);
auto* title = new QLabel(QStringLiteral("<h1 style='margin:0'>OrbitHub</h1>"), this);
auto* subtitle = new QLabel(
QStringLiteral("Unified remote session manager for SSH, RDP, and VNC workflows."),
this);
subtitle->setWordWrap(true);
subtitle->setStyleSheet(QStringLiteral("color: palette(mid);"));
const QString version = QCoreApplication::applicationVersion().trimmed().isEmpty()
? QStringLiteral("Development build")
: QCoreApplication::applicationVersion().trimmed();
auto* buildLine = new QLabel(
QStringLiteral("Version: %1 | Qt runtime linked dynamically").arg(version),
this);
buildLine->setStyleSheet(QStringLiteral("color: palette(mid);"));
titleColumn->addWidget(title);
titleColumn->addWidget(subtitle);
titleColumn->addWidget(buildLine);
titleColumn->addStretch();
headerRow->addWidget(iconLabel, 0, Qt::AlignTop);
headerRow->addLayout(titleColumn, 1);
auto* browser = new QTextBrowser(this);
browser->setOpenExternalLinks(true);
browser->setStyleSheet(QStringLiteral(
"QTextBrowser { border: 1px solid palette(midlight); border-radius: 8px; padding: 8px; }"));
browser->setHtml(QStringLiteral(R"(
<h3 style="margin-top:0">Third-Party Libraries</h3>
<p>OrbitHub uses the following external libraries:</p>
<table cellspacing="0" cellpadding="6" border="1" style="border-collapse:collapse; width:100%;">
<tr><th align="left">Library</th><th align="left">License</th><th align="left">Upstream</th></tr>
<tr><td>Qt 6 (Widgets / SQL)</td><td>LGPLv3 / GPLv3 / Commercial</td><td><a href="https://www.qt.io/licensing">qt.io/licensing</a></td></tr>
<tr><td>KodoTerm</td><td>MIT</td><td><a href="https://github.com/diegoiast/KodoTerm">github.com/diegoiast/KodoTerm</a></td></tr>
<tr><td>libvterm</td><td>MIT</td><td><a href="https://github.com/neovim/libvterm">github.com/neovim/libvterm</a></td></tr>
<tr><td>FreeRDP / WinPR</td><td>Apache License 2.0</td><td><a href="https://github.com/FreeRDP/FreeRDP">github.com/FreeRDP/FreeRDP</a></td></tr>
</table>
<h3>License Links</h3>
<ul>
<li><a href="https://www.gnu.org/licenses/lgpl-3.0.html">GNU LGPLv3 (gnu.org)</a></li>
<li><a href="https://opensource.org/licenses/MIT">MIT License (opensource.org)</a></li>
<li><a href="https://www.apache.org/licenses/LICENSE-2.0">Apache License 2.0 (apache.org)</a></li>
</ul>
<h3>License Files In This Repository</h3>
<ul>
<li><code>third_party/KodoTerm/LICENSE</code></li>
<li><code>third_party/FreeRDP/LICENSE</code></li>
<li><code>LICENSE</code> (project license)</li>
</ul>
)"));
auto* buttons = new QDialogButtonBox(QDialogButtonBox::Close, this);
connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::reject);
connect(buttons, &QDialogButtonBox::accepted, this, &QDialog::accept);
layout->addLayout(headerRow);
layout->addWidget(browser, 1);
layout->addWidget(buttons);
}
+14
View File
@@ -0,0 +1,14 @@
#ifndef ORBITHUB_ABOUT_DIALOG_H
#define ORBITHUB_ABOUT_DIALOG_H
#include <QDialog>
class AboutDialog : public QDialog
{
Q_OBJECT
public:
explicit AboutDialog(QWidget* parent = nullptr);
};
#endif
+101
View File
@@ -0,0 +1,101 @@
#include "app_icon.h"
#include <QBrush>
#include <QColor>
#include <QLinearGradient>
#include <QPainter>
#include <QPainterPath>
#include <QPen>
#include <QPixmap>
namespace {
QPixmap renderIconPixmap(int size)
{
QPixmap pixmap(size, size);
pixmap.fill(Qt::transparent);
QPainter painter(&pixmap);
painter.setRenderHint(QPainter::Antialiasing, true);
const qreal s = static_cast<qreal>(size);
const QRectF badgeRect(0.06 * s, 0.06 * s, 0.88 * s, 0.88 * s);
const qreal badgeRadius = 0.19 * s;
QLinearGradient badgeGradient(badgeRect.topLeft(), badgeRect.bottomRight());
badgeGradient.setColorAt(0.0, QColor(QStringLiteral("#0B1220")));
badgeGradient.setColorAt(1.0, QColor(QStringLiteral("#111827")));
QPainterPath badgePath;
badgePath.addRoundedRect(badgeRect, badgeRadius, badgeRadius);
painter.fillPath(badgePath, badgeGradient);
const QPointF center(0.5 * s, 0.5 * s);
const QRectF orbitRect(0.14 * s, 0.26 * s, 0.72 * s, 0.44 * s);
// Draw orbit behind monitor first.
QPen orbitBackPen(QColor(QStringLiteral("#38BDF8")));
orbitBackPen.setWidthF(0.06 * s);
orbitBackPen.setCapStyle(Qt::RoundCap);
painter.setPen(orbitBackPen);
painter.setBrush(Qt::NoBrush);
QTransform orbitTransform;
orbitTransform.translate(center.x(), center.y());
orbitTransform.rotate(-20.0);
orbitTransform.translate(-center.x(), -center.y());
painter.setTransform(orbitTransform);
painter.drawEllipse(orbitRect);
painter.resetTransform();
const QRectF monitorRect(0.2 * s, 0.2 * s, 0.6 * s, 0.44 * s);
const QRectF screenRect(0.24 * s, 0.24 * s, 0.52 * s, 0.34 * s);
const QRectF standStemRect(0.46 * s, 0.64 * s, 0.08 * s, 0.1 * s);
const QRectF standBaseRect(0.34 * s, 0.74 * s, 0.32 * s, 0.08 * s);
QPainterPath monitorPath;
monitorPath.addRoundedRect(monitorRect, 0.07 * s, 0.07 * s);
painter.fillPath(monitorPath, QColor(QStringLiteral("#1F2937")));
painter.setPen(QPen(QColor(QStringLiteral("#4B5563")), 0.016 * s));
painter.drawPath(monitorPath);
QLinearGradient screenGradient(screenRect.topLeft(), screenRect.bottomRight());
screenGradient.setColorAt(0.0, QColor(QStringLiteral("#22D3EE")));
screenGradient.setColorAt(0.55, QColor(QStringLiteral("#38BDF8")));
screenGradient.setColorAt(1.0, QColor(QStringLiteral("#0EA5E9")));
painter.fillRect(screenRect, screenGradient);
painter.setPen(Qt::NoPen);
painter.setBrush(QColor(QStringLiteral("#9CA3AF")));
painter.drawRoundedRect(standStemRect, 0.02 * s, 0.02 * s);
painter.setBrush(QColor(QStringLiteral("#6B7280")));
painter.drawRoundedRect(standBaseRect, 0.03 * s, 0.03 * s);
// Orbit front segment over monitor for depth.
QPen orbitFrontPen(QColor(QStringLiteral("#A3E635")));
orbitFrontPen.setWidthF(0.06 * s);
orbitFrontPen.setCapStyle(Qt::RoundCap);
painter.setPen(orbitFrontPen);
painter.setBrush(Qt::NoBrush);
painter.setTransform(orbitTransform);
painter.drawArc(orbitRect, 212 * 16, 126 * 16);
painter.drawArc(orbitRect, 6 * 16, 24 * 16);
painter.resetTransform();
// Small indicator star.
painter.setPen(Qt::NoPen);
painter.setBrush(QColor(QStringLiteral("#E5E7EB")));
painter.drawEllipse(QPointF(0.77 * s, 0.2 * s), 0.02 * s, 0.02 * s);
return pixmap;
}
}
QIcon createOrbitHubAppIcon()
{
QIcon icon;
const int sizes[] = {16, 24, 32, 48, 64, 128, 256};
for (const int size : sizes) {
icon.addPixmap(renderIconPixmap(size));
}
return icon;
}
+8
View File
@@ -0,0 +1,8 @@
#ifndef ORBITHUB_APP_ICON_H
#define ORBITHUB_APP_ICON_H
#include <QIcon>
QIcon createOrbitHubAppIcon();
#endif
+6 -2
View File
@@ -1,4 +1,5 @@
#include "profiles_window.h" #include "app_icon.h"
#include "session_window.h"
#include <QApplication> #include <QApplication>
@@ -7,8 +8,11 @@ int main(int argc, char* argv[])
Q_INIT_RESOURCE(KodoTermThemes); Q_INIT_RESOURCE(KodoTermThemes);
QApplication app(argc, argv); QApplication app(argc, argv);
app.setOrganizationName(QStringLiteral("FireBugIT"));
app.setApplicationName(QStringLiteral("OrbitHub"));
app.setWindowIcon(createOrbitHubAppIcon());
ProfilesWindow window; SessionWindow window;
window.show(); window.show();
return app.exec(); return app.exec();
+112 -12
View File
@@ -3,6 +3,7 @@
#include <QComboBox> #include <QComboBox>
#include <QDialogButtonBox> #include <QDialogButtonBox>
#include <QFileDialog> #include <QFileDialog>
#include <QFileInfo>
#include <QFormLayout> #include <QFormLayout>
#include <QHBoxLayout> #include <QHBoxLayout>
#include <QLabel> #include <QLabel>
@@ -24,6 +25,28 @@ int standardPortForProtocol(const QString& protocol)
} }
return 22; // SSH default return 22; // SSH default
} }
QString normalizedProtocol(const QString& protocol)
{
if (protocol.compare(QStringLiteral("RDP"), Qt::CaseInsensitive) == 0) {
return QStringLiteral("RDP");
}
if (protocol.compare(QStringLiteral("VNC"), Qt::CaseInsensitive) == 0) {
return QStringLiteral("VNC");
}
return QStringLiteral("SSH");
}
QString normalizedAuthMode(const QString& protocol, const QString& authMode)
{
if (protocol != QStringLiteral("SSH")) {
return QStringLiteral("Password");
}
if (authMode.compare(QStringLiteral("Private Key"), Qt::CaseInsensitive) == 0) {
return QStringLiteral("Private Key");
}
return QStringLiteral("Password");
}
} }
ProfileDialog::ProfileDialog(QWidget* parent) ProfileDialog::ProfileDialog(QWidget* parent)
@@ -33,18 +56,24 @@ ProfileDialog::ProfileDialog(QWidget* parent)
m_portInput(new QSpinBox(this)), m_portInput(new QSpinBox(this)),
m_usernameInput(new QLineEdit(this)), m_usernameInput(new QLineEdit(this)),
m_domainInput(new QLineEdit(this)), m_domainInput(new QLineEdit(this)),
m_tagsInput(new QLineEdit(this)),
m_protocolInput(new QComboBox(this)), m_protocolInput(new QComboBox(this)),
m_authModeInput(new QComboBox(this)), m_authModeInput(new QComboBox(this)),
m_privateKeyPathInput(new QLineEdit(this)), m_privateKeyPathInput(new QLineEdit(this)),
m_browsePrivateKeyButton(new QPushButton(QStringLiteral("Browse"), this)), m_browsePrivateKeyButton(new QPushButton(QStringLiteral("Browse"), this)),
m_knownHostsPolicyInput(new QComboBox(this)), m_knownHostsPolicyInput(new QComboBox(this)),
m_rdpSecurityModeInput(new QComboBox(this)), m_rdpSecurityModeInput(new QComboBox(this)),
m_rdpPerformanceProfileInput(new QComboBox(this)) m_rdpPerformanceProfileInput(new QComboBox(this)),
m_protocolHint(new QLabel(this)),
m_folderHint(new QLabel(this))
{ {
resize(520, 340); resize(560, 360);
auto* layout = new QVBoxLayout(this); auto* layout = new QVBoxLayout(this);
auto* form = new QFormLayout(); 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_nameInput->setPlaceholderText(QStringLiteral("Production Bastion"));
m_hostInput->setPlaceholderText(QStringLiteral("example.internal")); m_hostInput->setPlaceholderText(QStringLiteral("example.internal"));
@@ -52,6 +81,7 @@ ProfileDialog::ProfileDialog(QWidget* parent)
m_portInput->setValue(22); m_portInput->setValue(22);
m_usernameInput->setPlaceholderText(QStringLiteral("deploy")); m_usernameInput->setPlaceholderText(QStringLiteral("deploy"));
m_domainInput->setPlaceholderText(QStringLiteral("CONTOSO")); m_domainInput->setPlaceholderText(QStringLiteral("CONTOSO"));
m_tagsInput->setPlaceholderText(QStringLiteral("prod, linux, db"));
m_protocolInput->addItems({QStringLiteral("SSH"), QStringLiteral("RDP"), QStringLiteral("VNC")}); m_protocolInput->addItems({QStringLiteral("SSH"), QStringLiteral("RDP"), QStringLiteral("VNC")});
m_authModeInput->addItems({QStringLiteral("Password"), QStringLiteral("Private Key")}); m_authModeInput->addItems({QStringLiteral("Password"), QStringLiteral("Private Key")});
@@ -107,6 +137,7 @@ ProfileDialog::ProfileDialog(QWidget* parent)
form->addRow(QStringLiteral("Port"), m_portInput); form->addRow(QStringLiteral("Port"), m_portInput);
form->addRow(QStringLiteral("Username"), m_usernameInput); form->addRow(QStringLiteral("Username"), m_usernameInput);
form->addRow(QStringLiteral("Domain"), m_domainInput); form->addRow(QStringLiteral("Domain"), m_domainInput);
form->addRow(QStringLiteral("Tags"), m_tagsInput);
form->addRow(QStringLiteral("Protocol"), m_protocolInput); form->addRow(QStringLiteral("Protocol"), m_protocolInput);
form->addRow(QStringLiteral("Auth Mode"), m_authModeInput); form->addRow(QStringLiteral("Auth Mode"), m_authModeInput);
form->addRow(QStringLiteral("Private Key"), privateKeyRow); form->addRow(QStringLiteral("Private Key"), privateKeyRow);
@@ -118,12 +149,16 @@ ProfileDialog::ProfileDialog(QWidget* parent)
QStringLiteral("Passwords are requested at connect time and are not stored."), QStringLiteral("Passwords are requested at connect time and are not stored."),
this); this);
note->setWordWrap(true); note->setWordWrap(true);
m_protocolHint->setWordWrap(true);
m_folderHint->setWordWrap(true);
auto* buttons = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this); auto* buttons = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this);
connect(buttons, &QDialogButtonBox::accepted, this, &QDialog::accept); connect(buttons, &QDialogButtonBox::accepted, this, &QDialog::accept);
connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::reject); connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::reject);
layout->addLayout(form); layout->addLayout(form);
layout->addWidget(m_protocolHint);
layout->addWidget(m_folderHint);
layout->addWidget(note); layout->addWidget(note);
layout->addWidget(buttons); layout->addWidget(buttons);
@@ -135,6 +170,12 @@ void ProfileDialog::setDialogTitle(const QString& title)
setWindowTitle(title); setWindowTitle(title);
} }
void ProfileDialog::setDefaultFolderPath(const QString& folderPath)
{
m_defaultFolderPath = folderPath.trimmed();
refreshAuthFields();
}
void ProfileDialog::setProfile(const Profile& profile) void ProfileDialog::setProfile(const Profile& profile)
{ {
m_nameInput->setText(profile.name); m_nameInput->setText(profile.name);
@@ -142,6 +183,8 @@ void ProfileDialog::setProfile(const Profile& profile)
m_portInput->setValue(profile.port > 0 ? profile.port : 22); m_portInput->setValue(profile.port > 0 ? profile.port : 22);
m_usernameInput->setText(profile.username); m_usernameInput->setText(profile.username);
m_domainInput->setText(profile.domain); m_domainInput->setText(profile.domain);
m_defaultFolderPath = profile.folderPath.trimmed();
m_tagsInput->setText(profile.tags);
m_privateKeyPathInput->setText(profile.privateKeyPath); m_privateKeyPathInput->setText(profile.privateKeyPath);
const int protocolIndex = m_protocolInput->findText(profile.protocol); const int protocolIndex = m_protocolInput->findText(profile.protocol);
@@ -169,18 +212,31 @@ void ProfileDialog::setProfile(const Profile& profile)
Profile ProfileDialog::profile() const Profile ProfileDialog::profile() const
{ {
Profile profile; Profile profile;
const QString protocol = normalizedProtocol(m_protocolInput->currentText());
const QString authMode = normalizedAuthMode(protocol, m_authModeInput->currentText());
profile.id = -1; profile.id = -1;
profile.name = m_nameInput->text().trimmed(); profile.name = m_nameInput->text().trimmed();
profile.host = m_hostInput->text().trimmed(); profile.host = m_hostInput->text().trimmed();
profile.port = m_portInput->value(); profile.port = m_portInput->value();
profile.username = m_usernameInput->text().trimmed(); profile.username = m_usernameInput->text().trimmed();
profile.domain = m_domainInput->text().trimmed(); profile.domain = protocol == QStringLiteral("RDP") ? m_domainInput->text().trimmed() : QString();
profile.protocol = m_protocolInput->currentText(); profile.folderPath = m_defaultFolderPath.trimmed();
profile.authMode = m_authModeInput->currentText(); profile.tags = m_tagsInput->text().trimmed();
profile.privateKeyPath = m_privateKeyPathInput->text().trimmed(); profile.protocol = protocol;
profile.knownHostsPolicy = m_knownHostsPolicyInput->currentText(); profile.authMode = authMode;
profile.rdpSecurityMode = m_rdpSecurityModeInput->currentText(); profile.privateKeyPath = (protocol == QStringLiteral("SSH")
profile.rdpPerformanceProfile = m_rdpPerformanceProfileInput->currentText(); && authMode == QStringLiteral("Private Key"))
? m_privateKeyPathInput->text().trimmed()
: QString();
profile.knownHostsPolicy = protocol == QStringLiteral("SSH") ? m_knownHostsPolicyInput->currentText()
: QStringLiteral("Ask");
profile.rdpSecurityMode = protocol == QStringLiteral("RDP")
? m_rdpSecurityModeInput->currentText()
: QStringLiteral("Negotiate");
profile.rdpPerformanceProfile = protocol == QStringLiteral("RDP")
? m_rdpPerformanceProfileInput->currentText()
: QStringLiteral("Balanced");
return profile; return profile;
} }
@@ -209,14 +265,40 @@ void ProfileDialog::accept()
return; return;
} }
if (protocol == QStringLiteral("SSH")
&& m_authModeInput->currentText() == QStringLiteral("Private Key")) {
const QString privateKeyPath = m_privateKeyPathInput->text().trimmed();
if (privateKeyPath.isEmpty()) {
QMessageBox::warning(this,
QStringLiteral("Validation Error"),
QStringLiteral("Private key path is required for SSH private key authentication."));
return;
}
if (!QFileInfo::exists(privateKeyPath)) {
QMessageBox::warning(this,
QStringLiteral("Validation Error"),
QStringLiteral("Private key file does not exist: %1").arg(privateKeyPath));
return;
}
}
QDialog::accept(); QDialog::accept();
} }
void ProfileDialog::refreshAuthFields() void ProfileDialog::refreshAuthFields()
{ {
const bool isSsh = m_protocolInput->currentText() == QStringLiteral("SSH"); const QString protocol = normalizedProtocol(m_protocolInput->currentText());
const bool isRdp = m_protocolInput->currentText() == QStringLiteral("RDP"); const bool isSsh = protocol == QStringLiteral("SSH");
const bool isPrivateKey = m_authModeInput->currentText() == QStringLiteral("Private Key"); const bool isRdp = protocol == QStringLiteral("RDP");
const bool isVnc = protocol == QStringLiteral("VNC");
const QString normalizedMode = normalizedAuthMode(protocol, m_authModeInput->currentText());
if (normalizedMode != m_authModeInput->currentText()) {
const QSignalBlocker blocker(m_authModeInput);
m_authModeInput->setCurrentText(normalizedMode);
}
const bool isPrivateKey = normalizedMode == QStringLiteral("Private Key");
m_authModeInput->setEnabled(isSsh); m_authModeInput->setEnabled(isSsh);
m_privateKeyPathInput->setEnabled(isSsh && isPrivateKey); m_privateKeyPathInput->setEnabled(isSsh && isPrivateKey);
@@ -225,4 +307,22 @@ void ProfileDialog::refreshAuthFields()
m_domainInput->setEnabled(isRdp); m_domainInput->setEnabled(isRdp);
m_rdpSecurityModeInput->setEnabled(isRdp); m_rdpSecurityModeInput->setEnabled(isRdp);
m_rdpPerformanceProfileInput->setEnabled(isRdp); m_rdpPerformanceProfileInput->setEnabled(isRdp);
if (isSsh) {
m_usernameInput->setPlaceholderText(QStringLiteral("deploy"));
m_protocolHint->setText(
QStringLiteral("SSH: username is required. Choose Password or Private Key auth."));
} else if (isRdp) {
m_usernameInput->setPlaceholderText(QStringLiteral("Administrator"));
m_protocolHint->setText(
QStringLiteral("RDP: username and password are required. Domain is optional."));
} else if (isVnc) {
m_usernameInput->setPlaceholderText(QStringLiteral("optional"));
m_protocolHint->setText(
QStringLiteral("VNC: host and port are required. Username/domain are optional and ignored by most servers."));
}
m_folderHint->setText(m_defaultFolderPath.isEmpty()
? QStringLiteral("Target folder: root")
: QStringLiteral("Target folder: %1").arg(m_defaultFolderPath));
} }
+6
View File
@@ -6,6 +6,7 @@
#include <QDialog> #include <QDialog>
class QComboBox; class QComboBox;
class QLabel;
class QLineEdit; class QLineEdit;
class QPushButton; class QPushButton;
class QSpinBox; class QSpinBox;
@@ -18,6 +19,7 @@ public:
explicit ProfileDialog(QWidget* parent = nullptr); explicit ProfileDialog(QWidget* parent = nullptr);
void setDialogTitle(const QString& title); void setDialogTitle(const QString& title);
void setDefaultFolderPath(const QString& folderPath);
void setProfile(const Profile& profile); void setProfile(const Profile& profile);
Profile profile() const; Profile profile() const;
@@ -30,6 +32,7 @@ private:
QSpinBox* m_portInput; QSpinBox* m_portInput;
QLineEdit* m_usernameInput; QLineEdit* m_usernameInput;
QLineEdit* m_domainInput; QLineEdit* m_domainInput;
QLineEdit* m_tagsInput;
QComboBox* m_protocolInput; QComboBox* m_protocolInput;
QComboBox* m_authModeInput; QComboBox* m_authModeInput;
QLineEdit* m_privateKeyPathInput; QLineEdit* m_privateKeyPathInput;
@@ -37,6 +40,9 @@ private:
QComboBox* m_knownHostsPolicyInput; QComboBox* m_knownHostsPolicyInput;
QComboBox* m_rdpSecurityModeInput; QComboBox* m_rdpSecurityModeInput;
QComboBox* m_rdpPerformanceProfileInput; QComboBox* m_rdpPerformanceProfileInput;
QLabel* m_protocolHint;
QLabel* m_folderHint;
QString m_defaultFolderPath;
void refreshAuthFields(); void refreshAuthFields();
}; };
+278 -42
View File
@@ -7,6 +7,7 @@
#include <QSqlQuery> #include <QSqlQuery>
#include <QStandardPaths> #include <QStandardPaths>
#include <QVariant> #include <QVariant>
#include <QStringList>
namespace { namespace {
QString buildDatabasePath() QString buildDatabasePath()
@@ -52,21 +53,130 @@ QString normalizedRdpPerformanceProfile(const QString& value)
return QStringLiteral("Balanced"); return QStringLiteral("Balanced");
} }
QString normalizedProtocol(const QString& value)
{
const QString protocol = value.trimmed();
if (protocol.compare(QStringLiteral("RDP"), Qt::CaseInsensitive) == 0) {
return QStringLiteral("RDP");
}
if (protocol.compare(QStringLiteral("VNC"), Qt::CaseInsensitive) == 0) {
return QStringLiteral("VNC");
}
return QStringLiteral("SSH");
}
QString normalizedAuthMode(const QString& protocol, const QString& value)
{
if (protocol != QStringLiteral("SSH")) {
return QStringLiteral("Password");
}
const QString authMode = value.trimmed();
if (authMode.compare(QStringLiteral("Private Key"), Qt::CaseInsensitive) == 0) {
return QStringLiteral("Private Key");
}
return QStringLiteral("Password");
}
QString normalizedKnownHostsPolicy(const QString& value)
{
const QString policy = value.trimmed();
if (policy.compare(QStringLiteral("Strict"), Qt::CaseInsensitive) == 0) {
return QStringLiteral("Strict");
}
if (policy.compare(QStringLiteral("Accept New"), Qt::CaseInsensitive) == 0) {
return QStringLiteral("Accept New");
}
if (policy.compare(QStringLiteral("Ignore"), Qt::CaseInsensitive) == 0) {
return QStringLiteral("Ignore");
}
return QStringLiteral("Ask");
}
QString normalizedFolderPath(const QString& value)
{
QString path = value.trimmed();
path.replace(QChar::fromLatin1('\\'), QChar::fromLatin1('/'));
const QStringList rawParts = path.split(QChar::fromLatin1('/'), Qt::SkipEmptyParts);
QStringList normalized;
for (const QString& rawPart : rawParts) {
const QString part = rawPart.trimmed();
if (!part.isEmpty()) {
normalized.push_back(part);
}
}
return normalized.join(QStringLiteral("/"));
}
QString normalizedTags(const QString& value)
{
const QStringList rawTokens = value.split(QChar::fromLatin1(','), Qt::SkipEmptyParts);
QStringList normalized;
QSet<QString> dedupe;
for (const QString& token : rawTokens) {
const QString trimmed = token.trimmed();
if (trimmed.isEmpty()) {
continue;
}
const QString key = trimmed.toLower();
if (dedupe.contains(key)) {
continue;
}
dedupe.insert(key);
normalized.push_back(trimmed);
}
return normalized.join(QStringLiteral(", "));
}
QString orderByClause(ProfileSortOrder sortOrder)
{
switch (sortOrder) {
case ProfileSortOrder::ProtocolAsc:
return QStringLiteral("ORDER BY lower(protocol) ASC, lower(name) ASC, id ASC");
case ProfileSortOrder::HostAsc:
return QStringLiteral("ORDER BY lower(host) ASC, lower(name) ASC, id ASC");
case ProfileSortOrder::NameAsc:
default:
return QStringLiteral("ORDER BY lower(name) ASC, id ASC");
}
}
QString nonNullTrimmed(const QString& value)
{
const QString trimmed = value.trimmed();
return trimmed.isNull() ? QStringLiteral("") : trimmed;
}
void bindProfileFields(QSqlQuery& query, const Profile& profile) void bindProfileFields(QSqlQuery& query, const Profile& profile)
{ {
query.addBindValue(profile.name.trimmed()); const QString protocol = normalizedProtocol(profile.protocol);
query.addBindValue(profile.host.trimmed()); const QString authMode = normalizedAuthMode(protocol, profile.authMode);
const bool isSsh = protocol == QStringLiteral("SSH");
const bool isRdp = protocol == QStringLiteral("RDP");
query.addBindValue(nonNullTrimmed(profile.name));
query.addBindValue(nonNullTrimmed(profile.host));
query.addBindValue(profile.port); query.addBindValue(profile.port);
query.addBindValue(profile.username.trimmed()); query.addBindValue(nonNullTrimmed(profile.username));
query.addBindValue(profile.domain.trimmed()); query.addBindValue(isRdp ? nonNullTrimmed(profile.domain) : QStringLiteral(""));
query.addBindValue(profile.protocol.trimmed()); query.addBindValue(nonNullTrimmed(normalizedFolderPath(profile.folderPath)));
query.addBindValue(profile.authMode.trimmed()); query.addBindValue(protocol);
query.addBindValue(profile.privateKeyPath.trimmed()); query.addBindValue(authMode);
query.addBindValue(profile.knownHostsPolicy.trimmed().isEmpty() query.addBindValue((isSsh && authMode == QStringLiteral("Private Key"))
? QStringLiteral("Ask") ? nonNullTrimmed(profile.privateKeyPath)
: profile.knownHostsPolicy.trimmed()); : QStringLiteral(""));
query.addBindValue(normalizedRdpSecurityMode(profile.rdpSecurityMode)); query.addBindValue(isSsh ? normalizedKnownHostsPolicy(profile.knownHostsPolicy)
query.addBindValue(normalizedRdpPerformanceProfile(profile.rdpPerformanceProfile)); : QStringLiteral("Ask"));
query.addBindValue(isRdp ? normalizedRdpSecurityMode(profile.rdpSecurityMode)
: QStringLiteral("Negotiate"));
query.addBindValue(isRdp ? normalizedRdpPerformanceProfile(profile.rdpPerformanceProfile)
: QStringLiteral("Balanced"));
query.addBindValue(normalizedTags(profile.tags));
} }
Profile profileFromQuery(const QSqlQuery& query) Profile profileFromQuery(const QSqlQuery& query)
@@ -78,22 +188,67 @@ Profile profileFromQuery(const QSqlQuery& query)
profile.port = query.value(3).toInt(); profile.port = query.value(3).toInt();
profile.username = query.value(4).toString(); profile.username = query.value(4).toString();
profile.domain = query.value(5).toString(); profile.domain = query.value(5).toString();
profile.protocol = query.value(6).toString(); profile.folderPath = normalizedFolderPath(query.value(6).toString());
profile.authMode = query.value(7).toString(); profile.protocol = normalizedProtocol(query.value(7).toString());
profile.privateKeyPath = query.value(8).toString(); profile.authMode = normalizedAuthMode(profile.protocol, query.value(8).toString());
profile.knownHostsPolicy = query.value(9).toString(); profile.privateKeyPath = profile.authMode == QStringLiteral("Private Key")
if (profile.knownHostsPolicy.isEmpty()) { ? query.value(9).toString().trimmed()
profile.knownHostsPolicy = QStringLiteral("Ask"); : QString();
} profile.knownHostsPolicy = profile.protocol == QStringLiteral("SSH")
profile.rdpSecurityMode = normalizedRdpSecurityMode(query.value(10).toString()); ? normalizedKnownHostsPolicy(query.value(10).toString())
profile.rdpPerformanceProfile = normalizedRdpPerformanceProfile(query.value(11).toString()); : QStringLiteral("Ask");
profile.rdpSecurityMode = profile.protocol == QStringLiteral("RDP")
? normalizedRdpSecurityMode(query.value(11).toString())
: QStringLiteral("Negotiate");
profile.rdpPerformanceProfile = profile.protocol == QStringLiteral("RDP")
? normalizedRdpPerformanceProfile(query.value(12).toString())
: QStringLiteral("Balanced");
profile.tags = normalizedTags(query.value(13).toString());
return profile; return profile;
} }
bool isProfileValid(const Profile& profile) bool isProfileValid(const Profile& profile, QString* error)
{ {
return !profile.name.trimmed().isEmpty() && !profile.host.trimmed().isEmpty() if (profile.name.trimmed().isEmpty()) {
&& profile.port >= 1 && profile.port <= 65535; if (error != nullptr) {
*error = QStringLiteral("Profile name is required.");
}
return false;
}
if (profile.host.trimmed().isEmpty()) {
if (error != nullptr) {
*error = QStringLiteral("Host is required.");
}
return false;
}
if (profile.port < 1 || profile.port > 65535) {
if (error != nullptr) {
*error = QStringLiteral("Port must be between 1 and 65535.");
}
return false;
}
const QString protocol = normalizedProtocol(profile.protocol);
if ((protocol == QStringLiteral("SSH") || protocol == QStringLiteral("RDP"))
&& profile.username.trimmed().isEmpty()) {
if (error != nullptr) {
*error = QStringLiteral("Username is required for %1 profiles.").arg(protocol);
}
return false;
}
const QString authMode = normalizedAuthMode(protocol, profile.authMode);
if (protocol == QStringLiteral("SSH") && authMode == QStringLiteral("Private Key")
&& profile.privateKeyPath.trimmed().isEmpty()) {
if (error != nullptr) {
*error = QStringLiteral("Private key path is required for SSH private key authentication.");
}
return false;
}
return true;
} }
} }
@@ -125,7 +280,60 @@ QString ProfileRepository::lastError() const
return m_lastError; return m_lastError;
} }
std::vector<Profile> ProfileRepository::listProfiles(const QString& searchQuery) const std::vector<QString> ProfileRepository::listFolders() const
{
std::vector<QString> result;
if (!QSqlDatabase::contains(m_connectionName)) {
return result;
}
setLastError(QString());
QSqlQuery query(QSqlDatabase::database(m_connectionName));
query.prepare(QStringLiteral("SELECT path FROM profile_folders ORDER BY lower(path) ASC"));
if (!query.exec()) {
setLastError(query.lastError().text());
return result;
}
while (query.next()) {
const QString normalized = normalizedFolderPath(query.value(0).toString());
if (!normalized.isEmpty()) {
result.push_back(normalized);
}
}
return result;
}
bool ProfileRepository::createFolder(const QString& folderPath) const
{
if (!QSqlDatabase::contains(m_connectionName)) {
return false;
}
const QString normalized = normalizedFolderPath(folderPath);
if (normalized.isEmpty()) {
setLastError(QStringLiteral("Folder path is required."));
return false;
}
setLastError(QString());
QSqlQuery query(QSqlDatabase::database(m_connectionName));
query.prepare(QStringLiteral("INSERT OR IGNORE INTO profile_folders(path) VALUES (?)"));
query.addBindValue(normalized);
if (!query.exec()) {
setLastError(query.lastError().text());
return false;
}
return true;
}
std::vector<Profile> ProfileRepository::listProfiles(const QString& searchQuery,
ProfileSortOrder sortOrder) const
{ {
std::vector<Profile> result; std::vector<Profile> result;
@@ -136,20 +344,23 @@ std::vector<Profile> ProfileRepository::listProfiles(const QString& searchQuery)
setLastError(QString()); setLastError(QString());
QSqlQuery query(QSqlDatabase::database(m_connectionName)); QSqlQuery query(QSqlDatabase::database(m_connectionName));
const QString orderBy = orderByClause(sortOrder);
if (searchQuery.trimmed().isEmpty()) { if (searchQuery.trimmed().isEmpty()) {
query.prepare(QStringLiteral( query.prepare(QStringLiteral(
"SELECT id, name, host, port, username, domain, protocol, auth_mode, private_key_path, known_hosts_policy, rdp_security_mode, rdp_performance_profile " "SELECT id, name, host, port, username, domain, folder_path, protocol, auth_mode, private_key_path, known_hosts_policy, rdp_security_mode, rdp_performance_profile, tags "
"FROM profiles " "FROM profiles ")
"ORDER BY lower(name) ASC, id ASC")); + orderBy);
} else { } else {
query.prepare(QStringLiteral( query.prepare(QStringLiteral(
"SELECT id, name, host, port, username, domain, protocol, auth_mode, private_key_path, known_hosts_policy, rdp_security_mode, rdp_performance_profile " "SELECT id, name, host, port, username, domain, folder_path, protocol, auth_mode, private_key_path, known_hosts_policy, rdp_security_mode, rdp_performance_profile, tags "
"FROM profiles " "FROM profiles "
"WHERE lower(name) LIKE lower(?) OR lower(host) LIKE lower(?) " "WHERE lower(name) LIKE lower(?) OR lower(host) LIKE lower(?) OR lower(tags) LIKE lower(?) OR lower(folder_path) LIKE lower(?) ")
"ORDER BY lower(name) ASC, id ASC")); + orderBy);
const QString search = QStringLiteral("%") + searchQuery.trimmed() + QStringLiteral("%"); const QString search = QStringLiteral("%") + searchQuery.trimmed() + QStringLiteral("%");
query.addBindValue(search); query.addBindValue(search);
query.addBindValue(search); query.addBindValue(search);
query.addBindValue(search);
query.addBindValue(search);
} }
if (!query.exec()) { if (!query.exec()) {
@@ -174,7 +385,7 @@ std::optional<Profile> ProfileRepository::getProfile(qint64 id) const
QSqlQuery query(QSqlDatabase::database(m_connectionName)); QSqlQuery query(QSqlDatabase::database(m_connectionName));
query.prepare(QStringLiteral( query.prepare(QStringLiteral(
"SELECT id, name, host, port, username, domain, protocol, auth_mode, private_key_path, known_hosts_policy, rdp_security_mode, rdp_performance_profile " "SELECT id, name, host, port, username, domain, folder_path, protocol, auth_mode, private_key_path, known_hosts_policy, rdp_security_mode, rdp_performance_profile, tags "
"FROM profiles WHERE id = ?")); "FROM profiles WHERE id = ?"));
query.addBindValue(id); query.addBindValue(id);
@@ -198,15 +409,16 @@ std::optional<Profile> ProfileRepository::createProfile(const Profile& profile)
setLastError(QString()); setLastError(QString());
if (!isProfileValid(profile)) { QString validationError;
setLastError(QStringLiteral("Name, host, and a valid port are required.")); if (!isProfileValid(profile, &validationError)) {
setLastError(validationError);
return std::nullopt; return std::nullopt;
} }
QSqlQuery query(QSqlDatabase::database(m_connectionName)); QSqlQuery query(QSqlDatabase::database(m_connectionName));
query.prepare(QStringLiteral( query.prepare(QStringLiteral(
"INSERT INTO profiles(name, host, port, username, domain, protocol, auth_mode, private_key_path, known_hosts_policy, rdp_security_mode, rdp_performance_profile) " "INSERT INTO profiles(name, host, port, username, domain, folder_path, protocol, auth_mode, private_key_path, known_hosts_policy, rdp_security_mode, rdp_performance_profile, tags) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)")); "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"));
bindProfileFields(query, profile); bindProfileFields(query, profile);
if (!query.exec()) { if (!query.exec()) {
@@ -227,15 +439,17 @@ bool ProfileRepository::updateProfile(const Profile& profile) const
setLastError(QString()); setLastError(QString());
if (profile.id < 0 || !isProfileValid(profile)) { QString validationError;
setLastError(QStringLiteral("Invalid profile data.")); if (profile.id < 0 || !isProfileValid(profile, &validationError)) {
setLastError(validationError.isEmpty() ? QStringLiteral("Invalid profile data.")
: validationError);
return false; return false;
} }
QSqlQuery query(QSqlDatabase::database(m_connectionName)); QSqlQuery query(QSqlDatabase::database(m_connectionName));
query.prepare(QStringLiteral( query.prepare(QStringLiteral(
"UPDATE profiles " "UPDATE profiles "
"SET name = ?, host = ?, port = ?, username = ?, domain = ?, protocol = ?, auth_mode = ?, private_key_path = ?, known_hosts_policy = ?, rdp_security_mode = ?, rdp_performance_profile = ? " "SET name = ?, host = ?, port = ?, username = ?, domain = ?, folder_path = ?, protocol = ?, auth_mode = ?, private_key_path = ?, known_hosts_policy = ?, rdp_security_mode = ?, rdp_performance_profile = ?, tags = ? "
"WHERE id = ?")); "WHERE id = ?"));
bindProfileFields(query, profile); bindProfileFields(query, profile);
query.addBindValue(profile.id); query.addBindValue(profile.id);
@@ -287,12 +501,14 @@ bool ProfileRepository::initializeDatabase()
"port INTEGER NOT NULL DEFAULT 22," "port INTEGER NOT NULL DEFAULT 22,"
"username TEXT NOT NULL DEFAULT ''," "username TEXT NOT NULL DEFAULT '',"
"domain TEXT NOT NULL DEFAULT ''," "domain TEXT NOT NULL DEFAULT '',"
"folder_path TEXT NOT NULL DEFAULT '',"
"protocol TEXT NOT NULL DEFAULT 'SSH'," "protocol TEXT NOT NULL DEFAULT 'SSH',"
"auth_mode TEXT NOT NULL DEFAULT 'Password'," "auth_mode TEXT NOT NULL DEFAULT 'Password',"
"private_key_path TEXT NOT NULL DEFAULT ''," "private_key_path TEXT NOT NULL DEFAULT '',"
"known_hosts_policy TEXT NOT NULL DEFAULT 'Ask'," "known_hosts_policy TEXT NOT NULL DEFAULT 'Ask',"
"rdp_security_mode TEXT NOT NULL DEFAULT 'Negotiate'," "rdp_security_mode TEXT NOT NULL DEFAULT 'Negotiate',"
"rdp_performance_profile TEXT NOT NULL DEFAULT 'Balanced'" "rdp_performance_profile TEXT NOT NULL DEFAULT 'Balanced',"
"tags TEXT NOT NULL DEFAULT ''"
")")); ")"));
if (!created) { if (!created) {
@@ -300,6 +516,15 @@ bool ProfileRepository::initializeDatabase()
return false; return false;
} }
const bool foldersCreated = query.exec(QStringLiteral(
"CREATE TABLE IF NOT EXISTS profile_folders ("
"path TEXT PRIMARY KEY NOT NULL"
")"));
if (!foldersCreated) {
m_initError = query.lastError().text();
return false;
}
if (!ensureProfileSchema()) { if (!ensureProfileSchema()) {
m_initError = m_lastError; m_initError = m_lastError;
return false; return false;
@@ -336,12 +561,14 @@ bool ProfileRepository::ensureProfileSchema() const
{QStringLiteral("port"), QStringLiteral("ALTER TABLE profiles ADD COLUMN port INTEGER NOT NULL DEFAULT 22")}, {QStringLiteral("port"), QStringLiteral("ALTER TABLE profiles ADD COLUMN port INTEGER NOT NULL DEFAULT 22")},
{QStringLiteral("username"), QStringLiteral("ALTER TABLE profiles ADD COLUMN username TEXT NOT NULL DEFAULT ''")}, {QStringLiteral("username"), QStringLiteral("ALTER TABLE profiles ADD COLUMN username TEXT NOT NULL DEFAULT ''")},
{QStringLiteral("domain"), QStringLiteral("ALTER TABLE profiles ADD COLUMN domain TEXT NOT NULL DEFAULT ''")}, {QStringLiteral("domain"), QStringLiteral("ALTER TABLE profiles ADD COLUMN domain TEXT NOT NULL DEFAULT ''")},
{QStringLiteral("folder_path"), QStringLiteral("ALTER TABLE profiles ADD COLUMN folder_path TEXT NOT NULL DEFAULT ''")},
{QStringLiteral("protocol"), QStringLiteral("ALTER TABLE profiles ADD COLUMN protocol TEXT NOT NULL DEFAULT 'SSH'")}, {QStringLiteral("protocol"), QStringLiteral("ALTER TABLE profiles ADD COLUMN protocol TEXT NOT NULL DEFAULT 'SSH'")},
{QStringLiteral("auth_mode"), QStringLiteral("ALTER TABLE profiles ADD COLUMN auth_mode TEXT NOT NULL DEFAULT 'Password'")}, {QStringLiteral("auth_mode"), QStringLiteral("ALTER TABLE profiles ADD COLUMN auth_mode TEXT NOT NULL DEFAULT 'Password'")},
{QStringLiteral("private_key_path"), QStringLiteral("ALTER TABLE profiles ADD COLUMN private_key_path TEXT NOT NULL DEFAULT ''")}, {QStringLiteral("private_key_path"), QStringLiteral("ALTER TABLE profiles ADD COLUMN private_key_path TEXT NOT NULL DEFAULT ''")},
{QStringLiteral("known_hosts_policy"), QStringLiteral("ALTER TABLE profiles ADD COLUMN known_hosts_policy TEXT NOT NULL DEFAULT 'Ask'")}, {QStringLiteral("known_hosts_policy"), QStringLiteral("ALTER TABLE profiles ADD COLUMN known_hosts_policy TEXT NOT NULL DEFAULT 'Ask'")},
{QStringLiteral("rdp_security_mode"), QStringLiteral("ALTER TABLE profiles ADD COLUMN rdp_security_mode TEXT NOT NULL DEFAULT 'Negotiate'")}, {QStringLiteral("rdp_security_mode"), QStringLiteral("ALTER TABLE profiles ADD COLUMN rdp_security_mode TEXT NOT NULL DEFAULT 'Negotiate'")},
{QStringLiteral("rdp_performance_profile"), QStringLiteral("ALTER TABLE profiles ADD COLUMN rdp_performance_profile TEXT NOT NULL DEFAULT 'Balanced'")}}; {QStringLiteral("rdp_performance_profile"), QStringLiteral("ALTER TABLE profiles ADD COLUMN rdp_performance_profile TEXT NOT NULL DEFAULT 'Balanced'")},
{QStringLiteral("tags"), QStringLiteral("ALTER TABLE profiles ADD COLUMN tags TEXT NOT NULL DEFAULT ''")}};
for (const ColumnDef& column : required) { for (const ColumnDef& column : required) {
if (columns.contains(column.name)) { if (columns.contains(column.name)) {
@@ -355,6 +582,15 @@ bool ProfileRepository::ensureProfileSchema() const
} }
} }
QSqlQuery ensureFolders(QSqlDatabase::database(m_connectionName));
if (!ensureFolders.exec(QStringLiteral(
"CREATE TABLE IF NOT EXISTS profile_folders ("
"path TEXT PRIMARY KEY NOT NULL"
")"))) {
setLastError(ensureFolders.lastError().text());
return false;
}
setLastError(QString()); setLastError(QString());
return true; return true;
} }
+12 -1
View File
@@ -15,12 +15,20 @@ struct Profile
int port = 22; int port = 22;
QString username; QString username;
QString domain; QString domain;
QString folderPath;
QString protocol = QStringLiteral("SSH"); QString protocol = QStringLiteral("SSH");
QString authMode = QStringLiteral("Password"); QString authMode = QStringLiteral("Password");
QString privateKeyPath; QString privateKeyPath;
QString knownHostsPolicy = QStringLiteral("Ask"); QString knownHostsPolicy = QStringLiteral("Ask");
QString rdpSecurityMode = QStringLiteral("Negotiate"); QString rdpSecurityMode = QStringLiteral("Negotiate");
QString rdpPerformanceProfile = QStringLiteral("Balanced"); QString rdpPerformanceProfile = QStringLiteral("Balanced");
QString tags;
};
enum class ProfileSortOrder {
NameAsc,
ProtocolAsc,
HostAsc,
}; };
class ProfileRepository class ProfileRepository
@@ -32,7 +40,10 @@ public:
QString initError() const; QString initError() const;
QString lastError() const; QString lastError() const;
std::vector<Profile> listProfiles(const QString& searchQuery = QString()) const; std::vector<Profile> listProfiles(const QString& searchQuery = QString(),
ProfileSortOrder sortOrder = ProfileSortOrder::NameAsc) const;
std::vector<QString> listFolders() const;
bool createFolder(const QString& folderPath) const;
std::optional<Profile> getProfile(qint64 id) const; std::optional<Profile> getProfile(qint64 id) const;
std::optional<Profile> createProfile(const Profile& profile) const; std::optional<Profile> createProfile(const Profile& profile) const;
bool updateProfile(const Profile& profile) const; bool updateProfile(const Profile& profile) const;
+11
View File
@@ -0,0 +1,11 @@
#include "profiles_tree_widget.h"
#include <QDropEvent>
ProfilesTreeWidget::ProfilesTreeWidget(QWidget* parent) : QTreeWidget(parent) {}
void ProfilesTreeWidget::dropEvent(QDropEvent* event)
{
QTreeWidget::dropEvent(event);
emit itemsDropped();
}
+22
View File
@@ -0,0 +1,22 @@
#ifndef ORBITHUB_PROFILES_TREE_WIDGET_H
#define ORBITHUB_PROFILES_TREE_WIDGET_H
#include <QTreeWidget>
class QDropEvent;
class ProfilesTreeWidget : public QTreeWidget
{
Q_OBJECT
public:
explicit ProfilesTreeWidget(QWidget* parent = nullptr);
signals:
void itemsDropped();
protected:
void dropEvent(QDropEvent* event) override;
};
#endif
+719 -80
View File
@@ -2,40 +2,104 @@
#include "profile_dialog.h" #include "profile_dialog.h"
#include "profile_repository.h" #include "profile_repository.h"
#include "session_window.h" #include "profiles_tree_widget.h"
#include <QAction>
#include <QAbstractItemView> #include <QAbstractItemView>
#include <QComboBox>
#include <QHeaderView>
#include <QHBoxLayout> #include <QHBoxLayout>
#include <QLabel> #include <QLabel>
#include <QLineEdit> #include <QLineEdit>
#include <QListWidget> #include <QInputDialog>
#include <QListWidgetItem> #include <QMenu>
#include <QMessageBox> #include <QMessageBox>
#include <QPushButton> #include <QPushButton>
#include <QSet>
#include <QSettings>
#include <QSignalBlocker>
#include <QStringList>
#include <QStyle>
#include <QTreeWidget>
#include <QTreeWidgetItem>
#include <QVariant> #include <QVariant>
#include <QVBoxLayout> #include <QVBoxLayout>
#include <QWidget> #include <QWidget>
namespace { namespace {
QString formatProfileListItem(const Profile& profile) constexpr int kProfileIdRole = Qt::UserRole;
constexpr int kFolderPathRole = Qt::UserRole + 1;
QString normalizeFolderPathForView(const QString& value)
{ {
return QStringLiteral("%1 [%2 %3:%4]") QString path = value.trimmed();
.arg(profile.name, profile.protocol, profile.host, QString::number(profile.port)); path.replace(QChar::fromLatin1('\\'), QChar::fromLatin1('/'));
const QStringList rawParts = path.split(QChar::fromLatin1('/'), Qt::SkipEmptyParts);
QStringList normalized;
for (const QString& rawPart : rawParts) {
const QString part = rawPart.trimmed();
if (!part.isEmpty()) {
normalized.push_back(part);
}
}
return normalized.join(QStringLiteral("/"));
}
QString joinFolderPath(const QString& parentFolder, const QString& childName)
{
const QString parent = normalizeFolderPathForView(parentFolder);
const QString child = normalizeFolderPathForView(childName);
if (child.isEmpty()) {
return parent;
}
if (parent.isEmpty()) {
return child;
}
return QStringLiteral("%1/%2").arg(parent, child);
}
QStringList splitFolderPath(const QString& folderPath)
{
const QString normalized = normalizeFolderPathForView(folderPath);
if (normalized.isEmpty()) {
return {};
}
return normalized.split(QChar::fromLatin1('/'), Qt::SkipEmptyParts);
}
bool profileHasTag(const Profile& profile, const QString& requestedTag)
{
const QString needle = requestedTag.trimmed();
if (needle.isEmpty()) {
return true;
}
const QStringList tags = profile.tags.split(QChar::fromLatin1(','), Qt::SkipEmptyParts);
for (const QString& tag : tags) {
if (tag.trimmed().compare(needle, Qt::CaseInsensitive) == 0) {
return true;
}
}
return false;
} }
} }
ProfilesWindow::ProfilesWindow(QWidget* parent) ProfilesWindow::ProfilesWindow(QWidget* parent)
: QMainWindow(parent), : QWidget(parent),
m_searchBox(nullptr), m_searchBox(nullptr),
m_profilesList(nullptr), m_viewModeBox(nullptr),
m_sortBox(nullptr),
m_protocolFilterBox(nullptr),
m_tagFilterBox(nullptr),
m_profilesTree(nullptr),
m_newButton(nullptr), m_newButton(nullptr),
m_editButton(nullptr), m_editButton(nullptr),
m_deleteButton(nullptr), m_deleteButton(nullptr),
m_repository(std::make_unique<ProfileRepository>()) m_repository(std::make_unique<ProfileRepository>())
{ {
setWindowTitle(QStringLiteral("OrbitHub Profiles"));
resize(640, 620);
setupUi(); setupUi();
if (!m_repository->initError().isEmpty()) { if (!m_repository->initError().isEmpty()) {
@@ -47,10 +111,11 @@ ProfilesWindow::ProfilesWindow(QWidget* parent)
m_editButton->setEnabled(false); m_editButton->setEnabled(false);
m_deleteButton->setEnabled(false); m_deleteButton->setEnabled(false);
m_searchBox->setEnabled(false); m_searchBox->setEnabled(false);
m_profilesList->setEnabled(false); m_profilesTree->setEnabled(false);
return; return;
} }
loadUiPreferences();
loadProfiles(); loadProfiles();
} }
@@ -58,54 +123,139 @@ ProfilesWindow::~ProfilesWindow() = default;
void ProfilesWindow::setupUi() void ProfilesWindow::setupUi()
{ {
auto* central = new QWidget(this); auto* rootLayout = new QVBoxLayout(this);
auto* rootLayout = new QVBoxLayout(central);
auto* searchLabel = new QLabel(QStringLiteral("Search"), central); auto* searchLabel = new QLabel(QStringLiteral("Search"), this);
m_searchBox = new QLineEdit(central); m_searchBox = new QLineEdit(this);
m_searchBox->setPlaceholderText(QStringLiteral("Filter by name or host...")); m_searchBox->setPlaceholderText(QStringLiteral("Filter by name, host, folder, or tags..."));
m_profilesList = new QListWidget(central); auto* viewModeLabel = new QLabel(QStringLiteral("View"), this);
m_profilesList->setSelectionMode(QAbstractItemView::SingleSelection); m_viewModeBox = new QComboBox(this);
m_viewModeBox->addItem(QStringLiteral("List"));
m_viewModeBox->addItem(QStringLiteral("Folders"));
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"), 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"), this);
m_tagFilterBox = new QComboBox(this);
m_tagFilterBox->addItem(QStringLiteral("All"));
m_profilesTree = new ProfilesTreeWidget(this);
m_profilesTree->setSelectionMode(QAbstractItemView::SingleSelection);
m_profilesTree->setColumnCount(4);
m_profilesTree->setHeaderLabels(
{QStringLiteral("Name"), QStringLiteral("Protocol"), QStringLiteral("Server"), QStringLiteral("Tags")});
m_profilesTree->setRootIsDecorated(true);
m_profilesTree->setDragEnabled(true);
m_profilesTree->setAcceptDrops(true);
m_profilesTree->viewport()->setAcceptDrops(true);
m_profilesTree->setDropIndicatorShown(true);
m_profilesTree->setDragDropMode(QAbstractItemView::InternalMove);
m_profilesTree->setDefaultDropAction(Qt::MoveAction);
m_profilesTree->setContextMenuPolicy(Qt::CustomContextMenu);
m_profilesTree->header()->setSectionResizeMode(0, QHeaderView::Stretch);
m_profilesTree->header()->setSectionResizeMode(1, QHeaderView::ResizeToContents);
m_profilesTree->header()->setSectionResizeMode(2, QHeaderView::ResizeToContents);
m_profilesTree->header()->setSectionResizeMode(3, QHeaderView::Stretch);
auto* buttonRow = new QHBoxLayout(); auto* buttonRow = new QHBoxLayout();
m_newButton = new QPushButton(QStringLiteral("New"), central); m_newButton = new QPushButton(QStringLiteral("New"), this);
m_editButton = new QPushButton(QStringLiteral("Edit"), central); m_editButton = new QPushButton(QStringLiteral("Edit"), this);
m_deleteButton = new QPushButton(QStringLiteral("Delete"), central); m_deleteButton = new QPushButton(QStringLiteral("Delete"), this);
buttonRow->addWidget(m_newButton); buttonRow->addWidget(m_newButton);
buttonRow->addWidget(m_editButton); buttonRow->addWidget(m_editButton);
buttonRow->addWidget(m_deleteButton); buttonRow->addWidget(m_deleteButton);
buttonRow->addStretch(); buttonRow->addStretch();
rootLayout->addWidget(searchLabel); auto* filterRow = new QHBoxLayout();
rootLayout->addWidget(m_searchBox); filterRow->addWidget(searchLabel);
rootLayout->addWidget(m_profilesList, 1); filterRow->addWidget(m_searchBox, 1);
rootLayout->addLayout(buttonRow); filterRow->addWidget(viewModeLabel);
filterRow->addWidget(m_viewModeBox);
filterRow->addWidget(protocolFilterLabel);
filterRow->addWidget(m_protocolFilterBox);
filterRow->addWidget(tagFilterLabel);
filterRow->addWidget(m_tagFilterBox);
filterRow->addWidget(sortLabel);
filterRow->addWidget(m_sortBox);
setCentralWidget(central); rootLayout->addLayout(filterRow);
rootLayout->addWidget(m_profilesTree, 1);
rootLayout->addLayout(buttonRow);
connect(m_searchBox, connect(m_searchBox,
&QLineEdit::textChanged, &QLineEdit::textChanged,
this, this,
[this](const QString& text) { loadProfiles(text); }); [this](const QString&) {
saveUiPreferences();
connect(m_profilesList, loadProfiles();
&QListWidget::itemDoubleClicked, });
connect(m_viewModeBox,
&QComboBox::currentIndexChanged,
this, this,
[this](QListWidgetItem* item) { openSessionForItem(item); }); [this](int) {
saveUiPreferences();
loadProfiles();
});
connect(m_sortBox,
&QComboBox::currentIndexChanged,
this,
[this](int) {
saveUiPreferences();
loadProfiles();
});
connect(m_protocolFilterBox,
&QComboBox::currentIndexChanged,
this,
[this](int) {
saveUiPreferences();
loadProfiles();
});
connect(m_tagFilterBox,
&QComboBox::currentIndexChanged,
this,
[this](int) {
saveUiPreferences();
loadProfiles();
});
connect(m_profilesTree,
&QTreeWidget::itemDoubleClicked,
this,
[this](QTreeWidgetItem* item, int) { openSessionForItem(item); });
connect(m_profilesTree,
&QWidget::customContextMenuRequested,
this,
[this](const QPoint& pos) { showTreeContextMenu(pos); });
connect(m_profilesTree,
&ProfilesTreeWidget::itemsDropped,
this,
[this]() { persistFolderAssignmentsFromTree(); });
connect(m_newButton, &QPushButton::clicked, this, [this]() { createProfile(); }); connect(m_newButton, &QPushButton::clicked, this, [this]() { createProfile(); });
connect(m_editButton, &QPushButton::clicked, this, [this]() { editSelectedProfile(); }); connect(m_editButton, &QPushButton::clicked, this, [this]() { editSelectedProfile(); });
connect(m_deleteButton, &QPushButton::clicked, this, [this]() { deleteSelectedProfile(); }); connect(m_deleteButton, &QPushButton::clicked, this, [this]() { deleteSelectedProfile(); });
} }
void ProfilesWindow::loadProfiles(const QString& query) void ProfilesWindow::loadProfiles()
{ {
m_profilesList->clear(); m_profilesTree->clear();
m_profileCache.clear(); m_profileCache.clear();
const std::vector<Profile> profiles = m_repository->listProfiles(query); const QString query = m_searchBox == nullptr ? QString() : m_searchBox->text();
const std::vector<Profile> profiles = m_repository->listProfiles(query, selectedSortOrder());
if (!m_repository->lastError().isEmpty()) { if (!m_repository->lastError().isEmpty()) {
QMessageBox::warning(this, QMessageBox::warning(this,
QStringLiteral("Load Profiles"), QStringLiteral("Load Profiles"),
@@ -114,44 +264,522 @@ void ProfilesWindow::loadProfiles(const QString& query)
return; return;
} }
updateTagFilterOptions(profiles);
const QString protocolFilter = selectedProtocolFilter();
const QString tagFilter = selectedTagFilter();
const bool folderView = isFolderViewEnabled();
m_profilesTree->setDragEnabled(folderView);
m_profilesTree->setAcceptDrops(folderView);
m_profilesTree->viewport()->setAcceptDrops(folderView);
m_profilesTree->setDropIndicatorShown(folderView);
m_profilesTree->setDragDropMode(folderView ? QAbstractItemView::InternalMove
: QAbstractItemView::NoDragDrop);
std::vector<Profile> filteredProfiles;
filteredProfiles.reserve(profiles.size());
for (const Profile& profile : profiles) { for (const Profile& profile : profiles) {
auto* item = new QListWidgetItem(formatProfileListItem(profile), m_profilesList); if (!protocolFilter.isEmpty()
item->setData(Qt::UserRole, QVariant::fromValue(profile.id)); && profile.protocol.compare(protocolFilter, Qt::CaseInsensitive) != 0) {
const QString identity = [&profile]() { continue;
if (profile.protocol.compare(QStringLiteral("RDP"), Qt::CaseInsensitive) == 0
&& !profile.domain.trimmed().isEmpty()) {
return QStringLiteral("%1\\%2").arg(profile.domain.trimmed(),
profile.username.trimmed().isEmpty()
? QStringLiteral("<none>")
: profile.username.trimmed());
}
return profile.username.isEmpty() ? QStringLiteral("<none>") : profile.username;
}();
QString tooltip = QStringLiteral("%1://%2@%3:%4\nAuth: %5")
.arg(profile.protocol,
identity,
profile.host,
QString::number(profile.port),
profile.authMode);
if (profile.protocol.compare(QStringLiteral("SSH"), Qt::CaseInsensitive) == 0) {
tooltip += QStringLiteral("\nKnown Hosts: %1").arg(profile.knownHostsPolicy);
} else if (profile.protocol.compare(QStringLiteral("RDP"), Qt::CaseInsensitive) == 0) {
tooltip += QStringLiteral("\nRDP Security: %1\nRDP Performance: %2")
.arg(profile.rdpSecurityMode, profile.rdpPerformanceProfile);
} }
item->setToolTip(tooltip); if (!tagFilter.isEmpty() && !profileHasTag(profile, tagFilter)) {
continue;
}
filteredProfiles.push_back(profile);
}
if (folderView) {
std::map<QString, QTreeWidgetItem*> folderNodes;
const std::vector<QString> explicitFolders = m_repository->listFolders();
for (const QString& folderPath : explicitFolders) {
if (query.trimmed().isEmpty()
|| folderPath.contains(query.trimmed(), Qt::CaseInsensitive)) {
upsertFolderNode(splitFolderPath(folderPath), folderNodes);
}
}
for (const Profile& profile : filteredProfiles) {
QTreeWidgetItem* parent = nullptr;
const QStringList folderParts = splitFolderPath(profile.folderPath);
if (!folderParts.isEmpty()) {
parent = upsertFolderNode(folderParts, folderNodes);
}
addProfileNode(parent, profile);
}
m_profilesTree->expandAll();
} else {
for (const Profile& profile : filteredProfiles) {
addProfileNode(nullptr, profile);
}
}
}
ProfileSortOrder ProfilesWindow::selectedSortOrder() const
{
if (m_sortBox == nullptr) {
return ProfileSortOrder::NameAsc;
}
const QVariant value = m_sortBox->currentData();
if (!value.isValid()) {
return ProfileSortOrder::NameAsc;
}
const int orderValue = value.toInt();
switch (static_cast<ProfileSortOrder>(orderValue)) {
case ProfileSortOrder::ProtocolAsc:
return ProfileSortOrder::ProtocolAsc;
case ProfileSortOrder::HostAsc:
return ProfileSortOrder::HostAsc;
case ProfileSortOrder::NameAsc:
default:
return ProfileSortOrder::NameAsc;
}
}
bool ProfilesWindow::isFolderViewEnabled() const
{
if (m_viewModeBox == nullptr) {
return false;
}
return m_viewModeBox->currentText().compare(QStringLiteral("Folders"), Qt::CaseInsensitive)
== 0;
}
QString ProfilesWindow::selectedProtocolFilter() const
{
if (m_protocolFilterBox == nullptr) {
return QString();
}
const QString selected = m_protocolFilterBox->currentText().trimmed();
if (selected.compare(QStringLiteral("All"), Qt::CaseInsensitive) == 0) {
return QString();
}
return selected;
}
QString ProfilesWindow::selectedTagFilter() const
{
if (m_tagFilterBox == nullptr) {
return QString();
}
const QString selected = m_tagFilterBox->currentText().trimmed();
if (selected.compare(QStringLiteral("All"), Qt::CaseInsensitive) == 0) {
return QString();
}
return selected;
}
void ProfilesWindow::updateTagFilterOptions(const std::vector<Profile>& profiles)
{
if (m_tagFilterBox == nullptr) {
return;
}
const QString previousSelection = m_tagFilterBox->currentText().trimmed();
QString desiredSelection = previousSelection;
if (!m_pendingTagFilterPreference.trimmed().isEmpty()) {
desiredSelection = m_pendingTagFilterPreference.trimmed();
}
QSet<QString> seen;
QStringList tags;
for (const Profile& profile : profiles) {
const QStringList splitTags =
profile.tags.split(QChar::fromLatin1(','), Qt::SkipEmptyParts);
for (const QString& rawTag : splitTags) {
const QString tag = rawTag.trimmed();
if (tag.isEmpty()) {
continue;
}
const QString dedupeKey = tag.toLower();
if (seen.contains(dedupeKey)) {
continue;
}
seen.insert(dedupeKey);
tags.push_back(tag);
}
}
tags.sort(Qt::CaseInsensitive);
const QSignalBlocker blocker(m_tagFilterBox);
m_tagFilterBox->clear();
m_tagFilterBox->addItem(QStringLiteral("All"));
for (const QString& tag : tags) {
m_tagFilterBox->addItem(tag);
}
int restoredIndex = m_tagFilterBox->findText(desiredSelection, Qt::MatchFixedString);
if (restoredIndex < 0) {
restoredIndex = 0;
}
m_tagFilterBox->setCurrentIndex(restoredIndex);
m_pendingTagFilterPreference.clear();
}
QTreeWidgetItem* ProfilesWindow::upsertFolderNode(const QStringList& folderParts,
std::map<QString, QTreeWidgetItem*>& folderNodes)
{
QString currentPath;
QTreeWidgetItem* parent = nullptr;
for (const QString& rawPart : folderParts) {
const QString part = rawPart.trimmed();
if (part.isEmpty()) {
continue;
}
currentPath = currentPath.isEmpty() ? part
: QStringLiteral("%1/%2").arg(currentPath, part);
const auto existing = folderNodes.find(currentPath);
if (existing != folderNodes.end()) {
parent = existing->second;
continue;
}
auto* folderItem = parent == nullptr ? new QTreeWidgetItem(m_profilesTree)
: new QTreeWidgetItem(parent);
folderItem->setText(0, part);
folderItem->setIcon(0, style()->standardIcon(QStyle::SP_DirIcon));
folderItem->setData(0, kFolderPathRole, currentPath);
folderItem->setFlags((folderItem->flags() | Qt::ItemIsDropEnabled) & ~Qt::ItemIsDragEnabled);
folderNodes.insert_or_assign(currentPath, folderItem);
parent = folderItem;
}
return parent;
}
void ProfilesWindow::addProfileNode(QTreeWidgetItem* parent, const Profile& profile)
{
auto* item = parent == nullptr ? new QTreeWidgetItem(m_profilesTree) : new QTreeWidgetItem(parent);
item->setText(0, profile.name);
item->setText(1, profile.protocol);
item->setText(2, QStringLiteral("%1:%2").arg(profile.host, QString::number(profile.port)));
item->setText(3, profile.tags);
item->setIcon(0, style()->standardIcon(QStyle::SP_ComputerIcon));
item->setData(0, kProfileIdRole, QVariant::fromValue(profile.id));
item->setData(0, kFolderPathRole, normalizeFolderPathForView(profile.folderPath));
item->setFlags((item->flags() | Qt::ItemIsDragEnabled | Qt::ItemIsSelectable) & ~Qt::ItemIsDropEnabled);
const QString identity = [&profile]() {
if (profile.protocol.compare(QStringLiteral("RDP"), Qt::CaseInsensitive) == 0
&& !profile.domain.trimmed().isEmpty()) {
return QStringLiteral("%1\\%2").arg(profile.domain.trimmed(),
profile.username.trimmed().isEmpty()
? QStringLiteral("<none>")
: profile.username.trimmed());
}
return profile.username.trimmed().isEmpty() ? QStringLiteral("<none>")
: profile.username.trimmed();
}();
QString tooltip = QStringLiteral("%1://%2@%3:%4\nAuth: %5")
.arg(profile.protocol,
identity,
profile.host,
QString::number(profile.port),
profile.authMode);
if (profile.protocol.compare(QStringLiteral("SSH"), Qt::CaseInsensitive) == 0) {
tooltip += QStringLiteral("\nKnown Hosts: %1").arg(profile.knownHostsPolicy);
} else if (profile.protocol.compare(QStringLiteral("RDP"), Qt::CaseInsensitive) == 0) {
tooltip += QStringLiteral("\nRDP Security: %1\nRDP Performance: %2")
.arg(profile.rdpSecurityMode, profile.rdpPerformanceProfile);
}
if (!profile.folderPath.trimmed().isEmpty()) {
tooltip += QStringLiteral("\nFolder: %1").arg(profile.folderPath.trimmed());
}
if (!profile.tags.trimmed().isEmpty()) {
tooltip += QStringLiteral("\nTags: %1").arg(profile.tags);
}
item->setToolTip(0, tooltip);
item->setToolTip(1, tooltip);
item->setToolTip(2, tooltip);
item->setToolTip(3, tooltip);
m_profileCache.insert_or_assign(profile.id, profile);
}
QString ProfilesWindow::folderPathForItem(const QTreeWidgetItem* item) const
{
if (item == nullptr) {
return QString();
}
const QVariant idValue = item->data(0, kProfileIdRole);
if (idValue.isValid()) {
const qint64 id = idValue.toLongLong();
const auto cacheIt = m_profileCache.find(id);
if (cacheIt != m_profileCache.end()) {
return normalizeFolderPathForView(cacheIt->second.folderPath);
}
}
return normalizeFolderPathForView(item->data(0, kFolderPathRole).toString());
}
void ProfilesWindow::showTreeContextMenu(const QPoint& pos)
{
if (m_profilesTree == nullptr) {
return;
}
QTreeWidgetItem* item = m_profilesTree->itemAt(pos);
if (item != nullptr) {
m_profilesTree->setCurrentItem(item);
}
const QString contextFolder = folderPathForItem(item);
const bool isProfileItem = item != nullptr && item->data(0, kProfileIdRole).isValid();
QMenu menu(this);
QAction* newConnectionAction = menu.addAction(QStringLiteral("New Connection"));
QAction* newFolderAction = menu.addAction(QStringLiteral("New Folder"));
QAction* connectAction = nullptr;
QAction* editAction = nullptr;
QAction* deleteAction = nullptr;
if (isProfileItem) {
menu.addSeparator();
connectAction = menu.addAction(QStringLiteral("Connect"));
editAction = menu.addAction(QStringLiteral("Edit"));
deleteAction = menu.addAction(QStringLiteral("Delete"));
}
QAction* chosen = menu.exec(m_profilesTree->viewport()->mapToGlobal(pos));
if (chosen == nullptr) {
return;
}
if (chosen == newConnectionAction) {
createProfile(contextFolder);
return;
}
if (chosen == newFolderAction) {
createFolderInContext(contextFolder);
return;
}
if (isProfileItem && chosen == connectAction) {
openSessionForItem(item);
return;
}
if (isProfileItem && chosen == editAction) {
editSelectedProfile();
return;
}
if (isProfileItem && chosen == deleteAction) {
deleteSelectedProfile();
return;
}
}
void ProfilesWindow::createFolderInContext(const QString& baseFolderPath)
{
bool accepted = false;
const QString folderName = QInputDialog::getText(
this,
QStringLiteral("New Folder"),
QStringLiteral("Folder name"),
QLineEdit::Normal,
QString(),
&accepted);
if (!accepted) {
return;
}
const QString normalized = joinFolderPath(baseFolderPath, folderName);
if (normalized.isEmpty()) {
QMessageBox::information(this,
QStringLiteral("New Folder"),
QStringLiteral("Folder name is required."));
return;
}
if (!m_repository->createFolder(normalized)) {
QMessageBox::warning(this,
QStringLiteral("New Folder"),
QStringLiteral("Failed to create folder: %1")
.arg(m_repository->lastError().isEmpty()
? QStringLiteral("unknown error")
: m_repository->lastError()));
return;
}
loadProfiles();
}
void ProfilesWindow::persistFolderAssignmentsFromTree()
{
if (!isFolderViewEnabled() || m_profilesTree == nullptr) {
return;
}
std::unordered_map<qint64, QString> assignments;
const int topLevelCount = m_profilesTree->topLevelItemCount();
for (int index = 0; index < topLevelCount; ++index) {
collectProfileAssignments(m_profilesTree->topLevelItem(index), QString(), assignments);
}
bool hadErrors = false;
int updatedCount = 0;
for (const auto& [id, newFolderPath] : assignments) {
auto cacheIt = m_profileCache.find(id);
Profile profile;
if (cacheIt != m_profileCache.end()) {
profile = cacheIt->second;
} else {
const std::optional<Profile> fetched = m_repository->getProfile(id);
if (!fetched.has_value()) {
continue;
}
profile = fetched.value();
}
const QString existing = normalizeFolderPathForView(profile.folderPath);
const QString target = normalizeFolderPathForView(newFolderPath);
if (existing == target) {
continue;
}
profile.folderPath = target;
if (!target.isEmpty()) {
m_repository->createFolder(target);
}
if (!m_repository->updateProfile(profile)) {
hadErrors = true;
continue;
}
m_profileCache.insert_or_assign(profile.id, profile); m_profileCache.insert_or_assign(profile.id, profile);
++updatedCount;
}
if (hadErrors) {
QMessageBox::warning(this,
QStringLiteral("Move Profile"),
QStringLiteral("One or more profile moves could not be saved."));
}
if (updatedCount > 0 || hadErrors) {
loadProfiles();
}
}
void ProfilesWindow::collectProfileAssignments(
const QTreeWidgetItem* item,
const QString& parentFolderPath,
std::unordered_map<qint64, QString>& assignments) const
{
if (item == nullptr) {
return;
}
const QVariant idValue = item->data(0, kProfileIdRole);
if (idValue.isValid()) {
assignments.insert_or_assign(idValue.toLongLong(), normalizeFolderPathForView(parentFolderPath));
return;
}
const QString folderPath = joinFolderPath(parentFolderPath, item->text(0));
const int childCount = item->childCount();
for (int index = 0; index < childCount; ++index) {
collectProfileAssignments(item->child(index), folderPath, assignments);
}
}
void ProfilesWindow::loadUiPreferences()
{
QSettings settings;
const QString search = settings.value(QStringLiteral("profiles/searchText")).toString();
const QString viewMode =
settings.value(QStringLiteral("profiles/viewMode"), QStringLiteral("List")).toString();
const int sortValue = settings
.value(QStringLiteral("profiles/sortOrder"),
static_cast<int>(ProfileSortOrder::NameAsc))
.toInt();
const QString protocolFilter =
settings.value(QStringLiteral("profiles/protocolFilter"), QStringLiteral("All")).toString();
const QString tagFilter =
settings.value(QStringLiteral("profiles/tagFilter"), QStringLiteral("All")).toString();
m_pendingTagFilterPreference = tagFilter.trimmed();
if (m_searchBox != nullptr) {
const QSignalBlocker blocker(m_searchBox);
m_searchBox->setText(search);
}
if (m_viewModeBox != nullptr) {
int found = m_viewModeBox->findText(viewMode, Qt::MatchFixedString);
if (found < 0) {
found = 0;
}
const QSignalBlocker blocker(m_viewModeBox);
m_viewModeBox->setCurrentIndex(found);
}
if (m_sortBox != nullptr) {
int found = m_sortBox->findData(sortValue);
if (found < 0) {
found = 0;
}
const QSignalBlocker blocker(m_sortBox);
m_sortBox->setCurrentIndex(found);
}
if (m_protocolFilterBox != nullptr) {
int found = m_protocolFilterBox->findText(protocolFilter, Qt::MatchFixedString);
if (found < 0) {
found = 0;
}
const QSignalBlocker blocker(m_protocolFilterBox);
m_protocolFilterBox->setCurrentIndex(found);
}
if (m_tagFilterBox != nullptr) {
int found = m_tagFilterBox->findText(tagFilter, Qt::MatchFixedString);
if (found < 0) {
found = 0;
}
const QSignalBlocker blocker(m_tagFilterBox);
m_tagFilterBox->setCurrentIndex(found);
}
}
void ProfilesWindow::saveUiPreferences() const
{
QSettings settings;
if (m_searchBox != nullptr) {
settings.setValue(QStringLiteral("profiles/searchText"), m_searchBox->text());
}
if (m_viewModeBox != nullptr) {
settings.setValue(QStringLiteral("profiles/viewMode"), m_viewModeBox->currentText());
}
if (m_sortBox != nullptr) {
settings.setValue(QStringLiteral("profiles/sortOrder"), m_sortBox->currentData());
}
if (m_protocolFilterBox != nullptr) {
settings.setValue(QStringLiteral("profiles/protocolFilter"), m_protocolFilterBox->currentText());
}
if (m_tagFilterBox != nullptr) {
settings.setValue(QStringLiteral("profiles/tagFilter"), m_tagFilterBox->currentText());
} }
} }
std::optional<Profile> ProfilesWindow::selectedProfile() const std::optional<Profile> ProfilesWindow::selectedProfile() const
{ {
QListWidgetItem* item = m_profilesList->currentItem(); QTreeWidgetItem* item = m_profilesTree->currentItem();
if (item == nullptr) { if (item == nullptr) {
return std::nullopt; return std::nullopt;
} }
const QVariant value = item->data(Qt::UserRole); const QVariant value = item->data(0, kProfileIdRole);
if (!value.isValid()) { if (!value.isValid()) {
return std::nullopt; return std::nullopt;
} }
@@ -165,16 +793,22 @@ std::optional<Profile> ProfilesWindow::selectedProfile() const
return m_repository->getProfile(id); return m_repository->getProfile(id);
} }
void ProfilesWindow::createProfile() void ProfilesWindow::createProfile(const QString& defaultFolderPath)
{ {
ProfileDialog dialog(this); ProfileDialog dialog(this);
dialog.setDialogTitle(QStringLiteral("New Profile")); dialog.setDialogTitle(QStringLiteral("New Profile"));
dialog.setDefaultFolderPath(defaultFolderPath);
if (dialog.exec() != QDialog::Accepted) { if (dialog.exec() != QDialog::Accepted) {
return; return;
} }
if (!m_repository->createProfile(dialog.profile()).has_value()) { const Profile newProfile = dialog.profile();
if (!newProfile.folderPath.trimmed().isEmpty()) {
m_repository->createFolder(newProfile.folderPath);
}
if (!m_repository->createProfile(newProfile).has_value()) {
QMessageBox::warning(this, QMessageBox::warning(this,
QStringLiteral("Create Profile"), QStringLiteral("Create Profile"),
QStringLiteral("Failed to create profile: %1") QStringLiteral("Failed to create profile: %1")
@@ -184,7 +818,7 @@ void ProfilesWindow::createProfile()
return; return;
} }
loadProfiles(m_searchBox->text()); loadProfiles();
} }
void ProfilesWindow::editSelectedProfile() void ProfilesWindow::editSelectedProfile()
@@ -207,6 +841,9 @@ void ProfilesWindow::editSelectedProfile()
Profile updated = dialog.profile(); Profile updated = dialog.profile();
updated.id = selected->id; updated.id = selected->id;
if (!updated.folderPath.trimmed().isEmpty()) {
m_repository->createFolder(updated.folderPath);
}
if (!m_repository->updateProfile(updated)) { if (!m_repository->updateProfile(updated)) {
QMessageBox::warning(this, QMessageBox::warning(this,
@@ -218,7 +855,7 @@ void ProfilesWindow::editSelectedProfile()
return; return;
} }
loadProfiles(m_searchBox->text()); loadProfiles();
} }
void ProfilesWindow::deleteSelectedProfile() void ProfilesWindow::deleteSelectedProfile()
@@ -252,17 +889,18 @@ void ProfilesWindow::deleteSelectedProfile()
return; return;
} }
loadProfiles(m_searchBox->text()); loadProfiles();
} }
void ProfilesWindow::openSessionForItem(QListWidgetItem* item) void ProfilesWindow::openSessionForItem(QTreeWidgetItem* item)
{ {
if (item == nullptr) { if (item == nullptr) {
return; return;
} }
const QVariant value = item->data(Qt::UserRole); const QVariant value = item->data(0, kProfileIdRole);
if (!value.isValid()) { if (!value.isValid()) {
item->setExpanded(!item->isExpanded());
return; return;
} }
@@ -278,16 +916,17 @@ void ProfilesWindow::openSessionForItem(QListWidgetItem* item)
return; return;
} }
if (m_sessionWindow.isNull()) { emit connectRequested(profile.value());
m_sessionWindow = new SessionWindow(profile.value()); }
m_sessionWindow->setAttribute(Qt::WA_DeleteOnClose);
connect(m_sessionWindow, &QObject::destroyed, this, [this]() { m_sessionWindow = nullptr; }); void ProfilesWindow::createProfileInCurrentContext()
} else { {
m_sessionWindow->openProfile(profile.value()); const QString folderPath = folderPathForItem(m_profilesTree->currentItem());
} createProfile(folderPath);
}
m_sessionWindow->setWindowState(m_sessionWindow->windowState() & ~Qt::WindowMinimized);
m_sessionWindow->show(); void ProfilesWindow::createFolderInCurrentContext()
m_sessionWindow->raise(); {
m_sessionWindow->activateWindow(); const QString folderPath = folderPathForItem(m_profilesTree->currentItem());
createFolderInContext(folderPath);
} }
+42 -11
View File
@@ -3,22 +3,26 @@
#include "profile_repository.h" #include "profile_repository.h"
#include <QMainWindow> #include <QWidget>
#include <QString> #include <QString>
#include <QStringList>
#include <QtGlobal> #include <QtGlobal>
#include <memory> #include <memory>
#include <map>
#include <optional> #include <optional>
#include <QPointer> #include <vector>
#include <unordered_map> #include <unordered_map>
class QListWidget; class QTreeWidget;
class QListWidgetItem; class QTreeWidgetItem;
class QLineEdit; class QLineEdit;
class QPushButton; class QPushButton;
class SessionWindow; class QComboBox;
class QPoint;
class ProfilesTreeWidget;
class ProfilesWindow : public QMainWindow class ProfilesWindow : public QWidget
{ {
Q_OBJECT Q_OBJECT
@@ -26,23 +30,50 @@ public:
explicit ProfilesWindow(QWidget* parent = nullptr); explicit ProfilesWindow(QWidget* parent = nullptr);
~ProfilesWindow() override; ~ProfilesWindow() override;
void createProfileInCurrentContext();
void createFolderInCurrentContext();
signals:
void connectRequested(const Profile& profile);
private: private:
QLineEdit* m_searchBox; QLineEdit* m_searchBox;
QListWidget* m_profilesList; QComboBox* m_viewModeBox;
QComboBox* m_sortBox;
QComboBox* m_protocolFilterBox;
QComboBox* m_tagFilterBox;
ProfilesTreeWidget* m_profilesTree;
QPushButton* m_newButton; QPushButton* m_newButton;
QPushButton* m_editButton; QPushButton* m_editButton;
QPushButton* m_deleteButton; QPushButton* m_deleteButton;
QPointer<SessionWindow> m_sessionWindow;
std::unique_ptr<ProfileRepository> m_repository; std::unique_ptr<ProfileRepository> m_repository;
std::unordered_map<qint64, Profile> m_profileCache; std::unordered_map<qint64, Profile> m_profileCache;
QString m_pendingTagFilterPreference;
void setupUi(); void setupUi();
void loadProfiles(const QString& query = QString()); void loadProfiles();
ProfileSortOrder selectedSortOrder() const;
bool isFolderViewEnabled() const;
QString selectedProtocolFilter() const;
QString selectedTagFilter() const;
void updateTagFilterOptions(const std::vector<Profile>& profiles);
QTreeWidgetItem* upsertFolderNode(const QStringList& folderParts,
std::map<QString, QTreeWidgetItem*>& folderNodes);
void addProfileNode(QTreeWidgetItem* parent, const Profile& profile);
QString folderPathForItem(const QTreeWidgetItem* item) const;
void showTreeContextMenu(const QPoint& pos);
void createFolderInContext(const QString& baseFolderPath);
void persistFolderAssignmentsFromTree();
void collectProfileAssignments(const QTreeWidgetItem* item,
const QString& parentFolderPath,
std::unordered_map<qint64, QString>& assignments) const;
void loadUiPreferences();
void saveUiPreferences() const;
std::optional<Profile> selectedProfile() const; std::optional<Profile> selectedProfile() const;
void createProfile(); void createProfile(const QString& defaultFolderPath = QString());
void editSelectedProfile(); void editSelectedProfile();
void deleteSelectedProfile(); void deleteSelectedProfile();
void openSessionForItem(QListWidgetItem* item); void openSessionForItem(QTreeWidgetItem* item);
}; };
#endif #endif
+75 -2
View File
@@ -1,8 +1,10 @@
#include "rdp_display_widget.h" #include "rdp_display_widget.h"
#include <QCursor>
#include <QKeyEvent> #include <QKeyEvent>
#include <QMouseEvent> #include <QMouseEvent>
#include <QPainter> #include <QPainter>
#include <QPixmap>
#include <QResizeEvent> #include <QResizeEvent>
#include <QTimer> #include <QTimer>
#include <QWheelEvent> #include <QWheelEvent>
@@ -16,7 +18,7 @@ QSize sanitizeSize(const QSize& size)
} }
RdpDisplayWidget::RdpDisplayWidget(QWidget* parent) RdpDisplayWidget::RdpDisplayWidget(QWidget* parent)
: QWidget(parent), m_remoteSize(1280, 720) : QWidget(parent), m_remoteSize(1280, 720), m_cursorMode(CursorMode::Default)
{ {
setFocusPolicy(Qt::StrongFocus); setFocusPolicy(Qt::StrongFocus);
setMouseTracking(true); setMouseTracking(true);
@@ -61,6 +63,62 @@ void RdpDisplayWidget::clearFrame()
update(); 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) void RdpDisplayWidget::paintEvent(QPaintEvent* event)
{ {
Q_UNUSED(event); Q_UNUSED(event);
@@ -84,14 +142,21 @@ void RdpDisplayWidget::resizeEvent(QResizeEvent* event)
QWidget::resizeEvent(event); QWidget::resizeEvent(event);
const QSize size = sanitizeSize(event->size()); const QSize size = sanitizeSize(event->size());
emit viewportSizeChanged(size.width(), size.height()); emit viewportSizeChanged(size.width(), size.height());
applyCursor();
} }
void RdpDisplayWidget::keyPressEvent(QKeyEvent* event) void RdpDisplayWidget::keyPressEvent(QKeyEvent* event)
{ {
if (event == nullptr || event->isAutoRepeat()) { if (event == nullptr) {
return; 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(), emit keyInput(event->key(),
event->nativeScanCode(), event->nativeScanCode(),
event->text(), event->text(),
@@ -114,6 +179,14 @@ void RdpDisplayWidget::keyReleaseEvent(QKeyEvent* event)
event->accept(); 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) void RdpDisplayWidget::mousePressEvent(QMouseEvent* event)
{ {
if (event == nullptr) { if (event == nullptr) {
+14
View File
@@ -20,6 +20,9 @@ public:
void setFrame(const QImage& frame); void setFrame(const QImage& frame);
void setRemoteDesktopSize(int width, int height); void setRemoteDesktopSize(int width, int height);
void clearFrame(); void clearFrame();
void setCursorImage(const QImage& image, const QPoint& hotspot);
void setCursorHidden();
void setCursorDefault();
signals: signals:
void keyInput(int key, quint32 nativeScanCode, const QString& text, bool pressed, int modifiers); void keyInput(int key, quint32 nativeScanCode, const QString& text, bool pressed, int modifiers);
@@ -37,14 +40,25 @@ protected:
void mouseReleaseEvent(QMouseEvent* event) override; void mouseReleaseEvent(QMouseEvent* event) override;
void mouseMoveEvent(QMouseEvent* event) override; void mouseMoveEvent(QMouseEvent* event) override;
void wheelEvent(QWheelEvent* event) override; void wheelEvent(QWheelEvent* event) override;
bool focusNextPrevChild(bool next) override;
private: private:
enum class CursorMode {
Default,
Custom,
Hidden,
};
QImage m_frame; QImage m_frame;
QSize m_remoteSize; QSize m_remoteSize;
QImage m_cursorImage;
QPoint m_cursorHotspot;
CursorMode m_cursorMode;
QRectF renderRect() const; QRectF renderRect() const;
QPoint mapToRemote(const QPointF& pos) const; QPoint mapToRemote(const QPointF& pos) const;
QSize effectiveRemoteSize() const; QSize effectiveRemoteSize() const;
void applyCursor();
}; };
#endif #endif
+517 -25
View File
@@ -7,29 +7,74 @@
#include <cmath> #include <cmath>
#include <cstring> #include <cstring>
#include <mutex>
#include <new>
#include <utility> #include <utility>
#ifdef ORBITHUB_HAS_FREERDP #ifdef ORBITHUB_HAS_FREERDP
#include <freerdp/channels/channels.h> #include <freerdp/channels/channels.h>
#include <freerdp/channels/cliprdr.h>
#include <freerdp/channels/drdynvc.h> #include <freerdp/channels/drdynvc.h>
#include <freerdp/channels/disp.h> #include <freerdp/channels/disp.h>
#include <freerdp/client/cliprdr.h>
#include <freerdp/addin.h> #include <freerdp/addin.h>
#include <freerdp/client/channels.h> #include <freerdp/client/channels.h>
#include <freerdp/client/cmdline.h> #include <freerdp/client/cmdline.h>
#include <freerdp/client/disp.h> #include <freerdp/client/disp.h>
#include <freerdp/codec/color.h>
#include <freerdp/display.h> #include <freerdp/display.h>
#include <freerdp/error.h> #include <freerdp/error.h>
#include <freerdp/event.h> #include <freerdp/event.h>
#include <freerdp/freerdp.h> #include <freerdp/freerdp.h>
#include <freerdp/gdi/gdi.h> #include <freerdp/gdi/gdi.h>
#include <freerdp/graphics.h>
#include <freerdp/input.h> #include <freerdp/input.h>
#include <freerdp/locale/keyboard.h>
#include <freerdp/scancode.h> #include <freerdp/scancode.h>
#include <freerdp/settings.h> #include <freerdp/settings.h>
#include <freerdp/utils/signal.h>
#include <winpr/winsock.h>
#include <winpr/crt.h> #include <winpr/crt.h>
#include <winpr/synch.h> #include <winpr/synch.h>
#include <winpr/user.h>
#endif #endif
namespace { 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 kDefaultDesktopWidth = 1280;
constexpr int kDefaultDesktopHeight = 720; constexpr int kDefaultDesktopHeight = 720;
constexpr int kMinDesktopWidth = 640; constexpr int kMinDesktopWidth = 640;
@@ -146,6 +191,16 @@ UINT orbitDisplayControlCaps(DispClientContext* context,
UINT32 maxNumMonitors, UINT32 maxNumMonitors,
UINT32 maxMonitorAreaFactorA, UINT32 maxMonitorAreaFactorA,
UINT32 maxMonitorAreaFactorB); 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 orbitLoadChannels(freerdp* instance);
bool orbitLoadStaticChannel(rdpChannels* channels, bool orbitLoadStaticChannel(rdpChannels* channels,
rdpSettings* settings, rdpSettings* settings,
@@ -246,6 +301,118 @@ BOOL orbitDesktopResize(rdpContext* context)
return TRUE; 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) BOOL orbitPreConnect(freerdp* instance)
{ {
if (instance == nullptr || instance->context == nullptr || instance->context->settings == nullptr) { if (instance == nullptr || instance->context == nullptr || instance->context->settings == nullptr) {
@@ -274,6 +441,17 @@ BOOL orbitPostConnect(freerdp* instance)
context->update->EndPaint = orbitEndPaint; context->update->EndPaint = orbitEndPaint;
context->update->DesktopResize = orbitDesktopResize; 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)) { if (RdpSessionBackend* backend = backendFromContext(context)) {
const int width = static_cast<int>( const int width = static_cast<int>(
freerdp_settings_get_uint32(context->settings, FreeRDP_DesktopWidth)); freerdp_settings_get_uint32(context->settings, FreeRDP_DesktopWidth));
@@ -291,6 +469,7 @@ void orbitPostDisconnect(freerdp* instance)
if (instance->context != nullptr) { if (instance->context != nullptr) {
if (RdpSessionBackend* backend = backendFromContext(instance->context)) { if (RdpSessionBackend* backend = backendFromContext(instance->context)) {
backend->onChannelDisconnectedEvent(DISP_DVC_CHANNEL_NAME, nullptr); backend->onChannelDisconnectedEvent(DISP_DVC_CHANNEL_NAME, nullptr);
backend->onChannelDisconnectedEvent(CLIPRDR_SVC_CHANNEL_NAME, nullptr);
} }
PubSub_UnsubscribeChannelConnected(instance->context->pubSub, PubSub_UnsubscribeChannelConnected(instance->context->pubSub,
orbitOnChannelConnectedEventHandler); orbitOnChannelConnectedEventHandler);
@@ -337,6 +516,75 @@ UINT orbitDisplayControlCaps(DispClientContext* context,
return CHANNEL_RC_OK; 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) BOOL orbitLoadChannels(freerdp* instance)
{ {
if (instance == nullptr || instance->context == nullptr || instance->context->settings == nullptr if (instance == nullptr || instance->context == nullptr || instance->context->settings == nullptr
@@ -383,6 +631,14 @@ BOOL orbitLoadChannels(freerdp* instance)
return TRUE; 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) { if (backend != nullptr) {
emit backend->eventLogged(QStringLiteral("RDP channel loader: display-control channels initialized.")); emit backend->eventLogged(QStringLiteral("RDP channel loader: display-control channels initialized."));
} }
@@ -576,6 +832,25 @@ UINT32 scancodeFromNativeScanCode(quint32 nativeScanCode)
return RDP_SCANCODE_UNKNOWN; 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; quint32 code = 0;
bool extended = false; bool extended = false;
if ((nativeScanCode & 0xFF000000u) == 0xE0000000u) { if ((nativeScanCode & 0xFF000000u) == 0xE0000000u) {
@@ -607,6 +882,7 @@ UINT32 scancodeFromNativeScanCode(quint32 nativeScanCode)
} }
return scancode; return scancode;
#endif
} }
UINT32 scancodeForQtKey(int key, Qt::KeyboardModifiers modifiers, quint32 nativeScanCode) UINT32 scancodeForQtKey(int key, Qt::KeyboardModifiers modifiers, quint32 nativeScanCode)
@@ -1001,7 +1277,8 @@ RdpSessionBackend::RdpSessionBackend(const Profile& profile, QObject* parent)
m_displayControlReady(false), m_displayControlReady(false),
m_resizeFailureLogged(false), m_resizeFailureLogged(false),
m_lastResizeWidth(0), m_lastResizeWidth(0),
m_lastResizeHeight(0) m_lastResizeHeight(0),
m_cliprdrContext(nullptr)
{ {
} }
@@ -1112,6 +1389,18 @@ void RdpSessionBackend::sendKeyEvent(int key,
enqueueInputEvent(event); 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) void RdpSessionBackend::sendMouseMoveEvent(int x, int y)
{ {
if (!m_workerRunning.load()) { if (!m_workerRunning.load()) {
@@ -1221,6 +1510,8 @@ void RdpSessionBackend::workerMain()
m_workerRunning.store(false); m_workerRunning.store(false);
return; return;
#else #else
ensureFreeRdpRuntimeInitialized();
freerdp* instance = freerdp_new(); freerdp* instance = freerdp_new();
if (instance == nullptr) { if (instance == nullptr) {
emitConnectionFailureAsync(QStringLiteral("Failed to initialize FreeRDP runtime."), emitConnectionFailureAsync(QStringLiteral("Failed to initialize FreeRDP runtime."),
@@ -1537,6 +1828,38 @@ bool RdpSessionBackend::sendDisplayResize(rdp_freerdp* instance, int width, int
#endif #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) void RdpSessionBackend::processInputEvents(rdp_freerdp* instance)
{ {
#ifndef ORBITHUB_HAS_FREERDP #ifndef ORBITHUB_HAS_FREERDP
@@ -1661,6 +1984,9 @@ void RdpSessionBackend::processInputEvents(rdp_freerdp* instance)
} }
break; break;
} }
case InputEventType::SetClipboardText:
sendClipboardTextToRemote(instance, event.text);
break;
} }
} }
@@ -1686,24 +2012,47 @@ void RdpSessionBackend::processInputEvents(rdp_freerdp* instance)
void RdpSessionBackend::onChannelConnectedEvent(const char* name, void* channelInterface) void RdpSessionBackend::onChannelConnectedEvent(const char* name, void* channelInterface)
{ {
#ifdef ORBITHUB_HAS_FREERDP #ifdef ORBITHUB_HAS_FREERDP
if (name == nullptr || std::strcmp(name, DISP_DVC_CHANNEL_NAME) != 0) { if (name == nullptr) {
return; return;
} }
auto* dispContext = reinterpret_cast<DispClientContext*>(channelInterface); if (std::strcmp(name, DISP_DVC_CHANNEL_NAME) == 0) {
if (dispContext == nullptr) { 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; return;
} }
dispContext->custom = this; if (std::strcmp(name, CLIPRDR_SVC_CHANNEL_NAME) == 0) {
dispContext->DisplayControlCaps = orbitDisplayControlCaps; auto* cliprdrContext = reinterpret_cast<CliprdrClientContext*>(channelInterface);
if (cliprdrContext == nullptr) {
return;
}
{ cliprdrContext->custom = this;
std::lock_guard<std::mutex> guard(m_displayControlMutex); cliprdrContext->MonitorReady = orbitCliprdrMonitorReady;
m_displayControlContext = dispContext; cliprdrContext->ServerFormatList = orbitCliprdrServerFormatList;
m_displayControlReady = false; 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 #else
Q_UNUSED(name); Q_UNUSED(name);
Q_UNUSED(channelInterface); Q_UNUSED(channelInterface);
@@ -1713,24 +2062,43 @@ void RdpSessionBackend::onChannelConnectedEvent(const char* name, void* channelI
void RdpSessionBackend::onChannelDisconnectedEvent(const char* name, void* channelInterface) void RdpSessionBackend::onChannelDisconnectedEvent(const char* name, void* channelInterface)
{ {
#ifdef ORBITHUB_HAS_FREERDP #ifdef ORBITHUB_HAS_FREERDP
if (name != nullptr && std::strcmp(name, DISP_DVC_CHANNEL_NAME) != 0) { const bool matchesDisp = name == nullptr || std::strcmp(name, DISP_DVC_CHANNEL_NAME) == 0;
return; const bool matchesCliprdr =
} name == nullptr || std::strcmp(name, CLIPRDR_SVC_CHANNEL_NAME) == 0;
bool cleared = false; if (matchesDisp) {
bool hadDisplayControl = false; bool cleared = false;
{ bool hadDisplayControl = false;
std::lock_guard<std::mutex> guard(m_displayControlMutex); {
hadDisplayControl = (m_displayControlContext != nullptr) || m_displayControlReady; std::lock_guard<std::mutex> guard(m_displayControlMutex);
if (channelInterface == nullptr || m_displayControlContext == channelInterface) { hadDisplayControl = (m_displayControlContext != nullptr) || m_displayControlReady;
m_displayControlContext = nullptr; if (channelInterface == nullptr || m_displayControlContext == channelInterface) {
m_displayControlReady = false; m_displayControlContext = nullptr;
cleared = true; m_displayControlReady = false;
cleared = true;
}
}
if (cleared && hadDisplayControl) {
emit eventLogged(QStringLiteral("RDP display-control channel disconnected."));
} }
} }
if (cleared && hadDisplayControl) { if (matchesCliprdr) {
emit eventLogged(QStringLiteral("RDP display-control channel disconnected.")); 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 #else
Q_UNUSED(name); Q_UNUSED(name);
@@ -1769,6 +2137,130 @@ void RdpSessionBackend::onDisplayControlCaps(uint32_t maxNumMonitors,
#endif #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) void RdpSessionBackend::emitStateAsync(SessionState state, const QString& message)
{ {
QMetaObject::invokeMethod( QMetaObject::invokeMethod(
+11
View File
@@ -34,6 +34,7 @@ public slots:
void sendMouseMoveEvent(int x, int y) override; void sendMouseMoveEvent(int x, int y) override;
void sendMouseButtonEvent(int x, int y, int button, bool pressed) override; void sendMouseButtonEvent(int x, int y, int button, bool pressed) override;
void sendMouseWheelEvent(int x, int y, int deltaX, int deltaY) override; void sendMouseWheelEvent(int x, int y, int deltaX, int deltaY) override;
void setClipboardText(const QString& text) override;
private: private:
enum class InputEventType { enum class InputEventType {
@@ -42,6 +43,7 @@ private:
MouseButton, MouseButton,
MouseWheel, MouseWheel,
Resize, Resize,
SetClipboardText,
}; };
struct InputEvent { struct InputEvent {
@@ -84,6 +86,10 @@ private:
int m_lastResizeWidth; int m_lastResizeWidth;
int m_lastResizeHeight; int m_lastResizeHeight;
std::mutex m_cliprdrMutex;
void* m_cliprdrContext;
QString m_pendingLocalClipboardText;
void setState(SessionState state, const QString& message); void setState(SessionState state, const QString& message);
bool validateProfile(QString& message) const; bool validateProfile(QString& message) const;
void startWorker(); void startWorker();
@@ -92,12 +98,17 @@ private:
void enqueueInputEvent(const InputEvent& event); void enqueueInputEvent(const InputEvent& event);
void processInputEvents(rdp_freerdp* instance); void processInputEvents(rdp_freerdp* instance);
bool sendDisplayResize(rdp_freerdp* instance, int width, int height); bool sendDisplayResize(rdp_freerdp* instance, int width, int height);
void sendClipboardTextToRemote(rdp_freerdp* instance, const QString& text);
public: public:
void onChannelConnectedEvent(const char* name, void* channelInterface); void onChannelConnectedEvent(const char* name, void* channelInterface);
void onChannelDisconnectedEvent(const char* name, void* channelInterface); void onChannelDisconnectedEvent(const char* name, void* channelInterface);
void onDisplayControlCaps(uint32_t maxNumMonitors, void onDisplayControlCaps(uint32_t maxNumMonitors,
uint32_t maxMonitorAreaFactorA, uint32_t maxMonitorAreaFactorA,
uint32_t maxMonitorAreaFactorB); 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);
private: private:
void emitStateAsync(SessionState state, const QString& message); void emitStateAsync(SessionState state, const QString& message);
void emitConnectionFailureAsync(const QString& displayMessage, const QString& rawMessage); void emitConnectionFailureAsync(const QString& displayMessage, const QString& rawMessage);
+9
View File
@@ -5,6 +5,7 @@
#include <QImage> #include <QImage>
#include <QObject> #include <QObject>
#include <QPoint>
#include <QString> #include <QString>
#include <QtGlobal> #include <QtGlobal>
@@ -46,6 +47,10 @@ public slots:
virtual void sendInput(const QString& input) = 0; virtual void sendInput(const QString& input) = 0;
virtual void confirmHostKey(bool trustHost) = 0; virtual void confirmHostKey(bool trustHost) = 0;
virtual void updateTerminalSize(int columns, int rows) = 0; virtual void updateTerminalSize(int columns, int rows) = 0;
virtual void setClipboardText(const QString& text)
{
Q_UNUSED(text);
}
virtual void sendKeyEvent(int key, virtual void sendKeyEvent(int key,
quint32 nativeScanCode, quint32 nativeScanCode,
const QString& text, const QString& text,
@@ -86,6 +91,10 @@ signals:
void hostKeyConfirmationRequested(const QString& prompt); void hostKeyConfirmationRequested(const QString& prompt);
void frameUpdated(const QImage& frame); void frameUpdated(const QImage& frame);
void remoteDesktopSizeChanged(int width, int height); void remoteDesktopSizeChanged(int width, int height);
void remoteClipboardTextChanged(const QString& text);
void cursorImageChanged(const QImage& image, const QPoint& hotspot);
void cursorHidden();
void cursorReset();
private: private:
Profile m_profile; Profile m_profile;
+548 -88
View File
@@ -7,20 +7,26 @@
#include <KodoTerm/KodoTerm.hpp> #include <KodoTerm/KodoTerm.hpp>
#include <QDateTime> #include <QDateTime>
#include <QFile>
#include <QFileDialog> #include <QFileDialog>
#include <QFileInfo> #include <QFileInfo>
#include <QFont> #include <QFont>
#include <QFontDatabase> #include <QFontDatabase>
#include <QHBoxLayout> #include <QHBoxLayout>
#include <QInputDialog>
#include <QLabel> #include <QLabel>
#include <QLineEdit> #include <QLineEdit>
#include <QMessageBox> #include <QMessageBox>
#include <QPlainTextEdit> #include <QPlainTextEdit>
#include <QApplication>
#include <QClipboard>
#include <QMimeData>
#include <QComboBox>
#include <QProcessEnvironment> #include <QProcessEnvironment>
#include <QPushButton>
#include <QThread> #include <QThread>
#include <QTimer> #include <QTimer>
#include <QToolButton> #include <QToolButton>
#include <QTextStream>
#include <QVBoxLayout> #include <QVBoxLayout>
#include <memory> #include <memory>
@@ -53,7 +59,9 @@ TerminalTheme themeForName(const QString& themeName)
} }
} }
SessionTab::SessionTab(const Profile& profile, QWidget* parent) SessionTab::SessionTab(const Profile& profile,
const SessionUiPreferences& preferences,
QWidget* parent)
: QWidget(parent), : QWidget(parent),
m_profile(profile), m_profile(profile),
m_backendThread(nullptr), m_backendThread(nullptr),
@@ -61,13 +69,31 @@ SessionTab::SessionTab(const Profile& profile, QWidget* parent)
m_useKodoTermForSsh(profile.protocol.compare(QStringLiteral("SSH"), Qt::CaseInsensitive) m_useKodoTermForSsh(profile.protocol.compare(QStringLiteral("SSH"), Qt::CaseInsensitive)
== 0), == 0),
m_state(SessionState::Disconnected), m_state(SessionState::Disconnected),
m_terminalThemeName(QStringLiteral("Dark")), 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_sshTerminal(nullptr),
m_rdpDisplay(nullptr), m_rdpDisplay(nullptr),
m_terminalOutput(nullptr), m_terminalOutput(nullptr),
m_eventLog(nullptr), m_eventLog(nullptr),
m_toggleEventsButton(nullptr), m_toggleEventsButton(nullptr),
m_eventsPanel(nullptr) m_eventFilterInput(nullptr),
m_eventSeverityFilterInput(nullptr),
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)
{ {
qRegisterMetaType<SessionConnectOptions>("SessionConnectOptions"); qRegisterMetaType<SessionConnectOptions>("SessionConnectOptions");
qRegisterMetaType<SessionState>("SessionState"); qRegisterMetaType<SessionState>("SessionState");
@@ -170,6 +196,11 @@ SessionTab::SessionTab(const Profile& profile, QWidget* parent)
m_backend, m_backend,
&SessionBackend::sendMouseWheelEvent, &SessionBackend::sendMouseWheelEvent,
Qt::QueuedConnection); Qt::QueuedConnection);
connect(this,
&SessionTab::requestSetClipboardText,
m_backend,
&SessionBackend::setClipboardText,
Qt::QueuedConnection);
connect(m_backend, connect(m_backend,
&SessionBackend::stateChanged, &SessionBackend::stateChanged,
@@ -214,10 +245,49 @@ SessionTab::SessionTab(const Profile& profile, QWidget* parent)
} }
}, },
Qt::QueuedConnection); 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(); m_backendThread->start();
} }
if (m_clipboardSyncSupported) {
connect(QApplication::clipboard(),
&QClipboard::dataChanged,
this,
&SessionTab::onSystemClipboardChanged);
}
setState(SessionState::Disconnected, QStringLiteral("Ready to connect.")); setState(SessionState::Disconnected, QStringLiteral("Ready to connect."));
QTimer::singleShot(0, this, &SessionTab::connectSession); QTimer::singleShot(0, this, &SessionTab::connectSession);
} }
@@ -250,21 +320,20 @@ void SessionTab::connectSession()
return; return;
} }
const std::optional<SessionConnectOptions> options = buildConnectOptions(); requestConnectOptions([this](std::optional<SessionConnectOptions> options) {
if (!options.has_value()) { if (!options.has_value()) {
return;
}
m_lastConnectOptions = options.value();
if (m_useKodoTermForSsh) {
if (!startSshTerminal(options.value())) {
return; return;
} }
return;
}
emit requestConnect(options.value()); m_lastConnectOptions = options.value();
if (m_useKodoTermForSsh) {
startSshTerminal(options.value());
return;
}
emit requestConnect(options.value());
});
} }
void SessionTab::disconnectSession() void SessionTab::disconnectSession()
@@ -290,24 +359,25 @@ void SessionTab::reconnectSession()
return; return;
} }
const std::optional<SessionConnectOptions> options = buildConnectOptions(); requestConnectOptions([this](std::optional<SessionConnectOptions> options) {
if (!options.has_value()) { if (!options.has_value()) {
return; return;
}
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()); 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() void SessionTab::clearTerminal()
@@ -347,6 +417,7 @@ void SessionTab::setTerminalThemeName(const QString& themeName)
m_terminalThemeName = normalized; m_terminalThemeName = normalized;
applyTerminalTheme(m_terminalThemeName); applyTerminalTheme(m_terminalThemeName);
appendEvent(QStringLiteral("Terminal theme set to %1.").arg(m_terminalThemeName)); appendEvent(QStringLiteral("Terminal theme set to %1.").arg(m_terminalThemeName));
emit terminalThemeChanged(m_terminalThemeName);
} }
QString SessionTab::terminalThemeName() const QString SessionTab::terminalThemeName() const
@@ -364,6 +435,189 @@ bool SessionTab::supportsClearAction() const
return m_useKodoTermForSsh || m_terminalOutput != nullptr; 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;
}
void SessionTab::setEventsPanelExpanded(bool expanded)
{
if (m_eventsPanel == nullptr || m_toggleEventsButton == nullptr) {
return;
}
if (m_eventsPanelExpanded == expanded && m_eventsPanel->isVisible() == expanded) {
return;
}
m_eventsPanelExpanded = expanded;
setPanelExpanded(m_toggleEventsButton, m_eventsPanel, QStringLiteral("Events"), expanded);
emit eventsPanelVisibilityChanged(m_eventsPanelExpanded);
}
void SessionTab::clearEvents()
{
m_eventEntries.clear();
if (m_eventLog != nullptr) {
m_eventLog->clear();
}
}
void SessionTab::copyEvents() const
{
QStringList visibleLines;
for (const EventEntry& entry : m_eventEntries) {
const bool matchesText = m_eventFilter.isEmpty()
|| entry.line.contains(m_eventFilter, Qt::CaseInsensitive);
bool matchesSeverity = true;
if (m_eventSeverityFilter == EventSeverity::Warning) {
matchesSeverity = entry.severity == EventSeverity::Warning;
} else if (m_eventSeverityFilter == EventSeverity::Error) {
matchesSeverity = entry.severity == EventSeverity::Error;
} else if (m_eventSeverityFilter == EventSeverity::Info) {
matchesSeverity = true;
}
if (matchesText && matchesSeverity) {
visibleLines.push_back(entry.line);
}
}
if (!visibleLines.isEmpty()) {
QApplication::clipboard()->setText(visibleLines.join(QChar::fromLatin1('\n')));
}
}
void SessionTab::exportEventsToFile()
{
QStringList visibleLines;
for (const EventEntry& entry : m_eventEntries) {
const bool matchesText = m_eventFilter.isEmpty()
|| entry.line.contains(m_eventFilter, Qt::CaseInsensitive);
bool matchesSeverity = true;
if (m_eventSeverityFilter == EventSeverity::Warning) {
matchesSeverity = entry.severity == EventSeverity::Warning;
} else if (m_eventSeverityFilter == EventSeverity::Error) {
matchesSeverity = entry.severity == EventSeverity::Error;
} else if (m_eventSeverityFilter == EventSeverity::Info) {
matchesSeverity = true;
}
if (matchesText && matchesSeverity) {
visibleLines.push_back(entry.line);
}
}
if (visibleLines.isEmpty()) {
QMessageBox::information(this,
QStringLiteral("Export Events"),
QStringLiteral("No events match the current filters."));
return;
}
const QString defaultName =
QStringLiteral("orbithub-events-%1.log")
.arg(QDateTime::currentDateTime().toString(QStringLiteral("yyyyMMdd-HHmmss")));
const QString targetPath = QFileDialog::getSaveFileName(this,
QStringLiteral("Export Session Events"),
defaultName,
QStringLiteral("Log Files (*.log);;Text Files (*.txt);;All Files (*)"));
if (targetPath.isEmpty()) {
return;
}
QFile file(targetPath);
if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) {
QMessageBox::warning(this,
QStringLiteral("Export Events"),
QStringLiteral("Failed to write file: %1").arg(targetPath));
return;
}
QTextStream stream(&file);
for (const QString& line : visibleLines) {
stream << line << '\n';
}
file.close();
}
void SessionTab::onBackendStateChanged(SessionState state, const QString& message) void SessionTab::onBackendStateChanged(SessionState state, const QString& message)
{ {
setState(state, message); setState(state, message);
@@ -408,13 +662,46 @@ void SessionTab::onBackendHostKeyConfirmationRequested(const QString& prompt)
emit requestHostKeyConfirmation(reply == QMessageBox::Yes); 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() void SessionTab::setupUi()
{ {
auto* rootLayout = new QVBoxLayout(this); auto* rootLayout = new QVBoxLayout(this);
if (m_useKodoTermForSsh) { if (m_useKodoTermForSsh) {
m_sshTerminal = new KodoTerm(this); 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(); KodoTermConfig config = m_sshTerminal->getConfig();
config.font = terminalFont; config.font = terminalFont;
@@ -427,7 +714,11 @@ void SessionTab::setupUi()
rootLayout->addWidget(m_rdpDisplay, 1); rootLayout->addWidget(m_rdpDisplay, 1);
} else { } else {
m_terminalOutput = new TerminalView(this); 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->setMinimumHeight(260);
m_terminalOutput->setReadOnly(true); m_terminalOutput->setReadOnly(true);
if (m_profile.protocol.compare(QStringLiteral("VNC"), Qt::CaseInsensitive) == 0) { if (m_profile.protocol.compare(QStringLiteral("VNC"), Qt::CaseInsensitive) == 0) {
@@ -442,10 +733,63 @@ void SessionTab::setupUi()
applyTerminalTheme(m_terminalThemeName); 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(); auto* eventsHeader = new QHBoxLayout();
m_toggleEventsButton = new QToolButton(this); m_toggleEventsButton = new QToolButton(this);
m_toggleEventsButton->setCheckable(true); m_toggleEventsButton->setCheckable(true);
m_eventFilterInput = new QLineEdit(this);
m_eventFilterInput->setPlaceholderText(QStringLiteral("Filter events..."));
m_eventSeverityFilterInput = new QComboBox(this);
m_eventSeverityFilterInput->addItem(QStringLiteral("All"));
m_eventSeverityFilterInput->addItem(QStringLiteral("Warnings"));
m_eventSeverityFilterInput->addItem(QStringLiteral("Errors"));
m_clearEventsButton = new QToolButton(this);
m_clearEventsButton->setText(QStringLiteral("Clear Events"));
m_exportEventsButton = new QToolButton(this);
m_exportEventsButton->setText(QStringLiteral("Export Events"));
eventsHeader->addWidget(m_toggleEventsButton); eventsHeader->addWidget(m_toggleEventsButton);
eventsHeader->addWidget(m_eventFilterInput, 1);
eventsHeader->addWidget(m_eventSeverityFilterInput);
eventsHeader->addWidget(m_exportEventsButton);
eventsHeader->addWidget(m_clearEventsButton);
eventsHeader->addStretch(); eventsHeader->addStretch();
m_eventsPanel = new QWidget(this); m_eventsPanel = new QWidget(this);
@@ -464,15 +808,43 @@ void SessionTab::setupUi()
rootLayout->addLayout(eventsHeader); rootLayout->addLayout(eventsHeader);
rootLayout->addWidget(m_eventsPanel); rootLayout->addWidget(m_eventsPanel);
setPanelExpanded(m_toggleEventsButton, m_eventsPanel, QStringLiteral("Events"), false); setPanelExpanded(
m_toggleEventsButton, m_eventsPanel, QStringLiteral("Events"), m_eventsPanelExpanded);
connect(m_toggleEventsButton, connect(m_toggleEventsButton,
&QToolButton::toggled, &QToolButton::toggled,
this, this,
[this](bool expanded) { [this](bool expanded) {
setPanelExpanded( setEventsPanelExpanded(expanded);
m_toggleEventsButton, m_eventsPanel, QStringLiteral("Events"), expanded);
}); });
connect(m_eventFilterInput,
&QLineEdit::textChanged,
this,
[this](const QString& text) {
m_eventFilter = text.trimmed();
refreshEventLogView();
});
connect(m_eventSeverityFilterInput,
&QComboBox::currentTextChanged,
this,
[this](const QString& selected) {
if (selected.compare(QStringLiteral("Errors"), Qt::CaseInsensitive) == 0) {
m_eventSeverityFilter = EventSeverity::Error;
} else if (selected.compare(QStringLiteral("Warnings"), Qt::CaseInsensitive) == 0) {
m_eventSeverityFilter = EventSeverity::Warning;
} else {
m_eventSeverityFilter = EventSeverity::Info;
}
refreshEventLogView();
});
connect(m_exportEventsButton,
&QToolButton::clicked,
this,
[this]() { exportEventsToFile(); });
connect(m_clearEventsButton,
&QToolButton::clicked,
this,
[this]() { clearEvents(); });
if (m_terminalOutput != nullptr) { if (m_terminalOutput != nullptr) {
connect(m_terminalOutput, connect(m_terminalOutput,
@@ -513,77 +885,85 @@ void SessionTab::setupUi()
} }
} }
std::optional<SessionConnectOptions> SessionTab::buildConnectOptions() void SessionTab::requestConnectOptions(
std::function<void(std::optional<SessionConnectOptions>)> callback)
{ {
SessionConnectOptions options; SessionConnectOptions baseOptions;
options.knownHostsPolicy = m_profile.knownHostsPolicy; baseOptions.knownHostsPolicy = m_profile.knownHostsPolicy;
const bool isSsh = m_profile.protocol.compare(QStringLiteral("SSH"), Qt::CaseInsensitive) == 0; const bool isSsh = m_profile.protocol.compare(QStringLiteral("SSH"), Qt::CaseInsensitive) == 0;
const bool isRdp = m_profile.protocol.compare(QStringLiteral("RDP"), Qt::CaseInsensitive) == 0; const bool isRdp = m_profile.protocol.compare(QStringLiteral("RDP"), Qt::CaseInsensitive) == 0;
if (!isSsh && !isRdp) { if (!isSsh && !isRdp) {
return options; callback(baseOptions);
return;
} }
if (isRdp) { if (isRdp) {
if (m_profile.authMode.compare(QStringLiteral("Password"), Qt::CaseInsensitive) != 0) { if (m_profile.authMode.compare(QStringLiteral("Password"), Qt::CaseInsensitive) != 0) {
return options; callback(baseOptions);
return;
} }
bool accepted = false; const QString label = QStringLiteral("RDP password for %1:")
const QString password = QInputDialog::getText( .arg(m_profile.username.trimmed().isEmpty()
this, ? m_profile.host
QStringLiteral("RDP Password"), : QStringLiteral("%1@%2").arg(m_profile.username, m_profile.host));
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;
}
if (password.isEmpty()) { showPasswordPrompt(
QMessageBox::warning(this, label,
QStringLiteral("Connect"), [this, baseOptions, callback](std::optional<QString> password) {
QStringLiteral("Password is required for password authentication.")); if (!password.has_value()) {
return std::nullopt; callback(std::nullopt);
} return;
}
options.password = password; if (password->isEmpty()) {
return options; 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 if (m_useKodoTermForSsh
&& m_profile.authMode.compare(QStringLiteral("Password"), Qt::CaseInsensitive) == 0) { && m_profile.authMode.compare(QStringLiteral("Password"), Qt::CaseInsensitive) == 0) {
// Password is entered directly in terminal prompt. // Password is entered directly in terminal prompt.
return options; callback(baseOptions);
return;
} }
if (m_profile.authMode.compare(QStringLiteral("Password"), Qt::CaseInsensitive) == 0) { if (m_profile.authMode.compare(QStringLiteral("Password"), Qt::CaseInsensitive) == 0) {
bool accepted = false; showPasswordPrompt(
const QString password = QInputDialog::getText(this, QStringLiteral("SSH password for %1@%2:").arg(m_profile.username, m_profile.host),
QStringLiteral("SSH Password"), [this, baseOptions, callback](std::optional<QString> password) {
QStringLiteral("Password for %1@%2:") if (!password.has_value()) {
.arg(m_profile.username, m_profile.host), callback(std::nullopt);
QLineEdit::Password, return;
QString(), }
&accepted);
if (!accepted) {
return std::nullopt;
}
if (password.isEmpty()) { if (password->isEmpty()) {
QMessageBox::warning(this, QMessageBox::warning(
QStringLiteral("Connect"), this,
QStringLiteral("Password is required for password authentication.")); QStringLiteral("Connect"),
return std::nullopt; QStringLiteral("Password is required for password authentication."));
} callback(std::nullopt);
return;
}
options.password = password; SessionConnectOptions options = baseOptions;
return options; options.password = password.value();
callback(options);
});
return;
} }
QString keyPath = m_profile.privateKeyPath.trimmed(); QString keyPath = m_profile.privateKeyPath.trimmed();
@@ -593,7 +973,8 @@ std::optional<SessionConnectOptions> SessionTab::buildConnectOptions()
QString(), QString(),
QStringLiteral("All Files (*)")); QStringLiteral("All Files (*)"));
if (keyPath.isEmpty()) { if (keyPath.isEmpty()) {
return std::nullopt; callback(std::nullopt);
return;
} }
} }
@@ -601,11 +982,35 @@ std::optional<SessionConnectOptions> SessionTab::buildConnectOptions()
QMessageBox::warning(this, QMessageBox::warning(this,
QStringLiteral("Connect"), QStringLiteral("Connect"),
QStringLiteral("Private key file not found: %1").arg(keyPath)); QStringLiteral("Private key file not found: %1").arg(keyPath));
return std::nullopt; callback(std::nullopt);
return;
} }
SessionConnectOptions options = baseOptions;
options.privateKeyPath = keyPath; 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() bool SessionTab::validateProfileForConnect()
@@ -639,7 +1044,14 @@ bool SessionTab::validateProfileForConnect()
void SessionTab::appendEvent(const QString& message) void SessionTab::appendEvent(const QString& message)
{ {
const QString timestamp = QDateTime::currentDateTime().toString(QStringLiteral("yyyy-MM-dd HH:mm:ss")); const QString timestamp = QDateTime::currentDateTime().toString(QStringLiteral("yyyy-MM-dd HH:mm:ss"));
m_eventLog->appendPlainText(QStringLiteral("[%1] %2").arg(timestamp, message)); m_eventEntries.push_back(
EventEntry{QStringLiteral("[%1] %2").arg(timestamp, message),
classifyEventSeverity(message)});
constexpr int kMaxEventLines = 5000;
while (m_eventEntries.size() > static_cast<size_t>(kMaxEventLines)) {
m_eventEntries.erase(m_eventEntries.begin());
}
refreshEventLogView();
} }
void SessionTab::setState(SessionState state, const QString& message) void SessionTab::setState(SessionState state, const QString& message)
@@ -803,3 +1215,51 @@ void SessionTab::applyTerminalTheme(const QString& themeName)
m_terminalOutput->setThemeName(themeName); m_terminalOutput->setThemeName(themeName);
} }
} }
void SessionTab::refreshEventLogView()
{
if (m_eventLog == nullptr) {
return;
}
QStringList visibleLines;
visibleLines.reserve(static_cast<int>(m_eventEntries.size()));
for (const EventEntry& entry : m_eventEntries) {
if (!m_eventFilter.isEmpty() && !entry.line.contains(m_eventFilter, Qt::CaseInsensitive)) {
continue;
}
if (m_eventSeverityFilter == EventSeverity::Warning
&& entry.severity != EventSeverity::Warning) {
continue;
}
if (m_eventSeverityFilter == EventSeverity::Error
&& entry.severity != EventSeverity::Error) {
continue;
}
visibleLines.push_back(entry.line);
}
m_eventLog->setPlainText(visibleLines.join(QChar::fromLatin1('\n')));
m_eventLog->moveCursor(QTextCursor::End);
}
SessionTab::EventSeverity SessionTab::classifyEventSeverity(const QString& message)
{
const QString normalized = message.trimmed().toLower();
if (normalized.startsWith(QStringLiteral("error:"))
|| normalized.contains(QStringLiteral("failed"))
|| normalized.contains(QStringLiteral("permission denied"))) {
return EventSeverity::Error;
}
if (normalized.startsWith(QStringLiteral("warning:"))
|| normalized.contains(QStringLiteral("warning"))) {
return EventSeverity::Warning;
}
return EventSeverity::Info;
}
+66 -2
View File
@@ -5,9 +5,12 @@
#include "session_backend.h" #include "session_backend.h"
#include <QWidget> #include <QWidget>
#include <QStringList>
#include <QtGlobal> #include <QtGlobal>
#include <functional>
#include <optional> #include <optional>
#include <vector>
class QPlainTextEdit; class QPlainTextEdit;
class QThread; class QThread;
@@ -15,14 +18,27 @@ class SessionBackend;
class TerminalView; class TerminalView;
class RdpDisplayWidget; class RdpDisplayWidget;
class QToolButton; class QToolButton;
class QLineEdit;
class QComboBox;
class QLabel;
class QPushButton;
class KodoTerm; class KodoTerm;
struct SessionUiPreferences
{
QString terminalThemeName = QStringLiteral("Dark");
bool eventsPanelExpanded = false;
int terminalFontPointSize = 0;
};
class SessionTab : public QWidget class SessionTab : public QWidget
{ {
Q_OBJECT Q_OBJECT
public: public:
explicit SessionTab(const Profile& profile, QWidget* parent = nullptr); explicit SessionTab(const Profile& profile,
const SessionUiPreferences& preferences,
QWidget* parent = nullptr);
~SessionTab() override; ~SessionTab() override;
QString tabTitle() const; QString tabTitle() const;
@@ -34,10 +50,24 @@ public:
QString terminalThemeName() const; QString terminalThemeName() const;
bool supportsThemeSelection() const; bool supportsThemeSelection() const;
bool supportsClearAction() 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();
void copyEvents() const;
void exportEventsToFile();
signals: signals:
void tabTitleChanged(const QString& title); void tabTitleChanged(const QString& title);
void tabStateChanged(SessionState state); void tabStateChanged(SessionState state);
void terminalThemeChanged(const QString& themeName);
void terminalFontSizeChanged(int pointSize);
void eventsPanelVisibilityChanged(bool expanded);
void requestConnect(const SessionConnectOptions& options); void requestConnect(const SessionConnectOptions& options);
void requestDisconnect(); void requestDisconnect();
void requestReconnect(const SessionConnectOptions& options); void requestReconnect(const SessionConnectOptions& options);
@@ -52,6 +82,7 @@ signals:
void requestMouseMoveEvent(int x, int y); void requestMouseMoveEvent(int x, int y);
void requestMouseButtonEvent(int x, int y, int button, bool pressed); void requestMouseButtonEvent(int x, int y, int button, bool pressed);
void requestMouseWheelEvent(int x, int y, int deltaX, int deltaY); void requestMouseWheelEvent(int x, int y, int deltaX, int deltaY);
void requestSetClipboardText(const QString& text);
private slots: private slots:
void onBackendStateChanged(SessionState state, const QString& message); void onBackendStateChanged(SessionState state, const QString& message);
@@ -59,6 +90,8 @@ private slots:
void onBackendConnectionError(const QString& displayMessage, const QString& rawMessage); void onBackendConnectionError(const QString& displayMessage, const QString& rawMessage);
void onBackendOutputReceived(const QString& text); void onBackendOutputReceived(const QString& text);
void onBackendHostKeyConfirmationRequested(const QString& prompt); void onBackendHostKeyConfirmationRequested(const QString& prompt);
void onBackendRemoteClipboardTextChanged(const QString& text);
void onSystemClipboardChanged();
private: private:
Profile m_profile; Profile m_profile;
@@ -69,16 +102,45 @@ private:
QString m_lastError; QString m_lastError;
SessionConnectOptions m_lastConnectOptions; SessionConnectOptions m_lastConnectOptions;
QString m_terminalThemeName; QString m_terminalThemeName;
int m_terminalFontPointSize;
QString m_lastSyncedClipboardText;
bool m_clipboardSyncSupported;
KodoTerm* m_sshTerminal; KodoTerm* m_sshTerminal;
RdpDisplayWidget* m_rdpDisplay; RdpDisplayWidget* m_rdpDisplay;
TerminalView* m_terminalOutput; TerminalView* m_terminalOutput;
QPlainTextEdit* m_eventLog; QPlainTextEdit* m_eventLog;
QToolButton* m_toggleEventsButton; QToolButton* m_toggleEventsButton;
QLineEdit* m_eventFilterInput;
QComboBox* m_eventSeverityFilterInput;
QToolButton* m_clearEventsButton;
QToolButton* m_exportEventsButton;
QWidget* m_eventsPanel; 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,
Error,
};
struct EventEntry {
QString line;
EventSeverity severity;
};
std::vector<EventEntry> m_eventEntries;
QString m_eventFilter;
EventSeverity m_eventSeverityFilter;
bool m_eventsPanelExpanded;
void setupUi(); 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(); bool validateProfileForConnect();
void appendEvent(const QString& message); void appendEvent(const QString& message);
void setState(SessionState state, const QString& message); void setState(SessionState state, const QString& message);
@@ -87,6 +149,8 @@ private:
void setPanelExpanded(QToolButton* button, QWidget* panel, const QString& name, bool expanded); void setPanelExpanded(QToolButton* button, QWidget* panel, const QString& name, bool expanded);
bool startSshTerminal(const SessionConnectOptions& options); bool startSshTerminal(const SessionConnectOptions& options);
void applyTerminalTheme(const QString& themeName); void applyTerminalTheme(const QString& themeName);
void refreshEventLogView();
static EventSeverity classifyEventSeverity(const QString& message);
}; };
#endif #endif
+158 -8
View File
@@ -1,11 +1,17 @@
#include "session_window.h" #include "session_window.h"
#include "about_dialog.h"
#include "profiles_window.h"
#include <QApplication>
#include "session_tab.h" #include "session_tab.h"
#include <QAction> #include <QAction>
#include <QColor> #include <QColor>
#include <QInputDialog>
#include <QMenu> #include <QMenu>
#include <QMenuBar>
#include <QPalette> #include <QPalette>
#include <QSettings>
#include <QStringList> #include <QStringList>
#include <QTabBar> #include <QTabBar>
#include <QTabWidget> #include <QTabWidget>
@@ -33,11 +39,14 @@ QStringList terminalThemeNames()
} }
} }
SessionWindow::SessionWindow(const Profile& profile, QWidget* parent) SessionWindow::SessionWindow(QWidget* parent)
: QMainWindow(parent), m_tabs(new QTabWidget(this)) : QMainWindow(parent), m_tabs(new QTabWidget(this)), m_profilesWidget(nullptr)
{ {
setWindowTitle(QStringLiteral("OrbitHub Session - %1").arg(profile.name)); loadUiPreferences();
setWindowTitle(QStringLiteral("OrbitHub"));
resize(1080, 760); resize(1080, 760);
setWindowIcon(QApplication::windowIcon());
m_tabs->setTabsClosable(true); m_tabs->setTabsClosable(true);
connect(m_tabs, connect(m_tabs,
@@ -50,8 +59,8 @@ SessionWindow::SessionWindow(const Profile& profile, QWidget* parent)
} }
m_tabs->removeTab(index); m_tabs->removeTab(index);
delete tab; delete tab;
if (m_tabs->count() == 0) { if (sessionTabCount() == 0) {
close(); setWindowTitle(QStringLiteral("OrbitHub"));
} }
}); });
m_tabs->tabBar()->setContextMenuPolicy(Qt::CustomContextMenu); m_tabs->tabBar()->setContextMenuPolicy(Qt::CustomContextMenu);
@@ -72,6 +81,12 @@ SessionWindow::SessionWindow(const Profile& profile, QWidget* parent)
QMenu menu(this); QMenu menu(this);
QAction* disconnectAction = menu.addAction(QStringLiteral("Disconnect")); QAction* disconnectAction = menu.addAction(QStringLiteral("Disconnect"));
QAction* reconnectAction = menu.addAction(QStringLiteral("Reconnect")); QAction* reconnectAction = menu.addAction(QStringLiteral("Reconnect"));
QAction* toggleEventsAction = menu.addAction(
tab->isEventsPanelExpanded() ? QStringLiteral("Hide Events")
: QStringLiteral("Show Events"));
QAction* copyEventsAction = menu.addAction(QStringLiteral("Copy Events"));
QAction* exportEventsAction = menu.addAction(QStringLiteral("Export Events"));
QAction* clearEventsAction = menu.addAction(QStringLiteral("Clear Events"));
QList<QAction*> themeActions; QList<QAction*> themeActions;
if (tab->supportsThemeSelection()) { if (tab->supportsThemeSelection()) {
@@ -92,13 +107,54 @@ SessionWindow::SessionWindow(const Profile& profile, QWidget* parent)
clearAction = menu.addAction(QStringLiteral("Clear")); 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)); QAction* chosen = menu.exec(m_tabs->tabBar()->mapToGlobal(pos));
if (chosen == disconnectAction) { if (chosen == disconnectAction) {
tab->disconnectSession(); tab->disconnectSession();
} else if (chosen == reconnectAction) { } else if (chosen == reconnectAction) {
tab->reconnectSession(); tab->reconnectSession();
} else if (chosen == toggleEventsAction) {
tab->setEventsPanelExpanded(!tab->isEventsPanelExpanded());
} else if (chosen == copyEventsAction) {
tab->copyEvents();
} else if (chosen == exportEventsAction) {
tab->exportEventsToFile();
} else if (chosen == clearEventsAction) {
tab->clearEvents();
} else if (clearAction != nullptr && chosen == clearAction) { } else if (clearAction != nullptr && chosen == clearAction) {
tab->clearTerminal(); 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 { } else {
for (QAction* themeAction : themeActions) { for (QAction* themeAction : themeActions) {
if (chosen == themeAction) { if (chosen == themeAction) {
@@ -109,8 +165,42 @@ 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* aboutAction = helpMenu->addAction(QStringLiteral("About OrbitHub"));
connect(aboutAction,
&QAction::triggered,
this,
[this]() {
AboutDialog dialog(this);
dialog.exec();
});
setCentralWidget(m_tabs); 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) void SessionWindow::openProfile(const Profile& profile)
@@ -120,11 +210,13 @@ void SessionWindow::openProfile(const Profile& profile)
void SessionWindow::addSessionTab(const Profile& profile) void SessionWindow::addSessionTab(const Profile& profile)
{ {
auto* tab = new SessionTab(profile, this); auto* tab = new SessionTab(profile, m_preferences, this);
const int index = m_tabs->addTab(tab, tab->tabTitle()); const int index = m_tabs->addTab(tab, tab->tabTitle());
m_tabs->setCurrentIndex(index); m_tabs->setCurrentIndex(index);
if (m_tabs->count() > 1) { if (sessionTabCount() > 1) {
setWindowTitle(QStringLiteral("OrbitHub Sessions")); setWindowTitle(QStringLiteral("OrbitHub Sessions"));
} else {
setWindowTitle(QStringLiteral("OrbitHub Session - %1").arg(profile.name));
} }
m_tabs->tabBar()->setTabTextColor( m_tabs->tabBar()->setTabTextColor(
index, tabColorForState(SessionState::Disconnected, m_tabs->palette())); index, tabColorForState(SessionState::Disconnected, m_tabs->palette()));
@@ -145,6 +237,32 @@ void SessionWindow::addSessionTab(const Profile& profile)
} }
} }
}); });
connect(tab,
&SessionTab::terminalThemeChanged,
this,
[this](const QString& themeName) {
m_preferences.terminalThemeName = themeName.trimmed().isEmpty()
? QStringLiteral("Dark")
: 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,
[this](bool expanded) {
m_preferences.eventsPanelExpanded = expanded;
saveUiPreferences();
});
} }
void SessionWindow::updateTabTitle(SessionTab* tab, const QString& title) void SessionWindow::updateTabTitle(SessionTab* tab, const QString& title)
@@ -156,3 +274,35 @@ void SessionWindow::updateTabTitle(SessionTab* tab, const QString& title)
} }
} }
} }
int SessionWindow::sessionTabCount() const
{
return m_tabs->count() - 1;
}
void SessionWindow::loadUiPreferences()
{
QSettings settings;
m_preferences.terminalThemeName =
settings.value(QStringLiteral("session/defaultTerminalTheme"), QStringLiteral("Dark"))
.toString()
.trimmed();
if (m_preferences.terminalThemeName.isEmpty()) {
m_preferences.terminalThemeName = QStringLiteral("Dark");
}
m_preferences.eventsPanelExpanded =
settings.value(QStringLiteral("session/eventsPanelExpanded"), false).toBool();
m_preferences.terminalFontPointSize =
settings.value(QStringLiteral("session/terminalFontPointSize"), 0).toInt();
}
void SessionWindow::saveUiPreferences() const
{
QSettings settings;
settings.setValue(QStringLiteral("session/defaultTerminalTheme"),
m_preferences.terminalThemeName);
settings.setValue(QStringLiteral("session/eventsPanelExpanded"),
m_preferences.eventsPanelExpanded);
settings.setValue(QStringLiteral("session/terminalFontPointSize"),
m_preferences.terminalFontPointSize);
}
+8 -2
View File
@@ -2,25 +2,31 @@
#define ORBITHUB_SESSION_WINDOW_H #define ORBITHUB_SESSION_WINDOW_H
#include "profile_repository.h" #include "profile_repository.h"
#include "session_tab.h"
#include <QMainWindow> #include <QMainWindow>
class QTabWidget; class QTabWidget;
class SessionTab; class ProfilesWindow;
class SessionWindow : public QMainWindow class SessionWindow : public QMainWindow
{ {
Q_OBJECT Q_OBJECT
public: public:
explicit SessionWindow(const Profile& profile, QWidget* parent = nullptr); explicit SessionWindow(QWidget* parent = nullptr);
void openProfile(const Profile& profile); void openProfile(const Profile& profile);
private: private:
QTabWidget* m_tabs; QTabWidget* m_tabs;
ProfilesWindow* m_profilesWidget;
SessionUiPreferences m_preferences;
void addSessionTab(const Profile& profile); void addSessionTab(const Profile& profile);
void updateTabTitle(SessionTab* tab, const QString& title); void updateTabTitle(SessionTab* tab, const QString& title);
void loadUiPreferences();
void saveUiPreferences() const;
int sessionTabCount() const;
}; };
#endif #endif
+16
View File
@@ -52,6 +52,14 @@ void TerminalView::setThemeName(const QString& themeName)
applyThemePalette(paletteByName(themeName)); applyThemePalette(paletteByName(themeName));
} }
void TerminalView::setFontPointSize(int pointSize)
{
QFont updatedFont = font();
updatedFont.setPointSize(pointSize);
setFont(updatedFont);
emitTerminalSize();
}
void TerminalView::appendTerminalData(const QString& data) void TerminalView::appendTerminalData(const QString& data)
{ {
if (data.isEmpty()) { if (data.isEmpty()) {
@@ -211,6 +219,14 @@ void TerminalView::focusInEvent(QFocusEvent* event)
moveCursor(QTextCursor::End); 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) void TerminalView::resizeEvent(QResizeEvent* event)
{ {
QTextEdit::resizeEvent(event); QTextEdit::resizeEvent(event);
+2
View File
@@ -19,6 +19,7 @@ public:
static QStringList themeNames(); static QStringList themeNames();
void setThemeName(const QString& themeName); void setThemeName(const QString& themeName);
void appendTerminalData(const QString& data); void appendTerminalData(const QString& data);
void setFontPointSize(int pointSize);
signals: signals:
void inputGenerated(const QString& input); void inputGenerated(const QString& input);
@@ -28,6 +29,7 @@ protected:
void keyPressEvent(QKeyEvent* event) override; void keyPressEvent(QKeyEvent* event) override;
void focusInEvent(QFocusEvent* event) override; void focusInEvent(QFocusEvent* event) override;
void resizeEvent(QResizeEvent* event) override; void resizeEvent(QResizeEvent* event) override;
bool focusNextPrevChild(bool next) override;
private: private:
struct ThemePalette { struct ThemePalette {