13 Commits
Author SHA1 Message Date
ksmithandClaude Sonnet 5 3fab2f9de3 Wire up CTest and add unit test coverage for ProfileRepository
Adds a Qt6::Test-based unit test target (tests/test_profile_repository.cpp,
21 cases), gated behind an ORBITHUB_BUILD_TESTS option that no-ops
gracefully if Qt6::Test isn't available, so it can't break app-only
builds. Covers profile CRUD, validation rules, search/sort, tag
normalization, and folder handling, each against an isolated temporary
SQLite file (new ProfileRepository(databasePathOverride) constructor
overload added for exactly this).

Caught and fixed a real bug along the way: normalizedTags()'s result was
bound directly without the nonNullTrimmed() null-guard every other field
already uses, so creating a profile with no tags at all hit the `tags
NOT NULL` constraint and silently failed -- including via the Import
Profiles feature for any export where a profile has no tags key.

Partial progress on #1 (RdpSessionBackend/SshSessionBackend state-machine
coverage still open -- much larger lift, needs a testability pass on
those backends first).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-15 06:39:20 -06:00
ksmithandClaude Sonnet 5 4f5cf8ecd9 packaging: read project version after building, not before
build-deb.sh and build-dmg.sh both read VERSION from CMakeCache.txt
before calling cmake --build -- but that build step is exactly what
reconfigures CMakeCache.txt if CMakeLists.txt changed since the build
dir was last configured. If the version was bumped and the script is
run without an explicit reconfigure first, it silently packages the
stale version (observed: v2026.9.15's macOS build produced
OrbitHub-2026.9.14.2.dmg). Move the VERSION read to after the build.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-15 06:26:15 -06:00
ksmithandClaude Sonnet 5 df99c78998 Bump version to v2026.9.15
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-15 06:22:53 -06:00
ksmithandClaude Sonnet 5 dffca3afef Add Import/Export for profile lists
File -> Export Profiles... writes all profiles (plus explicit, including
empty, folders) to a JSON file. File -> Import Profiles... reads one back,
recreates folders, and inserts profiles as new rows so IDs never collide
with the destination database. No credentials are ever persisted on a
Profile in the first place (only privateKeyPath, a filesystem path), so
nothing sensitive is exposed by an exported file.

Verified with a standalone headless round-trip test against isolated
app-data databases (SSH + RDP profiles, nested folders, all fields);
caught and fixed a bug where import was redundantly creating an explicit
folder row per profile that didn't exist in the original export.

Fixes #17.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-15 06:22:26 -06:00
ksmithandClaude Sonnet 5 4f8fa3272b packaging: pin vendored FreeRDP's own version, stop git-tag bleed-through
third_party/FreeRDP is vendored in-tree rather than as a separate git
submodule, so its build-time git_get_exact_tag() call (in
cmake/GetProjectVersion.cmake) was resolving against OrbitHub's own git
tags instead of any real FreeRDP release tag. Its version-extraction regex
then greedily matched the last three dot-separated numbers of our tag
(e.g. v2026.9.14.2 -> "9.14.2"), mislabeling FreeRDP's own libraries in
packaged builds (libfreerdp9.so.9.14.2 instead of the real
libfreerdp3.so.3.23.1) -- self-consistent within a build, but misleading
and drifting release to release.

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

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

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

Fixes #18.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 21:57:08 -06:00
ksmith e0969041a7 packaging: pin Flathub manifest to v2026.9.14 About-dialog-fix commit 2026-09-14 21:32:56 -06:00
ksmithandClaude Sonnet 5 c4a62e8fb6 About dialog: fix unreadable dark-mode text and missing version
The subtitle and version line used palette(mid), a role meant for 3D
bevel/shadow decoration, not text -- it has poor contrast against the
window background in dark themes. Replaced with a color blended from
the widget's actual text/window palette colors, so it stays readable
(de-emphasized but never low-contrast) in either theme.

Also: QCoreApplication::applicationVersion() was never being set
anywhere, so the dialog always showed "Development build" regardless
of the actual built version. CMake's PROJECT_VERSION is now exposed
to the app via a compile definition and wired into
setApplicationVersion() at startup.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 21:29:36 -06:00
ksmithandClaude Sonnet 5 9a22597d4e packaging: pin Flathub manifest to v2026.9.14
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 21:17:33 -06:00
ksmithandClaude Sonnet 5 e90e9b5abf Bump version to v2026.9.14
RDP HiDPI text fix and resize-glitch mitigations.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 21:17:18 -06:00
ksmithandClaude Sonnet 5 92d8b62820 RDP: fix distorted text on HiDPI monitors, reduce resize glitches
The RDP session pipeline never accounted for display scale: it
requested a desktop canvas sized in Qt logical pixels (never
multiplied by devicePixelRatio()), and FreeRDP_DesktopScaleFactor/
DeviceScaleFactor were read but never actually set anywhere. On a
HiDPI monitor this meant the remote session rendered assuming a
96 DPI / 100% display, and the resulting canvas got stretched
locally — ClearType's subpixel hinting doesn't survive that kind of
resampling, producing distorted glyph shapes and color fringing
rather than plain blur.

