(kLicenseGracePeriod - elapsed).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(daysRemaining)
+ .arg(kUrlContact)
+ );
+ }
+}
+
+void LicenseHandler::handleActivationDeactivated(ActivationIntent intent, const QString &message)
+{
+ qWarning().noquote() << "server activation held by another computer:" << message;
+
+ m_settings.setHoldsServerActivation(false);
+ resetGracePeriod();
+
+ // The role is only reliably known at core start; an activation sent from anywhere else
+ // (e.g. serial key entry) must not ask or stop anything, the next core start will.
+ if (intent != ActivationIntent::kCoreStart) {
+ qInfo("not a core start activation, leaving the server question for the next core start");
+ return;
+ }
+
+ if (m_pCoreProcess != nullptr && m_pCoreProcess->isStarted()) {
+ qDebug("stopping core process while the server question is unanswered");
+ m_pCoreProcess->stop();
+ }
+
+ askServerQuestion();
+}
+
+void LicenseHandler::askServerQuestion()
+{
+ // The license allows one server per seat, but we never block the customer standing at this
+ // machine; they are almost always the rightful user (switching desks, replacing a machine).
+ // Asking first keeps use of one license fair and deliberate, and the other computer is
+ // asked the same question rather than cut off. Switching the old server to client mode
+ // releases the server slot, so the normal switching flow never sees this question.
+ QString question;
+ if (m_license.serialKey().seats > 1) {
+ question = tr("All of the server activations for your team's license are currently in use.
"
+ "If you need to add more seats to your team's license, please "
+ R"(contact us today.
)"
+ "Do you want to reassign a server activation to this computer?
"
+ "This will deactivate the other computer and interrupt an existing setup.
")
+ .arg(kUrlContact);
+ } else {
+ question = tr("Another computer is currently the server for your license.
"
+ "If you need more than one server running at the same time, please "
+ R"(contact us today.
)"
+ "Do you want to reassign the server activation to this computer?
"
+ "This will deactivate the other computer and interrupt an existing setup.
")
+ .arg(kUrlContact);
+ }
+ const auto reply = QMessageBox::question(m_pMainWindow, "License limit reached", question);
+ if (reply == QMessageBox::Yes) {
+ qInfo("server question accepted, reactivating");
+
+ // The customer just chose to be the server, so success may resume the stopped core.
+ m_apiClient.activate(buildApiData(), ActivationIntent::kCoreStart, true);
+ return;
+ }
+
+ // Decline changes nothing; writing the mode setting here would desync it from the main
+ // window's mode controls and the core process, which only the main window owns.
+ qInfo("server question declined, leaving core stopped");
+}
+
+void LicenseHandler::handleCheckDeactivated(CheckIntent intent, const QString &message)
+{
+ qWarning().noquote() << "server activation taken over by another computer:" << message;
+
+ m_settings.setHoldsServerActivation(false);
+ resetGracePeriod();
+
+ // The recurring poll only acts on a server that is actually running; a core start is becoming
+ // the server right now, so it asks regardless of how far the process has got.
+ const bool runningAsServer =
+ m_pCoreProcess != nullptr && m_pCoreProcess->isStarted() && liveCoreMode() == Settings::Server;
+ if (intent == CheckIntent::kPoll && !runningAsServer) {
+ qDebug("not running as server, ignoring deactivated check");
+ return;
+ }
+
+ if (m_pCoreProcess != nullptr && m_pCoreProcess->isStarted()) {
+ qInfo("stopping core, another computer took over as server");
+ m_pCoreProcess->stop();
+ }
+
+ askServerQuestion();
+}
+
+void LicenseHandler::disableLicenseAfterGrace(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).
+ // Keep the grace clock too, so a restart stays disabled instead of granting a fresh grace.
+ m_settings.setActivated(false);
+ m_settings.setHoldsServerActivation(false);
+ 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)
+ );
+ }
+}
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..9258901d4
--- /dev/null
+++ b/extra/src/lib/synergy/gui/license/LicenseHandler.h
@@ -0,0 +1,120 @@
+/*
+ * Synergy -- mouse and keyboard sharing utility
+ * Copyright (C) 2015 - 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/Settings.h"
+#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"
+
+#include
+
+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 handleAbout(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();
+
+ /// @brief Challenge code for offline activation, formatted for display.
+ QString offlineActivationChallenge() const;
+
+ /// @brief Verifies an offline activation response code and persists it if valid.
+ /// @return False if the response does not verify for this machine and serial key.
+ bool applyOfflineActivationResponse(const QString &responseCode);
+
+ bool isEnabled() const
+ {
+ return m_enabled;
+ }
+
+private:
+ void updateWindowTitle() const;
+ bool showSerialKeyDialog();
+ bool showOfflineActivationDialog();
+ bool isOfflineActivated() const;
+ bool check();
+ void runRemoteCheck();
+ void handleLicenseVerified();
+ void handleLicenseUnverified(const QString &message);
+ void handleActivationDeactivated(synergy::gui::license::LicenseApiClient::ActivationIntent intent, const QString &message);
+ void handleCheckDeactivated(synergy::gui::license::LicenseApiClient::CheckIntent intent, const QString &message);
+ void askServerQuestion();
+ bool isInGracePeriod() const;
+ bool isGracePeriodExpired() const;
+ void resetGracePeriod();
+ Settings::CoreMode liveCoreMode() const;
+ void disableLicenseAfterGrace(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;
+ qint64 m_lastCoreStartMs = 0;
+ QTimer m_remoteCheckTimer;
+ 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..1ec4a9d24
--- /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 - 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 "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 {
+ qCritical("license notice only for time limited licenses");
+ return "";
+ }
+}
+
+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("Your trial ends today. %1
").arg(buyLink);
+ } else {
+ return QString("Your trial ends in %1 %2. %3
")
+ .arg(daysLeft)
+ .arg((daysLeft == 1) ? "day" : "days")
+ .arg(buyLink);
+ }
+ }
+}
+
+QString subscriptionLicenseNotice(const License &license, const QString &linkColor)
+{
+ const QString renewLink = QString(kLinkRenew).arg(kUrlContact).arg(linkColor);
+ if (license.isExpired()) {
+ return QString("Your license has expired. %1
").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..a5a7e7423
--- /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 - 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 "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..41a01ce92
--- /dev/null
+++ b/extra/src/lib/synergy/gui/license/license_utils.cpp
@@ -0,0 +1,52 @@
+/*
+ * Synergy -- mouse and keyboard sharing utility
+ * Copyright (C) 2024 - 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 "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
+
+bool isActivationEnabled()
+{
+ return synergy::gui::TestSettings::instance().isLicensingEnabled() || 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..ac4e16d58
--- /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 - 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
+
+#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..00d4e9ade
--- /dev/null
+++ b/extra/src/lib/synergy/gui/styles.h
@@ -0,0 +1,34 @@
+/*
+ * Synergy -- mouse and keyboard sharing utility
+ * Copyright (C) 2024 - 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
+
+// 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..f7668df81
--- /dev/null
+++ b/extra/src/lib/synergy/hooks/gui_hook.h
@@ -0,0 +1,138 @@
+/*
+ * Synergy -- mouse and keyboard sharing utility
+ * Copyright (C) 2024 - 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/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/UpdateChannel.h"
+#include "synergy/gui/dev_mode.h"
+#include "synergy/gui/license/LicenseHandler.h"
+
+#include "synergy/gui/styles.h"
+
+#include
+#include
+#include
+#include
+#include
+#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)
+{
+ // Qt's default link color is unreadable on the dark theme; setting the palette link role
+ // once colors every anchor, so dialog copy never needs inline link styles.
+ auto palette = QGuiApplication::palette();
+ palette.setColor(QPalette::Link, QColor(kColorSecondary));
+ QGuiApplication::setPalette(palette);
+
+ LicenseHandler::instance().handleMainWindow(mainWindow, coreProcess);
+ FeatureHandler::instance().handleMainWindow(mainWindow);
+ synergy::gui::migration::showNoticeIfPending(mainWindow);
+}
+
+inline void onTitleApplied(QMainWindow *mainWindow)
+{
+ const bool showVersion = Settings::value(Settings::Gui::ShowVersionInTitle).toBool();
+ mainWindow->setWindowTitle(synergy::gui::windowTitle(synergy::kDisplayName, showVersion));
+}
+
+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 onAbout(QDialog *parent)
+{
+ FeatureHandler::instance().handleAbout(parent);
+ LicenseHandler::instance().handleAbout(parent);
+}
+
+inline void onVersionCheck(QString &versionUrl)
+{
+ LicenseHandler::instance().handleVersionCheck(versionUrl);
+ synergy::gui::UpdateChannel::applyToVersionCheckUrl(versionUrl);
+}
+
+inline bool onCoreStart()
+{
+ return LicenseHandler::instance().handleCoreStart();
+}
+
+inline void onTestStart()
+{
+ LicenseHandler::instance().disable();
+}
+
+/**
+ * @brief Build a crisp system-tray icon from a Qt-resource SVG.
+ *
+ * Synergy shows its colored brand logo in the tray, rendered straight from a
+ * resource SVG rather than a themed monochrome icon that GNOME would size
+ * itself. A fresh SVG-backed QIcon reports no available sizes, so Qt's
+ * StatusNotifierItem backend only hands GNOME 22px and 64px renderings; GNOME
+ * upscales the nearest one to its panel slot and the icon looks blurry.
+ * Pre-rendering the common tray sizes gives GNOME a near-exact match, so it
+ * barely scales the bitmap and the icon stays sharp.
+ *
+ * @param resourcePath Qt resource path of the SVG to render.
+ * @return A multi-size icon suitable for QSystemTrayIcon::setIcon.
+ */
+inline QIcon trayIcon(const QString &resourcePath)
+{
+ const QIcon source(resourcePath);
+ QIcon icon;
+ for (const int size : {16, 22, 24, 32, 48, 64})
+ icon.addPixmap(source.pixmap(QSize(size, size)));
+ return icon;
+}
+
+} // 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..3ff4af3b4
--- /dev/null
+++ b/extra/src/lib/synergy/license/CMakeLists.txt
@@ -0,0 +1,27 @@
+# Synergy -- mouse and keyboard sharing utility
+# Copyright (C) 2024 - 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 .
+
+file(GLOB headers "*.h")
+file(GLOB sources "*.cpp")
+
+if(ADD_HEADERS_TO_SOURCES)
+ list(APPEND sources ${headers})
+endif()
+
+find_package(OpenSSL ${REQUIRED_OPENSSL_VERSION} REQUIRED COMPONENTS Crypto)
+
+add_library(license STATIC ${sources})
+
+target_link_libraries(license arch base OpenSSL::Crypto)
diff --git a/extra/src/lib/synergy/license/License.cpp b/extra/src/lib/synergy/license/License.cpp
new file mode 100644
index 000000000..834d57487
--- /dev/null
+++ b/extra/src/lib/synergy/license/License.cpp
@@ -0,0 +1,123 @@
+/*
+ * Synergy -- mouse and keyboard sharing utility
+ * Copyright (C) 2016 - 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 "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..473c85fc7
--- /dev/null
+++ b/extra/src/lib/synergy/license/License.h
@@ -0,0 +1,118 @@
+/*
+ * Synergy -- mouse and keyboard sharing utility
+ * Copyright (C) 2016 - 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 "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/OfflineActivation.cpp b/extra/src/lib/synergy/license/OfflineActivation.cpp
new file mode 100644
index 000000000..3483fc3e6
--- /dev/null
+++ b/extra/src/lib/synergy/license/OfflineActivation.cpp
@@ -0,0 +1,277 @@
+/*
+ * 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 "OfflineActivation.h"
+
+#include
+#include
+#include
+#include
+#include
+
+#include
+#include
+#include
+
+namespace synergy::license {
+
+namespace {
+
+// The wire format here must stay in lockstep with the vendor signer in the web-muskoka repo
+// (website lib/server/product-license/offline-activation-protocol.ts).
+constexpr auto kDomain = std::string_view{"synergy-offline-activation-v1"};
+constexpr size_t kSecretLength = 32;
+
+// No vowels, so codes can never spell words; no I, L, or O, so nothing is mistaken
+// for 1 or 0 (and typed O/I/L are forgiven by mapping them to digits).
+constexpr auto kCodeAlphabet = std::string_view{"0123456789BCDFGHJKMNPQRSTVWXYZ"};
+
+constexpr std::uint64_t kCodeAlphabetSize = 30;
+constexpr size_t kCodeLength = 12;
+constexpr size_t kFingerprintDigits = 2;
+constexpr size_t kMachineDigits = 10;
+
+constexpr std::uint64_t codeSpace(size_t digits)
+{
+ std::uint64_t value = 1;
+ for (size_t i = 0; i < digits; ++i) {
+ value *= kCodeAlphabetSize;
+ }
+ return value;
+}
+
+constexpr auto kFingerprintRange = codeSpace(kFingerprintDigits);
+constexpr auto kMachineRange = codeSpace(kMachineDigits);
+constexpr auto kResponseRange = codeSpace(kCodeLength);
+
+using Bytes = std::vector;
+
+std::string encodeBase30(std::uint64_t value, size_t digits)
+{
+ std::string out(digits, '0');
+ for (size_t i = digits; i > 0; --i) {
+ out[i - 1] = kCodeAlphabet[value % kCodeAlphabetSize];
+ value /= kCodeAlphabetSize;
+ }
+ return out;
+}
+
+std::uint64_t bytesToUint(const Bytes &bytes, size_t count)
+{
+ std::uint64_t value = 0;
+ for (size_t i = 0; i < count; ++i) {
+ value = (value << 8) | bytes[i];
+ }
+ return value;
+}
+
+constexpr auto kAsciiWhitespace = std::string_view{" \t\n\r\f\v"};
+
+char asciiUpper(char c)
+{
+ return (c >= 'a' && c <= 'z') ? static_cast(c - ('a' - 'A')) : c;
+}
+
+char forgiveConfusable(char c)
+{
+ if (c == 'O') {
+ return '0';
+ }
+ if (c == 'I' || c == 'L') {
+ return '1';
+ }
+ return c;
+}
+
+std::optional canonicalizeCode(const std::string &code)
+{
+ std::string out;
+ for (const auto c : code) {
+ if (c == '-' || kAsciiWhitespace.find(c) != std::string_view::npos) {
+ continue;
+ }
+ const auto upper = forgiveConfusable(asciiUpper(c));
+ if (kCodeAlphabet.find(upper) == std::string_view::npos) {
+ return std::nullopt;
+ }
+ out.push_back(upper);
+ }
+ return out;
+}
+
+std::optional hexDecode(const std::string &hex)
+{
+ if (hex.length() % 2 != 0) {
+ return std::nullopt;
+ }
+ const auto nibble = [](char c) -> int {
+ if (c >= '0' && c <= '9') {
+ return c - '0';
+ }
+ if (c >= 'a' && c <= 'f') {
+ return c - 'a' + 10;
+ }
+ if (c >= 'A' && c <= 'F') {
+ return c - 'A' + 10;
+ }
+ return -1;
+ };
+ Bytes out;
+ out.reserve(hex.length() / 2);
+ for (size_t i = 0; i < hex.length(); i += 2) {
+ const auto high = nibble(hex[i]);
+ const auto low = nibble(hex[i + 1]);
+ if (high < 0 || low < 0) {
+ return std::nullopt;
+ }
+ out.push_back(static_cast((high << 4) | low));
+ }
+ return out;
+}
+
+Bytes sha256(const Bytes &data)
+{
+ Bytes digest(EVP_MAX_MD_SIZE);
+ unsigned int length = 0;
+ if (EVP_Digest(data.data(), data.size(), digest.data(), &length, EVP_sha256(), nullptr) != 1) {
+ return {};
+ }
+ digest.resize(length);
+ return digest;
+}
+
+void append(Bytes &bytes, std::string_view text)
+{
+ bytes.insert(bytes.end(), text.begin(), text.end());
+}
+
+std::string canonicalizeSerial(const std::string &serialHex)
+{
+ const auto first = serialHex.find_first_not_of(kAsciiWhitespace);
+ if (first == std::string::npos) {
+ return {};
+ }
+ const auto last = serialHex.find_last_not_of(kAsciiWhitespace);
+ auto serial = serialHex.substr(first, last - first + 1);
+ std::transform(serial.begin(), serial.end(), serial.begin(), [](char c) {
+ return (c >= 'A' && c <= 'Z') ? static_cast(c + ('a' - 'A')) : c;
+ });
+ return serial;
+}
+
+Bytes hmacSha256(const Bytes &key, const Bytes &data)
+{
+ Bytes digest(EVP_MAX_MD_SIZE);
+ unsigned int length = 0;
+ const auto result =
+ HMAC(EVP_sha256(), key.data(), static_cast(key.size()), data.data(), data.size(), digest.data(), &length);
+ if (result == nullptr) {
+ return {};
+ }
+ digest.resize(length);
+ return digest;
+}
+
+} // namespace
+
+std::string buildOfflineChallenge(const std::string &machineId, const std::string &serialHex)
+{
+ if (machineId.empty()) {
+ return {};
+ }
+
+ const auto serial = canonicalizeSerial(serialHex);
+
+ Bytes serialBytes;
+ append(serialBytes, serial);
+ const auto serialDigest = sha256(serialBytes);
+
+ Bytes message;
+ append(message, kDomain);
+ message.push_back(0);
+ append(message, machineId);
+ message.push_back(0);
+ append(message, serial);
+ const auto machineDigest = sha256(message);
+
+ if (serialDigest.size() < 4 || machineDigest.size() < 8) {
+ return {};
+ }
+
+ const auto fingerprint = bytesToUint(serialDigest, 4) % kFingerprintRange;
+ const auto machineCode = bytesToUint(machineDigest, 8) % kMachineRange;
+ return encodeBase30(fingerprint, kFingerprintDigits) + encodeBase30(machineCode, kMachineDigits);
+}
+
+bool verifyOfflineResponse(const std::string &machineId, const std::string &serialHex, const std::string &responseCode)
+{
+ return verifyOfflineResponse(machineId, serialHex, responseCode, kOfflineActivationHex);
+}
+
+bool verifyOfflineResponse(
+ const std::string &machineId, const std::string &serialHex, const std::string &responseCode,
+ const std::string &secretHex
+)
+{
+ if (machineId.empty()) {
+ return false;
+ }
+
+ const auto secret = hexDecode(secretHex);
+ if (!secret.has_value() || secret->size() != kSecretLength) {
+ return false;
+ }
+
+ const auto canonicalResponse = canonicalizeCode(responseCode);
+ if (!canonicalResponse.has_value() || canonicalResponse->length() != kCodeLength) {
+ return false;
+ }
+
+ const auto challenge = buildOfflineChallenge(machineId, serialHex);
+ if (challenge.empty()) {
+ return false;
+ }
+
+ Bytes message;
+ append(message, kDomain);
+ message.push_back(':');
+ append(message, challenge);
+ const auto mac = hmacSha256(*secret, message);
+ if (mac.size() < 8) {
+ return false;
+ }
+
+ const auto expected = encodeBase30(bytesToUint(mac, 8) % kResponseRange, kCodeLength);
+ return CRYPTO_memcmp(expected.data(), canonicalResponse->data(), kCodeLength) == 0;
+}
+
+std::string formatOfflineCode(const std::string &code, int groupSize)
+{
+ if (groupSize <= 0) {
+ return code;
+ }
+ std::string out;
+ for (size_t i = 0; i < code.length(); ++i) {
+ if (i > 0 && i % static_cast(groupSize) == 0) {
+ out.push_back('-');
+ }
+ out.push_back(code[i]);
+ }
+ return out;
+}
+
+} // namespace synergy::license
diff --git a/extra/src/lib/synergy/license/OfflineActivation.h b/extra/src/lib/synergy/license/OfflineActivation.h
new file mode 100644
index 000000000..3b115d8e4
--- /dev/null
+++ b/extra/src/lib/synergy/license/OfflineActivation.h
@@ -0,0 +1,68 @@
+/*
+ * 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::license {
+
+/**
+ * Challenge-response activation for air-gapped machines. The machine shows a
+ * short challenge code derived from its hardware id and serial key, the customer
+ * exchanges it for a response code on their account page (web-muskoka repo), and
+ * the machine verifies the response with no network access. Both codes are
+ * hand-typed, so the response is a short truncated MAC rather than a signature;
+ * extracting the embedded secret defeats it, which is an accepted trade-off
+ * (deterrence, not copy protection).
+ */
+
+/// @brief Shared hex the response codes are verified against (deliberately not secret)
+inline constexpr auto kOfflineActivationHex = "8acfc45b6524b47ae91e68610c7d2463c28b7e5f80a04d3fd589c87917848534";
+
+/**
+ * @brief Builds the challenge code the customer enters on their account page.
+ * @param machineId A stable hardware identifier for this machine.
+ * @param serialHex The license serial key as a hex string.
+ * @return Canonical 12-character challenge code (use @ref formatOfflineCode for display).
+ */
+std::string buildOfflineChallenge(const std::string &machineId, const std::string &serialHex);
+
+/**
+ * @brief Verifies a response code against the embedded shared secret.
+ * @param machineId Must match the machine the challenge was built on.
+ * @param serialHex Must match the serial key the challenge was built with.
+ * @param responseCode The response code from the account page; separators and letter case are ignored.
+ * @return True only if the response is valid for this machine and serial.
+ */
+bool verifyOfflineResponse(const std::string &machineId, const std::string &serialHex, const std::string &responseCode);
+
+/**
+ * @brief Same as the three-argument overload, with an explicit secret for tests.
+ * @param secretHex Shared secret, 64 hex characters.
+ */
+bool verifyOfflineResponse(
+ const std::string &machineId, const std::string &serialHex, const std::string &responseCode,
+ const std::string &secretHex
+);
+
+/**
+ * @brief Inserts dash separators for display, e.g. "AAAABBBB" with group size 4 becomes "AAAA-BBBB".
+ */
+std::string formatOfflineCode(const std::string &code, int groupSize);
+
+} // 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..316471b69
--- /dev/null
+++ b/extra/src/lib/synergy/license/Product.cpp
@@ -0,0 +1,170 @@
+/*
+ * Synergy -- mouse and keyboard sharing utility
+ * Copyright (C) 2016 - 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