Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
@@ -1 +1,3 @@
|
|||||||
/build/
|
/build/
|
||||||
|
/dist/
|
||||||
|
/.flatpak-builder/
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
cmake_minimum_required(VERSION 3.21)
|
cmake_minimum_required(VERSION 3.21)
|
||||||
|
|
||||||
project(OrbitHub VERSION 0.1.0 LANGUAGES CXX)
|
project(OrbitHub VERSION 2026.9.8 LANGUAGES CXX)
|
||||||
|
|
||||||
set(CMAKE_CXX_STANDARD 17)
|
set(CMAKE_CXX_STANDARD 17)
|
||||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||||
@@ -10,6 +10,8 @@ set(CMAKE_AUTOMOC ON)
|
|||||||
set(CMAKE_AUTOUIC ON)
|
set(CMAKE_AUTOUIC ON)
|
||||||
set(CMAKE_AUTORCC ON)
|
set(CMAKE_AUTORCC ON)
|
||||||
|
|
||||||
|
include(GNUInstallDirs)
|
||||||
|
|
||||||
find_package(Qt6 6.2 REQUIRED COMPONENTS Widgets Sql)
|
find_package(Qt6 6.2 REQUIRED COMPONENTS Widgets Sql)
|
||||||
|
|
||||||
qt_standard_project_setup()
|
qt_standard_project_setup()
|
||||||
@@ -40,7 +42,8 @@ set(CHANNEL_DISP ON CACHE BOOL "" FORCE)
|
|||||||
set(CHANNEL_DISP_CLIENT ON CACHE BOOL "" FORCE)
|
set(CHANNEL_DISP_CLIENT ON CACHE BOOL "" FORCE)
|
||||||
set(CHANNEL_AINPUT OFF CACHE BOOL "" FORCE)
|
set(CHANNEL_AINPUT OFF CACHE BOOL "" FORCE)
|
||||||
set(CHANNEL_AUDIN OFF CACHE BOOL "" FORCE)
|
set(CHANNEL_AUDIN OFF CACHE BOOL "" FORCE)
|
||||||
set(CHANNEL_CLIPRDR OFF CACHE BOOL "" FORCE)
|
set(CHANNEL_CLIPRDR ON CACHE BOOL "" FORCE)
|
||||||
|
set(CHANNEL_CLIPRDR_CLIENT ON CACHE BOOL "" FORCE)
|
||||||
set(CHANNEL_DRIVE OFF CACHE BOOL "" FORCE)
|
set(CHANNEL_DRIVE OFF CACHE BOOL "" FORCE)
|
||||||
set(CHANNEL_ECHO OFF CACHE BOOL "" FORCE)
|
set(CHANNEL_ECHO OFF CACHE BOOL "" FORCE)
|
||||||
set(CHANNEL_ENCOMSP OFF CACHE BOOL "" FORCE)
|
set(CHANNEL_ENCOMSP OFF CACHE BOOL "" FORCE)
|
||||||
@@ -75,12 +78,18 @@ set(WITH_WINPR_TOOLS OFF CACHE BOOL "" FORCE)
|
|||||||
|
|
||||||
add_subdirectory(third_party/FreeRDP EXCLUDE_FROM_ALL)
|
add_subdirectory(third_party/FreeRDP EXCLUDE_FROM_ALL)
|
||||||
|
|
||||||
add_executable(orbithub
|
set(ORBITHUB_SOURCES
|
||||||
|
src/about_dialog.cpp
|
||||||
|
src/about_dialog.h
|
||||||
|
src/app_icon.cpp
|
||||||
|
src/app_icon.h
|
||||||
src/main.cpp
|
src/main.cpp
|
||||||
src/profile_dialog.cpp
|
src/profile_dialog.cpp
|
||||||
src/profile_dialog.h
|
src/profile_dialog.h
|
||||||
src/profile_repository.cpp
|
src/profile_repository.cpp
|
||||||
src/profile_repository.h
|
src/profile_repository.h
|
||||||
|
src/profiles_tree_widget.cpp
|
||||||
|
src/profiles_tree_widget.h
|
||||||
src/profiles_window.cpp
|
src/profiles_window.cpp
|
||||||
src/profiles_window.h
|
src/profiles_window.h
|
||||||
src/session_backend.h
|
src/session_backend.h
|
||||||
@@ -102,6 +111,24 @@ add_executable(orbithub
|
|||||||
src/unsupported_session_backend.h
|
src/unsupported_session_backend.h
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if(WIN32)
|
||||||
|
list(APPEND ORBITHUB_SOURCES packaging/windows/orbithub.rc)
|
||||||
|
endif()
|
||||||
|
|
||||||
|
if(APPLE)
|
||||||
|
list(APPEND ORBITHUB_SOURCES packaging/macos/orbithub.icns)
|
||||||
|
set_source_files_properties(packaging/macos/orbithub.icns PROPERTIES
|
||||||
|
MACOSX_PACKAGE_LOCATION "Resources"
|
||||||
|
)
|
||||||
|
set(MACOSX_BUNDLE_ICON_FILE orbithub.icns)
|
||||||
|
set(MACOSX_BUNDLE_BUNDLE_NAME "OrbitHub")
|
||||||
|
set(MACOSX_BUNDLE_GUI_IDENTIFIER "org.darksingularity.OrbitHub")
|
||||||
|
set(MACOSX_BUNDLE_BUNDLE_VERSION "${PROJECT_VERSION}")
|
||||||
|
set(MACOSX_BUNDLE_SHORT_VERSION_STRING "${PROJECT_VERSION}")
|
||||||
|
endif()
|
||||||
|
|
||||||
|
add_executable(orbithub WIN32 MACOSX_BUNDLE ${ORBITHUB_SOURCES})
|
||||||
|
|
||||||
target_link_libraries(orbithub PRIVATE Qt6::Widgets Qt6::Sql)
|
target_link_libraries(orbithub PRIVATE Qt6::Widgets Qt6::Sql)
|
||||||
target_link_libraries(orbithub PRIVATE KodoTerm::KodoTerm)
|
target_link_libraries(orbithub PRIVATE KodoTerm::KodoTerm)
|
||||||
if(TARGET freerdp AND TARGET winpr)
|
if(TARGET freerdp AND TARGET winpr)
|
||||||
@@ -120,4 +147,114 @@ else()
|
|||||||
message(FATAL_ERROR "Vendored FreeRDP targets were not produced as expected.")
|
message(FATAL_ERROR "Vendored FreeRDP targets were not produced as expected.")
|
||||||
endif()
|
endif()
|
||||||
|
|
||||||
install(TARGETS orbithub RUNTIME DESTINATION bin)
|
# Qt loads platform integration and SQL driver plugins dynamically at
|
||||||
|
# runtime (QFactoryLoader), so they never appear in orbithub.exe's import
|
||||||
|
# table. vcpkg's automatic DLL deployment only follows the import table, so
|
||||||
|
# these plugins have to be copied out explicitly or the app fails to start
|
||||||
|
# with "Could not find the Qt platform plugin" / "can not load requested
|
||||||
|
# driver 'QSQLITE'".
|
||||||
|
if(WIN32)
|
||||||
|
if(TARGET Qt6::QWindowsIntegrationPlugin)
|
||||||
|
add_custom_command(TARGET orbithub POST_BUILD
|
||||||
|
COMMAND ${CMAKE_COMMAND} -E make_directory
|
||||||
|
"$<TARGET_FILE_DIR:orbithub>/platforms"
|
||||||
|
COMMAND ${CMAKE_COMMAND} -E copy_if_different
|
||||||
|
"$<TARGET_FILE:Qt6::QWindowsIntegrationPlugin>"
|
||||||
|
"$<TARGET_FILE_DIR:orbithub>/platforms/"
|
||||||
|
COMMENT "Deploying Qt Windows platform plugin"
|
||||||
|
)
|
||||||
|
install(FILES "$<TARGET_FILE:Qt6::QWindowsIntegrationPlugin>"
|
||||||
|
DESTINATION "${CMAKE_INSTALL_BINDIR}/platforms"
|
||||||
|
)
|
||||||
|
endif()
|
||||||
|
|
||||||
|
if(TARGET Qt6::QSQLiteDriverPlugin)
|
||||||
|
add_custom_command(TARGET orbithub POST_BUILD
|
||||||
|
COMMAND ${CMAKE_COMMAND} -E make_directory
|
||||||
|
"$<TARGET_FILE_DIR:orbithub>/sqldrivers"
|
||||||
|
COMMAND ${CMAKE_COMMAND} -E copy_if_different
|
||||||
|
"$<TARGET_FILE:Qt6::QSQLiteDriverPlugin>"
|
||||||
|
"$<TARGET_FILE_DIR:orbithub>/sqldrivers/"
|
||||||
|
COMMENT "Deploying Qt SQLite driver plugin"
|
||||||
|
)
|
||||||
|
install(FILES "$<TARGET_FILE:Qt6::QSQLiteDriverPlugin>"
|
||||||
|
DESTINATION "${CMAKE_INSTALL_BINDIR}/sqldrivers"
|
||||||
|
)
|
||||||
|
|
||||||
|
# vcpkg's qtbase[sql-sqlite] links the plugin against vcpkg's own
|
||||||
|
# shared sqlite3 port rather than bundling SQLite into the plugin,
|
||||||
|
# so the plugin fails to load ("can not load requested driver
|
||||||
|
# 'QSQLITE'") unless sqlite3.dll also ships next to the executable.
|
||||||
|
find_file(ORBITHUB_SQLITE3_DLL
|
||||||
|
NAMES sqlite3.dll
|
||||||
|
PATHS ${CMAKE_PREFIX_PATH}
|
||||||
|
PATH_SUFFIXES bin
|
||||||
|
)
|
||||||
|
if(ORBITHUB_SQLITE3_DLL)
|
||||||
|
add_custom_command(TARGET orbithub POST_BUILD
|
||||||
|
COMMAND ${CMAKE_COMMAND} -E copy_if_different
|
||||||
|
"${ORBITHUB_SQLITE3_DLL}"
|
||||||
|
"$<TARGET_FILE_DIR:orbithub>/"
|
||||||
|
COMMENT "Deploying sqlite3.dll (Qt SQLite plugin dependency)"
|
||||||
|
)
|
||||||
|
install(FILES "${ORBITHUB_SQLITE3_DLL}" DESTINATION "${CMAKE_INSTALL_BINDIR}")
|
||||||
|
endif()
|
||||||
|
endif()
|
||||||
|
endif()
|
||||||
|
|
||||||
|
if(APPLE)
|
||||||
|
# Mirrors macdeployqt's own layout for Qt frameworks: dylibs live in
|
||||||
|
# Contents/Frameworks inside the bundle, found via @executable_path
|
||||||
|
# (the macOS/dyld equivalent of Linux's $ORIGIN token, which dyld
|
||||||
|
# does not understand).
|
||||||
|
set_target_properties(orbithub PROPERTIES
|
||||||
|
INSTALL_RPATH "@executable_path/../Frameworks"
|
||||||
|
INSTALL_RPATH_USE_LINK_PATH ON
|
||||||
|
)
|
||||||
|
else()
|
||||||
|
set_target_properties(orbithub PROPERTIES
|
||||||
|
BUILD_RPATH_USE_ORIGIN ON
|
||||||
|
INSTALL_RPATH "$ORIGIN/../${CMAKE_INSTALL_LIBDIR}/orbithub"
|
||||||
|
INSTALL_RPATH_USE_LINK_PATH ON
|
||||||
|
)
|
||||||
|
endif()
|
||||||
|
|
||||||
|
install(TARGETS orbithub
|
||||||
|
RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR}
|
||||||
|
BUNDLE DESTINATION .
|
||||||
|
)
|
||||||
|
|
||||||
|
if(APPLE)
|
||||||
|
set(ORBITHUB_PRIVATE_LIB_DESTINATION "OrbitHub.app/Contents/Frameworks")
|
||||||
|
else()
|
||||||
|
set(ORBITHUB_PRIVATE_LIB_DESTINATION "${CMAKE_INSTALL_LIBDIR}/orbithub")
|
||||||
|
endif()
|
||||||
|
set(ORBITHUB_RUNTIME_TARGETS KodoTerm freerdp winpr)
|
||||||
|
if(TARGET freerdp-client)
|
||||||
|
list(APPEND ORBITHUB_RUNTIME_TARGETS freerdp-client)
|
||||||
|
endif()
|
||||||
|
foreach(runtime_target IN LISTS ORBITHUB_RUNTIME_TARGETS)
|
||||||
|
if(TARGET ${runtime_target})
|
||||||
|
install(TARGETS ${runtime_target}
|
||||||
|
RUNTIME DESTINATION ${ORBITHUB_PRIVATE_LIB_DESTINATION}
|
||||||
|
LIBRARY DESTINATION ${ORBITHUB_PRIVATE_LIB_DESTINATION}
|
||||||
|
ARCHIVE DESTINATION ${CMAKE_INSTALL_LIBDIR}
|
||||||
|
)
|
||||||
|
endif()
|
||||||
|
endforeach()
|
||||||
|
|
||||||
|
install(FILES
|
||||||
|
packaging/linux/org.darksingularity.OrbitHub.desktop
|
||||||
|
DESTINATION ${CMAKE_INSTALL_DATADIR}/applications
|
||||||
|
)
|
||||||
|
foreach(icon_size IN ITEMS 16 24 32 48 64 128 256)
|
||||||
|
install(FILES
|
||||||
|
packaging/linux/icons/${icon_size}x${icon_size}/apps/org.darksingularity.OrbitHub.png
|
||||||
|
DESTINATION ${CMAKE_INSTALL_DATADIR}/icons/hicolor/${icon_size}x${icon_size}/apps
|
||||||
|
)
|
||||||
|
endforeach()
|
||||||
|
install(FILES
|
||||||
|
packaging/linux/org.darksingularity.OrbitHub.metainfo.xml
|
||||||
|
DESTINATION ${CMAKE_INSTALL_DATADIR}/metainfo
|
||||||
|
)
|
||||||
|
install(FILES LICENSE DESTINATION ${CMAKE_INSTALL_DATADIR}/licenses/orbithub)
|
||||||
|
|||||||
@@ -0,0 +1,170 @@
|
|||||||
|
# OrbitHub
|
||||||
|
|
||||||
|
OrbitHub is a cross-platform native desktop app for managing and launching remote sessions from one place.
|
||||||
|
|
||||||
|
It is implemented in C++17 with Qt6 Widgets and built with CMake.
|
||||||
|
|
||||||
|
Supported target platforms:
|
||||||
|
- Windows
|
||||||
|
- Linux
|
||||||
|
- macOS
|
||||||
|
|
||||||
|
## Current Status
|
||||||
|
|
||||||
|
OrbitHub is in active development.
|
||||||
|
|
||||||
|
- Milestones completed: M0-M5, and M7-M9
|
||||||
|
- Current milestone: Milestone 10 (v1.0 Stabilization)
|
||||||
|
- Deferred milestone: Milestone 6 (VNC Fully Working)
|
||||||
|
- Latest checkpoint tag: `v0-m9-done`
|
||||||
|
- VNC implementation milestone (M6) is currently deferred
|
||||||
|
|
||||||
|
Progress and milestone details:
|
||||||
|
- [docs/PROGRESS.md](docs/PROGRESS.md)
|
||||||
|
|
||||||
|
## Screenshots
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Profiles organized into folders, with protocol, host, and tags shown at a glance. (Sample data shown; not real hosts.)
|
||||||
|
|
||||||
|
## Implemented Features
|
||||||
|
|
||||||
|
### Profile Management
|
||||||
|
|
||||||
|
- SQLite-backed profile storage
|
||||||
|
- Create, edit, delete profiles
|
||||||
|
- Protocol-aware profile validation (SSH/RDP/VNC)
|
||||||
|
- Profile search and sorting
|
||||||
|
- Tags support
|
||||||
|
- Folder/subfolder support
|
||||||
|
- `List` and `Folders` profile views
|
||||||
|
- Right-click profile tree actions:
|
||||||
|
- New Folder
|
||||||
|
- New Connection
|
||||||
|
- Drag-and-drop profile moves between folders with persistence
|
||||||
|
|
||||||
|
### Session Experience
|
||||||
|
|
||||||
|
- Multi-tab session window
|
||||||
|
- Auto-connect on tab open
|
||||||
|
- Disconnect on tab close
|
||||||
|
- Session state indicators on tabs
|
||||||
|
- Timestamped event log with filtering and export
|
||||||
|
|
||||||
|
### SSH
|
||||||
|
|
||||||
|
- Embedded interactive terminal (in-app typing)
|
||||||
|
- Theme support (`Dark`, `Light`, `Solarized Dark`)
|
||||||
|
- Password and private-key auth flows
|
||||||
|
- Known-hosts policy support
|
||||||
|
|
||||||
|
### RDP
|
||||||
|
|
||||||
|
- Embedded in-window RDP rendering surface (no external launcher)
|
||||||
|
- Keyboard/mouse input forwarding
|
||||||
|
- Resize handling and resolution renegotiation
|
||||||
|
- Domain-aware authentication support
|
||||||
|
- RDP security/performance profile options
|
||||||
|
|
||||||
|
### App UX
|
||||||
|
|
||||||
|
- App icon and themed About dialog
|
||||||
|
- `File` menu:
|
||||||
|
- New Profile
|
||||||
|
- New Folder
|
||||||
|
- Quit
|
||||||
|
- `Help` menu:
|
||||||
|
- About OrbitHub
|
||||||
|
|
||||||
|
## Build and Run
|
||||||
|
|
||||||
|
Detailed platform instructions:
|
||||||
|
- [docs/BUILDING.md](docs/BUILDING.md)
|
||||||
|
|
||||||
|
Quick start (Linux/macOS with Ninja):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cmake -S . -B build -G Ninja
|
||||||
|
cmake --build build
|
||||||
|
./build/orbithub
|
||||||
|
```
|
||||||
|
|
||||||
|
## Packaging
|
||||||
|
|
||||||
|
Detailed packaging instructions for all platforms:
|
||||||
|
- [docs/BUILDING.md](docs/BUILDING.md)
|
||||||
|
|
||||||
|
Linux (`.deb`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./packaging/linux/build-deb.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Linux (Flatpak):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./packaging/flatpak/build-flatpak.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Windows (Inno Setup installer):
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\packaging\windows\build-installer.ps1
|
||||||
|
```
|
||||||
|
|
||||||
|
macOS (`.dmg`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./packaging/macos/build-dmg.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
Core dependencies:
|
||||||
|
- Qt 6 (Widgets, SQL)
|
||||||
|
- CMake 3.21+
|
||||||
|
- C++17 toolchain
|
||||||
|
|
||||||
|
Protocol/runtime dependencies:
|
||||||
|
- SSH client (`ssh`) available on `PATH` for SSH sessions
|
||||||
|
|
||||||
|
Bundled/vendored third-party components:
|
||||||
|
- KodoTerm
|
||||||
|
- libvterm
|
||||||
|
- FreeRDP/WinPR
|
||||||
|
|
||||||
|
## Licensing
|
||||||
|
|
||||||
|
Project license:
|
||||||
|
- MIT (see [LICENSE](LICENSE))
|
||||||
|
|
||||||
|
License links:
|
||||||
|
- MIT License: <https://opensource.org/licenses/MIT>
|
||||||
|
- GNU LGPLv3: <https://www.gnu.org/licenses/lgpl-3.0.html>
|
||||||
|
- Apache License 2.0: <https://www.apache.org/licenses/LICENSE-2.0>
|
||||||
|
|
||||||
|
Important third-party license notes:
|
||||||
|
- Qt6 is dynamically linked in this project build setup.
|
||||||
|
- Qt6 is used under LGPLv3 terms in this project build setup.
|
||||||
|
- KodoTerm and libvterm are MIT-licensed.
|
||||||
|
- FreeRDP/WinPR is Apache-2.0 licensed.
|
||||||
|
|
||||||
|
Repository license files:
|
||||||
|
- Project: [LICENSE](LICENSE)
|
||||||
|
- KodoTerm: [third_party/KodoTerm/LICENSE](third_party/KodoTerm/LICENSE)
|
||||||
|
- FreeRDP: [third_party/FreeRDP/LICENSE](third_party/FreeRDP/LICENSE)
|
||||||
|
|
||||||
|
See in-app `Help -> About OrbitHub` for license links and third-party inventory.
|
||||||
|
|
||||||
|
## Repository Structure
|
||||||
|
|
||||||
|
- `src/` - application source code
|
||||||
|
- `docs/` - build guide, spec, and progress tracking
|
||||||
|
- `third_party/` - vendored third-party dependencies
|
||||||
|
- `build/` - local build output (generated)
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Passwords are requested at connect time and are not stored in the profile database.
|
||||||
|
- This repository currently prioritizes integrated SSH and RDP workflows while VNC implementation is pending.
|
||||||
@@ -2,6 +2,14 @@
|
|||||||
|
|
||||||
Run all commands from the repository root unless noted.
|
Run all commands from the repository root unless noted.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
Minimum toolchain requirements on all platforms:
|
||||||
|
- CMake 3.21+
|
||||||
|
- C++17 compiler toolchain
|
||||||
|
- Qt 6.2+ with `Widgets` and `Sql` modules (dynamic linking)
|
||||||
|
- OpenSSH client available on `PATH` (required for SSH sessions)
|
||||||
|
|
||||||
## Linux (Ubuntu / Mint)
|
## Linux (Ubuntu / Mint)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -9,9 +17,9 @@ sudo apt update
|
|||||||
sudo apt install -y \
|
sudo apt install -y \
|
||||||
build-essential cmake ninja-build git pkg-config \
|
build-essential cmake ninja-build git pkg-config \
|
||||||
qt6-base-dev qt6-base-dev-tools qt6-tools-dev qt6-tools-dev-tools \
|
qt6-base-dev qt6-base-dev-tools qt6-tools-dev qt6-tools-dev-tools \
|
||||||
openssh-client
|
openssh-client libssl-dev zlib1g-dev
|
||||||
|
|
||||||
cmake -S . -B build -G Ninja
|
cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release
|
||||||
cmake --build build
|
cmake --build build
|
||||||
./build/orbithub
|
./build/orbithub
|
||||||
```
|
```
|
||||||
@@ -21,39 +29,114 @@ cmake --build build
|
|||||||
```bash
|
```bash
|
||||||
xcode-select --install
|
xcode-select --install
|
||||||
brew update
|
brew update
|
||||||
brew install cmake ninja pkg-config qt@6 openssh
|
brew install cmake ninja pkg-config qt@6 openssh openssl@3
|
||||||
|
|
||||||
cmake -S . -B build -G Ninja -DCMAKE_PREFIX_PATH="$(brew --prefix qt@6)"
|
cmake -S . -B build -G Ninja \
|
||||||
|
-DCMAKE_BUILD_TYPE=Release \
|
||||||
|
-DCMAKE_PREFIX_PATH="$(brew --prefix qt@6);$(brew --prefix openssl@3)"
|
||||||
cmake --build build
|
cmake --build build
|
||||||
./build/orbithub
|
open build/orbithub.app
|
||||||
```
|
```
|
||||||
|
|
||||||
## Windows 11 (PowerShell + MSVC + vcpkg)
|
## Windows 11 (PowerShell + MSVC + vcpkg)
|
||||||
|
|
||||||
|
Install required software:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
winget install -e --id Git.Git
|
winget install -e --id Git.Git
|
||||||
winget install -e --id Kitware.CMake
|
winget install -e --id Kitware.CMake
|
||||||
winget install -e --id Ninja-build.Ninja
|
winget install -e --id Ninja-build.Ninja
|
||||||
Add-WindowsCapability -Online -Name OpenSSH.Client~~~~0.0.1.0
|
Add-WindowsCapability -Online -Name OpenSSH.Client~~~~0.0.1.0
|
||||||
winget install -e --id Microsoft.VisualStudio.2022.BuildTools `
|
winget install -e --id Microsoft.VisualStudio.2022.BuildTools `
|
||||||
--override "--quiet --wait --norestart --add Microsoft.VisualStudio.Workload.VCTools"
|
--override "--quiet --wait --norestart --add Microsoft.VisualStudio.Workload.VCTools --add Microsoft.VisualStudio.Component.Windows11SDK.22621"
|
||||||
```
|
```
|
||||||
|
|
||||||
Open a new terminal after installs, then:
|
Install dependencies via vcpkg:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
git clone https://github.com/microsoft/vcpkg C:\dev\vcpkg
|
git clone https://github.com/microsoft/vcpkg C:\dev\vcpkg
|
||||||
C:\dev\vcpkg\bootstrap-vcpkg.bat
|
C:\dev\vcpkg\bootstrap-vcpkg.bat
|
||||||
C:\dev\vcpkg\vcpkg.exe install qtbase:x64-windows
|
C:\dev\vcpkg\vcpkg.exe install qtbase:x64-windows openssl:x64-windows zlib:x64-windows
|
||||||
|
|
||||||
cmake -S . -B build -G Ninja `
|
|
||||||
-DCMAKE_TOOLCHAIN_FILE=C:/dev/vcpkg/scripts/buildsystems/vcpkg.cmake
|
|
||||||
cmake --build build
|
|
||||||
.\build\orbithub.exe
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Open `x64 Native Tools Command Prompt for VS 2022` (or Developer PowerShell), then build:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cmake -S . -B build -G Ninja `
|
||||||
|
-DCMAKE_BUILD_TYPE=Release `
|
||||||
|
-DCMAKE_TOOLCHAIN_FILE=C:/dev/vcpkg/scripts/buildsystems/vcpkg.cmake `
|
||||||
|
-DVCPKG_TARGET_TRIPLET=x64-windows
|
||||||
|
cmake --build build
|
||||||
|
```
|
||||||
|
|
||||||
|
Run (ensures DLL paths from vcpkg are present):
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
C:\dev\vcpkg\vcpkg.exe env --triplet x64-windows -- .\build\orbithub.exe
|
||||||
|
```
|
||||||
|
|
||||||
|
If you already have `Qt 6` from the Qt installer and do not want vcpkg Qt, you can point CMake at that Qt install with `-DCMAKE_PREFIX_PATH=...`, but you still need compatible `OpenSSL` and `zlib` development libraries for the embedded FreeRDP build.
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
- OrbitHub currently requires Qt6 Widgets and CMake 3.21+.
|
- OrbitHub builds vendored `KodoTerm`, `libvterm`, and `FreeRDP` from `third_party/`.
|
||||||
- Milestone 3 SSH sessions require an `ssh` client available on `PATH`.
|
|
||||||
- If Qt is installed in a custom location, pass `-DCMAKE_PREFIX_PATH=/path/to/Qt/6.x.x/<toolchain>` to CMake.
|
- If Qt is installed in a custom location, pass `-DCMAKE_PREFIX_PATH=/path/to/Qt/6.x.x/<toolchain>` to CMake.
|
||||||
|
- Build output executable:
|
||||||
|
- Linux: `build/orbithub`
|
||||||
|
- macOS: `build/orbithub.app` (a proper app bundle, launch with `open build/orbithub.app`)
|
||||||
|
- Windows: `build\\orbithub.exe`
|
||||||
|
|
||||||
|
## Linux Packaging
|
||||||
|
|
||||||
|
Build a Debian package (`.deb`) from the current Linux build:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./packaging/linux/build-deb.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Output path:
|
||||||
|
- `dist/orbithub_<version>_<arch>.deb`
|
||||||
|
|
||||||
|
Build a Flatpak bundle:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo apt-get install -y flatpak flatpak-builder
|
||||||
|
./packaging/flatpak/build-flatpak.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Output path:
|
||||||
|
- `dist/flatpak/org.darksingularity.OrbitHub.flatpak`
|
||||||
|
|
||||||
|
## Windows Packaging
|
||||||
|
|
||||||
|
Requires [Inno Setup 6](https://jrsoftware.org/isinfo.php) (`winget install -e --id JRSoftware.InnoSetup`).
|
||||||
|
|
||||||
|
From a configured and built `build\` directory (see Windows build steps above):
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\packaging\windows\build-installer.ps1
|
||||||
|
```
|
||||||
|
|
||||||
|
Output path:
|
||||||
|
- `dist\windows\OrbitHub-Setup-<version>.exe`
|
||||||
|
|
||||||
|
## macOS Packaging
|
||||||
|
|
||||||
|
Requires `qt@6` from Homebrew (for `macdeployqt`). For a custom volume icon on
|
||||||
|
the `.dmg`, also install `SetFile` (via Xcode's "Additional Tools", from
|
||||||
|
[developer.apple.com/download/all](https://developer.apple.com/download/all))
|
||||||
|
or `brew install fileicon` — packaging still works without either, just with
|
||||||
|
the generic disk image icon.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./packaging/macos/build-dmg.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
This runs `cmake --install`, then `macdeployqt` to bundle Qt frameworks and
|
||||||
|
plugins into the `.app`, then re-signs the bundle (ad hoc, since there is no
|
||||||
|
Developer ID certificate) so it passes macOS's launch-time code-integrity
|
||||||
|
check. The result is unsigned/unnotarized, so first launch requires
|
||||||
|
right-click → **Open** to bypass Gatekeeper's unidentified-developer warning.
|
||||||
|
|
||||||
|
Output path:
|
||||||
|
- `dist/macos/OrbitHub-<version>.dmg`
|
||||||
|
|||||||
@@ -100,11 +100,11 @@ Delivered:
|
|||||||
- Pulled FreeRDP source for integration planning and API review
|
- Pulled FreeRDP source for integration planning and API review
|
||||||
|
|
||||||
Git:
|
Git:
|
||||||
- Tag: pending (awaiting explicit approval before tagging/pushing)
|
- Tag: `v0-m5-done`
|
||||||
|
|
||||||
## Milestone 6 - VNC Fully Working
|
## Milestone 6 - VNC Fully Working
|
||||||
|
|
||||||
Status: Planned
|
Status: Deferred (temporarily postponed)
|
||||||
|
|
||||||
Planned Scope:
|
Planned Scope:
|
||||||
- Replace current unsupported VNC path with complete VNC implementation
|
- Replace current unsupported VNC path with complete VNC implementation
|
||||||
@@ -115,30 +115,88 @@ Planned Scope:
|
|||||||
|
|
||||||
## Milestone 7 - Cross-Platform Protocol Hardening
|
## Milestone 7 - Cross-Platform Protocol Hardening
|
||||||
|
|
||||||
Status: Planned
|
Status: Completed
|
||||||
|
|
||||||
Planned Scope:
|
Delivered:
|
||||||
- Validate SSH/RDP/VNC workflows on Windows, Linux, and macOS
|
- Validated SSH and RDP workflows on Linux, macOS, and Windows 11 (VNC is out
|
||||||
- Fix platform-specific runtime/process/auth issues
|
of scope while Milestone 6 remains deferred)
|
||||||
- Add repeatable protocol validation checklist/scripts
|
- Windows: validated the full `docs/BUILDING.md` toolchain end-to-end
|
||||||
|
(Git/CMake/Ninja/VS Build Tools with the C++ workload/vcpkg for
|
||||||
|
Qt6-OpenSSL-zlib); documented a VS Build Tools installer gotcha where the
|
||||||
|
actual compiler is a "recommended", not "required", component of the
|
||||||
|
VCTools workload
|
||||||
|
- Fixed RDP keyboard input misreading punctuation keys on Linux (X11 keycode
|
||||||
|
numbering was being treated as a PC/AT scancode; now uses FreeRDP's
|
||||||
|
authoritative X11-keycode-to-scancode table)
|
||||||
|
- Added RDP clipboard sync (bidirectional, plain text) and RDP cursor/pointer
|
||||||
|
shape sync (resize handles, text I-beam, etc. instead of a static arrow)
|
||||||
|
- Fixed Tab/Shift+Tab being intercepted by local UI focus navigation instead
|
||||||
|
of reaching SSH/RDP sessions
|
||||||
|
- Fixed RDP key auto-repeat being dropped, so holding a key only ever sent a
|
||||||
|
single keystroke to the remote machine
|
||||||
|
- Windows-specific RDP fixes: a crash on every connect attempt (FreeRDP's
|
||||||
|
signal-handling critical section was never initialized) and a host
|
||||||
|
resolution failure on every connect, including literal IP addresses
|
||||||
|
(Winsock was never initialized via `WSAStartup`)
|
||||||
|
- Windows: automatic build-time deployment of Qt's platform/SQL-driver
|
||||||
|
plugins and their runtime DLL dependencies, and marked the executable as a
|
||||||
|
GUI (`WIN32`) app to remove a stray console window behind the UI
|
||||||
|
- Windows and macOS: proper native app icon embedding (Windows `.rc`/`.ico`
|
||||||
|
resource; macOS `.app` bundle with `.icns`), replacing the generic default
|
||||||
|
icon previously shown for the built executable/bundle
|
||||||
|
- macOS: fixed Edit/New Profile dialog form fields collapsing to `sizeHint`
|
||||||
|
width due to the platform-default `QFormLayout` field growth policy
|
||||||
|
|
||||||
|
Git:
|
||||||
|
- Tag: Pending user approval (`v0-m7-done`)
|
||||||
|
|
||||||
## Milestone 8 - Profile and Session UX Completion
|
## Milestone 8 - Profile and Session UX Completion
|
||||||
|
|
||||||
Status: Planned
|
Status: Completed
|
||||||
|
|
||||||
Planned Scope:
|
Delivered:
|
||||||
- Complete protocol-aware profile validation and UX polish
|
- Added profile `tags` field to storage + schema migration and profile editor UX
|
||||||
- Add/persist session UI preferences and default behaviors
|
- Added profile `folder_path` field + nested folder/subfolder profile view mode
|
||||||
- Improve events/diagnostics visibility for long-running session usage
|
- Added profile tree context actions (`New Folder`, `New Connection`) and drag-to-folder profile moves with persistence
|
||||||
|
- Added `Help -> About OrbitHub` dialog with third-party library inventory and MIT/Apache-2.0 license links
|
||||||
|
- Extended profile search to include tags/folder path and added profile sort controls (`Name`, `Protocol`, `Host`)
|
||||||
|
- Persisted profile list UX preferences (`search text`, `view mode`, protocol/tag filters, `sort order`) across app restarts
|
||||||
|
- Added protocol-aware profile validation/normalization for SSH/RDP/VNC (repository + dialog)
|
||||||
|
- Improved profile form protocol UX hints and SSH private-key path validation
|
||||||
|
- Added session events filtering and tab-context actions (`Show/Hide Events`, `Copy Events`, `Clear Events`)
|
||||||
|
- Added session diagnostics QoL: severity quick-filter (`All/Warnings/Errors`) and `Export Events` action
|
||||||
|
- Persisted session UI defaults (`terminal theme`, `events panel visibility`) for new tabs/windows
|
||||||
|
- Added profile quick filters (`Protocol`, `Tag`) with persistence to speed profile browsing
|
||||||
|
|
||||||
|
Validation:
|
||||||
|
- Local build verification passed (`cmake --build build`)
|
||||||
|
- No automated tests are currently configured in CTest
|
||||||
|
|
||||||
|
Git:
|
||||||
|
- Tag: Pending user approval (`v0-m8-done`)
|
||||||
|
|
||||||
## Milestone 9 - Packaging and Distribution
|
## Milestone 9 - Packaging and Distribution
|
||||||
|
|
||||||
Status: Planned
|
Status: Completed
|
||||||
|
|
||||||
Planned Scope:
|
Delivered:
|
||||||
- Build distributable artifacts for Windows/Linux/macOS
|
- Linux `.deb` (`packaging/linux/build-deb.sh`) and Flatpak (`packaging/flatpak/build-flatpak.sh`) packages, both verified installed and launched with working SSH/RDP
|
||||||
- Document runtime dependencies and install prerequisites
|
- Renamed the app's reverse-DNS identity from `io.orbithub.OrbitHub` to `org.darksingularity.OrbitHub` (desktop file, AppStream metainfo, Flatpak manifest, macOS bundle identifier) to reflect a domain actually owned by the project
|
||||||
- Add reproducible release packaging steps/scripts
|
- Fixed Linux taskbar/panel pin matching (`StartupWMClass`) so a pinned launcher merges with its running window instead of creating a duplicate entry
|
||||||
|
- Replaced the stale, mismatched hand-authored launcher SVG with PNG icons rendered directly from the app's own `createOrbitHubAppIcon()` at each hicolor theme size
|
||||||
|
- Windows installer via Inno Setup (`packaging/windows/orbithub.iss`, `packaging/windows/build-installer.ps1`), verified installed silently and launched cleanly with no crash events
|
||||||
|
- macOS `.dmg` via `cmake --install` + `macdeployqt` + `hdiutil` (`packaging/macos/build-dmg.sh`), verified installed and launched after fixing:
|
||||||
|
- a launch crash caused by `macdeployqt` invalidating the code signature (fixed with ad hoc re-signing)
|
||||||
|
- a missing-library crash caused by the Linux-only `$ORIGIN` rpath token and vendored dylibs installing outside the `.app` bundle (fixed with `APPLE`-specific `@executable_path`/`Contents/Frameworks` layout)
|
||||||
|
- the dmg showing the generic disk icon instead of the app icon
|
||||||
|
- README and `docs/BUILDING.md` Packaging sections documented for all three platforms
|
||||||
|
|
||||||
|
Validation:
|
||||||
|
- All three installers built, installed, and launched successfully with working SSH/RDP sessions
|
||||||
|
- Code signing is ad hoc only (no purchased Apple Developer ID or Windows code-signing certificate), so macOS Gatekeeper and Windows SmartScreen still show first-run warnings by design
|
||||||
|
|
||||||
|
Git:
|
||||||
|
- Tag: `v0-m9-done`
|
||||||
|
|
||||||
## Milestone 10 - v1.0 Stabilization
|
## Milestone 10 - v1.0 Stabilization
|
||||||
|
|
||||||
|
|||||||
|
After Width: | Height: | Size: 71 KiB |
@@ -0,0 +1,26 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||||
|
MANIFEST="$ROOT_DIR/packaging/flatpak/org.darksingularity.OrbitHub.yml"
|
||||||
|
DIST_DIR="${1:-$ROOT_DIR/dist/flatpak}"
|
||||||
|
BUILD_DIR="$DIST_DIR/build"
|
||||||
|
REPO_DIR="$DIST_DIR/repo"
|
||||||
|
BUNDLE="$DIST_DIR/org.darksingularity.OrbitHub.flatpak"
|
||||||
|
|
||||||
|
if ! command -v flatpak-builder >/dev/null 2>&1; then
|
||||||
|
echo "flatpak-builder is required. Install it first:" >&2
|
||||||
|
echo " sudo apt-get install -y flatpak-builder" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "$DIST_DIR"
|
||||||
|
|
||||||
|
flatpak-builder \
|
||||||
|
--force-clean \
|
||||||
|
--repo="$REPO_DIR" \
|
||||||
|
"$BUILD_DIR" \
|
||||||
|
"$MANIFEST"
|
||||||
|
|
||||||
|
flatpak build-bundle "$REPO_DIR" "$BUNDLE" org.darksingularity.OrbitHub
|
||||||
|
echo "Created $BUNDLE"
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
app-id: org.darksingularity.OrbitHub
|
||||||
|
runtime: org.kde.Platform
|
||||||
|
runtime-version: "6.10"
|
||||||
|
sdk: org.kde.Sdk
|
||||||
|
command: orbithub
|
||||||
|
finish-args:
|
||||||
|
- --share=network
|
||||||
|
- --share=ipc
|
||||||
|
- --socket=fallback-x11
|
||||||
|
- --socket=wayland
|
||||||
|
- --device=dri
|
||||||
|
- --filesystem=home
|
||||||
|
modules:
|
||||||
|
- name: orbithub
|
||||||
|
buildsystem: cmake-ninja
|
||||||
|
builddir: true
|
||||||
|
config-opts:
|
||||||
|
- -DCMAKE_BUILD_TYPE=Release
|
||||||
|
sources:
|
||||||
|
- type: dir
|
||||||
|
path: ../..
|
||||||
@@ -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,16 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<component type="desktop-application">
|
||||||
|
<id>org.darksingularity.OrbitHub</id>
|
||||||
|
<name>OrbitHub</name>
|
||||||
|
<summary>Unified remote session manager for SSH and RDP</summary>
|
||||||
|
<metadata_license>MIT</metadata_license>
|
||||||
|
<project_license>MIT</project_license>
|
||||||
|
<description>
|
||||||
|
<p>OrbitHub is a native desktop app for organizing connection profiles and launching SSH and RDP sessions in one tabbed interface.</p>
|
||||||
|
</description>
|
||||||
|
<launchable type="desktop-id">org.darksingularity.OrbitHub.desktop</launchable>
|
||||||
|
<url type="homepage">https://git.darksingularity.org/DarkSingularity/orbithub</url>
|
||||||
|
<provides>
|
||||||
|
<binary>orbithub</binary>
|
||||||
|
</provides>
|
||||||
|
</component>
|
||||||
@@ -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,92 @@
|
|||||||
|
#include "about_dialog.h"
|
||||||
|
|
||||||
|
#include <QApplication>
|
||||||
|
#include <QCoreApplication>
|
||||||
|
#include <QDialogButtonBox>
|
||||||
|
#include <QHBoxLayout>
|
||||||
|
#include <QLabel>
|
||||||
|
#include <QTextBrowser>
|
||||||
|
#include <QVBoxLayout>
|
||||||
|
|
||||||
|
AboutDialog::AboutDialog(QWidget* parent) : QDialog(parent)
|
||||||
|
{
|
||||||
|
setWindowTitle(QStringLiteral("About OrbitHub"));
|
||||||
|
setWindowIcon(QApplication::windowIcon());
|
||||||
|
resize(760, 560);
|
||||||
|
|
||||||
|
auto* layout = new QVBoxLayout(this);
|
||||||
|
layout->setContentsMargins(16, 16, 16, 16);
|
||||||
|
layout->setSpacing(12);
|
||||||
|
|
||||||
|
auto* headerRow = new QHBoxLayout();
|
||||||
|
headerRow->setSpacing(12);
|
||||||
|
|
||||||
|
auto* iconLabel = new QLabel(this);
|
||||||
|
iconLabel->setFixedSize(80, 80);
|
||||||
|
iconLabel->setPixmap(QApplication::windowIcon().pixmap(80, 80));
|
||||||
|
iconLabel->setAlignment(Qt::AlignCenter);
|
||||||
|
|
||||||
|
auto* titleColumn = new QVBoxLayout();
|
||||||
|
titleColumn->setSpacing(4);
|
||||||
|
|
||||||
|
auto* title = new QLabel(QStringLiteral("<h1 style='margin:0'>OrbitHub</h1>"), this);
|
||||||
|
auto* subtitle = new QLabel(
|
||||||
|
QStringLiteral("Unified remote session manager for SSH, RDP, and VNC workflows."),
|
||||||
|
this);
|
||||||
|
subtitle->setWordWrap(true);
|
||||||
|
subtitle->setStyleSheet(QStringLiteral("color: palette(mid);"));
|
||||||
|
|
||||||
|
const QString version = QCoreApplication::applicationVersion().trimmed().isEmpty()
|
||||||
|
? QStringLiteral("Development build")
|
||||||
|
: QCoreApplication::applicationVersion().trimmed();
|
||||||
|
auto* buildLine = new QLabel(
|
||||||
|
QStringLiteral("Version: %1 | Qt runtime linked dynamically").arg(version),
|
||||||
|
this);
|
||||||
|
buildLine->setStyleSheet(QStringLiteral("color: palette(mid);"));
|
||||||
|
|
||||||
|
titleColumn->addWidget(title);
|
||||||
|
titleColumn->addWidget(subtitle);
|
||||||
|
titleColumn->addWidget(buildLine);
|
||||||
|
titleColumn->addStretch();
|
||||||
|
|
||||||
|
headerRow->addWidget(iconLabel, 0, Qt::AlignTop);
|
||||||
|
headerRow->addLayout(titleColumn, 1);
|
||||||
|
|
||||||
|
auto* browser = new QTextBrowser(this);
|
||||||
|
browser->setOpenExternalLinks(true);
|
||||||
|
browser->setStyleSheet(QStringLiteral(
|
||||||
|
"QTextBrowser { border: 1px solid palette(midlight); border-radius: 8px; padding: 8px; }"));
|
||||||
|
browser->setHtml(QStringLiteral(R"(
|
||||||
|
<h3 style="margin-top:0">Third-Party Libraries</h3>
|
||||||
|
<p>OrbitHub uses the following external libraries:</p>
|
||||||
|
<table cellspacing="0" cellpadding="6" border="1" style="border-collapse:collapse; width:100%;">
|
||||||
|
<tr><th align="left">Library</th><th align="left">License</th><th align="left">Upstream</th></tr>
|
||||||
|
<tr><td>Qt 6 (Widgets / SQL)</td><td>LGPLv3 / GPLv3 / Commercial</td><td><a href="https://www.qt.io/licensing">qt.io/licensing</a></td></tr>
|
||||||
|
<tr><td>KodoTerm</td><td>MIT</td><td><a href="https://github.com/diegoiast/KodoTerm">github.com/diegoiast/KodoTerm</a></td></tr>
|
||||||
|
<tr><td>libvterm</td><td>MIT</td><td><a href="https://github.com/neovim/libvterm">github.com/neovim/libvterm</a></td></tr>
|
||||||
|
<tr><td>FreeRDP / WinPR</td><td>Apache License 2.0</td><td><a href="https://github.com/FreeRDP/FreeRDP">github.com/FreeRDP/FreeRDP</a></td></tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h3>License Links</h3>
|
||||||
|
<ul>
|
||||||
|
<li><a href="https://www.gnu.org/licenses/lgpl-3.0.html">GNU LGPLv3 (gnu.org)</a></li>
|
||||||
|
<li><a href="https://opensource.org/licenses/MIT">MIT License (opensource.org)</a></li>
|
||||||
|
<li><a href="https://www.apache.org/licenses/LICENSE-2.0">Apache License 2.0 (apache.org)</a></li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>License Files In This Repository</h3>
|
||||||
|
<ul>
|
||||||
|
<li><code>third_party/KodoTerm/LICENSE</code></li>
|
||||||
|
<li><code>third_party/FreeRDP/LICENSE</code></li>
|
||||||
|
<li><code>LICENSE</code> (project license)</li>
|
||||||
|
</ul>
|
||||||
|
)"));
|
||||||
|
|
||||||
|
auto* buttons = new QDialogButtonBox(QDialogButtonBox::Close, this);
|
||||||
|
connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::reject);
|
||||||
|
connect(buttons, &QDialogButtonBox::accepted, this, &QDialog::accept);
|
||||||
|
|
||||||
|
layout->addLayout(headerRow);
|
||||||
|
layout->addWidget(browser, 1);
|
||||||
|
layout->addWidget(buttons);
|
||||||
|
}
|
||||||
@@ -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,4 +1,5 @@
|
|||||||
#include "profiles_window.h"
|
#include "app_icon.h"
|
||||||
|
#include "session_window.h"
|
||||||
|
|
||||||
#include <QApplication>
|
#include <QApplication>
|
||||||
|
|
||||||
@@ -7,8 +8,11 @@ int main(int argc, char* argv[])
|
|||||||
Q_INIT_RESOURCE(KodoTermThemes);
|
Q_INIT_RESOURCE(KodoTermThemes);
|
||||||
|
|
||||||
QApplication app(argc, argv);
|
QApplication app(argc, argv);
|
||||||
|
app.setOrganizationName(QStringLiteral("FireBugIT"));
|
||||||
|
app.setApplicationName(QStringLiteral("OrbitHub"));
|
||||||
|
app.setWindowIcon(createOrbitHubAppIcon());
|
||||||
|
|
||||||
ProfilesWindow window;
|
SessionWindow window;
|
||||||
window.show();
|
window.show();
|
||||||
|
|
||||||
return app.exec();
|
return app.exec();
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
#include <QComboBox>
|
#include <QComboBox>
|
||||||
#include <QDialogButtonBox>
|
#include <QDialogButtonBox>
|
||||||
#include <QFileDialog>
|
#include <QFileDialog>
|
||||||
|
#include <QFileInfo>
|
||||||
#include <QFormLayout>
|
#include <QFormLayout>
|
||||||
#include <QHBoxLayout>
|
#include <QHBoxLayout>
|
||||||
#include <QLabel>
|
#include <QLabel>
|
||||||
@@ -24,6 +25,28 @@ int standardPortForProtocol(const QString& protocol)
|
|||||||
}
|
}
|
||||||
return 22; // SSH default
|
return 22; // SSH default
|
||||||
}
|
}
|
||||||
|
|
||||||
|
QString normalizedProtocol(const QString& protocol)
|
||||||
|
{
|
||||||
|
if (protocol.compare(QStringLiteral("RDP"), Qt::CaseInsensitive) == 0) {
|
||||||
|
return QStringLiteral("RDP");
|
||||||
|
}
|
||||||
|
if (protocol.compare(QStringLiteral("VNC"), Qt::CaseInsensitive) == 0) {
|
||||||
|
return QStringLiteral("VNC");
|
||||||
|
}
|
||||||
|
return QStringLiteral("SSH");
|
||||||
|
}
|
||||||
|
|
||||||
|
QString normalizedAuthMode(const QString& protocol, const QString& authMode)
|
||||||
|
{
|
||||||
|
if (protocol != QStringLiteral("SSH")) {
|
||||||
|
return QStringLiteral("Password");
|
||||||
|
}
|
||||||
|
if (authMode.compare(QStringLiteral("Private Key"), Qt::CaseInsensitive) == 0) {
|
||||||
|
return QStringLiteral("Private Key");
|
||||||
|
}
|
||||||
|
return QStringLiteral("Password");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ProfileDialog::ProfileDialog(QWidget* parent)
|
ProfileDialog::ProfileDialog(QWidget* parent)
|
||||||
@@ -33,18 +56,24 @@ ProfileDialog::ProfileDialog(QWidget* parent)
|
|||||||
m_portInput(new QSpinBox(this)),
|
m_portInput(new QSpinBox(this)),
|
||||||
m_usernameInput(new QLineEdit(this)),
|
m_usernameInput(new QLineEdit(this)),
|
||||||
m_domainInput(new QLineEdit(this)),
|
m_domainInput(new QLineEdit(this)),
|
||||||
|
m_tagsInput(new QLineEdit(this)),
|
||||||
m_protocolInput(new QComboBox(this)),
|
m_protocolInput(new QComboBox(this)),
|
||||||
m_authModeInput(new QComboBox(this)),
|
m_authModeInput(new QComboBox(this)),
|
||||||
m_privateKeyPathInput(new QLineEdit(this)),
|
m_privateKeyPathInput(new QLineEdit(this)),
|
||||||
m_browsePrivateKeyButton(new QPushButton(QStringLiteral("Browse"), this)),
|
m_browsePrivateKeyButton(new QPushButton(QStringLiteral("Browse"), this)),
|
||||||
m_knownHostsPolicyInput(new QComboBox(this)),
|
m_knownHostsPolicyInput(new QComboBox(this)),
|
||||||
m_rdpSecurityModeInput(new QComboBox(this)),
|
m_rdpSecurityModeInput(new QComboBox(this)),
|
||||||
m_rdpPerformanceProfileInput(new QComboBox(this))
|
m_rdpPerformanceProfileInput(new QComboBox(this)),
|
||||||
|
m_protocolHint(new QLabel(this)),
|
||||||
|
m_folderHint(new QLabel(this))
|
||||||
{
|
{
|
||||||
resize(520, 340);
|
resize(560, 360);
|
||||||
|
|
||||||
auto* layout = new QVBoxLayout(this);
|
auto* layout = new QVBoxLayout(this);
|
||||||
auto* form = new QFormLayout();
|
auto* form = new QFormLayout();
|
||||||
|
form->setFieldGrowthPolicy(QFormLayout::ExpandingFieldsGrow);
|
||||||
|
form->setFormAlignment(Qt::AlignLeft | Qt::AlignTop);
|
||||||
|
form->setLabelAlignment(Qt::AlignRight);
|
||||||
|
|
||||||
m_nameInput->setPlaceholderText(QStringLiteral("Production Bastion"));
|
m_nameInput->setPlaceholderText(QStringLiteral("Production Bastion"));
|
||||||
m_hostInput->setPlaceholderText(QStringLiteral("example.internal"));
|
m_hostInput->setPlaceholderText(QStringLiteral("example.internal"));
|
||||||
@@ -52,6 +81,7 @@ ProfileDialog::ProfileDialog(QWidget* parent)
|
|||||||
m_portInput->setValue(22);
|
m_portInput->setValue(22);
|
||||||
m_usernameInput->setPlaceholderText(QStringLiteral("deploy"));
|
m_usernameInput->setPlaceholderText(QStringLiteral("deploy"));
|
||||||
m_domainInput->setPlaceholderText(QStringLiteral("CONTOSO"));
|
m_domainInput->setPlaceholderText(QStringLiteral("CONTOSO"));
|
||||||
|
m_tagsInput->setPlaceholderText(QStringLiteral("prod, linux, db"));
|
||||||
|
|
||||||
m_protocolInput->addItems({QStringLiteral("SSH"), QStringLiteral("RDP"), QStringLiteral("VNC")});
|
m_protocolInput->addItems({QStringLiteral("SSH"), QStringLiteral("RDP"), QStringLiteral("VNC")});
|
||||||
m_authModeInput->addItems({QStringLiteral("Password"), QStringLiteral("Private Key")});
|
m_authModeInput->addItems({QStringLiteral("Password"), QStringLiteral("Private Key")});
|
||||||
@@ -107,6 +137,7 @@ ProfileDialog::ProfileDialog(QWidget* parent)
|
|||||||
form->addRow(QStringLiteral("Port"), m_portInput);
|
form->addRow(QStringLiteral("Port"), m_portInput);
|
||||||
form->addRow(QStringLiteral("Username"), m_usernameInput);
|
form->addRow(QStringLiteral("Username"), m_usernameInput);
|
||||||
form->addRow(QStringLiteral("Domain"), m_domainInput);
|
form->addRow(QStringLiteral("Domain"), m_domainInput);
|
||||||
|
form->addRow(QStringLiteral("Tags"), m_tagsInput);
|
||||||
form->addRow(QStringLiteral("Protocol"), m_protocolInput);
|
form->addRow(QStringLiteral("Protocol"), m_protocolInput);
|
||||||
form->addRow(QStringLiteral("Auth Mode"), m_authModeInput);
|
form->addRow(QStringLiteral("Auth Mode"), m_authModeInput);
|
||||||
form->addRow(QStringLiteral("Private Key"), privateKeyRow);
|
form->addRow(QStringLiteral("Private Key"), privateKeyRow);
|
||||||
@@ -118,12 +149,16 @@ ProfileDialog::ProfileDialog(QWidget* parent)
|
|||||||
QStringLiteral("Passwords are requested at connect time and are not stored."),
|
QStringLiteral("Passwords are requested at connect time and are not stored."),
|
||||||
this);
|
this);
|
||||||
note->setWordWrap(true);
|
note->setWordWrap(true);
|
||||||
|
m_protocolHint->setWordWrap(true);
|
||||||
|
m_folderHint->setWordWrap(true);
|
||||||
|
|
||||||
auto* buttons = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this);
|
auto* buttons = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this);
|
||||||
connect(buttons, &QDialogButtonBox::accepted, this, &QDialog::accept);
|
connect(buttons, &QDialogButtonBox::accepted, this, &QDialog::accept);
|
||||||
connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::reject);
|
connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::reject);
|
||||||
|
|
||||||
layout->addLayout(form);
|
layout->addLayout(form);
|
||||||
|
layout->addWidget(m_protocolHint);
|
||||||
|
layout->addWidget(m_folderHint);
|
||||||
layout->addWidget(note);
|
layout->addWidget(note);
|
||||||
layout->addWidget(buttons);
|
layout->addWidget(buttons);
|
||||||
|
|
||||||
@@ -135,6 +170,12 @@ void ProfileDialog::setDialogTitle(const QString& title)
|
|||||||
setWindowTitle(title);
|
setWindowTitle(title);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void ProfileDialog::setDefaultFolderPath(const QString& folderPath)
|
||||||
|
{
|
||||||
|
m_defaultFolderPath = folderPath.trimmed();
|
||||||
|
refreshAuthFields();
|
||||||
|
}
|
||||||
|
|
||||||
void ProfileDialog::setProfile(const Profile& profile)
|
void ProfileDialog::setProfile(const Profile& profile)
|
||||||
{
|
{
|
||||||
m_nameInput->setText(profile.name);
|
m_nameInput->setText(profile.name);
|
||||||
@@ -142,6 +183,8 @@ void ProfileDialog::setProfile(const Profile& profile)
|
|||||||
m_portInput->setValue(profile.port > 0 ? profile.port : 22);
|
m_portInput->setValue(profile.port > 0 ? profile.port : 22);
|
||||||
m_usernameInput->setText(profile.username);
|
m_usernameInput->setText(profile.username);
|
||||||
m_domainInput->setText(profile.domain);
|
m_domainInput->setText(profile.domain);
|
||||||
|
m_defaultFolderPath = profile.folderPath.trimmed();
|
||||||
|
m_tagsInput->setText(profile.tags);
|
||||||
m_privateKeyPathInput->setText(profile.privateKeyPath);
|
m_privateKeyPathInput->setText(profile.privateKeyPath);
|
||||||
|
|
||||||
const int protocolIndex = m_protocolInput->findText(profile.protocol);
|
const int protocolIndex = m_protocolInput->findText(profile.protocol);
|
||||||
@@ -169,18 +212,31 @@ void ProfileDialog::setProfile(const Profile& profile)
|
|||||||
Profile ProfileDialog::profile() const
|
Profile ProfileDialog::profile() const
|
||||||
{
|
{
|
||||||
Profile profile;
|
Profile profile;
|
||||||
|
const QString protocol = normalizedProtocol(m_protocolInput->currentText());
|
||||||
|
const QString authMode = normalizedAuthMode(protocol, m_authModeInput->currentText());
|
||||||
|
|
||||||
profile.id = -1;
|
profile.id = -1;
|
||||||
profile.name = m_nameInput->text().trimmed();
|
profile.name = m_nameInput->text().trimmed();
|
||||||
profile.host = m_hostInput->text().trimmed();
|
profile.host = m_hostInput->text().trimmed();
|
||||||
profile.port = m_portInput->value();
|
profile.port = m_portInput->value();
|
||||||
profile.username = m_usernameInput->text().trimmed();
|
profile.username = m_usernameInput->text().trimmed();
|
||||||
profile.domain = m_domainInput->text().trimmed();
|
profile.domain = protocol == QStringLiteral("RDP") ? m_domainInput->text().trimmed() : QString();
|
||||||
profile.protocol = m_protocolInput->currentText();
|
profile.folderPath = m_defaultFolderPath.trimmed();
|
||||||
profile.authMode = m_authModeInput->currentText();
|
profile.tags = m_tagsInput->text().trimmed();
|
||||||
profile.privateKeyPath = m_privateKeyPathInput->text().trimmed();
|
profile.protocol = protocol;
|
||||||
profile.knownHostsPolicy = m_knownHostsPolicyInput->currentText();
|
profile.authMode = authMode;
|
||||||
profile.rdpSecurityMode = m_rdpSecurityModeInput->currentText();
|
profile.privateKeyPath = (protocol == QStringLiteral("SSH")
|
||||||
profile.rdpPerformanceProfile = m_rdpPerformanceProfileInput->currentText();
|
&& authMode == QStringLiteral("Private Key"))
|
||||||
|
? m_privateKeyPathInput->text().trimmed()
|
||||||
|
: QString();
|
||||||
|
profile.knownHostsPolicy = protocol == QStringLiteral("SSH") ? m_knownHostsPolicyInput->currentText()
|
||||||
|
: QStringLiteral("Ask");
|
||||||
|
profile.rdpSecurityMode = protocol == QStringLiteral("RDP")
|
||||||
|
? m_rdpSecurityModeInput->currentText()
|
||||||
|
: QStringLiteral("Negotiate");
|
||||||
|
profile.rdpPerformanceProfile = protocol == QStringLiteral("RDP")
|
||||||
|
? m_rdpPerformanceProfileInput->currentText()
|
||||||
|
: QStringLiteral("Balanced");
|
||||||
return profile;
|
return profile;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -209,14 +265,40 @@ void ProfileDialog::accept()
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (protocol == QStringLiteral("SSH")
|
||||||
|
&& m_authModeInput->currentText() == QStringLiteral("Private Key")) {
|
||||||
|
const QString privateKeyPath = m_privateKeyPathInput->text().trimmed();
|
||||||
|
if (privateKeyPath.isEmpty()) {
|
||||||
|
QMessageBox::warning(this,
|
||||||
|
QStringLiteral("Validation Error"),
|
||||||
|
QStringLiteral("Private key path is required for SSH private key authentication."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!QFileInfo::exists(privateKeyPath)) {
|
||||||
|
QMessageBox::warning(this,
|
||||||
|
QStringLiteral("Validation Error"),
|
||||||
|
QStringLiteral("Private key file does not exist: %1").arg(privateKeyPath));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
QDialog::accept();
|
QDialog::accept();
|
||||||
}
|
}
|
||||||
|
|
||||||
void ProfileDialog::refreshAuthFields()
|
void ProfileDialog::refreshAuthFields()
|
||||||
{
|
{
|
||||||
const bool isSsh = m_protocolInput->currentText() == QStringLiteral("SSH");
|
const QString protocol = normalizedProtocol(m_protocolInput->currentText());
|
||||||
const bool isRdp = m_protocolInput->currentText() == QStringLiteral("RDP");
|
const bool isSsh = protocol == QStringLiteral("SSH");
|
||||||
const bool isPrivateKey = m_authModeInput->currentText() == QStringLiteral("Private Key");
|
const bool isRdp = protocol == QStringLiteral("RDP");
|
||||||
|
const bool isVnc = protocol == QStringLiteral("VNC");
|
||||||
|
|
||||||
|
const QString normalizedMode = normalizedAuthMode(protocol, m_authModeInput->currentText());
|
||||||
|
if (normalizedMode != m_authModeInput->currentText()) {
|
||||||
|
const QSignalBlocker blocker(m_authModeInput);
|
||||||
|
m_authModeInput->setCurrentText(normalizedMode);
|
||||||
|
}
|
||||||
|
const bool isPrivateKey = normalizedMode == QStringLiteral("Private Key");
|
||||||
|
|
||||||
m_authModeInput->setEnabled(isSsh);
|
m_authModeInput->setEnabled(isSsh);
|
||||||
m_privateKeyPathInput->setEnabled(isSsh && isPrivateKey);
|
m_privateKeyPathInput->setEnabled(isSsh && isPrivateKey);
|
||||||
@@ -225,4 +307,22 @@ void ProfileDialog::refreshAuthFields()
|
|||||||
m_domainInput->setEnabled(isRdp);
|
m_domainInput->setEnabled(isRdp);
|
||||||
m_rdpSecurityModeInput->setEnabled(isRdp);
|
m_rdpSecurityModeInput->setEnabled(isRdp);
|
||||||
m_rdpPerformanceProfileInput->setEnabled(isRdp);
|
m_rdpPerformanceProfileInput->setEnabled(isRdp);
|
||||||
|
|
||||||
|
if (isSsh) {
|
||||||
|
m_usernameInput->setPlaceholderText(QStringLiteral("deploy"));
|
||||||
|
m_protocolHint->setText(
|
||||||
|
QStringLiteral("SSH: username is required. Choose Password or Private Key auth."));
|
||||||
|
} else if (isRdp) {
|
||||||
|
m_usernameInput->setPlaceholderText(QStringLiteral("Administrator"));
|
||||||
|
m_protocolHint->setText(
|
||||||
|
QStringLiteral("RDP: username and password are required. Domain is optional."));
|
||||||
|
} else if (isVnc) {
|
||||||
|
m_usernameInput->setPlaceholderText(QStringLiteral("optional"));
|
||||||
|
m_protocolHint->setText(
|
||||||
|
QStringLiteral("VNC: host and port are required. Username/domain are optional and ignored by most servers."));
|
||||||
|
}
|
||||||
|
|
||||||
|
m_folderHint->setText(m_defaultFolderPath.isEmpty()
|
||||||
|
? QStringLiteral("Target folder: root")
|
||||||
|
: QStringLiteral("Target folder: %1").arg(m_defaultFolderPath));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
#include <QDialog>
|
#include <QDialog>
|
||||||
|
|
||||||
class QComboBox;
|
class QComboBox;
|
||||||
|
class QLabel;
|
||||||
class QLineEdit;
|
class QLineEdit;
|
||||||
class QPushButton;
|
class QPushButton;
|
||||||
class QSpinBox;
|
class QSpinBox;
|
||||||
@@ -18,6 +19,7 @@ public:
|
|||||||
explicit ProfileDialog(QWidget* parent = nullptr);
|
explicit ProfileDialog(QWidget* parent = nullptr);
|
||||||
|
|
||||||
void setDialogTitle(const QString& title);
|
void setDialogTitle(const QString& title);
|
||||||
|
void setDefaultFolderPath(const QString& folderPath);
|
||||||
void setProfile(const Profile& profile);
|
void setProfile(const Profile& profile);
|
||||||
Profile profile() const;
|
Profile profile() const;
|
||||||
|
|
||||||
@@ -30,6 +32,7 @@ private:
|
|||||||
QSpinBox* m_portInput;
|
QSpinBox* m_portInput;
|
||||||
QLineEdit* m_usernameInput;
|
QLineEdit* m_usernameInput;
|
||||||
QLineEdit* m_domainInput;
|
QLineEdit* m_domainInput;
|
||||||
|
QLineEdit* m_tagsInput;
|
||||||
QComboBox* m_protocolInput;
|
QComboBox* m_protocolInput;
|
||||||
QComboBox* m_authModeInput;
|
QComboBox* m_authModeInput;
|
||||||
QLineEdit* m_privateKeyPathInput;
|
QLineEdit* m_privateKeyPathInput;
|
||||||
@@ -37,6 +40,9 @@ private:
|
|||||||
QComboBox* m_knownHostsPolicyInput;
|
QComboBox* m_knownHostsPolicyInput;
|
||||||
QComboBox* m_rdpSecurityModeInput;
|
QComboBox* m_rdpSecurityModeInput;
|
||||||
QComboBox* m_rdpPerformanceProfileInput;
|
QComboBox* m_rdpPerformanceProfileInput;
|
||||||
|
QLabel* m_protocolHint;
|
||||||
|
QLabel* m_folderHint;
|
||||||
|
QString m_defaultFolderPath;
|
||||||
|
|
||||||
void refreshAuthFields();
|
void refreshAuthFields();
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
#include <QSqlQuery>
|
#include <QSqlQuery>
|
||||||
#include <QStandardPaths>
|
#include <QStandardPaths>
|
||||||
#include <QVariant>
|
#include <QVariant>
|
||||||
|
#include <QStringList>
|
||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
QString buildDatabasePath()
|
QString buildDatabasePath()
|
||||||
@@ -52,21 +53,130 @@ QString normalizedRdpPerformanceProfile(const QString& value)
|
|||||||
return QStringLiteral("Balanced");
|
return QStringLiteral("Balanced");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
QString normalizedProtocol(const QString& value)
|
||||||
|
{
|
||||||
|
const QString protocol = value.trimmed();
|
||||||
|
if (protocol.compare(QStringLiteral("RDP"), Qt::CaseInsensitive) == 0) {
|
||||||
|
return QStringLiteral("RDP");
|
||||||
|
}
|
||||||
|
if (protocol.compare(QStringLiteral("VNC"), Qt::CaseInsensitive) == 0) {
|
||||||
|
return QStringLiteral("VNC");
|
||||||
|
}
|
||||||
|
return QStringLiteral("SSH");
|
||||||
|
}
|
||||||
|
|
||||||
|
QString normalizedAuthMode(const QString& protocol, const QString& value)
|
||||||
|
{
|
||||||
|
if (protocol != QStringLiteral("SSH")) {
|
||||||
|
return QStringLiteral("Password");
|
||||||
|
}
|
||||||
|
|
||||||
|
const QString authMode = value.trimmed();
|
||||||
|
if (authMode.compare(QStringLiteral("Private Key"), Qt::CaseInsensitive) == 0) {
|
||||||
|
return QStringLiteral("Private Key");
|
||||||
|
}
|
||||||
|
return QStringLiteral("Password");
|
||||||
|
}
|
||||||
|
|
||||||
|
QString normalizedKnownHostsPolicy(const QString& value)
|
||||||
|
{
|
||||||
|
const QString policy = value.trimmed();
|
||||||
|
if (policy.compare(QStringLiteral("Strict"), Qt::CaseInsensitive) == 0) {
|
||||||
|
return QStringLiteral("Strict");
|
||||||
|
}
|
||||||
|
if (policy.compare(QStringLiteral("Accept New"), Qt::CaseInsensitive) == 0) {
|
||||||
|
return QStringLiteral("Accept New");
|
||||||
|
}
|
||||||
|
if (policy.compare(QStringLiteral("Ignore"), Qt::CaseInsensitive) == 0) {
|
||||||
|
return QStringLiteral("Ignore");
|
||||||
|
}
|
||||||
|
return QStringLiteral("Ask");
|
||||||
|
}
|
||||||
|
|
||||||
|
QString normalizedFolderPath(const QString& value)
|
||||||
|
{
|
||||||
|
QString path = value.trimmed();
|
||||||
|
path.replace(QChar::fromLatin1('\\'), QChar::fromLatin1('/'));
|
||||||
|
|
||||||
|
const QStringList rawParts = path.split(QChar::fromLatin1('/'), Qt::SkipEmptyParts);
|
||||||
|
QStringList normalized;
|
||||||
|
for (const QString& rawPart : rawParts) {
|
||||||
|
const QString part = rawPart.trimmed();
|
||||||
|
if (!part.isEmpty()) {
|
||||||
|
normalized.push_back(part);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized.join(QStringLiteral("/"));
|
||||||
|
}
|
||||||
|
|
||||||
|
QString normalizedTags(const QString& value)
|
||||||
|
{
|
||||||
|
const QStringList rawTokens = value.split(QChar::fromLatin1(','), Qt::SkipEmptyParts);
|
||||||
|
QStringList normalized;
|
||||||
|
QSet<QString> dedupe;
|
||||||
|
for (const QString& token : rawTokens) {
|
||||||
|
const QString trimmed = token.trimmed();
|
||||||
|
if (trimmed.isEmpty()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const QString key = trimmed.toLower();
|
||||||
|
if (dedupe.contains(key)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
dedupe.insert(key);
|
||||||
|
normalized.push_back(trimmed);
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized.join(QStringLiteral(", "));
|
||||||
|
}
|
||||||
|
|
||||||
|
QString orderByClause(ProfileSortOrder sortOrder)
|
||||||
|
{
|
||||||
|
switch (sortOrder) {
|
||||||
|
case ProfileSortOrder::ProtocolAsc:
|
||||||
|
return QStringLiteral("ORDER BY lower(protocol) ASC, lower(name) ASC, id ASC");
|
||||||
|
case ProfileSortOrder::HostAsc:
|
||||||
|
return QStringLiteral("ORDER BY lower(host) ASC, lower(name) ASC, id ASC");
|
||||||
|
case ProfileSortOrder::NameAsc:
|
||||||
|
default:
|
||||||
|
return QStringLiteral("ORDER BY lower(name) ASC, id ASC");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
QString nonNullTrimmed(const QString& value)
|
||||||
|
{
|
||||||
|
const QString trimmed = value.trimmed();
|
||||||
|
return trimmed.isNull() ? QStringLiteral("") : trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
void bindProfileFields(QSqlQuery& query, const Profile& profile)
|
void bindProfileFields(QSqlQuery& query, const Profile& profile)
|
||||||
{
|
{
|
||||||
query.addBindValue(profile.name.trimmed());
|
const QString protocol = normalizedProtocol(profile.protocol);
|
||||||
query.addBindValue(profile.host.trimmed());
|
const QString authMode = normalizedAuthMode(protocol, profile.authMode);
|
||||||
|
const bool isSsh = protocol == QStringLiteral("SSH");
|
||||||
|
const bool isRdp = protocol == QStringLiteral("RDP");
|
||||||
|
|
||||||
|
query.addBindValue(nonNullTrimmed(profile.name));
|
||||||
|
query.addBindValue(nonNullTrimmed(profile.host));
|
||||||
query.addBindValue(profile.port);
|
query.addBindValue(profile.port);
|
||||||
query.addBindValue(profile.username.trimmed());
|
query.addBindValue(nonNullTrimmed(profile.username));
|
||||||
query.addBindValue(profile.domain.trimmed());
|
query.addBindValue(isRdp ? nonNullTrimmed(profile.domain) : QStringLiteral(""));
|
||||||
query.addBindValue(profile.protocol.trimmed());
|
query.addBindValue(nonNullTrimmed(normalizedFolderPath(profile.folderPath)));
|
||||||
query.addBindValue(profile.authMode.trimmed());
|
query.addBindValue(protocol);
|
||||||
query.addBindValue(profile.privateKeyPath.trimmed());
|
query.addBindValue(authMode);
|
||||||
query.addBindValue(profile.knownHostsPolicy.trimmed().isEmpty()
|
query.addBindValue((isSsh && authMode == QStringLiteral("Private Key"))
|
||||||
? QStringLiteral("Ask")
|
? nonNullTrimmed(profile.privateKeyPath)
|
||||||
: profile.knownHostsPolicy.trimmed());
|
: QStringLiteral(""));
|
||||||
query.addBindValue(normalizedRdpSecurityMode(profile.rdpSecurityMode));
|
query.addBindValue(isSsh ? normalizedKnownHostsPolicy(profile.knownHostsPolicy)
|
||||||
query.addBindValue(normalizedRdpPerformanceProfile(profile.rdpPerformanceProfile));
|
: QStringLiteral("Ask"));
|
||||||
|
query.addBindValue(isRdp ? normalizedRdpSecurityMode(profile.rdpSecurityMode)
|
||||||
|
: QStringLiteral("Negotiate"));
|
||||||
|
query.addBindValue(isRdp ? normalizedRdpPerformanceProfile(profile.rdpPerformanceProfile)
|
||||||
|
: QStringLiteral("Balanced"));
|
||||||
|
query.addBindValue(normalizedTags(profile.tags));
|
||||||
}
|
}
|
||||||
|
|
||||||
Profile profileFromQuery(const QSqlQuery& query)
|
Profile profileFromQuery(const QSqlQuery& query)
|
||||||
@@ -78,22 +188,67 @@ Profile profileFromQuery(const QSqlQuery& query)
|
|||||||
profile.port = query.value(3).toInt();
|
profile.port = query.value(3).toInt();
|
||||||
profile.username = query.value(4).toString();
|
profile.username = query.value(4).toString();
|
||||||
profile.domain = query.value(5).toString();
|
profile.domain = query.value(5).toString();
|
||||||
profile.protocol = query.value(6).toString();
|
profile.folderPath = normalizedFolderPath(query.value(6).toString());
|
||||||
profile.authMode = query.value(7).toString();
|
profile.protocol = normalizedProtocol(query.value(7).toString());
|
||||||
profile.privateKeyPath = query.value(8).toString();
|
profile.authMode = normalizedAuthMode(profile.protocol, query.value(8).toString());
|
||||||
profile.knownHostsPolicy = query.value(9).toString();
|
profile.privateKeyPath = profile.authMode == QStringLiteral("Private Key")
|
||||||
if (profile.knownHostsPolicy.isEmpty()) {
|
? query.value(9).toString().trimmed()
|
||||||
profile.knownHostsPolicy = QStringLiteral("Ask");
|
: QString();
|
||||||
}
|
profile.knownHostsPolicy = profile.protocol == QStringLiteral("SSH")
|
||||||
profile.rdpSecurityMode = normalizedRdpSecurityMode(query.value(10).toString());
|
? normalizedKnownHostsPolicy(query.value(10).toString())
|
||||||
profile.rdpPerformanceProfile = normalizedRdpPerformanceProfile(query.value(11).toString());
|
: QStringLiteral("Ask");
|
||||||
|
profile.rdpSecurityMode = profile.protocol == QStringLiteral("RDP")
|
||||||
|
? normalizedRdpSecurityMode(query.value(11).toString())
|
||||||
|
: QStringLiteral("Negotiate");
|
||||||
|
profile.rdpPerformanceProfile = profile.protocol == QStringLiteral("RDP")
|
||||||
|
? normalizedRdpPerformanceProfile(query.value(12).toString())
|
||||||
|
: QStringLiteral("Balanced");
|
||||||
|
profile.tags = normalizedTags(query.value(13).toString());
|
||||||
return profile;
|
return profile;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool isProfileValid(const Profile& profile)
|
bool isProfileValid(const Profile& profile, QString* error)
|
||||||
{
|
{
|
||||||
return !profile.name.trimmed().isEmpty() && !profile.host.trimmed().isEmpty()
|
if (profile.name.trimmed().isEmpty()) {
|
||||||
&& profile.port >= 1 && profile.port <= 65535;
|
if (error != nullptr) {
|
||||||
|
*error = QStringLiteral("Profile name is required.");
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (profile.host.trimmed().isEmpty()) {
|
||||||
|
if (error != nullptr) {
|
||||||
|
*error = QStringLiteral("Host is required.");
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (profile.port < 1 || profile.port > 65535) {
|
||||||
|
if (error != nullptr) {
|
||||||
|
*error = QStringLiteral("Port must be between 1 and 65535.");
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const QString protocol = normalizedProtocol(profile.protocol);
|
||||||
|
if ((protocol == QStringLiteral("SSH") || protocol == QStringLiteral("RDP"))
|
||||||
|
&& profile.username.trimmed().isEmpty()) {
|
||||||
|
if (error != nullptr) {
|
||||||
|
*error = QStringLiteral("Username is required for %1 profiles.").arg(protocol);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const QString authMode = normalizedAuthMode(protocol, profile.authMode);
|
||||||
|
if (protocol == QStringLiteral("SSH") && authMode == QStringLiteral("Private Key")
|
||||||
|
&& profile.privateKeyPath.trimmed().isEmpty()) {
|
||||||
|
if (error != nullptr) {
|
||||||
|
*error = QStringLiteral("Private key path is required for SSH private key authentication.");
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,7 +280,60 @@ QString ProfileRepository::lastError() const
|
|||||||
return m_lastError;
|
return m_lastError;
|
||||||
}
|
}
|
||||||
|
|
||||||
std::vector<Profile> ProfileRepository::listProfiles(const QString& searchQuery) const
|
std::vector<QString> ProfileRepository::listFolders() const
|
||||||
|
{
|
||||||
|
std::vector<QString> result;
|
||||||
|
|
||||||
|
if (!QSqlDatabase::contains(m_connectionName)) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLastError(QString());
|
||||||
|
|
||||||
|
QSqlQuery query(QSqlDatabase::database(m_connectionName));
|
||||||
|
query.prepare(QStringLiteral("SELECT path FROM profile_folders ORDER BY lower(path) ASC"));
|
||||||
|
if (!query.exec()) {
|
||||||
|
setLastError(query.lastError().text());
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
while (query.next()) {
|
||||||
|
const QString normalized = normalizedFolderPath(query.value(0).toString());
|
||||||
|
if (!normalized.isEmpty()) {
|
||||||
|
result.push_back(normalized);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ProfileRepository::createFolder(const QString& folderPath) const
|
||||||
|
{
|
||||||
|
if (!QSqlDatabase::contains(m_connectionName)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const QString normalized = normalizedFolderPath(folderPath);
|
||||||
|
if (normalized.isEmpty()) {
|
||||||
|
setLastError(QStringLiteral("Folder path is required."));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLastError(QString());
|
||||||
|
|
||||||
|
QSqlQuery query(QSqlDatabase::database(m_connectionName));
|
||||||
|
query.prepare(QStringLiteral("INSERT OR IGNORE INTO profile_folders(path) VALUES (?)"));
|
||||||
|
query.addBindValue(normalized);
|
||||||
|
if (!query.exec()) {
|
||||||
|
setLastError(query.lastError().text());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<Profile> ProfileRepository::listProfiles(const QString& searchQuery,
|
||||||
|
ProfileSortOrder sortOrder) const
|
||||||
{
|
{
|
||||||
std::vector<Profile> result;
|
std::vector<Profile> result;
|
||||||
|
|
||||||
@@ -136,20 +344,23 @@ std::vector<Profile> ProfileRepository::listProfiles(const QString& searchQuery)
|
|||||||
setLastError(QString());
|
setLastError(QString());
|
||||||
|
|
||||||
QSqlQuery query(QSqlDatabase::database(m_connectionName));
|
QSqlQuery query(QSqlDatabase::database(m_connectionName));
|
||||||
|
const QString orderBy = orderByClause(sortOrder);
|
||||||
if (searchQuery.trimmed().isEmpty()) {
|
if (searchQuery.trimmed().isEmpty()) {
|
||||||
query.prepare(QStringLiteral(
|
query.prepare(QStringLiteral(
|
||||||
"SELECT id, name, host, port, username, domain, protocol, auth_mode, private_key_path, known_hosts_policy, rdp_security_mode, rdp_performance_profile "
|
"SELECT id, name, host, port, username, domain, folder_path, protocol, auth_mode, private_key_path, known_hosts_policy, rdp_security_mode, rdp_performance_profile, tags "
|
||||||
"FROM profiles "
|
"FROM profiles ")
|
||||||
"ORDER BY lower(name) ASC, id ASC"));
|
+ orderBy);
|
||||||
} else {
|
} else {
|
||||||
query.prepare(QStringLiteral(
|
query.prepare(QStringLiteral(
|
||||||
"SELECT id, name, host, port, username, domain, protocol, auth_mode, private_key_path, known_hosts_policy, rdp_security_mode, rdp_performance_profile "
|
"SELECT id, name, host, port, username, domain, folder_path, protocol, auth_mode, private_key_path, known_hosts_policy, rdp_security_mode, rdp_performance_profile, tags "
|
||||||
"FROM profiles "
|
"FROM profiles "
|
||||||
"WHERE lower(name) LIKE lower(?) OR lower(host) LIKE lower(?) "
|
"WHERE lower(name) LIKE lower(?) OR lower(host) LIKE lower(?) OR lower(tags) LIKE lower(?) OR lower(folder_path) LIKE lower(?) ")
|
||||||
"ORDER BY lower(name) ASC, id ASC"));
|
+ orderBy);
|
||||||
const QString search = QStringLiteral("%") + searchQuery.trimmed() + QStringLiteral("%");
|
const QString search = QStringLiteral("%") + searchQuery.trimmed() + QStringLiteral("%");
|
||||||
query.addBindValue(search);
|
query.addBindValue(search);
|
||||||
query.addBindValue(search);
|
query.addBindValue(search);
|
||||||
|
query.addBindValue(search);
|
||||||
|
query.addBindValue(search);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!query.exec()) {
|
if (!query.exec()) {
|
||||||
@@ -174,7 +385,7 @@ std::optional<Profile> ProfileRepository::getProfile(qint64 id) const
|
|||||||
|
|
||||||
QSqlQuery query(QSqlDatabase::database(m_connectionName));
|
QSqlQuery query(QSqlDatabase::database(m_connectionName));
|
||||||
query.prepare(QStringLiteral(
|
query.prepare(QStringLiteral(
|
||||||
"SELECT id, name, host, port, username, domain, protocol, auth_mode, private_key_path, known_hosts_policy, rdp_security_mode, rdp_performance_profile "
|
"SELECT id, name, host, port, username, domain, folder_path, protocol, auth_mode, private_key_path, known_hosts_policy, rdp_security_mode, rdp_performance_profile, tags "
|
||||||
"FROM profiles WHERE id = ?"));
|
"FROM profiles WHERE id = ?"));
|
||||||
query.addBindValue(id);
|
query.addBindValue(id);
|
||||||
|
|
||||||
@@ -198,15 +409,16 @@ std::optional<Profile> ProfileRepository::createProfile(const Profile& profile)
|
|||||||
|
|
||||||
setLastError(QString());
|
setLastError(QString());
|
||||||
|
|
||||||
if (!isProfileValid(profile)) {
|
QString validationError;
|
||||||
setLastError(QStringLiteral("Name, host, and a valid port are required."));
|
if (!isProfileValid(profile, &validationError)) {
|
||||||
|
setLastError(validationError);
|
||||||
return std::nullopt;
|
return std::nullopt;
|
||||||
}
|
}
|
||||||
|
|
||||||
QSqlQuery query(QSqlDatabase::database(m_connectionName));
|
QSqlQuery query(QSqlDatabase::database(m_connectionName));
|
||||||
query.prepare(QStringLiteral(
|
query.prepare(QStringLiteral(
|
||||||
"INSERT INTO profiles(name, host, port, username, domain, protocol, auth_mode, private_key_path, known_hosts_policy, rdp_security_mode, rdp_performance_profile) "
|
"INSERT INTO profiles(name, host, port, username, domain, folder_path, protocol, auth_mode, private_key_path, known_hosts_policy, rdp_security_mode, rdp_performance_profile, tags) "
|
||||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"));
|
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"));
|
||||||
bindProfileFields(query, profile);
|
bindProfileFields(query, profile);
|
||||||
|
|
||||||
if (!query.exec()) {
|
if (!query.exec()) {
|
||||||
@@ -227,15 +439,17 @@ bool ProfileRepository::updateProfile(const Profile& profile) const
|
|||||||
|
|
||||||
setLastError(QString());
|
setLastError(QString());
|
||||||
|
|
||||||
if (profile.id < 0 || !isProfileValid(profile)) {
|
QString validationError;
|
||||||
setLastError(QStringLiteral("Invalid profile data."));
|
if (profile.id < 0 || !isProfileValid(profile, &validationError)) {
|
||||||
|
setLastError(validationError.isEmpty() ? QStringLiteral("Invalid profile data.")
|
||||||
|
: validationError);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
QSqlQuery query(QSqlDatabase::database(m_connectionName));
|
QSqlQuery query(QSqlDatabase::database(m_connectionName));
|
||||||
query.prepare(QStringLiteral(
|
query.prepare(QStringLiteral(
|
||||||
"UPDATE profiles "
|
"UPDATE profiles "
|
||||||
"SET name = ?, host = ?, port = ?, username = ?, domain = ?, protocol = ?, auth_mode = ?, private_key_path = ?, known_hosts_policy = ?, rdp_security_mode = ?, rdp_performance_profile = ? "
|
"SET name = ?, host = ?, port = ?, username = ?, domain = ?, folder_path = ?, protocol = ?, auth_mode = ?, private_key_path = ?, known_hosts_policy = ?, rdp_security_mode = ?, rdp_performance_profile = ?, tags = ? "
|
||||||
"WHERE id = ?"));
|
"WHERE id = ?"));
|
||||||
bindProfileFields(query, profile);
|
bindProfileFields(query, profile);
|
||||||
query.addBindValue(profile.id);
|
query.addBindValue(profile.id);
|
||||||
@@ -287,12 +501,14 @@ bool ProfileRepository::initializeDatabase()
|
|||||||
"port INTEGER NOT NULL DEFAULT 22,"
|
"port INTEGER NOT NULL DEFAULT 22,"
|
||||||
"username TEXT NOT NULL DEFAULT '',"
|
"username TEXT NOT NULL DEFAULT '',"
|
||||||
"domain TEXT NOT NULL DEFAULT '',"
|
"domain TEXT NOT NULL DEFAULT '',"
|
||||||
|
"folder_path TEXT NOT NULL DEFAULT '',"
|
||||||
"protocol TEXT NOT NULL DEFAULT 'SSH',"
|
"protocol TEXT NOT NULL DEFAULT 'SSH',"
|
||||||
"auth_mode TEXT NOT NULL DEFAULT 'Password',"
|
"auth_mode TEXT NOT NULL DEFAULT 'Password',"
|
||||||
"private_key_path TEXT NOT NULL DEFAULT '',"
|
"private_key_path TEXT NOT NULL DEFAULT '',"
|
||||||
"known_hosts_policy TEXT NOT NULL DEFAULT 'Ask',"
|
"known_hosts_policy TEXT NOT NULL DEFAULT 'Ask',"
|
||||||
"rdp_security_mode TEXT NOT NULL DEFAULT 'Negotiate',"
|
"rdp_security_mode TEXT NOT NULL DEFAULT 'Negotiate',"
|
||||||
"rdp_performance_profile TEXT NOT NULL DEFAULT 'Balanced'"
|
"rdp_performance_profile TEXT NOT NULL DEFAULT 'Balanced',"
|
||||||
|
"tags TEXT NOT NULL DEFAULT ''"
|
||||||
")"));
|
")"));
|
||||||
|
|
||||||
if (!created) {
|
if (!created) {
|
||||||
@@ -300,6 +516,15 @@ bool ProfileRepository::initializeDatabase()
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const bool foldersCreated = query.exec(QStringLiteral(
|
||||||
|
"CREATE TABLE IF NOT EXISTS profile_folders ("
|
||||||
|
"path TEXT PRIMARY KEY NOT NULL"
|
||||||
|
")"));
|
||||||
|
if (!foldersCreated) {
|
||||||
|
m_initError = query.lastError().text();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
if (!ensureProfileSchema()) {
|
if (!ensureProfileSchema()) {
|
||||||
m_initError = m_lastError;
|
m_initError = m_lastError;
|
||||||
return false;
|
return false;
|
||||||
@@ -336,12 +561,14 @@ bool ProfileRepository::ensureProfileSchema() const
|
|||||||
{QStringLiteral("port"), QStringLiteral("ALTER TABLE profiles ADD COLUMN port INTEGER NOT NULL DEFAULT 22")},
|
{QStringLiteral("port"), QStringLiteral("ALTER TABLE profiles ADD COLUMN port INTEGER NOT NULL DEFAULT 22")},
|
||||||
{QStringLiteral("username"), QStringLiteral("ALTER TABLE profiles ADD COLUMN username TEXT NOT NULL DEFAULT ''")},
|
{QStringLiteral("username"), QStringLiteral("ALTER TABLE profiles ADD COLUMN username TEXT NOT NULL DEFAULT ''")},
|
||||||
{QStringLiteral("domain"), QStringLiteral("ALTER TABLE profiles ADD COLUMN domain TEXT NOT NULL DEFAULT ''")},
|
{QStringLiteral("domain"), QStringLiteral("ALTER TABLE profiles ADD COLUMN domain TEXT NOT NULL DEFAULT ''")},
|
||||||
|
{QStringLiteral("folder_path"), QStringLiteral("ALTER TABLE profiles ADD COLUMN folder_path TEXT NOT NULL DEFAULT ''")},
|
||||||
{QStringLiteral("protocol"), QStringLiteral("ALTER TABLE profiles ADD COLUMN protocol TEXT NOT NULL DEFAULT 'SSH'")},
|
{QStringLiteral("protocol"), QStringLiteral("ALTER TABLE profiles ADD COLUMN protocol TEXT NOT NULL DEFAULT 'SSH'")},
|
||||||
{QStringLiteral("auth_mode"), QStringLiteral("ALTER TABLE profiles ADD COLUMN auth_mode TEXT NOT NULL DEFAULT 'Password'")},
|
{QStringLiteral("auth_mode"), QStringLiteral("ALTER TABLE profiles ADD COLUMN auth_mode TEXT NOT NULL DEFAULT 'Password'")},
|
||||||
{QStringLiteral("private_key_path"), QStringLiteral("ALTER TABLE profiles ADD COLUMN private_key_path TEXT NOT NULL DEFAULT ''")},
|
{QStringLiteral("private_key_path"), QStringLiteral("ALTER TABLE profiles ADD COLUMN private_key_path TEXT NOT NULL DEFAULT ''")},
|
||||||
{QStringLiteral("known_hosts_policy"), QStringLiteral("ALTER TABLE profiles ADD COLUMN known_hosts_policy TEXT NOT NULL DEFAULT 'Ask'")},
|
{QStringLiteral("known_hosts_policy"), QStringLiteral("ALTER TABLE profiles ADD COLUMN known_hosts_policy TEXT NOT NULL DEFAULT 'Ask'")},
|
||||||
{QStringLiteral("rdp_security_mode"), QStringLiteral("ALTER TABLE profiles ADD COLUMN rdp_security_mode TEXT NOT NULL DEFAULT 'Negotiate'")},
|
{QStringLiteral("rdp_security_mode"), QStringLiteral("ALTER TABLE profiles ADD COLUMN rdp_security_mode TEXT NOT NULL DEFAULT 'Negotiate'")},
|
||||||
{QStringLiteral("rdp_performance_profile"), QStringLiteral("ALTER TABLE profiles ADD COLUMN rdp_performance_profile TEXT NOT NULL DEFAULT 'Balanced'")}};
|
{QStringLiteral("rdp_performance_profile"), QStringLiteral("ALTER TABLE profiles ADD COLUMN rdp_performance_profile TEXT NOT NULL DEFAULT 'Balanced'")},
|
||||||
|
{QStringLiteral("tags"), QStringLiteral("ALTER TABLE profiles ADD COLUMN tags TEXT NOT NULL DEFAULT ''")}};
|
||||||
|
|
||||||
for (const ColumnDef& column : required) {
|
for (const ColumnDef& column : required) {
|
||||||
if (columns.contains(column.name)) {
|
if (columns.contains(column.name)) {
|
||||||
@@ -355,6 +582,15 @@ bool ProfileRepository::ensureProfileSchema() const
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
QSqlQuery ensureFolders(QSqlDatabase::database(m_connectionName));
|
||||||
|
if (!ensureFolders.exec(QStringLiteral(
|
||||||
|
"CREATE TABLE IF NOT EXISTS profile_folders ("
|
||||||
|
"path TEXT PRIMARY KEY NOT NULL"
|
||||||
|
")"))) {
|
||||||
|
setLastError(ensureFolders.lastError().text());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
setLastError(QString());
|
setLastError(QString());
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,12 +15,20 @@ struct Profile
|
|||||||
int port = 22;
|
int port = 22;
|
||||||
QString username;
|
QString username;
|
||||||
QString domain;
|
QString domain;
|
||||||
|
QString folderPath;
|
||||||
QString protocol = QStringLiteral("SSH");
|
QString protocol = QStringLiteral("SSH");
|
||||||
QString authMode = QStringLiteral("Password");
|
QString authMode = QStringLiteral("Password");
|
||||||
QString privateKeyPath;
|
QString privateKeyPath;
|
||||||
QString knownHostsPolicy = QStringLiteral("Ask");
|
QString knownHostsPolicy = QStringLiteral("Ask");
|
||||||
QString rdpSecurityMode = QStringLiteral("Negotiate");
|
QString rdpSecurityMode = QStringLiteral("Negotiate");
|
||||||
QString rdpPerformanceProfile = QStringLiteral("Balanced");
|
QString rdpPerformanceProfile = QStringLiteral("Balanced");
|
||||||
|
QString tags;
|
||||||
|
};
|
||||||
|
|
||||||
|
enum class ProfileSortOrder {
|
||||||
|
NameAsc,
|
||||||
|
ProtocolAsc,
|
||||||
|
HostAsc,
|
||||||
};
|
};
|
||||||
|
|
||||||
class ProfileRepository
|
class ProfileRepository
|
||||||
@@ -32,7 +40,10 @@ public:
|
|||||||
QString initError() const;
|
QString initError() const;
|
||||||
QString lastError() const;
|
QString lastError() const;
|
||||||
|
|
||||||
std::vector<Profile> listProfiles(const QString& searchQuery = QString()) const;
|
std::vector<Profile> listProfiles(const QString& searchQuery = QString(),
|
||||||
|
ProfileSortOrder sortOrder = ProfileSortOrder::NameAsc) const;
|
||||||
|
std::vector<QString> listFolders() const;
|
||||||
|
bool createFolder(const QString& folderPath) const;
|
||||||
std::optional<Profile> getProfile(qint64 id) const;
|
std::optional<Profile> getProfile(qint64 id) const;
|
||||||
std::optional<Profile> createProfile(const Profile& profile) const;
|
std::optional<Profile> createProfile(const Profile& profile) const;
|
||||||
bool updateProfile(const Profile& profile) const;
|
bool updateProfile(const Profile& profile) const;
|
||||||
|
|||||||
@@ -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,40 +2,104 @@
|
|||||||
|
|
||||||
#include "profile_dialog.h"
|
#include "profile_dialog.h"
|
||||||
#include "profile_repository.h"
|
#include "profile_repository.h"
|
||||||
#include "session_window.h"
|
#include "profiles_tree_widget.h"
|
||||||
|
|
||||||
|
#include <QAction>
|
||||||
#include <QAbstractItemView>
|
#include <QAbstractItemView>
|
||||||
|
#include <QComboBox>
|
||||||
|
#include <QHeaderView>
|
||||||
#include <QHBoxLayout>
|
#include <QHBoxLayout>
|
||||||
#include <QLabel>
|
#include <QLabel>
|
||||||
#include <QLineEdit>
|
#include <QLineEdit>
|
||||||
#include <QListWidget>
|
#include <QInputDialog>
|
||||||
#include <QListWidgetItem>
|
#include <QMenu>
|
||||||
#include <QMessageBox>
|
#include <QMessageBox>
|
||||||
#include <QPushButton>
|
#include <QPushButton>
|
||||||
|
#include <QSet>
|
||||||
|
#include <QSettings>
|
||||||
|
#include <QSignalBlocker>
|
||||||
|
#include <QStringList>
|
||||||
|
#include <QStyle>
|
||||||
|
#include <QTreeWidget>
|
||||||
|
#include <QTreeWidgetItem>
|
||||||
#include <QVariant>
|
#include <QVariant>
|
||||||
#include <QVBoxLayout>
|
#include <QVBoxLayout>
|
||||||
#include <QWidget>
|
#include <QWidget>
|
||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
QString formatProfileListItem(const Profile& profile)
|
constexpr int kProfileIdRole = Qt::UserRole;
|
||||||
|
constexpr int kFolderPathRole = Qt::UserRole + 1;
|
||||||
|
|
||||||
|
QString normalizeFolderPathForView(const QString& value)
|
||||||
{
|
{
|
||||||
return QStringLiteral("%1 [%2 %3:%4]")
|
QString path = value.trimmed();
|
||||||
.arg(profile.name, profile.protocol, profile.host, QString::number(profile.port));
|
path.replace(QChar::fromLatin1('\\'), QChar::fromLatin1('/'));
|
||||||
|
|
||||||
|
const QStringList rawParts = path.split(QChar::fromLatin1('/'), Qt::SkipEmptyParts);
|
||||||
|
QStringList normalized;
|
||||||
|
for (const QString& rawPart : rawParts) {
|
||||||
|
const QString part = rawPart.trimmed();
|
||||||
|
if (!part.isEmpty()) {
|
||||||
|
normalized.push_back(part);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized.join(QStringLiteral("/"));
|
||||||
|
}
|
||||||
|
|
||||||
|
QString joinFolderPath(const QString& parentFolder, const QString& childName)
|
||||||
|
{
|
||||||
|
const QString parent = normalizeFolderPathForView(parentFolder);
|
||||||
|
const QString child = normalizeFolderPathForView(childName);
|
||||||
|
if (child.isEmpty()) {
|
||||||
|
return parent;
|
||||||
|
}
|
||||||
|
if (parent.isEmpty()) {
|
||||||
|
return child;
|
||||||
|
}
|
||||||
|
return QStringLiteral("%1/%2").arg(parent, child);
|
||||||
|
}
|
||||||
|
|
||||||
|
QStringList splitFolderPath(const QString& folderPath)
|
||||||
|
{
|
||||||
|
const QString normalized = normalizeFolderPathForView(folderPath);
|
||||||
|
if (normalized.isEmpty()) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
return normalized.split(QChar::fromLatin1('/'), Qt::SkipEmptyParts);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool profileHasTag(const Profile& profile, const QString& requestedTag)
|
||||||
|
{
|
||||||
|
const QString needle = requestedTag.trimmed();
|
||||||
|
if (needle.isEmpty()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const QStringList tags = profile.tags.split(QChar::fromLatin1(','), Qt::SkipEmptyParts);
|
||||||
|
for (const QString& tag : tags) {
|
||||||
|
if (tag.trimmed().compare(needle, Qt::CaseInsensitive) == 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ProfilesWindow::ProfilesWindow(QWidget* parent)
|
ProfilesWindow::ProfilesWindow(QWidget* parent)
|
||||||
: QMainWindow(parent),
|
: QWidget(parent),
|
||||||
m_searchBox(nullptr),
|
m_searchBox(nullptr),
|
||||||
m_profilesList(nullptr),
|
m_viewModeBox(nullptr),
|
||||||
|
m_sortBox(nullptr),
|
||||||
|
m_protocolFilterBox(nullptr),
|
||||||
|
m_tagFilterBox(nullptr),
|
||||||
|
m_profilesTree(nullptr),
|
||||||
m_newButton(nullptr),
|
m_newButton(nullptr),
|
||||||
m_editButton(nullptr),
|
m_editButton(nullptr),
|
||||||
m_deleteButton(nullptr),
|
m_deleteButton(nullptr),
|
||||||
m_repository(std::make_unique<ProfileRepository>())
|
m_repository(std::make_unique<ProfileRepository>())
|
||||||
{
|
{
|
||||||
setWindowTitle(QStringLiteral("OrbitHub Profiles"));
|
|
||||||
resize(640, 620);
|
|
||||||
|
|
||||||
setupUi();
|
setupUi();
|
||||||
|
|
||||||
if (!m_repository->initError().isEmpty()) {
|
if (!m_repository->initError().isEmpty()) {
|
||||||
@@ -47,10 +111,11 @@ ProfilesWindow::ProfilesWindow(QWidget* parent)
|
|||||||
m_editButton->setEnabled(false);
|
m_editButton->setEnabled(false);
|
||||||
m_deleteButton->setEnabled(false);
|
m_deleteButton->setEnabled(false);
|
||||||
m_searchBox->setEnabled(false);
|
m_searchBox->setEnabled(false);
|
||||||
m_profilesList->setEnabled(false);
|
m_profilesTree->setEnabled(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
loadUiPreferences();
|
||||||
loadProfiles();
|
loadProfiles();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,54 +123,139 @@ ProfilesWindow::~ProfilesWindow() = default;
|
|||||||
|
|
||||||
void ProfilesWindow::setupUi()
|
void ProfilesWindow::setupUi()
|
||||||
{
|
{
|
||||||
auto* central = new QWidget(this);
|
auto* rootLayout = new QVBoxLayout(this);
|
||||||
auto* rootLayout = new QVBoxLayout(central);
|
|
||||||
|
|
||||||
auto* searchLabel = new QLabel(QStringLiteral("Search"), central);
|
auto* searchLabel = new QLabel(QStringLiteral("Search"), this);
|
||||||
m_searchBox = new QLineEdit(central);
|
m_searchBox = new QLineEdit(this);
|
||||||
m_searchBox->setPlaceholderText(QStringLiteral("Filter by name or host..."));
|
m_searchBox->setPlaceholderText(QStringLiteral("Filter by name, host, folder, or tags..."));
|
||||||
|
|
||||||
m_profilesList = new QListWidget(central);
|
auto* viewModeLabel = new QLabel(QStringLiteral("View"), this);
|
||||||
m_profilesList->setSelectionMode(QAbstractItemView::SingleSelection);
|
m_viewModeBox = new QComboBox(this);
|
||||||
|
m_viewModeBox->addItem(QStringLiteral("List"));
|
||||||
|
m_viewModeBox->addItem(QStringLiteral("Folders"));
|
||||||
|
|
||||||
|
auto* sortLabel = new QLabel(QStringLiteral("Sort"), this);
|
||||||
|
m_sortBox = new QComboBox(this);
|
||||||
|
m_sortBox->addItem(QStringLiteral("Name"), static_cast<int>(ProfileSortOrder::NameAsc));
|
||||||
|
m_sortBox->addItem(QStringLiteral("Protocol"), static_cast<int>(ProfileSortOrder::ProtocolAsc));
|
||||||
|
m_sortBox->addItem(QStringLiteral("Host"), static_cast<int>(ProfileSortOrder::HostAsc));
|
||||||
|
|
||||||
|
auto* protocolFilterLabel = new QLabel(QStringLiteral("Protocol"), this);
|
||||||
|
m_protocolFilterBox = new QComboBox(this);
|
||||||
|
m_protocolFilterBox->addItem(QStringLiteral("All"));
|
||||||
|
m_protocolFilterBox->addItem(QStringLiteral("SSH"));
|
||||||
|
m_protocolFilterBox->addItem(QStringLiteral("RDP"));
|
||||||
|
m_protocolFilterBox->addItem(QStringLiteral("VNC"));
|
||||||
|
|
||||||
|
auto* tagFilterLabel = new QLabel(QStringLiteral("Tag"), this);
|
||||||
|
m_tagFilterBox = new QComboBox(this);
|
||||||
|
m_tagFilterBox->addItem(QStringLiteral("All"));
|
||||||
|
|
||||||
|
m_profilesTree = new ProfilesTreeWidget(this);
|
||||||
|
m_profilesTree->setSelectionMode(QAbstractItemView::SingleSelection);
|
||||||
|
m_profilesTree->setColumnCount(4);
|
||||||
|
m_profilesTree->setHeaderLabels(
|
||||||
|
{QStringLiteral("Name"), QStringLiteral("Protocol"), QStringLiteral("Server"), QStringLiteral("Tags")});
|
||||||
|
m_profilesTree->setRootIsDecorated(true);
|
||||||
|
m_profilesTree->setDragEnabled(true);
|
||||||
|
m_profilesTree->setAcceptDrops(true);
|
||||||
|
m_profilesTree->viewport()->setAcceptDrops(true);
|
||||||
|
m_profilesTree->setDropIndicatorShown(true);
|
||||||
|
m_profilesTree->setDragDropMode(QAbstractItemView::InternalMove);
|
||||||
|
m_profilesTree->setDefaultDropAction(Qt::MoveAction);
|
||||||
|
m_profilesTree->setContextMenuPolicy(Qt::CustomContextMenu);
|
||||||
|
m_profilesTree->header()->setSectionResizeMode(0, QHeaderView::Stretch);
|
||||||
|
m_profilesTree->header()->setSectionResizeMode(1, QHeaderView::ResizeToContents);
|
||||||
|
m_profilesTree->header()->setSectionResizeMode(2, QHeaderView::ResizeToContents);
|
||||||
|
m_profilesTree->header()->setSectionResizeMode(3, QHeaderView::Stretch);
|
||||||
|
|
||||||
auto* buttonRow = new QHBoxLayout();
|
auto* buttonRow = new QHBoxLayout();
|
||||||
m_newButton = new QPushButton(QStringLiteral("New"), central);
|
m_newButton = new QPushButton(QStringLiteral("New"), this);
|
||||||
m_editButton = new QPushButton(QStringLiteral("Edit"), central);
|
m_editButton = new QPushButton(QStringLiteral("Edit"), this);
|
||||||
m_deleteButton = new QPushButton(QStringLiteral("Delete"), central);
|
m_deleteButton = new QPushButton(QStringLiteral("Delete"), this);
|
||||||
|
|
||||||
buttonRow->addWidget(m_newButton);
|
buttonRow->addWidget(m_newButton);
|
||||||
buttonRow->addWidget(m_editButton);
|
buttonRow->addWidget(m_editButton);
|
||||||
buttonRow->addWidget(m_deleteButton);
|
buttonRow->addWidget(m_deleteButton);
|
||||||
buttonRow->addStretch();
|
buttonRow->addStretch();
|
||||||
|
|
||||||
rootLayout->addWidget(searchLabel);
|
auto* filterRow = new QHBoxLayout();
|
||||||
rootLayout->addWidget(m_searchBox);
|
filterRow->addWidget(searchLabel);
|
||||||
rootLayout->addWidget(m_profilesList, 1);
|
filterRow->addWidget(m_searchBox, 1);
|
||||||
rootLayout->addLayout(buttonRow);
|
filterRow->addWidget(viewModeLabel);
|
||||||
|
filterRow->addWidget(m_viewModeBox);
|
||||||
|
filterRow->addWidget(protocolFilterLabel);
|
||||||
|
filterRow->addWidget(m_protocolFilterBox);
|
||||||
|
filterRow->addWidget(tagFilterLabel);
|
||||||
|
filterRow->addWidget(m_tagFilterBox);
|
||||||
|
filterRow->addWidget(sortLabel);
|
||||||
|
filterRow->addWidget(m_sortBox);
|
||||||
|
|
||||||
setCentralWidget(central);
|
rootLayout->addLayout(filterRow);
|
||||||
|
rootLayout->addWidget(m_profilesTree, 1);
|
||||||
|
rootLayout->addLayout(buttonRow);
|
||||||
|
|
||||||
connect(m_searchBox,
|
connect(m_searchBox,
|
||||||
&QLineEdit::textChanged,
|
&QLineEdit::textChanged,
|
||||||
this,
|
this,
|
||||||
[this](const QString& text) { loadProfiles(text); });
|
[this](const QString&) {
|
||||||
|
saveUiPreferences();
|
||||||
connect(m_profilesList,
|
loadProfiles();
|
||||||
&QListWidget::itemDoubleClicked,
|
});
|
||||||
|
connect(m_viewModeBox,
|
||||||
|
&QComboBox::currentIndexChanged,
|
||||||
this,
|
this,
|
||||||
[this](QListWidgetItem* item) { openSessionForItem(item); });
|
[this](int) {
|
||||||
|
saveUiPreferences();
|
||||||
|
loadProfiles();
|
||||||
|
});
|
||||||
|
connect(m_sortBox,
|
||||||
|
&QComboBox::currentIndexChanged,
|
||||||
|
this,
|
||||||
|
[this](int) {
|
||||||
|
saveUiPreferences();
|
||||||
|
loadProfiles();
|
||||||
|
});
|
||||||
|
connect(m_protocolFilterBox,
|
||||||
|
&QComboBox::currentIndexChanged,
|
||||||
|
this,
|
||||||
|
[this](int) {
|
||||||
|
saveUiPreferences();
|
||||||
|
loadProfiles();
|
||||||
|
});
|
||||||
|
connect(m_tagFilterBox,
|
||||||
|
&QComboBox::currentIndexChanged,
|
||||||
|
this,
|
||||||
|
[this](int) {
|
||||||
|
saveUiPreferences();
|
||||||
|
loadProfiles();
|
||||||
|
});
|
||||||
|
|
||||||
|
connect(m_profilesTree,
|
||||||
|
&QTreeWidget::itemDoubleClicked,
|
||||||
|
this,
|
||||||
|
[this](QTreeWidgetItem* item, int) { openSessionForItem(item); });
|
||||||
|
connect(m_profilesTree,
|
||||||
|
&QWidget::customContextMenuRequested,
|
||||||
|
this,
|
||||||
|
[this](const QPoint& pos) { showTreeContextMenu(pos); });
|
||||||
|
connect(m_profilesTree,
|
||||||
|
&ProfilesTreeWidget::itemsDropped,
|
||||||
|
this,
|
||||||
|
[this]() { persistFolderAssignmentsFromTree(); });
|
||||||
|
|
||||||
connect(m_newButton, &QPushButton::clicked, this, [this]() { createProfile(); });
|
connect(m_newButton, &QPushButton::clicked, this, [this]() { createProfile(); });
|
||||||
connect(m_editButton, &QPushButton::clicked, this, [this]() { editSelectedProfile(); });
|
connect(m_editButton, &QPushButton::clicked, this, [this]() { editSelectedProfile(); });
|
||||||
connect(m_deleteButton, &QPushButton::clicked, this, [this]() { deleteSelectedProfile(); });
|
connect(m_deleteButton, &QPushButton::clicked, this, [this]() { deleteSelectedProfile(); });
|
||||||
}
|
}
|
||||||
|
|
||||||
void ProfilesWindow::loadProfiles(const QString& query)
|
void ProfilesWindow::loadProfiles()
|
||||||
{
|
{
|
||||||
m_profilesList->clear();
|
m_profilesTree->clear();
|
||||||
m_profileCache.clear();
|
m_profileCache.clear();
|
||||||
|
|
||||||
const std::vector<Profile> profiles = m_repository->listProfiles(query);
|
const QString query = m_searchBox == nullptr ? QString() : m_searchBox->text();
|
||||||
|
const std::vector<Profile> profiles = m_repository->listProfiles(query, selectedSortOrder());
|
||||||
if (!m_repository->lastError().isEmpty()) {
|
if (!m_repository->lastError().isEmpty()) {
|
||||||
QMessageBox::warning(this,
|
QMessageBox::warning(this,
|
||||||
QStringLiteral("Load Profiles"),
|
QStringLiteral("Load Profiles"),
|
||||||
@@ -114,44 +264,522 @@ void ProfilesWindow::loadProfiles(const QString& query)
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
updateTagFilterOptions(profiles);
|
||||||
|
|
||||||
|
const QString protocolFilter = selectedProtocolFilter();
|
||||||
|
const QString tagFilter = selectedTagFilter();
|
||||||
|
const bool folderView = isFolderViewEnabled();
|
||||||
|
|
||||||
|
m_profilesTree->setDragEnabled(folderView);
|
||||||
|
m_profilesTree->setAcceptDrops(folderView);
|
||||||
|
m_profilesTree->viewport()->setAcceptDrops(folderView);
|
||||||
|
m_profilesTree->setDropIndicatorShown(folderView);
|
||||||
|
m_profilesTree->setDragDropMode(folderView ? QAbstractItemView::InternalMove
|
||||||
|
: QAbstractItemView::NoDragDrop);
|
||||||
|
|
||||||
|
std::vector<Profile> filteredProfiles;
|
||||||
|
filteredProfiles.reserve(profiles.size());
|
||||||
for (const Profile& profile : profiles) {
|
for (const Profile& profile : profiles) {
|
||||||
auto* item = new QListWidgetItem(formatProfileListItem(profile), m_profilesList);
|
if (!protocolFilter.isEmpty()
|
||||||
item->setData(Qt::UserRole, QVariant::fromValue(profile.id));
|
&& profile.protocol.compare(protocolFilter, Qt::CaseInsensitive) != 0) {
|
||||||
const QString identity = [&profile]() {
|
continue;
|
||||||
if (profile.protocol.compare(QStringLiteral("RDP"), Qt::CaseInsensitive) == 0
|
|
||||||
&& !profile.domain.trimmed().isEmpty()) {
|
|
||||||
return QStringLiteral("%1\\%2").arg(profile.domain.trimmed(),
|
|
||||||
profile.username.trimmed().isEmpty()
|
|
||||||
? QStringLiteral("<none>")
|
|
||||||
: profile.username.trimmed());
|
|
||||||
}
|
|
||||||
return profile.username.isEmpty() ? QStringLiteral("<none>") : profile.username;
|
|
||||||
}();
|
|
||||||
QString tooltip = QStringLiteral("%1://%2@%3:%4\nAuth: %5")
|
|
||||||
.arg(profile.protocol,
|
|
||||||
identity,
|
|
||||||
profile.host,
|
|
||||||
QString::number(profile.port),
|
|
||||||
profile.authMode);
|
|
||||||
if (profile.protocol.compare(QStringLiteral("SSH"), Qt::CaseInsensitive) == 0) {
|
|
||||||
tooltip += QStringLiteral("\nKnown Hosts: %1").arg(profile.knownHostsPolicy);
|
|
||||||
} else if (profile.protocol.compare(QStringLiteral("RDP"), Qt::CaseInsensitive) == 0) {
|
|
||||||
tooltip += QStringLiteral("\nRDP Security: %1\nRDP Performance: %2")
|
|
||||||
.arg(profile.rdpSecurityMode, profile.rdpPerformanceProfile);
|
|
||||||
}
|
}
|
||||||
item->setToolTip(tooltip);
|
if (!tagFilter.isEmpty() && !profileHasTag(profile, tagFilter)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
filteredProfiles.push_back(profile);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (folderView) {
|
||||||
|
std::map<QString, QTreeWidgetItem*> folderNodes;
|
||||||
|
const std::vector<QString> explicitFolders = m_repository->listFolders();
|
||||||
|
for (const QString& folderPath : explicitFolders) {
|
||||||
|
if (query.trimmed().isEmpty()
|
||||||
|
|| folderPath.contains(query.trimmed(), Qt::CaseInsensitive)) {
|
||||||
|
upsertFolderNode(splitFolderPath(folderPath), folderNodes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const Profile& profile : filteredProfiles) {
|
||||||
|
QTreeWidgetItem* parent = nullptr;
|
||||||
|
const QStringList folderParts = splitFolderPath(profile.folderPath);
|
||||||
|
if (!folderParts.isEmpty()) {
|
||||||
|
parent = upsertFolderNode(folderParts, folderNodes);
|
||||||
|
}
|
||||||
|
addProfileNode(parent, profile);
|
||||||
|
}
|
||||||
|
m_profilesTree->expandAll();
|
||||||
|
} else {
|
||||||
|
for (const Profile& profile : filteredProfiles) {
|
||||||
|
addProfileNode(nullptr, profile);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ProfileSortOrder ProfilesWindow::selectedSortOrder() const
|
||||||
|
{
|
||||||
|
if (m_sortBox == nullptr) {
|
||||||
|
return ProfileSortOrder::NameAsc;
|
||||||
|
}
|
||||||
|
|
||||||
|
const QVariant value = m_sortBox->currentData();
|
||||||
|
if (!value.isValid()) {
|
||||||
|
return ProfileSortOrder::NameAsc;
|
||||||
|
}
|
||||||
|
|
||||||
|
const int orderValue = value.toInt();
|
||||||
|
switch (static_cast<ProfileSortOrder>(orderValue)) {
|
||||||
|
case ProfileSortOrder::ProtocolAsc:
|
||||||
|
return ProfileSortOrder::ProtocolAsc;
|
||||||
|
case ProfileSortOrder::HostAsc:
|
||||||
|
return ProfileSortOrder::HostAsc;
|
||||||
|
case ProfileSortOrder::NameAsc:
|
||||||
|
default:
|
||||||
|
return ProfileSortOrder::NameAsc;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ProfilesWindow::isFolderViewEnabled() const
|
||||||
|
{
|
||||||
|
if (m_viewModeBox == nullptr) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return m_viewModeBox->currentText().compare(QStringLiteral("Folders"), Qt::CaseInsensitive)
|
||||||
|
== 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
QString ProfilesWindow::selectedProtocolFilter() const
|
||||||
|
{
|
||||||
|
if (m_protocolFilterBox == nullptr) {
|
||||||
|
return QString();
|
||||||
|
}
|
||||||
|
|
||||||
|
const QString selected = m_protocolFilterBox->currentText().trimmed();
|
||||||
|
if (selected.compare(QStringLiteral("All"), Qt::CaseInsensitive) == 0) {
|
||||||
|
return QString();
|
||||||
|
}
|
||||||
|
return selected;
|
||||||
|
}
|
||||||
|
|
||||||
|
QString ProfilesWindow::selectedTagFilter() const
|
||||||
|
{
|
||||||
|
if (m_tagFilterBox == nullptr) {
|
||||||
|
return QString();
|
||||||
|
}
|
||||||
|
|
||||||
|
const QString selected = m_tagFilterBox->currentText().trimmed();
|
||||||
|
if (selected.compare(QStringLiteral("All"), Qt::CaseInsensitive) == 0) {
|
||||||
|
return QString();
|
||||||
|
}
|
||||||
|
return selected;
|
||||||
|
}
|
||||||
|
|
||||||
|
void ProfilesWindow::updateTagFilterOptions(const std::vector<Profile>& profiles)
|
||||||
|
{
|
||||||
|
if (m_tagFilterBox == nullptr) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const QString previousSelection = m_tagFilterBox->currentText().trimmed();
|
||||||
|
QString desiredSelection = previousSelection;
|
||||||
|
if (!m_pendingTagFilterPreference.trimmed().isEmpty()) {
|
||||||
|
desiredSelection = m_pendingTagFilterPreference.trimmed();
|
||||||
|
}
|
||||||
|
QSet<QString> seen;
|
||||||
|
QStringList tags;
|
||||||
|
for (const Profile& profile : profiles) {
|
||||||
|
const QStringList splitTags =
|
||||||
|
profile.tags.split(QChar::fromLatin1(','), Qt::SkipEmptyParts);
|
||||||
|
for (const QString& rawTag : splitTags) {
|
||||||
|
const QString tag = rawTag.trimmed();
|
||||||
|
if (tag.isEmpty()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const QString dedupeKey = tag.toLower();
|
||||||
|
if (seen.contains(dedupeKey)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
seen.insert(dedupeKey);
|
||||||
|
tags.push_back(tag);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tags.sort(Qt::CaseInsensitive);
|
||||||
|
|
||||||
|
const QSignalBlocker blocker(m_tagFilterBox);
|
||||||
|
m_tagFilterBox->clear();
|
||||||
|
m_tagFilterBox->addItem(QStringLiteral("All"));
|
||||||
|
for (const QString& tag : tags) {
|
||||||
|
m_tagFilterBox->addItem(tag);
|
||||||
|
}
|
||||||
|
|
||||||
|
int restoredIndex = m_tagFilterBox->findText(desiredSelection, Qt::MatchFixedString);
|
||||||
|
if (restoredIndex < 0) {
|
||||||
|
restoredIndex = 0;
|
||||||
|
}
|
||||||
|
m_tagFilterBox->setCurrentIndex(restoredIndex);
|
||||||
|
m_pendingTagFilterPreference.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
QTreeWidgetItem* ProfilesWindow::upsertFolderNode(const QStringList& folderParts,
|
||||||
|
std::map<QString, QTreeWidgetItem*>& folderNodes)
|
||||||
|
{
|
||||||
|
QString currentPath;
|
||||||
|
QTreeWidgetItem* parent = nullptr;
|
||||||
|
|
||||||
|
for (const QString& rawPart : folderParts) {
|
||||||
|
const QString part = rawPart.trimmed();
|
||||||
|
if (part.isEmpty()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
currentPath = currentPath.isEmpty() ? part
|
||||||
|
: QStringLiteral("%1/%2").arg(currentPath, part);
|
||||||
|
const auto existing = folderNodes.find(currentPath);
|
||||||
|
if (existing != folderNodes.end()) {
|
||||||
|
parent = existing->second;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
auto* folderItem = parent == nullptr ? new QTreeWidgetItem(m_profilesTree)
|
||||||
|
: new QTreeWidgetItem(parent);
|
||||||
|
folderItem->setText(0, part);
|
||||||
|
folderItem->setIcon(0, style()->standardIcon(QStyle::SP_DirIcon));
|
||||||
|
folderItem->setData(0, kFolderPathRole, currentPath);
|
||||||
|
folderItem->setFlags((folderItem->flags() | Qt::ItemIsDropEnabled) & ~Qt::ItemIsDragEnabled);
|
||||||
|
folderNodes.insert_or_assign(currentPath, folderItem);
|
||||||
|
parent = folderItem;
|
||||||
|
}
|
||||||
|
|
||||||
|
return parent;
|
||||||
|
}
|
||||||
|
|
||||||
|
void ProfilesWindow::addProfileNode(QTreeWidgetItem* parent, const Profile& profile)
|
||||||
|
{
|
||||||
|
auto* item = parent == nullptr ? new QTreeWidgetItem(m_profilesTree) : new QTreeWidgetItem(parent);
|
||||||
|
item->setText(0, profile.name);
|
||||||
|
item->setText(1, profile.protocol);
|
||||||
|
item->setText(2, QStringLiteral("%1:%2").arg(profile.host, QString::number(profile.port)));
|
||||||
|
item->setText(3, profile.tags);
|
||||||
|
item->setIcon(0, style()->standardIcon(QStyle::SP_ComputerIcon));
|
||||||
|
item->setData(0, kProfileIdRole, QVariant::fromValue(profile.id));
|
||||||
|
item->setData(0, kFolderPathRole, normalizeFolderPathForView(profile.folderPath));
|
||||||
|
item->setFlags((item->flags() | Qt::ItemIsDragEnabled | Qt::ItemIsSelectable) & ~Qt::ItemIsDropEnabled);
|
||||||
|
|
||||||
|
const QString identity = [&profile]() {
|
||||||
|
if (profile.protocol.compare(QStringLiteral("RDP"), Qt::CaseInsensitive) == 0
|
||||||
|
&& !profile.domain.trimmed().isEmpty()) {
|
||||||
|
return QStringLiteral("%1\\%2").arg(profile.domain.trimmed(),
|
||||||
|
profile.username.trimmed().isEmpty()
|
||||||
|
? QStringLiteral("<none>")
|
||||||
|
: profile.username.trimmed());
|
||||||
|
}
|
||||||
|
return profile.username.trimmed().isEmpty() ? QStringLiteral("<none>")
|
||||||
|
: profile.username.trimmed();
|
||||||
|
}();
|
||||||
|
|
||||||
|
QString tooltip = QStringLiteral("%1://%2@%3:%4\nAuth: %5")
|
||||||
|
.arg(profile.protocol,
|
||||||
|
identity,
|
||||||
|
profile.host,
|
||||||
|
QString::number(profile.port),
|
||||||
|
profile.authMode);
|
||||||
|
if (profile.protocol.compare(QStringLiteral("SSH"), Qt::CaseInsensitive) == 0) {
|
||||||
|
tooltip += QStringLiteral("\nKnown Hosts: %1").arg(profile.knownHostsPolicy);
|
||||||
|
} else if (profile.protocol.compare(QStringLiteral("RDP"), Qt::CaseInsensitive) == 0) {
|
||||||
|
tooltip += QStringLiteral("\nRDP Security: %1\nRDP Performance: %2")
|
||||||
|
.arg(profile.rdpSecurityMode, profile.rdpPerformanceProfile);
|
||||||
|
}
|
||||||
|
if (!profile.folderPath.trimmed().isEmpty()) {
|
||||||
|
tooltip += QStringLiteral("\nFolder: %1").arg(profile.folderPath.trimmed());
|
||||||
|
}
|
||||||
|
if (!profile.tags.trimmed().isEmpty()) {
|
||||||
|
tooltip += QStringLiteral("\nTags: %1").arg(profile.tags);
|
||||||
|
}
|
||||||
|
|
||||||
|
item->setToolTip(0, tooltip);
|
||||||
|
item->setToolTip(1, tooltip);
|
||||||
|
item->setToolTip(2, tooltip);
|
||||||
|
item->setToolTip(3, tooltip);
|
||||||
|
|
||||||
|
m_profileCache.insert_or_assign(profile.id, profile);
|
||||||
|
}
|
||||||
|
|
||||||
|
QString ProfilesWindow::folderPathForItem(const QTreeWidgetItem* item) const
|
||||||
|
{
|
||||||
|
if (item == nullptr) {
|
||||||
|
return QString();
|
||||||
|
}
|
||||||
|
|
||||||
|
const QVariant idValue = item->data(0, kProfileIdRole);
|
||||||
|
if (idValue.isValid()) {
|
||||||
|
const qint64 id = idValue.toLongLong();
|
||||||
|
const auto cacheIt = m_profileCache.find(id);
|
||||||
|
if (cacheIt != m_profileCache.end()) {
|
||||||
|
return normalizeFolderPathForView(cacheIt->second.folderPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalizeFolderPathForView(item->data(0, kFolderPathRole).toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
void ProfilesWindow::showTreeContextMenu(const QPoint& pos)
|
||||||
|
{
|
||||||
|
if (m_profilesTree == nullptr) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
QTreeWidgetItem* item = m_profilesTree->itemAt(pos);
|
||||||
|
if (item != nullptr) {
|
||||||
|
m_profilesTree->setCurrentItem(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
const QString contextFolder = folderPathForItem(item);
|
||||||
|
const bool isProfileItem = item != nullptr && item->data(0, kProfileIdRole).isValid();
|
||||||
|
|
||||||
|
QMenu menu(this);
|
||||||
|
QAction* newConnectionAction = menu.addAction(QStringLiteral("New Connection"));
|
||||||
|
QAction* newFolderAction = menu.addAction(QStringLiteral("New Folder"));
|
||||||
|
QAction* connectAction = nullptr;
|
||||||
|
QAction* editAction = nullptr;
|
||||||
|
QAction* deleteAction = nullptr;
|
||||||
|
|
||||||
|
if (isProfileItem) {
|
||||||
|
menu.addSeparator();
|
||||||
|
connectAction = menu.addAction(QStringLiteral("Connect"));
|
||||||
|
editAction = menu.addAction(QStringLiteral("Edit"));
|
||||||
|
deleteAction = menu.addAction(QStringLiteral("Delete"));
|
||||||
|
}
|
||||||
|
|
||||||
|
QAction* chosen = menu.exec(m_profilesTree->viewport()->mapToGlobal(pos));
|
||||||
|
if (chosen == nullptr) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (chosen == newConnectionAction) {
|
||||||
|
createProfile(contextFolder);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (chosen == newFolderAction) {
|
||||||
|
createFolderInContext(contextFolder);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isProfileItem && chosen == connectAction) {
|
||||||
|
openSessionForItem(item);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isProfileItem && chosen == editAction) {
|
||||||
|
editSelectedProfile();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isProfileItem && chosen == deleteAction) {
|
||||||
|
deleteSelectedProfile();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void ProfilesWindow::createFolderInContext(const QString& baseFolderPath)
|
||||||
|
{
|
||||||
|
bool accepted = false;
|
||||||
|
const QString folderName = QInputDialog::getText(
|
||||||
|
this,
|
||||||
|
QStringLiteral("New Folder"),
|
||||||
|
QStringLiteral("Folder name"),
|
||||||
|
QLineEdit::Normal,
|
||||||
|
QString(),
|
||||||
|
&accepted);
|
||||||
|
if (!accepted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const QString normalized = joinFolderPath(baseFolderPath, folderName);
|
||||||
|
if (normalized.isEmpty()) {
|
||||||
|
QMessageBox::information(this,
|
||||||
|
QStringLiteral("New Folder"),
|
||||||
|
QStringLiteral("Folder name is required."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!m_repository->createFolder(normalized)) {
|
||||||
|
QMessageBox::warning(this,
|
||||||
|
QStringLiteral("New Folder"),
|
||||||
|
QStringLiteral("Failed to create folder: %1")
|
||||||
|
.arg(m_repository->lastError().isEmpty()
|
||||||
|
? QStringLiteral("unknown error")
|
||||||
|
: m_repository->lastError()));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
loadProfiles();
|
||||||
|
}
|
||||||
|
|
||||||
|
void ProfilesWindow::persistFolderAssignmentsFromTree()
|
||||||
|
{
|
||||||
|
if (!isFolderViewEnabled() || m_profilesTree == nullptr) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::unordered_map<qint64, QString> assignments;
|
||||||
|
const int topLevelCount = m_profilesTree->topLevelItemCount();
|
||||||
|
for (int index = 0; index < topLevelCount; ++index) {
|
||||||
|
collectProfileAssignments(m_profilesTree->topLevelItem(index), QString(), assignments);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool hadErrors = false;
|
||||||
|
int updatedCount = 0;
|
||||||
|
|
||||||
|
for (const auto& [id, newFolderPath] : assignments) {
|
||||||
|
auto cacheIt = m_profileCache.find(id);
|
||||||
|
Profile profile;
|
||||||
|
if (cacheIt != m_profileCache.end()) {
|
||||||
|
profile = cacheIt->second;
|
||||||
|
} else {
|
||||||
|
const std::optional<Profile> fetched = m_repository->getProfile(id);
|
||||||
|
if (!fetched.has_value()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
profile = fetched.value();
|
||||||
|
}
|
||||||
|
|
||||||
|
const QString existing = normalizeFolderPathForView(profile.folderPath);
|
||||||
|
const QString target = normalizeFolderPathForView(newFolderPath);
|
||||||
|
if (existing == target) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
profile.folderPath = target;
|
||||||
|
if (!target.isEmpty()) {
|
||||||
|
m_repository->createFolder(target);
|
||||||
|
}
|
||||||
|
if (!m_repository->updateProfile(profile)) {
|
||||||
|
hadErrors = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
m_profileCache.insert_or_assign(profile.id, profile);
|
m_profileCache.insert_or_assign(profile.id, profile);
|
||||||
|
++updatedCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hadErrors) {
|
||||||
|
QMessageBox::warning(this,
|
||||||
|
QStringLiteral("Move Profile"),
|
||||||
|
QStringLiteral("One or more profile moves could not be saved."));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updatedCount > 0 || hadErrors) {
|
||||||
|
loadProfiles();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void ProfilesWindow::collectProfileAssignments(
|
||||||
|
const QTreeWidgetItem* item,
|
||||||
|
const QString& parentFolderPath,
|
||||||
|
std::unordered_map<qint64, QString>& assignments) const
|
||||||
|
{
|
||||||
|
if (item == nullptr) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const QVariant idValue = item->data(0, kProfileIdRole);
|
||||||
|
if (idValue.isValid()) {
|
||||||
|
assignments.insert_or_assign(idValue.toLongLong(), normalizeFolderPathForView(parentFolderPath));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const QString folderPath = joinFolderPath(parentFolderPath, item->text(0));
|
||||||
|
const int childCount = item->childCount();
|
||||||
|
for (int index = 0; index < childCount; ++index) {
|
||||||
|
collectProfileAssignments(item->child(index), folderPath, assignments);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void ProfilesWindow::loadUiPreferences()
|
||||||
|
{
|
||||||
|
QSettings settings;
|
||||||
|
const QString search = settings.value(QStringLiteral("profiles/searchText")).toString();
|
||||||
|
const QString viewMode =
|
||||||
|
settings.value(QStringLiteral("profiles/viewMode"), QStringLiteral("List")).toString();
|
||||||
|
const int sortValue = settings
|
||||||
|
.value(QStringLiteral("profiles/sortOrder"),
|
||||||
|
static_cast<int>(ProfileSortOrder::NameAsc))
|
||||||
|
.toInt();
|
||||||
|
const QString protocolFilter =
|
||||||
|
settings.value(QStringLiteral("profiles/protocolFilter"), QStringLiteral("All")).toString();
|
||||||
|
const QString tagFilter =
|
||||||
|
settings.value(QStringLiteral("profiles/tagFilter"), QStringLiteral("All")).toString();
|
||||||
|
m_pendingTagFilterPreference = tagFilter.trimmed();
|
||||||
|
|
||||||
|
if (m_searchBox != nullptr) {
|
||||||
|
const QSignalBlocker blocker(m_searchBox);
|
||||||
|
m_searchBox->setText(search);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (m_viewModeBox != nullptr) {
|
||||||
|
int found = m_viewModeBox->findText(viewMode, Qt::MatchFixedString);
|
||||||
|
if (found < 0) {
|
||||||
|
found = 0;
|
||||||
|
}
|
||||||
|
const QSignalBlocker blocker(m_viewModeBox);
|
||||||
|
m_viewModeBox->setCurrentIndex(found);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (m_sortBox != nullptr) {
|
||||||
|
int found = m_sortBox->findData(sortValue);
|
||||||
|
if (found < 0) {
|
||||||
|
found = 0;
|
||||||
|
}
|
||||||
|
const QSignalBlocker blocker(m_sortBox);
|
||||||
|
m_sortBox->setCurrentIndex(found);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (m_protocolFilterBox != nullptr) {
|
||||||
|
int found = m_protocolFilterBox->findText(protocolFilter, Qt::MatchFixedString);
|
||||||
|
if (found < 0) {
|
||||||
|
found = 0;
|
||||||
|
}
|
||||||
|
const QSignalBlocker blocker(m_protocolFilterBox);
|
||||||
|
m_protocolFilterBox->setCurrentIndex(found);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (m_tagFilterBox != nullptr) {
|
||||||
|
int found = m_tagFilterBox->findText(tagFilter, Qt::MatchFixedString);
|
||||||
|
if (found < 0) {
|
||||||
|
found = 0;
|
||||||
|
}
|
||||||
|
const QSignalBlocker blocker(m_tagFilterBox);
|
||||||
|
m_tagFilterBox->setCurrentIndex(found);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void ProfilesWindow::saveUiPreferences() const
|
||||||
|
{
|
||||||
|
QSettings settings;
|
||||||
|
if (m_searchBox != nullptr) {
|
||||||
|
settings.setValue(QStringLiteral("profiles/searchText"), m_searchBox->text());
|
||||||
|
}
|
||||||
|
if (m_viewModeBox != nullptr) {
|
||||||
|
settings.setValue(QStringLiteral("profiles/viewMode"), m_viewModeBox->currentText());
|
||||||
|
}
|
||||||
|
if (m_sortBox != nullptr) {
|
||||||
|
settings.setValue(QStringLiteral("profiles/sortOrder"), m_sortBox->currentData());
|
||||||
|
}
|
||||||
|
if (m_protocolFilterBox != nullptr) {
|
||||||
|
settings.setValue(QStringLiteral("profiles/protocolFilter"), m_protocolFilterBox->currentText());
|
||||||
|
}
|
||||||
|
if (m_tagFilterBox != nullptr) {
|
||||||
|
settings.setValue(QStringLiteral("profiles/tagFilter"), m_tagFilterBox->currentText());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
std::optional<Profile> ProfilesWindow::selectedProfile() const
|
std::optional<Profile> ProfilesWindow::selectedProfile() const
|
||||||
{
|
{
|
||||||
QListWidgetItem* item = m_profilesList->currentItem();
|
QTreeWidgetItem* item = m_profilesTree->currentItem();
|
||||||
if (item == nullptr) {
|
if (item == nullptr) {
|
||||||
return std::nullopt;
|
return std::nullopt;
|
||||||
}
|
}
|
||||||
|
|
||||||
const QVariant value = item->data(Qt::UserRole);
|
const QVariant value = item->data(0, kProfileIdRole);
|
||||||
if (!value.isValid()) {
|
if (!value.isValid()) {
|
||||||
return std::nullopt;
|
return std::nullopt;
|
||||||
}
|
}
|
||||||
@@ -165,16 +793,22 @@ std::optional<Profile> ProfilesWindow::selectedProfile() const
|
|||||||
return m_repository->getProfile(id);
|
return m_repository->getProfile(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
void ProfilesWindow::createProfile()
|
void ProfilesWindow::createProfile(const QString& defaultFolderPath)
|
||||||
{
|
{
|
||||||
ProfileDialog dialog(this);
|
ProfileDialog dialog(this);
|
||||||
dialog.setDialogTitle(QStringLiteral("New Profile"));
|
dialog.setDialogTitle(QStringLiteral("New Profile"));
|
||||||
|
dialog.setDefaultFolderPath(defaultFolderPath);
|
||||||
|
|
||||||
if (dialog.exec() != QDialog::Accepted) {
|
if (dialog.exec() != QDialog::Accepted) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!m_repository->createProfile(dialog.profile()).has_value()) {
|
const Profile newProfile = dialog.profile();
|
||||||
|
if (!newProfile.folderPath.trimmed().isEmpty()) {
|
||||||
|
m_repository->createFolder(newProfile.folderPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!m_repository->createProfile(newProfile).has_value()) {
|
||||||
QMessageBox::warning(this,
|
QMessageBox::warning(this,
|
||||||
QStringLiteral("Create Profile"),
|
QStringLiteral("Create Profile"),
|
||||||
QStringLiteral("Failed to create profile: %1")
|
QStringLiteral("Failed to create profile: %1")
|
||||||
@@ -184,7 +818,7 @@ void ProfilesWindow::createProfile()
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
loadProfiles(m_searchBox->text());
|
loadProfiles();
|
||||||
}
|
}
|
||||||
|
|
||||||
void ProfilesWindow::editSelectedProfile()
|
void ProfilesWindow::editSelectedProfile()
|
||||||
@@ -207,6 +841,9 @@ void ProfilesWindow::editSelectedProfile()
|
|||||||
|
|
||||||
Profile updated = dialog.profile();
|
Profile updated = dialog.profile();
|
||||||
updated.id = selected->id;
|
updated.id = selected->id;
|
||||||
|
if (!updated.folderPath.trimmed().isEmpty()) {
|
||||||
|
m_repository->createFolder(updated.folderPath);
|
||||||
|
}
|
||||||
|
|
||||||
if (!m_repository->updateProfile(updated)) {
|
if (!m_repository->updateProfile(updated)) {
|
||||||
QMessageBox::warning(this,
|
QMessageBox::warning(this,
|
||||||
@@ -218,7 +855,7 @@ void ProfilesWindow::editSelectedProfile()
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
loadProfiles(m_searchBox->text());
|
loadProfiles();
|
||||||
}
|
}
|
||||||
|
|
||||||
void ProfilesWindow::deleteSelectedProfile()
|
void ProfilesWindow::deleteSelectedProfile()
|
||||||
@@ -252,17 +889,18 @@ void ProfilesWindow::deleteSelectedProfile()
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
loadProfiles(m_searchBox->text());
|
loadProfiles();
|
||||||
}
|
}
|
||||||
|
|
||||||
void ProfilesWindow::openSessionForItem(QListWidgetItem* item)
|
void ProfilesWindow::openSessionForItem(QTreeWidgetItem* item)
|
||||||
{
|
{
|
||||||
if (item == nullptr) {
|
if (item == nullptr) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const QVariant value = item->data(Qt::UserRole);
|
const QVariant value = item->data(0, kProfileIdRole);
|
||||||
if (!value.isValid()) {
|
if (!value.isValid()) {
|
||||||
|
item->setExpanded(!item->isExpanded());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -278,16 +916,17 @@ void ProfilesWindow::openSessionForItem(QListWidgetItem* item)
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (m_sessionWindow.isNull()) {
|
emit connectRequested(profile.value());
|
||||||
m_sessionWindow = new SessionWindow(profile.value());
|
}
|
||||||
m_sessionWindow->setAttribute(Qt::WA_DeleteOnClose);
|
|
||||||
connect(m_sessionWindow, &QObject::destroyed, this, [this]() { m_sessionWindow = nullptr; });
|
void ProfilesWindow::createProfileInCurrentContext()
|
||||||
} else {
|
{
|
||||||
m_sessionWindow->openProfile(profile.value());
|
const QString folderPath = folderPathForItem(m_profilesTree->currentItem());
|
||||||
}
|
createProfile(folderPath);
|
||||||
|
}
|
||||||
m_sessionWindow->setWindowState(m_sessionWindow->windowState() & ~Qt::WindowMinimized);
|
|
||||||
m_sessionWindow->show();
|
void ProfilesWindow::createFolderInCurrentContext()
|
||||||
m_sessionWindow->raise();
|
{
|
||||||
m_sessionWindow->activateWindow();
|
const QString folderPath = folderPathForItem(m_profilesTree->currentItem());
|
||||||
|
createFolderInContext(folderPath);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,22 +3,26 @@
|
|||||||
|
|
||||||
#include "profile_repository.h"
|
#include "profile_repository.h"
|
||||||
|
|
||||||
#include <QMainWindow>
|
#include <QWidget>
|
||||||
#include <QString>
|
#include <QString>
|
||||||
|
#include <QStringList>
|
||||||
#include <QtGlobal>
|
#include <QtGlobal>
|
||||||
|
|
||||||
#include <memory>
|
#include <memory>
|
||||||
|
#include <map>
|
||||||
#include <optional>
|
#include <optional>
|
||||||
#include <QPointer>
|
#include <vector>
|
||||||
#include <unordered_map>
|
#include <unordered_map>
|
||||||
|
|
||||||
class QListWidget;
|
class QTreeWidget;
|
||||||
class QListWidgetItem;
|
class QTreeWidgetItem;
|
||||||
class QLineEdit;
|
class QLineEdit;
|
||||||
class QPushButton;
|
class QPushButton;
|
||||||
class SessionWindow;
|
class QComboBox;
|
||||||
|
class QPoint;
|
||||||
|
class ProfilesTreeWidget;
|
||||||
|
|
||||||
class ProfilesWindow : public QMainWindow
|
class ProfilesWindow : public QWidget
|
||||||
{
|
{
|
||||||
Q_OBJECT
|
Q_OBJECT
|
||||||
|
|
||||||
@@ -26,23 +30,50 @@ public:
|
|||||||
explicit ProfilesWindow(QWidget* parent = nullptr);
|
explicit ProfilesWindow(QWidget* parent = nullptr);
|
||||||
~ProfilesWindow() override;
|
~ProfilesWindow() override;
|
||||||
|
|
||||||
|
void createProfileInCurrentContext();
|
||||||
|
void createFolderInCurrentContext();
|
||||||
|
|
||||||
|
signals:
|
||||||
|
void connectRequested(const Profile& profile);
|
||||||
|
|
||||||
private:
|
private:
|
||||||
QLineEdit* m_searchBox;
|
QLineEdit* m_searchBox;
|
||||||
QListWidget* m_profilesList;
|
QComboBox* m_viewModeBox;
|
||||||
|
QComboBox* m_sortBox;
|
||||||
|
QComboBox* m_protocolFilterBox;
|
||||||
|
QComboBox* m_tagFilterBox;
|
||||||
|
ProfilesTreeWidget* m_profilesTree;
|
||||||
QPushButton* m_newButton;
|
QPushButton* m_newButton;
|
||||||
QPushButton* m_editButton;
|
QPushButton* m_editButton;
|
||||||
QPushButton* m_deleteButton;
|
QPushButton* m_deleteButton;
|
||||||
QPointer<SessionWindow> m_sessionWindow;
|
|
||||||
std::unique_ptr<ProfileRepository> m_repository;
|
std::unique_ptr<ProfileRepository> m_repository;
|
||||||
std::unordered_map<qint64, Profile> m_profileCache;
|
std::unordered_map<qint64, Profile> m_profileCache;
|
||||||
|
QString m_pendingTagFilterPreference;
|
||||||
|
|
||||||
void setupUi();
|
void setupUi();
|
||||||
void loadProfiles(const QString& query = QString());
|
void loadProfiles();
|
||||||
|
ProfileSortOrder selectedSortOrder() const;
|
||||||
|
bool isFolderViewEnabled() const;
|
||||||
|
QString selectedProtocolFilter() const;
|
||||||
|
QString selectedTagFilter() const;
|
||||||
|
void updateTagFilterOptions(const std::vector<Profile>& profiles);
|
||||||
|
QTreeWidgetItem* upsertFolderNode(const QStringList& folderParts,
|
||||||
|
std::map<QString, QTreeWidgetItem*>& folderNodes);
|
||||||
|
void addProfileNode(QTreeWidgetItem* parent, const Profile& profile);
|
||||||
|
QString folderPathForItem(const QTreeWidgetItem* item) const;
|
||||||
|
void showTreeContextMenu(const QPoint& pos);
|
||||||
|
void createFolderInContext(const QString& baseFolderPath);
|
||||||
|
void persistFolderAssignmentsFromTree();
|
||||||
|
void collectProfileAssignments(const QTreeWidgetItem* item,
|
||||||
|
const QString& parentFolderPath,
|
||||||
|
std::unordered_map<qint64, QString>& assignments) const;
|
||||||
|
void loadUiPreferences();
|
||||||
|
void saveUiPreferences() const;
|
||||||
std::optional<Profile> selectedProfile() const;
|
std::optional<Profile> selectedProfile() const;
|
||||||
void createProfile();
|
void createProfile(const QString& defaultFolderPath = QString());
|
||||||
void editSelectedProfile();
|
void editSelectedProfile();
|
||||||
void deleteSelectedProfile();
|
void deleteSelectedProfile();
|
||||||
void openSessionForItem(QListWidgetItem* item);
|
void openSessionForItem(QTreeWidgetItem* item);
|
||||||
};
|
};
|
||||||
|
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
#include "rdp_display_widget.h"
|
#include "rdp_display_widget.h"
|
||||||
|
|
||||||
|
#include <QCursor>
|
||||||
#include <QKeyEvent>
|
#include <QKeyEvent>
|
||||||
#include <QMouseEvent>
|
#include <QMouseEvent>
|
||||||
#include <QPainter>
|
#include <QPainter>
|
||||||
|
#include <QPixmap>
|
||||||
#include <QResizeEvent>
|
#include <QResizeEvent>
|
||||||
#include <QTimer>
|
#include <QTimer>
|
||||||
#include <QWheelEvent>
|
#include <QWheelEvent>
|
||||||
@@ -16,7 +18,7 @@ QSize sanitizeSize(const QSize& size)
|
|||||||
}
|
}
|
||||||
|
|
||||||
RdpDisplayWidget::RdpDisplayWidget(QWidget* parent)
|
RdpDisplayWidget::RdpDisplayWidget(QWidget* parent)
|
||||||
: QWidget(parent), m_remoteSize(1280, 720)
|
: QWidget(parent), m_remoteSize(1280, 720), m_cursorMode(CursorMode::Default)
|
||||||
{
|
{
|
||||||
setFocusPolicy(Qt::StrongFocus);
|
setFocusPolicy(Qt::StrongFocus);
|
||||||
setMouseTracking(true);
|
setMouseTracking(true);
|
||||||
@@ -61,6 +63,62 @@ void RdpDisplayWidget::clearFrame()
|
|||||||
update();
|
update();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void RdpDisplayWidget::setCursorImage(const QImage& image, const QPoint& hotspot)
|
||||||
|
{
|
||||||
|
m_cursorImage = image;
|
||||||
|
m_cursorHotspot = hotspot;
|
||||||
|
m_cursorMode = CursorMode::Custom;
|
||||||
|
applyCursor();
|
||||||
|
}
|
||||||
|
|
||||||
|
void RdpDisplayWidget::setCursorHidden()
|
||||||
|
{
|
||||||
|
m_cursorMode = CursorMode::Hidden;
|
||||||
|
applyCursor();
|
||||||
|
}
|
||||||
|
|
||||||
|
void RdpDisplayWidget::setCursorDefault()
|
||||||
|
{
|
||||||
|
m_cursorMode = CursorMode::Default;
|
||||||
|
applyCursor();
|
||||||
|
}
|
||||||
|
|
||||||
|
void RdpDisplayWidget::applyCursor()
|
||||||
|
{
|
||||||
|
if (m_cursorMode == CursorMode::Hidden) {
|
||||||
|
setCursor(Qt::BlankCursor);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (m_cursorMode == CursorMode::Default || m_cursorImage.isNull()) {
|
||||||
|
unsetCursor();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const QSize remote = effectiveRemoteSize();
|
||||||
|
const QRectF target = renderRect();
|
||||||
|
if (remote.isEmpty() || target.isEmpty()) {
|
||||||
|
setCursor(QCursor(QPixmap::fromImage(m_cursorImage),
|
||||||
|
m_cursorHotspot.x(),
|
||||||
|
m_cursorHotspot.y()));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const qreal scale = target.width() / remote.width();
|
||||||
|
QImage scaledImage = m_cursorImage;
|
||||||
|
if (!qFuzzyCompare(scale, 1.0)) {
|
||||||
|
scaledImage = m_cursorImage.scaled(
|
||||||
|
qMax(1, qRound(m_cursorImage.width() * scale)),
|
||||||
|
qMax(1, qRound(m_cursorImage.height() * scale)),
|
||||||
|
Qt::IgnoreAspectRatio,
|
||||||
|
Qt::SmoothTransformation);
|
||||||
|
}
|
||||||
|
|
||||||
|
const int hotX = qBound(0, qRound(m_cursorHotspot.x() * scale), scaledImage.width());
|
||||||
|
const int hotY = qBound(0, qRound(m_cursorHotspot.y() * scale), scaledImage.height());
|
||||||
|
setCursor(QCursor(QPixmap::fromImage(scaledImage), hotX, hotY));
|
||||||
|
}
|
||||||
|
|
||||||
void RdpDisplayWidget::paintEvent(QPaintEvent* event)
|
void RdpDisplayWidget::paintEvent(QPaintEvent* event)
|
||||||
{
|
{
|
||||||
Q_UNUSED(event);
|
Q_UNUSED(event);
|
||||||
@@ -84,14 +142,21 @@ void RdpDisplayWidget::resizeEvent(QResizeEvent* event)
|
|||||||
QWidget::resizeEvent(event);
|
QWidget::resizeEvent(event);
|
||||||
const QSize size = sanitizeSize(event->size());
|
const QSize size = sanitizeSize(event->size());
|
||||||
emit viewportSizeChanged(size.width(), size.height());
|
emit viewportSizeChanged(size.width(), size.height());
|
||||||
|
applyCursor();
|
||||||
}
|
}
|
||||||
|
|
||||||
void RdpDisplayWidget::keyPressEvent(QKeyEvent* event)
|
void RdpDisplayWidget::keyPressEvent(QKeyEvent* event)
|
||||||
{
|
{
|
||||||
if (event == nullptr || event->isAutoRepeat()) {
|
if (event == nullptr) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Auto-repeat presses must reach the remote server so it can perform
|
||||||
|
// its own typematic repeat, exactly as a physical keyboard held down
|
||||||
|
// would. Only release events filter out isAutoRepeat() (below), since
|
||||||
|
// Qt uses a synthetic release/press pair purely to normalize platform
|
||||||
|
// auto-repeat quirks -- forwarding that synthetic release would send a
|
||||||
|
// spurious key-up for a key that is still physically held.
|
||||||
emit keyInput(event->key(),
|
emit keyInput(event->key(),
|
||||||
event->nativeScanCode(),
|
event->nativeScanCode(),
|
||||||
event->text(),
|
event->text(),
|
||||||
@@ -114,6 +179,14 @@ void RdpDisplayWidget::keyReleaseEvent(QKeyEvent* event)
|
|||||||
event->accept();
|
event->accept();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool RdpDisplayWidget::focusNextPrevChild(bool next)
|
||||||
|
{
|
||||||
|
Q_UNUSED(next);
|
||||||
|
// Tab/Shift+Tab must reach keyPressEvent() and be forwarded to the
|
||||||
|
// remote session instead of moving focus to the next local widget.
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
void RdpDisplayWidget::mousePressEvent(QMouseEvent* event)
|
void RdpDisplayWidget::mousePressEvent(QMouseEvent* event)
|
||||||
{
|
{
|
||||||
if (event == nullptr) {
|
if (event == nullptr) {
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ public:
|
|||||||
void setFrame(const QImage& frame);
|
void setFrame(const QImage& frame);
|
||||||
void setRemoteDesktopSize(int width, int height);
|
void setRemoteDesktopSize(int width, int height);
|
||||||
void clearFrame();
|
void clearFrame();
|
||||||
|
void setCursorImage(const QImage& image, const QPoint& hotspot);
|
||||||
|
void setCursorHidden();
|
||||||
|
void setCursorDefault();
|
||||||
|
|
||||||
signals:
|
signals:
|
||||||
void keyInput(int key, quint32 nativeScanCode, const QString& text, bool pressed, int modifiers);
|
void keyInput(int key, quint32 nativeScanCode, const QString& text, bool pressed, int modifiers);
|
||||||
@@ -37,14 +40,25 @@ protected:
|
|||||||
void mouseReleaseEvent(QMouseEvent* event) override;
|
void mouseReleaseEvent(QMouseEvent* event) override;
|
||||||
void mouseMoveEvent(QMouseEvent* event) override;
|
void mouseMoveEvent(QMouseEvent* event) override;
|
||||||
void wheelEvent(QWheelEvent* event) override;
|
void wheelEvent(QWheelEvent* event) override;
|
||||||
|
bool focusNextPrevChild(bool next) override;
|
||||||
|
|
||||||
private:
|
private:
|
||||||
|
enum class CursorMode {
|
||||||
|
Default,
|
||||||
|
Custom,
|
||||||
|
Hidden,
|
||||||
|
};
|
||||||
|
|
||||||
QImage m_frame;
|
QImage m_frame;
|
||||||
QSize m_remoteSize;
|
QSize m_remoteSize;
|
||||||
|
QImage m_cursorImage;
|
||||||
|
QPoint m_cursorHotspot;
|
||||||
|
CursorMode m_cursorMode;
|
||||||
|
|
||||||
QRectF renderRect() const;
|
QRectF renderRect() const;
|
||||||
QPoint mapToRemote(const QPointF& pos) const;
|
QPoint mapToRemote(const QPointF& pos) const;
|
||||||
QSize effectiveRemoteSize() const;
|
QSize effectiveRemoteSize() const;
|
||||||
|
void applyCursor();
|
||||||
};
|
};
|
||||||
|
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -7,29 +7,74 @@
|
|||||||
|
|
||||||
#include <cmath>
|
#include <cmath>
|
||||||
#include <cstring>
|
#include <cstring>
|
||||||
|
#include <mutex>
|
||||||
|
#include <new>
|
||||||
#include <utility>
|
#include <utility>
|
||||||
|
|
||||||
#ifdef ORBITHUB_HAS_FREERDP
|
#ifdef ORBITHUB_HAS_FREERDP
|
||||||
#include <freerdp/channels/channels.h>
|
#include <freerdp/channels/channels.h>
|
||||||
|
#include <freerdp/channels/cliprdr.h>
|
||||||
#include <freerdp/channels/drdynvc.h>
|
#include <freerdp/channels/drdynvc.h>
|
||||||
#include <freerdp/channels/disp.h>
|
#include <freerdp/channels/disp.h>
|
||||||
|
#include <freerdp/client/cliprdr.h>
|
||||||
#include <freerdp/addin.h>
|
#include <freerdp/addin.h>
|
||||||
#include <freerdp/client/channels.h>
|
#include <freerdp/client/channels.h>
|
||||||
#include <freerdp/client/cmdline.h>
|
#include <freerdp/client/cmdline.h>
|
||||||
#include <freerdp/client/disp.h>
|
#include <freerdp/client/disp.h>
|
||||||
|
#include <freerdp/codec/color.h>
|
||||||
#include <freerdp/display.h>
|
#include <freerdp/display.h>
|
||||||
#include <freerdp/error.h>
|
#include <freerdp/error.h>
|
||||||
#include <freerdp/event.h>
|
#include <freerdp/event.h>
|
||||||
#include <freerdp/freerdp.h>
|
#include <freerdp/freerdp.h>
|
||||||
#include <freerdp/gdi/gdi.h>
|
#include <freerdp/gdi/gdi.h>
|
||||||
|
#include <freerdp/graphics.h>
|
||||||
#include <freerdp/input.h>
|
#include <freerdp/input.h>
|
||||||
|
#include <freerdp/locale/keyboard.h>
|
||||||
#include <freerdp/scancode.h>
|
#include <freerdp/scancode.h>
|
||||||
#include <freerdp/settings.h>
|
#include <freerdp/settings.h>
|
||||||
|
#include <freerdp/utils/signal.h>
|
||||||
|
#include <winpr/winsock.h>
|
||||||
#include <winpr/crt.h>
|
#include <winpr/crt.h>
|
||||||
#include <winpr/synch.h>
|
#include <winpr/synch.h>
|
||||||
|
#include <winpr/user.h>
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
|
|
||||||
|
#ifdef ORBITHUB_HAS_FREERDP
|
||||||
|
// Mirrors wfreerdp_client_global_init() in FreeRDP's own reference Windows
|
||||||
|
// client (client/Windows/wf_client.c): every reference client calls
|
||||||
|
// WSAStartup + freerdp_handle_signals once at startup before connecting.
|
||||||
|
//
|
||||||
|
// WSAStartup: on Windows, sockets (getaddrinfo et al.) fail immediately
|
||||||
|
// with WSANOTINITIALISED until this has been called by *someone* in the
|
||||||
|
// process; OrbitHub never used Qt Network, so nothing else on Windows was
|
||||||
|
// calling it, and every connect failed instantly with
|
||||||
|
// ERRCONNECT_DNS_NAME_NOT_FOUND even for a literal IP address. WinPR
|
||||||
|
// provides a portable no-op WSAStartup shim on POSIX, so this is safe to
|
||||||
|
// call unconditionally cross-platform.
|
||||||
|
//
|
||||||
|
// freerdp_handle_signals: on Windows it's the only place that initializes
|
||||||
|
// a global CRITICAL_SECTION used by freerdp_add_signal_cleanup_handler();
|
||||||
|
// skipping it leaves that lock zero-initialized (invalid on Windows,
|
||||||
|
// tolerated on POSIX), so the first freerdp_connect() crashed with a
|
||||||
|
// null-pointer dereference inside EnterCriticalSection.
|
||||||
|
//
|
||||||
|
// Guarded so it only ever runs once.
|
||||||
|
void ensureFreeRdpRuntimeInitialized()
|
||||||
|
{
|
||||||
|
static std::once_flag onceFlag;
|
||||||
|
std::call_once(onceFlag, []() {
|
||||||
|
WSADATA wsaData;
|
||||||
|
const int wsaRc = WSAStartup(0x101, &wsaData);
|
||||||
|
Q_UNUSED(wsaRc);
|
||||||
|
|
||||||
|
const int signalRc = freerdp_handle_signals();
|
||||||
|
Q_UNUSED(signalRc);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
constexpr int kDefaultDesktopWidth = 1280;
|
constexpr int kDefaultDesktopWidth = 1280;
|
||||||
constexpr int kDefaultDesktopHeight = 720;
|
constexpr int kDefaultDesktopHeight = 720;
|
||||||
constexpr int kMinDesktopWidth = 640;
|
constexpr int kMinDesktopWidth = 640;
|
||||||
@@ -146,6 +191,16 @@ UINT orbitDisplayControlCaps(DispClientContext* context,
|
|||||||
UINT32 maxNumMonitors,
|
UINT32 maxNumMonitors,
|
||||||
UINT32 maxMonitorAreaFactorA,
|
UINT32 maxMonitorAreaFactorA,
|
||||||
UINT32 maxMonitorAreaFactorB);
|
UINT32 maxMonitorAreaFactorB);
|
||||||
|
UINT orbitCliprdrMonitorReady(CliprdrClientContext* context,
|
||||||
|
const CLIPRDR_MONITOR_READY* monitorReady);
|
||||||
|
UINT orbitCliprdrServerFormatList(CliprdrClientContext* context,
|
||||||
|
const CLIPRDR_FORMAT_LIST* formatList);
|
||||||
|
UINT orbitCliprdrServerFormatListResponse(
|
||||||
|
CliprdrClientContext* context, const CLIPRDR_FORMAT_LIST_RESPONSE* formatListResponse);
|
||||||
|
UINT orbitCliprdrServerFormatDataRequest(
|
||||||
|
CliprdrClientContext* context, const CLIPRDR_FORMAT_DATA_REQUEST* formatDataRequest);
|
||||||
|
UINT orbitCliprdrServerFormatDataResponse(
|
||||||
|
CliprdrClientContext* context, const CLIPRDR_FORMAT_DATA_RESPONSE* formatDataResponse);
|
||||||
BOOL orbitLoadChannels(freerdp* instance);
|
BOOL orbitLoadChannels(freerdp* instance);
|
||||||
bool orbitLoadStaticChannel(rdpChannels* channels,
|
bool orbitLoadStaticChannel(rdpChannels* channels,
|
||||||
rdpSettings* settings,
|
rdpSettings* settings,
|
||||||
@@ -246,6 +301,118 @@ BOOL orbitDesktopResize(rdpContext* context)
|
|||||||
return TRUE;
|
return TRUE;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Custom rdpPointer subclass: FreeRDP allocates this with calloc() and only
|
||||||
|
// ever touches the base rdpPointer fields itself, so the trailing fields here
|
||||||
|
// must stay plain-old-data and be managed manually in New()/Free() (mirrors
|
||||||
|
// FreeRDP's own X11 client, client/X11/xf_graphics.c).
|
||||||
|
struct OrbitRdpPointer
|
||||||
|
{
|
||||||
|
rdpPointer pointer;
|
||||||
|
uchar* rgbaData;
|
||||||
|
UINT32 rgbaWidth;
|
||||||
|
UINT32 rgbaHeight;
|
||||||
|
};
|
||||||
|
|
||||||
|
BOOL orbitPointerNew(rdpContext* context, rdpPointer* pointer)
|
||||||
|
{
|
||||||
|
if (context == nullptr || pointer == nullptr || context->gdi == nullptr) {
|
||||||
|
return FALSE;
|
||||||
|
}
|
||||||
|
|
||||||
|
auto* orbitPointer = reinterpret_cast<OrbitRdpPointer*>(pointer);
|
||||||
|
orbitPointer->rgbaData = nullptr;
|
||||||
|
orbitPointer->rgbaWidth = 0;
|
||||||
|
orbitPointer->rgbaHeight = 0;
|
||||||
|
|
||||||
|
if (pointer->width == 0 || pointer->height == 0) {
|
||||||
|
return TRUE;
|
||||||
|
}
|
||||||
|
|
||||||
|
const size_t size = static_cast<size_t>(pointer->width) * pointer->height * 4;
|
||||||
|
auto* buffer = new (std::nothrow) uchar[size];
|
||||||
|
if (buffer == nullptr) {
|
||||||
|
return FALSE;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!freerdp_image_copy_from_pointer_data(buffer,
|
||||||
|
PIXEL_FORMAT_BGRA32,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
pointer->width,
|
||||||
|
pointer->height,
|
||||||
|
pointer->xorMaskData,
|
||||||
|
pointer->lengthXorMask,
|
||||||
|
pointer->andMaskData,
|
||||||
|
pointer->lengthAndMask,
|
||||||
|
pointer->xorBpp,
|
||||||
|
&context->gdi->palette)) {
|
||||||
|
delete[] buffer;
|
||||||
|
return FALSE;
|
||||||
|
}
|
||||||
|
|
||||||
|
orbitPointer->rgbaData = buffer;
|
||||||
|
orbitPointer->rgbaWidth = pointer->width;
|
||||||
|
orbitPointer->rgbaHeight = pointer->height;
|
||||||
|
return TRUE;
|
||||||
|
}
|
||||||
|
|
||||||
|
void orbitPointerFree(rdpContext* context, rdpPointer* pointer)
|
||||||
|
{
|
||||||
|
Q_UNUSED(context);
|
||||||
|
if (pointer == nullptr) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
auto* orbitPointer = reinterpret_cast<OrbitRdpPointer*>(pointer);
|
||||||
|
delete[] orbitPointer->rgbaData;
|
||||||
|
orbitPointer->rgbaData = nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
BOOL orbitPointerSet(rdpContext* context, rdpPointer* pointer)
|
||||||
|
{
|
||||||
|
if (context == nullptr || pointer == nullptr) {
|
||||||
|
return FALSE;
|
||||||
|
}
|
||||||
|
|
||||||
|
RdpSessionBackend* backend = backendFromContext(context);
|
||||||
|
if (backend == nullptr) {
|
||||||
|
return TRUE;
|
||||||
|
}
|
||||||
|
|
||||||
|
auto* orbitPointer = reinterpret_cast<OrbitRdpPointer*>(pointer);
|
||||||
|
if (orbitPointer->rgbaData == nullptr || orbitPointer->rgbaWidth == 0
|
||||||
|
|| orbitPointer->rgbaHeight == 0) {
|
||||||
|
return TRUE;
|
||||||
|
}
|
||||||
|
|
||||||
|
const QImage image(orbitPointer->rgbaData,
|
||||||
|
static_cast<int>(orbitPointer->rgbaWidth),
|
||||||
|
static_cast<int>(orbitPointer->rgbaHeight),
|
||||||
|
QImage::Format_ARGB32);
|
||||||
|
|
||||||
|
emit backend->cursorImageChanged(
|
||||||
|
image.copy(),
|
||||||
|
QPoint(static_cast<int>(pointer->xPos), static_cast<int>(pointer->yPos)));
|
||||||
|
return TRUE;
|
||||||
|
}
|
||||||
|
|
||||||
|
BOOL orbitPointerSetNull(rdpContext* context)
|
||||||
|
{
|
||||||
|
if (RdpSessionBackend* backend = backendFromContext(context)) {
|
||||||
|
emit backend->cursorHidden();
|
||||||
|
}
|
||||||
|
return TRUE;
|
||||||
|
}
|
||||||
|
|
||||||
|
BOOL orbitPointerSetDefault(rdpContext* context)
|
||||||
|
{
|
||||||
|
if (RdpSessionBackend* backend = backendFromContext(context)) {
|
||||||
|
emit backend->cursorReset();
|
||||||
|
}
|
||||||
|
return TRUE;
|
||||||
|
}
|
||||||
|
|
||||||
BOOL orbitPreConnect(freerdp* instance)
|
BOOL orbitPreConnect(freerdp* instance)
|
||||||
{
|
{
|
||||||
if (instance == nullptr || instance->context == nullptr || instance->context->settings == nullptr) {
|
if (instance == nullptr || instance->context == nullptr || instance->context->settings == nullptr) {
|
||||||
@@ -274,6 +441,17 @@ BOOL orbitPostConnect(freerdp* instance)
|
|||||||
context->update->EndPaint = orbitEndPaint;
|
context->update->EndPaint = orbitEndPaint;
|
||||||
context->update->DesktopResize = orbitDesktopResize;
|
context->update->DesktopResize = orbitDesktopResize;
|
||||||
|
|
||||||
|
if (context->graphics != nullptr) {
|
||||||
|
rdpPointer pointerCallbacks = {};
|
||||||
|
pointerCallbacks.size = sizeof(OrbitRdpPointer);
|
||||||
|
pointerCallbacks.New = orbitPointerNew;
|
||||||
|
pointerCallbacks.Free = orbitPointerFree;
|
||||||
|
pointerCallbacks.Set = orbitPointerSet;
|
||||||
|
pointerCallbacks.SetNull = orbitPointerSetNull;
|
||||||
|
pointerCallbacks.SetDefault = orbitPointerSetDefault;
|
||||||
|
graphics_register_pointer(context->graphics, &pointerCallbacks);
|
||||||
|
}
|
||||||
|
|
||||||
if (RdpSessionBackend* backend = backendFromContext(context)) {
|
if (RdpSessionBackend* backend = backendFromContext(context)) {
|
||||||
const int width = static_cast<int>(
|
const int width = static_cast<int>(
|
||||||
freerdp_settings_get_uint32(context->settings, FreeRDP_DesktopWidth));
|
freerdp_settings_get_uint32(context->settings, FreeRDP_DesktopWidth));
|
||||||
@@ -291,6 +469,7 @@ void orbitPostDisconnect(freerdp* instance)
|
|||||||
if (instance->context != nullptr) {
|
if (instance->context != nullptr) {
|
||||||
if (RdpSessionBackend* backend = backendFromContext(instance->context)) {
|
if (RdpSessionBackend* backend = backendFromContext(instance->context)) {
|
||||||
backend->onChannelDisconnectedEvent(DISP_DVC_CHANNEL_NAME, nullptr);
|
backend->onChannelDisconnectedEvent(DISP_DVC_CHANNEL_NAME, nullptr);
|
||||||
|
backend->onChannelDisconnectedEvent(CLIPRDR_SVC_CHANNEL_NAME, nullptr);
|
||||||
}
|
}
|
||||||
PubSub_UnsubscribeChannelConnected(instance->context->pubSub,
|
PubSub_UnsubscribeChannelConnected(instance->context->pubSub,
|
||||||
orbitOnChannelConnectedEventHandler);
|
orbitOnChannelConnectedEventHandler);
|
||||||
@@ -337,6 +516,75 @@ UINT orbitDisplayControlCaps(DispClientContext* context,
|
|||||||
return CHANNEL_RC_OK;
|
return CHANNEL_RC_OK;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
UINT orbitCliprdrMonitorReady(CliprdrClientContext* context,
|
||||||
|
const CLIPRDR_MONITOR_READY* monitorReady)
|
||||||
|
{
|
||||||
|
Q_UNUSED(monitorReady);
|
||||||
|
if (context == nullptr || context->custom == nullptr) {
|
||||||
|
return CHANNEL_RC_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
auto* backend = reinterpret_cast<RdpSessionBackend*>(context->custom);
|
||||||
|
backend->onCliprdrMonitorReady();
|
||||||
|
return CHANNEL_RC_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
UINT orbitCliprdrServerFormatList(CliprdrClientContext* context,
|
||||||
|
const CLIPRDR_FORMAT_LIST* formatList)
|
||||||
|
{
|
||||||
|
if (context == nullptr || context->custom == nullptr || formatList == nullptr) {
|
||||||
|
return CHANNEL_RC_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool hasUnicodeText = false;
|
||||||
|
for (UINT32 i = 0; i < formatList->numFormats; ++i) {
|
||||||
|
if (formatList->formats[i].formatId == CF_UNICODETEXT) {
|
||||||
|
hasUnicodeText = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
auto* backend = reinterpret_cast<RdpSessionBackend*>(context->custom);
|
||||||
|
backend->onCliprdrServerFormatList(hasUnicodeText);
|
||||||
|
return CHANNEL_RC_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
UINT orbitCliprdrServerFormatListResponse(
|
||||||
|
CliprdrClientContext* context, const CLIPRDR_FORMAT_LIST_RESPONSE* formatListResponse)
|
||||||
|
{
|
||||||
|
Q_UNUSED(context);
|
||||||
|
Q_UNUSED(formatListResponse);
|
||||||
|
return CHANNEL_RC_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
UINT orbitCliprdrServerFormatDataRequest(
|
||||||
|
CliprdrClientContext* context, const CLIPRDR_FORMAT_DATA_REQUEST* formatDataRequest)
|
||||||
|
{
|
||||||
|
if (context == nullptr || context->custom == nullptr || formatDataRequest == nullptr) {
|
||||||
|
return CHANNEL_RC_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
auto* backend = reinterpret_cast<RdpSessionBackend*>(context->custom);
|
||||||
|
backend->onCliprdrServerFormatDataRequest(formatDataRequest->requestedFormatId);
|
||||||
|
return CHANNEL_RC_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
UINT orbitCliprdrServerFormatDataResponse(
|
||||||
|
CliprdrClientContext* context, const CLIPRDR_FORMAT_DATA_RESPONSE* formatDataResponse)
|
||||||
|
{
|
||||||
|
if (context == nullptr || context->custom == nullptr || formatDataResponse == nullptr) {
|
||||||
|
return CHANNEL_RC_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
auto* backend = reinterpret_cast<RdpSessionBackend*>(context->custom);
|
||||||
|
const bool success = (formatDataResponse->common.msgFlags & CB_RESPONSE_OK) != 0;
|
||||||
|
backend->onCliprdrServerFormatDataResponse(
|
||||||
|
success,
|
||||||
|
reinterpret_cast<const uint8_t*>(formatDataResponse->requestedFormatData),
|
||||||
|
formatDataResponse->common.dataLen);
|
||||||
|
return CHANNEL_RC_OK;
|
||||||
|
}
|
||||||
|
|
||||||
BOOL orbitLoadChannels(freerdp* instance)
|
BOOL orbitLoadChannels(freerdp* instance)
|
||||||
{
|
{
|
||||||
if (instance == nullptr || instance->context == nullptr || instance->context->settings == nullptr
|
if (instance == nullptr || instance->context == nullptr || instance->context->settings == nullptr
|
||||||
@@ -383,6 +631,14 @@ BOOL orbitLoadChannels(freerdp* instance)
|
|||||||
return TRUE;
|
return TRUE;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!orbitLoadStaticChannel(channels, settings, CLIPRDR_SVC_CHANNEL_NAME, settings)) {
|
||||||
|
if (backend != nullptr) {
|
||||||
|
emit backend->eventLogged(
|
||||||
|
QStringLiteral("RDP warning: failed to load static '%1' channel; clipboard sync unavailable.")
|
||||||
|
.arg(QString::fromUtf8(CLIPRDR_SVC_CHANNEL_NAME)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (backend != nullptr) {
|
if (backend != nullptr) {
|
||||||
emit backend->eventLogged(QStringLiteral("RDP channel loader: display-control channels initialized."));
|
emit backend->eventLogged(QStringLiteral("RDP channel loader: display-control channels initialized."));
|
||||||
}
|
}
|
||||||
@@ -576,6 +832,25 @@ UINT32 scancodeFromNativeScanCode(quint32 nativeScanCode)
|
|||||||
return RDP_SCANCODE_UNKNOWN;
|
return RDP_SCANCODE_UNKNOWN;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#if defined(Q_OS_LINUX)
|
||||||
|
// On X11, Qt's nativeScanCode() is the raw X11 KeyCode, not a PC/AT set-1
|
||||||
|
// scancode. X11 keycode numbering only coincidentally overlaps with PC/AT
|
||||||
|
// scancodes for some keys and diverges for others (e.g. X11 keycode 0x30
|
||||||
|
// is the apostrophe/quote key, while PC/AT scancode 0x30 is the B key), so
|
||||||
|
// treating one as the other silently sends the wrong key. FreeRDP ships an
|
||||||
|
// authoritative X11-keycode -> RDP-scancode table; use it directly.
|
||||||
|
// The function is marked deprecated upstream ("implement yourself in
|
||||||
|
// client") but remains present and correct in this pinned FreeRDP build.
|
||||||
|
#if defined(__GNUC__) || defined(__clang__)
|
||||||
|
#pragma GCC diagnostic push
|
||||||
|
#pragma GCC diagnostic ignored "-Wdeprecated-declarations"
|
||||||
|
#endif
|
||||||
|
const DWORD mapped = freerdp_keyboard_get_rdp_scancode_from_x11_keycode(nativeScanCode);
|
||||||
|
#if defined(__GNUC__) || defined(__clang__)
|
||||||
|
#pragma GCC diagnostic pop
|
||||||
|
#endif
|
||||||
|
return mapped != 0 ? mapped : RDP_SCANCODE_UNKNOWN;
|
||||||
|
#else
|
||||||
quint32 code = 0;
|
quint32 code = 0;
|
||||||
bool extended = false;
|
bool extended = false;
|
||||||
if ((nativeScanCode & 0xFF000000u) == 0xE0000000u) {
|
if ((nativeScanCode & 0xFF000000u) == 0xE0000000u) {
|
||||||
@@ -607,6 +882,7 @@ UINT32 scancodeFromNativeScanCode(quint32 nativeScanCode)
|
|||||||
}
|
}
|
||||||
|
|
||||||
return scancode;
|
return scancode;
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
UINT32 scancodeForQtKey(int key, Qt::KeyboardModifiers modifiers, quint32 nativeScanCode)
|
UINT32 scancodeForQtKey(int key, Qt::KeyboardModifiers modifiers, quint32 nativeScanCode)
|
||||||
@@ -1001,7 +1277,8 @@ RdpSessionBackend::RdpSessionBackend(const Profile& profile, QObject* parent)
|
|||||||
m_displayControlReady(false),
|
m_displayControlReady(false),
|
||||||
m_resizeFailureLogged(false),
|
m_resizeFailureLogged(false),
|
||||||
m_lastResizeWidth(0),
|
m_lastResizeWidth(0),
|
||||||
m_lastResizeHeight(0)
|
m_lastResizeHeight(0),
|
||||||
|
m_cliprdrContext(nullptr)
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1112,6 +1389,18 @@ void RdpSessionBackend::sendKeyEvent(int key,
|
|||||||
enqueueInputEvent(event);
|
enqueueInputEvent(event);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void RdpSessionBackend::setClipboardText(const QString& text)
|
||||||
|
{
|
||||||
|
if (!m_workerRunning.load()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
InputEvent event;
|
||||||
|
event.type = InputEventType::SetClipboardText;
|
||||||
|
event.text = text;
|
||||||
|
enqueueInputEvent(event);
|
||||||
|
}
|
||||||
|
|
||||||
void RdpSessionBackend::sendMouseMoveEvent(int x, int y)
|
void RdpSessionBackend::sendMouseMoveEvent(int x, int y)
|
||||||
{
|
{
|
||||||
if (!m_workerRunning.load()) {
|
if (!m_workerRunning.load()) {
|
||||||
@@ -1221,6 +1510,8 @@ void RdpSessionBackend::workerMain()
|
|||||||
m_workerRunning.store(false);
|
m_workerRunning.store(false);
|
||||||
return;
|
return;
|
||||||
#else
|
#else
|
||||||
|
ensureFreeRdpRuntimeInitialized();
|
||||||
|
|
||||||
freerdp* instance = freerdp_new();
|
freerdp* instance = freerdp_new();
|
||||||
if (instance == nullptr) {
|
if (instance == nullptr) {
|
||||||
emitConnectionFailureAsync(QStringLiteral("Failed to initialize FreeRDP runtime."),
|
emitConnectionFailureAsync(QStringLiteral("Failed to initialize FreeRDP runtime."),
|
||||||
@@ -1537,6 +1828,38 @@ bool RdpSessionBackend::sendDisplayResize(rdp_freerdp* instance, int width, int
|
|||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void RdpSessionBackend::sendClipboardTextToRemote(rdp_freerdp* instance, const QString& text)
|
||||||
|
{
|
||||||
|
#ifndef ORBITHUB_HAS_FREERDP
|
||||||
|
Q_UNUSED(instance);
|
||||||
|
Q_UNUSED(text);
|
||||||
|
#else
|
||||||
|
Q_UNUSED(instance);
|
||||||
|
|
||||||
|
CliprdrClientContext* cliprdrContext = nullptr;
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> guard(m_cliprdrMutex);
|
||||||
|
m_pendingLocalClipboardText = text;
|
||||||
|
cliprdrContext = reinterpret_cast<CliprdrClientContext*>(m_cliprdrContext);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cliprdrContext == nullptr || cliprdrContext->ClientFormatList == nullptr) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
CLIPRDR_FORMAT format = {};
|
||||||
|
format.formatId = CF_UNICODETEXT;
|
||||||
|
format.formatName = nullptr;
|
||||||
|
|
||||||
|
CLIPRDR_FORMAT_LIST formatList = {};
|
||||||
|
formatList.numFormats = 1;
|
||||||
|
formatList.formats = &format;
|
||||||
|
|
||||||
|
const UINT rc = cliprdrContext->ClientFormatList(cliprdrContext, &formatList);
|
||||||
|
Q_UNUSED(rc);
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
void RdpSessionBackend::processInputEvents(rdp_freerdp* instance)
|
void RdpSessionBackend::processInputEvents(rdp_freerdp* instance)
|
||||||
{
|
{
|
||||||
#ifndef ORBITHUB_HAS_FREERDP
|
#ifndef ORBITHUB_HAS_FREERDP
|
||||||
@@ -1661,6 +1984,9 @@ void RdpSessionBackend::processInputEvents(rdp_freerdp* instance)
|
|||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case InputEventType::SetClipboardText:
|
||||||
|
sendClipboardTextToRemote(instance, event.text);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1686,24 +2012,47 @@ void RdpSessionBackend::processInputEvents(rdp_freerdp* instance)
|
|||||||
void RdpSessionBackend::onChannelConnectedEvent(const char* name, void* channelInterface)
|
void RdpSessionBackend::onChannelConnectedEvent(const char* name, void* channelInterface)
|
||||||
{
|
{
|
||||||
#ifdef ORBITHUB_HAS_FREERDP
|
#ifdef ORBITHUB_HAS_FREERDP
|
||||||
if (name == nullptr || std::strcmp(name, DISP_DVC_CHANNEL_NAME) != 0) {
|
if (name == nullptr) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
auto* dispContext = reinterpret_cast<DispClientContext*>(channelInterface);
|
if (std::strcmp(name, DISP_DVC_CHANNEL_NAME) == 0) {
|
||||||
if (dispContext == nullptr) {
|
auto* dispContext = reinterpret_cast<DispClientContext*>(channelInterface);
|
||||||
|
if (dispContext == nullptr) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
dispContext->custom = this;
|
||||||
|
dispContext->DisplayControlCaps = orbitDisplayControlCaps;
|
||||||
|
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> guard(m_displayControlMutex);
|
||||||
|
m_displayControlContext = dispContext;
|
||||||
|
m_displayControlReady = false;
|
||||||
|
}
|
||||||
|
emit eventLogged(QStringLiteral("RDP display-control channel connected."));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
dispContext->custom = this;
|
if (std::strcmp(name, CLIPRDR_SVC_CHANNEL_NAME) == 0) {
|
||||||
dispContext->DisplayControlCaps = orbitDisplayControlCaps;
|
auto* cliprdrContext = reinterpret_cast<CliprdrClientContext*>(channelInterface);
|
||||||
|
if (cliprdrContext == nullptr) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
{
|
cliprdrContext->custom = this;
|
||||||
std::lock_guard<std::mutex> guard(m_displayControlMutex);
|
cliprdrContext->MonitorReady = orbitCliprdrMonitorReady;
|
||||||
m_displayControlContext = dispContext;
|
cliprdrContext->ServerFormatList = orbitCliprdrServerFormatList;
|
||||||
m_displayControlReady = false;
|
cliprdrContext->ServerFormatListResponse = orbitCliprdrServerFormatListResponse;
|
||||||
|
cliprdrContext->ServerFormatDataRequest = orbitCliprdrServerFormatDataRequest;
|
||||||
|
cliprdrContext->ServerFormatDataResponse = orbitCliprdrServerFormatDataResponse;
|
||||||
|
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> guard(m_cliprdrMutex);
|
||||||
|
m_cliprdrContext = cliprdrContext;
|
||||||
|
}
|
||||||
|
emit eventLogged(QStringLiteral("RDP clipboard channel connected."));
|
||||||
}
|
}
|
||||||
emit eventLogged(QStringLiteral("RDP display-control channel connected."));
|
|
||||||
#else
|
#else
|
||||||
Q_UNUSED(name);
|
Q_UNUSED(name);
|
||||||
Q_UNUSED(channelInterface);
|
Q_UNUSED(channelInterface);
|
||||||
@@ -1713,24 +2062,43 @@ void RdpSessionBackend::onChannelConnectedEvent(const char* name, void* channelI
|
|||||||
void RdpSessionBackend::onChannelDisconnectedEvent(const char* name, void* channelInterface)
|
void RdpSessionBackend::onChannelDisconnectedEvent(const char* name, void* channelInterface)
|
||||||
{
|
{
|
||||||
#ifdef ORBITHUB_HAS_FREERDP
|
#ifdef ORBITHUB_HAS_FREERDP
|
||||||
if (name != nullptr && std::strcmp(name, DISP_DVC_CHANNEL_NAME) != 0) {
|
const bool matchesDisp = name == nullptr || std::strcmp(name, DISP_DVC_CHANNEL_NAME) == 0;
|
||||||
return;
|
const bool matchesCliprdr =
|
||||||
}
|
name == nullptr || std::strcmp(name, CLIPRDR_SVC_CHANNEL_NAME) == 0;
|
||||||
|
|
||||||
bool cleared = false;
|
if (matchesDisp) {
|
||||||
bool hadDisplayControl = false;
|
bool cleared = false;
|
||||||
{
|
bool hadDisplayControl = false;
|
||||||
std::lock_guard<std::mutex> guard(m_displayControlMutex);
|
{
|
||||||
hadDisplayControl = (m_displayControlContext != nullptr) || m_displayControlReady;
|
std::lock_guard<std::mutex> guard(m_displayControlMutex);
|
||||||
if (channelInterface == nullptr || m_displayControlContext == channelInterface) {
|
hadDisplayControl = (m_displayControlContext != nullptr) || m_displayControlReady;
|
||||||
m_displayControlContext = nullptr;
|
if (channelInterface == nullptr || m_displayControlContext == channelInterface) {
|
||||||
m_displayControlReady = false;
|
m_displayControlContext = nullptr;
|
||||||
cleared = true;
|
m_displayControlReady = false;
|
||||||
|
cleared = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cleared && hadDisplayControl) {
|
||||||
|
emit eventLogged(QStringLiteral("RDP display-control channel disconnected."));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (cleared && hadDisplayControl) {
|
if (matchesCliprdr) {
|
||||||
emit eventLogged(QStringLiteral("RDP display-control channel disconnected."));
|
bool cleared = false;
|
||||||
|
bool hadCliprdr = false;
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> guard(m_cliprdrMutex);
|
||||||
|
hadCliprdr = m_cliprdrContext != nullptr;
|
||||||
|
if (channelInterface == nullptr || m_cliprdrContext == channelInterface) {
|
||||||
|
m_cliprdrContext = nullptr;
|
||||||
|
cleared = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cleared && hadCliprdr) {
|
||||||
|
emit eventLogged(QStringLiteral("RDP clipboard channel disconnected."));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
#else
|
#else
|
||||||
Q_UNUSED(name);
|
Q_UNUSED(name);
|
||||||
@@ -1769,6 +2137,130 @@ void RdpSessionBackend::onDisplayControlCaps(uint32_t maxNumMonitors,
|
|||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void RdpSessionBackend::onCliprdrMonitorReady()
|
||||||
|
{
|
||||||
|
#ifdef ORBITHUB_HAS_FREERDP
|
||||||
|
CliprdrClientContext* cliprdrContext = nullptr;
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> guard(m_cliprdrMutex);
|
||||||
|
cliprdrContext = reinterpret_cast<CliprdrClientContext*>(m_cliprdrContext);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cliprdrContext == nullptr) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
CLIPRDR_GENERAL_CAPABILITY_SET generalCapabilitySet = {};
|
||||||
|
generalCapabilitySet.capabilitySetType = CB_CAPSTYPE_GENERAL;
|
||||||
|
generalCapabilitySet.capabilitySetLength = CB_CAPSTYPE_GENERAL_LEN;
|
||||||
|
generalCapabilitySet.version = CB_CAPS_VERSION_2;
|
||||||
|
generalCapabilitySet.generalFlags = CB_USE_LONG_FORMAT_NAMES;
|
||||||
|
|
||||||
|
CLIPRDR_CAPABILITIES capabilities = {};
|
||||||
|
capabilities.cCapabilitiesSets = 1;
|
||||||
|
capabilities.capabilitySets = reinterpret_cast<CLIPRDR_CAPABILITY_SET*>(&generalCapabilitySet);
|
||||||
|
|
||||||
|
if (cliprdrContext->ClientCapabilities != nullptr) {
|
||||||
|
const UINT rc = cliprdrContext->ClientCapabilities(cliprdrContext, &capabilities);
|
||||||
|
Q_UNUSED(rc);
|
||||||
|
}
|
||||||
|
|
||||||
|
emit eventLogged(QStringLiteral("RDP clipboard channel ready."));
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
void RdpSessionBackend::onCliprdrServerFormatList(bool hasUnicodeText)
|
||||||
|
{
|
||||||
|
#ifdef ORBITHUB_HAS_FREERDP
|
||||||
|
CliprdrClientContext* cliprdrContext = nullptr;
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> guard(m_cliprdrMutex);
|
||||||
|
cliprdrContext = reinterpret_cast<CliprdrClientContext*>(m_cliprdrContext);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cliprdrContext == nullptr) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cliprdrContext->ClientFormatListResponse != nullptr) {
|
||||||
|
CLIPRDR_FORMAT_LIST_RESPONSE response = {};
|
||||||
|
response.common.msgFlags = CB_RESPONSE_OK;
|
||||||
|
const UINT rc = cliprdrContext->ClientFormatListResponse(cliprdrContext, &response);
|
||||||
|
Q_UNUSED(rc);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasUnicodeText && cliprdrContext->ClientFormatDataRequest != nullptr) {
|
||||||
|
CLIPRDR_FORMAT_DATA_REQUEST request = {};
|
||||||
|
request.requestedFormatId = CF_UNICODETEXT;
|
||||||
|
const UINT rc = cliprdrContext->ClientFormatDataRequest(cliprdrContext, &request);
|
||||||
|
Q_UNUSED(rc);
|
||||||
|
}
|
||||||
|
#else
|
||||||
|
Q_UNUSED(hasUnicodeText);
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
void RdpSessionBackend::onCliprdrServerFormatDataRequest(uint32_t requestedFormatId)
|
||||||
|
{
|
||||||
|
#ifdef ORBITHUB_HAS_FREERDP
|
||||||
|
CliprdrClientContext* cliprdrContext = nullptr;
|
||||||
|
QString pendingText;
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> guard(m_cliprdrMutex);
|
||||||
|
cliprdrContext = reinterpret_cast<CliprdrClientContext*>(m_cliprdrContext);
|
||||||
|
pendingText = m_pendingLocalClipboardText;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cliprdrContext == nullptr || cliprdrContext->ClientFormatDataResponse == nullptr) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
CLIPRDR_FORMAT_DATA_RESPONSE response = {};
|
||||||
|
QByteArray encoded;
|
||||||
|
if (requestedFormatId == CF_UNICODETEXT) {
|
||||||
|
const int byteLength = (pendingText.size() + 1) * static_cast<int>(sizeof(ushort));
|
||||||
|
encoded = QByteArray(reinterpret_cast<const char*>(pendingText.utf16()), byteLength);
|
||||||
|
}
|
||||||
|
|
||||||
|
response.common.msgFlags = encoded.isEmpty() ? CB_RESPONSE_FAIL : CB_RESPONSE_OK;
|
||||||
|
response.common.dataLen = static_cast<UINT32>(encoded.size());
|
||||||
|
response.requestedFormatData =
|
||||||
|
encoded.isEmpty() ? nullptr : reinterpret_cast<const BYTE*>(encoded.constData());
|
||||||
|
const UINT rc = cliprdrContext->ClientFormatDataResponse(cliprdrContext, &response);
|
||||||
|
Q_UNUSED(rc);
|
||||||
|
#else
|
||||||
|
Q_UNUSED(requestedFormatId);
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
void RdpSessionBackend::onCliprdrServerFormatDataResponse(bool success,
|
||||||
|
const uint8_t* data,
|
||||||
|
uint32_t size)
|
||||||
|
{
|
||||||
|
#ifdef ORBITHUB_HAS_FREERDP
|
||||||
|
if (!success || data == nullptr || size == 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const auto* utf16Data = reinterpret_cast<const char16_t*>(data);
|
||||||
|
const size_t charCount = size / sizeof(char16_t);
|
||||||
|
|
||||||
|
// CF_UNICODETEXT is NUL-terminated per MS-RDPECLIP; trim at the first NUL
|
||||||
|
// rather than trusting dataLen to exclude it.
|
||||||
|
size_t length = 0;
|
||||||
|
while (length < charCount && utf16Data[length] != 0) {
|
||||||
|
++length;
|
||||||
|
}
|
||||||
|
|
||||||
|
const QString text = QString::fromUtf16(utf16Data, static_cast<int>(length));
|
||||||
|
emit remoteClipboardTextChanged(text);
|
||||||
|
#else
|
||||||
|
Q_UNUSED(success);
|
||||||
|
Q_UNUSED(data);
|
||||||
|
Q_UNUSED(size);
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
void RdpSessionBackend::emitStateAsync(SessionState state, const QString& message)
|
void RdpSessionBackend::emitStateAsync(SessionState state, const QString& message)
|
||||||
{
|
{
|
||||||
QMetaObject::invokeMethod(
|
QMetaObject::invokeMethod(
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ public slots:
|
|||||||
void sendMouseMoveEvent(int x, int y) override;
|
void sendMouseMoveEvent(int x, int y) override;
|
||||||
void sendMouseButtonEvent(int x, int y, int button, bool pressed) override;
|
void sendMouseButtonEvent(int x, int y, int button, bool pressed) override;
|
||||||
void sendMouseWheelEvent(int x, int y, int deltaX, int deltaY) override;
|
void sendMouseWheelEvent(int x, int y, int deltaX, int deltaY) override;
|
||||||
|
void setClipboardText(const QString& text) override;
|
||||||
|
|
||||||
private:
|
private:
|
||||||
enum class InputEventType {
|
enum class InputEventType {
|
||||||
@@ -42,6 +43,7 @@ private:
|
|||||||
MouseButton,
|
MouseButton,
|
||||||
MouseWheel,
|
MouseWheel,
|
||||||
Resize,
|
Resize,
|
||||||
|
SetClipboardText,
|
||||||
};
|
};
|
||||||
|
|
||||||
struct InputEvent {
|
struct InputEvent {
|
||||||
@@ -84,6 +86,10 @@ private:
|
|||||||
int m_lastResizeWidth;
|
int m_lastResizeWidth;
|
||||||
int m_lastResizeHeight;
|
int m_lastResizeHeight;
|
||||||
|
|
||||||
|
std::mutex m_cliprdrMutex;
|
||||||
|
void* m_cliprdrContext;
|
||||||
|
QString m_pendingLocalClipboardText;
|
||||||
|
|
||||||
void setState(SessionState state, const QString& message);
|
void setState(SessionState state, const QString& message);
|
||||||
bool validateProfile(QString& message) const;
|
bool validateProfile(QString& message) const;
|
||||||
void startWorker();
|
void startWorker();
|
||||||
@@ -92,12 +98,17 @@ private:
|
|||||||
void enqueueInputEvent(const InputEvent& event);
|
void enqueueInputEvent(const InputEvent& event);
|
||||||
void processInputEvents(rdp_freerdp* instance);
|
void processInputEvents(rdp_freerdp* instance);
|
||||||
bool sendDisplayResize(rdp_freerdp* instance, int width, int height);
|
bool sendDisplayResize(rdp_freerdp* instance, int width, int height);
|
||||||
|
void sendClipboardTextToRemote(rdp_freerdp* instance, const QString& text);
|
||||||
public:
|
public:
|
||||||
void onChannelConnectedEvent(const char* name, void* channelInterface);
|
void onChannelConnectedEvent(const char* name, void* channelInterface);
|
||||||
void onChannelDisconnectedEvent(const char* name, void* channelInterface);
|
void onChannelDisconnectedEvent(const char* name, void* channelInterface);
|
||||||
void onDisplayControlCaps(uint32_t maxNumMonitors,
|
void onDisplayControlCaps(uint32_t maxNumMonitors,
|
||||||
uint32_t maxMonitorAreaFactorA,
|
uint32_t maxMonitorAreaFactorA,
|
||||||
uint32_t maxMonitorAreaFactorB);
|
uint32_t maxMonitorAreaFactorB);
|
||||||
|
void onCliprdrMonitorReady();
|
||||||
|
void onCliprdrServerFormatList(bool hasUnicodeText);
|
||||||
|
void onCliprdrServerFormatDataRequest(uint32_t requestedFormatId);
|
||||||
|
void onCliprdrServerFormatDataResponse(bool success, const uint8_t* data, uint32_t size);
|
||||||
private:
|
private:
|
||||||
void emitStateAsync(SessionState state, const QString& message);
|
void emitStateAsync(SessionState state, const QString& message);
|
||||||
void emitConnectionFailureAsync(const QString& displayMessage, const QString& rawMessage);
|
void emitConnectionFailureAsync(const QString& displayMessage, const QString& rawMessage);
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
|
|
||||||
#include <QImage>
|
#include <QImage>
|
||||||
#include <QObject>
|
#include <QObject>
|
||||||
|
#include <QPoint>
|
||||||
#include <QString>
|
#include <QString>
|
||||||
#include <QtGlobal>
|
#include <QtGlobal>
|
||||||
|
|
||||||
@@ -46,6 +47,10 @@ public slots:
|
|||||||
virtual void sendInput(const QString& input) = 0;
|
virtual void sendInput(const QString& input) = 0;
|
||||||
virtual void confirmHostKey(bool trustHost) = 0;
|
virtual void confirmHostKey(bool trustHost) = 0;
|
||||||
virtual void updateTerminalSize(int columns, int rows) = 0;
|
virtual void updateTerminalSize(int columns, int rows) = 0;
|
||||||
|
virtual void setClipboardText(const QString& text)
|
||||||
|
{
|
||||||
|
Q_UNUSED(text);
|
||||||
|
}
|
||||||
virtual void sendKeyEvent(int key,
|
virtual void sendKeyEvent(int key,
|
||||||
quint32 nativeScanCode,
|
quint32 nativeScanCode,
|
||||||
const QString& text,
|
const QString& text,
|
||||||
@@ -86,6 +91,10 @@ signals:
|
|||||||
void hostKeyConfirmationRequested(const QString& prompt);
|
void hostKeyConfirmationRequested(const QString& prompt);
|
||||||
void frameUpdated(const QImage& frame);
|
void frameUpdated(const QImage& frame);
|
||||||
void remoteDesktopSizeChanged(int width, int height);
|
void remoteDesktopSizeChanged(int width, int height);
|
||||||
|
void remoteClipboardTextChanged(const QString& text);
|
||||||
|
void cursorImageChanged(const QImage& image, const QPoint& hotspot);
|
||||||
|
void cursorHidden();
|
||||||
|
void cursorReset();
|
||||||
|
|
||||||
private:
|
private:
|
||||||
Profile m_profile;
|
Profile m_profile;
|
||||||
|
|||||||
@@ -7,20 +7,26 @@
|
|||||||
#include <KodoTerm/KodoTerm.hpp>
|
#include <KodoTerm/KodoTerm.hpp>
|
||||||
|
|
||||||
#include <QDateTime>
|
#include <QDateTime>
|
||||||
|
#include <QFile>
|
||||||
#include <QFileDialog>
|
#include <QFileDialog>
|
||||||
#include <QFileInfo>
|
#include <QFileInfo>
|
||||||
#include <QFont>
|
#include <QFont>
|
||||||
#include <QFontDatabase>
|
#include <QFontDatabase>
|
||||||
#include <QHBoxLayout>
|
#include <QHBoxLayout>
|
||||||
#include <QInputDialog>
|
|
||||||
#include <QLabel>
|
#include <QLabel>
|
||||||
#include <QLineEdit>
|
#include <QLineEdit>
|
||||||
#include <QMessageBox>
|
#include <QMessageBox>
|
||||||
#include <QPlainTextEdit>
|
#include <QPlainTextEdit>
|
||||||
|
#include <QApplication>
|
||||||
|
#include <QClipboard>
|
||||||
|
#include <QMimeData>
|
||||||
|
#include <QComboBox>
|
||||||
#include <QProcessEnvironment>
|
#include <QProcessEnvironment>
|
||||||
|
#include <QPushButton>
|
||||||
#include <QThread>
|
#include <QThread>
|
||||||
#include <QTimer>
|
#include <QTimer>
|
||||||
#include <QToolButton>
|
#include <QToolButton>
|
||||||
|
#include <QTextStream>
|
||||||
#include <QVBoxLayout>
|
#include <QVBoxLayout>
|
||||||
|
|
||||||
#include <memory>
|
#include <memory>
|
||||||
@@ -53,7 +59,9 @@ TerminalTheme themeForName(const QString& themeName)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
SessionTab::SessionTab(const Profile& profile, QWidget* parent)
|
SessionTab::SessionTab(const Profile& profile,
|
||||||
|
const SessionUiPreferences& preferences,
|
||||||
|
QWidget* parent)
|
||||||
: QWidget(parent),
|
: QWidget(parent),
|
||||||
m_profile(profile),
|
m_profile(profile),
|
||||||
m_backendThread(nullptr),
|
m_backendThread(nullptr),
|
||||||
@@ -61,13 +69,31 @@ SessionTab::SessionTab(const Profile& profile, QWidget* parent)
|
|||||||
m_useKodoTermForSsh(profile.protocol.compare(QStringLiteral("SSH"), Qt::CaseInsensitive)
|
m_useKodoTermForSsh(profile.protocol.compare(QStringLiteral("SSH"), Qt::CaseInsensitive)
|
||||||
== 0),
|
== 0),
|
||||||
m_state(SessionState::Disconnected),
|
m_state(SessionState::Disconnected),
|
||||||
m_terminalThemeName(QStringLiteral("Dark")),
|
m_terminalThemeName(preferences.terminalThemeName.trimmed().isEmpty()
|
||||||
|
? QStringLiteral("Dark")
|
||||||
|
: preferences.terminalThemeName.trimmed()),
|
||||||
|
m_terminalFontPointSize(preferences.terminalFontPointSize > 0
|
||||||
|
? preferences.terminalFontPointSize
|
||||||
|
: 0),
|
||||||
|
m_clipboardSyncSupported(profile.protocol.compare(QStringLiteral("RDP"), Qt::CaseInsensitive)
|
||||||
|
== 0),
|
||||||
m_sshTerminal(nullptr),
|
m_sshTerminal(nullptr),
|
||||||
m_rdpDisplay(nullptr),
|
m_rdpDisplay(nullptr),
|
||||||
m_terminalOutput(nullptr),
|
m_terminalOutput(nullptr),
|
||||||
m_eventLog(nullptr),
|
m_eventLog(nullptr),
|
||||||
m_toggleEventsButton(nullptr),
|
m_toggleEventsButton(nullptr),
|
||||||
m_eventsPanel(nullptr)
|
m_eventFilterInput(nullptr),
|
||||||
|
m_eventSeverityFilterInput(nullptr),
|
||||||
|
m_clearEventsButton(nullptr),
|
||||||
|
m_exportEventsButton(nullptr),
|
||||||
|
m_eventsPanel(nullptr),
|
||||||
|
m_passwordPromptBar(nullptr),
|
||||||
|
m_passwordPromptLabel(nullptr),
|
||||||
|
m_passwordPromptInput(nullptr),
|
||||||
|
m_passwordPromptConnectButton(nullptr),
|
||||||
|
m_passwordPromptCancelButton(nullptr),
|
||||||
|
m_eventSeverityFilter(EventSeverity::Info),
|
||||||
|
m_eventsPanelExpanded(preferences.eventsPanelExpanded)
|
||||||
{
|
{
|
||||||
qRegisterMetaType<SessionConnectOptions>("SessionConnectOptions");
|
qRegisterMetaType<SessionConnectOptions>("SessionConnectOptions");
|
||||||
qRegisterMetaType<SessionState>("SessionState");
|
qRegisterMetaType<SessionState>("SessionState");
|
||||||
@@ -170,6 +196,11 @@ SessionTab::SessionTab(const Profile& profile, QWidget* parent)
|
|||||||
m_backend,
|
m_backend,
|
||||||
&SessionBackend::sendMouseWheelEvent,
|
&SessionBackend::sendMouseWheelEvent,
|
||||||
Qt::QueuedConnection);
|
Qt::QueuedConnection);
|
||||||
|
connect(this,
|
||||||
|
&SessionTab::requestSetClipboardText,
|
||||||
|
m_backend,
|
||||||
|
&SessionBackend::setClipboardText,
|
||||||
|
Qt::QueuedConnection);
|
||||||
|
|
||||||
connect(m_backend,
|
connect(m_backend,
|
||||||
&SessionBackend::stateChanged,
|
&SessionBackend::stateChanged,
|
||||||
@@ -214,10 +245,49 @@ SessionTab::SessionTab(const Profile& profile, QWidget* parent)
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
Qt::QueuedConnection);
|
Qt::QueuedConnection);
|
||||||
|
connect(m_backend,
|
||||||
|
&SessionBackend::remoteClipboardTextChanged,
|
||||||
|
this,
|
||||||
|
&SessionTab::onBackendRemoteClipboardTextChanged,
|
||||||
|
Qt::QueuedConnection);
|
||||||
|
connect(m_backend,
|
||||||
|
&SessionBackend::cursorImageChanged,
|
||||||
|
this,
|
||||||
|
[this](const QImage& image, const QPoint& hotspot) {
|
||||||
|
if (m_rdpDisplay != nullptr) {
|
||||||
|
m_rdpDisplay->setCursorImage(image, hotspot);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Qt::QueuedConnection);
|
||||||
|
connect(m_backend,
|
||||||
|
&SessionBackend::cursorHidden,
|
||||||
|
this,
|
||||||
|
[this]() {
|
||||||
|
if (m_rdpDisplay != nullptr) {
|
||||||
|
m_rdpDisplay->setCursorHidden();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Qt::QueuedConnection);
|
||||||
|
connect(m_backend,
|
||||||
|
&SessionBackend::cursorReset,
|
||||||
|
this,
|
||||||
|
[this]() {
|
||||||
|
if (m_rdpDisplay != nullptr) {
|
||||||
|
m_rdpDisplay->setCursorDefault();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Qt::QueuedConnection);
|
||||||
|
|
||||||
m_backendThread->start();
|
m_backendThread->start();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (m_clipboardSyncSupported) {
|
||||||
|
connect(QApplication::clipboard(),
|
||||||
|
&QClipboard::dataChanged,
|
||||||
|
this,
|
||||||
|
&SessionTab::onSystemClipboardChanged);
|
||||||
|
}
|
||||||
|
|
||||||
setState(SessionState::Disconnected, QStringLiteral("Ready to connect."));
|
setState(SessionState::Disconnected, QStringLiteral("Ready to connect."));
|
||||||
QTimer::singleShot(0, this, &SessionTab::connectSession);
|
QTimer::singleShot(0, this, &SessionTab::connectSession);
|
||||||
}
|
}
|
||||||
@@ -250,21 +320,20 @@ void SessionTab::connectSession()
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const std::optional<SessionConnectOptions> options = buildConnectOptions();
|
requestConnectOptions([this](std::optional<SessionConnectOptions> options) {
|
||||||
if (!options.has_value()) {
|
if (!options.has_value()) {
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
m_lastConnectOptions = options.value();
|
|
||||||
|
|
||||||
if (m_useKodoTermForSsh) {
|
|
||||||
if (!startSshTerminal(options.value())) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
emit requestConnect(options.value());
|
m_lastConnectOptions = options.value();
|
||||||
|
|
||||||
|
if (m_useKodoTermForSsh) {
|
||||||
|
startSshTerminal(options.value());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
emit requestConnect(options.value());
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
void SessionTab::disconnectSession()
|
void SessionTab::disconnectSession()
|
||||||
@@ -290,24 +359,25 @@ void SessionTab::reconnectSession()
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const std::optional<SessionConnectOptions> options = buildConnectOptions();
|
requestConnectOptions([this](std::optional<SessionConnectOptions> options) {
|
||||||
if (!options.has_value()) {
|
if (!options.has_value()) {
|
||||||
return;
|
return;
|
||||||
}
|
|
||||||
|
|
||||||
m_lastConnectOptions = options.value();
|
|
||||||
|
|
||||||
if (m_useKodoTermForSsh) {
|
|
||||||
if (m_sshTerminal != nullptr) {
|
|
||||||
m_sshTerminal->kill();
|
|
||||||
}
|
}
|
||||||
QTimer::singleShot(50,
|
|
||||||
this,
|
|
||||||
[this, options]() { startSshTerminal(options.value()); });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
emit requestReconnect(options.value());
|
m_lastConnectOptions = options.value();
|
||||||
|
|
||||||
|
if (m_useKodoTermForSsh) {
|
||||||
|
if (m_sshTerminal != nullptr) {
|
||||||
|
m_sshTerminal->kill();
|
||||||
|
}
|
||||||
|
QTimer::singleShot(50,
|
||||||
|
this,
|
||||||
|
[this, options]() { startSshTerminal(options.value()); });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
emit requestReconnect(options.value());
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
void SessionTab::clearTerminal()
|
void SessionTab::clearTerminal()
|
||||||
@@ -347,6 +417,7 @@ void SessionTab::setTerminalThemeName(const QString& themeName)
|
|||||||
m_terminalThemeName = normalized;
|
m_terminalThemeName = normalized;
|
||||||
applyTerminalTheme(m_terminalThemeName);
|
applyTerminalTheme(m_terminalThemeName);
|
||||||
appendEvent(QStringLiteral("Terminal theme set to %1.").arg(m_terminalThemeName));
|
appendEvent(QStringLiteral("Terminal theme set to %1.").arg(m_terminalThemeName));
|
||||||
|
emit terminalThemeChanged(m_terminalThemeName);
|
||||||
}
|
}
|
||||||
|
|
||||||
QString SessionTab::terminalThemeName() const
|
QString SessionTab::terminalThemeName() const
|
||||||
@@ -364,6 +435,189 @@ bool SessionTab::supportsClearAction() const
|
|||||||
return m_useKodoTermForSsh || m_terminalOutput != nullptr;
|
return m_useKodoTermForSsh || m_terminalOutput != nullptr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool SessionTab::supportsZoom() const
|
||||||
|
{
|
||||||
|
return m_useKodoTermForSsh || m_terminalOutput != nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
void SessionTab::zoomIn()
|
||||||
|
{
|
||||||
|
if (m_useKodoTermForSsh && m_sshTerminal != nullptr) {
|
||||||
|
m_sshTerminal->zoomIn();
|
||||||
|
m_terminalFontPointSize = m_sshTerminal->getConfig().font.pointSize();
|
||||||
|
} else if (m_terminalOutput != nullptr) {
|
||||||
|
m_terminalFontPointSize = m_terminalOutput->font().pointSize() + 1;
|
||||||
|
m_terminalOutput->setFontPointSize(m_terminalFontPointSize);
|
||||||
|
} else {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
emit terminalFontSizeChanged(m_terminalFontPointSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
void SessionTab::zoomOut()
|
||||||
|
{
|
||||||
|
if (m_useKodoTermForSsh && m_sshTerminal != nullptr) {
|
||||||
|
m_sshTerminal->zoomOut();
|
||||||
|
m_terminalFontPointSize = m_sshTerminal->getConfig().font.pointSize();
|
||||||
|
} else if (m_terminalOutput != nullptr) {
|
||||||
|
const int newSize = m_terminalOutput->font().pointSize() - 1;
|
||||||
|
if (newSize < 6) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
m_terminalFontPointSize = newSize;
|
||||||
|
m_terminalOutput->setFontPointSize(m_terminalFontPointSize);
|
||||||
|
} else {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
emit terminalFontSizeChanged(m_terminalFontPointSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
void SessionTab::resetZoom()
|
||||||
|
{
|
||||||
|
if (m_useKodoTermForSsh && m_sshTerminal != nullptr) {
|
||||||
|
m_sshTerminal->resetZoom();
|
||||||
|
m_terminalFontPointSize = m_sshTerminal->getConfig().font.pointSize();
|
||||||
|
} else if (m_terminalOutput != nullptr) {
|
||||||
|
m_terminalFontPointSize = defaultTerminalFont().pointSize();
|
||||||
|
m_terminalOutput->setFontPointSize(m_terminalFontPointSize);
|
||||||
|
} else {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
emit terminalFontSizeChanged(m_terminalFontPointSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
void SessionTab::setTerminalFontPointSize(int pointSize)
|
||||||
|
{
|
||||||
|
const int clamped = qBound(6, pointSize, 72);
|
||||||
|
|
||||||
|
if (m_useKodoTermForSsh && m_sshTerminal != nullptr) {
|
||||||
|
KodoTermConfig config = m_sshTerminal->getConfig();
|
||||||
|
config.font.setPointSize(clamped);
|
||||||
|
m_sshTerminal->setConfig(config);
|
||||||
|
m_terminalFontPointSize = clamped;
|
||||||
|
} else if (m_terminalOutput != nullptr) {
|
||||||
|
m_terminalFontPointSize = clamped;
|
||||||
|
m_terminalOutput->setFontPointSize(clamped);
|
||||||
|
} else {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
emit terminalFontSizeChanged(m_terminalFontPointSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
int SessionTab::terminalFontPointSize() const
|
||||||
|
{
|
||||||
|
return m_terminalFontPointSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool SessionTab::isEventsPanelExpanded() const
|
||||||
|
{
|
||||||
|
return m_eventsPanelExpanded;
|
||||||
|
}
|
||||||
|
|
||||||
|
void SessionTab::setEventsPanelExpanded(bool expanded)
|
||||||
|
{
|
||||||
|
if (m_eventsPanel == nullptr || m_toggleEventsButton == nullptr) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (m_eventsPanelExpanded == expanded && m_eventsPanel->isVisible() == expanded) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
m_eventsPanelExpanded = expanded;
|
||||||
|
setPanelExpanded(m_toggleEventsButton, m_eventsPanel, QStringLiteral("Events"), expanded);
|
||||||
|
emit eventsPanelVisibilityChanged(m_eventsPanelExpanded);
|
||||||
|
}
|
||||||
|
|
||||||
|
void SessionTab::clearEvents()
|
||||||
|
{
|
||||||
|
m_eventEntries.clear();
|
||||||
|
if (m_eventLog != nullptr) {
|
||||||
|
m_eventLog->clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void SessionTab::copyEvents() const
|
||||||
|
{
|
||||||
|
QStringList visibleLines;
|
||||||
|
for (const EventEntry& entry : m_eventEntries) {
|
||||||
|
const bool matchesText = m_eventFilter.isEmpty()
|
||||||
|
|| entry.line.contains(m_eventFilter, Qt::CaseInsensitive);
|
||||||
|
bool matchesSeverity = true;
|
||||||
|
if (m_eventSeverityFilter == EventSeverity::Warning) {
|
||||||
|
matchesSeverity = entry.severity == EventSeverity::Warning;
|
||||||
|
} else if (m_eventSeverityFilter == EventSeverity::Error) {
|
||||||
|
matchesSeverity = entry.severity == EventSeverity::Error;
|
||||||
|
} else if (m_eventSeverityFilter == EventSeverity::Info) {
|
||||||
|
matchesSeverity = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matchesText && matchesSeverity) {
|
||||||
|
visibleLines.push_back(entry.line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!visibleLines.isEmpty()) {
|
||||||
|
QApplication::clipboard()->setText(visibleLines.join(QChar::fromLatin1('\n')));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void SessionTab::exportEventsToFile()
|
||||||
|
{
|
||||||
|
QStringList visibleLines;
|
||||||
|
for (const EventEntry& entry : m_eventEntries) {
|
||||||
|
const bool matchesText = m_eventFilter.isEmpty()
|
||||||
|
|| entry.line.contains(m_eventFilter, Qt::CaseInsensitive);
|
||||||
|
bool matchesSeverity = true;
|
||||||
|
if (m_eventSeverityFilter == EventSeverity::Warning) {
|
||||||
|
matchesSeverity = entry.severity == EventSeverity::Warning;
|
||||||
|
} else if (m_eventSeverityFilter == EventSeverity::Error) {
|
||||||
|
matchesSeverity = entry.severity == EventSeverity::Error;
|
||||||
|
} else if (m_eventSeverityFilter == EventSeverity::Info) {
|
||||||
|
matchesSeverity = true;
|
||||||
|
}
|
||||||
|
if (matchesText && matchesSeverity) {
|
||||||
|
visibleLines.push_back(entry.line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (visibleLines.isEmpty()) {
|
||||||
|
QMessageBox::information(this,
|
||||||
|
QStringLiteral("Export Events"),
|
||||||
|
QStringLiteral("No events match the current filters."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const QString defaultName =
|
||||||
|
QStringLiteral("orbithub-events-%1.log")
|
||||||
|
.arg(QDateTime::currentDateTime().toString(QStringLiteral("yyyyMMdd-HHmmss")));
|
||||||
|
const QString targetPath = QFileDialog::getSaveFileName(this,
|
||||||
|
QStringLiteral("Export Session Events"),
|
||||||
|
defaultName,
|
||||||
|
QStringLiteral("Log Files (*.log);;Text Files (*.txt);;All Files (*)"));
|
||||||
|
if (targetPath.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
QFile file(targetPath);
|
||||||
|
if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) {
|
||||||
|
QMessageBox::warning(this,
|
||||||
|
QStringLiteral("Export Events"),
|
||||||
|
QStringLiteral("Failed to write file: %1").arg(targetPath));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
QTextStream stream(&file);
|
||||||
|
for (const QString& line : visibleLines) {
|
||||||
|
stream << line << '\n';
|
||||||
|
}
|
||||||
|
file.close();
|
||||||
|
}
|
||||||
|
|
||||||
void SessionTab::onBackendStateChanged(SessionState state, const QString& message)
|
void SessionTab::onBackendStateChanged(SessionState state, const QString& message)
|
||||||
{
|
{
|
||||||
setState(state, message);
|
setState(state, message);
|
||||||
@@ -408,13 +662,46 @@ void SessionTab::onBackendHostKeyConfirmationRequested(const QString& prompt)
|
|||||||
emit requestHostKeyConfirmation(reply == QMessageBox::Yes);
|
emit requestHostKeyConfirmation(reply == QMessageBox::Yes);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void SessionTab::onBackendRemoteClipboardTextChanged(const QString& text)
|
||||||
|
{
|
||||||
|
if (text == m_lastSyncedClipboardText) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
m_lastSyncedClipboardText = text;
|
||||||
|
QApplication::clipboard()->setText(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
void SessionTab::onSystemClipboardChanged()
|
||||||
|
{
|
||||||
|
if (!m_clipboardSyncSupported || m_state != SessionState::Connected) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const QClipboard* clipboard = QApplication::clipboard();
|
||||||
|
if (!clipboard->mimeData()->hasText()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const QString text = clipboard->text();
|
||||||
|
if (text == m_lastSyncedClipboardText) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
m_lastSyncedClipboardText = text;
|
||||||
|
emit requestSetClipboardText(text);
|
||||||
|
}
|
||||||
|
|
||||||
void SessionTab::setupUi()
|
void SessionTab::setupUi()
|
||||||
{
|
{
|
||||||
auto* rootLayout = new QVBoxLayout(this);
|
auto* rootLayout = new QVBoxLayout(this);
|
||||||
|
|
||||||
if (m_useKodoTermForSsh) {
|
if (m_useKodoTermForSsh) {
|
||||||
m_sshTerminal = new KodoTerm(this);
|
m_sshTerminal = new KodoTerm(this);
|
||||||
const QFont terminalFont = defaultTerminalFont();
|
QFont terminalFont = defaultTerminalFont();
|
||||||
|
if (m_terminalFontPointSize > 0) {
|
||||||
|
terminalFont.setPointSize(m_terminalFontPointSize);
|
||||||
|
}
|
||||||
|
|
||||||
KodoTermConfig config = m_sshTerminal->getConfig();
|
KodoTermConfig config = m_sshTerminal->getConfig();
|
||||||
config.font = terminalFont;
|
config.font = terminalFont;
|
||||||
@@ -427,7 +714,11 @@ void SessionTab::setupUi()
|
|||||||
rootLayout->addWidget(m_rdpDisplay, 1);
|
rootLayout->addWidget(m_rdpDisplay, 1);
|
||||||
} else {
|
} else {
|
||||||
m_terminalOutput = new TerminalView(this);
|
m_terminalOutput = new TerminalView(this);
|
||||||
m_terminalOutput->setFont(defaultTerminalFont());
|
QFont fallbackFont = defaultTerminalFont();
|
||||||
|
if (m_terminalFontPointSize > 0) {
|
||||||
|
fallbackFont.setPointSize(m_terminalFontPointSize);
|
||||||
|
}
|
||||||
|
m_terminalOutput->setFont(fallbackFont);
|
||||||
m_terminalOutput->setMinimumHeight(260);
|
m_terminalOutput->setMinimumHeight(260);
|
||||||
m_terminalOutput->setReadOnly(true);
|
m_terminalOutput->setReadOnly(true);
|
||||||
if (m_profile.protocol.compare(QStringLiteral("VNC"), Qt::CaseInsensitive) == 0) {
|
if (m_profile.protocol.compare(QStringLiteral("VNC"), Qt::CaseInsensitive) == 0) {
|
||||||
@@ -442,10 +733,63 @@ void SessionTab::setupUi()
|
|||||||
|
|
||||||
applyTerminalTheme(m_terminalThemeName);
|
applyTerminalTheme(m_terminalThemeName);
|
||||||
|
|
||||||
|
auto* passwordPromptLayout = new QHBoxLayout();
|
||||||
|
m_passwordPromptLabel = new QLabel(this);
|
||||||
|
m_passwordPromptInput = new QLineEdit(this);
|
||||||
|
m_passwordPromptInput->setEchoMode(QLineEdit::Password);
|
||||||
|
m_passwordPromptConnectButton = new QPushButton(QStringLiteral("Connect"), this);
|
||||||
|
m_passwordPromptCancelButton = new QPushButton(QStringLiteral("Cancel"), this);
|
||||||
|
passwordPromptLayout->addWidget(m_passwordPromptLabel);
|
||||||
|
passwordPromptLayout->addWidget(m_passwordPromptInput, 1);
|
||||||
|
passwordPromptLayout->addWidget(m_passwordPromptConnectButton);
|
||||||
|
passwordPromptLayout->addWidget(m_passwordPromptCancelButton);
|
||||||
|
|
||||||
|
m_passwordPromptBar = new QWidget(this);
|
||||||
|
m_passwordPromptBar->setLayout(passwordPromptLayout);
|
||||||
|
m_passwordPromptBar->setAutoFillBackground(true);
|
||||||
|
m_passwordPromptBar->setVisible(false);
|
||||||
|
rootLayout->addWidget(m_passwordPromptBar);
|
||||||
|
|
||||||
|
connect(m_passwordPromptConnectButton, &QPushButton::clicked, this, [this]() {
|
||||||
|
if (!m_passwordPromptCallback) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const QString password = m_passwordPromptInput->text();
|
||||||
|
const auto callback = m_passwordPromptCallback;
|
||||||
|
hidePasswordPrompt();
|
||||||
|
callback(password);
|
||||||
|
});
|
||||||
|
connect(m_passwordPromptCancelButton, &QPushButton::clicked, this, [this]() {
|
||||||
|
if (!m_passwordPromptCallback) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const auto callback = m_passwordPromptCallback;
|
||||||
|
hidePasswordPrompt();
|
||||||
|
callback(std::nullopt);
|
||||||
|
});
|
||||||
|
connect(m_passwordPromptInput,
|
||||||
|
&QLineEdit::returnPressed,
|
||||||
|
m_passwordPromptConnectButton,
|
||||||
|
&QPushButton::click);
|
||||||
|
|
||||||
auto* eventsHeader = new QHBoxLayout();
|
auto* eventsHeader = new QHBoxLayout();
|
||||||
m_toggleEventsButton = new QToolButton(this);
|
m_toggleEventsButton = new QToolButton(this);
|
||||||
m_toggleEventsButton->setCheckable(true);
|
m_toggleEventsButton->setCheckable(true);
|
||||||
|
m_eventFilterInput = new QLineEdit(this);
|
||||||
|
m_eventFilterInput->setPlaceholderText(QStringLiteral("Filter events..."));
|
||||||
|
m_eventSeverityFilterInput = new QComboBox(this);
|
||||||
|
m_eventSeverityFilterInput->addItem(QStringLiteral("All"));
|
||||||
|
m_eventSeverityFilterInput->addItem(QStringLiteral("Warnings"));
|
||||||
|
m_eventSeverityFilterInput->addItem(QStringLiteral("Errors"));
|
||||||
|
m_clearEventsButton = new QToolButton(this);
|
||||||
|
m_clearEventsButton->setText(QStringLiteral("Clear Events"));
|
||||||
|
m_exportEventsButton = new QToolButton(this);
|
||||||
|
m_exportEventsButton->setText(QStringLiteral("Export Events"));
|
||||||
eventsHeader->addWidget(m_toggleEventsButton);
|
eventsHeader->addWidget(m_toggleEventsButton);
|
||||||
|
eventsHeader->addWidget(m_eventFilterInput, 1);
|
||||||
|
eventsHeader->addWidget(m_eventSeverityFilterInput);
|
||||||
|
eventsHeader->addWidget(m_exportEventsButton);
|
||||||
|
eventsHeader->addWidget(m_clearEventsButton);
|
||||||
eventsHeader->addStretch();
|
eventsHeader->addStretch();
|
||||||
|
|
||||||
m_eventsPanel = new QWidget(this);
|
m_eventsPanel = new QWidget(this);
|
||||||
@@ -464,15 +808,43 @@ void SessionTab::setupUi()
|
|||||||
rootLayout->addLayout(eventsHeader);
|
rootLayout->addLayout(eventsHeader);
|
||||||
rootLayout->addWidget(m_eventsPanel);
|
rootLayout->addWidget(m_eventsPanel);
|
||||||
|
|
||||||
setPanelExpanded(m_toggleEventsButton, m_eventsPanel, QStringLiteral("Events"), false);
|
setPanelExpanded(
|
||||||
|
m_toggleEventsButton, m_eventsPanel, QStringLiteral("Events"), m_eventsPanelExpanded);
|
||||||
|
|
||||||
connect(m_toggleEventsButton,
|
connect(m_toggleEventsButton,
|
||||||
&QToolButton::toggled,
|
&QToolButton::toggled,
|
||||||
this,
|
this,
|
||||||
[this](bool expanded) {
|
[this](bool expanded) {
|
||||||
setPanelExpanded(
|
setEventsPanelExpanded(expanded);
|
||||||
m_toggleEventsButton, m_eventsPanel, QStringLiteral("Events"), expanded);
|
|
||||||
});
|
});
|
||||||
|
connect(m_eventFilterInput,
|
||||||
|
&QLineEdit::textChanged,
|
||||||
|
this,
|
||||||
|
[this](const QString& text) {
|
||||||
|
m_eventFilter = text.trimmed();
|
||||||
|
refreshEventLogView();
|
||||||
|
});
|
||||||
|
connect(m_eventSeverityFilterInput,
|
||||||
|
&QComboBox::currentTextChanged,
|
||||||
|
this,
|
||||||
|
[this](const QString& selected) {
|
||||||
|
if (selected.compare(QStringLiteral("Errors"), Qt::CaseInsensitive) == 0) {
|
||||||
|
m_eventSeverityFilter = EventSeverity::Error;
|
||||||
|
} else if (selected.compare(QStringLiteral("Warnings"), Qt::CaseInsensitive) == 0) {
|
||||||
|
m_eventSeverityFilter = EventSeverity::Warning;
|
||||||
|
} else {
|
||||||
|
m_eventSeverityFilter = EventSeverity::Info;
|
||||||
|
}
|
||||||
|
refreshEventLogView();
|
||||||
|
});
|
||||||
|
connect(m_exportEventsButton,
|
||||||
|
&QToolButton::clicked,
|
||||||
|
this,
|
||||||
|
[this]() { exportEventsToFile(); });
|
||||||
|
connect(m_clearEventsButton,
|
||||||
|
&QToolButton::clicked,
|
||||||
|
this,
|
||||||
|
[this]() { clearEvents(); });
|
||||||
|
|
||||||
if (m_terminalOutput != nullptr) {
|
if (m_terminalOutput != nullptr) {
|
||||||
connect(m_terminalOutput,
|
connect(m_terminalOutput,
|
||||||
@@ -513,77 +885,85 @@ void SessionTab::setupUi()
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
std::optional<SessionConnectOptions> SessionTab::buildConnectOptions()
|
void SessionTab::requestConnectOptions(
|
||||||
|
std::function<void(std::optional<SessionConnectOptions>)> callback)
|
||||||
{
|
{
|
||||||
SessionConnectOptions options;
|
SessionConnectOptions baseOptions;
|
||||||
options.knownHostsPolicy = m_profile.knownHostsPolicy;
|
baseOptions.knownHostsPolicy = m_profile.knownHostsPolicy;
|
||||||
|
|
||||||
const bool isSsh = m_profile.protocol.compare(QStringLiteral("SSH"), Qt::CaseInsensitive) == 0;
|
const bool isSsh = m_profile.protocol.compare(QStringLiteral("SSH"), Qt::CaseInsensitive) == 0;
|
||||||
const bool isRdp = m_profile.protocol.compare(QStringLiteral("RDP"), Qt::CaseInsensitive) == 0;
|
const bool isRdp = m_profile.protocol.compare(QStringLiteral("RDP"), Qt::CaseInsensitive) == 0;
|
||||||
|
|
||||||
if (!isSsh && !isRdp) {
|
if (!isSsh && !isRdp) {
|
||||||
return options;
|
callback(baseOptions);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isRdp) {
|
if (isRdp) {
|
||||||
if (m_profile.authMode.compare(QStringLiteral("Password"), Qt::CaseInsensitive) != 0) {
|
if (m_profile.authMode.compare(QStringLiteral("Password"), Qt::CaseInsensitive) != 0) {
|
||||||
return options;
|
callback(baseOptions);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool accepted = false;
|
const QString label = QStringLiteral("RDP password for %1:")
|
||||||
const QString password = QInputDialog::getText(
|
.arg(m_profile.username.trimmed().isEmpty()
|
||||||
this,
|
? m_profile.host
|
||||||
QStringLiteral("RDP Password"),
|
: QStringLiteral("%1@%2").arg(m_profile.username, m_profile.host));
|
||||||
QStringLiteral("Password for %1:")
|
|
||||||
.arg(m_profile.username.trimmed().isEmpty()
|
|
||||||
? m_profile.host
|
|
||||||
: QStringLiteral("%1@%2").arg(m_profile.username, m_profile.host)),
|
|
||||||
QLineEdit::Password,
|
|
||||||
QString(),
|
|
||||||
&accepted);
|
|
||||||
if (!accepted) {
|
|
||||||
return std::nullopt;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (password.isEmpty()) {
|
showPasswordPrompt(
|
||||||
QMessageBox::warning(this,
|
label,
|
||||||
QStringLiteral("Connect"),
|
[this, baseOptions, callback](std::optional<QString> password) {
|
||||||
QStringLiteral("Password is required for password authentication."));
|
if (!password.has_value()) {
|
||||||
return std::nullopt;
|
callback(std::nullopt);
|
||||||
}
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
options.password = password;
|
if (password->isEmpty()) {
|
||||||
return options;
|
QMessageBox::warning(
|
||||||
|
this,
|
||||||
|
QStringLiteral("Connect"),
|
||||||
|
QStringLiteral("Password is required for password authentication."));
|
||||||
|
callback(std::nullopt);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
SessionConnectOptions options = baseOptions;
|
||||||
|
options.password = password.value();
|
||||||
|
callback(options);
|
||||||
|
});
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (m_useKodoTermForSsh
|
if (m_useKodoTermForSsh
|
||||||
&& m_profile.authMode.compare(QStringLiteral("Password"), Qt::CaseInsensitive) == 0) {
|
&& m_profile.authMode.compare(QStringLiteral("Password"), Qt::CaseInsensitive) == 0) {
|
||||||
// Password is entered directly in terminal prompt.
|
// Password is entered directly in terminal prompt.
|
||||||
return options;
|
callback(baseOptions);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (m_profile.authMode.compare(QStringLiteral("Password"), Qt::CaseInsensitive) == 0) {
|
if (m_profile.authMode.compare(QStringLiteral("Password"), Qt::CaseInsensitive) == 0) {
|
||||||
bool accepted = false;
|
showPasswordPrompt(
|
||||||
const QString password = QInputDialog::getText(this,
|
QStringLiteral("SSH password for %1@%2:").arg(m_profile.username, m_profile.host),
|
||||||
QStringLiteral("SSH Password"),
|
[this, baseOptions, callback](std::optional<QString> password) {
|
||||||
QStringLiteral("Password for %1@%2:")
|
if (!password.has_value()) {
|
||||||
.arg(m_profile.username, m_profile.host),
|
callback(std::nullopt);
|
||||||
QLineEdit::Password,
|
return;
|
||||||
QString(),
|
}
|
||||||
&accepted);
|
|
||||||
if (!accepted) {
|
|
||||||
return std::nullopt;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (password.isEmpty()) {
|
if (password->isEmpty()) {
|
||||||
QMessageBox::warning(this,
|
QMessageBox::warning(
|
||||||
QStringLiteral("Connect"),
|
this,
|
||||||
QStringLiteral("Password is required for password authentication."));
|
QStringLiteral("Connect"),
|
||||||
return std::nullopt;
|
QStringLiteral("Password is required for password authentication."));
|
||||||
}
|
callback(std::nullopt);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
options.password = password;
|
SessionConnectOptions options = baseOptions;
|
||||||
return options;
|
options.password = password.value();
|
||||||
|
callback(options);
|
||||||
|
});
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
QString keyPath = m_profile.privateKeyPath.trimmed();
|
QString keyPath = m_profile.privateKeyPath.trimmed();
|
||||||
@@ -593,7 +973,8 @@ std::optional<SessionConnectOptions> SessionTab::buildConnectOptions()
|
|||||||
QString(),
|
QString(),
|
||||||
QStringLiteral("All Files (*)"));
|
QStringLiteral("All Files (*)"));
|
||||||
if (keyPath.isEmpty()) {
|
if (keyPath.isEmpty()) {
|
||||||
return std::nullopt;
|
callback(std::nullopt);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -601,11 +982,35 @@ std::optional<SessionConnectOptions> SessionTab::buildConnectOptions()
|
|||||||
QMessageBox::warning(this,
|
QMessageBox::warning(this,
|
||||||
QStringLiteral("Connect"),
|
QStringLiteral("Connect"),
|
||||||
QStringLiteral("Private key file not found: %1").arg(keyPath));
|
QStringLiteral("Private key file not found: %1").arg(keyPath));
|
||||||
return std::nullopt;
|
callback(std::nullopt);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
SessionConnectOptions options = baseOptions;
|
||||||
options.privateKeyPath = keyPath;
|
options.privateKeyPath = keyPath;
|
||||||
return options;
|
callback(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
void SessionTab::showPasswordPrompt(const QString& labelText,
|
||||||
|
std::function<void(std::optional<QString>)> callback)
|
||||||
|
{
|
||||||
|
if (m_passwordPromptCallback) {
|
||||||
|
const auto previousCallback = m_passwordPromptCallback;
|
||||||
|
m_passwordPromptCallback = nullptr;
|
||||||
|
previousCallback(std::nullopt);
|
||||||
|
}
|
||||||
|
|
||||||
|
m_passwordPromptCallback = std::move(callback);
|
||||||
|
m_passwordPromptLabel->setText(labelText);
|
||||||
|
m_passwordPromptInput->clear();
|
||||||
|
m_passwordPromptBar->setVisible(true);
|
||||||
|
m_passwordPromptInput->setFocus();
|
||||||
|
}
|
||||||
|
|
||||||
|
void SessionTab::hidePasswordPrompt()
|
||||||
|
{
|
||||||
|
m_passwordPromptBar->setVisible(false);
|
||||||
|
m_passwordPromptCallback = nullptr;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool SessionTab::validateProfileForConnect()
|
bool SessionTab::validateProfileForConnect()
|
||||||
@@ -639,7 +1044,14 @@ bool SessionTab::validateProfileForConnect()
|
|||||||
void SessionTab::appendEvent(const QString& message)
|
void SessionTab::appendEvent(const QString& message)
|
||||||
{
|
{
|
||||||
const QString timestamp = QDateTime::currentDateTime().toString(QStringLiteral("yyyy-MM-dd HH:mm:ss"));
|
const QString timestamp = QDateTime::currentDateTime().toString(QStringLiteral("yyyy-MM-dd HH:mm:ss"));
|
||||||
m_eventLog->appendPlainText(QStringLiteral("[%1] %2").arg(timestamp, message));
|
m_eventEntries.push_back(
|
||||||
|
EventEntry{QStringLiteral("[%1] %2").arg(timestamp, message),
|
||||||
|
classifyEventSeverity(message)});
|
||||||
|
constexpr int kMaxEventLines = 5000;
|
||||||
|
while (m_eventEntries.size() > static_cast<size_t>(kMaxEventLines)) {
|
||||||
|
m_eventEntries.erase(m_eventEntries.begin());
|
||||||
|
}
|
||||||
|
refreshEventLogView();
|
||||||
}
|
}
|
||||||
|
|
||||||
void SessionTab::setState(SessionState state, const QString& message)
|
void SessionTab::setState(SessionState state, const QString& message)
|
||||||
@@ -803,3 +1215,51 @@ void SessionTab::applyTerminalTheme(const QString& themeName)
|
|||||||
m_terminalOutput->setThemeName(themeName);
|
m_terminalOutput->setThemeName(themeName);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void SessionTab::refreshEventLogView()
|
||||||
|
{
|
||||||
|
if (m_eventLog == nullptr) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
QStringList visibleLines;
|
||||||
|
visibleLines.reserve(static_cast<int>(m_eventEntries.size()));
|
||||||
|
|
||||||
|
for (const EventEntry& entry : m_eventEntries) {
|
||||||
|
if (!m_eventFilter.isEmpty() && !entry.line.contains(m_eventFilter, Qt::CaseInsensitive)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (m_eventSeverityFilter == EventSeverity::Warning
|
||||||
|
&& entry.severity != EventSeverity::Warning) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (m_eventSeverityFilter == EventSeverity::Error
|
||||||
|
&& entry.severity != EventSeverity::Error) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
visibleLines.push_back(entry.line);
|
||||||
|
}
|
||||||
|
|
||||||
|
m_eventLog->setPlainText(visibleLines.join(QChar::fromLatin1('\n')));
|
||||||
|
m_eventLog->moveCursor(QTextCursor::End);
|
||||||
|
}
|
||||||
|
|
||||||
|
SessionTab::EventSeverity SessionTab::classifyEventSeverity(const QString& message)
|
||||||
|
{
|
||||||
|
const QString normalized = message.trimmed().toLower();
|
||||||
|
if (normalized.startsWith(QStringLiteral("error:"))
|
||||||
|
|| normalized.contains(QStringLiteral("failed"))
|
||||||
|
|| normalized.contains(QStringLiteral("permission denied"))) {
|
||||||
|
return EventSeverity::Error;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalized.startsWith(QStringLiteral("warning:"))
|
||||||
|
|| normalized.contains(QStringLiteral("warning"))) {
|
||||||
|
return EventSeverity::Warning;
|
||||||
|
}
|
||||||
|
|
||||||
|
return EventSeverity::Info;
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,9 +5,12 @@
|
|||||||
#include "session_backend.h"
|
#include "session_backend.h"
|
||||||
|
|
||||||
#include <QWidget>
|
#include <QWidget>
|
||||||
|
#include <QStringList>
|
||||||
#include <QtGlobal>
|
#include <QtGlobal>
|
||||||
|
|
||||||
|
#include <functional>
|
||||||
#include <optional>
|
#include <optional>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
class QPlainTextEdit;
|
class QPlainTextEdit;
|
||||||
class QThread;
|
class QThread;
|
||||||
@@ -15,14 +18,27 @@ class SessionBackend;
|
|||||||
class TerminalView;
|
class TerminalView;
|
||||||
class RdpDisplayWidget;
|
class RdpDisplayWidget;
|
||||||
class QToolButton;
|
class QToolButton;
|
||||||
|
class QLineEdit;
|
||||||
|
class QComboBox;
|
||||||
|
class QLabel;
|
||||||
|
class QPushButton;
|
||||||
class KodoTerm;
|
class KodoTerm;
|
||||||
|
|
||||||
|
struct SessionUiPreferences
|
||||||
|
{
|
||||||
|
QString terminalThemeName = QStringLiteral("Dark");
|
||||||
|
bool eventsPanelExpanded = false;
|
||||||
|
int terminalFontPointSize = 0;
|
||||||
|
};
|
||||||
|
|
||||||
class SessionTab : public QWidget
|
class SessionTab : public QWidget
|
||||||
{
|
{
|
||||||
Q_OBJECT
|
Q_OBJECT
|
||||||
|
|
||||||
public:
|
public:
|
||||||
explicit SessionTab(const Profile& profile, QWidget* parent = nullptr);
|
explicit SessionTab(const Profile& profile,
|
||||||
|
const SessionUiPreferences& preferences,
|
||||||
|
QWidget* parent = nullptr);
|
||||||
~SessionTab() override;
|
~SessionTab() override;
|
||||||
|
|
||||||
QString tabTitle() const;
|
QString tabTitle() const;
|
||||||
@@ -34,10 +50,24 @@ public:
|
|||||||
QString terminalThemeName() const;
|
QString terminalThemeName() const;
|
||||||
bool supportsThemeSelection() const;
|
bool supportsThemeSelection() const;
|
||||||
bool supportsClearAction() const;
|
bool supportsClearAction() const;
|
||||||
|
bool supportsZoom() const;
|
||||||
|
void zoomIn();
|
||||||
|
void zoomOut();
|
||||||
|
void resetZoom();
|
||||||
|
void setTerminalFontPointSize(int pointSize);
|
||||||
|
int terminalFontPointSize() const;
|
||||||
|
bool isEventsPanelExpanded() const;
|
||||||
|
void setEventsPanelExpanded(bool expanded);
|
||||||
|
void clearEvents();
|
||||||
|
void copyEvents() const;
|
||||||
|
void exportEventsToFile();
|
||||||
|
|
||||||
signals:
|
signals:
|
||||||
void tabTitleChanged(const QString& title);
|
void tabTitleChanged(const QString& title);
|
||||||
void tabStateChanged(SessionState state);
|
void tabStateChanged(SessionState state);
|
||||||
|
void terminalThemeChanged(const QString& themeName);
|
||||||
|
void terminalFontSizeChanged(int pointSize);
|
||||||
|
void eventsPanelVisibilityChanged(bool expanded);
|
||||||
void requestConnect(const SessionConnectOptions& options);
|
void requestConnect(const SessionConnectOptions& options);
|
||||||
void requestDisconnect();
|
void requestDisconnect();
|
||||||
void requestReconnect(const SessionConnectOptions& options);
|
void requestReconnect(const SessionConnectOptions& options);
|
||||||
@@ -52,6 +82,7 @@ signals:
|
|||||||
void requestMouseMoveEvent(int x, int y);
|
void requestMouseMoveEvent(int x, int y);
|
||||||
void requestMouseButtonEvent(int x, int y, int button, bool pressed);
|
void requestMouseButtonEvent(int x, int y, int button, bool pressed);
|
||||||
void requestMouseWheelEvent(int x, int y, int deltaX, int deltaY);
|
void requestMouseWheelEvent(int x, int y, int deltaX, int deltaY);
|
||||||
|
void requestSetClipboardText(const QString& text);
|
||||||
|
|
||||||
private slots:
|
private slots:
|
||||||
void onBackendStateChanged(SessionState state, const QString& message);
|
void onBackendStateChanged(SessionState state, const QString& message);
|
||||||
@@ -59,6 +90,8 @@ private slots:
|
|||||||
void onBackendConnectionError(const QString& displayMessage, const QString& rawMessage);
|
void onBackendConnectionError(const QString& displayMessage, const QString& rawMessage);
|
||||||
void onBackendOutputReceived(const QString& text);
|
void onBackendOutputReceived(const QString& text);
|
||||||
void onBackendHostKeyConfirmationRequested(const QString& prompt);
|
void onBackendHostKeyConfirmationRequested(const QString& prompt);
|
||||||
|
void onBackendRemoteClipboardTextChanged(const QString& text);
|
||||||
|
void onSystemClipboardChanged();
|
||||||
|
|
||||||
private:
|
private:
|
||||||
Profile m_profile;
|
Profile m_profile;
|
||||||
@@ -69,16 +102,45 @@ private:
|
|||||||
QString m_lastError;
|
QString m_lastError;
|
||||||
SessionConnectOptions m_lastConnectOptions;
|
SessionConnectOptions m_lastConnectOptions;
|
||||||
QString m_terminalThemeName;
|
QString m_terminalThemeName;
|
||||||
|
int m_terminalFontPointSize;
|
||||||
|
QString m_lastSyncedClipboardText;
|
||||||
|
bool m_clipboardSyncSupported;
|
||||||
|
|
||||||
KodoTerm* m_sshTerminal;
|
KodoTerm* m_sshTerminal;
|
||||||
RdpDisplayWidget* m_rdpDisplay;
|
RdpDisplayWidget* m_rdpDisplay;
|
||||||
TerminalView* m_terminalOutput;
|
TerminalView* m_terminalOutput;
|
||||||
QPlainTextEdit* m_eventLog;
|
QPlainTextEdit* m_eventLog;
|
||||||
QToolButton* m_toggleEventsButton;
|
QToolButton* m_toggleEventsButton;
|
||||||
|
QLineEdit* m_eventFilterInput;
|
||||||
|
QComboBox* m_eventSeverityFilterInput;
|
||||||
|
QToolButton* m_clearEventsButton;
|
||||||
|
QToolButton* m_exportEventsButton;
|
||||||
QWidget* m_eventsPanel;
|
QWidget* m_eventsPanel;
|
||||||
|
QWidget* m_passwordPromptBar;
|
||||||
|
QLabel* m_passwordPromptLabel;
|
||||||
|
QLineEdit* m_passwordPromptInput;
|
||||||
|
QPushButton* m_passwordPromptConnectButton;
|
||||||
|
QPushButton* m_passwordPromptCancelButton;
|
||||||
|
std::function<void(std::optional<QString>)> m_passwordPromptCallback;
|
||||||
|
enum class EventSeverity {
|
||||||
|
Info,
|
||||||
|
Warning,
|
||||||
|
Error,
|
||||||
|
};
|
||||||
|
struct EventEntry {
|
||||||
|
QString line;
|
||||||
|
EventSeverity severity;
|
||||||
|
};
|
||||||
|
std::vector<EventEntry> m_eventEntries;
|
||||||
|
QString m_eventFilter;
|
||||||
|
EventSeverity m_eventSeverityFilter;
|
||||||
|
bool m_eventsPanelExpanded;
|
||||||
|
|
||||||
void setupUi();
|
void setupUi();
|
||||||
std::optional<SessionConnectOptions> buildConnectOptions();
|
void requestConnectOptions(std::function<void(std::optional<SessionConnectOptions>)> callback);
|
||||||
|
void showPasswordPrompt(const QString& labelText,
|
||||||
|
std::function<void(std::optional<QString>)> callback);
|
||||||
|
void hidePasswordPrompt();
|
||||||
bool validateProfileForConnect();
|
bool validateProfileForConnect();
|
||||||
void appendEvent(const QString& message);
|
void appendEvent(const QString& message);
|
||||||
void setState(SessionState state, const QString& message);
|
void setState(SessionState state, const QString& message);
|
||||||
@@ -87,6 +149,8 @@ private:
|
|||||||
void setPanelExpanded(QToolButton* button, QWidget* panel, const QString& name, bool expanded);
|
void setPanelExpanded(QToolButton* button, QWidget* panel, const QString& name, bool expanded);
|
||||||
bool startSshTerminal(const SessionConnectOptions& options);
|
bool startSshTerminal(const SessionConnectOptions& options);
|
||||||
void applyTerminalTheme(const QString& themeName);
|
void applyTerminalTheme(const QString& themeName);
|
||||||
|
void refreshEventLogView();
|
||||||
|
static EventSeverity classifyEventSeverity(const QString& message);
|
||||||
};
|
};
|
||||||
|
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -1,11 +1,17 @@
|
|||||||
#include "session_window.h"
|
#include "session_window.h"
|
||||||
|
|
||||||
|
#include "about_dialog.h"
|
||||||
|
#include "profiles_window.h"
|
||||||
|
#include <QApplication>
|
||||||
#include "session_tab.h"
|
#include "session_tab.h"
|
||||||
|
|
||||||
#include <QAction>
|
#include <QAction>
|
||||||
#include <QColor>
|
#include <QColor>
|
||||||
|
#include <QInputDialog>
|
||||||
#include <QMenu>
|
#include <QMenu>
|
||||||
|
#include <QMenuBar>
|
||||||
#include <QPalette>
|
#include <QPalette>
|
||||||
|
#include <QSettings>
|
||||||
#include <QStringList>
|
#include <QStringList>
|
||||||
#include <QTabBar>
|
#include <QTabBar>
|
||||||
#include <QTabWidget>
|
#include <QTabWidget>
|
||||||
@@ -33,11 +39,14 @@ QStringList terminalThemeNames()
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
SessionWindow::SessionWindow(const Profile& profile, QWidget* parent)
|
SessionWindow::SessionWindow(QWidget* parent)
|
||||||
: QMainWindow(parent), m_tabs(new QTabWidget(this))
|
: QMainWindow(parent), m_tabs(new QTabWidget(this)), m_profilesWidget(nullptr)
|
||||||
{
|
{
|
||||||
setWindowTitle(QStringLiteral("OrbitHub Session - %1").arg(profile.name));
|
loadUiPreferences();
|
||||||
|
|
||||||
|
setWindowTitle(QStringLiteral("OrbitHub"));
|
||||||
resize(1080, 760);
|
resize(1080, 760);
|
||||||
|
setWindowIcon(QApplication::windowIcon());
|
||||||
|
|
||||||
m_tabs->setTabsClosable(true);
|
m_tabs->setTabsClosable(true);
|
||||||
connect(m_tabs,
|
connect(m_tabs,
|
||||||
@@ -50,8 +59,8 @@ SessionWindow::SessionWindow(const Profile& profile, QWidget* parent)
|
|||||||
}
|
}
|
||||||
m_tabs->removeTab(index);
|
m_tabs->removeTab(index);
|
||||||
delete tab;
|
delete tab;
|
||||||
if (m_tabs->count() == 0) {
|
if (sessionTabCount() == 0) {
|
||||||
close();
|
setWindowTitle(QStringLiteral("OrbitHub"));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
m_tabs->tabBar()->setContextMenuPolicy(Qt::CustomContextMenu);
|
m_tabs->tabBar()->setContextMenuPolicy(Qt::CustomContextMenu);
|
||||||
@@ -72,6 +81,12 @@ SessionWindow::SessionWindow(const Profile& profile, QWidget* parent)
|
|||||||
QMenu menu(this);
|
QMenu menu(this);
|
||||||
QAction* disconnectAction = menu.addAction(QStringLiteral("Disconnect"));
|
QAction* disconnectAction = menu.addAction(QStringLiteral("Disconnect"));
|
||||||
QAction* reconnectAction = menu.addAction(QStringLiteral("Reconnect"));
|
QAction* reconnectAction = menu.addAction(QStringLiteral("Reconnect"));
|
||||||
|
QAction* toggleEventsAction = menu.addAction(
|
||||||
|
tab->isEventsPanelExpanded() ? QStringLiteral("Hide Events")
|
||||||
|
: QStringLiteral("Show Events"));
|
||||||
|
QAction* copyEventsAction = menu.addAction(QStringLiteral("Copy Events"));
|
||||||
|
QAction* exportEventsAction = menu.addAction(QStringLiteral("Export Events"));
|
||||||
|
QAction* clearEventsAction = menu.addAction(QStringLiteral("Clear Events"));
|
||||||
QList<QAction*> themeActions;
|
QList<QAction*> themeActions;
|
||||||
|
|
||||||
if (tab->supportsThemeSelection()) {
|
if (tab->supportsThemeSelection()) {
|
||||||
@@ -92,13 +107,54 @@ SessionWindow::SessionWindow(const Profile& profile, QWidget* parent)
|
|||||||
clearAction = menu.addAction(QStringLiteral("Clear"));
|
clearAction = menu.addAction(QStringLiteral("Clear"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
QAction* zoomInAction = nullptr;
|
||||||
|
QAction* zoomOutAction = nullptr;
|
||||||
|
QAction* resetZoomAction = nullptr;
|
||||||
|
QAction* setFontSizeAction = nullptr;
|
||||||
|
if (tab->supportsZoom()) {
|
||||||
|
menu.addSeparator();
|
||||||
|
zoomInAction = menu.addAction(QStringLiteral("Increase Font Size"));
|
||||||
|
zoomOutAction = menu.addAction(QStringLiteral("Decrease Font Size"));
|
||||||
|
resetZoomAction = menu.addAction(QStringLiteral("Reset Font Size"));
|
||||||
|
setFontSizeAction = menu.addAction(QStringLiteral("Set Font Size..."));
|
||||||
|
}
|
||||||
|
|
||||||
QAction* chosen = menu.exec(m_tabs->tabBar()->mapToGlobal(pos));
|
QAction* chosen = menu.exec(m_tabs->tabBar()->mapToGlobal(pos));
|
||||||
if (chosen == disconnectAction) {
|
if (chosen == disconnectAction) {
|
||||||
tab->disconnectSession();
|
tab->disconnectSession();
|
||||||
} else if (chosen == reconnectAction) {
|
} else if (chosen == reconnectAction) {
|
||||||
tab->reconnectSession();
|
tab->reconnectSession();
|
||||||
|
} else if (chosen == toggleEventsAction) {
|
||||||
|
tab->setEventsPanelExpanded(!tab->isEventsPanelExpanded());
|
||||||
|
} else if (chosen == copyEventsAction) {
|
||||||
|
tab->copyEvents();
|
||||||
|
} else if (chosen == exportEventsAction) {
|
||||||
|
tab->exportEventsToFile();
|
||||||
|
} else if (chosen == clearEventsAction) {
|
||||||
|
tab->clearEvents();
|
||||||
} else if (clearAction != nullptr && chosen == clearAction) {
|
} else if (clearAction != nullptr && chosen == clearAction) {
|
||||||
tab->clearTerminal();
|
tab->clearTerminal();
|
||||||
|
} else if (zoomInAction != nullptr && chosen == zoomInAction) {
|
||||||
|
tab->zoomIn();
|
||||||
|
} else if (zoomOutAction != nullptr && chosen == zoomOutAction) {
|
||||||
|
tab->zoomOut();
|
||||||
|
} else if (resetZoomAction != nullptr && chosen == resetZoomAction) {
|
||||||
|
tab->resetZoom();
|
||||||
|
} else if (setFontSizeAction != nullptr && chosen == setFontSizeAction) {
|
||||||
|
bool accepted = false;
|
||||||
|
const int currentSize =
|
||||||
|
tab->terminalFontPointSize() > 0 ? tab->terminalFontPointSize() : 10;
|
||||||
|
const int newSize = QInputDialog::getInt(this,
|
||||||
|
QStringLiteral("Set Font Size"),
|
||||||
|
QStringLiteral("Font size (points):"),
|
||||||
|
currentSize,
|
||||||
|
6,
|
||||||
|
72,
|
||||||
|
1,
|
||||||
|
&accepted);
|
||||||
|
if (accepted) {
|
||||||
|
tab->setTerminalFontPointSize(newSize);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
for (QAction* themeAction : themeActions) {
|
for (QAction* themeAction : themeActions) {
|
||||||
if (chosen == themeAction) {
|
if (chosen == themeAction) {
|
||||||
@@ -109,8 +165,42 @@ SessionWindow::SessionWindow(const Profile& profile, QWidget* parent)
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
QMenu* fileMenu = menuBar()->addMenu(QStringLiteral("File"));
|
||||||
|
QAction* newProfileAction = fileMenu->addAction(QStringLiteral("New Profile"));
|
||||||
|
QAction* newFolderAction = fileMenu->addAction(QStringLiteral("New Folder"));
|
||||||
|
fileMenu->addSeparator();
|
||||||
|
QAction* quitAction = fileMenu->addAction(QStringLiteral("Quit"));
|
||||||
|
|
||||||
|
connect(newProfileAction,
|
||||||
|
&QAction::triggered,
|
||||||
|
this,
|
||||||
|
[this]() { m_profilesWidget->createProfileInCurrentContext(); });
|
||||||
|
connect(newFolderAction,
|
||||||
|
&QAction::triggered,
|
||||||
|
this,
|
||||||
|
[this]() { m_profilesWidget->createFolderInCurrentContext(); });
|
||||||
|
connect(quitAction, &QAction::triggered, this, []() { qApp->quit(); });
|
||||||
|
|
||||||
|
QMenu* helpMenu = menuBar()->addMenu(QStringLiteral("Help"));
|
||||||
|
QAction* aboutAction = helpMenu->addAction(QStringLiteral("About OrbitHub"));
|
||||||
|
connect(aboutAction,
|
||||||
|
&QAction::triggered,
|
||||||
|
this,
|
||||||
|
[this]() {
|
||||||
|
AboutDialog dialog(this);
|
||||||
|
dialog.exec();
|
||||||
|
});
|
||||||
|
|
||||||
setCentralWidget(m_tabs);
|
setCentralWidget(m_tabs);
|
||||||
addSessionTab(profile);
|
|
||||||
|
m_profilesWidget = new ProfilesWindow(this);
|
||||||
|
const int profilesIndex = m_tabs->addTab(m_profilesWidget, QStringLiteral("Profiles"));
|
||||||
|
m_tabs->tabBar()->setTabButton(profilesIndex, QTabBar::LeftSide, nullptr);
|
||||||
|
m_tabs->tabBar()->setTabButton(profilesIndex, QTabBar::RightSide, nullptr);
|
||||||
|
connect(m_profilesWidget,
|
||||||
|
&ProfilesWindow::connectRequested,
|
||||||
|
this,
|
||||||
|
&SessionWindow::addSessionTab);
|
||||||
}
|
}
|
||||||
|
|
||||||
void SessionWindow::openProfile(const Profile& profile)
|
void SessionWindow::openProfile(const Profile& profile)
|
||||||
@@ -120,11 +210,13 @@ void SessionWindow::openProfile(const Profile& profile)
|
|||||||
|
|
||||||
void SessionWindow::addSessionTab(const Profile& profile)
|
void SessionWindow::addSessionTab(const Profile& profile)
|
||||||
{
|
{
|
||||||
auto* tab = new SessionTab(profile, this);
|
auto* tab = new SessionTab(profile, m_preferences, this);
|
||||||
const int index = m_tabs->addTab(tab, tab->tabTitle());
|
const int index = m_tabs->addTab(tab, tab->tabTitle());
|
||||||
m_tabs->setCurrentIndex(index);
|
m_tabs->setCurrentIndex(index);
|
||||||
if (m_tabs->count() > 1) {
|
if (sessionTabCount() > 1) {
|
||||||
setWindowTitle(QStringLiteral("OrbitHub Sessions"));
|
setWindowTitle(QStringLiteral("OrbitHub Sessions"));
|
||||||
|
} else {
|
||||||
|
setWindowTitle(QStringLiteral("OrbitHub Session - %1").arg(profile.name));
|
||||||
}
|
}
|
||||||
m_tabs->tabBar()->setTabTextColor(
|
m_tabs->tabBar()->setTabTextColor(
|
||||||
index, tabColorForState(SessionState::Disconnected, m_tabs->palette()));
|
index, tabColorForState(SessionState::Disconnected, m_tabs->palette()));
|
||||||
@@ -145,6 +237,32 @@ void SessionWindow::addSessionTab(const Profile& profile)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
connect(tab,
|
||||||
|
&SessionTab::terminalThemeChanged,
|
||||||
|
this,
|
||||||
|
[this](const QString& themeName) {
|
||||||
|
m_preferences.terminalThemeName = themeName.trimmed().isEmpty()
|
||||||
|
? QStringLiteral("Dark")
|
||||||
|
: themeName.trimmed();
|
||||||
|
saveUiPreferences();
|
||||||
|
});
|
||||||
|
connect(tab,
|
||||||
|
&SessionTab::terminalFontSizeChanged,
|
||||||
|
this,
|
||||||
|
[this](int pointSize) {
|
||||||
|
if (pointSize <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
m_preferences.terminalFontPointSize = pointSize;
|
||||||
|
saveUiPreferences();
|
||||||
|
});
|
||||||
|
connect(tab,
|
||||||
|
&SessionTab::eventsPanelVisibilityChanged,
|
||||||
|
this,
|
||||||
|
[this](bool expanded) {
|
||||||
|
m_preferences.eventsPanelExpanded = expanded;
|
||||||
|
saveUiPreferences();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
void SessionWindow::updateTabTitle(SessionTab* tab, const QString& title)
|
void SessionWindow::updateTabTitle(SessionTab* tab, const QString& title)
|
||||||
@@ -156,3 +274,35 @@ void SessionWindow::updateTabTitle(SessionTab* tab, const QString& title)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
int SessionWindow::sessionTabCount() const
|
||||||
|
{
|
||||||
|
return m_tabs->count() - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
void SessionWindow::loadUiPreferences()
|
||||||
|
{
|
||||||
|
QSettings settings;
|
||||||
|
m_preferences.terminalThemeName =
|
||||||
|
settings.value(QStringLiteral("session/defaultTerminalTheme"), QStringLiteral("Dark"))
|
||||||
|
.toString()
|
||||||
|
.trimmed();
|
||||||
|
if (m_preferences.terminalThemeName.isEmpty()) {
|
||||||
|
m_preferences.terminalThemeName = QStringLiteral("Dark");
|
||||||
|
}
|
||||||
|
m_preferences.eventsPanelExpanded =
|
||||||
|
settings.value(QStringLiteral("session/eventsPanelExpanded"), false).toBool();
|
||||||
|
m_preferences.terminalFontPointSize =
|
||||||
|
settings.value(QStringLiteral("session/terminalFontPointSize"), 0).toInt();
|
||||||
|
}
|
||||||
|
|
||||||
|
void SessionWindow::saveUiPreferences() const
|
||||||
|
{
|
||||||
|
QSettings settings;
|
||||||
|
settings.setValue(QStringLiteral("session/defaultTerminalTheme"),
|
||||||
|
m_preferences.terminalThemeName);
|
||||||
|
settings.setValue(QStringLiteral("session/eventsPanelExpanded"),
|
||||||
|
m_preferences.eventsPanelExpanded);
|
||||||
|
settings.setValue(QStringLiteral("session/terminalFontPointSize"),
|
||||||
|
m_preferences.terminalFontPointSize);
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,25 +2,31 @@
|
|||||||
#define ORBITHUB_SESSION_WINDOW_H
|
#define ORBITHUB_SESSION_WINDOW_H
|
||||||
|
|
||||||
#include "profile_repository.h"
|
#include "profile_repository.h"
|
||||||
|
#include "session_tab.h"
|
||||||
|
|
||||||
#include <QMainWindow>
|
#include <QMainWindow>
|
||||||
|
|
||||||
class QTabWidget;
|
class QTabWidget;
|
||||||
class SessionTab;
|
class ProfilesWindow;
|
||||||
|
|
||||||
class SessionWindow : public QMainWindow
|
class SessionWindow : public QMainWindow
|
||||||
{
|
{
|
||||||
Q_OBJECT
|
Q_OBJECT
|
||||||
|
|
||||||
public:
|
public:
|
||||||
explicit SessionWindow(const Profile& profile, QWidget* parent = nullptr);
|
explicit SessionWindow(QWidget* parent = nullptr);
|
||||||
void openProfile(const Profile& profile);
|
void openProfile(const Profile& profile);
|
||||||
|
|
||||||
private:
|
private:
|
||||||
QTabWidget* m_tabs;
|
QTabWidget* m_tabs;
|
||||||
|
ProfilesWindow* m_profilesWidget;
|
||||||
|
SessionUiPreferences m_preferences;
|
||||||
|
|
||||||
void addSessionTab(const Profile& profile);
|
void addSessionTab(const Profile& profile);
|
||||||
void updateTabTitle(SessionTab* tab, const QString& title);
|
void updateTabTitle(SessionTab* tab, const QString& title);
|
||||||
|
void loadUiPreferences();
|
||||||
|
void saveUiPreferences() const;
|
||||||
|
int sessionTabCount() const;
|
||||||
};
|
};
|
||||||
|
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -52,6 +52,14 @@ void TerminalView::setThemeName(const QString& themeName)
|
|||||||
applyThemePalette(paletteByName(themeName));
|
applyThemePalette(paletteByName(themeName));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void TerminalView::setFontPointSize(int pointSize)
|
||||||
|
{
|
||||||
|
QFont updatedFont = font();
|
||||||
|
updatedFont.setPointSize(pointSize);
|
||||||
|
setFont(updatedFont);
|
||||||
|
emitTerminalSize();
|
||||||
|
}
|
||||||
|
|
||||||
void TerminalView::appendTerminalData(const QString& data)
|
void TerminalView::appendTerminalData(const QString& data)
|
||||||
{
|
{
|
||||||
if (data.isEmpty()) {
|
if (data.isEmpty()) {
|
||||||
@@ -211,6 +219,14 @@ void TerminalView::focusInEvent(QFocusEvent* event)
|
|||||||
moveCursor(QTextCursor::End);
|
moveCursor(QTextCursor::End);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool TerminalView::focusNextPrevChild(bool next)
|
||||||
|
{
|
||||||
|
Q_UNUSED(next);
|
||||||
|
// Tab/Shift+Tab must reach keyPressEvent() and be forwarded to the
|
||||||
|
// remote session instead of moving focus to the next local widget.
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
void TerminalView::resizeEvent(QResizeEvent* event)
|
void TerminalView::resizeEvent(QResizeEvent* event)
|
||||||
{
|
{
|
||||||
QTextEdit::resizeEvent(event);
|
QTextEdit::resizeEvent(event);
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ public:
|
|||||||
static QStringList themeNames();
|
static QStringList themeNames();
|
||||||
void setThemeName(const QString& themeName);
|
void setThemeName(const QString& themeName);
|
||||||
void appendTerminalData(const QString& data);
|
void appendTerminalData(const QString& data);
|
||||||
|
void setFontPointSize(int pointSize);
|
||||||
|
|
||||||
signals:
|
signals:
|
||||||
void inputGenerated(const QString& input);
|
void inputGenerated(const QString& input);
|
||||||
@@ -28,6 +29,7 @@ protected:
|
|||||||
void keyPressEvent(QKeyEvent* event) override;
|
void keyPressEvent(QKeyEvent* event) override;
|
||||||
void focusInEvent(QFocusEvent* event) override;
|
void focusInEvent(QFocusEvent* event) override;
|
||||||
void resizeEvent(QResizeEvent* event) override;
|
void resizeEvent(QResizeEvent* event) override;
|
||||||
|
bool focusNextPrevChild(bool next) override;
|
||||||
|
|
||||||
private:
|
private:
|
||||||
struct ThemePalette {
|
struct ThemePalette {
|
||||||
|
|||||||