From 645507e1b9d1b1887fa6c9d7f50af8fad92df271 Mon Sep 17 00:00:00 2001 From: Jonathan Bnayahu Date: Tue, 11 Aug 2026 21:06:21 +0300 Subject: [PATCH 1/5] fix: collapse the triple auto-switch re-apply in the Options OK handler MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pressing OK could call CheckAndApplyAutoSwitch() three times: once in the auto-switch settings block, once in the base-mouse-count block, and once in the forced re-apply after the cached device state is discarded. Each call enumerates raw input devices, retrying GetRawInputDeviceList up to three times, so a single click cost up to three enumerations. The repeats were harmless — the direction is persisted before all three calls and ApplyMouseOrientation() sets an absolute value rather than toggling — but only the last call could do useful work in the common case, since the first two early-return on unchanged state. Remove the two earlier calls and keep the forced re-apply at the end of the handler. By that point every setting the check reads is persisted, so one pass applies them all; the first call previously ran before SetBaseMouseCount(), so it could not see a changed base count anyway. Dropping the base-mouse-count call also stops a re-apply from running when the auto-switch flag itself failed to persist: that call was gated on autoSwitchEnabled alone, not on autoSwitchWritten. The surviving call is gated on both, matching 3a00a42. Closes #2 Assisted-By: Claude (Anthropic AI) Signed-off-by: Jonathan Bnayahu --- src/primary.cpp | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/src/primary.cpp b/src/primary.cpp index 190c010..057d629 100644 --- a/src/primary.cpp +++ b/src/primary.cpp @@ -517,7 +517,6 @@ INT_PTR CALLBACK OptionsDialogProc(HWND hwndDlg, UINT msg, WPARAM wParam, LPARAM // Start or stop monitoring based on setting if (autoSwitchEnabled) { StartAutoSwitchMonitoring(g_hwndMain); - CheckAndApplyAutoSwitch(); // Apply immediately (will detect change and apply) } else { StopAutoSwitchMonitoring(g_hwndMain); } @@ -529,16 +528,15 @@ INT_PTR CALLBACK OptionsDialogProc(HWND hwndDlg, UINT msg, WPARAM wParam, LPARAM L"Failed to update base mouse count. Please check your permissions.", L"Error", MB_ICONERROR | MB_OK); - } else { - // Re-check if auto-switch is enabled, to apply new settings immediately - if (autoSwitchEnabled) { - CheckAndApplyAutoSwitch(); - } } + // Single re-apply point: every setting the check reads — + // direction, auto-switch flag, base device count — is + // persisted by now, so one pass covers them all. + // // Settings may have changed the mapping without the device // state changing, so discard the cached state to force the - // next check to apply the new configuration. + // check to apply the new configuration. if (autoSwitchEnabled && autoSwitchWritten) { g_lastExternalMouseState = EXTERNAL_MOUSE_UNKNOWN; CheckAndApplyAutoSwitch(); From c113fd9354006d330844de83ea1dccaa53f5a318 Mon Sep 17 00:00:00 2001 From: Jonathan Bnayahu Date: Tue, 11 Aug 2026 21:10:26 +0300 Subject: [PATCH 2/5] ci: restrict build.yml to read-only repository permissions build.yml declared no permissions: block, so the workflow ran with the default GITHUB_TOKEN permissions. The job only checks out the repository, builds, and uploads an artifact, so read access to contents is all it needs. actions/upload-artifact authenticates with the Actions runtime token rather than GITHUB_TOKEN, so it is unaffected. Nothing was broken; this narrows the token to what the job actually uses. The comment warns against copying the block into release.yml, which genuinely needs contents: write for `gh release create`. Closes #3 Assisted-By: Claude (Anthropic AI) Signed-off-by: Jonathan Bnayahu --- .github/workflows/build.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 1a9b060..41ec67f 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -6,6 +6,12 @@ on: pull_request: workflow_dispatch: +# This workflow only builds and uploads an artifact; it makes no repository +# writes. release.yml needs contents: write for `gh release create` — do not +# copy this block there. +permissions: + contents: read + jobs: build: runs-on: ubuntu-latest From 2125cce108aae629a40380ac0d2fdf6e42df6b5f Mon Sep 17 00:00:00 2001 From: Jonathan Bnayahu Date: Tue, 11 Aug 2026 21:27:46 +0300 Subject: [PATCH 3/5] ci: extract each APP_VERSION_* component instead of relying on macro order MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both workflows derived the source version with a single alternation piped through `paste`: grep -oP 'APP_VERSION_(MAJOR|MINOR|PATCH)\s+\K\d+' ... | paste -sd. That takes the macros in the order they appear in the header rather than in semantic order. They currently appear as MAJOR, MINOR, PATCH on adjacent lines, so the result is correct today, but nothing enforced it — reordering them silently produced a transposed version string. The existing failure mode was fail-closed: release.yml's tag gate rejects a mismatch loudly rather than publishing something mislabelled. This makes the extraction correct in the first place, so a reorder is a non-event instead of a confusing release failure. Extract each component by name, and assert all three are non-empty — the old form would emit a short "1.2" if a macro went missing, which could then match a substring of the binary's version strings. Closes #6 Assisted-By: Claude (Anthropic AI) Signed-off-by: Jonathan Bnayahu --- .github/workflows/build.yml | 13 +++++++++++-- .github/workflows/release.yml | 13 +++++++++++-- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 41ec67f..d0587d2 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -74,10 +74,19 @@ jobs: done exit $status + # Extracted per macro rather than with one alternation piped through + # `paste`, which silently assumed MAJOR/MINOR/PATCH appear in that order + # in the header. An empty component would build a nonsense version like + # "1..0", so each is asserted non-empty. - name: Verify the version resource matches app_strings.h run: | - src=$(grep -oP 'APP_VERSION_(MAJOR|MINOR|PATCH)\s+\K\d+' \ - resources/app_strings.h | paste -sd.) + maj=$(grep -oP 'APP_VERSION_MAJOR\s+\K\d+' resources/app_strings.h) + min=$(grep -oP 'APP_VERSION_MINOR\s+\K\d+' resources/app_strings.h) + pat=$(grep -oP 'APP_VERSION_PATCH\s+\K\d+' resources/app_strings.h) + for c in "$maj" "$min" "$pat"; do + [ -n "$c" ] || { echo "::error::Could not read APP_VERSION_* from resources/app_strings.h"; exit 1; } + done + src="$maj.$min.$pat" echo "Source version: $src" strings -el Primary.exe | grep -qF "$src" || { echo "::error::Binary does not report source version $src"; exit 1; } diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4471e74..1b3fa00 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -23,8 +23,17 @@ jobs: - name: Verify the tag matches the source version run: | tag="${GITHUB_REF_NAME#v}" - src=$(grep -oP 'APP_VERSION_(MAJOR|MINOR|PATCH)\s+\K\d+' \ - resources/app_strings.h | paste -sd.) + # Extracted per macro rather than with one alternation piped through + # `paste`, which silently assumed MAJOR/MINOR/PATCH appear in that + # order in the header. An empty component would build a nonsense + # version like "1..0", so each is asserted non-empty. + maj=$(grep -oP 'APP_VERSION_MAJOR\s+\K\d+' resources/app_strings.h) + min=$(grep -oP 'APP_VERSION_MINOR\s+\K\d+' resources/app_strings.h) + pat=$(grep -oP 'APP_VERSION_PATCH\s+\K\d+' resources/app_strings.h) + for c in "$maj" "$min" "$pat"; do + [ -n "$c" ] || { echo "::error::Could not read APP_VERSION_* from resources/app_strings.h"; exit 1; } + done + src="$maj.$min.$pat" echo "Tag: $tag / source: $src" if [ "$tag" != "$src" ]; then echo "::error::Tag $tag does not match source version $src. Bump APP_VERSION_* in resources/app_strings.h." From 67d3eea6b98837ae8282195c83e5136fb2e640e6 Mon Sep 17 00:00:00 2001 From: Jonathan Bnayahu Date: Tue, 11 Aug 2026 21:23:58 +0300 Subject: [PATCH 4/5] ci: fail the build when primary.manifest drifts from app_strings.h MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit primary.rc references primary.manifest as an opaque file, so windres never runs the preprocessor over it and its assemblyIdentity version cannot be derived from APP_VERSION_*. It has to be bumped by hand, and nothing enforced that — the only safeguard was a manual release-checklist step. Assert it in CI instead: compare the manifest's assemblyIdentity version against MAJOR.MINOR.PATCH.0 from app_strings.h, in build.yml (every push and PR) and in release.yml (refuse to ship a mismatch). Drift now fails loudly instead of shipping silently. Of the options in the issue, this leaves the build path untouched. Generating the manifest from a template in build.sh was rejected because README.md documents a direct windres/g++ invocation that bypasses build.sh — build.yml verifies that command still works — so a generated manifest would break or stale out that documented path. The manifest comment now records that CI enforces the match, and that the check expects name= and version= on one line. Closes #5 Assisted-By: Claude (Anthropic AI) Signed-off-by: Jonathan Bnayahu --- .github/workflows/build.yml | 22 ++++++++++++++++++++++ .github/workflows/release.yml | 19 +++++++++++++++++++ resources/primary.manifest | 5 ++++- 3 files changed, 45 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index d0587d2..240a2cc 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -18,6 +18,28 @@ jobs: steps: - uses: actions/checkout@v4 + # primary.rc references primary.manifest as an opaque file, so windres + # never preprocesses it and its assemblyIdentity version cannot come from + # app_strings.h — it has to be bumped by hand. This makes the two drifting + # apart fail the build instead of relying on a release-checklist step. + # + # Expects name= and version= on one line of the assemblyIdentity element; + # reformatting that element fails this check rather than skipping it. + - name: Verify the manifest version matches app_strings.h + run: | + maj=$(grep -oP 'APP_VERSION_MAJOR\s+\K\d+' resources/app_strings.h) + min=$(grep -oP 'APP_VERSION_MINOR\s+\K\d+' resources/app_strings.h) + pat=$(grep -oP 'APP_VERSION_PATCH\s+\K\d+' resources/app_strings.h) + # VERSIONINFO is MAJOR,MINOR,PATCH,0; the manifest mirrors that. + want="$maj.$min.$pat.0" + got=$(grep -oP ']*\bname="Primary"[^>]*\bversion="\K[^"]+' \ + resources/primary.manifest) + echo "app_strings.h: $want / manifest: ${got:-none}" + if [ "$got" != "$want" ]; then + echo "::error::resources/primary.manifest version ${got:-(not found)} does not match $want from resources/app_strings.h. Update the assemblyIdentity version." + exit 1 + fi + - name: Install MinGW-w64 run: | sudo apt-get update diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1b3fa00..a36984f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -40,6 +40,25 @@ jobs: exit 1 fi + # primary.manifest is referenced by primary.rc as an opaque file, so + # windres never preprocesses it and its assemblyIdentity version has to be + # bumped by hand. Refuse to ship a release where it disagrees with the + # source version. (Kept as a separate step from the tag gate above so the + # failure says which of the two is wrong.) + - name: Verify the manifest version matches the source version + run: | + maj=$(grep -oP 'APP_VERSION_MAJOR\s+\K\d+' resources/app_strings.h) + min=$(grep -oP 'APP_VERSION_MINOR\s+\K\d+' resources/app_strings.h) + pat=$(grep -oP 'APP_VERSION_PATCH\s+\K\d+' resources/app_strings.h) + want="$maj.$min.$pat.0" + got=$(grep -oP ']*\bname="Primary"[^>]*\bversion="\K[^"]+' \ + resources/primary.manifest) + echo "app_strings.h: $want / manifest: ${got:-none}" + if [ "$got" != "$want" ]; then + echo "::error::resources/primary.manifest version ${got:-(not found)} does not match $want from resources/app_strings.h. Update the assemblyIdentity version." + exit 1 + fi + - name: Build shell: bash run: ./build.sh 2>&1 | tee build.log diff --git a/resources/primary.manifest b/resources/primary.manifest index 32bb9b4..c5478f8 100644 --- a/resources/primary.manifest +++ b/resources/primary.manifest @@ -1,7 +1,10 @@ + bumped by hand alongside APP_VERSION_* in resources/app_strings.h. + Both workflows fail the build if the two disagree, so drift is caught + rather than shipped. Keep name= and version= on this one line: the check + looks for them together. --> From 3fe62dbcbf355afdacbcf2c5d2f28bfa6ffb28dd Mon Sep 17 00:00:00 2001 From: Jonathan Bnayahu Date: Tue, 11 Aug 2026 21:17:40 +0300 Subject: [PATCH 5/5] ci: publish releases through a draft so a failed upload cannot ship MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `gh release create` created the release object and uploaded both assets in one invocation, so an upload that failed partway could leave a published release with missing or truncated downloads. Every correctness gate (tag and source version match, warning check, binary version assertion) already runs before this point, so only a network or API failure could cause it — but the window was real and users see published releases immediately. Split publishing into three steps: 1. create the release as a --draft, with no assets 2. upload the assets (--clobber, so a rerun after a partial upload replaces the incomplete asset instead of failing on "already exists") 3. verify both assets, then flip --draft=false A draft is hidden from users and from the "latest release" API, so a failure at step 1 or 2 no longer produces a visible partial release. Step 3 accepts an asset only when GitHub reports state == "uploaded" and its size matches the local file, which catches a truncated upload as well as a missing one. Anything short of that fails the job with the release still a draft. Each step is now independently retryable, and recovery no longer needs a re-tag: delete the leftover draft with `gh release delete "$TAG"` and rerun. Closes #4 Assisted-By: Claude (Anthropic AI) Signed-off-by: Jonathan Bnayahu --- .github/workflows/release.yml | 44 +++++++++++++++++++++++++++++++++-- 1 file changed, 42 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a36984f..91af3ec 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -86,12 +86,52 @@ jobs: echo "--- notes.md ---" cat notes.md - - name: Create the GitHub release + # Publishing is split across three steps so a failed asset upload cannot + # leave a *published* release with missing or truncated downloads. The + # release object starts as a draft — hidden from users and from the + # "latest release" API — and is only published after both assets are + # confirmed attached. + # + # If a step fails, the draft is left behind for inspection; delete it with + # `gh release delete "$TAG"` (the tag itself survives, so a fixed rerun of + # this workflow needs no re-tagging). + - name: Create the release as a draft env: GH_TOKEN: ${{ github.token }} run: | gh release create "$GITHUB_REF_NAME" \ - Primary.exe SHA256SUMS.txt \ + --draft \ --title "Primary $GITHUB_REF_NAME" \ --notes-file notes.md \ --verify-tag + + # --clobber so a rerun after a partial upload replaces the incomplete + # asset instead of failing on "already exists". + - name: Upload the release assets + env: + GH_TOKEN: ${{ github.token }} + run: | + gh release upload "$GITHUB_REF_NAME" \ + Primary.exe SHA256SUMS.txt \ + --clobber + + # An asset counts only if GitHub reports it fully uploaded *and* its size + # matches the local file, which catches a truncated upload as well as a + # missing one. Anything short of that leaves the release a draft. + - name: Verify both assets uploaded intact, then publish + env: + GH_TOKEN: ${{ github.token }} + run: | + status=0 + for asset in Primary.exe SHA256SUMS.txt; do + want=$(stat -c%s "$asset") + got=$(gh release view "$GITHUB_REF_NAME" --json assets \ + --jq ".assets[] | select(.name == \"$asset\" and .state == \"uploaded\") | .size") + echo "$asset: local $want bytes / attached ${got:-none}" + if [ "$got" != "$want" ]; then + echo "::error::Release asset $asset is missing or incompletely uploaded" + status=1 + fi + done + [ "$status" -eq 0 ] || exit 1 + gh release edit "$GITHUB_REF_NAME" --draft=false