RdpDisplayWidget now reports physical pixel dimensions and the real
devicePixelRatio (recomputed on resize and on screen changes, e.g.
dragging the window to a different-DPI monitor). RdpSessionBackend
maps that to the nearest FreeRDP-legal scale value ({100, 140, 180},
per MS-RDPEDISP and FreeRDP's own reference client) and sets it at
both connect time and on every dynamic resize, including the
FreeRDP_MonitorOverrideFlags required for the values to actually be
honored rather than silently ignored.

While testing this against real infrastructure, found and fixed two
related (pre-existing, not caused by this change) resize issues:
- A stale-frame race where the old frame could be drawn at the wrong
  scale for a moment after a resize, before a correctly-sized one
  arrives — now the frame is cleared during that transition instead.
- No debounce on outgoing resize requests — every single resize event
  fired an immediate request to the server, which can visibly
  contribute to host-side redraw glitches during rapid layout churn
  (e.g. right after connecting). Coalesced into one request per burst,
  plus an explicit refresh-rect request after each resize completes
  as a best-effort nudge for hosts that don't fully repaint on their
  own.

A separate, deeper issue was also found during testing (the remote
guest's actual resolution sometimes not changing despite the resize
channel reporting success) and is tracked separately, not fixed here.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 21:16:41 -06:00
ksmithandClaude Sonnet 5 7ee930693e packaging: pin Flathub manifest to v2026.9.8.3
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 18:32:09 -06:00
24 changed files with 791 additions and 28 deletions
+17 -1
View File
@@ -1,6 +1,6 @@
cmake_minimum_required(VERSION 3.21)
project(OrbitHub VERSION 2026.9.8.3 LANGUAGES CXX)
project(OrbitHub VERSION 2026.9.15 LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
@@ -16,6 +16,17 @@ find_package(Qt6 6.2 REQUIRED COMPONENTS Widgets Sql)
qt_standard_project_setup()
option(ORBITHUB_BUILD_TESTS "Build unit tests (requires Qt6::Test)" ON)
if(ORBITHUB_BUILD_TESTS)
find_package(Qt6 6.2 QUIET COMPONENTS Test)
if(TARGET Qt6::Test)
enable_testing()
else()
message(STATUS "Qt6::Test not found -- skipping unit tests (set ORBITHUB_BUILD_TESTS=OFF to silence this)")
set(ORBITHUB_BUILD_TESTS OFF)
endif()
endif()
add_subdirectory(third_party/KodoTerm)
if(NOT EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/third_party/FreeRDP/CMakeLists.txt")
@@ -134,6 +145,7 @@ add_executable(orbithub WIN32 MACOSX_BUNDLE ${ORBITHUB_SOURCES})
target_link_libraries(orbithub PRIVATE Qt6::Widgets Qt6::Sql)
target_link_libraries(orbithub PRIVATE KodoTerm::KodoTerm)
target_compile_definitions(orbithub PRIVATE ORBITHUB_VERSION_STRING="${PROJECT_VERSION}")
if(TARGET freerdp AND TARGET winpr)
target_compile_definitions(orbithub PRIVATE ORBITHUB_HAS_FREERDP)
target_include_directories(orbithub PRIVATE
@@ -271,3 +283,7 @@ install(FILES third_party/KodoTerm/LICENSE
DESTINATION ${CMAKE_INSTALL_DATADIR}/licenses/org.darksingularity.OrbitHub
RENAME LICENSE-KodoTerm
)
if(ORBITHUB_BUILD_TESTS)
add_subdirectory(tests)
endif()
+2 -2
View File
@@ -16,14 +16,14 @@ OrbitHub is in active development.
- Milestones completed: M0-M5, and M7-M9
- Current milestone: Milestone 10 (v1.0 Stabilization)
- Deferred milestone: Milestone 6 (VNC Fully Working)
- Latest checkpoint tag: `v2026.9.8.3`
- Latest checkpoint tag: `v2026.9.15`
- VNC implementation milestone (M6) is currently deferred
Progress and milestone details:
- [docs/PROGRESS.md](docs/PROGRESS.md)
Latest release (installers for Windows, Linux, and macOS):
- [v2026.9.8.3](https://git.darksingularity.org/DarkSingularity/orbithub/releases/tag/v2026.9.8.3)
- [v2026.9.15](https://git.darksingularity.org/DarkSingularity/orbithub/releases/tag/v2026.9.15)
User Guide:
- [docs/USER_GUIDE.md](docs/USER_GUIDE.md) (also available as a PDF attached to each release, and in-app via `Help -> User Guide`)
+3
View File
@@ -200,6 +200,9 @@ Git:
- Release: [v2026.9.8](https://git.darksingularity.org/DarkSingularity/orbithub/releases/tag/v2026.9.8) (`v2026.9.8` tag, installers for Windows/Linux/macOS)
- Release: [v2026.9.8.2](https://git.darksingularity.org/DarkSingularity/orbithub/releases/tag/v2026.9.8.2) — same-day patch fixing RDP TLS certificate verification (was fully disabled) and preparing Flatpak packaging for Flathub submission
- Release: [v2026.9.8.3](https://git.darksingularity.org/DarkSingularity/orbithub/releases/tag/v2026.9.8.3) — same-day patch adding an in-app User Guide and standalone User Guide PDF
- Release: [v2026.9.14](https://git.darksingularity.org/DarkSingularity/orbithub/releases/tag/v2026.9.14) — fixes distorted RDP text on HiDPI monitors and reduces RDP resize-related display glitches
- Release: [v2026.9.14.2](https://git.darksingularity.org/DarkSingularity/orbithub/releases/tag/v2026.9.14.2) — same-day patch fixing RDP display corruption (missing/misplaced taskbar) after resizing the session window (the client never resized its own display buffer for channel-driven RDP resizes)
- Release: [v2026.9.15](https://git.darksingularity.org/DarkSingularity/orbithub/releases/tag/v2026.9.15) — adds Import/Export for profile lists (File menu)
## Milestone 10 - v1.0 Stabilization
@@ -19,5 +19,5 @@ modules:
sources:
- type: git
url: https://git.darksingularity.org/DarkSingularity/orbithub.git
tag: v2026.9.8.2
commit: 4c649f727fb794f4b5afdc3186e545e20ce2f138
tag: v2026.9.15
commit: dffca3afef80b5a3ca4832e0b7f748775cb90328
+5 -1
View File
@@ -17,6 +17,11 @@ mkdir -p "$DIST_DIR"
rm -rf "$STAGE_DIR"
mkdir -p "$PKG_ROOT/DEBIAN"
# Read VERSION only after the build (which reconfigures CMakeCache.txt if
# CMakeLists.txt changed since the build dir was last configured) --
# reading it beforehand risks packaging a stale version string.
cmake --build "$BUILD_DIR" -j
VERSION="$(sed -n 's/^CMAKE_PROJECT_VERSION:STATIC=//p' "$BUILD_DIR/CMakeCache.txt" | head -n1)"
ARCH="$(dpkg --print-architecture)"
@@ -25,7 +30,6 @@ if [[ -z "$VERSION" ]]; then
exit 1
fi
cmake --build "$BUILD_DIR" -j
cmake --install "$BUILD_DIR" --prefix "$PKG_ROOT/usr"
cat > "$PKG_ROOT/DEBIAN/control" <<EOF
@@ -34,6 +34,24 @@
</screenshot>
</screenshots>
<releases>
<release version="2026.9.15" date="2026-09-15">
<url>https://git.darksingularity.org/DarkSingularity/orbithub/releases/tag/v2026.9.15</url>
<description>
<p>Adds Import/Export for profile lists (File menu).</p>
</description>
</release>
<release version="2026.9.14.2" date="2026-09-14">
<url>https://git.darksingularity.org/DarkSingularity/orbithub/releases/tag/v2026.9.14.2</url>
<description>
<p>Fixes RDP display corruption (missing/misplaced taskbar, stale composited content) after resizing the session window.</p>
</description>
</release>
<release version="2026.9.14" date="2026-09-14">
<url>https://git.darksingularity.org/DarkSingularity/orbithub/releases/tag/v2026.9.14</url>
<description>
<p>Fixes distorted RDP text on HiDPI monitors, and reduces RDP resize-related display glitches.</p>
</description>
</release>
<release version="2026.9.8.3" date="2026-09-08">
<url>https://git.darksingularity.org/DarkSingularity/orbithub/releases/tag/v2026.9.8.3</url>
<description>
+10 -6
View File
@@ -14,12 +14,6 @@ if [[ ! -f "$BUILD_DIR/CMakeCache.txt" ]]; then
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
@@ -31,7 +25,17 @@ mkdir -p "$DIST_DIR"
rm -rf "$STAGE_DIR" "$INSTALL_PREFIX"
mkdir -p "$STAGE_DIR"
# Read VERSION only after the build (which reconfigures CMakeCache.txt if
# CMakeLists.txt changed since the build dir was last configured) --
# reading it beforehand risks packaging a stale version string.
cmake --build "$BUILD_DIR" -j
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
cmake --install "$BUILD_DIR" --prefix "$INSTALL_PREFIX"
if [[ ! -d "$INSTALL_PREFIX/$APP_BUNDLE" ]]; then
+27 -2
View File
@@ -8,6 +8,31 @@
#include <QTextBrowser>
#include <QVBoxLayout>
namespace {
// palette(mid) is meant for 3D bevel/shadow decoration, not text — it has
// poor contrast against the window background in dark themes (barely
// legible). Blend the widget's actual text and window colors instead, so
// the result is reliably readable — de-emphasized relative to full-strength
// text, but never low-contrast — in either a light or dark theme.
QColor mutedTextColor(const QWidget* widget)
{
const QPalette pal = widget->palette();
const QColor text = pal.color(QPalette::WindowText);
const QColor background = pal.color(QPalette::Window);
constexpr qreal kTextWeight = 0.65;
auto blend = [kTextWeight](int textChannel, int backgroundChannel) {
return qBound(0,
qRound(textChannel * kTextWeight + backgroundChannel * (1.0 - kTextWeight)),
255);
};
return QColor(blend(text.red(), background.red()),
blend(text.green(), background.green()),
blend(text.blue(), background.blue()));
}
}
AboutDialog::AboutDialog(QWidget* parent) : QDialog(parent)
{
setWindowTitle(QStringLiteral("About OrbitHub"));
@@ -34,7 +59,7 @@ AboutDialog::AboutDialog(QWidget* parent) : QDialog(parent)
QStringLiteral("Unified remote session manager for SSH, RDP, and VNC workflows."),
this);
subtitle->setWordWrap(true);
subtitle->setStyleSheet(QStringLiteral("color: palette(mid);"));
subtitle->setStyleSheet(QStringLiteral("color: %1;").arg(mutedTextColor(this).name()));
const QString version = QCoreApplication::applicationVersion().trimmed().isEmpty()
? QStringLiteral("Development build")
@@ -42,7 +67,7 @@ AboutDialog::AboutDialog(QWidget* parent) : QDialog(parent)
auto* buildLine = new QLabel(
QStringLiteral("Version: %1 | Qt runtime linked dynamically").arg(version),
this);
buildLine->setStyleSheet(QStringLiteral("color: palette(mid);"));
buildLine->setStyleSheet(QStringLiteral("color: %1;").arg(mutedTextColor(this).name()));
titleColumn->addWidget(title);
titleColumn->addWidget(subtitle);
+3
View File
@@ -10,6 +10,9 @@ int main(int argc, char* argv[])
QApplication app(argc, argv);
app.setOrganizationName(QStringLiteral("FireBugIT"));
app.setApplicationName(QStringLiteral("OrbitHub"));
#ifdef ORBITHUB_VERSION_STRING
app.setApplicationVersion(QStringLiteral(ORBITHUB_VERSION_STRING));
#endif
app.setWindowIcon(createOrbitHubAppIcon());
SessionWindow window;
+17 -2
View File
@@ -9,7 +9,11 @@
#include <QVariant>
#include <QStringList>
#include <atomic>
namespace {
std::atomic<int> g_testConnectionCounter{0};
QString buildDatabasePath()
{
QString appDataPath = QStandardPaths::writableLocation(QStandardPaths::AppDataLocation);
@@ -176,7 +180,7 @@ void bindProfileFields(QSqlQuery& query, const Profile& profile)
: QStringLiteral("Negotiate"));
query.addBindValue(isRdp ? normalizedRdpPerformanceProfile(profile.rdpPerformanceProfile)
: QStringLiteral("Balanced"));
query.addBindValue(normalizedTags(profile.tags));
query.addBindValue(nonNullTrimmed(normalizedTags(profile.tags)));
}
Profile profileFromQuery(const QSqlQuery& query)
@@ -259,6 +263,16 @@ ProfileRepository::ProfileRepository() : m_connectionName(QStringLiteral("orbith
}
}
ProfileRepository::ProfileRepository(const QString& databasePathOverride)
: m_connectionName(QStringLiteral("orbithub_test_%1")
.arg(g_testConnectionCounter.fetch_add(1))),
m_databasePathOverride(databasePathOverride)
{
if (!initializeDatabase()) {
QSqlDatabase::removeDatabase(m_connectionName);
}
}
ProfileRepository::~ProfileRepository()
{
if (QSqlDatabase::contains(m_connectionName)) {
@@ -485,7 +499,8 @@ bool ProfileRepository::deleteProfile(qint64 id) const
bool ProfileRepository::initializeDatabase()
{
QSqlDatabase database = QSqlDatabase::addDatabase(QStringLiteral("QSQLITE"), m_connectionName);
database.setDatabaseName(buildDatabasePath());
database.setDatabaseName(
m_databasePathOverride.isEmpty() ? buildDatabasePath() : m_databasePathOverride);
if (!database.open()) {
m_initError = database.lastError().text();
+4
View File
@@ -35,6 +35,9 @@ class ProfileRepository
{
public:
ProfileRepository();
// databasePathOverride lets tests point the repository at an isolated,
// disposable SQLite file instead of the real app-data location.
explicit ProfileRepository(const QString& databasePathOverride);
~ProfileRepository();
QString initError() const;
@@ -51,6 +54,7 @@ public:
private:
QString m_connectionName;
QString m_databasePathOverride;
QString m_initError;
mutable QString m_lastError;
+177
View File
@@ -7,8 +7,13 @@
#include <QAction>
#include <QAbstractItemView>
#include <QComboBox>
#include <QFile>
#include <QFileDialog>
#include <QHeaderView>
#include <QHBoxLayout>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QLabel>
#include <QLineEdit>
#include <QInputDialog>
@@ -85,6 +90,53 @@ bool profileHasTag(const Profile& profile, const QString& requestedTag)
return false;
}
constexpr int kProfileExportFormatVersion = 1;
QJsonObject profileToJson(const Profile& profile)
{
QJsonObject object;
object.insert(QStringLiteral("name"), profile.name);
object.insert(QStringLiteral("host"), profile.host);
object.insert(QStringLiteral("port"), profile.port);
object.insert(QStringLiteral("username"), profile.username);
object.insert(QStringLiteral("domain"), profile.domain);
object.insert(QStringLiteral("folderPath"), profile.folderPath);
object.insert(QStringLiteral("protocol"), profile.protocol);
object.insert(QStringLiteral("authMode"), profile.authMode);
object.insert(QStringLiteral("privateKeyPath"), profile.privateKeyPath);
object.insert(QStringLiteral("knownHostsPolicy"), profile.knownHostsPolicy);
object.insert(QStringLiteral("rdpSecurityMode"), profile.rdpSecurityMode);
object.insert(QStringLiteral("rdpPerformanceProfile"), profile.rdpPerformanceProfile);
object.insert(QStringLiteral("tags"), profile.tags);
return object;
}
// Deliberately excludes id (import always creates new rows -- an imported
// profile's id has no meaning in the destination database) and any
// credential material (none is ever persisted on Profile in the first
// place, see ProfileRepository).
Profile profileFromJson(const QJsonObject& object)
{
Profile profile;
profile.name = object.value(QStringLiteral("name")).toString();
profile.host = object.value(QStringLiteral("host")).toString();
profile.port = object.value(QStringLiteral("port")).toInt(22);
profile.username = object.value(QStringLiteral("username")).toString();
profile.domain = object.value(QStringLiteral("domain")).toString();
profile.folderPath = object.value(QStringLiteral("folderPath")).toString();
profile.protocol = object.value(QStringLiteral("protocol")).toString(QStringLiteral("SSH"));
profile.authMode = object.value(QStringLiteral("authMode")).toString(QStringLiteral("Password"));
profile.privateKeyPath = object.value(QStringLiteral("privateKeyPath")).toString();
profile.knownHostsPolicy =
object.value(QStringLiteral("knownHostsPolicy")).toString(QStringLiteral("Ask"));
profile.rdpSecurityMode =
object.value(QStringLiteral("rdpSecurityMode")).toString(QStringLiteral("Negotiate"));
profile.rdpPerformanceProfile =
object.value(QStringLiteral("rdpPerformanceProfile")).toString(QStringLiteral("Balanced"));
profile.tags = object.value(QStringLiteral("tags")).toString();
return profile;
}
}
ProfilesWindow::ProfilesWindow(QWidget* parent)
@@ -930,3 +982,128 @@ void ProfilesWindow::createFolderInCurrentContext()
const QString folderPath = folderPathForItem(m_profilesTree->currentItem());
createFolderInContext(folderPath);
}
void ProfilesWindow::exportProfiles()
{
const QString fileName = QFileDialog::getSaveFileName(
this, QStringLiteral("Export Profiles"), QStringLiteral("orbithub-profiles.json"),
QStringLiteral("JSON Files (*.json)"));
if (fileName.isEmpty()) {
return;
}
QJsonArray folders;
for (const QString& folderPath : m_repository->listFolders()) {
folders.append(folderPath);
}
QJsonArray profiles;
for (const Profile& profile : m_repository->listProfiles()) {
profiles.append(profileToJson(profile));
}
QJsonObject root;
root.insert(QStringLiteral("orbithubProfileExport"), kProfileExportFormatVersion);
root.insert(QStringLiteral("folders"), folders);
root.insert(QStringLiteral("profiles"), profiles);
QFile file(fileName);
if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate)) {
QMessageBox::warning(this,
QStringLiteral("Export Profiles"),
QStringLiteral("Failed to write %1: %2")
.arg(fileName, file.errorString()));
return;
}
file.write(QJsonDocument(root).toJson(QJsonDocument::Indented));
file.close();
QMessageBox::information(this,
QStringLiteral("Export Profiles"),
QStringLiteral("Exported %1 profile(s) to %2.")
.arg(profiles.size())
.arg(fileName));
}
void ProfilesWindow::importProfiles()
{
const QString fileName = QFileDialog::getOpenFileName(
this, QStringLiteral("Import Profiles"), QString(), QStringLiteral("JSON Files (*.json)"));
if (fileName.isEmpty()) {
return;
}
QFile file(fileName);
if (!file.open(QIODevice::ReadOnly)) {
QMessageBox::warning(this,
QStringLiteral("Import Profiles"),
QStringLiteral("Failed to read %1: %2")
.arg(fileName, file.errorString()));
return;
}
QJsonParseError parseError{};
const QJsonDocument doc = QJsonDocument::fromJson(file.readAll(), &parseError);
file.close();
if (parseError.error != QJsonParseError::NoError || !doc.isObject()) {
QMessageBox::warning(this,
QStringLiteral("Import Profiles"),
QStringLiteral("%1 is not a valid OrbitHub profile export: %2")
.arg(fileName, parseError.errorString()));
return;
}
const QJsonObject root = doc.object();
if (!root.value(QStringLiteral("profiles")).isArray()) {
QMessageBox::warning(this,
QStringLiteral("Import Profiles"),
QStringLiteral("%1 does not contain a profile list.").arg(fileName));
return;
}
for (const QJsonValue& folderValue : root.value(QStringLiteral("folders")).toArray()) {
const QString folderPath = folderValue.toString();
if (!folderPath.isEmpty()) {
m_repository->createFolder(folderPath);
}
}
const QJsonArray profilesArray = root.value(QStringLiteral("profiles")).toArray();
int imported = 0;
QStringList failures;
for (const QJsonValue& profileValue : profilesArray) {
if (!profileValue.isObject()) {
continue;
}
const Profile profile = profileFromJson(profileValue.toObject());
if (profile.name.isEmpty() || profile.host.isEmpty()) {
failures.push_back(QStringLiteral("(unnamed profile): missing name or host"));
continue;
}
// No need to createFolder(profile.folderPath) here: the tree view
// already synthesizes folder nodes from a profile's own folderPath
// (see ProfilesWindow::loadProfiles). Explicit profile_folders rows
// are only for folders with no profiles in them, and those are
// already recreated above from the export's top-level "folders"
// list -- doing it again per-profile would just add spurious
// entries not present in the original export.
if (m_repository->createProfile(profile).has_value()) {
++imported;
} else {
failures.push_back(QStringLiteral("%1: %2").arg(profile.name, m_repository->lastError()));
}
}
loadProfiles();
QString summary = QStringLiteral("Imported %1 of %2 profile(s) from %3.")
.arg(imported)
.arg(profilesArray.size())
.arg(fileName);
if (!failures.isEmpty()) {
summary += QStringLiteral("\n\nFailed:\n%1").arg(failures.join(QChar::fromLatin1('\n')));
QMessageBox::warning(this, QStringLiteral("Import Profiles"), summary);
} else {
QMessageBox::information(this, QStringLiteral("Import Profiles"), summary);
}
}
+2
View File
@@ -32,6 +32,8 @@ public:
void createProfileInCurrentContext();
void createFolderInCurrentContext();
void exportProfiles();
void importProfiles();
signals:
void connectRequested(const Profile& profile);
+61 -7
View File
@@ -1,6 +1,7 @@
#include "rdp_display_widget.h"
#include <QCursor>
#include <QEvent>
#include <QKeyEvent>
#include <QMouseEvent>
#include <QPainter>
@@ -15,20 +16,38 @@ QSize sanitizeSize(const QSize& size)
{
return QSize(qMax(1, size.width()), qMax(1, size.height()));
}
qreal sanitizeDevicePixelRatio(qreal ratio)
{
if (!(ratio > 0.0)) {
return 1.0;
}
return qBound(1.0, ratio, 4.0);
}
// Windows' virtual-display driver can visibly glitch (stale composited
// content left on screen) when asked to change resolution repeatedly in
// quick succession, which naturally happens as the window's layout settles
// right after creation/connect. Coalescing bursts of resize events into one
// request avoids triggering that.
constexpr int kResizeDebounceMs = 150;
}
RdpDisplayWidget::RdpDisplayWidget(QWidget* parent)
: QWidget(parent), m_remoteSize(1280, 720), m_cursorMode(CursorMode::Default)
: QWidget(parent),
m_remoteSize(1280, 720),
m_cursorMode(CursorMode::Default),
m_resizeDebounceTimer(new QTimer(this))
{
setFocusPolicy(Qt::StrongFocus);
setMouseTracking(true);
setAutoFillBackground(false);
setMinimumSize(320, 200);
QTimer::singleShot(0, this, [this]() {
const QSize size = sanitizeSize(this->size());
emit viewportSizeChanged(size.width(), size.height());
});
m_resizeDebounceTimer->setSingleShot(true);
connect(m_resizeDebounceTimer, &QTimer::timeout, this, &RdpDisplayWidget::emitViewportGeometry);
scheduleViewportGeometryEmit();
}
void RdpDisplayWidget::setFrame(const QImage& frame)
@@ -54,6 +73,13 @@ void RdpDisplayWidget::setRemoteDesktopSize(int width, int height)
}
m_remoteSize = nextSize;
// The next actual frame (via setFrame) arrives asynchronously and isn't
// guaranteed to be sized to match yet. Drawing the old frame stretched
// to a renderRect() computed from the new m_remoteSize would scale it
// by the wrong factor for the transition window, producing visibly
// distorted/duplicated-looking content. Clear it and show the existing
// "waiting for frame" placeholder until a correctly-sized frame lands.
m_frame = QImage();
update();
}
@@ -140,11 +166,39 @@ void RdpDisplayWidget::paintEvent(QPaintEvent* event)
void RdpDisplayWidget::resizeEvent(QResizeEvent* event)
{
QWidget::resizeEvent(event);
const QSize size = sanitizeSize(event->size());
emit viewportSizeChanged(size.width(), size.height());
scheduleViewportGeometryEmit();
applyCursor();
}
bool RdpDisplayWidget::event(QEvent* event)
{
// Fires when this widget's effective screen changes (e.g. dragged to a
// different monitor), which is what changes devicePixelRatio(). Newer
// Qt versions add a more specific QEvent::DevicePixelRatioChange, but
// this project's Qt 6.2 floor doesn't have it.
if (event->type() == QEvent::ScreenChangeInternal) {
scheduleViewportGeometryEmit();
}
return QWidget::event(event);
}
void RdpDisplayWidget::scheduleViewportGeometryEmit()
{
// Restarting an already-running single-shot timer resets its countdown,
// so a burst of resize events collapses into one emission after things
// settle, rather than one request per event.
m_resizeDebounceTimer->start(kResizeDebounceMs);
}
void RdpDisplayWidget::emitViewportGeometry()
{
const QSize logicalSize = sanitizeSize(this->size());
const qreal ratio = sanitizeDevicePixelRatio(this->devicePixelRatioF());
const QSize physicalSize(qRound(logicalSize.width() * ratio), qRound(logicalSize.height() * ratio));
emit viewportSizeChanged(physicalSize.width(), physicalSize.height());
emit displayScaleChanged(ratio);
}
void RdpDisplayWidget::keyPressEvent(QKeyEvent* event)
{
if (event == nullptr) {
+6
View File
@@ -8,6 +8,7 @@ class QKeyEvent;
class QMouseEvent;
class QPaintEvent;
class QResizeEvent;
class QTimer;
class QWheelEvent;
class RdpDisplayWidget : public QWidget
@@ -30,10 +31,12 @@ signals:
void mouseButtonInput(int x, int y, int button, bool pressed);
void mouseWheelInput(int x, int y, int deltaX, int deltaY);
void viewportSizeChanged(int width, int height);
void displayScaleChanged(qreal devicePixelRatio);
protected:
void paintEvent(QPaintEvent* event) override;
void resizeEvent(QResizeEvent* event) override;
bool event(QEvent* event) override;
void keyPressEvent(QKeyEvent* event) override;
void keyReleaseEvent(QKeyEvent* event) override;
void mousePressEvent(QMouseEvent* event) override;
@@ -54,11 +57,14 @@ private:
QImage m_cursorImage;
QPoint m_cursorHotspot;
CursorMode m_cursorMode;
QTimer* m_resizeDebounceTimer;
QRectF renderRect() const;
QPoint mapToRemote(const QPointF& pos) const;
QSize effectiveRemoteSize() const;
void applyCursor();
void emitViewportGeometry();
void scheduleViewportGeometryEmit();
};
#endif
+113 -5
View File
@@ -104,6 +104,22 @@ QString normalizedRdpSecurityMode(const QString& value)
return QStringLiteral("Negotiate");
}
// MS-RDPEDISP restricts DesktopScaleFactor/DeviceScaleFactor to exactly
// these three values; FreeRDP's own reference client enforces the same
// set (client/common/cmdline.c, parse_scale_options). Anything else is
// silently ignored by the server, so map the real, continuous
// devicePixelRatio down to the nearest one.
UINT32 nearestFreeRdpScaleValue(qreal ratio)
{
if (ratio <= 1.2) {
return 100;
}
if (ratio <= 1.6) {
return 140;
}
return 180;
}
QString normalizedRdpPerformanceProfile(const QString& value)
{
const QString profile = value.trimmed();
@@ -1304,6 +1320,7 @@ RdpSessionBackend::RdpSessionBackend(const Profile& profile, QObject* parent)
m_userInitiatedDisconnect(false),
m_requestedDesktopWidth(kDefaultDesktopWidth),
m_requestedDesktopHeight(kDefaultDesktopHeight),
m_devicePixelRatio(1.0),
m_workerRunning(false),
m_stopRequested(false),
m_instance(nullptr),
@@ -1312,6 +1329,7 @@ RdpSessionBackend::RdpSessionBackend(const Profile& profile, QObject* parent)
m_resizeFailureLogged(false),
m_lastResizeWidth(0),
m_lastResizeHeight(0),
m_lastResizeScale(0),
m_cliprdrContext(nullptr)
{
}
@@ -1403,6 +1421,28 @@ void RdpSessionBackend::updateTerminalSize(int columns, int rows)
enqueueInputEvent(event);
}
void RdpSessionBackend::updateDisplayScale(qreal devicePixelRatio)
{
const qreal clamped = qBound(1.0, devicePixelRatio, 4.0);
m_devicePixelRatio.store(clamped);
if (!m_workerRunning.load()) {
return;
}
// Reuses the resize input-event path so the worker thread (which owns
// m_instance) picks this up safely; processInputEvents' dedup check
// also compares the quantized scale value, so this correctly triggers
// a fresh SendMonitorLayout even when width/height haven't changed
// (e.g. the window moved to a different-DPI monitor at the same
// logical size).
InputEvent event;
event.type = InputEventType::Resize;
event.width = sanitizeDesktopWidth(m_requestedDesktopWidth.load());
event.height = sanitizeDesktopHeight(m_requestedDesktopHeight.load());
enqueueInputEvent(event);
}
void RdpSessionBackend::sendKeyEvent(int key,
quint32 nativeScanCode,
const QString& text,
@@ -1637,9 +1677,29 @@ void RdpSessionBackend::workerMain()
const QString performanceProfile = normalizedRdpPerformanceProfile(p.rdpPerformanceProfile);
freerdp_settings_set_bool(settings, FreeRDP_SupportDisplayControl, TRUE);
freerdp_settings_set_bool(settings, FreeRDP_DynamicResolutionUpdate, TRUE);
// Lets us actively request a full-screen repaint after a resize (see
// sendDisplayResize) — some RDP hosts (particularly VMs using a
// synthetic/virtual display driver) occasionally fail to fully redraw
// their own desktop after a resolution change; requesting a refresh
// forces them to resend everything rather than leaving stale content.
freerdp_settings_set_bool(settings, FreeRDP_RefreshRect, TRUE);
freerdp_settings_set_uint32(settings, FreeRDP_DesktopWidth, static_cast<UINT32>(desktopWidth));
freerdp_settings_set_uint32(settings, FreeRDP_DesktopHeight, static_cast<UINT32>(desktopHeight));
freerdp_settings_set_uint32(settings, FreeRDP_ColorDepth, 32);
// Tell the remote session the real display scale so it renders its own
// UI/ClearType text natively at that size, instead of assuming 96 DPI
// and having the result stretched (and ClearType's subpixel hinting
// distorted) by client-side scaling. The override flags are required —
// without them FreeRDP ignores the scale factor values entirely.
{
const UINT32 scaleValue = nearestFreeRdpScaleValue(m_devicePixelRatio.load());
freerdp_settings_set_uint32(settings, FreeRDP_DesktopScaleFactor, scaleValue);
freerdp_settings_set_uint32(settings, FreeRDP_DeviceScaleFactor, scaleValue);
freerdp_settings_set_uint64(settings,
FreeRDP_MonitorOverrideFlags,
FREERDP_MONITOR_OVERRIDE_DESKTOP_SCALE
| FREERDP_MONITOR_OVERRIDE_DEVICE_SCALE);
}
freerdp_settings_set_bool(settings, FreeRDP_AuthenticationOnly, FALSE);
freerdp_settings_set_bool(settings, FreeRDP_AutoLogonEnabled, TRUE);
if (!applyRdpSecurityMode(settings, securityMode)) {
@@ -1831,9 +1891,14 @@ bool RdpSessionBackend::sendDisplayResize(rdp_freerdp* instance, int width, int
return false;
}
const qreal ratio = m_devicePixelRatio.load();
const UINT32 scaleValue = nearestFreeRdpScaleValue(ratio);
rdpSettings* settings = instance->context->settings;
freerdp_settings_set_uint32(settings, FreeRDP_DesktopWidth, static_cast<UINT32>(width));
freerdp_settings_set_uint32(settings, FreeRDP_DesktopHeight, static_cast<UINT32>(height));
freerdp_settings_set_uint32(settings, FreeRDP_DesktopScaleFactor, scaleValue);
freerdp_settings_set_uint32(settings, FreeRDP_DeviceScaleFactor, scaleValue);
DispClientContext* dispContext = nullptr;
bool displayControlReady = false;
@@ -1851,17 +1916,58 @@ bool RdpSessionBackend::sendDisplayResize(rdp_freerdp* instance, int width, int
layout.Width = static_cast<UINT32>(width);
layout.Height = static_cast<UINT32>(height);
layout.Orientation = ORIENTATION_LANDSCAPE;
layout.DesktopScaleFactor = freerdp_settings_get_uint32(settings, FreeRDP_DesktopScaleFactor);
layout.DeviceScaleFactor = freerdp_settings_get_uint32(settings, FreeRDP_DeviceScaleFactor);
layout.DesktopScaleFactor = scaleValue;
layout.DeviceScaleFactor = scaleValue;
// Physical size in mm must reflect the real DPI (96 * ratio), not
// the plain baseline, so it stays consistent with the scale factor
// above rather than implying a standard-DPI display of this size.
const double effectiveDpi = kDefaultDpi * ratio;
layout.PhysicalWidth = static_cast<UINT32>(
std::lround((static_cast<double>(width) / kDefaultDpi) * kMillimetersPerInch));
std::lround((static_cast<double>(width) / effectiveDpi) * kMillimetersPerInch));
layout.PhysicalHeight = static_cast<UINT32>(
std::lround((static_cast<double>(height) / kDefaultDpi) * kMillimetersPerInch));
std::lround((static_cast<double>(height) / effectiveDpi) * kMillimetersPerInch));
const UINT rc = dispContext->SendMonitorLayout(dispContext, 1, &layout);
if (rc == CHANNEL_RC_OK) {
m_lastResizeWidth = width;
m_lastResizeHeight = height;
m_lastResizeScale = static_cast<int>(scaleValue);
// The Display Control channel (MS-RDPEDISP) has no server
// acknowledgment PDU, and real hosts apply the new resolution
// without a Deactivation-Reactivation sequence — so
// update->DesktopResize (which only fires for that sequence, or
// for the GFX/Progressive pipeline we don't use) never runs for
// a channel-driven resize. Without a matching gdi_resize() call,
// gdi->width/height stay at the old size, and FreeRDP's own
// surface-bits handling (intersect_rect in gdi.c) then silently
// *drops* any update reaching outside those stale bounds —
// which is what produced the missing/misplaced taskbar and
// stale composited-looking content: this call is the fix, not
// just a best-effort nudge.
if (instance->context->gdi != nullptr
&& gdi_resize(instance->context->gdi, static_cast<UINT32>(width),
static_cast<UINT32>(height))) {
emit remoteDesktopSizeChanged(width, height);
} else if (instance->context->gdi != nullptr) {
emit eventLogged(QStringLiteral(
"RDP warning: local resize to %1x%2 failed; display may show stale content "
"until the next full repaint.")
.arg(width)
.arg(height));
}
// Best-effort nudge: some hosts don't fully repaint their own
// desktop after a resolution change (observed: taskbar missing
// until something else forces a redraw). Explicitly asking for
// the whole new area to be resent costs little and helps
// recover from that when it happens.
if (instance->context->update != nullptr
&& instance->context->update->RefreshRect != nullptr) {
RECTANGLE_16 fullArea = {0, 0, static_cast<UINT16>(qMin(width, 65535)),
static_cast<UINT16>(qMin(height, 65535))};
instance->context->update->RefreshRect(instance->context, 1, &fullArea);
}
return true;
}
}
@@ -2035,7 +2141,9 @@ void RdpSessionBackend::processInputEvents(rdp_freerdp* instance)
if (hasResize) {
const int width = sanitizeDesktopWidth(resizeWidth);
const int height = sanitizeDesktopHeight(resizeHeight);
if (width != m_lastResizeWidth || height != m_lastResizeHeight) {
const int scaleValue = static_cast<int>(nearestFreeRdpScaleValue(m_devicePixelRatio.load()));
if (width != m_lastResizeWidth || height != m_lastResizeHeight
|| scaleValue != m_lastResizeScale) {
if (sendDisplayResize(instance, width, height)) {
if (m_resizeFailureLogged) {
emit eventLogged(QStringLiteral("Dynamic RDP resize recovered."));
+3
View File
@@ -26,6 +26,7 @@ public slots:
void sendInput(const QString& input) override;
void confirmHostKey(bool trustHost) override;
void updateTerminalSize(int columns, int rows) override;
void updateDisplayScale(qreal devicePixelRatio) override;
void sendKeyEvent(int key,
quint32 nativeScanCode,
const QString& text,
@@ -68,6 +69,7 @@ private:
std::atomic_int m_requestedDesktopWidth;
std::atomic_int m_requestedDesktopHeight;
std::atomic<qreal> m_devicePixelRatio;
std::thread m_worker;
std::atomic_bool m_workerRunning;
@@ -85,6 +87,7 @@ private:
bool m_resizeFailureLogged;
int m_lastResizeWidth;
int m_lastResizeHeight;
int m_lastResizeScale;
std::mutex m_cliprdrMutex;
void* m_cliprdrContext;
+4
View File
@@ -47,6 +47,10 @@ public slots:
virtual void sendInput(const QString& input) = 0;
virtual void confirmHostKey(bool trustHost) = 0;
virtual void updateTerminalSize(int columns, int rows) = 0;
virtual void updateDisplayScale(qreal devicePixelRatio)
{
Q_UNUSED(devicePixelRatio);
}
virtual void setClipboardText(const QString& text)
{
Q_UNUSED(text);
+9
View File
@@ -176,6 +176,11 @@ SessionTab::SessionTab(const Profile& profile,
m_backend,
&SessionBackend::updateTerminalSize,
Qt::QueuedConnection);
connect(this,
&SessionTab::requestDisplayScale,
m_backend,
&SessionBackend::updateDisplayScale,
Qt::QueuedConnection);
connect(this,
&SessionTab::requestKeyEvent,
m_backend,
@@ -860,6 +865,10 @@ void SessionTab::setupUi()
&RdpDisplayWidget::viewportSizeChanged,
this,
[this](int width, int height) { emit requestTerminalSize(width, height); });
connect(m_rdpDisplay,
&RdpDisplayWidget::displayScaleChanged,
this,
[this](qreal ratio) { emit requestDisplayScale(ratio); });
connect(m_rdpDisplay,
&RdpDisplayWidget::keyInput,
this,
+1
View File
@@ -74,6 +74,7 @@ signals:
void requestInput(const QString& input);
void requestHostKeyConfirmation(bool trustHost);
void requestTerminalSize(int columns, int rows);
void requestDisplayScale(qreal devicePixelRatio);
void requestKeyEvent(int key,
quint32 nativeScanCode,
const QString& text,
+11
View File
@@ -170,6 +170,9 @@ SessionWindow::SessionWindow(QWidget* parent)
QAction* newProfileAction = fileMenu->addAction(QStringLiteral("New Profile"));
QAction* newFolderAction = fileMenu->addAction(QStringLiteral("New Folder"));
fileMenu->addSeparator();
QAction* importProfilesAction = fileMenu->addAction(QStringLiteral("Import Profiles..."));
QAction* exportProfilesAction = fileMenu->addAction(QStringLiteral("Export Profiles..."));
fileMenu->addSeparator();
QAction* quitAction = fileMenu->addAction(QStringLiteral("Quit"));
connect(newProfileAction,
@@ -180,6 +183,14 @@ SessionWindow::SessionWindow(QWidget* parent)
&QAction::triggered,
this,
[this]() { m_profilesWidget->createFolderInCurrentContext(); });
connect(importProfilesAction,
&QAction::triggered,
this,
[this]() { m_profilesWidget->importProfiles(); });
connect(exportProfilesAction,
&QAction::triggered,
this,
[this]() { m_profilesWidget->exportProfiles(); });
connect(quitAction, &QAction::triggered, this, []() { qApp->quit(); });
QMenu* helpMenu = menuBar()->addMenu(QStringLiteral("Help"));
+7
View File
@@ -0,0 +1,7 @@
add_executable(test_profile_repository
test_profile_repository.cpp
${CMAKE_SOURCE_DIR}/src/profile_repository.cpp
)
target_include_directories(test_profile_repository PRIVATE ${CMAKE_SOURCE_DIR}/src)
target_link_libraries(test_profile_repository PRIVATE Qt6::Core Qt6::Sql Qt6::Test)
add_test(NAME test_profile_repository COMMAND test_profile_repository)
+288
View File
@@ -0,0 +1,288 @@
#include "profile_repository.h"
#include <QTemporaryDir>
#include <QTest>
#include <memory>
namespace {
Profile makeSshProfile(const QString& name = QStringLiteral("Prod SSH Box"))
{
Profile profile;
profile.name = name;
profile.host = QStringLiteral("prod.example.com");
profile.port = 22;
profile.username = QStringLiteral("deploy");
profile.protocol = QStringLiteral("SSH");
profile.authMode = QStringLiteral("Password");
profile.tags = QStringLiteral("prod,linux");
return profile;
}
Profile makeRdpProfile(const QString& name = QStringLiteral("Windows RDP Box"))
{
Profile profile;
profile.name = name;
profile.host = QStringLiteral("win.example.com");
profile.port = 3389;
profile.username = QStringLiteral("admin");
profile.domain = QStringLiteral("CORP");
profile.protocol = QStringLiteral("RDP");
profile.rdpSecurityMode = QStringLiteral("NLA");
profile.rdpPerformanceProfile = QStringLiteral("Best Performance");
return profile;
}
}
class TestProfileRepository : public QObject
{
Q_OBJECT
private slots:
void init();
void cleanup();
void initializesCleanly();
void createAndGetSshProfile();
void createAndGetRdpProfile();
void createProfileRejectsMissingName();
void createProfileRejectsMissingHost();
void createProfileRejectsInvalidPort();
void createProfileRejectsMissingUsernameForSsh();
void createProfileRejectsMissingPrivateKeyForKeyAuth();
void createProfileRejectsDuplicateName();
void updateProfilePersistsChanges();
void deleteProfileRemovesIt();
void getProfileReturnsNulloptForUnknownId();
void listProfilesFiltersBySearchQuery();
void listProfilesSortsByRequestedOrder();
void tagsAreTrimmedDedupedAndJoined();
void emptyTagsRoundTripAsEmpty();
void folderCreateAndListRoundTrips();
void folderCreateIgnoresDuplicates();
void folderPathIsNormalized();
private:
std::unique_ptr<QTemporaryDir> m_tempDir;
std::unique_ptr<ProfileRepository> m_repo;
};
void TestProfileRepository::init()
{
m_tempDir = std::make_unique<QTemporaryDir>();
QVERIFY(m_tempDir->isValid());
m_repo = std::make_unique<ProfileRepository>(m_tempDir->filePath(QStringLiteral("test.sqlite")));
}
void TestProfileRepository::cleanup()
{
m_repo.reset();
m_tempDir.reset();
}
void TestProfileRepository::initializesCleanly()
{
QCOMPARE(m_repo->initError(), QString());
QCOMPARE(m_repo->listProfiles().size(), size_t(0));
QCOMPARE(m_repo->listFolders().size(), size_t(0));
}
void TestProfileRepository::createAndGetSshProfile()
{
const Profile input = makeSshProfile();
const std::optional<Profile> created = m_repo->createProfile(input);
QVERIFY(created.has_value());
QVERIFY(created->id > 0);
const std::optional<Profile> fetched = m_repo->getProfile(created->id);
QVERIFY(fetched.has_value());
QCOMPARE(fetched->name, input.name);
QCOMPARE(fetched->host, input.host);
QCOMPARE(fetched->port, input.port);
QCOMPARE(fetched->username, input.username);
QCOMPARE(fetched->protocol, QStringLiteral("SSH"));
QCOMPARE(fetched->authMode, QStringLiteral("Password"));
QCOMPARE(fetched->tags, QStringLiteral("prod, linux"));
}
void TestProfileRepository::createAndGetRdpProfile()
{
const Profile input = makeRdpProfile();
const std::optional<Profile> created = m_repo->createProfile(input);
QVERIFY(created.has_value());
const std::optional<Profile> fetched = m_repo->getProfile(created->id);
QVERIFY(fetched.has_value());
QCOMPARE(fetched->protocol, QStringLiteral("RDP"));
QCOMPARE(fetched->domain, QStringLiteral("CORP"));
QCOMPARE(fetched->rdpSecurityMode, QStringLiteral("NLA"));
QCOMPARE(fetched->rdpPerformanceProfile, QStringLiteral("Best Performance"));
QCOMPARE(fetched->port, 3389);
// Auth-mode/private-key fields are SSH-only and must not leak through
// for a non-SSH protocol.
QCOMPARE(fetched->authMode, QStringLiteral("Password"));
QCOMPARE(fetched->privateKeyPath, QString());
}
void TestProfileRepository::createProfileRejectsMissingName()
{
Profile profile = makeSshProfile();
profile.name.clear();
QVERIFY(!m_repo->createProfile(profile).has_value());
QVERIFY(!m_repo->lastError().isEmpty());
}
void TestProfileRepository::createProfileRejectsMissingHost()
{
Profile profile = makeSshProfile();
profile.host.clear();
QVERIFY(!m_repo->createProfile(profile).has_value());
QVERIFY(!m_repo->lastError().isEmpty());
}
void TestProfileRepository::createProfileRejectsInvalidPort()
{
Profile profile = makeSshProfile();
profile.port = 0;
QVERIFY(!m_repo->createProfile(profile).has_value());
profile.port = 70000;
QVERIFY(!m_repo->createProfile(profile).has_value());
}
void TestProfileRepository::createProfileRejectsMissingUsernameForSsh()
{
Profile profile = makeSshProfile();
profile.username.clear();
QVERIFY(!m_repo->createProfile(profile).has_value());
}
void TestProfileRepository::createProfileRejectsMissingPrivateKeyForKeyAuth()
{
Profile profile = makeSshProfile();
profile.authMode = QStringLiteral("Private Key");
profile.privateKeyPath.clear();
QVERIFY(!m_repo->createProfile(profile).has_value());
}
void TestProfileRepository::createProfileRejectsDuplicateName()
{
QVERIFY(m_repo->createProfile(makeSshProfile(QStringLiteral("Same Name"))).has_value());
QVERIFY(!m_repo->createProfile(makeSshProfile(QStringLiteral("Same Name"))).has_value());
}
void TestProfileRepository::updateProfilePersistsChanges()
{
const std::optional<Profile> created = m_repo->createProfile(makeSshProfile());
QVERIFY(created.has_value());
Profile updated = created.value();
updated.host = QStringLiteral("new-host.example.com");
updated.port = 2222;
updated.tags = QStringLiteral("updated");
QVERIFY(m_repo->updateProfile(updated));
const std::optional<Profile> fetched = m_repo->getProfile(created->id);
QVERIFY(fetched.has_value());
QCOMPARE(fetched->host, QStringLiteral("new-host.example.com"));
QCOMPARE(fetched->port, 2222);
QCOMPARE(fetched->tags, QStringLiteral("updated"));
}
void TestProfileRepository::deleteProfileRemovesIt()
{
const std::optional<Profile> created = m_repo->createProfile(makeSshProfile());
QVERIFY(created.has_value());
QVERIFY(m_repo->deleteProfile(created->id));
QVERIFY(!m_repo->getProfile(created->id).has_value());
}
void TestProfileRepository::getProfileReturnsNulloptForUnknownId()
{
QVERIFY(!m_repo->getProfile(999999).has_value());
}
void TestProfileRepository::listProfilesFiltersBySearchQuery()
{
QVERIFY(m_repo->createProfile(makeSshProfile(QStringLiteral("Alpha"))).has_value());
QVERIFY(m_repo->createProfile(makeRdpProfile(QStringLiteral("Beta"))).has_value());
const auto byName = m_repo->listProfiles(QStringLiteral("Alpha"));
QCOMPARE(byName.size(), size_t(1));
QCOMPARE(byName[0].name, QStringLiteral("Alpha"));
const auto byHost = m_repo->listProfiles(QStringLiteral("win.example"));
QCOMPARE(byHost.size(), size_t(1));
QCOMPARE(byHost[0].name, QStringLiteral("Beta"));
const auto byTag = m_repo->listProfiles(QStringLiteral("linux"));
QCOMPARE(byTag.size(), size_t(1));
QCOMPARE(byTag[0].name, QStringLiteral("Alpha"));
QCOMPARE(m_repo->listProfiles(QStringLiteral("nonexistent")).size(), size_t(0));
}
void TestProfileRepository::listProfilesSortsByRequestedOrder()
{
QVERIFY(m_repo->createProfile(makeSshProfile(QStringLiteral("Zeta"))).has_value());
QVERIFY(m_repo->createProfile(makeRdpProfile(QStringLiteral("Alpha"))).has_value());
const auto byName = m_repo->listProfiles(QString(), ProfileSortOrder::NameAsc);
QCOMPARE(byName.size(), size_t(2));
QCOMPARE(byName[0].name, QStringLiteral("Alpha"));
QCOMPARE(byName[1].name, QStringLiteral("Zeta"));
const auto byProtocol = m_repo->listProfiles(QString(), ProfileSortOrder::ProtocolAsc);
QCOMPARE(byProtocol[0].protocol, QStringLiteral("RDP"));
QCOMPARE(byProtocol[1].protocol, QStringLiteral("SSH"));
}
void TestProfileRepository::tagsAreTrimmedDedupedAndJoined()
{
Profile profile = makeSshProfile();
profile.tags = QStringLiteral(" prod ,, Prod , linux ,linux");
const std::optional<Profile> created = m_repo->createProfile(profile);
QVERIFY(created.has_value());
// createProfile()'s return value echoes the input as-is; normalization
// only happens on the DB round trip, so re-fetch to observe it.
const std::optional<Profile> fetched = m_repo->getProfile(created->id);
QVERIFY(fetched.has_value());
// Case-insensitive de-dup keeps the first-seen casing of each tag.
QCOMPARE(fetched->tags, QStringLiteral("prod, linux"));
}
void TestProfileRepository::emptyTagsRoundTripAsEmpty()
{
const std::optional<Profile> created = m_repo->createProfile(makeRdpProfile());
QVERIFY(created.has_value());
const std::optional<Profile> fetched = m_repo->getProfile(created->id);
QVERIFY(fetched.has_value());
QCOMPARE(fetched->tags, QString());
}
void TestProfileRepository::folderCreateAndListRoundTrips()
{
QVERIFY(m_repo->createFolder(QStringLiteral("Work/Servers")));
const auto folders = m_repo->listFolders();
QCOMPARE(folders.size(), size_t(1));
QCOMPARE(folders[0], QStringLiteral("Work/Servers"));
}
void TestProfileRepository::folderCreateIgnoresDuplicates()
{
QVERIFY(m_repo->createFolder(QStringLiteral("Work")));
QVERIFY(m_repo->createFolder(QStringLiteral("Work")));
QCOMPARE(m_repo->listFolders().size(), size_t(1));
}
void TestProfileRepository::folderPathIsNormalized()
{
QVERIFY(m_repo->createFolder(QStringLiteral("\\Work\\\\Servers\\")));
const auto folders = m_repo->listFolders();
QCOMPARE(folders.size(), size_t(1));
QCOMPARE(folders[0], QStringLiteral("Work/Servers"));
}
QTEST_GUILESS_MAIN(TestProfileRepository)
#include "test_profile_repository.moc"
+1
View File
@@ -0,0 +1 @@
3.23.1-dev0