diff --git a/.github/README.md b/.github/README.md deleted file mode 100644 index 667ed74b9..000000000 --- a/.github/README.md +++ /dev/null @@ -1,187 +0,0 @@ - - - - Deskflow - - -**Deskflow** is a free and open source keyboard and mouse sharing app. -Use the keyboard, mouse, or trackpad of one computer to control nearby computers, -and work seamlessly between them. -It's like a software KVM (but without the video). -TLS encryption is enabled by default. Wayland is supported. Clipboard sharing is supported. - -> [!TIP] -> -> **Chat with us** -> -> - Main discussion on Matrix: [`#deskflow:matrix.org`](https://matrix.to/#/#deskflow:matrix.org) ([Matrix clients](https://matrix.org/ecosystem/clients/)) -> - Discussion also happens on IRC: `#deskflow` or `#deskflow-dev` on [Libera Chat](https://libera.chat/) -> - Start a [new discussion](https://github.com/deskflow/deskflow/discussions) on our GitHub project. - -## Download - -[![Downloads: Stable Release](https://img.shields.io/github/downloads/deskflow/deskflow/latest/total?style=for-the-badge&logo=github&label=Download%20Stable)](https://github.com/deskflow/deskflow/releases/latest)      [![Downloads: Continuous Build](https://img.shields.io/github/downloads/deskflow/deskflow/continuous/total?style=for-the-badge&logo=github&label=Download%20Continuous)](https://github.com/deskflow/deskflow/releases/continuous)      [![Download From Flathub](https://img.shields.io/flathub/downloads/org.deskflow.deskflow?style=for-the-badge&logo=flathub&label=Download%20from%20flathub)](https://flathub.org/apps/org.deskflow.deskflow) - -> [!NOTE] -> On Windows, you will need to install the -> [Microsoft Visual C++ Redistributable](https://learn.microsoft.com/en-us/cpp/windows/latest-supported-vc-redist?view=msvc-170#latest-microsoft-visual-c-redistributable-version). -> Download latest: [`vc_redist.x64.exe`](https://aka.ms/vc14/vc_redist.x64.exe) [`vc_redist.arm64.exe`](https://aka.ms/vc14/vc_redist.arm64.exe) - -> [!TIP] -> For macOS users, the easiest way to install and stay up to date is to use [Homebrew](https://brew.sh) with our [homebrew-tap](https://github.com/deskflow/homebrew-tap). -> macOS reports unsigned apps as damaged. This occurs because we do not use an Apple certificate for notarization. Clear the quarantine attribute to run the app: `xattr -c Deskflow.app` - -To use Deskflow, download one of our [packages](https://github.com/deskflow/deskflow/releases), install `deskflow` (from your package repository), or [build it](https://github.com/deskflow/deskflow/wiki/Building) from source. - -## Stats - -[![GitHub commit activity](https://img.shields.io/github/commit-activity/m/deskflow/deskflow?logo=github)](https://github.com/deskflow/deskflow/commits/master/) -[![GitHub top language](https://img.shields.io/github/languages/top/deskflow/deskflow?logo=github)](https://github.com/deskflow/deskflow/commits/master/) -[![GitHub License](https://img.shields.io/github/license/deskflow/deskflow?logo=github)](LICENSE) -[![REUSE status](https://api.reuse.software/badge/github.com/deskflow/deskflow)](https://api.reuse.software/info/github.com/deskflow/deskflow) - -[![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=deskflow_deskflow&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=deskflow_deskflow) -[![Coverage](https://sonarcloud.io/api/project_badges/measure?project=deskflow_deskflow&metric=coverage)](https://sonarcloud.io/summary/new_code?id=deskflow_deskflow) -[![Code Smells](https://sonarcloud.io/api/project_badges/measure?project=deskflow_deskflow&metric=code_smells)](https://sonarcloud.io/summary/new_code?id=deskflow_deskflow) -[![Vulnerabilities](https://sonarcloud.io/api/project_badges/measure?project=deskflow_deskflow&metric=vulnerabilities)](https://sonarcloud.io/summary/new_code?id=deskflow_deskflow) - -[![CI](https://github.com/deskflow/deskflow/actions/workflows/continuous-integration.yml/badge.svg)](https://github.com/deskflow/deskflow/actions/workflows/continuous-integration.yml) -[![CodeQL Analysis](https://github.com/deskflow/deskflow/actions/workflows/codeql-analysis.yml/badge.svg)](https://github.com/deskflow/deskflow/actions/workflows/codeql-analysis.yml) -[![SonarCloud Analysis](https://github.com/deskflow/deskflow/actions/workflows/sonarcloud-analysis.yml/badge.svg)](https://github.com/deskflow/deskflow/actions/workflows/sonarcloud-analysis.yml) - -## Contribute - -[![Good first issues](https://img.shields.io/github/issues/deskflow/deskflow/good%20first%20issue?label=good%20first%20issues&color=%2344cc11)](https://github.com/deskflow/deskflow/labels/good%20first%20issue) - -There are many ways to contribute to the Deskflow project. - -We're a friendly, active, and welcoming community focused on building a great app. - -Read our [Contributing](https://github.com/deskflow/deskflow/wiki/Contributing) page to get started. - -For instructions on building Deskflow, use the wiki page: [Building](https://github.com/deskflow/deskflow/wiki/Building) - -## Operating Systems - -We support all major operating systems, including Windows, macOS, Linux, and Unix-like BSD-derived. - -Windows 10 v1809 or higher is required. - -macOS 13 or higher is required to use our CI builds for Apple Silicon machines. macOS 12 or higher is required for Intel macs or local builds. - -Linux requires libei 1.3+ and libportal 0.8+ for the server/client. Additionally, Qt 6.7+ is required for the GUI. -Linux users with systems not meeting these requirements should use flatpak in place of a native package. - -We officially support FreeBSD, and would also like to support: OpenBSD, NetBSD, DragonFly, Solaris. - -## Repology - -Repology monitors a huge number of package repositories and other sources comparing package -versions across them and gathering other information. - -[![Repology](https://repology.org/badge/vertical-allrepos/deskflow.svg?columns=2&exclude_unsupported)](https://repology.org/project/deskflow/versions) - -## Installing on macOS - -When you install Deskflow on macOS, you need to allow accessibility access (Privacy & Security) to both the `Deskflow` app and the `deskflow` process. - -If using Sequoia, you may also need to allow `Deskflow` under Local Network‍ settings (Privacy & Security). -When prompted by the OS, go to the settings and enable the access. - -If you are upgrading and you already have `Deskflow` or `deskflow` -on the allowed list you will need to manually remove them before accessibility access can be granted to the new version. - -macOS users who download directly from releases may need to run `xattr -c /Applications/Deskflow.app` after copying the app to the `Applications` dir. - -It is recommended to install Deskflow using [Homebrew](https://brew.sh) from our [homebrew-tap](https://github.com/deskflow/homebrew-tap) - -To add our tap, run: - -``` -brew tap deskflow/tap -``` - -Then install either: - -- Stable: `brew install deskflow` -- Continuous: `brew install deskflow-dev` - -## Similar Projects - -In the open source developer community, similar projects collaborate for the improvement of all -mouse and keyboard sharing tools. We aim for idea sharing and interoperability. - -- [**Lan Mouse**](https://github.com/feschber/lan-mouse) - - Rust implementation with the goal of having native front-ends and interoperability with - Deskflow/Synergy. -- [**Synergy**](https://symless.com/synergy) - - Downstream commercial fork. Synergy sponsors Deskflow with financial support and contributes code ([learn more](https://github.com/deskflow/deskflow/wiki/Relationship-with-Synergy)). -- [**Input Leap**](https://github.com/input-leap/input-leap) - - Inactive Deskflow/Synergy-derivative with the goal continuing Barrier development (now a dead fork). - -## FAQ - -### Is Deskflow compatible with Synergy, Input Leap, or Barrier? - -Yes, Deskflow has network compatibility with all forks: - -- Requires Deskflow >= v1.17.0.96 -- Deskflow will _just work_ with Input Leap and Barrier (server or client). -- Connecting a Deskflow client to a Synergy 1 server will also _just work_. -- To connect a Synergy 1 client, you need to select the Synergy protocol in the Deskflow server settings. - -_Note:_ Only Synergy 1 is compatible with Deskflow (Synergy 3 is not yet compatible). - -### Is Deskflow compatible with Lan Mouse? - -We would love to see compatibility with Lan Mouse. This may be quite an effort as currently the way they handle the generated input is very different. - -### If I want to solve issues in Deskflow do I need to contribute to a fork? - -We welcome PRs (pull requests) from the community. If you'd like to make a change, please feel -free to [start a discussion](https://github.com/deskflow/deskflow/discussions) or -[open a PR](https://github.com/deskflow/deskflow/wiki/Contributing). - -### Is clipboard sharing supported? - -Absolutely. The clipboard-sharing feature is a cornerstone feature of the product and we are -committed to maintaining and improving that feature. - -### Is Wayland for Linux supported? - -Yes! Wayland (the Linux display server protocol aimed to become the successor of the X Window -System) is an important platform for us. -The [`libei`](https://gitlab.freedesktop.org/libinput/libei) and -[`libportal`](https://github.com/flatpak/libportal) libraries enable -Wayland support for Deskflow. We would like to give special thanks to Peter Hutterer, -who is the author of `libei`, a major contributor to `libportal`, and the author of the Wayland -implementation in Deskflow. Others such as Olivier Fourdan and Povilas Kanapickas helped with the -Wayland implementation. - -Some features _may_ be unavailable or broken on Wayland. Please see the [known Wayland issues](https://github.com/deskflow/deskflow/discussions/7499). - -### Where did it all start? - -Deskflow was first created as Synergy in 2001 by Chris Schoeneman. -Read about the [history of the project](https://github.com/deskflow/deskflow/wiki/History) on our -wiki. - -## Meow'Dib (our mascot) - -![Meow'Dib](https://github.com/user-attachments/assets/726f695c-3dfb-4abd-875d-ed658f6c610f) - -## Deskflow Contributors - -[![Sponsored by Synergy](https://raw.githubusercontent.com/deskflow/deskflow-artwork/b2c72a3e60a42dee793bd47efc275b5ee0bdaa5f/misc/synergy-sponsor.svg)](https://symless.com/synergy) - -[Synergy](https://symless.com/synergy) sponsors the Deskflow project by contributing code and providing financial support ([learn more](https://github.com/deskflow/deskflow/wiki/Relationship-with-Synergy)). - -Deskflow is made by possible by these contributors. - - - - - -## License - -This project is licensed under [GPL-2.0](LICENSE) with an [OpenSSL exception](../LICENSES/LicenseRef-OpenSSL-Exception.txt). diff --git a/.github/actions/get-version/action.yml b/.github/actions/get-version/action.yml index 9e80a8574..e18aa5030 100644 --- a/.github/actions/get-version/action.yml +++ b/.github/actions/get-version/action.yml @@ -1,15 +1,30 @@ name: "Get version" -description: "Gets the version number from GitHub environment" +description: > + Computes the build version via extra/cmake/SaveVersion.cmake — runs the same + synergy_compute_version() the build uses, so the CI-side version (used in + package filenames, S3 paths, release titles) matches what binaries report. + +outputs: + version: + description: "Synergy version (e.g. 1.21.0, 1.21.0-snapshot+r42, 1.21.0-dev)" + value: ${{ steps.compute.outputs.version }} runs: using: "composite" - steps: - - run: | - echo "DESKFLOW_VERSION=$(git describe --long | sed 's/-/./g;s/v//g' | grep -o '^[0-9]*.[0-9]*.[0-9]*.[0-9]*')">> $GITHUB_ENV - if [[ "$GITHUB_REF" == *"tags/v"* ]]; then - echo "DESKFLOW_PACKAGE_VERSION=$(echo $GITHUB_REF | sed 's,refs/tags/v,,g')">> $GITHUB_ENV - else - echo "DESKFLOW_PACKAGE_VERSION=continuous">> $GITHUB_ENV - fi + - id: compute shell: bash + run: | + # Container jobs bind-mount the workspace owned by the host user while + # running as root, so git rejects it as dubious-owned and the SHA lookup + # in Version.cmake fails silently, degrading the version to a no-SHA form. + git config --global --add safe.directory "$GITHUB_WORKSPACE" + mkdir -p build + cmake \ + -DVERSION_FILE=build/VERSION \ + -DSYNERGY_VERSION_RELEASE=${SYNERGY_VERSION_RELEASE:-false} \ + -DSYNERGY_VERSION_SNAPSHOT=${SYNERGY_VERSION_SNAPSHOT:-false} \ + -P extra/cmake/SaveVersion.cmake + version=$(cat build/VERSION) + echo "Computed version: $version" + echo "version=$version" >> "$GITHUB_OUTPUT" diff --git a/.github/actions/install-dependencies/action.yml b/.github/actions/install-dependencies/action.yml index af9c1680e..80051a112 100644 --- a/.github/actions/install-dependencies/action.yml +++ b/.github/actions/install-dependencies/action.yml @@ -34,9 +34,12 @@ runs: elif [ "$RUNNER_OS" == "Linux" ]; then if [ ${{inputs.like}} == "debian" ]; then apt update -qqq > /dev/null + # qt6-l10n-tools + qt6-tools-dev-tools provide lconvert/lupdate/lrelease that + # qt6-tools-dev's LinguistTools cmake references but doesn't always pull in. apt install -qqq cmake build-essential ninja-build \ xorg-dev libx11-dev libxtst-dev libssl-dev \ libglib2.0-dev libxkbfile-dev qt6-base-dev qt6-tools-dev \ + qt6-l10n-tools qt6-tools-dev-tools \ libgtk-3-dev libgtest-dev libgmock-dev \ libei-dev libportal-dev help2man doxygen -y >/dev/null elif [ ${{inputs.like}} == "fedora" ]; then diff --git a/.github/actions/lint-clang/action.yml b/.github/actions/lint-clang/action.yml index 07eb9b9c9..0b83902fb 100644 --- a/.github/actions/lint-clang/action.yml +++ b/.github/actions/lint-clang/action.yml @@ -1,5 +1,5 @@ name: "Lint Clang" -description: "Checks for Clang lint errors and posts a helpful comment" +description: "Fails CI if any source file doesn't match clang-format" runs: using: "composite" @@ -43,48 +43,20 @@ runs: if-no-files-found: error - name: Set summary - id: summary if: steps.changes.outputs.diff run: | code_block="\`\`\`" - clang_version=$(clang-format --version | sed -n 's/^.*version //p') + cat <> $GITHUB_STEP_SUMMARY + ❌ \`clang-format\` \`v${clang_version}\`: changes don't match the code style. - summary=$(cat <> $GITHUB_STEP_SUMMARY - - pr_comment=$(cat <> $GITHUB_OUTPUT shell: bash - - name: Upload CI summary as artifact - if: steps.summary.outputs.file - uses: actions/upload-artifact@v7 - with: - name: summary-clang-format - path: ${{ steps.summary.outputs.file }} - if-no-files-found: error - - name: Fail if diff exists if: steps.changes.outputs.diff run: exit 1 diff --git a/.github/actions/run-tests/action.yml b/.github/actions/run-tests/action.yml index 52b234708..e92828213 100644 --- a/.github/actions/run-tests/action.yml +++ b/.github/actions/run-tests/action.yml @@ -1,12 +1,7 @@ name: "Run tests" -description: "Runs both unit tests and integration tests and appends to a PR comment" +description: "Runs both unit tests and legacy tests" inputs: - job: - description: "The job name to append to the PR comment" - required: true - default: "unknown" - bin-dir: description: "The directory containing the test binaries" default: "build/bin" @@ -20,11 +15,12 @@ runs: env: QT_QPA_PLATFORM: offscreen run: | - ctest --test-dir "build/src/unittests" --output-on-failure - result=$? + ctest --test-dir "build/src/unittests" --output-on-failure || result=1 + ctest --test-dir "build/extra/src/unittests" --output-on-failure || result=1 - if [ $result -ne 0 ]; then - echo "Unit tests failed with code: $result" >> $GITHUB_STEP_SUMMARY + if [ "${result:-0}" -ne 0 ]; then + echo "Unit tests failed" >> $GITHUB_STEP_SUMMARY + exit 1 fi shell: bash continue-on-error: true @@ -43,39 +39,6 @@ runs: shell: bash continue-on-error: true - - name: Get test results - id: results - run: | - pass="✅ Pass" - fail="❌ Fail" - unit_tests_outcome="${{ steps.unit-tests.outcome }}" - legacy_tests_outcome="${{ steps.legacy-tests.outcome }}" - unit_tests=$( [ "$unit_tests_outcome" = "success" ] && echo $pass || echo $fail ) - legacy_tests=$( [ "$legacy_tests_outcome" = "success" ] && echo $pass || echo $fail ) - echo "unit-tests=$unit_tests" >> $GITHUB_OUTPUT - echo "legacy-tests=$legacy_tests" >> $GITHUB_OUTPUT - shell: bash - - - name: Summary row - id: row - run: | - file="row-${{ inputs.job }}.md" - - row="" - row+="| ${{ inputs.job }} " - row+="| ${{ steps.results.outputs.unit-tests }} " - row+="| ${{ steps.results.outputs.legacy-tests }} " - echo "$row" > $file - - echo "file=$file" > $GITHUB_OUTPUT - shell: bash - - - name: Save summary row - uses: actions/upload-artifact@v7 - with: - name: test-result-${{ inputs.job }} - path: ${{ steps.row.outputs.file }} - - name: Check test outcome if: (steps.unit-tests.outcome != 'success' || steps.legacy-tests.outcome != 'success') run: | diff --git a/.github/actions/test-package/action.yml b/.github/actions/test-package/action.yml index 574eddbb0..291d6940c 100644 --- a/.github/actions/test-package/action.yml +++ b/.github/actions/test-package/action.yml @@ -15,14 +15,14 @@ runs: shell: bash run: | if [ "$RUNNER_OS" == "macOS" ]; then - dmg=$(ls build/deskflow-*.dmg | head -1) - echo "Y" | hdiutil attach "$dmg" -nobrowse -mountpoint /tmp/deskflow-dmg - /tmp/deskflow-dmg/Deskflow.app/Contents/MacOS/deskflow-core --version - hdiutil detach /tmp/deskflow-dmg + dmg=$(ls build/synergy[-_]*.dmg | head -1) + echo "Y" | hdiutil attach "$dmg" -nobrowse -mountpoint /tmp/synergy-dmg + /tmp/synergy-dmg/Synergy.app/Contents/MacOS/synergy-core --version + hdiutil detach /tmp/synergy-dmg elif [ "$RUNNER_OS" == "Windows" ]; then - 7z x build/deskflow-*-portable.7z -otmp/deskflow-pkg - exe=$(find tmp/deskflow-pkg -name "deskflow-core.exe" -type f | head -1) + 7z x build/synergy[-_]*-portable.7z -otmp/synergy-pkg + exe=$(find tmp/synergy-pkg -name "synergy-core.exe" -type f | head -1) exe_dir=$(dirname "$exe") pkg_root=$(dirname "$exe_dir") export PATH="$exe_dir:$pkg_root:$pkg_root/lib:$PATH" @@ -30,18 +30,18 @@ runs: elif [ "$RUNNER_OS" == "Linux" ]; then if [ "${{inputs.like}}" == "debian" ]; then - apt-get install -y ./build/deskflow-*.deb + apt-get install -y ./build/synergy[-_]*.deb elif [ "${{inputs.like}}" == "fedora" ]; then - dnf install -y build/deskflow-*.rpm + dnf install -y build/synergy[-_]*.rpm elif [ "${{inputs.like}}" == "suse" ]; then - zypper install -y --allow-unsigned-rpm build/deskflow-*.rpm + zypper install -y --allow-unsigned-rpm build/synergy[-_]*.rpm elif [ "${{inputs.like}}" == "arch" ]; then - pacman -U --noconfirm build/deskflow-*.pkg.tar.zst + pacman -U --noconfirm build/synergy[-_]*.pkg.tar.zst else echo "Unknown like: ${{inputs.like}}" exit 1 fi - deskflow-core --version + synergy-core --version else echo "Unknown OS: $RUNNER_OS" diff --git a/.github/actions/test-summary/action.yml b/.github/actions/test-summary/action.yml deleted file mode 100644 index f0890206f..000000000 --- a/.github/actions/test-summary/action.yml +++ /dev/null @@ -1,93 +0,0 @@ -name: "Test Summary" -description: "Creates a markdown table from test results and uploads a summary file" - -inputs: - download-pattern: - description: "The pattern to download test result rows" - required: false - default: test-result-* - - upload-name: - description: "The artifact upload name for the summary" - required: false - default: summary-tests - -runs: - using: "composite" - steps: - - name: Download test result rows - uses: actions/download-artifact@v8 - with: - pattern: ${{ inputs.download-pattern }} - merge-multiple: true - path: rows - - - name: Build markdown table - id: markdown-table - run: | - # Builds a markdown table from the row artifacts. - - header=$(cat <> $GITHUB_OUTPUT - shell: bash - - - name: Set step summary - id: summary - run: | - # Sets the step summary and creates a CI summary file. - - table="${{ steps.markdown-table.outputs.markdown }}" - if [ -z "$table" ]; then - echo "No test results found" | tee $GITHUB_STEP_SUMMARY >&2 - exit 1 - fi - - echo "$table" > $GITHUB_STEP_SUMMARY - - count=$(echo "$table" | { grep -o '❌' || true; } | wc -l) - - # Keep at this indentation level for heredoc. - fail_summary=$(cat <> $GITHUB_OUTPUT - else - # For debugging; don't send success to CI summary (reduce noise). - echo > $file - echo "✅🔬 All tests passed" - fi - - shell: bash - - - name: Upload CI summary - if: steps.summary.outputs.file - uses: actions/upload-artifact@v7 - with: - name: ${{ inputs.upload-name }} - path: ${{ steps.summary.outputs.file }} - if-no-files-found: error diff --git a/.github/workflows/ci-comment.yml b/.github/workflows/ci-comment.yml deleted file mode 100644 index b9a02d99b..000000000 --- a/.github/workflows/ci-comment.yml +++ /dev/null @@ -1,105 +0,0 @@ -# Uses `workflow_run` to securely add a comment to the PR that triggered CI. -# -# Important: For security, the `workflow_run` trigger runs the workflow from the default branch, -# so any changes to this workflow must be made in the default branch to take effect. -# -# This workflow is neccesary because PRs opened by external forks do not have write access to -# their PR, so the PR-triggered workflow can't add comments to the PR. -# If this was in the CI workflow, it'd be tidier and easier to debug but unfortunately that -# isn't possible due to the lower security context that the CI workflow runs in. -name: CI comment - -on: - workflow_run: - workflows: ["Continuous Integration"] - types: - - completed - -jobs: - summary: - if: github.event.workflow_run.event == 'pull_request' - runs-on: ubuntu-slim - - steps: - # If the workflow fails before uploading summaries, no artifacts exist and this step fails. - # Allow failure so the "Delete PR comment" step still runs to clean up stale comments. - - name: Download summaries - id: download - uses: actions/download-artifact@v8 - continue-on-error: true - with: - run-id: ${{ github.event.workflow_run.id }} - pattern: summary-* - path: summaries - github-token: ${{ secrets.GITHUB_TOKEN }} - - # When the triggering workflow is running in a restricted security context, some - # data is omitted from `github.event.workflow_run` (including `pull_requests`). - # Therefore, we have to take some extra steps to find out the PR that triggered - # the original workflow. The simplest way is to iterate through our open PRs and - # find the one with the same head SHA as the triggering workflow. - - name: Get PR number - id: get-pr-number - uses: actions/github-script@v6 - with: - script: | - const head_sha = "${{ github.event.workflow_run.head_sha }}"; - console.log("Finding PR for SHA:", head_sha); - - const { data: prs } = await github.rest.pulls.list({ - owner: context.repo.owner, - repo: context.repo.repo, - state: 'open', - }); - - const pr = prs.find(pr => pr.head.sha === head_sha); - if (pr) { - console.log("Found PR:", pr.number); - return pr.number; - } else { - core.warning("PR not found for SHA (maybe removed by force push)"); - } - - - name: Merge summaries - if: steps.get-pr-number.outputs.result - id: summary - run: | - ls -R - - files=$(find summaries -type f 2>/dev/null || true) - if [ -z "$files" ]; then - echo "No files found in dir: $dir" - exit 0 - fi - - repo_url="${{ github.server_url }}/${{ github.repository }}" - run_url="$repo_url/actions/runs/${{ github.event.workflow_run.id }}" - - { - echo "message<> $GITHUB_OUTPUT - - - name: Set PR comment - if: steps.get-pr-number.outputs.result && steps.summary.outputs.message - uses: marocchino/sticky-pull-request-comment@v2 - with: - number: ${{ steps.get-pr-number.outputs.result }} - header: ${{ github.event.workflow_run.name }} - message: ${{ steps.summary.outputs.message }} - - - name: Delete PR comment - if: ${{ steps.get-pr-number.outputs.result && !steps.summary.outputs.message }} - uses: marocchino/sticky-pull-request-comment@v2 - with: - number: ${{ steps.get-pr-number.outputs.result }} - header: ${{ github.event.workflow_run.name }} - delete: true diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 000000000..34039d0da --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,785 @@ +# All-in-one continuous integration (CI) workflow. +# Runs on all platforms (Windows, macOS, and Linux) +# for all events (pull request, release, and schedule). + +name: CI + +on: + workflow_dispatch: + inputs: + package-type: + description: "dev (commit SHA), snapshot (nightly), release (clean semver)" + required: false + default: "dev" + type: choice + options: + - dev + - snapshot + - release + aws-s3-uri: + description: "Override AWS S3 target (e.g. s3://bucket/path)" + required: false + type: string + platform: + description: "Build only one platform (blank = all)" + required: false + default: "" + type: choice + options: + - "" + - windows + - macos + - linux + push: + branches: [beta] + pull_request: + release: + types: [published] + schedule: + - cron: "0 5 * * *" # 5am UTC nightly snapshot + +# Cancel superseded PR builds; pushes to branches/tags always run to completion. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +env: + GIT_SHA: ${{ github.event.pull_request.head.sha || github.sha }} + PACKAGE_PREFIX: ${{ vars.SYNERGY_PACKAGE_PREFIX || 'synergy' }} + PACKAGE_PATH: ./dist + CMAKE_CONFIGURE: "cmake -Bbuild -DCMAKE_BUILD_TYPE=Release -DSKIP_BUILD_TESTS=ON -DCMAKE_COMPILE_WARNING_AS_ERROR=ON" + + # Release shape, propagated to cmake (Synergy.cmake reads these as options). + # Release events + explicit "release" workflow_dispatch input → release build. + # Push to beta + schedule + explicit "snapshot" dispatch → snapshot build. + # PRs and "dev" dispatch fall through to dev (the cmake default). + SYNERGY_VERSION_RELEASE: ${{ github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.package-type == 'release') }} + SYNERGY_VERSION_SNAPSHOT: ${{ github.event_name == 'push' || github.event_name == 'schedule' || (github.event_name == 'workflow_dispatch' && github.event.inputs.package-type == 'snapshot') }} + +jobs: + # Quality gate to allow PR merge, used in the branch protection rules. + ci-passed: + runs-on: ubuntu-latest + needs: [build-windows, build-macos, build-linux, build-flatpak] + if: always() + + steps: + # 'skipped' is treated as passing because the user/event deliberately + # filtered the platform set (workflow_dispatch with inputs.platform=windows + # skips macos+linux jobs). A failed job is still a failure. + - name: Check all jobs passed or were intentionally skipped + run: | + ok() { + [[ "$1" == "success" || "$1" == "skipped" ]] + } + if ok "${{needs.build-windows.result}}" \ + && ok "${{needs.build-macos.result}}" \ + && ok "${{needs.build-linux.result}}" \ + && ok "${{needs.build-flatpak.result}}"; then + echo "✅ CI passed" > $GITHUB_STEP_SUMMARY + else + exit 1 + fi + + lint-clang: + runs-on: ubuntu-slim + timeout-minutes: 5 + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Lint Checker + uses: ./.github/actions/lint-clang + + analyze-valgrind: + needs: lint-clang + if: ${{ github.event_name == 'pull_request' }} + uses: ./.github/workflows/valgrind-analysis.yml + + build-windows: + needs: lint-clang + if: ${{ inputs.platform == '' || inputs.platform == 'windows' }} + name: ${{ matrix.target.name }} + runs-on: ${{ matrix.target.runs-on }} + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + target: + - name: "windows-2022-x64" + runs-on: "windows-2022" + vcpkg-triplet: x64-windows-release + arch: "amd64" + qt-version: 6.10.3 + + - name: "windows-2022-arm64" + runs-on: "windows-11-arm" + vcpkg-triplet: arm64-windows + arch: "arm64" + qt-version: 6.11.1 + + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + fetch-depth: 0 + fetch-tags: true + + # Effectively runs vcvarsall.bat. VC++ itself is already pre-installed on the runner. + - name: Setup VC++ environment + uses: TheMrMilchmann/setup-msvc-dev@v4 + with: + arch: ${{ matrix.target.arch }} + spectre: true + + - name: Install dependencies + id: get-deps + uses: ./.github/actions/install-dependencies + with: + qt-version: ${{ matrix.target.qt-version }} + vcpkg-triplet: ${{ matrix.target.vcpkg-triplet }} + + - name: Get version + id: get-version + uses: ./.github/actions/get-version + + - name: Update Windows Paths + shell: pwsh + run: echo "C:\Program Files\doxygen\bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append + + - name: Configure + run: ${{ env.CMAKE_CONFIGURE }} -G Ninja ${{ steps.get-deps.outputs.vcpkg-cmake-config }} -DSYNERGY_VERSION_RELEASE=${{ env.SYNERGY_VERSION_RELEASE }} -DSYNERGY_VERSION_SNAPSHOT=${{ env.SYNERGY_VERSION_SNAPSHOT }} + + - name: Build + shell: bash + run: cmake --build build --config Release -j8 + + # Inner binaries (synergy-core.exe, synergy.exe, synergy-daemon.exe) must + # be signed BEFORE the MSI is built — WiX embeds them into the installer, + # and signing the MSI afterward doesn't propagate signatures to embedded + # files. Stages all non-test exes into one dir so they can be batch-signed + # with a single OTP, then restores them in-place before the package step. + - name: Stage binaries for signing + if: env.SYNERGY_VERSION_RELEASE == 'true' + shell: pwsh + run: | + $stageIn = "$env:RUNNER_TEMP\sign-stage\in" + $stageOut = "$env:RUNNER_TEMP\sign-stage\out" + New-Item -ItemType Directory -Force -Path $stageIn, $stageOut | Out-Null + $exes = Get-ChildItem build/bin/*.exe | Where-Object { $_.Name -notmatch 'test' } + if (-not $exes) { throw "no binaries found in build/bin to sign" } + foreach ($exe in $exes) { Copy-Item $exe.FullName "$stageIn\$($exe.Name)" -Force } + + - name: Sign inner binaries + if: env.SYNERGY_VERSION_RELEASE == 'true' + uses: SSLcom/esigner-codesign@develop + with: + command: batch_sign + username: ${{ secrets.WINDOWS_SSL_USERNAME }} + password: ${{ secrets.WINDOWS_SSL_PASSWORD }} + credential_id: ${{ secrets.WINDOWS_SSL_CREDENTIAL_ID }} + totp_secret: ${{ secrets.WINDOWS_SSL_TOTP_SECRET }} + dir_path: ${{ runner.temp }}\sign-stage\in + output_path: ${{ runner.temp }}\sign-stage\out + environment_name: PROD + + # The pre-sign malware scan draws on a separate, much smaller quota than signing and + # exhausts it across a full release; signing produces a valid signature without it. + malware_block: "false" + + - name: Restore + verify signed binaries + if: env.SYNERGY_VERSION_RELEASE == 'true' + shell: pwsh + run: | + Get-ChildItem "$env:RUNNER_TEMP\sign-stage\out\*.exe" | ForEach-Object { + $dst = "build/bin/$($_.Name)" + Copy-Item $_.FullName $dst -Force + $sig = Get-AuthenticodeSignature $dst + if ($sig.Status -ne 'Valid') { throw "signature check failed on $dst (Status: $($sig.Status))" } + Write-Host "OK: $dst signed by $($sig.SignerCertificate.Subject)" + } + + - name: Package + shell: bash + run: cmake --build build --config Release -j8 --target package + + - name: Locate MSI + if: env.SYNERGY_VERSION_RELEASE == 'true' + id: locate-msi + shell: pwsh + run: | + $msi = (Get-ChildItem build/*.msi | Select-Object -First 1).FullName + if (-not $msi) { throw "no MSI found in build/" } + "path=$msi" | Out-File $env:GITHUB_OUTPUT -Append + + - name: Sign MSI + if: env.SYNERGY_VERSION_RELEASE == 'true' + uses: SSLcom/esigner-codesign@develop + with: + command: sign + username: ${{ secrets.WINDOWS_SSL_USERNAME }} + password: ${{ secrets.WINDOWS_SSL_PASSWORD }} + credential_id: ${{ secrets.WINDOWS_SSL_CREDENTIAL_ID }} + totp_secret: ${{ secrets.WINDOWS_SSL_TOTP_SECRET }} + file_path: ${{ steps.locate-msi.outputs.path }} + override: "true" + # The pre-sign malware scan draws on a separate, much smaller quota than signing and + # exhausts it across a full payload; signing produces a valid signature without it. + malware_block: "false" + environment_name: PROD + + - name: Verify MSI is signed + if: env.SYNERGY_VERSION_RELEASE == 'true' + shell: pwsh + run: | + $sig = Get-AuthenticodeSignature "${{ steps.locate-msi.outputs.path }}" + if ($sig.Status -ne 'Valid') { throw "MSI not signed (Status: $($sig.Status))" } + Write-Host "OK: MSI signed by $($sig.SignerCertificate.Subject)" + + - name: Check for unexpected repo changes + shell: bash + run: | + if [[ `git status --porcelain` ]]; then + echo "Unexpected changes to the repo, Often caused by forgetting to commit the updated translation files" + exit 1 + fi + + - name: Tests + uses: ./.github/actions/run-tests + timeout-minutes: 2 + + - name: Test package + uses: ./.github/actions/test-package + + - name: Upload + uses: actions/upload-artifact@v7 + with: + name: package-${{ env.PACKAGE_PREFIX }}-${{ steps.get-version.outputs.version }}-${{ matrix.target.name }} + path: ${{ github.workspace }}/build/${{ env.PACKAGE_PREFIX }}[-_]*.* + + build-macos: + needs: lint-clang + if: ${{ inputs.platform == '' || inputs.platform == 'macos' }} + name: ${{ matrix.target.name }} + runs-on: ${{ matrix.target.runs-on }} + timeout-minutes: ${{ matrix.target.timeout }} + + env: + MAC_SIGNING: ${{ secrets.APPLE_CODESIGN_ID != '' }} + + strategy: + fail-fast: false + matrix: + target: + - name: "macos-arm64" + runs-on: macos-15 + timeout: 10 + qt-version: 6.11.1 + # Deployment target is set once in extra/cmake/Synergy.cmake (single + # source of truth); don't pass -DCMAKE_OSX_DEPLOYMENT_TARGET here. + config-args: '-DCMAKE_OSX_ARCHITECTURES="arm64"' + + - name: "macos-x64" + runs-on: macos-15-intel + timeout: 20 + qt-version: 6.9.3 + config-args: '-DCMAKE_OSX_ARCHITECTURES="x86_64" -DCMAKE_OSX_SYSROOT=/Applications/Xcode_16.4.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX.sdk' + + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + fetch-depth: 0 + fetch-tags: true + + - name: Install dependencies + uses: ./.github/actions/install-dependencies + with: + qt-version: ${{ matrix.target.qt-version }} + + - name: Get version + id: get-version + uses: ./.github/actions/get-version + + - name: Configure + run: ${{ env.CMAKE_CONFIGURE }} ${{ matrix.target.config-args }} -DSYNERGY_VERSION_RELEASE=${{ env.SYNERGY_VERSION_RELEASE }} -DSYNERGY_VERSION_SNAPSHOT=${{ env.SYNERGY_VERSION_SNAPSHOT }} -DAPPLE_CODESIGN_ID="${{ secrets.APPLE_CODESIGN_ID }}" + + - name: Build + shell: bash + run: cmake --build build --config Release -j8 + + # Create an ephemeral keychain just for this run and import the Developer + # ID Application certificate from the base64-encoded P12 in secrets. Using + # a per-run keychain (rather than System.keychain) keeps the cert out of + # any shared state on the runner; the keychain is destroyed at job end. + # set-key-partition-list grants codesign passwordless access so the later + # sign step doesn't prompt. + - name: Set up signing keychain + if: env.MAC_SIGNING == 'true' + shell: bash + env: + APPLE_P12_CERTIFICATE: ${{ secrets.APPLE_P12_CERTIFICATE }} + APPLE_P12_PASSWORD: ${{ secrets.APPLE_P12_PASSWORD }} + run: | + keychain="$RUNNER_TEMP/sign.keychain-db" + keychain_pass=$(openssl rand -base64 32) + security create-keychain -p "$keychain_pass" "$keychain" + security set-keychain-settings -lut 3600 "$keychain" + security unlock-keychain -p "$keychain_pass" "$keychain" + security list-keychains -d user -s "$keychain" $(security list-keychains -d user | tr -d ' "') + cert_file="$RUNNER_TEMP/cert.p12" + echo "$APPLE_P12_CERTIFICATE" | base64 --decode > "$cert_file" + security import "$cert_file" -k "$keychain" -P "$APPLE_P12_PASSWORD" -T /usr/bin/codesign + security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$keychain_pass" "$keychain" + rm -f "$cert_file" + echo "SIGN_KEYCHAIN=$keychain" >> "$GITHUB_ENV" + echo "SIGN_KEYCHAIN_PASS=$keychain_pass" >> "$GITHUB_ENV" + + # CPack runs macdeployqt and, when a Developer ID is configured, codesigns + # the bundle that gets embedded in the DMG (see deploy/mac/deploy.cmake), so + # the keychain must be unlocked first. CPack on macOS is flaky; retry a + # handful of times before failing. + - name: Package + shell: bash + run: | + if [ -n "$SIGN_KEYCHAIN" ]; then + security unlock-keychain -p "$SIGN_KEYCHAIN_PASS" "$SIGN_KEYCHAIN" + fi + for i in $(seq 1 5); do + if cmake --build build --config Release -j8 --target package 2>&1; then + echo "Package successful" + exit 0 + fi + echo "Package attempt $i failed" + sleep 1 + done + echo "ERROR: package failed after 5 attempts" >&2 + exit 1 + + # Sign the DMG itself so Gatekeeper trusts it before extraction. Then + # submit to notarytool (synchronous via --wait) and staple the returned + # ticket so Gatekeeper can verify offline. + - name: Sign + notarize + staple DMG + if: env.MAC_SIGNING == 'true' + shell: bash + env: + APPLE_CODESIGN_ID: ${{ secrets.APPLE_CODESIGN_ID }} + APPLE_NOTARY_USER: ${{ secrets.APPLE_NOTARY_USER }} + APPLE_NOTARY_PASSWORD: ${{ secrets.APPLE_NOTARY_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + run: | + dmg=$(find build -maxdepth 2 -name "*.dmg" -type f | head -n1) + if [ -z "$dmg" ]; then + echo "ERROR: no DMG found under build/" >&2 + find build -maxdepth 2 -name "*.dmg" 2>/dev/null + exit 1 + fi + echo "Found DMG: $dmg" + + security unlock-keychain -p "$SIGN_KEYCHAIN_PASS" "$SIGN_KEYCHAIN" + /usr/bin/codesign --force --timestamp --sign "$APPLE_CODESIGN_ID" "$dmg" + + result=$(xcrun notarytool submit "$dmg" \ + --apple-id "$APPLE_NOTARY_USER" \ + --password "$APPLE_NOTARY_PASSWORD" \ + --team-id "$APPLE_TEAM_ID" \ + --output-format json --wait) + echo "$result" + id=$(echo "$result" | python3 -c "import sys, json; print(json.load(sys.stdin)['id'])") + status=$(echo "$result" | python3 -c "import sys, json; print(json.load(sys.stdin)['status'])") + if [ "$status" != "Accepted" ]; then + echo "Notarization $status; per-file detail follows:" >&2 + xcrun notarytool log "$id" \ + --apple-id "$APPLE_NOTARY_USER" \ + --password "$APPLE_NOTARY_PASSWORD" \ + --team-id "$APPLE_TEAM_ID" + exit 1 + fi + + xcrun stapler staple "$dmg" + xcrun stapler validate "$dmg" + spctl -a -t open --context context:primary-signature -v "$dmg" + echo "DMG signed + notarized + stapled: $dmg" + + - name: Check for unexpected repo changes + shell: bash + run: | + if [[ `git status --porcelain` ]]; then + echo "Unexpected changes to the repo, Often caused by forgetting to commit the updated translation files" + exit 1 + fi + + - name: Tests + uses: ./.github/actions/run-tests + timeout-minutes: 2 + + - name: Test package + uses: ./.github/actions/test-package + + - name: Upload + uses: actions/upload-artifact@v7 + with: + name: package-${{ env.PACKAGE_PREFIX }}-${{ steps.get-version.outputs.version }}-${{ matrix.target.name }} + path: ${{ github.workspace }}/build/${{ env.PACKAGE_PREFIX }}[-_]*.* + + build-linux: + needs: lint-clang + if: ${{ inputs.platform == '' || inputs.platform == 'linux' }} + name: ${{ matrix.target.name }} + runs-on: ${{ matrix.target.runs-on }} + container: ${{ matrix.target.container }} + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + target: + - name: "debian-x86_64" + runs-on: ubuntu-latest + container: debian:stable-slim + like: "debian" + config-args: "-G Ninja -DCMAKE_INSTALL_PREFIX=/usr" + + - name: "debian-arm64" + runs-on: ubuntu-24.04-arm + container: debian:stable-slim + like: "debian" + config-args: "-G Ninja -DCMAKE_INSTALL_PREFIX=/usr" + + - name: "debian-testing-x86_64" + runs-on: ubuntu-latest + container: debian:testing-slim + like: "debian" + config-args: "-G Ninja -DCMAKE_INSTALL_PREFIX=/usr" + + - name: "debian-testing-arm64" + runs-on: ubuntu-24.04-arm + container: debian:testing-slim + like: "debian" + config-args: "-G Ninja -DCMAKE_INSTALL_PREFIX=/usr" + + - name: "fedora-42-x86_64" + runs-on: ubuntu-latest + container: fedora:42 + like: "fedora" + config-args: "-G Ninja -DCMAKE_INSTALL_PREFIX=/usr" + + - name: "fedora-42-arm64" + runs-on: ubuntu-24.04-arm + container: fedora:42 + like: "fedora" + config-args: "-G Ninja -DCMAKE_INSTALL_PREFIX=/usr" + + - name: "fedora-43-x86_64" + runs-on: ubuntu-latest + container: fedora:43 + like: "fedora" + config-args: "-G Ninja -DCMAKE_INSTALL_PREFIX=/usr" + + - name: "fedora-43-arm64" + runs-on: ubuntu-24.04-arm + container: fedora:43 + like: "fedora" + config-args: "-G Ninja -DCMAKE_INSTALL_PREFIX=/usr" + + - name: "fedora-44-x86_64" + runs-on: ubuntu-latest + container: fedora:44 + like: "fedora" + config-args: "-G Ninja -DCMAKE_INSTALL_PREFIX=/usr" + + - name: "fedora-44-arm64" + runs-on: ubuntu-24.04-arm + container: fedora:44 + like: "fedora" + config-args: "-G Ninja -DCMAKE_INSTALL_PREFIX=/usr" + + - name: "opensuse-x86_64" + runs-on: ubuntu-latest + container: opensuse/tumbleweed:latest + like: "suse" + config-args: "-G Ninja -DCMAKE_INSTALL_PREFIX=/usr" + + - name: "opensuse-arm64" + runs-on: ubuntu-24.04-arm + container: opensuse/tumbleweed:latest + like: "suse" + config-args: "-G Ninja -DCMAKE_INSTALL_PREFIX=/usr" + + - name: "archlinux-x86_64" + runs-on: ubuntu-latest + container: archlinux:latest + like: "arch" + config-args: "-G Ninja -DCMAKE_INSTALL_PREFIX=/usr -DBUILD_DEV_DOCS=ON" + + - name: "ubuntu-24.04-x86_64" + runs-on: ubuntu-latest + container: ubuntu:24.04 + like: "debian" + config-args: "-G Ninja -DCMAKE_INSTALL_PREFIX=/usr" + + - name: "ubuntu-24.04-arm64" + runs-on: ubuntu-24.04-arm + container: ubuntu:24.04 + like: "debian" + config-args: "-G Ninja -DCMAKE_INSTALL_PREFIX=/usr" + + - name: "ubuntu-26.04-x86_64" + runs-on: ubuntu-latest + container: ubuntu:26.04 + like: "debian" + config-args: "-G Ninja -DCMAKE_INSTALL_PREFIX=/usr" + + - name: "ubuntu-26.04-arm64" + runs-on: ubuntu-24.04-arm + container: ubuntu:26.04 + like: "debian" + config-args: "-G Ninja -DCMAKE_INSTALL_PREFIX=/usr" + + steps: + # Containers don't ship git; everything else needs it for checkout + describe. + - name: Install Git on Container + shell: bash + run: | + if [ "${{ matrix.target.like }}" == "debian" ]; then + apt update -qqq > /dev/null && apt install -qqq git devscripts > /dev/null + elif [ "${{ matrix.target.like }}" == "fedora" ]; then + dnf install -y git + elif [ "${{ matrix.target.like }}" == "suse" ]; then + zypper refresh + zypper install -y --force-resolution git + elif [ "${{ matrix.target.like }}" == "arch" ]; then + pacman -Syu --noconfirm git + else + echo "Unknown: ${{ matrix.target.like }}" + fi + + - name: Checkout + uses: actions/checkout@v6 + with: + fetch-depth: 0 + fetch-tags: true + + - name: Install dependencies + uses: ./.github/actions/install-dependencies + with: + like: ${{ matrix.target.like }} + + # 24.04's system libportal (0.7.x) lacks the input-capture portal the code needs + # (>= 0.8.0), and that LTS's libs are frozen. Build a newer libportal and static-link + # it so the shipped binary carries no runtime dependency on a >= 0.8 system libportal + # (libei from the system is fine). The code uses no xdp_parent, so no GUI backend is + # needed, and removing the system .so makes `-lportal` resolve to the static archive. + - name: Build newer libportal (Ubuntu 24.04) + if: startsWith(matrix.target.name, 'ubuntu-24.04') + shell: bash + run: | + apt-get install -y -qq meson pkg-config + git clone --depth 1 --branch 0.9.1 https://github.com/flatpak/libportal.git /tmp/libportal + meson setup /tmp/libportal/_build /tmp/libportal --prefix=/usr --libdir=lib/$(gcc -dumpmachine) \ + --default-library=static \ + -Dbackend-gtk3=disabled -Dbackend-gtk4=disabled -Dbackend-qt5=disabled -Dbackend-qt6=disabled \ + -Ddocs=false -Dintrospection=false -Dvapi=false -Dtests=false + meson install -C /tmp/libportal/_build + rm -f /usr/lib/$(gcc -dumpmachine)/libportal.so* + ldconfig + + - name: Get version + id: get-version + uses: ./.github/actions/get-version + + - name: Configure + run: ${{ env.CMAKE_CONFIGURE }} ${{ matrix.target.config-args }} -DSYNERGY_VERSION_RELEASE=${{ env.SYNERGY_VERSION_RELEASE }} -DSYNERGY_VERSION_SNAPSHOT=${{ env.SYNERGY_VERSION_SNAPSHOT }} + + - name: Build + shell: bash + run: cmake --build build --config Release -j8 + + - name: Package + shell: bash + run: | + if [[ "${{ matrix.target.like }}" == "arch" ]]; then + useradd -m build + sudo chown -R build build + cd build + sudo -u build makepkg -s + export OSNAME=$(cat /etc/os-release | grep ^ID= | sed 's/ID=//g') + [ "$OSNAME" = "arch" ] && OSNAME="arch-linux" + export ARCH=$(uname -m) + mv *.pkg.tar.zst ${{ env.PACKAGE_PREFIX }}_${{ steps.get-version.outputs.version }}_${OSNAME}_${ARCH}.pkg.tar.zst + cd .. + else + cmake --build build --config Release -j8 --target package + fi + + - name: Check for unexpected repo changes + shell: bash + run: | + if [[ `git status --porcelain` ]]; then + echo "Unexpected changes to the repo, Often caused by forgetting to commit the updated translation files" + exit 1 + fi + + - name: Tests + uses: ./.github/actions/run-tests + timeout-minutes: 2 + + - name: Test package + uses: ./.github/actions/test-package + with: + like: ${{ matrix.target.like }} + + - name: Update Development Documentation + if: matrix.target.like == 'arch' && github.ref == 'refs/heads/beta' + uses: JamesIves/github-pages-deploy-action@v4.7.3 + with: + branch: gh-pages + folder: build/docs/dev/html + + - name: Upload + uses: actions/upload-artifact@v7 + with: + name: package-${{ env.PACKAGE_PREFIX }}-${{ steps.get-version.outputs.version }}-${{ matrix.target.name }} + path: ${{ github.workspace }}/build/${{ env.PACKAGE_PREFIX }}[-_]*.* + + build-flatpak: + needs: lint-clang + if: ${{ inputs.platform == '' || inputs.platform == 'linux' }} + name: flatpak-${{matrix.flatpak.arch}} + runs-on: ${{matrix.flatpak.runs-on}} + timeout-minutes: 60 + container: + image: ghcr.io/flathub-infra/flatpak-github-actions:kde-6.10 + options: --privileged + strategy: + fail-fast: false + matrix: + flatpak: + - runs-on: ubuntu-latest + arch: x86_64 + - runs-on: ubuntu-24.04-arm + arch: aarch64 + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + fetch-depth: 0 + fetch-tags: true + + - run: git config --global protocol.file.allow always + + - name: Get version + id: get-version + uses: ./.github/actions/get-version + + - name: Lint appsteam + run: flatpak-builder-lint appstream extra/deploy/linux/com.symless.synergy.metainfo.xml + + - name: Lint manifest + run: flatpak-builder-lint manifest extra/deploy/linux/flatpak/com.symless.synergy.yml + + - name: Build + uses: flatpak/flatpak-github-actions/flatpak-builder@v6 + with: + bundle: ${{ env.PACKAGE_PREFIX }}_${{ steps.get-version.outputs.version }}_linux_${{ matrix.flatpak.arch }}.flatpak + manifest-path: extra/deploy/linux/flatpak/com.symless.synergy.yml + cache-key: flatpak-builder-${{matrix.flatpak.arch}}-2.0 + arch: ${{matrix.flatpak.arch}} + upload-artifact: false + + - name: Validate build + run: flatpak-builder-lint --exceptions --user-exceptions extra/deploy/linux/flatpak/ci-build-lint-exceptions.json repo repo + + - name: Upload + uses: actions/upload-artifact@v7 + with: + name: package-${{ env.PACKAGE_PREFIX }}-${{ steps.get-version.outputs.version }}-flatpak-${{matrix.flatpak.arch}} + path: ${{github.workspace}}/${{ env.PACKAGE_PREFIX }}[-_]*.flatpak + + # Upload all built packages to AWS S3 for distribution. Runs after the gate + # (ci-passed) on push/dispatch/release events; PRs are skipped (no secrets). + # Layout: s3://////... + s3-upload: + needs: ci-passed + if: ${{ always() && needs.ci-passed.result == 'success' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch' || github.event_name == 'release') }} + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + fetch-depth: 0 + fetch-tags: true + + - name: Get version + id: get-version + uses: ./.github/actions/get-version + + - name: Download all artifacts + uses: actions/download-artifact@v8 + with: + pattern: package-* + path: dist + merge-multiple: true + + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v4 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: us-east-2 + + - name: Upload to AWS S3 + shell: bash + run: | + version="${{ steps.get-version.outputs.version }}" + s3_uri="${{ inputs.aws-s3-uri || format('s3://{0}/{1}', vars.AWS_S3_BUCKET, vars.AWS_S3_UPLOAD_DIR) }}" + s3_uri="${s3_uri%/}" + + if [[ "${{ env.SYNERGY_VERSION_RELEASE }}" == "true" ]]; then + sub_dir="release" + elif [[ "${{ env.SYNERGY_VERSION_SNAPSHOT }}" == "true" ]]; then + # Organize snapshots by major.minor.patch for easier navigation. + short_version=$(echo "$version" | cut -d'-' -f1 | cut -d'+' -f1) + sub_dir="snapshot/$short_version" + else + sub_dir="dev" + fi + + url="$s3_uri/$sub_dir/$version" + echo "Uploading to AWS S3: $url" + aws s3 cp ./dist/ "$url" --recursive + + report: + needs: [build-windows, build-macos, build-linux, build-flatpak, s3-upload] + if: ${{ !cancelled() && github.event_name != 'pull_request' && contains(needs.*.result, 'failure') }} + runs-on: ubuntu-latest + timeout-minutes: 2 + + steps: + - name: Build Slack message + id: message + shell: bash + run: | + link="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" + { + echo "text<" + echo "❌ Synergy CI failed" + [ "${{ needs.build-windows.result }}" = "failure" ] && echo "• Windows build failed" + [ "${{ needs.build-macos.result }}" = "failure" ] && echo "• macOS build failed" + [ "${{ needs.build-linux.result }}" = "failure" ] && echo "• Linux build failed" + [ "${{ needs.build-flatpak.result }}" = "failure" ] && echo "• Flatpak build failed" + [ "${{ needs.s3-upload.result }}" = "failure" ] && echo "• S3 upload failed" + echo "<$link|View workflow>" + echo "EOF" + } >> $GITHUB_OUTPUT + + - name: Send Slack notification + uses: slackapi/slack-github-action@v3 + with: + method: chat.postMessage + token: ${{ secrets.SLACK_TOKEN }} + payload: | + channel: ${{ secrets.SLACK_NOTIFY_CHANNEL_ID }} + text: ${{ toJSON(steps.message.outputs.text) }} diff --git a/.github/workflows/continuous-integration.yml b/.github/workflows/continuous-integration.yml deleted file mode 100644 index a8b98d8c9..000000000 --- a/.github/workflows/continuous-integration.yml +++ /dev/null @@ -1,467 +0,0 @@ -# All-in-one continuous integration (CI) workflow. -# Runs on all platforms (Windows, macOS, and Linux) -# for all events (pull request, release, and schedule). - -name: Continuous Integration - -on: - push: - branches: [master] - tags: - - "v*" - pull_request: - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -env: - GIT_SHA: ${{ github.event.pull_request.head.sha || github.sha }} - PACKAGE_PREFIX: "deskflow" - PACKAGE_PATH: ./dist - CMAKE_CONFIGURE: "cmake -Bbuild -DCMAKE_BUILD_TYPE=Release -DSKIP_BUILD_TESTS=ON -DCMAKE_COMPILE_WARNING_AS_ERROR=ON" - -jobs: - # Quality gate to allow PR merge, used in the branch protection rules. - ci-passed: - runs-on: ubuntu-latest - needs: [main-build, test-results, unix, flatpak] - if: always() - - steps: - - name: Check all steps passed - run: | - if [[ ${{needs.main-build.result}} == 'success' && ${{needs.test-results.result}} == 'success' && ${{needs.unix.result}} == 'success' && ${{needs.flatpak.result}} == 'success' ]]; then - echo "✅ CI passed" > $GITHUB_STEP_SUMMARY - else - exit 1 - fi - - # Summary of test results, combined from test result artifacts. - # Runs even if the tests fail to provide a summary of the failures. - test-results: - needs: main-build - if: always() && needs.main-build.result != 'skipped' - - runs-on: ubuntu-slim - timeout-minutes: 5 - - steps: - - name: Checkout - uses: actions/checkout@v6 - - - name: Test summary - uses: ./.github/actions/test-summary - - lint-reuse: - runs-on: ubuntu-latest - timeout-minutes: 5 - - steps: - - uses: actions/checkout@v6 - - - name: REUSE Compliance Check - uses: fsfe/reuse-action@v5 - - lint-clang: - needs: [lint-reuse] - runs-on: ubuntu-slim - timeout-minutes: 5 - - steps: - - name: Checkout - uses: actions/checkout@v6 - - - name: Lint Checker - uses: ./.github/actions/lint-clang - - analyze-valgrind: - needs: lint-clang - if: ${{ github.event_name == 'pull_request' }} - uses: ./.github/workflows/valgrind-analysis.yml - - main-build: - needs: lint-clang - name: ${{ matrix.target.name }} - runs-on: ${{ matrix.target.runs-on }} - container: ${{ matrix.target.container }} - timeout-minutes: ${{ matrix.target.timeout }} - strategy: - # Normally, we want to fail fast, but in this case we shouldn't since one target may - # fail due to transient issues unrelated to the build. - fail-fast: false - - matrix: - target: - - name: "windows-2022-x64" - runs-on: "windows-2022" - timeout: 30 - config-args: "-G Ninja" - vcpkg-triplet: x64-windows-release - arch: "amd64" - qt-version: 6.10.3 - - - name: "windows-2022-arm64" - runs-on: "windows-11-arm" - timeout: 30 - config-args: "-G Ninja" - vcpkg-triplet: arm64-windows - arch: "arm64" - qt-version: 6.11.1 - - - name: "macos-arm64" - runs-on: macos-15 - timeout: 10 - qt-version: 6.11.1 - config-args: '-DCMAKE_OSX_ARCHITECTURES="arm64" -DCMAKE_OSX_DEPLOYMENT_TARGET=14' - - - name: "macos-x64" - runs-on: macos-15-intel - timeout: 20 - qt-version: 6.9.3 - config-args: '-DCMAKE_OSX_ARCHITECTURES="x86_64" -DCMAKE_OSX_DEPLOYMENT_TARGET=12 -DCMAKE_OSX_SYSROOT=/Applications/Xcode_16.4.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX.sdk' - - - name: "debian-x86_64" - runs-on: ubuntu-latest - container: debian:stable-slim - like: "debian" - timeout: 20 - config-args: "-G Ninja -DCMAKE_INSTALL_PREFIX=/usr" - - - name: "debian-arm64" - runs-on: ubuntu-24.04-arm - container: debian:stable-slim - like: "debian" - timeout: 20 - config-args: "-G Ninja -DCMAKE_INSTALL_PREFIX=/usr" - - - name: "debian-testing-x86_64" - runs-on: ubuntu-latest - container: debian:testing-slim - like: "debian" - timeout: 20 - config-args: "-G Ninja -DCMAKE_INSTALL_PREFIX=/usr" - - - name: "debian-testing-arm64" - runs-on: ubuntu-24.04-arm - container: debian:testing-slim - like: "debian" - timeout: 20 - config-args: "-G Ninja -DCMAKE_INSTALL_PREFIX=/usr" - - - name: "fedora-43-x86_64" - runs-on: ubuntu-latest - container: fedora:43 - like: "fedora" - timeout: 20 - config-args: "-G Ninja -DCMAKE_INSTALL_PREFIX=/usr" - - - name: "fedora-43-arm64" - runs-on: ubuntu-24.04-arm - container: fedora:43 - like: "fedora" - timeout: 20 - config-args: "-G Ninja -DCMAKE_INSTALL_PREFIX=/usr" - - - name: "fedora-44-x86_64" - runs-on: ubuntu-latest - container: fedora:44 - like: "fedora" - timeout: 20 - config-args: "-G Ninja -DCMAKE_INSTALL_PREFIX=/usr" - - - name: "fedora-44-arm64" - runs-on: ubuntu-24.04-arm - container: fedora:44 - like: "fedora" - timeout: 20 - config-args: "-G Ninja -DCMAKE_INSTALL_PREFIX=/usr" - - - name: "opensuse-x86_64" - runs-on: ubuntu-latest - container: opensuse/tumbleweed:latest - like: "suse" - timeout: 20 - config-args: "-G Ninja -DCMAKE_INSTALL_PREFIX=/usr" - - - name: "opensuse-arm64" - runs-on: ubuntu-24.04-arm - container: opensuse/tumbleweed:latest - like: "suse" - timeout: 20 - config-args: "-G Ninja -DCMAKE_INSTALL_PREFIX=/usr" - - - name: "archlinux-x86_64" - runs-on: ubuntu-latest - container: archlinux:latest - like: "arch" - timeout: 20 - config-args: "-G Ninja -DCMAKE_INSTALL_PREFIX=/usr -DBUILD_DEV_DOCS=ON" - - - name: "ubuntu-26.04-x86_64" - runs-on: ubuntu-latest - container: ubuntu:26.04 - like: "debian" - timeout: 20 - config-args: "-G Ninja -DCMAKE_INSTALL_PREFIX=/usr" - - - name: "ubuntu-26.04-arm64" - runs-on: ubuntu-24.04-arm - container: ubuntu:26.04 - like: "debian" - timeout: 20 - config-args: "-G Ninja -DCMAKE_INSTALL_PREFIX=/usr" - - steps: - # Make sure the container has git before we do anything else - - name: Install Git on Container - if: ${{ matrix.target.container }} - shell: bash - run: | - if [ "${{matrix.target.like}}" == "debian" ]; then - apt update -qqq > /dev/null && apt install -qqq git devscripts > /dev/null - elif [ "${{matrix.target.like}}" == "fedora" ]; then - dnf install -y git - elif [ "${{matrix.target.like}}" == "suse" ]; then - zypper refresh - zypper install -y --force-resolution git - elif [ "${{matrix.target.like}}" == "arch" ]; then - pacman -Syu --noconfirm git - else - echo "Unknown: ${{matrix.target.like}}" - fi - # Fancy checkout gets all the tags - # it also makes sure we can use git --describe correctly - - name: Fancy Checkout - uses: sithlord48/fancy-checkout@v2 - - # This effectively runs `vcvarsall.bat`, etc. It's not actually installing - # VC++ as that's already pre-installed on the Windows runner. - - name: Setup VC++ environment - if: ${{ runner.os == 'Windows' }} - uses: TheMrMilchmann/setup-msvc-dev@v4 - with: - arch: ${{matrix.target.arch}} - spectre: true - - - name: Install dependencies - id: get-deps - uses: ./.github/actions/install-dependencies - with: - qt-version: ${{matrix.target.qt-version}} - vcpkg-triplet: ${{matrix.target.vcpkg-triplet}} - like: ${{ matrix.target.like }} - - - name: Get version - uses: ./.github/actions/get-version - - - name: Update Windows Paths - if: (runner.os == 'Windows') - shell: pwsh - run: echo "C:\Program Files\doxygen\bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append - - - name: Configure - run: ${{env.CMAKE_CONFIGURE}} ${{ matrix.target.config-args }} ${{ steps.get-deps.outputs.vcpkg-cmake-config }} -DPACKAGE_VERSION_LABEL="${{env.DESKFLOW_PACKAGE_VERSION}}" - - - name: Build - shell: bash - run: | - if [[ "${{matrix.target.like}}" != "arch" ]]; then - if [ "$RUNNER_OS" != "macOS" ]; then - cmake --build build --config Release -j8 --target package - else - cmake --build build --config Release -j8 - for i in $(seq 1 5); do - cmake --build build --config Release -j8 --target package 2>&1 - if [ $? -eq 0 ]; then - echo "Package successful" - break - else - echo "Package attempt $i failed" - sleep 1 - fi - done - fi - else - cmake --build build --config Release -j8 - useradd -m build - sudo chown -R build build - cd build - sudo -u build makepkg -s - export OSNAME=$(cat /etc/os-release | grep ^ID= | sed 's/ID=//g') - export ARCH=$(uname -m) - mv *.pkg.tar.zst deskflow-${{env.DESKFLOW_PACKAGE_VERSION}}-${OSNAME}-${ARCH}.pkg.tar.zst - cd .. - fi - - - name: Check for unexpected repo changes - shell: bash - run: | - if [[ `git status --porcelain` ]]; then - echo "Unexpected changes to the repo, Often caused by forgetting to commit the updated translation files" - exit 1 - fi - - - name: Tests - uses: ./.github/actions/run-tests - timeout-minutes: 2 - with: - job: ${{ matrix.target.name }} - - - name: Test package - uses: ./.github/actions/test-package - with: - like: ${{ matrix.target.like }} - - - name: Update Development Documentation - if: matrix.target.like == 'arch' && github.ref == 'refs/heads/master' - uses: JamesIves/github-pages-deploy-action@v4.7.3 - with: - branch: gh-pages - folder: build/docs/dev/html - - - name: Upload - uses: actions/upload-artifact@v7 - with: - name: package-${{ env.PACKAGE_PREFIX }}-${{ matrix.target.name }} - path: ${{github.workspace}}/build/deskflow[-_]*.* - - # Technically, "unix" is a misnomer, but we use it here to mean "Unix-like BSD-derived". - unix: - needs: lint-clang - name: unix-${{ matrix.distro.name }} - runs-on: ${{ vars.CI_UNIX_RUNNER || 'ubuntu-24.04' }} - timeout-minutes: 20 - - strategy: - fail-fast: false - - matrix: - distro: - - name: freebsd - - steps: - # Fancy checkout gets all the tags - # it also makes sure we can use git --describe correctly - - name: Fancy Checkout - uses: sithlord48/fancy-checkout@v2 - - name: Build on FreeBSD - if: ${{ matrix.distro.name == 'freebsd' }} - uses: vmactions/freebsd-vm@v1 - with: - usesh: true - - # Workaround for unreliable pkg: Pin to a stable release. - # vmactions/freebsd-vm@v1 gets latest FreeBSD, which sometimes has a broken ports catalog. - release: "14.4" - - run: | - pkg install -y cmake ninja gmake gcc12 openssl glib \ - libX11 libXtst libxkbfile qt6-base qt6-tools gtk3 \ - googletest pkgconf libei libportal doxygen - ${{env.CMAKE_CONFIGURE}} -G Ninja - cmake --build build -j16 - flatpak: - needs: lint-clang - name: flatpak-${{matrix.flatpak.arch}} - runs-on: ${{matrix.flatpak.runs-on}} - timeout-minutes: 60 - container: - image: ghcr.io/flathub-infra/flatpak-github-actions:kde-6.10 - options: --privileged - strategy: - fail-fast: false - matrix: - flatpak: - - runs-on: ubuntu-latest - arch: x86_64 - - runs-on: ubuntu-24.04-arm - arch: aarch64 - steps: - - name: Check out repository - uses: sithlord48/fancy-checkout@v2 - - - run: git config --global protocol.file.allow always - - - name: Get version - uses: ./.github/actions/get-version - - - name: Lint appsteam - run: flatpak-builder-lint appstream deploy/linux/org.deskflow.deskflow.metainfo.xml - - - name: Lint manifest - run: flatpak-builder-lint manifest deploy/linux/flatpak/org.deskflow.deskflow.yml - - - name: Build - uses: flatpak/flatpak-github-actions/flatpak-builder@v6 - with: - bundle: deskflow-${{env.DESKFLOW_PACKAGE_VERSION}}-linux-${{matrix.flatpak.arch}}.flatpak - manifest-path: deploy/linux/flatpak/org.deskflow.deskflow.yml - cache-key: flatpak-builder-${{matrix.flatpak.arch}}-2.0 - arch: ${{matrix.flatpak.arch}} - upload-artifact: false - - - name: Validate build - run: flatpak-builder-lint --exceptions --user-exceptions deploy/linux/flatpak/ci-build-lint-exceptions.json repo repo - - - name: Upload - uses: actions/upload-artifact@v7 - with: - name: package-${{ env.PACKAGE_PREFIX }}-flatpak-${{matrix.flatpak.arch}} - path: ${{github.workspace}}/deskflow[-_]*.flatpak - - release: - needs: ci-passed - if: (github.ref == 'refs/heads/master') || (contains(github.ref, '/tags/v')) - runs-on: ubuntu-latest - - steps: - - name: Fancy Checkout - uses: sithlord48/fancy-checkout@v2 - - - name: Get version - uses: ./.github/actions/get-version - - - name: Download artifacts - uses: actions/download-artifact@v8 - - - name: Generate package checksums - id: generate_notes - shell: bash - run: | - mv $GITHUB_WORKSPACE/package-*/deskflow-* . - rm -rf $GITHUB_WORKSPACE/package-* - echo "Build: ${{env.DESKFLOW_VERSION }}" > sums.txt - sha256sum deskflow-* >> sums.txt - - - name: Deploy continuous - if: (github.ref == 'refs/heads/master') && !(contains(github.ref, '/tags/v')) - uses: crowbarmaster/GH-Automatic-Releases@latest - with: - repo_token: "${{ secrets.GITHUB_TOKEN }}" - automatic_release_tag: "continuous" - prerelease: true - title: "Continuous v${{env.DESKFLOW_VERSION}}" - files: | - deskflow-* - sums.txt - - - name: Deploy release - if: contains(github.ref, '/tags/v') - uses: crowbarmaster/GH-Automatic-Releases@latest - with: - repo_token: "${{ secrets.GITHUB_TOKEN }}" - prerelease: false - files: | - deskflow-* - sums.txt - - - name: Update Homebrewtap - shell: bash - run: | - curl -L -X POST \ - -H "Accept: application/vnd.github+json" \ - -H "Authorization: Bearer ${{ secrets.DF_TAP_TOKEN }}" \ - -H "X-GitHub-Api-Version: 2022-11-28" \ - https://api.github.com/repos/deskflow/homebrew-tap/dispatches \ - -d '{"event_type":"update_tap"}' diff --git a/.gitignore b/.gitignore index e10ad34f5..a7da0a026 100644 --- a/.gitignore +++ b/.gitignore @@ -17,11 +17,12 @@ Brewfile.lock.json # typical developer-created files deskflow-config.toml +/Synergy.test.conf .vscode .DS_Store *.code-workspace .env* -/scripts/*.egg-info +/scripts /*.user *.ui.autosave @@ -40,12 +41,6 @@ CMakeCache.txt CMakeUserPresets.json CMakeFiles/* -# vscode folder -/.vscode - -# scripts folder -/scripts +# AI +/.claude -# Ai helperfilers -**/[cC]laude.[mM][dD] -**/CLAUDE.[mM][dD] diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 000000000..0c264114d --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,9 @@ +{ + "recommendations": [ + "ms-vscode.cmake-tools", + "llvm-vs-code-extensions.vscode-clangd", + "ms-vscode.cpptools", + "llvm-vs-code-extensions.lldb-dap", + "jacqueslucke.gcov-viewer" + ] +} diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 000000000..db58957e2 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,155 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "unix - gui", + "type": "lldb-dap", + "cwd": "${workspaceRoot}", + "request": "launch", + "program": "${workspaceFolder}/build/bin/synergy", + "preLaunchTask": "build-silent", + "initCommands": ["settings set target.preload-symbols false"], + "osx": { + "program": "${workspaceFolder}/build/bin/synergy.app/Contents/MacOS/synergy" + } + }, + { + "name": "unix - unittests", + "type": "lldb-dap", + "cwd": "${workspaceRoot}", + "request": "launch", + "program": "${workspaceFolder}/build/bin/unittests", + "args": ["${input:gtest-args}"], + "preLaunchTask": "build-silent" + }, + { + "name": "unix - integtests", + "type": "lldb-dap", + "cwd": "${workspaceRoot}", + "request": "launch", + "program": "${workspaceFolder}/build/bin/integtests", + "args": ["${input:gtest-args}"], + "preLaunchTask": "build-silent" + }, + { + "name": "unix - server", + "type": "lldb-dap", + "cwd": "${workspaceRoot}", + "request": "launch", + "program": "${workspaceFolder}/build/bin/synergy-core", + "args": ["server"], + "preLaunchTask": "build-silent", + "osx": { + "program": "${workspaceFolder}/build/bin/synergy.app/Contents/MacOS/synergy-core" + } + }, + { + "name": "unix - client", + "type": "lldb-dap", + "cwd": "${workspaceRoot}", + "request": "launch", + "program": "${workspaceFolder}/build/bin/synergy-core", + "args": ["client"], + "preLaunchTask": "build-silent", + "osx": { + "program": "${workspaceFolder}/build/bin/synergy.app/Contents/MacOS/synergy-core" + } + }, + { + "name": "unix - daemon", + "type": "lldb-dap", + "cwd": "${workspaceRoot}", + "request": "launch", + "program": "${workspaceFolder}/build/bin/synergy-daemon", + "args": ["-f"], + "preLaunchTask": "build-silent" + }, + { + "name": "unix - attach", + "type": "lldb-dap", + "request": "attach", + "pid": "${command:pickProcess}" + }, + { + "name": "windows - gui", + "type": "cppvsdbg", + "cwd": "${workspaceRoot}", + "request": "launch", + "program": "${workspaceFolder}/build/bin-copy/synergy", + "internalConsoleOptions": "openOnSessionStart", + "preLaunchTask": "build-kill-gui" + }, + { + "name": "windows - unittests", + "type": "cppvsdbg", + "cwd": "${workspaceRoot}", + "request": "launch", + "program": "${workspaceFolder}/build/bin-copy/unittests", + "args": ["${input:gtest-args}"], + "internalConsoleOptions": "openOnSessionStart" + }, + { + "name": "windows - integtests", + "type": "cppvsdbg", + "cwd": "${workspaceRoot}", + "request": "launch", + "program": "${workspaceFolder}/build/bin-copy/integtests", + "args": ["${input:gtest-args}"], + "internalConsoleOptions": "openOnSessionStart" + }, + { + "name": "windows - server", + "type": "cppvsdbg", + "cwd": "${workspaceRoot}", + "request": "launch", + "program": "${workspaceFolder}/build/bin-copy/synergy-core", + "args": ["server"], + "internalConsoleOptions": "openOnSessionStart", + "preLaunchTask": "build-kill-core" + }, + { + "name": "windows - client", + "type": "cppvsdbg", + "cwd": "${workspaceRoot}", + "request": "launch", + "program": "${workspaceFolder}/build/bin-copy/synergy-core", + "args": ["client"], + "internalConsoleOptions": "openOnSessionStart", + "preLaunchTask": "build-kill-core" + }, + { + "name": "windows - active-desktop", + "type": "cppvsdbg", + "cwd": "${workspaceRoot}", + "request": "launch", + "program": "${workspaceFolder}/build/bin-copy/synergy-core", + "args": ["--active-desktop"], + "internalConsoleOptions": "openOnSessionStart", + "preLaunchTask": "build-kill-core" + }, + { + "name": "windows - daemon", + "type": "cppvsdbg", + "cwd": "${workspaceRoot}", + "request": "launch", + "program": "${workspaceFolder}/build/bin-copy/synergy-daemon", + "args": ["-f"], + "internalConsoleOptions": "openOnSessionStart", + "preLaunchTask": "build-kill-core" + }, + { + "name": "windows - attach", + "type": "cppvsdbg", + "request": "attach", + "processId": "${command:pickProcess}" + } + ], + "inputs": [ + { + "id": "gtest-args", + "type": "promptString", + "description": "Test arguments", + "default": "--gtest_filter=*" + } + ] +} diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 000000000..7bb778ada --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,201 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "build", + "type": "shell", + "dependsOn": ["build-cmake"], + "windows": { + "command": "robocopy ${workspaceFolder}/build/bin ${workspaceFolder}/build/bin-copy /R:0 /E /S /NJS /NJH /NFL /NDL; exit 0" + }, + "group": { + "kind": "build", + "isDefault": true + } + }, + { + "label": "build-cmake", + "type": "cmake", + "command": "build", + "targets": ["all"], + "preset": "${command:cmake.activeBuildPresetName}", + "group": "build", + "problemMatcher": { + "base": "$gcc", + "fileLocation": ["absolute"] + } + }, + { + "label": "build-kill-core", + "dependsOn": ["kill-daemon", "kill-core", "build"], + "dependsOrder": "sequence" + }, + { + "label": "build-kill-gui", + "dependsOn": ["kill-gui", "kill-core", "build"], + "dependsOrder": "sequence" + }, + { + "label": "build-silent", + "type": "shell", + "dependsOn": ["build"], + "group": "build", + "presentation": { + "reveal": "silent" + } + }, + { + "label": "clean", + "type": "cmake", + "command": "build", + "targets": ["clean"], + "preset": "${command:cmake.activeBuildPresetName}", + "group": "build" + }, + { + "label": "clean-gcda", + "type": "shell", + "command": "find . -name '*.gcda' -delete", + "windows": { + "command": "$null" + }, + "presentation": { + "reveal": "silent" + } + }, + { + "label": "clean-qt", + "type": "shell", + "command": "rm -r build/src/gui build/src/lib/gui", + "windows": { + "command": "remove-item -recurse build/src/gui,build/src/lib/gui" + } + }, + { + "label": "clean-config", + "type": "shell", + "linux": { + "command": "rm -r ~/.config/Synergy/Synergy.conf" + }, + "windows": { + "command": "remove-item -recurse $env:APPDATA\\Synergy\\Synergy" + }, + "osx": { + "command": "rm -r ~/Library/Application\\ Support/Synergy/Synergy" + } + }, + { + "label": "kill-all", + "type": "shell", + "dependsOn": ["kill-gui", "kill-daemon", "kill-core"] + }, + { + "label": "kill-gui", + "type": "shell", + "command": "killall -v -9 synergy || true", + "windows": { + "command": "taskkill /F /IM synergy.exe; $true" + } + }, + { + "label": "kill-daemon", + "type": "shell", + "command": "killall -v -9 synergy-daemon || true", + "windows": { + "command": "taskkill /F /IM synergy-daemon.exe; $true" + }, + "dependsOn": ["daemon-stop"] + }, + { + "label": "kill-core", + "type": "shell", + "command": "killall -v -9 synergy-core || true", + "windows": { + "command": "taskkill /F /IM synergy-core.exe; $true" + } + }, + { + "label": "gui", + "type": "process", + "command": "${workspaceFolder}/build/bin/synergy", + "problemMatcher": [], + "dependsOn": ["build"], + "windows": { + "dependsOn": ["build-kill-gui"], + "command": "${workspaceFolder}/build/bin-copy/synergy.exe" + }, + "osx": { + "command": "${workspaceFolder}/build/bin/synergy.app/Contents/MacOS/synergy" + } + }, + { + "label": "daemon", + "type": "process", + "command": "echo noop > /dev/null", + "problemMatcher": [], + "dependsOn": ["daemon-stop", "build"], + "windows": { + "command": "${workspaceFolder}/build/bin-copy/synergy-daemon.exe", + "args": ["-f"] + } + }, + { + "label": "daemon-stop", + "type": "shell", + "command": "echo noop > /dev/null", + "windows": { + "command": "Stop-Service -Name 'Synergy' -Force; while ((Get-Service -Name 'Synergy').Status -ne 'Stopped') { Start-Sleep -Seconds 1 }" + } + }, + { + "label": "daemon-start", + "type": "shell", + "command": "echo noop > /dev/null", + "windows": { + "command": "Start-Service -Name 'Synergy'; while ((Get-Service -Name 'Synergy').Status -ne 'Running') { Start-Sleep -Seconds 1 }" + } + }, + { + "label": "daemon-restart", + "type": "shell", + "command": "echo noop > /dev/null", + "windows": { + "command": "Restart-Service -Name 'Synergy'" + } + }, + { + "label": "daemon-deploy", + "type": "shell", + "dependsOn": ["build-kill-core", "daemon-start"], + "dependsOrder": "sequence" + }, + { + "label": "daemon-install", + "type": "shell", + "command": "echo noop > /dev/null", + "windows": { + "command": "sc.exe create Synergy binPath= \"${workspaceFolder}\\build\\bin-copy\\synergy-daemon.exe\" start= auto DisplayName= Synergy" + } + }, + { + "label": "daemon-uninstall", + "type": "shell", + "command": "echo noop > /dev/null", + "windows": { + "command": "sc.exe delete Synergy" + } + }, + { + "label": "daemon-reinstall", + "type": "shell", + "dependsOn": [ + "build-kill-core", + "daemon-stop", + "daemon-uninstall", + "daemon-install", + "daemon-start" + ], + "dependsOrder": "sequence" + } + ] +} diff --git a/CMakeLists.txt b/CMakeLists.txt index 3e4f9b3ec..65ed3e83f 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -21,13 +21,17 @@ set(CMAKE_CXX_STANDARD 20) set(CMAKE_CXX_EXTENSIONS OFF) set(CMAKE_CXX_STANDARD_REQUIRED ON) -# Fallback for when git can not be found -set(DESKFLOW_VERSION_MAJOR 1) -set(DESKFLOW_VERSION_MINOR 26) -set(DESKFLOW_VERSION_PATCH 0) +# Project version. SYNERGY_VERSION_* constants live in extra/cmake/Version.cmake +# (single source of truth, also consumed by SaveVersion.cmake in CI); bridged to +# the upstream-named DESKFLOW_VERSION_* here so project() can keep its existing +# shape. Synergy.cmake adds the dev/snapshot/release suffix later. +include(${CMAKE_CURRENT_SOURCE_DIR}/extra/cmake/Version.cmake) +set(DESKFLOW_VERSION_MAJOR ${SYNERGY_VERSION_MAJOR}) +set(DESKFLOW_VERSION_MINOR ${SYNERGY_VERSION_MINOR}) +set(DESKFLOW_VERSION_PATCH ${SYNERGY_VERSION_PATCH}) set(DESKFLOW_VERSION_TWEAK 0) -# Get the version from git if it's a git repository +# Git short SHA, consumed by VersionInfo.h.in (kVersionGitSha). if(EXISTS ${CMAKE_CURRENT_SOURCE_DIR}/.git) find_package(Git) if(GIT_FOUND) @@ -37,29 +41,6 @@ if(EXISTS ${CMAKE_CURRENT_SOURCE_DIR}/.git) OUTPUT_VARIABLE GIT_SHA_SHORT ERROR_QUIET OUTPUT_STRIP_TRAILING_WHITESPACE ) - execute_process( - COMMAND ${GIT_EXECUTABLE} describe --long --match v* --always - WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}" - OUTPUT_VARIABLE GITREV - ERROR_QUIET OUTPUT_STRIP_TRAILING_WHITESPACE - ) - string(FIND "${GITREV}" "v" isRev) - if(NOT ${isRev} EQUAL -1) - string(REGEX MATCH [0-9]+ MAJOR ${GITREV}) - string(REGEX MATCH \\.[0-9]+ MINOR ${GITREV}) - string(REPLACE "." "" MINOR "${MINOR}") - string(REGEX MATCH [0-9]+\- PATCH ${GITREV}) - string(REPLACE "-" "" PATCH "${PATCH}") - string(REGEX MATCH \-[0-9]+\- TWEAK ${GITREV}) - string(REPLACE "-" "" TWEAK "${TWEAK}") - set(DESKFLOW_VERSION_MAJOR ${MAJOR}) - set(DESKFLOW_VERSION_MINOR ${MINOR}) - set(DESKFLOW_VERSION_PATCH ${PATCH}) - set(DESKFLOW_VERSION_TWEAK ${TWEAK}) - else() - set(DESKFLOW_VERSION_TWEAK "9999") - endif() - unset(GITREV) endif() endif() @@ -87,6 +68,17 @@ set(CMAKE_PROJECT_VENDOR "${CMAKE_PROJECT_PROPER_NAME} Devs") set(CMAKE_PROJECT_COPYRIGHT "(C) 2024-2026 ${CMAKE_PROJECT_VENDOR}") set(CMAKE_PROJECT_CONTACT "${CMAKE_PROJECT_PROPER_NAME} ") set(CMAKE_PROJECT_REV_FQDN "org.deskflow.deskflow") +set(CMAKE_PROJECT_DOMAIN "deskflow.org") + +# Captured before any downstream brand override of CMAKE_PROJECT_NAME, for use +# by code that reads upstream-named on-disk artifacts (Qt .qm translation files). +set(UPSTREAM_PROJECT_NAME ${CMAKE_PROJECT_NAME}) + +# Identifier-safe form of the project version; downstream brands overwrite this. +set(CMAKE_PROJECT_VERSION_BASE "${CMAKE_PROJECT_VERSION}") + +# Pull in Synergy branding overrides. +include(${CMAKE_SOURCE_DIR}/extra/cmake/Synergy.cmake) #Unset the vars used in the project call unset(DESKFLOW_VERSION_MAJOR) @@ -98,9 +90,9 @@ message(STATUS "Building ${CMAKE_PROJECT_NAME}-${CMAKE_PROJECT_VERSION}") # Set lib versions set(REQUIRED_OPENSSL_VERSION 3.0) -set(REQUIRED_LIBEI_VERSION 1.3) -set(REQUIRED_LIBPORTAL_VERSION 0.9.1) -set(REQUIRED_QT_VERSION 6.7.0) +set(REQUIRED_LIBEI_VERSION 1.0) +set(REQUIRED_LIBPORTAL_VERSION 0.8.0) +set(REQUIRED_QT_VERSION 6.4.0) if (WIN32) # VSCMD_ARG_TGT_ARCH is set on CI @@ -134,6 +126,9 @@ if (MSVC) endif() endif() +# Synergy overlay: anchor the runtime requirement to the bundled CRT merge module. +include(${CMAKE_SOURCE_DIR}/extra/cmake/MsvcRuntime.cmake) + # Control debug item visibility # When not set logging is forced to DEBUG and show code locations # Also exposes a test menu @@ -176,6 +171,7 @@ if (WIN32) elseif(BUILD_OSX_BUNDLE) set(CMAKE_INSTALL_BINDIR ${CMAKE_PROJECT_PROPER_NAME}.app/Contents/MacOS) set(CMAKE_INSTALL_LICENSE_DIR ${CMAKE_PROJECT_PROPER_NAME}.app/Contents/Resources) + set(CMAKE_INSTALL_DOCDIR ${CMAKE_PROJECT_PROPER_NAME}.app/Contents/Resources) else() set(CMAKE_INSTALL_LICENSE_DIR ${CMAKE_INSTALL_DATADIR}/licenses/${CMAKE_PROJECT_NAME}) set(CMAKE_INSTALL_I18N_DIR ${CMAKE_INSTALL_DATADIR}/${CMAKE_PROJECT_NAME}/translations) @@ -183,10 +179,15 @@ endif() add_subdirectory(docs) +option(BUILD_GUI "Build GUI" ON) + # build translations before source, I18N unit tests fail if they are missing -add_subdirectory(translations) +if(BUILD_GUI) + add_subdirectory(translations) +endif() add_subdirectory(src) +add_subdirectory(extra) option(BUILD_INSTALLER "Build installer" ON) if(BUILD_INSTALLER) diff --git a/CMakePresets.json b/CMakePresets.json new file mode 100644 index 000000000..3f91b05c5 --- /dev/null +++ b/CMakePresets.json @@ -0,0 +1,139 @@ +{ + "version": 2, + "configurePresets": [ + { + "name": "base", + "hidden": true, + "binaryDir": "${sourceDir}/build", + "cacheVariables": { + "CMAKE_EXPORT_COMPILE_COMMANDS": "ON" + } + }, + { + "name": "minimal", + "hidden": true, + "environment": { + "SYNERGY_BUILD_MINIMAL": "ON" + } + }, + { + "name": "windows", + "inherits": "base", + "hidden": true, + "generator": "Ninja", + "cacheVariables": { + "CMAKE_C_COMPILER": "cl.exe", + "CMAKE_CXX_COMPILER": "cl.exe", + "CMAKE_TOOLCHAIN_FILE": "$env{VCPKG_ROOT}/scripts/buildsystems/vcpkg.cmake", + "QT_PATH": "$env{QT_PATH}" + }, + "architecture": { + "value": "x64", + "strategy": "external" + }, + "toolset": { + "value": "host=x64", + "strategy": "external" + } + }, + { + "name": "linux", + "hidden": true, + "inherits": "base", + "generator": "Unix Makefiles" + }, + { + "name": "macos", + "hidden": true, + "inherits": "base", + "generator": "Unix Makefiles", + "cacheVariables": { + "CMAKE_OSX_SYSROOT": "/Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX.sdk" + } + }, + { + "name": "windows-debug", + "inherits": "windows", + "cacheVariables": { + "CMAKE_BUILD_TYPE": "Debug" + } + }, + { + "name": "windows-release", + "inherits": "windows", + "cacheVariables": { + "CMAKE_BUILD_TYPE": "Release" + } + }, + { + "name": "linux-debug", + "inherits": "linux", + "cacheVariables": { + "CMAKE_BUILD_TYPE": "Debug" + } + }, + { + "name": "linux-release", + "inherits": "linux", + "cacheVariables": { + "CMAKE_BUILD_TYPE": "Release" + } + }, + { + "name": "macos-debug", + "inherits": "macos", + "cacheVariables": { + "CMAKE_BUILD_TYPE": "Debug" + } + }, + { + "name": "macos-release", + "inherits": "macos", + "cacheVariables": { + "CMAKE_BUILD_TYPE": "Release" + } + }, + { + "name": "windows-debug-min", + "inherits": ["windows", "minimal"], + "cacheVariables": { + "CMAKE_BUILD_TYPE": "Debug" + } + }, + { + "name": "windows-release-min", + "inherits": ["windows", "minimal"], + "cacheVariables": { + "CMAKE_BUILD_TYPE": "Release" + } + }, + { + "name": "linux-debug-min", + "inherits": ["linux", "minimal"], + "cacheVariables": { + "CMAKE_BUILD_TYPE": "Debug" + } + }, + { + "name": "linux-release-min", + "inherits": ["linux", "minimal"], + "cacheVariables": { + "CMAKE_BUILD_TYPE": "Release" + } + }, + { + "name": "macos-debug-min", + "inherits": ["macos", "minimal"], + "cacheVariables": { + "CMAKE_BUILD_TYPE": "Debug" + } + }, + { + "name": "macos-release-min", + "inherits": ["macos", "minimal"], + "cacheVariables": { + "CMAKE_BUILD_TYPE": "Release" + } + } + ] +} diff --git a/README.md b/README.md new file mode 100644 index 000000000..157537874 --- /dev/null +++ b/README.md @@ -0,0 +1,49 @@ +# Synergy + +[![CodeQL Analysis](https://github.com/symless/synergy/actions/workflows/codeql-analysis.yml/badge.svg)](https://github.com/symless/synergy/actions/workflows/codeql-analysis.yml) +[![SonarCloud Analysis](https://github.com/symless/synergy/actions/workflows/sonarcloud-analysis.yml/badge.svg)](https://github.com/symless/synergy/actions/workflows/sonarcloud-analysis.yml) + +Use the keyboard, mouse, or trackpad of one computer to control nearby computers, and work seamlessly between them. + +- [Get Synergy](https://synergyapp.io) +- [Technical support](https://synergyapp.io/contact) + +This repository contains the source code used to build Synergy 1 and the Core for Synergy 3. +It's based on the upstream Deskflow community project, which is sponsored by Synergy. + +- [Contibute to Deskflow](https://deskflow.org) + +## FAQ + +### How do I build the source code? +If you're a customer, you generally don’t need to build Synergy yourself as we provide pre-built, tested releases. +However, if you're a customer looking to build Synergy from source, [contact us](https://synergyapp.io/contact) so we can help with that. +If you're a developer looking to contribute to an open source community, join us in our [Deskflow](https://deskflow.org) project. + +### What’s the difference between Synergy and Deskflow? +Synergy is a stable, supported commercial product. It is quality assurance tested, has a warranty, and is maintained by a team of full-time engineers. +Deskflow is the upstream project where the open source community, including Synergy engineers, prototype and iterate on new features. +Synergy is your business-ready solution; Deskflow is for open source contributors and early adopters. + +### Where should I file bugs or feature requests? +For supported customers, reach out to our [support team](https://synergyapp.io/contact) and we’ll triage and track issues internally. +If you're contributing to the community project, use [Deskflow issues](https://github.com/deskflow/deskflow/issues) to report bugs or request features. + +### Can I contribute code to Synergy? +We welcome contributions, but our community development happens upstream in Deskflow. +That’s the best place to propose changes and collaborate with the wider community. +Changes flow downstream to Synergy once they have matured enough and are ready for customer usage. + +### How often does Deskflow merge into Synergy? +We regularly port stable features and fixes from Deskflow into Synergy. +This involves QA, integration testing, and compliance review. Critical bug fixes are fast-tracked. +For specific timelines on particular bug fixes and features, please [get in touch](https://synergyapp.io/contact). + +### Is Deskflow stable? +Deskflow is intended for developers and contributors who can self-support and fix issues. +It’s not suitable for production or business-critical environments requiring stability guarantees. +For those cases, we recommend using Synergy. + +### Why have two projects? +This model lets us move fast without breaking things. Deskflow empowers rapid community-driven innovation. +Synergy delivers a stable, supported experience to customers. diff --git a/deploy/CMakeLists.txt b/deploy/CMakeLists.txt index c7b0ca781..bd3fde6c3 100644 --- a/deploy/CMakeLists.txt +++ b/deploy/CMakeLists.txt @@ -33,8 +33,12 @@ else() message(STATUS "UNKNOWN System: ${CMAKE_SYSTEM_NAME}") endif() -# Always use "deskflow" for start of name set(CPACK_PACKAGE_FILE_NAME "${CMAKE_PROJECT_NAME}-${PACKAGE_VERSION_LABEL}-${OS_STRING}") + +# Synergy overlay: rewrite CPACK_PACKAGE_FILE_NAME to the release-templates scheme +# (synergy___); kept in extra/ since upstream won't adopt it. +include(${CMAKE_SOURCE_DIR}/extra/deploy/PackageFileName.cmake) + message(STATUS "Package Basename: ${CPACK_PACKAGE_FILE_NAME}") include(CPack) diff --git a/deploy/linux/arch/PKGBUILD.in b/deploy/linux/arch/PKGBUILD.in index 67d6cc546..f7686dfba 100644 --- a/deploy/linux/arch/PKGBUILD.in +++ b/deploy/linux/arch/PKGBUILD.in @@ -4,7 +4,7 @@ _basename=@CMAKE_PROJECT_NAME@ pkgname=${_basename}-git -pkgver=@CMAKE_PROJECT_VERSION@ +pkgver=@CMAKE_PROJECT_VERSION_BASE@ pkgrel=1 pkgdesc="@CMAKE_PROJECT_DESCRIPTION@" url="@CMAKE_PROJECT_HOMEPAGE_URL@" diff --git a/deploy/linux/deploy.cmake b/deploy/linux/deploy.cmake index c599cced2..1d64cb917 100644 --- a/deploy/linux/deploy.cmake +++ b/deploy/linux/deploy.cmake @@ -7,22 +7,27 @@ set(MY_DIR ${CMAKE_CURRENT_LIST_DIR}) # Install our desktop file install( - FILES ${MY_DIR}/${CMAKE_PROJECT_REV_FQDN}.desktop + FILES ${CMAKE_SOURCE_DIR}/extra/deploy/linux/${CMAKE_PROJECT_REV_FQDN}.desktop DESTINATION ${CMAKE_INSTALL_DATADIR}/applications ) # Install our icon -install(FILES ${MY_DIR}/org.deskflow.deskflow.png DESTINATION ${CMAKE_INSTALL_DATADIR}/icons/hicolor/512x512/apps/) +install( + FILES ${CMAKE_SOURCE_DIR}/extra/src/apps/res/synergy.png + RENAME ${CMAKE_PROJECT_REV_FQDN}.png + DESTINATION ${CMAKE_INSTALL_DATADIR}/icons/hicolor/512x512/apps/ +) # Install our symbolic icon install( - FILES ${CMAKE_SOURCE_DIR}/src/apps/res/icons/deskflow-light/apps/64/org.deskflow.deskflow-symbolic.svg + FILES ${CMAKE_SOURCE_DIR}/extra/src/apps/res/synergy-symbolic-light.svg + RENAME ${CMAKE_PROJECT_REV_FQDN}-symbolic.svg DESTINATION ${CMAKE_INSTALL_DATADIR}/icons/hicolor/symbolic/apps/ ) # Install our metainfo install( - FILES ${MY_DIR}/${CMAKE_PROJECT_REV_FQDN}.metainfo.xml + FILES ${CMAKE_SOURCE_DIR}/extra/deploy/linux/${CMAKE_PROJECT_REV_FQDN}.metainfo.xml DESTINATION ${CMAKE_INSTALL_DATADIR}/metainfo/ ) diff --git a/deploy/mac/deploy.cmake b/deploy/mac/deploy.cmake index d4bcafce7..ae074ad04 100644 --- a/deploy/mac/deploy.cmake +++ b/deploy/mac/deploy.cmake @@ -14,10 +14,26 @@ if (OSX_BUNDLE) \"\${CMAKE_INSTALL_PREFIX}/${CMAKE_PROJECT_PROPER_NAME}.app\" -timestamp -codesign=- )") + + # macdeployqt above only ad-hoc signs the staged bundle, and later install() + # steps (e.g. LICENSE into Resources) would invalidate any signature applied + # at install time. So when a Developer ID is provided, defer signing to a CPack + # pre-build script that runs once the staging tree is fully populated, right + # before the DMG is built; otherwise the notary service rejects it as Invalid. + if(APPLE_CODESIGN_ID) + configure_file( + ${CMAKE_SOURCE_DIR}/extra/deploy/mac/codesign.cmake.in + ${CMAKE_CURRENT_BINARY_DIR}/mac-codesign.cmake + @ONLY + ) + set(CPACK_PRE_BUILD_SCRIPTS ${CMAKE_CURRENT_BINARY_DIR}/mac-codesign.cmake) + endif() + set(CPACK_PACKAGE_ICON "${MY_DIR}/dmg-volume.icns") - set(CPACK_DMG_BACKGROUND_IMAGE "${MY_DIR}/dmg-background.tiff") + set(CPACK_DMG_BACKGROUND_IMAGE "${CMAKE_SOURCE_DIR}/extra/deploy/mac/dmg-background.tiff") set(CPACK_DMG_DS_STORE_SETUP_SCRIPT "${MY_DIR}/generate_ds_store.applescript") set(CPACK_DMG_VOLUME_NAME "${CMAKE_PROJECT_PROPER_NAME}") + set(CPACK_RESOURCE_FILE_LICENSE "${CMAKE_SOURCE_DIR}/extra/deploy/mac/synergy-eula.txt") set(CPACK_DMG_SLA_USE_RESOURCE_FILE_LICENSE ON) set(CPACK_GENERATOR "DragNDrop") endif() diff --git a/deploy/mac/generate_ds_store.applescript b/deploy/mac/generate_ds_store.applescript index 45c872e88..e9e04ab1c 100644 --- a/deploy/mac/generate_ds_store.applescript +++ b/deploy/mac/generate_ds_store.applescript @@ -39,7 +39,7 @@ on run argv set toolbar visible to false set pathbar visible to false set the bounds to { 200, 120, 800, 520 } - set position of item "Deskflow.app" to { 144, 190 } + set position of item (image_name & ".app") to { 144, 190 } set position of item "Applications" to { 455, 190 } end tell close diff --git a/deploy/windows/deploy.cmake b/deploy/windows/deploy.cmake index 47ed908f2..664d3c4f7 100644 --- a/deploy/windows/deploy.cmake +++ b/deploy/windows/deploy.cmake @@ -31,15 +31,19 @@ set(CPACK_PACKAGE_NAME "${CMAKE_PROJECT_PROPER_NAME}") # Menu Entry set(CPACK_WIX_PROGRAM_MENU_FOLDER "${CMAKE_PROJECT_PROPER_NAME}") -set(CPACK_PACKAGE_EXECUTABLES "deskflow" "${CMAKE_PROJECT_PROPER_NAME}") +set(CPACK_PACKAGE_EXECUTABLES "${CMAKE_PROJECT_NAME}" "${CMAKE_PROJECT_PROPER_NAME}") # Default Install Path set(CPACK_PACKAGE_INSTALL_DIRECTORY "${CMAKE_PROJECT_PROPER_NAME}") # Wix Specific Values set(CPACK_WIX_UPGRADE_GUID "027D1C8A-E7A5-4754-BB93-B2D45BFDBDC8") -set(CPACK_WIX_UI_BANNER "${MY_DIR}/wix-banner.png") -set(CPACK_WIX_UI_DIALOG "${MY_DIR}/wix-dialog.png") +set(CPACK_WIX_UI_BANNER "${CMAKE_SOURCE_DIR}/extra/deploy/windows/wix-banner.png") +set(CPACK_WIX_UI_DIALOG "${CMAKE_SOURCE_DIR}/extra/deploy/windows/wix-dialog.png") + +# Show the Synergy EULA (which references the canonical copy at synergyapp.io/eula) +# on the license page instead of the GPL; the GPL still ships as the LICENSE file. +set(CPACK_WIX_LICENSE_RTF "${CMAKE_SOURCE_DIR}/extra/deploy/windows/synergy-eula.rtf") # Required Extra Extenstions list(APPEND CPACK_WIX_EXTENSIONS "WixToolset.Util.wixext" "WixToolset.Firewall.wixext") @@ -47,26 +51,39 @@ list(APPEND CPACK_WIX_EXTENSIONS "WixToolset.Util.wixext" "WixToolset.Firewall.w # Make sure to also put the xmlns for the ext into the wix block on generated files list(APPEND CPACK_WIX_CUSTOM_XMLNS "util=http://wixtoolset.org/schemas/v4/wxs/util" "firewall=http://wixtoolset.org/schemas/v4/wxs/firewall") -# The patch has to know the full path of our msm file +# Bundle the MSVC C++ runtime (CRT) as a merge module inside the MSI so that end +# users don't have to download and install the Visual C++ redistributable +# themselves. The merge module ships with the Visual Studio build environment. +# Docs: https://learn.microsoft.com/en-us/cpp/windows/redistributing-components-by-using-merge-modules +set(REDIST_MERGE_MODULE_DIR "$ENV{VCINSTALLDIR}Redist/MSVC") +file(GLOB REDIST_MERGE_MODULE_PATHS + "${REDIST_MERGE_MODULE_DIR}/*/MergeModules/Microsoft_VC*_CRT_${BUILD_ARCHITECTURE}.msm") +if (REDIST_MERGE_MODULE_PATHS) + # Multiple toolset versions may be installed; pick the newest. + list(SORT REDIST_MERGE_MODULE_PATHS) + list(GET REDIST_MERGE_MODULE_PATHS -1 REDIST_MERGE_MODULE_PATH) + message(STATUS "MSVC CRT merge module: ${REDIST_MERGE_MODULE_PATH}") + # XML snippets injected into wix-patch.xml.in to merge the CRT into the MSI. + set(WIX_REDIST_MERGE + "") + set(WIX_REDIST_MERGE_REF "") +elseif(SYNERGY_VERSION_RELEASE OR SYNERGY_VERSION_SNAPSHOT) + # Shipping build: a missing CRT module means a broken installer for end users. + message(WARNING + "MSVC CRT merge module not found under ${REDIST_MERGE_MODULE_DIR}; " + "the installer will NOT bundle the Visual C++ runtime") +else() + # Dev builds routinely lack the redist merge module; not bundling it is expected. + message(STATUS + "MSVC CRT merge module not found; installer will not bundle the Visual C++ " + "runtime (expected for dev builds)") +endif() + +# The patch needs the full path of the CRT merge module (see above). configure_file( ${MY_DIR}/wix-patch.xml.in ${CMAKE_CURRENT_BINARY_DIR}/wix-patch.xml @ONLY ) -# This patch set ups filewall rules, the service and msm module +# This patch sets up firewall rules, the service and the CRT merge module. set(CPACK_WIX_PATCH_FILE "${CMAKE_CURRENT_BINARY_DIR}/wix-patch.xml") - -# Creates a DLL that can be used by our MSI for custom actions. -configure_file( - ${MY_DIR}/wix-custom.h.in - ${CMAKE_CURRENT_BINARY_DIR}/wix-custom.h @ONLY -) -add_library( - wix-custom SHARED - ${MY_DIR}/wix-custom.cpp -) -target_include_directories(wix-custom PRIVATE ${CMAKE_CURRENT_BINARY_DIR}) -set_target_properties(wix-custom PROPERTIES - RUNTIME_OUTPUT_DIRECTORY "${CMAKE_CURRENT_BINARY_DIR}" -) -target_link_libraries(wix-custom PRIVATE Msi) diff --git a/deploy/windows/pre-cpack.cmake.in b/deploy/windows/pre-cpack.cmake.in index 003876058..8e2e3ad0f 100644 --- a/deploy/windows/pre-cpack.cmake.in +++ b/deploy/windows/pre-cpack.cmake.in @@ -8,12 +8,12 @@ if(CPACK_GENERATOR MATCHES 7Z|ZIP) string(REPLACE "*" " " _ITEM ${ITEM}) file(COPY ${_ITEM} DESTINATION ${CPACK_TEMPORARY_INSTALL_DIRECTORY}) endforeach() - file(WRITE ${CPACK_TEMPORARY_INSTALL_DIRECTORY}/settings/Deskflow.conf " ") - file(REMOVE ${CPACK_TEMPORARY_INSTALL_DIRECTORY}/deskflow-daemon.exe) + file(WRITE ${CPACK_TEMPORARY_INSTALL_DIRECTORY}/settings/@CMAKE_PROJECT_PROPER_NAME@.conf " ") + file(REMOVE ${CPACK_TEMPORARY_INSTALL_DIRECTORY}/@CMAKE_PROJECT_NAME@-daemon.exe) file(WRITE ${CPACK_TEMPORARY_INSTALL_DIRECTORY}/README.txt -" Portable Deskflow: @CMAKE_PROJECT_VERSION@ +" Portable @CMAKE_PROJECT_PROPER_NAME@: @CMAKE_PROJECT_VERSION@ - The portable version must have the settings/Deskflow.conf file to save settings, or it will try to use the system settings location. + The portable version must have the settings/@CMAKE_PROJECT_PROPER_NAME@.conf file to save settings, or it will try to use the system settings location. The portable version does not include the daemon, so the client will not work at UAC prompts or the login screen. ") endif() diff --git a/deploy/windows/wix-custom.cpp b/deploy/windows/wix-custom.cpp deleted file mode 100644 index 0d58bac75..000000000 --- a/deploy/windows/wix-custom.cpp +++ /dev/null @@ -1,72 +0,0 @@ -/* - * Deskflow -- mouse and keyboard sharing utility - * SPDX-FileCopyrightText: (C) 2025 Symless Ltd. - * SPDX-License-Identifier: GPL-2.0-only WITH LicenseRef-OpenSSL-Exception - */ - -#include "wix-custom.h" - -#define WIN32_LEAN_AND_MEAN -#include - -// Include after Windows.h -#include - -#include -#include - -namespace { -// Warning: DLL will crash with error code 1603 if we exceed this. -const auto kLogLineMax = 1024; - -// Prefixes log messages with the app name so they're easier to find/filter. -const std::string kLogPrefix = std::string(kAppId) + " installer: "; - -// Note: Resized to log line max when used. -static std::string s_logMessageBuffer; // NOSONAR - Must be mutable. -} // namespace - -// This log output can be viewed by using the DebugView program. -#define MS_LOG_DEBUG(message, ...) \ - s_logMessageBuffer.resize(kLogLineMax); \ - sprintf(s_logMessageBuffer.data(), message, __VA_ARGS__); \ - OutputDebugStringA((kLogPrefix + s_logMessageBuffer + "\n").c_str()) - -extern "C" __declspec(dllexport) UINT __stdcall CheckVCRedist(MSIHANDLE hInstall) -{ - const auto kKeyName = TEXT(kRegKey); - const auto kValueName = TEXT("Minor"); - const auto kProperty = "VC_REDIST_VERSION_OK"; - - MS_LOG_DEBUG("checking for msvc redist v%d.%d", kWindowsRuntimeMajor, kWindowsRuntimeMinor); - - HKEY hKey; - if (RegOpenKeyEx(HKEY_LOCAL_MACHINE, kKeyName, 0, KEY_READ, &hKey) != ERROR_SUCCESS) { - MS_LOG_DEBUG("msvc redist registry key not found"); - return ERROR_FUNCTION_FAILED; - } - - MS_LOG_DEBUG("msvc redist registry key found, querying minor version"); - - DWORD minorVersion = 0; - DWORD size = sizeof(DWORD); - RegQueryValueEx(hKey, kValueName, nullptr, nullptr, (LPBYTE)&minorVersion, &size); - RegCloseKey(hKey); - - MS_LOG_DEBUG("msvc redist minor version: %lu", minorVersion); - - if (minorVersion < kWindowsRuntimeMinor) { - MS_LOG_DEBUG("msvc redist minor version %lu too low, expected >= %d", minorVersion, kWindowsRuntimeMinor); - // Returning success allows the installer will show a friendly error message. - return ERROR_SUCCESS; - } - - MS_LOG_DEBUG("msvc redist version ok, setting: %s", kProperty); - if (MsiSetProperty(hInstall, kProperty, "ok") != ERROR_SUCCESS) { - MS_LOG_DEBUG("failed to set property: %s", kProperty); - return ERROR_FUNCTION_FAILED; - } - - MS_LOG_DEBUG("msvc redist version check successful"); - return ERROR_SUCCESS; -} diff --git a/deploy/windows/wix-custom.h.in b/deploy/windows/wix-custom.h.in deleted file mode 100644 index 2032be38f..000000000 --- a/deploy/windows/wix-custom.h.in +++ /dev/null @@ -1,15 +0,0 @@ -/* - * Deskflow -- mouse and keyboard sharing utility - * SPDX-FileCopyrightText: (C) 2025 Symless Ltd. - * SPDX-License-Identifier: GPL-2.0-only WITH LicenseRef-OpenSSL-Exception - */ - -#pragma once - -const auto kAppId = "@CMAKE_PROJECT_NAME@"; -const auto kRegKey = "SOFTWARE\\Microsoft\\VisualStudio\\14.0\\VC\\Runtimes\\@BUILD_ARCHITECTURE@"; - -// clang-format off -const auto kWindowsRuntimeMajor = @REQUIRED_MSVC_RUNTIME_MAJOR@; -const auto kWindowsRuntimeMinor = @REQUIRED_MSVC_RUNTIME_MINOR@; -// clang-format on diff --git a/deploy/windows/wix-patch.xml.in b/deploy/windows/wix-patch.xml.in index 22cf616ff..b81507024 100644 --- a/deploy/windows/wix-patch.xml.in +++ b/deploy/windows/wix-patch.xml.in @@ -1,9 +1,9 @@ - + - - + + - - - + + + - - - - - + + @WIX_REDIST_MERGE@ - + - - - + - - - - + + @WIX_REDIST_MERGE_REF@ diff --git a/extra/CMakeLists.txt b/extra/CMakeLists.txt new file mode 100644 index 000000000..24207f25d --- /dev/null +++ b/extra/CMakeLists.txt @@ -0,0 +1,34 @@ +# SPDX-FileCopyrightText: (C) 2012 - 2026 Synergy App Ltd +# SPDX-License-Identifier: MIT + +# Make synergy/... headers under this tree resolvable from anywhere that descends +# from this directory (and from upstream code once hooks are wired). +include_directories("${CMAKE_CURRENT_SOURCE_DIR}/src/lib") + +add_subdirectory(src) + +# Wire the synergy::hooks::* callsites in upstream src/. The synergy-gui sublib +# provides the inline functions in synergy/hooks/gui_hook.h that upstream calls +# behind the SYNERGY_EXTRA_HEADER guard. Setting the define + include path on +# the upstream gui target lets MainWindow.cpp / SettingsDialog.cpp / etc. find +# the header without editing upstream's CMakeLists. +if(TARGET gui) + target_compile_definitions(gui PRIVATE SYNERGY_EXTRA_HEADER) + target_include_directories(gui PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src/lib) + # Cascade synergy-gui to anything that links gui (the synergy executable, + # but also unit tests that link libgui.a, which pull in MainWindow.cpp.o + # which now references synergy::hooks::* symbols). Plain signature matches + # upstream's gui CMakeLists. + target_link_libraries(gui synergy-gui) +endif() + +# Pick the gui app target name (Apple uses the proper-cased brand name). +if(APPLE) + set(_synergy_gui_app ${CMAKE_PROJECT_PROPER_NAME}) +else() + set(_synergy_gui_app ${CMAKE_PROJECT_NAME}) +endif() +if(TARGET ${_synergy_gui_app}) + target_compile_definitions(${_synergy_gui_app} PRIVATE SYNERGY_EXTRA_HEADER) + target_include_directories(${_synergy_gui_app} PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src/lib) +endif() diff --git a/extra/Synergy.test.example.conf b/extra/Synergy.test.example.conf new file mode 100644 index 000000000..bd161667b --- /dev/null +++ b/extra/Synergy.test.example.conf @@ -0,0 +1,72 @@ +# Synergy test settings: example/template. +# +# Copy to the repo root as `Synergy.test.conf` (git-ignored). The app +# walks up from the binary's directory and loads the first +# Synergy.test.conf it finds, so a repo-root copy covers any build dir +# inside the tree and survives wipes of the app settings dir. +# +# When nothing is found above the binary (e.g. installed builds), it +# falls back to the location sibling to Synergy.conf: +# ~/.config/Synergy/Synergy.test.conf (Linux) +# ~/Library/Synergy/Synergy.test.conf (macOS) +# %APPDATA%\Synergy\Synergy.test.conf (Windows) +# +# This file is the only source of test settings; SYNERGY_TEST_* env vars +# are no longer read. + +[test] + +# Master switch. When true: +# - The Test menu appears in the menu bar (fatal/critical-error triggers). +# - The other values in this file are honored. +# Set false (or leave the file absent) to disable test mode entirely. +# enabled=true + +# Force the license activation flow on. Useful for verifying activation +# behavior in development; left off by default so test mode (Test menu, +# URL overrides, etc.) can be on without forcing license prompts every +# launch. +# licensing=true + +# Override the base URL for license API calls. The app appends the +# endpoint paths (/product/activate, /product/check) itself, so set this +# to the API root only. Default points at production. Use this to point +# at a local stack or staging environment during development. +# apiUrlBase=http://localhost:4200/synergy/api + +# Pre-fill the activation dialog's serial key field. Hex-encoded payload +# matching the format the activation API expects. Test serial keys are +# distributed internally. Do not hardcode customer-visible keys here. +# serialKey= + +# Override the machine identity used for offline activation. Offline +# activation codes are bound to this machine's hardware id, so on a real +# box the activation code never changes for a given serial key. Set this +# to any value to impersonate a different machine: change it (or the +# stored response) to confirm one machine's response code does not +# activate another, i.e. that an offline serial cannot be shared. Affects +# only offline activation, not the online activation machine signature. +# machineId=test-machine-1 + +# Time-travel testing. UNIX epoch seconds; if set, the GUI behaves as if +# the system clock were at this point in time when checking license +# expiry, grace periods, etc. Use to verify "expired", "expiring soon", +# and "in grace period" UI without waiting for real wall-clock time to +# elapse. +# startTime=1751324400 + +[features] + +# Extra debug logging from the GUI. Logs every license-check decision, +# scope-switch step, settings reload. Useful when tracing a bug report. +# verbose=true + +# Skip the periodic remote license re-check. Use when developing offline +# or when the API endpoint is intentionally unavailable, so the GUI +# doesn't enter the grace-period flow. +# skipRemoteCheck=true + +# Treat expired licenses as still valid. Lets you start the core process +# with a known-expired test key without bypassing license enforcement +# entirely (useful for testing what features remain enabled vs blocked). +# allowExpiredLicenses=true diff --git a/extra/cmake/MsvcRuntime.cmake b/extra/cmake/MsvcRuntime.cmake new file mode 100644 index 000000000..85cfcc820 --- /dev/null +++ b/extra/cmake/MsvcRuntime.cmake @@ -0,0 +1,29 @@ +# SPDX-FileCopyrightText: (C) 2012 - 2026 Synergy App Ltd +# SPDX-License-Identifier: MIT + +# Synergy bundles the MSVC C++ runtime as a merge module inside the MSI +# (deploy/windows/deploy.cmake) instead of making the user install the +# redistributable. The runtime guard (ArchMiscWindows::guardRuntimeVersion) must +# therefore require the version we actually ship, not the build machine's +# installed redist that upstream's registry query returns: CI runners carry a +# newer redist than the bundled module, so the guard rejects our own runtime and +# the service fails to start. +# +# The bundled module ships the runtime of the compiler's own toolset, but its +# path carries only the toolset major (.../Redist/MSVC/v143/MergeModules/...), so +# the runtime version isn't in it. Take the minor from the compiler version +# instead: cl major 19 maps to CRT major 14 and the minor matches (e.g. 19.44 -> +# 14.44), per Microsoft's _MSC_VER / build-tools version table: +# https://learn.microsoft.com/en-us/cpp/overview/compiler-versions?view=msvc-170 +# That floor is always <= the runtime we bundle, so it can't reject what we ship. +# Builds with no bundled module (dev) keep upstream's registry value. +# +# The glob mirrors deploy/windows/deploy.cmake's bundling condition; keep in sync. +if(MSVC) + file(GLOB _crt_msms + "$ENV{VCINSTALLDIR}Redist/MSVC/*/MergeModules/Microsoft_VC*_CRT_${BUILD_ARCHITECTURE}.msm") + if(_crt_msms AND CMAKE_CXX_COMPILER_VERSION MATCHES "^[0-9]+\\.([0-9]+)") + set(REQUIRED_MSVC_RUNTIME_MINOR "${CMAKE_MATCH_1}") + message(STATUS "MSVC runtime (compiler toolset): ${REQUIRED_MSVC_RUNTIME_MAJOR}.${REQUIRED_MSVC_RUNTIME_MINOR}") + endif() +endif() diff --git a/extra/cmake/SaveVersion.cmake b/extra/cmake/SaveVersion.cmake new file mode 100644 index 000000000..7dd7278b5 --- /dev/null +++ b/extra/cmake/SaveVersion.cmake @@ -0,0 +1,23 @@ +# SPDX-FileCopyrightText: (C) 2012 - 2026 Synergy App Ltd +# SPDX-License-Identifier: MIT + +# Standalone driver: invoked from CI as +# cmake -DVERSION_FILE=build/VERSION \ +# -DSYNERGY_VERSION_RELEASE=true|false \ +# -DSYNERGY_VERSION_SNAPSHOT=true|false \ +# -P extra/cmake/SaveVersion.cmake +# +# Includes Version.cmake (the same one Synergy.cmake includes during the build) +# and writes the result to VERSION_FILE. Guarantees the CI-side version matches +# what the binaries report. + +include(${CMAKE_CURRENT_LIST_DIR}/Version.cmake) + +if(NOT VERSION_FILE) + message(FATAL_ERROR "VERSION_FILE not set (pass via -DVERSION_FILE=...)") +endif() + +get_filename_component(_repo_root "${CMAKE_CURRENT_LIST_DIR}/../.." ABSOLUTE) +synergy_compute_version("${_repo_root}" _version _tweak _base) +message(STATUS "Computed version: ${_version}") +file(WRITE "${VERSION_FILE}" "${_version}") diff --git a/extra/cmake/Synergy.cmake b/extra/cmake/Synergy.cmake new file mode 100644 index 000000000..688468b39 --- /dev/null +++ b/extra/cmake/Synergy.cmake @@ -0,0 +1,105 @@ +# SPDX-FileCopyrightText: (C) 2012 - 2026 Synergy App Ltd +# SPDX-License-Identifier: MIT + +# Must be included after deskflow's project() call and the CMAKE_PROJECT_* +# defaults are set, so these overrides take effect. +set(CMAKE_PROJECT_PROPER_NAME "Synergy") +set(CMAKE_PROJECT_VENDOR "Synergy App Ltd") +set(CMAKE_PROJECT_COPYRIGHT "(C) 2012-2026 ${CMAKE_PROJECT_VENDOR}") +set(CMAKE_PROJECT_CONTACT "${CMAKE_PROJECT_PROPER_NAME} ") +set(CMAKE_PROJECT_REV_FQDN "com.symless.synergy") +set(CMAKE_PROJECT_DOMAIN "synergyapp.io") +set(CMAKE_PROJECT_HOMEPAGE_URL "https://synergyapp.io") + +# Display brand. "Synergy 1" is the default user-facing name (window title, +# About dialog). When building as the Core, flip to "Synergy Core" so the +# same codebase ships under a different product label. +# Distinct from CMAKE_PROJECT_PROPER_NAME, which stays "Synergy" to keep file paths +# (~/.config/Synergy/, Synergy.conf) and Windows globals space-free. +option(SYNERGY_CORE_FLAVOR "Build as Synergy Core" OFF) +if(SYNERGY_CORE_FLAVOR) + set(SYNERGY_DISPLAY_NAME "Synergy Core") +else() + set(SYNERGY_DISPLAY_NAME "Synergy 1") +endif() +add_compile_definitions(SYNERGY_DISPLAY_NAME="${SYNERGY_DISPLAY_NAME}") + +# Single source of truth for the minimum macOS version. Synergy is long-term +# stable (unlike upstream, which tracks recent macOS), so we target the oldest +# macOS the linked Qt 6.x supports, the same value for every architecture. +# Both the build (here) and the packaged app's advertised minimum +# (apps/gui-electron/dist/package.config.js, which reads this line) derive from +# this one value, so they can't drift. CI must NOT pass -DCMAKE_OSX_DEPLOYMENT_TARGET. +if(APPLE) + set(CMAKE_OSX_DEPLOYMENT_TARGET "12") +endif() + +# Core flavor seeds headless-build defaults (no GUI, no tests, no installer). +# No `FORCE` on the cache writes: the seed only fills empty slots, so a user +# passing -DBUILD_GUI=ON alongside the flavor flag still wins. +if(SYNERGY_CORE_FLAVOR) + set(BUILD_GUI OFF CACHE BOOL "Build GUI") + set(BUILD_TESTS OFF CACHE BOOL "Build tests") + set(BUILD_INSTALLER OFF CACHE BOOL "Build installer") + # On macOS the core normally nests into the GUI .app bundle + # ($), but with BUILD_GUI=OFF that target + # doesn't exist and cmake generation fails. Headless builds ship the core + # binary directly, so disable the bundle too. + set(BUILD_OSX_BUNDLE OFF CACHE BOOL "Build mac os bundle") +endif() + +# Don't run unit tests as part of the build. Devs can opt back in with +# -DSKIP_BUILD_TESTS=OFF if they want post-build ctest invocation. +set(SKIP_BUILD_TESTS ON CACHE BOOL "Skip build time test") + +# Resource paths consumed by extra/src/lib/synergy/gui/CMakeLists.txt. +set(GUI_RES_DIR "${CMAKE_SOURCE_DIR}/extra/src/apps/res") +set(GUI_QRC_FILE "${GUI_RES_DIR}/synergy.qrc") + +# Override deskflow's project name. This cascades into binary names +# (${CMAKE_PROJECT_NAME}-core, etc.), install paths, package names, +# translation file naming, and CPack metadata. Source files in src/apps/*/ +# are patched to use literal filenames since they previously assumed +# target name == source basename. +set(CMAKE_PROJECT_NAME synergy) + +# Synergy version. Base semver lives in ./VERSION (read by the root CMakeLists.txt); +# composition rules — dev/snapshot/release suffix, rev count — are shared with +# extra/cmake/SaveVersion.cmake via synergy_compute_version() so the CI-side +# version (used in package filenames, S3 paths, etc.) matches what the binaries +# report. Default mode is dev; flip with -DSYNERGY_VERSION_RELEASE=ON or +# -DSYNERGY_VERSION_SNAPSHOT=ON for CI/release builds. +option(SYNERGY_VERSION_RELEASE "Release version" OFF) +option(SYNERGY_VERSION_SNAPSHOT "Snapshot version" OFF) + +include(${CMAKE_CURRENT_LIST_DIR}/Version.cmake) +synergy_compute_version("${CMAKE_SOURCE_DIR}" + CMAKE_PROJECT_VERSION + CMAKE_PROJECT_VERSION_TWEAK + CMAKE_PROJECT_VERSION_BASE +) +set(CMAKE_PROJECT_VERSION_MAJOR ${SYNERGY_VERSION_MAJOR}) +set(CMAKE_PROJECT_VERSION_MINOR ${SYNERGY_VERSION_MINOR}) +set(CMAKE_PROJECT_VERSION_PATCH ${SYNERGY_VERSION_PATCH}) + +# Human-facing version. The composed version already carries its build metadata +# (dev: +, snapshot: +rN), so it is the display string as-is. Snapshot is the +# exception: +rN is a rev count, not the commit, so append the short sha for +# traceability. Dev already embeds the sha (don't double it); release stays clean. +# Consumed by VersionInfo.h.in (kDisplayVersion). +if(SYNERGY_VERSION_SNAPSHOT AND GIT_SHA_SHORT) + set(CMAKE_PROJECT_VERSION_DISPLAY "${CMAKE_PROJECT_VERSION} (${GIT_SHA_SHORT})") +else() + set(CMAKE_PROJECT_VERSION_DISPLAY "${CMAKE_PROJECT_VERSION}") +endif() + +if(NOT SYNERGY_VERSION_RELEASE AND NOT SYNERGY_VERSION_SNAPSHOT) + add_compile_definitions(SYNERGY_VERSION_DEV) +endif() + +# Compile activation in for distributable builds only; dev builds opt in at +# runtime via Synergy.test.conf (licensing=true) so local iteration isn't gated +# on a serial key. +if(SYNERGY_VERSION_RELEASE OR SYNERGY_VERSION_SNAPSHOT) + add_compile_definitions(SYNERGY_ENABLE_ACTIVATION) +endif() diff --git a/extra/cmake/Version.cmake b/extra/cmake/Version.cmake new file mode 100644 index 000000000..7133acbbb --- /dev/null +++ b/extra/cmake/Version.cmake @@ -0,0 +1,72 @@ +# SPDX-FileCopyrightText: (C) 2012 - 2026 Synergy App Ltd +# SPDX-License-Identifier: MIT + +# Synergy version: single source of truth, shared between the build (root +# CMakeLists.txt → Synergy.cmake) and CI (SaveVersion.cmake run via cmake -P). +# Bump these constants to release a new version. +set(SYNERGY_VERSION_MAJOR 1) +set(SYNERGY_VERSION_MINOR 21) +set(SYNERGY_VERSION_PATCH 0) +set(SYNERGY_VERSION_STAGE "beta") + +# Composes the full version string by applying SYNERGY_VERSION_RELEASE / +# SYNERGY_VERSION_SNAPSHOT semantics on top of the SYNERGY_VERSION_MAJOR/MINOR/PATCH +# constants above, plus a git-derived revision count for snapshot/dev builds: +# +# RELEASE → "X.Y.Z[-STAGE]" (STAGE = SYNERGY_VERSION_STAGE, e.g. beta1) +# SNAPSHOT → "X.Y.Z[-STAGE]-snapshot+rN" (N = commits since last v* tag) +# neither (dev) → "X.Y.Z[-STAGE]-dev+" +# +# Outputs (PARENT_SCOPE): +# OUT_VERSION the composed version string +# OUT_TWEAK rev count (used by `windows.rc.in` as the 4th MSI digit so +# each snapshot/dev build is a distinguishable Windows installer) +# OUT_BASE plain base semver (X.Y.Z) for consumers that reject prerelease +# suffixes (Arch makepkg pkgver, anything treating the version as +# a strict identifier). +function(synergy_compute_version SOURCE_DIR OUT_VERSION OUT_TWEAK OUT_BASE) + set(_rev_count 0) + set(_git_sha "") + find_package(Git QUIET) + if(GIT_FOUND) + execute_process( + COMMAND ${GIT_EXECUTABLE} describe HEAD --tags --long --match "v[0-9]*" + WORKING_DIRECTORY "${SOURCE_DIR}" + OUTPUT_VARIABLE _git_describe + ERROR_QUIET OUTPUT_STRIP_TRAILING_WHITESPACE + ) + if(_git_describe MATCHES "-([0-9]+)-g") + set(_rev_count "${CMAKE_MATCH_1}") + endif() + execute_process( + COMMAND ${GIT_EXECUTABLE} rev-parse --short=8 HEAD + WORKING_DIRECTORY "${SOURCE_DIR}" + OUTPUT_VARIABLE _git_sha + ERROR_QUIET OUTPUT_STRIP_TRAILING_WHITESPACE + ) + endif() + + set(_base "${SYNERGY_VERSION_MAJOR}.${SYNERGY_VERSION_MINOR}.${SYNERGY_VERSION_PATCH}") + set(_stage "") + if(SYNERGY_VERSION_STAGE) + set(_stage "-${SYNERGY_VERSION_STAGE}") + endif() + if(SYNERGY_VERSION_RELEASE) + set(_version "${_base}${_stage}") + set(_tweak 0) + elseif(SYNERGY_VERSION_SNAPSHOT) + set(_version "${_base}${_stage}-snapshot+r${_rev_count}") + set(_tweak ${_rev_count}) + else() + if(_git_sha) + set(_version "${_base}${_stage}-dev+${_git_sha}") + else() + set(_version "${_base}${_stage}-dev") + endif() + set(_tweak ${_rev_count}) + endif() + + set(${OUT_VERSION} "${_version}" PARENT_SCOPE) + set(${OUT_TWEAK} "${_tweak}" PARENT_SCOPE) + set(${OUT_BASE} "${_base}" PARENT_SCOPE) +endfunction() diff --git a/extra/deploy/PackageFileName.cmake b/extra/deploy/PackageFileName.cmake new file mode 100644 index 000000000..65eac1a2d --- /dev/null +++ b/extra/deploy/PackageFileName.cmake @@ -0,0 +1,47 @@ +# SPDX-FileCopyrightText: (C) 2012 - 2026 Synergy App Ltd +# SPDX-License-Identifier: MIT + +# Synergy distribution filename: synergy___., matching the +# release Package Templates that the downloads page / release flow match artifacts +# against. Upstream's deploy/ builds a different scheme (hyphen-joined, win/macos, +# os-release-derived distro tokens) it will never change to ours, so we override +# CPACK_PACKAGE_FILE_NAME here instead of patching deploy/*. Included once from +# deploy/CMakeLists.txt after the per-platform OS detection, so it can reuse the +# DISTRO_* vars deploy/linux/deploy.cmake already parsed from /etc/os-release. +# +# The flatpak bundle and the Arch .pkg.tar.zst are named in +# .github/workflows/ci.yml (built outside CPack; Actions can't include cmake) — +# keep those in step with this scheme. + +set(_os "") +if(WIN32) + set(_os "windows_${BUILD_ARCHITECTURE}") +elseif(APPLE) + # mac uses the short arch token (mac_x64 / mac_arm64), not x86_64. + set(_arch "${BUILD_ARCHITECTURE}") + if(_arch STREQUAL "x86_64") + set(_arch "x64") + endif() + set(_os "mac_${_arch}") +elseif(UNIX) + set(_distro "${DISTRO_NAME}") + set(_ver "") + if("${_distro}" STREQUAL "arch") + set(_distro "arch-linux") # os-release ID is "arch" + elseif("${_distro}" STREQUAL "opensuse") + set(_ver "-${DISTRO_CODENAME}") # rolling: codename (tumbleweed), not the ISO date + elseif(NOT "${DISTRO_VERSION_ID}" STREQUAL "") + string(REPLACE "." "-" _ver "${DISTRO_VERSION_ID}") # 24.04 -> 24-04 + set(_ver "-${_ver}") + elseif(NOT "${DISTRO_CODENAME}" STREQUAL "") + set(_ver "-${DISTRO_CODENAME}") + endif() + if("${_distro}" STREQUAL "") + set(_distro "linux") + endif() + set(_os "${_distro}${_ver}_${BUILD_ARCHITECTURE}") +endif() + +if(_os) + set(CPACK_PACKAGE_FILE_NAME "${CMAKE_PROJECT_NAME}_${PACKAGE_VERSION_LABEL}_${_os}") +endif() diff --git a/extra/deploy/linux/com.symless.synergy.desktop b/extra/deploy/linux/com.symless.synergy.desktop new file mode 100644 index 000000000..d2546a0bb --- /dev/null +++ b/extra/deploy/linux/com.symless.synergy.desktop @@ -0,0 +1,13 @@ +# SPDX-FileCopyrightText: (C) 2012 - 2026 Synergy App Ltd +# SPDX-License-Identifier: GPL-2.0-only + +[Desktop Entry] +Type=Application +Version=1.0 +Name=Synergy +Comment=Share one keyboard and mouse between computers +Exec=synergy +Icon=com.symless.synergy +Terminal=false +Categories=Utility; +Keywords=keyboard;mouse;sharing;network;share; diff --git a/extra/deploy/linux/com.symless.synergy.metainfo.xml b/extra/deploy/linux/com.symless.synergy.metainfo.xml new file mode 100644 index 000000000..3a351e788 --- /dev/null +++ b/extra/deploy/linux/com.symless.synergy.metainfo.xml @@ -0,0 +1,85 @@ + + + + + com.symless.synergy + CC0-1.0 + GPL-2.0-only + Synergy + + Synergy App Ltd + + Share one keyboard and mouse between computers + +

+ Use the keyboard, mouse, or trackpad of one computer to control nearby computers, and work + seamlessly between them. +

+
+ com.symless.synergy.desktop + https://symless.com/synergy + + com.symless.synergy.desktop + + + Utility + + + Input + Sharing + KVM + + + #dddddd + #555555 + + + + + +

+ This release folds a large number of upstream improvements into Synergy from Deskflow, + much of it built by the Synergy team and contributed upstream. + Highlights since the previous release: +

+

Stronger security

+
    +
  • TLS fingerprints are now kept in a fingerprint database, with a clearer comparison + dialog that shows both the local and remote prints side by side.
  • +
  • A new option for servers to require a client certificate before accepting a + connection.
  • +
+

Reliability

+
    +
  • The app now connects to the running core process over a dedicated IPC channel and + receives status updates directly, instead of inferring the connection state by parsing + log output.
  • +
  • Only one copy of the core runs at a time, so duplicate instances no longer fight over + the connection.
  • +
  • On Windows, clipboard changes are tracked with a modern format-listener API for more + reliable clipboard sharing.
  • +
+

Features and interface

+
    +
  • A single combined core binary replaces the separate client and server processes.
  • +
  • A hotkey to cycle through computers, and an option to start with the cursor locked + to the current computer.
  • +
  • An option to show the version in the window title.
  • +
  • The server address can be a list of host names or IP addresses, with a more accurate + suggested address that updates as the network changes.
  • +
  • A new client configuration dialog, a searchable log you can dock and hide, millisecond + timestamps, and a refreshed about dialog and status area.
  • +
  • A setting to choose the network protocol (Synergy or Barrier) for compatibility with + different clients.
  • +
  • Live interface translation without restarting, with Spanish, Italian, Japanese, + Chinese (simplified), Korean, and Russian added.
  • +
  • Mono or colorful tray icons with matching light and dark icon themes.
  • +
  • A new scroll scaling option replaces the old scroll-speed setting.
  • +
  • On Linux, server and client configuration honors XDG_CONFIG_HOME.
  • +
+

See the release notes for the full changelog.

+
+ https://symless.com/synergy/download +
+
+
diff --git a/extra/deploy/linux/flatpak/ci-build-lint-exceptions.json b/extra/deploy/linux/flatpak/ci-build-lint-exceptions.json new file mode 100644 index 000000000..267662cae --- /dev/null +++ b/extra/deploy/linux/flatpak/ci-build-lint-exceptions.json @@ -0,0 +1,6 @@ +{ + "com.symless.synergy": [ + "metainfo-missing-screenshots", + "appstream-screenshots-not-mirrored-in-ostree" + ] +} diff --git a/extra/deploy/linux/flatpak/com.symless.synergy.yml b/extra/deploy/linux/flatpak/com.symless.synergy.yml new file mode 100644 index 000000000..0b33524ca --- /dev/null +++ b/extra/deploy/linux/flatpak/com.symless.synergy.yml @@ -0,0 +1,102 @@ +# SPDX-FileCopyrightText: (C) 2012 - 2026 Synergy App Ltd +# SPDX-License-Identifier: GPL-2.0-only + +# Synergy-branded Flatpak manifest. Lives under extra/ so the build identifies +# itself to the Flatpak runtime as Synergy (com.symless.synergy), not Deskflow. +# The build modules (Jinja2, libei, libportal, gtest) are unchanged from the +# upstream manifest at deploy/linux/flatpak/org.deskflow.deskflow.yml — only +# the brand surface (app-id, command, the project module's source path) differs. +# +# Source path is relative to this file's location, so it's one ../ deeper than +# the upstream manifest's: extra/deploy/linux/flatpak → ../../../../ = repo root. + +app-id: com.symless.synergy +runtime: org.kde.Platform +runtime-version: "6.10" +sdk: org.kde.Sdk +command: synergy +finish-args: + - --share=ipc + - --socket=wayland + - --socket=fallback-x11 + - --share=network + - --device=dri + - --talk-name=org.kde.StatusNotifierWatcher +cleanup: + - /include + - "*.a" + - /bin/ei-debug-events + - /lib/cmake + - /lib/pkgconfig + - /share/pkgconfig + - /share/cmake + - /share/doc + - /share/gir-1.0 + - /lib/girepository-1.0 +modules: + - name: python3-Jinja2 + buildsystem: simple + build-commands: + - pip3 install --verbose --exists-action=i --no-index --find-links="file://${PWD}" + --prefix=${FLATPAK_DEST} --no-build-isolation Jinja2 + sources: + - type: file + url: https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl + sha256: 85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67 + x-checker-data: + type: pypi + name: Jinja2 + packagetype: bdist_wheel + - type: file + url: https://files.pythonhosted.org/packages/87/5b/aae44c6655f3801e81aa3eef09dbbf012431987ba564d7231722f68df02d/MarkupSafe-2.1.5.tar.gz + sha256: d283d37a890ba4c1ae73ffadf8046435c76e7bc2247bbb63c00bd1a709c6544b + cleanup: + - '*' + - name: libei + buildsystem: meson + config-opts: + - -Ddocumentation=[] + - -Dtests=disabled + - -Dliboeffis=disabled + sources: + - type: git + url: https://gitlab.freedesktop.org/libinput/libei + tag: 1.5.0 + commit: 19b64535408aa47abbc8151bc2c925afd6583851 + x-checker-data: + type: git + tag-pattern: ^([\\d.]+)$ + - name: libportal + buildsystem: meson + config-opts: + - -Ddocs=false + - -Dtests=false + - -Dportal-tests=false + - -Dvapi=false + - -Dbackend-qt6=auto + sources: + - type: git + url: https://github.com/flatpak/libportal.git + tag: 0.9.1 + commit: 8f5dc8d192f6e31dafe69e35219e3b707bde71ce + - type: patch + path: ../../../../deploy/linux/flatpak/libportal-qt69.patch + - name: gtest + buildsystem: cmake-ninja + sources: + - type: git + url: https://github.com/google/googletest.git + tag: v1.17.0 + commit: 52eb8108c5bdec04579160ae17225d66034bd723 + x-checker-data: + type: git + tag-pattern: ^([\\d.]+)$ + cleanup: + - '*' + - name: synergy + buildsystem: cmake-ninja + config-opts: + - "-DCMAKE_BUILD_TYPE=Release" + sources: + - type: dir + path: ../../../../ diff --git a/extra/deploy/mac/bundle/Contents/Info.plist.in b/extra/deploy/mac/bundle/Contents/Info.plist.in new file mode 100644 index 000000000..90f4d5ad5 --- /dev/null +++ b/extra/deploy/mac/bundle/Contents/Info.plist.in @@ -0,0 +1,31 @@ + + + + CFBundleDevelopmentRegion + English + CFBundleDisplayName + @DESKFLOW_APP_NAME@ + CFBundleExecutable + @DESKFLOW_APP_ID@ + CFBundleIconFile + @DESKFLOW_APP_NAME@.icns + CFBundleIdentifier + @DESKFLOW_APP_ID@ + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + @DESKFLOW_APP_NAME@ + CFBundlePackageType + APPL + CFBundleSignature + @DESKFLOW_MAC_BUNDLE_CODE@ + CFBundleShortVersionString + @DESKFLOW_VERSION@ + CFBundleVersion + @DESKFLOW_VERSION@ + NSHumanReadableCopyright + © 2012-@DESKFLOW_BUILD_YEAR@ Synergy App Ltd + LSMinimumSystemVersion + 10.9.0 + + diff --git a/extra/deploy/mac/bundle/Contents/PkgInfo.in b/extra/deploy/mac/bundle/Contents/PkgInfo.in new file mode 100644 index 000000000..c50e7b961 --- /dev/null +++ b/extra/deploy/mac/bundle/Contents/PkgInfo.in @@ -0,0 +1 @@ +APPL@DESKFLOW_MAC_BUNDLE_CODE@ \ No newline at end of file diff --git a/extra/deploy/mac/bundle/Contents/Resources/Background.tiff b/extra/deploy/mac/bundle/Contents/Resources/Background.tiff new file mode 100644 index 000000000..e13ab35b3 Binary files /dev/null and b/extra/deploy/mac/bundle/Contents/Resources/Background.tiff differ diff --git a/extra/deploy/mac/bundle/Contents/Resources/Synergy.icns b/extra/deploy/mac/bundle/Contents/Resources/Synergy.icns new file mode 100644 index 000000000..8fc714750 Binary files /dev/null and b/extra/deploy/mac/bundle/Contents/Resources/Synergy.icns differ diff --git a/extra/deploy/mac/bundle/Contents/Resources/Volume.icns b/extra/deploy/mac/bundle/Contents/Resources/Volume.icns new file mode 100644 index 000000000..0e165618f Binary files /dev/null and b/extra/deploy/mac/bundle/Contents/Resources/Volume.icns differ diff --git a/extra/deploy/mac/codesign.cmake.in b/extra/deploy/mac/codesign.cmake.in new file mode 100644 index 000000000..559aa30eb --- /dev/null +++ b/extra/deploy/mac/codesign.cmake.in @@ -0,0 +1,18 @@ +# SPDX-FileCopyrightText: (C) 2012 - 2026 Synergy App Ltd +# SPDX-License-Identifier: MIT + +# CPack pre-build script: sign the app bundle as the final packaging step, after +# every install() has populated it (frameworks, LICENSE, translations) and just +# before the DMG is built. Signing earlier and then adding files breaks the seal, +# which the notary service rejects with "the signature of the binary is invalid". + +set(app "${CPACK_TEMPORARY_INSTALL_DIRECTORY}/@CMAKE_PROJECT_PROPER_NAME@.app") + +execute_process( + COMMAND /usr/bin/codesign --force --deep --options runtime --timestamp + --sign "@APPLE_CODESIGN_ID@" "${app}" + RESULT_VARIABLE result +) +if(NOT result EQUAL 0) + message(FATAL_ERROR "codesign of the app bundle failed (${result})") +endif() diff --git a/extra/deploy/mac/dmg-background.tiff b/extra/deploy/mac/dmg-background.tiff new file mode 100644 index 000000000..e13ab35b3 Binary files /dev/null and b/extra/deploy/mac/dmg-background.tiff differ diff --git a/extra/deploy/mac/dmgbuild/settings.py b/extra/deploy/mac/dmgbuild/settings.py new file mode 100644 index 000000000..16ff4b8ad --- /dev/null +++ b/extra/deploy/mac/dmgbuild/settings.py @@ -0,0 +1,69 @@ +# -*- coding: utf-8 -*- + +# Example: https://dmgbuild.readthedocs.io/en/latest/example.html + +from __future__ import unicode_literals + +import os.path + +app = defines.get("app") +app_basename = os.path.basename(app) +format = defines.get("format", "UDBZ") +size = defines.get("size", None) +files = [app] +symlinks = {"Applications": "/Applications"} +icon = os.path.join(app, "Contents/Resources/Volume.icns") +icon_locations = { + app_basename: (144, 190), + "Applications": (455, 190), +} +background = os.path.join(app, "Contents/Resources/Background.tiff") +show_status_bar = False +show_tab_view = False +show_toolbar = False +show_pathbar = False +show_sidebar = False +sidebar_width = 180 +window_rect = ((200, 120), (620, 420)) +default_view = "icon-view" +show_icon_preview = False +include_icon_view_settings = "auto" +include_list_view_settings = "auto" +arrange_by = None +grid_offset = (0, 0) +grid_spacing = 100 +scroll_position = (0, 0) +label_pos = "bottom" +text_size = 16 +icon_size = 100 +list_icon_size = 16 +list_text_size = 12 +list_scroll_position = (0, 0) +list_sort_by = "name" +list_use_relative_dates = True +list_calculate_all_sizes = (False,) +list_columns = ("name", "date-modified", "size", "kind", "date-added") +list_column_widths = { + "name": 300, + "date-modified": 181, + "date-created": 181, + "date-added": 181, + "date-last-opened": 181, + "size": 97, + "kind": 115, + "label": 100, + "version": 75, + "comments": 300, +} +list_column_sort_directions = { + "name": "ascending", + "date-modified": "descending", + "date-created": "descending", + "date-added": "descending", + "date-last-opened": "descending", + "size": "descending", + "kind": "ascending", + "label": "ascending", + "version": "ascending", + "comments": "ascending", +} diff --git a/extra/deploy/mac/synergy-eula.txt b/extra/deploy/mac/synergy-eula.txt new file mode 100644 index 000000000..f41809d3f --- /dev/null +++ b/extra/deploy/mac/synergy-eula.txt @@ -0,0 +1 @@ +By using this software you agree to the Synergy End User License Agreement (EULA). diff --git a/extra/deploy/windows/synergy-eula.rtf b/extra/deploy/windows/synergy-eula.rtf new file mode 100644 index 000000000..630be2c27 --- /dev/null +++ b/extra/deploy/windows/synergy-eula.rtf @@ -0,0 +1,4 @@ +{\rtf1\ansi\ansicpg1252\deff0{\fonttbl{\f0\fnil\fcharset0 Segoe UI;}} +\fs20 +By using this software you agree to the Synergy End User License Agreement (EULA).\par +} diff --git a/extra/deploy/windows/wix-banner.png b/extra/deploy/windows/wix-banner.png new file mode 100644 index 000000000..3b39f0fea Binary files /dev/null and b/extra/deploy/windows/wix-banner.png differ diff --git a/extra/deploy/windows/wix-dialog.png b/extra/deploy/windows/wix-dialog.png new file mode 100644 index 000000000..a1c5e5bd1 Binary files /dev/null and b/extra/deploy/windows/wix-dialog.png differ diff --git a/extra/deploy/windows/wix-dialog.svg b/extra/deploy/windows/wix-dialog.svg new file mode 100644 index 000000000..758adeef5 --- /dev/null +++ b/extra/deploy/windows/wix-dialog.svg @@ -0,0 +1,4 @@ + + + + diff --git a/extra/src/CMakeLists.txt b/extra/src/CMakeLists.txt new file mode 100644 index 000000000..1cde58922 --- /dev/null +++ b/extra/src/CMakeLists.txt @@ -0,0 +1,8 @@ +# SPDX-FileCopyrightText: (C) 2012 - 2026 Synergy App Ltd +# SPDX-License-Identifier: MIT + +add_subdirectory(lib) + +if(BUILD_TESTS) + add_subdirectory(unittests) +endif() diff --git a/extra/src/apps/res/image/logo-dark.png b/extra/src/apps/res/image/logo-dark.png new file mode 100644 index 000000000..456e75eae Binary files /dev/null and b/extra/src/apps/res/image/logo-dark.png differ diff --git a/extra/src/apps/res/image/logo-light.png b/extra/src/apps/res/image/logo-light.png new file mode 100644 index 000000000..d0d640982 Binary files /dev/null and b/extra/src/apps/res/image/logo-light.png differ diff --git a/extra/src/apps/res/synergy-dark.theme b/extra/src/apps/res/synergy-dark.theme new file mode 100644 index 000000000..534eaab75 --- /dev/null +++ b/extra/src/apps/res/synergy-dark.theme @@ -0,0 +1,12 @@ +[Icon Theme] +Name=synergy-dark +Inherits=deskflow-dark + +Directories=apps/64 + +[apps/64] +Size=64 +Context=Applications +Type=Scalable +MinSize=22 +MaxSize=256 diff --git a/extra/src/apps/res/synergy-light.theme b/extra/src/apps/res/synergy-light.theme new file mode 100644 index 000000000..061a89bf8 --- /dev/null +++ b/extra/src/apps/res/synergy-light.theme @@ -0,0 +1,12 @@ +[Icon Theme] +Name=synergy-light +Inherits=deskflow-light + +Directories=apps/64 + +[apps/64] +Size=64 +Context=Applications +Type=Scalable +MinSize=22 +MaxSize=256 diff --git a/extra/src/apps/res/synergy-symbolic-dark.svg b/extra/src/apps/res/synergy-symbolic-dark.svg new file mode 100644 index 000000000..10909340e --- /dev/null +++ b/extra/src/apps/res/synergy-symbolic-dark.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/extra/src/apps/res/synergy-symbolic-light.svg b/extra/src/apps/res/synergy-symbolic-light.svg new file mode 100644 index 000000000..c08f6a86e --- /dev/null +++ b/extra/src/apps/res/synergy-symbolic-light.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/extra/src/apps/res/synergy.ico b/extra/src/apps/res/synergy.ico new file mode 100644 index 000000000..b35873027 Binary files /dev/null and b/extra/src/apps/res/synergy.ico differ diff --git a/extra/src/apps/res/synergy.png b/extra/src/apps/res/synergy.png new file mode 100644 index 000000000..15da1fa2e Binary files /dev/null and b/extra/src/apps/res/synergy.png differ diff --git a/extra/src/apps/res/synergy.qrc b/extra/src/apps/res/synergy.qrc new file mode 100644 index 000000000..b2bbcf042 --- /dev/null +++ b/extra/src/apps/res/synergy.qrc @@ -0,0 +1,12 @@ + + + image/logo-dark.png + image/logo-light.png + synergy-dark.theme + synergy-light.theme + synergy.svg + synergy-symbolic-dark.svg + synergy.svg + synergy-symbolic-light.svg + + diff --git a/extra/src/apps/res/synergy.svg b/extra/src/apps/res/synergy.svg new file mode 100644 index 000000000..dbbe9bc49 --- /dev/null +++ b/extra/src/apps/res/synergy.svg @@ -0,0 +1,23 @@ + + + + + + image/svg+xml + + + + + + + + + + + + + diff --git a/extra/src/integtests/CMakeLists.txt b/extra/src/integtests/CMakeLists.txt new file mode 100644 index 000000000..d68fb0b15 --- /dev/null +++ b/extra/src/integtests/CMakeLists.txt @@ -0,0 +1,19 @@ +# 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 . + +config_test() +set(target ${INTEG_TESTS_BIN}) +add_executable(${target} ${sources}) +target_link_libraries(${target} ${test_libs}) diff --git a/extra/src/lib/CMakeLists.txt b/extra/src/lib/CMakeLists.txt new file mode 100644 index 000000000..5b0c49c31 --- /dev/null +++ b/extra/src/lib/CMakeLists.txt @@ -0,0 +1,4 @@ +# SPDX-FileCopyrightText: (C) 2012 - 2026 Synergy App Ltd +# SPDX-License-Identifier: MIT + +add_subdirectory(synergy) diff --git a/extra/src/lib/synergy/CMakeLists.txt b/extra/src/lib/synergy/CMakeLists.txt new file mode 100644 index 000000000..ed29bec0f --- /dev/null +++ b/extra/src/lib/synergy/CMakeLists.txt @@ -0,0 +1,19 @@ +# 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 . + +add_subdirectory(license) +if(BUILD_GUI) + add_subdirectory(gui) +endif() diff --git a/extra/src/lib/synergy/build_config.h b/extra/src/lib/synergy/build_config.h new file mode 100644 index 000000000..84492ddc2 --- /dev/null +++ b/extra/src/lib/synergy/build_config.h @@ -0,0 +1,35 @@ +/* + * 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 + +// Single point of translation from cmake-defined preprocessor macros to +// typed C++ constants. Code elsewhere should consume these constants and +// never reference SYNERGY_* macros directly. Keeps the macro surface +// contained and makes the build-flavor knobs greppable. + +namespace synergy { + +constexpr auto kDisplayName = SYNERGY_DISPLAY_NAME; + +#ifdef SYNERGY_VERSION_DEV +constexpr bool kIsDevBuild = true; +#else +constexpr bool kIsDevBuild = false; +#endif + +} // namespace synergy diff --git a/extra/src/lib/synergy/gui/ActivationDialog.cpp b/extra/src/lib/synergy/gui/ActivationDialog.cpp new file mode 100644 index 000000000..5b4827c7f --- /dev/null +++ b/extra/src/lib/synergy/gui/ActivationDialog.cpp @@ -0,0 +1,232 @@ +/* + * 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 "ActivationDialog.h" + +#include "CancelActivationDialog.h" +#include "common/Settings.h" +#include "synergy/gui/TestSettings.h" +#include "synergy/gui/constants.h" +#include "synergy/gui/license/LicenseHandler.h" +#include "synergy/gui/license/license_notices.h" +#include "synergy/gui/styles.h" +#include "synergy/license/parse_serial_key.h" +#include "ui_ActivationDialog.h" + +#include +#include +#include +#include +#include +#include +#include + +using namespace deskflow::gui; +using namespace synergy::gui; +using namespace synergy::license; + +const QString successTitle = "Serial key"; +const QString problemTitle = "Serial key problem"; + +ActivationDialog::ActivationDialog(QWidget *parent, LicenseHandler &licenseHandler) + : QDialog(parent), + m_ui(new Ui::ActivationDialog), + m_licenseHandler(licenseHandler) +{ + m_ui->setupUi(this); + + m_ui->m_pLabelNotice->setStyleSheet(kStyleNoticeLabel); + m_ui->m_pTextEditSerialKey->setFont(QFontDatabase::systemFont(QFontDatabase::FixedFont)); + + refreshSerialKey(); +} + +ActivationDialog::~ActivationDialog() +{ + delete m_ui; +} + +void ActivationDialog::refreshSerialKey() +{ + const QString testSerialKey = TestSettings::instance().serialKey(); + if (!testSerialKey.isEmpty()) { + qDebug("using serial key from test settings"); + m_ui->m_pTextEditSerialKey->setText(testSerialKey); + } else { + qDebug("using serial key from config"); + const auto hexString = m_licenseHandler.license().serialKey().hexString; + m_ui->m_pTextEditSerialKey->setText(QString::fromStdString(hexString)); + } + + m_ui->m_pTextEditSerialKey->setFocus(); + m_ui->m_pTextEditSerialKey->moveCursor(QTextCursor::End); + + const auto &license = m_licenseHandler.license(); + if (license.isTimeLimited() && (license.isExpired() || license.isExpiringSoon())) { + m_ui->m_pLabelNotice->setText(licenseNotice(license, kColorWhite)); + m_ui->m_widgetNotice->show(); + } else { + m_ui->m_widgetNotice->hide(); + } +} + +void ActivationDialog::showEvent(QShowEvent *event) +{ + QDialog::showEvent(event); + + QTimer::singleShot(0, this, [this]() { + const auto &license = m_licenseHandler.license(); + if (license.isTimeLimited()) { + const auto notice = licenseNotice(license, kColorSecondary); + if (license.isExpired()) { + QMessageBox::warning( + this, "License expired", + tr("%1" + "

The application will now stop working. Please renew your license today to continue using the " + "application.

" + "

Once you have received your new serial key, you can enter it on the next screen.

") + .arg(notice) + ); + } else if (license.isExpiringSoon()) { + QMessageBox::warning( + this, "License expiring soon", + tr("%1" + "

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) + ); + break; + + case kExpired: + QMessageBox::warning( + this, problemTitle, + QString( + "Sorry, that serial key has expired. " + R"(Please renew your license.)" + ) + .arg(kUrlContact) + ); + break; + + default: + qCritical("unexpected change serial key result: %d", static_cast(result)); + break; + } +} + +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:

" + "%2" + ) + .arg(kUrlContact) + .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..b54203cf7 --- /dev/null +++ b/extra/src/lib/synergy/gui/ActivationDialog.h @@ -0,0 +1,65 @@ +/* + * 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 "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..2a2f443f3 --- /dev/null +++ b/extra/src/lib/synergy/gui/ActivationDialog.ui @@ -0,0 +1,143 @@ + + + ActivationDialog + + + + 0 + 0 + 541 + 330 + + + + Serial key + + + + + + + true + + + + Enter your serial key + + + + + + + <p>Your serial key is on your <a href="https://synergyapp.io/account?utm_source=gui-s1" style="color:#4285f4;">account</a> page.<br/>Don't have a license? <a href="https://synergyapp.io/contact?utm_source=gui-s1" style="color:#4285f4;">Contact us</a> or <a href="https://synergyapp.io/purchase?utm_source=gui-s1" style="color:#4285f4;">purchase</a> today.</p><p>Using Synergy on an offline or air-gapped network?<br/>Offline serial keys are <a href="https://synergyapp.io/contact?utm_source=gui-s1" style="color:#4285f4;">available on request</a>.</p> + + + true + + + true + + + + + + + true + + + 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..e15c2c16c --- /dev/null +++ b/extra/src/lib/synergy/gui/AppTime.cpp @@ -0,0 +1,49 @@ +/* + * 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 "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..3bb009624 --- /dev/null +++ b/extra/src/lib/synergy/gui/AppTime.h @@ -0,0 +1,39 @@ +/* + * 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 + +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..cdfeca2a6 --- /dev/null +++ b/extra/src/lib/synergy/gui/CMakeLists.txt @@ -0,0 +1,42 @@ +# 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 . + +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..466612d03 --- /dev/null +++ b/extra/src/lib/synergy/gui/CancelActivationDialog.cpp @@ -0,0 +1,35 @@ +/* + * 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 "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..9735e1b07 --- /dev/null +++ b/extra/src/lib/synergy/gui/CancelActivationDialog.h @@ -0,0 +1,36 @@ +/* + * 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 + +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..15cc6878f --- /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?utm_source=gui-s1"><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..6c9977478 --- /dev/null +++ b/extra/src/lib/synergy/gui/ExtraSettings.cpp @@ -0,0 +1,110 @@ +/* + * 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 "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 kLicenseGroup = QStringLiteral("license"); + +const auto kSerialKey = QStringLiteral("serialKey"); +const auto kActivated = QStringLiteral("activated"); +const auto kHoldsServerActivation = QStringLiteral("holdsServerActivation"); +const auto kGraceStart = QStringLiteral("graceStartEpochSecs"); +const auto kOfflineActivationResponse = QStringLiteral("offlineActivationResponse"); +const auto kLegacyActivationState = QStringLiteral("activationState"); + +} // namespace + +void ExtraSettings::load() +{ + QSettings ini(settingsFile(), QSettings::IniFormat); + + // License state moved from the top level into the license group; fall back to the old flat key + // so existing installs keep their serial key until the next sync rewrites it under the group. + const auto value = [&ini](const QString &key) { + const auto grouped = QStringLiteral("%1/%2").arg(kLicenseGroup, key); + return ini.contains(grouped) ? ini.value(grouped) : ini.value(key); + }; + + m_serialKey = value(kSerialKey).toString(); + m_activated = value(kActivated).toBool(); + m_holdsServerActivation = value(kHoldsServerActivation).toBool(); + m_graceStartEpochSecs = value(kGraceStart).toLongLong(); + m_offlineActivationResponse = value(kOfflineActivationResponse).toString(); +} + +void ExtraSettings::sync() +{ + QSettings ini(settingsFile(), QSettings::IniFormat); + if (!ini.isWritable()) { + qCritical() << "unable to save synergy settings, file not writable:" << ini.fileName(); + return; + } + + // Drop the old top-level keys (license state now lives under the license group) and the dead + // activationState left over from the enum. + for (const auto &key : + {kSerialKey, kActivated, kHoldsServerActivation, kGraceStart, kOfflineActivationResponse, + kLegacyActivationState}) { + ini.remove(key); + } + + ini.beginGroup(kLicenseGroup); + ini.setValue(kSerialKey, m_serialKey); + ini.setValue(kActivated, m_activated); + ini.setValue(kHoldsServerActivation, m_holdsServerActivation); + ini.setValue(kGraceStart, m_graceStartEpochSecs); + ini.setValue(kOfflineActivationResponse, m_offlineActivationResponse); + ini.endGroup(); + 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..b7079fef2 --- /dev/null +++ b/extra/src/lib/synergy/gui/ExtraSettings.h @@ -0,0 +1,90 @@ +/* + * 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 + +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; + } + + bool holdsServerActivation() const + { + return m_holdsServerActivation; + } + void setHoldsServerActivation(bool holdsServerActivation) + { + m_holdsServerActivation = holdsServerActivation; + } + + qint64 graceStartEpochSecs() const + { + return m_graceStartEpochSecs; + } + void setGraceStartEpochSecs(qint64 epochSecs) + { + m_graceStartEpochSecs = epochSecs; + } + + QString offlineActivationResponse() const + { + return m_offlineActivationResponse; + } + void setOfflineActivationResponse(const QString &response) + { + m_offlineActivationResponse = response; + } + + QString fileName() const; + bool isWritable() const; + +private: + QString m_serialKey; + bool m_activated = false; + bool m_holdsServerActivation = false; + qint64 m_graceStartEpochSecs = 0; + QString m_offlineActivationResponse; +}; + +} // 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..e153bf206 --- /dev/null +++ b/extra/src/lib/synergy/gui/FeatureHandler.cpp @@ -0,0 +1,367 @@ +/* + * Synergy -- mouse and keyboard sharing utility + * Copyright (C) 2025 - 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 "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/UpdateChannel.h" +#include "synergy/gui/constants.h" +#include "synergy/gui/styles.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#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; + styleUpdateNotice(mainWindow); +} + +void FeatureHandler::styleUpdateNotice(QMainWindow *mainWindow) const +{ + // Reuse the license-expiry notice pill (kStyleNoticeLabel) so the upstream + // "update available" status-bar button reads identically to it. + auto *button = mainWindow->findChild(QStringLiteral("btnUpdate")); + if (button == nullptr) { + qWarning("update notice: no btnUpdate in status bar, skipping styling"); + return; + } + button->setStyleSheet(kStyleNoticeLabel); +} + +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(); + } + + addUpdateChannelOption(parent); + + const auto &licenseHandler = LicenseHandler::instance(); + if (licenseHandler.isEnabled() && !licenseHandler.license().isSettingsScopeAvailable()) { + return; + } + addScopeTab(parent); +} + +// The helpers below reach into upstream's AboutDialog.ui by object name and grid +// coordinate. Those references are upstream's, not ours, so they look like magic +// strings/numbers: the logo, version and license links share "gridLayout", where +// row 1 is the logo, row 3 the version and row 6 a trailing spacer. + +void FeatureHandler::handleAbout(QDialog *parent) const +{ + if (parent == nullptr) { + return; + } + addTagline(parent); + setAttribution(parent); + tightenVersionRow(parent); + addBuildDate(parent); + addLicenseLinks(parent); + addTrademark(parent); +} + +void FeatureHandler::addTagline(QDialog *parent) const +{ + auto *grid = parent->findChild(QStringLiteral("gridLayout")); + if (grid == nullptr) { + return; + } + // Row 2 is empty, so the tagline sits under the logo; the margin is its gap to the version. + auto *tagline = new QLabel(QObject::tr("Unite every computer on your desk"), parent); + tagline->setContentsMargins(0, 0, 0, 16); + grid->addWidget(tagline, 2, 1); + + // Collapse the trailing spacer to tighten the gap to the section below. + if (auto *item = grid->itemAtPosition(6, 1); item != nullptr && item->spacerItem() != nullptr) { + item->spacerItem()->changeSize(0, 0); + grid->invalidate(); + } +} + +void FeatureHandler::setAttribution(QDialog *parent) const +{ + auto *description = parent->findChild(QStringLiteral("lblDescription")); + if (description == nullptr) { + return; + } + // Dim via alpha on the theme foreground (not a fixed grey) so it adapts to light/dark. + description->setWordWrap(true); + auto font = description->font(); + font.setPointSizeF(font.pointSizeF() * 0.85); + description->setFont(font); + auto palette = description->palette(); + auto color = palette.color(QPalette::WindowText); + color.setAlphaF(0.6); + palette.setColor(QPalette::WindowText, color); + description->setPalette(palette); + description->setText( + QObject::tr( + "Synergy was originally created by Chris Schoeneman. Thanks to our contributors " + "and the open source projects Synergy builds on, including Deskflow, Qt, OpenSSL, " + "and many others." + ) + ); +} + +void FeatureHandler::tightenVersionRow(QDialog *parent) const +{ + // The .ui pins lblVersion to a fixed point size, so it renders out of step with its + // "Version:" sibling on systems whose default UI font differs; match the sibling instead. + auto *prefix = parent->findChild(QStringLiteral("label")); + auto *version = parent->findChild(QStringLiteral("lblVersion")); + if (prefix != nullptr && version != nullptr) { + version->setFont(prefix->font()); + } + + // The copy button is taller than the version text and padded the centred row; cap it. + if (auto *copyButton = parent->findChild(QStringLiteral("btnCopyVersion"))) { + copyButton->setMaximumHeight(copyButton->fontMetrics().height() + 4); + } +} + +void FeatureHandler::addBuildDate(QDialog *parent) const +{ + auto *version = parent->findChild(QStringLiteral("lblVersion")); + if (version == nullptr) { + return; + } + + // Compile date of this file; close enough to the build date since release + // builds always compile from scratch in CI. + const auto date = QLocale::c().toDate(QStringLiteral(__DATE__).simplified(), QStringLiteral("MMM d yyyy")); + if (!date.isValid()) { + return; + } + version->setText(QStringLiteral("%1, built %2").arg(version->text(), date.toString(Qt::ISODate))); +} + +void FeatureHandler::addLicenseLinks(QDialog *parent) const +{ + // Upstream shows only the GPL link; add the EULA alongside it. + if (auto *linkGpl = parent->findChild(QStringLiteral("linkGpl"))) { + const auto eula = QString(kLink).arg(kUrlEula, kColorSecondary, QObject::tr("End User License Agreement")); + const auto gpl = QString(kLink).arg(kUrlGpl, kColorSecondary, QObject::tr("License: GNU GPL Version 2")); + linkGpl->setText(QStringLiteral("%1  |  %2").arg(eula, gpl)); + } +} + +void FeatureHandler::addTrademark(QDialog *parent) const +{ + if (auto *copyright = parent->findChild(QStringLiteral("lblCopyright"))) { + copyright->setText( + copyright->text() + QStringLiteral("\n") + QObject::tr("The Synergy logo is a trademark of Synergy App Ltd") + ); + } +} + +static 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)")); +} + +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")); +} + +void FeatureHandler::addUpdateChannelOption(QDialog *parent) const +{ + auto *autoUpdate = parent->findChild(QStringLiteral("cbAutoUpdate")); + if (autoUpdate == nullptr) { + qWarning("update channel: no cbAutoUpdate in settings dialog, skipping"); + return; + } + + auto *page = autoUpdate->parentWidget(); + auto *pageLayout = page ? qobject_cast(page->layout()) : nullptr; + const int checkboxIndex = pageLayout ? pageLayout->indexOf(autoUpdate) : -1; + if (checkboxIndex < 0) { + qWarning("update channel: unexpected update-checkbox layout, skipping"); + return; + } + + auto *row = new QHBoxLayout(); + auto *label = new QLabel(QObject::tr("Update channel"), page); + auto *combo = new QComboBox(page); + combo->addItem(QObject::tr("Stable"), UpdateChannel::Stable); + combo->addItem(QObject::tr("Beta"), UpdateChannel::Beta); + combo->setCurrentIndex(combo->findData(UpdateChannel::current())); + label->setBuddy(combo); + row->setContentsMargins(22, 0, 0, 0); + row->addWidget(label); + row->addWidget(combo); + row->addStretch(); + pageLayout->insertLayout(checkboxIndex + 1, row); + + // The channel only affects the on-startup check, so mirror the checkbox's + // enabled state; the checkbox itself is disabled when settings aren't writable. + const auto syncEnabled = [label, combo](bool on) { + label->setEnabled(on); + combo->setEnabled(on); + }; + syncEnabled(autoUpdate->isChecked() && autoUpdate->isEnabled()); + QObject::connect(autoUpdate, &QCheckBox::toggled, combo, syncEnabled); + + // The injected combo isn't part of the dialog's isModified() check, so changing + // it would never enable Save. Enable it directly on change, and persist on the + // dialog's accept() (not on change) so Cancel still discards. + auto *buttonBox = parent->findChild(); + if (auto *saveButton = buttonBox ? buttonBox->button(QDialogButtonBox::Save) : nullptr) { + QObject::connect(combo, &QComboBox::currentIndexChanged, saveButton, [saveButton]() { + saveButton->setEnabled(true); + }); + } + + QObject::connect(parent, &QDialog::accepted, combo, [combo]() { + UpdateChannel::setCurrent(combo->currentData().toString()); + }); +} diff --git a/extra/src/lib/synergy/gui/FeatureHandler.h b/extra/src/lib/synergy/gui/FeatureHandler.h new file mode 100644 index 000000000..3c6059d53 --- /dev/null +++ b/extra/src/lib/synergy/gui/FeatureHandler.h @@ -0,0 +1,50 @@ +/* + * Synergy -- mouse and keyboard sharing utility + * Copyright (C) 2025 - 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; +class QMainWindow; + +class FeatureHandler +{ +public: + static FeatureHandler &instance() + { + static FeatureHandler instance; + return instance; + } + + void handleMainWindow(QMainWindow *mainWindow); + void handleAppStart(); + void handleSettings(QDialog *parent) const; + void handleAbout(QDialog *parent) const; + +private: + void addTestMenu(); + void styleUpdateNotice(QMainWindow *mainWindow) const; + void addScopeTab(QDialog *parent) const; + void addUpdateChannelOption(QDialog *parent) const; + void addTagline(QDialog *parent) const; + void setAttribution(QDialog *parent) const; + void tightenVersionRow(QDialog *parent) const; + void addBuildDate(QDialog *parent) const; + void addLicenseLinks(QDialog *parent) const; + void addTrademark(QDialog *parent) const; + + QMainWindow *m_pMainWindow = nullptr; +}; diff --git a/extra/src/lib/synergy/gui/OfflineActivationDialog.cpp b/extra/src/lib/synergy/gui/OfflineActivationDialog.cpp new file mode 100644 index 000000000..e44c0d95f --- /dev/null +++ b/extra/src/lib/synergy/gui/OfflineActivationDialog.cpp @@ -0,0 +1,97 @@ +/* + * 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 "OfflineActivationDialog.h" + +#include "synergy/gui/constants.h" +#include "synergy/gui/license/LicenseHandler.h" + +#include +#include +#include +#include +#include +#include +#include + +using namespace synergy::gui; + +OfflineActivationDialog::OfflineActivationDialog(QWidget *parent, LicenseHandler &licenseHandler) + : QDialog(parent), + m_licenseHandler(licenseHandler) +{ + setWindowTitle(tr("Offline activation")); + setMinimumWidth(440); + + const auto layout = new QVBoxLayout(this); + + const auto infoLabel = new QLabel( + tr(R"(On a device with internet access, enter the activation code at )" + R"(your account page to get a response code.)") + .arg(kUrlAccount), + this + ); + infoLabel->setWordWrap(true); + infoLabel->setOpenExternalLinks(true); + layout->addWidget(infoLabel); + + const auto form = new QFormLayout(); + + const auto challengeEdit = new QLineEdit(m_licenseHandler.offlineActivationChallenge(), this); + challengeEdit->setReadOnly(true); + challengeEdit->setCursorPosition(0); + form->addRow(tr("Activation code"), challengeEdit); + + m_pResponseEdit = new QLineEdit(this); + form->addRow(tr("Response code"), m_pResponseEdit); + + layout->addLayout(form); + + const auto buttons = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this); + buttons->button(QDialogButtonBox::Ok)->setText(tr("Activate")); + connect(buttons, &QDialogButtonBox::accepted, this, &OfflineActivationDialog::accept); + connect(buttons, &QDialogButtonBox::rejected, this, &OfflineActivationDialog::reject); + layout->addWidget(buttons); + + m_pResponseEdit->setFocus(); +} + +void OfflineActivationDialog::accept() +{ + const auto response = m_pResponseEdit->text(); + if (response.trimmed().isEmpty()) { + QMessageBox::information(this, tr("Offline activation"), tr("Please enter the response code.")); + return; + } + + if (!m_licenseHandler.applyOfflineActivationResponse(response)) { + QMessageBox::warning( + this, tr("Offline activation"), + tr("

That response code is not valid for this computer.

" + "

Please check that you typed the whole response code correctly, and that " + "the activation code was generated on this computer. " + R"(If the problem persists, please contact us.

)") + .arg(kUrlContact) + ); + return; + } + + QMessageBox::information( + this, tr("Offline activation"), tr("Thanks, your license is now activated on this computer.") + ); + QDialog::accept(); +} diff --git a/extra/src/lib/synergy/gui/OfflineActivationDialog.h b/extra/src/lib/synergy/gui/OfflineActivationDialog.h new file mode 100644 index 000000000..ba4a147ff --- /dev/null +++ b/extra/src/lib/synergy/gui/OfflineActivationDialog.h @@ -0,0 +1,45 @@ +/* + * 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 + +class LicenseHandler; +class QLineEdit; + +/** + * @brief Walks the customer through offline challenge-response activation. + * + * Shows the machine-bound challenge code the customer exchanges for a response + * code on their account page, and accepts that response code. Accepted only once + * the response verifies, after which it is persisted by the license handler. + */ +class OfflineActivationDialog : public QDialog +{ + Q_OBJECT + +public: + OfflineActivationDialog(QWidget *parent, LicenseHandler &licenseHandler); + +public Q_SLOTS: + void accept() override; + +private: + LicenseHandler &m_licenseHandler; + QLineEdit *m_pResponseEdit = 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.

" + ) + .arg(QApplication::applicationName(), Settings::SystemSettingFile) + ); + return false; + } + + Settings::save(); + + const auto destFile = toSystem ? Settings::SystemSettingFile : Settings::UserSettingFile; + const auto sourceFile = toSystem ? Settings::UserSettingFile : Settings::SystemSettingFile; + + QSettings dest(destFile, QSettings::IniFormat); + if (dest.allKeys().isEmpty()) { + qDebug() << "destination settings empty, copying from:" << sourceFile; + QSettings source(sourceFile, QSettings::IniFormat); + for (const auto &key : source.allKeys()) { + dest.setValue(key, source.value(key)); + } + dest.sync(); + } + + setPreferSystem(toSystem); + + const auto choice = QMessageBox::information( + parent, QObject::tr("Restart required"), + QObject::tr( + "

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..f684ad7f3 --- /dev/null +++ b/extra/src/lib/synergy/gui/TestSettings.cpp @@ -0,0 +1,107 @@ +/* + * 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 +#include +#include + +namespace synergy::gui { + +namespace { + +QString resolveFileName() +{ + const auto name = QStringLiteral("%1.test.conf").arg(kAppName); + const auto fallback = QStringLiteral("%1/%2").arg(Settings::UserDir, name); + + // applicationDirPath() warns if called before the app object exists + if (QCoreApplication::instance() == nullptr) { + return fallback; + } + + QDir dir(QCoreApplication::applicationDirPath()); + do { + if (dir.exists(name)) { + return dir.filePath(name); + } + } while (dir.cdUp()); + return fallback; +} + +} // namespace + +TestSettings &TestSettings::instance() +{ + static TestSettings inst; + return inst; +} + +TestSettings::TestSettings() +{ + load(); +} + +void TestSettings::load() +{ + m_fileName = resolveFileName(); + m_enabled = false; + m_licensing = false; + m_serialKey.clear(); + m_apiUrlBase.clear(); + m_machineId.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_apiUrlBase = ini.value(QStringLiteral("test/apiUrlBase")).toString(); + m_machineId = ini.value(QStringLiteral("test/machineId")).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(); +} + +} // 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..1f945a1da --- /dev/null +++ b/extra/src/lib/synergy/gui/TestSettings.h @@ -0,0 +1,112 @@ +/* + * 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. The config file is `${kAppName}.test.conf`, +// found by walking up from the application binary's directory: a git-ignored +// copy at the repo root covers any build dir inside the tree and survives +// wipes of the app settings dir. When nothing is found above the binary +// (e.g. installed builds), falls back to `${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. +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; empty / 0 unless isEnabled(). + QString serialKey() const + { + return m_serialKey; + } + QString apiUrlBase() const + { + return m_apiUrlBase; + } + QString machineId() const + { + return m_machineId; + } + qint64 startTimeEpochSecs() const + { + return m_startTimeEpochSecs; + } + + // Feature toggles read from the [features] section. + 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_apiUrlBase; + QString m_machineId; + 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/UpdateChannel.cpp b/extra/src/lib/synergy/gui/UpdateChannel.cpp new file mode 100644 index 000000000..b018d349a --- /dev/null +++ b/extra/src/lib/synergy/gui/UpdateChannel.cpp @@ -0,0 +1,62 @@ +/* + * 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 "UpdateChannel.h" + +#include "common/Constants.h" +#include "common/Settings.h" + +#include + +namespace synergy::gui { + +namespace { + +const auto kChannelKey = QStringLiteral("update/channel"); + +QString extraFile() +{ + return QStringLiteral("%1/%2.extra.conf").arg(Settings::UserDir, kAppName); +} + +} // namespace + +QString UpdateChannel::current() +{ + QSettings extra(extraFile(), QSettings::IniFormat); + const auto channel = extra.value(kChannelKey, Stable).toString(); + return channel == Beta ? Beta : Stable; +} + +void UpdateChannel::setCurrent(const QString &channel) +{ + QSettings extra(extraFile(), QSettings::IniFormat); + extra.setValue(kChannelKey, channel == Beta ? Beta : Stable); + extra.sync(); +} + +void UpdateChannel::applyToVersionCheckUrl(QString &url) +{ + const auto channel = current(); + if (channel == Stable) { + return; + } + + url += (url.contains(QLatin1Char('?')) ? QLatin1Char('&') : QLatin1Char('?')) + QStringLiteral("track=") + channel; +} + +} // namespace synergy::gui diff --git a/extra/src/lib/synergy/gui/UpdateChannel.h b/extra/src/lib/synergy/gui/UpdateChannel.h new file mode 100644 index 000000000..061e4ee00 --- /dev/null +++ b/extra/src/lib/synergy/gui/UpdateChannel.h @@ -0,0 +1,56 @@ +/* + * 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 { + +/** + * @brief The update channel (stable/beta) the user receives version notifications for. + * + * Synergy-only: upstream Deskflow has no track/channel concept, so this lives in the + * overlay and persists to Synergy.extra.conf rather than upstream's allow-listed + * settings file. The choice is sent to the version server as a `track=` query param. + */ +class UpdateChannel +{ +public: + // Persisted/UI values; also the track names the version server expects. + inline static const QString Stable = QStringLiteral("stable"); + inline static const QString Beta = QStringLiteral("beta"); + + /** + * @brief The user's chosen channel; defaults to Stable when unset. + */ + static QString current(); + + /** + * @brief Records the user's channel choice. + */ + static void setCurrent(const QString &channel); + + /** + * @brief Appends `track=` to the version-check URL for non-stable + * channels (the param the version server expects, matching the 1.21 line), + * leaving stable requests byte-identical so the server needs no change. + */ + static void applyToVersionCheckUrl(QString &url); +}; + +} // 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..3cc865169 --- /dev/null +++ b/extra/src/lib/synergy/gui/constants.h @@ -0,0 +1,45 @@ +/* + * 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 // IWYU pragma: keep -- clangd wrongly thinks this is unused + +namespace synergy::gui { + +const auto kUrlApi = "https://symless.com/synergy/api"; +const auto kUrlWebsite = QStringLiteral("https://synergyapp.io"); +const auto kUrlSourceQuery = "utm_source=gui-s1"; + +const auto kUrlGpl = QStringLiteral("https://www.gnu.org/licenses/old-licenses/gpl-2.0.html"); +const auto kUrlEula = QString("%1/eula").arg(kUrlWebsite); + +const auto kLink = R"(%3)"; +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 kUrlAccount = QString("%1/account?%2").arg(kUrlWebsite, kUrlSourceQuery); + +constexpr auto kLicenseGracePeriod = std::chrono::days{14}; +constexpr auto kRemoteCheckInterval = std::chrono::hours{24}; + +} // 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..6a2531b38 --- /dev/null +++ b/extra/src/lib/synergy/gui/dev_mode.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 "common/VersionInfo.h" +#include "synergy/build_config.h" + +#include +#include +#include + +namespace synergy::gui { + +// Extracts the release stage (e.g. "beta") from the composed version string. +// kVersion is "X.Y.Z[-STAGE][-dev|-snapshot+rN]", so the stage is the first +// token after the base semver that isn't a build-type marker. +inline QString versionStage() +{ + const QStringList parts = QString::fromLatin1(kVersion).split('-'); + if (parts.size() < 2) { + return {}; + } + const QString &candidate = parts.at(1); + if (candidate == QStringLiteral("dev") || candidate.startsWith(QStringLiteral("snapshot"))) { + return {}; + } + return candidate; +} + +// Composes the main-window title. The stage / dev-build marker is always part +// of the name so a pre-release binary can't masquerade as stable; showVersion +// (the upstream "Include version in the window title" setting) gates only the +// trailing version-number suffix. +inline QString windowTitle(const QString &productName, bool showVersion) +{ + const QString stage = versionStage(); + const QString prettyStage = stage.isEmpty() ? QString() : stage.left(1).toUpper() + stage.mid(1); + + QString name = productName; + if constexpr (synergy::kIsDevBuild) { + name = prettyStage.isEmpty() ? QStringLiteral("%1 (Development Build)").arg(productName) + : QStringLiteral("%1 (%2 Development Build)").arg(productName, prettyStage); + } else if (!prettyStage.isEmpty()) { + name = QStringLiteral("%1 %2").arg(productName, prettyStage); + } + + if (showVersion) { + return QStringLiteral("%1 - %2").arg(name, kDisplayVersion); + } + return name; +} + +} // 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..f36e0ca61 --- /dev/null +++ b/extra/src/lib/synergy/gui/dialogs/UpgradeDialog.cpp @@ -0,0 +1,47 @@ +/* + * Synergy -- mouse and keyboard sharing utility + * Copyright (C) 2022 - 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 "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..848aaa4f0 --- /dev/null +++ b/extra/src/lib/synergy/gui/dialogs/UpgradeDialog.h @@ -0,0 +1,32 @@ +/* + * Synergy -- mouse and keyboard sharing utility + * Copyright (C) 2022 - 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 + +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..6c02f0db6 --- /dev/null +++ b/extra/src/lib/synergy/gui/license/LicenseApiClient.cpp @@ -0,0 +1,262 @@ +/* + * synergy -- mouse and keyboard sharing utility + * Copyright (C) 2022 - 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 "LicenseApiClient.h" + +#include "synergy/gui/TestSettings.h" +#include "synergy/gui/constants.h" + +#include +#include +#include + +namespace synergy::gui::license { + +constexpr auto kPropRequestKind = "requestKind"; +constexpr auto kPropActivationIntent = "activationIntent"; +constexpr auto kPropCheckIntent = "checkIntent"; + +QString apiBaseUrl() +{ + const auto testBase = TestSettings::instance().apiUrlBase(); + return testBase.isEmpty() ? QString::fromUtf8(kUrlApi) : testBase; +} + +QString activateUrl() +{ + return QStringLiteral("%1/product/activate").arg(apiBaseUrl()); +} + +QString checkUrl() +{ + return QStringLiteral("%1/product/check").arg(apiBaseUrl()); +} + +LicenseApiClient::LicenseApiClient() +{ + // Without a timeout, a request that connects but never completes leaves the client busy + // forever, and busy gates core start. + m_manager.setTransferTimeout(); + + connect(&m_manager, &QNetworkAccessManager::finished, this, &LicenseApiClient::handleResponse); +} + +void LicenseApiClient::activate(Data data, ActivationIntent intent, bool takeover) +{ + post(RequestKind::kActivate, QUrl(activateUrl()), data, intent, CheckIntent::kPoll, takeover); +} + +void LicenseApiClient::check(Data data, CheckIntent intent) +{ + post(RequestKind::kCheck, QUrl(checkUrl()), data, ActivationIntent::kCoreStart, intent); +} + +void LicenseApiClient::validate(Data data) +{ + post(RequestKind::kValidate, QUrl(checkUrl()), data); +} + +void LicenseApiClient::post( + RequestKind kind, const QUrl &url, const Data &data, ActivationIntent intent, CheckIntent checkIntent, + std::optional takeover +) +{ + m_isBusy = true; + + qDebug().noquote() << "license api request:" << url.toString(); + + auto request = QNetworkRequest(url); + request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json"); + + auto *reply = m_manager.post(request, getRequestData(data, takeover)); + reply->setProperty(kPropRequestKind, static_cast(kind)); + reply->setProperty(kPropActivationIntent, static_cast(intent)); + reply->setProperty(kPropCheckIntent, static_cast(checkIntent)); +} + +void LicenseApiClient::handleResponse(QNetworkReply *reply) +{ + m_isBusy = false; + + if (!reply) { + qWarning("no license api reply"); + return; + } + + const auto kind = static_cast(reply->property(kPropRequestKind).toInt()); + const auto intent = static_cast(reply->property(kPropActivationIntent).toInt()); + const auto checkIntent = static_cast(reply->property(kPropCheckIntent).toInt()); + + const auto emitFailed = [this, kind, intent](const QString &message) { + if (kind == RequestKind::kActivate) { + Q_EMIT activationFailed(intent, message); + } else if (kind == RequestKind::kValidate) { + Q_EMIT validateFailed(message); + } else { + Q_EMIT checkFailed(message); + } + }; + + const auto emitSucceeded = [this, kind, intent] { + if (kind == RequestKind::kActivate) { + Q_EMIT activationSucceeded(intent); + } else if (kind == RequestKind::kValidate) { + Q_EMIT validateSucceeded(); + } else { + Q_EMIT checkSucceeded(); + } + }; + + // A transport failure is not a license verdict; activation tolerates it and retries + // later, while the check path owns the grace period. Key entry can't validate offline, + // so it proceeds and lets the runtime check catch a bad key later. + const auto emitUnreachable = [this, kind, intent](const QString &message) { + if (kind == RequestKind::kActivate) { + Q_EMIT activationUnreachable(intent); + } else if (kind == RequestKind::kValidate) { + Q_EMIT validateSucceeded(); + } else { + Q_EMIT checkFailed(message); + } + }; + + 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; + emitUnreachable("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"); + emitUnreachable("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(); + + // Not a failure: the license is valid, another computer just holds the server activation. + if (status == "deactivated") { + qWarning("license api found this machine deactivated"); + if (kind == RequestKind::kActivate) { + Q_EMIT activationDeactivated(intent, message); + } else if (kind == RequestKind::kValidate) { + Q_EMIT validateDeactivated(message); + } else { + Q_EMIT checkDeactivated(checkIntent, message); + } + reply->deleteLater(); + return; + } + + // The license is valid but this machine has no activation row. At key entry that is + // expected (the row appears once the role is known); at runtime the app must (re)claim it. + if (status == "notActivated") { + if (kind == RequestKind::kValidate) { + Q_EMIT validateSucceeded(); + reply->deleteLater(); + return; + } + if (kind == RequestKind::kCheck) { + Q_EMIT checkNotActivated(); + reply->deleteLater(); + return; + } + } + + 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"); + } + + // An explicit disable gets the grace window like any other failure, not an abrupt cutoff. + if (!message.isEmpty()) { + emitFailed(message); + } else if (status == "disabled") { + emitFailed("License has been disabled."); + } 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, std::optional takeover) const +{ + if (data.machineSignature.isEmpty()) { + qCritical("cannot create license request, no machine id"); + return {}; + } + + if (data.hostnameSignature.isEmpty()) { + qCritical("cannot create license request, no hostname"); + return {}; + } + + if (data.serialKey.isEmpty()) { + qCritical("cannot create license request, no serial key"); + return {}; + } + + if (data.appVersion.isEmpty()) { + qCritical("cannot create license request, no app version"); + return {}; + } + + if (data.osName.isEmpty()) { + qCritical("cannot create license request, no os name"); + return {}; + } + + 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; + if (takeover.has_value()) { + requestData["takeover"] = takeover.value(); + } + + 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..4d0fdd645 --- /dev/null +++ b/extra/src/lib/synergy/gui/license/LicenseApiClient.h @@ -0,0 +1,108 @@ +/* + * synergy -- mouse and keyboard sharing utility + * Copyright (C) 2022 - 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 + +#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; + }; + + /// @brief Why an activation was requested, which decides whether its result starts the core. + enum class ActivationIntent + { + kKeyEntry, + kCoreStart + }; + + /// @brief Why a check was requested. A core start asks the takeover question on a deactivated + /// verdict regardless of how far the process has got; the recurring poll only acts on a server + /// that is actually running. + enum class CheckIntent + { + kPoll, + kCoreStart + }; + + explicit LicenseApiClient(); + + void activate(Data data, ActivationIntent intent, bool takeover = false); + void check(Data data, CheckIntent intent = CheckIntent::kPoll); + + // Validates a serial key without activating, for business key entry where the role is not yet + // known. Hits the check endpoint but stays out of the grace-period machinery the poll owns. + void validate(Data data); + + bool isBusy() + { + return m_isBusy; + } + +Q_SIGNALS: + void activationFailed(ActivationIntent intent, const QString &message); + void activationSucceeded(ActivationIntent intent); + void activationUnreachable(ActivationIntent intent); + void activationDeactivated(ActivationIntent intent, const QString &message); + void checkFailed(const QString &message); + void checkSucceeded(); + void checkDeactivated(CheckIntent intent, const QString &message); + void checkNotActivated(); + void validateFailed(const QString &message); + void validateSucceeded(); + void validateDeactivated(const QString &message); + +private Q_SLOTS: + void handleResponse(QNetworkReply *reply); + +private: + enum class RequestKind + { + kActivate, + kCheck, + kValidate + }; + + void post( + RequestKind kind, const QUrl &url, const Data &data, ActivationIntent intent = ActivationIntent::kCoreStart, + CheckIntent checkIntent = CheckIntent::kPoll, std::optional takeover = std::nullopt + ); + QByteArray getRequestData(const Data &data, std::optional takeover) const; + + QNetworkAccessManager m_manager; + bool m_isBusy = false; +}; + +} // 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..49bf5f1d7 --- /dev/null +++ b/extra/src/lib/synergy/gui/license/LicenseHandler.cpp @@ -0,0 +1,886 @@ +/* + * 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 . + */ + +#include "LicenseHandler.h" + +#include "ActivationDialog.h" +#include "OfflineActivationDialog.h" +#include "common/Settings.h" +#include "common/VersionInfo.h" +#include "gui/core/CoreProcess.h" +#include "synergy/gui/TestSettings.h" +#include "synergy/gui/constants.h" +#include "synergy/gui/dev_mode.h" +#include "synergy/gui/license/license_utils.h" +#include "synergy/license/OfflineActivation.h" +#include "synergy/license/Product.h" + +#include +#include +#include +#include +#include +#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 ActivationIntent = LicenseApiClient::ActivationIntent; +using CheckIntent = LicenseApiClient::CheckIntent; + +namespace { + +std::string licenseMachineId() +{ + const auto testId = TestSettings::instance().machineId(); + return testId.isEmpty() ? QSysInfo::machineUniqueId().toStdString() : testId.toStdString(); +} + +QByteArray anonymousSignature(const QByteArray &value) +{ + return QCryptographicHash::hash(value, QCryptographicHash::Sha256).toHex(); +} + +} // namespace +using License = synergy::license::License; + +LicenseHandler::LicenseHandler() +{ + m_enabled = synergy::gui::license::isActivationEnabled(); + + connect( + &m_apiClient, &LicenseApiClient::activationFailed, this, + [this](ActivationIntent intent, const QString &message) { + handleLicenseUnverified(message); + + if (intent == ActivationIntent::kKeyEntry) { + qWarning("activation failed at key entry, showing serial key dialog"); + showSerialKeyDialog(); + } + } + ); + + connect(&m_apiClient, &LicenseApiClient::activationSucceeded, this, [this](ActivationIntent intent) { + qDebug("license activation succeeded, saving settings"); + const bool holdsServerActivation = + m_license.productEdition() == Product::Edition::kBusiness && liveCoreMode() == Settings::Server; + m_settings.setActivated(true); + m_settings.setHoldsServerActivation(holdsServerActivation); + resetGracePeriod(); + + if (m_pCoreProcess == nullptr) { + qCritical("core process not set"); + return; + } + + // Key entry activations must never start the core; the customer chose nothing yet. + if (intent == ActivationIntent::kCoreStart && + m_pCoreProcess->processState() == deskflow::core::ProcessState::Stopped) { + qDebug("resuming core process after activation"); + m_pCoreProcess->start(); + } + }); + + connect(&m_apiClient, &LicenseApiClient::activationUnreachable, this, [this](ActivationIntent intent) { + if (isGracePeriodExpired()) { + qWarning("license activation server unreachable and grace period expired, not starting core"); + return; + } + + qWarning("license activation server unreachable, continuing without activation"); + + if (intent == ActivationIntent::kCoreStart && m_pCoreProcess != nullptr && + m_pCoreProcess->processState() == deskflow::core::ProcessState::Stopped) { + m_pCoreProcess->start(); + } + }); + + connect(&m_apiClient, &LicenseApiClient::activationDeactivated, this, &LicenseHandler::handleActivationDeactivated); + connect(&m_apiClient, &LicenseApiClient::checkSucceeded, this, &LicenseHandler::handleLicenseVerified); + connect(&m_apiClient, &LicenseApiClient::checkFailed, this, &LicenseHandler::handleLicenseUnverified); + connect(&m_apiClient, &LicenseApiClient::checkDeactivated, this, &LicenseHandler::handleCheckDeactivated); + + connect(&m_apiClient, &LicenseApiClient::checkNotActivated, this, [this] { + // The server has no record of this machine, so a read-only check cannot heal it; claim now. + qInfo("license check found no activation for this machine, activating"); + m_apiClient.activate(buildApiData(), ActivationIntent::kCoreStart); + }); + + connect(&m_apiClient, &LicenseApiClient::validateSucceeded, this, [] { qInfo("business license key validated"); }); + + connect(&m_apiClient, &LicenseApiClient::validateFailed, this, [this](const QString &message) { + qWarning().noquote() << "business license key validation failed:" << message; + if (m_pMainWindow != nullptr) { + QMessageBox::warning(m_pMainWindow, "License check failed", message); + } + showSerialKeyDialog(); + }); + + connect(&m_apiClient, &LicenseApiClient::validateDeactivated, this, [](const QString &message) { + // The role is unknown at key entry; the next core start resolves any server contention. + qInfo().noquote() << "validation found another server, deferring to core start:" << message; + }); +} + +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()) { + qCritical("failed to load license settings"); + } +} + +bool LicenseHandler::handleAppStart() +{ + if (m_pMainWindow == nullptr) { + qCritical("main window not set"); + return false; + } + + 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(); + + // A long-running session must still honor a disabled (e.g. unpaid) license within a day, so + // re-check on a cadence; runRemoteCheck self-skips personal and offline licenses. + m_remoteCheckTimer.setInterval(kRemoteCheckInterval); + connect(&m_remoteCheckTimer, &QTimer::timeout, this, &LicenseHandler::runRemoteCheck); + m_remoteCheckTimer.start(); + 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::handleAbout(QDialog *parent) const +{ + if (!m_enabled || parent == nullptr) { + return; + } + + if (!license().serialKey().isValid) { + return; + } + + auto *mainLayout = qobject_cast(parent->layout()); + auto *anchor = parent->findChild(QStringLiteral("frameLogo")); + const int index = (mainLayout && anchor) ? mainLayout->indexOf(anchor) : -1; + if (index < 0) { + qWarning("about: no frameLogo anchor, skipping license info"); + return; + } + + auto *section = new QVBoxLayout(); + section->setContentsMargins(0, 0, 0, 8); + + auto *registrantLabel = new QLabel(parent); + auto *keyField = new QLineEdit(parent); + keyField->setReadOnly(true); + auto *changeButton = new QPushButton(QObject::tr("Change"), parent); + + section->addWidget(registrantLabel); + auto *keyRow = new QHBoxLayout(); + keyRow->addWidget(keyField, 1); + keyRow->addWidget(changeButton); + section->addLayout(keyRow); + + // The serial key can change while the About dialog is open (via the Change + // button), so refresh the widgets from the current license each time rather + // than building them once with whatever key was active at construction. + const auto refresh = [registrantLabel, keyField] { + const auto &k = LicenseHandler::instance().license().serialKey(); + const auto name = QString::fromStdString(k.name); + auto company = QString::fromStdString(k.company); + if (!company.isEmpty()) { + const auto seats = k.seats == 1 ? QObject::tr("1 seat") : QObject::tr("%1 seats").arg(k.seats); + company = QStringLiteral("%1 (%2)").arg(company, seats); + } + QString registrant = name; + if (!company.isEmpty()) { + registrant = name.isEmpty() ? company : QStringLiteral("%1, %2").arg(name, company); + } + registrantLabel->setText(QObject::tr("Registered to %1").arg(registrant)); + registrantLabel->setVisible(!registrant.isEmpty()); + keyField->setText(QString::fromStdString(k.toString())); + keyField->setCursorPosition(0); + }; + refresh(); + + QObject::connect(changeButton, &QPushButton::clicked, changeButton, [refresh] { + LicenseHandler::instance().showSerialKeyDialog(); + refresh(); + }); + + mainLayout->insertLayout(index + 1, section); +} + +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. Absorb the duplicate by time rather than by network state, or an unanswered + // license request blocks core starts entirely. + const auto now = QDateTime::currentMSecsSinceEpoch(); + if (now - m_lastCoreStartMs < 500) { + qDebug("duplicate core start trigger, skipping"); + return false; + } + m_lastCoreStartMs = now; + + if (!m_enabled) { + qDebug("license handler disabled, skipping core start handler"); + return true; + } + + if (m_pMainWindow == nullptr) { + qCritical("main window not set"); + return false; + } + + if (m_pCoreProcess == nullptr) { + qCritical("core process not set"); + return false; + } + + // The role is only reliably known at core start; start optimistically, a deactivated verdict stops the core. + if (m_settings.activated() && !m_license.serialKey().isOffline) { + // A personal license activates once and stays activated; refreshing here would be the + // kind of post-activation phone-home that breaks offline networks. + if (m_license.productEdition() != Product::Edition::kBusiness) { + qDebug("license is activated, starting core for personal license"); + return true; + } + if (m_apiClient.isBusy()) { + qInfo("license api busy, starting core without refreshing activation"); + return true; + } + + // A routine restart must not re-claim the slot, or the takeover ledger would inflate and a + // displaced server would silently reclaim instead of asking. So a machine that already holds + // its slot (and a plain client) only checks; a machine taking on the server role claims it. + const bool isServer = liveCoreMode() == Settings::Server; + if (isServer != m_settings.holdsServerActivation()) { + if (isServer) { + qDebug("core server, claiming server activation slot on core start"); + } else { + qDebug("core client, releasing server activation slot on core start"); + } + m_apiClient.activate(buildApiData(), ActivationIntent::kCoreStart); + } else { + qDebug("license is activated, checking on core start"); + m_apiClient.check(buildApiData(), CheckIntent::kCoreStart); + } + return true; + } + + if (m_license.serialKey().isOffline) { + if (liveCoreMode() != Settings::Server) { + qDebug("offline license in client mode, starting core without activation"); + return true; + } + + if (isOfflineActivated()) { + qDebug("offline activation verified, starting core"); + return true; + } + qInfo("offline serial key not activated as server, showing offline activation dialog"); + return showOfflineActivationDialog(); + } + + if (!m_license.isValid()) { + qWarning("no valid license, skipping core start"); + return false; + } + + if (m_apiClient.isBusy()) { + qWarning("license api busy, cannot activate for core start, try again shortly"); + return false; + } + + qInfo("activating license"); + m_apiClient.activate(buildApiData(), ActivationIntent::kCoreStart); + + 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.setHoldsServerActivation(false); + m_settings.setGraceStartEpochSecs(0); + m_settings.setOfflineActivationResponse({}); + m_warnedAboutGrace = false; + m_settings.sync(); + } + + saveSettings(); + updateWindowTitle(); + clampFeatures(); + + // Key entry never touches the core process; everything a new key implies (activation, the + // offline challenge, feature clamps) is applied at the next core start. + // If the user accepted the dialog while not activated (e.g. recovering from a + // remote disable), reach out 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()) { + // The role is unknown at key entry, so business only validates the key here; its first + // activation happens at core start once the role is known. Personal has no role, so it + // activates immediately. + if (m_license.productEdition() == Product::Edition::kBusiness) { + qInfo("validating business license after dialog accept"); + m_apiClient.validate(buildApiData()); + } else { + qInfo("retrying activation after dialog accept"); + m_apiClient.activate(buildApiData(), ActivationIntent::kKeyEntry); + } + } + + qDebug("license serial key dialog accepted"); + return true; +} + +bool LicenseHandler::showOfflineActivationDialog() +{ + 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; + } + + if (offlineActivationChallenge().isEmpty()) { + QMessageBox::critical( + m_pMainWindow, tr("Offline activation"), + tr("

A unique ID for this computer could not be determined, " + "so an activation code cannot be generated.

" + R"(

Please contact us for help.

)") + .arg(kUrlContact) + ); + return false; + } + + OfflineActivationDialog dialog(m_pMainWindow, *this); + return dialog.exec() == QDialog::Accepted; +} + +QString LicenseHandler::offlineActivationChallenge() const +{ + const auto machineId = licenseMachineId(); + const auto challenge = synergy::license::buildOfflineChallenge(machineId, m_license.serialKey().hexString); + return QString::fromStdString(synergy::license::formatOfflineCode(challenge, 4)); +} + +bool LicenseHandler::isOfflineActivated() const +{ + const auto response = m_settings.offlineActivationResponse(); + if (response.isEmpty()) { + return false; + } + const auto machineId = licenseMachineId(); + return synergy::license::verifyOfflineResponse(machineId, m_license.serialKey().hexString, response.toStdString()); +} + +bool LicenseHandler::applyOfflineActivationResponse(const QString &responseCode) +{ + const auto response = responseCode.simplified(); + const auto machineId = licenseMachineId(); + if (!synergy::license::verifyOfflineResponse(machineId, m_license.serialKey().hexString, response.toStdString())) { + qWarning("offline activation response not valid for this machine"); + return false; + } + + qInfo("offline activation response verified"); + m_settings.setOfflineActivationResponse(response); + m_settings.sync(); + return true; +} + +void LicenseHandler::updateWindowTitle() const +{ + const auto productName = QString::fromStdString(m_license.productName()); + qDebug("updating main window title: %s", qPrintable(productName)); + const bool showVersion = Settings::value(Settings::Gui::ShowVersionInTitle).toBool(); + m_pMainWindow->setWindowTitle(synergy::gui::windowTitle(productName, showVersion)); +} + +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()) { + qCritical("serial key is empty"); + return kInvalid; + } + + 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()) { + qCritical("settings scope not available"); + return; + } + + 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); +} + +void LicenseHandler::resetGracePeriod() +{ + m_settings.setGraceStartEpochSecs(0); + m_settings.sync(); + m_warnedAboutGrace = false; +} + +Settings::CoreMode LicenseHandler::liveCoreMode() const +{ + // The saved mode setting lags the UI at core start. + if (m_pCoreProcess != nullptr) { + return m_pCoreProcess->mode(); + } + return static_cast(Settings::value(Settings::Core::CoreMode).toInt()); +} + +LicenseApiClient::Data LicenseHandler::buildApiData() const +{ + const auto machineSignature = anonymousSignature(QByteArray::fromStdString(licenseMachineId())); + const auto hostnameSignature = anonymousSignature(QHostInfo::localHostName().toUtf8()); + + return { + machineSignature, + hostnameSignature, + QString::fromStdString(m_license.serialKey().hexString), + kVersion, + QSysInfo::prettyProductName(), + liveCoreMode() == Settings::Server + }; +} + +void LicenseHandler::runRemoteCheck() +{ + if (!m_license.isValid() || m_license.serialKey().isOffline) { + qDebug("license invalid or offline, skipping remote check"); + return; + } + + // Personal licenses activate once and are never re-validated, so they keep working on + // offline and restricted networks. Only business licenses are re-checked. + if (m_license.productEdition() != Product::Edition::kBusiness) { + if (isInGracePeriod()) { + // Nothing else clears grace once personal checks stop, and a stale grace flag + // suppresses the renew nag forever. + qInfo("clearing stale grace period for personal license"); + resetGracePeriod(); + } + qDebug("personal license, 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::handleLicenseVerified() +{ + qInfo("remote license check succeeded"); + + const bool wasInGrace = isInGracePeriod(); + resetGracePeriod(); + + if (wasInGrace && m_pMainWindow != nullptr) { + QMessageBox::information( + m_pMainWindow, "License restored", tr("Your license is valid again. Thanks for your patience.") + ); + } +} + +void LicenseHandler::handleLicenseUnverified(const QString &message) +{ + qWarning().noquote() << "license could not be verified:" << message; + + if (!isInGracePeriod()) { + m_settings.setGraceStartEpochSecs(QDateTime::currentSecsSinceEpoch()); + m_settings.sync(); + } + + if (isGracePeriodExpired()) { + disableLicenseAfterGrace(message); + return; + } + + if (!m_warnedAboutGrace && m_pMainWindow != nullptr) { + m_warnedAboutGrace = true; + const auto now = QDateTime::currentSecsSinceEpoch(); + const auto elapsed = seconds{now - m_settings.graceStartEpochSecs()}; + const auto daysRemaining = ceil(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 + +#include "Product.h" +#include "synergy/build_config.h" + +using SKE = Product::SerialKeyEditionID; + +const char *const kLicensedProductName = synergy::kDisplayName; + +const std::string SKE::Pro = "pro"; +const std::string SKE::Basic = "basic"; +const std::string SKE::Business = "business"; + +using Edition = Product::Edition; + +const std::map> kSerialKeyEditions{ + {SKE::Basic, Edition::kBasic}, + {SKE::Pro, Edition::kPro}, + {SKE::Business, Edition::kBusiness}, +}; + +Product::Product(Edition edition) : m_edition(edition) +{ +} + +Product::Product(const std::string &serialKeyEditionID) +{ + setEdition(serialKeyEditionID); +} + +Edition Product::edition() const +{ + return m_edition; +} + +std::string Product::serialKeyId() const +{ + switch (edition()) { + using enum Edition; + + case kPro: + return SKE::Pro; + + case kBasic: + return SKE::Basic; + + case kBusiness: + return SKE::Business; + + default: + throw InvalidProductEdition(); + } +} + +std::string Product::name() const +{ + + const std::string nameBase = kLicensedProductName; + switch (edition()) { + using enum Edition; + + case kUnregistered: + return nameBase + " (unregistered)"; + + case kBasic: + return nameBase + " Basic"; + + case kPro: + return nameBase + " Pro"; + + case kBusiness: + return nameBase + " Business"; + + default: + throw InvalidProductEdition(); + } +} + +void Product::setEdition(Edition edition) +{ + m_edition = edition; +} + +void Product::setEdition(const std::string &name) +{ + const auto &pType = kSerialKeyEditions.find(name); + + if (pType != kSerialKeyEditions.end()) { + m_edition = pType->second; + } else { + throw InvalidProductEdition(); + } +} + +bool Product::isValid() const +{ + if (m_edition == Edition::kUnregistered) { + return false; + } + return kSerialKeyEditions.contains(serialKeyId()); +} + +bool Product::isTlsAvailable() const +{ + switch (edition()) { + using enum Edition; + + case kPro: + case kBusiness: + return true; + + case kBasic: + case kUnregistered: + return false; + + default: + throw InvalidProductEdition(); + } +} + +bool Product::isSettingsScopeAvailable() const +{ + switch (edition()) { + using enum Edition; + + case kBusiness: + return true; + + case kBasic: + case kPro: + case kUnregistered: + return false; + + default: + throw InvalidProductEdition(); + } +} + +bool Product::isFeatureAvailable(Product::Feature feature) const +{ + switch (feature) { + using enum Product::Feature; + + case kTls: + return isTlsAvailable(); + + case kSettingsScope: + return isSettingsScopeAvailable(); + + default: + throw InvalidFeature(); + } +} diff --git a/extra/src/lib/synergy/license/Product.h b/extra/src/lib/synergy/license/Product.h new file mode 100644 index 000000000..a8a9d552b --- /dev/null +++ b/extra/src/lib/synergy/license/Product.h @@ -0,0 +1,87 @@ +/* + * 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 +#include + +class Product +{ + friend bool operator==(Product const &, Product const &) = default; + +public: + class InvalidProductEdition : public std::runtime_error + { + public: + explicit InvalidProductEdition() : std::runtime_error("invalid product edition") + { + } + }; + + class InvalidFeature : public std::runtime_error + { + public: + explicit InvalidFeature() : std::runtime_error("invalid feature") + { + } + }; + + enum class Edition + { + kUnregistered = -1, + kBasic = 0, + kPro = 1, + kBusiness = 4, + }; + + enum class Feature + { + kTls = 0, + kSettingsScope = 2, + }; + + /** + * @brief Product edition IDs found in a decoded serial key. + */ + class SerialKeyEditionID + { + public: + static const std::string Basic; + static const std::string Pro; + static const std::string Business; + }; + + Product() = default; + explicit Product(Edition edition); + explicit Product(const std::string &serialKeyEditionID); + + bool isValid() const; + Edition edition() const; + std::string serialKeyId() const; + std::string name() const; + bool isFeatureAvailable(Feature feature) const; + + void setEdition(Edition type); + void setEdition(const std::string &serialKeyId); + +private: + bool isTlsAvailable() const; + bool isSettingsScopeAvailable() const; + + Edition m_edition = Edition::kUnregistered; +}; diff --git a/extra/src/lib/synergy/license/SerialKey.h b/extra/src/lib/synergy/license/SerialKey.h new file mode 100644 index 000000000..bf9de5719 --- /dev/null +++ b/extra/src/lib/synergy/license/SerialKey.h @@ -0,0 +1,71 @@ +/* + * Synergy -- mouse and keyboard sharing utility + * Copyright (C) 2022 - 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 "Product.h" +#include "SerialKeyType.h" + +#include +#include +#include + +namespace synergy::license { + +struct SerialKey +{ + using time_point = std::chrono::system_clock::time_point; + + friend bool operator==(const SerialKey &lhs, const SerialKey &rhs) + { + return (lhs.hexString == rhs.hexString) && (lhs.warnTime == rhs.warnTime) && (lhs.expireTime == rhs.expireTime) && + (lhs.product == rhs.product) && (lhs.type == rhs.type); + } + + explicit SerialKey(const std::string &key) : hexString(key) + { + } + + static SerialKey invalid() + { + return SerialKey(Product::Edition::kUnregistered); + } + + const std::string &toString() const + { + return hexString; + } + + bool isValid = false; + std::string hexString = ""; + Product product; + SerialKeyType type; + std::optional warnTime = std::nullopt; + std::optional expireTime = std::nullopt; + bool isOffline = false; + int seats = 1; + std::string name = ""; + std::string email = ""; + std::string company = ""; + +private: + explicit SerialKey(Product::Edition edition) : product(edition) + { + } +}; + +} // namespace synergy::license diff --git a/extra/src/lib/synergy/license/SerialKeyType.cpp b/extra/src/lib/synergy/license/SerialKeyType.cpp new file mode 100644 index 000000000..9d7331596 --- /dev/null +++ b/extra/src/lib/synergy/license/SerialKeyType.cpp @@ -0,0 +1,37 @@ +/* + * 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 . + */ + +#include "SerialKeyType.h" + +const std::string SerialKeyType::Trial = "trial"; +const std::string SerialKeyType::Subscription = "subscription"; + +void SerialKeyType::setType(const std::string_view &type) +{ + m_isTrial = (type == SerialKeyType::Trial); + m_isSubscription = (type == SerialKeyType::Subscription); +} + +bool SerialKeyType::isTrial() const +{ + return m_isTrial; +} + +bool SerialKeyType::isSubscription() const +{ + return m_isSubscription; +} diff --git a/extra/src/lib/synergy/license/SerialKeyType.h b/extra/src/lib/synergy/license/SerialKeyType.h new file mode 100644 index 000000000..8927217ca --- /dev/null +++ b/extra/src/lib/synergy/license/SerialKeyType.h @@ -0,0 +1,44 @@ +/* + * 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 + +class SerialKeyType +{ +private: + friend bool operator==(SerialKeyType const &lhs, SerialKeyType const &rhs) = default; + +public: + static const std::string Trial; + static const std::string Subscription; + + explicit SerialKeyType() = default; + explicit SerialKeyType(const std::string_view &type) + { + setType(type); + } + + void setType(const std::string_view &type); + bool isTrial() const; + bool isSubscription() const; + +private: + bool m_isTrial = false; + bool m_isSubscription = false; +}; diff --git a/extra/src/lib/synergy/license/parse_serial_key.cpp b/extra/src/lib/synergy/license/parse_serial_key.cpp new file mode 100644 index 000000000..ac763f8d2 --- /dev/null +++ b/extra/src/lib/synergy/license/parse_serial_key.cpp @@ -0,0 +1,212 @@ +/* + * 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 "parse_serial_key.h" + +#include "SerialKey.h" +#include "SerialKeyType.h" + +#include +#include +#include +#include +#include + +using Parts = std::vector; +using system_clock = std::chrono::system_clock; +using time_point = system_clock::time_point; + +namespace { +std::string trim(const std::string &s) +{ + const auto first = s.find_first_not_of(" \t\n\r\f\v"); + if (first == std::string::npos) { + return ""; + } + const auto last = s.find_last_not_of(" \t\n\r\f\v"); + return s.substr(first, last - first + 1); +} + +int parseSeats(const std::string &value) +{ + const auto clean = trim(value); + if (clean.empty()) { + return 1; + } + try { + const auto seats = std::stoi(clean); + return seats > 0 ? seats : 1; + } catch (const std::exception &) { + return 1; + } +} +} // namespace + +namespace synergy::license { + +std::string decode(const std::string &hexString); +Parts tokenize(const std::string &plainText); +SerialKey parseV1(const std::string &hexString, const Parts &parts); +SerialKey parseV2(const std::string &hexString, const Parts &parts); +SerialKey parseV3(const std::string &hexString, const Parts &parts); +std::optional parseDate(const std::string &unixTimeString); + +SerialKey parseSerialKey(const std::string &hexString) +{ + const auto &trimmed = trim(hexString); + const auto &plainText = decode(trimmed); + const auto &parts = tokenize(plainText); + const auto &version = parts.at(0); + + if (version == "v1") { + return parseV1(trimmed, parts); + } else if (version == "v2") { + return parseV2(trimmed, parts); + } else if (version == "v3") { + return parseV3(trimmed, parts); + } else { + throw InvalidSerialKeyVersion(version); + } +} + +std::string decode(const std::string &hexString) +{ + if (hexString.length() % 2 != 0) { + throw InvalidHexString(); + } + + std::string plainText; + plainText.reserve(hexString.length() / 2); + + for (size_t i = 0; i < hexString.length(); i += 2) { + std::string byteString = hexString.substr(i, 2); + auto byte = static_cast(std::stoi(byteString, nullptr, 16)); + plainText.push_back(byte); + } + + return plainText; +} + +SerialKey parseV1(const std::string &hexString, const Parts &parts) +{ + if (parts.size() < 8) { + throw InvalidSerialKeyFormat(); + } + + // e.g.: {v1;basic;name;seats;email;company;1398297600;1398384000} + SerialKey serialKey(hexString); + serialKey.product = Product(parts.at(1)); + serialKey.name = parts.at(2); + serialKey.seats = parseSeats(parts.at(3)); + serialKey.email = parts.at(4); + serialKey.company = parts.at(5); + serialKey.warnTime = parseDate(parts.at(6)); + serialKey.expireTime = parseDate(parts.at(7)); + serialKey.isValid = true; + return serialKey; +} + +SerialKey parseV2(const std::string &hexString, const Parts &parts) +{ + if (parts.size() < 9) { + throw InvalidSerialKeyFormat(); + } + // e.g.: {v2;trial;basic;name;seats;email;company;1398297600;1398384000} + SerialKey serialKey(hexString); + serialKey.type = SerialKeyType(parts.at(1)); + serialKey.product = Product(parts.at(2)); + serialKey.name = parts.at(3); + serialKey.seats = parseSeats(parts.at(4)); + serialKey.email = parts.at(5); + serialKey.company = parts.at(6); + serialKey.warnTime = parseDate(parts.at(7)); + serialKey.expireTime = parseDate(parts.at(8)); + serialKey.isValid = true; + return serialKey; +} + +SerialKey parseV3(const std::string &hexString, const Parts &parts) +{ + if (parts.size() < 10) { + throw InvalidSerialKeyFormat(); + } + // e.g.: {v3;offline;trial;basic;name;seats;email;company;1398297600;1398384000} + SerialKey serialKey(hexString); + const auto &mode = parts.at(1); + serialKey.isOffline = (mode == "offline"); + serialKey.type = SerialKeyType(parts.at(2)); + serialKey.product = Product(parts.at(3)); + serialKey.name = parts.at(4); + serialKey.seats = parseSeats(parts.at(5)); + serialKey.email = parts.at(6); + serialKey.company = parts.at(7); + serialKey.warnTime = parseDate(parts.at(8)); + serialKey.expireTime = parseDate(parts.at(9)); + serialKey.isValid = true; + return serialKey; +} + +Parts tokenize(const std::string &plainText) +{ + if (plainText.front() != '{' || plainText.back() != '}') { + throw InvalidSerialKeyFormat(); + } + + const auto serialData = plainText.substr(1, plainText.length() - 2); + + Parts parts; + std::stringstream ss(serialData); + std::string item; + + while (std::getline(ss, item, ';')) { + parts.push_back(item); + } + + // it's possible that the last character is a delimiter, so add an empty part + if (!serialData.empty() && serialData.back() == ';') { + parts.emplace_back(""); + } + + return parts; +} + +std::optional parseDate(const std::string &unixTimeString) +{ + auto clean = trim(unixTimeString); + if (clean.empty()) { + return std::nullopt; + } + + try { + auto seconds = std::stoll(clean); + if (seconds <= 0) { + return std::nullopt; + } else { + return time_point{std::chrono::seconds{seconds}}; + } + } catch (std::invalid_argument &) { + throw InvalidSerialKeyDate(unixTimeString, "invalid argument"); + } catch (std::out_of_range &) { + throw InvalidSerialKeyDate(unixTimeString, "out of range"); + } catch (std::exception &ex) { + throw InvalidSerialKeyDate(unixTimeString, ex.what()); + } catch (...) { // NOSONAR + throw InvalidSerialKeyDate(unixTimeString, "unknown error"); + } +} + +} // namespace synergy::license diff --git a/extra/src/lib/synergy/license/parse_serial_key.h b/extra/src/lib/synergy/license/parse_serial_key.h new file mode 100644 index 000000000..bbddd46de --- /dev/null +++ b/extra/src/lib/synergy/license/parse_serial_key.h @@ -0,0 +1,69 @@ +/* + * 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 "SerialKey.h" + +#include +#include + +namespace synergy::license { + +class SerialKeyParseError : public std::runtime_error +{ +public: + explicit SerialKeyParseError(const std::string &message) : std::runtime_error(message) + { + } +}; + +class InvalidHexString : public SerialKeyParseError +{ +public: + explicit InvalidHexString() : SerialKeyParseError("invalid hex string") + { + } +}; + +class InvalidSerialKeyFormat : public SerialKeyParseError +{ +public: + explicit InvalidSerialKeyFormat() : SerialKeyParseError("invalid serial key format") + { + } +}; + +class InvalidSerialKeyDate : public SerialKeyParseError +{ +public: + explicit InvalidSerialKeyDate(const std::string &date, const std::string &cause) + : SerialKeyParseError("invalid serial key date: " + date + "\n" + cause) + { + } +}; + +class InvalidSerialKeyVersion : public SerialKeyParseError +{ +public: + explicit InvalidSerialKeyVersion(const std::string &version) + : SerialKeyParseError("invalid serial key version: " + version) + { + } +}; + +SerialKey parseSerialKey(const std::string &hexString); + +} // namespace synergy::license diff --git a/extra/src/unittests/CMakeLists.txt b/extra/src/unittests/CMakeLists.txt new file mode 100644 index 000000000..0982bbb53 --- /dev/null +++ b/extra/src/unittests/CMakeLists.txt @@ -0,0 +1,55 @@ +# 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 . + +enable_testing() +find_package(Qt6 ${REQUIRED_QT_VERSION} REQUIRED COMPONENTS Test) + +# legacytests provisions googletest, but when that is satisfied by find_package +# the imported targets are scoped to its own directory, so resolve them here too. +if(NOT TARGET GTest::gtest_main) + find_package(GTest REQUIRED) +endif() + +create_test( + NAME LicenseTests + DEPENDS license + LIBS GTest::gtest_main + SOURCE license/LicenseTests.cpp + WORKING_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR} +) + +create_test( + NAME ProductTests + DEPENDS license + LIBS GTest::gtest_main + SOURCE license/ProductTests.cpp + WORKING_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR} +) + +create_test( + NAME SerialKeyTypeTests + DEPENDS license + LIBS GTest::gtest_main + SOURCE license/SerialKeyTypeTests.cpp + WORKING_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR} +) + +create_test( + NAME OfflineActivationTests + DEPENDS license + LIBS GTest::gtest_main + SOURCE license/OfflineActivationTests.cpp + WORKING_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR} +) diff --git a/extra/src/unittests/gui/license/LicenseHandlerTests.cpp b/extra/src/unittests/gui/license/LicenseHandlerTests.cpp new file mode 100644 index 000000000..4fe43a6c1 --- /dev/null +++ b/extra/src/unittests/gui/license/LicenseHandlerTests.cpp @@ -0,0 +1,40 @@ +/* + * 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 "gui/license/LicenseHandler.h" + +#include +#include + +using namespace synergy::license; +using namespace std::chrono; + +const auto kPast = system_clock::now() - hours(1); +const auto kFuture = system_clock::now() + hours(1); + +TEST(LicenseHandlerTests, changeSerialKey_validExpiredLicense_returnsTrue) +{ + LicenseHandler licenseHandler; + licenseHandler.setEnabled(true); + auto hexString = // + "7B76313B70726F3B6E69636B20626F6C746F6E3B313B6" + "E69636B4073796D6C6573732E636F6D3B203B303B307D"; + + auto result = licenseHandler.changeSerialKey(hexString); + + ASSERT_EQ(LicenseHandler::ChangeSerialKeyResult::kSuccess, result); +} diff --git a/extra/src/unittests/gui/license/license_notices_tests.cpp b/extra/src/unittests/gui/license/license_notices_tests.cpp new file mode 100644 index 000000000..86d1ae127 --- /dev/null +++ b/extra/src/unittests/gui/license/license_notices_tests.cpp @@ -0,0 +1,144 @@ +/* + * 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 "gui/license/license_notices.h" + +#include +#include +#include + +using namespace std::chrono; +using namespace synergy::license; +using namespace synergy::gui; +using ::testing::HasSubstr; + +const auto kPast = system_clock::now() - hours(1); +const auto kFutureOneHour = system_clock::now() + hours(1); +const auto kFutureOneDay = system_clock::now() + days(1) + hours(1); +const auto kFutureOneWeek = system_clock::now() + days(7) + hours(1); + +TEST(license_notices_tests, licenseNotice_trialExpired_correctText) +{ + SerialKey serialKey(""); + serialKey.isValid = true; + serialKey.warnTime = kPast; + serialKey.expireTime = kPast; + serialKey.type.setType("trial"); + License license(serialKey); + + QString notice = licenseNotice(license); + + EXPECT_THAT(notice.toStdString(), HasSubstr("Your trial has expired")); +} + +TEST(license_notices_tests, licenseNotice_trialExpiringInOneHour_correctText) +{ + SerialKey serialKey(""); + serialKey.isValid = true; + serialKey.warnTime = kFutureOneHour; + serialKey.expireTime = kFutureOneHour; + serialKey.type.setType("trial"); + License license(serialKey); + + QString notice = licenseNotice(license); + + EXPECT_THAT(notice.toStdString(), HasSubstr("Your trial expires today")); +} + +TEST(license_notices_tests, licenseNotice_trialExpiringInOneDay_correctText) +{ + SerialKey serialKey(""); + serialKey.isValid = true; + serialKey.warnTime = kFutureOneDay; + serialKey.expireTime = kFutureOneDay; + serialKey.type.setType("trial"); + License license(serialKey); + + QString notice = licenseNotice(license); + + EXPECT_THAT(notice.toStdString(), HasSubstr("Your trial expires in 1 day")); +} + +TEST(license_notices_tests, licenseNotice_trialExpiringInOneWeek_correctText) +{ + SerialKey serialKey(""); + serialKey.isValid = true; + serialKey.warnTime = kFutureOneWeek; + serialKey.expireTime = kFutureOneWeek; + serialKey.type.setType("trial"); + License license(serialKey); + + QString notice = licenseNotice(license); + + EXPECT_THAT(notice.toStdString(), HasSubstr("Your trial expires in 7 days")); +} + +TEST(license_notices_tests, licenseNotice_subscriptionExpired_correctText) +{ + SerialKey serialKey(""); + serialKey.isValid = true; + serialKey.warnTime = kPast; + serialKey.expireTime = kPast; + serialKey.type.setType("subscription"); + License license(serialKey); + + QString notice = licenseNotice(license); + + EXPECT_THAT(notice.toStdString(), HasSubstr("Your license has expired")); +} + +TEST(license_notices_tests, licenseNotice_subscriptionExpiringInOneHour_correctText) +{ + SerialKey serialKey(""); + serialKey.isValid = true; + serialKey.warnTime = kFutureOneHour; + serialKey.expireTime = kFutureOneHour; + serialKey.type.setType("subscription"); + License license(serialKey); + + QString notice = licenseNotice(license); + + EXPECT_THAT(notice.toStdString(), HasSubstr("Your license expires today")); +} + +TEST(license_notices_tests, licenseNotice_subscriptionExpiringInOneDay_correctText) +{ + SerialKey serialKey(""); + serialKey.isValid = true; + serialKey.warnTime = kFutureOneDay; + serialKey.expireTime = kFutureOneDay; + serialKey.type.setType("subscription"); + License license(serialKey); + + QString notice = licenseNotice(license); + + EXPECT_THAT(notice.toStdString(), HasSubstr("Your license expires in 1 day")); +} + +TEST(license_notices_tests, licenseNotice_subscriptionExpiringInOneWeek_correctText) +{ + SerialKey serialKey(""); + serialKey.isValid = true; + serialKey.warnTime = kFutureOneWeek; + serialKey.expireTime = kFutureOneWeek; + serialKey.type.setType("subscription"); + License license(serialKey); + + QString notice = licenseNotice(license); + + EXPECT_THAT(notice.toStdString(), HasSubstr("Your license expires in 7 days")); +} diff --git a/extra/src/unittests/license/LicenseTests.cpp b/extra/src/unittests/license/LicenseTests.cpp new file mode 100644 index 000000000..199211ca8 --- /dev/null +++ b/extra/src/unittests/license/LicenseTests.cpp @@ -0,0 +1,198 @@ +/* + * 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 +#define TEST_ENV + +#include "synergy/license/License.h" +#include "synergy/license/Product.h" + +#include +#include + +using enum Product::Edition; +using namespace synergy::license; +using time_point = std::chrono::system_clock::time_point; +using seconds = std::chrono::seconds; + +class LicenseTests : public ::testing::Test +{ +protected: + void setNow(License &license, int unixTime) const + { + license.setNowFunc([unixTime]() { return time_point{seconds{unixTime}}; }); + } +}; + +TEST_F(LicenseTests, isExpiring_validV2TrialBasicSerial_isTrial) +{ + // {v2;trial;basic;Bob;1;email;company name;1;86400} + License license("7B76323B747269616C3B62617369633B426F623B313B656D61696C3B636" + "F6D70616E79206E616D653B313B38363430307D"); + setNow(license, 0); + + EXPECT_TRUE(license.isTrial()); +} + +TEST_F(LicenseTests, isExpiring_validV2TrialBasicSerial_isTimeLimited) +{ + // {v2;trial;basic;Bob;1;email;company name;1;86400} + License license("7B76323B747269616C3B62617369633B426F623B313B656D61696C3B636" + "F6D70616E79206E616D653B313B38363430307D"); + setNow(license, 0); + + EXPECT_TRUE(license.isTimeLimited()); +} + +TEST_F(LicenseTests, isExpiring_validV2TrialBasicSerial_isNotSubscription) +{ + // {v2;trial;basic;Bob;1;email;company name;1;86400} + License license("7B76323B747269616C3B62617369633B426F623B313B656D61696C3B636" + "F6D70616E79206E616D653B313B38363430307D"); + setNow(license, 0); + + EXPECT_FALSE(license.isSubscription()); +} + +TEST_F(LicenseTests, isExpiring_validV2TrialBasicSerial_isExpiring) +{ + // {v2;trial;basic;Bob;1;email;company name;1;86400} + License license("7B76323B747269616C3B62617369633B426F623B313B656D61696C3B636" + "F6D70616E79206E616D653B313B38363430307D"); + setNow(license, 0); + + EXPECT_FALSE(license.isExpiringSoon()); +} + +TEST_F(LicenseTests, isExpiring_validV2TrialBasicSerial_isBasicEdition) +{ + // {v2;trial;basic;Bob;1;email;company name;1;86400} + License license("7B76323B747269616C3B62617369633B426F623B313B656D61696C3B636" + "F6D70616E79206E616D653B313B38363430307D"); + setNow(license, 0); + + EXPECT_EQ(kBasic, license.productEdition()); +} + +TEST_F(LicenseTests, isExpiring_expiringV2TrialBasicSerial_returnTrue) +{ + // {v2;trial;basic;Bob;1;email;company name;86400;0} + License license("7B76323B747269616C3B62617369633B426F623B313B656D61696C3B636" + "F6D70616E79206E616D653B38363430303B307D"); + setNow(license, 86401); + + EXPECT_TRUE(license.isTrial()); + EXPECT_TRUE(license.isExpiringSoon()); +} + +TEST_F(LicenseTests, isExpired_validV2TrialBasicSerial_returnFalse) +{ + // {v2;trial;basic;Bob;1;email;company name;0;86400} + License license("7B76323B747269616C3B62617369633B426F623B313B656D61696C3B636" + "F6D70616E79206E616D653B303B38363430307D"); + setNow(license, 0); + + EXPECT_TRUE(license.isTrial()); + EXPECT_FALSE(license.isExpired()); +} + +TEST_F(LicenseTests, isExpired_expiringV2TrialBasicSerial_returnFalse) +{ + // {v2;trial;basic;Bob;1;email;company name;0;86400} + License license("7B76323B747269616C3B62617369633B426F623B313B656D61696C3B636" + "F6D70616E79206E616D653B303B38363430307D"); + setNow(license, 1); + + EXPECT_TRUE(license.isTrial()); + EXPECT_FALSE(license.isExpired()); +} + +TEST_F(LicenseTests, isExpired_expiredV2TrialBasicSerial_returnTrue) +{ + // {v2;trial;basic;Bob;1;email;company name;0;86400} + License license("7B76323B747269616C3B62617369633B426F623B313B656D61696C3B636" + "F6D70616E79206E616D653B303B38363430307D"); + setNow(license, 86401); + + EXPECT_TRUE(license.isTrial()); + EXPECT_TRUE(license.isExpired()); +} + +TEST_F(LicenseTests, daysLeft_validExactlyOneDayV2TrialBasicSerial_returnOne) +{ + // {v2;trial;basic;Bob;1;email;company name;0;86400} + License license("7B76323B747269616C3B62617369633B426F623B313B656D61696C3B636" + "F6D70616E79206E616D653B303B38363430307D"); + setNow(license, 0); + + EXPECT_EQ(1, license.daysLeft().count()); +} + +TEST_F(LicenseTests, daysLeft_validWithinOneDayV2TrialBasicSerial_returnOne) +{ + // {v2;trial;basic;Bob;1;email;company name;0;86400} + License license("7B76323B747269616C3B62617369633B426F623B313B656D61696C3B636" + "F6D70616E79206E616D653B303B38363430307D"); + setNow(license, 0); + + EXPECT_EQ(1, license.daysLeft().count()); +} + +TEST_F(LicenseTests, daysLeft_expiredV2TrialBasicSerial_returnZero) +{ + // {v2;trial;basic;Bob;1;email;company name;0;86400} + License license("7B76323B747269616C3B62617369633B426F623B313B656D61696C3B636" + "F6D70616E79206E616D653B303B38363430307D"); + setNow(license, 86401); + + EXPECT_EQ(0, license.daysLeft().count()); +} + +// Subscription license tests +TEST_F(LicenseTests, isExpiring_validV2SubscriptionBasicSerial_returnFalse) +{ + // {v2;subscription;basic;Bob;1;email;company name;1;86400} + License license("7B76323B737562736372697074696F6E3B62617369633B426F623B313B6" + "56D61696C3B636F6D70616E79206E616D653B313B38363430307D"); + setNow(license, 0); + + EXPECT_TRUE(license.isSubscription()); + EXPECT_FALSE(license.isExpiringSoon()); + EXPECT_EQ(kBasic, license.productEdition()); +} + +TEST_F(LicenseTests, isExpiring_expiringV2SubscriptionBasicSerial_returnTrue) +{ + // {v2;subscription;basic;Bob;1;email;company name;86400;0} + License license("7B76323B737562736372697074696F6E3B62617369633B426F623B313B6" + "56D61696C3B636F6D70616E79206E616D653B38363430303B307D"); + setNow(license, 86401); + + EXPECT_TRUE(license.isSubscription()); + EXPECT_TRUE(license.isExpiringSoon()); +} + +TEST_F(LicenseTests, isExpired_expiredV2SubscriptionBasicSerial_returnTrue) +{ + // {v2;subscription;basic;Bob;1;email;company name;0;86400} + License license("7B76323B737562736372697074696F6E3B62617369633B426F623B313B6" + "56D61696C3B636F6D70616E79206E616D653B303B38363430307D"); + setNow(license, 86401); + + EXPECT_TRUE(license.isSubscription()); + EXPECT_TRUE(license.isExpired()); +} diff --git a/extra/src/unittests/license/OfflineActivationTests.cpp b/extra/src/unittests/license/OfflineActivationTests.cpp new file mode 100644 index 000000000..9398fb87c --- /dev/null +++ b/extra/src/unittests/license/OfflineActivationTests.cpp @@ -0,0 +1,151 @@ +/* + * 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 "synergy/license/OfflineActivation.h" +#include "synergy/license/parse_serial_key.h" + +#include + +#include + +using namespace synergy::license; + +// Vectors produced by the vendor signer (web-muskoka offline-activation-protocol.spec.ts, +// fixed secret 0707...07), so these tests prove the client and the server signer implement +// the same wire format. +const auto kVectorSecret = "0707070707070707070707070707070707070707070707070707070707070707"; +const auto kVectorMachineId = "test-machine-id-0001"; +const auto kVectorSerial = "deadbeef00112233"; +const auto kVectorChallenge = "CS34563YJPF9"; +const auto kVectorResponse = "FNY0QQP3MM39"; + +TEST(OfflineActivationTests, buildOfflineChallenge_vectorInputs_matchesVendorTool) +{ + EXPECT_EQ(buildOfflineChallenge(kVectorMachineId, kVectorSerial), kVectorChallenge); +} + +TEST(OfflineActivationTests, buildOfflineChallenge_serialCaseAndPaddingDiffer_sameChallenge) +{ + EXPECT_EQ(buildOfflineChallenge(kVectorMachineId, " DEADBEEF00112233 "), kVectorChallenge); +} + +TEST(OfflineActivationTests, buildOfflineChallenge_differentMachine_differentChallenge) +{ + EXPECT_NE(buildOfflineChallenge("test-machine-id-0002", kVectorSerial), kVectorChallenge); +} + +TEST(OfflineActivationTests, buildOfflineChallenge_differentSerial_differentChallenge) +{ + EXPECT_NE(buildOfflineChallenge(kVectorMachineId, "deadbeef00112234"), kVectorChallenge); +} + +TEST(OfflineActivationTests, verifyOfflineResponse_vendorSignedResponse_verifies) +{ + EXPECT_TRUE(verifyOfflineResponse(kVectorMachineId, kVectorSerial, kVectorResponse, kVectorSecret)); +} + +TEST(OfflineActivationTests, verifyOfflineResponse_groupedLowercaseResponse_verifies) +{ + EXPECT_TRUE(verifyOfflineResponse(kVectorMachineId, kVectorSerial, "fny0-qqp3-mm39", kVectorSecret)); +} + +TEST(OfflineActivationTests, verifyOfflineResponse_confusableLettersTyped_verifies) +{ + EXPECT_TRUE(verifyOfflineResponse(kVectorMachineId, kVectorSerial, "FNYOQQP3MM39", kVectorSecret)); +} + +TEST(OfflineActivationTests, verifyOfflineResponse_tamperedResponse_fails) +{ + auto tampered = std::string(kVectorResponse); + tampered[0] = (tampered[0] == 'C') ? 'D' : 'C'; + + EXPECT_FALSE(verifyOfflineResponse(kVectorMachineId, kVectorSerial, tampered, kVectorSecret)); +} + +TEST(OfflineActivationTests, verifyOfflineResponse_differentMachine_fails) +{ + EXPECT_FALSE(verifyOfflineResponse("test-machine-id-0002", kVectorSerial, kVectorResponse, kVectorSecret)); +} + +TEST(OfflineActivationTests, verifyOfflineResponse_differentSerial_fails) +{ + EXPECT_FALSE(verifyOfflineResponse(kVectorMachineId, "deadbeef00112234", kVectorResponse, kVectorSecret)); +} + +TEST(OfflineActivationTests, buildOfflineChallenge_emptyMachineId_returnsEmpty) +{ + EXPECT_EQ(buildOfflineChallenge("", kVectorSerial), ""); +} + +TEST(OfflineActivationTests, verifyOfflineResponse_emptyMachineId_fails) +{ + EXPECT_FALSE(verifyOfflineResponse("", kVectorSerial, kVectorResponse, kVectorSecret)); +} + +TEST(OfflineActivationTests, verifyOfflineResponse_malformedResponse_fails) +{ + EXPECT_FALSE(verifyOfflineResponse(kVectorMachineId, kVectorSerial, "", kVectorSecret)); + EXPECT_FALSE(verifyOfflineResponse(kVectorMachineId, kVectorSerial, "not a code @#!", kVectorSecret)); + EXPECT_FALSE(verifyOfflineResponse(kVectorMachineId, kVectorSerial, "FNY0", kVectorSecret)); +} + +TEST(OfflineActivationTests, verifyOfflineResponse_malformedSecret_fails) +{ + EXPECT_FALSE(verifyOfflineResponse(kVectorMachineId, kVectorSerial, kVectorResponse, "")); + EXPECT_FALSE(verifyOfflineResponse(kVectorMachineId, kVectorSerial, kVectorResponse, "abc123")); +} + +TEST(OfflineActivationTests, verifyOfflineResponse_embeddedProductionSecret_rejectsTestVector) +{ + EXPECT_FALSE(verifyOfflineResponse(kVectorMachineId, kVectorSerial, kVectorResponse)); +} + +// Fixed-secret vector asserted identically in the vendor signer's +// offline-activation-protocol.spec.ts (web-muskoka), so this pins both +// implementations to one shared vector. +TEST(OfflineActivationTests, verifyOfflineResponse_sharedVendorVector_verifies) +{ + EXPECT_EQ(buildOfflineChallenge("test machine id", "TEST SERIAL KEY"), "7D6NNQP2VMRH"); + EXPECT_TRUE(verifyOfflineResponse("test machine id", "TEST SERIAL KEY", "PHSD-R6RS-YSSX", kVectorSecret)); +} + +TEST(OfflineActivationTests, formatOfflineCode_groupsOfFour_insertsDashes) +{ + EXPECT_EQ(formatOfflineCode("AAAABBBBCC", 4), "AAAA-BBBB-CC"); + EXPECT_EQ(formatOfflineCode("AAAA", 4), "AAAA"); + EXPECT_EQ(formatOfflineCode("", 4), ""); +} + +TEST(OfflineActivationTests, parseSerialKey_offlineMode_isOffline) +{ + // {v3;offline;;business;Bob;1;bob@example.com;Acme;;} + const auto serialKey = parseSerialKey("7b76333b6f66666c696e653b3b627573696e6573733b426f623b313b626f6240657" + "8616d706c652e636f6d3b41636d653b3b7d"); + + EXPECT_TRUE(serialKey.isValid); + EXPECT_TRUE(serialKey.isOffline); + EXPECT_EQ(serialKey.seats, 1); +} + +TEST(OfflineActivationTests, parseSerialKey_multiSeatKey_parsesSeatCount) +{ + // {v3;offline;;business;Bob;2;bob@example.com;Acme;;} + const auto serialKey = parseSerialKey("7b76333b6f66666c696e653b3b627573696e6573733b426f623b323b626f6240657" + "8616d706c652e636f6d3b41636d653b3b7d"); + + EXPECT_EQ(serialKey.seats, 2); +} diff --git a/extra/src/unittests/license/ProductTests.cpp b/extra/src/unittests/license/ProductTests.cpp new file mode 100644 index 000000000..87dba0f73 --- /dev/null +++ b/extra/src/unittests/license/ProductTests.cpp @@ -0,0 +1,90 @@ +/* + * 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 . + */ + +// TODO: Move these tests and the code under test downstream to Synergy + +#define TEST_ENV + +#include "synergy/license/Product.h" + +#include + +using enum Product::Edition; + +TEST(ProductTests, equal_operator) +{ + Product product1(kPro); + Product product2(kPro); + + EXPECT_EQ(product1, product2); +} + +TEST(ProductTests, ctor_businessName_isValid) +{ + Product product(Product::SerialKeyEditionID::Business); + + EXPECT_EQ(kBusiness, product.edition()); + EXPECT_TRUE(product.isValid()); +} + +TEST(ProductTests, ctor_basicType_isValid) +{ + Product product(kBasic); + + EXPECT_TRUE(product.isValid()); +} + +TEST(ProductTests, setEdition_invalidType_throws) +{ + Product product; + + EXPECT_THROW(product.setEdition("test"), Product::InvalidProductEdition); +} + +TEST(ProductTests, setEdition_pro_isValid) +{ + Product product; + + product.setEdition(kPro); + + EXPECT_EQ(kPro, product.edition()); + EXPECT_EQ(Product::SerialKeyEditionID::Pro, product.serialKeyId()); + EXPECT_EQ("Synergy 1 Pro", product.name()); + EXPECT_TRUE(product.isValid()); +} + +TEST(ProductTests, setEdition_basic_isValid) +{ + Product product; + + product.setEdition(kBasic); + + EXPECT_EQ(kBasic, product.edition()); + EXPECT_EQ(Product::SerialKeyEditionID::Basic, product.serialKeyId()); + EXPECT_EQ("Synergy 1 Basic", product.name()); +} + +TEST(ProductTests, setEdition_business_isValid) +{ + Product product; + + product.setEdition(kBusiness); + + EXPECT_EQ(kBusiness, product.edition()); + EXPECT_EQ(Product::SerialKeyEditionID::Business, product.serialKeyId()); + EXPECT_EQ("Synergy 1 Business", product.name()); +} diff --git a/extra/src/unittests/license/SerialKeyTypeTests.cpp b/extra/src/unittests/license/SerialKeyTypeTests.cpp new file mode 100644 index 000000000..0f2ec725d --- /dev/null +++ b/extra/src/unittests/license/SerialKeyTypeTests.cpp @@ -0,0 +1,45 @@ +/* + * 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 . + */ + +#define TEST_ENV + +#include "synergy/license/SerialKeyType.h" + +#include + +TEST(SerialKeyTypeTests, TrialTemporaryKeyType_false) +{ + SerialKeyType KeyType; + EXPECT_FALSE(KeyType.isTrial()); + EXPECT_FALSE(KeyType.isSubscription()); +} + +TEST(SerialKeyTypeTests, TrialTemporaryKeyType_true) +{ + SerialKeyType KeyType; + KeyType.setType("trial"); + EXPECT_TRUE(KeyType.isTrial()); + EXPECT_FALSE(KeyType.isSubscription()); +} + +TEST(SerialKeyTypeTests, TimeLimitedKeyType_true) +{ + SerialKeyType KeyType; + KeyType.setType("subscription"); + EXPECT_FALSE(KeyType.isTrial()); + EXPECT_TRUE(KeyType.isSubscription()); +} diff --git a/sonar-project.properties b/sonar-project.properties index 82e227410..e7731c01a 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -1,5 +1,6 @@ -sonar.organization=deskflow -sonar.projectKey=deskflow_deskflow +sonar.projectName=synergy +sonar.organization=symless +sonar.projectKey=symless_synergy sonar.sources=src/apps,src/lib sonar.tests=src/unittests sonar.exclusions=subprojects/**,build/**,translations/** diff --git a/src/apps/CMakeLists.txt b/src/apps/CMakeLists.txt index 2f7669de9..f0bb44ec0 100644 --- a/src/apps/CMakeLists.txt +++ b/src/apps/CMakeLists.txt @@ -18,11 +18,11 @@ function(generate_app_man TARGET NAME) --name ${NAME} --include ${CMAKE_SOURCE_DIR}/src/apps/res/manpage.txt --no-info - ${target} - -o $/${target}.1 + $ + -o $/$.1 ) install( - FILES $/${target}.1 + FILES $/$.1 DESTINATION ${CMAKE_INSTALL_MANDIR}/man1 ) endif() @@ -49,4 +49,6 @@ endif() add_subdirectory(deskflow-core) add_subdirectory(deskflow-daemon) #Only used on windows -add_subdirectory(deskflow-gui) +if(BUILD_GUI) + add_subdirectory(deskflow-gui) +endif() diff --git a/src/apps/deskflow-core/CMakeLists.txt b/src/apps/deskflow-core/CMakeLists.txt index d1715a3d9..2e2804add 100644 --- a/src/apps/deskflow-core/CMakeLists.txt +++ b/src/apps/deskflow-core/CMakeLists.txt @@ -4,9 +4,10 @@ # SPDX-License-Identifier: MIT set(target ${CMAKE_PROJECT_NAME}-core) +set(filename deskflow-core) add_executable(${target} - "${target}.cpp" + "${filename}.cpp" CoreArgs.h CoreArgParser.h CoreArgParser.cpp @@ -15,12 +16,12 @@ add_executable(${target} if(WIN32) # Generate rc file set(EXE_DESCRIPTION "${CMAKE_PROJECT_PROPER_NAME} combined server and client application") - set(EXE_ICON "IDI_DESKFLOW ICON DISCARDABLE \"${CMAKE_SOURCE_DIR}/src/apps/res/deskflow.ico\"") + set(EXE_ICON "IDI_DESKFLOW ICON DISCARDABLE \"${CMAKE_SOURCE_DIR}/extra/src/apps/res/synergy.ico\"") configure_file(${CMAKE_SOURCE_DIR}/src/apps/res/windows.rc.in ${target}.rc) target_sources(${target} PRIVATE - ${target}.exe.manifest - ${CMAKE_SOURCE_DIR}/src/apps/res/deskflow.ico + ${filename}.exe.manifest + ${CMAKE_SOURCE_DIR}/extra/src/apps/res/synergy.ico ${CMAKE_CURRENT_BINARY_DIR}/${target}.rc ) endif() diff --git a/src/apps/deskflow-core/deskflow-core.cpp b/src/apps/deskflow-core/deskflow-core.cpp index 9d6368718..52df86ef4 100644 --- a/src/apps/deskflow-core/deskflow-core.cpp +++ b/src/apps/deskflow-core/deskflow-core.cpp @@ -116,7 +116,7 @@ int main(int argc, char **argv) sharedMemory.detach(); if (!sharedMemory.create(1) && parser.singleInstanceOnly()) { - LOG_WARN("an instance of deskflow core is already running"); + LOG_WARN("an instance of %s is already running", kCoreBinName); return s_exitDuplicate; } diff --git a/src/apps/deskflow-daemon/CMakeLists.txt b/src/apps/deskflow-daemon/CMakeLists.txt index 69cfea48e..ebfebc9f4 100644 --- a/src/apps/deskflow-daemon/CMakeLists.txt +++ b/src/apps/deskflow-daemon/CMakeLists.txt @@ -6,15 +6,16 @@ # Daemon is only needed on Windows for elevating processes to deal with UAC. if(WIN32) set(target ${CMAKE_PROJECT_NAME}-daemon) + set(filename deskflow-daemon) # Generate rc file set(EXE_DESCRIPTION "${CMAKE_PROJECT_PROPER_NAME} Daemon for handling secure desktops (UAC prompts, login screen, etc)") - set(EXE_ICON "IDI_DESKFLOW ICON DISCARDABLE \"${CMAKE_SOURCE_DIR}/src/apps/res/deskflow.ico\"") + set(EXE_ICON "IDI_DESKFLOW ICON DISCARDABLE \"${CMAKE_SOURCE_DIR}/extra/src/apps/res/synergy.ico\"") configure_file(${CMAKE_SOURCE_DIR}/src/apps/res/windows.rc.in ${target}.rc) add_executable( ${target} WIN32 - ${target}.cpp + ${filename}.cpp DaemonApp.cpp DaemonApp.h ${CMAKE_CURRENT_BINARY_DIR}/${target}.rc) diff --git a/src/apps/deskflow-daemon/deskflow-daemon.cpp b/src/apps/deskflow-daemon/deskflow-daemon.cpp index c88912d42..82dc0d101 100644 --- a/src/apps/deskflow-daemon/deskflow-daemon.cpp +++ b/src/apps/deskflow-daemon/deskflow-daemon.cpp @@ -10,6 +10,7 @@ #include "arch/Arch.h" #include "base/EventQueue.h" #include "base/Log.h" +#include "common/Constants.h" #include "common/ExitCodes.h" #include "common/Settings.h" #include "common/VersionInfo.h" @@ -131,6 +132,7 @@ void handleError(const char *message) #if defined(Q_OS_WIN) // Show a message box for when run from MSI in Win32 subsystem. - MessageBoxA(nullptr, message, "Deskflow daemon error", MB_OK | MB_ICONERROR); + const std::string title = std::string(kAppName) + " daemon error"; + MessageBoxA(nullptr, message, title.c_str(), MB_OK | MB_ICONERROR); #endif } diff --git a/src/apps/deskflow-gui/CMakeLists.txt b/src/apps/deskflow-gui/CMakeLists.txt index b349c2385..5bc2f1c02 100644 --- a/src/apps/deskflow-gui/CMakeLists.txt +++ b/src/apps/deskflow-gui/CMakeLists.txt @@ -3,16 +3,17 @@ # SPDX-License-Identifier: MIT if(APPLE) - set(target Deskflow) + set(target ${CMAKE_PROJECT_PROPER_NAME}) else() - set(target deskflow) + set(target ${CMAKE_PROJECT_NAME}) endif() +set(filename deskflow-gui) set(CMAKE_INCLUDE_CURRENT_DIR ON) if(WIN32) set(EXE_DESCRIPTION "${CMAKE_PROJECT_PROPER_NAME} GUI configuration tool") - set(EXE_ICON "IDI_ICON1 ICON DISCARDABLE \"${CMAKE_SOURCE_DIR}/src/apps/res/deskflow.ico\" ") + set(EXE_ICON "IDI_ICON1 ICON DISCARDABLE \"${CMAKE_SOURCE_DIR}/extra/src/apps/res/synergy.ico\" ") configure_file(${CMAKE_SOURCE_DIR}/src/apps/res/windows.rc.in deskflow.rc) set(platform_extra deskflow.rc) elseif(BUILD_OSX_BUNDLE) @@ -31,14 +32,15 @@ elseif(BUILD_OSX_BUNDLE) @ONLY ) - set(platform_extra ../res/Deskflow.icns) + set(platform_extra ${CMAKE_SOURCE_DIR}/extra/deploy/mac/bundle/Contents/Resources/${target}.icns) set_source_files_properties(${platform_extra} PROPERTIES MACOSX_PACKAGE_LOCATION "Resources") endif() add_executable(${target} WIN32 MACOSX_BUNDLE ${platform_extra} ../res/deskflow.qrc - deskflow-gui.cpp + ${CMAKE_SOURCE_DIR}/extra/src/apps/res/synergy.qrc + ${filename}.cpp ) target_link_libraries( diff --git a/src/apps/deskflow-gui/deskflow-gui.cpp b/src/apps/deskflow-gui/deskflow-gui.cpp index 7fd5cc09e..80ad3fe42 100644 --- a/src/apps/deskflow-gui/deskflow-gui.cpp +++ b/src/apps/deskflow-gui/deskflow-gui.cpp @@ -17,6 +17,10 @@ #include "gui/Messages.h" #include "gui/StyleUtils.h" +#ifdef SYNERGY_EXTRA_HEADER +#include "synergy/hooks/gui_hook.h" +#endif + #include #include #include @@ -63,6 +67,10 @@ int main(int argc, char *argv[]) QApplication app(argc, argv); +#ifdef SYNERGY_EXTRA_HEADER + synergy::hooks::onPreInit(); +#endif + // Ensure the I18N object is made before strings QTextStream(stdout) << "initial language: " << I18N::currentLanguage() << '\n'; @@ -152,6 +160,12 @@ int main(int argc, char *argv[]) MainWindow mainWindow; mainWindow.open(); +#ifdef SYNERGY_EXTRA_HEADER + if (!synergy::hooks::onAppStart()) { + return s_exitFailed; + } +#endif + return QApplication::exec(); } diff --git a/src/apps/res/deskflow.plist.in b/src/apps/res/deskflow.plist.in index c95e4d4d3..da3cbced2 100644 --- a/src/apps/res/deskflow.plist.in +++ b/src/apps/res/deskflow.plist.in @@ -41,7 +41,7 @@ NSLocalNetworkUsageDescription - This app uses local network permissions for communication between a Deskflow server and clients. + This app uses local network permissions for communication between a @CMAKE_PROJECT_PROPER_NAME@ server and clients. com.apple.security.networking.local diff --git a/src/lib/CMakeLists.txt b/src/lib/CMakeLists.txt index d007a8900..f114faf59 100644 --- a/src/lib/CMakeLists.txt +++ b/src/lib/CMakeLists.txt @@ -13,4 +13,6 @@ add_subdirectory(mt) add_subdirectory(net) add_subdirectory(platform) add_subdirectory(server) -add_subdirectory(gui) +if(BUILD_GUI) + add_subdirectory(gui) +endif() diff --git a/src/lib/arch/Arch.h b/src/lib/arch/Arch.h index d1d75da9c..809b0a70a 100644 --- a/src/lib/arch/Arch.h +++ b/src/lib/arch/Arch.h @@ -5,7 +5,9 @@ * SPDX-FileCopyrightText: (C) 2002 Chris Schoeneman * SPDX-License-Identifier: GPL-2.0-only WITH LicenseRef-OpenSSL-Exception */ -#include + +#include + // Consider whether or not to use either encapsulation (as below) // or inheritance (as it is now) for the ARCH stuff. // diff --git a/src/lib/arch/win32/ArchDaemonWindows.cpp b/src/lib/arch/win32/ArchDaemonWindows.cpp index 9e840caf4..d8285919a 100644 --- a/src/lib/arch/win32/ArchDaemonWindows.cpp +++ b/src/lib/arch/win32/ArchDaemonWindows.cpp @@ -22,7 +22,7 @@ ArchDaemonWindows *ArchDaemonWindows::s_daemon = nullptr; ArchDaemonWindows::ArchDaemonWindows() : m_daemonThreadID(0) { - m_quitMessage = RegisterWindowMessage(L"DeskflowDaemonExit"); + m_quitMessage = RegisterWindowMessage(kDaemonExitMsgNameW); } int ArchDaemonWindows::runDaemon(RunFunc runFunc) diff --git a/src/lib/common/Constants.h.in b/src/lib/common/Constants.h.in index 1aa97f1c0..4bf449406 100644 --- a/src/lib/common/Constants.h.in +++ b/src/lib/common/Constants.h.in @@ -1,7 +1,7 @@ /* * Deskflow -- mouse and keyboard sharing utility * SPDX-FileCopyrightText: (C) 2024 - 2025 Chris Rizzitello - * SPDX-FileCopyrightText: (C) 2025 Symless Ltd. + * SPDX-FileCopyrightText: (C) 2025 - 2026 Symless Ltd. * SPDX-License-Identifier: GPL-2.0-only WITH LicenseRef-OpenSSL-Exception */ @@ -9,19 +9,21 @@ const auto kAppName = "@CMAKE_PROJECT_PROPER_NAME@"; const auto kAppId = "@CMAKE_PROJECT_NAME@"; +const auto kUpstreamId = "@UPSTREAM_PROJECT_NAME@"; const auto kAppDescription = "@CMAKE_PROJECT_DESCRIPTION@"; +const auto kAppDomain = "@CMAKE_PROJECT_DOMAIN@"; const auto kDaemonBinName = "@CMAKE_PROJECT_NAME@-daemon"; const auto kDaemonIpcName = "@CMAKE_PROJECT_NAME@-daemon"; const auto kRevFqdnName = "@CMAKE_PROJECT_REV_FQDN@"; const auto kCopyright = // "Copyright @CMAKE_PROJECT_COPYRIGHT@\n" - "Copyright (C) 2012-2025 Symless Ltd.\n" "Copyright (C) 2009-2012 Nick Bolton\n" "Copyright (C) 2002-2009 Chris Schoeneman"; const auto kCoreBinName = "@CORE_BINARY@"; const auto kCoreIpcName = "@CMAKE_PROJECT_NAME@-core"; +const auto kGuiSocketName = "@CMAKE_PROJECT_NAME@-gui"; #ifdef _WIN32 @@ -30,7 +32,11 @@ const auto kWindowsRuntimeMajor = @REQUIRED_MSVC_RUNTIME_MAJOR@; const auto kWindowsRuntimeMinor = @REQUIRED_MSVC_RUNTIME_MINOR@; // clang-format on +constexpr auto kCoreBinNameW = L"@CORE_BINARY@"; constexpr auto kCloseEventName = L"Global\\@CMAKE_PROJECT_PROPER_NAME@Close"; constexpr auto kSendSasEventName = L"Global\\@CMAKE_PROJECT_PROPER_NAME@SendSAS"; +constexpr auto kClipboardOwnershipFormatW = L"@CMAKE_PROJECT_PROPER_NAME@ Ownership"; +constexpr auto kDeskClassNameW = L"@CMAKE_PROJECT_PROPER_NAME@Desk"; +constexpr auto kDaemonExitMsgNameW = L"@CMAKE_PROJECT_PROPER_NAME@DaemonExit"; #endif diff --git a/src/lib/common/I18N.cpp b/src/lib/common/I18N.cpp index 65c614438..91a58d118 100644 --- a/src/lib/common/I18N.cpp +++ b/src/lib/common/I18N.cpp @@ -36,7 +36,7 @@ I18N::I18N(QObject *parent) : QObject{parent} {QStringLiteral("/usr/local/share/%1/translations").arg(kAppId)}, {QStringLiteral("/usr/share/%1/translations").arg(kAppId)} }; - const QStringList appTrFilter{QStringLiteral("%1*.qm").arg(kAppId)}; + const QStringList appTrFilter{QStringLiteral("%1*.qm").arg(kUpstreamId)}; for (const auto &dir : appTrDirs) { if (!dir.entryList(appTrFilter, QDir::Files, QDir::Name).isEmpty()) { @@ -84,7 +84,7 @@ I18N::I18N(QObject *parent) : QObject{parent} if (Settings::value(Settings::Core::Language).toString().isEmpty()) { auto appTranslator = new QTranslator(this); - if (appTranslator->load(QLocale(), kAppId, s_prefix, m_appTrPath)) { + if (appTranslator->load(QLocale(), kUpstreamId, s_prefix, m_appTrPath)) { m_currentTranslations.append(appTranslator); QCoreApplication::installTranslator(appTranslator); } @@ -173,7 +173,7 @@ void I18N::detectLanguages() m_translations.clear(); m_nameMap.clear(); - QStringList nameFilter = {QStringLiteral("%1_*.qm").arg(kAppId)}; + QStringList nameFilter = {QStringLiteral("%1_*.qm").arg(kUpstreamId)}; QMap appTranslations; QStringList detectedLangCodes; QDir dir(m_appTrPath); diff --git a/src/lib/common/LogLevel.h b/src/lib/common/LogLevel.h index a6e790a85..3fe39fb7f 100644 --- a/src/lib/common/LogLevel.h +++ b/src/lib/common/LogLevel.h @@ -42,9 +42,18 @@ class LogLevel : private QObject return m_levelOptions.at(level); } + static qsizetype indexOfOption(const QString &level) + { + for (qsizetype i = 0; i < m_levelOptions.size(); ++i) { + if (m_levelOptions.at(i).compare(level, Qt::CaseInsensitive) == 0) + return i; + } + return -1; + } + static LogLevel::Level fromOption(const QString &level) { - const auto index = m_levelOptions.indexOf(level, 0, Qt::CaseInsensitive); + const auto index = indexOfOption(level); if (index < 0 || index >= m_levelOptions.count()) return LogLevel::Level::Info; return LogLevel::Level(index); diff --git a/src/lib/common/Settings.cpp b/src/lib/common/Settings.cpp index d35b4d38a..d5345e363 100644 --- a/src/lib/common/Settings.cpp +++ b/src/lib/common/Settings.cpp @@ -8,7 +8,7 @@ #include "LogLevel.h" #include "NetworkProtocol.h" -#include "UrlConstants.h" +#include "common/UrlConstants.h" #include #include @@ -81,9 +81,12 @@ Settings::Settings(QObject *parent) : QObject(parent) qInfo().noquote() << "initial settings file:" << m_settings->fileName(); const auto xdgStateHome = qEnvironmentVariable("XDG_STATE_HOME"); - const auto stateBase = !xdgStateHome.isEmpty() - ? xdgStateHome - : QStandardPaths::standardLocations(QStandardPaths::GenericStateLocation).at(0); +#if QT_VERSION >= QT_VERSION_CHECK(6, 7, 0) + const auto stateDir = QStandardPaths::standardLocations(QStandardPaths::GenericStateLocation).at(0); +#else + const auto stateDir = QStandardPaths::writableLocation(QStandardPaths::GenericDataLocation); +#endif + const auto stateBase = !xdgStateHome.isEmpty() ? xdgStateHome : stateDir; const auto stateFile = QStringLiteral("%1/%2.state").arg(stateBase, kAppName); m_stateSettings = new QSettings(stateFile, QSettings::IniFormat, this); @@ -97,10 +100,12 @@ Settings::Settings(QObject *parent) : QObject(parent) void Settings::upgradeSettings() { const auto logValue = m_settings->value(Settings::Log::Level).toString(); - if (!LogLevel::logLevelOptions().contains(logValue, Qt::CaseInsensitive)) + if (LogLevel::indexOfOption(logValue) < 0) m_settings->setValue(Settings::Log::Level, defaultValue(Settings::Log::Level)); - for (const auto [oldKey, newKey] : m_upgradedMap.asKeyValueRange()) { + for (auto it = m_upgradedMap.cbegin(); it != m_upgradedMap.cend(); ++it) { + const auto &oldKey = it.key(); + const auto &newKey = it.value(); if (m_settings->contains(oldKey) && !m_settings->contains(newKey)) { m_settings->setValue(newKey, m_settings->value(oldKey)); } @@ -151,9 +156,9 @@ QString Settings::cleanComputerName(const QString &name) cleanName.replace(space, underscore); cleanName.replace(nameRegex, {}); while (cleanName.startsWith(hyphen) || cleanName.startsWith(underscore) || cleanName.startsWith(period)) - cleanName.removeFirst(); + cleanName.remove(0, 1); while (cleanName.endsWith(hyphen) || cleanName.endsWith(underscore) || cleanName.endsWith(period)) - cleanName.removeLast(); + cleanName.chop(1); if (cleanName.length() > 255) { cleanName.truncate(255); cleanName = cleanComputerName(cleanName); @@ -208,7 +213,7 @@ QVariant Settings::defaultValue(const QString &key) return 1.0; if (key == Server::Protocol) - return QVariant::fromValue(NetworkProtocol::Barrier); + return QVariant::fromValue(NetworkProtocol::Synergy); return QVariant(); } diff --git a/src/lib/common/UrlConstants.h b/src/lib/common/UrlConstants.h index 332db4201..7dc6d011c 100644 --- a/src/lib/common/UrlConstants.h +++ b/src/lib/common/UrlConstants.h @@ -1,24 +1,26 @@ /* * Deskflow -- mouse and keyboard sharing utility - * SPDX-FileCopyrightText: (C) 2024 Symless Ltd. + * SPDX-FileCopyrightText: (C) 2024 - 2026 Symless Ltd. * SPDX-License-Identifier: GPL-2.0-only WITH LicenseRef-OpenSSL-Exception */ #pragma once +#include "common/Constants.h" + #include // important: this is used for settings paths on some platforms, // and must not be a url. qt automatically converts this to reverse domain // notation (rdn), e.g. org.deskflow -const auto kOrgDomain = QStringLiteral("deskflow.org"); +const auto kOrgDomain = QString::fromUtf8(kAppDomain); -const auto kUrlSourceQuery = QStringLiteral("source=gui"); +const auto kUrlSourceQuery = QStringLiteral("utm_source=gui-s1"); const auto kUrlApp = QStringLiteral("https://%1").arg(kOrgDomain); const auto kUrlHelp = QStringLiteral("%1/help?%2").arg(kUrlApp, kUrlSourceQuery); const auto kUrlDownload = QStringLiteral("%1/download?%2").arg(kUrlApp, kUrlSourceQuery); -const auto kUrlUpdateCheck = QStringLiteral("https://api.%1/version").arg(kOrgDomain); +const auto kUrlUpdateCheck = QStringLiteral("https://api.symless.com/version"); #if defined(Q_OS_LINUX) const auto kUrlGnomeTrayFix = QStringLiteral("https://extensions.gnome.org/extension/615/appindicator-support/"); diff --git a/src/lib/common/VersionInfo.h.in b/src/lib/common/VersionInfo.h.in index f380ac9fb..5ab83af87 100644 --- a/src/lib/common/VersionInfo.h.in +++ b/src/lib/common/VersionInfo.h.in @@ -9,6 +9,4 @@ const auto kVersion = "@CMAKE_PROJECT_VERSION@"; const auto kVersionGitSha = "@GIT_SHA_SHORT@"; -const auto kDisplayVersion = @CMAKE_PROJECT_VERSION_TWEAK@ - ? "@CMAKE_PROJECT_VERSION@ (@GIT_SHA_SHORT@)" - : "@CMAKE_PROJECT_VERSION_MAJOR@.@CMAKE_PROJECT_VERSION_MINOR@.@CMAKE_PROJECT_VERSION_PATCH@"; +const auto kDisplayVersion = "@CMAKE_PROJECT_VERSION_DISPLAY@"; diff --git a/src/lib/gui/MainWindow.cpp b/src/lib/gui/MainWindow.cpp index ab467db76..c598589d8 100644 --- a/src/lib/gui/MainWindow.cpp +++ b/src/lib/gui/MainWindow.cpp @@ -31,6 +31,10 @@ #include "net/FingerprintDatabase.h" #include "widgets/StatusBar.h" +#ifdef SYNERGY_EXTRA_HEADER +#include "synergy/hooks/gui_hook.h" +#endif + #include #include #include @@ -89,7 +93,7 @@ MainWindow::MainWindow() // Setup Actions m_actionAbout->setMenuRole(QAction::AboutRole); - m_actionAbout->setIcon(QIcon::fromTheme(QIcon::ThemeIcon::HelpAbout)); + m_actionAbout->setIcon(QIcon::fromTheme(QStringLiteral("help-about"))); m_actionMinimize->setIcon(QIcon::fromTheme(QStringLiteral("window-minimize-pip"))); m_actionRestore->setIcon(QIcon::fromTheme(QStringLiteral("window-restore-pip"))); @@ -118,7 +122,7 @@ MainWindow::MainWindow() m_actionRestartCore->setIcon(QIcon::fromTheme(QStringLiteral("view-refresh"))); m_actionRestartCore->setMenuRole(QAction::NoRole); - m_actionStopCore->setIcon(QIcon::fromTheme(QIcon::ThemeIcon::ProcessStop)); + m_actionStopCore->setIcon(QIcon::fromTheme(QStringLiteral("process-stop"))); m_actionStopCore->setMenuRole(QAction::NoRole); m_actionReportBug->setIcon(QIcon::fromTheme(QStringLiteral("tools-report-bug"))); @@ -157,6 +161,10 @@ MainWindow::MainWindow() applyConfig(); m_statusBar->setSecurityIcon(TlsUtility::isEnabled()); restoreWindow(); + +#ifdef SYNERGY_EXTRA_HEADER + synergy::hooks::onMainWindow(this, &m_coreProcess); +#endif } MainWindow::~MainWindow() { @@ -403,8 +411,15 @@ void MainWindow::startCore() m_serverStartSuggestedIP = m_serverStartIPs.isEmpty() ? "" : m_serverStartIPs.first(); } +#ifdef SYNERGY_EXTRA_HEADER + if (!synergy::hooks::onCoreStart()) { + return; + } +#endif + m_actionStartCore->setVisible(false); m_actionRestartCore->setVisible(true); + m_coreProcess.start(); } @@ -483,6 +498,12 @@ void MainWindow::openSettings() void MainWindow::resetCore() { +#ifdef SYNERGY_EXTRA_HEADER + if (!synergy::hooks::onCoreStart()) { + return; + } +#endif + m_coreProcess.restart(); } @@ -561,7 +582,7 @@ void MainWindow::updateModeControlLabels() startText = tr("Start"); stopText = tr("Stop"); startIcon = QIcon::fromTheme(QStringLiteral("system-run")); - stopIcon = QIcon::fromTheme(QIcon::ThemeIcon::ProcessStop); + stopIcon = QIcon::fromTheme(QStringLiteral("process-stop")); } else { startText = tr("Connect"); stopText = tr("Disconnect"); @@ -649,6 +670,10 @@ void MainWindow::open() } if (Settings::value(Settings::Gui::AutoStartCore).toBool()) { + if (m_coreProcess.mode() == CoreMode::None) { + qWarning() << "skipping core auto start, mode is not set"; + return; + } if (ui->rbModeClient->isChecked() && ui->lineHostname->text().isEmpty()) return; startCore(); @@ -702,6 +727,9 @@ void MainWindow::applyConfig() } else { setWindowTitle(kAppName); } +#ifdef SYNERGY_EXTRA_HEADER + synergy::hooks::onTitleApplied(this); +#endif if (const auto host = Settings::value(Settings::Client::RemoteHost).toString(); !host.isEmpty()) ui->lineHostname->setText(host); @@ -738,7 +766,11 @@ void MainWindow::setTrayIcon() if (deskflow::platform::isMac()) m_trayIcon->setIcon(QIcon::fromTheme(themeIcon)); else +#ifdef SYNERGY_EXTRA_HEADER + m_trayIcon->setIcon(synergy::hooks::trayIcon(fallbackPath.arg(kAppId, QStringLiteral("dark"), themeIcon))); +#else m_trayIcon->setIcon(QIcon(fallbackPath.arg(kAppId, QStringLiteral("dark"), themeIcon))); +#endif return; } @@ -755,7 +787,12 @@ void MainWindow::setTrayIcon() return; } - auto icon = QIcon::fromTheme(themeIcon, QIcon(fallbackPath.arg(kAppId, iconMode(), themeIcon))); + auto icon = +#ifdef SYNERGY_EXTRA_HEADER + synergy::hooks::trayIcon(fallbackPath.arg(kAppId, iconMode(), themeIcon)); +#else + QIcon::fromTheme(themeIcon, QIcon(fallbackPath.arg(kAppId, iconMode(), themeIcon))); +#endif icon.setIsMask(true); m_trayIcon->setIcon(icon); } @@ -1046,7 +1083,7 @@ void MainWindow::updateText() m_menuHelp->setTitle(tr("&Help")); m_actionClearSettings->setText(tr("Clear settings")); - m_actionReportBug->setText(tr("Report a Bug")); + m_actionReportBug->setText(tr("Get help")); m_actionMinimize->setText(tr("&Minimize to tray")); m_actionQuit->setText(tr("&Quit")); m_actionTrayQuit->setText(tr("&Quit")); diff --git a/src/lib/gui/MainWindow.h b/src/lib/gui/MainWindow.h index 1ec5d97f6..3c34f706e 100644 --- a/src/lib/gui/MainWindow.h +++ b/src/lib/gui/MainWindow.h @@ -15,6 +15,7 @@ #include #include "VersionChecker.h" +#include "common/Constants.h" #include "config/ServerConfig.h" #include "gui/core/CoreProcess.h" #include "gui/core/NetworkMonitor.h" @@ -160,7 +161,7 @@ class MainWindow : public QMainWindow void serverClientsChanged(const QStringList &clients); - inline static const auto m_guiSocketName = QStringLiteral("deskflow-gui"); + inline static const auto m_guiSocketName = QString::fromUtf8(kGuiSocketName); inline static const auto m_nameRegEx = QRegularExpression(QStringLiteral("^[\\w\\-_\\.]{0,255}$")); VersionChecker m_versionChecker; diff --git a/src/lib/gui/MainWindow.ui b/src/lib/gui/MainWindow.ui index 733be38ca..f85b494ae 100644 --- a/src/lib/gui/MainWindow.ui +++ b/src/lib/gui/MainWindow.ui @@ -290,7 +290,7 @@ Export server configuration - + diff --git a/src/lib/gui/VersionChecker.cpp b/src/lib/gui/VersionChecker.cpp index 1dbcf6cdf..f384433a9 100644 --- a/src/lib/gui/VersionChecker.cpp +++ b/src/lib/gui/VersionChecker.cpp @@ -9,6 +9,10 @@ #include "common/Settings.h" #include "common/VersionInfo.h" +#ifdef SYNERGY_EXTRA_HEADER +#include "synergy/hooks/gui_hook.h" +#endif + #include #include #include @@ -24,7 +28,10 @@ VersionChecker::VersionChecker(QObject *parent) : QObject(parent), m_network{new void VersionChecker::checkLatest() const { - const QString url = Settings::value(Settings::Gui::UpdateCheckUrl).toString(); + QString url = Settings::value(Settings::Gui::UpdateCheckUrl).toString(); +#ifdef SYNERGY_EXTRA_HEADER + synergy::hooks::onVersionCheck(url); +#endif qDebug("checking for updates at: %s", qPrintable(url)); auto request = QNetworkRequest(url); auto userAgent = QString("%1 %2 on %3").arg(kAppName, kVersion, QSysInfo::prettyProductName()); diff --git a/src/lib/gui/config/ServerConfig.cpp b/src/lib/gui/config/ServerConfig.cpp index 75f38e0a6..0f976fd30 100644 --- a/src/lib/gui/config/ServerConfig.cpp +++ b/src/lib/gui/config/ServerConfig.cpp @@ -258,7 +258,7 @@ QTextStream &operator<<(QTextStream &outStream, const ServerConfig &config) outStream << "\t" << "heartbeat = " << config.heartbeat() << Qt::endl; if (config.protocol() == NetworkProtocol::Unknown) - qFatal("unrecognized protocol when writing config"); + qCritical("unrecognized protocol when writing config"); outStream << "\t" << "protocol = " << networkProtocolToOption(config.protocol()) << Qt::endl; outStream << "\t" diff --git a/src/lib/gui/config/ServerConfig.h b/src/lib/gui/config/ServerConfig.h index 5196c56b2..492ea542c 100644 --- a/src/lib/gui/config/ServerConfig.h +++ b/src/lib/gui/config/ServerConfig.h @@ -25,8 +25,7 @@ class ServerConfigDialog; namespace deskflow::gui { -// The default protocol was decided by a community vote. -const auto kDefaultProtocol = NetworkProtocol::Barrier; +const auto kDefaultProtocol = NetworkProtocol::Synergy; } // namespace deskflow::gui diff --git a/src/lib/gui/core/CoreProcess.cpp b/src/lib/gui/core/CoreProcess.cpp index be4e5d667..83be94011 100644 --- a/src/lib/gui/core/CoreProcess.cpp +++ b/src/lib/gui/core/CoreProcess.cpp @@ -105,7 +105,7 @@ CoreProcess::CoreProcess(const ServerConfig &serverConfig) { m_appPath = QStringLiteral("%1/%2").arg(QCoreApplication::applicationDirPath(), kCoreBinName); if (!QFile::exists(m_appPath)) { - qFatal("core server binary does not exist"); + qCritical("core server binary does not exist"); return; } @@ -216,7 +216,8 @@ void CoreProcess::startForegroundProcess(const QStringList &args) using enum ProcessState; if (m_processState != Starting) { - qFatal("core process must be in starting state"); + qCritical("core process must be in starting state"); + return; } // only make quoted args for printing the command for convenience; so that the @@ -245,7 +246,8 @@ void CoreProcess::startForegroundProcess(const QStringList &args) void CoreProcess::startProcessFromDaemon() { if (m_processState != ProcessState::Starting) { - qFatal("core process must be in starting state"); + qCritical("core process must be in starting state"); + return; } const auto configFile = Settings::settingsFile(); @@ -271,11 +273,13 @@ void CoreProcess::startProcessFromDaemon() void CoreProcess::stopForegroundProcess() const { if (m_processState != ProcessState::Stopping) { - qFatal("core process must be in stopping state"); + qCritical("core process must be in stopping state"); + return; } if (!m_process) { - qFatal("process not set, cannot stop"); + qCritical("process not set, cannot stop"); + return; } qInfo("stopping core desktop process"); @@ -291,7 +295,8 @@ void CoreProcess::stopForegroundProcess() const void CoreProcess::stopProcessFromDaemon() { if (m_processState != ProcessState::Stopping) { - qFatal("core process must be in stopping state"); + qCritical("core process must be in stopping state"); + return; } auto sendStop = [this] { @@ -353,7 +358,7 @@ void CoreProcess::start(std::optional processModeOption) } if (m_mode == Settings::CoreMode::None) { - qFatal("set core mode before starting"); + qCritical("core mode is not set, skipping core start"); return; } @@ -388,10 +393,17 @@ void CoreProcess::start(std::optional processModeOption) QStringList args = {coreMode}; + // Pin the core to the same settings file the GUI is using. Without this the + // core falls through Settings::load()'s default lookup (user file before + // system file), so a user who has selected system scope in the GUI still + // gets a core running against the user-scope file. The daemon path already + // forwards the settings path via IPC below; this covers the foreground path. + args << QStringLiteral("--settings") << Settings::settingsFile(); + if (m_mode == Settings::CoreMode::Server) { const auto [hasNeededPermissions, configFilename] = persistServerConfig(); if (configFilename.isEmpty()) { - qFatal("config file name empty for server args"); + qCritical("config file name empty for server args"); return; } if (!hasNeededPermissions) { @@ -654,7 +666,8 @@ void CoreProcess::clearSettings() } if (processMode != ProcessMode::Service) { - qFatal("invalid process mode"); + qCritical("invalid process mode"); + return; } qInfo("clearing core settings through daemon"); diff --git a/src/lib/gui/core/NetworkMonitor.cpp b/src/lib/gui/core/NetworkMonitor.cpp index b58ca45fb..a0815a08a 100644 --- a/src/lib/gui/core/NetworkMonitor.cpp +++ b/src/lib/gui/core/NetworkMonitor.cpp @@ -7,6 +7,7 @@ #include "NetworkMonitor.h" #include +#include #include #include #include @@ -15,6 +16,24 @@ namespace deskflow::gui { +namespace { + +// QHostAddress::isPrivateUse() arrived in Qt 6.6; on older Qt replicate its ranges +// (RFC 1918 private IPv4 + ULA fc00::/7) via isInSubnet(). +bool isPrivateUse(const QHostAddress &address) +{ +#if QT_VERSION >= QT_VERSION_CHECK(6, 6, 0) + return address.isPrivateUse(); +#else + return address.isInSubnet(QHostAddress(QStringLiteral("10.0.0.0")), 8) || + address.isInSubnet(QHostAddress(QStringLiteral("172.16.0.0")), 12) || + address.isInSubnet(QHostAddress(QStringLiteral("192.168.0.0")), 16) || + address.isInSubnet(QHostAddress(QStringLiteral("fc00::")), 7); +#endif +} + +} // namespace + bool NetworkMonitor::isVirtualInterface(const QString &interfaceName) { // Common virtual network interface patterns @@ -96,8 +115,8 @@ QStringList NetworkMonitor::validAddresses() } std::ranges::sort(physicalIP4, [](const QHostAddress &a, const QHostAddress &b) { - if (a.isPrivateUse() != b.isPrivateUse()) - return a.isPrivateUse(); + if (isPrivateUse(a) != isPrivateUse(b)) + return isPrivateUse(a); return a.toIPv4Address() < b.toIPv4Address(); }); @@ -106,8 +125,8 @@ QStringList NetworkMonitor::validAddresses() }); std::ranges::sort(physicalIP6, [](const QHostAddress &a, const QHostAddress &b) { - if (a.isPrivateUse() != b.isPrivateUse()) - return a.isPrivateUse(); + if (isPrivateUse(a) != isPrivateUse(b)) + return isPrivateUse(a); return a.toString() < b.toString(); }); diff --git a/src/lib/gui/dialogs/AboutDialog.cpp b/src/lib/gui/dialogs/AboutDialog.cpp index 4b1fda8c9..74a884ee6 100644 --- a/src/lib/gui/dialogs/AboutDialog.cpp +++ b/src/lib/gui/dialogs/AboutDialog.cpp @@ -12,6 +12,11 @@ #include "common/Constants.h" #include "common/Settings.h" #include "common/VersionInfo.h" +#include "gui/StyleUtils.h" + +#ifdef SYNERGY_EXTRA_HEADER +#include "synergy/hooks/gui_hook.h" +#endif #include @@ -19,6 +24,12 @@ AboutDialog::AboutDialog(QWidget *parent) : QDialog(parent), ui{std::make_unique { ui->setupUi(this); + setWindowTitle(windowTitle().arg(kAppName)); + ui->lblIcon->hide(); + ui->lblName->setPixmap(QPixmap(QStringLiteral(":/image/logo-%1.png").arg(deskflow::gui::iconMode()))); + ui->lblName->setContentsMargins(0, 0, 0, 10); + ui->linkContributors->hide(); + const int px = (fontMetrics().height() * 6); const QSize pixmapSize(px, px); ui->lblIcon->setFixedSize(pixmapSize); @@ -26,7 +37,7 @@ AboutDialog::AboutDialog(QWidget *parent) : QDialog(parent), ui{std::make_unique ui->lblIcon->setPixmap(QPixmap(QIcon::fromTheme(kRevFqdnName).pixmap(QSize().scaled(pixmapSize, Qt::KeepAspectRatio))) ); - ui->btnCopyVersion->setIcon(QIcon::fromTheme(QIcon::ThemeIcon::EditCopy)); + ui->btnCopyVersion->setIcon(QIcon::fromTheme(QStringLiteral("edit-copy"))); connect(ui->btnCopyVersion, &QPushButton::clicked, this, &AboutDialog::copyVersionText); ui->lblVersion->setText(kDisplayVersion); @@ -45,6 +56,10 @@ AboutDialog::AboutDialog(QWidget *parent) : QDialog(parent), ui{std::make_unique ui->btnOk->setDefault(true); connect(ui->btnOk, &QPushButton::clicked, this, &AboutDialog::close); + +#ifdef SYNERGY_EXTRA_HEADER + synergy::hooks::onAbout(this); +#endif } void AboutDialog::copyVersionText() const diff --git a/src/lib/gui/dialogs/AboutDialog.ui b/src/lib/gui/dialogs/AboutDialog.ui index 173823351..1b1becb3a 100644 --- a/src/lib/gui/dialogs/AboutDialog.ui +++ b/src/lib/gui/dialogs/AboutDialog.ui @@ -20,7 +20,7 @@ - About Deskflow + About %1 diff --git a/src/lib/gui/dialogs/ClientConfigDialog.cpp b/src/lib/gui/dialogs/ClientConfigDialog.cpp index 12503fb5f..642601948 100644 --- a/src/lib/gui/dialogs/ClientConfigDialog.cpp +++ b/src/lib/gui/dialogs/ClientConfigDialog.cpp @@ -52,13 +52,16 @@ void ClientConfigDialog::initConnections() const &ClientConfigDialog::resetToDefault ); - connect( - ui->cbDynamicConnectTime, &QCheckBox::checkStateChanged, this, &ClientConfigDialog::setButtonBoxEnabledButtons - ); - connect(ui->cbLanguageSync, &QCheckBox::checkStateChanged, this, &ClientConfigDialog::setButtonBoxEnabledButtons); - connect(ui->cbYScrollInvert, &QCheckBox::checkStateChanged, this, &ClientConfigDialog::setButtonBoxEnabledButtons); +#if QT_VERSION >= QT_VERSION_CHECK(6, 7, 0) + const auto checkboxSignal = &QCheckBox::checkStateChanged; +#else + const auto checkboxSignal = &QCheckBox::stateChanged; +#endif + connect(ui->cbDynamicConnectTime, checkboxSignal, this, &ClientConfigDialog::setButtonBoxEnabledButtons); + connect(ui->cbLanguageSync, checkboxSignal, this, &ClientConfigDialog::setButtonBoxEnabledButtons); + connect(ui->cbYScrollInvert, checkboxSignal, this, &ClientConfigDialog::setButtonBoxEnabledButtons); connect(ui->sbYScrollScale, &QDoubleSpinBox::valueChanged, this, &ClientConfigDialog::setButtonBoxEnabledButtons); - connect(ui->cbXScrollInvert, &QCheckBox::checkStateChanged, this, &ClientConfigDialog::setButtonBoxEnabledButtons); + connect(ui->cbXScrollInvert, checkboxSignal, this, &ClientConfigDialog::setButtonBoxEnabledButtons); connect(ui->sbXScrollScale, &QDoubleSpinBox::valueChanged, this, &ClientConfigDialog::setButtonBoxEnabledButtons); } diff --git a/src/lib/gui/dialogs/ServerConfigDialog.cpp b/src/lib/gui/dialogs/ServerConfigDialog.cpp index 7ac8c3737..faabb487e 100644 --- a/src/lib/gui/dialogs/ServerConfigDialog.cpp +++ b/src/lib/gui/dialogs/ServerConfigDialog.cpp @@ -33,6 +33,8 @@ ServerConfigDialog::ServerConfigDialog(QWidget *parent, ServerConfig &config) { ui->setupUi(this); + ui->labelProtocol->setWhatsThis(ui->labelProtocol->whatsThis().arg(kAppName)); + m_originalProtocol = Settings::value(Settings::Server::Protocol).value(); connect(ui->buttonBox, &QDialogButtonBox::accepted, this, &ServerConfigDialog::accept); connect(ui->buttonBox, &QDialogButtonBox::rejected, this, &ServerConfigDialog::reject); @@ -65,7 +67,7 @@ ServerConfigDialog::ServerConfigDialog(QWidget *parent, ServerConfig &config) // the developer was looking at, and it's easy to accidentally save that. ui->tabWidget->setCurrentIndex(0); - ui->btnBrowseConfigFile->setIcon(QIcon::fromTheme(QIcon::ThemeIcon::DocumentOpen)); + ui->btnBrowseConfigFile->setIcon(QIcon::fromTheme(QStringLiteral("document-open"))); ui->lineConfigFile->setText(serverConfig().configFile()); ui->rbProtocolSynergy->setChecked(serverConfig().protocol() == NetworkProtocol::Synergy); diff --git a/src/lib/gui/dialogs/ServerConfigDialog.ui b/src/lib/gui/dialogs/ServerConfigDialog.ui index 523b32db6..599127388 100644 --- a/src/lib/gui/dialogs/ServerConfigDialog.ui +++ b/src/lib/gui/dialogs/ServerConfigDialog.ui @@ -186,7 +186,7 @@ &New - + @@ -212,7 +212,7 @@ &Remove - + @@ -255,7 +255,7 @@ Ne&w - + @@ -281,7 +281,7 @@ Re&move - + @@ -614,7 +614,7 @@ - <html><head/><body><p>Enables compatibility with programs that use either the Synergy or Barrier protocols:</p><ul style="margin-top: 0px; margin-bottom: 0px; margin-left: 0px; margin-right: 0px; -qt-list-indent: 1;"><li style=" margin-top:12px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">Synergy 3 uses the Synergy protocol.</li><li style=" margin-top:0px; margin-bottom:12px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">Barrier, Input-Leap and Synergy 1 uses the Barrier protocol.</li></ul><p>A Deskflow client will automatically use either the Synergy or Barrier protocol depending on the server protocol.</p></body></html> + <html><head/><body><p>Enables compatibility with programs that use either the Synergy or Barrier protocols:</p><ul style="margin-top: 0px; margin-bottom: 0px; margin-left: 0px; margin-right: 0px; -qt-list-indent: 1;"><li style=" margin-top:12px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">Synergy 3 uses the Synergy protocol.</li><li style=" margin-top:0px; margin-bottom:12px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">Barrier, Input-Leap and Synergy 1 uses the Barrier protocol.</li></ul><p>A %1 client will automatically use either the Synergy or Barrier protocol depending on the server protocol.</p></body></html> Network protocol diff --git a/src/lib/gui/dialogs/SettingsDialog.cpp b/src/lib/gui/dialogs/SettingsDialog.cpp index 66672cbd0..11514fc14 100644 --- a/src/lib/gui/dialogs/SettingsDialog.cpp +++ b/src/lib/gui/dialogs/SettingsDialog.cpp @@ -11,12 +11,17 @@ #include "common/PlatformInfo.h" #include "ui_SettingsDialog.h" +#include "common/Constants.h" #include "common/I18N.h" #include "common/Settings.h" #include "gui/Messages.h" #include "gui/TlsUtility.h" #include "gui/core/NetworkMonitor.h" +#ifdef SYNERGY_EXTRA_HEADER +#include "synergy/hooks/gui_hook.h" +#endif + #include #include #include @@ -32,6 +37,8 @@ SettingsDialog::SettingsDialog(QWidget *parent, const ServerConfig &serverConfig ui->setupUi(this); + ui->lblWlClipboard->setText(ui->lblWlClipboard->text().arg(kAppName)); + // these are enabled by the control next to them ui->lineCommandEnter->setEnabled(false); ui->lineCommandExit->setEnabled(false); @@ -44,7 +51,7 @@ SettingsDialog::SettingsDialog(QWidget *parent, const ServerConfig &serverConfig updateText(); ui->comboTlsKeyLength->setItemIcon(0, QIcon::fromTheme(QStringLiteral("security-medium"))); - ui->comboTlsKeyLength->setItemIcon(1, QIcon::fromTheme(QIcon::ThemeIcon::SecurityHigh)); + ui->comboTlsKeyLength->setItemIcon(1, QIcon::fromTheme(QStringLiteral("security-high"))); ui->lblTlsCertInfo->setFixedSize(28, 28); ui->rbIconMono->setIcon(QIcon::fromTheme(QStringLiteral("%1-symbolic").arg(kRevFqdnName))); @@ -78,6 +85,10 @@ SettingsDialog::SettingsDialog(QWidget *parent, const ServerConfig &serverConfig setButtonBoxEnabledButtons(); initConnections(); + +#ifdef SYNERGY_EXTRA_HEADER + synergy::hooks::onSettings(this); +#endif } void SettingsDialog::changeEvent(QEvent *e) @@ -354,15 +365,16 @@ bool SettingsDialog::isClientMode() const void SettingsDialog::updateKeyLengthOnFile(const QString &path) { if (!QFile(path).exists()) { - qFatal("tls certificate file not found: %s", qUtf8Printable(path)); + qCritical("tls certificate file not found: %s", qUtf8Printable(path)); + return; } auto length = TlsUtility::getCertKeyLength(path); - auto labelIcon = QPixmap(QIcon::fromTheme(QIcon::ThemeIcon::SecurityLow).pixmap(24, 24)); + auto labelIcon = QPixmap(QIcon::fromTheme(QStringLiteral("security-low")).pixmap(24, 24)); if (length == 2048) labelIcon = QPixmap(QIcon::fromTheme(QStringLiteral("security-medium")).pixmap(24, 24)); if (length == 4096) - labelIcon = QPixmap(QIcon::fromTheme(QIcon::ThemeIcon::SecurityHigh).pixmap(24, 24)); + labelIcon = QPixmap(QIcon::fromTheme(QStringLiteral("security-high")).pixmap(24, 24)); ui->lblTlsCertInfo->setPixmap(labelIcon); ui->lblTlsCertInfo->setToolTip(QStringLiteral("Key length: %1 bits").arg(QString::number(length))); diff --git a/src/lib/gui/dialogs/SettingsDialog.ui b/src/lib/gui/dialogs/SettingsDialog.ui index ae33488fc..088ce8de1 100644 --- a/src/lib/gui/dialogs/SettingsDialog.ui +++ b/src/lib/gui/dialogs/SettingsDialog.ui @@ -404,7 +404,7 @@ - + true @@ -641,7 +641,7 @@ - + true @@ -702,7 +702,7 @@ Regenerate certificate - + @@ -820,7 +820,7 @@ - <html><head/><body><p>Requires the wl-clipboard package</p><p>When using wl-clipboard v2.2.1, there is a focus stealing bug that may make Deskflow harder to use. This has been fixed when using the wl-clipboard master branch, unless your Compositor lacks wlroots-data-control protocol support.</p></body></html> + <html><head/><body><p>Requires the wl-clipboard package</p><p>When using wl-clipboard v2.2.1, there is a focus stealing bug that may make %1 harder to use. This has been fixed when using the wl-clipboard master branch, unless your Compositor lacks wlroots-data-control protocol support.</p></body></html> true diff --git a/src/lib/gui/validators/LineEditValidator.cpp b/src/lib/gui/validators/LineEditValidator.cpp index c4278df2f..b9203110c 100644 --- a/src/lib/gui/validators/LineEditValidator.cpp +++ b/src/lib/gui/validators/LineEditValidator.cpp @@ -21,7 +21,7 @@ LineEditValidator::LineEditValidator(QLineEdit *lineEdit, ValidationError *error { if (!m_pLineEdit) { - qFatal("validator line edit not set"); + qCritical("validator line edit not set"); } } diff --git a/src/lib/gui/widgets/SearchWidget.cpp b/src/lib/gui/widgets/SearchWidget.cpp index bbc8e235c..24acd2c3b 100644 --- a/src/lib/gui/widgets/SearchWidget.cpp +++ b/src/lib/gui/widgets/SearchWidget.cpp @@ -27,17 +27,17 @@ SearchWidget::SearchWidget(QWidget *parent) m_btnToggle->setCheckable(true); m_btnToggle->setChecked(false); m_btnToggle->setFlat(true); - m_btnToggle->setIcon(QIcon::fromTheme(QIcon::ThemeIcon::SystemSearch)); + m_btnToggle->setIcon(QIcon::fromTheme(QStringLiteral("system-search"))); m_btnToggle->setIconSize(iconSize); m_btnNext->setFixedSize(maxBtnSize); m_btnNext->setFlat(true); - m_btnNext->setIcon(QIcon::fromTheme(QIcon::ThemeIcon::GoDown)); + m_btnNext->setIcon(QIcon::fromTheme(QStringLiteral("go-down"))); m_btnNext->setIconSize(iconSize); m_btnPrev->setFixedSize(maxBtnSize); m_btnPrev->setFlat(true); - m_btnPrev->setIcon(QIcon::fromTheme(QIcon::ThemeIcon::GoUp)); + m_btnPrev->setIcon(QIcon::fromTheme(QStringLiteral("go-up"))); m_btnPrev->setIconSize(iconSize); m_searchLine->setMaximumHeight(maxBtnSize.height()); diff --git a/src/lib/gui/widgets/StatusBar.cpp b/src/lib/gui/widgets/StatusBar.cpp index cd22323d5..12b3dc0b0 100644 --- a/src/lib/gui/widgets/StatusBar.cpp +++ b/src/lib/gui/widgets/StatusBar.cpp @@ -40,6 +40,7 @@ StatusBar::StatusBar(QWidget *parent) m_lblStatus->setText(tr("%1 is not running").arg(kAppName)); insertPermanentWidget(2, m_lblStatus, 1); + m_btnUpdate->setObjectName(QStringLiteral("btnUpdate")); m_btnUpdate->setVisible(false); m_btnUpdate->setFlat(true); m_btnUpdate->setLayoutDirection(Qt::RightToLeft); @@ -197,7 +198,7 @@ void StatusBar::updateTimerLabel() void StatusBar::setSecurityIcon(bool encrypted) { - const auto icon = QIcon::fromTheme(encrypted ? QIcon::ThemeIcon::SecurityHigh : QIcon::ThemeIcon::SecurityLow); + const auto icon = QIcon::fromTheme(encrypted ? QStringLiteral("security-high") : QStringLiteral("security-low")); m_lblSecurityIcon->setPixmap(icon.pixmap(QSize(32, 32))); m_encrypted = encrypted; setSecurityLevel(m_securityLevel); diff --git a/src/lib/net/FingerprintDatabase.h b/src/lib/net/FingerprintDatabase.h index 57e9f9881..4fe2069fb 100644 --- a/src/lib/net/FingerprintDatabase.h +++ b/src/lib/net/FingerprintDatabase.h @@ -11,6 +11,8 @@ #include +class QTextStream; + class FingerprintDatabase { public: diff --git a/src/lib/net/SecureUtils.cpp b/src/lib/net/SecureUtils.cpp index f47e4379a..f2981a559 100644 --- a/src/lib/net/SecureUtils.cpp +++ b/src/lib/net/SecureUtils.cpp @@ -8,6 +8,7 @@ #include "SecureUtils.h" #include "base/FinalAction.h" +#include "common/Constants.h" #include #include @@ -85,7 +86,7 @@ void generatePemSelfSignedCert(const QString &path, int keyLength) X509_set_pubkey(cert, privateKey); auto *name = X509_get_subject_name(cert); - X509_NAME_add_entry_by_txt(name, "CN", MBSTRING_ASC, reinterpret_cast("Deskflow"), -1, -1, 0); + X509_NAME_add_entry_by_txt(name, "CN", MBSTRING_ASC, reinterpret_cast(kAppName), -1, -1, 0); X509_set_issuer_name(cert, name); X509_sign(cert, privateKey, EVP_sha256()); @@ -119,13 +120,13 @@ QString formatSSLFingerprintColumns(const QByteArray &fingerprint) QString formattedString; while (!hex.isEmpty()) { const auto take = std::min(kMaxColumns, hex.size()); - formattedString.append(hex.first(take)); + formattedString.append(hex.left(take)); hex.remove(0, take); if (formattedString.endsWith(QLatin1Char(':'))) - formattedString.removeLast(); + formattedString.chop(1); formattedString.append(QLatin1Char('\n')); } - formattedString.removeLast(); + formattedString.chop(1); return formattedString; } diff --git a/src/lib/platform/MSWindowsClipboard.cpp b/src/lib/platform/MSWindowsClipboard.cpp index eab6155df..3cf2bf89c 100644 --- a/src/lib/platform/MSWindowsClipboard.cpp +++ b/src/lib/platform/MSWindowsClipboard.cpp @@ -9,6 +9,7 @@ #include "platform/MSWindowsClipboard.h" #include "base/Log.h" +#include "common/Constants.h" #include "platform/MSWindowsClipboardBitmapConverter.h" #include "platform/MSWindowsClipboardFacade.h" #include "platform/MSWindowsClipboardHTMLConverter.h" @@ -207,7 +208,7 @@ bool MSWindowsClipboard::isOwnedByDeskflow() { // create ownership format if we haven't yet if (s_ownershipFormat == 0) { - s_ownershipFormat = RegisterClipboardFormat(TEXT("Deskflow Ownership")); + s_ownershipFormat = RegisterClipboardFormatW(kClipboardOwnershipFormatW); } return (IsClipboardFormatAvailable(getOwnershipFormat()) != 0); } @@ -216,7 +217,7 @@ UINT MSWindowsClipboard::getOwnershipFormat() { // create ownership format if we haven't yet if (s_ownershipFormat == 0) { - s_ownershipFormat = RegisterClipboardFormat(TEXT("Deskflow Ownership")); + s_ownershipFormat = RegisterClipboardFormatW(kClipboardOwnershipFormatW); } // return the format diff --git a/src/lib/platform/MSWindowsDesks.cpp b/src/lib/platform/MSWindowsDesks.cpp index 143425a8b..786429f51 100644 --- a/src/lib/platform/MSWindowsDesks.cpp +++ b/src/lib/platform/MSWindowsDesks.cpp @@ -13,6 +13,7 @@ #include "base/IJob.h" #include "base/Log.h" #include "base/TMethodJob.h" +#include "common/Constants.h" #include "deskflow/IScreenSaver.h" #include "deskflow/ScreenException.h" #include "deskflow/win32/AppUtilWindows.h" @@ -353,7 +354,7 @@ ATOM MSWindowsDesks::createDeskWindowClass(bool isPrimary) const classInfo.hCursor = m_cursor; classInfo.hbrBackground = nullptr; classInfo.lpszMenuName = nullptr; - classInfo.lpszClassName = L"DeskflowDesk"; + classInfo.lpszClassName = kDeskClassNameW; classInfo.hIconSm = nullptr; return RegisterClassEx(&classInfo); } @@ -615,7 +616,7 @@ void MSWindowsDesks::deskThread(const void *vdesk) // create a window. we use this window to hide the cursor. try { - desk->m_window = createWindow(m_deskClass, L"DeskflowDesk"); + desk->m_window = createWindow(m_deskClass, kDeskClassNameW); LOG_DEBUG("desk %ls window is 0x%08x", desk->m_name.c_str(), desk->m_window); } catch (...) { // ignore diff --git a/src/lib/platform/MSWindowsWatchdog.cpp b/src/lib/platform/MSWindowsWatchdog.cpp index 8f56b9a69..cb9a5003f 100644 --- a/src/lib/platform/MSWindowsWatchdog.cpp +++ b/src/lib/platform/MSWindowsWatchdog.cpp @@ -14,7 +14,6 @@ #include "common/Constants.h" #include "common/LogLevel.h" #include "deskflow/App.h" -#include "mt/Thread.h" #include "platform/MSWindowsHandle.h" #include @@ -37,9 +36,7 @@ HANDLE openProcessForKill(const PROCESSENTRY32 &entry) if (entry.th32ProcessID == 0) return nullptr; - if (_wcsicmp(entry.szExeFile, L"deskflow-client.exe") != 0 && // - _wcsicmp(entry.szExeFile, L"deskflow-server.exe") != 0 && // - _wcsicmp(entry.szExeFile, L"deskflow-core.exe") != 0) { + if (_wcsicmp(entry.szExeFile, kCoreBinNameW) != 0) { return nullptr; } @@ -74,6 +71,11 @@ MSWindowsWatchdog::MSWindowsWatchdog(bool foreground, FileLogOutputter &fileLogO initOutputReadPipe(); } +MSWindowsWatchdog::~MSWindowsWatchdog() +{ + stop(); +} + void MSWindowsWatchdog::startAsync() { m_mainThread = std::make_unique(new TMethodJob(this, &MSWindowsWatchdog::mainLoop, nullptr)); @@ -83,20 +85,21 @@ void MSWindowsWatchdog::startAsync() void MSWindowsWatchdog::stop() { - const auto kThreadWaitSeconds = 5; + static constexpr double kThreadWaitSeconds = 5; m_running = false; - if (!m_mainThread->wait(kThreadWaitSeconds)) { - LOG_WARN("could not stop main thread"); + if (m_mainThread != nullptr && !m_mainThread->wait(kThreadWaitSeconds)) { + LOG_WARN("main thread is slow to stop, waiting"); + m_mainThread->wait(); } - - if (!m_outputThread->wait(kThreadWaitSeconds)) { - LOG_WARN("could not stop output thread"); + if (m_outputThread != nullptr && !m_outputThread->wait(kThreadWaitSeconds)) { + LOG_WARN("output thread is slow to stop, waiting"); + m_outputThread->wait(); } - - if (!m_sasThread->wait(kThreadWaitSeconds)) { - LOG_WARN("could not stop sas thread"); + if (m_sasThread != nullptr && !m_sasThread->wait(kThreadWaitSeconds)) { + LOG_WARN("sas thread is slow to stop, waiting"); + m_sasThread->wait(); } } diff --git a/src/lib/platform/MSWindowsWatchdog.h b/src/lib/platform/MSWindowsWatchdog.h index b61715429..73282796e 100644 --- a/src/lib/platform/MSWindowsWatchdog.h +++ b/src/lib/platform/MSWindowsWatchdog.h @@ -13,6 +13,7 @@ #define WIN32_LEAN_AND_MEAN #include +#include #include #include #include @@ -38,7 +39,7 @@ class MSWindowsWatchdog public: explicit MSWindowsWatchdog(bool foreground, FileLogOutputter &fileLogOutputter); - ~MSWindowsWatchdog() = default; + ~MSWindowsWatchdog(); /** * @brief Start threads for main loop and and output loop. @@ -51,7 +52,7 @@ class MSWindowsWatchdog void setProcessConfig(const std::string_view &command, bool elevate); /** - * @brief Stop the main loop and output loop threads. + * @brief Stop the watchdog threads, blocking until they have all exited. */ void stop(); @@ -129,7 +130,7 @@ class MSWindowsWatchdog static void shutdownExistingProcesses(); private: - bool m_running = true; + std::atomic m_running = true; std::unique_ptr m_mainThread; std::unique_ptr m_outputThread; std::unique_ptr m_sasThread; diff --git a/src/lib/platform/OSXClipboardBMPConverter.h b/src/lib/platform/OSXClipboardBMPConverter.h index 1f7a17594..8cfce9288 100644 --- a/src/lib/platform/OSXClipboardBMPConverter.h +++ b/src/lib/platform/OSXClipboardBMPConverter.h @@ -9,7 +9,7 @@ #include "platform/OSXClipboard.h" -#include +#include class OSXClipboardBMPConverter : public IOSXClipboardConverter { diff --git a/src/lib/platform/OSXPowerManager.cpp b/src/lib/platform/OSXPowerManager.cpp index c17c99906..3ed168d46 100644 --- a/src/lib/platform/OSXPowerManager.cpp +++ b/src/lib/platform/OSXPowerManager.cpp @@ -7,6 +7,9 @@ #include "OSXPowerManager.h" #include "base/Log.h" +#include "common/Constants.h" + +#include OSXPowerManager::~OSXPowerManager() { @@ -16,10 +19,12 @@ OSXPowerManager::~OSXPowerManager() void OSXPowerManager::disableSleep() { if (!m_sleepPreventionAssertionID) { - CFStringRef reasonForActivity = CFSTR("Deskflow application"); + const std::string reason = std::string(kAppName) + " application"; + CFStringRef reasonForActivity = CFStringCreateWithCString(nullptr, reason.c_str(), kCFStringEncodingUTF8); IOReturn result = IOPMAssertionCreateWithName( kIOPMAssertPreventUserIdleDisplaySleep, kIOPMAssertionLevelOn, reasonForActivity, &m_sleepPreventionAssertionID ); + CFRelease(reasonForActivity); if (result != kIOReturnSuccess) { m_sleepPreventionAssertionID = 0; LOG_ERR("failed to disable system idle sleep"); diff --git a/src/lib/server/Server.h b/src/lib/server/Server.h index 424cf6028..444f8ac8e 100644 --- a/src/lib/server/Server.h +++ b/src/lib/server/Server.h @@ -419,7 +419,7 @@ class Server ClipboardInfo m_clipboards[kClipboardEnd]; // used in hello message sent to the client - NetworkProtocol m_protocol = NetworkProtocol::Barrier; + NetworkProtocol m_protocol = NetworkProtocol::Synergy; // the sequence number of enter messages uint32_t m_seqNum = 0; diff --git a/src/unittests/deskflow/ClipboardTests.cpp b/src/unittests/deskflow/ClipboardTests.cpp index dc362b6a4..c2ee33b88 100644 --- a/src/unittests/deskflow/ClipboardTests.cpp +++ b/src/unittests/deskflow/ClipboardTests.cpp @@ -84,7 +84,7 @@ void ClipboardTests::longerText() // char before the last stores a bit-shifted version of the number, each // 1 more power than the last, which is done by bit-shifting [0] by 24, // [1] by 16, [2] by 8 ([3] is not bit-shifted). - qInfo() << actual; + qInfo() << QString::fromStdString(actual); QCOMPARE(actual[8], 0); // 287 >> 24 = 287 / (256^3) = 0 QCOMPARE(actual[9], 0); // 287 >> 16 = 287 / (256^2) = 0 QCOMPARE(actual[10], 1); // 287 >> 8 = 287 / (256^1) = 1(.121) diff --git a/src/unittests/platform/XWindowsClipboardTests.cpp b/src/unittests/platform/XWindowsClipboardTests.cpp index 9d828c663..fcc062814 100644 --- a/src/unittests/platform/XWindowsClipboardTests.cpp +++ b/src/unittests/platform/XWindowsClipboardTests.cpp @@ -33,6 +33,8 @@ void XWindowsClipboardTests::defaultCtor() void XWindowsClipboardTests::initTestCase() { m_display = XOpenDisplay(nullptr); + if (!m_display) + QSKIP("no X display available (e.g. headless CI); X11 clipboard tests need a running X server"); int screen = DefaultScreen(m_display); Window root = XRootWindow(m_display, screen); @@ -46,6 +48,8 @@ void XWindowsClipboardTests::initTestCase() void XWindowsClipboardTests::cleanupTestCase() { + if (!m_display) + return; XDestroyWindow(m_display, m_window); XCloseDisplay(m_display); } @@ -61,12 +65,12 @@ void XWindowsClipboardTests::singleFormat() { auto &clipboard = getClipboard(); QVERIFY(clipboard.empty()); - clipboard.add(XWindowsClipboard::kText, m_testString); - QVERIFY(!clipboard.has(XWindowsClipboard::kText)); - QCOMPARE(clipboard.get(XWindowsClipboard::kText), m_testString); + clipboard.add(IClipboard::Format::Text, m_testString); + QVERIFY(!clipboard.has(IClipboard::Format::Text)); + QCOMPARE(clipboard.get(IClipboard::Format::Text), m_testString); - clipboard.add(XWindowsClipboard::kText, m_testString2); - QCOMPARE(clipboard.get(XWindowsClipboard::kText), m_testString2); + clipboard.add(IClipboard::Format::Text, m_testString2); + QCOMPARE(clipboard.get(IClipboard::Format::Text), m_testString2); } XWindowsClipboard &XWindowsClipboardTests::getClipboard() diff --git a/src/unittests/platform/XWindowsClipboardTests.h b/src/unittests/platform/XWindowsClipboardTests.h index e7a698c4e..3f04b97d2 100644 --- a/src/unittests/platform/XWindowsClipboardTests.h +++ b/src/unittests/platform/XWindowsClipboardTests.h @@ -6,12 +6,14 @@ #include "base/Log.h" +// Qt headers that pull in qtextstream.h must precede X11 headers (XWindowsClipboard.h +// → Xlib.h) because X11 '#define Status int' breaks qtextstream.h's 'enum Status'. +#include + #if !WINAPI_LIBEI && !WINAPI_PORTAL #include "platform/XWindowsClipboard.h" #endif -#include - class XWindowsClipboardTests : public QObject { Q_OBJECT diff --git a/translations/CMakeLists.txt b/translations/CMakeLists.txt index 6e8a7bc87..c142f66f0 100644 --- a/translations/CMakeLists.txt +++ b/translations/CMakeLists.txt @@ -4,14 +4,16 @@ set_directory_properties(PROPERTIES CLEAN_NO_CUSTOM 1) option(CLEAN_TRS "Clean obsolete translations from tr files" OFF) find_package(Qt6 ${REQUIRED_QT_VERSION} REQUIRED NO_MODULE COMPONENTS LinguistTools) +set(filename "deskflow") + # To add a new language Add 639 shortname -set (${CMAKE_PROJECT_NAME}_TRS - ${CMAKE_PROJECT_NAME}_es.ts - ${CMAKE_PROJECT_NAME}_it.ts - ${CMAKE_PROJECT_NAME}_ja.ts - ${CMAKE_PROJECT_NAME}_zh_CN.ts - ${CMAKE_PROJECT_NAME}_ru.ts - ${CMAKE_PROJECT_NAME}_ko.ts +set (translations + ${filename}_es.ts + ${filename}_it.ts + ${filename}_ja.ts + ${filename}_zh_CN.ts + ${filename}_ru.ts + ${filename}_ko.ts ) set(TR_OPTIONS -no-ui-lines -locations none -silent) @@ -20,10 +22,10 @@ if(CLEAN_TRS) endif() # English will have only plurals -qt_create_translation(TRS ${CMAKE_SOURCE_DIR}/src ${CMAKE_PROJECT_NAME}_en.ts OPTIONS -pluralonly ${TR_OPTIONS}) +qt_create_translation(TRS ${CMAKE_SOURCE_DIR}/src ${filename}_en.ts OPTIONS -pluralonly ${TR_OPTIONS}) # Other languages contain the full set of strings. -qt_create_translation(TRS ${CMAKE_SOURCE_DIR}/src ${${CMAKE_PROJECT_NAME}_TRS} OPTIONS ${TR_OPTIONS}) +qt_create_translation(TRS ${CMAKE_SOURCE_DIR}/src ${translations} OPTIONS ${TR_OPTIONS}) #ensure that the targets are built always add_custom_target(app_translations ALL DEPENDS ${TRS}) @@ -50,8 +52,8 @@ else() ) # install the other Qt lang files renamed to qt_lang.qm - foreach(LANG ${${CMAKE_PROJECT_NAME}_TRS}) - string(REPLACE "${CMAKE_PROJECT_NAME}_" "" LANG ${LANG}) + foreach(LANG ${translations}) + string(REPLACE "${filename}_" "" LANG ${LANG}) string(REPLACE ".ts" "" LANG ${LANG}) add_custom_command( TARGET app_translations POST_BUILD diff --git a/translations/deskflow_es.ts b/translations/deskflow_es.ts index d29fe9e3f..2a3fc508e 100644 --- a/translations/deskflow_es.ts +++ b/translations/deskflow_es.ts @@ -4,8 +4,8 @@ AboutDialog - About Deskflow - Acerca de Deskflow + About %1 + Acerca de %1 <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.0//EN" "http://www.w3.org/TR/REC-html40/strict.dtd"> @@ -373,10 +373,6 @@ Do you want to connect to the server? Clear settings Borrar configuración - - Report a Bug - Informar un error - &Minimize to tray &Minimizar a la bandeja @@ -465,6 +461,10 @@ Do you want to connect to the server? %1 Connection Error %1 Error de conexión + + Get help + Obtener ayuda + No IP Detected No se detectó ninguna IP @@ -1065,8 +1065,8 @@ Además, verifique que puede %1 el archivo de configuración del servidor: %2ms - <html><head/><body><p>Enables compatibility with programs that use either the Synergy or Barrier protocols:</p><ul style="margin-top: 0px; margin-bottom: 0px; margin-left: 0px; margin-right: 0px; -qt-list-indent: 1;"><li style=" margin-top:12px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">Synergy 3 uses the Synergy protocol.</li><li style=" margin-top:0px; margin-bottom:12px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">Barrier, Input-Leap and Synergy 1 uses the Barrier protocol.</li></ul><p>A Deskflow client will automatically use either the Synergy or Barrier protocol depending on the server protocol.</p></body></html> - <html><head/><body><p>Habilita la compatibilidad con programas que usan los protocolos Synergy o Barrier:</p><ul style="margin-top: 0px; margin-bottom: 0px; margin-left: 0px; margin-right: 0px; -qt-list-indent: 1;"><li style=" margin-top:12px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">Synergy 3 usa el protocolo Synergy.</li><li style=" margin-top:0px; margin-bottom:12px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">Barrier, Input-Leap y Synergy 1 usan el protocolo Barrier.</li></ul><p>Un cliente de Deskflow usará automáticamente el Protocolo Synergy o Barrier según el protocolo del servidor.</p></body></html> + <html><head/><body><p>Enables compatibility with programs that use either the Synergy or Barrier protocols:</p><ul style="margin-top: 0px; margin-bottom: 0px; margin-left: 0px; margin-right: 0px; -qt-list-indent: 1;"><li style=" margin-top:12px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">Synergy 3 uses the Synergy protocol.</li><li style=" margin-top:0px; margin-bottom:12px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">Barrier, Input-Leap and Synergy 1 uses the Barrier protocol.</li></ul><p>A %1 client will automatically use either the Synergy or Barrier protocol depending on the server protocol.</p></body></html> + <html><head/><body><p>Habilita la compatibilidad con programas que usan los protocolos Synergy o Barrier:</p><ul style="margin-top: 0px; margin-bottom: 0px; margin-left: 0px; margin-right: 0px; -qt-list-indent: 1;"><li style=" margin-top:12px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">Synergy 3 usa el protocolo Synergy.</li><li style=" margin-top:0px; margin-bottom:12px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">Barrier, Input-Leap y Synergy 1 usan el protocolo Barrier.</li></ul><p>Un cliente de %1 usará automáticamente el Protocolo Synergy o Barrier según el protocolo del servidor.</p></body></html> Network protocol @@ -1265,8 +1265,8 @@ Al habilitar esta opción, se deshabilitará la interfaz gráfica de usuario (GU Habilitar la compatibilidad con wl-clipboard - <html><head/><body><p>Requires the wl-clipboard package</p><p>When using wl-clipboard v2.2.1, there is a focus stealing bug that may make Deskflow harder to use. This has been fixed when using the wl-clipboard master branch, unless your Compositor lacks wlroots-data-control protocol support.</p></body></html> - <html><head/><body><p>Requiere el paquete wl-clipboard</p><p>Al usar wl-clipboard v2.2.1, existe un error que provoca la pérdida del foco y que puede dificultar el uso de Deskflow. Este error se ha corregido al usar la rama principal de wl-clipboard, a menos que su Compositor no sea compatible con el protocolo wlroots-data-control.</p></body></html> + <html><head/><body><p>Requires the wl-clipboard package</p><p>When using wl-clipboard v2.2.1, there is a focus stealing bug that may make %1 harder to use. This has been fixed when using the wl-clipboard master branch, unless your Compositor lacks wlroots-data-control protocol support.</p></body></html> + <html><head/><body><p>Requiere el paquete wl-clipboard</p><p>Al usar wl-clipboard v2.2.1, existe un error que provoca la pérdida del foco y que puede dificultar el uso de %1. Este error se ha corregido al usar la rama principal de wl-clipboard, a menos que su Compositor no sea compatible con el protocolo wlroots-data-control.</p></body></html> Automatic diff --git a/translations/deskflow_it.ts b/translations/deskflow_it.ts index 733484120..deaa67ee3 100644 --- a/translations/deskflow_it.ts +++ b/translations/deskflow_it.ts @@ -4,8 +4,8 @@ AboutDialog - About Deskflow - Informazioni su Deskflow + About %1 + Informazioni su %1 Version: @@ -361,10 +361,6 @@ Vuoi connetterti al server? Clear settings Cancella impostazioni - - Report a Bug - Segnala un Bug - &Minimize to tray &Minimizza a icona @@ -453,6 +449,10 @@ Vuoi connetterti al server? %1 Connection Error Errore di connessione %1 + + Get help + Ottieni aiuto + No IP Detected Nessun IP rilevato @@ -1065,8 +1065,8 @@ Inoltre, verifica di poter %1 il file di configurazione del server: %2ms - <html><head/><body><p>Enables compatibility with programs that use either the Synergy or Barrier protocols:</p><ul style="margin-top: 0px; margin-bottom: 0px; margin-left: 0px; margin-right: 0px; -qt-list-indent: 1;"><li style=" margin-top:12px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">Synergy 3 uses the Synergy protocol.</li><li style=" margin-top:0px; margin-bottom:12px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">Barrier, Input-Leap and Synergy 1 uses the Barrier protocol.</li></ul><p>A Deskflow client will automatically use either the Synergy or Barrier protocol depending on the server protocol.</p></body></html> - <html><head/><body><p>Abilita la compatibilità con programmi che utilizzano i protocolli Synergy o Barrier:</p><ul style="margin-top: 0px; margin-bottom: 0px; margin-left: 0px; margin-right: 0px; -qt-list-indent: 1;"><li style=" margin-top:12px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">Synergy 3 usa il protocollo Synergy.</li><li style=" margin-top:0px; margin-bottom:12px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">Barrier, Input-Leap e Synergy 1 usano il protocollo Barrier.</li></ul><p>Un client Deskflow utilizzerà automaticamente il protocollo Synergy o Barrier a seconda del protocollo del server.</p></body></html> + <html><head/><body><p>Enables compatibility with programs that use either the Synergy or Barrier protocols:</p><ul style="margin-top: 0px; margin-bottom: 0px; margin-left: 0px; margin-right: 0px; -qt-list-indent: 1;"><li style=" margin-top:12px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">Synergy 3 uses the Synergy protocol.</li><li style=" margin-top:0px; margin-bottom:12px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">Barrier, Input-Leap and Synergy 1 uses the Barrier protocol.</li></ul><p>A %1 client will automatically use either the Synergy or Barrier protocol depending on the server protocol.</p></body></html> + <html><head/><body><p>Abilita la compatibilità con programmi che utilizzano i protocolli Synergy o Barrier:</p><ul style="margin-top: 0px; margin-bottom: 0px; margin-left: 0px; margin-right: 0px; -qt-list-indent: 1;"><li style=" margin-top:12px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">Synergy 3 usa il protocollo Synergy.</li><li style=" margin-top:0px; margin-bottom:12px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">Barrier, Input-Leap e Synergy 1 usano il protocollo Barrier.</li></ul><p>Un client %1 utilizzerà automaticamente il protocollo Synergy o Barrier a seconda del protocollo del server.</p></body></html> Network protocol @@ -1265,8 +1265,8 @@ L'abilitazione di questa impostazione disabiliterà l'interfaccia graf Abilita il supporto wl-clipboard - <html><head/><body><p>Requires the wl-clipboard package</p><p>When using wl-clipboard v2.2.1, there is a focus stealing bug that may make Deskflow harder to use. This has been fixed when using the wl-clipboard master branch, unless your Compositor lacks wlroots-data-control protocol support.</p></body></html> - <html><head/><body><p>Richiede il pacchetto wl-clipboard</p><p>Quando si utilizza wl-clipboard v2.2.1, si verifica un bug di furto del focus che potrebbe rendere Deskflow più difficile da usare. Questo problema è stato risolto quando si utilizza il ramo master di wl-clipboard, a meno che il proprio compositore non supporti il ​​protocollo wlroots-data-control.</p></body></html> + <html><head/><body><p>Requires the wl-clipboard package</p><p>When using wl-clipboard v2.2.1, there is a focus stealing bug that may make %1 harder to use. This has been fixed when using the wl-clipboard master branch, unless your Compositor lacks wlroots-data-control protocol support.</p></body></html> + <html><head/><body><p>Richiede il pacchetto wl-clipboard</p><p>Quando si utilizza wl-clipboard v2.2.1, si verifica un bug di furto del focus che potrebbe rendere %1 più difficile da usare. Questo problema è stato risolto quando si utilizza il ramo master di wl-clipboard, a meno che il proprio compositore non supporti il ​​protocollo wlroots-data-control.</p></body></html> Automatic diff --git a/translations/deskflow_ja.ts b/translations/deskflow_ja.ts index e93b0d591..5fc3c2aab 100644 --- a/translations/deskflow_ja.ts +++ b/translations/deskflow_ja.ts @@ -4,8 +4,8 @@ AboutDialog - About Deskflow - Deskflow について + About %1 + %1 について <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.0//EN" "http://www.w3.org/TR/REC-html40/strict.dtd"> @@ -421,6 +421,10 @@ Do you want to connect to the server? <p>Failed to connect to the server '%1'.</p><p>A Client with your name is already connected to the server.</p>Please ensure that you're using a unique name and that only a single instance of the client process is running.</p> <p>サーバー '%1' への接続に失敗しました。</p><p>同じ名前のクライアントがサーバーに接続済です。</p><p>名前の重複がないことと、クライアントプロセスが多重起動していない事を確認してください。</p> + + Get help + ヘルプを表示 + No IP Detected IPアドレスが見つかりません @@ -471,10 +475,6 @@ A bound IP is now invalid, you may need to restart the server. Clear settings 設定を消去 - - Report a Bug - バグレポート - &Minimize to tray トレイに最小化(&M) @@ -1067,8 +1067,8 @@ Additionally, check you are able to %1 the server config file: %2 ms - <html><head/><body><p>Enables compatibility with programs that use either the Synergy or Barrier protocols:</p><ul style="margin-top: 0px; margin-bottom: 0px; margin-left: 0px; margin-right: 0px; -qt-list-indent: 1;"><li style=" margin-top:12px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">Synergy 3 uses the Synergy protocol.</li><li style=" margin-top:0px; margin-bottom:12px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">Barrier, Input-Leap and Synergy 1 uses the Barrier protocol.</li></ul><p>A Deskflow client will automatically use either the Synergy or Barrier protocol depending on the server protocol.</p></body></html> - <html><head/><body><p>Synergy もしくは Barrier プロトコルを利用するプログラムとの互換性を有効にします。</p><ul style="margin-top: 0px; margin-bottom: 0px; margin-left: 0px; margin-right: 0px; -qt-list-indent: 1;"><li style=" margin-top:12px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">Synergy 3 は Synergy プロトコルを利用します。</li><li style=" margin-top:0px; margin-bottom:12px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">Barrier、Input-Leap、Synergy 1 は Barrier プロトコルを利用します。</li></ul><p>Deskflow クライアントはサーバー側のプロトコルに従って自動的に Synergy か Barrier プロトコルを選択します。</p></body></html> + <html><head/><body><p>Enables compatibility with programs that use either the Synergy or Barrier protocols:</p><ul style="margin-top: 0px; margin-bottom: 0px; margin-left: 0px; margin-right: 0px; -qt-list-indent: 1;"><li style=" margin-top:12px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">Synergy 3 uses the Synergy protocol.</li><li style=" margin-top:0px; margin-bottom:12px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">Barrier, Input-Leap and Synergy 1 uses the Barrier protocol.</li></ul><p>A %1 client will automatically use either the Synergy or Barrier protocol depending on the server protocol.</p></body></html> + <html><head/><body><p>Synergy もしくは Barrier プロトコルを利用するプログラムとの互換性を有効にします。</p><ul style="margin-top: 0px; margin-bottom: 0px; margin-left: 0px; margin-right: 0px; -qt-list-indent: 1;"><li style=" margin-top:12px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">Synergy 3 は Synergy プロトコルを利用します。</li><li style=" margin-top:0px; margin-bottom:12px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">Barrier、Input-Leap、Synergy 1 は Barrier プロトコルを利用します。</li></ul><p>%1 クライアントはサーバー側のプロトコルに従って自動的に Synergy か Barrier プロトコルを選択します。</p></body></html> Network protocol @@ -1267,8 +1267,8 @@ Enabling this setting will disable the server config GUI. wl-clipboard によるクリップボード対応を有効にする - <html><head/><body><p>Requires the wl-clipboard package</p><p>When using wl-clipboard v2.2.1, there is a focus stealing bug that may make Deskflow harder to use. This has been fixed when using the wl-clipboard master branch, unless your Compositor lacks wlroots-data-control protocol support.</p></body></html> - <html><head/><body><p>wl-clipboard パッケージが必要です。</p><p>wl-clipboard v2.2.1 を使用すると、フォーカス盗用のバグにより Deskflow の使い勝手が悪くなる可能性があります。この問題は wl-clipboard のマスターブランチで修正されていますが、使用しているコンポジターが wlroots-data-control プロトコルに対応している必要があります。</p></body></html> + <html><head/><body><p>Requires the wl-clipboard package</p><p>When using wl-clipboard v2.2.1, there is a focus stealing bug that may make %1 harder to use. This has been fixed when using the wl-clipboard master branch, unless your Compositor lacks wlroots-data-control protocol support.</p></body></html> + <html><head/><body><p>wl-clipboard パッケージが必要です。</p><p>wl-clipboard v2.2.1 を使用すると、フォーカス盗用のバグにより %1 の使い勝手が悪くなる可能性があります。この問題は wl-clipboard のマスターブランチで修正されていますが、使用しているコンポジターが wlroots-data-control プロトコルに対応している必要があります。</p></body></html> Automatic diff --git a/translations/deskflow_ko.ts b/translations/deskflow_ko.ts index 4df41ed3c..8850a3999 100644 --- a/translations/deskflow_ko.ts +++ b/translations/deskflow_ko.ts @@ -4,8 +4,8 @@ AboutDialog - About Deskflow - Deskflow 정보 + About %1 + %1 정보 <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.0//EN" "http://www.w3.org/TR/REC-html40/strict.dtd"> @@ -421,6 +421,10 @@ Do you want to connect to the server? <p>Failed to connect to the server '%1'.</p><p>A Client with your name is already connected to the server.</p>Please ensure that you're using a unique name and that only a single instance of the client process is running.</p> <p>서버 '%1'에 연결하지 못했습니다.</p><p>같은 이름의 클라이언트가 이미 서버에 연결되어 있습니다.</p><p>고유한 이름을 사용하고, 클라이언트 프로세스가 하나만 실행 중인지 확인하세요.</p> + + Get help + 도움 받기 + No IP Detected IP를 감지하지 못했습니다 @@ -471,10 +475,6 @@ A bound IP is now invalid, you may need to restart the server. Clear settings 설정 초기화 - - Report a Bug - 버그 신고 - &Minimize to tray 트레이로 최소화(&M) @@ -1065,8 +1065,8 @@ Additionally, check you are able to %1 the server config file: %2 ms - <html><head/><body><p>Enables compatibility with programs that use either the Synergy or Barrier protocols:</p><ul style="margin-top: 0px; margin-bottom: 0px; margin-left: 0px; margin-right: 0px; -qt-list-indent: 1;"><li style=" margin-top:12px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">Synergy 3 uses the Synergy protocol.</li><li style=" margin-top:0px; margin-bottom:12px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">Barrier, Input-Leap and Synergy 1 uses the Barrier protocol.</li></ul><p>A Deskflow client will automatically use either the Synergy or Barrier protocol depending on the server protocol.</p></body></html> - <html><head/><body><p>Synergy 또는 Barrier 프로토콜을 사용하는 프로그램과의 호환성을 활성화합니다:</p><ul style="margin-top: 0px; margin-bottom: 0px; margin-left: 0px; margin-right: 0px; -qt-list-indent: 1;"><li style=" margin-top:12px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">Synergy 3는 Synergy 프로토콜을 사용합니다.</li><li style=" margin-top:0px; margin-bottom:12px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">Barrier, Input-Leap, Synergy 1은 Barrier 프로토콜을 사용합니다.</li></ul><p>Deskflow 클라이언트는 서버 프로토콜에 따라 Synergy 또는 Barrier 프로토콜을 자동으로 사용합니다.</p></body></html> + <html><head/><body><p>Enables compatibility with programs that use either the Synergy or Barrier protocols:</p><ul style="margin-top: 0px; margin-bottom: 0px; margin-left: 0px; margin-right: 0px; -qt-list-indent: 1;"><li style=" margin-top:12px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">Synergy 3 uses the Synergy protocol.</li><li style=" margin-top:0px; margin-bottom:12px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">Barrier, Input-Leap and Synergy 1 uses the Barrier protocol.</li></ul><p>A %1 client will automatically use either the Synergy or Barrier protocol depending on the server protocol.</p></body></html> + <html><head/><body><p>Synergy 또는 Barrier 프로토콜을 사용하는 프로그램과의 호환성을 활성화합니다:</p><ul style="margin-top: 0px; margin-bottom: 0px; margin-left: 0px; margin-right: 0px; -qt-list-indent: 1;"><li style=" margin-top:12px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">Synergy 3는 Synergy 프로토콜을 사용합니다.</li><li style=" margin-top:0px; margin-bottom:12px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">Barrier, Input-Leap, Synergy 1은 Barrier 프로토콜을 사용합니다.</li></ul><p>%1 클라이언트는 서버 프로토콜에 따라 Synergy 또는 Barrier 프로토콜을 자동으로 사용합니다.</p></body></html> Network protocol @@ -1265,8 +1265,8 @@ Enabling this setting will disable the server config GUI. wl-clipboard 지원 사용 - <html><head/><body><p>Requires the wl-clipboard package</p><p>When using wl-clipboard v2.2.1, there is a focus stealing bug that may make Deskflow harder to use. This has been fixed when using the wl-clipboard master branch, unless your Compositor lacks wlroots-data-control protocol support.</p></body></html> - <html><head/><body><p>wl-clipboard 패키지가 필요합니다.</p><p>wl-clipboard v2.2.1 사용 시 포커스 탈취 버그로 인해 Deskflow 사용이 어려워질 수 있습니다. 이 문제는 wl-clipboard master 브랜치를 사용하면 해결되지만, 사용 중인 컴포지터가 wlroots-data-control 프로토콜을 지원하지 않으면 해결이 안 될 수 있습니다.</p></body></html> + <html><head/><body><p>Requires the wl-clipboard package</p><p>When using wl-clipboard v2.2.1, there is a focus stealing bug that may make %1 harder to use. This has been fixed when using the wl-clipboard master branch, unless your Compositor lacks wlroots-data-control protocol support.</p></body></html> + <html><head/><body><p>wl-clipboard 패키지가 필요합니다.</p><p>wl-clipboard v2.2.1 사용 시 포커스 탈취 버그로 인해 %1 사용이 어려워질 수 있습니다. 이 문제는 wl-clipboard master 브랜치를 사용하면 해결되지만, 사용 중인 컴포지터가 wlroots-data-control 프로토콜을 지원하지 않으면 해결이 안 될 수 있습니다.</p></body></html> Automatic diff --git a/translations/deskflow_ru.ts b/translations/deskflow_ru.ts index fc5d6ab22..c2fd20fb4 100644 --- a/translations/deskflow_ru.ts +++ b/translations/deskflow_ru.ts @@ -4,8 +4,8 @@ AboutDialog - About Deskflow - О Deskflow + About %1 + О %1 <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.0//EN" "http://www.w3.org/TR/REC-html40/strict.dtd"> @@ -421,6 +421,10 @@ Do you want to connect to the server? <p>Failed to connect to the server '%1'.</p><p>A Client with your name is already connected to the server.</p>Please ensure that you're using a unique name and that only a single instance of the client process is running.</p> <p>Не удалось подключиться к серверу '%1'.</p><p>Клиент с таким именем уже подключен к серверу.</p>Убедитесь, что вы используете уникальное имя и запущен только один процесс клиента.</p> + + Get help + Получить помощь + No IP Detected IP-адрес не обнаружен @@ -471,10 +475,6 @@ A bound IP is now invalid, you may need to restart the server. Clear settings Сбросить настройки - - Report a Bug - Сообщить об ошибке - &Minimize to tray &Свернуть в трей @@ -1065,7 +1065,7 @@ Additionally, check you are able to %1 the server config file: %2 мс - <html><head/><body><p>Enables compatibility with programs that use either the Synergy or Barrier protocols:</p><ul style="margin-top: 0px; margin-bottom: 0px; margin-left: 0px; margin-right: 0px; -qt-list-indent: 1;"><li style=" margin-top:12px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">Synergy 3 uses the Synergy protocol.</li><li style=" margin-top:0px; margin-bottom:12px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">Barrier, Input-Leap and Synergy 1 uses the Barrier protocol.</li></ul><p>A Deskflow client will automatically use either the Synergy or Barrier protocol depending on the server protocol.</p></body></html> + <html><head/><body><p>Enables compatibility with programs that use either the Synergy or Barrier protocols:</p><ul style="margin-top: 0px; margin-bottom: 0px; margin-left: 0px; margin-right: 0px; -qt-list-indent: 1;"><li style=" margin-top:12px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">Synergy 3 uses the Synergy protocol.</li><li style=" margin-top:0px; margin-bottom:12px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">Barrier, Input-Leap and Synergy 1 uses the Barrier protocol.</li></ul><p>A %1 client will automatically use either the Synergy or Barrier protocol depending on the server protocol.</p></body></html> <html><head/><body><p>Включает совместимость с протоколами Synergy или Barrier.</p></body></html> @@ -1263,7 +1263,7 @@ Enabling this setting will disable the server config GUI. Сбросить до значений по умолчанию - <html><head/><body><p>Requires the wl-clipboard package</p><p>When using wl-clipboard v2.2.1, there is a focus stealing bug that may make Deskflow harder to use. This has been fixed when using the wl-clipboard master branch, unless your Compositor lacks wlroots-data-control protocol support.</p></body></html> + <html><head/><body><p>Requires the wl-clipboard package</p><p>When using wl-clipboard v2.2.1, there is a focus stealing bug that may make %1 harder to use. This has been fixed when using the wl-clipboard master branch, unless your Compositor lacks wlroots-data-control protocol support.</p></body></html> <html><head/><body><p>Требуется пакет wl-clipboard. В версии 2.2.1 есть ошибка перехвата фокуса.</p></body></html> diff --git a/translations/deskflow_zh_CN.ts b/translations/deskflow_zh_CN.ts index 667bd7c00..979a23525 100644 --- a/translations/deskflow_zh_CN.ts +++ b/translations/deskflow_zh_CN.ts @@ -4,8 +4,8 @@ AboutDialog - About Deskflow - 关于 Deskflow + About %1 + 关于 %1 <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.0//EN" "http://www.w3.org/TR/REC-html40/strict.dtd"> @@ -421,6 +421,10 @@ Do you want to connect to the server? <p>Failed to connect to the server '%1'.</p><p>A Client with your name is already connected to the server.</p>Please ensure that you're using a unique name and that only a single instance of the client process is running.</p> <p>连接到服务器“%1”失败。</p><p>一个同名的客户端已连接到服务器。</p>请确保您使用的名称唯一,且只有一个客户端进程实例在运行。</p> + + Get help + 获取帮助 + No IP Detected 未检测到 IP @@ -471,10 +475,6 @@ A bound IP is now invalid, you may need to restart the server. Clear settings 清除设置 - - Report a Bug - 报告 Bug - &Minimize to tray 最小化到托盘(&M) @@ -1067,8 +1067,8 @@ Additionally, check you are able to %1 the server config file: %2 毫秒 - <html><head/><body><p>Enables compatibility with programs that use either the Synergy or Barrier protocols:</p><ul style="margin-top: 0px; margin-bottom: 0px; margin-left: 0px; margin-right: 0px; -qt-list-indent: 1;"><li style=" margin-top:12px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">Synergy 3 uses the Synergy protocol.</li><li style=" margin-top:0px; margin-bottom:12px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">Barrier, Input-Leap and Synergy 1 uses the Barrier protocol.</li></ul><p>A Deskflow client will automatically use either the Synergy or Barrier protocol depending on the server protocol.</p></body></html> - <html><head/><body><p>启用与使用 Synergy 或 Barrier 协议的程序的兼容性:</p><ul style="margin-top: 0px; margin-bottom: 0px; margin-left: 0px; margin-right: 0px; -qt-list-indent: 1;"><li style=" margin-top:12px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">Synergy 3 使用 Synergy 协议。</li><li style=" margin-top:0px; margin-bottom:12px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">Barrier, Input-Leap 和 Synergy 1 使用 Barrier 协议。</li></ul><p>Deskflow 客户端将根据服务器协议自动使用 Synergy 或 Barrier 协议。</p></body></html> + <html><head/><body><p>Enables compatibility with programs that use either the Synergy or Barrier protocols:</p><ul style="margin-top: 0px; margin-bottom: 0px; margin-left: 0px; margin-right: 0px; -qt-list-indent: 1;"><li style=" margin-top:12px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">Synergy 3 uses the Synergy protocol.</li><li style=" margin-top:0px; margin-bottom:12px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">Barrier, Input-Leap and Synergy 1 uses the Barrier protocol.</li></ul><p>A %1 client will automatically use either the Synergy or Barrier protocol depending on the server protocol.</p></body></html> + <html><head/><body><p>启用与使用 Synergy 或 Barrier 协议的程序的兼容性:</p><ul style="margin-top: 0px; margin-bottom: 0px; margin-left: 0px; margin-right: 0px; -qt-list-indent: 1;"><li style=" margin-top:12px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">Synergy 3 使用 Synergy 协议。</li><li style=" margin-top:0px; margin-bottom:12px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">Barrier, Input-Leap 和 Synergy 1 使用 Barrier 协议。</li></ul><p>%1 客户端将根据服务器协议自动使用 Synergy 或 Barrier 协议。</p></body></html> Network protocol @@ -1267,8 +1267,8 @@ Enabling this setting will disable the server config GUI. 启用 wl-clipboard 支持 - <html><head/><body><p>Requires the wl-clipboard package</p><p>When using wl-clipboard v2.2.1, there is a focus stealing bug that may make Deskflow harder to use. This has been fixed when using the wl-clipboard master branch, unless your Compositor lacks wlroots-data-control protocol support.</p></body></html> - <html><head/><body><p>需要 wl-clipboard 包</p><p>使用 wl-clipboard v2.2.1 时存在一个焦点抢夺 Bug,可能导致 Deskflow 使用不便。该问题已在 wl-clipboard 的 master 分支中修复,除非您的合成器不支持 wlroots-data-control 协议。</p></body></html> + <html><head/><body><p>Requires the wl-clipboard package</p><p>When using wl-clipboard v2.2.1, there is a focus stealing bug that may make %1 harder to use. This has been fixed when using the wl-clipboard master branch, unless your Compositor lacks wlroots-data-control protocol support.</p></body></html> + <html><head/><body><p>需要 wl-clipboard 包</p><p>使用 wl-clipboard v2.2.1 时存在一个焦点抢夺 Bug,可能导致 %1 使用不便。该问题已在 wl-clipboard 的 master 分支中修复,除非您的合成器不支持 wlroots-data-control 协议。</p></body></html> Automatic