If your license expires, the application will stop working. Please renew your license today to "
+ "avoid any interruptions.
"
+ "
Once you have received your new serial key, you can enter it on the next screen.
")
+ .arg(notice)
+ );
+ }
+ }
+ });
+}
+
+void ActivationDialog::reject()
+{
+ // don't show the cancel confirmation dialog if they've already registered,
+ // since it's not relevant to customers who are changing their serial key.
+ const auto &license = m_licenseHandler.license();
+ if (license.isValid() && !license.isExpired()) {
+ QDialog::reject();
+ return;
+ }
+
+ // the accept button should be labeled "Exit" on the cancel dialog.
+ CancelActivationDialog cancelActivationDialog(this);
+ if (cancelActivationDialog.exec() == QDialog::Accepted) {
+ QApplication::exit();
+ }
+}
+
+void ActivationDialog::accept()
+{
+ using Result = LicenseHandler::SetSerialKeyResult;
+ auto serialKey = m_ui->m_pTextEditSerialKey->toPlainText();
+
+ if (serialKey.isEmpty()) {
+ QMessageBox::information(this, "Activation", "Please enter a serial key.");
+ return;
+ }
+
+ const auto result = m_licenseHandler.setLicense(serialKey);
+ if (result == Result::kUnchanged) {
+ qInfo() << "serial key did not change, nothing to do";
+ QDialog::accept();
+ return;
+ }
+
+ if (result != Result::kSuccess) {
+ showResultDialog(result);
+ return;
+ }
+
+ m_serialKeyChanged = true;
+ showSuccessDialog();
+ QDialog::accept();
+}
+
+void ActivationDialog::showResultDialog(LicenseHandler::SetSerialKeyResult result)
+{
+ switch (result) {
+ using enum LicenseHandler::SetSerialKeyResult;
+
+ case kInvalid:
+ QMessageBox::warning(
+ this, problemTitle,
+ QString(
+ "Invalid serial key. "
+ R"(Please contact us for help.)"
+ )
+ .arg(kUrlContact)
+ .arg(kColorSecondary)
+ );
+ break;
+
+ case kExpired:
+ QMessageBox::warning(
+ this, problemTitle,
+ QString(
+ "Sorry, that serial key has expired. "
+ R"(Please renew your license.)"
+ )
+ .arg(kUrlContact)
+ .arg(kColorSecondary)
+ );
+ break;
+
+ default:
+ qFatal("unexpected change serial key result: %d", static_cast(result));
+ }
+}
+
+void ActivationDialog::showSuccessDialog()
+{
+ const auto &license = m_licenseHandler.license();
+
+ QString title = successTitle;
+ QString message = tr("
Thanks for entering your serial key for %1.
").arg(m_licenseHandler.productName());
+
+ const auto tlsAvailable = m_licenseHandler.license().isTlsAvailable();
+ if (tlsAvailable && Settings::value(Settings::Security::TlsEnabled).toBool()) {
+ message += "
To ensure that TLS encryption works correctly, "
+ "please use the same serial key on all of your computers.
";
+ }
+
+ if (license.isTimeLimited()) {
+ auto daysLeft = license.daysLeft().count();
+ if (license.isTrial()) {
+ title = "Trial started";
+ message += QString("Your trial will expire in %1 %2.").arg(daysLeft).arg((daysLeft == 1) ? "day" : "days");
+ } else if (license.isSubscription()) {
+ message += QString("Your license will expire in %1 %2.").arg(daysLeft).arg((daysLeft == 1) ? "day" : "days");
+ }
+ }
+
+ QMessageBox::information(this, title, message);
+}
+
+void ActivationDialog::showErrorDialog(const QString &message)
+{
+ QString fullMessage = QString(
+ "
There was a problem with your serial key.
"
+ R"(
Please contact us )"
+ "and provide the following information:
"
+ "%3"
+ )
+ .arg(kUrlContact)
+ .arg(kColorSecondary)
+ .arg(message);
+ QMessageBox::warning(this, problemTitle, fullMessage);
+}
diff --git a/extra/src/lib/synergy/gui/ActivationDialog.h b/extra/src/lib/synergy/gui/ActivationDialog.h
new file mode 100644
index 000000000..2e1389dae
--- /dev/null
+++ b/extra/src/lib/synergy/gui/ActivationDialog.h
@@ -0,0 +1,65 @@
+/*
+ * Synergy -- mouse and keyboard sharing utility
+ * Copyright (C) 2016 Synergy App Ltd
+ *
+ * This package is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU General Public License
+ * found in the file LICENSE that should have accompanied this file.
+ *
+ * This package is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+#pragma once
+
+#include "synergy/gui/license/LicenseHandler.h"
+
+#include
+
+namespace Ui {
+class ActivationDialog;
+}
+
+class ActivationDialog : public QDialog
+{
+ Q_OBJECT
+
+public:
+ ActivationDialog(QWidget *parent, LicenseHandler &licenseHandler);
+ ~ActivationDialog() override;
+
+ class ActivationMessageError : public std::runtime_error
+ {
+ public:
+ ActivationMessageError() : std::runtime_error("could not show activation message")
+ {
+ }
+ };
+
+ bool serialKeyChanged() const
+ {
+ return m_serialKeyChanged;
+ }
+
+public Q_SLOTS:
+ void reject() override;
+ void accept() override;
+
+protected:
+ void refreshSerialKey();
+
+private:
+ void showResultDialog(LicenseHandler::SetSerialKeyResult result);
+ void showSuccessDialog();
+ void showErrorDialog(const QString &message);
+ void showEvent(QShowEvent *) override;
+
+ Ui::ActivationDialog *m_ui;
+ LicenseHandler &m_licenseHandler;
+ bool m_serialKeyChanged = false;
+};
diff --git a/extra/src/lib/synergy/gui/ActivationDialog.ui b/extra/src/lib/synergy/gui/ActivationDialog.ui
new file mode 100644
index 000000000..909b9ab7b
--- /dev/null
+++ b/extra/src/lib/synergy/gui/ActivationDialog.ui
@@ -0,0 +1,153 @@
+
+
+ ActivationDialog
+
+
+
+ 0
+ 0
+ 541
+ 241
+
+
+
+ Serial key
+
+
+
+
+
+
+ true
+
+
+
+ Enter your serial key
+
+
+
+
+
+
+ <p>Your serial key is on your <a href="https://synergyapp.io/account?source=gui" style="color:#4285f4;">account</span></a> page. Don't have a license? <a href="https://synergyapp.io/contact?source=gui" style="color:#4285f4;">Contact us</span></a></p>
+
+
+ true
+
+
+
+
+
+
+ true
+
+
+ true
+
+
+ <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.0//EN" "http://www.w3.org/TR/REC-html40/strict.dtd">
+<html><head><meta name="qrichtext" content="1" /><meta charset="utf-8" /><style type="text/css">
+p, li { white-space: pre-wrap; }
+hr { height: 1px; border-width: 0; }
+li.unchecked::marker { content: "\2610"; }
+li.checked::marker { content: "\2612"; }
+</style></head><body style=" font-family:'Cantarell'; font-size:11pt; font-weight:400; font-style:normal;">
+<p style="-qt-paragraph-type:empty; margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px; font-family:'Sans'; font-size:10pt;"><br /></p></body></html>
+
+
+ false
+
+
+
+
+
+
+
+ 2
+
+
+ 0
+
+
+ 0
+
+
+ 8
+
+
+
+
+ m_pLabelNotice
+
+
+ true
+
+
+
+
+
+
+ Qt::Horizontal
+
+
+
+ 40
+ 20
+
+
+
+
+
+
+
+
+
+
+ Qt::Horizontal
+
+
+ QDialogButtonBox::Cancel|QDialogButtonBox::Ok
+
+
+
+
+
+
+ m_pTextEditSerialKey
+
+
+
+
+ buttonBox
+ accepted()
+ ActivationDialog
+ accept()
+
+
+ 248
+ 254
+
+
+ 157
+ 274
+
+
+
+
+ buttonBox
+ rejected()
+ ActivationDialog
+ reject()
+
+
+ 316
+ 260
+
+
+ 286
+ 274
+
+
+
+
+
diff --git a/extra/src/lib/synergy/gui/AppTime.cpp b/extra/src/lib/synergy/gui/AppTime.cpp
new file mode 100644
index 000000000..7f27594ff
--- /dev/null
+++ b/extra/src/lib/synergy/gui/AppTime.cpp
@@ -0,0 +1,49 @@
+/*
+ * synergy -- mouse and keyboard sharing utility
+ * Copyright (C) 2024 Synergy App Ltd
+ *
+ * This package is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU General Public License
+ * found in the file LICENSE that should have accompanied this file.
+ *
+ * This package is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+#include "AppTime.h"
+
+#include "synergy/gui/TestSettings.h"
+
+#include
+
+namespace synergy::gui {
+
+AppTime::AppTime()
+{
+ m_realStartTime = std::chrono::system_clock::now();
+ if (const auto testTime = TestSettings::instance().startTimeEpochSecs(); testTime != 0) {
+ qDebug("setting test time to: %lld", static_cast(testTime));
+ m_testStartTime = std::chrono::seconds{testTime};
+ }
+}
+
+bool AppTime::hasTestTime() const
+{
+ return m_testStartTime.has_value();
+}
+
+AppTime::TimePoint AppTime::now()
+{
+ if (m_testStartTime.has_value()) {
+ const auto runtime = std::chrono::system_clock::now() - m_realStartTime;
+ return TimePoint{m_testStartTime.value()} + runtime;
+ }
+ return std::chrono::system_clock::now();
+}
+
+} // namespace synergy::gui
diff --git a/extra/src/lib/synergy/gui/AppTime.h b/extra/src/lib/synergy/gui/AppTime.h
new file mode 100644
index 000000000..97aeac4a7
--- /dev/null
+++ b/extra/src/lib/synergy/gui/AppTime.h
@@ -0,0 +1,39 @@
+/*
+ * synergy -- mouse and keyboard sharing utility
+ * Copyright (C) 2024 Synergy App Ltd
+ *
+ * This package is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU General Public License
+ * found in the file LICENSE that should have accompanied this file.
+ *
+ * This package is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+#pragma once
+
+#include
+#include
+
+namespace synergy::gui {
+
+class AppTime
+{
+ using TimePoint = std::chrono::system_clock::time_point;
+
+public:
+ AppTime();
+ TimePoint now();
+ bool hasTestTime() const;
+
+private:
+ std::optional m_testStartTime = std::nullopt;
+ TimePoint m_realStartTime = std::chrono::system_clock::now();
+};
+
+} // namespace synergy::gui
diff --git a/extra/src/lib/synergy/gui/CMakeLists.txt b/extra/src/lib/synergy/gui/CMakeLists.txt
new file mode 100644
index 000000000..9b6518254
--- /dev/null
+++ b/extra/src/lib/synergy/gui/CMakeLists.txt
@@ -0,0 +1,42 @@
+# Synergy -- mouse and keyboard sharing utility
+# Copyright (C) 2024 Synergy App Ltd
+#
+# This package is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# found in the file LICENSE that should have accompanied this file.
+#
+# This package is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see .
+
+set(target synergy-gui)
+
+set(res_dir ${GUI_RES_DIR})
+set(qrc_file ${GUI_QRC_FILE})
+
+set(CMAKE_AUTOMOC ON)
+set(CMAKE_AUTORCC ON)
+set(CMAKE_AUTOUIC ON)
+set(CMAKE_INCLUDE_CURRENT_DIR ON)
+
+file(GLOB_RECURSE sources *.cpp)
+file(GLOB_RECURSE headers *.h)
+file(GLOB_RECURSE ui_files *.ui)
+
+if(ADD_HEADERS_TO_SOURCES)
+ list(APPEND sources ${headers})
+endif()
+
+add_library(${target} STATIC ${sources} ${ui_files} ${qrc_file})
+
+target_link_libraries(
+ ${target}
+ gui
+ license
+ Qt6::Core
+ Qt6::Widgets
+ Qt6::Network)
diff --git a/extra/src/lib/synergy/gui/CancelActivationDialog.cpp b/extra/src/lib/synergy/gui/CancelActivationDialog.cpp
new file mode 100644
index 000000000..21e217c9f
--- /dev/null
+++ b/extra/src/lib/synergy/gui/CancelActivationDialog.cpp
@@ -0,0 +1,35 @@
+/*
+ * Deskflow -- mouse and keyboard sharing utility
+ * Copyright (C) 2016 Synergy App Ltd
+ *
+ * This package is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU General Public License
+ * found in the file LICENSE that should have accompanied this file.
+ *
+ * This package is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+#include "CancelActivationDialog.h"
+
+#include "ui_CancelActivationDialog.h"
+
+#include "QPushButton"
+
+CancelActivationDialog::CancelActivationDialog(QWidget *parent) : QDialog(parent), ui(new Ui::CancelActivationDialog)
+{
+ ui->setupUi(this);
+
+ ui->m_pButtonBox->button(QDialogButtonBox::Cancel)->setText("&Back");
+ ui->m_pButtonBox->button(QDialogButtonBox::Ok)->setText("&Exit");
+}
+
+CancelActivationDialog::~CancelActivationDialog()
+{
+ delete ui;
+}
diff --git a/extra/src/lib/synergy/gui/CancelActivationDialog.h b/extra/src/lib/synergy/gui/CancelActivationDialog.h
new file mode 100644
index 000000000..05362214a
--- /dev/null
+++ b/extra/src/lib/synergy/gui/CancelActivationDialog.h
@@ -0,0 +1,36 @@
+/*
+ * Deskflow -- mouse and keyboard sharing utility
+ * Copyright (C) 2016 Synergy App Ltd
+ *
+ * This package is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU General Public License
+ * found in the file LICENSE that should have accompanied this file.
+ *
+ * This package is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+#pragma once
+
+#include
+
+namespace Ui {
+class CancelActivationDialog;
+}
+
+class CancelActivationDialog : public QDialog
+{
+ Q_OBJECT
+
+public:
+ explicit CancelActivationDialog(QWidget *parent = 0);
+ ~CancelActivationDialog();
+
+private:
+ Ui::CancelActivationDialog *ui;
+};
diff --git a/extra/src/lib/synergy/gui/CancelActivationDialog.ui b/extra/src/lib/synergy/gui/CancelActivationDialog.ui
new file mode 100644
index 000000000..7640407ea
--- /dev/null
+++ b/extra/src/lib/synergy/gui/CancelActivationDialog.ui
@@ -0,0 +1,77 @@
+
+
+ CancelActivationDialog
+
+
+
+ 0
+ 0
+ 429
+ 273
+
+
+
+ Cancel Activation
+
+
+
+
+
+ <html><head/><body><p>You'll need to purchase a license to use Synergy.</p><p><a href="https://synergyapp.io/contact?source=gui"><span style=" text-decoration: underline; color:#007af4;">Contact us</span></a></p><p>The application will now exit.</p></body></html>
+
+
+ true
+
+
+ true
+
+
+
+
+
+
+ Qt::Orientation::Horizontal
+
+
+ QDialogButtonBox::StandardButton::Cancel|QDialogButtonBox::StandardButton::Ok
+
+
+
+
+
+
+
+
+ m_pButtonBox
+ accepted()
+ CancelActivationDialog
+ accept()
+
+
+ 248
+ 254
+
+
+ 157
+ 274
+
+
+
+
+ m_pButtonBox
+ rejected()
+ CancelActivationDialog
+ reject()
+
+
+ 316
+ 260
+
+
+ 286
+ 274
+
+
+
+
+
diff --git a/extra/src/lib/synergy/gui/ExtraSettings.cpp b/extra/src/lib/synergy/gui/ExtraSettings.cpp
new file mode 100644
index 000000000..3de9b3dc2
--- /dev/null
+++ b/extra/src/lib/synergy/gui/ExtraSettings.cpp
@@ -0,0 +1,82 @@
+/*
+ * Synergy -- mouse and keyboard sharing utility
+ * Copyright (C) 2024 Synergy App Ltd
+ *
+ * This package is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU General Public License
+ * found in the file LICENSE that should have accompanied this file.
+ *
+ * This package is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+#include "ExtraSettings.h"
+
+#include "common/Constants.h"
+#include "common/Settings.h"
+
+#include
+#include
+#include
+
+namespace synergy::gui {
+
+namespace {
+
+// Persisted synergy-side state lives in its own sibling file rather than
+// upstream's Synergy.conf, because upstream's Settings::cleanSettings()
+// strips any key not in its allow-list at startup. Keeping our state out
+// of that file means no upstream patch + no key-allow-list maintenance.
+QString settingsFile()
+{
+ return QStringLiteral("%1/%2.extra.conf").arg(Settings::UserDir, kAppName);
+}
+
+const auto kSerialKey = QStringLiteral("serialKey");
+const auto kActivated = QStringLiteral("activated");
+const auto kGraceStart = QStringLiteral("graceStartEpochSecs");
+
+} // namespace
+
+void ExtraSettings::load()
+{
+ QSettings ini(settingsFile(), QSettings::IniFormat);
+ m_serialKey = ini.value(kSerialKey).toString();
+ m_activated = ini.value(kActivated).toBool();
+ m_graceStartEpochSecs = ini.value(kGraceStart).toLongLong();
+}
+
+void ExtraSettings::sync()
+{
+ QSettings ini(settingsFile(), QSettings::IniFormat);
+ if (!ini.isWritable()) {
+ qCritical() << "unable to save synergy settings, file not writable:" << ini.fileName();
+ return;
+ }
+ ini.setValue(kSerialKey, m_serialKey);
+ ini.setValue(kActivated, m_activated);
+ ini.setValue(kGraceStart, m_graceStartEpochSecs);
+ ini.sync();
+}
+
+QString ExtraSettings::fileName() const
+{
+ return settingsFile();
+}
+
+bool ExtraSettings::isWritable() const
+{
+ QFileInfo info(settingsFile());
+ if (info.exists()) {
+ return info.isWritable();
+ }
+ // If the file doesn't exist yet, writability depends on the parent dir.
+ return QFileInfo(info.absolutePath()).isWritable();
+}
+
+} // namespace synergy::gui
diff --git a/extra/src/lib/synergy/gui/ExtraSettings.h b/extra/src/lib/synergy/gui/ExtraSettings.h
new file mode 100644
index 000000000..ce737cc76
--- /dev/null
+++ b/extra/src/lib/synergy/gui/ExtraSettings.h
@@ -0,0 +1,70 @@
+/*
+ * Synergy -- mouse and keyboard sharing utility
+ * Copyright (C) 2024 Synergy App Ltd
+ *
+ * This package is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU General Public License
+ * found in the file LICENSE that should have accompanied this file.
+ *
+ * This package is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+#pragma once
+
+#include
+
+namespace synergy::gui {
+
+// Synergy license state (serial key, activation, grace period). Stored as
+// synergy/* keys in the upstream Settings file via the new static Settings API.
+class ExtraSettings
+{
+public:
+ ExtraSettings() = default;
+
+ void load();
+ void sync();
+
+ QString serialKey() const
+ {
+ return m_serialKey;
+ }
+ void setSerialKey(const QString &serialKey)
+ {
+ m_serialKey = serialKey;
+ }
+
+ bool activated() const
+ {
+ return m_activated;
+ }
+ void setActivated(bool activated)
+ {
+ m_activated = activated;
+ }
+
+ qint64 graceStartEpochSecs() const
+ {
+ return m_graceStartEpochSecs;
+ }
+ void setGraceStartEpochSecs(qint64 epochSecs)
+ {
+ m_graceStartEpochSecs = epochSecs;
+ }
+
+ QString fileName() const;
+ bool isWritable() const;
+
+private:
+ QString m_serialKey;
+ bool m_activated = false;
+ qint64 m_graceStartEpochSecs = 0;
+};
+
+} // namespace synergy::gui
diff --git a/extra/src/lib/synergy/gui/FeatureHandler.cpp b/extra/src/lib/synergy/gui/FeatureHandler.cpp
new file mode 100644
index 000000000..a5b6f9d96
--- /dev/null
+++ b/extra/src/lib/synergy/gui/FeatureHandler.cpp
@@ -0,0 +1,183 @@
+/*
+ * Synergy -- mouse and keyboard sharing utility
+ * Copyright (C) 2025 Synergy App Ltd
+ *
+ * This package is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU General Public License
+ * found in the file LICENSE that should have accompanied this file.
+ *
+ * This package is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+#include "FeatureHandler.h"
+
+#include "common/Settings.h"
+#include "license/LicenseHandler.h"
+#include "synergy/gui/SettingsMigration.h"
+#include "synergy/gui/SettingsScope.h"
+#include "synergy/gui/TestSettings.h"
+#include "synergy/gui/constants.h"
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+using namespace synergy::gui;
+
+void FeatureHandler::handleMainWindow(QMainWindow *mainWindow)
+{
+ m_pMainWindow = mainWindow;
+}
+
+void FeatureHandler::handleAppStart()
+{
+ // wl-clipboard's focus-stealing behavior on common compositors makes it
+ // unfit for shipping; force the setting off on every launch regardless
+ // of how it got set.
+ Settings::setValue(Settings::Core::UseWlClipboard, false);
+
+ if (TestSettings::instance().isEnabled()) {
+ addTestMenu();
+ }
+}
+
+void FeatureHandler::addTestMenu()
+{
+ if (m_pMainWindow == nullptr) {
+ qWarning("cannot add test menu, main window not set");
+ return;
+ }
+
+ auto testMenu = new QMenu(QObject::tr("Test"), m_pMainWindow);
+ m_pMainWindow->menuBar()->addMenu(testMenu);
+
+ auto fatalAction = new QAction(QObject::tr("Trigger fatal error"), m_pMainWindow);
+ QObject::connect(fatalAction, &QAction::triggered, [] { qFatal("test fatal error"); });
+ testMenu->addAction(fatalAction);
+
+ auto criticalAction = new QAction(QObject::tr("Trigger critical error"), m_pMainWindow);
+ QObject::connect(criticalAction, &QAction::triggered, [] { qCritical("test critical error"); });
+ testMenu->addAction(criticalAction);
+}
+
+void FeatureHandler::handleSettings(QDialog *parent) const
+{
+ if (parent == nullptr) {
+ return;
+ }
+ if (auto *wlClipboard = parent->findChild(QStringLiteral("widgetWlClipboard"))) {
+ wlClipboard->hide();
+ }
+
+ const auto &licenseHandler = LicenseHandler::instance();
+ if (licenseHandler.isEnabled() && !licenseHandler.license().isSettingsScopeAvailable()) {
+ return;
+ }
+ addScopeTab(parent);
+}
+
+namespace {
+
+QString pathLabel(const QString &path)
+{
+ if (QFileInfo::exists(path)) {
+ return QStringLiteral("%1").arg(path);
+ }
+ return QStringLiteral("%1 %2")
+ .arg(path, QObject::tr("(not yet created)"));
+}
+
+} // namespace
+
+void FeatureHandler::addScopeTab(QDialog *parent) const
+{
+ auto *tabWidget = parent->findChild();
+ if (tabWidget == nullptr) {
+ qWarning("scope tab: no QTabWidget in settings dialog, skipping");
+ return;
+ }
+
+ auto *scopeTab = new QWidget(tabWidget);
+ auto *layout = new QVBoxLayout(scopeTab);
+
+ auto *intro = new QLabel(
+ QObject::tr("Choose where %1 stores its settings. Changes take effect when %1 restarts.")
+ .arg(QApplication::applicationName()),
+ scopeTab
+ );
+ intro->setWordWrap(true);
+ layout->addWidget(intro);
+ layout->addSpacing(8);
+
+ const auto mutedColor = scopeTab->palette().color(QPalette::Disabled, QPalette::WindowText).name();
+ const auto helpStyle = QStringLiteral("color: %1;").arg(mutedColor);
+ const auto pathStyle = QStringLiteral("color: %1; font-size: 10pt;").arg(mutedColor);
+
+ const auto buildOption = [&](const QString &title, const QString &help, const QString &path) {
+ auto *radio = new QRadioButton(title, scopeTab);
+ auto *helpLabel = new QLabel(help, scopeTab);
+ helpLabel->setWordWrap(true);
+ helpLabel->setIndent(22);
+ helpLabel->setStyleSheet(helpStyle);
+ auto *pathWidget = new QLabel(pathLabel(path), scopeTab);
+ pathWidget->setIndent(22);
+ pathWidget->setOpenExternalLinks(true);
+ pathWidget->setTextInteractionFlags(Qt::TextBrowserInteraction);
+ pathWidget->setWordWrap(true);
+ pathWidget->setStyleSheet(pathStyle);
+
+ layout->addWidget(radio);
+ layout->addWidget(helpLabel);
+ layout->addWidget(pathWidget);
+ layout->addSpacing(14);
+ return radio;
+ };
+
+ auto *userRadio = buildOption(
+ QObject::tr("Current user"),
+ QObject::tr("Settings apply only to your user account on this computer."),
+ Settings::UserSettingFile
+ );
+ auto *systemRadio = buildOption(
+ QObject::tr("All users"),
+ QObject::tr("Settings apply to every user on this computer. Requires administrator privileges."),
+ Settings::SystemSettingFile
+ );
+
+ const bool isSystem = (Settings::settingsFile() == Settings::SystemSettingFile);
+ userRadio->setChecked(!isSystem);
+ systemRadio->setChecked(isSystem);
+
+ layout->addStretch();
+
+ QObject::connect(systemRadio, &QRadioButton::toggled, parent, [parent, userRadio, systemRadio](bool toSystem) {
+ if (SettingsScope::switchTo(parent, toSystem)) {
+ return;
+ }
+ QSignalBlocker blockUser(userRadio);
+ QSignalBlocker blockSystem(systemRadio);
+ userRadio->setChecked(!toSystem);
+ systemRadio->setChecked(toSystem ? false : true);
+ });
+
+ tabWidget->addTab(scopeTab, QObject::tr("Scope"));
+}
diff --git a/extra/src/lib/synergy/gui/FeatureHandler.h b/extra/src/lib/synergy/gui/FeatureHandler.h
new file mode 100644
index 000000000..fa59accf1
--- /dev/null
+++ b/extra/src/lib/synergy/gui/FeatureHandler.h
@@ -0,0 +1,41 @@
+/*
+ * Synergy -- mouse and keyboard sharing utility
+ * Copyright (C) 2025 Synergy App Ltd
+ *
+ * This package is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU General Public License
+ * found in the file LICENSE that should have accompanied this file.
+ *
+ * This package is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+#pragma once
+
+class QDialog;
+class QMainWindow;
+
+class FeatureHandler
+{
+public:
+ static FeatureHandler &instance()
+ {
+ static FeatureHandler instance;
+ return instance;
+ }
+
+ void handleMainWindow(QMainWindow *mainWindow);
+ void handleAppStart();
+ void handleSettings(QDialog *parent) const;
+
+private:
+ void addTestMenu();
+ void addScopeTab(QDialog *parent) const;
+
+ QMainWindow *m_pMainWindow = nullptr;
+};
diff --git a/extra/src/lib/synergy/gui/SettingsMigration.cpp b/extra/src/lib/synergy/gui/SettingsMigration.cpp
new file mode 100644
index 000000000..c73156f7e
--- /dev/null
+++ b/extra/src/lib/synergy/gui/SettingsMigration.cpp
@@ -0,0 +1,308 @@
+/*
+ * Synergy -- mouse and keyboard sharing utility
+ * Copyright (C) 2026 Synergy App Ltd
+ *
+ * This package is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU General Public License
+ * found in the file LICENSE that should have accompanied this file.
+ *
+ * This package is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+#include "SettingsMigration.h"
+
+#include "common/Constants.h"
+#include "common/Settings.h"
+#include "synergy/gui/SettingsScope.h"
+
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include
+#include
+
+namespace synergy::gui::migration {
+
+namespace {
+
+const auto kSchemaKey = QStringLiteral("migration/schemaVersion");
+const auto kNotifiedKey = QStringLiteral("migration/notifiedFor");
+const auto kLegacySystemScopeKey = QStringLiteral("systemScope");
+
+QString extraFile()
+{
+ return QStringLiteral("%1/%2.extra.conf").arg(Settings::UserDir, kAppName);
+}
+
+int storedSchemaVersion()
+{
+ QSettings ini(extraFile(), QSettings::IniFormat);
+ return ini.value(kSchemaKey, 0).toInt();
+}
+
+void writeSchemaVersion(int version)
+{
+ QSettings ini(extraFile(), QSettings::IniFormat);
+ ini.setValue(kSchemaKey, version);
+ ini.sync();
+}
+
+int notifiedSchemaVersion()
+{
+ QSettings ini(extraFile(), QSettings::IniFormat);
+ return ini.value(kNotifiedKey, 0).toInt();
+}
+
+void writeNotifiedVersion(int version)
+{
+ QSettings ini(extraFile(), QSettings::IniFormat);
+ ini.setValue(kNotifiedKey, version);
+ ini.sync();
+}
+
+std::optional> mapKey(const QString &oldKey, const QVariant &value)
+{
+ if (oldKey == "screenName") {
+ return std::make_pair(Settings::Core::ComputerName, value);
+ }
+ if (oldKey == "port") {
+ return std::make_pair(Settings::Core::Port, value);
+ }
+ if (oldKey == "interface") {
+ return std::make_pair(Settings::Core::Interface, value);
+ }
+ if (oldKey == "logLevel2") {
+ return std::make_pair(Settings::Log::Level, value);
+ }
+ if (oldKey == "logToFile") {
+ return std::make_pair(Settings::Log::ToFile, value);
+ }
+ if (oldKey == "logFilename") {
+ return std::make_pair(Settings::Log::File, value);
+ }
+ if (oldKey == "elevateModeEnum") {
+ return std::make_pair(Settings::Daemon::Elevate, value);
+ }
+ if (oldKey == "cryptoEnabled") {
+ return std::make_pair(Settings::Security::TlsEnabled, value);
+ }
+ if (oldKey == "autoHide") {
+ return std::make_pair(Settings::Gui::Autohide, value);
+ }
+ if (oldKey == "lastVersion") {
+ return std::make_pair(Settings::Core::LastVersion, value);
+ }
+ if (oldKey == "groupServerChecked") {
+ if (value.toBool()) {
+ return std::make_pair(Settings::Core::CoreMode, QVariant(Settings::Server));
+ }
+ return std::nullopt;
+ }
+ if (oldKey == "groupClientChecked") {
+ if (value.toBool()) {
+ return std::make_pair(Settings::Core::CoreMode, QVariant(Settings::Client));
+ }
+ return std::nullopt;
+ }
+ if (oldKey == "useExternalConfig") {
+ return std::make_pair(Settings::Server::ExternalConfig, value);
+ }
+ if (oldKey == "configFile") {
+ return std::make_pair(Settings::Server::ExternalConfigFile, value);
+ }
+ if (oldKey == "serverHostname") {
+ return std::make_pair(Settings::Client::RemoteHost, value);
+ }
+ if (oldKey == "tlsCertPath") {
+ return std::make_pair(Settings::Security::Certificate, value);
+ }
+ if (oldKey == "tlsKeyLength") {
+ return std::make_pair(Settings::Security::KeySize, value);
+ }
+ if (oldKey == "preventSleep") {
+ return std::make_pair(Settings::Core::PreventSleep, value);
+ }
+ if (oldKey == "languageSync") {
+ return std::make_pair(Settings::Client::LanguageSync, value);
+ }
+ if (oldKey == "invertScrollDirection") {
+ return std::make_pair(Settings::Client::InvertYScroll, value);
+ }
+ if (oldKey == "enableService") {
+ const auto mode = value.toBool() ? Settings::Service : Settings::Desktop;
+ return std::make_pair(Settings::Core::ProcessMode, QVariant(mode));
+ }
+ if (oldKey == "closeToTray") {
+ return std::make_pair(Settings::Gui::CloseToTray, value);
+ }
+ if (oldKey == "showCloseReminder") {
+ return std::make_pair(Settings::Gui::CloseReminder, value);
+ }
+ if (oldKey == "enableUpdateCheck") {
+ return std::make_pair(Settings::Gui::AutoUpdateCheck, value);
+ }
+ return std::nullopt;
+}
+
+// File-existence isn't a usable signal: on Linux the legacy NativeFormat
+// path coincides with the new IniFormat path, so the file is always
+// "present". Sentinel keys discriminate.
+bool looksLikeLegacy(const QSettings &legacy)
+{
+ return legacy.contains(QStringLiteral("startedBefore")) || legacy.contains(QStringLiteral("groupServerChecked")) ||
+ legacy.contains(QStringLiteral("groupClientChecked")) || legacy.contains(kLegacySystemScopeKey);
+}
+
+// Dumps in-memory contents instead of file-copying so the backup format is
+// uniform regardless of the legacy backend (file, plist, or registry).
+QString backupLegacy(const QSettings &legacy, const QString &destPath)
+{
+ if (QFile::exists(destPath)) {
+ QFile::remove(destPath);
+ }
+ QSettings backup(destPath, QSettings::IniFormat);
+ for (const auto &key : legacy.allKeys()) {
+ backup.setValue(key, legacy.value(key));
+ }
+ backup.sync();
+ return destPath;
+}
+
+// Master had hardcoded behavior where beta exposes settings; align beta's
+// defaults with master's runtime behavior so migrated users see no change.
+void applyMasterCompatDefaults(const QString &newPath)
+{
+ QSettings newSettings(newPath, QSettings::IniFormat);
+ newSettings.setValue(Settings::Gui::AutoStartCore, true);
+ newSettings.sync();
+}
+
+int migrateOneScope(QSettings &legacy, const QString &newPath)
+{
+ QSettings newSettings(newPath, QSettings::IniFormat);
+ int migrated = 0;
+ int dropped = 0;
+ for (const auto &oldKey : legacy.allKeys()) {
+ if (auto mapped = mapKey(oldKey, legacy.value(oldKey)); mapped.has_value()) {
+ newSettings.setValue(mapped->first, mapped->second);
+ migrated++;
+ } else {
+ dropped++;
+ }
+ }
+ newSettings.sync();
+ qInfo("settings migration: scope %s, migrated %d keys, dropped %d obsolete", qPrintable(newPath), migrated, dropped);
+ return migrated;
+}
+
+bool s_migrationRanThisLaunch = false;
+QString s_lastBackupPath;
+
+// On Linux, NativeFormat resolves to the same file beta's Settings uses,
+// so clear() on the legacy QSettings would wipe the keys we just wrote.
+// On macOS/Windows the storage backends are distinct (plist / registry).
+bool sharesPathWith(const QSettings &legacy, const QString &newPath)
+{
+ const auto legacyCanonical = QFileInfo(legacy.fileName()).canonicalFilePath();
+ const auto newCanonical = QFileInfo(newPath).canonicalFilePath();
+ if (legacyCanonical.isEmpty() || newCanonical.isEmpty()) {
+ return QFileInfo(legacy.fileName()).absoluteFilePath() == QFileInfo(newPath).absoluteFilePath();
+ }
+ return legacyCanonical == newCanonical;
+}
+
+void maybeClearLegacy(QSettings &legacy, const QString &newPath, const char *scopeLabel)
+{
+ if (sharesPathWith(legacy, newPath)) {
+ qDebug("settings migration: %s legacy shares storage with new file, skipping clear", scopeLabel);
+ return;
+ }
+ if (!legacy.isWritable()) {
+ qWarning("settings migration: %s legacy not writable, leaving legacy keys in place", scopeLabel);
+ return;
+ }
+ legacy.clear();
+ legacy.sync();
+ qInfo("settings migration: cleared %s legacy storage at %s", scopeLabel, qPrintable(legacy.fileName()));
+}
+
+bool runLegacyMigration()
+{
+ bool any = false;
+
+ QSettings legacyUser(QSettings::NativeFormat, QSettings::UserScope, kAppName, kAppName);
+ const bool legacyHadSystemScope = legacyUser.value(kLegacySystemScopeKey, false).toBool();
+ if (looksLikeLegacy(legacyUser)) {
+ s_lastBackupPath = backupLegacy(legacyUser, Settings::UserSettingFile + QStringLiteral(".legacy.bak"));
+ qInfo().noquote() << "settings migration: user-scope legacy backed up to" << s_lastBackupPath;
+ if (migrateOneScope(legacyUser, Settings::UserSettingFile) > 0) {
+ any = true;
+ applyMasterCompatDefaults(Settings::UserSettingFile);
+ }
+ maybeClearLegacy(legacyUser, Settings::UserSettingFile, "user-scope");
+ }
+
+ QSettings legacySystem(QSettings::NativeFormat, QSettings::SystemScope, kAppName, kAppName);
+ if (looksLikeLegacy(legacySystem)) {
+ const auto backupPath = Settings::SystemSettingFile + QStringLiteral(".legacy.bak");
+ s_lastBackupPath = backupLegacy(legacySystem, backupPath);
+ qInfo().noquote() << "settings migration: system-scope legacy backed up to" << s_lastBackupPath;
+ if (migrateOneScope(legacySystem, Settings::SystemSettingFile) > 0) {
+ any = true;
+ applyMasterCompatDefaults(Settings::SystemSettingFile);
+ }
+ maybeClearLegacy(legacySystem, Settings::SystemSettingFile, "system-scope");
+ }
+
+ if (legacyHadSystemScope) {
+ SettingsScope::setPreferSystem(true);
+ }
+
+ return any;
+}
+
+} // namespace
+
+bool migrateIfNeeded()
+{
+ if (storedSchemaVersion() >= kCurrentSchemaVersion) {
+ return false;
+ }
+
+ s_migrationRanThisLaunch = runLegacyMigration();
+ writeSchemaVersion(kCurrentSchemaVersion);
+ return s_migrationRanThisLaunch;
+}
+
+void showNoticeIfPending(QWidget *parent)
+{
+ if (notifiedSchemaVersion() >= kCurrentSchemaVersion) {
+ return;
+ }
+ if (!s_migrationRanThisLaunch) {
+ writeNotifiedVersion(kCurrentSchemaVersion);
+ return;
+ }
+
+ QMessageBox::information(
+ parent, QObject::tr("Settings updated"),
+ QObject::tr("
We've migrated your settings to a new format used by this version of Synergy.
"
+ "
Your previous settings have been backed up to:
"
+ "
%1
"
+ "
If anything looks different, please contact us.
")
+ .arg(s_lastBackupPath)
+ );
+ writeNotifiedVersion(kCurrentSchemaVersion);
+}
+
+} // namespace synergy::gui::migration
diff --git a/extra/src/lib/synergy/gui/SettingsMigration.h b/extra/src/lib/synergy/gui/SettingsMigration.h
new file mode 100644
index 000000000..ace999331
--- /dev/null
+++ b/extra/src/lib/synergy/gui/SettingsMigration.h
@@ -0,0 +1,49 @@
+/*
+ * Synergy -- mouse and keyboard sharing utility
+ * Copyright (C) 2026 Synergy App Ltd
+ *
+ * This package is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU General Public License
+ * found in the file LICENSE that should have accompanied this file.
+ *
+ * This package is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+#pragma once
+
+class QWidget;
+
+namespace synergy::gui::migration {
+
+/// Bumped each time a new migration is added.
+constexpr int kCurrentSchemaVersion = 1;
+
+/**
+ * @brief Ports legacy-format settings (Synergy 1.x, both user and system
+ * scope, native QSettings backend) into the new Settings layout.
+ *
+ * Must run before Settings::instance() is constructed: upstream's
+ * cleanSettings() strips any key not in its allow-list, which would erase
+ * the legacy keys before this can read them. Idempotent, gated on
+ * migration/schemaVersion in Synergy.extra.conf.
+ *
+ * @return true if a migration was performed this launch.
+ */
+bool migrateIfNeeded();
+
+/**
+ * @brief Modal one-time notice shown after MainWindow is open.
+ *
+ * No-op unless a migration ran since the last call. Marks
+ * migration/notifiedFor=schemaVersion when dismissed so a new migration
+ * (bumped schema version) shows the popup again.
+ */
+void showNoticeIfPending(QWidget *parent);
+
+} // namespace synergy::gui::migration
diff --git a/extra/src/lib/synergy/gui/SettingsScope.cpp b/extra/src/lib/synergy/gui/SettingsScope.cpp
new file mode 100644
index 000000000..edbaa18ec
--- /dev/null
+++ b/extra/src/lib/synergy/gui/SettingsScope.cpp
@@ -0,0 +1,120 @@
+/*
+ * Synergy -- mouse and keyboard sharing utility
+ * Copyright (C) 2026 Synergy App Ltd
+ *
+ * This package is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU General Public License
+ * found in the file LICENSE that should have accompanied this file.
+ *
+ * This package is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+#include "SettingsScope.h"
+
+#include "common/Constants.h"
+#include "common/Settings.h"
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+namespace synergy::gui {
+
+namespace {
+
+const auto kPreferSystemKey = QStringLiteral("scope/preferSystem");
+
+QString extraFile()
+{
+ return QStringLiteral("%1/%2.extra.conf").arg(Settings::UserDir, kAppName);
+}
+
+} // namespace
+
+bool SettingsScope::preferSystem()
+{
+ QSettings extra(extraFile(), QSettings::IniFormat);
+ return extra.value(kPreferSystemKey, false).toBool();
+}
+
+void SettingsScope::setPreferSystem(bool prefer)
+{
+ QSettings extra(extraFile(), QSettings::IniFormat);
+ extra.setValue(kPreferSystemKey, prefer);
+ extra.sync();
+}
+
+bool SettingsScope::isSystemWritable()
+{
+ const QFileInfo info(Settings::SystemSettingFile);
+ if (info.exists()) {
+ return info.isWritable();
+ }
+ QFileInfo dir(QFileInfo(Settings::SystemSettingFile).absolutePath());
+ if (dir.exists()) {
+ return dir.isWritable();
+ }
+ QDir cursor = dir.dir();
+ while (!cursor.exists() && cursor.cdUp()) {
+ }
+ return QFileInfo(cursor.absolutePath()).isWritable();
+}
+
+bool SettingsScope::switchTo(QDialog *parent, bool toSystem)
+{
+ if (toSystem && !isSystemWritable()) {
+ QMessageBox::warning(
+ parent, QObject::tr("Cannot switch to 'All users' scope"),
+ QObject::tr(
+ "
%1 can't write to %2.
"
+ "
The 'All users' scope requires administrator privileges. "
+ "Run %1 as administrator/root to change settings here.
Settings scope changes take effect when %1 restarts.
"
+ "
Quit %1 now? You'll need to launch it again from your applications menu.
"
+ )
+ .arg(QApplication::applicationName()),
+ QMessageBox::Yes | QMessageBox::No, QMessageBox::Yes
+ );
+ if (choice == QMessageBox::Yes) {
+ QApplication::quit();
+ }
+ return true;
+}
+
+} // namespace synergy::gui
diff --git a/extra/src/lib/synergy/gui/SettingsScope.h b/extra/src/lib/synergy/gui/SettingsScope.h
new file mode 100644
index 000000000..0882d369d
--- /dev/null
+++ b/extra/src/lib/synergy/gui/SettingsScope.h
@@ -0,0 +1,54 @@
+/*
+ * Synergy -- mouse and keyboard sharing utility
+ * Copyright (C) 2026 Synergy App Ltd
+ *
+ * This package is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU General Public License
+ * found in the file LICENSE that should have accompanied this file.
+ *
+ * This package is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+#pragma once
+
+class QDialog;
+
+namespace synergy::gui {
+
+class SettingsScope
+{
+public:
+ /**
+ * @brief Whether the user has chosen the system-scope settings file.
+ * Persisted in Synergy.extra.conf so it survives restarts.
+ */
+ static bool preferSystem();
+
+ /**
+ * @brief Records the user's scope preference.
+ */
+ static void setPreferSystem(bool prefer);
+
+ /**
+ * @brief Whether the current process can write to the system-scope settings
+ * directory. False on Linux without root, often false on Windows without admin.
+ */
+ static bool isSystemWritable();
+
+ /**
+ * @brief Apply a scope switch: copy settings to the destination scope,
+ * persist the preference, prompt the user to restart.
+ *
+ * @return false if the switch was refused (e.g., system scope not writable
+ * on this user). Caller should revert any UI state on false.
+ */
+ static bool switchTo(QDialog *parent, bool toSystem);
+};
+
+} // namespace synergy::gui
diff --git a/extra/src/lib/synergy/gui/TestSettings.cpp b/extra/src/lib/synergy/gui/TestSettings.cpp
new file mode 100644
index 000000000..813e2b450
--- /dev/null
+++ b/extra/src/lib/synergy/gui/TestSettings.cpp
@@ -0,0 +1,119 @@
+/*
+ * Synergy -- mouse and keyboard sharing utility
+ * Copyright (C) 2026 Synergy App Ltd
+ *
+ * This package is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU General Public License
+ * found in the file LICENSE that should have accompanied this file.
+ *
+ * This package is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+#include "TestSettings.h"
+
+#include "common/Constants.h"
+#include "common/Settings.h"
+
+#include
+#include
+#include
+
+namespace synergy::gui {
+
+namespace {
+
+QString envOrFile(const char *envName, const QString &fileValue, bool fileEnabled)
+{
+ const auto env = qEnvironmentVariable(envName);
+ if (!env.isEmpty()) {
+ return env;
+ }
+ return fileEnabled ? fileValue : QString();
+}
+
+} // namespace
+
+TestSettings &TestSettings::instance()
+{
+ static TestSettings inst;
+ return inst;
+}
+
+TestSettings::TestSettings()
+ : m_fileName(QStringLiteral("%1/%2.test.conf").arg(Settings::UserDir, kAppName))
+{
+ load();
+}
+
+void TestSettings::load()
+{
+ m_enabled = false;
+ m_licensing = false;
+ m_serialKey.clear();
+ m_apiUrlActivate.clear();
+ m_apiUrlCheck.clear();
+ m_startTimeEpochSecs = 0;
+ m_verbose = false;
+ m_skipRemoteCheck = false;
+ m_allowExpiredLicenses = false;
+
+ if (!QFile::exists(m_fileName)) {
+ qDebug().noquote() << "test settings file not found, test mode off:" << m_fileName;
+ return;
+ }
+
+ QSettings ini(m_fileName, QSettings::IniFormat);
+ m_enabled = ini.value(QStringLiteral("test/enabled"), false).toBool();
+ if (!m_enabled) {
+ qDebug().noquote() << "test settings file present but test/enabled=false:" << m_fileName;
+ return;
+ }
+ m_licensing = ini.value(QStringLiteral("test/licensing"), false).toBool();
+ m_serialKey = ini.value(QStringLiteral("test/serialKey")).toString();
+ m_apiUrlActivate = ini.value(QStringLiteral("test/apiUrlActivate")).toString();
+ m_apiUrlCheck = ini.value(QStringLiteral("test/apiUrlCheck")).toString();
+ m_startTimeEpochSecs = ini.value(QStringLiteral("test/startTime"), 0).toLongLong();
+
+ m_verbose = ini.value(QStringLiteral("features/verbose"), false).toBool();
+ m_skipRemoteCheck = ini.value(QStringLiteral("features/skipRemoteCheck"), false).toBool();
+ m_allowExpiredLicenses = ini.value(QStringLiteral("features/allowExpiredLicenses"), false).toBool();
+
+ qInfo().noquote() << "test mode enabled, loaded:" << m_fileName;
+}
+
+void TestSettings::reload()
+{
+ load();
+}
+
+QString TestSettings::serialKey() const
+{
+ return envOrFile("SYNERGY_TEST_SERIAL_KEY", m_serialKey, m_enabled);
+}
+
+QString TestSettings::apiUrlActivate() const
+{
+ return envOrFile("SYNERGY_TEST_API_URL_ACTIVATE", m_apiUrlActivate, m_enabled);
+}
+
+QString TestSettings::apiUrlCheck() const
+{
+ return envOrFile("SYNERGY_TEST_API_URL_CHECK", m_apiUrlCheck, m_enabled);
+}
+
+qint64 TestSettings::startTimeEpochSecs() const
+{
+ const auto env = qEnvironmentVariable("SYNERGY_TEST_START_TIME");
+ if (!env.isEmpty()) {
+ return env.toLongLong();
+ }
+ return m_enabled ? m_startTimeEpochSecs : 0;
+}
+
+} // namespace synergy::gui
diff --git a/extra/src/lib/synergy/gui/TestSettings.h b/extra/src/lib/synergy/gui/TestSettings.h
new file mode 100644
index 000000000..7d85745e6
--- /dev/null
+++ b/extra/src/lib/synergy/gui/TestSettings.h
@@ -0,0 +1,101 @@
+/*
+ * Synergy -- mouse and keyboard sharing utility
+ * Copyright (C) 2026 Synergy App Ltd
+ *
+ * This package is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU General Public License
+ * found in the file LICENSE that should have accompanied this file.
+ *
+ * This package is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+#pragma once
+
+#include
+
+namespace synergy::gui {
+
+// QA/test overrides for Synergy. Lives at `${UserDir}/${kAppName}.test.conf`,
+// sibling to the main Synergy.conf settings file. Sourcing values from a file
+// (rather than env vars) sidesteps the pain of getting env vars into GUI
+// launches on macOS / Windows / VS Code F5.
+//
+// All accessors fall back to the corresponding SYNERGY_TEST_* env var when
+// the env var is set, so existing CI/automation flows are unchanged.
+class TestSettings
+{
+public:
+ static TestSettings &instance();
+
+ // True when the test config file exists and `[test]/enabled=true`. The
+ // value is cached at construction; call reload() to re-read.
+ bool isEnabled() const
+ {
+ return m_enabled;
+ }
+
+ // True when test mode is on AND `[test]/licensing=true`. Drives the
+ // license activation flow when set; off by default so test mode can be
+ // on (Test menu, URL overrides, etc.) without forcing license prompts.
+ bool isLicensingEnabled() const
+ {
+ return m_enabled && m_licensing;
+ }
+
+ // Test overrides. Each accessor returns the env var when set, otherwise
+ // the file value when isEnabled(), otherwise empty / 0.
+ QString serialKey() const;
+ QString apiUrlActivate() const;
+ QString apiUrlCheck() const;
+ qint64 startTimeEpochSecs() const;
+
+ // Feature toggles read from the [features] section. File-only, no env
+ // var fallback for these (no precedent).
+ bool verbose() const
+ {
+ return m_verbose;
+ }
+ bool skipRemoteCheck() const
+ {
+ return m_skipRemoteCheck;
+ }
+ bool allowExpiredLicenses() const
+ {
+ return m_allowExpiredLicenses;
+ }
+
+ // Path to the test config file (whether or not it exists).
+ QString fileName() const
+ {
+ return m_fileName;
+ }
+
+ // Re-read the file from disk (e.g., from a Test menu "Reload" action).
+ void reload();
+
+private:
+ TestSettings();
+ TestSettings(const TestSettings &) = delete;
+ TestSettings &operator=(const TestSettings &) = delete;
+
+ void load();
+
+ QString m_fileName;
+ bool m_enabled = false;
+ bool m_licensing = false;
+ QString m_serialKey;
+ QString m_apiUrlActivate;
+ QString m_apiUrlCheck;
+ qint64 m_startTimeEpochSecs = 0;
+ bool m_verbose = false;
+ bool m_skipRemoteCheck = false;
+ bool m_allowExpiredLicenses = false;
+};
+
+} // namespace synergy::gui
diff --git a/extra/src/lib/synergy/gui/constants.h b/extra/src/lib/synergy/gui/constants.h
new file mode 100644
index 000000000..8f6f7109c
--- /dev/null
+++ b/extra/src/lib/synergy/gui/constants.h
@@ -0,0 +1,42 @@
+/*
+ * synergy -- mouse and keyboard sharing utility
+ * Copyright (C) 2024 Synergy App Ltd
+ *
+ * This package is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU General Public License
+ * found in the file LICENSE that should have accompanied this file.
+ *
+ * This package is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+#pragma once
+
+#include
+
+#include
+
+namespace synergy::gui {
+
+const auto kUrlApi = "https://symless.com/synergy/api";
+const auto kUrlWebsite = QStringLiteral("https://synergyapp.io");
+const auto kUrlSourceQuery = "source=gui";
+
+const auto kLinkBuy = R"(Buy now)";
+const auto kLinkRenew = R"(Renew now)";
+const auto kLinkDownload = R"(Download now)";
+
+const auto kUrlPersonalUpgrade = QString("%1/purchase/upgrade?%2").arg(kUrlWebsite, kUrlSourceQuery);
+const auto kUrlContact = QString("%1/contact?%2").arg(kUrlWebsite, kUrlSourceQuery);
+
+const auto kUrlApiLicenseActivate = QString("%1/product/activate").arg(kUrlApi);
+const auto kUrlApiLicenseCheck = QString("%1/product/check").arg(kUrlApi);
+
+constexpr auto kLicenseGracePeriod = std::chrono::days{14};
+
+} // namespace synergy::gui
diff --git a/extra/src/lib/synergy/gui/dev_mode.h b/extra/src/lib/synergy/gui/dev_mode.h
new file mode 100644
index 000000000..9b5953299
--- /dev/null
+++ b/extra/src/lib/synergy/gui/dev_mode.h
@@ -0,0 +1,37 @@
+/*
+ * Synergy -- mouse and keyboard sharing utility
+ * Copyright (C) 2026 Synergy App Ltd
+ *
+ * This package is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU General Public License
+ * found in the file LICENSE that should have accompanied this file.
+ *
+ * This package is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+#pragma once
+
+#include "common/VersionInfo.h"
+#include "synergy/build_config.h"
+
+#include
+#include
+
+namespace synergy::gui {
+
+inline QString titleWithDevSuffix(const QString &productName)
+{
+ if constexpr (synergy::kIsDevBuild) {
+ return QStringLiteral("%1 - Developer build - v%2").arg(productName, kDisplayVersion);
+ } else {
+ return productName;
+ }
+}
+
+} // namespace synergy::gui
diff --git a/extra/src/lib/synergy/gui/dialogs/UpgradeDialog.cpp b/extra/src/lib/synergy/gui/dialogs/UpgradeDialog.cpp
new file mode 100644
index 000000000..dad7fbd14
--- /dev/null
+++ b/extra/src/lib/synergy/gui/dialogs/UpgradeDialog.cpp
@@ -0,0 +1,47 @@
+/*
+ * Deskflow -- mouse and keyboard sharing utility
+ * Copyright (C) 2022 Synergy App Ltd
+ *
+ * This package is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU General Public License
+ * found in the file LICENSE that should have accompanied this file.
+ *
+ * This package is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+#include "UpgradeDialog.h"
+
+#include "synergy/gui/constants.h"
+
+#include
+#include
+
+UpgradeDialog::UpgradeDialog(QWidget *parent) : QMessageBox(parent)
+{
+ m_cancel = addButton("Cancel", QMessageBox::RejectRole);
+ m_upgrade = addButton("Upgrade", QMessageBox::AcceptRole);
+}
+
+void UpgradeDialog::showDialog(const QString &title, const QString &body, const QString &link)
+{
+ setWindowTitle(title);
+ setText(body);
+ exec();
+
+ if (clickedButton() == m_upgrade) {
+ const auto url = QUrl(link);
+ if (QDesktopServices::openUrl(url)) {
+ qDebug("opened url: %s", qUtf8Printable(url.toString()));
+ } else {
+ qCritical("failed to open url: %s", qUtf8Printable(url.toString()));
+ }
+ } else {
+ qDebug("upgrade was declined");
+ }
+}
diff --git a/extra/src/lib/synergy/gui/dialogs/UpgradeDialog.h b/extra/src/lib/synergy/gui/dialogs/UpgradeDialog.h
new file mode 100644
index 000000000..77d6ba7e6
--- /dev/null
+++ b/extra/src/lib/synergy/gui/dialogs/UpgradeDialog.h
@@ -0,0 +1,32 @@
+/*
+ * Deskflow -- mouse and keyboard sharing utility
+ * Copyright (C) 2022 Synergy App Ltd
+ *
+ * This package is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU General Public License
+ * found in the file LICENSE that should have accompanied this file.
+ *
+ * This package is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+#pragma once
+
+#include
+#include
+
+class UpgradeDialog : public QMessageBox
+{
+public:
+ explicit UpgradeDialog(QWidget *parent = nullptr);
+ void showDialog(const QString &title, const QString &body, const QString &link);
+
+private:
+ QPushButton *m_upgrade = nullptr;
+ QPushButton *m_cancel = nullptr;
+};
diff --git a/extra/src/lib/synergy/gui/license/LicenseApiClient.cpp b/extra/src/lib/synergy/gui/license/LicenseApiClient.cpp
new file mode 100644
index 000000000..2f723846a
--- /dev/null
+++ b/extra/src/lib/synergy/gui/license/LicenseApiClient.cpp
@@ -0,0 +1,185 @@
+/*
+ * synergy -- mouse and keyboard sharing utility
+ * Copyright (C) 2022-2026 Synergy Ltd.
+ *
+ * This package is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU General Public License
+ * found in the file LICENSE that should have accompanied this file.
+ *
+ * This package is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+#include "LicenseApiClient.h"
+
+#include "synergy/gui/TestSettings.h"
+#include "synergy/gui/constants.h"
+
+#include
+#include
+#include
+#include
+#include
+
+namespace synergy::gui::license {
+
+QString activateUrl()
+{
+ const auto testUrl = TestSettings::instance().apiUrlActivate();
+ return testUrl.isEmpty() ? kUrlApiLicenseActivate : testUrl;
+}
+
+QString checkUrl()
+{
+ const auto testUrl = TestSettings::instance().apiUrlCheck();
+ return testUrl.isEmpty() ? kUrlApiLicenseCheck : testUrl;
+}
+
+LicenseApiClient::LicenseApiClient()
+{
+ connect(&m_manager, &QNetworkAccessManager::finished, this, &LicenseApiClient::handleResponse);
+}
+
+void LicenseApiClient::activate(Data data)
+{
+ post(RequestKind::kActivate, QUrl(activateUrl()), data);
+}
+
+void LicenseApiClient::check(Data data)
+{
+ post(RequestKind::kCheck, QUrl(checkUrl()), data);
+}
+
+void LicenseApiClient::post(RequestKind kind, const QUrl &url, const Data &data)
+{
+ m_isBusy = true;
+ m_pendingKind = kind;
+
+ qDebug().noquote() << "license api request:" << url.toString();
+
+ auto request = QNetworkRequest(url);
+ request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json");
+
+ m_manager.post(request, getRequestData(data));
+}
+
+void LicenseApiClient::handleResponse(QNetworkReply *reply)
+{
+ m_isBusy = false;
+ const auto kind = m_pendingKind;
+
+ const auto emitFailed = [this, kind](const QString &message) {
+ if (kind == RequestKind::kActivate) {
+ Q_EMIT activationFailed(message);
+ } else {
+ Q_EMIT checkFailed(message);
+ }
+ };
+
+ const auto emitSucceeded = [this, kind] {
+ if (kind == RequestKind::kActivate) {
+ Q_EMIT activationSucceeded();
+ } else {
+ Q_EMIT checkSucceeded();
+ }
+ };
+
+ if (!reply) {
+ qWarning("no license api reply");
+ emitFailed("License request failed, empty network reply.");
+ return;
+ }
+
+ const auto response = reply->readAll();
+
+ if (reply->error() != QNetworkReply::NoError) {
+ const auto kLimit = 200;
+ const auto responseSliced = response.length() > kLimit ? response.sliced(0, kLimit) + "..." : response;
+ qWarning().noquote() << "license api error:" << reply->error() << reply->errorString() << responseSliced;
+ emitFailed("License request failed, there was a network error.");
+ reply->deleteLater();
+ return;
+ }
+
+ qDebug().noquote() << "license api response:" << response;
+ const auto jsonDoc = QJsonDocument::fromJson(response);
+ if (response.isNull()) {
+ qWarning("empty license api response");
+ emitFailed("License request failed, the server sent an empty response.");
+ reply->deleteLater();
+ return;
+ }
+
+ const auto json = jsonDoc.object();
+ if (json["status"].toString() != "success") {
+ const auto status = json["status"].toString();
+ const auto message = json["message"].toString();
+
+ if (!status.isEmpty()) {
+ qWarning().noquote() << "license api status:" << status;
+ } else {
+ qWarning("license api status was empty");
+ }
+
+ if (!message.isEmpty()) {
+ qWarning().noquote() << "license api message:" << message;
+ } else {
+ qWarning("license api message was empty");
+ }
+
+ if (status == "disabled") {
+ Q_EMIT licenseDisabled(message.isEmpty() ? QStringLiteral("License has been disabled.") : message);
+ } else if (!message.isEmpty()) {
+ emitFailed(message);
+ } else {
+ emitFailed("License request failed, unknown error.");
+ }
+
+ reply->deleteLater();
+ return;
+ }
+
+ qInfo().noquote() << "license api request successful";
+ emitSucceeded();
+ reply->deleteLater();
+}
+
+QByteArray LicenseApiClient::getRequestData(const Data &data) const
+{
+ if (data.machineSignature.isEmpty()) {
+ qFatal("cannot create license request, no machine id");
+ }
+
+ if (data.hostnameSignature.isEmpty()) {
+ qFatal("cannot create license request, no hostname");
+ }
+
+ if (data.serialKey.isEmpty()) {
+ qFatal("cannot create license request, no serial key");
+ }
+
+ if (data.appVersion.isEmpty()) {
+ qFatal("cannot create license request, no app version");
+ }
+
+ if (data.osName.isEmpty()) {
+ qFatal("cannot create license request, no os name");
+ }
+
+ QJsonObject requestData;
+ requestData["machineSignature"] = data.machineSignature;
+ requestData["hostnameSignature"] = data.hostnameSignature;
+ requestData["serialKey"] = data.serialKey;
+ requestData["appVersion"] = data.appVersion;
+ requestData["osName"] = data.osName;
+ requestData["isServer"] = data.isServer;
+
+ return QJsonDocument(requestData).toJson();
+}
+
+}; // namespace synergy::gui::license
diff --git a/extra/src/lib/synergy/gui/license/LicenseApiClient.h b/extra/src/lib/synergy/gui/license/LicenseApiClient.h
new file mode 100644
index 000000000..126e63514
--- /dev/null
+++ b/extra/src/lib/synergy/gui/license/LicenseApiClient.h
@@ -0,0 +1,78 @@
+/*
+ * synergy -- mouse and keyboard sharing utility
+ * Copyright (C) 2022-2026 Synergy Ltd.
+ *
+ * This package is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU General Public License
+ * found in the file LICENSE that should have accompanied this file.
+ *
+ * This package is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+#pragma once
+
+#include
+#include
+#include
+
+class QNetworkReply;
+
+namespace synergy::gui::license {
+
+class LicenseApiClient : public QObject
+{
+ Q_OBJECT
+
+public:
+ struct Data
+ {
+ QString machineSignature;
+ QString hostnameSignature;
+ QString serialKey;
+ QString appVersion;
+ QString osName;
+ bool isServer;
+ };
+
+ explicit LicenseApiClient();
+
+ void activate(Data data);
+ void check(Data data);
+
+ bool isBusy()
+ {
+ return m_isBusy;
+ }
+
+Q_SIGNALS:
+ void activationFailed(const QString &message);
+ void activationSucceeded();
+ void checkFailed(const QString &message);
+ void checkSucceeded();
+ void licenseDisabled(const QString &message);
+
+private Q_SLOTS:
+ void handleResponse(QNetworkReply *reply);
+
+private:
+ enum class RequestKind
+ {
+ kActivate,
+ kCheck
+ };
+
+ void post(RequestKind kind, const QUrl &url, const Data &data);
+ QByteArray getRequestData(const Data &data) const;
+
+ QNetworkAccessManager m_manager;
+ bool m_isBusy = false;
+ RequestKind m_pendingKind = RequestKind::kActivate;
+};
+
+} // namespace synergy::gui::license
diff --git a/extra/src/lib/synergy/gui/license/LicenseHandler.cpp b/extra/src/lib/synergy/gui/license/LicenseHandler.cpp
new file mode 100644
index 000000000..4023e1216
--- /dev/null
+++ b/extra/src/lib/synergy/gui/license/LicenseHandler.cpp
@@ -0,0 +1,534 @@
+/*
+ * Synergy -- mouse and keyboard sharing utility
+ * Copyright (C) 2015 Synergy App Ltd
+ *
+ * This package is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU General Public License
+ * found in the file LICENSE that should have accompanied this file.
+ *
+ * This package is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+#include "LicenseHandler.h"
+
+#include "ActivationDialog.h"
+#include "common/Settings.h"
+#include "common/VersionInfo.h"
+#include "dialogs/UpgradeDialog.h"
+#include "gui/core/CoreProcess.h"
+#include "synergy/gui/constants.h"
+#include "synergy/gui/dev_mode.h"
+#include "synergy/gui/license/license_utils.h"
+#include "synergy/gui/styles.h"
+#include "synergy/license/Product.h"
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+using namespace std::chrono;
+using namespace synergy::gui::license;
+using namespace synergy::gui;
+using namespace deskflow::gui;
+using License = synergy::license::License;
+
+LicenseHandler::LicenseHandler()
+{
+ m_enabled = synergy::gui::license::isActivationEnabled();
+
+ connect(&m_apiClient, &LicenseApiClient::activationFailed, this, [this](const QString &message) {
+ QString fullMessage = QString(
+ "
There was a problem activating your license.
"
+ "%3"
+ R"(
Please contact us )"
+ "if there is anything we can do to help.
"
+ )
+ .arg(kUrlContact)
+ .arg(kColorSecondary)
+ .arg(message);
+ QMessageBox::warning(m_pMainWindow, "Activation failed", fullMessage);
+
+ qWarning("activation failed, showing serial key dialog");
+ return showSerialKeyDialog();
+ });
+
+ connect(&m_apiClient, &LicenseApiClient::activationSucceeded, this, [this] {
+ qDebug("license activation succeeded, saving settings");
+ m_settings.setActivated(true);
+ m_settings.setGraceStartEpochSecs(0);
+ m_settings.sync();
+ m_warnedAboutGrace = false;
+
+ if (m_pCoreProcess == nullptr) {
+ qFatal("core process not set");
+ }
+
+ qDebug("resuming core process after activation");
+ m_pCoreProcess->start();
+ });
+
+ connect(&m_apiClient, &LicenseApiClient::checkSucceeded, this, &LicenseHandler::handleRemoteCheckSucceeded);
+ connect(&m_apiClient, &LicenseApiClient::checkFailed, this, &LicenseHandler::handleRemoteCheckFailed);
+ connect(&m_apiClient, &LicenseApiClient::licenseDisabled, this, &LicenseHandler::disableLicenseRemotely);
+}
+
+void LicenseHandler::handleMainWindow(QMainWindow *mainWindow, deskflow::gui::CoreProcess *coreProcess)
+{
+ // Must still be set as these are used when not enabled.
+ m_pMainWindow = mainWindow;
+ m_pCoreProcess = coreProcess;
+
+ if (!m_enabled) {
+ qDebug("license handler disabled, skipping main window handler");
+ return;
+ }
+
+ qDebug("main window create handled");
+
+ if (!loadSettings()) {
+ qFatal("failed to load license settings");
+ }
+}
+
+bool LicenseHandler::handleAppStart()
+{
+ if (m_pMainWindow == nullptr) {
+ qFatal("main window not set");
+ }
+
+ if (!m_enabled) {
+ qDebug("license handler disabled, skipping start handler");
+ return true;
+ }
+
+ updateWindowTitle();
+
+ const auto serialKeyAction = new QAction("Change serial key", m_pMainWindow);
+ QObject::connect(serialKeyAction, &QAction::triggered, [this] { showSerialKeyDialog(); });
+
+ const auto licenseMenu = new QMenu("License");
+ licenseMenu->addAction(serialKeyAction);
+ m_pMainWindow->menuBar()->addAction(licenseMenu->menuAction());
+
+ const auto checkResult = check();
+ if (!checkResult) {
+ return false;
+ }
+
+ qDebug("license is valid, continuing with start");
+ updateWindowTitle();
+ clampFeatures();
+ runRemoteCheck();
+ return true;
+}
+
+void LicenseHandler::handleSettings(QDialog *parent) const
+{
+ Q_UNUSED(parent);
+
+ // Settings UI injection (TLS toggle, scope radios, etc.) lives in extra/.
+ // To be wired when the synergy widget-injection layer lands; until then,
+ // license-tier clamping happens in clampFeatures() at app start and on
+ // license change.
+}
+
+void LicenseHandler::handleVersionCheck(QString &versionUrl)
+{
+ if (!m_enabled) {
+ qDebug("license handler disabled, skipping version check handler");
+ return;
+ }
+
+ const auto edition = license().productEdition();
+ if (edition == Product::Edition::kBusiness) {
+ versionUrl.append("/business");
+ } else {
+ versionUrl.append("/personal");
+ }
+}
+
+bool LicenseHandler::handleCoreStart()
+{
+ // HACK: For some reason, the core start trigger gets called twice when clicking the 'start' button.
+ // If the activator is called twice in quick succession, the core is started twice.
+ if (m_apiClient.isBusy()) {
+ qDebug("activator is busy, skipping core start handler");
+ return false;
+ }
+
+ if (!m_enabled) {
+ qDebug("license handler disabled, skipping core start handler");
+ return true;
+ }
+
+ if (m_pMainWindow == nullptr) {
+ qFatal("main window not set");
+ }
+
+ if (m_pCoreProcess == nullptr) {
+ qFatal("core process not set");
+ }
+
+ if (m_settings.activated()) {
+ qDebug("license is activated, starting core");
+ return true;
+ }
+
+ if (m_license.serialKey().isOffline) {
+ qDebug("offline serial key, starting core");
+ return true;
+ }
+
+ if (!m_license.isValid()) {
+ qWarning("no valid license, skipping core start");
+ return false;
+ }
+
+ qInfo("activating license");
+ m_apiClient.activate(buildApiData());
+
+ return false;
+}
+
+bool LicenseHandler::loadSettings()
+{
+ using enum SetSerialKeyResult;
+
+ m_settings.load();
+
+ const auto serialKey = m_settings.serialKey();
+ if (!serialKey.isEmpty()) {
+ const auto result = setLicense(m_settings.serialKey(), true);
+ if (result != kSuccess && result != kUnchanged) {
+ qWarning("set serial key failed, showing activation dialog");
+ return showSerialKeyDialog();
+ }
+ }
+
+ return true;
+}
+
+void LicenseHandler::saveSettings()
+{
+ const auto hexString = m_license.serialKey().hexString;
+ m_settings.setSerialKey(QString::fromStdString(hexString));
+ m_settings.sync();
+}
+
+bool LicenseHandler::showSerialKeyDialog()
+{
+ if (!m_settings.isWritable()) {
+ QMessageBox::warning(
+ m_pMainWindow, "Write access required",
+ tr("
The settings file is not writable:
"
+ "
%1
"
+ "
Please check the file permissions and try again.
")
+ .arg(m_settings.fileName())
+ );
+ return false;
+ }
+
+ ActivationDialog dialog(m_pMainWindow, *this);
+ const auto result = dialog.exec();
+ if (result != QDialog::Accepted) {
+ qWarning("license serial key dialog declined");
+ return false;
+ }
+
+ if (dialog.serialKeyChanged()) {
+ // Reset activation so new serial key can be activated.
+ qDebug("serial key changed, updating settings");
+ m_settings.setActivated(false);
+ m_settings.setGraceStartEpochSecs(0);
+ m_warnedAboutGrace = false;
+ m_settings.sync();
+ }
+
+ saveSettings();
+ updateWindowTitle();
+ clampFeatures();
+
+ if (dialog.serialKeyChanged() && m_pCoreProcess->isStarted()) {
+ qDebug("restarting core on serial key change");
+ m_pCoreProcess->restart();
+ }
+
+ // If the user accepted the dialog while not activated (e.g. recovering from a
+ // remote disable), retry activation so something visible happens regardless of
+ // whether the serial key changed.
+ if (!m_settings.activated() && m_license.isValid() && !m_license.serialKey().isOffline && !m_apiClient.isBusy()) {
+ qInfo("retrying activation after dialog accept");
+ m_apiClient.activate(buildApiData());
+ }
+
+ qDebug("license serial key dialog accepted");
+ return true;
+}
+
+void LicenseHandler::updateWindowTitle() const
+{
+ const auto productName = QString::fromStdString(m_license.productName());
+ qDebug("updating main window title: %s", qPrintable(productName));
+ m_pMainWindow->setWindowTitle(synergy::gui::titleWithDevSuffix(productName));
+}
+
+const synergy::license::License &LicenseHandler::license() const
+{
+ return m_license;
+}
+
+Product::Edition LicenseHandler::productEdition() const
+{
+ return m_license.productEdition();
+}
+
+QString LicenseHandler::productName() const
+{
+ return QString::fromStdString(m_license.productName());
+}
+
+/// @param allowExpired If true, allow expired licenses to be set.
+/// Useful for passing an expired license to the activation dialog.
+LicenseHandler::SetSerialKeyResult LicenseHandler::setLicense(const QString &hexString, bool allowExpired)
+{
+ using enum LicenseHandler::SetSerialKeyResult;
+
+ if (hexString.isEmpty()) {
+ qFatal("serial key is empty");
+ }
+
+ qDebug() << "changing serial key to:" << hexString;
+ auto serialKey = parseSerialKey(hexString);
+
+ if (!serialKey.isValid) {
+ qWarning() << "invalid serial key, ignoring:" << hexString;
+ return kInvalid;
+ }
+
+ auto license = License(serialKey);
+ if (m_time.hasTestTime()) {
+ license.setNowFunc([this]() { return m_time.now(); });
+ }
+
+ if (!allowExpired && license.isExpired()) {
+ qDebug("license is expired, ignoring");
+ return kExpired;
+ }
+
+ const auto oldSerialKey = m_license.serialKey();
+ m_license = license;
+
+ // This delayed check logic seems really complex. Is it really worth the maintenance and testing cost?
+ // Condition must run *after* the license member is set, since it's async callback uses this member.
+ if (!m_license.isExpired() && m_license.isTimeLimited()) {
+ auto secondsLeft = m_license.secondsLeft();
+ if (secondsLeft.count() < INT_MAX) {
+ const auto validateAt = secondsLeft + seconds{1};
+ const auto interval = duration_cast(validateAt);
+ QTimer::singleShot(interval, this, &LicenseHandler::check);
+ } else {
+ qDebug("license expiry too distant to schedule timer");
+ }
+ }
+
+ if (serialKey == oldSerialKey) {
+ qDebug("serial key did not change, ignoring");
+ return kUnchanged;
+ }
+
+ return kSuccess;
+}
+
+bool LicenseHandler::check()
+{
+ if (!m_license.isValid()) {
+ qDebug("license validation failed, license invalid");
+ return showSerialKeyDialog();
+ } else if (m_license.isExpired()) {
+ qDebug("license validation failed, license expired");
+ return showSerialKeyDialog();
+ } else if (m_license.isExpiringSoon()) {
+ if (isInGracePeriod()) {
+ qDebug("license expiring soon but in remote grace period, suppressing renew nag");
+ return true;
+ }
+ qDebug("license is expiring soon, showing serial key dialog");
+ showSerialKeyDialog();
+ // Return true even if dialog cancelled, since expiring soon licenses are still valid.
+ return true;
+ } else {
+ qDebug("license validation succeeded");
+ return true;
+ }
+}
+
+void LicenseHandler::clampFeatures()
+{
+ if (Settings::value(Settings::Security::TlsEnabled).toBool() && !m_license.isTlsAvailable()) {
+ qWarning("tls not available, disabling tls");
+ Settings::setValue(Settings::Security::TlsEnabled, false);
+ }
+
+ const auto isSystemScope = (Settings::settingsFile() == Settings::SystemSettingFile);
+ if (isSystemScope && !m_license.isSettingsScopeAvailable()) {
+ qFatal("settings scope not available");
+ }
+
+ qDebug("committing default feature settings");
+ Settings::save();
+}
+
+void LicenseHandler::disable()
+{
+ qDebug("disabling license handler");
+ m_enabled = false;
+}
+
+bool LicenseHandler::isInGracePeriod() const
+{
+ return m_settings.graceStartEpochSecs() > 0;
+}
+
+bool LicenseHandler::isGracePeriodExpired() const
+{
+ if (!isInGracePeriod()) {
+ return false;
+ }
+ const auto now = QDateTime::currentSecsSinceEpoch();
+ const auto elapsed = seconds{now - m_settings.graceStartEpochSecs()};
+ return elapsed >= duration_cast(kLicenseGracePeriod);
+}
+
+LicenseApiClient::Data LicenseHandler::buildApiData() const
+{
+ const auto machineId = QSysInfo::machineUniqueId();
+ const auto hostname = QHostInfo::localHostName();
+
+ // Anonymise so a leak doesn't reveal which customer machine the data belongs to.
+ const auto machineSignature = QCryptographicHash::hash(machineId, QCryptographicHash::Sha256).toHex();
+ const auto hostnameSignature = QCryptographicHash::hash(hostname.toUtf8(), QCryptographicHash::Sha256).toHex();
+
+ const auto isServer = (Settings::value(Settings::Core::CoreMode).toInt() == Settings::Server);
+ return {
+ machineSignature,
+ hostnameSignature,
+ QString::fromStdString(m_license.serialKey().hexString),
+ kVersion,
+ QSysInfo::prettyProductName(),
+ isServer
+ };
+}
+
+void LicenseHandler::runRemoteCheck()
+{
+ if (!m_settings.activated() || !m_license.isValid() || m_license.serialKey().isOffline) {
+ qDebug("license not activated or offline, skipping remote check");
+ return;
+ }
+
+ if (m_apiClient.isBusy()) {
+ qDebug("license activator busy, skipping remote check");
+ return;
+ }
+
+ qInfo("running remote license check");
+ m_apiClient.check(buildApiData());
+}
+
+void LicenseHandler::handleRemoteCheckSucceeded()
+{
+ qInfo("remote license check succeeded");
+
+ const bool wasInGrace = isInGracePeriod();
+ m_settings.setGraceStartEpochSecs(0);
+ m_settings.sync();
+ m_warnedAboutGrace = false;
+
+ if (wasInGrace && m_pMainWindow != nullptr) {
+ QMessageBox::information(
+ m_pMainWindow, "License restored", tr("Your license is valid again. Thanks for your patience.")
+ );
+ }
+}
+
+void LicenseHandler::handleRemoteCheckFailed(const QString &message)
+{
+ qWarning().noquote() << "remote license check failed:" << message;
+
+ if (!isInGracePeriod()) {
+ m_settings.setGraceStartEpochSecs(QDateTime::currentSecsSinceEpoch());
+ m_settings.sync();
+ }
+
+ if (isGracePeriodExpired()) {
+ disableLicenseRemotely(message);
+ return;
+ }
+
+ if (!m_warnedAboutGrace && m_pMainWindow != nullptr) {
+ m_warnedAboutGrace = true;
+ const auto graceDays = static_cast(kLicenseGracePeriod.count());
+ QMessageBox::warning(
+ m_pMainWindow, "License check failed",
+ tr("
We could not verify your license:
"
+ "
%1
"
+ "
%2 will keep working for %3 days. "
+ R"(If the problem persists, please contact us.)"
+ "
")
+ .arg(message.toHtmlEscaped())
+ .arg(productName())
+ .arg(graceDays)
+ .arg(kUrlContact)
+ .arg(kColorSecondary)
+ );
+ }
+}
+
+void LicenseHandler::disableLicenseRemotely(const QString &reason)
+{
+ qWarning().noquote() << "license grace period expired, disabling:" << reason;
+
+ if (m_pCoreProcess != nullptr && m_pCoreProcess->isStarted()) {
+ qDebug("stopping core process due to disabled license");
+ m_pCoreProcess->stop();
+ }
+
+ // Keep the serial key + in-memory license so the next activation attempt can succeed
+ // automatically if the server re-enables the license (e.g. after the customer pays).
+ m_settings.setActivated(false);
+ m_settings.setGraceStartEpochSecs(0);
+ m_settings.sync();
+ m_warnedAboutGrace = false;
+
+ if (m_pMainWindow != nullptr) {
+ QMessageBox::warning(
+ m_pMainWindow, "License disabled",
+ tr("
Your license has been disabled and could not be verified within the grace period:
"
+ "
%1
"
+ R"(
Please contact us to restore access. )"
+ "Once your license is reinstated, the app will resume automatically.
")
+ .arg(reason.toHtmlEscaped())
+ .arg(kUrlContact)
+ .arg(kColorSecondary)
+ );
+ }
+}
diff --git a/extra/src/lib/synergy/gui/license/LicenseHandler.h b/extra/src/lib/synergy/gui/license/LicenseHandler.h
new file mode 100644
index 000000000..fc0a45f4b
--- /dev/null
+++ b/extra/src/lib/synergy/gui/license/LicenseHandler.h
@@ -0,0 +1,100 @@
+/*
+ * Synergy -- mouse and keyboard sharing utility
+ * Copyright (C) 2015 Synergy App Ltd
+ *
+ * This package is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU General Public License
+ * found in the file LICENSE that should have accompanied this file.
+ *
+ * This package is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+#pragma once
+
+#include "synergy/gui/AppTime.h"
+#include "synergy/gui/ExtraSettings.h"
+#include "synergy/gui/license/LicenseApiClient.h"
+#include "synergy/license/License.h"
+#include "synergy/license/Product.h"
+
+class QMainWindow;
+class QDialog;
+
+namespace deskflow::gui {
+class CoreProcess;
+}
+
+/**
+ * @brief A convenience wrapper for `License` that provides Qt signals, etc.
+ */
+class LicenseHandler : public QObject
+{
+ Q_OBJECT
+
+ using License = synergy::license::License;
+ using SerialKey = synergy::license::SerialKey;
+
+public:
+ enum class SetSerialKeyResult
+ {
+ kSuccess,
+ kFatal,
+ kUnchanged,
+ kInvalid,
+ kExpired
+ };
+
+ explicit LicenseHandler();
+
+ static LicenseHandler &instance()
+ {
+ static LicenseHandler instance;
+ return instance;
+ }
+
+ void handleMainWindow(QMainWindow *mainWindow, deskflow::gui::CoreProcess *coreProcess);
+ bool handleAppStart();
+ void handleSettings(QDialog *parent) const;
+ void handleVersionCheck(QString &versionUrl);
+ bool handleCoreStart();
+ bool loadSettings();
+ void saveSettings();
+ const License &license() const;
+ Product::Edition productEdition() const;
+ QString productName() const;
+ SetSerialKeyResult setLicense(const QString &hexString, bool allowExpired = false);
+ void clampFeatures();
+ void disable();
+
+ bool isEnabled() const
+ {
+ return m_enabled;
+ }
+
+private:
+ void updateWindowTitle() const;
+ bool showSerialKeyDialog();
+ bool check();
+ void runRemoteCheck();
+ void handleRemoteCheckSucceeded();
+ void handleRemoteCheckFailed(const QString &message);
+ bool isInGracePeriod() const;
+ bool isGracePeriodExpired() const;
+ void disableLicenseRemotely(const QString &reason);
+ synergy::gui::license::LicenseApiClient::Data buildApiData() const;
+
+ bool m_enabled = true;
+ synergy::gui::AppTime m_time;
+ License m_license = License::invalid();
+ synergy::gui::ExtraSettings m_settings;
+ synergy::gui::license::LicenseApiClient m_apiClient;
+ bool m_warnedAboutGrace = false;
+ QMainWindow *m_pMainWindow = nullptr;
+ deskflow::gui::CoreProcess *m_pCoreProcess = nullptr;
+};
diff --git a/extra/src/lib/synergy/gui/license/license_notices.cpp b/extra/src/lib/synergy/gui/license/license_notices.cpp
new file mode 100644
index 000000000..635ee8701
--- /dev/null
+++ b/extra/src/lib/synergy/gui/license/license_notices.cpp
@@ -0,0 +1,78 @@
+/*
+ * Synergy -- mouse and keyboard sharing utility
+ * Copyright (C) 2024 Synergy App Ltd
+ *
+ * This package is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU General Public License
+ * found in the file LICENSE that should have accompanied this file.
+ *
+ * This package is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+#include "license_notices.h"
+
+#include "constants.h"
+#include "synergy/license/License.h"
+
+using License = synergy::license::License;
+
+namespace synergy::gui {
+
+QString trialLicenseNotice(const License &license, const QString &linkColor);
+QString subscriptionLicenseNotice(const License &license, const QString &linkColor);
+
+QString licenseNotice(const License &license, const QString &linkColor)
+{
+ if (license.isTrial()) {
+ return trialLicenseNotice(license, linkColor);
+ } else if (license.isSubscription()) {
+ return subscriptionLicenseNotice(license, linkColor);
+ } else {
+ qFatal("license notice only for time limited licenses");
+ return ""; // Workaround for no return warning on Windows.
+ }
+}
+
+QString trialLicenseNotice(const License &license, const QString &linkColor)
+{
+ const QString buyLink = QString(kLinkBuy).arg(kUrlContact).arg(linkColor);
+ if (license.isExpired()) {
+ return QString("
Your trial has ended. %1
").arg(buyLink);
+ } else {
+ auto daysLeft = license.daysLeft().count();
+ if (daysLeft <= 0) {
+ return QString("
").arg(renewLink);
+ } else {
+ auto daysLeft = license.daysLeft().count();
+ if (daysLeft <= 0) {
+ return QString("
Your license expires today. %1
").arg(renewLink);
+ } else {
+ return QString("
Your license expires in %1 %2. %3
")
+ .arg(daysLeft)
+ .arg((daysLeft == 1) ? "day" : "days")
+ .arg(renewLink);
+ }
+ }
+}
+
+} // namespace synergy::gui
diff --git a/extra/src/lib/synergy/gui/license/license_notices.h b/extra/src/lib/synergy/gui/license/license_notices.h
new file mode 100644
index 000000000..72c0f98a7
--- /dev/null
+++ b/extra/src/lib/synergy/gui/license/license_notices.h
@@ -0,0 +1,28 @@
+/*
+ * Synergy -- mouse and keyboard sharing utility
+ * Copyright (C) 2024 Synergy App Ltd
+ *
+ * This package is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU General Public License
+ * found in the file LICENSE that should have accompanied this file.
+ *
+ * This package is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+#pragma once
+
+#include "synergy/license/License.h"
+
+#include
+
+namespace synergy::gui {
+
+QString licenseNotice(const synergy::license::License &license, const QString &linkColor);
+
+} // namespace synergy::gui
diff --git a/extra/src/lib/synergy/gui/license/license_utils.cpp b/extra/src/lib/synergy/gui/license/license_utils.cpp
new file mode 100644
index 000000000..e14767648
--- /dev/null
+++ b/extra/src/lib/synergy/gui/license/license_utils.cpp
@@ -0,0 +1,67 @@
+/*
+ * Synergy -- mouse and keyboard sharing utility
+ * Copyright (C) 2024 Synergy App Ltd
+ *
+ * This package is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU General Public License
+ * found in the file LICENSE that should have accompanied this file.
+ *
+ * This package is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+#include "license_utils.h"
+
+#include "synergy/gui/TestSettings.h"
+#include "synergy/license/parse_serial_key.h"
+
+#include
+#include
+
+namespace synergy::gui::license {
+
+#ifdef SYNERGY_ENABLE_ACTIVATION
+const bool kEnableActivation = true;
+#else
+const bool kEnableActivation = false;
+#endif // SYNERGY_ENABLE_ACTIVATION
+
+namespace {
+// Inlined from upstream's removed gui/string_utils.h. Trivial helper kept
+// local to avoid taking a new dependency just for this one call site.
+bool strToTrue(const QString &str)
+{
+ return str.toLower() == "true" || str == "1";
+}
+} // namespace
+
+bool isActivationEnabled()
+{
+ if (strToTrue(qEnvironmentVariable("SYNERGY_ENABLE_ACTIVATION"))) {
+ return true;
+ }
+ if (synergy::gui::TestSettings::instance().isLicensingEnabled()) {
+ return true;
+ }
+ return kEnableActivation;
+}
+
+synergy::license::SerialKey parseSerialKey(const QString &hexString)
+{
+ try {
+ return synergy::license::parseSerialKey(hexString.toStdString());
+ } catch (const std::exception &e) {
+ qWarning("failed to parse serial key: %s", e.what());
+ return synergy::license::SerialKey::invalid();
+ } catch (...) {
+ qWarning("failed to parse serial key, unknown error");
+ return synergy::license::SerialKey::invalid();
+ }
+}
+
+} // namespace synergy::gui::license
diff --git a/extra/src/lib/synergy/gui/license/license_utils.h b/extra/src/lib/synergy/gui/license/license_utils.h
new file mode 100644
index 000000000..448724a1c
--- /dev/null
+++ b/extra/src/lib/synergy/gui/license/license_utils.h
@@ -0,0 +1,29 @@
+/*
+ * Synergy -- mouse and keyboard sharing utility
+ * Copyright (C) 2024 Synergy App Ltd
+ *
+ * This package is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU General Public License
+ * found in the file LICENSE that should have accompanied this file.
+ *
+ * This package is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+#pragma once
+
+#include
+
+#include "synergy/license/SerialKey.h"
+
+namespace synergy::gui::license {
+
+bool isActivationEnabled();
+synergy::license::SerialKey parseSerialKey(const QString &hexString);
+
+} // namespace synergy::gui::license
diff --git a/extra/src/lib/synergy/gui/styles.h b/extra/src/lib/synergy/gui/styles.h
new file mode 100644
index 000000000..44963e5be
--- /dev/null
+++ b/extra/src/lib/synergy/gui/styles.h
@@ -0,0 +1,34 @@
+/*
+ * Synergy -- mouse and keyboard sharing utility
+ * Copyright (C) 2024 Synergy App Ltd
+ *
+ * This package is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU General Public License
+ * found in the file LICENSE that should have accompanied this file.
+ *
+ * This package is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+#pragma once
+
+#include
+
+// Style constants used by Synergy UI. Previously lived in upstream's
+// `gui/styles.h`; that header was removed during the deskflow gui refactor,
+// so we keep our own copy here in the overlay.
+
+const auto kColorWhite = "#ffffff";
+const auto kColorPrimary = "#ff7c00";
+const auto kColorSecondary = "#4285f4";
+const auto kColorNotice = "#3b67d3";
+
+const auto kStyleNoticeLabel = //
+ QString("padding: 3px 5px; border-radius: 3px;"
+ "background-color: %1; color: %2")
+ .arg(kColorNotice, kColorWhite);
diff --git a/extra/src/lib/synergy/hooks/gui_hook.h b/extra/src/lib/synergy/hooks/gui_hook.h
new file mode 100644
index 000000000..876f74b68
--- /dev/null
+++ b/extra/src/lib/synergy/hooks/gui_hook.h
@@ -0,0 +1,93 @@
+/*
+ * Synergy -- mouse and keyboard sharing utility
+ * Copyright (C) 2024 - 2025 Synergy App Ltd
+ *
+ * This package is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU General Public License
+ * found in the file LICENSE that should have accompanied this file.
+ *
+ * This package is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+#pragma once
+
+#include "common/Settings.h"
+#include "synergy/build_config.h"
+#include "synergy/gui/FeatureHandler.h"
+#include "synergy/gui/SettingsMigration.h"
+#include "synergy/gui/SettingsScope.h"
+#include "synergy/gui/dev_mode.h"
+#include "synergy/gui/license/LicenseHandler.h"
+
+#include
+#include
+
+namespace deskflow::gui {
+class CoreProcess;
+}
+
+namespace synergy::hooks {
+
+// Runs before any Settings::value() call, so that legacy-format keys can be
+// migrated to the new format before upstream's cleanSettings() wipes them.
+inline void onPreInit()
+{
+ synergy::gui::migration::migrateIfNeeded();
+
+ // setSettingsFile() instantiates Settings; that's expected here.
+ if (synergy::gui::SettingsScope::preferSystem()) {
+ if (synergy::gui::SettingsScope::isSystemWritable()) {
+ Settings::setSettingsFile(Settings::SystemSettingFile);
+ } else {
+ qWarning("scope: system-scope no longer writable, falling back to user scope");
+ synergy::gui::SettingsScope::setPreferSystem(false);
+ }
+ }
+}
+
+inline void onMainWindow(QMainWindow *mainWindow, deskflow::gui::CoreProcess *coreProcess)
+{
+ LicenseHandler::instance().handleMainWindow(mainWindow, coreProcess);
+ FeatureHandler::instance().handleMainWindow(mainWindow);
+ synergy::gui::migration::showNoticeIfPending(mainWindow);
+}
+
+inline void onTitleApplied(QMainWindow *mainWindow)
+{
+ mainWindow->setWindowTitle(synergy::gui::titleWithDevSuffix(synergy::kDisplayName));
+}
+
+inline bool onAppStart()
+{
+ FeatureHandler::instance().handleAppStart();
+ return LicenseHandler::instance().handleAppStart();
+}
+
+inline void onSettings(QDialog *parent)
+{
+ LicenseHandler::instance().handleSettings(parent);
+ FeatureHandler::instance().handleSettings(parent);
+}
+
+inline void onVersionCheck(QString &versionUrl)
+{
+ return LicenseHandler::instance().handleVersionCheck(versionUrl);
+}
+
+inline bool onCoreStart()
+{
+ return LicenseHandler::instance().handleCoreStart();
+}
+
+inline void onTestStart()
+{
+ LicenseHandler::instance().disable();
+}
+
+} // namespace synergy::hooks
diff --git a/extra/src/lib/synergy/license/CMakeLists.txt b/extra/src/lib/synergy/license/CMakeLists.txt
new file mode 100644
index 000000000..cc8f71243
--- /dev/null
+++ b/extra/src/lib/synergy/license/CMakeLists.txt
@@ -0,0 +1,25 @@
+# Synergy -- mouse and keyboard sharing utility
+# Copyright (C) 2024 Synergy App Ltd
+#
+# This package is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# found in the file LICENSE that should have accompanied this file.
+#
+# This package is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see .
+
+file(GLOB headers "*.h")
+file(GLOB sources "*.cpp")
+
+if(ADD_HEADERS_TO_SOURCES)
+ list(APPEND sources ${headers})
+endif()
+
+add_library(license STATIC ${sources})
+
+target_link_libraries(license arch base)
diff --git a/extra/src/lib/synergy/license/License.cpp b/extra/src/lib/synergy/license/License.cpp
new file mode 100644
index 000000000..31fdac70f
--- /dev/null
+++ b/extra/src/lib/synergy/license/License.cpp
@@ -0,0 +1,123 @@
+/*
+ * Synergy -- mouse and keyboard sharing utility
+ * Copyright (C) 2016 Synergy App Ltd
+ *
+ * This package is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU General Public License
+ * found in the file LICENSE that should have accompanied this file.
+ *
+ * This package is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+#include "License.h"
+
+#include "Product.h"
+#include "synergy/license/SerialKey.h"
+#include "synergy/license/parse_serial_key.h"
+
+#include
+
+using namespace std::chrono;
+
+namespace synergy::license {
+
+License::License(const std::string &hexString) : m_serialKey(parseSerialKey(hexString))
+{
+}
+
+License::License(const SerialKey &serialKey) : m_serialKey(serialKey)
+{
+ if (!m_serialKey.isValid) {
+ throw InvalidSerialKey();
+ }
+}
+
+bool License::isTrial() const
+{
+ return m_serialKey.type.isTrial();
+}
+
+bool License::isSubscription() const
+{
+ return m_serialKey.type.isSubscription();
+}
+
+bool License::isTimeLimited() const
+{
+ return m_serialKey.type.isSubscription() || m_serialKey.type.isTrial();
+}
+
+bool License::isTlsAvailable() const
+{
+ return m_serialKey.product.isFeatureAvailable(Product::Feature::kTls);
+}
+
+bool License::isSettingsScopeAvailable() const
+{
+ return m_serialKey.product.isFeatureAvailable(Product::Feature::kSettingsScope);
+}
+
+Product::Edition License::productEdition() const
+{
+ return m_serialKey.product.edition();
+}
+
+bool License::isExpiringSoon() const
+{
+ if (!isTimeLimited()) {
+ return false;
+ }
+
+ if (!m_serialKey.warnTime.has_value()) {
+ throw NoTimeLimitError();
+ }
+
+ return m_nowFunc() >= m_serialKey.warnTime.value();
+}
+
+bool License::isExpired() const
+{
+ if (!isTimeLimited()) {
+ return false;
+ }
+
+ if (!m_serialKey.expireTime.has_value()) {
+ throw NoTimeLimitError();
+ }
+
+ return m_nowFunc() >= m_serialKey.expireTime.value();
+}
+
+seconds License::secondsLeft() const
+{
+ if (!m_serialKey.expireTime.has_value()) {
+ throw NoTimeLimitError();
+ }
+
+ auto expireTime = m_serialKey.expireTime.value();
+
+ auto timeLeft = expireTime - m_nowFunc();
+ return duration_cast(timeLeft);
+}
+
+days License::daysLeft() const
+{
+ return duration_cast(secondsLeft());
+}
+
+std::string License::productName() const
+{
+ auto name = m_serialKey.product.name();
+ if (m_serialKey.type.isTrial()) {
+ name += " (Trial)";
+ }
+ return name;
+}
+
+} // namespace synergy::license
diff --git a/extra/src/lib/synergy/license/License.h b/extra/src/lib/synergy/license/License.h
new file mode 100644
index 000000000..e1a34352d
--- /dev/null
+++ b/extra/src/lib/synergy/license/License.h
@@ -0,0 +1,118 @@
+/*
+ * Synergy -- mouse and keyboard sharing utility
+ * Copyright (C) 2016 Synergy App Ltd
+ *
+ * This package is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU General Public License
+ * found in the file LICENSE that should have accompanied this file.
+ *
+ * This package is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+#pragma once
+
+#include "SerialKey.h"
+
+#include
+#include
+#include
+#include
+
+class Server;
+class LicenseHandler;
+class LicenseTests;
+
+namespace synergy::license {
+
+class License
+{
+ friend class ::Server;
+ friend class ::LicenseHandler;
+ friend class ::LicenseTests;
+
+ using days = std::chrono::days;
+ using system_clock = std::chrono::system_clock;
+ using time_point = system_clock::time_point;
+ using NowFunc = std::function;
+ using LicenseError = std::runtime_error;
+
+public:
+ explicit License(const SerialKey &serialKey);
+ explicit License(const std::string &hexString);
+ ~License() = default;
+
+ friend bool operator==(License const &lhs, License const &rhs)
+ {
+ return lhs.m_serialKey == rhs.m_serialKey;
+ }
+
+ bool isTlsAvailable() const;
+ bool isSettingsScopeAvailable() const;
+ bool isValid() const
+ {
+ return m_serialKey.isValid;
+ }
+ bool isExpiringSoon() const;
+ bool isExpired() const;
+ bool isTrial() const;
+ bool isSubscription() const;
+ bool isTimeLimited() const;
+ std::chrono::days daysLeft() const;
+ std::chrono::seconds secondsLeft() const;
+ Product::Edition productEdition() const;
+ std::string productName() const;
+ const SerialKey &serialKey() const
+ {
+ return m_serialKey;
+ }
+ void invalidate()
+ {
+ m_serialKey = SerialKey::invalid();
+ }
+
+ class InvalidSerialKey : public LicenseError
+ {
+ public:
+ explicit InvalidSerialKey() : LicenseError("invalid serial key")
+ {
+ }
+ };
+
+ class NoTimeLimitError : public LicenseError
+ {
+ public:
+ explicit NoTimeLimitError() : LicenseError("serial key has no time limit")
+ {
+ }
+ };
+
+protected:
+ void setNowFunc(const NowFunc &nowFunc)
+ {
+ m_nowFunc = nowFunc;
+ }
+
+private:
+ // for intentionality, force use of `invalid()` static function.
+ License() = default;
+
+ // prevent copy, so that changes can be reflected in one instance.
+ License(const License &) = default;
+ License &operator=(const License &) = default;
+
+ static License invalid()
+ {
+ return License();
+ }
+
+ SerialKey m_serialKey = SerialKey::invalid();
+ NowFunc m_nowFunc = []() { return system_clock::now(); };
+};
+
+} // namespace synergy::license
diff --git a/extra/src/lib/synergy/license/Product.cpp b/extra/src/lib/synergy/license/Product.cpp
new file mode 100644
index 000000000..efbb9b8f3
--- /dev/null
+++ b/extra/src/lib/synergy/license/Product.cpp
@@ -0,0 +1,170 @@
+/*
+ * Synergy -- mouse and keyboard sharing utility
+ * Copyright (C) 2016 Synergy App Ltd
+ *
+ * This package is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU General Public License
+ * found in the file LICENSE that should have accompanied this file.
+ *
+ * This package is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+#include