Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c4a62e8fb6 | ||
|
|
9a22597d4e | ||
|
|
e90e9b5abf | ||
|
|
92d8b62820 | ||
|
|
7ee930693e | ||
|
|
8e98c208c9 | ||
|
|
ce1e40a12d | ||
|
|
80bc50e54c | ||
|
|
1c66adb646 | ||
|
|
4c649f727f | ||
|
|
18f234105d | ||
|
|
186480dcf5 | ||
|
|
dd974c684a | ||
|
|
8c56d489af | ||
|
|
d7910f1631 | ||
|
|
68214db744 | ||
|
|
6c8146e79c | ||
|
|
aa812b0da7 | ||
|
|
3840ea9f62 | ||
|
|
7ce7260305 | ||
|
|
48adcf33ee | ||
|
|
21e7a39c53 | ||
|
|
f126f4b55c | ||
|
|
4daf5cfc65 | ||
|
|
0990049241 | ||
|
|
6d5133e1bd | ||
|
|
9d88b74c04 | ||
|
|
49ee12f5a1 | ||
|
|
ac3cebc9ae | ||
|
|
8f83b0c8d9 | ||
|
|
ac1deac148 | ||
|
|
b28adf8cbe | ||
|
|
317df68d1f | ||
|
|
fce69b8be9 | ||
|
|
f3ea7f12a3 | ||
|
|
30f0134748 | ||
|
|
a89748be67 | ||
|
|
4b4b7d68f2 | ||
|
|
793fdd9366 | ||
|
|
27cc3a3bb2 | ||
|
|
c1c23d115a | ||
|
|
f54c2e9bcd | ||
|
|
ae9928782d | ||
|
|
2485ffb14f | ||
|
|
eadcdd7f10 | ||
|
|
36006bd4aa | ||
|
|
230a401386 | ||
|
|
e77f2598b9 | ||
|
|
2b25f805cd | ||
|
|
776ddc1a53 | ||
|
|
c3369b8e48 | ||
|
|
20ee48db32 | ||
|
|
2b4f498259 | ||
|
|
614d31fa71 | ||
|
|
ceed19d517 | ||
|
|
2ea712db36 |
@@ -1 +1,5 @@
|
|||||||
/build/
|
/build/
|
||||||
|
/dist/
|
||||||
|
/.flatpak-builder/
|
||||||
|
/build-doc-tool/
|
||||||
|
/docs/USER_GUIDE.pdf
|
||||||
|
|||||||
@@ -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.14 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,16 +10,89 @@ 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()
|
||||||
|
|
||||||
add_executable(orbithub
|
add_subdirectory(third_party/KodoTerm)
|
||||||
|
|
||||||
|
if(NOT EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/third_party/FreeRDP/CMakeLists.txt")
|
||||||
|
message(FATAL_ERROR "Vendored FreeRDP source is missing at third_party/FreeRDP")
|
||||||
|
endif()
|
||||||
|
|
||||||
|
# Pin FreeRDP build to the vendored source tree so headers/libs are always from the same revision.
|
||||||
|
set(FREERDP_UNIFIED_BUILD ON CACHE BOOL "" FORCE)
|
||||||
|
set(WITH_MANPAGES OFF CACHE BOOL "" FORCE)
|
||||||
|
set(WITH_SAMPLE OFF CACHE BOOL "" FORCE)
|
||||||
|
set(BUILD_TESTING OFF CACHE BOOL "" FORCE)
|
||||||
|
set(BUILD_TESTING_INTERNAL OFF CACHE BOOL "" FORCE)
|
||||||
|
set(WITH_CLIENT_COMMON ON CACHE BOOL "" FORCE)
|
||||||
|
set(WITH_CLIENT OFF CACHE BOOL "" FORCE)
|
||||||
|
set(WITH_CLIENT_SDL OFF CACHE BOOL "" FORCE)
|
||||||
|
set(WITH_CLIENT_INTERFACE OFF CACHE BOOL "" FORCE)
|
||||||
|
set(WITH_SERVER OFF CACHE BOOL "" FORCE)
|
||||||
|
set(WITH_CHANNELS ON CACHE BOOL "" FORCE)
|
||||||
|
set(WITH_CLIENT_CHANNELS ON CACHE BOOL "" FORCE)
|
||||||
|
set(WITH_FUSE OFF CACHE BOOL "" FORCE)
|
||||||
|
set(CHANNEL_DRDYNVC ON CACHE BOOL "" FORCE)
|
||||||
|
set(CHANNEL_DRDYNVC_CLIENT ON CACHE BOOL "" FORCE)
|
||||||
|
set(CHANNEL_DISP ON CACHE BOOL "" FORCE)
|
||||||
|
set(CHANNEL_DISP_CLIENT ON CACHE BOOL "" FORCE)
|
||||||
|
set(CHANNEL_AINPUT OFF CACHE BOOL "" FORCE)
|
||||||
|
set(CHANNEL_AUDIN OFF CACHE BOOL "" FORCE)
|
||||||
|
set(CHANNEL_CLIPRDR ON CACHE BOOL "" FORCE)
|
||||||
|
set(CHANNEL_CLIPRDR_CLIENT ON CACHE BOOL "" FORCE)
|
||||||
|
set(CHANNEL_DRIVE OFF CACHE BOOL "" FORCE)
|
||||||
|
set(CHANNEL_ECHO OFF CACHE BOOL "" FORCE)
|
||||||
|
set(CHANNEL_ENCOMSP OFF CACHE BOOL "" FORCE)
|
||||||
|
set(CHANNEL_GEOMETRY OFF CACHE BOOL "" FORCE)
|
||||||
|
set(CHANNEL_LOCATION OFF CACHE BOOL "" FORCE)
|
||||||
|
set(CHANNEL_PARALLEL OFF CACHE BOOL "" FORCE)
|
||||||
|
set(CHANNEL_PRINTER OFF CACHE BOOL "" FORCE)
|
||||||
|
set(CHANNEL_RAIL OFF CACHE BOOL "" FORCE)
|
||||||
|
set(CHANNEL_RDPDR OFF CACHE BOOL "" FORCE)
|
||||||
|
set(CHANNEL_RDPEAR OFF CACHE BOOL "" FORCE)
|
||||||
|
set(CHANNEL_RDPECAM OFF CACHE BOOL "" FORCE)
|
||||||
|
set(CHANNEL_RDPEI OFF CACHE BOOL "" FORCE)
|
||||||
|
set(CHANNEL_RDPEMSC OFF CACHE BOOL "" FORCE)
|
||||||
|
set(CHANNEL_RDPGFX OFF CACHE BOOL "" FORCE)
|
||||||
|
set(CHANNEL_RDPSND OFF CACHE BOOL "" FORCE)
|
||||||
|
set(CHANNEL_REMDESK OFF CACHE BOOL "" FORCE)
|
||||||
|
set(CHANNEL_SERIAL OFF CACHE BOOL "" FORCE)
|
||||||
|
set(CHANNEL_SMARTCARD OFF CACHE BOOL "" FORCE)
|
||||||
|
set(CHANNEL_SSHAGENT OFF CACHE BOOL "" FORCE)
|
||||||
|
set(CHANNEL_TELEMETRY OFF CACHE BOOL "" FORCE)
|
||||||
|
set(CHANNEL_URBDRC OFF CACHE BOOL "" FORCE)
|
||||||
|
set(CHANNEL_VIDEO OFF CACHE BOOL "" FORCE)
|
||||||
|
set(WITH_FFMPEG OFF CACHE BOOL "" FORCE)
|
||||||
|
set(WITH_DSP_FFMPEG OFF CACHE BOOL "" FORCE)
|
||||||
|
set(WITH_VIDEO_FFMPEG OFF CACHE BOOL "" FORCE)
|
||||||
|
set(WITH_CAIRO OFF CACHE BOOL "" FORCE)
|
||||||
|
set(WITH_SWSCALE OFF CACHE BOOL "" FORCE)
|
||||||
|
set(WITH_JPEG OFF CACHE BOOL "" FORCE)
|
||||||
|
set(WITH_KRB5 OFF CACHE BOOL "" FORCE)
|
||||||
|
set(WITH_UNICODE_BUILTIN ON CACHE BOOL "" FORCE)
|
||||||
|
set(WITH_WINPR_TOOLS OFF CACHE BOOL "" FORCE)
|
||||||
|
|
||||||
|
add_subdirectory(third_party/FreeRDP EXCLUDE_FROM_ALL)
|
||||||
|
|
||||||
|
set(ORBITHUB_SOURCES
|
||||||
|
src/about_dialog.cpp
|
||||||
|
src/about_dialog.h
|
||||||
|
src/user_guide_dialog.cpp
|
||||||
|
src/user_guide_dialog.h
|
||||||
|
docs/user_guide.qrc
|
||||||
|
src/app_icon.cpp
|
||||||
|
src/app_icon.h
|
||||||
src/main.cpp
|
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
|
||||||
@@ -27,14 +100,175 @@ add_executable(orbithub
|
|||||||
src/session_backend_factory.h
|
src/session_backend_factory.h
|
||||||
src/session_tab.cpp
|
src/session_tab.cpp
|
||||||
src/session_tab.h
|
src/session_tab.h
|
||||||
|
src/rdp_display_widget.cpp
|
||||||
|
src/rdp_display_widget.h
|
||||||
|
src/terminal_view.cpp
|
||||||
|
src/terminal_view.h
|
||||||
src/session_window.cpp
|
src/session_window.cpp
|
||||||
src/session_window.h
|
src/session_window.h
|
||||||
|
src/rdp_session_backend.cpp
|
||||||
|
src/rdp_session_backend.h
|
||||||
src/ssh_session_backend.cpp
|
src/ssh_session_backend.cpp
|
||||||
src/ssh_session_backend.h
|
src/ssh_session_backend.h
|
||||||
src/unsupported_session_backend.cpp
|
src/unsupported_session_backend.cpp
|
||||||
src/unsupported_session_backend.h
|
src/unsupported_session_backend.h
|
||||||
)
|
)
|
||||||
|
|
||||||
target_link_libraries(orbithub PRIVATE Qt6::Widgets Qt6::Sql)
|
if(WIN32)
|
||||||
|
list(APPEND ORBITHUB_SOURCES packaging/windows/orbithub.rc)
|
||||||
|
endif()
|
||||||
|
|
||||||
install(TARGETS orbithub RUNTIME DESTINATION bin)
|
if(APPLE)
|
||||||
|
list(APPEND ORBITHUB_SOURCES packaging/macos/orbithub.icns)
|
||||||
|
set_source_files_properties(packaging/macos/orbithub.icns PROPERTIES
|
||||||
|
MACOSX_PACKAGE_LOCATION "Resources"
|
||||||
|
)
|
||||||
|
set(MACOSX_BUNDLE_ICON_FILE orbithub.icns)
|
||||||
|
set(MACOSX_BUNDLE_BUNDLE_NAME "OrbitHub")
|
||||||
|
set(MACOSX_BUNDLE_GUI_IDENTIFIER "org.darksingularity.OrbitHub")
|
||||||
|
set(MACOSX_BUNDLE_BUNDLE_VERSION "${PROJECT_VERSION}")
|
||||||
|
set(MACOSX_BUNDLE_SHORT_VERSION_STRING "${PROJECT_VERSION}")
|
||||||
|
endif()
|
||||||
|
|
||||||
|
add_executable(orbithub WIN32 MACOSX_BUNDLE ${ORBITHUB_SOURCES})
|
||||||
|
|
||||||
|
target_link_libraries(orbithub PRIVATE Qt6::Widgets Qt6::Sql)
|
||||||
|
target_link_libraries(orbithub PRIVATE KodoTerm::KodoTerm)
|
||||||
|
target_compile_definitions(orbithub PRIVATE ORBITHUB_VERSION_STRING="${PROJECT_VERSION}")
|
||||||
|
if(TARGET freerdp AND TARGET winpr)
|
||||||
|
target_compile_definitions(orbithub PRIVATE ORBITHUB_HAS_FREERDP)
|
||||||
|
target_include_directories(orbithub PRIVATE
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/third_party/FreeRDP/include
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/third_party/FreeRDP/winpr/include
|
||||||
|
${CMAKE_CURRENT_BINARY_DIR}/third_party/FreeRDP/include
|
||||||
|
${CMAKE_CURRENT_BINARY_DIR}/third_party/FreeRDP/winpr/include
|
||||||
|
)
|
||||||
|
target_link_libraries(orbithub PRIVATE freerdp winpr)
|
||||||
|
if(TARGET freerdp-client)
|
||||||
|
target_link_libraries(orbithub PRIVATE freerdp-client)
|
||||||
|
endif()
|
||||||
|
else()
|
||||||
|
message(FATAL_ERROR "Vendored FreeRDP targets were not produced as expected.")
|
||||||
|
endif()
|
||||||
|
|
||||||
|
# Qt loads platform integration and SQL driver plugins dynamically at
|
||||||
|
# runtime (QFactoryLoader), so they never appear in orbithub.exe's import
|
||||||
|
# table. vcpkg's automatic DLL deployment only follows the import table, so
|
||||||
|
# these plugins have to be copied out explicitly or the app fails to start
|
||||||
|
# with "Could not find the Qt platform plugin" / "can not load requested
|
||||||
|
# driver 'QSQLITE'".
|
||||||
|
if(WIN32)
|
||||||
|
if(TARGET Qt6::QWindowsIntegrationPlugin)
|
||||||
|
add_custom_command(TARGET orbithub POST_BUILD
|
||||||
|
COMMAND ${CMAKE_COMMAND} -E make_directory
|
||||||
|
"$<TARGET_FILE_DIR:orbithub>/platforms"
|
||||||
|
COMMAND ${CMAKE_COMMAND} -E copy_if_different
|
||||||
|
"$<TARGET_FILE:Qt6::QWindowsIntegrationPlugin>"
|
||||||
|
"$<TARGET_FILE_DIR:orbithub>/platforms/"
|
||||||
|
COMMENT "Deploying Qt Windows platform plugin"
|
||||||
|
)
|
||||||
|
install(FILES "$<TARGET_FILE:Qt6::QWindowsIntegrationPlugin>"
|
||||||
|
DESTINATION "${CMAKE_INSTALL_BINDIR}/platforms"
|
||||||
|
)
|
||||||
|
endif()
|
||||||
|
|
||||||
|
if(TARGET Qt6::QSQLiteDriverPlugin)
|
||||||
|
add_custom_command(TARGET orbithub POST_BUILD
|
||||||
|
COMMAND ${CMAKE_COMMAND} -E make_directory
|
||||||
|
"$<TARGET_FILE_DIR:orbithub>/sqldrivers"
|
||||||
|
COMMAND ${CMAKE_COMMAND} -E copy_if_different
|
||||||
|
"$<TARGET_FILE:Qt6::QSQLiteDriverPlugin>"
|
||||||
|
"$<TARGET_FILE_DIR:orbithub>/sqldrivers/"
|
||||||
|
COMMENT "Deploying Qt SQLite driver plugin"
|
||||||
|
)
|
||||||
|
install(FILES "$<TARGET_FILE:Qt6::QSQLiteDriverPlugin>"
|
||||||
|
DESTINATION "${CMAKE_INSTALL_BINDIR}/sqldrivers"
|
||||||
|
)
|
||||||
|
|
||||||
|
# vcpkg's qtbase[sql-sqlite] links the plugin against vcpkg's own
|
||||||
|
# shared sqlite3 port rather than bundling SQLite into the plugin,
|
||||||
|
# so the plugin fails to load ("can not load requested driver
|
||||||
|
# 'QSQLITE'") unless sqlite3.dll also ships next to the executable.
|
||||||
|
find_file(ORBITHUB_SQLITE3_DLL
|
||||||
|
NAMES sqlite3.dll
|
||||||
|
PATHS ${CMAKE_PREFIX_PATH}
|
||||||
|
PATH_SUFFIXES bin
|
||||||
|
)
|
||||||
|
if(ORBITHUB_SQLITE3_DLL)
|
||||||
|
add_custom_command(TARGET orbithub POST_BUILD
|
||||||
|
COMMAND ${CMAKE_COMMAND} -E copy_if_different
|
||||||
|
"${ORBITHUB_SQLITE3_DLL}"
|
||||||
|
"$<TARGET_FILE_DIR:orbithub>/"
|
||||||
|
COMMENT "Deploying sqlite3.dll (Qt SQLite plugin dependency)"
|
||||||
|
)
|
||||||
|
install(FILES "${ORBITHUB_SQLITE3_DLL}" DESTINATION "${CMAKE_INSTALL_BINDIR}")
|
||||||
|
endif()
|
||||||
|
endif()
|
||||||
|
endif()
|
||||||
|
|
||||||
|
if(APPLE)
|
||||||
|
# Mirrors macdeployqt's own layout for Qt frameworks: dylibs live in
|
||||||
|
# Contents/Frameworks inside the bundle, found via @executable_path
|
||||||
|
# (the macOS/dyld equivalent of Linux's $ORIGIN token, which dyld
|
||||||
|
# does not understand).
|
||||||
|
set_target_properties(orbithub PROPERTIES
|
||||||
|
INSTALL_RPATH "@executable_path/../Frameworks"
|
||||||
|
INSTALL_RPATH_USE_LINK_PATH ON
|
||||||
|
)
|
||||||
|
else()
|
||||||
|
set_target_properties(orbithub PROPERTIES
|
||||||
|
BUILD_RPATH_USE_ORIGIN ON
|
||||||
|
INSTALL_RPATH "$ORIGIN/../${CMAKE_INSTALL_LIBDIR}/orbithub"
|
||||||
|
INSTALL_RPATH_USE_LINK_PATH ON
|
||||||
|
)
|
||||||
|
endif()
|
||||||
|
|
||||||
|
install(TARGETS orbithub
|
||||||
|
RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR}
|
||||||
|
BUNDLE DESTINATION .
|
||||||
|
)
|
||||||
|
|
||||||
|
if(APPLE)
|
||||||
|
set(ORBITHUB_PRIVATE_LIB_DESTINATION "OrbitHub.app/Contents/Frameworks")
|
||||||
|
else()
|
||||||
|
set(ORBITHUB_PRIVATE_LIB_DESTINATION "${CMAKE_INSTALL_LIBDIR}/orbithub")
|
||||||
|
endif()
|
||||||
|
set(ORBITHUB_RUNTIME_TARGETS KodoTerm freerdp winpr)
|
||||||
|
if(TARGET freerdp-client)
|
||||||
|
list(APPEND ORBITHUB_RUNTIME_TARGETS freerdp-client)
|
||||||
|
endif()
|
||||||
|
foreach(runtime_target IN LISTS ORBITHUB_RUNTIME_TARGETS)
|
||||||
|
if(TARGET ${runtime_target})
|
||||||
|
install(TARGETS ${runtime_target}
|
||||||
|
RUNTIME DESTINATION ${ORBITHUB_PRIVATE_LIB_DESTINATION}
|
||||||
|
LIBRARY DESTINATION ${ORBITHUB_PRIVATE_LIB_DESTINATION}
|
||||||
|
ARCHIVE DESTINATION ${CMAKE_INSTALL_LIBDIR}
|
||||||
|
)
|
||||||
|
endif()
|
||||||
|
endforeach()
|
||||||
|
|
||||||
|
install(FILES
|
||||||
|
packaging/linux/org.darksingularity.OrbitHub.desktop
|
||||||
|
DESTINATION ${CMAKE_INSTALL_DATADIR}/applications
|
||||||
|
)
|
||||||
|
foreach(icon_size IN ITEMS 16 24 32 48 64 128 256)
|
||||||
|
install(FILES
|
||||||
|
packaging/linux/icons/${icon_size}x${icon_size}/apps/org.darksingularity.OrbitHub.png
|
||||||
|
DESTINATION ${CMAKE_INSTALL_DATADIR}/icons/hicolor/${icon_size}x${icon_size}/apps
|
||||||
|
)
|
||||||
|
endforeach()
|
||||||
|
install(FILES
|
||||||
|
packaging/linux/org.darksingularity.OrbitHub.metainfo.xml
|
||||||
|
DESTINATION ${CMAKE_INSTALL_DATADIR}/metainfo
|
||||||
|
)
|
||||||
|
install(FILES LICENSE
|
||||||
|
DESTINATION ${CMAKE_INSTALL_DATADIR}/licenses/org.darksingularity.OrbitHub
|
||||||
|
)
|
||||||
|
install(FILES third_party/FreeRDP/LICENSE
|
||||||
|
DESTINATION ${CMAKE_INSTALL_DATADIR}/licenses/org.darksingularity.OrbitHub
|
||||||
|
RENAME LICENSE-FreeRDP
|
||||||
|
)
|
||||||
|
install(FILES third_party/KodoTerm/LICENSE
|
||||||
|
DESTINATION ${CMAKE_INSTALL_DATADIR}/licenses/org.darksingularity.OrbitHub
|
||||||
|
RENAME LICENSE-KodoTerm
|
||||||
|
)
|
||||||
|
|||||||
@@ -0,0 +1,184 @@
|
|||||||
|
# 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: `v2026.9.14`
|
||||||
|
- VNC implementation milestone (M6) is currently deferred
|
||||||
|
|
||||||
|
Progress and milestone details:
|
||||||
|
- [docs/PROGRESS.md](docs/PROGRESS.md)
|
||||||
|
|
||||||
|
Latest release (installers for Windows, Linux, and macOS):
|
||||||
|
- [v2026.9.14](https://git.darksingularity.org/DarkSingularity/orbithub/releases/tag/v2026.9.14)
|
||||||
|
|
||||||
|
User Guide:
|
||||||
|
- [docs/USER_GUIDE.md](docs/USER_GUIDE.md) (also available as a PDF attached to each release, and in-app via `Help -> User Guide`)
|
||||||
|
|
||||||
|
## Screenshots
|
||||||
|
|
||||||
|

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

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

|
||||||
|
|
||||||
|
An embedded RDP session in a tab.
|
||||||
|
|
||||||
|
## Implemented Features
|
||||||
|
|
||||||
|
### 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.
|
||||||
@@ -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,130 @@ 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`
|
||||||
|
|
||||||
|
## User Guide PDF
|
||||||
|
|
||||||
|
The in-app User Guide (`Help -> User Guide`) is built from
|
||||||
|
`docs/USER_GUIDE.md` and embedded into the app at compile time — no extra
|
||||||
|
step needed for that. A standalone PDF version is generated separately by a
|
||||||
|
small tool (kept out of the main app's dependencies, since it needs
|
||||||
|
`Qt6::PrintSupport`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./packaging/docs/build-user-guide-pdf.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Output path:
|
||||||
|
- `docs/USER_GUIDE.pdf` (not committed to git — a release asset, like the
|
||||||
|
platform installers)
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
# Flathub Submission Readiness
|
||||||
|
|
||||||
|
Tracks OrbitHub's readiness for submission to Flathub. This is a separate
|
||||||
|
checklist from `docs/PROGRESS.md`'s development milestones, since Flathub
|
||||||
|
submission is an external process with its own requirements.
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
| Item | Status |
|
||||||
|
|---|---|
|
||||||
|
| Production manifest with pinned, reproducible git source | Done — `packaging/flatpak/flathub/org.darksingularity.OrbitHub.yml`; commit pin updated at each tagged release |
|
||||||
|
| `flathub.json` for build settings (`only-arches`, etc.) | Done — `packaging/flatpak/flathub/flathub.json` |
|
||||||
|
| Offline build (no network fetches during build) | Verified — no `FetchContent`/`ExternalProject`/`curl`/`wget` in CMake; all vendored deps committed in `third_party/`; confirmed with a real `flatpak-builder` build |
|
||||||
|
| SSH client available inside the sandbox | Verified — provided by the `org.kde.Platform` runtime base, no packaging needed |
|
||||||
|
| `--filesystem=home` removed | Done — narrowed to `--filesystem=~/.ssh` (read-write, needed for `known_hosts` and SSH config) |
|
||||||
|
| SSH known-hosts trust persists with narrowed permissions | Verified in sandbox against real infrastructure |
|
||||||
|
| RDP works with zero filesystem permission | Verified — FreeRDP's cert trust store lives outside the sandboxed home path concerns entirely (see below) |
|
||||||
|
| Private-key/export file pickers use the desktop portal | **Verified interactively** — `QFileDialog`'s Browse button correctly opens the native GTK portal chooser ("Select Private Key"), which can browse the full filesystem via user consent regardless of the sandbox's static `~/.ssh`-only grant |
|
||||||
|
| Current, supported KDE runtime | Verified — upgraded to `6.11` (linter's recommended latest); confirmed the app still builds and launches against it |
|
||||||
|
| Desktop entry validates | Verified |
|
||||||
|
| Application icon validates | Verified — PNGs at all standard hicolor sizes, matching the real app icon |
|
||||||
|
| MetaInfo/AppStream validates | Verified via both `appstreamcli validate` and Flathub's own `flatpak-builder-lint appstream` (0 errors either way) |
|
||||||
|
| Screenshots present | Done — profiles view, active SSH session, active RDP session, all captured against real (test) infrastructure |
|
||||||
|
| Release information present | Done — `<releases>` block with `v2026.9.8` (add an entry per future tagged release) |
|
||||||
|
| Developer/project URLs present | Done — homepage, bugtracker, vcs-browser, developer block |
|
||||||
|
| Architecture support decided | `x86_64` only (no ARM hardware available to test FreeRDP/WinPR on aarch64), set via `flathub.json`'s `only-arches` |
|
||||||
|
| Flathub manifest linter passes | One expected finding remains: `finish-args-ssh-filesystem-access` (see below) — everything else passes, including `only-arches` placement and runtime-version currency |
|
||||||
|
| AppStream linter passes | Passing (both `appstreamcli validate` and `flatpak-builder-lint appstream`) |
|
||||||
|
| Clean install works without host dependencies | Verified via local `.flatpak` bundle install and launch, on both KDE 6.10 and 6.11 runtimes |
|
||||||
|
| Bundled-dependency license files installed per Flathub's `$FLATPAK_ID` convention | Partially done — path fixed from `share/licenses/orbithub` to the required `share/licenses/org.darksingularity.OrbitHub`; FreeRDP's and KodoTerm's `LICENSE` files now installed there too. **`libvterm`'s vendored copy has no `LICENSE`/`COPYING` file at all** — needs to be pulled from upstream and added as `third_party/libvterm/LICENSE` before submission (README claims MIT; not verified against an actual license file in-tree) |
|
||||||
|
|
||||||
|
## ⚠️ Not yet addressed: Generative AI disclosure policy is a real acceptance risk, not a checklist item
|
||||||
|
|
||||||
|
See the dedicated section below — unlike everything else on this page, this
|
||||||
|
isn't something more packaging work resolves.
|
||||||
|
|
||||||
|
### `finish-args-ssh-filesystem-access` — expected, needs a submission-time justification
|
||||||
|
|
||||||
|
Flathub's linter flags *any* `~/.ssh` filesystem grant by policy — it's not a
|
||||||
|
bug in this manifest, it's a deliberate prompt for the submitter to justify
|
||||||
|
the access during PR review. Checked the linter's own exceptions list:
|
||||||
|
several existing SSH-client apps already have this exact permission approved
|
||||||
|
with justifications like *"Read-only access to ~/.ssh is required to load
|
||||||
|
SSH keys for connecting to devices over SSH"* and *"Needed to manage SSH keys
|
||||||
|
and configurations for connections"* — OrbitHub's case is the same pattern
|
||||||
|
(read-write, specifically for `known_hosts` persistence and default identity
|
||||||
|
file discovery). Include a similar justification in the submission PR.
|
||||||
|
|
||||||
|
## ⚠️ Not yet addressed: Generative AI disclosure policy
|
||||||
|
|
||||||
|
Flathub's [Generative AI policy](https://docs.flathub.org/docs/for-app-authors/requirements#generative-ai-policy)
|
||||||
|
requires submitters to disclose "any AI-generated code, documentation,
|
||||||
|
packaging, or other material" included in the app or its Flathub packaging,
|
||||||
|
identifying "the affected parts and approximate extent." This is not a
|
||||||
|
formality — it's evaluated at reviewer discretion, and reviewers may reject
|
||||||
|
"based on the extent or role of generated material."
|
||||||
|
|
||||||
|
OrbitHub's development has used Claude Code extensively — the app's C++
|
||||||
|
source, this Flatpak packaging (manifest, metainfo, build scripts), and this
|
||||||
|
tracking doc itself. Every commit in this repository carries a
|
||||||
|
`Co-Authored-By: Claude Sonnet 5` trailer, which is itself effectively an
|
||||||
|
existing disclosure trail. An honest submission disclosure needs to reflect
|
||||||
|
that extent truthfully — not a token "some AI assistance was used" note.
|
||||||
|
|
||||||
|
The same policy also prohibits AI tools from opening or automating the
|
||||||
|
submission PR itself, or generating its commit messages, description, or
|
||||||
|
review replies. **This means the actual submission PR — including its AI
|
||||||
|
disclosure — has to be written and opened by a human, not drafted by
|
||||||
|
Claude.** Not done, and not something this repo's tooling should attempt.
|
||||||
|
|
||||||
|
This is a real acceptance risk that no amount of technical packaging work
|
||||||
|
resolves — it's a policy/reviewer-discretion matter, separate from every
|
||||||
|
other item on this page.
|
||||||
|
|
||||||
|
## Related finding (not a packaging blocker)
|
||||||
|
|
||||||
|
During permission-narrowing research, RDP certificate verification was found
|
||||||
|
to be completely disabled (`IgnoreCertificate=TRUE`, all server certificates
|
||||||
|
silently accepted including *changed* ones). This has been fixed separately
|
||||||
|
in `src/rdp_session_backend.cpp` — FreeRDP's own trust-on-first-use
|
||||||
|
certificate store is now used, matching SSH's known-hosts model. Not a
|
||||||
|
Flathub-specific issue, but worth noting since it was found in the course of
|
||||||
|
this work.
|
||||||
|
|
||||||
|
## Explicitly out of scope for this repo
|
||||||
|
|
||||||
|
- Opening the actual submission PR against `github.com/flathub/flathub` —
|
||||||
|
requires the maintainer's GitHub identity, done outside this repo, and per
|
||||||
|
the Generative AI policy above must be written by a human, not drafted here.
|
||||||
|
- ARM64 build/testing — no hardware available.
|
||||||
|
- Flathub's post-acceptance developer-verification step — done via
|
||||||
|
Flathub's own website after acceptance, using DNS control of
|
||||||
|
`darksingularity.org`.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
- Dev manifest (local iteration, `type: dir`): `packaging/flatpak/org.darksingularity.OrbitHub.yml`
|
||||||
|
- Flathub submission manifest (pinned `type: git`): `packaging/flatpak/flathub/org.darksingularity.OrbitHub.yml`
|
||||||
|
- AppStream metainfo: `packaging/linux/org.darksingularity.OrbitHub.metainfo.xml`
|
||||||
|
- Desktop entry: `packaging/linux/org.darksingularity.OrbitHub.desktop`
|
||||||
@@ -77,3 +77,76 @@ OrbitHub uses a two-window model:
|
|||||||
- Per-session timestamped event log and user-friendly error mapping
|
- Per-session timestamped event log and user-friendly error mapping
|
||||||
- Profile schema extended with `private_key_path` and `known_hosts_policy`
|
- Profile schema extended with `private_key_path` and `known_hosts_policy`
|
||||||
- Tag: v0-m3-done
|
- Tag: v0-m3-done
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Milestone 4
|
||||||
|
|
||||||
|
- Interactive embedded SSH terminal (`KodoTerm` + `libvterm`)
|
||||||
|
- SSH host-key trust prompt flow for `Ask` policy
|
||||||
|
- Improved SSH auth flow for password / private key
|
||||||
|
- Terminal utilities and UX polish (theme, clear, resize/input behavior)
|
||||||
|
- Session lifecycle UX cleanup (auto-connect, disconnect on close, tab state indicators)
|
||||||
|
- Tag: v0-m4-done
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Milestone 5 - RDP Fully Working
|
||||||
|
|
||||||
|
- Implement complete RDP protocol support (replace not-implemented RDP path)
|
||||||
|
- Deliver a usable in-app RDP session experience consistent with SSH tab UX
|
||||||
|
- Support RDP connect/disconnect/reconnect lifecycle from OrbitHub
|
||||||
|
- Add required RDP-specific connect options in profile/session flows
|
||||||
|
- Normalize event/error reporting with existing SSH behavior
|
||||||
|
- Planned Tag: v0-m5-done
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Milestone 6 - VNC Fully Working
|
||||||
|
|
||||||
|
- Implement complete VNC protocol support (replace not-implemented VNC path)
|
||||||
|
- Deliver a usable in-app VNC session experience consistent with SSH/RDP tab UX
|
||||||
|
- Support VNC connect/disconnect/reconnect lifecycle from OrbitHub
|
||||||
|
- Add required VNC-specific connect options in profile/session flows
|
||||||
|
- Normalize event/error reporting with SSH/RDP behavior
|
||||||
|
- Planned Tag: v0-m6-done
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Milestone 7 - Cross-Platform Protocol Hardening
|
||||||
|
|
||||||
|
- Validate SSH/RDP/VNC behavior on Windows, Linux, and macOS
|
||||||
|
- Resolve platform-specific path/process/auth differences
|
||||||
|
- Improve diagnostics and failure messaging for common protocol issues
|
||||||
|
- Add protocol regression checklist and repeatable verification scripts
|
||||||
|
- Planned Tag: v0-m7-done
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Milestone 8 - Profile and Session UX Completion
|
||||||
|
|
||||||
|
- Complete profile fields/validation for all protocols
|
||||||
|
- Add quality-of-life controls for active sessions (tab context actions, defaults, persistence)
|
||||||
|
- Persist per-user UI/session preferences (theme, panel visibility, terminal defaults)
|
||||||
|
- Improve session history/event visibility and filtering
|
||||||
|
- Planned Tag: v0-m8-done
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Milestone 9 - Packaging and Distribution
|
||||||
|
|
||||||
|
- Produce distributable artifacts for Windows, Linux, and macOS
|
||||||
|
- Document dependency/runtime requirements per platform
|
||||||
|
- Add release build scripts for reproducible packaging
|
||||||
|
- Validate clean install + first-run flow on each platform
|
||||||
|
- Planned Tag: v0-m9-done
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Milestone 10 - v1.0 Stabilization
|
||||||
|
|
||||||
|
- End-to-end QA pass across core workflows (profile CRUD + SSH/RDP/VNC session lifecycle)
|
||||||
|
- Fix blocker/critical defects from validation
|
||||||
|
- Finalize docs (`BUILDING`, usage notes, known limitations)
|
||||||
|
- Prepare v1.0 release notes and final acceptance checklist
|
||||||
|
- Planned Tag: v1.0.0
|
||||||
|
|||||||
@@ -60,3 +60,153 @@ Delivered:
|
|||||||
|
|
||||||
Git:
|
Git:
|
||||||
- Tag: `v0-m3-done`
|
- Tag: `v0-m3-done`
|
||||||
|
|
||||||
|
## Milestone 4 - Interactive SSH Session UX
|
||||||
|
|
||||||
|
Status: Completed
|
||||||
|
|
||||||
|
Delivered:
|
||||||
|
- Embedded interactive SSH terminal using `KodoTerm` + vendored `libvterm`
|
||||||
|
- Native in-terminal typing for SSH sessions (no separate input box)
|
||||||
|
- ANSI/color rendering with selectable terminal themes (`Dark`, `Light`, `Solarized Dark`)
|
||||||
|
- Cross-platform SSH auth path improvements (`ssh-askpass` handling and host-key policy wiring)
|
||||||
|
- Session UX simplification: auto-connect on tab open, disconnect on tab close
|
||||||
|
- Tab-state indicators via tab color and state suffix (`Connecting`, `Connected`, `Disconnected`, `Failed`)
|
||||||
|
- Right-click tab menu for `Disconnect`, `Reconnect`, `Theme`, and `Clear`
|
||||||
|
- Collapsible events panel retained as primary diagnostics surface; inline detail/status banners removed
|
||||||
|
- Terminal behavior polish: better fixed-width font selection, cursor visibility, backspace handling, and terminal-size negotiation stability
|
||||||
|
|
||||||
|
Git:
|
||||||
|
- Tag: `v0-m4-done`
|
||||||
|
|
||||||
|
## Milestone 5 - RDP Fully Working
|
||||||
|
|
||||||
|
Status: Completed
|
||||||
|
|
||||||
|
Delivered:
|
||||||
|
- Added `RdpSessionBackend` and wired protocol selection so `RDP` no longer routes to unsupported backend
|
||||||
|
- Pivoted RDP design to embedded-only integration (no external RDP process launches)
|
||||||
|
- Implemented embedded FreeRDP client thread with connect/disconnect lifecycle and event-loop handling
|
||||||
|
- Added in-window `RdpDisplayWidget` rendering surface with frame updates from FreeRDP GDI
|
||||||
|
- Wired direct keyboard/mouse input from the embedded RDP surface to the backend
|
||||||
|
- Added RDP connect-time password prompt flow and settings wiring (host/port/user/password, desktop size)
|
||||||
|
- Added explicit profile `Domain` support for RDP auth (with `DOMAIN\username` fallback parsing)
|
||||||
|
- Updated session tab/context-menu behavior so terminal-only actions are hidden on RDP tabs
|
||||||
|
- Implemented dynamic in-session RDP resolution renegotiation from viewport resize events
|
||||||
|
- Enabled minimal FreeRDP client-channel build (`drdynvc` + `disp`) and channel loading for runtime resize support
|
||||||
|
- Added RDP profile-level security mode and performance profile options, wired into FreeRDP connection settings
|
||||||
|
- Hardened RDP lifecycle handling for disconnect/reconnect/abort flows to avoid false failure states on user-initiated stops
|
||||||
|
- Expanded RDP error/disconnect diagnostics with richer FreeRDP code mapping and raw disconnect detail events
|
||||||
|
- Pulled FreeRDP source for integration planning and API review
|
||||||
|
|
||||||
|
Git:
|
||||||
|
- Tag: `v0-m5-done`
|
||||||
|
|
||||||
|
## Milestone 6 - VNC Fully Working
|
||||||
|
|
||||||
|
Status: Deferred (temporarily postponed)
|
||||||
|
|
||||||
|
Planned Scope:
|
||||||
|
- Replace current unsupported VNC path with complete VNC implementation
|
||||||
|
- Deliver usable in-app VNC session behavior aligned to SSH/RDP UX
|
||||||
|
- Implement VNC connect/disconnect/reconnect lifecycle handling
|
||||||
|
- Extend profile/session connect options needed by VNC
|
||||||
|
- Standardize event log and error mapping behavior with SSH/RDP
|
||||||
|
|
||||||
|
## Milestone 7 - Cross-Platform Protocol Hardening
|
||||||
|
|
||||||
|
Status: Completed
|
||||||
|
|
||||||
|
Delivered:
|
||||||
|
- Validated SSH and RDP workflows on Linux, macOS, and Windows 11 (VNC is out
|
||||||
|
of scope while Milestone 6 remains deferred)
|
||||||
|
- Windows: validated the full `docs/BUILDING.md` toolchain end-to-end
|
||||||
|
(Git/CMake/Ninja/VS Build Tools with the C++ workload/vcpkg for
|
||||||
|
Qt6-OpenSSL-zlib); documented a VS Build Tools installer gotcha where the
|
||||||
|
actual compiler is a "recommended", not "required", component of the
|
||||||
|
VCTools workload
|
||||||
|
- Fixed RDP keyboard input misreading punctuation keys on Linux (X11 keycode
|
||||||
|
numbering was being treated as a PC/AT scancode; now uses FreeRDP's
|
||||||
|
authoritative X11-keycode-to-scancode table)
|
||||||
|
- Added RDP clipboard sync (bidirectional, plain text) and RDP cursor/pointer
|
||||||
|
shape sync (resize handles, text I-beam, etc. instead of a static arrow)
|
||||||
|
- Fixed Tab/Shift+Tab being intercepted by local UI focus navigation instead
|
||||||
|
of reaching SSH/RDP sessions
|
||||||
|
- Fixed RDP key auto-repeat being dropped, so holding a key only ever sent a
|
||||||
|
single keystroke to the remote machine
|
||||||
|
- Windows-specific RDP fixes: a crash on every connect attempt (FreeRDP's
|
||||||
|
signal-handling critical section was never initialized) and a host
|
||||||
|
resolution failure on every connect, including literal IP addresses
|
||||||
|
(Winsock was never initialized via `WSAStartup`)
|
||||||
|
- Windows: automatic build-time deployment of Qt's platform/SQL-driver
|
||||||
|
plugins and their runtime DLL dependencies, and marked the executable as a
|
||||||
|
GUI (`WIN32`) app to remove a stray console window behind the UI
|
||||||
|
- Windows and macOS: proper native app icon embedding (Windows `.rc`/`.ico`
|
||||||
|
resource; macOS `.app` bundle with `.icns`), replacing the generic default
|
||||||
|
icon previously shown for the built executable/bundle
|
||||||
|
- macOS: fixed Edit/New Profile dialog form fields collapsing to `sizeHint`
|
||||||
|
width due to the platform-default `QFormLayout` field growth policy
|
||||||
|
|
||||||
|
Git:
|
||||||
|
- Tag: Pending user approval (`v0-m7-done`)
|
||||||
|
|
||||||
|
## Milestone 8 - Profile and Session UX Completion
|
||||||
|
|
||||||
|
Status: Completed
|
||||||
|
|
||||||
|
Delivered:
|
||||||
|
- Added profile `tags` field to storage + schema migration and profile editor UX
|
||||||
|
- Added profile `folder_path` field + nested folder/subfolder profile view mode
|
||||||
|
- 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
|
||||||
|
|
||||||
|
Status: Completed
|
||||||
|
|
||||||
|
Delivered:
|
||||||
|
- Linux `.deb` (`packaging/linux/build-deb.sh`) and Flatpak (`packaging/flatpak/build-flatpak.sh`) packages, both verified installed and launched with working SSH/RDP
|
||||||
|
- Renamed the app's reverse-DNS identity from `io.orbithub.OrbitHub` to `org.darksingularity.OrbitHub` (desktop file, AppStream metainfo, Flatpak manifest, macOS bundle identifier) to reflect a domain actually owned by the project
|
||||||
|
- Fixed Linux taskbar/panel pin matching (`StartupWMClass`) so a pinned launcher merges with its running window instead of creating a duplicate entry
|
||||||
|
- Replaced the stale, mismatched hand-authored launcher SVG with PNG icons rendered directly from the app's own `createOrbitHubAppIcon()` at each hicolor theme size
|
||||||
|
- Windows installer via Inno Setup (`packaging/windows/orbithub.iss`, `packaging/windows/build-installer.ps1`), verified installed silently and launched cleanly with no crash events
|
||||||
|
- macOS `.dmg` via `cmake --install` + `macdeployqt` + `hdiutil` (`packaging/macos/build-dmg.sh`), verified installed and launched after fixing:
|
||||||
|
- a launch crash caused by `macdeployqt` invalidating the code signature (fixed with ad hoc re-signing)
|
||||||
|
- a missing-library crash caused by the Linux-only `$ORIGIN` rpath token and vendored dylibs installing outside the `.app` bundle (fixed with `APPLE`-specific `@executable_path`/`Contents/Frameworks` layout)
|
||||||
|
- the dmg showing the generic disk icon instead of the app icon
|
||||||
|
- README and `docs/BUILDING.md` Packaging sections documented for all three platforms
|
||||||
|
|
||||||
|
Validation:
|
||||||
|
- All three installers built, installed, and launched successfully with working SSH/RDP sessions
|
||||||
|
- Code signing is ad hoc only (no purchased Apple Developer ID or Windows code-signing certificate), so macOS Gatekeeper and Windows SmartScreen still show first-run warnings by design
|
||||||
|
|
||||||
|
Git:
|
||||||
|
- Tag: `v0-m9-done`
|
||||||
|
- Release: [v2026.9.8](https://git.darksingularity.org/DarkSingularity/orbithub/releases/tag/v2026.9.8) (`v2026.9.8` tag, installers for Windows/Linux/macOS)
|
||||||
|
- Release: [v2026.9.8.2](https://git.darksingularity.org/DarkSingularity/orbithub/releases/tag/v2026.9.8.2) — same-day patch fixing RDP TLS certificate verification (was fully disabled) and preparing Flatpak packaging for Flathub submission
|
||||||
|
- Release: [v2026.9.8.3](https://git.darksingularity.org/DarkSingularity/orbithub/releases/tag/v2026.9.8.3) — same-day patch adding an in-app User Guide and standalone User Guide PDF
|
||||||
|
- Release: [v2026.9.14](https://git.darksingularity.org/DarkSingularity/orbithub/releases/tag/v2026.9.14) — fixes distorted RDP text on HiDPI monitors and reduces RDP resize-related display glitches
|
||||||
|
|
||||||
|
## Milestone 10 - v1.0 Stabilization
|
||||||
|
|
||||||
|
Status: Planned
|
||||||
|
|
||||||
|
Planned Scope:
|
||||||
|
- Run final regression and acceptance testing across all protocols
|
||||||
|
- Resolve release-blocking defects
|
||||||
|
- Finalize docs and publish v1.0 release notes/checklist
|
||||||
|
|||||||
@@ -0,0 +1,230 @@
|
|||||||
|
## Introduction
|
||||||
|
|
||||||
|
OrbitHub is a native desktop application for organizing connection profiles
|
||||||
|
and launching SSH and RDP sessions from one place, in a single tabbed
|
||||||
|
window. It runs on Windows, Linux, and macOS.
|
||||||
|
|
||||||
|
This guide covers everyday use: creating and organizing profiles,
|
||||||
|
connecting over SSH and RDP, managing active sessions, and what to do when
|
||||||
|
something goes wrong. It does not cover installation or building from
|
||||||
|
source — see the project's `README.md` and `docs/BUILDING.md` for that.
|
||||||
|
|
||||||
|
VNC support is planned but not yet implemented; profiles can be tagged for
|
||||||
|
it, but connecting will show an "unsupported protocol" message for now.
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
When OrbitHub opens, you land on the **Profiles** tab — a searchable,
|
||||||
|
sortable list of every connection you've saved. Two toolbar controls in
|
||||||
|
the top-right change how that list is presented:
|
||||||
|
|
||||||
|
- **View**: `List` shows every profile in one flat table; `Folders` groups
|
||||||
|
them into the folder tree you've organized them into.
|
||||||
|
- **Sort**: orders the list by Name, Protocol, or Host.
|
||||||
|
|
||||||
|
Along the top of the window, a **Search** box filters by name, host,
|
||||||
|
folder path, or tag as you type, and **Protocol**/**Tag** dropdowns narrow
|
||||||
|
the list further. These filters, plus your last view mode and sort order,
|
||||||
|
are remembered the next time you open OrbitHub.
|
||||||
|
|
||||||
|
Along the bottom of the Profiles tab: **New**, **Edit**, and **Delete**
|
||||||
|
buttons for managing the selected profile. The same actions are available
|
||||||
|
by right-clicking a profile or folder, and from the **File** menu (`New
|
||||||
|
Profile`, `New Folder`).
|
||||||
|
|
||||||
|
To connect, double-click a profile, or select it and press the connect
|
||||||
|
control — this opens a new tab for that session and connects
|
||||||
|
automatically.
|
||||||
|
|
||||||
|
## Managing Profiles
|
||||||
|
|
||||||
|
A profile stores everything needed to reach one remote host. Open **New**
|
||||||
|
(or **Edit** on an existing profile) to fill in:
|
||||||
|
|
||||||
|
- **Name** — a label for the profile; shown on its tab and in the list.
|
||||||
|
- **Host** — hostname or IP address.
|
||||||
|
- **Port** — defaults to `22` for SSH, `3389` for RDP.
|
||||||
|
- **Username** — the account to log in as.
|
||||||
|
- **Domain** — Windows domain for RDP logins (leave blank for local/
|
||||||
|
workgroup accounts or SSH profiles).
|
||||||
|
- **Tags** — free-form, comma-separated labels for filtering and grouping
|
||||||
|
(e.g. `prod, linux, db`).
|
||||||
|
- **Folder** — where the profile lives in the Folders view.
|
||||||
|
- **Protocol** — `SSH`, `RDP`, or `VNC` (VNC is accepted but not yet
|
||||||
|
connectable — see Introduction).
|
||||||
|
|
||||||
|
Fields below Protocol change based on what you pick:
|
||||||
|
|
||||||
|
**SSH**: **Auth Mode** (`Password` or `Private Key`), and if Private Key,
|
||||||
|
a **Private Key** file path with a **Browse** button, plus **Known Hosts**
|
||||||
|
policy — see [Connecting via SSH](#connecting-via-ssh) for what each
|
||||||
|
policy means.
|
||||||
|
|
||||||
|
**RDP**: **RDP Security** and **RDP Performance** — see
|
||||||
|
[Connecting via RDP](#connecting-via-rdp).
|
||||||
|
|
||||||
|
Passwords are never saved in the profile — OrbitHub asks for them each
|
||||||
|
time you connect (unless you've set up private-key SSH auth, which needs
|
||||||
|
no password to be entered per-connection if the key itself has none).
|
||||||
|
|
||||||
|
## Organizing Profiles
|
||||||
|
|
||||||
|
As your profile list grows, two independent tools keep it manageable:
|
||||||
|
|
||||||
|
**Folders.** Switch the Profiles tab to `Folders` view to see profiles
|
||||||
|
grouped into a tree. Create a folder from the **File** menu or by
|
||||||
|
right-clicking in the tree (`New Folder`), and drag any profile onto a
|
||||||
|
folder to move it there. Folders can nest inside other folders.
|
||||||
|
|
||||||
|
**Tags.** Tags are independent of folders — a profile can be in one folder
|
||||||
|
but carry several tags (e.g. `prod`, `linux`, `db` all at once). Use the
|
||||||
|
**Tag** filter dropdown in the toolbar to instantly narrow the list to
|
||||||
|
everything sharing a tag, regardless of which folder it's filed under.
|
||||||
|
|
||||||
|
Combine both with the **Search** box (matches name, host, folder path, or
|
||||||
|
tags) and the **Sort** control (Name / Protocol / Host) to find what you
|
||||||
|
need quickly even with a large profile list.
|
||||||
|
|
||||||
|
## Connecting via SSH
|
||||||
|
|
||||||
|
Double-clicking an SSH profile opens a new tab with an embedded, fully
|
||||||
|
interactive terminal — type directly into it as you would any terminal
|
||||||
|
emulator. A **theme** selector lets you switch between `Dark`, `Light`,
|
||||||
|
and `Solarized Dark`; your choice is remembered for future sessions.
|
||||||
|
|
||||||
|
**Authentication.** Set in the profile itself:
|
||||||
|
- **Password** — OrbitHub prompts for a password each time you connect.
|
||||||
|
It is never stored.
|
||||||
|
- **Private Key** — point at a key file (via the profile's Browse button);
|
||||||
|
no password prompt unless the key itself is passphrase-protected.
|
||||||
|
|
||||||
|
**Known Hosts policy.** This controls how OrbitHub reacts to a server's
|
||||||
|
SSH host key — the mechanism that protects against a different machine
|
||||||
|
silently impersonating a host you've connected to before:
|
||||||
|
|
||||||
|
| Policy | Behavior |
|
||||||
|
|---|---|
|
||||||
|
| `Ask` | Prompts you to confirm trust the first time a host is seen, and on any later change. Recommended default. |
|
||||||
|
| `Accept-new` | Silently trusts a host the first time it's seen, but still stops and warns if a previously-trusted host's key later changes. |
|
||||||
|
| `Strict` | Never trusts an unknown host automatically — the connection fails until you've manually confirmed the host key some other way. |
|
||||||
|
| `Ignore` | Skips host-key checking entirely. Only use this for throwaway/test environments — it removes protection against on-path attacks. |
|
||||||
|
|
||||||
|
Trusted host keys are recorded in your system's normal SSH `known_hosts`
|
||||||
|
file (the same one the `ssh` command line tool uses), so trust decisions
|
||||||
|
made through OrbitHub or a terminal `ssh` session carry over to each
|
||||||
|
other.
|
||||||
|
|
||||||
|
## Connecting via RDP
|
||||||
|
|
||||||
|
RDP sessions render in an embedded display surface inside the tab — no
|
||||||
|
external RDP client window opens. Keyboard and mouse input go straight to
|
||||||
|
the remote desktop while the tab has focus, and resizing the OrbitHub
|
||||||
|
window renegotiates the remote resolution to match. Clipboard content
|
||||||
|
syncs between your machine and the remote session automatically.
|
||||||
|
|
||||||
|
**Authentication** uses the profile's Username/Domain fields; OrbitHub
|
||||||
|
prompts for the password at connect time.
|
||||||
|
|
||||||
|
**RDP Security** controls which transport-security layer is used to
|
||||||
|
negotiate the connection:
|
||||||
|
|
||||||
|
| Mode | Behavior |
|
||||||
|
|---|---|
|
||||||
|
| `Negotiate` | Lets the client and server agree on the strongest mutually-supported option automatically. Recommended default. |
|
||||||
|
| `NLA` | Requires Network Level Authentication (credentials verified before a full session starts) — the modern standard for current Windows versions. |
|
||||||
|
| `TLS` | Requires TLS-only security, without NLA. |
|
||||||
|
| `RDP` | The legacy RDP-native security layer, for older servers that don't support TLS/NLA. |
|
||||||
|
|
||||||
|
**RDP Performance** trades visual fidelity for responsiveness:
|
||||||
|
`Balanced` (default), `Best Quality`, `Best Performance`, or
|
||||||
|
`Auto Detect` (adapts based on the detected connection).
|
||||||
|
|
||||||
|
**Server certificate verification.** The first time you connect to an RDP
|
||||||
|
host, OrbitHub trusts and remembers its TLS certificate — the same
|
||||||
|
trust-on-first-use model SSH uses for host keys. If that certificate ever
|
||||||
|
changes on a later connection, OrbitHub refuses the connection rather than
|
||||||
|
connecting anyway, since a changed certificate can mean either a
|
||||||
|
legitimate server certificate renewal or an active
|
||||||
|
machine-in-the-middle presenting a different one. The event log (see
|
||||||
|
below) shows the specific fingerprints involved. If the change is
|
||||||
|
expected — you rotated the server's certificate yourself — reconnecting
|
||||||
|
after clearing the old entry from FreeRDP's certificate store will trust
|
||||||
|
the new one.
|
||||||
|
|
||||||
|
## Managing Sessions
|
||||||
|
|
||||||
|
Every open connection lives in its own tab in the same window, alongside
|
||||||
|
the Profiles tab. A tab's title and a colored state indicator show
|
||||||
|
whether it's connecting, connected, disconnected, or failed. Closing a
|
||||||
|
tab disconnects that session; opening a profile again starts a fresh one.
|
||||||
|
|
||||||
|
Each session tab includes a collapsible **event log** beneath the
|
||||||
|
connection surface — a timestamped record of connection state changes,
|
||||||
|
warnings, and errors for that session. Controls above the log let you:
|
||||||
|
|
||||||
|
- **Show/Hide Events** — collapse the panel when you don't need it.
|
||||||
|
- **Filter** — a text box to search event text, and an `All` /
|
||||||
|
`Warnings` / `Errors` severity dropdown to narrow what's shown.
|
||||||
|
- **Export Events** — save the current session's full event log to a
|
||||||
|
file, useful when reporting a connection problem.
|
||||||
|
- **Clear Events** — empty the log for that tab.
|
||||||
|
|
||||||
|
## Settings & Preferences
|
||||||
|
|
||||||
|
OrbitHub remembers your preferences across restarts without any separate
|
||||||
|
settings screen — they're saved automatically as you use the app:
|
||||||
|
|
||||||
|
- Profile list: search text, view mode (List/Folders), Protocol/Tag
|
||||||
|
filters, and sort order.
|
||||||
|
- Session tabs: terminal theme choice, and whether the events panel is
|
||||||
|
shown or hidden for new tabs.
|
||||||
|
|
||||||
|
Profile data itself (names, hosts, tags, folder structure, and so on) is
|
||||||
|
stored in a local SQLite database — see the README for its exact path on
|
||||||
|
your platform. Passwords are never part of that stored data.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
**"Host could not be resolved"** — the hostname in the profile can't be
|
||||||
|
looked up by DNS. Check for typos, or try the host's IP address directly
|
||||||
|
to confirm whether it's a DNS problem or something else.
|
||||||
|
|
||||||
|
**SSH connection fails immediately, no prompt** — double-check the
|
||||||
|
profile's Port (default `22`) and that a firewall or network path isn't
|
||||||
|
blocking that port from your machine.
|
||||||
|
|
||||||
|
**RDP: "Authentication failed. Check username and password."** — confirm
|
||||||
|
Username and Domain are correct for the target server; some servers
|
||||||
|
require the domain to be set explicitly even for local accounts.
|
||||||
|
|
||||||
|
**RDP: "RDP security negotiation failed. Try a different RDP security
|
||||||
|
mode."** — the server doesn't support the security mode selected in the
|
||||||
|
profile. Try `Negotiate` first, or a more specific mode if you know what
|
||||||
|
the server requires.
|
||||||
|
|
||||||
|
**RDP: connection refused with a certificate-changed message** — see
|
||||||
|
[Connecting via RDP](#connecting-via-rdp) above; this is expected,
|
||||||
|
protective behavior, not a bug, whenever a previously-trusted server's
|
||||||
|
certificate is replaced.
|
||||||
|
|
||||||
|
**SSH: connection hangs at "Ask" waiting for host-key trust** — check
|
||||||
|
that policy's behavior under
|
||||||
|
[Connecting via SSH](#connecting-via-ssh); switching to `Accept-new` avoids
|
||||||
|
the prompt for genuinely new hosts while still protecting against a later
|
||||||
|
key change.
|
||||||
|
|
||||||
|
If none of this covers what you're seeing, a session's exported event log
|
||||||
|
(see Managing Sessions) is the most useful thing to include when asking
|
||||||
|
for help or filing an issue.
|
||||||
|
|
||||||
|
## About & Support
|
||||||
|
|
||||||
|
OrbitHub is open source under the MIT license. Source code, issue
|
||||||
|
tracking, and releases are hosted at
|
||||||
|
[git.darksingularity.org/DarkSingularity/orbithub](https://git.darksingularity.org/DarkSingularity/orbithub).
|
||||||
|
|
||||||
|
For a list of bundled third-party libraries and their licenses, see
|
||||||
|
**Help → About OrbitHub** inside the app.
|
||||||
|
|
||||||
|
To report a bug or request a feature, open an issue at
|
||||||
|
[git.darksingularity.org/DarkSingularity/orbithub/issues](https://git.darksingularity.org/DarkSingularity/orbithub/issues).
|
||||||
|
After Width: | Height: | Size: 71 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 100 KiB |
@@ -0,0 +1,5 @@
|
|||||||
|
<RCC>
|
||||||
|
<qresource prefix="/docs">
|
||||||
|
<file>USER_GUIDE.md</file>
|
||||||
|
</qresource>
|
||||||
|
</RCC>
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||||
|
BUILD_DIR="${1:-$ROOT_DIR/build-doc-tool}"
|
||||||
|
OUTPUT_PATH="${2:-$ROOT_DIR/docs/USER_GUIDE.pdf}"
|
||||||
|
|
||||||
|
cmake -S "$ROOT_DIR/tools/user-guide-pdf" -B "$BUILD_DIR" -G Ninja
|
||||||
|
cmake --build "$BUILD_DIR"
|
||||||
|
|
||||||
|
QT_QPA_PLATFORM=offscreen "$BUILD_DIR/user-guide-pdf" \
|
||||||
|
"$ROOT_DIR/docs/USER_GUIDE.md" \
|
||||||
|
"$OUTPUT_PATH"
|
||||||
|
|
||||||
|
echo "Created $OUTPUT_PATH"
|
||||||
@@ -0,0 +1,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,3 @@
|
|||||||
|
{
|
||||||
|
"only-arches": ["x86_64"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
app-id: org.darksingularity.OrbitHub
|
||||||
|
runtime: org.kde.Platform
|
||||||
|
runtime-version: "6.11"
|
||||||
|
sdk: org.kde.Sdk
|
||||||
|
command: orbithub
|
||||||
|
finish-args:
|
||||||
|
- --share=network
|
||||||
|
- --share=ipc
|
||||||
|
- --socket=fallback-x11
|
||||||
|
- --socket=wayland
|
||||||
|
- --device=dri
|
||||||
|
- --filesystem=~/.ssh
|
||||||
|
modules:
|
||||||
|
- name: orbithub
|
||||||
|
buildsystem: cmake-ninja
|
||||||
|
builddir: true
|
||||||
|
config-opts:
|
||||||
|
- -DCMAKE_BUILD_TYPE=Release
|
||||||
|
sources:
|
||||||
|
- type: git
|
||||||
|
url: https://git.darksingularity.org/DarkSingularity/orbithub.git
|
||||||
|
tag: v2026.9.14
|
||||||
|
commit: e90e9b5abfad585ce2ce6dc54453f110c6ed217f
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
app-id: org.darksingularity.OrbitHub
|
||||||
|
runtime: org.kde.Platform
|
||||||
|
runtime-version: "6.11"
|
||||||
|
sdk: org.kde.Sdk
|
||||||
|
command: orbithub
|
||||||
|
finish-args:
|
||||||
|
- --share=network
|
||||||
|
- --share=ipc
|
||||||
|
- --socket=fallback-x11
|
||||||
|
- --socket=wayland
|
||||||
|
- --device=dri
|
||||||
|
- --filesystem=~/.ssh
|
||||||
|
modules:
|
||||||
|
- name: orbithub
|
||||||
|
buildsystem: cmake-ninja
|
||||||
|
builddir: true
|
||||||
|
config-opts:
|
||||||
|
- -DCMAKE_BUILD_TYPE=Release
|
||||||
|
sources:
|
||||||
|
- type: dir
|
||||||
|
path: ../..
|
||||||
@@ -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"
|
||||||
|
After Width: | Height: | Size: 6.2 KiB |
|
After Width: | Height: | Size: 645 B |
|
After Width: | Height: | Size: 960 B |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
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,59 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<component type="desktop-application">
|
||||||
|
<id>org.darksingularity.OrbitHub</id>
|
||||||
|
<name>OrbitHub</name>
|
||||||
|
<summary>Unified remote session manager for SSH and RDP</summary>
|
||||||
|
<metadata_license>MIT</metadata_license>
|
||||||
|
<project_license>MIT</project_license>
|
||||||
|
<description>
|
||||||
|
<p>OrbitHub is a native desktop app for organizing connection profiles and launching SSH and RDP sessions in one tabbed interface.</p>
|
||||||
|
</description>
|
||||||
|
<launchable type="desktop-id">org.darksingularity.OrbitHub.desktop</launchable>
|
||||||
|
<url type="homepage">https://git.darksingularity.org/DarkSingularity/orbithub</url>
|
||||||
|
<url type="bugtracker">https://git.darksingularity.org/DarkSingularity/orbithub/issues</url>
|
||||||
|
<url type="vcs-browser">https://git.darksingularity.org/DarkSingularity/orbithub</url>
|
||||||
|
<developer id="org.darksingularity">
|
||||||
|
<name>DarkSingularity</name>
|
||||||
|
</developer>
|
||||||
|
<provides>
|
||||||
|
<binary>orbithub</binary>
|
||||||
|
</provides>
|
||||||
|
<content_rating type="oars-1.1" />
|
||||||
|
<screenshots>
|
||||||
|
<screenshot type="default">
|
||||||
|
<caption>Profiles organized into folders</caption>
|
||||||
|
<image>https://git.darksingularity.org/DarkSingularity/orbithub/raw/branch/main/docs/images/screenshot-profiles.png</image>
|
||||||
|
</screenshot>
|
||||||
|
<screenshot>
|
||||||
|
<caption>Active SSH terminal session</caption>
|
||||||
|
<image>https://git.darksingularity.org/DarkSingularity/orbithub/raw/branch/main/docs/images/screenshot-ssh-session.png</image>
|
||||||
|
</screenshot>
|
||||||
|
<screenshot>
|
||||||
|
<caption>Active RDP session</caption>
|
||||||
|
<image>https://git.darksingularity.org/DarkSingularity/orbithub/raw/branch/main/docs/images/screenshot-rdp-session.png</image>
|
||||||
|
</screenshot>
|
||||||
|
</screenshots>
|
||||||
|
<releases>
|
||||||
|
<release version="2026.9.14" date="2026-09-14">
|
||||||
|
<url>https://git.darksingularity.org/DarkSingularity/orbithub/releases/tag/v2026.9.14</url>
|
||||||
|
<description>
|
||||||
|
<p>Fixes distorted RDP text on HiDPI monitors, and reduces RDP resize-related display glitches.</p>
|
||||||
|
</description>
|
||||||
|
</release>
|
||||||
|
<release version="2026.9.8.3" date="2026-09-08">
|
||||||
|
<url>https://git.darksingularity.org/DarkSingularity/orbithub/releases/tag/v2026.9.8.3</url>
|
||||||
|
<description>
|
||||||
|
<p>Adds an in-app User Guide (Help -> User Guide) and a standalone User Guide PDF.</p>
|
||||||
|
</description>
|
||||||
|
</release>
|
||||||
|
<release version="2026.9.8.2" date="2026-09-08">
|
||||||
|
<url>https://git.darksingularity.org/DarkSingularity/orbithub/releases/tag/v2026.9.8.2</url>
|
||||||
|
<description>
|
||||||
|
<p>Fixes RDP connections silently accepting any server TLS certificate, including changed ones. Certificates are now verified with trust-on-first-use, matching SSH's known-hosts behavior.</p>
|
||||||
|
</description>
|
||||||
|
</release>
|
||||||
|
<release version="2026.9.8" date="2026-09-08">
|
||||||
|
<url>https://git.darksingularity.org/DarkSingularity/orbithub/releases/tag/v2026.9.8</url>
|
||||||
|
</release>
|
||||||
|
</releases>
|
||||||
|
</component>
|
||||||
@@ -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"
|
||||||
@@ -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"
|
||||||
|
After Width: | Height: | Size: 35 KiB |
@@ -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
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
1 ICON "orbithub.ico"
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
#include "about_dialog.h"
|
||||||
|
|
||||||
|
#include <QApplication>
|
||||||
|
#include <QCoreApplication>
|
||||||
|
#include <QDialogButtonBox>
|
||||||
|
#include <QHBoxLayout>
|
||||||
|
#include <QLabel>
|
||||||
|
#include <QTextBrowser>
|
||||||
|
#include <QVBoxLayout>
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
// palette(mid) is meant for 3D bevel/shadow decoration, not text — it has
|
||||||
|
// poor contrast against the window background in dark themes (barely
|
||||||
|
// legible). Blend the widget's actual text and window colors instead, so
|
||||||
|
// the result is reliably readable — de-emphasized relative to full-strength
|
||||||
|
// text, but never low-contrast — in either a light or dark theme.
|
||||||
|
QColor mutedTextColor(const QWidget* widget)
|
||||||
|
{
|
||||||
|
const QPalette pal = widget->palette();
|
||||||
|
const QColor text = pal.color(QPalette::WindowText);
|
||||||
|
const QColor background = pal.color(QPalette::Window);
|
||||||
|
constexpr qreal kTextWeight = 0.65;
|
||||||
|
|
||||||
|
auto blend = [kTextWeight](int textChannel, int backgroundChannel) {
|
||||||
|
return qBound(0,
|
||||||
|
qRound(textChannel * kTextWeight + backgroundChannel * (1.0 - kTextWeight)),
|
||||||
|
255);
|
||||||
|
};
|
||||||
|
|
||||||
|
return QColor(blend(text.red(), background.red()),
|
||||||
|
blend(text.green(), background.green()),
|
||||||
|
blend(text.blue(), background.blue()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AboutDialog::AboutDialog(QWidget* parent) : QDialog(parent)
|
||||||
|
{
|
||||||
|
setWindowTitle(QStringLiteral("About OrbitHub"));
|
||||||
|
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: %1;").arg(mutedTextColor(this).name()));
|
||||||
|
|
||||||
|
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: %1;").arg(mutedTextColor(this).name()));
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
#ifndef ORBITHUB_APP_ICON_H
|
||||||
|
#define ORBITHUB_APP_ICON_H
|
||||||
|
|
||||||
|
#include <QIcon>
|
||||||
|
|
||||||
|
QIcon createOrbitHubAppIcon();
|
||||||
|
|
||||||
|
#endif
|
||||||
@@ -1,12 +1,21 @@
|
|||||||
#include "profiles_window.h"
|
#include "app_icon.h"
|
||||||
|
#include "session_window.h"
|
||||||
|
|
||||||
#include <QApplication>
|
#include <QApplication>
|
||||||
|
|
||||||
int main(int argc, char* argv[])
|
int main(int argc, char* argv[])
|
||||||
{
|
{
|
||||||
QApplication app(argc, argv);
|
Q_INIT_RESOURCE(KodoTermThemes);
|
||||||
|
|
||||||
ProfilesWindow window;
|
QApplication app(argc, argv);
|
||||||
|
app.setOrganizationName(QStringLiteral("FireBugIT"));
|
||||||
|
app.setApplicationName(QStringLiteral("OrbitHub"));
|
||||||
|
#ifdef ORBITHUB_VERSION_STRING
|
||||||
|
app.setApplicationVersion(QStringLiteral(ORBITHUB_VERSION_STRING));
|
||||||
|
#endif
|
||||||
|
app.setWindowIcon(createOrbitHubAppIcon());
|
||||||
|
|
||||||
|
SessionWindow window;
|
||||||
window.show();
|
window.show();
|
||||||
|
|
||||||
return app.exec();
|
return app.exec();
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -32,27 +55,44 @@ ProfileDialog::ProfileDialog(QWidget* parent)
|
|||||||
m_hostInput(new QLineEdit(this)),
|
m_hostInput(new QLineEdit(this)),
|
||||||
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_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_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"));
|
||||||
m_portInput->setRange(1, 65535);
|
m_portInput->setRange(1, 65535);
|
||||||
m_portInput->setValue(22);
|
m_portInput->setValue(22);
|
||||||
m_usernameInput->setPlaceholderText(QStringLiteral("deploy"));
|
m_usernameInput->setPlaceholderText(QStringLiteral("deploy"));
|
||||||
|
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")});
|
||||||
m_knownHostsPolicyInput->addItems(
|
m_knownHostsPolicyInput->addItems(
|
||||||
{QStringLiteral("Strict"), QStringLiteral("Accept New"), QStringLiteral("Ignore")});
|
{QStringLiteral("Ask"), QStringLiteral("Strict"), QStringLiteral("Accept New"), QStringLiteral("Ignore")});
|
||||||
|
m_rdpSecurityModeInput->addItems(
|
||||||
|
{QStringLiteral("Negotiate"), QStringLiteral("NLA"), QStringLiteral("TLS"), QStringLiteral("RDP")});
|
||||||
|
m_rdpPerformanceProfileInput->addItems({QStringLiteral("Balanced"),
|
||||||
|
QStringLiteral("Best Quality"),
|
||||||
|
QStringLiteral("Best Performance"),
|
||||||
|
QStringLiteral("Auto Detect")});
|
||||||
|
|
||||||
m_privateKeyPathInput->setPlaceholderText(QStringLiteral("/home/user/.ssh/id_ed25519"));
|
m_privateKeyPathInput->setPlaceholderText(QStringLiteral("/home/user/.ssh/id_ed25519"));
|
||||||
|
|
||||||
@@ -80,6 +120,10 @@ ProfileDialog::ProfileDialog(QWidget* parent)
|
|||||||
this,
|
this,
|
||||||
[this](const QString& protocol) {
|
[this](const QString& protocol) {
|
||||||
m_portInput->setValue(standardPortForProtocol(protocol));
|
m_portInput->setValue(standardPortForProtocol(protocol));
|
||||||
|
if (protocol != QStringLiteral("SSH")) {
|
||||||
|
const QSignalBlocker blocker(m_authModeInput);
|
||||||
|
m_authModeInput->setCurrentText(QStringLiteral("Password"));
|
||||||
|
}
|
||||||
refreshAuthFields();
|
refreshAuthFields();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -92,21 +136,29 @@ ProfileDialog::ProfileDialog(QWidget* parent)
|
|||||||
form->addRow(QStringLiteral("Host"), m_hostInput);
|
form->addRow(QStringLiteral("Host"), m_hostInput);
|
||||||
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("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);
|
||||||
form->addRow(QStringLiteral("Known Hosts"), m_knownHostsPolicyInput);
|
form->addRow(QStringLiteral("Known Hosts"), m_knownHostsPolicyInput);
|
||||||
|
form->addRow(QStringLiteral("RDP Security"), m_rdpSecurityModeInput);
|
||||||
|
form->addRow(QStringLiteral("RDP Performance"), m_rdpPerformanceProfileInput);
|
||||||
|
|
||||||
auto* note = new QLabel(
|
auto* note = new QLabel(
|
||||||
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);
|
||||||
|
|
||||||
@@ -118,12 +170,21 @@ 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);
|
||||||
m_hostInput->setText(profile.host);
|
m_hostInput->setText(profile.host);
|
||||||
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_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);
|
||||||
@@ -138,6 +199,12 @@ void ProfileDialog::setProfile(const Profile& profile)
|
|||||||
|
|
||||||
const int knownHostsIndex = m_knownHostsPolicyInput->findText(profile.knownHostsPolicy);
|
const int knownHostsIndex = m_knownHostsPolicyInput->findText(profile.knownHostsPolicy);
|
||||||
m_knownHostsPolicyInput->setCurrentIndex(knownHostsIndex >= 0 ? knownHostsIndex : 0);
|
m_knownHostsPolicyInput->setCurrentIndex(knownHostsIndex >= 0 ? knownHostsIndex : 0);
|
||||||
|
const int securityModeIndex = m_rdpSecurityModeInput->findText(profile.rdpSecurityMode);
|
||||||
|
m_rdpSecurityModeInput->setCurrentIndex(securityModeIndex >= 0 ? securityModeIndex : 0);
|
||||||
|
const int performanceProfileIndex =
|
||||||
|
m_rdpPerformanceProfileInput->findText(profile.rdpPerformanceProfile);
|
||||||
|
m_rdpPerformanceProfileInput->setCurrentIndex(performanceProfileIndex >= 0 ? performanceProfileIndex
|
||||||
|
: 0);
|
||||||
|
|
||||||
refreshAuthFields();
|
refreshAuthFields();
|
||||||
}
|
}
|
||||||
@@ -145,15 +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.protocol = m_protocolInput->currentText();
|
profile.domain = protocol == QStringLiteral("RDP") ? m_domainInput->text().trimmed() : QString();
|
||||||
profile.authMode = m_authModeInput->currentText();
|
profile.folderPath = m_defaultFolderPath.trimmed();
|
||||||
profile.privateKeyPath = m_privateKeyPathInput->text().trimmed();
|
profile.tags = m_tagsInput->text().trimmed();
|
||||||
profile.knownHostsPolicy = m_knownHostsPolicyInput->currentText();
|
profile.protocol = protocol;
|
||||||
|
profile.authMode = authMode;
|
||||||
|
profile.privateKeyPath = (protocol == QStringLiteral("SSH")
|
||||||
|
&& 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -173,24 +256,73 @@ void ProfileDialog::accept()
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (m_protocolInput->currentText() == QStringLiteral("SSH")
|
const QString protocol = m_protocolInput->currentText();
|
||||||
|
if ((protocol == QStringLiteral("SSH") || protocol == QStringLiteral("RDP"))
|
||||||
&& m_usernameInput->text().trimmed().isEmpty()) {
|
&& m_usernameInput->text().trimmed().isEmpty()) {
|
||||||
QMessageBox::warning(this,
|
QMessageBox::warning(this,
|
||||||
QStringLiteral("Validation Error"),
|
QStringLiteral("Validation Error"),
|
||||||
QStringLiteral("Username is required for SSH profiles."));
|
QStringLiteral("Username is required for %1 profiles.").arg(protocol));
|
||||||
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 isPrivateKey = m_authModeInput->currentText() == QStringLiteral("Private Key");
|
const bool isSsh = protocol == QStringLiteral("SSH");
|
||||||
|
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);
|
||||||
m_browsePrivateKeyButton->setEnabled(isSsh && isPrivateKey);
|
m_browsePrivateKeyButton->setEnabled(isSsh && isPrivateKey);
|
||||||
m_knownHostsPolicyInput->setEnabled(isSsh);
|
m_knownHostsPolicyInput->setEnabled(isSsh);
|
||||||
|
m_domainInput->setEnabled(isRdp);
|
||||||
|
m_rdpSecurityModeInput->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,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;
|
||||||
|
|
||||||
@@ -29,11 +31,18 @@ private:
|
|||||||
QLineEdit* m_hostInput;
|
QLineEdit* m_hostInput;
|
||||||
QSpinBox* m_portInput;
|
QSpinBox* m_portInput;
|
||||||
QLineEdit* m_usernameInput;
|
QLineEdit* m_usernameInput;
|
||||||
|
QLineEdit* m_domainInput;
|
||||||
|
QLineEdit* m_tagsInput;
|
||||||
QComboBox* m_protocolInput;
|
QComboBox* m_protocolInput;
|
||||||
QComboBox* m_authModeInput;
|
QComboBox* m_authModeInput;
|
||||||
QLineEdit* m_privateKeyPathInput;
|
QLineEdit* m_privateKeyPathInput;
|
||||||
QPushButton* m_browsePrivateKeyButton;
|
QPushButton* m_browsePrivateKeyButton;
|
||||||
QComboBox* m_knownHostsPolicyInput;
|
QComboBox* m_knownHostsPolicyInput;
|
||||||
|
QComboBox* m_rdpSecurityModeInput;
|
||||||
|
QComboBox* m_rdpPerformanceProfileInput;
|
||||||
|
QLabel* m_protocolHint;
|
||||||
|
QLabel* m_folderHint;
|
||||||
|
QString m_defaultFolderPath;
|
||||||
|
|
||||||
void refreshAuthFields();
|
void refreshAuthFields();
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
#include <QSqlQuery>
|
#include <QSqlQuery>
|
||||||
#include <QStandardPaths>
|
#include <QStandardPaths>
|
||||||
#include <QVariant>
|
#include <QVariant>
|
||||||
|
#include <QStringList>
|
||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
QString buildDatabasePath()
|
QString buildDatabasePath()
|
||||||
@@ -22,18 +23,160 @@ QString buildDatabasePath()
|
|||||||
return dataDir.filePath(QStringLiteral("orbithub_profiles.sqlite"));
|
return dataDir.filePath(QStringLiteral("orbithub_profiles.sqlite"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
QString normalizedRdpSecurityMode(const QString& value)
|
||||||
|
{
|
||||||
|
const QString mode = value.trimmed();
|
||||||
|
if (mode.compare(QStringLiteral("NLA"), Qt::CaseInsensitive) == 0) {
|
||||||
|
return QStringLiteral("NLA");
|
||||||
|
}
|
||||||
|
if (mode.compare(QStringLiteral("TLS"), Qt::CaseInsensitive) == 0) {
|
||||||
|
return QStringLiteral("TLS");
|
||||||
|
}
|
||||||
|
if (mode.compare(QStringLiteral("RDP"), Qt::CaseInsensitive) == 0) {
|
||||||
|
return QStringLiteral("RDP");
|
||||||
|
}
|
||||||
|
return QStringLiteral("Negotiate");
|
||||||
|
}
|
||||||
|
|
||||||
|
QString normalizedRdpPerformanceProfile(const QString& value)
|
||||||
|
{
|
||||||
|
const QString profile = value.trimmed();
|
||||||
|
if (profile.compare(QStringLiteral("Best Quality"), Qt::CaseInsensitive) == 0) {
|
||||||
|
return QStringLiteral("Best Quality");
|
||||||
|
}
|
||||||
|
if (profile.compare(QStringLiteral("Best Performance"), Qt::CaseInsensitive) == 0) {
|
||||||
|
return QStringLiteral("Best Performance");
|
||||||
|
}
|
||||||
|
if (profile.compare(QStringLiteral("Auto Detect"), Qt::CaseInsensitive) == 0) {
|
||||||
|
return QStringLiteral("Auto Detect");
|
||||||
|
}
|
||||||
|
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.protocol.trimmed());
|
query.addBindValue(isRdp ? nonNullTrimmed(profile.domain) : QStringLiteral(""));
|
||||||
query.addBindValue(profile.authMode.trimmed());
|
query.addBindValue(nonNullTrimmed(normalizedFolderPath(profile.folderPath)));
|
||||||
query.addBindValue(profile.privateKeyPath.trimmed());
|
query.addBindValue(protocol);
|
||||||
query.addBindValue(profile.knownHostsPolicy.trimmed().isEmpty()
|
query.addBindValue(authMode);
|
||||||
? QStringLiteral("Strict")
|
query.addBindValue((isSsh && authMode == QStringLiteral("Private Key"))
|
||||||
: profile.knownHostsPolicy.trimmed());
|
? nonNullTrimmed(profile.privateKeyPath)
|
||||||
|
: QStringLiteral(""));
|
||||||
|
query.addBindValue(isSsh ? normalizedKnownHostsPolicy(profile.knownHostsPolicy)
|
||||||
|
: 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)
|
||||||
@@ -44,20 +187,68 @@ Profile profileFromQuery(const QSqlQuery& query)
|
|||||||
profile.host = query.value(2).toString();
|
profile.host = query.value(2).toString();
|
||||||
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.protocol = query.value(5).toString();
|
profile.domain = query.value(5).toString();
|
||||||
profile.authMode = query.value(6).toString();
|
profile.folderPath = normalizedFolderPath(query.value(6).toString());
|
||||||
profile.privateKeyPath = query.value(7).toString();
|
profile.protocol = normalizedProtocol(query.value(7).toString());
|
||||||
profile.knownHostsPolicy = query.value(8).toString();
|
profile.authMode = normalizedAuthMode(profile.protocol, query.value(8).toString());
|
||||||
if (profile.knownHostsPolicy.isEmpty()) {
|
profile.privateKeyPath = profile.authMode == QStringLiteral("Private Key")
|
||||||
profile.knownHostsPolicy = QStringLiteral("Strict");
|
? query.value(9).toString().trimmed()
|
||||||
}
|
: QString();
|
||||||
|
profile.knownHostsPolicy = profile.protocol == QStringLiteral("SSH")
|
||||||
|
? normalizedKnownHostsPolicy(query.value(10).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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,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;
|
||||||
|
|
||||||
@@ -100,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, protocol, auth_mode, private_key_path, known_hosts_policy "
|
"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, protocol, auth_mode, private_key_path, known_hosts_policy "
|
"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()) {
|
||||||
@@ -138,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, protocol, auth_mode, private_key_path, known_hosts_policy "
|
"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);
|
||||||
|
|
||||||
@@ -162,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, protocol, auth_mode, private_key_path, known_hosts_policy) "
|
"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()) {
|
||||||
@@ -191,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 = ?, protocol = ?, auth_mode = ?, private_key_path = ?, known_hosts_policy = ? "
|
"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);
|
||||||
@@ -250,10 +500,15 @@ bool ProfileRepository::initializeDatabase()
|
|||||||
"host TEXT NOT NULL DEFAULT '',"
|
"host TEXT NOT NULL DEFAULT '',"
|
||||||
"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 '',"
|
||||||
|
"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 'Strict'"
|
"known_hosts_policy TEXT NOT NULL DEFAULT 'Ask',"
|
||||||
|
"rdp_security_mode TEXT NOT NULL DEFAULT 'Negotiate',"
|
||||||
|
"rdp_performance_profile TEXT NOT NULL DEFAULT 'Balanced',"
|
||||||
|
"tags TEXT NOT NULL DEFAULT ''"
|
||||||
")"));
|
")"));
|
||||||
|
|
||||||
if (!created) {
|
if (!created) {
|
||||||
@@ -261,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;
|
||||||
@@ -296,10 +560,15 @@ bool ProfileRepository::ensureProfileSchema() const
|
|||||||
{QStringLiteral("host"), QStringLiteral("ALTER TABLE profiles ADD COLUMN host TEXT NOT NULL DEFAULT ''")},
|
{QStringLiteral("host"), QStringLiteral("ALTER TABLE profiles ADD COLUMN host TEXT NOT NULL DEFAULT ''")},
|
||||||
{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("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 'Strict'")}};
|
{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_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)) {
|
||||||
@@ -313,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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,10 +14,21 @@ struct Profile
|
|||||||
QString host;
|
QString host;
|
||||||
int port = 22;
|
int port = 22;
|
||||||
QString username;
|
QString username;
|
||||||
|
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("Strict");
|
QString knownHostsPolicy = QStringLiteral("Ask");
|
||||||
|
QString rdpSecurityMode = QStringLiteral("Negotiate");
|
||||||
|
QString rdpPerformanceProfile = QStringLiteral("Balanced");
|
||||||
|
QString tags;
|
||||||
|
};
|
||||||
|
|
||||||
|
enum class ProfileSortOrder {
|
||||||
|
NameAsc,
|
||||||
|
ProtocolAsc,
|
||||||
|
HostAsc,
|
||||||
};
|
};
|
||||||
|
|
||||||
class ProfileRepository
|
class ProfileRepository
|
||||||
@@ -29,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;
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -2,42 +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 <algorithm>
|
|
||||||
|
|
||||||
|
#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()) {
|
||||||
@@ -49,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();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,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"),
|
||||||
@@ -116,28 +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) {
|
||||||
item->setToolTip(QStringLiteral("%1://%2@%3:%4\nAuth: %5\nKnown Hosts: %6")
|
continue;
|
||||||
.arg(profile.protocol,
|
}
|
||||||
profile.username.isEmpty() ? QStringLiteral("<none>") : profile.username,
|
if (!tagFilter.isEmpty() && !profileHasTag(profile, tagFilter)) {
|
||||||
profile.host,
|
continue;
|
||||||
QString::number(profile.port),
|
}
|
||||||
profile.authMode,
|
filteredProfiles.push_back(profile);
|
||||||
profile.knownHostsPolicy));
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
@@ -151,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")
|
||||||
@@ -170,7 +818,7 @@ void ProfilesWindow::createProfile()
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
loadProfiles(m_searchBox->text());
|
loadProfiles();
|
||||||
}
|
}
|
||||||
|
|
||||||
void ProfilesWindow::editSelectedProfile()
|
void ProfilesWindow::editSelectedProfile()
|
||||||
@@ -193,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,
|
||||||
@@ -204,7 +855,7 @@ void ProfilesWindow::editSelectedProfile()
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
loadProfiles(m_searchBox->text());
|
loadProfiles();
|
||||||
}
|
}
|
||||||
|
|
||||||
void ProfilesWindow::deleteSelectedProfile()
|
void ProfilesWindow::deleteSelectedProfile()
|
||||||
@@ -238,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -264,22 +916,17 @@ void ProfilesWindow::openSessionForItem(QListWidgetItem* item)
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
auto* session = new SessionWindow(profile.value());
|
emit connectRequested(profile.value());
|
||||||
session->setAttribute(Qt::WA_DeleteOnClose);
|
}
|
||||||
|
|
||||||
m_sessionWindows.emplace_back(session);
|
void ProfilesWindow::createProfileInCurrentContext()
|
||||||
connect(session,
|
{
|
||||||
&QObject::destroyed,
|
const QString folderPath = folderPathForItem(m_profilesTree->currentItem());
|
||||||
this,
|
createProfile(folderPath);
|
||||||
[this](QObject* object) {
|
}
|
||||||
m_sessionWindows.erase(
|
|
||||||
std::remove_if(m_sessionWindows.begin(),
|
void ProfilesWindow::createFolderInCurrentContext()
|
||||||
m_sessionWindows.end(),
|
{
|
||||||
[object](const QPointer<SessionWindow>& candidate) {
|
const QString folderPath = folderPathForItem(m_profilesTree->currentItem());
|
||||||
return candidate.isNull() || candidate.data() == object;
|
createFolderInContext(folderPath);
|
||||||
}),
|
|
||||||
m_sessionWindows.end());
|
|
||||||
});
|
|
||||||
|
|
||||||
session->show();
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,23 +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 <unordered_map>
|
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
#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
|
||||||
|
|
||||||
@@ -27,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;
|
||||||
std::vector<QPointer<SessionWindow>> m_sessionWindows;
|
|
||||||
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
|
||||||
|
|||||||
@@ -0,0 +1,334 @@
|
|||||||
|
#include "rdp_display_widget.h"
|
||||||
|
|
||||||
|
#include <QCursor>
|
||||||
|
#include <QEvent>
|
||||||
|
#include <QKeyEvent>
|
||||||
|
#include <QMouseEvent>
|
||||||
|
#include <QPainter>
|
||||||
|
#include <QPixmap>
|
||||||
|
#include <QResizeEvent>
|
||||||
|
#include <QTimer>
|
||||||
|
#include <QWheelEvent>
|
||||||
|
#include <QtGlobal>
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
QSize sanitizeSize(const QSize& size)
|
||||||
|
{
|
||||||
|
return QSize(qMax(1, size.width()), qMax(1, size.height()));
|
||||||
|
}
|
||||||
|
|
||||||
|
qreal sanitizeDevicePixelRatio(qreal ratio)
|
||||||
|
{
|
||||||
|
if (!(ratio > 0.0)) {
|
||||||
|
return 1.0;
|
||||||
|
}
|
||||||
|
return qBound(1.0, ratio, 4.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Windows' virtual-display driver can visibly glitch (stale composited
|
||||||
|
// content left on screen) when asked to change resolution repeatedly in
|
||||||
|
// quick succession, which naturally happens as the window's layout settles
|
||||||
|
// right after creation/connect. Coalescing bursts of resize events into one
|
||||||
|
// request avoids triggering that.
|
||||||
|
constexpr int kResizeDebounceMs = 150;
|
||||||
|
}
|
||||||
|
|
||||||
|
RdpDisplayWidget::RdpDisplayWidget(QWidget* parent)
|
||||||
|
: QWidget(parent),
|
||||||
|
m_remoteSize(1280, 720),
|
||||||
|
m_cursorMode(CursorMode::Default),
|
||||||
|
m_resizeDebounceTimer(new QTimer(this))
|
||||||
|
{
|
||||||
|
setFocusPolicy(Qt::StrongFocus);
|
||||||
|
setMouseTracking(true);
|
||||||
|
setAutoFillBackground(false);
|
||||||
|
setMinimumSize(320, 200);
|
||||||
|
|
||||||
|
m_resizeDebounceTimer->setSingleShot(true);
|
||||||
|
connect(m_resizeDebounceTimer, &QTimer::timeout, this, &RdpDisplayWidget::emitViewportGeometry);
|
||||||
|
|
||||||
|
scheduleViewportGeometryEmit();
|
||||||
|
}
|
||||||
|
|
||||||
|
void RdpDisplayWidget::setFrame(const QImage& frame)
|
||||||
|
{
|
||||||
|
if (frame.isNull()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
m_frame = frame;
|
||||||
|
m_remoteSize = sanitizeSize(frame.size());
|
||||||
|
update();
|
||||||
|
}
|
||||||
|
|
||||||
|
void RdpDisplayWidget::setRemoteDesktopSize(int width, int height)
|
||||||
|
{
|
||||||
|
if (width < 1 || height < 1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const QSize nextSize(width, height);
|
||||||
|
if (m_remoteSize == nextSize) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
m_remoteSize = nextSize;
|
||||||
|
// The next actual frame (via setFrame) arrives asynchronously and isn't
|
||||||
|
// guaranteed to be sized to match yet. Drawing the old frame stretched
|
||||||
|
// to a renderRect() computed from the new m_remoteSize would scale it
|
||||||
|
// by the wrong factor for the transition window, producing visibly
|
||||||
|
// distorted/duplicated-looking content. Clear it and show the existing
|
||||||
|
// "waiting for frame" placeholder until a correctly-sized frame lands.
|
||||||
|
m_frame = QImage();
|
||||||
|
update();
|
||||||
|
}
|
||||||
|
|
||||||
|
void RdpDisplayWidget::clearFrame()
|
||||||
|
{
|
||||||
|
m_frame = QImage();
|
||||||
|
update();
|
||||||
|
}
|
||||||
|
|
||||||
|
void RdpDisplayWidget::setCursorImage(const QImage& image, const QPoint& hotspot)
|
||||||
|
{
|
||||||
|
m_cursorImage = image;
|
||||||
|
m_cursorHotspot = hotspot;
|
||||||
|
m_cursorMode = CursorMode::Custom;
|
||||||
|
applyCursor();
|
||||||
|
}
|
||||||
|
|
||||||
|
void RdpDisplayWidget::setCursorHidden()
|
||||||
|
{
|
||||||
|
m_cursorMode = CursorMode::Hidden;
|
||||||
|
applyCursor();
|
||||||
|
}
|
||||||
|
|
||||||
|
void RdpDisplayWidget::setCursorDefault()
|
||||||
|
{
|
||||||
|
m_cursorMode = CursorMode::Default;
|
||||||
|
applyCursor();
|
||||||
|
}
|
||||||
|
|
||||||
|
void RdpDisplayWidget::applyCursor()
|
||||||
|
{
|
||||||
|
if (m_cursorMode == CursorMode::Hidden) {
|
||||||
|
setCursor(Qt::BlankCursor);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (m_cursorMode == CursorMode::Default || m_cursorImage.isNull()) {
|
||||||
|
unsetCursor();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const QSize remote = effectiveRemoteSize();
|
||||||
|
const QRectF target = renderRect();
|
||||||
|
if (remote.isEmpty() || target.isEmpty()) {
|
||||||
|
setCursor(QCursor(QPixmap::fromImage(m_cursorImage),
|
||||||
|
m_cursorHotspot.x(),
|
||||||
|
m_cursorHotspot.y()));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const qreal scale = target.width() / remote.width();
|
||||||
|
QImage scaledImage = m_cursorImage;
|
||||||
|
if (!qFuzzyCompare(scale, 1.0)) {
|
||||||
|
scaledImage = m_cursorImage.scaled(
|
||||||
|
qMax(1, qRound(m_cursorImage.width() * scale)),
|
||||||
|
qMax(1, qRound(m_cursorImage.height() * scale)),
|
||||||
|
Qt::IgnoreAspectRatio,
|
||||||
|
Qt::SmoothTransformation);
|
||||||
|
}
|
||||||
|
|
||||||
|
const int hotX = qBound(0, qRound(m_cursorHotspot.x() * scale), scaledImage.width());
|
||||||
|
const int hotY = qBound(0, qRound(m_cursorHotspot.y() * scale), scaledImage.height());
|
||||||
|
setCursor(QCursor(QPixmap::fromImage(scaledImage), hotX, hotY));
|
||||||
|
}
|
||||||
|
|
||||||
|
void RdpDisplayWidget::paintEvent(QPaintEvent* event)
|
||||||
|
{
|
||||||
|
Q_UNUSED(event);
|
||||||
|
|
||||||
|
QPainter painter(this);
|
||||||
|
painter.fillRect(rect(), QColor(QStringLiteral("#101214")));
|
||||||
|
|
||||||
|
const QRectF target = renderRect();
|
||||||
|
if (!m_frame.isNull()) {
|
||||||
|
painter.drawImage(target, m_frame);
|
||||||
|
} else {
|
||||||
|
painter.setPen(QColor(QStringLiteral("#b0bec5")));
|
||||||
|
painter.drawText(rect(),
|
||||||
|
Qt::AlignCenter,
|
||||||
|
QStringLiteral("Waiting for remote desktop frame..."));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void RdpDisplayWidget::resizeEvent(QResizeEvent* event)
|
||||||
|
{
|
||||||
|
QWidget::resizeEvent(event);
|
||||||
|
scheduleViewportGeometryEmit();
|
||||||
|
applyCursor();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool RdpDisplayWidget::event(QEvent* event)
|
||||||
|
{
|
||||||
|
// Fires when this widget's effective screen changes (e.g. dragged to a
|
||||||
|
// different monitor), which is what changes devicePixelRatio(). Newer
|
||||||
|
// Qt versions add a more specific QEvent::DevicePixelRatioChange, but
|
||||||
|
// this project's Qt 6.2 floor doesn't have it.
|
||||||
|
if (event->type() == QEvent::ScreenChangeInternal) {
|
||||||
|
scheduleViewportGeometryEmit();
|
||||||
|
}
|
||||||
|
return QWidget::event(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
void RdpDisplayWidget::scheduleViewportGeometryEmit()
|
||||||
|
{
|
||||||
|
// Restarting an already-running single-shot timer resets its countdown,
|
||||||
|
// so a burst of resize events collapses into one emission after things
|
||||||
|
// settle, rather than one request per event.
|
||||||
|
m_resizeDebounceTimer->start(kResizeDebounceMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
void RdpDisplayWidget::emitViewportGeometry()
|
||||||
|
{
|
||||||
|
const QSize logicalSize = sanitizeSize(this->size());
|
||||||
|
const qreal ratio = sanitizeDevicePixelRatio(this->devicePixelRatioF());
|
||||||
|
const QSize physicalSize(qRound(logicalSize.width() * ratio), qRound(logicalSize.height() * ratio));
|
||||||
|
emit viewportSizeChanged(physicalSize.width(), physicalSize.height());
|
||||||
|
emit displayScaleChanged(ratio);
|
||||||
|
}
|
||||||
|
|
||||||
|
void RdpDisplayWidget::keyPressEvent(QKeyEvent* event)
|
||||||
|
{
|
||||||
|
if (event == nullptr) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-repeat presses must reach the remote server so it can perform
|
||||||
|
// its own typematic repeat, exactly as a physical keyboard held down
|
||||||
|
// would. Only release events filter out isAutoRepeat() (below), since
|
||||||
|
// Qt uses a synthetic release/press pair purely to normalize platform
|
||||||
|
// auto-repeat quirks -- forwarding that synthetic release would send a
|
||||||
|
// spurious key-up for a key that is still physically held.
|
||||||
|
emit keyInput(event->key(),
|
||||||
|
event->nativeScanCode(),
|
||||||
|
event->text(),
|
||||||
|
true,
|
||||||
|
static_cast<int>(event->modifiers()));
|
||||||
|
event->accept();
|
||||||
|
}
|
||||||
|
|
||||||
|
void RdpDisplayWidget::keyReleaseEvent(QKeyEvent* event)
|
||||||
|
{
|
||||||
|
if (event == nullptr || event->isAutoRepeat()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
emit keyInput(event->key(),
|
||||||
|
event->nativeScanCode(),
|
||||||
|
event->text(),
|
||||||
|
false,
|
||||||
|
static_cast<int>(event->modifiers()));
|
||||||
|
event->accept();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool RdpDisplayWidget::focusNextPrevChild(bool next)
|
||||||
|
{
|
||||||
|
Q_UNUSED(next);
|
||||||
|
// Tab/Shift+Tab must reach keyPressEvent() and be forwarded to the
|
||||||
|
// remote session instead of moving focus to the next local widget.
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
void RdpDisplayWidget::mousePressEvent(QMouseEvent* event)
|
||||||
|
{
|
||||||
|
if (event == nullptr) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setFocus(Qt::MouseFocusReason);
|
||||||
|
const QPoint mapped = mapToRemote(event->position());
|
||||||
|
emit mouseButtonInput(mapped.x(), mapped.y(), static_cast<int>(event->button()), true);
|
||||||
|
event->accept();
|
||||||
|
}
|
||||||
|
|
||||||
|
void RdpDisplayWidget::mouseReleaseEvent(QMouseEvent* event)
|
||||||
|
{
|
||||||
|
if (event == nullptr) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const QPoint mapped = mapToRemote(event->position());
|
||||||
|
emit mouseButtonInput(mapped.x(), mapped.y(), static_cast<int>(event->button()), false);
|
||||||
|
event->accept();
|
||||||
|
}
|
||||||
|
|
||||||
|
void RdpDisplayWidget::mouseMoveEvent(QMouseEvent* event)
|
||||||
|
{
|
||||||
|
if (event == nullptr) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const QPoint mapped = mapToRemote(event->position());
|
||||||
|
emit mouseMoveInput(mapped.x(), mapped.y());
|
||||||
|
event->accept();
|
||||||
|
}
|
||||||
|
|
||||||
|
void RdpDisplayWidget::wheelEvent(QWheelEvent* event)
|
||||||
|
{
|
||||||
|
if (event == nullptr) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const QPoint mapped = mapToRemote(event->position());
|
||||||
|
const QPoint angle = event->angleDelta();
|
||||||
|
emit mouseWheelInput(mapped.x(), mapped.y(), angle.x(), angle.y());
|
||||||
|
event->accept();
|
||||||
|
}
|
||||||
|
|
||||||
|
QRectF RdpDisplayWidget::renderRect() const
|
||||||
|
{
|
||||||
|
const QSize remote = effectiveRemoteSize();
|
||||||
|
const QRectF area = rect();
|
||||||
|
if (area.isEmpty()) {
|
||||||
|
return QRectF();
|
||||||
|
}
|
||||||
|
|
||||||
|
const qreal scale = qMin(area.width() / remote.width(), area.height() / remote.height());
|
||||||
|
const qreal drawWidth = remote.width() * scale;
|
||||||
|
const qreal drawHeight = remote.height() * scale;
|
||||||
|
const qreal x = area.x() + ((area.width() - drawWidth) * 0.5);
|
||||||
|
const qreal y = area.y() + ((area.height() - drawHeight) * 0.5);
|
||||||
|
return QRectF(x, y, drawWidth, drawHeight);
|
||||||
|
}
|
||||||
|
|
||||||
|
QPoint RdpDisplayWidget::mapToRemote(const QPointF& pos) const
|
||||||
|
{
|
||||||
|
const QSize remote = effectiveRemoteSize();
|
||||||
|
const QRectF target = renderRect();
|
||||||
|
if (target.isEmpty()) {
|
||||||
|
return QPoint(0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const qreal clampedX = qBound(target.left(), pos.x(), target.right());
|
||||||
|
const qreal clampedY = qBound(target.top(), pos.y(), target.bottom());
|
||||||
|
|
||||||
|
const qreal normalizedX = (clampedX - target.left()) / qMax(1.0, target.width());
|
||||||
|
const qreal normalizedY = (clampedY - target.top()) / qMax(1.0, target.height());
|
||||||
|
|
||||||
|
const int remoteX = qBound(0, static_cast<int>(normalizedX * remote.width()), remote.width() - 1);
|
||||||
|
const int remoteY = qBound(0, static_cast<int>(normalizedY * remote.height()), remote.height() - 1);
|
||||||
|
return QPoint(remoteX, remoteY);
|
||||||
|
}
|
||||||
|
|
||||||
|
QSize RdpDisplayWidget::effectiveRemoteSize() const
|
||||||
|
{
|
||||||
|
if (m_remoteSize.width() > 0 && m_remoteSize.height() > 0) {
|
||||||
|
return m_remoteSize;
|
||||||
|
}
|
||||||
|
if (!m_frame.isNull()) {
|
||||||
|
return sanitizeSize(m_frame.size());
|
||||||
|
}
|
||||||
|
return QSize(1280, 720);
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
#ifndef ORBITHUB_RDP_DISPLAY_WIDGET_H
|
||||||
|
#define ORBITHUB_RDP_DISPLAY_WIDGET_H
|
||||||
|
|
||||||
|
#include <QImage>
|
||||||
|
#include <QWidget>
|
||||||
|
|
||||||
|
class QKeyEvent;
|
||||||
|
class QMouseEvent;
|
||||||
|
class QPaintEvent;
|
||||||
|
class QResizeEvent;
|
||||||
|
class QTimer;
|
||||||
|
class QWheelEvent;
|
||||||
|
|
||||||
|
class RdpDisplayWidget : public QWidget
|
||||||
|
{
|
||||||
|
Q_OBJECT
|
||||||
|
|
||||||
|
public:
|
||||||
|
explicit RdpDisplayWidget(QWidget* parent = nullptr);
|
||||||
|
|
||||||
|
void setFrame(const QImage& frame);
|
||||||
|
void setRemoteDesktopSize(int width, int height);
|
||||||
|
void clearFrame();
|
||||||
|
void setCursorImage(const QImage& image, const QPoint& hotspot);
|
||||||
|
void setCursorHidden();
|
||||||
|
void setCursorDefault();
|
||||||
|
|
||||||
|
signals:
|
||||||
|
void keyInput(int key, quint32 nativeScanCode, const QString& text, bool pressed, int modifiers);
|
||||||
|
void mouseMoveInput(int x, int y);
|
||||||
|
void mouseButtonInput(int x, int y, int button, bool pressed);
|
||||||
|
void mouseWheelInput(int x, int y, int deltaX, int deltaY);
|
||||||
|
void viewportSizeChanged(int width, int height);
|
||||||
|
void displayScaleChanged(qreal devicePixelRatio);
|
||||||
|
|
||||||
|
protected:
|
||||||
|
void paintEvent(QPaintEvent* event) override;
|
||||||
|
void resizeEvent(QResizeEvent* event) override;
|
||||||
|
bool event(QEvent* event) override;
|
||||||
|
void keyPressEvent(QKeyEvent* event) override;
|
||||||
|
void keyReleaseEvent(QKeyEvent* event) override;
|
||||||
|
void mousePressEvent(QMouseEvent* event) override;
|
||||||
|
void mouseReleaseEvent(QMouseEvent* event) override;
|
||||||
|
void mouseMoveEvent(QMouseEvent* event) override;
|
||||||
|
void wheelEvent(QWheelEvent* event) override;
|
||||||
|
bool focusNextPrevChild(bool next) override;
|
||||||
|
|
||||||
|
private:
|
||||||
|
enum class CursorMode {
|
||||||
|
Default,
|
||||||
|
Custom,
|
||||||
|
Hidden,
|
||||||
|
};
|
||||||
|
|
||||||
|
QImage m_frame;
|
||||||
|
QSize m_remoteSize;
|
||||||
|
QImage m_cursorImage;
|
||||||
|
QPoint m_cursorHotspot;
|
||||||
|
CursorMode m_cursorMode;
|
||||||
|
QTimer* m_resizeDebounceTimer;
|
||||||
|
|
||||||
|
QRectF renderRect() const;
|
||||||
|
QPoint mapToRemote(const QPointF& pos) const;
|
||||||
|
QSize effectiveRemoteSize() const;
|
||||||
|
void applyCursor();
|
||||||
|
void emitViewportGeometry();
|
||||||
|
void scheduleViewportGeometryEmit();
|
||||||
|
};
|
||||||
|
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
#ifndef ORBITHUB_RDP_SESSION_BACKEND_H
|
||||||
|
#define ORBITHUB_RDP_SESSION_BACKEND_H
|
||||||
|
|
||||||
|
#include "session_backend.h"
|
||||||
|
|
||||||
|
#include <atomic>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <deque>
|
||||||
|
#include <mutex>
|
||||||
|
#include <thread>
|
||||||
|
|
||||||
|
struct rdp_freerdp;
|
||||||
|
|
||||||
|
class RdpSessionBackend : public SessionBackend
|
||||||
|
{
|
||||||
|
Q_OBJECT
|
||||||
|
|
||||||
|
public:
|
||||||
|
explicit RdpSessionBackend(const Profile& profile, QObject* parent = nullptr);
|
||||||
|
~RdpSessionBackend() override;
|
||||||
|
|
||||||
|
public slots:
|
||||||
|
void connectSession(const SessionConnectOptions& options) override;
|
||||||
|
void disconnectSession() override;
|
||||||
|
void reconnectSession(const SessionConnectOptions& options) override;
|
||||||
|
void sendInput(const QString& input) override;
|
||||||
|
void confirmHostKey(bool trustHost) override;
|
||||||
|
void updateTerminalSize(int columns, int rows) override;
|
||||||
|
void updateDisplayScale(qreal devicePixelRatio) override;
|
||||||
|
void sendKeyEvent(int key,
|
||||||
|
quint32 nativeScanCode,
|
||||||
|
const QString& text,
|
||||||
|
bool pressed,
|
||||||
|
int modifiers) override;
|
||||||
|
void sendMouseMoveEvent(int x, int y) override;
|
||||||
|
void sendMouseButtonEvent(int x, int y, int button, bool pressed) override;
|
||||||
|
void sendMouseWheelEvent(int x, int y, int deltaX, int deltaY) override;
|
||||||
|
void setClipboardText(const QString& text) override;
|
||||||
|
|
||||||
|
private:
|
||||||
|
enum class InputEventType {
|
||||||
|
Key,
|
||||||
|
MouseMove,
|
||||||
|
MouseButton,
|
||||||
|
MouseWheel,
|
||||||
|
Resize,
|
||||||
|
SetClipboardText,
|
||||||
|
};
|
||||||
|
|
||||||
|
struct InputEvent {
|
||||||
|
InputEventType type = InputEventType::MouseMove;
|
||||||
|
int key = 0;
|
||||||
|
quint32 nativeScanCode = 0;
|
||||||
|
QString text;
|
||||||
|
bool pressed = false;
|
||||||
|
int modifiers = 0;
|
||||||
|
int x = 0;
|
||||||
|
int y = 0;
|
||||||
|
int button = 0;
|
||||||
|
int deltaX = 0;
|
||||||
|
int deltaY = 0;
|
||||||
|
int width = 0;
|
||||||
|
int height = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
SessionState m_state;
|
||||||
|
SessionConnectOptions m_activeOptions;
|
||||||
|
std::atomic_bool m_userInitiatedDisconnect;
|
||||||
|
|
||||||
|
std::atomic_int m_requestedDesktopWidth;
|
||||||
|
std::atomic_int m_requestedDesktopHeight;
|
||||||
|
std::atomic<qreal> m_devicePixelRatio;
|
||||||
|
|
||||||
|
std::thread m_worker;
|
||||||
|
std::atomic_bool m_workerRunning;
|
||||||
|
std::atomic_bool m_stopRequested;
|
||||||
|
|
||||||
|
std::mutex m_instanceMutex;
|
||||||
|
rdp_freerdp* m_instance;
|
||||||
|
|
||||||
|
std::mutex m_inputMutex;
|
||||||
|
std::deque<InputEvent> m_inputEvents;
|
||||||
|
|
||||||
|
std::mutex m_displayControlMutex;
|
||||||
|
void* m_displayControlContext;
|
||||||
|
bool m_displayControlReady;
|
||||||
|
bool m_resizeFailureLogged;
|
||||||
|
int m_lastResizeWidth;
|
||||||
|
int m_lastResizeHeight;
|
||||||
|
int m_lastResizeScale;
|
||||||
|
|
||||||
|
std::mutex m_cliprdrMutex;
|
||||||
|
void* m_cliprdrContext;
|
||||||
|
QString m_pendingLocalClipboardText;
|
||||||
|
|
||||||
|
// Set synchronously by orbitVerifyChangedCertificateEx (called from this
|
||||||
|
// object's own worker thread during freerdp_connect) when a server's TLS
|
||||||
|
// certificate has changed since a prior trusted connection. Read back by
|
||||||
|
// workerMain() right after freerdp_connect() fails, to show the specific
|
||||||
|
// reason instead of a generic "TLS negotiation failed" message. Cleared
|
||||||
|
// at the start of every connect attempt.
|
||||||
|
QString m_certificateRejectionReason;
|
||||||
|
|
||||||
|
void setState(SessionState state, const QString& message);
|
||||||
|
bool validateProfile(QString& message) const;
|
||||||
|
void startWorker();
|
||||||
|
void stopWorker(bool userInitiated);
|
||||||
|
void workerMain();
|
||||||
|
void enqueueInputEvent(const InputEvent& event);
|
||||||
|
void processInputEvents(rdp_freerdp* instance);
|
||||||
|
bool sendDisplayResize(rdp_freerdp* instance, int width, int height);
|
||||||
|
void sendClipboardTextToRemote(rdp_freerdp* instance, const QString& text);
|
||||||
|
public:
|
||||||
|
void onChannelConnectedEvent(const char* name, void* channelInterface);
|
||||||
|
void onChannelDisconnectedEvent(const char* name, void* channelInterface);
|
||||||
|
void onDisplayControlCaps(uint32_t maxNumMonitors,
|
||||||
|
uint32_t maxMonitorAreaFactorA,
|
||||||
|
uint32_t maxMonitorAreaFactorB);
|
||||||
|
void onCliprdrMonitorReady();
|
||||||
|
void onCliprdrServerFormatList(bool hasUnicodeText);
|
||||||
|
void onCliprdrServerFormatDataRequest(uint32_t requestedFormatId);
|
||||||
|
void onCliprdrServerFormatDataResponse(bool success, const uint8_t* data, uint32_t size);
|
||||||
|
void recordCertificateRejection(const QString& reason);
|
||||||
|
private:
|
||||||
|
void emitStateAsync(SessionState state, const QString& message);
|
||||||
|
void emitConnectionFailureAsync(const QString& displayMessage, const QString& rawMessage);
|
||||||
|
int sanitizeDesktopWidth(int width) const;
|
||||||
|
int sanitizeDesktopHeight(int height) const;
|
||||||
|
};
|
||||||
|
|
||||||
|
#endif
|
||||||
@@ -3,8 +3,11 @@
|
|||||||
|
|
||||||
#include "profile_repository.h"
|
#include "profile_repository.h"
|
||||||
|
|
||||||
|
#include <QImage>
|
||||||
#include <QObject>
|
#include <QObject>
|
||||||
|
#include <QPoint>
|
||||||
#include <QString>
|
#include <QString>
|
||||||
|
#include <QtGlobal>
|
||||||
|
|
||||||
class SessionConnectOptions
|
class SessionConnectOptions
|
||||||
{
|
{
|
||||||
@@ -41,11 +44,61 @@ public slots:
|
|||||||
virtual void connectSession(const SessionConnectOptions& options) = 0;
|
virtual void connectSession(const SessionConnectOptions& options) = 0;
|
||||||
virtual void disconnectSession() = 0;
|
virtual void disconnectSession() = 0;
|
||||||
virtual void reconnectSession(const SessionConnectOptions& options) = 0;
|
virtual void reconnectSession(const SessionConnectOptions& options) = 0;
|
||||||
|
virtual void sendInput(const QString& input) = 0;
|
||||||
|
virtual void confirmHostKey(bool trustHost) = 0;
|
||||||
|
virtual void updateTerminalSize(int columns, int rows) = 0;
|
||||||
|
virtual void updateDisplayScale(qreal devicePixelRatio)
|
||||||
|
{
|
||||||
|
Q_UNUSED(devicePixelRatio);
|
||||||
|
}
|
||||||
|
virtual void setClipboardText(const QString& text)
|
||||||
|
{
|
||||||
|
Q_UNUSED(text);
|
||||||
|
}
|
||||||
|
virtual void sendKeyEvent(int key,
|
||||||
|
quint32 nativeScanCode,
|
||||||
|
const QString& text,
|
||||||
|
bool pressed,
|
||||||
|
int modifiers)
|
||||||
|
{
|
||||||
|
Q_UNUSED(key);
|
||||||
|
Q_UNUSED(nativeScanCode);
|
||||||
|
Q_UNUSED(text);
|
||||||
|
Q_UNUSED(pressed);
|
||||||
|
Q_UNUSED(modifiers);
|
||||||
|
}
|
||||||
|
virtual void sendMouseMoveEvent(int x, int y)
|
||||||
|
{
|
||||||
|
Q_UNUSED(x);
|
||||||
|
Q_UNUSED(y);
|
||||||
|
}
|
||||||
|
virtual void sendMouseButtonEvent(int x, int y, int button, bool pressed)
|
||||||
|
{
|
||||||
|
Q_UNUSED(x);
|
||||||
|
Q_UNUSED(y);
|
||||||
|
Q_UNUSED(button);
|
||||||
|
Q_UNUSED(pressed);
|
||||||
|
}
|
||||||
|
virtual void sendMouseWheelEvent(int x, int y, int deltaX, int deltaY)
|
||||||
|
{
|
||||||
|
Q_UNUSED(x);
|
||||||
|
Q_UNUSED(y);
|
||||||
|
Q_UNUSED(deltaX);
|
||||||
|
Q_UNUSED(deltaY);
|
||||||
|
}
|
||||||
|
|
||||||
signals:
|
signals:
|
||||||
void stateChanged(SessionState state, const QString& message);
|
void stateChanged(SessionState state, const QString& message);
|
||||||
void eventLogged(const QString& message);
|
void eventLogged(const QString& message);
|
||||||
void connectionError(const QString& displayMessage, const QString& rawMessage);
|
void connectionError(const QString& displayMessage, const QString& rawMessage);
|
||||||
|
void outputReceived(const QString& text);
|
||||||
|
void hostKeyConfirmationRequested(const QString& prompt);
|
||||||
|
void frameUpdated(const QImage& frame);
|
||||||
|
void remoteDesktopSizeChanged(int width, int height);
|
||||||
|
void remoteClipboardTextChanged(const QString& text);
|
||||||
|
void cursorImageChanged(const QImage& image, const QPoint& hotspot);
|
||||||
|
void cursorHidden();
|
||||||
|
void cursorReset();
|
||||||
|
|
||||||
private:
|
private:
|
||||||
Profile m_profile;
|
Profile m_profile;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
#include "session_backend_factory.h"
|
#include "session_backend_factory.h"
|
||||||
|
|
||||||
|
#include "rdp_session_backend.h"
|
||||||
#include "session_backend.h"
|
#include "session_backend.h"
|
||||||
#include "ssh_session_backend.h"
|
#include "ssh_session_backend.h"
|
||||||
#include "unsupported_session_backend.h"
|
#include "unsupported_session_backend.h"
|
||||||
@@ -9,6 +10,9 @@ std::unique_ptr<SessionBackend> createSessionBackend(const Profile& profile)
|
|||||||
if (profile.protocol.compare(QStringLiteral("SSH"), Qt::CaseInsensitive) == 0) {
|
if (profile.protocol.compare(QStringLiteral("SSH"), Qt::CaseInsensitive) == 0) {
|
||||||
return std::make_unique<SshSessionBackend>(profile);
|
return std::make_unique<SshSessionBackend>(profile);
|
||||||
}
|
}
|
||||||
|
if (profile.protocol.compare(QStringLiteral("RDP"), Qt::CaseInsensitive) == 0) {
|
||||||
|
return std::make_unique<RdpSessionBackend>(profile);
|
||||||
|
}
|
||||||
|
|
||||||
return std::make_unique<UnsupportedSessionBackend>(profile);
|
return std::make_unique<UnsupportedSessionBackend>(profile);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,63 +5,153 @@
|
|||||||
#include "session_backend.h"
|
#include "session_backend.h"
|
||||||
|
|
||||||
#include <QWidget>
|
#include <QWidget>
|
||||||
|
#include <QStringList>
|
||||||
|
#include <QtGlobal>
|
||||||
|
|
||||||
|
#include <functional>
|
||||||
#include <optional>
|
#include <optional>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
class QLabel;
|
|
||||||
class QPlainTextEdit;
|
class QPlainTextEdit;
|
||||||
class QPushButton;
|
|
||||||
class QThread;
|
class QThread;
|
||||||
class SessionBackend;
|
class SessionBackend;
|
||||||
|
class TerminalView;
|
||||||
|
class RdpDisplayWidget;
|
||||||
|
class QToolButton;
|
||||||
|
class QLineEdit;
|
||||||
|
class QComboBox;
|
||||||
|
class QLabel;
|
||||||
|
class QPushButton;
|
||||||
|
class KodoTerm;
|
||||||
|
|
||||||
|
struct SessionUiPreferences
|
||||||
|
{
|
||||||
|
QString terminalThemeName = QStringLiteral("Dark");
|
||||||
|
bool eventsPanelExpanded = false;
|
||||||
|
int terminalFontPointSize = 0;
|
||||||
|
};
|
||||||
|
|
||||||
class SessionTab : public QWidget
|
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;
|
||||||
|
void connectSession();
|
||||||
|
void disconnectSession();
|
||||||
|
void reconnectSession();
|
||||||
|
void clearTerminal();
|
||||||
|
void setTerminalThemeName(const QString& themeName);
|
||||||
|
QString terminalThemeName() const;
|
||||||
|
bool supportsThemeSelection() const;
|
||||||
|
bool supportsClearAction() const;
|
||||||
|
bool supportsZoom() const;
|
||||||
|
void zoomIn();
|
||||||
|
void zoomOut();
|
||||||
|
void resetZoom();
|
||||||
|
void setTerminalFontPointSize(int pointSize);
|
||||||
|
int terminalFontPointSize() const;
|
||||||
|
bool isEventsPanelExpanded() const;
|
||||||
|
void setEventsPanelExpanded(bool expanded);
|
||||||
|
void clearEvents();
|
||||||
|
void copyEvents() const;
|
||||||
|
void exportEventsToFile();
|
||||||
|
|
||||||
signals:
|
signals:
|
||||||
void tabTitleChanged(const QString& title);
|
void tabTitleChanged(const QString& title);
|
||||||
|
void tabStateChanged(SessionState state);
|
||||||
|
void terminalThemeChanged(const QString& themeName);
|
||||||
|
void terminalFontSizeChanged(int pointSize);
|
||||||
|
void eventsPanelVisibilityChanged(bool expanded);
|
||||||
void requestConnect(const SessionConnectOptions& options);
|
void requestConnect(const SessionConnectOptions& options);
|
||||||
void requestDisconnect();
|
void requestDisconnect();
|
||||||
void requestReconnect(const SessionConnectOptions& options);
|
void requestReconnect(const SessionConnectOptions& options);
|
||||||
|
void requestInput(const QString& input);
|
||||||
|
void requestHostKeyConfirmation(bool trustHost);
|
||||||
|
void requestTerminalSize(int columns, int rows);
|
||||||
|
void requestDisplayScale(qreal devicePixelRatio);
|
||||||
|
void requestKeyEvent(int key,
|
||||||
|
quint32 nativeScanCode,
|
||||||
|
const QString& text,
|
||||||
|
bool pressed,
|
||||||
|
int modifiers);
|
||||||
|
void requestMouseMoveEvent(int x, int y);
|
||||||
|
void requestMouseButtonEvent(int x, int y, int button, bool pressed);
|
||||||
|
void requestMouseWheelEvent(int x, int y, int deltaX, int deltaY);
|
||||||
|
void requestSetClipboardText(const QString& text);
|
||||||
|
|
||||||
private slots:
|
private slots:
|
||||||
void onConnectClicked();
|
|
||||||
void onDisconnectClicked();
|
|
||||||
void onReconnectClicked();
|
|
||||||
void onCopyErrorClicked();
|
|
||||||
|
|
||||||
void onBackendStateChanged(SessionState state, const QString& message);
|
void onBackendStateChanged(SessionState state, const QString& message);
|
||||||
void onBackendEventLogged(const QString& message);
|
void onBackendEventLogged(const QString& message);
|
||||||
void onBackendConnectionError(const QString& displayMessage, const QString& rawMessage);
|
void onBackendConnectionError(const QString& displayMessage, const QString& rawMessage);
|
||||||
|
void onBackendOutputReceived(const QString& text);
|
||||||
|
void onBackendHostKeyConfirmationRequested(const QString& prompt);
|
||||||
|
void onBackendRemoteClipboardTextChanged(const QString& text);
|
||||||
|
void onSystemClipboardChanged();
|
||||||
|
|
||||||
private:
|
private:
|
||||||
Profile m_profile;
|
Profile m_profile;
|
||||||
QThread* m_backendThread;
|
QThread* m_backendThread;
|
||||||
SessionBackend* m_backend;
|
SessionBackend* m_backend;
|
||||||
|
bool m_useKodoTermForSsh;
|
||||||
SessionState m_state;
|
SessionState m_state;
|
||||||
QString m_lastError;
|
QString m_lastError;
|
||||||
|
SessionConnectOptions m_lastConnectOptions;
|
||||||
|
QString m_terminalThemeName;
|
||||||
|
int m_terminalFontPointSize;
|
||||||
|
QString m_lastSyncedClipboardText;
|
||||||
|
bool m_clipboardSyncSupported;
|
||||||
|
|
||||||
QLabel* m_statusLabel;
|
KodoTerm* m_sshTerminal;
|
||||||
QLabel* m_errorLabel;
|
RdpDisplayWidget* m_rdpDisplay;
|
||||||
|
TerminalView* m_terminalOutput;
|
||||||
QPlainTextEdit* m_eventLog;
|
QPlainTextEdit* m_eventLog;
|
||||||
QPushButton* m_connectButton;
|
QToolButton* m_toggleEventsButton;
|
||||||
QPushButton* m_disconnectButton;
|
QLineEdit* m_eventFilterInput;
|
||||||
QPushButton* m_reconnectButton;
|
QComboBox* m_eventSeverityFilterInput;
|
||||||
QPushButton* m_copyErrorButton;
|
QToolButton* m_clearEventsButton;
|
||||||
|
QToolButton* m_exportEventsButton;
|
||||||
|
QWidget* m_eventsPanel;
|
||||||
|
QWidget* m_passwordPromptBar;
|
||||||
|
QLabel* m_passwordPromptLabel;
|
||||||
|
QLineEdit* m_passwordPromptInput;
|
||||||
|
QPushButton* m_passwordPromptConnectButton;
|
||||||
|
QPushButton* m_passwordPromptCancelButton;
|
||||||
|
std::function<void(std::optional<QString>)> m_passwordPromptCallback;
|
||||||
|
enum class EventSeverity {
|
||||||
|
Info,
|
||||||
|
Warning,
|
||||||
|
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);
|
||||||
QString stateSuffix() const;
|
QString stateSuffix() const;
|
||||||
void refreshActionButtons();
|
void refreshActionButtons();
|
||||||
|
void setPanelExpanded(QToolButton* button, QWidget* panel, const QString& name, bool expanded);
|
||||||
|
bool startSshTerminal(const SessionConnectOptions& options);
|
||||||
|
void applyTerminalTheme(const QString& themeName);
|
||||||
|
void refreshEventLogView();
|
||||||
|
static EventSeverity classifyEventSeverity(const QString& message);
|
||||||
};
|
};
|
||||||
|
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -1,14 +1,53 @@
|
|||||||
#include "session_window.h"
|
#include "session_window.h"
|
||||||
|
|
||||||
|
#include "about_dialog.h"
|
||||||
|
#include "profiles_window.h"
|
||||||
|
#include "user_guide_dialog.h"
|
||||||
|
#include <QApplication>
|
||||||
#include "session_tab.h"
|
#include "session_tab.h"
|
||||||
|
|
||||||
|
#include <QAction>
|
||||||
|
#include <QColor>
|
||||||
|
#include <QInputDialog>
|
||||||
|
#include <QMenu>
|
||||||
|
#include <QMenuBar>
|
||||||
|
#include <QPalette>
|
||||||
|
#include <QSettings>
|
||||||
|
#include <QStringList>
|
||||||
|
#include <QTabBar>
|
||||||
#include <QTabWidget>
|
#include <QTabWidget>
|
||||||
|
|
||||||
SessionWindow::SessionWindow(const Profile& profile, QWidget* parent)
|
namespace {
|
||||||
: QMainWindow(parent), m_tabs(new QTabWidget(this))
|
QColor tabColorForState(SessionState state, const QPalette& palette)
|
||||||
{
|
{
|
||||||
setWindowTitle(QStringLiteral("OrbitHub Session - %1").arg(profile.name));
|
switch (state) {
|
||||||
|
case SessionState::Disconnected:
|
||||||
|
return palette.color(QPalette::WindowText);
|
||||||
|
case SessionState::Connecting:
|
||||||
|
return QColor(QStringLiteral("#9a6700"));
|
||||||
|
case SessionState::Connected:
|
||||||
|
return QColor(QStringLiteral("#2e7d32"));
|
||||||
|
case SessionState::Failed:
|
||||||
|
return QColor(QStringLiteral("#c62828"));
|
||||||
|
}
|
||||||
|
|
||||||
|
return palette.color(QPalette::WindowText);
|
||||||
|
}
|
||||||
|
|
||||||
|
QStringList terminalThemeNames()
|
||||||
|
{
|
||||||
|
return {QStringLiteral("Dark"), QStringLiteral("Light"), QStringLiteral("Solarized Dark")};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SessionWindow::SessionWindow(QWidget* parent)
|
||||||
|
: QMainWindow(parent), m_tabs(new QTabWidget(this)), m_profilesWidget(nullptr)
|
||||||
|
{
|
||||||
|
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,
|
||||||
@@ -16,27 +55,223 @@ SessionWindow::SessionWindow(const Profile& profile, QWidget* parent)
|
|||||||
this,
|
this,
|
||||||
[this](int index) {
|
[this](int index) {
|
||||||
QWidget* tab = m_tabs->widget(index);
|
QWidget* tab = m_tabs->widget(index);
|
||||||
|
if (auto* sessionTab = qobject_cast<SessionTab*>(tab)) {
|
||||||
|
sessionTab->disconnectSession();
|
||||||
|
}
|
||||||
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);
|
||||||
|
connect(m_tabs->tabBar(),
|
||||||
|
&QWidget::customContextMenuRequested,
|
||||||
|
this,
|
||||||
|
[this](const QPoint& pos) {
|
||||||
|
const int index = m_tabs->tabBar()->tabAt(pos);
|
||||||
|
if (index < 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
auto* tab = qobject_cast<SessionTab*>(m_tabs->widget(index));
|
||||||
|
if (tab == nullptr) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
QMenu menu(this);
|
||||||
|
QAction* disconnectAction = menu.addAction(QStringLiteral("Disconnect"));
|
||||||
|
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;
|
||||||
|
|
||||||
|
if (tab->supportsThemeSelection()) {
|
||||||
|
menu.addSeparator();
|
||||||
|
QMenu* themeMenu = menu.addMenu(QStringLiteral("Theme"));
|
||||||
|
const QString currentTheme = tab->terminalThemeName();
|
||||||
|
for (const QString& themeName : terminalThemeNames()) {
|
||||||
|
QAction* themeAction = themeMenu->addAction(themeName);
|
||||||
|
themeAction->setCheckable(true);
|
||||||
|
themeAction->setChecked(
|
||||||
|
themeName.compare(currentTheme, Qt::CaseInsensitive) == 0);
|
||||||
|
themeActions.append(themeAction);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
QAction* clearAction = nullptr;
|
||||||
|
if (tab->supportsClearAction()) {
|
||||||
|
clearAction = menu.addAction(QStringLiteral("Clear"));
|
||||||
|
}
|
||||||
|
|
||||||
|
QAction* zoomInAction = nullptr;
|
||||||
|
QAction* zoomOutAction = nullptr;
|
||||||
|
QAction* resetZoomAction = nullptr;
|
||||||
|
QAction* setFontSizeAction = nullptr;
|
||||||
|
if (tab->supportsZoom()) {
|
||||||
|
menu.addSeparator();
|
||||||
|
zoomInAction = menu.addAction(QStringLiteral("Increase Font Size"));
|
||||||
|
zoomOutAction = menu.addAction(QStringLiteral("Decrease Font Size"));
|
||||||
|
resetZoomAction = menu.addAction(QStringLiteral("Reset Font Size"));
|
||||||
|
setFontSizeAction = menu.addAction(QStringLiteral("Set Font Size..."));
|
||||||
|
}
|
||||||
|
|
||||||
|
QAction* chosen = menu.exec(m_tabs->tabBar()->mapToGlobal(pos));
|
||||||
|
if (chosen == disconnectAction) {
|
||||||
|
tab->disconnectSession();
|
||||||
|
} else if (chosen == reconnectAction) {
|
||||||
|
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) {
|
||||||
|
tab->clearTerminal();
|
||||||
|
} else if (zoomInAction != nullptr && chosen == zoomInAction) {
|
||||||
|
tab->zoomIn();
|
||||||
|
} else if (zoomOutAction != nullptr && chosen == zoomOutAction) {
|
||||||
|
tab->zoomOut();
|
||||||
|
} else if (resetZoomAction != nullptr && chosen == resetZoomAction) {
|
||||||
|
tab->resetZoom();
|
||||||
|
} else if (setFontSizeAction != nullptr && chosen == setFontSizeAction) {
|
||||||
|
bool accepted = false;
|
||||||
|
const int currentSize =
|
||||||
|
tab->terminalFontPointSize() > 0 ? tab->terminalFontPointSize() : 10;
|
||||||
|
const int newSize = QInputDialog::getInt(this,
|
||||||
|
QStringLiteral("Set Font Size"),
|
||||||
|
QStringLiteral("Font size (points):"),
|
||||||
|
currentSize,
|
||||||
|
6,
|
||||||
|
72,
|
||||||
|
1,
|
||||||
|
&accepted);
|
||||||
|
if (accepted) {
|
||||||
|
tab->setTerminalFontPointSize(newSize);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (QAction* themeAction : themeActions) {
|
||||||
|
if (chosen == themeAction) {
|
||||||
|
tab->setTerminalThemeName(themeAction->text());
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
QMenu* fileMenu = menuBar()->addMenu(QStringLiteral("File"));
|
||||||
|
QAction* newProfileAction = fileMenu->addAction(QStringLiteral("New Profile"));
|
||||||
|
QAction* newFolderAction = fileMenu->addAction(QStringLiteral("New Folder"));
|
||||||
|
fileMenu->addSeparator();
|
||||||
|
QAction* quitAction = fileMenu->addAction(QStringLiteral("Quit"));
|
||||||
|
|
||||||
|
connect(newProfileAction,
|
||||||
|
&QAction::triggered,
|
||||||
|
this,
|
||||||
|
[this]() { m_profilesWidget->createProfileInCurrentContext(); });
|
||||||
|
connect(newFolderAction,
|
||||||
|
&QAction::triggered,
|
||||||
|
this,
|
||||||
|
[this]() { m_profilesWidget->createFolderInCurrentContext(); });
|
||||||
|
connect(quitAction, &QAction::triggered, this, []() { qApp->quit(); });
|
||||||
|
|
||||||
|
QMenu* helpMenu = menuBar()->addMenu(QStringLiteral("Help"));
|
||||||
|
QAction* userGuideAction = helpMenu->addAction(QStringLiteral("User Guide"));
|
||||||
|
connect(userGuideAction,
|
||||||
|
&QAction::triggered,
|
||||||
|
this,
|
||||||
|
[this]() {
|
||||||
|
UserGuideDialog dialog(this);
|
||||||
|
dialog.exec();
|
||||||
|
});
|
||||||
|
QAction* aboutAction = helpMenu->addAction(QStringLiteral("About OrbitHub"));
|
||||||
|
connect(aboutAction,
|
||||||
|
&QAction::triggered,
|
||||||
|
this,
|
||||||
|
[this]() {
|
||||||
|
AboutDialog dialog(this);
|
||||||
|
dialog.exec();
|
||||||
|
});
|
||||||
|
|
||||||
setCentralWidget(m_tabs);
|
setCentralWidget(m_tabs);
|
||||||
|
|
||||||
|
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)
|
||||||
|
{
|
||||||
addSessionTab(profile);
|
addSessionTab(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 (sessionTabCount() > 1) {
|
||||||
|
setWindowTitle(QStringLiteral("OrbitHub Sessions"));
|
||||||
|
} else {
|
||||||
|
setWindowTitle(QStringLiteral("OrbitHub Session - %1").arg(profile.name));
|
||||||
|
}
|
||||||
|
m_tabs->tabBar()->setTabTextColor(
|
||||||
|
index, tabColorForState(SessionState::Disconnected, m_tabs->palette()));
|
||||||
|
|
||||||
connect(tab,
|
connect(tab,
|
||||||
&SessionTab::tabTitleChanged,
|
&SessionTab::tabTitleChanged,
|
||||||
this,
|
this,
|
||||||
[this, tab](const QString& title) { updateTabTitle(tab, title); });
|
[this, tab](const QString& title) { updateTabTitle(tab, title); });
|
||||||
|
connect(tab,
|
||||||
|
&SessionTab::tabStateChanged,
|
||||||
|
this,
|
||||||
|
[this, tab](SessionState state) {
|
||||||
|
for (int i = 0; i < m_tabs->count(); ++i) {
|
||||||
|
if (m_tabs->widget(i) == tab) {
|
||||||
|
m_tabs->tabBar()->setTabTextColor(
|
||||||
|
i, tabColorForState(state, m_tabs->palette()));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
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)
|
||||||
@@ -48,3 +283,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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,24 +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);
|
||||||
|
|
||||||
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
|
||||||
|
|||||||
@@ -14,17 +14,6 @@ QString escapeForShellSingleQuotes(const QString& value)
|
|||||||
escaped.replace(QStringLiteral("'"), QStringLiteral("'\"'\"'"));
|
escaped.replace(QStringLiteral("'"), QStringLiteral("'\"'\"'"));
|
||||||
return escaped;
|
return escaped;
|
||||||
}
|
}
|
||||||
|
|
||||||
QString escapedForWindowsEcho(const QString& value)
|
|
||||||
{
|
|
||||||
QString escaped = value;
|
|
||||||
escaped.replace(QStringLiteral("^"), QStringLiteral("^^"));
|
|
||||||
escaped.replace(QStringLiteral("&"), QStringLiteral("^&"));
|
|
||||||
escaped.replace(QStringLiteral("|"), QStringLiteral("^|"));
|
|
||||||
escaped.replace(QStringLiteral("<"), QStringLiteral("^<"));
|
|
||||||
escaped.replace(QStringLiteral(">"), QStringLiteral("^>"));
|
|
||||||
return escaped;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
SshSessionBackend::SshSessionBackend(const Profile& profile, QObject* parent)
|
SshSessionBackend::SshSessionBackend(const Profile& profile, QObject* parent)
|
||||||
@@ -33,7 +22,12 @@ SshSessionBackend::SshSessionBackend(const Profile& profile, QObject* parent)
|
|||||||
m_connectedProbeTimer(new QTimer(this)),
|
m_connectedProbeTimer(new QTimer(this)),
|
||||||
m_state(SessionState::Disconnected),
|
m_state(SessionState::Disconnected),
|
||||||
m_userInitiatedDisconnect(false),
|
m_userInitiatedDisconnect(false),
|
||||||
m_reconnectPending(false)
|
m_reconnectPending(false),
|
||||||
|
m_waitingForPasswordPrompt(false),
|
||||||
|
m_waitingForHostKeyConfirmation(false),
|
||||||
|
m_passwordSubmitted(false),
|
||||||
|
m_terminalColumns(0),
|
||||||
|
m_terminalRows(0)
|
||||||
{
|
{
|
||||||
m_connectedProbeTimer->setSingleShot(true);
|
m_connectedProbeTimer->setSingleShot(true);
|
||||||
|
|
||||||
@@ -46,6 +40,10 @@ SshSessionBackend::SshSessionBackend(const Profile& profile, QObject* parent)
|
|||||||
qOverload<int, QProcess::ExitStatus>(&QProcess::finished),
|
qOverload<int, QProcess::ExitStatus>(&QProcess::finished),
|
||||||
this,
|
this,
|
||||||
&SshSessionBackend::onProcessFinished);
|
&SshSessionBackend::onProcessFinished);
|
||||||
|
connect(m_process,
|
||||||
|
&QProcess::readyReadStandardOutput,
|
||||||
|
this,
|
||||||
|
&SshSessionBackend::onReadyReadStandardOutput);
|
||||||
connect(m_process,
|
connect(m_process,
|
||||||
&QProcess::readyReadStandardError,
|
&QProcess::readyReadStandardError,
|
||||||
this,
|
this,
|
||||||
@@ -75,6 +73,10 @@ void SshSessionBackend::connectSession(const SessionConnectOptions& options)
|
|||||||
m_userInitiatedDisconnect = false;
|
m_userInitiatedDisconnect = false;
|
||||||
m_reconnectPending = false;
|
m_reconnectPending = false;
|
||||||
m_lastRawError.clear();
|
m_lastRawError.clear();
|
||||||
|
m_activeOptions = options;
|
||||||
|
m_waitingForPasswordPrompt = false;
|
||||||
|
m_waitingForHostKeyConfirmation = false;
|
||||||
|
m_passwordSubmitted = false;
|
||||||
|
|
||||||
if (!startSshProcess(options)) {
|
if (!startSshProcess(options)) {
|
||||||
return;
|
return;
|
||||||
@@ -123,6 +125,45 @@ void SshSessionBackend::reconnectSession(const SessionConnectOptions& options)
|
|||||||
m_process->terminate();
|
m_process->terminate();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void SshSessionBackend::sendInput(const QString& input)
|
||||||
|
{
|
||||||
|
if (m_process->state() != QProcess::Running) {
|
||||||
|
emit eventLogged(QStringLiteral("Input ignored: session is not running."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
m_process->write(input.toUtf8());
|
||||||
|
}
|
||||||
|
|
||||||
|
void SshSessionBackend::confirmHostKey(bool trustHost)
|
||||||
|
{
|
||||||
|
if (m_process->state() != QProcess::Running || !m_waitingForHostKeyConfirmation) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
m_waitingForHostKeyConfirmation = false;
|
||||||
|
const QString response = trustHost ? QStringLiteral("yes\n") : QStringLiteral("no\n");
|
||||||
|
m_process->write(response.toUtf8());
|
||||||
|
|
||||||
|
emit eventLogged(trustHost
|
||||||
|
? QStringLiteral("Host key accepted by user.")
|
||||||
|
: QStringLiteral("Host key rejected by user."));
|
||||||
|
}
|
||||||
|
|
||||||
|
void SshSessionBackend::updateTerminalSize(int columns, int rows)
|
||||||
|
{
|
||||||
|
m_terminalColumns = columns;
|
||||||
|
m_terminalRows = rows;
|
||||||
|
|
||||||
|
if (m_state == SessionState::Connected) {
|
||||||
|
applyTerminalSizeIfAvailable();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void SshSessionBackend::onProcessStarted()
|
void SshSessionBackend::onProcessStarted()
|
||||||
{
|
{
|
||||||
emit eventLogged(QStringLiteral("ssh process started."));
|
emit eventLogged(QStringLiteral("ssh process started."));
|
||||||
@@ -178,6 +219,21 @@ void SshSessionBackend::onProcessFinished(int exitCode, QProcess::ExitStatus)
|
|||||||
setState(SessionState::Disconnected, QStringLiteral("SSH session ended."));
|
setState(SessionState::Disconnected, QStringLiteral("SSH session ended."));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void SshSessionBackend::onReadyReadStandardOutput()
|
||||||
|
{
|
||||||
|
const QString chunk = QString::fromUtf8(m_process->readAllStandardOutput());
|
||||||
|
if (chunk.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
emit outputReceived(chunk);
|
||||||
|
|
||||||
|
if (m_state == SessionState::Connecting && !m_waitingForHostKeyConfirmation
|
||||||
|
&& !m_waitingForPasswordPrompt) {
|
||||||
|
setState(SessionState::Connected, QStringLiteral("SSH session established."));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void SshSessionBackend::onReadyReadStandardError()
|
void SshSessionBackend::onReadyReadStandardError()
|
||||||
{
|
{
|
||||||
const QString chunk = QString::fromUtf8(m_process->readAllStandardError());
|
const QString chunk = QString::fromUtf8(m_process->readAllStandardError());
|
||||||
@@ -186,10 +242,40 @@ void SshSessionBackend::onReadyReadStandardError()
|
|||||||
}
|
}
|
||||||
|
|
||||||
m_lastRawError += chunk;
|
m_lastRawError += chunk;
|
||||||
|
emit outputReceived(chunk);
|
||||||
|
|
||||||
const QStringList lines = chunk.split(QLatin1Char('\n'), Qt::SkipEmptyParts);
|
const QStringList lines = chunk.split(QLatin1Char('\n'), Qt::SkipEmptyParts);
|
||||||
for (const QString& line : lines) {
|
for (const QString& line : lines) {
|
||||||
emit eventLogged(line.trimmed());
|
const QString trimmed = line.trimmed();
|
||||||
|
if (!trimmed.isEmpty()) {
|
||||||
|
emit eventLogged(trimmed);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trimmed.contains(QStringLiteral("Are you sure you want to continue connecting"),
|
||||||
|
Qt::CaseInsensitive)
|
||||||
|
&& !m_waitingForHostKeyConfirmation) {
|
||||||
|
m_waitingForHostKeyConfirmation = true;
|
||||||
|
emit eventLogged(QStringLiteral("Awaiting host key confirmation from user."));
|
||||||
|
emit hostKeyConfirmationRequested(trimmed);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trimmed.contains(QStringLiteral("password:"), Qt::CaseInsensitive)
|
||||||
|
&& profile().authMode.compare(QStringLiteral("Password"), Qt::CaseInsensitive) == 0
|
||||||
|
&& !m_passwordSubmitted) {
|
||||||
|
if (m_activeOptions.password.isEmpty()) {
|
||||||
|
const QString message = QStringLiteral("Password prompt received but no password is available.");
|
||||||
|
setState(SessionState::Failed, message);
|
||||||
|
emit connectionError(message, trimmed);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
m_waitingForPasswordPrompt = false;
|
||||||
|
m_passwordSubmitted = true;
|
||||||
|
m_process->write((m_activeOptions.password + QStringLiteral("\n")).toUtf8());
|
||||||
|
emit eventLogged(QStringLiteral("Password prompt received; credentials submitted."));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -199,7 +285,8 @@ void SshSessionBackend::onConnectedProbeTimeout()
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (m_process->state() == QProcess::Running) {
|
if (m_process->state() == QProcess::Running && !m_waitingForHostKeyConfirmation
|
||||||
|
&& !m_waitingForPasswordPrompt) {
|
||||||
setState(SessionState::Connected, QStringLiteral("SSH session established."));
|
setState(SessionState::Connected, QStringLiteral("SSH session established."));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -209,6 +296,10 @@ void SshSessionBackend::setState(SessionState state, const QString& message)
|
|||||||
m_state = state;
|
m_state = state;
|
||||||
emit stateChanged(state, message);
|
emit stateChanged(state, message);
|
||||||
emit eventLogged(message);
|
emit eventLogged(message);
|
||||||
|
|
||||||
|
if (m_state == SessionState::Connected) {
|
||||||
|
applyTerminalSizeIfAvailable();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
bool SshSessionBackend::startSshProcess(const SessionConnectOptions& options)
|
bool SshSessionBackend::startSshProcess(const SessionConnectOptions& options)
|
||||||
@@ -229,12 +320,9 @@ bool SshSessionBackend::startSshProcess(const SessionConnectOptions& options)
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
cleanupAskPassScript();
|
|
||||||
|
|
||||||
QStringList args;
|
QStringList args;
|
||||||
args << QStringLiteral("-N") << QStringLiteral("-T") << QStringLiteral("-p")
|
args << QStringLiteral("-tt") << QStringLiteral("-p") << QString::number(p.port)
|
||||||
<< QString::number(p.port) << QStringLiteral("-o")
|
<< QStringLiteral("-o") << QStringLiteral("ConnectTimeout=12") << QStringLiteral("-o")
|
||||||
<< QStringLiteral("ConnectTimeout=12") << QStringLiteral("-o")
|
|
||||||
<< QStringLiteral("ServerAliveInterval=20") << QStringLiteral("-o")
|
<< QStringLiteral("ServerAliveInterval=20") << QStringLiteral("-o")
|
||||||
<< QStringLiteral("ServerAliveCountMax=2");
|
<< QStringLiteral("ServerAliveCountMax=2");
|
||||||
|
|
||||||
@@ -248,6 +336,8 @@ bool SshSessionBackend::startSshProcess(const SessionConnectOptions& options)
|
|||||||
<< QStringLiteral("UserKnownHostsFile=%1").arg(knownHostsFileForNullDevice());
|
<< QStringLiteral("UserKnownHostsFile=%1").arg(knownHostsFileForNullDevice());
|
||||||
} else if (policy.compare(QStringLiteral("Accept New"), Qt::CaseInsensitive) == 0) {
|
} else if (policy.compare(QStringLiteral("Accept New"), Qt::CaseInsensitive) == 0) {
|
||||||
args << QStringLiteral("-o") << QStringLiteral("StrictHostKeyChecking=accept-new");
|
args << QStringLiteral("-o") << QStringLiteral("StrictHostKeyChecking=accept-new");
|
||||||
|
} else if (policy.compare(QStringLiteral("Ask"), Qt::CaseInsensitive) == 0) {
|
||||||
|
args << QStringLiteral("-o") << QStringLiteral("StrictHostKeyChecking=ask");
|
||||||
} else {
|
} else {
|
||||||
args << QStringLiteral("-o") << QStringLiteral("StrictHostKeyChecking=yes");
|
args << QStringLiteral("-o") << QStringLiteral("StrictHostKeyChecking=yes");
|
||||||
}
|
}
|
||||||
@@ -265,6 +355,7 @@ bool SshSessionBackend::startSshProcess(const SessionConnectOptions& options)
|
|||||||
args << QStringLiteral("-o") << QStringLiteral("PreferredAuthentications=password")
|
args << QStringLiteral("-o") << QStringLiteral("PreferredAuthentications=password")
|
||||||
<< QStringLiteral("-o") << QStringLiteral("PubkeyAuthentication=no")
|
<< QStringLiteral("-o") << QStringLiteral("PubkeyAuthentication=no")
|
||||||
<< QStringLiteral("-o") << QStringLiteral("NumberOfPasswordPrompts=1");
|
<< QStringLiteral("-o") << QStringLiteral("NumberOfPasswordPrompts=1");
|
||||||
|
m_waitingForPasswordPrompt = false;
|
||||||
|
|
||||||
QString askPassError;
|
QString askPassError;
|
||||||
if (!configureAskPass(options, environment, askPassError)) {
|
if (!configureAskPass(options, environment, askPassError)) {
|
||||||
@@ -346,10 +437,11 @@ bool SshSessionBackend::configureAskPass(const SessionConnectOptions& options,
|
|||||||
QTextStream out(&script);
|
QTextStream out(&script);
|
||||||
#ifdef Q_OS_WIN
|
#ifdef Q_OS_WIN
|
||||||
out << "@echo off\r\n";
|
out << "@echo off\r\n";
|
||||||
out << "echo " << escapedForWindowsEcho(options.password) << "\r\n";
|
out << "echo " << options.password << "\r\n";
|
||||||
#else
|
#else
|
||||||
|
const QString escapedPassword = escapeForShellSingleQuotes(options.password);
|
||||||
out << "#!/bin/sh\n";
|
out << "#!/bin/sh\n";
|
||||||
out << "printf '%s\\n' '" << escapeForShellSingleQuotes(options.password) << "'\n";
|
out << "printf '%s\\n' '" << escapedPassword << "'\n";
|
||||||
#endif
|
#endif
|
||||||
out.flush();
|
out.flush();
|
||||||
script.close();
|
script.close();
|
||||||
@@ -368,7 +460,6 @@ bool SshSessionBackend::configureAskPass(const SessionConnectOptions& options,
|
|||||||
if (!environment.contains(QStringLiteral("DISPLAY"))) {
|
if (!environment.contains(QStringLiteral("DISPLAY"))) {
|
||||||
environment.insert(QStringLiteral("DISPLAY"), QStringLiteral(":0"));
|
environment.insert(QStringLiteral("DISPLAY"), QStringLiteral(":0"));
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -407,11 +498,11 @@ QString SshSessionBackend::mapSshError(const QString& rawError) const
|
|||||||
return QStringLiteral("Private key file is not accessible.");
|
return QStringLiteral("Private key file is not accessible.");
|
||||||
}
|
}
|
||||||
if (raw.contains(QStringLiteral("No such file or directory"), Qt::CaseInsensitive)) {
|
if (raw.contains(QStringLiteral("No such file or directory"), Qt::CaseInsensitive)) {
|
||||||
|
if (raw.contains(QStringLiteral("ssh-askpass"), Qt::CaseInsensitive)) {
|
||||||
|
return QStringLiteral("SSH password helper is missing or failed to launch.");
|
||||||
|
}
|
||||||
return QStringLiteral("Required file was not found.");
|
return QStringLiteral("Required file was not found.");
|
||||||
}
|
}
|
||||||
if (raw.contains(QStringLiteral("Text file busy"), Qt::CaseInsensitive)) {
|
|
||||||
return QStringLiteral("Credential helper could not start (text file busy). Retry the connection.");
|
|
||||||
}
|
|
||||||
if (raw.isEmpty()) {
|
if (raw.isEmpty()) {
|
||||||
return QStringLiteral("SSH connection failed for an unknown reason.");
|
return QStringLiteral("SSH connection failed for an unknown reason.");
|
||||||
}
|
}
|
||||||
@@ -427,3 +518,21 @@ QString SshSessionBackend::knownHostsFileForNullDevice() const
|
|||||||
return QStringLiteral("/dev/null");
|
return QStringLiteral("/dev/null");
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void SshSessionBackend::applyTerminalSizeIfAvailable()
|
||||||
|
{
|
||||||
|
if (m_process->state() != QProcess::Running) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (m_terminalColumns <= 0 || m_terminalRows <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const QString command = QStringLiteral("stty cols %1 rows %2\\n")
|
||||||
|
.arg(m_terminalColumns)
|
||||||
|
.arg(m_terminalRows);
|
||||||
|
m_process->write(command.toUtf8());
|
||||||
|
emit eventLogged(
|
||||||
|
QStringLiteral("Applied terminal size: %1x%2").arg(m_terminalColumns).arg(m_terminalRows));
|
||||||
|
}
|
||||||
|
|||||||
@@ -19,11 +19,15 @@ public slots:
|
|||||||
void connectSession(const SessionConnectOptions& options) override;
|
void connectSession(const SessionConnectOptions& options) override;
|
||||||
void disconnectSession() override;
|
void disconnectSession() override;
|
||||||
void reconnectSession(const SessionConnectOptions& options) override;
|
void reconnectSession(const SessionConnectOptions& options) override;
|
||||||
|
void sendInput(const QString& input) override;
|
||||||
|
void confirmHostKey(bool trustHost) override;
|
||||||
|
void updateTerminalSize(int columns, int rows) override;
|
||||||
|
|
||||||
private slots:
|
private slots:
|
||||||
void onProcessStarted();
|
void onProcessStarted();
|
||||||
void onProcessErrorOccurred(QProcess::ProcessError error);
|
void onProcessErrorOccurred(QProcess::ProcessError error);
|
||||||
void onProcessFinished(int exitCode, QProcess::ExitStatus exitStatus);
|
void onProcessFinished(int exitCode, QProcess::ExitStatus exitStatus);
|
||||||
|
void onReadyReadStandardOutput();
|
||||||
void onReadyReadStandardError();
|
void onReadyReadStandardError();
|
||||||
void onConnectedProbeTimeout();
|
void onConnectedProbeTimeout();
|
||||||
|
|
||||||
@@ -34,8 +38,14 @@ private:
|
|||||||
bool m_userInitiatedDisconnect;
|
bool m_userInitiatedDisconnect;
|
||||||
bool m_reconnectPending;
|
bool m_reconnectPending;
|
||||||
SessionConnectOptions m_reconnectOptions;
|
SessionConnectOptions m_reconnectOptions;
|
||||||
|
SessionConnectOptions m_activeOptions;
|
||||||
QString m_lastRawError;
|
QString m_lastRawError;
|
||||||
QString m_askPassScriptPath;
|
QString m_askPassScriptPath;
|
||||||
|
bool m_waitingForPasswordPrompt;
|
||||||
|
bool m_waitingForHostKeyConfirmation;
|
||||||
|
bool m_passwordSubmitted;
|
||||||
|
int m_terminalColumns;
|
||||||
|
int m_terminalRows;
|
||||||
|
|
||||||
void setState(SessionState state, const QString& message);
|
void setState(SessionState state, const QString& message);
|
||||||
bool startSshProcess(const SessionConnectOptions& options);
|
bool startSshProcess(const SessionConnectOptions& options);
|
||||||
@@ -45,6 +55,7 @@ private:
|
|||||||
void cleanupAskPassScript();
|
void cleanupAskPassScript();
|
||||||
QString mapSshError(const QString& rawError) const;
|
QString mapSshError(const QString& rawError) const;
|
||||||
QString knownHostsFileForNullDevice() const;
|
QString knownHostsFileForNullDevice() const;
|
||||||
|
void applyTerminalSizeIfAvailable();
|
||||||
};
|
};
|
||||||
|
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -0,0 +1,536 @@
|
|||||||
|
#include "terminal_view.h"
|
||||||
|
|
||||||
|
#include <QApplication>
|
||||||
|
#include <QClipboard>
|
||||||
|
#include <QColor>
|
||||||
|
#include <QFocusEvent>
|
||||||
|
#include <QFontMetrics>
|
||||||
|
#include <QKeyEvent>
|
||||||
|
#include <QResizeEvent>
|
||||||
|
#include <QTimer>
|
||||||
|
#include <QTextCursor>
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
QString normalizedThemeName(const QString& value)
|
||||||
|
{
|
||||||
|
return value.trimmed().toLower();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TerminalView::TerminalView(QWidget* parent)
|
||||||
|
: QTextEdit(parent),
|
||||||
|
m_bold(false),
|
||||||
|
m_hasFgColor(false),
|
||||||
|
m_hasBgColor(false)
|
||||||
|
{
|
||||||
|
setReadOnly(false);
|
||||||
|
setUndoRedoEnabled(false);
|
||||||
|
setAcceptRichText(false);
|
||||||
|
setLineWrapMode(QTextEdit::NoWrap);
|
||||||
|
setContextMenuPolicy(Qt::NoContextMenu);
|
||||||
|
setCursorWidth(2);
|
||||||
|
document()->setMaximumBlockCount(4000);
|
||||||
|
|
||||||
|
applyThemePalette(paletteByName(QStringLiteral("Dark")));
|
||||||
|
resetSgrState();
|
||||||
|
|
||||||
|
QTimer::singleShot(0, this, [this]() {
|
||||||
|
moveCursor(QTextCursor::End);
|
||||||
|
emitTerminalSize();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
QStringList TerminalView::themeNames()
|
||||||
|
{
|
||||||
|
return {QStringLiteral("Dark"), QStringLiteral("Light"), QStringLiteral("Solarized Dark")};
|
||||||
|
}
|
||||||
|
|
||||||
|
void TerminalView::setThemeName(const QString& themeName)
|
||||||
|
{
|
||||||
|
applyThemePalette(paletteByName(themeName));
|
||||||
|
}
|
||||||
|
|
||||||
|
void TerminalView::setFontPointSize(int pointSize)
|
||||||
|
{
|
||||||
|
QFont updatedFont = font();
|
||||||
|
updatedFont.setPointSize(pointSize);
|
||||||
|
setFont(updatedFont);
|
||||||
|
emitTerminalSize();
|
||||||
|
}
|
||||||
|
|
||||||
|
void TerminalView::appendTerminalData(const QString& data)
|
||||||
|
{
|
||||||
|
if (data.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const QString merged = m_pendingEscape + data;
|
||||||
|
m_pendingEscape.clear();
|
||||||
|
|
||||||
|
QString plainBuffer;
|
||||||
|
|
||||||
|
for (int i = 0; i < merged.size();) {
|
||||||
|
const QChar ch = merged.at(i);
|
||||||
|
|
||||||
|
if (ch == QChar::fromLatin1('\x1b')) {
|
||||||
|
if (!plainBuffer.isEmpty()) {
|
||||||
|
appendTextChunk(plainBuffer);
|
||||||
|
plainBuffer.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (i + 1 >= merged.size()) {
|
||||||
|
m_pendingEscape = merged.mid(i);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (merged.at(i + 1) != QChar::fromLatin1('[')) {
|
||||||
|
i += 2;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
int end = i + 2;
|
||||||
|
while (end < merged.size()) {
|
||||||
|
const ushort c = merged.at(end).unicode();
|
||||||
|
if (c >= 0x40 && c <= 0x7e) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
++end;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (end >= merged.size()) {
|
||||||
|
m_pendingEscape = merged.mid(i);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const QChar finalByte = merged.at(end);
|
||||||
|
const QString params = merged.mid(i + 2, end - (i + 2));
|
||||||
|
|
||||||
|
if (finalByte == QChar::fromLatin1('m')) {
|
||||||
|
handleSgrSequence(params);
|
||||||
|
} else if (finalByte == QChar::fromLatin1('J')) {
|
||||||
|
if (params.isEmpty() || params == QStringLiteral("2")) {
|
||||||
|
clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
i = end + 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ch == QChar::fromLatin1('\r')) {
|
||||||
|
const bool hasLfAfter = (i + 1 < merged.size() && merged.at(i + 1) == QChar::fromLatin1('\n'));
|
||||||
|
if (!hasLfAfter) {
|
||||||
|
plainBuffer.append(QChar::fromLatin1('\n'));
|
||||||
|
}
|
||||||
|
++i;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
plainBuffer.append(ch);
|
||||||
|
++i;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!plainBuffer.isEmpty()) {
|
||||||
|
appendTextChunk(plainBuffer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void TerminalView::keyPressEvent(QKeyEvent* event)
|
||||||
|
{
|
||||||
|
if (event == nullptr) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
moveCursor(QTextCursor::End);
|
||||||
|
|
||||||
|
const Qt::KeyboardModifiers modifiers = event->modifiers();
|
||||||
|
|
||||||
|
if (modifiers == (Qt::ControlModifier | Qt::ShiftModifier)
|
||||||
|
&& event->key() == Qt::Key_C) {
|
||||||
|
const QString selected = textCursor().selectedText();
|
||||||
|
if (!selected.isEmpty()) {
|
||||||
|
QApplication::clipboard()->setText(selected);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (modifiers == Qt::ControlModifier) {
|
||||||
|
switch (event->key()) {
|
||||||
|
case Qt::Key_C:
|
||||||
|
emit inputGenerated(QStringLiteral("\x03"));
|
||||||
|
return;
|
||||||
|
case Qt::Key_D:
|
||||||
|
emit inputGenerated(QStringLiteral("\x04"));
|
||||||
|
return;
|
||||||
|
case Qt::Key_L:
|
||||||
|
emit inputGenerated(QStringLiteral("\x0c"));
|
||||||
|
return;
|
||||||
|
case Qt::Key_V: {
|
||||||
|
const QString clipboardText = QApplication::clipboard()->text();
|
||||||
|
if (!clipboardText.isEmpty()) {
|
||||||
|
emit inputGenerated(clipboardText);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (event->key()) {
|
||||||
|
case Qt::Key_Return:
|
||||||
|
case Qt::Key_Enter:
|
||||||
|
emit inputGenerated(QStringLiteral("\n"));
|
||||||
|
return;
|
||||||
|
case Qt::Key_Backspace:
|
||||||
|
emit inputGenerated(QStringLiteral("\x7f"));
|
||||||
|
return;
|
||||||
|
case Qt::Key_Tab:
|
||||||
|
emit inputGenerated(QStringLiteral("\t"));
|
||||||
|
return;
|
||||||
|
case Qt::Key_Left:
|
||||||
|
emit inputGenerated(QStringLiteral("\x1b[D"));
|
||||||
|
return;
|
||||||
|
case Qt::Key_Right:
|
||||||
|
emit inputGenerated(QStringLiteral("\x1b[C"));
|
||||||
|
return;
|
||||||
|
case Qt::Key_Up:
|
||||||
|
emit inputGenerated(QStringLiteral("\x1b[A"));
|
||||||
|
return;
|
||||||
|
case Qt::Key_Down:
|
||||||
|
emit inputGenerated(QStringLiteral("\x1b[B"));
|
||||||
|
return;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const QString text = event->text();
|
||||||
|
if (!text.isEmpty()) {
|
||||||
|
emit inputGenerated(text);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void TerminalView::focusInEvent(QFocusEvent* event)
|
||||||
|
{
|
||||||
|
QTextEdit::focusInEvent(event);
|
||||||
|
moveCursor(QTextCursor::End);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool TerminalView::focusNextPrevChild(bool next)
|
||||||
|
{
|
||||||
|
Q_UNUSED(next);
|
||||||
|
// Tab/Shift+Tab must reach keyPressEvent() and be forwarded to the
|
||||||
|
// remote session instead of moving focus to the next local widget.
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
void TerminalView::resizeEvent(QResizeEvent* event)
|
||||||
|
{
|
||||||
|
QTextEdit::resizeEvent(event);
|
||||||
|
emitTerminalSize();
|
||||||
|
}
|
||||||
|
|
||||||
|
TerminalView::ThemePalette TerminalView::paletteByName(const QString& themeName)
|
||||||
|
{
|
||||||
|
const QString theme = normalizedThemeName(themeName);
|
||||||
|
|
||||||
|
if (theme == QStringLiteral("light")) {
|
||||||
|
return ThemePalette{QStringLiteral("Light"),
|
||||||
|
QColor(QStringLiteral("#ececec")),
|
||||||
|
QColor(QStringLiteral("#000000")),
|
||||||
|
{QColor(QStringLiteral("#000000")),
|
||||||
|
QColor(QStringLiteral("#aa0000")),
|
||||||
|
QColor(QStringLiteral("#008000")),
|
||||||
|
QColor(QStringLiteral("#7a5f00")),
|
||||||
|
QColor(QStringLiteral("#0033cc")),
|
||||||
|
QColor(QStringLiteral("#8a00a8")),
|
||||||
|
QColor(QStringLiteral("#005f87")),
|
||||||
|
QColor(QStringLiteral("#333333"))},
|
||||||
|
{QColor(QStringLiteral("#5c5c5c")),
|
||||||
|
QColor(QStringLiteral("#d30000")),
|
||||||
|
QColor(QStringLiteral("#00a000")),
|
||||||
|
QColor(QStringLiteral("#9a7700")),
|
||||||
|
QColor(QStringLiteral("#0055ff")),
|
||||||
|
QColor(QStringLiteral("#b300db")),
|
||||||
|
QColor(QStringLiteral("#007ea7")),
|
||||||
|
QColor(QStringLiteral("#111111"))}};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (theme == QStringLiteral("solarized dark")) {
|
||||||
|
return ThemePalette{QStringLiteral("Solarized Dark"),
|
||||||
|
QColor(QStringLiteral("#002b36")),
|
||||||
|
QColor(QStringLiteral("#839496")),
|
||||||
|
{QColor(QStringLiteral("#073642")),
|
||||||
|
QColor(QStringLiteral("#dc322f")),
|
||||||
|
QColor(QStringLiteral("#859900")),
|
||||||
|
QColor(QStringLiteral("#b58900")),
|
||||||
|
QColor(QStringLiteral("#268bd2")),
|
||||||
|
QColor(QStringLiteral("#d33682")),
|
||||||
|
QColor(QStringLiteral("#2aa198")),
|
||||||
|
QColor(QStringLiteral("#eee8d5"))},
|
||||||
|
{QColor(QStringLiteral("#586e75")),
|
||||||
|
QColor(QStringLiteral("#cb4b16")),
|
||||||
|
QColor(QStringLiteral("#586e75")),
|
||||||
|
QColor(QStringLiteral("#657b83")),
|
||||||
|
QColor(QStringLiteral("#839496")),
|
||||||
|
QColor(QStringLiteral("#6c71c4")),
|
||||||
|
QColor(QStringLiteral("#93a1a1")),
|
||||||
|
QColor(QStringLiteral("#fdf6e3"))}};
|
||||||
|
}
|
||||||
|
|
||||||
|
return ThemePalette{QStringLiteral("Dark"),
|
||||||
|
QColor(QStringLiteral("#1e1e1e")),
|
||||||
|
QColor(QStringLiteral("#d4d4d4")),
|
||||||
|
{QColor(QStringLiteral("#000000")),
|
||||||
|
QColor(QStringLiteral("#cd3131")),
|
||||||
|
QColor(QStringLiteral("#0dbc79")),
|
||||||
|
QColor(QStringLiteral("#e5e510")),
|
||||||
|
QColor(QStringLiteral("#2472c8")),
|
||||||
|
QColor(QStringLiteral("#bc3fbc")),
|
||||||
|
QColor(QStringLiteral("#11a8cd")),
|
||||||
|
QColor(QStringLiteral("#e5e5e5"))},
|
||||||
|
{QColor(QStringLiteral("#666666")),
|
||||||
|
QColor(QStringLiteral("#f14c4c")),
|
||||||
|
QColor(QStringLiteral("#23d18b")),
|
||||||
|
QColor(QStringLiteral("#f5f543")),
|
||||||
|
QColor(QStringLiteral("#3b8eea")),
|
||||||
|
QColor(QStringLiteral("#d670d6")),
|
||||||
|
QColor(QStringLiteral("#29b8db")),
|
||||||
|
QColor(QStringLiteral("#ffffff"))}};
|
||||||
|
}
|
||||||
|
|
||||||
|
QColor TerminalView::colorFrom256Index(int index)
|
||||||
|
{
|
||||||
|
if (index < 0) {
|
||||||
|
index = 0;
|
||||||
|
}
|
||||||
|
if (index > 255) {
|
||||||
|
index = 255;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (index < 16) {
|
||||||
|
static const std::array<QColor, 16> base = {
|
||||||
|
QColor(QStringLiteral("#000000")), QColor(QStringLiteral("#800000")),
|
||||||
|
QColor(QStringLiteral("#008000")), QColor(QStringLiteral("#808000")),
|
||||||
|
QColor(QStringLiteral("#000080")), QColor(QStringLiteral("#800080")),
|
||||||
|
QColor(QStringLiteral("#008080")), QColor(QStringLiteral("#c0c0c0")),
|
||||||
|
QColor(QStringLiteral("#808080")), QColor(QStringLiteral("#ff0000")),
|
||||||
|
QColor(QStringLiteral("#00ff00")), QColor(QStringLiteral("#ffff00")),
|
||||||
|
QColor(QStringLiteral("#0000ff")), QColor(QStringLiteral("#ff00ff")),
|
||||||
|
QColor(QStringLiteral("#00ffff")), QColor(QStringLiteral("#ffffff"))};
|
||||||
|
return base.at(static_cast<size_t>(index));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (index >= 16 && index <= 231) {
|
||||||
|
const int c = index - 16;
|
||||||
|
const int r = c / 36;
|
||||||
|
const int g = (c / 6) % 6;
|
||||||
|
const int b = c % 6;
|
||||||
|
|
||||||
|
const auto channel = [](int v) { return v == 0 ? 0 : 55 + v * 40; };
|
||||||
|
return QColor(channel(r), channel(g), channel(b));
|
||||||
|
}
|
||||||
|
|
||||||
|
const int gray = 8 + (index - 232) * 10;
|
||||||
|
return QColor(gray, gray, gray);
|
||||||
|
}
|
||||||
|
|
||||||
|
void TerminalView::applyThemePalette(const ThemePalette& palette)
|
||||||
|
{
|
||||||
|
m_palette = palette;
|
||||||
|
|
||||||
|
const QString stylesheet = QStringLiteral("QTextEdit { background: %1; color: %2; }")
|
||||||
|
.arg(m_palette.background.name(), m_palette.foreground.name());
|
||||||
|
setStyleSheet(stylesheet);
|
||||||
|
|
||||||
|
if (!m_hasFgColor) {
|
||||||
|
m_fgColor = m_palette.foreground;
|
||||||
|
}
|
||||||
|
if (!m_hasBgColor) {
|
||||||
|
m_bgColor = m_palette.background;
|
||||||
|
}
|
||||||
|
applyCurrentFormat();
|
||||||
|
}
|
||||||
|
|
||||||
|
void TerminalView::applyCurrentFormat()
|
||||||
|
{
|
||||||
|
m_currentFormat = QTextCharFormat();
|
||||||
|
m_currentFormat.setForeground(m_hasFgColor ? m_fgColor : m_palette.foreground);
|
||||||
|
if (m_hasBgColor) {
|
||||||
|
m_currentFormat.setBackground(m_bgColor);
|
||||||
|
}
|
||||||
|
QFont font = currentFont();
|
||||||
|
font.setBold(m_bold);
|
||||||
|
m_currentFormat.setFont(font);
|
||||||
|
}
|
||||||
|
|
||||||
|
void TerminalView::resetSgrState()
|
||||||
|
{
|
||||||
|
m_bold = false;
|
||||||
|
m_hasFgColor = false;
|
||||||
|
m_hasBgColor = false;
|
||||||
|
m_fgColor = m_palette.foreground;
|
||||||
|
m_bgColor = m_palette.background;
|
||||||
|
applyCurrentFormat();
|
||||||
|
}
|
||||||
|
|
||||||
|
void TerminalView::handleSgrSequence(const QString& params)
|
||||||
|
{
|
||||||
|
QStringList parts = params.split(QChar::fromLatin1(';'), Qt::KeepEmptyParts);
|
||||||
|
if (parts.isEmpty()) {
|
||||||
|
parts.push_back(QStringLiteral("0"));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int i = 0; i < parts.size(); ++i) {
|
||||||
|
const QString part = parts.at(i).trimmed();
|
||||||
|
bool ok = false;
|
||||||
|
const int code = part.isEmpty() ? 0 : part.toInt(&ok);
|
||||||
|
if (!ok && !part.isEmpty()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (code == 0) {
|
||||||
|
resetSgrState();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (code == 1) {
|
||||||
|
m_bold = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (code == 22) {
|
||||||
|
m_bold = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (code == 39) {
|
||||||
|
m_hasFgColor = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (code == 49) {
|
||||||
|
m_hasBgColor = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (code >= 30 && code <= 37) {
|
||||||
|
m_fgColor = paletteColor(false, code - 30, false);
|
||||||
|
m_hasFgColor = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (code >= 90 && code <= 97) {
|
||||||
|
m_fgColor = paletteColor(false, code - 90, true);
|
||||||
|
m_hasFgColor = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (code >= 40 && code <= 47) {
|
||||||
|
m_bgColor = paletteColor(true, code - 40, false);
|
||||||
|
m_hasBgColor = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (code >= 100 && code <= 107) {
|
||||||
|
m_bgColor = paletteColor(true, code - 100, true);
|
||||||
|
m_hasBgColor = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (code == 38 || code == 48) {
|
||||||
|
const bool background = (code == 48);
|
||||||
|
if (i + 1 >= parts.size()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const int mode = parts.at(i + 1).toInt(&ok);
|
||||||
|
if (!ok) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode == 5 && i + 2 < parts.size()) {
|
||||||
|
const int index = parts.at(i + 2).toInt(&ok);
|
||||||
|
if (ok) {
|
||||||
|
const QColor color = colorFrom256Index(index);
|
||||||
|
if (background) {
|
||||||
|
m_bgColor = color;
|
||||||
|
m_hasBgColor = true;
|
||||||
|
} else {
|
||||||
|
m_fgColor = color;
|
||||||
|
m_hasFgColor = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
i += 2;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode == 2 && i + 4 < parts.size()) {
|
||||||
|
const int r = parts.at(i + 2).toInt(&ok);
|
||||||
|
if (!ok) {
|
||||||
|
i += 4;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const int g = parts.at(i + 3).toInt(&ok);
|
||||||
|
if (!ok) {
|
||||||
|
i += 4;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const int b = parts.at(i + 4).toInt(&ok);
|
||||||
|
if (!ok) {
|
||||||
|
i += 4;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const QColor color(r, g, b);
|
||||||
|
if (background) {
|
||||||
|
m_bgColor = color;
|
||||||
|
m_hasBgColor = true;
|
||||||
|
} else {
|
||||||
|
m_fgColor = color;
|
||||||
|
m_hasFgColor = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
i += 4;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
applyCurrentFormat();
|
||||||
|
}
|
||||||
|
|
||||||
|
void TerminalView::appendTextChunk(const QString& text)
|
||||||
|
{
|
||||||
|
if (text.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
QTextCursor cursor = textCursor();
|
||||||
|
cursor.movePosition(QTextCursor::End);
|
||||||
|
cursor.insertText(text, m_currentFormat);
|
||||||
|
setTextCursor(cursor);
|
||||||
|
ensureCursorVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
QColor TerminalView::paletteColor(bool, int index, bool bright) const
|
||||||
|
{
|
||||||
|
const int safeIndex = std::clamp(index, 0, 7);
|
||||||
|
return bright ? m_palette.bright.at(static_cast<size_t>(safeIndex))
|
||||||
|
: m_palette.normal.at(static_cast<size_t>(safeIndex));
|
||||||
|
}
|
||||||
|
|
||||||
|
int TerminalView::terminalColumns() const
|
||||||
|
{
|
||||||
|
const QFontMetrics metrics(font());
|
||||||
|
const int cellWidth = std::max(1, metrics.horizontalAdvance(QChar::fromLatin1('M')));
|
||||||
|
return std::max(1, viewport()->width() / cellWidth);
|
||||||
|
}
|
||||||
|
|
||||||
|
int TerminalView::terminalRows() const
|
||||||
|
{
|
||||||
|
const QFontMetrics metrics(font());
|
||||||
|
const int cellHeight = std::max(1, metrics.lineSpacing());
|
||||||
|
return std::max(1, viewport()->height() / cellHeight);
|
||||||
|
}
|
||||||
|
|
||||||
|
void TerminalView::emitTerminalSize()
|
||||||
|
{
|
||||||
|
emit terminalSizeChanged(terminalColumns(), terminalRows());
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
#ifndef ORBITHUB_TERMINAL_VIEW_H
|
||||||
|
#define ORBITHUB_TERMINAL_VIEW_H
|
||||||
|
|
||||||
|
#include <QTextEdit>
|
||||||
|
|
||||||
|
#include <array>
|
||||||
|
|
||||||
|
class QKeyEvent;
|
||||||
|
class QFocusEvent;
|
||||||
|
class QResizeEvent;
|
||||||
|
|
||||||
|
class TerminalView : public QTextEdit
|
||||||
|
{
|
||||||
|
Q_OBJECT
|
||||||
|
|
||||||
|
public:
|
||||||
|
explicit TerminalView(QWidget* parent = nullptr);
|
||||||
|
|
||||||
|
static QStringList themeNames();
|
||||||
|
void setThemeName(const QString& themeName);
|
||||||
|
void appendTerminalData(const QString& data);
|
||||||
|
void setFontPointSize(int pointSize);
|
||||||
|
|
||||||
|
signals:
|
||||||
|
void inputGenerated(const QString& input);
|
||||||
|
void terminalSizeChanged(int columns, int rows);
|
||||||
|
|
||||||
|
protected:
|
||||||
|
void keyPressEvent(QKeyEvent* event) override;
|
||||||
|
void focusInEvent(QFocusEvent* event) override;
|
||||||
|
void resizeEvent(QResizeEvent* event) override;
|
||||||
|
bool focusNextPrevChild(bool next) override;
|
||||||
|
|
||||||
|
private:
|
||||||
|
struct ThemePalette {
|
||||||
|
QString name;
|
||||||
|
QColor background;
|
||||||
|
QColor foreground;
|
||||||
|
std::array<QColor, 8> normal;
|
||||||
|
std::array<QColor, 8> bright;
|
||||||
|
};
|
||||||
|
|
||||||
|
ThemePalette m_palette;
|
||||||
|
QString m_pendingEscape;
|
||||||
|
QString m_rawHistory;
|
||||||
|
bool m_bold;
|
||||||
|
bool m_hasFgColor;
|
||||||
|
bool m_hasBgColor;
|
||||||
|
QColor m_fgColor;
|
||||||
|
QColor m_bgColor;
|
||||||
|
QTextCharFormat m_currentFormat;
|
||||||
|
|
||||||
|
static ThemePalette paletteByName(const QString& themeName);
|
||||||
|
static QColor colorFrom256Index(int index);
|
||||||
|
|
||||||
|
void applyThemePalette(const ThemePalette& palette);
|
||||||
|
void applyCurrentFormat();
|
||||||
|
void resetSgrState();
|
||||||
|
void handleSgrSequence(const QString& params);
|
||||||
|
void appendTextChunk(const QString& text);
|
||||||
|
QColor paletteColor(bool background, int index, bool bright) const;
|
||||||
|
void processData(const QString& data, bool storeInHistory);
|
||||||
|
int terminalColumns() const;
|
||||||
|
int terminalRows() const;
|
||||||
|
void emitTerminalSize();
|
||||||
|
};
|
||||||
|
|
||||||
|
#endif
|
||||||
@@ -24,3 +24,16 @@ void UnsupportedSessionBackend::reconnectSession(const SessionConnectOptions& op
|
|||||||
{
|
{
|
||||||
connectSession(options);
|
connectSession(options);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void UnsupportedSessionBackend::sendInput(const QString&)
|
||||||
|
{
|
||||||
|
emit eventLogged(QStringLiteral("Input ignored: protocol backend is not interactive."));
|
||||||
|
}
|
||||||
|
|
||||||
|
void UnsupportedSessionBackend::confirmHostKey(bool)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
void UnsupportedSessionBackend::updateTerminalSize(int, int)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ public slots:
|
|||||||
void connectSession(const SessionConnectOptions& options) override;
|
void connectSession(const SessionConnectOptions& options) override;
|
||||||
void disconnectSession() override;
|
void disconnectSession() override;
|
||||||
void reconnectSession(const SessionConnectOptions& options) override;
|
void reconnectSession(const SessionConnectOptions& options) override;
|
||||||
|
void sendInput(const QString& input) override;
|
||||||
|
void confirmHostKey(bool trustHost) override;
|
||||||
|
void updateTerminalSize(int columns, int rows) override;
|
||||||
};
|
};
|
||||||
|
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
#include "user_guide_dialog.h"
|
||||||
|
|
||||||
|
#include <QApplication>
|
||||||
|
#include <QDesktopServices>
|
||||||
|
#include <QDialogButtonBox>
|
||||||
|
#include <QFile>
|
||||||
|
#include <QListWidget>
|
||||||
|
#include <QSplitter>
|
||||||
|
#include <QTextBrowser>
|
||||||
|
#include <QTextStream>
|
||||||
|
#include <QUrl>
|
||||||
|
#include <QVBoxLayout>
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
QString slugify(const QString& title)
|
||||||
|
{
|
||||||
|
QString slug;
|
||||||
|
slug.reserve(title.size());
|
||||||
|
bool lastWasHyphen = false;
|
||||||
|
for (const QChar& ch : title) {
|
||||||
|
if (ch.isLetterOrNumber()) {
|
||||||
|
slug += ch.toLower();
|
||||||
|
lastWasHyphen = false;
|
||||||
|
} else if (!lastWasHyphen && !slug.isEmpty()) {
|
||||||
|
slug += QLatin1Char('-');
|
||||||
|
lastWasHyphen = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
while (slug.endsWith(QLatin1Char('-'))) {
|
||||||
|
slug.chop(1);
|
||||||
|
}
|
||||||
|
return slug;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
UserGuideDialog::UserGuideDialog(QWidget* parent)
|
||||||
|
: QDialog(parent), m_sectionList(nullptr), m_browser(nullptr)
|
||||||
|
{
|
||||||
|
setWindowTitle(QStringLiteral("OrbitHub User Guide"));
|
||||||
|
setWindowIcon(QApplication::windowIcon());
|
||||||
|
resize(900, 640);
|
||||||
|
|
||||||
|
auto* layout = new QVBoxLayout(this);
|
||||||
|
layout->setContentsMargins(16, 16, 16, 16);
|
||||||
|
layout->setSpacing(12);
|
||||||
|
|
||||||
|
auto* splitter = new QSplitter(Qt::Horizontal, this);
|
||||||
|
|
||||||
|
m_sectionList = new QListWidget(splitter);
|
||||||
|
m_sectionList->setMaximumWidth(220);
|
||||||
|
|
||||||
|
m_browser = new QTextBrowser(splitter);
|
||||||
|
m_browser->setOpenExternalLinks(false);
|
||||||
|
m_browser->setOpenLinks(false);
|
||||||
|
|
||||||
|
splitter->addWidget(m_sectionList);
|
||||||
|
splitter->addWidget(m_browser);
|
||||||
|
splitter->setStretchFactor(0, 0);
|
||||||
|
splitter->setStretchFactor(1, 1);
|
||||||
|
|
||||||
|
auto* buttons = new QDialogButtonBox(QDialogButtonBox::Close, this);
|
||||||
|
connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::reject);
|
||||||
|
connect(buttons, &QDialogButtonBox::accepted, this, &QDialog::accept);
|
||||||
|
|
||||||
|
layout->addWidget(splitter, 1);
|
||||||
|
layout->addWidget(buttons);
|
||||||
|
|
||||||
|
loadSections();
|
||||||
|
|
||||||
|
connect(m_sectionList,
|
||||||
|
&QListWidget::currentRowChanged,
|
||||||
|
this,
|
||||||
|
[this](int row) { showSection(row); });
|
||||||
|
connect(m_browser, &QTextBrowser::anchorClicked, this, &UserGuideDialog::onAnchorClicked);
|
||||||
|
|
||||||
|
if (!m_sections.isEmpty()) {
|
||||||
|
m_sectionList->setCurrentRow(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void UserGuideDialog::loadSections()
|
||||||
|
{
|
||||||
|
QFile file(QStringLiteral(":/docs/USER_GUIDE.md"));
|
||||||
|
if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
QTextStream stream(&file);
|
||||||
|
const QString content = stream.readAll();
|
||||||
|
const QStringList lines = content.split(QLatin1Char('\n'));
|
||||||
|
|
||||||
|
QString currentTitle;
|
||||||
|
QStringList currentLines;
|
||||||
|
|
||||||
|
auto flushSection = [this, ¤tTitle, ¤tLines]() {
|
||||||
|
if (currentTitle.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Section section;
|
||||||
|
section.title = currentTitle;
|
||||||
|
section.anchor = slugify(currentTitle);
|
||||||
|
section.markdown = currentLines.join(QLatin1Char('\n'));
|
||||||
|
m_sections.append(section);
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const QString& line : lines) {
|
||||||
|
if (line.startsWith(QStringLiteral("## "))) {
|
||||||
|
flushSection();
|
||||||
|
currentTitle = line.mid(3).trimmed();
|
||||||
|
currentLines.clear();
|
||||||
|
}
|
||||||
|
currentLines.append(line);
|
||||||
|
}
|
||||||
|
flushSection();
|
||||||
|
|
||||||
|
for (const Section& section : m_sections) {
|
||||||
|
m_sectionList->addItem(section.title);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void UserGuideDialog::showSection(int index)
|
||||||
|
{
|
||||||
|
if (index < 0 || index >= m_sections.size()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
m_browser->setMarkdown(m_sections[index].markdown);
|
||||||
|
}
|
||||||
|
|
||||||
|
void UserGuideDialog::onAnchorClicked(const QUrl& url)
|
||||||
|
{
|
||||||
|
if (!url.scheme().isEmpty()) {
|
||||||
|
QDesktopServices::openUrl(url);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const QString fragment = url.fragment();
|
||||||
|
if (fragment.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int i = 0; i < m_sections.size(); ++i) {
|
||||||
|
if (m_sections[i].anchor == fragment) {
|
||||||
|
m_sectionList->setCurrentRow(i);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
#ifndef ORBITHUB_USER_GUIDE_DIALOG_H
|
||||||
|
#define ORBITHUB_USER_GUIDE_DIALOG_H
|
||||||
|
|
||||||
|
#include <QDialog>
|
||||||
|
#include <QString>
|
||||||
|
#include <QVector>
|
||||||
|
|
||||||
|
class QListWidget;
|
||||||
|
class QTextBrowser;
|
||||||
|
class QUrl;
|
||||||
|
|
||||||
|
class UserGuideDialog : public QDialog
|
||||||
|
{
|
||||||
|
Q_OBJECT
|
||||||
|
|
||||||
|
public:
|
||||||
|
explicit UserGuideDialog(QWidget* parent = nullptr);
|
||||||
|
|
||||||
|
private:
|
||||||
|
struct Section {
|
||||||
|
QString title;
|
||||||
|
QString anchor;
|
||||||
|
QString markdown;
|
||||||
|
};
|
||||||
|
|
||||||
|
void loadSections();
|
||||||
|
void showSection(int index);
|
||||||
|
void onAnchorClicked(const QUrl& url);
|
||||||
|
|
||||||
|
QListWidget* m_sectionList;
|
||||||
|
QTextBrowser* m_browser;
|
||||||
|
QVector<Section> m_sections;
|
||||||
|
};
|
||||||
|
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
---
|
||||||
|
AccessModifierOffset: -2
|
||||||
|
AlignAfterOpenBracket: Align
|
||||||
|
AlignConsecutiveAssignments: false
|
||||||
|
AlignConsecutiveDeclarations: false
|
||||||
|
AlignEscapedNewlines: Left
|
||||||
|
AlignOperands: true
|
||||||
|
AlignTrailingComments: true
|
||||||
|
AllowAllParametersOfDeclarationOnNextLine: true
|
||||||
|
AllowShortBlocksOnASingleLine: false
|
||||||
|
AllowShortCaseLabelsOnASingleLine: false
|
||||||
|
AllowShortFunctionsOnASingleLine: None
|
||||||
|
AllowShortIfStatementsOnASingleLine: false
|
||||||
|
AllowShortLoopsOnASingleLine: false
|
||||||
|
AlwaysBreakAfterDefinitionReturnType: None
|
||||||
|
AlwaysBreakAfterReturnType: None
|
||||||
|
AlwaysBreakBeforeMultilineStrings: false
|
||||||
|
AlwaysBreakTemplateDeclarations: false
|
||||||
|
BinPackArguments: true
|
||||||
|
BinPackParameters: true
|
||||||
|
BraceWrapping:
|
||||||
|
AfterClass: true
|
||||||
|
AfterControlStatement: true
|
||||||
|
AfterEnum: true
|
||||||
|
AfterFunction: true
|
||||||
|
AfterNamespace: true
|
||||||
|
AfterObjCDeclaration: true
|
||||||
|
AfterStruct: true
|
||||||
|
AfterUnion: true
|
||||||
|
AfterExternBlock: true
|
||||||
|
BeforeCatch: true
|
||||||
|
BeforeElse: true
|
||||||
|
IndentBraces: false
|
||||||
|
SplitEmptyFunction: true
|
||||||
|
SplitEmptyRecord: true
|
||||||
|
SplitEmptyNamespace: true
|
||||||
|
BreakBeforeBinaryOperators: None
|
||||||
|
BreakBeforeBraces: Allman
|
||||||
|
BreakBeforeInheritanceComma: false
|
||||||
|
BreakBeforeTernaryOperators: true
|
||||||
|
BreakConstructorInitializersBeforeComma: false
|
||||||
|
BreakConstructorInitializers: BeforeColon
|
||||||
|
BreakStringLiterals: true
|
||||||
|
ColumnLimit: 100
|
||||||
|
CommentPragmas: '^ IWYU pragma:'
|
||||||
|
CompactNamespaces: false
|
||||||
|
ConstructorInitializerAllOnOneLineOrOnePerLine: false
|
||||||
|
ConstructorInitializerIndentWidth: 4
|
||||||
|
ContinuationIndentWidth: 4
|
||||||
|
Cpp11BracedListStyle: false
|
||||||
|
DerivePointerAlignment: false
|
||||||
|
DisableFormat: false
|
||||||
|
ExperimentalAutoDetectBinPacking: false
|
||||||
|
FixNamespaceComments: false
|
||||||
|
IncludeBlocks: Preserve
|
||||||
|
IncludeCategories:
|
||||||
|
- Regex: '^"(llvm|llvm-c|clang|clang-c)/'
|
||||||
|
Priority: 2
|
||||||
|
- Regex: '^(<|"(gtest|gmock|isl|json)/)'
|
||||||
|
Priority: 3
|
||||||
|
- Regex: '.*'
|
||||||
|
Priority: 1
|
||||||
|
IncludeIsMainRegex: '(Test)?$'
|
||||||
|
IndentCaseLabels: true
|
||||||
|
IndentPPDirectives: None
|
||||||
|
IndentWidth: 4
|
||||||
|
IndentWrappedFunctionNames: false
|
||||||
|
KeepEmptyLinesAtTheStartOfBlocks: true
|
||||||
|
MacroBlockBegin: ''
|
||||||
|
MacroBlockEnd: ''
|
||||||
|
MaxEmptyLinesToKeep: 1
|
||||||
|
PenaltyBreakAssignment: 2
|
||||||
|
PenaltyBreakBeforeFirstCallParameter: 19
|
||||||
|
PenaltyBreakComment: 300
|
||||||
|
PenaltyBreakFirstLessLess: 120
|
||||||
|
PenaltyBreakString: 1000
|
||||||
|
PenaltyExcessCharacter: 1000000
|
||||||
|
PenaltyReturnTypeOnItsOwnLine: 60
|
||||||
|
PointerAlignment: Left
|
||||||
|
ReflowComments: true
|
||||||
|
SortIncludes: false
|
||||||
|
SortUsingDeclarations: true
|
||||||
|
SpaceAfterCStyleCast: false
|
||||||
|
SpaceAfterTemplateKeyword: true
|
||||||
|
SpaceBeforeAssignmentOperators: true
|
||||||
|
SpaceBeforeParens: ControlStatements
|
||||||
|
SpaceInEmptyParentheses: false
|
||||||
|
SpacesBeforeTrailingComments: 1
|
||||||
|
SpacesInAngles: false
|
||||||
|
SpacesInContainerLiterals: false
|
||||||
|
SpacesInCStyleCastParentheses: false
|
||||||
|
SpacesInParentheses: false
|
||||||
|
SpacesInSquareBrackets: false
|
||||||
|
TabWidth: 4
|
||||||
|
UseTab: ForIndentation
|
||||||
|
...
|
||||||
|
Language: Cpp
|
||||||
|
Standard: Auto
|
||||||
|
NamespaceIndentation: All
|
||||||
|
ForEachMacros:
|
||||||
|
- foreach
|
||||||
|
- Q_FOREACH
|
||||||
|
- BOOST_FOREACH
|
||||||
|
...
|
||||||
|
Language: ObjC
|
||||||
|
PointerBindsToType: false
|
||||||
|
SortIncludes: false
|
||||||
|
ObjCBlockIndentWidth: 4
|
||||||
|
ObjCSpaceAfterProperty: false
|
||||||
|
ObjCSpaceBeforeProtocolList: true
|
||||||
|
...
|
||||||
|
Language: Java
|
||||||
|
BreakAfterJavaFieldAnnotations: false
|
||||||
|
...
|
||||||
|
Language: JavaScript
|
||||||
|
JavaScriptQuotes: Leave
|
||||||
|
JavaScriptWrapImports: true
|
||||||
|
...
|
||||||
|
Language: Proto
|
||||||
|
...
|
||||||
|
Language: TableGen
|
||||||
|
...
|
||||||
|
Language: TextProto
|
||||||
|
...
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
---
|
||||||
|
Checks: >
|
||||||
|
-*,
|
||||||
|
abseil-*,
|
||||||
|
altera-*,
|
||||||
|
bugprone-*,
|
||||||
|
cert-*,
|
||||||
|
clang-analyzer*,
|
||||||
|
concurrency-*,
|
||||||
|
cppcoreguidelines*,
|
||||||
|
google-*,
|
||||||
|
hicpp-*,
|
||||||
|
llvm-*,
|
||||||
|
modernize-*,
|
||||||
|
objc-*,
|
||||||
|
openmp-*,
|
||||||
|
performance-*,
|
||||||
|
portability-*,
|
||||||
|
readability-*,
|
||||||
|
-altera-id-dependent-backward-branch,
|
||||||
|
-altera-struct-pack-align,
|
||||||
|
-altera-unroll-loops,
|
||||||
|
-cppcoreguidelines-interfaces-global-init,
|
||||||
|
-bugprone-easily-swappable-parameters,
|
||||||
|
-bugprone-assignment-in-if-condition,
|
||||||
|
-bugprone-branch-clone,
|
||||||
|
-bugprone-macro-parentheses,
|
||||||
|
-cert-dcl16-c,
|
||||||
|
-cert-env33-c,
|
||||||
|
-cert-dcl50-cpp,
|
||||||
|
-clang-analyzer-webkit.NoUncountedMemberChecker,
|
||||||
|
-clang-analyzer-optin.performance.Padding,
|
||||||
|
-clang-analyzer-security.insecureAPI.DeprecatedOrUnsafeBufferHandling,
|
||||||
|
-clang-analyzer-security.VAList,
|
||||||
|
-clang-analyzer-valist.Uninitialized,
|
||||||
|
-cppcoreguidelines-pro-bounds-array-to-pointer-decay,
|
||||||
|
-cppcoreguidelines-owning-memory,
|
||||||
|
-cppcoreguidelines-avoid-c-arrays,
|
||||||
|
-cppcoreguidelines-avoid-do-while,
|
||||||
|
-cppcoreguidelines-avoid-magic-numbers,
|
||||||
|
-cppcoreguidelines-avoid-non-const-global-variables,
|
||||||
|
-cppcoreguidelines-macro-to-enum,
|
||||||
|
-cppcoreguidelines-macro-usage,
|
||||||
|
-cppcoreguidelines-pro-type-vararg,
|
||||||
|
-cppcoreguidelines-pro-type-reinterpret-cast,
|
||||||
|
-cppcoreguidelines-pro-bounds-pointer-arithmetic,
|
||||||
|
-cppcoreguidelines-no-malloc,
|
||||||
|
-cppcoreguidelines-use-enum-class,
|
||||||
|
-google-readability-braces-around-statements,
|
||||||
|
-google-readability-todo,
|
||||||
|
-hicpp-avoid-c-arrays,
|
||||||
|
-hicpp-braces-around-statements,
|
||||||
|
-hicpp-no-array-decay,
|
||||||
|
-hicpp-no-assembler,
|
||||||
|
-hicpp-multiway-paths-covered,
|
||||||
|
-hicpp-signed-bitwise,
|
||||||
|
-hicpp-uppercase-literal-suffix,
|
||||||
|
-hicpp-vararg,
|
||||||
|
-hicpp-no-malloc,
|
||||||
|
-llvm-use-ranges,
|
||||||
|
-llvm-header-guard,
|
||||||
|
-llvm-include-order,
|
||||||
|
-llvm-qualified-auto,
|
||||||
|
-llvm-else-after-return,
|
||||||
|
-readability-else-after-return,
|
||||||
|
-readability-avoid-nested-conditional-operator,
|
||||||
|
-modernize-use-using,
|
||||||
|
-modernize-avoid-variadic-functions,
|
||||||
|
-modernize-use-trailing-return-type,
|
||||||
|
-modernize-return-braced-init-list,
|
||||||
|
-modernize-macro-to-enum,
|
||||||
|
-modernize-pass-by-value,
|
||||||
|
-modernize-avoid-c-arrays,
|
||||||
|
-readability-use-anyofallof,
|
||||||
|
-readability-braces-around-statements,
|
||||||
|
-readability-convert-member-functions-to-static,
|
||||||
|
-readability-function-cognitive-complexity,
|
||||||
|
-readability-identifier-length,
|
||||||
|
-readability-implicit-bool-conversion,
|
||||||
|
-readability-magic-numbers,
|
||||||
|
-readability-math-missing-parentheses,
|
||||||
|
-readability-misleading-indentation,
|
||||||
|
-readability-qualified-auto,
|
||||||
|
-readability-redundant-parentheses,
|
||||||
|
-readability-suspicious-call-argument,
|
||||||
|
-readability-string-compare,
|
||||||
|
-readability-uppercase-literal-suffix,
|
||||||
|
-readability-use-concise-preprocessor-directives,
|
||||||
|
-performance-no-int-to-ptr,
|
||||||
|
-performance-enum-size,
|
||||||
|
-performance-avoid-endl,
|
||||||
|
-portability-avoid-pragma-once
|
||||||
|
WarningsAsErrors: ''
|
||||||
|
HeaderFilterRegex: ''
|
||||||
|
FormatStyle: file
|
||||||
|
User: nin
|
||||||
|
CheckOptions:
|
||||||
|
- key: readability-implicit-bool-conversion.AllowIntegerConditions
|
||||||
|
value: 'true'
|
||||||
|
- key: llvm-else-after-return.WarnOnConditionVariables
|
||||||
|
value: 'false'
|
||||||
|
- key: modernize-loop-convert.MinConfidence
|
||||||
|
value: reasonable
|
||||||
|
- key: modernize-replace-auto-ptr.IncludeStyle
|
||||||
|
value: llvm
|
||||||
|
- key: cert-str34-c.DiagnoseSignedUnsignedCharComparisons
|
||||||
|
value: 'false'
|
||||||
|
- key: google-readability-namespace-comments.ShortNamespaceLines
|
||||||
|
value: '10'
|
||||||
|
- key: cert-err33-c.CheckedFunctions
|
||||||
|
value: '::aligned_alloc;::asctime_s;::at_quick_exit;::atexit;::bsearch;::bsearch_s;::btowc;::c16rtomb;::c32rtomb;::calloc;::clock;::cnd_broadcast;::cnd_init;::cnd_signal;::cnd_timedwait;::cnd_wait;::ctime_s;::fclose;::fflush;::fgetc;::fgetpos;::fgets;::fgetwc;::fopen;::fopen_s;::fprintf;::fprintf_s;::fputc;::fputs;::fputwc;::fputws;::fread;::freopen;::freopen_s;::fscanf;::fscanf_s;::fseek;::fsetpos;::ftell;::fwprintf;::fwprintf_s;::fwrite;::fwscanf;::fwscanf_s;::getc;::getchar;::getenv;::getenv_s;::gets_s;::getwc;::getwchar;::gmtime;::gmtime_s;::localtime;::localtime_s;::malloc;::mbrtoc16;::mbrtoc32;::mbsrtowcs;::mbsrtowcs_s;::mbstowcs;::mbstowcs_s;::memchr;::mktime;::mtx_init;::mtx_lock;::mtx_timedlock;::mtx_trylock;::mtx_unlock;::printf_s;::putc;::putwc;::raise;::realloc;::remove;::rename;::scanf;::scanf_s;::setlocale;::setvbuf;::signal;::snprintf;::snprintf_s;::sprintf;::sprintf_s;::sscanf;::sscanf_s;::strchr;::strerror_s;::strftime;::strpbrk;::strrchr;::strstr;::strtod;::strtof;::strtoimax;::strtok;::strtok_s;::strtol;::strtold;::strtoll;::strtoul;::strtoull;::strtoumax;::strxfrm;::swprintf;::swprintf_s;::swscanf;::swscanf_s;::thrd_create;::thrd_detach;::thrd_join;::thrd_sleep;::time;::timespec_get;::tmpfile;::tmpfile_s;::tmpnam;::tmpnam_s;::tss_create;::tss_get;::tss_set;::ungetc;::ungetwc;::vfprintf;::vfprintf_s;::vfscanf;::vfscanf_s;::vfwprintf;::vfwprintf_s;::vfwscanf;::vfwscanf_s;::vprintf_s;::vscanf;::vscanf_s;::vsnprintf;::vsnprintf_s;::vsprintf;::vsprintf_s;::vsscanf;::vsscanf_s;::vswprintf;::vswprintf_s;::vswscanf;::vswscanf_s;::vwprintf_s;::vwscanf;::vwscanf_s;::wcrtomb;::wcschr;::wcsftime;::wcspbrk;::wcsrchr;::wcsrtombs;::wcsrtombs_s;::wcsstr;::wcstod;::wcstof;::wcstoimax;::wcstok;::wcstok_s;::wcstol;::wcstold;::wcstoll;::wcstombs;::wcstombs_s;::wcstoul;::wcstoull;::wcstoumax;::wcsxfrm;::wctob;::wctrans;::wctype;::wmemchr;::wprintf_s;::wscanf;::wscanf_s;'
|
||||||
|
- key: cert-oop54-cpp.WarnOnlyIfThisHasSuspiciousField
|
||||||
|
value: 'false'
|
||||||
|
- key: cert-dcl16-c.NewSuffixes
|
||||||
|
value: 'L;LL;LU;LLU'
|
||||||
|
- key: google-readability-braces-around-statements.ShortStatementLines
|
||||||
|
value: '1'
|
||||||
|
- key: cppcoreguidelines-non-private-member-variables-in-classes.IgnoreClassesWithAllMemberVariablesBeingPublic
|
||||||
|
value: 'true'
|
||||||
|
- key: google-readability-namespace-comments.SpacesBeforeComments
|
||||||
|
value: '2'
|
||||||
|
- key: modernize-loop-convert.MaxCopySize
|
||||||
|
value: '16'
|
||||||
|
- key: modernize-pass-by-value.IncludeStyle
|
||||||
|
value: llvm
|
||||||
|
- key: modernize-use-nullptr.NullMacros
|
||||||
|
value: 'NULL'
|
||||||
|
- key: llvm-qualified-auto.AddConstToQualified
|
||||||
|
value: 'false'
|
||||||
|
- key: modernize-loop-convert.NamingStyle
|
||||||
|
value: CamelCase
|
||||||
|
- key: llvm-else-after-return.WarnOnUnfixable
|
||||||
|
value: 'false'
|
||||||
|
- key: google-readability-function-size.StatementThreshold
|
||||||
|
value: '800'
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
.gitattributes export-ignore
|
||||||
|
.gitignore export-ignore
|
||||||
|
.github export-ignore
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
## Found a bug? - We would like to help you and smash the bug away.
|
||||||
|
1. __Please don't "report" questions as bugs.__
|
||||||
|
* We are reachable via
|
||||||
|
* Matrix room : #FreeRDP:matrix.org (main)
|
||||||
|
* XMPP channel: #FreeRDP#matrix.org@matrix.org (bridged)
|
||||||
|
* IRC channel : #freerdp @ irc.oftc.net (bridged)
|
||||||
|
* We are reachable via mailing list <freerdp-devel@lists.sourceforge.net>
|
||||||
|
* Try our mailing list for discussions/questions
|
||||||
|
1. Before reporting a bug have a look into our issue tracker to see if the bug was already reported and you can add some additional information.
|
||||||
|
1. If it's a __new__ bug - create a new issue.
|
||||||
|
1. For more details see https://github.com/FreeRDP/FreeRDP/wiki/BugReporting
|
||||||
|
|
||||||
|
## To save time and help us identify the issue a bug report should at least contain the following:
|
||||||
|
* a useful description of the bug - "It's not working" isn't good enough - you must try harder ;)
|
||||||
|
* the steps to reproduce the bug
|
||||||
|
* command line you have used
|
||||||
|
* to what system did you connect to? (win8, 2008, ..)
|
||||||
|
* what did you expect to happen?
|
||||||
|
* what actually happened?
|
||||||
|
* freerdp version (e.g. xfreerdp --version) or package version or git commit
|
||||||
|
* freerdp configuration (e.g. xfreerdp --buildconfig)
|
||||||
|
* operating System, architecture, distribution e.g. linux, amd64, debian
|
||||||
|
* if you built it yourself add some notes which branch you have used, also your cmake parameters can help
|
||||||
|
* extra information helping us to find the bug
|
||||||
|
|
||||||
|
## Please remove this text before submitting your issue!
|
||||||
|
|
||||||
|
_Thank you for reporting a bug!_
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
name: Backport
|
||||||
|
about: Create a issue to request/track a backport
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Related pull request for master:
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
---
|
||||||
|
name: Bug report
|
||||||
|
about: Create a report to help us improve
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Found a bug? - We would like to help you and smash the bug away.**
|
||||||
|
1. __Please don't "report" questions as bugs. For these (questions/build instructions/...) please use one of the following means of contact:__
|
||||||
|
* We are reachable via:
|
||||||
|
* Matrix room : #FreeRDP:matrix.org (main)
|
||||||
|
* XMPP channel: #FreeRDP#matrix.org@matrix.org (bridged)
|
||||||
|
* IRC channel : #freerdp @ irc.oftc.net (bridged)
|
||||||
|
* We are reachable via mailing list <freerdp-devel@lists.sourceforge.net>
|
||||||
|
* Try our mailing list for discussions/questions
|
||||||
|
1. Before reporting a bug have a look into our issue tracker to see if the bug was already reported and you can add some additional information.
|
||||||
|
1. If it's a __new__ bug - create a new issue.
|
||||||
|
1. For more details see https://github.com/FreeRDP/FreeRDP/wiki/BugReporting
|
||||||
|
|
||||||
|
|
||||||
|
**Describe the bug**
|
||||||
|
A clear and concise description of what the bug is.
|
||||||
|
|
||||||
|
**To Reproduce**
|
||||||
|
Steps to reproduce the behavior:
|
||||||
|
1. Go to '...'
|
||||||
|
2. Click on '....'
|
||||||
|
3. Scroll down to '....'
|
||||||
|
4. See error
|
||||||
|
|
||||||
|
**Expected behavior**
|
||||||
|
A clear and concise description of what you expected to happen.
|
||||||
|
|
||||||
|
**Screenshots**
|
||||||
|
If applicable, add screenshots to help explain your problem.
|
||||||
|
|
||||||
|
**Application details**
|
||||||
|
* FreeRDP version (`xfreerdp /version`)
|
||||||
|
* Command line used
|
||||||
|
* Output of `xfreerdp /buildconfig`
|
||||||
|
* OS version connecting to (server side)
|
||||||
|
* If available the log output from a run with `/log-level:trace 2>&1 | tee log.txt`
|
||||||
|
* If you built it yourself add some notes which tag/commit/branch you have used, also your cmake parameters and
|
||||||
|
compiler can help
|
||||||
|
|
||||||
|
**Environment (please complete the following information):**
|
||||||
|
- OS: [e.g. Linux/Windows/Android/..]
|
||||||
|
- Version/Distribution: [e.g. Debian 10, Windows 2008, Android 10]
|
||||||
|
- Architecture: [amd64, arm]:
|
||||||
|
|
||||||
|
**Additional context**
|
||||||
|
Add any other context about the problem here.
|
||||||
|
|
||||||
|
** Please remove this text before submitting your issue!
|
||||||
|
|
||||||
|
_Thank you for reporting a bug!_
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
---
|
||||||
|
name: Feature request
|
||||||
|
about: Suggest an idea for this project
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Is your feature request related to a problem? Please describe.**
|
||||||
|
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||||
|
|
||||||
|
**Describe the solution you'd like**
|
||||||
|
A clear and concise description of what you want to happen.
|
||||||
|
|
||||||
|
**Describe alternatives you've considered**
|
||||||
|
A clear and concise description of any alternative solutions or features you've considered.
|
||||||
|
|
||||||
|
**Additional context**
|
||||||
|
Add any other context or screenshots about the feature request here.
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
## This is how are pull requests handled by FreeRDP
|
||||||
|
1. Every new pull request needs to build and pass the unit tests at https://ci.freerdp.com
|
||||||
|
1. At least 1 (better two) people need to review and test a pull request and agree to accept
|
||||||
|
|
||||||
|
## Preparations before creating a pull
|
||||||
|
* Rebase your branch to current master, no merges allowed!
|
||||||
|
* Try to clean up your commit history, group changes to commits
|
||||||
|
* Check your formatting! A _clang-format_ script can be found at ```.clang-format```
|
||||||
|
* The cmake target ```clangformat``` reformats the whole codebase
|
||||||
|
* Optional (but higly recommended)
|
||||||
|
* Run a clang scanbuild before and after your changes to avoid introducing new bugs
|
||||||
|
* Run your compiler at pedantic level to check for new warnings
|
||||||
|
|
||||||
|
## To ease accepting your contribution
|
||||||
|
* Give the pull request a proper name so people looking at it have an basic idea what it is for
|
||||||
|
* Add at least a brief description what it does (or should do :) and what it's good for
|
||||||
|
* Give instructions on how to test your changes
|
||||||
|
* Ideally add unit tests if adding new features
|
||||||
|
|
||||||
|
## What you should be prepared for
|
||||||
|
* fix issues found during the review phase
|
||||||
|
* Joining our chat to talk to other developers or help them test your pull might accelerate acceptance
|
||||||
|
* Matrix room : #FreeRDP:matrix.org (main)
|
||||||
|
* XMPP channel: #FreeRDP#matrix.org@matrix.org (bridged)
|
||||||
|
* IRC channel : #freerdp @ irc.oftc.net (bridged)
|
||||||
|
* Joining our mailing list <freerdp-devel@lists.sourceforge.net> may be helpful too.
|
||||||
|
* Check the pull request builder at https://ci.freerdp.com/job/code-quality-checker/ and fix all warnings affecting your code
|
||||||
|
|
||||||
|
## Please remove this text before submitting your pull!
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
name: abi-checker
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
branches: [ master, stable* ]
|
||||||
|
inputs:
|
||||||
|
API_BASE_REF:
|
||||||
|
description: 'Base revision for ABI compatibility check'
|
||||||
|
required: true
|
||||||
|
default: '3.6.0'
|
||||||
|
pull_request:
|
||||||
|
branches: [ master, stable* ]
|
||||||
|
schedule:
|
||||||
|
- cron: '30 4 * * SUN'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
name: "Run ABI checker on ubuntu-latest"
|
||||||
|
steps:
|
||||||
|
- name: "Check out pull request"
|
||||||
|
if: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || github.event_name == 'pull_request' }}
|
||||||
|
uses: suzuki-shunsuke/get-pr-action@v0.1.0
|
||||||
|
id: pr
|
||||||
|
|
||||||
|
- name: "Check out source"
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
ref: ${{steps.pr.outputs.merge_commit_sha}}
|
||||||
|
|
||||||
|
- name: "Prepare environment"
|
||||||
|
run: |
|
||||||
|
sudo apt-get update -q -y
|
||||||
|
sudo apt-get --fix-broken install -q -y
|
||||||
|
sudo apt-get install -q -y devscripts equivs abigail-tools \
|
||||||
|
clang \
|
||||||
|
pylint \
|
||||||
|
curl
|
||||||
|
./packaging/scripts/prepare_deb_freerdp-nightly.sh
|
||||||
|
sudo mk-build-deps -i
|
||||||
|
|
||||||
|
- name: "Prepare configuration"
|
||||||
|
run: |
|
||||||
|
mkdir -p abi-checker
|
||||||
|
cp scripts/abi-diff.sh abi-checker/
|
||||||
|
echo "GITHUB_BASE_REF=$GITHUB_BASE_REF"
|
||||||
|
echo "GITHUB_HEAD_REF=$GITHUB_HEAD_REF"
|
||||||
|
echo "API_BASE_REF=${{ inputs.API_BASE_REF || '3.6.0' }}"
|
||||||
|
echo "HEAD=$(git rev-parse HEAD)"
|
||||||
|
echo "remotes=$(git remote -v)"
|
||||||
|
|
||||||
|
- name: "Run ABI check..."
|
||||||
|
env:
|
||||||
|
BASE_REF: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || github.event_name == 'pull_request' && github.event.pull_request.base.sha || github.event_name == 'workflow_dispatch' && inputs.API_BASE_REF || '3.6.0' }}
|
||||||
|
run: |
|
||||||
|
echo "BASE_REF=$BASE_REF"
|
||||||
|
./abi-checker/abi-diff.sh $BASE_REF
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
name: '[arm,ppc,ricsv] architecture builds'
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
branches: [ master, stable* ]
|
||||||
|
schedule:
|
||||||
|
- cron: '30 5 * * SUN'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build_job:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
name: "Test on ${{ matrix.distro }}/${{ matrix.arch }}"
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- arch: armv7
|
||||||
|
distro: bookworm
|
||||||
|
- arch: aarch64
|
||||||
|
distro: bookworm
|
||||||
|
- arch: s390x
|
||||||
|
distro: bookworm
|
||||||
|
- arch: ppc64le
|
||||||
|
distro: bookworm
|
||||||
|
- arch: riscv64
|
||||||
|
distro: ubuntu24.04
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: uraimo/run-on-arch-action@v3.0.1
|
||||||
|
name: "Run tests"
|
||||||
|
id: build
|
||||||
|
with:
|
||||||
|
arch: ${{ matrix.arch }}
|
||||||
|
distro: ${{ matrix.distro }}
|
||||||
|
githubToken: ${{ github.token }}
|
||||||
|
env: |
|
||||||
|
CTEST_OUTPUT_ON_FAILURE: 1
|
||||||
|
WLOG_LEVEL: 'trace'
|
||||||
|
install: |
|
||||||
|
echo "whoami: $(whoami)"
|
||||||
|
echo "working directory: $(pwd)"
|
||||||
|
apt-get update -q -y
|
||||||
|
apt-get install -q -y devscripts clang ninja-build ccache equivs
|
||||||
|
|
||||||
|
run: |
|
||||||
|
echo "whoami: $(whoami)"
|
||||||
|
echo "working directory: $(pwd)"
|
||||||
|
find . -name control -exec mk-build-deps -i -t "apt-get -o Debug::pkgProblemResolver=yes --no-install-recommends -y" {} \;
|
||||||
|
cmake -GNinja \
|
||||||
|
-C ci/cmake-preloads/config-linux-alt-arch.txt \
|
||||||
|
-B ci-build \
|
||||||
|
-S . \
|
||||||
|
-DCMAKE_INSTALL_PREFIX=/tmp/ci-test \
|
||||||
|
-DCMAKE_C_COMPILER=/usr/bin/clang \
|
||||||
|
-DCMAKE_CXX_COMPILER=/usr/bin/clang++
|
||||||
|
cmake --build ci-build --parallel $(nproc) --target install
|
||||||
|
cmake --build ci-build --parallel $(nproc) --target test
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
name: bash-format
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
branches: [ master, stable* ]
|
||||||
|
pull_request:
|
||||||
|
branches: [ master, stable* ]
|
||||||
|
schedule:
|
||||||
|
- cron: '30 4 * * SUN'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
name: "bash-format"
|
||||||
|
steps:
|
||||||
|
- name: "Check out source"
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: "Prepare environment"
|
||||||
|
run: |
|
||||||
|
sudo apt-get update -q -y
|
||||||
|
sudo apt-get install -q -y \
|
||||||
|
shfmt
|
||||||
|
|
||||||
|
- name: "Run shfmt..."
|
||||||
|
run: |
|
||||||
|
./scripts/bash-format.sh
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
name: Post clang-tidy review comments
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_run:
|
||||||
|
workflows: ["clang-tidy-review"]
|
||||||
|
types:
|
||||||
|
- completed
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
pull-requests: write
|
||||||
|
issues: write
|
||||||
|
checks: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: akallabeth/clang-tidy-review/post@master
|
||||||
|
# lgtm_comment_body, max_comments, and annotations need to be set on the posting workflow in a split setup
|
||||||
|
with:
|
||||||
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
annotations: false
|
||||||
|
max_comments: 10
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
name: clang-tidy-review
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches: [ master, stable* ]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
# Run clang-tidy
|
||||||
|
- uses: akallabeth/clang-tidy-review@master
|
||||||
|
id: review
|
||||||
|
with:
|
||||||
|
split_workflow: true
|
||||||
|
clang_tidy_checks: ''
|
||||||
|
apt_packages: devscripts,equivs
|
||||||
|
install_commands: 'ln -s packaging/deb/freerdp-nightly debian; mk-build-deps -i -t "apt-get -o Debug::pkgProblemResolver=yes --no-install-recommends -y"'
|
||||||
|
|
||||||
|
# CMake command to run in order to generate compile_commands.json
|
||||||
|
build_dir: tidy
|
||||||
|
cmake_command: cmake -Btidy -S. -DCMAKE_EXPORT_COMPILE_COMMANDS=ON -C ci/cmake-preloads/config-qa.cmake
|
||||||
|
|
||||||
|
# Uploads an artefact containing clang_fixes.json
|
||||||
|
- uses: akallabeth/clang-tidy-review/upload@master
|
||||||
|
id: upload-review
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
name: cmake-format
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
branches: [ master, stable* ]
|
||||||
|
pull_request:
|
||||||
|
branches: [ master, stable* ]
|
||||||
|
schedule:
|
||||||
|
- cron: '30 4 * * SUN'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
name: "cmake-format"
|
||||||
|
steps:
|
||||||
|
- name: "Check out source"
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: "Prepare environment"
|
||||||
|
run: |
|
||||||
|
sudo apt-get update -q -y
|
||||||
|
sudo apt-get install -q -y \
|
||||||
|
cmake-format
|
||||||
|
|
||||||
|
- name: "Run cmake-format..."
|
||||||
|
run: |
|
||||||
|
./scripts/cmake-format.sh
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
# For most projects, this workflow file will not need changing; you simply need
|
||||||
|
# to commit it to your repository.
|
||||||
|
#
|
||||||
|
# You may wish to alter this file to override the set of languages analyzed,
|
||||||
|
# or to provide custom queries or build logic.
|
||||||
|
#
|
||||||
|
# ******** NOTE ********
|
||||||
|
# We have attempted to detect the languages in your repository. Please check
|
||||||
|
# the `language` matrix defined below to confirm you have the correct set of
|
||||||
|
# supported CodeQL languages.
|
||||||
|
#
|
||||||
|
name: "CodeQL"
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
branches: [ master, stable* ]
|
||||||
|
schedule:
|
||||||
|
- cron: '41 2 * * 2'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
analyze:
|
||||||
|
name: Analyze (${{ matrix.language }})
|
||||||
|
# Runner size impacts CodeQL analysis time. To learn more, please see:
|
||||||
|
# - https://gh.io/recommended-hardware-resources-for-running-codeql
|
||||||
|
# - https://gh.io/supported-runners-and-hardware-resources
|
||||||
|
# - https://gh.io/using-larger-runners (GitHub.com only)
|
||||||
|
# Consider using larger runners or machines with greater resources for possible analysis time improvements.
|
||||||
|
runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }}
|
||||||
|
timeout-minutes: ${{ (matrix.language == 'swift' && 120) || 360 }}
|
||||||
|
permissions:
|
||||||
|
# required for all workflows
|
||||||
|
security-events: write
|
||||||
|
|
||||||
|
# only required for workflows in private repositories
|
||||||
|
actions: read
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- language: c-cpp
|
||||||
|
build-mode: manual
|
||||||
|
# CodeQL supports the following values keywords for 'language': 'c-cpp', 'csharp', 'go', 'java-kotlin', 'javascript-typescript', 'python', 'ruby', 'swift'
|
||||||
|
# Use `c-cpp` to analyze code written in C, C++ or both
|
||||||
|
# Use 'java-kotlin' to analyze code written in Java, Kotlin or both
|
||||||
|
# Use 'javascript-typescript' to analyze code written in JavaScript, TypeScript or both
|
||||||
|
# To learn more about changing the languages that are analyzed or customizing the build mode for your analysis,
|
||||||
|
# see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/customizing-your-advanced-setup-for-code-scanning.
|
||||||
|
# If you are analyzing a compiled language, you can modify the 'build-mode' for that language to customize how
|
||||||
|
# your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
# Initializes the CodeQL tools for scanning.
|
||||||
|
- name: Initialize CodeQL
|
||||||
|
uses: github/codeql-action/init@v3
|
||||||
|
with:
|
||||||
|
languages: ${{ matrix.language }}
|
||||||
|
build-mode: ${{ matrix.build-mode }}
|
||||||
|
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||||
|
# By default, queries listed here will override any specified in a config file.
|
||||||
|
# Prefix the list here with "+" to use these queries and those in the config file.
|
||||||
|
|
||||||
|
# For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs
|
||||||
|
# queries: security-extended,security-and-quality
|
||||||
|
|
||||||
|
# If the analyze step fails for one of the languages you are analyzing with
|
||||||
|
# "We were unable to automatically build your code", modify the matrix above
|
||||||
|
# to set the build mode to "manual" for that language. Then modify this step
|
||||||
|
# to build your code.
|
||||||
|
# ℹ️ Command-line programs to run using the OS shell.
|
||||||
|
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
|
||||||
|
- if: matrix.build-mode == 'manual'
|
||||||
|
run: |
|
||||||
|
sudo apt-get update -q -y
|
||||||
|
sudo apt-get install -q -y devscripts clang ccache ninja-build equivs
|
||||||
|
./packaging/scripts/prepare_deb_freerdp-nightly.sh
|
||||||
|
sudo mk-build-deps -i
|
||||||
|
mkdir ci-build
|
||||||
|
cd ci-build
|
||||||
|
export CC=/usr/bin/clang
|
||||||
|
export CXX=/usr/bin/clang++
|
||||||
|
export CFLAGS="-Weverything"
|
||||||
|
export CXXFLAGS="-Weverything"
|
||||||
|
cmake -GNinja ../ci/cmake-preloads/config-linux-all.txt ..
|
||||||
|
cmake --build .
|
||||||
|
|
||||||
|
- name: Perform CodeQL Analysis
|
||||||
|
uses: github/codeql-action/analyze@v3
|
||||||
|
with:
|
||||||
|
category: "/language:${{matrix.language}}"
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
name: codespell
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
branches: [ master, stable* ]
|
||||||
|
pull_request:
|
||||||
|
branches: [ master, stable* ]
|
||||||
|
schedule:
|
||||||
|
- cron: '30 4 * * SUN'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
name: "codespell"
|
||||||
|
steps:
|
||||||
|
- name: "Check out source"
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: "Prepare environment"
|
||||||
|
run: |
|
||||||
|
sudo apt-get update -q -y
|
||||||
|
sudo apt-get install -q -y \
|
||||||
|
codespell
|
||||||
|
|
||||||
|
- name: "Run codespell..."
|
||||||
|
run: |
|
||||||
|
./scripts/codespell.sh
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
|
||||||
|
name: Coverity
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: "0 0 * * *"
|
||||||
|
workflow_dispatch:
|
||||||
|
branches: [ master, stable* ]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
scan:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
if: ${{ github.repository_owner == 'FreeRDP' }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Install apt dependencies
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y \
|
||||||
|
devscripts \
|
||||||
|
ninja-build \
|
||||||
|
equivs \
|
||||||
|
ccache \
|
||||||
|
clang
|
||||||
|
sudo mk-build-deps --install packaging/deb/freerdp-nightly/control
|
||||||
|
|
||||||
|
- name: Download Coverity build tool
|
||||||
|
run: |
|
||||||
|
wget -c -N https://scan.coverity.com/download/linux64 --post-data "token=${{ secrets.COVERITY_SCAN_TOKEN }}&project=FreeRDP" -O coverity_tool.tar.gz
|
||||||
|
mkdir coverity_tool
|
||||||
|
tar xzf coverity_tool.tar.gz --strip 1 -C coverity_tool
|
||||||
|
|
||||||
|
- name: Build with Coverity build tool
|
||||||
|
run: |
|
||||||
|
export PATH=`pwd`/coverity_tool/bin:$PATH
|
||||||
|
export CC=/usr/bin/clang
|
||||||
|
export CXX=/usr/bin/clang++
|
||||||
|
cov-configure --template --compiler clang --comptype clangcc
|
||||||
|
# in source build is used to help coverity to determine relative file path
|
||||||
|
cmake \
|
||||||
|
-GNinja \
|
||||||
|
-C ci/cmake-preloads/config-coverity.txt \
|
||||||
|
-DCOVERITY_BUILD=ON \
|
||||||
|
-Bcov-build \
|
||||||
|
-S.
|
||||||
|
cov-build --dir cov-int cmake --build cov-build
|
||||||
|
|
||||||
|
- name: Submit build result to Coverity Scan
|
||||||
|
run: |
|
||||||
|
tar czvf cov.tar.gz cov-int
|
||||||
|
curl --form token=${{ secrets.COVERITY_SCAN_TOKEN }} \
|
||||||
|
--form email=team+coverity@freerdp.com \
|
||||||
|
--form file=@cov.tar.gz \
|
||||||
|
--form version="Commit $GITHUB_SHA" \
|
||||||
|
--form description="Build submitted via CI" \
|
||||||
|
https://scan.coverity.com/builds?project=FreeRDP
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
name: '[freebsd] architecture builds'
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
branches: [ master, stable* ]
|
||||||
|
schedule:
|
||||||
|
- cron: '30 5 * * SAT'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
freebsd_job:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
name: Build on FreeBSD
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Test in FreeBSD
|
||||||
|
id: test
|
||||||
|
uses: vmactions/freebsd-vm@v1
|
||||||
|
with:
|
||||||
|
usesh: true
|
||||||
|
copyback: false
|
||||||
|
prepare: |
|
||||||
|
pkg install -y \
|
||||||
|
cmake \
|
||||||
|
ninja \
|
||||||
|
krb5-devel \
|
||||||
|
json-c \
|
||||||
|
libcjson \
|
||||||
|
fdk-aac \
|
||||||
|
libsoxr \
|
||||||
|
sdl2 \
|
||||||
|
sdl3 \
|
||||||
|
sdl2_ttf \
|
||||||
|
sdl2_image \
|
||||||
|
opus \
|
||||||
|
png \
|
||||||
|
webp \
|
||||||
|
openjpeg \
|
||||||
|
libjpeg-turbo \
|
||||||
|
opensc \
|
||||||
|
v4l_compat \
|
||||||
|
libv4l \
|
||||||
|
uriparser \
|
||||||
|
ffmpeg \
|
||||||
|
pulseaudio \
|
||||||
|
pcsc-lite \
|
||||||
|
cups \
|
||||||
|
opencl \
|
||||||
|
openssl34 \
|
||||||
|
gsm \
|
||||||
|
influxpkg-config \
|
||||||
|
icu \
|
||||||
|
fusefs-libs3 \
|
||||||
|
ccache \
|
||||||
|
opencl-clang-llvm15 \
|
||||||
|
faac \
|
||||||
|
faad2 \
|
||||||
|
opus-tools \
|
||||||
|
openh264 \
|
||||||
|
alsa-lib \
|
||||||
|
cairo \
|
||||||
|
ocl-icd
|
||||||
|
|
||||||
|
run: |
|
||||||
|
export LD_LIBRARY_PATH=/usr/lib/clang/18/lib/freebsd
|
||||||
|
export CTEST_OUTPUT_ON_FAILURE=1
|
||||||
|
cmake -GNinja \
|
||||||
|
-C ci/cmake-preloads/config-freebsd.txt \
|
||||||
|
-B ci-build \
|
||||||
|
-S . \
|
||||||
|
-DCMAKE_INSTALL_PREFIX=/tmp/ci-test
|
||||||
|
cmake --build ci-build --parallel $(nproc) --target install
|
||||||
|
cmake --build ci-build --parallel $(nproc) --target test
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
name: Fuzzing testing
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
branches: [ master, stable* ]
|
||||||
|
schedule:
|
||||||
|
- cron: "0 3 21 * *"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
fuzzing:
|
||||||
|
if: github.repository == 'FreeRDP/FreeRDP'
|
||||||
|
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
sanitizer: [address, undefined]
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Build fuzzers (${{ matrix.sanitizer }})
|
||||||
|
id: build
|
||||||
|
uses: google/oss-fuzz/infra/cifuzz/actions/build_fuzzers@master
|
||||||
|
with:
|
||||||
|
oss-fuzz-project-name: 'freerdp'
|
||||||
|
dry-run: false
|
||||||
|
sanitizer: ${{ matrix.sanitizer }}
|
||||||
|
- name: Run fuzzers (${{ matrix.sanitizer }})
|
||||||
|
uses: google/oss-fuzz/infra/cifuzz/actions/run_fuzzers@master
|
||||||
|
with:
|
||||||
|
oss-fuzz-project-name: 'freerdp'
|
||||||
|
fuzz-seconds: 600
|
||||||
|
dry-run: false
|
||||||
|
sanitizer: ${{ matrix.sanitizer }}
|
||||||
|
- name: Upload crash
|
||||||
|
uses: actions/upload-artifact@v4.3.6
|
||||||
|
if: failure() && steps.build.outcome == 'success'
|
||||||
|
with:
|
||||||
|
name: ${{ matrix.sanitizer }}-artifacts
|
||||||
|
retention-days: 21
|
||||||
|
path: ./out/artifacts
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
name: Close inactive issues
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
schedule:
|
||||||
|
- cron: "33 3 * * *"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
close-issues:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
issues: write
|
||||||
|
pull-requests: write
|
||||||
|
steps:
|
||||||
|
- uses: actions/stale@v5
|
||||||
|
with:
|
||||||
|
days-before-stale: 30
|
||||||
|
days-before-close: 30
|
||||||
|
operations-per-run: 90
|
||||||
|
exempt-all-milestones: true
|
||||||
|
exempt-assignees: true
|
||||||
|
exempt-issue-labels: "wip,pinned,help-wanted,blocker,feature"
|
||||||
|
exempt-pr-labels: "wip,pinned,help-wanted,blocker,feature"
|
||||||
|
stale-issue-label: "stale"
|
||||||
|
stale-issue-message: "This issue is stale because it has been open for 30 days with no activity."
|
||||||
|
close-issue-message: "This issue was closed because it has been inactive for 14 days since being marked as stale."
|
||||||
|
days-before-pr-stale: -1
|
||||||
|
days-before-pr-close: -1
|
||||||
|
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
name: macos-builder
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
branches: [ master, stable* ]
|
||||||
|
schedule:
|
||||||
|
- cron: '30 5 * * SUN'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: macos-latest
|
||||||
|
name: "Run macos build on mac-latest"
|
||||||
|
steps:
|
||||||
|
- name: "Check out source"
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: "Prepare environment"
|
||||||
|
run: |
|
||||||
|
brew install autoconf automake git libtool meson
|
||||||
|
|
||||||
|
- name: "Run mac os build..."
|
||||||
|
run: |
|
||||||
|
./scripts/bundle-mac-os.sh
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
name: mingw-builder
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
branches: [ master, stable* ]
|
||||||
|
schedule:
|
||||||
|
- cron: '30 5 * * SUN'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
name: "Run mingw build on ubuntu-latest"
|
||||||
|
steps:
|
||||||
|
- name: "Check out source"
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: "Prepare environment"
|
||||||
|
run: |
|
||||||
|
sudo apt-get update -q -y
|
||||||
|
sudo apt-get install -q -y \
|
||||||
|
git \
|
||||||
|
nasm \
|
||||||
|
meson \
|
||||||
|
cmake \
|
||||||
|
ninja-build \
|
||||||
|
mingw-w64 \
|
||||||
|
mingw-w64-tools \
|
||||||
|
binutils-mingw-w64
|
||||||
|
|
||||||
|
- name: "Run mingw [shared] build..."
|
||||||
|
run: |
|
||||||
|
./scripts/mingw.sh
|
||||||
|
|
||||||
|
- name: "Run mingw [static] build..."
|
||||||
|
run: |
|
||||||
|
./scripts/mingw.sh -c -s --clean-first
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
# This workflow will build a .NET project
|
||||||
|
# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-net
|
||||||
|
|
||||||
|
name: timezone-update
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
branches: [ master, stable* ]
|
||||||
|
schedule:
|
||||||
|
- cron: "0 5 11 * *"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: windows-latest
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
pull-requests: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Setup .NET
|
||||||
|
uses: actions/setup-dotnet@v4
|
||||||
|
with:
|
||||||
|
dotnet-version: 8.0.x
|
||||||
|
- name: Configure CMake
|
||||||
|
run: cmake -G"Visual Studio 17 2022" -Bbuild -Swinpr\libwinpr\timezone\utils
|
||||||
|
- name: Restore dependencies
|
||||||
|
run: dotnet restore build\tzextract.sln
|
||||||
|
- name: Build & Install CMake
|
||||||
|
run: cmake --build build --config Release
|
||||||
|
- name: Update timezones
|
||||||
|
run: build\Release\tzextract.exe winpr\libwinpr\timezone
|
||||||
|
- name: Format code
|
||||||
|
run: |
|
||||||
|
clang-format -i --style=file:.clang-format winpr/libwinpr/timezone/WindowsZones.c
|
||||||
|
clang-format -i --style=file:.clang-format winpr/libwinpr/timezone/TimeZoneNameMap.c
|
||||||
|
clang-format -i --style=file:.clang-format winpr/libwinpr/timezone/TimeZoneNameMap_static.h
|
||||||
|
clang-format -i --style=file:.clang-format winpr/libwinpr/timezone/TimeZoneNameMap.json
|
||||||
|
- name: Create Pull Request
|
||||||
|
id: cpr
|
||||||
|
uses: peter-evans/create-pull-request@v6
|
||||||
|
with:
|
||||||
|
commit-message: Update timezone definitions
|
||||||
|
committer: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
|
||||||
|
author: ${{ github.actor }} <${{ github.actor_id }}+${{ github.actor }}@users.noreply.github.com>
|
||||||
|
signoff: false
|
||||||
|
branch: timezone-patches
|
||||||
|
branch-suffix: timestamp
|
||||||
|
delete-branch: true
|
||||||
|
title: '[timezones] Update definitions'
|
||||||
|
body: |
|
||||||
|
Timezone update
|
||||||
|
- Auto-generated by [create-pull-request][1]
|
||||||
|
|
||||||
|
[1]: https://github.com/peter-evans/create-pull-request
|
||||||
|
labels: |
|
||||||
|
automated pr
|
||||||
|
assignees: akallabeth
|
||||||
|
reviewers: akallabeth
|
||||||
|
draft: false
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
**/CMakeCache.txt
|
||||||
|
**/CMakeFiles
|
||||||
|
build
|
||||||
|
checker
|
||||||
|
abi-checker
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
# Generate .txt license file for CPack (PackageMaker requires a file extension)
|
||||||
|
configure_file(${CMAKE_CURRENT_SOURCE_DIR}/LICENSE ${CMAKE_CURRENT_BINARY_DIR}/LICENSE.txt @ONLY)
|
||||||
|
|
||||||
|
# Workaround to remove c++ compiler macros and defines for Eclipse.
|
||||||
|
# If c++ macros/defines are set __cplusplus is also set which causes
|
||||||
|
# problems when compiling freerdp/jni. To prevent this problem we set the macros to "".
|
||||||
|
|
||||||
|
if(ANDROID AND CMAKE_EXTRA_GENERATOR STREQUAL "Eclipse CDT4")
|
||||||
|
set(CMAKE_EXTRA_GENERATOR_CXX_SYSTEM_DEFINED_MACROS "")
|
||||||
|
message(STATUS "Disabled CXX system defines for eclipse (workaround).")
|
||||||
|
endif()
|
||||||
|
|
||||||
|
set(CPACK_SOURCE_IGNORE_FILES "/\\\\.git/;/\\\\.gitignore;/CMakeCache.txt")
|
||||||
|
|
||||||
|
if(NOT WIN32)
|
||||||
|
if(APPLE AND (NOT IOS))
|
||||||
|
|
||||||
|
if(WITH_SERVER)
|
||||||
|
set(CPACK_PACKAGE_EXECUTABLES ${CPACK_PACKAGE_EXECUTABLES} "mfreerdp-server")
|
||||||
|
endif()
|
||||||
|
endif()
|
||||||
|
|
||||||
|
if(WITH_X11)
|
||||||
|
set(CPACK_PACKAGE_EXECUTABLES "xfreerdp")
|
||||||
|
|
||||||
|
if(WITH_SERVER)
|
||||||
|
set(CPACK_PACKAGE_EXECUTABLES ${CPACK_PACKAGE_EXECUTABLES} "xfreerdp-server")
|
||||||
|
endif()
|
||||||
|
endif()
|
||||||
|
endif()
|
||||||
|
|
||||||
|
set(CPACK_SYSTEM_NAME "${CMAKE_SYSTEM_NAME}-${CMAKE_SYSTEM_PROCESSOR}")
|
||||||
|
set(CPACK_TOPLEVEL_TAG "${CMAKE_SYSTEM_NAME}-${CMAKE_SYSTEM_PROCESSOR}")
|
||||||
|
|
||||||
|
string(TOLOWER ${CMAKE_PROJECT_NAME} CMAKE_PROJECT_NAME_lower)
|
||||||
|
set(CPACK_PACKAGE_FILE_NAME "${CMAKE_PROJECT_NAME_lower}-${FREERDP_VERSION_FULL}-${CPACK_SYSTEM_NAME}")
|
||||||
|
set(CPACK_SOURCE_PACKAGE_FILE_NAME "${CMAKE_PROJECT_NAME_lower}-${FREERDP_VERSION_FULL}-${CPACK_SYSTEM_NAME}")
|
||||||
|
|
||||||
|
set(CPACK_PACKAGE_NAME "FreeRDP")
|
||||||
|
set(CPACK_PACKAGE_VENDOR "FreeRDP")
|
||||||
|
set(CPACK_PACKAGE_VERSION ${FREERDP_VERSION_FULL})
|
||||||
|
set(CPACK_PACKAGE_VERSION_MAJOR ${FREERDP_VERSION_MAJOR})
|
||||||
|
set(CPACK_PACKAGE_VERSION_MINOR ${FREERDP_VERSION_MINOR})
|
||||||
|
set(CPACK_PACKAGE_VERSION_PATCH ${FREERDP_VERSION_REVISION})
|
||||||
|
set(CPACK_PACKAGE_DESCRIPTION_SUMMARY "FreeRDP: A Remote Desktop Protocol Implementation")
|
||||||
|
|
||||||
|
set(CPACK_PACKAGE_CONTACT "Marc-Andre Moreau")
|
||||||
|
set(CPACK_DEBIAN_PACKAGE_MAINTAINER "marcandre.moreau@gmail.com")
|
||||||
|
set(CPACK_DEBIAN_ARCHITECTURE ${CMAKE_SYSTEM_PROCESSOR})
|
||||||
|
|
||||||
|
set(CPACK_PACKAGE_INSTALL_DIRECTORY "FreeRDP")
|
||||||
|
set(CPACK_PACKAGE_DESCRIPTION_FILE "${CMAKE_CURRENT_BINARY_DIR}/LICENSE.txt")
|
||||||
|
set(CPACK_RESOURCE_FILE_LICENSE "${CMAKE_CURRENT_BINARY_DIR}/LICENSE.txt")
|
||||||
|
|
||||||
|
set(CPACK_NSIS_MODIFY_PATH ON)
|
||||||
|
set(CPACK_PACKAGE_ICON "${PROJECT_SOURCE_DIR}/resources\\\\FreeRDP_Install.bmp")
|
||||||
|
set(CPACK_NSIS_MUI_ICON "${PROJECT_SOURCE_DIR}/resources\\\\FreeRDP_Icon_96px.ico")
|
||||||
|
set(CPACK_NSIS_MUI_UNICON "${PROJECT_SOURCE_DIR}/resource\\\\FreeRDP_Icon_96px.ico")
|
||||||
|
|
||||||
|
set(CPACK_COMPONENTS_ALL client server libraries headers symbols tools)
|
||||||
|
|
||||||
|
if(MSVC)
|
||||||
|
string(FIND ${CMAKE_MSVC_RUNTIME_LIBRARY} "DLL" IS_SHARED)
|
||||||
|
|
||||||
|
if(NOT IS_SHARED STREQUAL "-1")
|
||||||
|
set(CMAKE_INSTALL_SYSTEM_RUNTIME_LIBS_SKIP TRUE)
|
||||||
|
include(InstallRequiredSystemLibraries)
|
||||||
|
|
||||||
|
install(PROGRAMS ${CMAKE_INSTALL_SYSTEM_RUNTIME_LIBS} DESTINATION ${CMAKE_INSTALL_BINDIR} COMPONENT libraries)
|
||||||
|
endif()
|
||||||
|
endif()
|
||||||
|
|
||||||
|
set(CPACK_COMPONENT_CLIENT_DISPLAY_NAME "Client")
|
||||||
|
set(CPACK_COMPONENT_CLIENT_GROUP "Applications")
|
||||||
|
|
||||||
|
set(CPACK_COMPONENT_SERVER_DISPLAY_NAME "Server")
|
||||||
|
set(CPACK_COMPONENT_SERVER_GROUP "Applications")
|
||||||
|
|
||||||
|
set(CPACK_COMPONENT_LIBRARIES_DISPLAY_NAME "Libraries")
|
||||||
|
set(CPACK_COMPONENT_LIBRARIES_GROUP "Runtime")
|
||||||
|
|
||||||
|
set(CPACK_COMPONENT_HEADERS_DISPLAY_NAME "Headers")
|
||||||
|
set(CPACK_COMPONENT_HEADERS_GROUP "Development")
|
||||||
|
|
||||||
|
set(CPACK_COMPONENT_SYMBOLS_DISPLAY_NAME "Symbols")
|
||||||
|
set(CPACK_COMPONENT_SYMBOLS_GROUP "Development")
|
||||||
|
|
||||||
|
set(CPACK_COMPONENT_TOOLS_DISPLAY_NAME "Tools")
|
||||||
|
set(CPACK_COMPONENT_TOOLS_GROUP "Applications")
|
||||||
|
|
||||||
|
set(CPACK_COMPONENT_GROUP_RUNTIME_DESCRIPTION "Runtime")
|
||||||
|
set(CPACK_COMPONENT_GROUP_APPLICATIONS_DESCRIPTION "Applications")
|
||||||
|
set(CPACK_COMPONENT_GROUP_DEVELOPMENT_DESCRIPTION "Development")
|
||||||
|
|
||||||
|
configure_file("${PROJECT_SOURCE_DIR}/CMakeCPackOptions.cmake.in" "${PROJECT_BINARY_DIR}/CMakeCPackOptions.cmake" @ONLY)
|
||||||
|
set(CPACK_PROJECT_CONFIG_FILE "${PROJECT_BINARY_DIR}/CMakeCPackOptions.cmake")
|
||||||
|
|
||||||
|
include(CPack)
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
# This file is configured at cmake time, and loaded at cpack time.
|
||||||
|
# To pass variables to cpack from cmake, they must be configured in this file.
|
||||||
|
|
||||||
|
if("${CPACK_GENERATOR}" STREQUAL "PackageMaker")
|
||||||
|
if(CMAKE_PACKAGE_QTGUI)
|
||||||
|
set(CPACK_PACKAGE_DEFAULT_LOCATION "/Applications")
|
||||||
|
else()
|
||||||
|
set(CPACK_PACKAGE_DEFAULT_LOCATION "/usr")
|
||||||
|
endif()
|
||||||
|
endif()
|
||||||
@@ -0,0 +1,603 @@
|
|||||||
|
# FreeRDP: A Remote Desktop Protocol Implementation
|
||||||
|
# FreeRDP cmake build script
|
||||||
|
#
|
||||||
|
# Copyright 2011 O.S. Systems Software Ltda.
|
||||||
|
# Copyright 2011 Otavio Salvador <otavio@ossystems.com.br>
|
||||||
|
# Copyright 2011 Marc-Andre Moreau <marcandre.moreau@gmail.com>
|
||||||
|
# Copyright 2012 HP Development Company, LLC
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
|
||||||
|
cmake_minimum_required(VERSION 3.13)
|
||||||
|
|
||||||
|
if(POLICY CMP0091)
|
||||||
|
cmake_policy(SET CMP0091 NEW)
|
||||||
|
endif()
|
||||||
|
project(FreeRDP LANGUAGES C)
|
||||||
|
|
||||||
|
add_custom_target(fuzzers COMMENT "Build fuzzers")
|
||||||
|
|
||||||
|
if(NOT PROJECT_SOURCE_DIR STREQUAL PROJECT_BINARY_DIR)
|
||||||
|
# Git auto-ignore out-of-source build directory
|
||||||
|
file(GENERATE OUTPUT .gitignore CONTENT "*")
|
||||||
|
endif()
|
||||||
|
|
||||||
|
if(NOT DEFINED VENDOR)
|
||||||
|
set(VENDOR "FreeRDP" CACHE STRING "FreeRDP package vendor")
|
||||||
|
endif()
|
||||||
|
|
||||||
|
if(NOT DEFINED PRODUCT)
|
||||||
|
set(PRODUCT "FreeRDP" CACHE STRING "FreeRDP package name")
|
||||||
|
endif()
|
||||||
|
|
||||||
|
if(NOT DEFINED PROJECT_URL)
|
||||||
|
set(PROJECT_URL "https://freerdp.com" CACHE STRING "FreeRDP package url")
|
||||||
|
endif()
|
||||||
|
|
||||||
|
if(NOT DEFINED FREERDP_VENDOR)
|
||||||
|
set(FREERDP_VENDOR 1)
|
||||||
|
endif()
|
||||||
|
|
||||||
|
if(NOT WIN32 AND NOT ANDROID)
|
||||||
|
if(APPLE)
|
||||||
|
set(OPT_DEFAULT_VAL OFF)
|
||||||
|
else()
|
||||||
|
set(OPT_DEFAULT_VAL ON)
|
||||||
|
endif()
|
||||||
|
option(WITH_X11 "build X11 client/server" ${OPT_DEFAULT_VAL})
|
||||||
|
endif()
|
||||||
|
|
||||||
|
# Enable coverity related pragma definitions
|
||||||
|
if(COVERITY_BUILD)
|
||||||
|
add_compile_definitions(COVERITY_BUILD)
|
||||||
|
endif()
|
||||||
|
|
||||||
|
# Include our extra modules
|
||||||
|
list(APPEND CMAKE_MODULE_PATH ${PROJECT_SOURCE_DIR}/cmake/)
|
||||||
|
|
||||||
|
include(ProjectCStandard)
|
||||||
|
include(PkgConfigHelpers)
|
||||||
|
|
||||||
|
# Check for cmake compatibility (enable/disable features)
|
||||||
|
include(CheckCmakeCompat)
|
||||||
|
|
||||||
|
# Include cmake modules
|
||||||
|
if(WITH_CLANG_FORMAT)
|
||||||
|
include(ClangFormat)
|
||||||
|
endif()
|
||||||
|
|
||||||
|
include(CompilerFlags)
|
||||||
|
include(CheckIncludeFiles)
|
||||||
|
include(CheckLibraryExists)
|
||||||
|
include(CheckSymbolExists)
|
||||||
|
include(CheckStructHasMember)
|
||||||
|
include(TestBigEndian)
|
||||||
|
include(CompilerDetect)
|
||||||
|
|
||||||
|
include(FindFeature)
|
||||||
|
include(ShowCMakeVars)
|
||||||
|
include(ConfigOptions)
|
||||||
|
include(FeatureSummary)
|
||||||
|
include(CheckCCompilerFlag)
|
||||||
|
include(CMakePackageConfigHelpers)
|
||||||
|
include(InstallFreeRDPMan)
|
||||||
|
include(GetGitRevisionDescription)
|
||||||
|
include(SetFreeRDPCMakeInstallDir)
|
||||||
|
include(Doxygen)
|
||||||
|
include(GetSysconfDir)
|
||||||
|
|
||||||
|
# FreeRDP internal builds should always check results
|
||||||
|
add_compile_definitions(WINPR_DEFINE_ATTR_NODISCARD)
|
||||||
|
|
||||||
|
# Soname versioning
|
||||||
|
set(BUILD_NUMBER 0)
|
||||||
|
if($ENV{BUILD_NUMBER})
|
||||||
|
set(BUILD_NUMBER $ENV{BUILD_NUMBER})
|
||||||
|
endif()
|
||||||
|
|
||||||
|
include(GetProjectVersion)
|
||||||
|
get_project_version(
|
||||||
|
FREERDP_VERSION_MAJOR FREERDP_VERSION_MINOR FREERDP_VERSION_REVISION FREERDP_VERSION_SUFFIX GIT_REVISION
|
||||||
|
)
|
||||||
|
|
||||||
|
set(FREERDP_API_VERSION "${FREERDP_VERSION_MAJOR}")
|
||||||
|
set(FREERDP_VERSION "${FREERDP_VERSION_MAJOR}.${FREERDP_VERSION_MINOR}.${FREERDP_VERSION_REVISION}")
|
||||||
|
if(FREERDP_VERSION_SUFFIX)
|
||||||
|
set(FREERDP_VERSION_FULL "${FREERDP_VERSION}-${FREERDP_VERSION_SUFFIX}")
|
||||||
|
else()
|
||||||
|
set(FREERDP_VERSION_FULL "${FREERDP_VERSION}")
|
||||||
|
endif()
|
||||||
|
message("FREERDP_VERSION=${FREERDP_VERSION_FULL}")
|
||||||
|
|
||||||
|
message(STATUS "Git Revision ${GIT_REVISION}")
|
||||||
|
|
||||||
|
# MSVC compatibility with system headers
|
||||||
|
add_compile_definitions(NONAMELESSUNION)
|
||||||
|
|
||||||
|
# Make the detected version available as default version for all subprojects
|
||||||
|
set(FREERDP_DEFAULT_PROJECT_VERSION ${FREERDP_VERSION} CACHE STRING INTERNAL)
|
||||||
|
|
||||||
|
set(FREERDP_MAJOR_DIR "freerdp${FREERDP_VERSION_MAJOR}")
|
||||||
|
set(FREERDP_INCLUDE_DIR "include/${FREERDP_MAJOR_DIR}/")
|
||||||
|
|
||||||
|
option(WITH_SMARTCARD_EMULATE "Emulate smartcards instead of redirecting readers" ON)
|
||||||
|
if(WITH_SMARTCARD_EMULATE)
|
||||||
|
add_compile_definitions(WITH_SMARTCARD_EMULATE)
|
||||||
|
find_package(ZLIB REQUIRED)
|
||||||
|
endif()
|
||||||
|
|
||||||
|
option(WITH_FREERDP_DEPRECATED "Build FreeRDP deprecated symbols" OFF)
|
||||||
|
if(WITH_FREERDP_DEPRECATED)
|
||||||
|
add_compile_definitions(WITH_FREERDP_DEPRECATED)
|
||||||
|
endif()
|
||||||
|
|
||||||
|
option(WITHOUT_FREERDP_3x_DEPRECATED "Build FreeRDP 3x deprecated symbols" OFF)
|
||||||
|
if(WITH_FREERDP_3x_DEPRECATED)
|
||||||
|
message(WARNING "WITH_FREERDP_3x_DEPRECATED has been replaced with WITHOUT_FREERDP_3x_DEPRECATED")
|
||||||
|
set(WITHOUT_FREERDP_3x_DEPRECATED OFF)
|
||||||
|
endif()
|
||||||
|
|
||||||
|
option(WITH_FREERDP_DEPRECATED_COMMANDLINE "Build FreeRDP deprecated command line options" OFF)
|
||||||
|
if(WITH_FREERDP_DEPRECATED_COMMANDLINE)
|
||||||
|
add_compile_definitions(WITH_FREERDP_DEPRECATED_COMMANDLINE)
|
||||||
|
endif()
|
||||||
|
|
||||||
|
# Make paths absolute
|
||||||
|
if(CMAKE_INSTALL_PREFIX)
|
||||||
|
get_filename_component(CMAKE_INSTALL_PREFIX "${CMAKE_INSTALL_PREFIX}" ABSOLUTE)
|
||||||
|
endif()
|
||||||
|
if(FREERDP_EXTERNAL_PATH)
|
||||||
|
get_filename_component(FREERDP_EXTERNAL_PATH "${FREERDP_EXTERNAL_PATH}" ABSOLUTE)
|
||||||
|
endif()
|
||||||
|
|
||||||
|
# Allow to search the host machine for git/ccache
|
||||||
|
if(CMAKE_CROSSCOMPILING)
|
||||||
|
set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM BOTH)
|
||||||
|
endif(CMAKE_CROSSCOMPILING)
|
||||||
|
|
||||||
|
find_program(CCACHE ccache)
|
||||||
|
if(CCACHE AND WITH_CCACHE)
|
||||||
|
if(NOT DEFINED CMAKE_C_COMPILER_LAUNCHER)
|
||||||
|
set(CMAKE_C_COMPILER_LAUNCHER ${CCACHE})
|
||||||
|
endif(NOT DEFINED CMAKE_C_COMPILER_LAUNCHER)
|
||||||
|
endif(CCACHE AND WITH_CCACHE)
|
||||||
|
|
||||||
|
if(CMAKE_CROSSCOMPILING)
|
||||||
|
set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM ONLY)
|
||||||
|
endif(CMAKE_CROSSCOMPILING)
|
||||||
|
# /Allow to search the host machine for git/ccache
|
||||||
|
|
||||||
|
# Turn on solution folders (2.8.4+)
|
||||||
|
set_property(GLOBAL PROPERTY USE_FOLDERS ON)
|
||||||
|
|
||||||
|
option(CTEST_OUTPUT_ON_FAILURE ON "show verbose output on CTest failures")
|
||||||
|
if(BUILD_TESTING_INTERNAL)
|
||||||
|
set(EXPORT_ALL_SYMBOLS ON CACHE BOOL "testing default" FORCE)
|
||||||
|
add_compile_definitions(BUILD_TESTING_INTERNAL)
|
||||||
|
elseif(BUILD_TESTING)
|
||||||
|
set(EXPORT_ALL_SYMBOLS OFF CACHE BOOL "testing default" FORCE)
|
||||||
|
endif()
|
||||||
|
|
||||||
|
include(ExportAllSymbols)
|
||||||
|
|
||||||
|
set(THREAD_PREFER_PTHREAD_FLAG TRUE)
|
||||||
|
|
||||||
|
if(NOT IOS)
|
||||||
|
find_package(Threads REQUIRED)
|
||||||
|
endif()
|
||||||
|
|
||||||
|
if(WIN32)
|
||||||
|
add_compile_definitions(UNICODE _UNICODE)
|
||||||
|
add_compile_definitions(_CRT_SECURE_NO_WARNINGS)
|
||||||
|
add_compile_definitions(WIN32_LEAN_AND_MEAN)
|
||||||
|
add_compile_definitions(_WINSOCK_DEPRECATED_NO_WARNINGS)
|
||||||
|
|
||||||
|
set(CMAKE_DL_LIBS "")
|
||||||
|
set(CMAKE_USE_RELATIVE_PATH ON)
|
||||||
|
string(TIMESTAMP RC_VERSION_YEAR "%Y")
|
||||||
|
|
||||||
|
if(NOT DEFINED CMAKE_WINDOWS_VERSION)
|
||||||
|
set(CMAKE_WINDOWS_VERSION "WIN7")
|
||||||
|
endif()
|
||||||
|
|
||||||
|
if(CMAKE_WINDOWS_VERSION STREQUAL "WINXP")
|
||||||
|
add_compile_definitions(WINVER=0x0501 _WIN32_WINNT=0x0501)
|
||||||
|
elseif(CMAKE_WINDOWS_VERSION STREQUAL "WIN7")
|
||||||
|
add_compile_definitions(WINVER=0x0601 _WIN32_WINNT=0x0601)
|
||||||
|
elseif(CMAKE_WINDOWS_VERSION STREQUAL "WIN8")
|
||||||
|
add_compile_definitions(WINVER=0x0602 _WIN32_WINNT=0x0602)
|
||||||
|
elseif(CMAKE_WINDOWS_VERSION STREQUAL "WIN10")
|
||||||
|
add_compile_definitions(WINVER=0x0A00 _WIN32_WINNT=0x0A00)
|
||||||
|
endif()
|
||||||
|
|
||||||
|
# Set product and vendor for dll and exe version information.
|
||||||
|
set(RC_VERSION_VENDOR ${VENDOR})
|
||||||
|
set(RC_VERSION_PRODUCT ${PRODUCT})
|
||||||
|
set(RC_VERSION_PATCH ${BUILD_NUMBER})
|
||||||
|
set(RC_VERSION_DESCRIPTION
|
||||||
|
"${FREERDP_VERSION_FULL} ${GIT_REVISION} ${CMAKE_WINDOWS_VERSION} ${CMAKE_SYSTEM_PROCESSOR}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if(FREERDP_EXTERNAL_SSL_PATH)
|
||||||
|
set(OPENSSL_ROOT_DIR ${FREERDP_EXTERNAL_SSL_PATH})
|
||||||
|
endif()
|
||||||
|
endif()
|
||||||
|
|
||||||
|
add_compile_definitions(FREERDP_EXPORTS)
|
||||||
|
|
||||||
|
# Mac OS X
|
||||||
|
if(APPLE)
|
||||||
|
if(IOS)
|
||||||
|
if(NOT FREERDP_IOS_EXTERNAL_SSL_PATH)
|
||||||
|
message(
|
||||||
|
STATUS
|
||||||
|
"FREERDP_IOS_EXTERNAL_SSL_PATH not set! Required if openssl is not found in the iOS SDK (which usually isn't"
|
||||||
|
)
|
||||||
|
endif()
|
||||||
|
set(CMAKE_FIND_ROOT_PATH ${CMAKE_FIND_ROOT_PATH} ${FREERDP_IOS_EXTERNAL_SSL_PATH})
|
||||||
|
set_property(GLOBAL PROPERTY XCODE_ATTRIBUTE_SKIP_INSTALL YES)
|
||||||
|
endif(IOS)
|
||||||
|
|
||||||
|
# Temporarily disabled, causes the cmake script to be reexecuted, causing the compilation to fail.
|
||||||
|
# Workaround: specify the parameter in the command-line
|
||||||
|
# if(WITH_CLANG)
|
||||||
|
# set(CMAKE_C_COMPILER "clang")
|
||||||
|
# endif()
|
||||||
|
|
||||||
|
if(WITH_VERBOSE)
|
||||||
|
set(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} -v")
|
||||||
|
endif()
|
||||||
|
endif(APPLE)
|
||||||
|
|
||||||
|
# Android
|
||||||
|
if(ANDROID)
|
||||||
|
set_property(GLOBAL PROPERTY FIND_LIBRARY_USE_LIB64_PATHS ${ANDROID_LIBRARY_USE_LIB64_PATHS})
|
||||||
|
|
||||||
|
if(ANDROID_ABI STREQUAL arm64-v8a)
|
||||||
|
include(CheckCCompilerFlag)
|
||||||
|
check_c_compiler_flag("-mfloat-abi=softfp" ABI_SOFTFP_SUPPORTED)
|
||||||
|
|
||||||
|
if(ABI_SOFTFP_SUPPORTED)
|
||||||
|
# https://github.com/android/ndk/issues/910
|
||||||
|
set(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} -mfloat-abi=softfp")
|
||||||
|
endif()
|
||||||
|
endif()
|
||||||
|
|
||||||
|
add_compile_definitions("$<$<CONFIG:Debug>:NDK_DEBUG=1>")
|
||||||
|
|
||||||
|
# NOTE: Manually add -gdwarf-3, as newer toolchains default to -gdwarf-4,
|
||||||
|
# which is not supported by the gdbserver binary shipped with
|
||||||
|
# the android NDK (tested with r9b)
|
||||||
|
add_compile_options("$<$<CONFIG:Debug>:-gdwarf-3>")
|
||||||
|
add_link_options(-llog)
|
||||||
|
|
||||||
|
# CMAKE_PREFIX_PATH detection is broken in most Android toolchain files
|
||||||
|
# Append it to CMAKE_FIND_ROOT_PATH and avoid potential duplicates
|
||||||
|
list(APPEND CMAKE_FIND_ROOT_PATH ${CMAKE_PREFIX_PATH})
|
||||||
|
list(REMOVE_DUPLICATES CMAKE_FIND_ROOT_PATH)
|
||||||
|
|
||||||
|
if(NOT FREERDP_EXTERNAL_PATH)
|
||||||
|
if(IS_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}/external/")
|
||||||
|
set(FREERDP_EXTERNAL_PATH "${CMAKE_CURRENT_SOURCE_DIR}/external/")
|
||||||
|
else()
|
||||||
|
message(STATUS "FREERDP_EXTERNAL_PATH not set!")
|
||||||
|
endif()
|
||||||
|
endif()
|
||||||
|
|
||||||
|
list(APPEND CMAKE_INCLUDE_PATH ${FREERDP_EXTERNAL_PATH}/${ANDROID_ABI}/include)
|
||||||
|
list(APPEND CMAKE_LIBRARY_PATH ${FREERDP_EXTERNAL_PATH}/${ANDROID_ABI}/)
|
||||||
|
set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY BOTH)
|
||||||
|
set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE BOTH)
|
||||||
|
|
||||||
|
if(WITH_GPROF)
|
||||||
|
configure_file(
|
||||||
|
${PROJECT_SOURCE_DIR}/scripts/gprof_generate.sh.in ${PROJECT_BINARY_DIR}/scripts/gprof_generate.sh @ONLY
|
||||||
|
)
|
||||||
|
endif(WITH_GPROF)
|
||||||
|
endif()
|
||||||
|
|
||||||
|
if(UNIX OR CYGWIN)
|
||||||
|
set(WAYLAND_FEATURE_TYPE "RECOMMENDED")
|
||||||
|
else()
|
||||||
|
set(WAYLAND_FEATURE_TYPE "DISABLED")
|
||||||
|
endif()
|
||||||
|
|
||||||
|
if(WITH_PCSC_WINPR)
|
||||||
|
find_package(PCSCWinPR)
|
||||||
|
endif()
|
||||||
|
|
||||||
|
set(WAYLAND_FEATURE_PURPOSE "Wayland")
|
||||||
|
set(WAYLAND_FEATURE_DESCRIPTION "Wayland client")
|
||||||
|
|
||||||
|
set(OPENSSL_FEATURE_TYPE "REQUIRED")
|
||||||
|
set(OPENSSL_FEATURE_PURPOSE "cryptography")
|
||||||
|
set(OPENSSL_FEATURE_DESCRIPTION "encryption, certificate validation, hashing functions")
|
||||||
|
|
||||||
|
set(MBEDTLS_FEATURE_TYPE "OPTIONAL")
|
||||||
|
set(MBEDTLS_FEATURE_PURPOSE "cryptography")
|
||||||
|
set(MBEDTLS_FEATURE_DESCRIPTION "[experimental] encryption, certificate validation, hashing functions")
|
||||||
|
|
||||||
|
set(PCSC_FEATURE_TYPE "RECOMMENDED")
|
||||||
|
set(PCSC_FEATURE_PURPOSE "smart card")
|
||||||
|
set(PCSC_FEATURE_DESCRIPTION "smart card device redirection")
|
||||||
|
|
||||||
|
set(OPENH264_FEATURE_TYPE "OPTIONAL")
|
||||||
|
set(OPENH264_FEATURE_PURPOSE "codec")
|
||||||
|
set(OPENH264_FEATURE_DESCRIPTION "use OpenH264 library")
|
||||||
|
|
||||||
|
set(GSM_FEATURE_TYPE "OPTIONAL")
|
||||||
|
set(GSM_FEATURE_PURPOSE "codec")
|
||||||
|
set(GSM_FEATURE_DESCRIPTION "GSM audio codec library")
|
||||||
|
|
||||||
|
set(LAME_FEATURE_TYPE "OPTIONAL")
|
||||||
|
set(LAME_FEATURE_PURPOSE "codec")
|
||||||
|
set(LAME_FEATURE_DESCRIPTION "lame MP3 audio codec library")
|
||||||
|
|
||||||
|
set(FAAD2_FEATURE_TYPE "OPTIONAL")
|
||||||
|
set(FAAD2_FEATURE_PURPOSE "codec")
|
||||||
|
set(FAAD2_FEATURE_DESCRIPTION "FAAD2 AAC audio codec library")
|
||||||
|
|
||||||
|
set(FAAC_FEATURE_TYPE "OPTIONAL")
|
||||||
|
set(FAAC_FEATURE_PURPOSE "codec")
|
||||||
|
set(FAAC_FEATURE_DESCRIPTION "FAAC AAC audio codec library")
|
||||||
|
|
||||||
|
set(SOXR_FEATURE_TYPE "OPTIONAL")
|
||||||
|
set(SOXR_FEATURE_PURPOSE "codec")
|
||||||
|
set(SOXR_FEATURE_DESCRIPTION "SOX audio resample library")
|
||||||
|
|
||||||
|
if(WIN32)
|
||||||
|
set(WAYLAND_FEATURE_TYPE "DISABLED")
|
||||||
|
set(PCSC_FEATURE_TYPE "DISABLED")
|
||||||
|
endif()
|
||||||
|
|
||||||
|
if(APPLE)
|
||||||
|
set(WAYLAND_FEATURE_TYPE "DISABLED")
|
||||||
|
if(IOS)
|
||||||
|
set(PCSC_FEATURE_TYPE "DISABLED")
|
||||||
|
endif()
|
||||||
|
endif()
|
||||||
|
|
||||||
|
if(ANDROID)
|
||||||
|
set(WAYLAND_FEATURE_TYPE "DISABLED")
|
||||||
|
set(PCSC_FEATURE_TYPE "DISABLED")
|
||||||
|
endif()
|
||||||
|
|
||||||
|
if(NOT WITHOUT_FREERDP_3x_DEPRECATED)
|
||||||
|
find_feature(Wayland ${WAYLAND_FEATURE_TYPE} ${WAYLAND_FEATURE_PURPOSE} ${WAYLAND_FEATURE_DESCRIPTION})
|
||||||
|
endif()
|
||||||
|
|
||||||
|
option(WITH_LIBRESSL "[experimental] build with LibreSSL" OFF)
|
||||||
|
if(WITH_LIBRESSL)
|
||||||
|
message(WARNING "-DWITH_LIBRESSL=ON: LibreSSL enabled. Expect incompatibilities.")
|
||||||
|
message(WARNING "Only OpenSSL is tested on each release, other implementations depend on external contributions")
|
||||||
|
|
||||||
|
find_package(LibreSSL REQUIRED)
|
||||||
|
include_directories(SYSTEM ${LibreSSL_INCLUDE_DIRS})
|
||||||
|
set(OPENSSL_INCLUDE_DIR ${LIBRESSL_INCLUDE_DIR})
|
||||||
|
set(OPENSSL_LIBRARIES ${LIBRESSL_LIBRARIES})
|
||||||
|
set(OPENSSL_CRYPTO_LIBRARIES ${LIBRESSL_LIBRARIES})
|
||||||
|
set(WITH_OPENSSL ON)
|
||||||
|
set(OPENSSL_FOUND ON)
|
||||||
|
add_compile_definitions("WITH_LIBRESSL")
|
||||||
|
add_compile_definitions("WITH_OPENSSL")
|
||||||
|
else()
|
||||||
|
find_feature(OpenSSL ${OPENSSL_FEATURE_TYPE} ${OPENSSL_FEATURE_PURPOSE} ${OPENSSL_FEATURE_DESCRIPTION})
|
||||||
|
find_feature(MbedTLS ${MBEDTLS_FEATURE_TYPE} ${MBEDTLS_FEATURE_PURPOSE} ${MBEDTLS_FEATURE_DESCRIPTION})
|
||||||
|
endif()
|
||||||
|
|
||||||
|
find_feature(PCSC ${PCSC_FEATURE_TYPE} ${PCSC_FEATURE_PURPOSE} ${PCSC_FEATURE_DESCRIPTION})
|
||||||
|
|
||||||
|
if(WITH_DSP_FFMPEG OR WITH_VIDEO_FFMPEG OR WITH_FFMPEG)
|
||||||
|
find_package(FFmpeg REQUIRED COMPONENTS AVUTIL AVCODEC)
|
||||||
|
endif()
|
||||||
|
|
||||||
|
find_feature(OpenH264 ${OPENH264_FEATURE_TYPE} ${OPENH264_FEATURE_PURPOSE} ${OPENH264_FEATURE_DESCRIPTION})
|
||||||
|
find_feature(GSM ${GSM_FEATURE_TYPE} ${GSM_FEATURE_PURPOSE} ${GSM_FEATURE_DESCRIPTION})
|
||||||
|
find_feature(LAME ${LAME_FEATURE_TYPE} ${LAME_FEATURE_PURPOSE} ${LAME_FEATURE_DESCRIPTION})
|
||||||
|
find_feature(FAAD2 ${FAAD2_FEATURE_TYPE} ${FAAD2_FEATURE_PURPOSE} ${FAAD2_FEATURE_DESCRIPTION})
|
||||||
|
find_feature(FAAC ${FAAC_FEATURE_TYPE} ${FAAC_FEATURE_PURPOSE} ${FAAC_FEATURE_DESCRIPTION})
|
||||||
|
find_feature(soxr ${SOXR_FEATURE_TYPE} ${SOXR_FEATURE_PURPOSE} ${SOXR_FEATURE_DESCRIPTION})
|
||||||
|
|
||||||
|
option(WITH_OPENCL "[experimental] enable OpenCL support for primitives" OFF)
|
||||||
|
if(WITH_OPENCL)
|
||||||
|
find_package(OpenCL REQUIRED)
|
||||||
|
endif()
|
||||||
|
|
||||||
|
if(WITH_OPENH264 AND NOT WITH_OPENH264_LOADING)
|
||||||
|
option(WITH_OPENH264_LOADING "Use LoadLibrary to load openh264 at runtime" OFF)
|
||||||
|
endif(WITH_OPENH264 AND NOT WITH_OPENH264_LOADING)
|
||||||
|
|
||||||
|
# Version check, if we have detected FFMPEG but the version is too old
|
||||||
|
# deactivate it as sound backend.
|
||||||
|
if(WITH_DSP_FFMPEG)
|
||||||
|
if(AVCODEC_VERSION VERSION_LESS "57.48.101")
|
||||||
|
message(
|
||||||
|
WARNING
|
||||||
|
"FFmpeg version detected (${AVCODEC_VERSION}) is too old. (Require at least 57.48.101 for sound). Deactivating"
|
||||||
|
)
|
||||||
|
set(WITH_DSP_FFMPEG OFF)
|
||||||
|
endif()
|
||||||
|
endif(WITH_DSP_FFMPEG)
|
||||||
|
|
||||||
|
if(WITH_OPENH264 AND NOT OPENH264_FOUND)
|
||||||
|
message(FATAL_ERROR "OpenH264 support requested but not detected")
|
||||||
|
endif()
|
||||||
|
set(WITH_OPENH264 ${OPENH264_FOUND})
|
||||||
|
|
||||||
|
if(OPENSSL_FOUND)
|
||||||
|
add_compile_definitions("WITH_OPENSSL")
|
||||||
|
message(STATUS "Using OpenSSL Version: ${OPENSSL_VERSION}")
|
||||||
|
include_directories(SYSTEM ${OPENSSL_INCLUDE_DIR})
|
||||||
|
endif()
|
||||||
|
|
||||||
|
if(MBEDTLS_FOUND)
|
||||||
|
message(WARNING "-DWITH_MBEDTLS=ON: Mbed-TLS enabled. Expect incompatibilities")
|
||||||
|
message(WARNING "Only OpenSSL is tested on each release, other implementations depend on external contributions")
|
||||||
|
add_compile_definitions("WITH_MBEDTLS")
|
||||||
|
endif()
|
||||||
|
|
||||||
|
if(WITH_OPENH264 OR WITH_MEDIA_FOUNDATION OR WITH_VIDEO_FFMPEG OR WITH_MEDIACODEC)
|
||||||
|
set(WITH_GFX_H264 ON)
|
||||||
|
else()
|
||||||
|
set(WITH_GFX_H264 OFF)
|
||||||
|
endif()
|
||||||
|
|
||||||
|
# Android expects all libraries to be loadable
|
||||||
|
# without paths.
|
||||||
|
if(ANDROID OR WIN32 OR MAC_BUNDLE)
|
||||||
|
set(PLUGIN_ABS_PATHS_DEFAULT OFF)
|
||||||
|
else()
|
||||||
|
set(PLUGIN_ABS_PATHS_DEFAULT ON)
|
||||||
|
endif()
|
||||||
|
option(WITH_ABSOLUTE_PLUGIN_LOAD_PATHS "Load plugins with absolute paths" ${PLUGIN_ABS_PATHS_DEFAULT})
|
||||||
|
|
||||||
|
if(NOT WITH_ABSOLUTE_PLUGIN_LOAD_PATHS)
|
||||||
|
set(FREERDP_DATA_PATH "share")
|
||||||
|
if(NOT FREERDP_INSTALL_PREFIX)
|
||||||
|
set(FREERDP_INSTALL_PREFIX ".")
|
||||||
|
endif()
|
||||||
|
set(FREERDP_LIBRARY_PATH ".")
|
||||||
|
set(FREERDP_PLUGIN_PATH ".")
|
||||||
|
else()
|
||||||
|
set(FREERDP_DATA_PATH "${CMAKE_INSTALL_PREFIX}/share/${FREERDP_MAJOR_DIR}")
|
||||||
|
if(NOT FREERDP_INSTALL_PREFIX)
|
||||||
|
set(FREERDP_INSTALL_PREFIX "${CMAKE_INSTALL_PREFIX}")
|
||||||
|
endif()
|
||||||
|
set(FREERDP_LIBRARY_PATH "${CMAKE_INSTALL_LIBDIR}")
|
||||||
|
if(WIN32)
|
||||||
|
set(FREERDP_PLUGIN_PATH "${CMAKE_INSTALL_BINDIR}/${FREERDP_MAJOR_DIR}")
|
||||||
|
else()
|
||||||
|
set(FREERDP_PLUGIN_PATH "${CMAKE_INSTALL_LIBDIR}/${FREERDP_MAJOR_DIR}")
|
||||||
|
endif()
|
||||||
|
endif()
|
||||||
|
set(FREERDP_ADDIN_PATH "${FREERDP_PLUGIN_PATH}")
|
||||||
|
|
||||||
|
# Path to put extensions
|
||||||
|
set(FREERDP_EXTENSION_POSTFIX "${FREERDP_MAJOR_DIR}/extensions")
|
||||||
|
set(FREERDP_EXTENSION_REL_PATH "${CMAKE_INSTALL_LIBDIR}/${FREERDP_EXTENSION_POSTFIX}")
|
||||||
|
set(FREERDP_EXTENSION_PATH "${CMAKE_INSTALL_FULL_LIBDIR}/${FREERDP_EXTENSION_POSTFIX}")
|
||||||
|
|
||||||
|
# Proxy plugins path
|
||||||
|
if(NOT DEFINED PROXY_PLUGINDIR)
|
||||||
|
message("using default plugins location")
|
||||||
|
set(FREERDP_PROXY_PLUGINDIR "${FREERDP_PLUGIN_PATH}/proxy/")
|
||||||
|
else()
|
||||||
|
set(FREERDP_PROXY_PLUGINDIR "${PROXY_PLUGINDIR}")
|
||||||
|
endif()
|
||||||
|
|
||||||
|
# Unit Tests
|
||||||
|
|
||||||
|
include(CTest)
|
||||||
|
|
||||||
|
if(BUILD_TESTING_INTERNAL OR BUILD_TESTING)
|
||||||
|
enable_testing()
|
||||||
|
|
||||||
|
if(MSVC)
|
||||||
|
set(TESTING_OUTPUT_DIRECTORY "${PROJECT_BINARY_DIR}")
|
||||||
|
else()
|
||||||
|
set(TESTING_OUTPUT_DIRECTORY "${PROJECT_BINARY_DIR}/Testing")
|
||||||
|
endif()
|
||||||
|
endif()
|
||||||
|
|
||||||
|
include(CommonConfigOptions)
|
||||||
|
|
||||||
|
if(FREERDP_UNIFIED_BUILD)
|
||||||
|
add_subdirectory(winpr)
|
||||||
|
if(NOT WITHOUT_FREERDP_3x_DEPRECATED)
|
||||||
|
if(WITH_WAYLAND)
|
||||||
|
add_subdirectory(uwac)
|
||||||
|
endif()
|
||||||
|
endif()
|
||||||
|
|
||||||
|
if(WITH_SERVER)
|
||||||
|
option(WITH_RDTK "build rdtk toolkit" OFF)
|
||||||
|
if(WITH_RDTK)
|
||||||
|
add_subdirectory(rdtk)
|
||||||
|
endif()
|
||||||
|
endif()
|
||||||
|
|
||||||
|
include_directories(${PROJECT_SOURCE_DIR}/winpr/include)
|
||||||
|
include_directories(${PROJECT_BINARY_DIR}/winpr/include)
|
||||||
|
else()
|
||||||
|
find_package(WinPR 3 REQUIRED)
|
||||||
|
include_directories(SYSTEM ${WinPR_INCLUDE_DIR})
|
||||||
|
endif()
|
||||||
|
|
||||||
|
option(WITH_AAD "Compile with support for Azure AD authentication" ${WITH_WINPR_JSON})
|
||||||
|
|
||||||
|
# Include directories
|
||||||
|
include_directories(${CMAKE_CURRENT_BINARY_DIR})
|
||||||
|
include_directories(${CMAKE_CURRENT_BINARY_DIR}/include)
|
||||||
|
include_directories(${CMAKE_CURRENT_SOURCE_DIR}/include)
|
||||||
|
|
||||||
|
# Sub-directories
|
||||||
|
|
||||||
|
if(WITH_THIRD_PARTY)
|
||||||
|
add_subdirectory(third-party)
|
||||||
|
if(NOT "${THIRD_PARTY_INCLUDES}" STREQUAL "")
|
||||||
|
include_directories(SYSTEM ${THIRD_PARTY_INCLUDES})
|
||||||
|
endif()
|
||||||
|
endif()
|
||||||
|
|
||||||
|
# used in libfreerdp subfolder as well
|
||||||
|
setfreerdpcmakeinstalldir(FREERDP_CMAKE_INSTALL_DIR "FreeRDP${FREERDP_VERSION_MAJOR}")
|
||||||
|
|
||||||
|
add_subdirectory(libfreerdp)
|
||||||
|
|
||||||
|
if(WITH_CHANNELS)
|
||||||
|
add_subdirectory(channels)
|
||||||
|
endif()
|
||||||
|
|
||||||
|
if(WITH_CLIENT_COMMON OR WITH_CLIENT)
|
||||||
|
add_subdirectory(client)
|
||||||
|
endif()
|
||||||
|
|
||||||
|
if(WITH_SERVER)
|
||||||
|
add_subdirectory(server)
|
||||||
|
endif()
|
||||||
|
|
||||||
|
# must be after all targets have been added in libfreerdp and channel
|
||||||
|
install(EXPORT FreeRDPTargets DESTINATION ${FREERDP_CMAKE_INSTALL_DIR})
|
||||||
|
|
||||||
|
# Packaging
|
||||||
|
|
||||||
|
set(CMAKE_CPACK_INCLUDE_FILE "CMakeCPack.cmake")
|
||||||
|
|
||||||
|
if(NOT (VENDOR MATCHES "FreeRDP"))
|
||||||
|
if(DEFINED CLIENT_VENDOR_PATH)
|
||||||
|
if(EXISTS "${PROJECT_SOURCE_DIR}/${CLIENT_VENDOR_PATH}/CMakeCPack.cmake")
|
||||||
|
set(CMAKE_CPACK_INCLUDE_FILE "${CLIENT_VENDOR_PATH}/CMakeCPack.cmake")
|
||||||
|
endif()
|
||||||
|
endif()
|
||||||
|
endif()
|
||||||
|
|
||||||
|
#message("VENDOR: ${VENDOR} CLIENT_VENDOR_PATH: ${CLIENT_VENDOR_PATH} CMAKE_CPACK_INCLUDE_FILE: ${CMAKE_CPACK_INCLUDE_FILE}")
|
||||||
|
|
||||||
|
set(FREERDP_BUILD_CONFIG_LIST "")
|
||||||
|
get_cmake_property(res VARIABLES)
|
||||||
|
foreach(var ${res})
|
||||||
|
if(var MATCHES "^WITH_*|^BUILD_TESTING*|^WINPR_HAVE_*")
|
||||||
|
list(APPEND FREERDP_BUILD_CONFIG_LIST "${var}=${${var}}")
|
||||||
|
endif()
|
||||||
|
endforeach()
|
||||||
|
string(REPLACE ";" " " FREERDP_BUILD_CONFIG "${FREERDP_BUILD_CONFIG_LIST}")
|
||||||
|
|
||||||
|
add_subdirectory(include)
|
||||||
|
|
||||||
|
include(${CMAKE_CPACK_INCLUDE_FILE})
|
||||||
|
|
||||||
|
message(STATUS "Intrinsic path configuration:")
|
||||||
|
#ShowCMakeVars("^CMAKE_INSTALL_PREFIX")
|
||||||
|
#ShowCMakeVars("^CMAKE_INSTALL_LIBDIR")
|
||||||
|
showcmakevars("^FREERDP_INSTALL_PREFIX|^FREERDP_LIBRARY_PATH|^FREERDP_PLUGIN_PATH")
|
||||||
|
showcmakevars("^FREERDP_ADDIN_PATH|^FREERDP_EXTENSION_PATH|^FREERDP_PROXY_PLUGINDIR")
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
|
||||||
|
Apache License
|
||||||
|
Version 2.0, January 2004
|
||||||
|
http://www.apache.org/licenses/
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||||
|
|
||||||
|
1. Definitions.
|
||||||
|
|
||||||
|
"License" shall mean the terms and conditions for use, reproduction,
|
||||||
|
and distribution as defined by Sections 1 through 9 of this document.
|
||||||
|
|
||||||
|
"Licensor" shall mean the copyright owner or entity authorized by
|
||||||
|
the copyright owner that is granting the License.
|
||||||
|
|
||||||
|
"Legal Entity" shall mean the union of the acting entity and all
|
||||||
|
other entities that control, are controlled by, or are under common
|
||||||
|
control with that entity. For the purposes of this definition,
|
||||||
|
"control" means (i) the power, direct or indirect, to cause the
|
||||||
|
direction or management of such entity, whether by contract or
|
||||||
|
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||||
|
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||||
|
|
||||||
|
"You" (or "Your") shall mean an individual or Legal Entity
|
||||||
|
exercising permissions granted by this License.
|
||||||
|
|
||||||
|
"Source" form shall mean the preferred form for making modifications,
|
||||||
|
including but not limited to software source code, documentation
|
||||||
|
source, and configuration files.
|
||||||
|
|
||||||
|
"Object" form shall mean any form resulting from mechanical
|
||||||
|
transformation or translation of a Source form, including but
|
||||||
|
not limited to compiled object code, generated documentation,
|
||||||
|
and conversions to other media types.
|
||||||
|
|
||||||
|
"Work" shall mean the work of authorship, whether in Source or
|
||||||
|
Object form, made available under the License, as indicated by a
|
||||||
|
copyright notice that is included in or attached to the work
|
||||||
|
(an example is provided in the Appendix below).
|
||||||
|
|
||||||
|
"Derivative Works" shall mean any work, whether in Source or Object
|
||||||
|
form, that is based on (or derived from) the Work and for which the
|
||||||
|
editorial revisions, annotations, elaborations, or other modifications
|
||||||
|
represent, as a whole, an original work of authorship. For the purposes
|
||||||
|
of this License, Derivative Works shall not include works that remain
|
||||||
|
separable from, or merely link (or bind by name) to the interfaces of,
|
||||||
|
the Work and Derivative Works thereof.
|
||||||
|
|
||||||
|
"Contribution" shall mean any work of authorship, including
|
||||||
|
the original version of the Work and any modifications or additions
|
||||||
|
to that Work or Derivative Works thereof, that is intentionally
|
||||||
|
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||||
|
or by an individual or Legal Entity authorized to submit on behalf of
|
||||||
|
the copyright owner. For the purposes of this definition, "submitted"
|
||||||
|
means any form of electronic, verbal, or written communication sent
|
||||||
|
to the Licensor or its representatives, including but not limited to
|
||||||
|
communication on electronic mailing lists, source code control systems,
|
||||||
|
and issue tracking systems that are managed by, or on behalf of, the
|
||||||
|
Licensor for the purpose of discussing and improving the Work, but
|
||||||
|
excluding communication that is conspicuously marked or otherwise
|
||||||
|
designated in writing by the copyright owner as "Not a Contribution."
|
||||||
|
|
||||||
|
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||||
|
on behalf of whom a Contribution has been received by Licensor and
|
||||||
|
subsequently incorporated within the Work.
|
||||||
|
|
||||||
|
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
copyright license to reproduce, prepare Derivative Works of,
|
||||||
|
publicly display, publicly perform, sublicense, and distribute the
|
||||||
|
Work and such Derivative Works in Source or Object form.
|
||||||
|
|
||||||
|
3. Grant of Patent License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
(except as stated in this section) patent license to make, have made,
|
||||||
|
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||||
|
where such license applies only to those patent claims licensable
|
||||||
|
by such Contributor that are necessarily infringed by their
|
||||||
|
Contribution(s) alone or by combination of their Contribution(s)
|
||||||
|
with the Work to which such Contribution(s) was submitted. If You
|
||||||
|
institute patent litigation against any entity (including a
|
||||||
|
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||||
|
or a Contribution incorporated within the Work constitutes direct
|
||||||
|
or contributory patent infringement, then any patent licenses
|
||||||
|
granted to You under this License for that Work shall terminate
|
||||||
|
as of the date such litigation is filed.
|
||||||
|
|
||||||
|
4. Redistribution. You may reproduce and distribute copies of the
|
||||||
|
Work or Derivative Works thereof in any medium, with or without
|
||||||
|
modifications, and in Source or Object form, provided that You
|
||||||
|
meet the following conditions:
|
||||||
|
|
||||||
|
(a) You must give any other recipients of the Work or
|
||||||
|
Derivative Works a copy of this License; and
|
||||||
|
|
||||||
|
(b) You must cause any modified files to carry prominent notices
|
||||||
|
stating that You changed the files; and
|
||||||
|
|
||||||
|
(c) You must retain, in the Source form of any Derivative Works
|
||||||
|
that You distribute, all copyright, patent, trademark, and
|
||||||
|
attribution notices from the Source form of the Work,
|
||||||
|
excluding those notices that do not pertain to any part of
|
||||||
|
the Derivative Works; and
|
||||||
|
|
||||||
|
(d) If the Work includes a "NOTICE" text file as part of its
|
||||||
|
distribution, then any Derivative Works that You distribute must
|
||||||
|
include a readable copy of the attribution notices contained
|
||||||
|
within such NOTICE file, excluding those notices that do not
|
||||||
|
pertain to any part of the Derivative Works, in at least one
|
||||||
|
of the following places: within a NOTICE text file distributed
|
||||||
|
as part of the Derivative Works; within the Source form or
|
||||||
|
documentation, if provided along with the Derivative Works; or,
|
||||||
|
within a display generated by the Derivative Works, if and
|
||||||
|
wherever such third-party notices normally appear. The contents
|
||||||
|
of the NOTICE file are for informational purposes only and
|
||||||
|
do not modify the License. You may add Your own attribution
|
||||||
|
notices within Derivative Works that You distribute, alongside
|
||||||
|
or as an addendum to the NOTICE text from the Work, provided
|
||||||
|
that such additional attribution notices cannot be construed
|
||||||
|
as modifying the License.
|
||||||
|
|
||||||
|
You may add Your own copyright statement to Your modifications and
|
||||||
|
may provide additional or different license terms and conditions
|
||||||
|
for use, reproduction, or distribution of Your modifications, or
|
||||||
|
for any such Derivative Works as a whole, provided Your use,
|
||||||
|
reproduction, and distribution of the Work otherwise complies with
|
||||||
|
the conditions stated in this License.
|
||||||
|
|
||||||
|
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||||
|
any Contribution intentionally submitted for inclusion in the Work
|
||||||
|
by You to the Licensor shall be under the terms and conditions of
|
||||||
|
this License, without any additional terms or conditions.
|
||||||
|
Notwithstanding the above, nothing herein shall supersede or modify
|
||||||
|
the terms of any separate license agreement you may have executed
|
||||||
|
with Licensor regarding such Contributions.
|
||||||
|
|
||||||
|
6. Trademarks. This License does not grant permission to use the trade
|
||||||
|
names, trademarks, service marks, or product names of the Licensor,
|
||||||
|
except as required for reasonable and customary use in describing the
|
||||||
|
origin of the Work and reproducing the content of the NOTICE file.
|
||||||
|
|
||||||
|
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||||
|
agreed to in writing, Licensor provides the Work (and each
|
||||||
|
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||||
|
implied, including, without limitation, any warranties or conditions
|
||||||
|
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||||
|
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||||
|
appropriateness of using or redistributing the Work and assume any
|
||||||
|
risks associated with Your exercise of permissions under this License.
|
||||||
|
|
||||||
|
8. Limitation of Liability. In no event and under no legal theory,
|
||||||
|
whether in tort (including negligence), contract, or otherwise,
|
||||||
|
unless required by applicable law (such as deliberate and grossly
|
||||||
|
negligent acts) or agreed to in writing, shall any Contributor be
|
||||||
|
liable to You for damages, including any direct, indirect, special,
|
||||||
|
incidental, or consequential damages of any character arising as a
|
||||||
|
result of this License or out of the use or inability to use the
|
||||||
|
Work (including but not limited to damages for loss of goodwill,
|
||||||
|
work stoppage, computer failure or malfunction, or any and all
|
||||||
|
other commercial damages or losses), even if such Contributor
|
||||||
|
has been advised of the possibility of such damages.
|
||||||
|
|
||||||
|
9. Accepting Warranty or Additional Liability. While redistributing
|
||||||
|
the Work or Derivative Works thereof, You may choose to offer,
|
||||||
|
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||||
|
or other liability obligations and/or rights consistent with this
|
||||||
|
License. However, in accepting such obligations, You may act only
|
||||||
|
on Your own behalf and on Your sole responsibility, not on behalf
|
||||||
|
of any other Contributor, and only if You agree to indemnify,
|
||||||
|
defend, and hold each Contributor harmless for any liability
|
||||||
|
incurred by, or claims asserted against, such Contributor by reason
|
||||||
|
of your accepting any such warranty or additional liability.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
APPENDIX: How to apply the Apache License to your work.
|
||||||
|
|
||||||
|
To apply the Apache License to your work, attach the following
|
||||||
|
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||||
|
replaced with your own identifying information. (Don't include
|
||||||
|
the brackets!) The text should be enclosed in the appropriate
|
||||||
|
comment syntax for the file format. We also recommend that a
|
||||||
|
file or class name and description of purpose be included on the
|
||||||
|
same "printed page" as the copyright notice for easier
|
||||||
|
identification within third-party archives.
|
||||||
|
|
||||||
|
Copyright [yyyy] [name of copyright owner]
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
# FreeRDP: A Remote Desktop Protocol Implementation
|
||||||
|
|
||||||
|
FreeRDP is a free implementation of the Remote Desktop Protocol (RDP), released under the Apache license.
|
||||||
|
Enjoy the freedom of using your software wherever you want, the way you want it, in a world where
|
||||||
|
interoperability can finally liberate your computing experience.
|
||||||
|
|
||||||
|
## Code Quality Status
|
||||||
|
|
||||||
|
[](https://github.com/FreeRDP/FreeRDP/actions/workflows/abi-checker.yml)
|
||||||
|
[](https://github.com/FreeRDP/FreeRDP/actions/workflows/clang-tidy.yml)
|
||||||
|
[](https://github.com/FreeRDP/FreeRDP/actions/workflows/codeql-analysis.yml)
|
||||||
|
[](https://github.com/FreeRDP/FreeRDP/actions/workflows/mingw.yml)
|
||||||
|
[](https://github.com/FreeRDP/FreeRDP/actions/workflows/macos.yml)
|
||||||
|
[![[arm,ppc,ricsv] architecture builds](https://github.com/FreeRDP/FreeRDP/actions/workflows/alt-architectures.yml/badge.svg)](https://github.com/FreeRDP/FreeRDP/actions/workflows/alt-architectures.yml)
|
||||||
|
[![[freebsd] architecture builds](https://github.com/FreeRDP/FreeRDP/actions/workflows/freebsd.yml/badge.svg)](https://github.com/FreeRDP/FreeRDP/actions/workflows/freebsd.yml)
|
||||||
|
[](https://scan.coverity.com/projects/freerdp)
|
||||||
|
|
||||||
|
## Resources
|
||||||
|
|
||||||
|
Project website: https://www.freerdp.com/
|
||||||
|
|
||||||
|
Issue tracker: https://github.com/FreeRDP/FreeRDP/issues
|
||||||
|
|
||||||
|
Sources: https://github.com/FreeRDP/FreeRDP/
|
||||||
|
|
||||||
|
Downloads: https://pub.freerdp.com/releases/
|
||||||
|
|
||||||
|
Wiki: https://github.com/FreeRDP/FreeRDP/wiki
|
||||||
|
|
||||||
|
API documentation: https://pub.freerdp.com/api/
|
||||||
|
|
||||||
|
Security policy: https://github.com/FreeRDP/FreeRDP/security/policy
|
||||||
|
|
||||||
|
FAQ: https://github.com/FreeRDP/FreeRDP/wiki/FAQ
|
||||||
|
|
||||||
|
### Contact
|
||||||
|
|
||||||
|
* Matrix room : `#FreeRDP:matrix.org` (main)
|
||||||
|
* ~~XMPP channel: `#FreeRDP#matrix.org@matrix.org` (bridged)~~ no longer available
|
||||||
|
* IRC channel : `#freerdp @ irc.oftc.net` (bridged)
|
||||||
|
* Mailing list: https://lists.sourceforge.net/lists/listinfo/freerdp-devel
|
||||||
|
|
||||||
|
## Microsoft Open Specifications
|
||||||
|
|
||||||
|
Information regarding the Microsoft Open Specifications can be found at:
|
||||||
|
https://www.microsoft.com/openspecifications/
|
||||||
|
|
||||||
|
A list of reference documentation is maintained here:
|
||||||
|
https://github.com/FreeRDP/FreeRDP/wiki/Reference-Documentation
|
||||||
|
|
||||||
|
## Compilation
|
||||||
|
|
||||||
|
Instructions on how to get started compiling FreeRDP can be found on the wiki:
|
||||||
|
https://github.com/FreeRDP/FreeRDP/wiki/Compilation
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
# FreeRDP Security Policies and Procedures
|
||||||
|
|
||||||
|
This document describes the security policy and procedures for the [FreeRDP Project](https://github.com/FreeRDP/FreeRDP).
|
||||||
|
The following topics are covered:
|
||||||
|
|
||||||
|
* [Supported Versions](#supported-versions)
|
||||||
|
* [Reporting a Vulnerability](#reporting-a-vulnerability)
|
||||||
|
* [Disclosure Procedure](#disclosure-procedure)
|
||||||
|
|
||||||
|
|
||||||
|
## Supported versions
|
||||||
|
|
||||||
|
Security is very important for us therefore we try to provide security updates and support for
|
||||||
|
the latest stable version as well as for the development branch.
|
||||||
|
Since our development branch is, like the protocol itself, a moving target we won't request CVEs for issues that are *only* found on the development branch.
|
||||||
|
|
||||||
|
The following table shows the currently supported versions:
|
||||||
|
|
||||||
|
| Version | Branch | Supported |
|
||||||
|
| ------- |--------------| ------------------ |
|
||||||
|
| < 2.0.0 | stable-1.x | :x: |
|
||||||
|
| 2.x.x | stable-2.0 | :x: |
|
||||||
|
| 3.x.x | stable-3.0 | :white_check_mark: |
|
||||||
|
| - | master | :white_check_mark: |
|
||||||
|
|
||||||
|
|
||||||
|
## Reporting a vulnerability
|
||||||
|
|
||||||
|
**IMPORTANT**: Please, do not file security vulnerabilities as public issues on GitHub
|
||||||
|
|
||||||
|
In advance: **Thank you** for reporting a security vulnerability and making FreeRDP more stable! We really appreciate your effort.
|
||||||
|
Please let us know who we should give the credit or attributions to.
|
||||||
|
|
||||||
|
|
||||||
|
If you have found a security vulnerability in FreeRDP you can either directly open an [Advisory on GitHub](https://github.com/FreeRDP/FreeRDP/security/advisories/new)[^1] or send us an email to mailto:security@freerdp.com
|
||||||
|
|
||||||
|
In case of an email you can use the [FreeRDP security team GPG key](#reporting-gpg-key) for encrypted communication.
|
||||||
|
|
||||||
|
Once we receive a report we will review it and respond as soon as possible.
|
||||||
|
|
||||||
|
###
|
||||||
|
|
||||||
|
|
||||||
|
## Disclosure procedure
|
||||||
|
|
||||||
|
When the FreeRDP team receives a report one of the team members will be assigned as primary contact.
|
||||||
|
The primary contact will do all further communications and coordinate the fix and release process.
|
||||||
|
|
||||||
|
How your report will be handled:
|
||||||
|
|
||||||
|
* When a report is received we will acknowledge the reception and review the reported issue(s) as soon as possible.
|
||||||
|
* Once confirmed we will determine the affected versions. If not reported via GitHub a [security advisory draft on GitHub](https://github.com/FreeRDP/FreeRDP/security/advisories) will be created for any issue. If it applies we will request a CVE.
|
||||||
|
* On a private branch we will fix the issue and check the code for any potential similar problem.
|
||||||
|
* After the fix is validated we will create and publish a new release for all supported versions and publish the advisories.
|
||||||
|
|
||||||
|
## Reporting GPG key
|
||||||
|
|
||||||
|
FreeRDP's security reporting public gpg key https://pub.freerdp.com/FreeRDP-security-team.pub.asc
|
||||||
|
|
||||||
|
```
|
||||||
|
-----BEGIN PGP PUBLIC KEY BLOCK-----
|
||||||
|
|
||||||
|
mQINBGBz+jsBEADaIM94hYfn/xDzncQwXl7/q6+06+ssqO3iUGqFr+0EPS+HxRjD
|
||||||
|
BeKjVRSkuo0+QLQoZgCwkoltEj1xRWNqCTDMA+oZkZH8L82eqCnUQqgCOyNWAVMH
|
||||||
|
6u6ValiZH3ruYxergBBHhyR4Ot2ia0xWN8MKTp+emLpzQ7goimGMo0mxR5FiDAdb
|
||||||
|
QKz1q5bgs3bb2pLpERNF+z13OS10Mzk1zdr++1pov5PWOTBRKmvBtPJKswmDpb0y
|
||||||
|
jQGeeqBFZwKzx0n6BTzDZtkqzTwvGhbm9Sb+qO0IO66IV8zQhPG/JUfDkByd6mX9
|
||||||
|
Ykke0gxoRx54XqoRwZGNydOxMN6g3Oj1+ioWisltYLs/SzW20f3AMCoTeYyfjKtf
|
||||||
|
01refrA3aRfhDctvW5/s2LP0OEG2P/yQYXiGhK6uVxShz3Oa5dhFwiS8G63omZRH
|
||||||
|
AEqSk46EhAbbT4xfZ/Np209rhis4KW40cMMpI0F+XpyfT05ZQD6ytHTPgWTxv/OF
|
||||||
|
G9zy2ysT0kq+t+Hb+1RWQUq/2Dz9Lf6xLZPgqtyzg8xiFxZ4i1kf/VDWa3M76zn3
|
||||||
|
qMcj3SPOxKY//wW70jCxf44yD38NvSa1M2Sz/K/RJKWkRWP/jhV1UHYusbzCmsvm
|
||||||
|
M9JkknNMJvGIjBDjHEVy6dlTaHQoHDY+Me9gsrEX0ZS9xXgAiB2IupabEwARAQAB
|
||||||
|
tEJGcmVlUkRQIFNlY3VyaXR5IFRlYW0gKGh0dHBzOi8vZnJlZXJkcC5jb20pIDxz
|
||||||
|
ZWN1cml0eUBmcmVlcmRwLmNvbT6JAk4EEwEKADgWIQRvuAE0sDt7JnxXu0o3Ibww
|
||||||
|
YbfjNAUCYHP6OwIbAwULCQgHAgYVCgkICwIEFgIDAQIeAQIXgAAKCRA3IbwwYbfj
|
||||||
|
NPviD/43NLg7YfjAlvj5GipSmgelLwlIA+L/qbrf4NAB+NZ9oqp3bBdj4e5gZmiI
|
||||||
|
zd6bkANCqk21YiOE31medUfy+nfBQFVvj0oUg1X16C6RaIX5qA3Dwt5qBwKmDkT5
|
||||||
|
j7JlxUS6Eluiau67ePiDYu2Wbp0qYuAmNUNL+Y2NCO9UJiy0Oq6YVXS971D5lC+a
|
||||||
|
SX0x9pizmFV3zro+l6/3kHTVbPednfX99yz9SZge64aWXo3MXVN8JD0lR3+92l99
|
||||||
|
XsFDc+lGeR4azLFIqXC4Cr5Lbk34Hw/VwUC32xxFUaJ2ZmV3pA8bhCtBSxSmxnHS
|
||||||
|
H3hoBaD1WpuApbW8Psx6qsgoaSUdWsjluA4eQ5afJBf9O2NlT1mim5MAINY4PbWP
|
||||||
|
o4zq3p1ABVTzuB8tsGA9o6DeYVUUrj7lCv9STdGRhm0472BDkp/gvKMBoPgg3Qez
|
||||||
|
kvGKK7iVy8R/BOPjh9wP1art5JLVsralXGHA/5Ceid4ojKFzGIC9g3lnAPh+T/eM
|
||||||
|
duyY9XH4un1r73r6DRqUoczSfHYbxhKxWt0cRNdIadcXXusMPV/w4J4j55WcLrBE
|
||||||
|
5nopp/prJ5bYegUvRRrwVSFwLDxkE2dh68Zvlh5VWXIPFge0RPEAijYWR5qR2z+/
|
||||||
|
VHgPYmliOnWFJN1rzekmWjKFtg5A57FkZyk3cp5x0/2xAX+TIbkCDQRgc/o7ARAA
|
||||||
|
vw53CoVkMzBlisSEETNdEKQMaiQ8BtbC438v/b1mOOeoE0YCfSW7RyflA/TXHOah
|
||||||
|
db0s3v/Kk2xmbjeMS9IJXlWviKKnOVMrMZvtJdQ4EKfqc5EpxNx7OiEofA/7n7Xs
|
||||||
|
1YEt6KjYaM/vgANl9HA2UXzqSFiRhkWjj1WA7vhqCWUArpAMGeCDYab2BBfp6Z4f
|
||||||
|
W9178N2vHH+Hh/uBwGUDnShU38GH8Nstkdcyw5puiJqNQBfZ1Fz9luzutp6zAgHz
|
||||||
|
WzobeRPZCCXs7CfxcvpkFS0ctOteQtIRIfP+jbDnldMmClQ87UVcKv0pCCJkMLNk
|
||||||
|
YUCMAb2UC2boCIf0omeeque4+FOphcO4+R/8jc6cYlQpgwUg2/IwBEEnCqtvo3qu
|
||||||
|
k6uzONhfWZPtUdJd158MGKGTogXVXGzoGzxIrKkZ4W1VuuMiEmhIQZO8e7/4Iz4a
|
||||||
|
Zp4qQXI8rsmNJN3lB5a7MWgrZ8mjllYRdfiTEvfQ+PiQqnG6PEHZ82om9kp555gs
|
||||||
|
15UqhjHAqRRtfXzQvZko0ngAxxZNVFPwK8LnxkyEPClRBC5eV3ljI8cvCfnWD01q
|
||||||
|
rCzSlSafFHCEUEQOhOrf/bBbXPkYTJw2KlumH5w9R6xQWgqneiD/+Qmqdclzdn36
|
||||||
|
Pgbhyu6uSNZehbx5ptt/EM66JSAW7Q7W6Qnz5PNnHgEAEQEAAYkCNgQYAQoAIBYh
|
||||||
|
BG+4ATSwO3smfFe7SjchvDBht+M0BQJgc/o7AhsMAAoJEDchvDBht+M0JYUQALlV
|
||||||
|
dwmk6ZFq5dq0utWgutysL47b30BhYwNMVe0/6UW4h4TYaW6B3f58X7ik7EdYciyR
|
||||||
|
68eYfwKGhuv/y90QaGXJMU13XHpoInSaHQRhn5M/GkN16DBXdBok70Fh9Gx89Zhs
|
||||||
|
VKF3qwIVx5AO5CwrVA6F/iOiUEW31xiT7VFkbW1Cfl5H+M6nVXSR1bOdmxTObTz7
|
||||||
|
CEeJMOVrZs36hVLMWLqZF0igVebO2AsDOY63fy/9MLn8ynCHhnAMvsm9ULWuFzGj
|
||||||
|
OsJezChduaHqPkopgwihe7jthUn4qWjABbbzKkS6HLBpGAfCzUun+lMpvIEUf+EJ
|
||||||
|
bpk7gj9xDEP6y96tV/dCeWb4p8N8webR8nVgsRxoEnfIdCkoB80iZGOzKfYYnvdz
|
||||||
|
ngs8MIL6dC4Nc1/t9ECV4O/w4uwIH65nC1ay0YOK/O/j2SEfnVHQmAuOsgTz+pBn
|
||||||
|
u6DIA2HsBzFdOCljtf3m4AeAaTbL7MBSDceApqg0lcrhjclqHJo1aJh3M6aVm3gq
|
||||||
|
yUt7y26Hkh/vYEJwW4gqRho4gb7BvjTZh5LUbrjmRtexFQ1eWM82u23yYS2L+y2Y
|
||||||
|
ejSKIKmJhXHqsgCVGYw5woZEEMzgpkoIWYG/Eoy+oVuU02QITh/Uc5VRsA9DuwSV
|
||||||
|
Vw2F8gu/fHiadawxWIhUH+plFVQZc1KwgPcIMW3S
|
||||||
|
=O0kP
|
||||||
|
-----END PGP PUBLIC KEY BLOCK-----
|
||||||
|
```
|
||||||
|
[^1]: https://docs.github.com/en/code-security/security-advisories/guidance-on-reporting-and-writing/privately-reporting-a-security-vulnerability
|
||||||
@@ -0,0 +1,292 @@
|
|||||||
|
# FreeRDP: A Remote Desktop Protocol Implementation
|
||||||
|
# FreeRDP cmake build script
|
||||||
|
#
|
||||||
|
# Copyright 2012 Marc-Andre Moreau <marcandre.moreau@gmail.com>
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
|
||||||
|
include(CMakeParseArguments)
|
||||||
|
include(CMakeDependentOption)
|
||||||
|
|
||||||
|
macro(define_channel_options)
|
||||||
|
set(PREFIX "CHANNEL")
|
||||||
|
|
||||||
|
cmake_parse_arguments(
|
||||||
|
${PREFIX} "" "NAME;TYPE;DESCRIPTION;SPECIFICATIONS;DEFAULT;CLIENT_DEFAULT;SERVER_DEFAULT" "" ${ARGN}
|
||||||
|
)
|
||||||
|
|
||||||
|
string(TOUPPER "CHANNEL_${CHANNEL_NAME}" CHANNEL_OPTION)
|
||||||
|
string(TOUPPER "CHANNEL_${CHANNEL_NAME}_CLIENT" CHANNEL_CLIENT_OPTION)
|
||||||
|
string(TOUPPER "CHANNEL_${CHANNEL_NAME}_SERVER" CHANNEL_SERVER_OPTION)
|
||||||
|
string(TOUPPER "${CHANNEL_TYPE}" CHANNEL_TYPE)
|
||||||
|
|
||||||
|
if(CHANNEL_DEFAULT)
|
||||||
|
set(OPTION_DEFAULT ${CHANNEL_DEFAULT})
|
||||||
|
elseif(CHANNEL_CLIENT_OPTION OR CHANNEL_SERVER_OPTION)
|
||||||
|
set(OPTION_DEFAULT "ON")
|
||||||
|
endif()
|
||||||
|
|
||||||
|
set(CHANNEL_OPTION_DOC "Build ${CHANNEL_NAME} ${CHANNEL_TYPE} channel")
|
||||||
|
set(CHANNEL_CLIENT_OPTION_DOC "Build ${CHANNEL_NAME} ${CHANNEL_TYPE} channel client")
|
||||||
|
set(CHANNEL_SERVER_OPTION_DOC "Build ${CHANNEL_NAME} ${CHANNEL_TYPE} channel server")
|
||||||
|
|
||||||
|
if("${CHANNEL_TYPE}" STREQUAL "DYNAMIC")
|
||||||
|
cmake_dependent_option(${CHANNEL_OPTION} "${CHANNEL_OPTION_DOC}" ${CHANNEL_DEFAULT} "CHANNEL_DRDYNVC" OFF)
|
||||||
|
else()
|
||||||
|
option(${CHANNEL_OPTION} "${CHANNEL_OPTION_DOC}" ${CHANNEL_DEFAULT})
|
||||||
|
endif()
|
||||||
|
|
||||||
|
# If the channel was enabled before the client/server options will stay ensure
|
||||||
|
# they are deleted if the channel is gone.
|
||||||
|
if(NOT ${CHANNEL_OPTION})
|
||||||
|
unset(${CHANNEL_CLIENT_OPTION} CACHE)
|
||||||
|
unset(${CHANNEL_SERVER_OPTION} CACHE)
|
||||||
|
endif()
|
||||||
|
|
||||||
|
cmake_dependent_option(
|
||||||
|
${CHANNEL_CLIENT_OPTION} "${CHANNEL_CLIENT_OPTION_DOC}" ${CHANNEL_CLIENT_DEFAULT} "${CHANNEL_OPTION}" OFF
|
||||||
|
)
|
||||||
|
|
||||||
|
cmake_dependent_option(
|
||||||
|
${CHANNEL_SERVER_OPTION} "${CHANNEL_SERVER_OPTION_DOC}" ${CHANNEL_SERVER_DEFAULT} "${CHANNEL_OPTION}" OFF
|
||||||
|
)
|
||||||
|
endmacro(define_channel_options)
|
||||||
|
|
||||||
|
macro(define_channel _channel_name)
|
||||||
|
set(CHANNEL_NAME ${_channel_name})
|
||||||
|
set(MODULE_NAME ${CHANNEL_NAME})
|
||||||
|
string(TOUPPER "CHANNEL_${CHANNEL_NAME}" MODULE_PREFIX)
|
||||||
|
endmacro(define_channel)
|
||||||
|
|
||||||
|
macro(define_channel_client _channel_name)
|
||||||
|
set(CHANNEL_NAME ${_channel_name})
|
||||||
|
set(MODULE_NAME "${CHANNEL_NAME}-client")
|
||||||
|
string(TOUPPER "CHANNEL_${CHANNEL_NAME}_CLIENT" MODULE_PREFIX)
|
||||||
|
endmacro(define_channel_client)
|
||||||
|
|
||||||
|
macro(define_channel_server _channel_name)
|
||||||
|
set(CHANNEL_NAME ${_channel_name})
|
||||||
|
set(MODULE_NAME "${CHANNEL_NAME}-server")
|
||||||
|
string(TOUPPER "CHANNEL_${CHANNEL_NAME}_SERVER" MODULE_PREFIX)
|
||||||
|
endmacro(define_channel_server)
|
||||||
|
|
||||||
|
macro(define_channel_client_subsystem _channel_name _subsystem _type)
|
||||||
|
set(CHANNEL_NAME ${_channel_name})
|
||||||
|
set(CHANNEL_SUBSYSTEM ${_subsystem})
|
||||||
|
string(LENGTH "${_type}" _type_length)
|
||||||
|
string(TOUPPER "CHANNEL_${CHANNEL_NAME}_CLIENT" CHANNEL_PREFIX)
|
||||||
|
if(_type_length GREATER 0)
|
||||||
|
set(SUBSYSTEM_TYPE ${_type})
|
||||||
|
set(MODULE_NAME "${CHANNEL_NAME}-client-${CHANNEL_SUBSYSTEM}-${SUBSYSTEM_TYPE}")
|
||||||
|
string(TOUPPER "CHANNEL_${CHANNEL_NAME}_CLIENT_${CHANNEL_SUBSYSTEM}_${SUBSYSTEM_TYPE}" MODULE_PREFIX)
|
||||||
|
else()
|
||||||
|
set(MODULE_NAME "${CHANNEL_NAME}-client-${CHANNEL_SUBSYSTEM}")
|
||||||
|
string(TOUPPER "CHANNEL_${CHANNEL_NAME}_CLIENT_${CHANNEL_SUBSYSTEM}" MODULE_PREFIX)
|
||||||
|
endif()
|
||||||
|
endmacro(define_channel_client_subsystem)
|
||||||
|
|
||||||
|
macro(define_channel_server_subsystem _channel_name _subsystem _type)
|
||||||
|
set(CHANNEL_NAME ${_channel_name})
|
||||||
|
set(CHANNEL_SUBSYSTEM ${_subsystem})
|
||||||
|
set(MODULE_NAME "${CHANNEL_NAME}-server-${CHANNEL_SUBSYSTEM}")
|
||||||
|
string(TOUPPER "CHANNEL_${CHANNEL_NAME}_server_${CHANNEL_SUBSYSTEM}" MODULE_PREFIX)
|
||||||
|
endmacro(define_channel_server_subsystem)
|
||||||
|
|
||||||
|
macro(add_channel_client _channel_prefix _channel_name)
|
||||||
|
if(${_channel_prefix}_CLIENT)
|
||||||
|
add_subdirectory(client)
|
||||||
|
if(${${_channel_prefix}_CLIENT_STATIC})
|
||||||
|
set(CHANNEL_STATIC_CLIENT_MODULES ${CHANNEL_STATIC_CLIENT_MODULES} ${_channel_prefix} PARENT_SCOPE)
|
||||||
|
set(${_channel_prefix}_CLIENT_NAME ${${_channel_prefix}_CLIENT_NAME} PARENT_SCOPE)
|
||||||
|
set(${_channel_prefix}_CLIENT_CHANNEL ${${_channel_prefix}_CLIENT_CHANNEL} PARENT_SCOPE)
|
||||||
|
set(${_channel_prefix}_CLIENT_ENTRY ${${_channel_prefix}_CLIENT_ENTRY} PARENT_SCOPE)
|
||||||
|
set(CHANNEL_STATIC_CLIENT_ENTRIES ${CHANNEL_STATIC_CLIENT_ENTRIES} ${${_channel_prefix}_CLIENT_ENTRY}
|
||||||
|
PARENT_SCOPE
|
||||||
|
)
|
||||||
|
endif()
|
||||||
|
endif()
|
||||||
|
endmacro(add_channel_client)
|
||||||
|
|
||||||
|
macro(add_channel_server _channel_prefix _channel_name)
|
||||||
|
if(${_channel_prefix}_SERVER)
|
||||||
|
add_subdirectory(server)
|
||||||
|
if(${${_channel_prefix}_SERVER_STATIC})
|
||||||
|
set(CHANNEL_STATIC_SERVER_MODULES ${CHANNEL_STATIC_SERVER_MODULES} ${_channel_prefix} PARENT_SCOPE)
|
||||||
|
set(${_channel_prefix}_SERVER_NAME ${${_channel_prefix}_SERVER_NAME} PARENT_SCOPE)
|
||||||
|
set(${_channel_prefix}_SERVER_CHANNEL ${${_channel_prefix}_SERVER_CHANNEL} PARENT_SCOPE)
|
||||||
|
set(${_channel_prefix}_SERVER_ENTRY ${${_channel_prefix}_SERVER_ENTRY} PARENT_SCOPE)
|
||||||
|
set(CHANNEL_STATIC_SERVER_ENTRIES ${CHANNEL_STATIC_SERVER_ENTRIES} ${${_channel_prefix}_SERVER_ENTRY}
|
||||||
|
PARENT_SCOPE
|
||||||
|
)
|
||||||
|
endif()
|
||||||
|
endif()
|
||||||
|
endmacro(add_channel_server)
|
||||||
|
|
||||||
|
macro(add_channel_client_subsystem _channel_prefix _channel_name _subsystem _type)
|
||||||
|
add_subdirectory(${_subsystem})
|
||||||
|
set(_channel_module_name "${_channel_name}-client")
|
||||||
|
string(LENGTH "${_type}" _type_length)
|
||||||
|
if(_type_length GREATER 0)
|
||||||
|
string(TOUPPER "CHANNEL_${_channel_name}_CLIENT_${_subsystem}_${_type}" _subsystem_prefix)
|
||||||
|
else()
|
||||||
|
string(TOUPPER "CHANNEL_${_channel_name}_CLIENT_${_subsystem}" _subsystem_prefix)
|
||||||
|
endif()
|
||||||
|
if(${${_subsystem_prefix}_STATIC})
|
||||||
|
get_target_property(CHANNEL_SUBSYSTEMS ${_channel_module_name} SUBSYSTEMS)
|
||||||
|
if(_type_length GREATER 0)
|
||||||
|
set(SUBSYSTEMS ${SUBSYSTEMS} "${_subsystem}-${_type}")
|
||||||
|
else()
|
||||||
|
set(SUBSYSTEMS ${SUBSYSTEMS} ${_subsystem})
|
||||||
|
endif()
|
||||||
|
set_target_properties(${_channel_module_name} PROPERTIES SUBSYSTEMS "${SUBSYSTEMS}")
|
||||||
|
endif()
|
||||||
|
endmacro(add_channel_client_subsystem)
|
||||||
|
|
||||||
|
macro(channel_install _targets _destination _export_target)
|
||||||
|
if(NOT BUILD_SHARED_LIBS)
|
||||||
|
foreach(_target_name IN ITEMS ${_targets})
|
||||||
|
target_include_directories(${_target_name} INTERFACE $<INSTALL_INTERFACE:include>)
|
||||||
|
endforeach()
|
||||||
|
installwithrpath(TARGETS ${_targets} DESTINATION ${_destination} EXPORT ${_export_target})
|
||||||
|
endif()
|
||||||
|
endmacro(channel_install)
|
||||||
|
|
||||||
|
macro(server_channel_install _targets _destination)
|
||||||
|
channel_install(${_targets} ${_destination} "FreeRDP-ServerTargets")
|
||||||
|
endmacro(server_channel_install)
|
||||||
|
|
||||||
|
macro(client_channel_install _targets _destination)
|
||||||
|
channel_install(${_targets} ${_destination} "FreeRDP-ClientTargets")
|
||||||
|
endmacro(client_channel_install)
|
||||||
|
|
||||||
|
macro(add_channel_client_library _module_prefix _module_name _channel_name _dynamic _entry)
|
||||||
|
set(_lnk_dir ${${_module_prefix}_LINK_DIRS})
|
||||||
|
if(NOT "${_lnk_dir}" STREQUAL "")
|
||||||
|
link_directories(${_lnk_dir})
|
||||||
|
endif()
|
||||||
|
|
||||||
|
set(${_module_prefix}_STATIC ON PARENT_SCOPE)
|
||||||
|
set(${_module_prefix}_NAME ${_module_name} PARENT_SCOPE)
|
||||||
|
set(${_module_prefix}_CHANNEL ${_channel_name} PARENT_SCOPE)
|
||||||
|
set(${_module_prefix}_ENTRY ${_entry} PARENT_SCOPE)
|
||||||
|
|
||||||
|
add_library(${_module_name} OBJECT ${${_module_prefix}_SRCS})
|
||||||
|
set_property(TARGET ${_module_name} PROPERTY FOLDER "Channels/${CHANNEL_NAME}/Client")
|
||||||
|
|
||||||
|
if(${_module_prefix}_LIBS)
|
||||||
|
target_link_libraries(${_module_name} PUBLIC ${${_module_prefix}_LIBS})
|
||||||
|
endif()
|
||||||
|
client_channel_install(${_module_name} ${FREERDP_ADDIN_PATH})
|
||||||
|
endmacro(add_channel_client_library)
|
||||||
|
|
||||||
|
macro(
|
||||||
|
add_channel_client_subsystem_library
|
||||||
|
_module_prefix
|
||||||
|
_module_name
|
||||||
|
_channel_name
|
||||||
|
_type
|
||||||
|
_dynamic
|
||||||
|
_entry
|
||||||
|
)
|
||||||
|
set(_lnk_dir ${${_module_prefix}_LINK_DIRS})
|
||||||
|
if(NOT "${_lnk_dir}" STREQUAL "")
|
||||||
|
link_directories(${_lnk_dir})
|
||||||
|
endif()
|
||||||
|
|
||||||
|
set(${_module_prefix}_STATIC ON PARENT_SCOPE)
|
||||||
|
set(${_module_prefix}_NAME ${_module_name} PARENT_SCOPE)
|
||||||
|
set(${_module_prefix}_TYPE ${_type} PARENT_SCOPE)
|
||||||
|
|
||||||
|
add_library(${_module_name} OBJECT ${${_module_prefix}_SRCS})
|
||||||
|
set_property(TARGET ${_module_name} PROPERTY FOLDER "Channels/${_channel_name}/Client/Subsystem/${CHANNEL_SUBSYSTEM}")
|
||||||
|
|
||||||
|
if(${_module_prefix}_LIBS)
|
||||||
|
target_link_libraries(${_module_name} PUBLIC ${${_module_prefix}_LIBS})
|
||||||
|
endif()
|
||||||
|
client_channel_install(${_module_name} ${FREERDP_ADDIN_PATH})
|
||||||
|
endmacro(add_channel_client_subsystem_library)
|
||||||
|
|
||||||
|
macro(add_channel_server_library _module_prefix _module_name _channel_name _dynamic _entry)
|
||||||
|
set(_lnk_dir ${${_module_prefix}_LINK_DIRS})
|
||||||
|
if(NOT "${_lnk_dir}" STREQUAL "")
|
||||||
|
link_directories(${_lnk_dir})
|
||||||
|
endif()
|
||||||
|
|
||||||
|
set(${_module_prefix}_STATIC ON PARENT_SCOPE)
|
||||||
|
set(${_module_prefix}_NAME ${_module_name} PARENT_SCOPE)
|
||||||
|
set(${_module_prefix}_CHANNEL ${_channel_name} PARENT_SCOPE)
|
||||||
|
set(${_module_prefix}_ENTRY ${_entry} PARENT_SCOPE)
|
||||||
|
|
||||||
|
add_library(${_module_name} OBJECT ${${_module_prefix}_SRCS})
|
||||||
|
set_property(TARGET ${_module_name} PROPERTY FOLDER "Channels/${CHANNEL_NAME}/Server")
|
||||||
|
|
||||||
|
if(${_module_prefix}_LIBS)
|
||||||
|
target_link_libraries(${_module_name} PUBLIC ${${_module_prefix}_LIBS})
|
||||||
|
endif()
|
||||||
|
server_channel_install(${_module_name} ${FREERDP_ADDIN_PATH})
|
||||||
|
endmacro(add_channel_server_library)
|
||||||
|
|
||||||
|
set(FILENAME "ChannelOptions.cmake")
|
||||||
|
file(GLOB FILEPATHS RELATIVE ${CMAKE_CURRENT_SOURCE_DIR} "*/${FILENAME}")
|
||||||
|
|
||||||
|
# We need special treatment for drdynvc:
|
||||||
|
# It needs to be the first entry so that every
|
||||||
|
# dynamic channel has the dependent options available.
|
||||||
|
set(DRDYNVC_MATCH "")
|
||||||
|
|
||||||
|
foreach(FILEPATH ${FILEPATHS})
|
||||||
|
if(${FILEPATH} MATCHES "^([^/]*)drdynvc/+${FILENAME}")
|
||||||
|
set(DRDYNVC_MATCH ${FILEPATH})
|
||||||
|
endif()
|
||||||
|
endforeach()
|
||||||
|
|
||||||
|
if(NOT "${DRDYNVC_MATCH}" STREQUAL "")
|
||||||
|
list(REMOVE_ITEM FILEPATHS ${DRDYNVC_MATCH})
|
||||||
|
list(APPEND FILEPATHS ${DRDYNVC_MATCH})
|
||||||
|
list(REVERSE FILEPATHS) # list PREPEND is not available on old CMake3
|
||||||
|
endif()
|
||||||
|
|
||||||
|
foreach(FILEPATH ${FILEPATHS})
|
||||||
|
if(${FILEPATH} MATCHES "^([^/]*)/+${FILENAME}")
|
||||||
|
string(REGEX REPLACE "^([^/]*)/+${FILENAME}" "\\1" DIR ${FILEPATH})
|
||||||
|
set(CHANNEL_OPTION)
|
||||||
|
include(${FILEPATH})
|
||||||
|
if(${CHANNEL_OPTION})
|
||||||
|
set(CHANNEL_MESSAGE "Adding ${CHANNEL_TYPE} channel")
|
||||||
|
if(${CHANNEL_CLIENT_OPTION})
|
||||||
|
set(CHANNEL_MESSAGE "${CHANNEL_MESSAGE} client")
|
||||||
|
endif()
|
||||||
|
if(${CHANNEL_SERVER_OPTION})
|
||||||
|
set(CHANNEL_MESSAGE "${CHANNEL_MESSAGE} server")
|
||||||
|
endif()
|
||||||
|
set(CHANNEL_MESSAGE "${CHANNEL_MESSAGE} \"${CHANNEL_NAME}\"")
|
||||||
|
set(CHANNEL_MESSAGE "${CHANNEL_MESSAGE}: ${CHANNEL_DESCRIPTION}")
|
||||||
|
message(STATUS "${CHANNEL_MESSAGE}")
|
||||||
|
add_subdirectory(${DIR})
|
||||||
|
endif()
|
||||||
|
endif()
|
||||||
|
endforeach(FILEPATH)
|
||||||
|
|
||||||
|
if(WITH_CHANNELS)
|
||||||
|
if(WITH_CLIENT_CHANNELS)
|
||||||
|
add_subdirectory(client)
|
||||||
|
set(FREERDP_CHANNELS_CLIENT_SRCS ${FREERDP_CHANNELS_CLIENT_SRCS} PARENT_SCOPE)
|
||||||
|
set(FREERDP_CHANNELS_CLIENT_LIBS ${FREERDP_CHANNELS_CLIENT_LIBS} PARENT_SCOPE)
|
||||||
|
endif()
|
||||||
|
|
||||||
|
if(WITH_SERVER_CHANNELS)
|
||||||
|
add_subdirectory(server)
|
||||||
|
set(FREERDP_CHANNELS_SERVER_SRCS ${FREERDP_CHANNELS_SERVER_SRCS} PARENT_SCOPE)
|
||||||
|
set(FREERDP_CHANNELS_SERVER_LIBS ${FREERDP_CHANNELS_SERVER_LIBS} PARENT_SCOPE)
|
||||||
|
endif()
|
||||||
|
endif()
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# FreeRDP: A Remote Desktop Protocol Implementation
|
||||||
|
# FreeRDP cmake build script
|
||||||
|
#
|
||||||
|
# Copyright 2022 Armin Novak <anovak@thincast.com>
|
||||||
|
# Copyright 2022 Thincast Technologies GmbH
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
|
||||||
|
define_channel("ainput")
|
||||||
|
|
||||||
|
if(WITH_CLIENT_CHANNELS)
|
||||||
|
add_channel_client(${MODULE_PREFIX} ${CHANNEL_NAME})
|
||||||
|
endif()
|
||||||
|
|
||||||
|
if(WITH_SERVER_CHANNELS)
|
||||||
|
add_channel_server(${MODULE_PREFIX} ${CHANNEL_NAME})
|
||||||
|
endif()
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
set(OPTION_DEFAULT ON)
|
||||||
|
set(OPTION_CLIENT_DEFAULT ON)
|
||||||
|
set(OPTION_SERVER_DEFAULT ON)
|
||||||
|
|
||||||
|
define_channel_options(
|
||||||
|
NAME
|
||||||
|
"ainput"
|
||||||
|
TYPE
|
||||||
|
"dynamic"
|
||||||
|
DESCRIPTION
|
||||||
|
"Advanced Input Virtual Channel Extension"
|
||||||
|
SPECIFICATIONS
|
||||||
|
"[XXXXX]"
|
||||||
|
DEFAULT
|
||||||
|
${OPTION_DEFAULT}
|
||||||
|
CLIENT_DEFAULT
|
||||||
|
${OPTION_CLIENT_DEFAULT}
|
||||||
|
SERVER_DEFAULT
|
||||||
|
${OPTION_SERVER_DEFAULT}
|
||||||
|
)
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# FreeRDP: A Remote Desktop Protocol Implementation
|
||||||
|
# FreeRDP cmake build script
|
||||||
|
#
|
||||||
|
# Copyright 2022 Armin Novak <anovak@thincast.com>
|
||||||
|
# Copyright 2022 Thincast Technologies GmbH
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
|
||||||
|
define_channel_client("ainput")
|
||||||
|
|
||||||
|
set(${MODULE_PREFIX}_SRCS ainput_main.c ainput_main.h)
|
||||||
|
|
||||||
|
set(${MODULE_PREFIX}_LIBS winpr)
|
||||||
|
|
||||||
|
include_directories(..)
|
||||||
|
|
||||||
|
add_channel_client_library(${MODULE_PREFIX} ${MODULE_NAME} ${CHANNEL_NAME} TRUE "DVCPluginEntry")
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
/**
|
||||||
|
* FreeRDP: A Remote Desktop Protocol Implementation
|
||||||
|
* Advanced Input Virtual Channel Extension
|
||||||
|
*
|
||||||
|
* Copyright 2022 Armin Novak <anovak@thincast.com>
|
||||||
|
* Copyright 2022 Thincast Technologies GmbH
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
#include <freerdp/config.h>
|
||||||
|
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <stdlib.h>
|
||||||
|
|
||||||
|
#include <winpr/crt.h>
|
||||||
|
#include <winpr/assert.h>
|
||||||
|
#include <winpr/stream.h>
|
||||||
|
#include <winpr/sysinfo.h>
|
||||||
|
|
||||||
|
#include "ainput_main.h"
|
||||||
|
#include <freerdp/channels/log.h>
|
||||||
|
#include <freerdp/client/channels.h>
|
||||||
|
#include <freerdp/client/ainput.h>
|
||||||
|
#include <freerdp/channels/ainput.h>
|
||||||
|
|
||||||
|
#include "../common/ainput_common.h"
|
||||||
|
|
||||||
|
#define TAG CHANNELS_TAG("ainput.client")
|
||||||
|
|
||||||
|
typedef struct AINPUT_PLUGIN_ AINPUT_PLUGIN;
|
||||||
|
struct AINPUT_PLUGIN_
|
||||||
|
{
|
||||||
|
GENERIC_DYNVC_PLUGIN base;
|
||||||
|
AInputClientContext* context;
|
||||||
|
UINT32 MajorVersion;
|
||||||
|
UINT32 MinorVersion;
|
||||||
|
CRITICAL_SECTION lock;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Function description
|
||||||
|
*
|
||||||
|
* @return 0 on success, otherwise a Win32 error code
|
||||||
|
*/
|
||||||
|
static UINT ainput_on_data_received(IWTSVirtualChannelCallback* pChannelCallback, wStream* data)
|
||||||
|
{
|
||||||
|
UINT16 type = 0;
|
||||||
|
AINPUT_PLUGIN* ainput = nullptr;
|
||||||
|
GENERIC_CHANNEL_CALLBACK* callback = (GENERIC_CHANNEL_CALLBACK*)pChannelCallback;
|
||||||
|
|
||||||
|
WINPR_ASSERT(callback);
|
||||||
|
WINPR_ASSERT(data);
|
||||||
|
|
||||||
|
ainput = (AINPUT_PLUGIN*)callback->plugin;
|
||||||
|
WINPR_ASSERT(ainput);
|
||||||
|
|
||||||
|
if (!Stream_CheckAndLogRequiredLength(TAG, data, 2))
|
||||||
|
return ERROR_NO_DATA;
|
||||||
|
Stream_Read_UINT16(data, type);
|
||||||
|
switch (type)
|
||||||
|
{
|
||||||
|
case MSG_AINPUT_VERSION:
|
||||||
|
if (!Stream_CheckAndLogRequiredLength(TAG, data, 8))
|
||||||
|
return ERROR_NO_DATA;
|
||||||
|
Stream_Read_UINT32(data, ainput->MajorVersion);
|
||||||
|
Stream_Read_UINT32(data, ainput->MinorVersion);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
WLog_WARN(TAG, "Received unsupported message type 0x%04" PRIx16, type);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return CHANNEL_RC_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
static UINT ainput_send_input_event(AInputClientContext* context, UINT64 flags, INT32 x, INT32 y)
|
||||||
|
{
|
||||||
|
BYTE buffer[32] = WINPR_C_ARRAY_INIT;
|
||||||
|
wStream sbuffer = WINPR_C_ARRAY_INIT;
|
||||||
|
wStream* s = Stream_StaticInit(&sbuffer, buffer, sizeof(buffer));
|
||||||
|
|
||||||
|
WINPR_ASSERT(s);
|
||||||
|
WINPR_ASSERT(context);
|
||||||
|
|
||||||
|
const UINT64 time = GetTickCount64();
|
||||||
|
AINPUT_PLUGIN* ainput = (AINPUT_PLUGIN*)context->handle;
|
||||||
|
WINPR_ASSERT(ainput);
|
||||||
|
|
||||||
|
if (ainput->MajorVersion != AINPUT_VERSION_MAJOR)
|
||||||
|
{
|
||||||
|
WLog_WARN(TAG, "Unsupported channel version %" PRIu32 ".%" PRIu32 ", aborting.",
|
||||||
|
ainput->MajorVersion, ainput->MinorVersion);
|
||||||
|
return CHANNEL_RC_UNSUPPORTED_VERSION;
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
char ebuffer[128] = WINPR_C_ARRAY_INIT;
|
||||||
|
WLog_VRB(TAG, "sending timestamp=0x%08" PRIx64 ", flags=%s, %" PRId32 "x%" PRId32, time,
|
||||||
|
ainput_flags_to_string(flags, ebuffer, sizeof(ebuffer)), x, y);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Message type */
|
||||||
|
Stream_Write_UINT16(s, MSG_AINPUT_MOUSE);
|
||||||
|
|
||||||
|
/* Event data */
|
||||||
|
Stream_Write_UINT64(s, time);
|
||||||
|
Stream_Write_UINT64(s, flags);
|
||||||
|
Stream_Write_INT32(s, x);
|
||||||
|
Stream_Write_INT32(s, y);
|
||||||
|
Stream_SealLength(s);
|
||||||
|
|
||||||
|
/* ainput back what we have received. AINPUT does not have any message IDs. */
|
||||||
|
EnterCriticalSection(&ainput->lock);
|
||||||
|
GENERIC_CHANNEL_CALLBACK* callback = ainput->base.listener_callback->channel_callback;
|
||||||
|
WINPR_ASSERT(callback);
|
||||||
|
WINPR_ASSERT(callback->channel);
|
||||||
|
WINPR_ASSERT(callback->channel->Write);
|
||||||
|
const UINT rc = callback->channel->Write(callback->channel, (ULONG)Stream_Length(s),
|
||||||
|
Stream_Buffer(s), nullptr);
|
||||||
|
LeaveCriticalSection(&ainput->lock);
|
||||||
|
return rc;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Function description
|
||||||
|
*
|
||||||
|
* @return 0 on success, otherwise a Win32 error code
|
||||||
|
*/
|
||||||
|
static UINT ainput_on_close(IWTSVirtualChannelCallback* pChannelCallback)
|
||||||
|
{
|
||||||
|
GENERIC_CHANNEL_CALLBACK* callback = (GENERIC_CHANNEL_CALLBACK*)pChannelCallback;
|
||||||
|
|
||||||
|
if (callback)
|
||||||
|
{
|
||||||
|
AINPUT_PLUGIN* ainput = (AINPUT_PLUGIN*)callback->plugin;
|
||||||
|
WINPR_ASSERT(ainput);
|
||||||
|
|
||||||
|
/* Lock here to ensure that no ainput_send_input_event is in progress. */
|
||||||
|
EnterCriticalSection(&ainput->lock);
|
||||||
|
free(callback);
|
||||||
|
LeaveCriticalSection(&ainput->lock);
|
||||||
|
}
|
||||||
|
return CHANNEL_RC_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
static UINT init_plugin_cb(GENERIC_DYNVC_PLUGIN* base, WINPR_ATTR_UNUSED rdpContext* rcontext,
|
||||||
|
WINPR_ATTR_UNUSED rdpSettings* settings)
|
||||||
|
{
|
||||||
|
AINPUT_PLUGIN* ainput = (AINPUT_PLUGIN*)base;
|
||||||
|
AInputClientContext* context = (AInputClientContext*)calloc(1, sizeof(AInputClientContext));
|
||||||
|
if (!context)
|
||||||
|
return CHANNEL_RC_NO_MEMORY;
|
||||||
|
|
||||||
|
context->handle = (void*)base;
|
||||||
|
context->AInputSendInputEvent = ainput_send_input_event;
|
||||||
|
|
||||||
|
InitializeCriticalSection(&ainput->lock);
|
||||||
|
|
||||||
|
EnterCriticalSection(&ainput->lock);
|
||||||
|
ainput->context = context;
|
||||||
|
ainput->base.iface.pInterface = context;
|
||||||
|
LeaveCriticalSection(&ainput->lock);
|
||||||
|
return CHANNEL_RC_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void terminate_plugin_cb(GENERIC_DYNVC_PLUGIN* base)
|
||||||
|
{
|
||||||
|
AINPUT_PLUGIN* ainput = (AINPUT_PLUGIN*)base;
|
||||||
|
WINPR_ASSERT(ainput);
|
||||||
|
|
||||||
|
DeleteCriticalSection(&ainput->lock);
|
||||||
|
free(ainput->context);
|
||||||
|
}
|
||||||
|
|
||||||
|
static const IWTSVirtualChannelCallback ainput_functions = { ainput_on_data_received,
|
||||||
|
nullptr, /* Open */
|
||||||
|
ainput_on_close, nullptr };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Function description
|
||||||
|
*
|
||||||
|
* @return 0 on success, otherwise a Win32 error code
|
||||||
|
*/
|
||||||
|
FREERDP_ENTRY_POINT(UINT VCAPITYPE ainput_DVCPluginEntry(IDRDYNVC_ENTRY_POINTS* pEntryPoints))
|
||||||
|
{
|
||||||
|
return freerdp_generic_DVCPluginEntry(pEntryPoints, TAG, AINPUT_DVC_CHANNEL_NAME,
|
||||||
|
sizeof(AINPUT_PLUGIN), sizeof(GENERIC_CHANNEL_CALLBACK),
|
||||||
|
&ainput_functions, init_plugin_cb, terminate_plugin_cb);
|
||||||
|
}
|
||||||