Repository: lujjjh/LinearMouse Branch: main Commit: 169f52014a0f Files: 308 Total size: 2.6 MB Directory structure: gitextract_23xgv3jw/ ├── .github/ │ ├── ISSUE_TEMPLATE/ │ │ ├── bug_report.yml │ │ ├── config.yml │ │ └── feature_request.yml │ ├── release.yml │ └── workflows/ │ ├── add-coauthor.yml │ ├── build.yml │ └── stale.yml ├── .gitignore ├── .swift-version ├── .swiftformat ├── .swiftlint.yml ├── .vscode/ │ └── settings.json ├── ACCESSIBILITY.md ├── CLAUDE.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING-cn.md ├── CONTRIBUTING.md ├── Config.xcconfig ├── Documentation/ │ ├── Configuration.d.ts │ ├── Configuration.json │ ├── Configuration.md │ └── translate-xcstrings.md ├── ExportOptions.plist ├── LICENSE ├── LinearMouse/ │ ├── AccessibilityPermission.swift │ ├── AppDelegate.swift │ ├── Assets.xcassets/ │ │ ├── AccentColor.colorset/ │ │ │ └── Contents.json │ │ ├── AccessibilityIcon.imageset/ │ │ │ └── Contents.json │ │ ├── AppIcon.appiconset/ │ │ │ └── Contents.json │ │ ├── AppIconDev.appiconset/ │ │ │ └── Contents.json │ │ ├── Contents.json │ │ ├── MenuIcon.imageset/ │ │ │ └── Contents.json │ │ ├── Minus.imageset/ │ │ │ └── Contents.json │ │ ├── Plus.imageset/ │ │ │ └── Contents.json │ │ └── Sidebar/ │ │ ├── Buttons.imageset/ │ │ │ └── Contents.json │ │ ├── Contents.json │ │ ├── General.imageset/ │ │ │ └── Contents.json │ │ ├── Modifier Keys.imageset/ │ │ │ └── Contents.json │ │ ├── Pointer.imageset/ │ │ │ └── Contents.json │ │ └── Scrolling.imageset/ │ │ └── Contents.json │ ├── AutoUpdateManager.swift │ ├── DefaultsKeys.swift │ ├── Device/ │ │ ├── Device.swift │ │ ├── DeviceManager.swift │ │ ├── InputReportHandler.swift │ │ ├── InputReportHandlers/ │ │ │ ├── GenericSideButtonHandler.swift │ │ │ └── KensingtonSlimbladeHandler.swift │ │ ├── ReceiverLogicalDeviceIdentity.swift │ │ ├── ReceiverMonitor.swift │ │ └── VendorSpecific/ │ │ ├── BatteryDeviceMonitor.swift │ │ ├── ConnectedBatteryDeviceInventory.swift │ │ ├── ConnectedLogitechDeviceInventory.swift │ │ ├── Logitech/ │ │ │ └── LogitechHIDPPDeviceMetadataProvider.swift │ │ ├── PointerDevice+VendorSpecificDeviceContext.swift │ │ └── VendorSpecificDeviceMetadata.swift │ ├── EventTap/ │ │ ├── EventTap.swift │ │ ├── EventThread.swift │ │ ├── EventType.swift │ │ ├── GlobalEventTap.swift │ │ └── GlobalEventTapWatchdog.swift │ ├── EventTransformer/ │ │ ├── AutoScrollTransformer.swift │ │ ├── ButtonActionsTransformer.swift │ │ ├── ClickDebouncingTransformer.swift │ │ ├── EventTransformer.swift │ │ ├── EventTransformerManager.swift │ │ ├── GestureButtonTransformer.swift │ │ ├── LinearScrollingHorizontalTransformer.swift │ │ ├── LinearScrollingVerticalTransformer.swift │ │ ├── LogitechEventContext.swift │ │ ├── ModifierActionsTransformer.swift │ │ ├── PointerRedirectsToScrollTransformer.swift │ │ ├── ReverseScrollingTransformer.swift │ │ ├── ScrollingAccelerationSpeedAdjustmentTransformer.swift │ │ ├── SmoothedScrollingEngine.swift │ │ ├── SmoothedScrollingTransformer.swift │ │ ├── SwitchPrimaryAndSecondaryButtonsTransformer.swift │ │ └── UniversalBackForwardTransformer.swift │ ├── EventView/ │ │ ├── EventView.swift │ │ ├── MouseEventView.swift │ │ └── ScrollWheelEventView.swift │ ├── Info.plist │ ├── LinearMouse-Bridging-Header.h │ ├── LinearMouse.entitlements │ ├── LinearMouse.swift │ ├── Localizable.xcstrings │ ├── Model/ │ │ ├── AppTarget.swift │ │ ├── Configuration/ │ │ │ ├── Configuration.swift │ │ │ ├── DeviceMatcher.swift │ │ │ └── Scheme/ │ │ │ ├── Buttons/ │ │ │ │ ├── AutoScroll/ │ │ │ │ │ └── AutoScroll.swift │ │ │ │ ├── Buttons.swift │ │ │ │ ├── ClickDebouncing/ │ │ │ │ │ └── ClickDebouncing.swift │ │ │ │ ├── Gesture/ │ │ │ │ │ └── Gesture.swift │ │ │ │ └── Mapping/ │ │ │ │ ├── Action+Codable.swift │ │ │ │ ├── Action+CustomStringConvertible.swift │ │ │ │ ├── Action+Kind.swift │ │ │ │ ├── Action.swift │ │ │ │ └── Mapping.swift │ │ │ ├── If/ │ │ │ │ └── If.swift │ │ │ ├── Pointer.swift │ │ │ ├── Scheme.swift │ │ │ └── Scrolling/ │ │ │ ├── Bidirectional.swift │ │ │ ├── Distance+Mode.swift │ │ │ ├── Distance.swift │ │ │ ├── Modifiers+Kind.swift │ │ │ ├── Modifiers.swift │ │ │ ├── Scrolling.swift │ │ │ └── Smoothed.swift │ │ └── DeviceModel.swift │ ├── ModifierKeys.swift │ ├── ScreenManager/ │ │ └── ScreenManager.swift │ ├── State/ │ │ ├── ConfigurationState.swift │ │ ├── DeviceState.swift │ │ ├── ModifierState.swift │ │ ├── SchemeState.swift │ │ └── SettingsState.swift │ ├── UI/ │ │ ├── AccessibilityPermissionView.swift │ │ ├── AccessibilityPermissionWindow.swift │ │ ├── Base.lproj/ │ │ │ └── Main.storyboard │ │ ├── ButtonStyle/ │ │ │ └── SecondaryButtonStyle.swift │ │ ├── ButtonsSettings/ │ │ │ ├── AutoScrollSection/ │ │ │ │ └── AutoScrollSection.swift │ │ │ ├── ButtonMappingsSection/ │ │ │ │ ├── ButtonMapping.swift │ │ │ │ ├── ButtonMappingAction/ │ │ │ │ │ ├── ButtonMappingAction+Binding.swift │ │ │ │ │ ├── ButtonMappingAction.swift │ │ │ │ │ ├── ButtonMappingActionKeyPress.swift │ │ │ │ │ ├── ButtonMappingActionPicker.swift │ │ │ │ │ ├── ButtonMappingActionRun.swift │ │ │ │ │ ├── ButtonMappingActionScroll.swift │ │ │ │ │ └── ScrollingDistance+Binding.swift │ │ │ │ ├── ButtonMappingButtonRecorder.swift │ │ │ │ ├── ButtonMappingEditSheet.swift │ │ │ │ └── ButtonMappingsSection.swift │ │ │ ├── ButtonsSettings.swift │ │ │ ├── ButtonsSettingsState.swift │ │ │ ├── ClickDebouncingSection.swift │ │ │ ├── GestureButtonSection/ │ │ │ │ ├── GestureActionPicker.swift │ │ │ │ └── GestureButtonSection.swift │ │ │ ├── SwitchPrimaryAndSecondaryButtonsSection.swift │ │ │ └── UniversalBackForwardSection.swift │ │ ├── CheckForUpdatesView.swift │ │ ├── DetailView/ │ │ │ └── DetailView.swift │ │ ├── Extensions/ │ │ │ ├── NSWindow.swift │ │ │ └── View.swift │ │ ├── GeneralSettings/ │ │ │ ├── ConfigurationSection.swift │ │ │ ├── GeneralSettings.swift │ │ │ └── LoggingSection.swift │ │ ├── Header/ │ │ │ ├── AppIndicator/ │ │ │ │ ├── AppIndicator.swift │ │ │ │ └── AppPickerSheet/ │ │ │ │ ├── AppPicker.swift │ │ │ │ ├── AppPickerSheet.swift │ │ │ │ └── AppPickerState.swift │ │ │ ├── DeviceIndicator/ │ │ │ │ ├── DeviceIndicator.swift │ │ │ │ ├── DeviceIndicatorState.swift │ │ │ │ └── DevicePickerSheet/ │ │ │ │ ├── DeviceButtonStyle.swift │ │ │ │ ├── DevicePicker.swift │ │ │ │ ├── DevicePickerBatteryCoordinator.swift │ │ │ │ ├── DevicePickerSection.swift │ │ │ │ ├── DevicePickerSectionItem.swift │ │ │ │ ├── DevicePickerSheet.swift │ │ │ │ └── DevicePickerState.swift │ │ │ ├── DisplayIndicator/ │ │ │ │ ├── DisplayIndicator.swift │ │ │ │ └── DisplayPickerSheet/ │ │ │ │ ├── DisplayPicker.swift │ │ │ │ ├── DisplayPickerSheet.swift │ │ │ │ └── DisplayPickerState.swift │ │ │ └── SchemeIndicator.swift │ │ ├── HelpButton/ │ │ │ └── HelpButton.swift │ │ ├── HyperLink.swift │ │ ├── PointerSettings/ │ │ │ ├── PointerSettings.swift │ │ │ └── PointerSettingsState.swift │ │ ├── ScrollingSettings/ │ │ │ ├── Header.swift │ │ │ ├── ModifierKeysSection/ │ │ │ │ ├── ModifierAction+Binding.swift │ │ │ │ ├── ModifierKeyActionPicker.swift │ │ │ │ └── ModifierKeysSection.swift │ │ │ ├── ReverseScrollingSection.swift │ │ │ ├── ScrollingModeSection.swift │ │ │ ├── ScrollingSettings.swift │ │ │ ├── ScrollingSettingsState.swift │ │ │ └── SmoothedScrollingSection.swift │ │ ├── Settings.swift │ │ ├── SettingsWindowController.swift │ │ ├── Sidebar/ │ │ │ ├── Sidebar.swift │ │ │ └── SidebarItem.swift │ │ ├── StatusItem.swift │ │ ├── ViewModifiers/ │ │ │ └── FormViewModifier.swift │ │ ├── VisualEffectView.swift │ │ └── zh-Hant-HK.lproj/ │ │ └── Main.strings │ └── Utilities/ │ ├── Application.swift │ ├── ApplicationBundle.swift │ ├── CGEvent+LinearMouseSynthetic.swift │ ├── Codable/ │ │ ├── Clamp.swift │ │ ├── HexRepresentation.swift │ │ ├── ImplicitOptional.swift │ │ ├── SingleValueOrArray.swift │ │ └── Unsettable.swift │ ├── CustomDecodingError.swift │ ├── Extensions.swift │ ├── KeyboardSettingsSnapshot.swift │ ├── Notifier.swift │ ├── Process.h │ ├── Process.m │ ├── ProcessEnvironment.swift │ ├── WeakRef.swift │ └── WindowInfo.swift ├── LinearMouse.xcodeproj/ │ ├── project.pbxproj │ ├── project.xcworkspace/ │ │ ├── contents.xcworkspacedata │ │ └── xcshareddata/ │ │ ├── IDEWorkspaceChecks.plist │ │ └── swiftpm/ │ │ └── Package.resolved │ └── xcshareddata/ │ └── xcschemes/ │ └── LinearMouse.xcscheme ├── LinearMouseUnitTests/ │ ├── Device/ │ │ ├── InputReportHandlerTests.swift │ │ └── VendorSpecificDeviceMetadataTests.swift │ ├── EventTransformer/ │ │ ├── AutoScrollTransformerTests.swift │ │ ├── ButtonActionsTransformerTests.swift │ │ ├── EventTransformerManagerTests.swift │ │ ├── LinearScrollingTransformerTests.swift │ │ ├── ModifierActionsTransformerTests.swift │ │ ├── ReverseScrollingTransformerTests.swift │ │ ├── SmoothedScrollingEngineTests.swift │ │ ├── SmoothedScrollingTransformerTests.swift │ │ └── UniversalBackForwardTransformerTests.swift │ ├── Model/ │ │ ├── ConfigurationTests.swift │ │ └── Scheme/ │ │ ├── Buttons/ │ │ │ ├── GestureTests.swift │ │ │ └── MappingActionKindTests.swift │ │ └── Scrolling/ │ │ ├── BidirectionalTests.swift │ │ ├── DistanceModeTests.swift │ │ └── ModifiersKindTests.swift │ ├── UI/ │ │ ├── ButtonMappingActionBindingTests.swift │ │ ├── ModifierKeyActionPickerTests.swift │ │ ├── ScrollingDistanceBindingTests.swift │ │ └── StatusItemBatteryIndicatorTests.swift │ └── Utilities/ │ ├── Codable/ │ │ └── ImplicitOptionalTests.swift │ └── KeyboardSettingsSnapshotTests.swift ├── Makefile ├── Modules/ │ ├── DockKit/ │ │ ├── .gitignore │ │ ├── Package.swift │ │ ├── README.md │ │ ├── Sources/ │ │ │ ├── DockKit/ │ │ │ │ └── DockKit.swift │ │ │ └── DockKitC/ │ │ │ ├── DockKitC.m │ │ │ └── include/ │ │ │ └── ApplicationServicesSPI.h │ │ └── Tests/ │ │ └── DockKitTests/ │ │ └── DockKitTests.swift │ ├── GestureKit/ │ │ ├── .gitignore │ │ ├── Package.swift │ │ ├── README.md │ │ ├── Sources/ │ │ │ └── GestureKit/ │ │ │ ├── CGEventField+Extensions.swift │ │ │ ├── CGEventType+Extensions.swift │ │ │ ├── GestureEvent+NavigationSwipe.swift │ │ │ ├── GestureEvent+Zoom.swift │ │ │ └── GestureEvent.swift │ │ └── Tests/ │ │ └── GestureKitTests/ │ │ └── GestureKitTests.swift │ ├── KeyKit/ │ │ ├── .gitignore │ │ ├── Package.swift │ │ ├── README.md │ │ ├── Sources/ │ │ │ ├── KeyKit/ │ │ │ │ ├── Key.swift │ │ │ │ ├── KeyCodeResolver.swift │ │ │ │ ├── KeySimulator.swift │ │ │ │ ├── SymbolicHotKey.swift │ │ │ │ └── SystemDefinedKey.swift │ │ │ └── KeyKitC/ │ │ │ ├── CGSInternal/ │ │ │ │ ├── CGSAccessibility.h │ │ │ │ ├── CGSCIFilter.h │ │ │ │ ├── CGSConnection.h │ │ │ │ ├── CGSCursor.h │ │ │ │ ├── CGSDebug.h │ │ │ │ ├── CGSDevice.h │ │ │ │ ├── CGSDisplays.h │ │ │ │ ├── CGSEvent.h │ │ │ │ ├── CGSHotKeys.h │ │ │ │ ├── CGSInternal.h │ │ │ │ ├── CGSMisc.h │ │ │ │ ├── CGSRegion.h │ │ │ │ ├── CGSSession.h │ │ │ │ ├── CGSSpace.h │ │ │ │ ├── CGSSurface.h │ │ │ │ ├── CGSTile.h │ │ │ │ ├── CGSTransitions.h │ │ │ │ ├── CGSWindow.h │ │ │ │ └── CGSWorkspace.h │ │ │ ├── KeyKitC.m │ │ │ └── include/ │ │ │ └── KeyKitC.h │ │ └── Tests/ │ │ └── KeyKitTests/ │ │ └── KeyKitTests.swift │ ├── ObservationToken/ │ │ ├── .gitignore │ │ ├── Package.swift │ │ ├── README.md │ │ ├── Sources/ │ │ │ └── ObservationToken/ │ │ │ ├── LifetimeAssociation.swift │ │ │ └── ObservationToken.swift │ │ └── Tests/ │ │ └── ObservationTokenTests/ │ │ └── ObservationTokenTests.swift │ ├── PointerKit/ │ │ ├── .gitignore │ │ ├── Package.swift │ │ ├── README.md │ │ ├── Sources/ │ │ │ ├── PointerKit/ │ │ │ │ ├── Extensions/ │ │ │ │ │ ├── Comparable+Extensions.swift │ │ │ │ │ ├── Dictionary+Extensions.swift │ │ │ │ │ ├── IOHIDElement+Extensions.swift │ │ │ │ │ ├── IOHIDServiceClient+Property.swift │ │ │ │ │ ├── IOHIDServiceClient+Service.swift │ │ │ │ │ └── IOHIDValue+Extensions.swift │ │ │ │ ├── PointerDevice.swift │ │ │ │ └── PointerDeviceManager.swift │ │ │ └── PointerKitC/ │ │ │ ├── PointerKitC.m │ │ │ └── include/ │ │ │ └── IOKitSPIMac.h │ │ └── Tests/ │ │ └── PointerKitTests/ │ │ └── PointerDeviceManagerTest.swift │ └── README.md ├── README-cn.md ├── README.md ├── Scripts/ │ ├── configure-code-signing │ ├── configure-release │ ├── configure-version │ ├── pre-commit │ ├── sign-and-notarize │ └── translate-xcstrings.mjs ├── Signing.xcconfig.tpl ├── crowdin.yml └── package.json ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/ISSUE_TEMPLATE/bug_report.yml ================================================ name: Bug report description: Create a report to help us improve. labels: bug body: - type: input id: os attributes: label: OS placeholder: macOS 13.1 (Build 22C65) validations: required: true - type: input id: linearmouse attributes: label: LinearMouse placeholder: v0.7.5 validations: required: true - type: textarea attributes: label: Describe the bug description: A clear and concise description of what the bug is. validations: required: true - type: textarea attributes: label: To reproduce description: Steps to reproduce the behavior. placeholder: | 1. In this environment... 2. With this config... 3. Run '...' 4. See error... validations: required: false - type: textarea attributes: label: Expected behavior description: A clear and concise description of what you expected to happen. validations: required: false - type: textarea attributes: label: Anything else? description: | Links? References? Anything that will give us more context about the issue you are encountering! Tip: You can attach images or log files by clicking this area to highlight it and then dragging files in. validations: required: false ================================================ FILE: .github/ISSUE_TEMPLATE/config.yml ================================================ contact_links: - name: Discussions url: https://github.com/linearmouse/linearmouse/discussions about: Please ask and answer questions here. ================================================ FILE: .github/ISSUE_TEMPLATE/feature_request.yml ================================================ name: Feature request description: Suggest an idea for this project. labels: enhancement body: - type: input id: os attributes: label: OS placeholder: macOS 13.1 (Build 22C65) validations: required: false - type: input id: linearmouse attributes: label: LinearMouse placeholder: v0.7.5 validations: required: false - type: textarea attributes: label: Is your feature request related to a problem? description: A clear and concise description of what the problem is. placeholder: I'm always frustrated when [...] validations: required: false - type: textarea attributes: label: Describe the solution you'd like description: A clear and concise description of what you want to happen. placeholder: I'd like to have per-app settings [...] validations: required: true - type: textarea attributes: label: Describe alternatives you've considered description: A clear and concise description of any alternative solutions or features you've considered. validations: required: false - type: textarea attributes: label: Additional context description: | Add any other context or screenshots about the feature request here. Tip: You can attach images or log files by clicking this area to highlight it and then dragging files in. validations: required: false ================================================ FILE: .github/release.yml ================================================ changelog: exclude: labels: - ignore for release categories: - title: New features labels: - enhancement - title: Bug fixes labels: - bug - title: Other changes labels: - "*" ================================================ FILE: .github/workflows/add-coauthor.yml ================================================ name: Add Co-Author to PR on: issue_comment: types: [created] jobs: add-coauthor: runs-on: ubuntu-latest if: ${{ github.event.issue.pull_request && startsWith(github.event.comment.body, '/add-author') }} permissions: pull-requests: write contents: write steps: - name: Checkout repository uses: actions/checkout@v3 with: fetch-depth: 0 - name: Add co-author to commit uses: actions/github-script@v6 with: script: | try { // Parse the comment to extract the GitHub username const commentBody = context.payload.comment.body; const match = commentBody.match(/\/add-author\s+(\S+)/); if (!match || !match[1]) { return; } const username = match[1]; // Get user information from GitHub API let userInfo; try { const { data } = await github.rest.users.getByUsername({ username: username }); userInfo = data; } catch (error) { await github.rest.issues.createComment({ owner: context.repo.owner, repo: context.repo.repo, issue_number: context.payload.issue.number, body: `Error: Could not find GitHub user "${username}"` }); return; } // Construct the co-author line // GitHub noreply email format: ID+username@users.noreply.github.com const githubEmail = `${userInfo.id}+${username}@users.noreply.github.com`; const coAuthorLine = `Co-authored-by: ${userInfo.name || username} <${githubEmail}>`; // Get the PR details to find the head branch and latest commit const { data: pullRequest } = await github.rest.pulls.get({ owner: context.repo.owner, repo: context.repo.repo, pull_number: context.payload.issue.number }); const headRef = pullRequest.head.ref; const headSha = pullRequest.head.sha; // Get the latest commit message const { data: commit } = await github.rest.git.getCommit({ owner: context.repo.owner, repo: context.repo.repo, commit_sha: headSha }); let commitMessage = commit.message; // Check if the co-author is already in the commit message if (commitMessage.includes(coAuthorLine)) { // Add a "eyes" reaction to indicate the co-author is already there await github.rest.reactions.createForIssueComment({ owner: context.repo.owner, repo: context.repo.repo, comment_id: context.payload.comment.id, content: 'eyes' }); return; } // Add the co-author line to the commit message if (!commitMessage.includes('\n\n')) { commitMessage = `${commitMessage}\n\n${coAuthorLine}`; } else if (!commitMessage.endsWith('\n')) { commitMessage = `${commitMessage}\n${coAuthorLine}`; } else { commitMessage = `${commitMessage}${coAuthorLine}`; } // Create a new commit with the updated message (amend) const { data: latestCommit } = await github.rest.git.getCommit({ owner: context.repo.owner, repo: context.repo.repo, commit_sha: headSha }); const { data: tree } = await github.rest.git.getTree({ owner: context.repo.owner, repo: context.repo.repo, tree_sha: latestCommit.tree.sha }); const newCommit = await github.rest.git.createCommit({ owner: context.repo.owner, repo: context.repo.repo, message: commitMessage, tree: latestCommit.tree.sha, parents: [headSha] }); // Update the reference await github.rest.git.updateRef({ owner: context.repo.owner, repo: context.repo.repo, ref: `heads/${headRef}`, sha: newCommit.data.sha, force: true }); // Add a "rocket" reaction to indicate success await github.rest.reactions.createForIssueComment({ owner: context.repo.owner, repo: context.repo.repo, comment_id: context.payload.comment.id, content: 'rocket' }); } catch (error) { console.error('Error:', error); await github.rest.issues.createComment({ owner: context.repo.owner, repo: context.repo.repo, issue_number: context.payload.issue.number, body: `Error adding co-author: ${error.message}` }); } ================================================ FILE: .github/workflows/build.yml ================================================ name: Build on: push: branches: - main tags: - v* pull_request: branches: - main jobs: build: runs-on: macos-26 env: APPLE_ID: ${{ secrets.APPLE_ID }} CODE_SIGN_IDENTITY: ${{ secrets.CODE_SIGN_IDENTITY }} DEVELOPMENT_TEAM: ${{ secrets.DEVELOPMENT_TEAM }} steps: - name: Install dependencies run: | brew update brew upgrade swiftformat brew install swiftlint - name: Checkout repository uses: actions/checkout@v4 - name: Install the Apple certificate env: BUILD_CERTIFICATE_BASE64: ${{ secrets.BUILD_CERTIFICATE_BASE64 }} KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }} P12_PASSWORD: ${{ secrets.P12_PASSWORD }} if: github.event_name != 'pull_request' && env.BUILD_CERTIFICATE_BASE64 != null run: | # create variables CERTIFICATE_PATH=$RUNNER_TEMP/build_certificate.p12 KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db # import certificate from secrets echo -n "$BUILD_CERTIFICATE_BASE64" | base64 --decode --output $CERTIFICATE_PATH # create temporary keychain security create-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH security set-keychain-settings -lut 21600 $KEYCHAIN_PATH security unlock-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH # import certificate to keychain security import $CERTIFICATE_PATH -P "$P12_PASSWORD" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH security list-keychain -d user -s $KEYCHAIN_PATH # Configure code signing make configure-release - name: Build pull request if: github.event_name == 'pull_request' run: make configure clean lint test XCODEBUILD_ARGS="CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO CODE_SIGN_IDENTITY= DEVELOPMENT_TEAM=" - name: Build if: github.event_name != 'pull_request' run: make - name: Prepublish if: startsWith(github.ref, 'refs/tags/') env: NOTARIZATION_PASSWORD: ${{ secrets.NOTARIZATION_PASSWORD }} run: make prepublish - name: Prepare dSYM archive if: startsWith(github.ref, 'refs/tags/') run: cd build/LinearMouse.xcarchive/dSYMs && zip -r "$GITHUB_WORKSPACE/build/LinearMouse.dSYM.zip" LinearMouse.app.dSYM - name: Upload dmg if: github.event_name != 'pull_request' uses: actions/upload-artifact@v4 with: name: LinearMouse.dmg path: build/LinearMouse.dmg retention-days: 7 - name: Release uses: softprops/action-gh-release@v2 if: startsWith(github.ref, 'refs/tags/') with: draft: true prerelease: ${{ contains(github.ref, '-') }} files: | build/LinearMouse.dmg build/LinearMouse.dSYM.zip fail_on_unmatched_files: true generate_release_notes: true ================================================ FILE: .github/workflows/stale.yml ================================================ name: "Close stale issues and PRs" on: schedule: - cron: "0 0 * * *" issue_comment: types: [created] jobs: stale: runs-on: ubuntu-latest steps: - uses: actions/stale@v7 with: stale-issue-message: "This issue is stale because it has been open 60 days with no activity. Remove stale label or comment or this will be closed in 5 days." stale-pr-message: "This PR is stale because it has been open 60 days with no activity. Remove stale label or comment or this will be closed in 10 days." close-issue-message: "This issue was closed because it has been stalled for 5 days with no activity." close-pr-message: "This PR was closed because it has been stalled for 10 days with no activity." days-before-issue-stale: 60 days-before-pr-stale: 60 days-before-issue-close: 5 days-before-pr-close: 10 stale-issue-label: stale stale-pr-label: stale exempt-issue-labels: help wanted exempt-draft-pr: true exempt-assignees: lujjjh ================================================ FILE: .gitignore ================================================ # Xcode # # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore ## User settings xcuserdata/ ## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) *.xcscmblueprint *.xccheckout ## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) build/ DerivedData/ *.moved-aside *.pbxuser !default.pbxuser *.mode1v3 !default.mode1v3 *.mode2v3 !default.mode2v3 *.perspectivev3 !default.perspectivev3 ## Gcc Patch /*.gcno ## Configuration /Signing.xcconfig /Version.xcconfig /Release.xcconfig # macOS .DS_Store # Node.js node_modules ================================================ FILE: .swift-version ================================================ 5.6 ================================================ FILE: .swiftformat ================================================ --commas inline --disable wrapMultilineStatementBraces --enable wrapConditionalBodies --enable wrapMultilineFunctionChains --header "MIT License\nCopyright (c) 2021-{year} LinearMouse" --decimalgrouping 3,5 --maxwidth 120 --wraparguments before-first --wrapparameters before-first ================================================ FILE: .swiftlint.yml ================================================ only_rules: - accessibility_trait_for_button - array_init - blanket_disable_command - block_based_kvo - class_delegate_protocol - closing_brace - closure_end_indentation - closure_parameter_position - closure_spacing - collection_alignment - colon - comma - comma_inheritance - compiler_protocol_init - computed_accessors_order - conditional_returns_on_newline - contains_over_filter_count - contains_over_filter_is_empty - contains_over_first_not_nil - contains_over_range_nil_comparison - control_statement - custom_rules - deployment_target - direct_return - discarded_notification_center_observer - discouraged_assert - discouraged_direct_init - discouraged_none_name - discouraged_object_literal - discouraged_optional_collection - duplicate_conditions - duplicate_enum_cases - duplicate_imports - duplicated_key_in_dictionary_literal - dynamic_inline - empty_collection_literal - empty_count - empty_enum_arguments - empty_parameters - empty_parentheses_with_trailing_closure - empty_string - empty_xctest_method - enum_case_associated_values_count - explicit_init - fallthrough - fatal_error_message - final_test_case - first_where - flatmap_over_map_reduce - for_where - generic_type_name - ibinspectable_in_extension - identical_operands - implicit_getter - implicit_return - inclusive_language - invalid_swiftlint_command - is_disjoint - joined_default_parameter - last_where - leading_whitespace - legacy_cggeometry_functions - legacy_constant - legacy_constructor - legacy_hashing - legacy_multiple - legacy_nsgeometry_functions - legacy_random - literal_expression_end_indentation - lower_acl_than_parent - mark - modifier_order - multiline_arguments - multiline_arguments_brackets - multiline_function_chains - multiline_literal_brackets - multiline_parameters - multiline_parameters_brackets - nimble_operator - number_separator - no_fallthrough_only - no_space_in_method_call - non_optional_string_data_conversion - non_overridable_class_declaration - notification_center_detachment - ns_number_init_as_function_reference - nsobject_prefer_isequal - operator_usage_whitespace - operator_whitespace - optional_data_string_conversion - overridden_super_call - prefer_key_path - prefer_self_in_static_references - prefer_self_type_over_type_of_self - prefer_zero_over_explicit_init - private_action - private_outlet - private_subject - private_swiftui_state - private_unit_test - prohibited_super_call - protocol_property_accessors_order - reduce_boolean - reduce_into - redundant_discardable_let - redundant_nil_coalescing - redundant_objc_attribute - redundant_optional_initialization - redundant_sendable - redundant_set_access_control - redundant_string_enum_value - redundant_type_annotation - redundant_void_return - required_enum_case - return_arrow_whitespace - return_value_from_void_function - self_binding - self_in_property_initialization - shorthand_operator - shorthand_optional_binding - sorted_first_last - statement_position - static_operator - static_over_final_class - strong_iboutlet - superfluous_disable_command - superfluous_else - switch_case_alignment - switch_case_on_newline - syntactic_sugar - test_case_accessibility - toggle_bool - trailing_closure - trailing_comma - trailing_newline - trailing_semicolon - trailing_whitespace - unavailable_condition - unavailable_function - unneeded_break_in_switch - unneeded_override - unneeded_parentheses_in_closure_argument - unowned_variable_capture - untyped_error_in_catch - unused_closure_parameter - unused_control_flow_label - unused_enumerated - unused_optional_binding - unused_setter_value - valid_ibinspectable - vertical_parameter_alignment - vertical_parameter_alignment_on_call - vertical_whitespace_closing_braces - vertical_whitespace_opening_braces - void_function_in_ternary - void_return - xct_specific_matcher - xctfail_message - yoda_condition analyzer_rules: - capture_variable - typesafe_array_init - unneeded_synthesized_initializer - unused_declaration - unused_import redundant_discardable_let: ignore_swiftui_view_bodies: true for_where: allow_for_as_filter: true number_separator: minimum_length: 5 redundant_type_annotation: consider_default_literal_types_redundant: true unneeded_override: affect_initializers: true ================================================ FILE: .vscode/settings.json ================================================ { "editor.formatOnSave": true } ================================================ FILE: ACCESSIBILITY.md ================================================ # Accessibility permission LinearMouse requires accessibility features to work properly. You need to grant Accessibility permission at first launch. ## Grant Accessibility permission 1. Click “Open Accessibility”. 2. Click the lock to make changes. 3. Toggle “LinearMouse” on. https://user-images.githubusercontent.com/3000535/173173454-b4b8e7ae-5184-4b7a-ba72-f6ce8041f721.mp4 ## Not working? If LinearMouse continues to display accessibility permission request window even after it has been granted, it's likely due to a common macOS bug. To resolve this issue, you can try the following steps: 1. Remove LinearMouse from accessibility permissions using the "-" button. 2. Re-add it. If the previous steps did not resolve the issue, you can try the following: 1. Quit LinearMouse. 2. Open Terminal.app. 3.

Copy and paste the following command:

tccutil reset Accessibility com.lujjjh.LinearMouse
Then press the return key. 4. Launch LinearMouse and try again. ================================================ FILE: CLAUDE.md ================================================ # CLAUDE.md This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. ## Project Overview LinearMouse is a macOS utility app that enhances mouse and trackpad functionality. It's built with Swift and uses SwiftUI for the user interface. The app provides customizable mouse button mappings, scrolling behavior, and pointer settings that can be configured per-device, per-application, or per-display. ## Development Commands ### Build and Test ```bash # Build the project xcodebuild -project LinearMouse.xcodeproj -scheme LinearMouse # Run tests make test # or xcodebuild test -project LinearMouse.xcodeproj -scheme LinearMouse # Full build pipeline (configure, clean, lint, test, package) make all ``` ### Code Quality ```bash # Lint the codebase make lint # or run individually: swiftformat --lint . swiftlint . # Clean build artifacts make clean ``` ### Packaging ```bash # Create DMG package make package # For release (requires signing certificates) make configure-release make prepublish ``` ### Configuration Schema Generation ```bash # Generate JSON schema from TypeScript definitions npm run generate:json-schema ``` ## Architecture Overview ### Core Components 1. **EventTransformer System** (`LinearMouse/EventTransformer/`): - `EventTransformerManager`: Central coordinator that manages event processing - Individual transformers handle specific functionality (scrolling, button mapping, etc.) - Uses LRU cache for performance optimization 2. **Configuration System** (`LinearMouse/Model/Configuration/`): - `Configuration.swift`: Main configuration model with JSON schema validation - `Scheme.swift`: Defines device-specific settings - `DeviceMatcher.swift`: Logic for matching devices to configurations 3. **Device Management** (`LinearMouse/Device/`): - `DeviceManager.swift`: Manages connected input devices - `Device.swift`: Represents individual input devices 4. **Event Processing** (`LinearMouse/EventTap/`): - `GlobalEventTap.swift`: Captures system-wide input events - `EventTap.swift`: Base event handling functionality 5. **User Interface** (`LinearMouse/UI/`): - SwiftUI-based settings interface - Modular components for different settings categories - State management using `@Published` properties ### Custom Modules The project includes several custom Swift packages in the `Modules/` directory: - **KeyKit**: Keyboard input handling and simulation - **PointerKit**: Mouse/trackpad device interaction - **GestureKit**: Gesture recognition (zoom, navigation swipes) - **DockKit**: Dock integration utilities - **ObservationToken**: Observation pattern utilities ### Key Patterns 1. **Event Transformation Pipeline**: Events flow through multiple transformers in sequence 2. **Configuration-Driven Behavior**: All functionality is controlled by JSON configuration 3. **Device Matching**: Settings are applied based on device type, application, or display 4. **State Management**: Uses Combine framework for reactive state updates ## Important Development Notes - The app requires accessibility permissions to function - Event processing happens at the system level using CGEvent - Configuration is stored as JSON and validated against a schema - The project uses Swift Package Manager for dependencies - Localization is handled through Crowdin integration - Code signing is required for distribution (see `Scripts/` directory) ## Testing - Unit tests are in `LinearMouseUnitTests/` - Focus on testing event transformers and configuration parsing - Run tests before submitting changes: `make test` ## Configuration Structure The app uses a JSON configuration format with: - `schemes`: Array of device-specific configurations - Each scheme can target specific devices, applications, or displays - Settings include button mappings, scrolling behavior, and pointer adjustments - Configuration schema is defined in `Documentation/Configuration.d.ts` ================================================ FILE: CODE_OF_CONDUCT.md ================================================ # Contributor Covenant Code of Conduct ## Our Pledge We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, caste, color, religion, or sexual identity and orientation. We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community. ## Our Standards Examples of behavior that contributes to a positive environment for our community include: * Demonstrating empathy and kindness toward other people * Being respectful of differing opinions, viewpoints, and experiences * Giving and gracefully accepting constructive feedback * Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience * Focusing on what is best not just for us as individuals, but for the overall community Examples of unacceptable behavior include: * The use of sexualized language or imagery, and sexual attention or advances of any kind * Trolling, insulting or derogatory comments, and personal or political attacks * Public or private harassment * Publishing others' private information, such as a physical or email address, without their explicit permission * Other conduct which could reasonably be considered inappropriate in a professional setting ## Enforcement Responsibilities Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful. Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate. ## Scope This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. ## Enforcement Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at feedback@linearmouse.org. All complaints will be reviewed and investigated promptly and fairly. All community leaders are obligated to respect the privacy and security of the reporter of any incident. ## Enforcement Guidelines Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct: ### 1. Correction **Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community. **Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested. ### 2. Warning **Community Impact**: A violation through a single incident or series of actions. **Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban. ### 3. Temporary Ban **Community Impact**: A serious violation of community standards, including sustained inappropriate behavior. **Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban. ### 4. Permanent Ban **Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals. **Consequence**: A permanent ban from any sort of public interaction within the community. ## Attribution This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.1, available at [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder][Mozilla CoC]. For answers to common questions about this code of conduct, see the FAQ at [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at [https://www.contributor-covenant.org/translations][translations]. [homepage]: https://www.contributor-covenant.org [v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html [Mozilla CoC]: https://github.com/mozilla/diversity [FAQ]: https://www.contributor-covenant.org/faq [translations]: https://www.contributor-covenant.org/translations ================================================ FILE: CONTRIBUTING-cn.md ================================================ # 贡献指南 感谢你投入时间为 LinearMouse 做出贡献。 阅读我们的[行为准则](CODE_OF_CONDUCT.md),以保持我们的社区平易近人,受到尊重。 ## 构建指南 在 macOS 上构建 LinearMouse 的指南。 ### 设置仓库 ```sh $ git clone https://github.com/linearmouse/linearmouse.git $ cd linearmouse ``` ### 配置代码签名 Apple 要求代码签名。你可以运行以下命令来生成代码签名配置。 ``` $ make configure ``` > 注:如果你希望为 LinearMouse 贡献代码,请不要在 Xcode 中直接修改“Signing & Capabilities”。使用 `make configure` 或者修改 `Signing.xcconfig`。 如果在你的钥匙串中没有代码签名证书,会生成一份使用 ad-hoc 证书签名应用的配置。 使用 ad-hoc 证书,你需要为每次构建[授予辅助功能权限](https://github.com/linearmouse/linearmouse#accessibility-permission)。因此,推荐使用 Apple Development 证书。你可以[在 Xcode 中](https://help.apple.com/xcode/mac/current/#/dev154b28f09) 创建 Apple Development 证书,这是完全免费的。 ### 构建 现在,你可以运行以下命令来构建和打包 LinearMouse 了。 ```sh $ make ``` ================================================ FILE: CONTRIBUTING.md ================================================ # Contributing guide Thank you for investing your time in contributing to LinearMouse! Read our [Code of Conduct](CODE_OF_CONDUCT.md) to keep our community approachable and respectable. ## Build instructions Instructions for building LinearMouse on macOS. ### Dependencies - [Xcode](https://apps.apple.com/app/xcode/id497799835), obviously - [SwiftLint](https://github.com/realm/SwiftLint), used to lint Swift files - [SwiftFormat](https://github.com/nicklockwood/SwiftFormat), used to format Swift files - `npm` & [ts-json-schema-generator](https://www.npmjs.com/package/ts-json-schema-generator), used to generate and document the custom configuration JSON scheme Install tools using brew: ```bash $ brew install npm swiftlint swiftformat ``` Install npm dependencies from the [package.json](./package.json) ```bash $ npm install ``` ### Setup the repository ```sh $ git clone https://github.com/linearmouse/linearmouse.git $ cd linearmouse ``` ### Configure code signing Code signing is required by Apple. You can generate a code signing configuration by running ``` $ make configure ``` > Note: If you want to contribute to LinearMouse, please don't modify the ‘Signing & Capabilities’ configurations directly in Xcode. Instead, use `make configure` or modify the `Signing.xcconfig`. If there are no available code signing certificates in your Keychain, it will generate a configuration that uses ad-hoc certificates to sign the app. By using ad-hoc certificates, you'll have to [grant accessibility permissions](https://github.com/linearmouse/linearmouse#accessibility-permission) for each builds. In that case, using Apple Development certificates is recommended. You can create an Apple Development certificate [in Xcode](https://help.apple.com/xcode/mac/current/#/dev154b28f09), which is totally free. ### Build Now, you can build and package LinearMouse by running ```sh $ make ``` ================================================ FILE: Config.xcconfig ================================================ CURRENT_PROJECT_VERSION = dev MARKETING_VERSION = $(CURRENT_PROJECT_VERSION) PRODUCT_BUNDLE_IDENTIFIER = com.lujjjh.dev.LinearMouse ASSETCATALOG_COMPILER_APPICON_NAME = AppIconDev #include? "Signing.xcconfig" #include? "Version.xcconfig" #include? "Release.xcconfig" ================================================ FILE: Documentation/Configuration.d.ts ================================================ type SingleValueOrArray = T | T[]; /** @asType number */ type Int = number; /** @pattern ^\d+$ */ type IntString = string; /** @pattern ^0x[0-9a-fA-F]+$ */ type HexString = string; type PhysicalButton = Primary | Secondary | Auxiliary | Back | Forward | number; /** * @title Unset * @description A special value that explicitly restores a setting to the system or device default. Currently supported in pointer settings; may be supported more broadly in the future. */ export type Unset = "unset"; /** * @description Primary button, usually the left button. */ type Primary = 0; /** * @description Secondary button, usually the right button. */ type Secondary = 1; /** * @description Auxiliary button, usually the wheel button or the middle button. */ type Auxiliary = 2; /** * @description Forth button, typically the back button. */ type Back = 3; /** * @description Fifth button, typically the forward button. */ type Forward = 4; export type Configuration = { $schema?: string; /** * @title Schemes * @description A scheme is a collection of settings that are activated in specified circumstances. * @examples [{"if":{"device":{"category":"mouse"}},"scrolling":{"reverse":"vertical"}}] */ schemes?: Scheme[]; }; type Scheme = { /** * @title Scheme activation conditions * @description This value can be a single condition or an array. A scheme is activated if at least one of the conditions is met. */ if?: SingleValueOrArray; /** * @title Scrolling settings * @description Customize the scrolling behavior. */ scrolling?: Scheme.Scrolling; /** * @title Pointer settings * @description Customize the pointer acceleration and speed. */ pointer?: Scheme.Pointer; /** * @title Buttons settings * @description Customize the buttons behavior. */ buttons?: Scheme.Buttons; }; declare namespace Scheme { type If = { /** * @title Device * @description Match one or more devices. If not provided, the scheme is activated on all devices. */ device?: If.Device; /** * @title App * @description Match apps by providing the bundle ID. For example, `com.apple.Safari`. */ app?: string; /** * @title Parent app * @description Match apps by providing the bundle ID of the parent process. For example, `org.polymc.PolyMC`. */ parentApp?: string; /** * @title Group app * @description Match apps by providing the bundle ID of the process group. For example, `org.polymc.PolyMC`. */ groupApp?: string; /** * @title Process name * @description Match by the executable file name of the frontmost process (from NSRunningApplication.executableURL.lastPathComponent). Case-sensitive. */ processName?: string; /** * @title Process path * @description Match by the absolute executable path of the frontmost process (from NSRunningApplication.executableURL.path). Case-sensitive. */ processPath?: string; /** * @title Display name * @description Match displays by providing the display name. For example, `DELL P2415Q`. */ display?: string; }; namespace If { type Device = { /** * @title Vendor ID * @description The vendor ID of the devices. * @examples ["0xA123"] */ vendorID?: HexString | Int; /** * @title Product ID * @description The product ID of the devices. * @examples ["0xA123"] */ productID?: HexString | Int; /** * @title Product name * @description The product name of the devices. */ productName?: string; /** * @title Serial number * @description The serial number of the devices. */ serialNumber?: string; /** * @title Category * @description The category of the devices. */ category?: SingleValueOrArray; }; /** * @title Mouse * @description Match mouse devices. */ type Mouse = "mouse"; /** * @title Trackpad * @description Match trackpad devices. */ type Trackpad = "trackpad"; type Category = Mouse | Trackpad; } type Scrolling = { /** * @title Reverse scrolling */ reverse?: Scrolling.Bidirectional; /** * @title Scroll distance * @description The distance after rolling the wheel. */ distance?: Scrolling.Bidirectional; /** * @description The scrolling acceleration. * @default 1 */ acceleration?: Scrolling.Bidirectional; /** * @description The scrolling speed. * @default 0 */ speed?: Scrolling.Bidirectional; /** * @title Smoothed scrolling * @description Use a preset curve or fine-tune response, speed, acceleration, and inertia. */ smoothed?: Scrolling.Bidirectional; /** * @title Modifier keys settings */ modifiers?: Scrolling.Bidirectional; }; namespace Scrolling { type Bidirectional = | T | undefined | { vertical?: T; horizontal?: T; }; /** * @description The scrolling distance will not be modified. */ type Auto = "auto"; type Distance = Auto | Distance.Line | Distance.Pixel; namespace Distance { /** * @description The scrolling distance in lines. */ type Line = Int | IntString; /** * @description The scrolling distance in pixels. * @pattern ^\d[1-9]*(\.\d+)?px */ type Pixel = string; } type Smoothed = { /** * @description Set to `false` to explicitly disable inherited smoothed scrolling for this direction. * @default true */ enabled?: boolean; /** * @description The preset curve to use. * @default "natural" */ preset?: Smoothed.Preset; /** * @description How quickly the scrolling responds to input. */ response?: number; /** * @description The scrolling speed. */ speed?: number; /** * @description The scrolling acceleration. */ acceleration?: number; /** * @description The scrolling inertia. */ inertia?: number; }; namespace Smoothed { type Preset = | "custom" | "linear" | "easeIn" | "easeOut" | "easeInOut" | "easeOutIn" | "quadratic" | "cubic" | "quartic" | "easeOutCubic" | "easeInOutCubic" | "easeOutQuartic" | "easeInOutQuartic" | "quintic" | "sine" | "exponential" | "circular" | "back" | "bounce" | "elastic" | "spring" | "natural" | "smooth" | "snappy" | "gentle"; } type Modifiers = { /** * @description The action when command key is pressed. */ command?: Modifiers.Action; /** * @description The action when shift key is pressed. */ shift?: Modifiers.Action; /** * @description The action when option key is pressed. */ option?: Modifiers.Action; /** * @description The action when control key is pressed. */ control?: Modifiers.Action; }; namespace Modifiers { /** * @deprecated * @description Default action. */ type None = { type: "none" }; /** * @description Default action. */ type Auto = { type: "auto" }; /** * @description Ignore modifier. */ type Ignore = { type: "ignore" }; /** * @description No action. */ type PreventDefault = { type: "preventDefault" }; /** * @description Alter the scrolling orientation from vertical to horizontal or vice versa. */ type AlterOrientation = { type: "alterOrientation"; }; /** * @description Scale the scrolling speed. */ type ChangeSpeed = { type: "changeSpeed"; /** * @description The factor to scale the scrolling speed. */ scale: number; }; /** * @description Zoom in and out using ⌘+ and ⌘-. */ type Zoom = { type: "zoom"; }; /** * @description Zoom in and out using pinch gestures. */ type PinchZoom = { type: "pinchZoom"; }; type Action = | None | Auto | Ignore | PreventDefault | AlterOrientation | ChangeSpeed | Zoom | PinchZoom; } } type Pointer = { /** * @title Pointer acceleration * @description A number to set acceleration, or "unset" to restore system default. If omitted, the previous/merged value applies. * @minimum 0 * @maximum 20 */ acceleration?: number | Unset; /** * @title Pointer speed * @description A number to set speed, or "unset" to restore device default. If omitted, the previous/merged value applies. * @minimal 0 * @maximum 1 */ speed?: number | Unset; /** * @title Disable pointer acceleration * @description If the value is true, the pointer acceleration will be disabled and acceleration and speed will not take effect. * @default false */ disableAcceleration?: boolean; /** * @title Redirects to scroll * @description If the value is true, pointer movements will be redirected to scroll events. * @default false */ redirectsToScroll?: boolean; }; type Buttons = { /** * @title Button mappings * @description Assign actions to buttons. */ mappings?: Buttons.Mapping[]; /** * @title Universal back and forward * @description If the value is true, the back and forward side buttons will be enabled in Safari and some other apps that do not handle these side buttons correctly. If the value is "backOnly" or "forwardOnly", only universal back or universal forward will be enabled. * @default false */ universalBackForward?: Buttons.UniversalBackForward; /** * @title Switch primary and secondary buttons * @description If the value is true, the primary button will be the right button and the secondary button will be the left button. * @default false */ switchPrimaryButtonAndSecondaryButtons?: boolean; /** * @title Debounce button clicks * @description Ignore rapid clicks with a certain time period. */ clickDebouncing?: Buttons.ClickDebouncing; /** * @title Gesture button * @description Press and hold a button while dragging to trigger gestures like switching desktop spaces or opening Mission Control. */ gesture?: Buttons.Gesture; /** * @title Auto scroll * @description Click or hold a mouse button, then move the pointer to continuously scroll like the Windows middle-button autoscroll behavior. */ autoScroll?: Buttons.AutoScroll; }; namespace Buttons { type AutoScroll = { /** * @description Indicates if auto scroll is enabled. * @default false */ enabled?: boolean; /** * @title Activation mode * @description Use \"toggle\" to click once and move until clicking again, \"hold\" to scroll only while the trigger stays pressed, or provide both to support both behaviors. */ mode?: SingleValueOrArray; /** * @description Adjust the auto scroll speed multiplier. * @default 1 */ speed?: number; /** * @description If true, a plain middle click on a pressable element such as a link will keep its native behavior when possible. * @default false */ preserveNativeMiddleClick?: boolean; /** * @title Trigger * @description Choose the mouse button and modifier keys used to activate auto scroll. */ trigger?: AutoScroll.Trigger; }; namespace AutoScroll { /** * @description Click once to enter auto scroll and click again to exit. */ type Toggle = "toggle"; /** * @description Auto scroll is active only while the trigger button remains pressed. */ type Hold = "hold"; type Mode = Toggle | Hold; type Trigger = { /** * @title Button number * @description The button number. See https://developer.apple.com/documentation/coregraphics/cgmousebutton */ button: Mapping.Button; /** * @description Indicates if the command modifier key should be pressed. */ command?: boolean; /** * @description Indicates if the shift modifier key should be pressed. */ shift?: boolean; /** * @description Indicates if the option modifier key should be pressed. */ option?: boolean; /** * @description Indicates if the control modifier key should be pressed. */ control?: boolean; }; } type Mapping = ( | { /** * @title Button number * @description The button number. See https://developer.apple.com/documentation/coregraphics/cgmousebutton */ button: Mapping.Button; /** * @description Indicates if key repeat is enabled. If the value is true, the action will be repeatedly executed when the button is hold according to the key repeat settings in System Settings. */ repeat?: boolean; /** * @description Indicates if keyboard shortcut actions should stay pressed while the button is held. When enabled, LinearMouse sends key down on button press and key up on button release instead of repeating the shortcut. */ hold?: boolean; } | { /** * @title Scroll direction * @description Map scroll events to specific actions. */ scroll: Mapping.ScrollDirection; } ) & { /** * @description Indicates if the command modifier key should be pressed. */ command?: boolean; /** * @description Indicates if the shift modifier key should be pressed. */ shift?: boolean; /** * @description Indicates if the option modifier key should be pressed. */ option?: boolean; /** * @description Indicates if the control modifier key should be pressed. */ control?: boolean; /** * @title Action */ action?: Mapping.Action; }; namespace Mapping { type Button = PhysicalButton | LogitechControlButton; type LogitechControlButton = { /** * @description Logitech control button identifier. */ kind: "logitechControl"; /** * @description Logitech control ID (CID). */ controlID: Int; /** * @description Match a specific Logitech device product ID when needed. */ productID?: HexString | Int; /** * @description Match a specific Logitech device serial number when needed. */ serialNumber?: string; }; type Action = | SimpleAction | Run | MouseWheelScrollUpWithDistance | MouseWheelScrollDownWithDistance | MouseWheelScrollLeftWithDistance | MouseWheelScrollRightWithDistance | KeyPress; type SimpleAction = | Auto | None | MissionControlSpaceLeft | MissionControlSpaceRight | MissionControl | AppExpose | Launchpad | ShowDesktop | LookUpAndDataDetectors | SmartZoom | DisplayBrightnessUp | DisplayBrightnessDown | MediaVolumeUp | MediaVolumeDown | MediaMute | MediaPlayPause | MediaNext | MediaPrevious | MediaFastForward | MediaRewind | KeyboardBrightnessUp | KeyboardBrightnessDown | MouseWheelScrollUp | MouseWheelScrollDown | MouseWheelScrollLeft | MouseWheelScrollRight | MouseButtonLeft | MouseButtonMiddle | MouseButtonRight | MouseButtonBack | MouseButtonForward; /** * @description Do not modify the button behavior. */ type Auto = "auto"; /** * @description Prevent the button events. */ type None = "none"; /** * @description Mission Control. */ type MissionControl = "missionControl"; /** * @description Mission Control: Move left a space. */ type MissionControlSpaceLeft = "missionControl.spaceLeft"; /** * @description Mission Control: Move right a space. */ type MissionControlSpaceRight = "missionControl.spaceRight"; /** * @description Application windows. */ type AppExpose = "appExpose"; /** * @description Launchpad. */ type Launchpad = "launchpad"; /** * @description Show desktop. */ type ShowDesktop = "showDesktop"; /** * @description Look up & data detectors. */ type LookUpAndDataDetectors = "lookUpAndDataDetectors"; /** * @description Smart zoom. */ type SmartZoom = "smartZoom"; /** * @description Display: Brightness up. */ type DisplayBrightnessUp = "display.brightnessUp"; /** * @description Display: Brightness down. */ type DisplayBrightnessDown = "display.brightnessDown"; /** * @description Media: Volume up. */ type MediaVolumeUp = "media.volumeUp"; /** * @description Media: Volume down. */ type MediaVolumeDown = "media.volumeDown"; /** * @description Media: Toggle mute. */ type MediaMute = "media.mute"; /** * @description Media: Play / pause. */ type MediaPlayPause = "media.playPause"; /** * @description Media: Next. */ type MediaNext = "media.next"; /** * @description Media: Previous. */ type MediaPrevious = "media.previous"; /** * @description Media: Fast forward. */ type MediaFastForward = "media.fastForward"; /** * @description Media: Rewind. */ type MediaRewind = "media.rewind"; /** * @description Keyboard: Brightness up. */ type KeyboardBrightnessUp = "keyboard.brightnessUp"; /** * @description Keyboard: Brightness down. */ type KeyboardBrightnessDown = "keyboard.brightnessDown"; /** * @description Mouse: Wheel: Scroll up. */ type MouseWheelScrollUp = "mouse.wheel.scrollUp"; /** * @description Mouse: Wheel: Scroll down. */ type MouseWheelScrollDown = "mouse.wheel.scrollDown"; /** * @description Mouse: Wheel: Scroll left. */ type MouseWheelScrollLeft = "mouse.wheel.scrollLeft"; /** * @description Mouse: Wheel: Scroll right. */ type MouseWheelScrollRight = "mouse.wheel.scrollRight"; /** * @description Mouse: Button: Act as left button. */ type MouseButtonLeft = "mouse.button.left"; /** * @description Mouse: Button: Act as middle button. */ type MouseButtonMiddle = "mouse.button.middle"; /** * @description Mouse: Button: Act as right button. */ type MouseButtonRight = "mouse.button.right"; /** * @description Mouse: Button: Act as back button. */ type MouseButtonBack = "mouse.button.back"; /** * @description Mouse: Button: Act as forward button. */ type MouseButtonForward = "mouse.button.forward"; type Run = { /** * @description Run a specific command. For example, `"open -a 'Mission Control'"`. */ run: string; }; type MouseWheelScrollUpWithDistance = { /** * @description Mouse: Wheel: Scroll up a certain distance. */ "mouse.wheel.scrollUp": Scheme.Scrolling.Distance; }; type MouseWheelScrollDownWithDistance = { /** * @description Mouse: Wheel: Scroll down a certain distance. */ "mouse.wheel.scrollDown": Scheme.Scrolling.Distance; }; type MouseWheelScrollLeftWithDistance = { /** * @description Mouse: Wheel: Scroll left a certain distance. */ "mouse.wheel.scrollLeft": Scheme.Scrolling.Distance; }; type MouseWheelScrollRightWithDistance = { /** * @description Mouse: Wheel: Scroll right a certain distance. */ "mouse.wheel.scrollRight": Scheme.Scrolling.Distance; }; type KeyPress = { /** * @description Keyboard: Keyboard shortcut. */ keyPress: Array; }; /** * @description Scroll direction. */ type ScrollDirection = "up" | "down" | "left" | "right"; type Key = | "enter" | "tab" | "space" | "delete" | "escape" | "command" | "shift" | "capsLock" | "option" | "control" | "commandRight" | "shiftRight" | "optionRight" | "controlRight" | "arrowLeft" | "arrowRight" | "arrowDown" | "arrowUp" | "home" | "pageUp" | "backspace" | "end" | "pageDown" | "f1" | "f2" | "f3" | "f4" | "f5" | "f6" | "f7" | "f8" | "f9" | "f10" | "f11" | "f12" | "a" | "b" | "c" | "d" | "e" | "f" | "g" | "h" | "i" | "j" | "k" | "l" | "m" | "n" | "o" | "p" | "q" | "r" | "s" | "t" | "u" | "v" | "w" | "x" | "y" | "z" | "0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" | "=" | "-" | ";" | "'" | "," | "." | "/" | "\\" | "`" | "[" | "]" | "numpadPlus" | "numpadMinus" | "numpadMultiply" | "numpadDivide" | "numpadEnter" | "numpadEquals" | "numpadDecimal" | "numpadClear" | "numpad0" | "numpad1" | "numpad2" | "numpad3" | "numpad4" | "numpad5" | "numpad6" | "numpad7" | "numpad8" | "numpad9"; } type UniversalBackForward = | boolean | UniversalBackForward.BackOnly | UniversalBackForward.ForwardOnly; namespace UniversalBackForward { /** * @description Enable universal back only. */ type BackOnly = "backOnly"; /** * @description Enable universal forward only. */ type ForwardOnly = "forwardOnly"; } type ClickDebouncing = { /** * @description The time period in which rapid clicks are ignored. */ timeout?: Int; /** * @description If the value is true, the timer will be reset on mouse up. */ resetTimerOnMouseUp?: boolean; /** * @description Buttons to debounce. */ buttons?: PhysicalButton[]; }; type Gesture = { /** * @title Enable gesture button * @description If the value is true, the gesture button feature is enabled. * @default false */ enabled?: boolean; /** * @title Trigger * @description Choose the mouse button and modifier keys used to activate gestures. */ trigger?: Gesture.Trigger; /** * @title Button * @description Deprecated. Use trigger instead. * @deprecated */ button?: PhysicalButton; /** * @title Threshold * @description The distance in pixels that must be dragged before triggering a gesture. * @default 50 * @minimum 20 * @maximum 200 */ threshold?: Int; /** * @title Dead zone * @description The tolerance in pixels for the non-dominant axis to prevent accidental gestures. * @default 40 * @minimum 0 * @maximum 100 */ deadZone?: Int; /** * @title Cooldown * @description The cooldown period in milliseconds between gestures to prevent double-triggering. * @default 500 * @minimum 0 * @maximum 2000 */ cooldownMs?: Int; /** * @title Gesture actions * @description Actions to trigger for each gesture direction. */ actions?: Gesture.Actions; }; namespace Gesture { type Trigger = { /** * @title Button number * @description The button number. See https://developer.apple.com/documentation/coregraphics/cgmousebutton */ button: Mapping.Button; /** * @description Indicates if the command modifier key should be pressed. */ command?: boolean; /** * @description Indicates if the shift modifier key should be pressed. */ shift?: boolean; /** * @description Indicates if the option modifier key should be pressed. */ option?: boolean; /** * @description Indicates if the control modifier key should be pressed. */ control?: boolean; }; type Actions = { /** * @title Swipe left action * @description Action to trigger when dragging left. * @default "missionControl.spaceLeft" */ left?: GestureAction; /** * @title Swipe right action * @description Action to trigger when dragging right. * @default "missionControl.spaceRight" */ right?: GestureAction; /** * @title Swipe up action * @description Action to trigger when dragging up. * @default "missionControl" */ up?: GestureAction; /** * @title Swipe down action * @description Action to trigger when dragging down. * @default "appExpose" */ down?: GestureAction; }; type GestureAction = | "none" | "missionControl.spaceLeft" | "missionControl.spaceRight" | "missionControl" | "appExpose" | "showDesktop" | "launchpad"; } } } ================================================ FILE: Documentation/Configuration.json ================================================ { "$ref": "#/definitions/Configuration", "$schema": "http://json-schema.org/draft-07/schema#", "definitions": { "Auxiliary": { "const": 2, "description": "Auxiliary button, usually the wheel button or the middle button.", "type": "number" }, "Back": { "const": 3, "description": "Forth button, typically the back button.", "type": "number" }, "Configuration": { "additionalProperties": false, "properties": { "$schema": { "type": "string" }, "schemes": { "description": "A scheme is a collection of settings that are activated in specified circumstances.", "examples": [ { "if": { "device": { "category": "mouse" } }, "scrolling": { "reverse": "vertical" } } ], "items": { "$ref": "#/definitions/Scheme" }, "title": "Schemes", "type": "array" } }, "type": "object" }, "Forward": { "const": 4, "description": "Fifth button, typically the forward button.", "type": "number" }, "HexString": { "pattern": "^0x[0-9a-fA-F]+$", "type": "string" }, "Int": { "type": "number" }, "IntString": { "pattern": "^\\d+$", "type": "string" }, "PhysicalButton": { "anyOf": [ { "$ref": "#/definitions/Primary" }, { "$ref": "#/definitions/Secondary" }, { "$ref": "#/definitions/Auxiliary" }, { "$ref": "#/definitions/Back" }, { "$ref": "#/definitions/Forward" }, { "type": "number" } ] }, "Primary": { "const": 0, "description": "Primary button, usually the left button.", "type": "number" }, "Scheme": { "additionalProperties": false, "properties": { "buttons": { "$ref": "#/definitions/Scheme.Buttons", "description": "Customize the buttons behavior.", "title": "Buttons settings" }, "if": { "$ref": "#/definitions/SingleValueOrArray%3CScheme.If%3E", "description": "This value can be a single condition or an array. A scheme is activated if at least one of the conditions is met.", "title": "Scheme activation conditions" }, "pointer": { "$ref": "#/definitions/Scheme.Pointer", "description": "Customize the pointer acceleration and speed.", "title": "Pointer settings" }, "scrolling": { "$ref": "#/definitions/Scheme.Scrolling", "description": "Customize the scrolling behavior.", "title": "Scrolling settings" } }, "type": "object" }, "Scheme.Buttons": { "additionalProperties": false, "properties": { "autoScroll": { "$ref": "#/definitions/Scheme.Buttons.AutoScroll", "description": "Click or hold a mouse button, then move the pointer to continuously scroll like the Windows middle-button autoscroll behavior.", "title": "Auto scroll" }, "clickDebouncing": { "$ref": "#/definitions/Scheme.Buttons.ClickDebouncing", "description": "Ignore rapid clicks with a certain time period.", "title": "Debounce button clicks" }, "gesture": { "$ref": "#/definitions/Scheme.Buttons.Gesture", "description": "Press and hold a button while dragging to trigger gestures like switching desktop spaces or opening Mission Control.", "title": "Gesture button" }, "mappings": { "description": "Assign actions to buttons.", "items": { "$ref": "#/definitions/Scheme.Buttons.Mapping" }, "title": "Button mappings", "type": "array" }, "switchPrimaryButtonAndSecondaryButtons": { "default": false, "description": "If the value is true, the primary button will be the right button and the secondary button will be the left button.", "title": "Switch primary and secondary buttons", "type": "boolean" }, "universalBackForward": { "$ref": "#/definitions/Scheme.Buttons.UniversalBackForward", "default": false, "description": "If the value is true, the back and forward side buttons will be enabled in Safari and some other apps that do not handle these side buttons correctly. If the value is \"backOnly\" or \"forwardOnly\", only universal back or universal forward will be enabled.", "title": "Universal back and forward" } }, "type": "object" }, "Scheme.Buttons.AutoScroll": { "additionalProperties": false, "properties": { "enabled": { "default": false, "description": "Indicates if auto scroll is enabled.", "type": "boolean" }, "mode": { "$ref": "#/definitions/SingleValueOrArray%3CScheme.Buttons.AutoScroll.Mode%3E", "description": "Use \\\"toggle\\\" to click once and move until clicking again, \\\"hold\\\" to scroll only while the trigger stays pressed, or provide both to support both behaviors.", "title": "Activation mode" }, "preserveNativeMiddleClick": { "default": false, "description": "If true, a plain middle click on a pressable element such as a link will keep its native behavior when possible.", "type": "boolean" }, "speed": { "default": 1, "description": "Adjust the auto scroll speed multiplier.", "type": "number" }, "trigger": { "$ref": "#/definitions/Scheme.Buttons.AutoScroll.Trigger", "description": "Choose the mouse button and modifier keys used to activate auto scroll.", "title": "Trigger" } }, "type": "object" }, "Scheme.Buttons.AutoScroll.Hold": { "const": "hold", "description": "Auto scroll is active only while the trigger button remains pressed.", "type": "string" }, "Scheme.Buttons.AutoScroll.Mode": { "anyOf": [ { "$ref": "#/definitions/Scheme.Buttons.AutoScroll.Toggle" }, { "$ref": "#/definitions/Scheme.Buttons.AutoScroll.Hold" } ] }, "Scheme.Buttons.AutoScroll.Toggle": { "const": "toggle", "description": "Click once to enter auto scroll and click again to exit.", "type": "string" }, "Scheme.Buttons.AutoScroll.Trigger": { "additionalProperties": false, "properties": { "button": { "$ref": "#/definitions/Scheme.Buttons.Mapping.Button", "description": "The button number. See https://developer.apple.com/documentation/coregraphics/cgmousebutton", "title": "Button number" }, "command": { "description": "Indicates if the command modifier key should be pressed.", "type": "boolean" }, "control": { "description": "Indicates if the control modifier key should be pressed.", "type": "boolean" }, "option": { "description": "Indicates if the option modifier key should be pressed.", "type": "boolean" }, "shift": { "description": "Indicates if the shift modifier key should be pressed.", "type": "boolean" } }, "required": [ "button" ], "type": "object" }, "Scheme.Buttons.ClickDebouncing": { "additionalProperties": false, "properties": { "buttons": { "description": "Buttons to debounce.", "items": { "$ref": "#/definitions/PhysicalButton" }, "type": "array" }, "resetTimerOnMouseUp": { "description": "If the value is true, the timer will be reset on mouse up.", "type": "boolean" }, "timeout": { "$ref": "#/definitions/Int", "description": "The time period in which rapid clicks are ignored." } }, "type": "object" }, "Scheme.Buttons.Gesture": { "additionalProperties": false, "properties": { "actions": { "$ref": "#/definitions/Scheme.Buttons.Gesture.Actions", "description": "Actions to trigger for each gesture direction.", "title": "Gesture actions" }, "button": { "$ref": "#/definitions/PhysicalButton", "deprecated": true, "description": "Deprecated. Use trigger instead.", "title": "Button" }, "cooldownMs": { "$ref": "#/definitions/Int", "default": 500, "description": "The cooldown period in milliseconds between gestures to prevent double-triggering.", "maximum": 2000, "minimum": 0, "title": "Cooldown" }, "deadZone": { "$ref": "#/definitions/Int", "default": 40, "description": "The tolerance in pixels for the non-dominant axis to prevent accidental gestures.", "maximum": 100, "minimum": 0, "title": "Dead zone" }, "enabled": { "default": false, "description": "If the value is true, the gesture button feature is enabled.", "title": "Enable gesture button", "type": "boolean" }, "threshold": { "$ref": "#/definitions/Int", "default": 50, "description": "The distance in pixels that must be dragged before triggering a gesture.", "maximum": 200, "minimum": 20, "title": "Threshold" }, "trigger": { "$ref": "#/definitions/Scheme.Buttons.Gesture.Trigger", "description": "Choose the mouse button and modifier keys used to activate gestures.", "title": "Trigger" } }, "type": "object" }, "Scheme.Buttons.Gesture.Actions": { "additionalProperties": false, "properties": { "down": { "$ref": "#/definitions/Scheme.Buttons.Gesture.GestureAction", "default": "appExpose", "description": "Action to trigger when dragging down.", "title": "Swipe down action" }, "left": { "$ref": "#/definitions/Scheme.Buttons.Gesture.GestureAction", "default": "missionControl.spaceLeft", "description": "Action to trigger when dragging left.", "title": "Swipe left action" }, "right": { "$ref": "#/definitions/Scheme.Buttons.Gesture.GestureAction", "default": "missionControl.spaceRight", "description": "Action to trigger when dragging right.", "title": "Swipe right action" }, "up": { "$ref": "#/definitions/Scheme.Buttons.Gesture.GestureAction", "default": "missionControl", "description": "Action to trigger when dragging up.", "title": "Swipe up action" } }, "type": "object" }, "Scheme.Buttons.Gesture.GestureAction": { "enum": [ "none", "missionControl.spaceLeft", "missionControl.spaceRight", "missionControl", "appExpose", "showDesktop", "launchpad" ], "type": "string" }, "Scheme.Buttons.Gesture.Trigger": { "additionalProperties": false, "properties": { "button": { "$ref": "#/definitions/Scheme.Buttons.Mapping.Button", "description": "The button number. See https://developer.apple.com/documentation/coregraphics/cgmousebutton", "title": "Button number" }, "command": { "description": "Indicates if the command modifier key should be pressed.", "type": "boolean" }, "control": { "description": "Indicates if the control modifier key should be pressed.", "type": "boolean" }, "option": { "description": "Indicates if the option modifier key should be pressed.", "type": "boolean" }, "shift": { "description": "Indicates if the shift modifier key should be pressed.", "type": "boolean" } }, "required": [ "button" ], "type": "object" }, "Scheme.Buttons.Mapping": { "anyOf": [ { "additionalProperties": false, "properties": { "action": { "$ref": "#/definitions/Scheme.Buttons.Mapping.Action", "title": "Action" }, "button": { "$ref": "#/definitions/Scheme.Buttons.Mapping.Button", "description": "The button number. See https://developer.apple.com/documentation/coregraphics/cgmousebutton", "title": "Button number" }, "command": { "description": "Indicates if the command modifier key should be pressed.", "type": "boolean" }, "control": { "description": "Indicates if the control modifier key should be pressed.", "type": "boolean" }, "hold": { "description": "Indicates if keyboard shortcut actions should stay pressed while the button is held. When enabled, LinearMouse sends key down on button press and key up on button release instead of repeating the shortcut.", "type": "boolean" }, "option": { "description": "Indicates if the option modifier key should be pressed.", "type": "boolean" }, "repeat": { "description": "Indicates if key repeat is enabled. If the value is true, the action will be repeatedly executed when the button is hold according to the key repeat settings in System Settings.", "type": "boolean" }, "shift": { "description": "Indicates if the shift modifier key should be pressed.", "type": "boolean" } }, "required": [ "button" ], "type": "object" }, { "additionalProperties": false, "properties": { "action": { "$ref": "#/definitions/Scheme.Buttons.Mapping.Action", "title": "Action" }, "command": { "description": "Indicates if the command modifier key should be pressed.", "type": "boolean" }, "control": { "description": "Indicates if the control modifier key should be pressed.", "type": "boolean" }, "option": { "description": "Indicates if the option modifier key should be pressed.", "type": "boolean" }, "scroll": { "$ref": "#/definitions/Scheme.Buttons.Mapping.ScrollDirection", "description": "Map scroll events to specific actions.", "title": "Scroll direction" }, "shift": { "description": "Indicates if the shift modifier key should be pressed.", "type": "boolean" } }, "required": [ "scroll" ], "type": "object" } ] }, "Scheme.Buttons.Mapping.Action": { "anyOf": [ { "$ref": "#/definitions/Scheme.Buttons.Mapping.SimpleAction" }, { "$ref": "#/definitions/Scheme.Buttons.Mapping.Run" }, { "$ref": "#/definitions/Scheme.Buttons.Mapping.MouseWheelScrollUpWithDistance" }, { "$ref": "#/definitions/Scheme.Buttons.Mapping.MouseWheelScrollDownWithDistance" }, { "$ref": "#/definitions/Scheme.Buttons.Mapping.MouseWheelScrollLeftWithDistance" }, { "$ref": "#/definitions/Scheme.Buttons.Mapping.MouseWheelScrollRightWithDistance" }, { "$ref": "#/definitions/Scheme.Buttons.Mapping.KeyPress" } ] }, "Scheme.Buttons.Mapping.AppExpose": { "const": "appExpose", "description": "Application windows.", "type": "string" }, "Scheme.Buttons.Mapping.Auto": { "const": "auto", "description": "Do not modify the button behavior.", "type": "string" }, "Scheme.Buttons.Mapping.Button": { "anyOf": [ { "$ref": "#/definitions/PhysicalButton" }, { "$ref": "#/definitions/Scheme.Buttons.Mapping.LogitechControlButton" } ] }, "Scheme.Buttons.Mapping.DisplayBrightnessDown": { "const": "display.brightnessDown", "description": "Display: Brightness down.", "type": "string" }, "Scheme.Buttons.Mapping.DisplayBrightnessUp": { "const": "display.brightnessUp", "description": "Display: Brightness up.", "type": "string" }, "Scheme.Buttons.Mapping.Key": { "enum": [ "enter", "tab", "space", "delete", "escape", "command", "shift", "capsLock", "option", "control", "commandRight", "shiftRight", "optionRight", "controlRight", "arrowLeft", "arrowRight", "arrowDown", "arrowUp", "home", "pageUp", "backspace", "end", "pageDown", "f1", "f2", "f3", "f4", "f5", "f6", "f7", "f8", "f9", "f10", "f11", "f12", "a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l", "m", "n", "o", "p", "q", "r", "s", "t", "u", "v", "w", "x", "y", "z", "0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "=", "-", ";", "'", ",", ".", "/", "\\", "`", "[", "]", "numpadPlus", "numpadMinus", "numpadMultiply", "numpadDivide", "numpadEnter", "numpadEquals", "numpadDecimal", "numpadClear", "numpad0", "numpad1", "numpad2", "numpad3", "numpad4", "numpad5", "numpad6", "numpad7", "numpad8", "numpad9" ], "type": "string" }, "Scheme.Buttons.Mapping.KeyPress": { "additionalProperties": false, "properties": { "keyPress": { "description": "Keyboard: Keyboard shortcut.", "items": { "$ref": "#/definitions/Scheme.Buttons.Mapping.Key" }, "type": "array" } }, "required": [ "keyPress" ], "type": "object" }, "Scheme.Buttons.Mapping.KeyboardBrightnessDown": { "const": "keyboard.brightnessDown", "description": "Keyboard: Brightness down.", "type": "string" }, "Scheme.Buttons.Mapping.KeyboardBrightnessUp": { "const": "keyboard.brightnessUp", "description": "Keyboard: Brightness up.", "type": "string" }, "Scheme.Buttons.Mapping.Launchpad": { "const": "launchpad", "description": "Launchpad.", "type": "string" }, "Scheme.Buttons.Mapping.LogitechControlButton": { "additionalProperties": false, "properties": { "controlID": { "$ref": "#/definitions/Int", "description": "Logitech control ID (CID)." }, "kind": { "const": "logitechControl", "description": "Logitech control button identifier.", "type": "string" }, "productID": { "anyOf": [ { "$ref": "#/definitions/HexString" }, { "$ref": "#/definitions/Int" } ], "description": "Match a specific Logitech device product ID when needed." }, "serialNumber": { "description": "Match a specific Logitech device serial number when needed.", "type": "string" } }, "required": [ "kind", "controlID" ], "type": "object" }, "Scheme.Buttons.Mapping.LookUpAndDataDetectors": { "const": "lookUpAndDataDetectors", "description": "Look up & data detectors.", "type": "string" }, "Scheme.Buttons.Mapping.MediaFastForward": { "const": "media.fastForward", "description": "Media: Fast forward.", "type": "string" }, "Scheme.Buttons.Mapping.MediaMute": { "const": "media.mute", "description": "Media: Toggle mute.", "type": "string" }, "Scheme.Buttons.Mapping.MediaNext": { "const": "media.next", "description": "Media: Next.", "type": "string" }, "Scheme.Buttons.Mapping.MediaPlayPause": { "const": "media.playPause", "description": "Media: Play / pause.", "type": "string" }, "Scheme.Buttons.Mapping.MediaPrevious": { "const": "media.previous", "description": "Media: Previous.", "type": "string" }, "Scheme.Buttons.Mapping.MediaRewind": { "const": "media.rewind", "description": "Media: Rewind.", "type": "string" }, "Scheme.Buttons.Mapping.MediaVolumeDown": { "const": "media.volumeDown", "description": "Media: Volume down.", "type": "string" }, "Scheme.Buttons.Mapping.MediaVolumeUp": { "const": "media.volumeUp", "description": "Media: Volume up.", "type": "string" }, "Scheme.Buttons.Mapping.MissionControl": { "const": "missionControl", "description": "Mission Control.", "type": "string" }, "Scheme.Buttons.Mapping.MissionControlSpaceLeft": { "const": "missionControl.spaceLeft", "description": "Mission Control: Move left a space.", "type": "string" }, "Scheme.Buttons.Mapping.MissionControlSpaceRight": { "const": "missionControl.spaceRight", "description": "Mission Control: Move right a space.", "type": "string" }, "Scheme.Buttons.Mapping.MouseButtonBack": { "const": "mouse.button.back", "description": "Mouse: Button: Act as back button.", "type": "string" }, "Scheme.Buttons.Mapping.MouseButtonForward": { "const": "mouse.button.forward", "description": "Mouse: Button: Act as forward button.", "type": "string" }, "Scheme.Buttons.Mapping.MouseButtonLeft": { "const": "mouse.button.left", "description": "Mouse: Button: Act as left button.", "type": "string" }, "Scheme.Buttons.Mapping.MouseButtonMiddle": { "const": "mouse.button.middle", "description": "Mouse: Button: Act as middle button.", "type": "string" }, "Scheme.Buttons.Mapping.MouseButtonRight": { "const": "mouse.button.right", "description": "Mouse: Button: Act as right button.", "type": "string" }, "Scheme.Buttons.Mapping.MouseWheelScrollDown": { "const": "mouse.wheel.scrollDown", "description": "Mouse: Wheel: Scroll down.", "type": "string" }, "Scheme.Buttons.Mapping.MouseWheelScrollDownWithDistance": { "additionalProperties": false, "properties": { "mouse.wheel.scrollDown": { "$ref": "#/definitions/Scheme.Scrolling.Distance", "description": "Mouse: Wheel: Scroll down a certain distance." } }, "required": [ "mouse.wheel.scrollDown" ], "type": "object" }, "Scheme.Buttons.Mapping.MouseWheelScrollLeft": { "const": "mouse.wheel.scrollLeft", "description": "Mouse: Wheel: Scroll left.", "type": "string" }, "Scheme.Buttons.Mapping.MouseWheelScrollLeftWithDistance": { "additionalProperties": false, "properties": { "mouse.wheel.scrollLeft": { "$ref": "#/definitions/Scheme.Scrolling.Distance", "description": "Mouse: Wheel: Scroll left a certain distance." } }, "required": [ "mouse.wheel.scrollLeft" ], "type": "object" }, "Scheme.Buttons.Mapping.MouseWheelScrollRight": { "const": "mouse.wheel.scrollRight", "description": "Mouse: Wheel: Scroll right.", "type": "string" }, "Scheme.Buttons.Mapping.MouseWheelScrollRightWithDistance": { "additionalProperties": false, "properties": { "mouse.wheel.scrollRight": { "$ref": "#/definitions/Scheme.Scrolling.Distance", "description": "Mouse: Wheel: Scroll right a certain distance." } }, "required": [ "mouse.wheel.scrollRight" ], "type": "object" }, "Scheme.Buttons.Mapping.MouseWheelScrollUp": { "const": "mouse.wheel.scrollUp", "description": "Mouse: Wheel: Scroll up.", "type": "string" }, "Scheme.Buttons.Mapping.MouseWheelScrollUpWithDistance": { "additionalProperties": false, "properties": { "mouse.wheel.scrollUp": { "$ref": "#/definitions/Scheme.Scrolling.Distance", "description": "Mouse: Wheel: Scroll up a certain distance." } }, "required": [ "mouse.wheel.scrollUp" ], "type": "object" }, "Scheme.Buttons.Mapping.None": { "const": "none", "description": "Prevent the button events.", "type": "string" }, "Scheme.Buttons.Mapping.Run": { "additionalProperties": false, "properties": { "run": { "description": "Run a specific command. For example, `\"open -a 'Mission Control'\"`.", "type": "string" } }, "required": [ "run" ], "type": "object" }, "Scheme.Buttons.Mapping.ScrollDirection": { "description": "Scroll direction.", "enum": [ "up", "down", "left", "right" ], "type": "string" }, "Scheme.Buttons.Mapping.ShowDesktop": { "const": "showDesktop", "description": "Show desktop.", "type": "string" }, "Scheme.Buttons.Mapping.SimpleAction": { "anyOf": [ { "$ref": "#/definitions/Scheme.Buttons.Mapping.Auto" }, { "$ref": "#/definitions/Scheme.Buttons.Mapping.None" }, { "$ref": "#/definitions/Scheme.Buttons.Mapping.MissionControlSpaceLeft" }, { "$ref": "#/definitions/Scheme.Buttons.Mapping.MissionControlSpaceRight" }, { "$ref": "#/definitions/Scheme.Buttons.Mapping.MissionControl" }, { "$ref": "#/definitions/Scheme.Buttons.Mapping.AppExpose" }, { "$ref": "#/definitions/Scheme.Buttons.Mapping.Launchpad" }, { "$ref": "#/definitions/Scheme.Buttons.Mapping.ShowDesktop" }, { "$ref": "#/definitions/Scheme.Buttons.Mapping.LookUpAndDataDetectors" }, { "$ref": "#/definitions/Scheme.Buttons.Mapping.SmartZoom" }, { "$ref": "#/definitions/Scheme.Buttons.Mapping.DisplayBrightnessUp" }, { "$ref": "#/definitions/Scheme.Buttons.Mapping.DisplayBrightnessDown" }, { "$ref": "#/definitions/Scheme.Buttons.Mapping.MediaVolumeUp" }, { "$ref": "#/definitions/Scheme.Buttons.Mapping.MediaVolumeDown" }, { "$ref": "#/definitions/Scheme.Buttons.Mapping.MediaMute" }, { "$ref": "#/definitions/Scheme.Buttons.Mapping.MediaPlayPause" }, { "$ref": "#/definitions/Scheme.Buttons.Mapping.MediaNext" }, { "$ref": "#/definitions/Scheme.Buttons.Mapping.MediaPrevious" }, { "$ref": "#/definitions/Scheme.Buttons.Mapping.MediaFastForward" }, { "$ref": "#/definitions/Scheme.Buttons.Mapping.MediaRewind" }, { "$ref": "#/definitions/Scheme.Buttons.Mapping.KeyboardBrightnessUp" }, { "$ref": "#/definitions/Scheme.Buttons.Mapping.KeyboardBrightnessDown" }, { "$ref": "#/definitions/Scheme.Buttons.Mapping.MouseWheelScrollUp" }, { "$ref": "#/definitions/Scheme.Buttons.Mapping.MouseWheelScrollDown" }, { "$ref": "#/definitions/Scheme.Buttons.Mapping.MouseWheelScrollLeft" }, { "$ref": "#/definitions/Scheme.Buttons.Mapping.MouseWheelScrollRight" }, { "$ref": "#/definitions/Scheme.Buttons.Mapping.MouseButtonLeft" }, { "$ref": "#/definitions/Scheme.Buttons.Mapping.MouseButtonMiddle" }, { "$ref": "#/definitions/Scheme.Buttons.Mapping.MouseButtonRight" }, { "$ref": "#/definitions/Scheme.Buttons.Mapping.MouseButtonBack" }, { "$ref": "#/definitions/Scheme.Buttons.Mapping.MouseButtonForward" } ] }, "Scheme.Buttons.Mapping.SmartZoom": { "const": "smartZoom", "description": "Smart zoom.", "type": "string" }, "Scheme.Buttons.UniversalBackForward": { "anyOf": [ { "type": "boolean" }, { "$ref": "#/definitions/Scheme.Buttons.UniversalBackForward.BackOnly" }, { "$ref": "#/definitions/Scheme.Buttons.UniversalBackForward.ForwardOnly" } ] }, "Scheme.Buttons.UniversalBackForward.BackOnly": { "const": "backOnly", "description": "Enable universal back only.", "type": "string" }, "Scheme.Buttons.UniversalBackForward.ForwardOnly": { "const": "forwardOnly", "description": "Enable universal forward only.", "type": "string" }, "Scheme.If": { "additionalProperties": false, "properties": { "app": { "description": "Match apps by providing the bundle ID. For example, `com.apple.Safari`.", "title": "App", "type": "string" }, "device": { "$ref": "#/definitions/Scheme.If.Device", "description": "Match one or more devices. If not provided, the scheme is activated on all devices.", "title": "Device" }, "display": { "description": "Match displays by providing the display name. For example, `DELL P2415Q`.", "title": "Display name", "type": "string" }, "groupApp": { "description": "Match apps by providing the bundle ID of the process group. For example, `org.polymc.PolyMC`.", "title": "Group app", "type": "string" }, "parentApp": { "description": "Match apps by providing the bundle ID of the parent process. For example, `org.polymc.PolyMC`.", "title": "Parent app", "type": "string" }, "processName": { "description": "Match by the executable file name of the frontmost process (from NSRunningApplication.executableURL.lastPathComponent). Case-sensitive.", "title": "Process name", "type": "string" }, "processPath": { "description": "Match by the absolute executable path of the frontmost process (from NSRunningApplication.executableURL.path). Case-sensitive.", "title": "Process path", "type": "string" } }, "type": "object" }, "Scheme.If.Category": { "anyOf": [ { "$ref": "#/definitions/Scheme.If.Mouse" }, { "$ref": "#/definitions/Scheme.If.Trackpad" } ] }, "Scheme.If.Device": { "additionalProperties": false, "properties": { "category": { "$ref": "#/definitions/SingleValueOrArray%3CScheme.If.Category%3E", "description": "The category of the devices.", "title": "Category" }, "productID": { "anyOf": [ { "$ref": "#/definitions/HexString" }, { "$ref": "#/definitions/Int" } ], "description": "The product ID of the devices.", "examples": [ "0xA123" ], "title": "Product ID" }, "productName": { "description": "The product name of the devices.", "title": "Product name", "type": "string" }, "serialNumber": { "description": "The serial number of the devices.", "title": "Serial number", "type": "string" }, "vendorID": { "anyOf": [ { "$ref": "#/definitions/HexString" }, { "$ref": "#/definitions/Int" } ], "description": "The vendor ID of the devices.", "examples": [ "0xA123" ], "title": "Vendor ID" } }, "type": "object" }, "Scheme.If.Mouse": { "const": "mouse", "description": "Match mouse devices.", "title": "Mouse", "type": "string" }, "Scheme.If.Trackpad": { "const": "trackpad", "description": "Match trackpad devices.", "title": "Trackpad", "type": "string" }, "Scheme.Pointer": { "additionalProperties": false, "properties": { "acceleration": { "anyOf": [ { "type": "number" }, { "$ref": "#/definitions/Unset" } ], "description": "A number to set acceleration, or \"unset\" to restore system default. If omitted, the previous/merged value applies.", "maximum": 20, "minimum": 0, "title": "Pointer acceleration" }, "disableAcceleration": { "default": false, "description": "If the value is true, the pointer acceleration will be disabled and acceleration and speed will not take effect.", "title": "Disable pointer acceleration", "type": "boolean" }, "redirectsToScroll": { "default": false, "description": "If the value is true, pointer movements will be redirected to scroll events.", "title": "Redirects to scroll", "type": "boolean" }, "speed": { "anyOf": [ { "type": "number" }, { "$ref": "#/definitions/Unset" } ], "description": "A number to set speed, or \"unset\" to restore device default. If omitted, the previous/merged value applies.", "maximum": 1, "title": "Pointer speed" } }, "type": "object" }, "Scheme.Scrolling": { "additionalProperties": false, "properties": { "acceleration": { "anyOf": [ { "type": "number" }, { "additionalProperties": false, "properties": { "horizontal": { "type": "number" }, "vertical": { "type": "number" } }, "type": "object" } ], "default": 1, "description": "The scrolling acceleration." }, "distance": { "anyOf": [ { "$ref": "#/definitions/Scheme.Scrolling.Distance" }, { "additionalProperties": false, "properties": { "horizontal": { "$ref": "#/definitions/Scheme.Scrolling.Distance" }, "vertical": { "$ref": "#/definitions/Scheme.Scrolling.Distance" } }, "type": "object" } ], "description": "The distance after rolling the wheel.", "title": "Scroll distance" }, "modifiers": { "anyOf": [ { "$ref": "#/definitions/Scheme.Scrolling.Modifiers" }, { "additionalProperties": false, "properties": { "horizontal": { "$ref": "#/definitions/Scheme.Scrolling.Modifiers" }, "vertical": { "$ref": "#/definitions/Scheme.Scrolling.Modifiers" } }, "type": "object" } ], "title": "Modifier keys settings" }, "reverse": { "anyOf": [ { "type": "boolean" }, { "additionalProperties": false, "properties": { "horizontal": { "type": "boolean" }, "vertical": { "type": "boolean" } }, "type": "object" } ], "title": "Reverse scrolling" }, "smoothed": { "anyOf": [ { "$ref": "#/definitions/Scheme.Scrolling.Smoothed" }, { "additionalProperties": false, "properties": { "horizontal": { "$ref": "#/definitions/Scheme.Scrolling.Smoothed" }, "vertical": { "$ref": "#/definitions/Scheme.Scrolling.Smoothed" } }, "type": "object" } ], "description": "Use a preset curve or fine-tune response, speed, acceleration, and inertia.", "title": "Smoothed scrolling" }, "speed": { "anyOf": [ { "type": "number" }, { "additionalProperties": false, "properties": { "horizontal": { "type": "number" }, "vertical": { "type": "number" } }, "type": "object" } ], "default": 0, "description": "The scrolling speed." } }, "type": "object" }, "Scheme.Scrolling.Auto": { "const": "auto", "description": "The scrolling distance will not be modified.", "type": "string" }, "Scheme.Scrolling.Distance": { "anyOf": [ { "$ref": "#/definitions/Scheme.Scrolling.Auto" }, { "$ref": "#/definitions/Scheme.Scrolling.Distance.Line" }, { "$ref": "#/definitions/Scheme.Scrolling.Distance.Pixel" } ] }, "Scheme.Scrolling.Distance.Line": { "anyOf": [ { "$ref": "#/definitions/Int" }, { "$ref": "#/definitions/IntString" } ], "description": "The scrolling distance in lines." }, "Scheme.Scrolling.Distance.Pixel": { "description": "The scrolling distance in pixels.", "pattern": "^\\d[1-9]*(\\.\\d+)?px", "type": "string" }, "Scheme.Scrolling.Modifiers": { "additionalProperties": false, "properties": { "command": { "$ref": "#/definitions/Scheme.Scrolling.Modifiers.Action", "description": "The action when command key is pressed." }, "control": { "$ref": "#/definitions/Scheme.Scrolling.Modifiers.Action", "description": "The action when control key is pressed." }, "option": { "$ref": "#/definitions/Scheme.Scrolling.Modifiers.Action", "description": "The action when option key is pressed." }, "shift": { "$ref": "#/definitions/Scheme.Scrolling.Modifiers.Action", "description": "The action when shift key is pressed." } }, "type": "object" }, "Scheme.Scrolling.Modifiers.Action": { "anyOf": [ { "$ref": "#/definitions/Scheme.Scrolling.Modifiers.None" }, { "$ref": "#/definitions/Scheme.Scrolling.Modifiers.Auto" }, { "$ref": "#/definitions/Scheme.Scrolling.Modifiers.Ignore" }, { "$ref": "#/definitions/Scheme.Scrolling.Modifiers.PreventDefault" }, { "$ref": "#/definitions/Scheme.Scrolling.Modifiers.AlterOrientation" }, { "$ref": "#/definitions/Scheme.Scrolling.Modifiers.ChangeSpeed" }, { "$ref": "#/definitions/Scheme.Scrolling.Modifiers.Zoom" }, { "$ref": "#/definitions/Scheme.Scrolling.Modifiers.PinchZoom" } ] }, "Scheme.Scrolling.Modifiers.AlterOrientation": { "additionalProperties": false, "description": "Alter the scrolling orientation from vertical to horizontal or vice versa.", "properties": { "type": { "const": "alterOrientation", "type": "string" } }, "required": [ "type" ], "type": "object" }, "Scheme.Scrolling.Modifiers.Auto": { "additionalProperties": false, "description": "Default action.", "properties": { "type": { "const": "auto", "type": "string" } }, "required": [ "type" ], "type": "object" }, "Scheme.Scrolling.Modifiers.ChangeSpeed": { "additionalProperties": false, "description": "Scale the scrolling speed.", "properties": { "scale": { "description": "The factor to scale the scrolling speed.", "type": "number" }, "type": { "const": "changeSpeed", "type": "string" } }, "required": [ "type", "scale" ], "type": "object" }, "Scheme.Scrolling.Modifiers.Ignore": { "additionalProperties": false, "description": "Ignore modifier.", "properties": { "type": { "const": "ignore", "type": "string" } }, "required": [ "type" ], "type": "object" }, "Scheme.Scrolling.Modifiers.None": { "additionalProperties": false, "deprecated": true, "description": "Default action.", "properties": { "type": { "const": "none", "type": "string" } }, "required": [ "type" ], "type": "object" }, "Scheme.Scrolling.Modifiers.PinchZoom": { "additionalProperties": false, "description": "Zoom in and out using pinch gestures.", "properties": { "type": { "const": "pinchZoom", "type": "string" } }, "required": [ "type" ], "type": "object" }, "Scheme.Scrolling.Modifiers.PreventDefault": { "additionalProperties": false, "description": "No action.", "properties": { "type": { "const": "preventDefault", "type": "string" } }, "required": [ "type" ], "type": "object" }, "Scheme.Scrolling.Modifiers.Zoom": { "additionalProperties": false, "description": "Zoom in and out using ⌘+ and ⌘-.", "properties": { "type": { "const": "zoom", "type": "string" } }, "required": [ "type" ], "type": "object" }, "Scheme.Scrolling.Smoothed": { "additionalProperties": false, "properties": { "acceleration": { "description": "The scrolling acceleration.", "type": "number" }, "enabled": { "default": true, "description": "Set to `false` to explicitly disable inherited smoothed scrolling for this direction.", "type": "boolean" }, "inertia": { "description": "The scrolling inertia.", "type": "number" }, "preset": { "$ref": "#/definitions/Scheme.Scrolling.Smoothed.Preset", "default": "natural", "description": "The preset curve to use." }, "response": { "description": "How quickly the scrolling responds to input.", "type": "number" }, "speed": { "description": "The scrolling speed.", "type": "number" } }, "type": "object" }, "Scheme.Scrolling.Smoothed.Preset": { "enum": [ "custom", "linear", "easeIn", "easeOut", "easeInOut", "easeOutIn", "quadratic", "cubic", "quartic", "easeOutCubic", "easeInOutCubic", "easeOutQuartic", "easeInOutQuartic", "quintic", "sine", "exponential", "circular", "back", "bounce", "elastic", "spring", "natural", "smooth", "snappy", "gentle" ], "type": "string" }, "Secondary": { "const": 1, "description": "Secondary button, usually the right button.", "type": "number" }, "SingleValueOrArray": { "anyOf": [ { "$ref": "#/definitions/Scheme.Buttons.AutoScroll.Mode" }, { "items": { "$ref": "#/definitions/Scheme.Buttons.AutoScroll.Mode" }, "type": "array" } ] }, "SingleValueOrArray": { "anyOf": [ { "$ref": "#/definitions/Scheme.If.Category" }, { "items": { "$ref": "#/definitions/Scheme.If.Category" }, "type": "array" } ] }, "SingleValueOrArray": { "anyOf": [ { "$ref": "#/definitions/Scheme.If" }, { "items": { "$ref": "#/definitions/Scheme.If" }, "type": "array" } ] }, "Unset": { "const": "unset", "description": "A special value that explicitly restores a setting to the system or device default. Currently supported in pointer settings; may be supported more broadly in the future.", "title": "Unset", "type": "string" } } } ================================================ FILE: Documentation/Configuration.md ================================================ # Configuration The LinearMouse configuration is stored in `~/.config/linearmouse/linearmouse.json`. If the configuration file does not exist, LinearMouse will create an empty configuration automatically. > **Note** > It's preferable to use the GUI to alter settings rather than manually updating configuration > unless you want to use advanced features. > **Note** > JSON5 is not supported yet. Writing comments in configuration will raise a parsing error. ## Get started Here is a simple example of LinearMouse configuration. ```json { "$schema": "https://app.linearmouse.org/schema/0.7.2", "schemes": [ { "if": { "device": { "category": "mouse" } }, "scrolling": { "reverse": { "vertical": true } } } ] } ``` This configuration reverses the vertical scrolling direction for any mouse connected to your device. ## JSON Schema As you can see, `$schema` defines the JSON schema of the LinearMouse configuration, which enables autocompletion in editors like VS Code. SON schemas are published for each LinearMouse version. Backward compatibility is guaranteed for the same major versions. ## Schemes A scheme is a collection of settings that are activated in specified circumstances. For example, in [get started](#get-started), we defined a scheme. The `if` field instructs LinearMouse to activate this scheme only when the active device is a mouse: ```json { "if": { "device": { "category": "mouse" } } } ``` And the `scrolling` field in this scheme defines the scrolling behaviors, with `"reverse": { "vertical": true }` reversing the vertical scrolling direction: ```json { "scrolling": { "reverse": { "vertical": true } } } ``` ## Smoothed scrolling `scrolling.smoothed` enables a phase-aware scrolling curve that can be tuned separately for vertical and horizontal scrolling. You can choose a preset such as `easeIn`, `easeOut`, `easeInOut`, `quadratic`, `cubic`, `easeOutCubic`, `easeInOutCubic`, `quartic`, `easeOutQuartic`, `easeInOutQuartic`, `smooth`, or `custom`, then fine-tune `response`, `speed`, `acceleration`, and `inertia` as needed. Set `enabled` to `false` to explicitly disable an inherited smoothed scrolling configuration for a direction. For example, to use a smoother scrolling profile for a mouse: ```json { "schemes": [ { "if": { "device": { "category": "mouse" } }, "scrolling": { "smoothed": { "enabled": true, "preset": "easeInOut", "response": 0.45, "speed": 1, "acceleration": 1.2, "inertia": 0.65 } } } ] } ``` If you want different tuning for each direction, provide `vertical` and `horizontal` values under `smoothed`. ## Device matching Vendor ID and product ID can be provided to match a specific device. You may find these values in About This Mac → System Report... → Bluetooth / USB. For example, to configure pointer speed of my Logitech mouse and Microsoft mouse respectively, I would create two schemes and specify the vendor ID and product ID: ```json { "schemes": [ { "if": { "device": { "vendorID": "0x046d", "productID": "0xc52b" } }, "pointer": { "acceleration": 0, "speed": 0.36 } }, { "if": { "device": { "vendorID": "0x045e", "productID": "0x0827" } }, "pointer": { "acceleration": 0, "speed": 0.4 } } ] } ``` Then, the pointer speed of my Logitech mouse and Microsoft mouse will be set to 0.36 and 0.4 respectively. ### Unsetting values LinearMouse supports a special "unset" value to explicitly restore settings back to their system or device defaults. This differs from omitting a field, which keeps the previously merged value. Currently, "unset" is supported for pointer acceleration and speed. ```json { "schemes": [ { "if": { "device": { "category": "mouse" } }, "pointer": { "acceleration": "unset", "speed": "unset" } } ] } ``` ## App matching App bundle ID can be provided to match a specific app. For example, to modify the pointer acceleration in Safari for my Logitech mouse: ```json { "schemes": [ { "if": { "device": { "vendorID": "0x046d", "productID": "0xc52b" }, "app": "com.apple.Safari" }, "pointer": { "acceleration": 0.5 } } ] } ``` Or, to disable reverse scrolling in Safari for all devices: ```json { "schemes": [ { "if": { "app": "com.apple.Safari" }, "scrolling": { "reverse": { "vertical": false, "horizontal": false } } } ] } ``` By default, LinearMouse checks the app bundle ID of the frontmost process. However, in some circumstances, a program might not be placed in a specific application bundle. In that case, you may specify the app bundle ID of the parent process or the process group of the frontmost process by specify `parentApp` and `groupApp`. For example, to match the Minecraft (a Java process) launched by PolyMC: ```json { "schemes": [ { "if": { "parentApp": "org.polymc.PolyMC" } } ] } ``` Or, to match the whole process group: ```json { "schemes": [ { "if": { "groupApp": "org.polymc.PolyMC" } } ] } ``` ### Process (binary) matching Some programs do not have a stable or any bundle identifier. You can match by the frontmost process's executable instead. - processName: Match by executable name (case-sensitive). Example: ```json { "schemes": [ { "if": { "processName": "wezterm" }, "scrolling": { "reverse": false } } ] } ``` - processPath: Match by absolute executable path (case-sensitive). Example: ```json { "schemes": [ { "if": { "processPath": "/Applications/WezTerm.app/Contents/MacOS/WezTerm" }, "pointer": { "acceleration": 0.4 } } ] } ``` Notes - processName/processPath compare exactly; no wildcard or regex. - Matching is against the frontmost application process (NSRunningApplication); child processes inside a terminal are not detected as the frontmost process. - You can still combine with device and display conditions. ## Display Matching Display name can be provided to match a specific display. For example, to modify the pointer acceleration on DELL P2415Q: ```json { "schemes": [ { "if": { "device": { "vendorID": "0x046d", "productID": "0xc52b" }, "display": "DELL P2415Q" }, "pointer": { "acceleration": 0.5 } } ] } ``` ## Schemes merging and multiple `if`s If multiple schemes are activated at the same time, they will be merged in the order of their definitions. Additionally, if multiple `if`s are specified, the scheme will be activated as long as any of them is satisfied. For example, the configuration above can alternatively be written as: ```json { "schemes": [ { "if": [ { "device": { "vendorID": "0x046d", "productID": "0xc52b" } }, { "device": { "vendorID": "0x045e", "productID": "0x0827" } } ], "pointer": { "acceleration": 0 } }, { "if": { "device": { "vendorID": "0x046d", "productID": "0xc52b" } }, "pointer": { "speed": 0.36 } }, { "if": { "device": { "vendorID": "0x045e", "productID": "0x0827" } }, "pointer": { "speed": 0.4 } } ] } ``` Or, with fewer lines but more difficult to maintain: ```json { "schemes": [ { "if": [ { "device": { "vendorID": "0x046d", "productID": "0xc52b" } }, { "device": { "vendorID": "0x045e", "productID": "0x0827" } } ], "pointer": { "acceleration": 0, "speed": 0.36 } }, { "if": { "device": { "vendorID": "0x045e", "productID": "0x0827" } }, "pointer": { "speed": 0.4 } } ] } ``` ## Button mappings Button mappings is a list that allows you to assign actions to buttons or scroll wheels. For example, to open Launchpad when the wheel button is clicked, or to switch spaces when command + back or command + forward is clicked. ### Basic example ```json { "schemes": [ { "if": [ { "device": { "category": "mouse" } } ], "buttons": { "mappings": [ { "button": 2, "action": "launchpad" } ] } } ] } ``` In this example, the wheel button is bound to open Launchpad. `"button": 2` denotes the auxiliary button, which is usually the wheel button. The following table lists all the buttons: | Button | Description | | ------ | ---------------------------------------------------------------- | | 0 | Primary button, usually the left button. | | 1 | Secondary button, usually the right button. | | 2 | Auxiliary button, usually the wheel button or the middle button. | | 3 | The fourth button, typically the back button. | | 4 | The fifth button, typically the forward button. | | 5-31 | Other buttons. | `{ "action": { "run": "open -a Launchpad" } }` assigns a shell command `open -a LaunchPad` to the button. When the button is clicked, the shell command will be executed. ### Modifier keys In this example, command + forward is bound to open Mission Control. ```json { "schemes": [ { "if": [ { "device": { "category": "mouse" } } ], "buttons": { "mappings": [ { "button": 4, "command": true, "action": "missionControl" } ] } } ] } ``` `"command": true` denotes that command should be pressed. You can specify `shift`, `option` and `control` as well. ### Switch spaces (desktops) with the command + back and command + forward `missionControl.spaceLeft` and `missionControl.spaceRight` can be used to move left and right a space. ```json { "schemes": [ { "if": [ { "device": { "category": "mouse" } } ], "buttons": { "mappings": [ { "button": 3, "command": true, "action": "missionControl.spaceLeft" }, { "button": 4, "command": true, "action": "missionControl.spaceRight" } ] } } ] } ``` > **Note** > You will have to grant an additional permission to allow LinearMouse to simulate keys. ### Key repeat With `repeat: true`, actions will be repeated until the button is up. In this example, option + back and option + forward is bound to volume down and volume up. If you hold option + back, the volume will continue to decrease. > **Note** > If you disabled key repeat in System Settings, `repeat: true` will not work. > If you change key repeat rate or delay until repeat in System Settings, you have to restart > LinearMouse to take effect. ```json { "schemes": [ { "if": [ { "device": { "category": "mouse" } } ], "buttons": { "mappings": [ { "button": 4, "repeat": true, "option": true, "action": "media.volumeUp" }, { "button": 3, "repeat": true, "option": true, "action": "media.volumeDown" } ] } } ] } ``` ### Hold keyboard shortcuts while pressed With `hold: true`, keyboard shortcut actions stay pressed for as long as the mouse button is held. This is different from `repeat: true`: - `repeat: true` keeps sending the shortcut over and over. - `hold: true` sends key down when the mouse button is pressed, then key up when it is released. This is useful for apps that expect a real held key, such as timeline scrubbing or temporary tools. ```json { "schemes": [ { "if": { "device": { "category": "mouse" } }, "buttons": { "mappings": [ { "button": 3, "hold": true, "action": { "keyPress": ["c"] } } ] } } ] } ``` ### Volume up and down with option + scrollUp and option + scrollDown `scroll` can be specified instead of `button` to map scroll events to specific actions. ```json { "schemes": [ { "if": [ { "device": { "category": "mouse" } } ], "buttons": { "mappings": [ { "scroll": "up", "option": true, "action": "media.volumeUp" }, { "scroll": "down", "option": true, "action": "media.volumeDown" } ] } } ] } ``` ### Swap back and forward buttons ```json { "schemes": [ { "if": [ { "device": { "category": "mouse" } } ], "buttons": { "mappings": [ { "button": 3, "action": "mouse.button.forward" }, { "button": 4, "action": "mouse.button.back" } ] } } ] } ``` ### Action sheet #### Simple actions A simple action is an action without any parameters. ```json { "action": "" } ``` `` could be one of: | Action | Description | | --------------------------- | ------------------------------------- | | `auto` | Do not modify the button behavior. | | `none` | Prevent the button events. | | `missionControl` | Mission Control. | | `missionControl.spaceLeft` | Mission Control: Move left a space. | | `missionControl.spaceRight` | Mission Control: Move right a space. | | `appExpose` | App Exposé. | | `launchpad` | Launchpad. | | `showDesktop` | Show desktop. | | `showDesktop` | Show desktop. | | `lookUpAndDataDetectors` | Look up & data detectors. | | `smartZoom` | Smart zoom. | | `display.brightnessUp` | Display: Brightness up. | | `display.brightnessDown` | Display: Brightness down. | | `media.volumeUp` | Media: Volume up. | | `media.volumeDown` | Media: Volume down. | | `media.mute` | Media: Toggle mute. | | `media.playPause` | Media: Play / pause. | | `media.next` | Media: Next. | | `media.previous` | Media: Previous. | | `media.fastForward` | Media: Fast forward. | | `media.rewind` | Media: Rewind. | | `keyboard.brightnessUp` | Keyboard: Brightness up. | | `keyboard.brightnessDown` | Keyboard: Brightness down. | | `mouse.wheel.scrollUp` | Mouse: Wheel: Scroll up. | | `mouse.wheel.scrollDown` | Mouse: Wheel: Scroll down. | | `mouse.wheel.scrollLeft` | Mouse: Wheel: Scroll left. | | `mouse.wheel.scrollRight` | Mouse: Wheel: Scroll right. | | `mouse.button.left` | Mouse: Button: Act as left button. | | `mouse.button.middle` | Mouse: Button: Act as middle button. | | `mouse.button.right` | Mouse: Button: Act as right button. | | `mouse.button.back` | Mouse: Button: Act as back button. | | `mouse.button.forward` | Mouse: Button: Act as forward button. | #### Run shell commands ```json { "action": { "run": "" } } ``` The `` will be executed with bash. #### Scroll a certain distance ##### Scroll up 2 lines ```json { "action": { "mouse.wheel.scrollUp": 2 } } ``` ##### Scroll left 32 pixels ```json { "action": { "mouse.wheel.scrollLeft": "32px" } } ``` #### Press keyboard shortcuts ```json { "action": { "keyPress": ["shift", "command", "4"] } } ``` To see the full list of keys, please refer to [Configuration.d.ts#L652](Configuration.d.ts#L652). #### Numpad keys support LinearMouse supports all numpad keys for keyboard shortcuts: - Number keys: `numpad0`, `numpad1`, `numpad2`, `numpad3`, `numpad4`, `numpad5`, `numpad6`, `numpad7`, `numpad8`, `numpad9` - Operator keys: `numpadPlus`, `numpadMinus`, `numpadMultiply`, `numpadDivide`, `numpadEquals` - Function keys: `numpadEnter`, `numpadDecimal`, `numpadClear` Example usage: ```json { "action": { "keyPress": ["numpad5"] } } ``` ## Pointer settings ### Redirects to scroll The `redirectsToScroll` property allows you to redirect pointer movements to scroll events. This is useful for scenarios where you want mouse movements to control scrolling instead of cursor positioning. ```json { "schemes": [ { "if": { "device": { "category": "mouse" } }, "pointer": { "redirectsToScroll": true } } ] } ``` When `redirectsToScroll` is set to `true`, horizontal mouse movements will generate horizontal scroll events, and vertical mouse movements will generate vertical scroll events. ================================================ FILE: Documentation/translate-xcstrings.md ================================================ # XCStrings LLM Translation Script `Scripts/translate-xcstrings.mjs` uses Xcode's native localization export/import flow, then fills unfinished xcstrings entries with an LLM through OpenRouter. What it does: - exports `.xcloc` / `.xliff` bundles with `xcodebuild -exportLocalizations` - inspects only `.xcstrings` translation units inside the exported XLIFF - skips units whose target is already `translated` - sends only unfinished units to the model - imports the translated XLIFF back with `xcodebuild -importLocalizations -mergeImport` ## Install ```bash npm install ``` ## Required environment variables ```bash export OPENROUTER_API_KEY="your-api-key" ``` Optional: ```bash export OPENROUTER_MODEL="openai/gpt-4.1-mini" export OPENROUTER_SITE_URL="https://github.com/linearmouse/linearmouse" export OPENROUTER_APP_NAME="LinearMouse xcstrings translator" ``` ## Usage Dry run first: ```bash npm run translate:xcstrings -- --dry-run ``` Translate everything unfinished: ```bash npm run translate:xcstrings ``` Translate only selected languages: ```bash npm run translate:xcstrings -- --languages ja,zh-Hans,zh-Hant ``` Use a different model or smaller batches: ```bash npm run translate:xcstrings -- --model openai/gpt-4.1 --batch-size 5 ``` Limit a test run to a few translation units: ```bash npm run translate:xcstrings -- --max-units 20 ``` Keep the exported localization bundle for inspection: ```bash npm run translate:xcstrings -- --languages ja --keep-export ``` ## Notes - The script uses the OpenAI SDK against OpenRouter's OpenAI-compatible endpoint. - Extraction and import are handled by Xcode, not by a custom xcstrings parser. - The script only edits XLIFF units whose `original` file ends with `.xcstrings`; other exported resources are left untouched. - Xcode's exported XLIFF already expands plural and variation entries into individual `trans-unit` items, so the model works on those units directly. - Review the diff after each run because model translations can still need terminology cleanup. ================================================ FILE: ExportOptions.plist ================================================ destination export method developer-id signingCertificate Developer ID Application signingStyle manual teamID C5686NKYJ7 ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2021-2024 LinearMouse Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: LinearMouse/AccessibilityPermission.swift ================================================ // MIT License // Copyright (c) 2021-2026 LinearMouse import Foundation import os.log import SwiftUI enum AccessibilityPermission { private static let log = OSLog(subsystem: Bundle.main.bundleIdentifier!, category: "AccessibilityPermission") static var enabled: Bool { AXIsProcessTrustedWithOptions([ kAXTrustedCheckOptionPrompt.takeUnretainedValue(): false ] as CFDictionary) } static func prompt() { AXIsProcessTrustedWithOptions([ kAXTrustedCheckOptionPrompt.takeUnretainedValue(): true ] as CFDictionary) } static func pollingUntilEnabled(completion: @escaping () -> Void) { guard enabled else { DispatchQueue.main.asyncAfter(deadline: .now() + 1) { os_log("Polling accessibility permission", log: log, type: .info) pollingUntilEnabled(completion: completion) } return } completion() } } enum AccessibilityPermissionError: Error { case resetError } ================================================ FILE: LinearMouse/AppDelegate.swift ================================================ // MIT License // Copyright (c) 2021-2026 LinearMouse import AppMover import Combine import LaunchAtLogin import os.log import SwiftUI @main class AppDelegate: NSObject, NSApplicationDelegate { private static let log = OSLog(subsystem: Bundle.main.bundleIdentifier!, category: "AppDelegate") private let autoUpdateManager = AutoUpdateManager.shared private let statusItem = StatusItem.shared private var subscriptions = Set() func applicationDidFinishLaunching(_: Notification) { guard ProcessEnvironment.isRunningApp else { return } #if !DEBUG if AppMover.moveIfNecessary() { return } #endif guard AccessibilityPermission.enabled else { AccessibilityPermissionWindow.shared.bringToFront() return } setup() if CommandLine.arguments.contains("--show") { SettingsWindowController.shared.bringToFront() } } func applicationShouldHandleReopen(_: NSApplication, hasVisibleWindows flag: Bool) -> Bool { guard ProcessEnvironment.isRunningApp else { return true } if flag { return true } SettingsWindowController.shared.bringToFront() return false } func applicationWillTerminate(_: Notification) { guard ProcessEnvironment.isRunningApp else { return } stop() } } extension AppDelegate { func setup() { setupConfiguration() setupNotifications() KeyboardSettingsSnapshot.shared.refresh() start() } func setupConfiguration() { ConfigurationState.shared.load() // Start watching the configuration file for hot reload ConfigurationState.shared.startHotReload() } func setupNotifications() { // Prepare user notifications for error popups Notifier.shared.setup() NSWorkspace.shared.notificationCenter.addObserver( forName: NSWorkspace.sessionDidResignActiveNotification, object: nil, queue: .main ) { [weak self] _ in os_log("Session inactive", log: Self.log, type: .info) self?.stop() } NSWorkspace.shared.notificationCenter.addObserver( forName: NSWorkspace.sessionDidBecomeActiveNotification, object: nil, queue: .main ) { [weak self] _ in os_log("Session active", log: Self.log, type: .info) KeyboardSettingsSnapshot.shared.refresh() self?.start() } } func start() { DeviceManager.shared.start() BatteryDeviceMonitor.shared.start() GlobalEventTap.shared.start() } func stop() { BatteryDeviceMonitor.shared.stop() DeviceManager.shared.stop() GlobalEventTap.shared.stop() } } ================================================ FILE: LinearMouse/Assets.xcassets/AccentColor.colorset/Contents.json ================================================ { "colors" : [ { "color" : { "color-space" : "srgb", "components" : { "alpha" : "1.000", "blue" : "0xF6", "green" : "0x82", "red" : "0x3A" } }, "idiom" : "universal" } ], "info" : { "author" : "xcode", "version" : 1 } } ================================================ FILE: LinearMouse/Assets.xcassets/AccessibilityIcon.imageset/Contents.json ================================================ { "images" : [ { "idiom" : "universal", "scale" : "1x" }, { "filename" : "UniversalAccessPref@2x.png", "idiom" : "universal", "scale" : "2x" }, { "idiom" : "universal", "scale" : "3x" } ], "info" : { "author" : "xcode", "version" : 1 } } ================================================ FILE: LinearMouse/Assets.xcassets/AppIcon.appiconset/Contents.json ================================================ { "images" : [ { "idiom" : "mac", "scale" : "1x", "size" : "16x16" }, { "filename" : "Icon-32.png", "idiom" : "mac", "scale" : "2x", "size" : "16x16" }, { "filename" : "Icon-33.png", "idiom" : "mac", "scale" : "1x", "size" : "32x32" }, { "filename" : "Icon-64.png", "idiom" : "mac", "scale" : "2x", "size" : "32x32" }, { "filename" : "Icon-128.png", "idiom" : "mac", "scale" : "1x", "size" : "128x128" }, { "filename" : "Icon-256.png", "idiom" : "mac", "scale" : "2x", "size" : "128x128" }, { "filename" : "Icon-257.png", "idiom" : "mac", "scale" : "1x", "size" : "256x256" }, { "filename" : "Icon-513.png", "idiom" : "mac", "scale" : "2x", "size" : "256x256" }, { "filename" : "Icon-512.png", "idiom" : "mac", "scale" : "1x", "size" : "512x512" }, { "filename" : "Icon-1024.png", "idiom" : "mac", "scale" : "2x", "size" : "512x512" } ], "info" : { "author" : "xcode", "version" : 1 } } ================================================ FILE: LinearMouse/Assets.xcassets/AppIconDev.appiconset/Contents.json ================================================ { "images" : [ { "idiom" : "mac", "scale" : "1x", "size" : "16x16" }, { "idiom" : "mac", "scale" : "2x", "size" : "16x16" }, { "idiom" : "mac", "scale" : "1x", "size" : "32x32" }, { "idiom" : "mac", "scale" : "2x", "size" : "32x32" }, { "idiom" : "mac", "scale" : "1x", "size" : "128x128" }, { "idiom" : "mac", "scale" : "2x", "size" : "128x128" }, { "idiom" : "mac", "scale" : "1x", "size" : "256x256" }, { "filename" : "Icon-512.png", "idiom" : "mac", "scale" : "2x", "size" : "256x256" }, { "idiom" : "mac", "scale" : "1x", "size" : "512x512" }, { "idiom" : "mac", "scale" : "2x", "size" : "512x512" } ], "info" : { "author" : "xcode", "version" : 1 } } ================================================ FILE: LinearMouse/Assets.xcassets/Contents.json ================================================ { "info" : { "author" : "xcode", "version" : 1 } } ================================================ FILE: LinearMouse/Assets.xcassets/MenuIcon.imageset/Contents.json ================================================ { "images" : [ { "filename" : "MenuIcon.png", "idiom" : "universal", "scale" : "1x" }, { "filename" : "MenuIcon@2x.png", "idiom" : "universal", "scale" : "2x" }, { "filename" : "MenuIcon@3x.png", "idiom" : "universal", "scale" : "3x" } ], "info" : { "author" : "xcode", "version" : 1 }, "properties" : { "template-rendering-intent" : "template" } } ================================================ FILE: LinearMouse/Assets.xcassets/Minus.imageset/Contents.json ================================================ { "images" : [ { "filename" : "minus.svg", "idiom" : "universal", "scale" : "1x" }, { "idiom" : "universal", "scale" : "2x" }, { "idiom" : "universal", "scale" : "3x" } ], "info" : { "author" : "xcode", "version" : 1 }, "properties" : { "template-rendering-intent" : "template" } } ================================================ FILE: LinearMouse/Assets.xcassets/Plus.imageset/Contents.json ================================================ { "images" : [ { "filename" : "plus.svg", "idiom" : "universal", "scale" : "1x" }, { "idiom" : "universal", "scale" : "2x" }, { "idiom" : "universal", "scale" : "3x" } ], "info" : { "author" : "xcode", "version" : 1 }, "properties" : { "template-rendering-intent" : "template" } } ================================================ FILE: LinearMouse/Assets.xcassets/Sidebar/Buttons.imageset/Contents.json ================================================ { "images" : [ { "idiom" : "universal", "scale" : "1x" }, { "filename" : "button.programmable@2x.png", "idiom" : "universal", "scale" : "2x" }, { "idiom" : "universal", "scale" : "3x" } ], "info" : { "author" : "xcode", "version" : 1 }, "properties" : { "template-rendering-intent" : "template" } } ================================================ FILE: LinearMouse/Assets.xcassets/Sidebar/Contents.json ================================================ { "info" : { "author" : "xcode", "version" : 1 } } ================================================ FILE: LinearMouse/Assets.xcassets/Sidebar/General.imageset/Contents.json ================================================ { "images" : [ { "idiom" : "universal", "scale" : "1x" }, { "filename" : "gearshape.fill@2x.png", "idiom" : "universal", "scale" : "2x" }, { "idiom" : "universal", "scale" : "3x" } ], "info" : { "author" : "xcode", "version" : 1 }, "properties" : { "template-rendering-intent" : "template" } } ================================================ FILE: LinearMouse/Assets.xcassets/Sidebar/Modifier Keys.imageset/Contents.json ================================================ { "images" : [ { "idiom" : "universal", "scale" : "1x" }, { "filename" : "command@2x.png", "idiom" : "universal", "scale" : "2x" }, { "idiom" : "universal", "scale" : "3x" } ], "info" : { "author" : "xcode", "version" : 1 }, "properties" : { "template-rendering-intent" : "template" } } ================================================ FILE: LinearMouse/Assets.xcassets/Sidebar/Pointer.imageset/Contents.json ================================================ { "images" : [ { "idiom" : "universal", "scale" : "1x" }, { "filename" : "cursorarrow@2x.png", "idiom" : "universal", "scale" : "2x" }, { "idiom" : "universal", "scale" : "3x" } ], "info" : { "author" : "xcode", "version" : 1 }, "properties" : { "template-rendering-intent" : "template" } } ================================================ FILE: LinearMouse/Assets.xcassets/Sidebar/Scrolling.imageset/Contents.json ================================================ { "images" : [ { "idiom" : "universal", "scale" : "1x" }, { "filename" : "arrow.up.and.down@2x.png", "idiom" : "universal", "scale" : "2x" }, { "idiom" : "universal", "scale" : "3x" } ], "info" : { "author" : "xcode", "version" : 1 }, "properties" : { "template-rendering-intent" : "template" } } ================================================ FILE: LinearMouse/AutoUpdateManager.swift ================================================ // MIT License // Copyright (c) 2021-2026 LinearMouse import Defaults import Foundation import Sparkle import Version class AutoUpdateManager: NSObject { static let shared = AutoUpdateManager() private var _controller: SPUStandardUpdaterController! var controller: SPUStandardUpdaterController { _controller } override init() { super.init() _controller = SPUStandardUpdaterController( startingUpdater: true, updaterDelegate: self, userDriverDelegate: nil ) } } extension AutoUpdateManager: SPUUpdaterDelegate { func allowedChannels(for _: SPUUpdater) -> Set { Defaults[.betaChannelOn] ? ["beta"] : [] } func versionComparator(for _: SPUUpdater) -> SUVersionComparison? { SemanticVersioningComparator() } } class SemanticVersioningComparator: SUVersionComparison { func compareVersion(_ versionA: String, toVersion versionB: String) -> ComparisonResult { guard let a = try? Version(versionA) else { return .orderedAscending } guard let b = try? Version(versionB) else { return .orderedDescending } if a < b { return .orderedAscending } if a > b { return .orderedDescending } return .orderedSame } } ================================================ FILE: LinearMouse/DefaultsKeys.swift ================================================ // MIT License // Copyright (c) 2021-2026 LinearMouse import Defaults enum MenuBarBatteryDisplayMode: String, Codable, Defaults.Serializable { case off case below5 case below10 case below15 case below20 case always var threshold: Int? { switch self { case .off: return nil case .below5: return 5 case .below10: return 10 case .below15: return 15 case .below20: return 20 case .always: return 100 } } } extension Defaults.Keys { static let showInMenuBar = Key("showInMenuBar", default: true) static let menuBarBatteryDisplayMode = Key("menuBarBatteryDisplayMode", default: .off) static let showInDock = Key("showInDock", default: true) static let betaChannelOn = Key("betaChannelOn", default: false) static let bypassEventsFromOtherApplications = Key("bypassEventsFromOtherApplications", default: false) static let verbosedLoggingOn = Key("verbosedLoggingOn", default: false) } ================================================ FILE: LinearMouse/Device/Device.swift ================================================ // MIT License // Copyright (c) 2021-2026 LinearMouse import Defaults import Foundation import ObservationToken import os.log import PointerKit class Device { private static let log = OSLog( subsystem: Bundle.main.bundleIdentifier!, category: "Device" ) static let fallbackPointerAcceleration = 0.6875 static let fallbackPointerResolution = 400.0 static let fallbackPointerSpeed = pointerSpeed( fromPointerResolution: fallbackPointerResolution ) private static var nextID: Int32 = 0 private(set) lazy var id: Int32 = OSAtomicIncrement32(&Self.nextID) var name: String var productName: String? var vendorID: Int? var productID: Int? var serialNumber: String? var buttonCount: Int? var batteryLevel: Int? private let categoryValue: Category private weak var manager: DeviceManager? private var inputReportHandlers: [InputReportHandler] = [] private var logitechReprogrammableControlsMonitor: LogitechReprogrammableControlsMonitor? private let device: PointerDevice var pointerDevice: PointerDevice { device } private var removed = false private var verbosedLoggingOn = Defaults[.verbosedLoggingOn] private let initialPointerResolution: Double private var inputObservationToken: ObservationToken? private var reportObservationToken: ObservationToken? private var lastButtonStates: UInt8 = 0 var category: Category { categoryValue } init(_ manager: DeviceManager, _ device: PointerDevice) { self.manager = manager self.device = device vendorID = device.vendorID productID = device.productID serialNumber = device.serialNumber buttonCount = device.buttonCount let rawProductName = device.product let rawName = rawProductName ?? device.name name = rawName productName = rawProductName batteryLevel = nil categoryValue = Self.detectCategory(for: device) initialPointerResolution = device.pointerResolution ?? Self.fallbackPointerResolution // TODO: More elegant way? inputObservationToken = device.observeInput { [weak self] in self?.inputValueCallback($0, $1) } // Some bluetooth devices, such as Mi Dual Mode Wireless Mouse Silent Edition, report only // 3 buttons in the HID report descriptor. As a result, macOS does not recognize side button // clicks from these devices. // // To work around this issue, we subscribe to the input reports and monitor the side button // states. When the side buttons are clicked, we simulate those events. if let vendorID, let productID { let handlers = InputReportHandlerRegistry.handlers(for: vendorID, productID: productID) let needsObservation = handlers.contains { $0.alwaysNeedsReportObservation() } || buttonCount == 3 if needsObservation, !handlers.isEmpty { inputReportHandlers = handlers reportObservationToken = device.observeReport { [weak self] in self?.inputReportCallback($0, $1) } } } if LogitechReprogrammableControlsMonitor.supports(device: self) { let monitor = LogitechReprogrammableControlsMonitor(device: self) logitechReprogrammableControlsMonitor = monitor monitor.start() } os_log( "Device initialized: %{public}@: HIDPointerResolution=%{public}f, HIDPointerAccelerationType=%{public}@, battery=%{public}@", log: Self.log, type: .info, String(describing: device), initialPointerResolution, device.pointerAccelerationType ?? "(unknown)", batteryLevel.map(formattedPercent) ?? "(unknown)" ) Defaults.observe(.verbosedLoggingOn) { [weak self] change in guard let self else { return } verbosedLoggingOn = change.newValue } .tieToLifetime(of: self) } func markRemoved() { removed = true inputObservationToken = nil reportObservationToken = nil logitechReprogrammableControlsMonitor?.stop() logitechReprogrammableControlsMonitor = nil } func markActive(reason: String) { manager?.markDeviceActive(self, reason: reason) } var hasLogitechControlsMonitor: Bool { logitechReprogrammableControlsMonitor != nil } func requestLogitechControlsForcedReconfiguration() { logitechReprogrammableControlsMonitor?.requestForcedReconfiguration() } } extension Device { enum Category { case mouse, trackpad } private static let appleVendorIDs = Set([0x004C, 0x05AC]) private static let appleMagicMouseProductIDs = Set([0x0269, 0x030D]) private static let appleMagicTrackpadProductIDs = Set([0x0265, 0x030E]) private static let appleBuiltInTrackpadProductIDs = Set([0x0273, 0x0276, 0x0278, 0x0340]) private static func detectCategory(for device: PointerDevice) -> Category { if let vendorID = device.vendorID, let productID = device.productID, isAppleMagicMouse(vendorID: vendorID, productID: productID) { return .mouse } if device.confirmsTo(kHIDPage_Digitizer, kHIDUsage_Dig_TouchPad) { return .trackpad } return .mouse } private static func isAppleMagicMouse(vendorID: Int, productID: Int) -> Bool { appleVendorIDs.contains(vendorID) && appleMagicMouseProductIDs.contains(productID) } private static func isAppleMagicTrackpad(vendorID: Int, productID: Int) -> Bool { appleVendorIDs.contains(vendorID) && appleMagicTrackpadProductIDs.contains(productID) } private static func isAppleBuiltInTrackpad(vendorID: Int, productID: Int) -> Bool { vendorID == 0x05AC && appleBuiltInTrackpadProductIDs.contains(productID) } var showsPointerSpeedLimitationNotice: Bool { guard let vendorID, let productID else { return false } return Self.isAppleMagicMouse(vendorID: vendorID, productID: productID) || Self.isAppleMagicTrackpad(vendorID: vendorID, productID: productID) || Self.isAppleBuiltInTrackpad(vendorID: vendorID, productID: productID) } /** This feature was introduced in macOS Sonoma. In the earlier versions of macOS, this value would be nil. */ var disablePointerAcceleration: Bool? { get { device.useLinearScalingMouseAcceleration.map { $0 != 0 } } set { guard device.useLinearScalingMouseAcceleration != nil, let newValue else { return } device.useLinearScalingMouseAcceleration = newValue ? 1 : 0 } } var pointerAcceleration: Double { get { device.pointerAcceleration ?? Self.fallbackPointerAcceleration } set { os_log( "Update pointer acceleration for device: %{public}@: %{public}f", log: Self.log, type: .info, String(describing: self), newValue ) device.pointerAcceleration = newValue } } private static let pointerSpeedRange = 1.0 / 1200 ... 1.0 / 40 static func pointerSpeed(fromPointerResolution pointerResolution: Double) -> Double { (1 / pointerResolution).normalized(from: pointerSpeedRange) } static func pointerResolution(fromPointerSpeed pointerSpeed: Double) -> Double { 1 / (pointerSpeed.normalized(to: pointerSpeedRange)) } var pointerSpeed: Double { get { device.pointerResolution.map { Self.pointerSpeed(fromPointerResolution: $0) } ?? Self .fallbackPointerSpeed } set { os_log( "Update pointer speed for device: %{public}@: %{public}f", log: Self.log, type: .info, String(describing: self), newValue ) device.pointerResolution = Self.pointerResolution(fromPointerSpeed: newValue) } } func restorePointerAcceleration() { let systemPointerAcceleration = (DeviceManager.shared .getSystemProperty(forKey: device.pointerAccelerationType ?? kIOHIDMouseAccelerationTypeKey) as IOFixed? ) .map { Double($0) / 65_536 } ?? Self.fallbackPointerAcceleration os_log( "Restore pointer acceleration for device: %{public}@: %{public}f", log: Self.log, type: .info, String(describing: device), systemPointerAcceleration ) pointerAcceleration = systemPointerAcceleration } func restorePointerSpeed() { os_log( "Restore pointer speed for device: %{public}@: %{public}f", log: Self.log, type: .info, String(describing: device), Self.pointerSpeed(fromPointerResolution: initialPointerResolution) ) device.pointerResolution = initialPointerResolution } func restorePointerAccelerationAndPointerSpeed() { restorePointerSpeed() restorePointerAcceleration() } private func inputValueCallback( _ device: PointerDevice, _ value: IOHIDValue ) { if verbosedLoggingOn { os_log( "Received input value from: %{public}@: %{public}@", log: Self.log, type: .info, String(describing: device), String(describing: value) ) } guard let manager else { os_log("manager is nil", log: Self.log, type: .error) return } guard manager.lastActiveDeviceId != id else { return } let element = value.element let usagePage = element.usagePage let usage = element.usage switch Int(usagePage) { case kHIDPage_GenericDesktop: switch Int(usage) { case kHIDUsage_GD_X, kHIDUsage_GD_Y, kHIDUsage_GD_Z: guard IOHIDValueGetIntegerValue(value) != 0 else { return } default: return } case kHIDPage_Button: break default: return } manager.markDeviceActive( self, reason: "Received input value: usagePage=0x\(String(format: "%02X", usagePage)), usage=0x\(String(format: "%02X", usage))" ) } private func inputReportCallback(_ device: PointerDevice, _ report: Data) { if verbosedLoggingOn { let reportHex = report.map { String(format: "%02X", $0) }.joined(separator: " ") os_log( "Received input report from: %{public}@: %{public}@", log: Self.log, type: .info, String(describing: device), String(describing: reportHex) ) } let context = InputReportContext(report: report, lastButtonStates: lastButtonStates) let chain = inputReportHandlers.reversed().reduce({ (_: InputReportContext) in }) { next, handler in { context in handler.handleReport(context, next: next) } } chain(context) lastButtonStates = context.lastButtonStates } } extension Device: Hashable { static func == (lhs: Device, rhs: Device) -> Bool { lhs === rhs } func hash(into hasher: inout Hasher) { hasher.combine(ObjectIdentifier(self)) } } extension Device: CustomStringConvertible { var description: String { let vendorIDString = vendorID.map { String(format: "0x%04X", $0) } ?? "(nil)" let productIDString = productID.map { String(format: "0x%04X", $0) } ?? "(nil)" return String(format: "%@ (VID=%@, PID=%@)", name, vendorIDString, productIDString) } } ================================================ FILE: LinearMouse/Device/DeviceManager.swift ================================================ // MIT License // Copyright (c) 2021-2026 LinearMouse import AppKit import Combine import Foundation import os.log import PointerKit class DeviceManager: ObservableObject { static let shared = DeviceManager() private static let log = OSLog(subsystem: Bundle.main.bundleIdentifier!, category: "DeviceManager") private let manager = PointerDeviceManager() private let receiverMonitor = ReceiverMonitor() private var pointerDeviceToDevice = [PointerDevice: Device]() @Published private(set) var receiverPairedDeviceIdentities = [Int: [ReceiverLogicalDeviceIdentity]]() @Published var devices: [Device] = [] var lastActiveDeviceId: Int32? @Published var lastActiveDeviceRef: WeakRef? init() { manager.observeDeviceAdded { [weak self] in self?.deviceAdded($0, $1) } .tieToLifetime(of: self) manager.observeDeviceRemoved { [weak self] in self?.deviceRemoved($0, $1) } .tieToLifetime(of: self) manager.observeEventReceived { [weak self] in self?.eventReceived($0, $1, $2) } .tieToLifetime(of: self) receiverMonitor.onPointingDevicesChanged = { [weak self] locationID, identities in self?.receiverPointingDevicesChanged(locationID: locationID, identities: identities) } for property in [ kIOHIDMouseAccelerationType, kIOHIDTrackpadAccelerationType, kIOHIDPointerResolutionKey, "HIDUseLinearScalingMouseAcceleration" ] { manager.observePropertyChanged(property: property) { [self] _ in os_log("Property %{public}@ changed", log: Self.log, type: .info, property) updatePointerSpeed() }.tieToLifetime(of: self) } } deinit { stop() } private enum State { case stopped, running } private var state: State = .stopped private var subscriptions = Set() private var activateApplicationObserver: Any? func stop() { guard state == .running else { return } state = .stopped restorePointerSpeedToInitialValue() manager.stopObservation() subscriptions.removeAll() if let activateApplicationObserver { NSWorkspace.shared.notificationCenter.removeObserver(activateApplicationObserver) } } func start() { guard state == .stopped else { return } state = .running manager.startObservation() ConfigurationState.shared .$configuration .debounce(for: 0.1, scheduler: RunLoop.main) .sink { [weak self] _ in guard let self else { return } DispatchQueue.main.async { self.updatePointerSpeed() } } .store(in: &subscriptions) ScreenManager.shared .$currentScreenName .sink { [weak self] _ in guard let self else { return } DispatchQueue.main.async { self.updatePointerSpeed() } } .store(in: &subscriptions) activateApplicationObserver = NSWorkspace.shared.notificationCenter.addObserver( forName: NSWorkspace.didActivateApplicationNotification, object: nil, queue: .main ) { [weak self] notification in let application = notification.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication os_log( "Frontmost app changed: %{public}@", log: Self.log, type: .info, application?.bundleIdentifier ?? "(nil)" ) self?.updatePointerSpeed() } } private func deviceAdded(_: PointerDeviceManager, _ pointerDevice: PointerDevice) { let device = Device(self, pointerDevice) objectWillChange.send() pointerDeviceToDevice[pointerDevice] = device refreshVisibleDevices() os_log( "Device added: %{public}@", log: Self.log, type: .info, String(describing: device) ) updatePointerSpeed(for: device) if shouldMonitorReceiver(device) { receiverMonitor.startMonitoring(device: device) } } private func deviceRemoved(_: PointerDeviceManager, _ pointerDevice: PointerDevice) { guard let device = pointerDeviceToDevice[pointerDevice] else { return } device.markRemoved() objectWillChange.send() if lastActiveDeviceId == device.id { lastActiveDeviceId = nil lastActiveDeviceRef = nil } if let locationID = pointerDevice.locationID { let hasRemainingReceiverAtLocation = pointerDeviceToDevice .filter { $0.key != pointerDevice } .contains { _, existingDevice in existingDevice.pointerDevice.locationID == locationID && shouldMonitorReceiver(existingDevice) } if hasRemainingReceiverAtLocation { os_log( "Keep receiver monitor running because another receiver device shares locationID=%{public}d", log: Self.log, type: .info, locationID ) } else { receiverMonitor.stopMonitoring(device: device) receiverPairedDeviceIdentities.removeValue(forKey: locationID) } } pointerDeviceToDevice.removeValue(forKey: pointerDevice) refreshVisibleDevices() os_log( "Device removed: %{public}@", log: Self.log, type: .info, String(describing: device) ) } /// Observes events from `DeviceManager`. /// /// It seems that extenal Trackpads do not trigger to `IOHIDDevice`'s inputValueCallback. /// That's why we need to observe events from `DeviceManager` too. private func eventReceived(_: PointerDeviceManager, _ pointerDevice: PointerDevice, _ event: IOHIDEvent) { guard let physicalDevice = pointerDeviceToDevice[pointerDevice] else { return } guard IOHIDEventGetType(event) == kIOHIDEventTypeScroll else { return } let scrollX = IOHIDEventGetFloatValue(event, kIOHIDEventFieldScrollX) let scrollY = IOHIDEventGetFloatValue(event, kIOHIDEventFieldScrollY) guard scrollX != 0 || scrollY != 0 else { return } markDeviceActive(physicalDevice, reason: "Received event from DeviceManager") } func deviceFromCGEvent(_ cgEvent: CGEvent) -> Device? { // Issue: https://github.com/linearmouse/linearmouse/issues/677#issuecomment-1938208542 guard ![.flagsChanged, .keyDown, .keyUp].contains(cgEvent.type) else { return lastActiveDeviceRef?.value } guard let ioHIDEvent = CGEventCopyIOHIDEvent(cgEvent) else { return lastActiveDeviceRef?.value } guard let pointerDevice = manager.pointerDeviceFromIOHIDEvent(ioHIDEvent) else { return lastActiveDeviceRef?.value } guard let physicalDevice = pointerDeviceToDevice[pointerDevice] else { return lastActiveDeviceRef?.value } return physicalDevice } func updatePointerSpeed() { for device in devices { updatePointerSpeed(for: device) } } func updatePointerSpeed(for device: Device) { let scheme = ConfigurationState.shared.configuration.matchScheme( withDevice: device, withPid: NSWorkspace.shared.frontmostApplication?.processIdentifier, withDisplay: ScreenManager.shared .currentScreenName ) if let pointerDisableAcceleration = scheme.pointer.disableAcceleration, pointerDisableAcceleration { // If the pointer acceleration is turned off, it is preferable to utilize // the new API introduced by macOS Sonoma. // Otherwise, set pointer acceleration to -1. if device.disablePointerAcceleration != nil { device.disablePointerAcceleration = true // This might be a bit confusing because of the historical naming // convention, but here, the pointerAcceleration actually refers to // the tracking speed. if let pointerAcceleration = scheme.pointer.acceleration { switch pointerAcceleration { case let .value(v): device.pointerAcceleration = v.asTruncatedDouble case .unset: device.restorePointerAcceleration() } } else { device.restorePointerAcceleration() } } else { device.pointerAcceleration = -1 } return } if device.disablePointerAcceleration != nil { device.disablePointerAcceleration = false } if let pointerSpeed = scheme.pointer.speed { switch pointerSpeed { case let .value(v): device.pointerSpeed = v.asTruncatedDouble case .unset: device.restorePointerSpeed() } } else { device.restorePointerSpeed() } if let pointerAcceleration = scheme.pointer.acceleration { switch pointerAcceleration { case let .value(v): device.pointerAcceleration = v.asTruncatedDouble case .unset: device.restorePointerAcceleration() } } else { device.restorePointerAcceleration() } } func restorePointerSpeedToInitialValue() { for device in devices { device.restorePointerAccelerationAndPointerSpeed() } } func getSystemProperty(forKey key: String) -> T? { let service = IORegistryEntryFromPath(kIOMasterPortDefault, "\(kIOServicePlane):/IOResources/IOHIDSystem") guard service != .zero else { return nil } defer { IOObjectRelease(service) } var handle: io_connect_t = .zero guard IOServiceOpen(service, mach_task_self_, UInt32(kIOHIDParamConnectType), &handle) == KERN_SUCCESS else { return nil } defer { IOServiceClose(handle) } var valueRef: Unmanaged? guard IOHIDCopyCFTypeParameter(handle, key as CFString, &valueRef) == KERN_SUCCESS else { return nil } guard let valueRefUnwrapped = valueRef else { return nil } guard let value = valueRefUnwrapped.takeRetainedValue() as? T else { return nil } return value } func markDeviceActive(_ device: Device, reason: String) { guard lastActiveDeviceId != device.id else { return } lastActiveDeviceId = device.id lastActiveDeviceRef = .init(device) os_log( "Last active device changed: %{public}@, category=%{public}@ (Reason: %{public}@)", log: Self.log, type: .info, String(describing: device), String(describing: device.category), reason ) updatePointerSpeed() } func pairedReceiverDevices(for device: Device) -> [ReceiverLogicalDeviceIdentity] { guard shouldMonitorReceiver(device), let locationID = device.pointerDevice.locationID else { return [] } let identities = receiverPairedDeviceIdentities[locationID] ?? [] os_log( "Receiver paired device lookup: locationID=%{public}d device=%{public}@ count=%{public}u", log: Self.log, type: .info, locationID, String(describing: device), UInt32(identities.count) ) return identities } func preferredName(for device: Device, fallback: String? = nil) -> String { fallback ?? device.name } func displayName(for device: Device, fallbackBaseName: String? = nil) -> String { Self.displayName( baseName: preferredName(for: device, fallback: fallbackBaseName), pairedDevices: pairedReceiverDevices(for: device) ) } private func shouldMonitorReceiver(_ device: Device) -> Bool { guard let vendorID = device.vendorID, vendorID == LogitechHIDPPDeviceMetadataProvider.Constants.vendorID, device.pointerDevice.transport == PointerDeviceTransportName.usb else { return false } let productName = device.productName ?? device.name return productName.localizedCaseInsensitiveContains("receiver") } private func receiverPointingDevicesChanged(locationID: Int, identities: [ReceiverLogicalDeviceIdentity]) { guard pointerDeviceToDevice.values.contains(where: { $0.pointerDevice.locationID == locationID }) else { os_log( "Drop receiver logical device update because no visible device matches locationID=%{public}d count=%{public}u", log: Self.log, type: .info, locationID, UInt32(identities.count) ) return } let previousIdentities = receiverPairedDeviceIdentities[locationID] ?? [] receiverPairedDeviceIdentities[locationID] = identities let identitiesDescription = identities.map { identity in let battery = identity.batteryLevel.map(String.init) ?? "(nil)" return "slot=\(identity.slot) name=\(identity.name) battery=\(battery)" } .joined(separator: ", ") os_log( "Receiver logical devices updated for locationID=%{public}d: %{public}@", log: Self.log, type: .info, locationID, identitiesDescription ) // Only trigger forced reconfiguration when a device has actually reconnected // (a slot appeared that wasn't in the previous identity set), since device // firmware resets diversion state on reconnect. let previousSlots = Set(previousIdentities.map(\.slot)) let hasReconnectedDevice = identities.contains { !previousSlots.contains($0.slot) } if hasReconnectedDevice { for (_, device) in pointerDeviceToDevice where device.pointerDevice.locationID == locationID { device.requestLogitechControlsForcedReconfiguration() } } } private func refreshVisibleDevices() { devices = pointerDeviceToDevice.values.sorted { $0.id < $1.id } } static func displayName(baseName: String, pairedDevices: [ReceiverLogicalDeviceIdentity]) -> String { guard !pairedDevices.isEmpty else { return baseName } if pairedDevices.count == 1, let pairedName = pairedDevices.first?.name { return "\(baseName) (\(pairedName))" } return String( format: NSLocalizedString("%@ (%lld devices)", comment: ""), baseName, Int64(pairedDevices.count) ) } } ================================================ FILE: LinearMouse/Device/InputReportHandler.swift ================================================ // MIT License // Copyright (c) 2021-2026 LinearMouse import CoreGraphics import Foundation typealias MouseButtonEmitter = (_ button: Int, _ down: Bool) -> Void enum SyntheticMouseButtonEventEmitter { static func post(button: Int, down: Bool) { guard let location = CGEvent(source: nil)?.location, let mouseButton = CGMouseButton(rawValue: UInt32(button)), let event = CGEvent( mouseEventSource: nil, mouseType: down ? .otherMouseDown : .otherMouseUp, mouseCursorPosition: location, mouseButton: mouseButton ) else { return } event.flags = ModifierState.normalize(ModifierState.shared.currentFlags) event.setIntegerValueField(.mouseEventButtonNumber, value: Int64(button)) event.isLinearMouseSyntheticEvent = true event.post(tap: .cghidEventTap) } } /// Context passed through the handler chain class InputReportContext { let report: Data var lastButtonStates: UInt8 init(report: Data, lastButtonStates: UInt8) { self.report = report self.lastButtonStates = lastButtonStates } } protocol InputReportHandler { /// Check if this handler should be used for the given device func matches(vendorID: Int, productID: Int) -> Bool /// Whether report observation is needed regardless of button count /// Most devices only need observation when buttonCount == 3 func alwaysNeedsReportObservation() -> Bool /// Handle input report and simulate button events as needed /// Call `next(context)` to pass control to the next handler in the chain func handleReport(_ context: InputReportContext, next: (InputReportContext) -> Void) } extension InputReportHandler { func alwaysNeedsReportObservation() -> Bool { false } } enum InputReportHandlerRegistry { static let handlers: [InputReportHandler] = [ GenericSideButtonHandler(), KensingtonSlimbladeHandler() ] static func handlers(for vendorID: Int, productID: Int) -> [InputReportHandler] { handlers.filter { $0.matches(vendorID: vendorID, productID: productID) } } } ================================================ FILE: LinearMouse/Device/InputReportHandlers/GenericSideButtonHandler.swift ================================================ // MIT License // Copyright (c) 2021-2026 LinearMouse import Foundation /// Handles side button fixes for devices that report only 3 buttons in HID descriptor /// but actually have side buttons (button 3 and 4). /// /// Supported devices: /// - Mi Dual Mode Wireless Mouse Silent Edition (0x2717:0x5014) /// - Delux M729DB mouse (0x248A:0x8266) struct GenericSideButtonHandler: InputReportHandler { private struct Product: Hashable { let vendorID: Int let productID: Int } private static let supportedProducts: Set = [ .init(vendorID: 0x2717, productID: 0x5014), // Mi Silent Mouse .init(vendorID: 0x248A, productID: 0x8266) // Delux M729DB mouse ] private let emit: MouseButtonEmitter init(emit: @escaping MouseButtonEmitter = SyntheticMouseButtonEventEmitter.post) { self.emit = emit } func matches(vendorID: Int, productID: Int) -> Bool { Self.supportedProducts.contains(.init(vendorID: vendorID, productID: productID)) } func handleReport(_ context: InputReportContext, next: (InputReportContext) -> Void) { defer { next(context) } guard context.report.count >= 2 else { return } // Report format: | Button 0 (1 bit) | ... | Button 4 (1 bit) | Not Used (3 bits) | // We only care about bits 3 and 4 (side buttons) let buttonStates = context.report[1] & 0x18 let toggled = context.lastButtonStates ^ buttonStates guard toggled != 0 else { return } for button in 3 ... 4 { guard toggled & (1 << button) != 0 else { continue } let down = buttonStates & (1 << button) != 0 emit(button, down) } context.lastButtonStates = buttonStates } } ================================================ FILE: LinearMouse/Device/InputReportHandlers/KensingtonSlimbladeHandler.swift ================================================ // MIT License // Copyright (c) 2021-2026 LinearMouse import Foundation /// Handles Kensington Slimblade trackball's top buttons. /// /// The Slimblade has vendor-defined buttons that are reported in a different /// format than standard HID buttons. This handler parses byte 4 of the input /// report to detect top-left and top-right button presses. /// /// Supported devices: /// - Kensington Slimblade (0x047D:0x2041) struct KensingtonSlimbladeHandler: InputReportHandler { private static let vendorID = 0x047D private static let productID = 0x2041 private static let topLeftMask: UInt8 = 0x1 private static let topRightMask: UInt8 = 0x2 private let emit: MouseButtonEmitter init(emit: @escaping MouseButtonEmitter = SyntheticMouseButtonEventEmitter.post) { self.emit = emit } func matches(vendorID: Int, productID: Int) -> Bool { vendorID == Self.vendorID && productID == Self.productID } func alwaysNeedsReportObservation() -> Bool { // Slimblade needs report monitoring regardless of button count true } func handleReport(_ context: InputReportContext, next: (InputReportContext) -> Void) { defer { next(context) } guard context.report.count >= 5 else { return } // For Slimblade, byte 4 contains the vendor-defined button states let buttonStates = context.report[4] let toggled = context.lastButtonStates ^ buttonStates guard toggled != 0 else { return } // Check top left button (maps to button 3) if toggled & Self.topLeftMask != 0 { let down = buttonStates & Self.topLeftMask != 0 emit(3, down) } // Check top right button (maps to button 4) if toggled & Self.topRightMask != 0 { let down = buttonStates & Self.topRightMask != 0 emit(4, down) } context.lastButtonStates = buttonStates } } ================================================ FILE: LinearMouse/Device/ReceiverLogicalDeviceIdentity.swift ================================================ // MIT License // Copyright (c) 2021-2026 LinearMouse import Foundation enum ReceiverLogicalDeviceKind: UInt8, Hashable { case keyboard = 0x01 case mouse = 0x02 case numpad = 0x03 case trackball = 0x08 case touchpad = 0x09 var isPointingDevice: Bool { switch self { case .mouse, .trackball, .touchpad: return true case .keyboard, .numpad: return false } } } struct ReceiverLogicalDeviceIdentity: Hashable { let receiverLocationID: Int let slot: UInt8 let kind: ReceiverLogicalDeviceKind let name: String let serialNumber: String? let productID: Int? let batteryLevel: Int? func isSameLogicalDevice(as other: Self) -> Bool { receiverLocationID == other.receiverLocationID && slot == other.slot } } ================================================ FILE: LinearMouse/Device/ReceiverMonitor.swift ================================================ // MIT License // Copyright (c) 2021-2026 LinearMouse import Foundation import os.log final class ReceiverMonitor { static let log = OSLog(subsystem: Bundle.main.bundleIdentifier!, category: "ReceiverMonitor") static let initialDiscoveryTimeout: TimeInterval = 3 static let refreshInterval: TimeInterval = 15 private let provider = LogitechHIDPPDeviceMetadataProvider() private var contexts = [Int: ReceiverContext]() var onPointingDevicesChanged: ((Int, [ReceiverLogicalDeviceIdentity]) -> Void)? func startMonitoring(device: Device) { guard let locationID = device.pointerDevice.locationID else { return } guard contexts[locationID] == nil else { return } let context = ReceiverContext(device: device, locationID: locationID, provider: provider) context.onDiscoveryTimedOut = { [weak self, weak context] in guard let self, let context, self.contexts[locationID] === context else { return } self.onPointingDevicesChanged?(locationID, []) } context.onSlotsChanged = { [weak self, weak context] identities in guard let self, let context, self.contexts[locationID] === context else { return } self.onPointingDevicesChanged?(locationID, identities) } contexts[locationID] = context context.start() os_log("Started receiver monitor for %{public}@", log: Self.log, type: .info, String(describing: device)) } func stopMonitoring(device: Device) { guard let locationID = device.pointerDevice.locationID, let context = contexts.removeValue(forKey: locationID) else { return } context.stop() } } struct ReceiverSlotStateStore { enum SlotPresenceState { case unknown case connected case disconnected } private var pairedIdentitiesBySlot = [UInt8: ReceiverLogicalDeviceIdentity]() private var slotPresenceBySlot = [UInt8: SlotPresenceState]() mutating func reset() { pairedIdentitiesBySlot = [:] slotPresenceBySlot = [:] } mutating func mergeDiscovery(_ discovery: LogitechHIDPPDeviceMetadataProvider.ReceiverPointingDeviceDiscovery) { let latestIdentitiesBySlot = Dictionary(uniqueKeysWithValues: discovery.identities.map { ($0.slot, $0) }) let previousIdentitiesBySlot = pairedIdentitiesBySlot for slot in pairedIdentitiesBySlot.keys where latestIdentitiesBySlot[slot] == nil { pairedIdentitiesBySlot.removeValue(forKey: slot) slotPresenceBySlot.removeValue(forKey: slot) } for (slot, identity) in latestIdentitiesBySlot { pairedIdentitiesBySlot[slot] = identity if slotPresenceBySlot[slot] == nil { slotPresenceBySlot[slot] = .unknown } } mergeConnectionSnapshots(discovery.connectionSnapshots) for slot in discovery.liveReachableSlots { if slotPresenceBySlot[slot] != .connected { slotPresenceBySlot[slot] = .connected } } for (slot, identity) in latestIdentitiesBySlot where discovery.connectionSnapshots[slot] == nil { guard slotPresenceBySlot[slot] == .disconnected, previousIdentitiesBySlot[slot]?.batteryLevel == nil, identity.batteryLevel != nil, !discovery.liveReachableSlots.contains(slot) else { continue } slotPresenceBySlot[slot] = .connected } } mutating func mergeConnectionSnapshots( _ newSnapshots: [UInt8: LogitechHIDPPDeviceMetadataProvider.ReceiverConnectionSnapshot] ) { for (slot, snapshot) in newSnapshots { let newPresence: SlotPresenceState = snapshot.isConnected ? .connected : .disconnected let oldPresence = slotPresenceBySlot[slot] slotPresenceBySlot[slot] = newPresence // Clear stale identity when a device reconnects to a slot, // so the next needsIdentityRefresh check will trigger a refresh. if newPresence == .connected, oldPresence == .disconnected { pairedIdentitiesBySlot.removeValue(forKey: slot) } } } mutating func updateSlotIdentity(_ identity: ReceiverLogicalDeviceIdentity) { pairedIdentitiesBySlot[identity.slot] = identity slotPresenceBySlot[identity.slot] = .connected } func needsIdentityRefresh(slot: UInt8) -> Bool { pairedIdentitiesBySlot[slot] == nil } func currentPublishedIdentities() -> [ReceiverLogicalDeviceIdentity] { pairedIdentitiesBySlot.keys.sorted().compactMap { slot in guard let identity = pairedIdentitiesBySlot[slot] else { return nil } return slotPresenceBySlot[slot] == .disconnected ? nil : identity } } } private final class ReceiverContext { let device: Device private let locationID: Int private let provider: LogitechHIDPPDeviceMetadataProvider private var workerThread: Thread? private var isRunning = false private let stateLock = NSLock() private var lastPublishedIdentities = [ReceiverLogicalDeviceIdentity]() private var stateStore = ReceiverSlotStateStore() var onDiscoveryTimedOut: (() -> Void)? var onSlotsChanged: (([ReceiverLogicalDeviceIdentity]) -> Void)? init(device: Device, locationID: Int, provider: LogitechHIDPPDeviceMetadataProvider) { self.device = device self.locationID = locationID self.provider = provider } func start() { stateLock.lock() defer { stateLock.unlock() } guard !isRunning else { return } isRunning = true lastPublishedIdentities = [] stateStore.reset() let thread = Thread { [weak self] in self?.workerMain() } thread.name = "linearmouse.receiver-monitor.\(locationID)" workerThread = thread thread.start() } func stop() { stateLock.lock() isRunning = false let thread = workerThread workerThread = nil stateLock.unlock() thread?.cancel() } private func workerMain() { let initialDeadline = Date().addingTimeInterval(ReceiverMonitor.initialDiscoveryTimeout) var hasPublishedInitialState = false var currentChannel: LogitechReceiverChannel? var hasCompletedInitialDiscovery = false while shouldContinueRunning() { if currentChannel == nil { currentChannel = provider.openReceiverChannel(for: device.pointerDevice) hasCompletedInitialDiscovery = false } guard let receiverChannel = currentChannel else { os_log( "Receiver monitor is waiting for channel: locationID=%{public}d device=%{public}@", log: ReceiverMonitor.log, type: .info, locationID, String(describing: device) ) if !hasPublishedInitialState, Date() >= initialDeadline { DispatchQueue.main.async { [weak self] in self?.onDiscoveryTimedOut?() } hasPublishedInitialState = true } CFRunLoopRunInMode(.defaultMode, 0.5, true) continue } // Full discovery only once per channel open if !hasCompletedInitialDiscovery { let discovery = provider.receiverPointingDeviceDiscovery( for: device.pointerDevice, using: receiverChannel ) mergeDiscovery(discovery) hasCompletedInitialDiscovery = true let identities = currentPublishedIdentities() let identitiesDescription = identities.map { identity in let battery = identity.batteryLevel.map(String.init) ?? "(nil)" return "slot=\(identity.slot) name=\(identity.name) battery=\(battery)" } .joined(separator: ", ") os_log( "Receiver initial discovery completed: locationID=%{public}d count=%{public}u identities=%{public}@", log: ReceiverMonitor.log, type: .info, locationID, UInt32(identities.count), identitiesDescription ) if identities != lastPublishedIdentities { publish(identities) hasPublishedInitialState = true } else if !hasPublishedInitialState, !identities.isEmpty { publish(identities) hasPublishedInitialState = true } else if !hasPublishedInitialState, Date() >= initialDeadline { os_log( "Receiver logical discovery timed out: locationID=%{public}d device=%{public}@", log: ReceiverMonitor.log, type: .info, locationID, String(describing: device) ) DispatchQueue.main.async { [weak self] in self?.onDiscoveryTimedOut?() } hasPublishedInitialState = true } } // Wait for connection events (event-driven, no periodic rescan) let connectionSnapshots = provider.waitForReceiverConnectionChange( using: receiverChannel, timeout: ReceiverMonitor.refreshInterval ) { [weak self] in self?.shouldContinueRunning() ?? false } if !shouldContinueRunning() { break } guard !connectionSnapshots.isEmpty else { // Timeout with no events — verify channel is still alive if receiverChannel.readNotificationFlags() == nil { os_log( "Receiver channel appears dead, will reopen: locationID=%{public}d device=%{public}@", log: ReceiverMonitor.log, type: .info, locationID, String(describing: device) ) currentChannel = nil } continue } mergeConnectionSnapshots(connectionSnapshots) // For newly connected devices, read their identity info for (slot, snapshot) in connectionSnapshots where snapshot.isConnected { if needsIdentityRefresh(slot: slot) { refreshSlotIdentity( slot: slot, connectionSnapshot: snapshot, using: receiverChannel ) } } let snapshotDescription = connectionSnapshots.keys .sorted() .compactMap { slot -> String? in guard let snapshot = connectionSnapshots[slot] else { return nil } return "slot=\(slot) connected=\(snapshot.isConnected)" } .joined(separator: ", ") os_log( "Receiver connection change detected: locationID=%{public}d device=%{public}@ snapshots=%{public}@", log: ReceiverMonitor.log, type: .info, locationID, String(describing: device), snapshotDescription ) let identities = currentPublishedIdentities() if identities != lastPublishedIdentities { publish(identities) } } } private func publish(_ identities: [ReceiverLogicalDeviceIdentity]) { lastPublishedIdentities = identities let identitiesDescription = identities.map { identity in let battery = identity.batteryLevel.map(String.init) ?? "(nil)" return "slot=\(identity.slot) name=\(identity.name) battery=\(battery)" } .joined(separator: ", ") os_log( "Receiver logical discovery updated: locationID=%{public}d identities=%{public}@", log: ReceiverMonitor.log, type: .info, locationID, identitiesDescription ) DispatchQueue.main.async { [weak self] in self?.onSlotsChanged?(identities) } } private func shouldContinueRunning() -> Bool { stateLock.lock() defer { stateLock.unlock() } return isRunning } private func mergeDiscovery(_ discovery: LogitechHIDPPDeviceMetadataProvider.ReceiverPointingDeviceDiscovery) { stateLock.lock() defer { stateLock.unlock() } stateStore.mergeDiscovery(discovery) } private func mergeConnectionSnapshots( _ newSnapshots: [UInt8: LogitechHIDPPDeviceMetadataProvider.ReceiverConnectionSnapshot] ) { guard !newSnapshots.isEmpty else { return } stateLock.lock() stateStore.mergeConnectionSnapshots(newSnapshots) stateLock.unlock() } private func refreshSlotIdentity( slot: UInt8, connectionSnapshot: LogitechHIDPPDeviceMetadataProvider.ReceiverConnectionSnapshot?, using receiverChannel: LogitechReceiverChannel ) { guard let identity = provider.receiverSlotIdentity( for: device.pointerDevice, slot: slot, connectionSnapshot: connectionSnapshot, using: receiverChannel ) else { return } stateLock.lock() stateStore.updateSlotIdentity(identity) stateLock.unlock() os_log( "Refreshed slot identity: locationID=%{public}d slot=%{public}u name=%{public}@ battery=%{public}@", log: ReceiverMonitor.log, type: .info, locationID, slot, identity.name, identity.batteryLevel.map(String.init) ?? "(nil)" ) } private func needsIdentityRefresh(slot: UInt8) -> Bool { stateLock.lock() let needs = stateStore.needsIdentityRefresh(slot: slot) stateLock.unlock() return needs } private func currentPublishedIdentities() -> [ReceiverLogicalDeviceIdentity] { stateLock.lock() let identities = stateStore.currentPublishedIdentities() stateLock.unlock() return identities } } ================================================ FILE: LinearMouse/Device/VendorSpecific/BatteryDeviceMonitor.swift ================================================ // MIT License // Copyright (c) 2021-2026 LinearMouse import Combine import Foundation final class BatteryDeviceMonitor: NSObject, ObservableObject { static let shared = BatteryDeviceMonitor() @Published private(set) var devices: [ConnectedBatteryDeviceInfo] = [] private static let pollingInterval: TimeInterval = 60 private let queue = DispatchQueue(label: "linearmouse.battery-monitor", qos: .utility) private let timerQueue = DispatchQueue(label: "linearmouse.battery-monitor.timer", qos: .utility) private var timer: DispatchSourceTimer? private var isRunning = false private var isRefreshing = false private var needsRefresh = false private let stateLock = NSLock() private var subscriptions = Set() override init() { super.init() DeviceManager.shared .$devices .receive(on: RunLoop.main) .debounce(for: .milliseconds(250), scheduler: RunLoop.main) .sink { [weak self] _ in self?.refreshIfNeeded() } .store(in: &subscriptions) DeviceManager.shared .$receiverPairedDeviceIdentities .receive(on: RunLoop.main) .debounce(for: .milliseconds(250), scheduler: RunLoop.main) .sink { [weak self] _ in self?.refreshIfNeeded() } .store(in: &subscriptions) } func start() { stateLock.lock() defer { stateLock.unlock() } guard !isRunning else { return } isRunning = true let timer = DispatchSource.makeTimerSource(queue: timerQueue) timer.schedule(deadline: .now(), repeating: Self.pollingInterval) timer.setEventHandler { [weak self] in self?.refreshIfNeeded() } self.timer = timer timer.resume() } func stop() { stateLock.lock() defer { stateLock.unlock() } guard isRunning else { return } isRunning = false timer?.setEventHandler {} timer?.cancel() timer = nil } func currentDeviceBatteryLevel(for device: Device) -> Int? { let pairedDevices = DeviceManager.shared.pairedReceiverDevices(for: device) let directDeviceIdentity = ConnectedBatteryDeviceInfo.directIdentity( vendorID: device.vendorID, productID: device.productID, serialNumber: device.serialNumber, locationID: device.pointerDevice.locationID, transport: device.pointerDevice.transport, fallbackName: device.productName ?? device.name ) return ConnectedBatteryDeviceInfo.currentDeviceBatteryLevel( pairedDevices: pairedDevices, directDeviceIdentity: directDeviceIdentity, inventory: devices ) } private func refreshIfNeeded() { stateLock.lock() guard isRunning, !isRefreshing else { if isRunning { needsRefresh = true } stateLock.unlock() return } isRefreshing = true needsRefresh = false stateLock.unlock() queue.async { [weak self] in guard let self else { return } let receiverPairedBatteries = DeviceManager.shared.devices.flatMap { device in DeviceManager.shared .pairedReceiverDevices(for: device) .compactMap { identity -> ConnectedBatteryDeviceInfo? in guard let batteryLevel = identity.batteryLevel else { return nil } return ConnectedBatteryDeviceInfo( id: ConnectedBatteryDeviceInfo.receiverIdentity( receiverLocationID: identity.receiverLocationID, slot: identity.slot ), name: identity.name, batteryLevel: batteryLevel ) } } let visibleDeviceBatteries = DeviceManager.shared .devices .compactMap { device -> ConnectedBatteryDeviceInfo? in guard DeviceManager.shared.pairedReceiverDevices(for: device).isEmpty, let batteryLevel = device.batteryLevel else { return nil } return ConnectedBatteryDeviceInfo( id: ConnectedBatteryDeviceInfo.directIdentity( vendorID: device.vendorID, productID: device.productID, serialNumber: device.serialNumber, locationID: device.pointerDevice.locationID, transport: device.pointerDevice.transport, fallbackName: device.productName ?? device.name ), name: device.name, batteryLevel: batteryLevel ) } let propertyBackedDevices = ConnectedBatteryDeviceInventory.devices() let directlyAddressableLogitechDevices = DeviceManager.shared.devices.filter { DeviceManager.shared.pairedReceiverDevices(for: $0).isEmpty } let logitechDevices = ConnectedLogitechDeviceInventory .devices(from: directlyAddressableLogitechDevices.map(\.pointerDevice)) DispatchQueue.main.async { self.devices = self.merge( logitechDevices: receiverPairedBatteries + visibleDeviceBatteries + logitechDevices, propertyBackedDevices: propertyBackedDevices ) } self.finishRefreshCycle() } } private func finishRefreshCycle() { stateLock.lock() isRefreshing = false let shouldRefreshAgain = needsRefresh needsRefresh = false stateLock.unlock() if shouldRefreshAgain { refreshIfNeeded() } } private func merge( logitechDevices: [ConnectedBatteryDeviceInfo], propertyBackedDevices: [ConnectedBatteryDeviceInfo] ) -> [ConnectedBatteryDeviceInfo] { var merged = [ConnectedBatteryDeviceInfo]() var seen = Set() for device in logitechDevices + propertyBackedDevices { guard seen.insert(device.id).inserted else { continue } merged.append(device) } return merged.sorted { let byName = $0.name.localizedCaseInsensitiveCompare($1.name) if byName == .orderedSame { return $0.batteryLevel > $1.batteryLevel } return byName == .orderedAscending } } } ================================================ FILE: LinearMouse/Device/VendorSpecific/ConnectedBatteryDeviceInventory.swift ================================================ // MIT License // Copyright (c) 2021-2026 LinearMouse import Foundation import IOKit.hid import PointerKit private typealias AppleBatterySnapshot = (id: String, level: Int) struct ConnectedBatteryDeviceInfo: Hashable { let id: String let name: String let batteryLevel: Int static func directIdentity( vendorID: Int?, productID: Int?, serialNumber: String?, locationID: Int?, transport: String?, fallbackName: String ) -> String { if let serialNumber, !serialNumber.isEmpty { return "serial|\(vendorID ?? 0)|\(productID ?? 0)|\(serialNumber)" } if let locationID { return "location|\(vendorID ?? 0)|\(productID ?? 0)|\(locationID)" } return "fallback|\(transport ?? "")|\(vendorID ?? 0)|\(productID ?? 0)|\(fallbackName)" } static func receiverIdentity(receiverLocationID: Int, slot: UInt8) -> String { "receiver|\(receiverLocationID)|\(slot)" } static func currentDeviceBatteryLevel( pairedDevices: [ReceiverLogicalDeviceIdentity], directDeviceIdentity: String?, inventory: [Self] ) -> Int? { let pairedBatteryLevels = pairedDevices.compactMap(\.batteryLevel) if let lowestPairedBatteryLevel = pairedBatteryLevels.min() { return lowestPairedBatteryLevel } guard let directDeviceIdentity else { return nil } return inventory.first { $0.id == directDeviceIdentity }?.batteryLevel } static func isAppleBluetoothDevice(vendorID: Int?, transport: String?) -> Bool { vendorID == 0x004C && transport == PointerDeviceTransportName.bluetooth } } enum ConnectedBatteryDeviceInventory { static func devices() -> [ConnectedBatteryDeviceInfo] { let manager = IOHIDManagerCreate(kCFAllocatorDefault, IOOptionBits(kIOHIDOptionsTypeNone)) IOHIDManagerSetDeviceMatching(manager, nil) guard IOHIDManagerOpen(manager, IOOptionBits(kIOHIDOptionsTypeNone)) == kIOReturnSuccess else { return [] } defer { IOHIDManagerClose(manager, IOOptionBits(kIOHIDOptionsTypeNone)) } guard let hidDevices = IOHIDManagerCopyDevices(manager) as? Set else { return [] } var results = [ConnectedBatteryDeviceInfo]() var seen = Set() let appleBatterySnapshots = appleBluetoothBatterySnapshots() for hidDevice in hidDevices { guard let result = batteryDeviceInfo(for: hidDevice, appleBatterySnapshots: appleBatterySnapshots) else { continue } guard seen.insert(result.id).inserted else { continue } results.append(result) } return results.sorted { let byName = $0.name.localizedCaseInsensitiveCompare($1.name) if byName == .orderedSame { return $0.batteryLevel > $1.batteryLevel } return byName == .orderedAscending } } private static func batteryDeviceInfo( for hidDevice: IOHIDDevice, appleBatterySnapshots: [AppleBatterySnapshot] ) -> ConnectedBatteryDeviceInfo? { let vendorID: NSNumber? = getProperty(kIOHIDVendorIDKey, from: hidDevice) let productID: NSNumber? = getProperty(kIOHIDProductIDKey, from: hidDevice) let serialNumber: String? = getProperty(kIOHIDSerialNumberKey, from: hidDevice) let locationID: NSNumber? = getProperty("LocationID", from: hidDevice) let transport: String? = getProperty("Transport", from: hidDevice) let candidateKeys = [ "BatteryPercent", "BatteryLevel", "BatteryPercentRemaining", "BatteryPercentSingle" ] let directBatteryLevel = candidateKeys.lazy .compactMap { key -> Int? in if let value: NSNumber = getProperty(key, from: hidDevice) { return value.intValue } return nil } .first let batteryLevel = directBatteryLevel ?? fallbackAppleBluetoothBatteryLevel( vendorID: vendorID?.intValue, productID: productID?.intValue, serialNumber: serialNumber, locationID: locationID?.intValue, transport: transport, appleBatterySnapshots: appleBatterySnapshots ) guard let batteryLevel else { return nil } let name: String = getProperty(kIOHIDProductKey, from: hidDevice) ?? "(unknown)" if isGenericLogitechReceiver(name: name, hidDevice: hidDevice) { return nil } return ConnectedBatteryDeviceInfo( id: ConnectedBatteryDeviceInfo.directIdentity( vendorID: vendorID?.intValue, productID: productID?.intValue, serialNumber: serialNumber, locationID: locationID?.intValue, transport: transport, fallbackName: name ), name: name, batteryLevel: batteryLevel ) } private static func isGenericLogitechReceiver(name: String, hidDevice: IOHIDDevice) -> Bool { guard let vendorID: NSNumber = getProperty(kIOHIDVendorIDKey, from: hidDevice), vendorID.intValue == 0x046D else { return false } return name.localizedCaseInsensitiveContains("receiver") } private static func getProperty(_ key: String, from device: IOHIDDevice) -> T? { guard let value = IOHIDDeviceGetProperty(device, key as CFString) else { return nil } return value as? T } private static func fallbackAppleBluetoothBatteryLevel( vendorID: Int?, productID: Int?, serialNumber: String?, locationID: Int?, transport: String?, appleBatterySnapshots: [AppleBatterySnapshot] ) -> Int? { guard ConnectedBatteryDeviceInfo.isAppleBluetoothDevice(vendorID: vendorID, transport: transport) else { return nil } let directID = ConnectedBatteryDeviceInfo.directIdentity( vendorID: vendorID, productID: productID, serialNumber: serialNumber, locationID: locationID, transport: transport, fallbackName: "" ) return appleBatterySnapshots.first { $0.id == directID }?.level } private static func appleBluetoothBatterySnapshots() -> [AppleBatterySnapshot] { var snapshots = [AppleBatterySnapshot]() var iterator = io_iterator_t() guard IOServiceGetMatchingServices( kIOMasterPortDefault, IOServiceMatching("AppleDeviceManagementHIDEventService"), &iterator ) == KERN_SUCCESS else { return [] } defer { IOObjectRelease(iterator) } var service = IOIteratorNext(iterator) while service != MACH_PORT_NULL { defer { IOObjectRelease(service) service = IOIteratorNext(iterator) } guard let vendorIDNumber = IORegistryEntryCreateCFProperty( service, kIOHIDVendorIDKey as CFString, kCFAllocatorDefault, 0 )?.takeRetainedValue() as? NSNumber, let transport = IORegistryEntryCreateCFProperty( service, "Transport" as CFString, kCFAllocatorDefault, 0 )?.takeRetainedValue() as? String, let batteryLevel = IORegistryEntryCreateCFProperty( service, "BatteryPercent" as CFString, kCFAllocatorDefault, 0 )?.takeRetainedValue() as? NSNumber, ConnectedBatteryDeviceInfo.isAppleBluetoothDevice( vendorID: vendorIDNumber.intValue, transport: transport ) else { continue } let productID = (IORegistryEntryCreateCFProperty( service, kIOHIDProductIDKey as CFString, kCFAllocatorDefault, 0 )?.takeRetainedValue() as? NSNumber)?.intValue let serialNumber = IORegistryEntryCreateCFProperty( service, kIOHIDSerialNumberKey as CFString, kCFAllocatorDefault, 0 )?.takeRetainedValue() as? String let locationID = (IORegistryEntryCreateCFProperty( service, "LocationID" as CFString, kCFAllocatorDefault, 0 )?.takeRetainedValue() as? NSNumber)?.intValue let id = ConnectedBatteryDeviceInfo.directIdentity( vendorID: vendorIDNumber.intValue, productID: productID, serialNumber: serialNumber, locationID: locationID, transport: transport, fallbackName: "" ) snapshots.append((id: id, level: batteryLevel.intValue)) } return snapshots } } ================================================ FILE: LinearMouse/Device/VendorSpecific/ConnectedLogitechDeviceInventory.swift ================================================ // MIT License // Copyright (c) 2021-2026 LinearMouse import Foundation import PointerKit enum ConnectedLogitechDeviceInventory { static func devices(from pointerDevices: [PointerDevice]) -> [ConnectedBatteryDeviceInfo] { var results = [ConnectedBatteryDeviceInfo]() var seen = Set() for device in pointerDevices where device.vendorID == LogitechHIDPPDeviceMetadataProvider.Constants.vendorID { let productName = device.product ?? device.name if device.transport == PointerDeviceTransportName.usb, productName.localizedCaseInsensitiveContains("receiver") { continue } guard let metadata = VendorSpecificDeviceMetadataRegistry.metadata(for: device), let batteryLevel = metadata.batteryLevel else { continue } let name = metadata.name ?? productName let identity = ConnectedBatteryDeviceInfo.directIdentity( vendorID: device.vendorID, productID: device.productID, serialNumber: device.serialNumber, locationID: device.locationID, transport: device.transport, fallbackName: name ) guard seen.insert(identity).inserted else { continue } results.append(.init(id: identity, name: name, batteryLevel: batteryLevel)) } return results.sorted { let byName = $0.name.localizedCaseInsensitiveCompare($1.name) if byName == .orderedSame { return $0.batteryLevel > $1.batteryLevel } return byName == .orderedAscending } } } ================================================ FILE: LinearMouse/Device/VendorSpecific/Logitech/LogitechHIDPPDeviceMetadataProvider.swift ================================================ // MIT License // Copyright (c) 2021-2026 LinearMouse import AppKit import Combine import Foundation import IOKit.hid import ObservationToken import os.log import PointerKit struct LogitechHIDPPDeviceMetadataProvider: VendorSpecificDeviceMetadataProvider { static let log = OSLog( subsystem: Bundle.main.bundleIdentifier ?? "LinearMouse", category: "LogitechHIDPP" ) enum Constants { static let vendorID = 0x046D static let softwareID: UInt8 = 0x08 static let shortReportID: UInt8 = 0x10 static let longReportID: UInt8 = 0x11 static let shortReportLength = 7 static let longReportLength = 20 static let timeout: TimeInterval = 2.0 static let receiverIndex: UInt8 = 0xFF static let directReplyIndices: Set = [0x00, 0xFF] static let receiverNotificationFlagsRegister: UInt8 = 0x00 static let receiverConnectionStateRegister: UInt8 = 0x02 static let receiverInfoRegister: UInt8 = 0xB5 static let receiverWirelessNotifications: UInt32 = 0x000100 static let receiverSoftwarePresentNotifications: UInt32 = 0x000800 } enum FeatureID: UInt16 { case root = 0x0000 case deviceName = 0x0005 case deviceFriendlyName = 0x0007 case batteryStatus = 0x1000 case batteryVoltage = 0x1001 case unifiedBattery = 0x1004 case reprogControlsV4 = 0x1B04 case adcMeasurement = 0x1F20 } enum ReprogControlsV4 { static let gestureButtonControlIDs: Set = [0x00C3, 0x00D0] static let virtualGestureButtonControlIDs: Set = [0x00D7] static let gestureButtonTaskIDs: Set = [0x009C, 0x00A9, 0x00AD] static let virtualGestureButtonTaskIDs: Set = [0x00B4] /// Control IDs that should never be diverted because they are natively handled by the OS. /// Diverting these would break their default behavior (click, back/forward, etc.). static let nativeControlIDs: Set = [ 0x0050, 0x0051, 0x0052, // Left / right / middle mouse button 0x0053, 0x0056, // Standard back/forward 0x00CE, 0x00CF, // Alternate back/forward 0x00D9, 0x00DB // Additional back/forward variants ] /// Reserved button number written into config for virtual controls. /// Older versions do not generate this button, so persisted mappings will not misfire after downgrade. /// This value is intentionally vendor-agnostic so other protocol-backed controls can reuse it later. static let reservedVirtualButtonNumber = 0x1000 static let getControlCountFunction: UInt8 = 0x00 static let getControlInfoFunction: UInt8 = 0x01 static let getControlReportingFunction: UInt8 = 0x02 static let setControlReportingFunction: UInt8 = 0x03 struct ControlFlags: OptionSet { let rawValue: UInt16 static let mouseButton = Self(rawValue: 1 << 0) static let reprogrammable = Self(rawValue: 1 << 4) static let divertable = Self(rawValue: 1 << 5) static let persistentlyDivertable = Self(rawValue: 1 << 6) static let virtual = Self(rawValue: 1 << 7) static let rawXY = Self(rawValue: 1 << 8) static let forceRawXY = Self(rawValue: 1 << 9) } struct ReportingFlags: OptionSet { let rawValue: UInt16 static let diverted = Self(rawValue: 1 << 0) static let persistentlyDiverted = Self(rawValue: 1 << 2) static let rawXYDiverted = Self(rawValue: 1 << 4) static let forceRawXYDiverted = Self(rawValue: 1 << 6) } } private enum DeviceKind: UInt8 { case keyboard = 0x01 case mouse = 0x02 case trackball = 0x08 case touchpad = 0x09 } struct Response { let payload: [UInt8] } struct ReceiverSlotInfo { let slot: UInt8 let kind: UInt8 let name: String? let productID: Int? let serialNumber: String? let batteryLevel: Int? let hasLiveMetadata: Bool } struct ReceiverSlotMetadata { let slot: UInt8 let name: String? let batteryLevel: Int? } struct ReceiverConnectionSnapshot: Equatable { let isConnected: Bool let kind: UInt8? } struct ReceiverSlotDiscovery { let slots: [ReceiverSlotInfo] let connectionSnapshots: [UInt8: ReceiverConnectionSnapshot] } struct ReceiverPointingDeviceDiscovery { let identities: [ReceiverLogicalDeviceIdentity] let connectionSnapshots: [UInt8: ReceiverConnectionSnapshot] let liveReachableSlots: Set } struct ReceiverSlotMatchCandidate { let slot: UInt8 let kind: UInt8 let name: String? let serialNumber: String? let productID: Int? let batteryLevel: Int? let hasLiveMetadata: Bool } private enum ApproximateBatteryLevel: UInt8 { case full = 8 case good = 4 case low = 2 case critical = 1 var percent: Int { switch self { case .full: return 100 case .good: return 50 case .low: return 20 case .critical: return 5 } } } let matcher = VendorSpecificDeviceMatcher( vendorID: Constants.vendorID, productIDs: nil, transports: [PointerDeviceTransportName.bluetoothLowEnergy, PointerDeviceTransportName.usb] ) func matches(device: VendorSpecificDeviceContext) -> Bool { let maxInputReportSize = device.maxInputReportSize ?? 0 let maxOutputReportSize = device.maxOutputReportSize ?? 0 if isReceiverVendorChannel(device) { return false } return matcher.matches(device: device) && maxInputReportSize >= Constants.shortReportLength && maxOutputReportSize >= Constants.shortReportLength } func metadata(for device: VendorSpecificDeviceContext) -> VendorSpecificDeviceMetadata? { if let directTransport = directTransport(for: device) { return metadata(using: directTransport) } if let receiverTransport = receiverTransport(for: device) { return metadata(using: receiverTransport) } return nil } func receiverPointingDeviceDiscovery(for device: VendorSpecificDeviceContext) -> ReceiverPointingDeviceDiscovery { guard device.transport == PointerDeviceTransportName.usb else { os_log( "Skip receiver discovery for non-USB device: name=%{public}@ transport=%{public}@", log: Self.log, type: .info, device.name, device.transport ?? "(nil)" ) return .init(identities: [], connectionSnapshots: [:], liveReachableSlots: []) } guard let locationID = device.locationID else { os_log( "Skip receiver discovery without locationID: name=%{public}@", log: Self.log, type: .info, device.name ) return .init(identities: [], connectionSnapshots: [:], liveReachableSlots: []) } guard let receiverChannel = openReceiverChannel(for: device) else { os_log( "Failed to open receiver channel: locationID=%{public}d name=%{public}@", log: Self.log, type: .info, locationID, device.product ?? device.name ) return .init(identities: [], connectionSnapshots: [:], liveReachableSlots: []) } let discovery = receiverPointingDeviceDiscovery(for: device, using: receiverChannel) let slots = discovery.identities let slotSummary = slots.map { identity in let battery = identity.batteryLevel.map(String.init) ?? "(nil)" let name = identity.name return "slot=\(identity.slot) kind=\(identity.kind.rawValue) name=\(name) battery=\(battery)" } .joined(separator: ", ") os_log( "Receiver discovery produced identities: locationID=%{public}d count=%{public}u identities=%{public}@", log: Self.log, type: .info, locationID, UInt32(slots.count), slotSummary ) return discovery } func receiverPointingDeviceIdentities(for device: VendorSpecificDeviceContext) -> [ReceiverLogicalDeviceIdentity] { receiverPointingDeviceDiscovery(for: device).identities } func openReceiverChannel(for device: VendorSpecificDeviceContext) -> LogitechReceiverChannel? { guard device.transport == PointerDeviceTransportName.usb, let locationID = device.locationID else { return nil } return LogitechReceiverChannel.open(locationID: locationID) } func receiverSlot(for device: VendorSpecificDeviceContext) -> UInt8? { guard device.transport == PointerDeviceTransportName.usb, let locationID = device.locationID, let receiverChannel = LogitechReceiverChannel.open(locationID: locationID) else { return nil } return receiverSlot(for: device, using: receiverChannel) } func receiverSlot(for device: VendorSpecificDeviceContext, using receiver: LogitechReceiverChannel) -> UInt8? { discoverReceiverSlot(for: device, using: receiver)?.slot } func receiverPointingDeviceDiscovery( for device: VendorSpecificDeviceContext, using receiverChannel: LogitechReceiverChannel ) -> ReceiverPointingDeviceDiscovery { receiverChannel.discoverPointingDeviceDiscovery(baseName: device.product ?? device.name) } func receiverSlotIdentity( for device: VendorSpecificDeviceContext, slot: UInt8, connectionSnapshot: ReceiverConnectionSnapshot?, using receiverChannel: LogitechReceiverChannel ) -> ReceiverLogicalDeviceIdentity? { guard let locationID = receiverChannel.locationID else { return nil } guard let slotInfo = receiverChannel.discoverSlotInfo(slot, connectionSnapshot: connectionSnapshot) else { return nil } guard let kind = ReceiverLogicalDeviceKind(rawValue: slotInfo.kind), kind.isPointingDevice else { return nil } return ReceiverLogicalDeviceIdentity( receiverLocationID: locationID, slot: slot, kind: kind, name: slotInfo.name ?? device.product ?? device.name, serialNumber: slotInfo.serialNumber, productID: slotInfo.productID, batteryLevel: slotInfo.batteryLevel ) } func waitForReceiverConnectionChange( for device: VendorSpecificDeviceContext, timeout: TimeInterval, until shouldContinue: @escaping () -> Bool ) -> [UInt8: ReceiverConnectionSnapshot] { guard device.transport == PointerDeviceTransportName.usb, let locationID = device.locationID, let receiverChannel = LogitechReceiverChannel.open(locationID: locationID) else { os_log( "Skip receiver wait because channel is unavailable: name=%{public}@ transport=%{public}@ locationID=%{public}@", log: Self.log, type: .info, device.name, device.transport ?? "(nil)", device.locationID.map(String.init) ?? "(nil)" ) let deadline = Date().addingTimeInterval(timeout) while shouldContinue(), Date() < deadline { CFRunLoopRunInMode(.defaultMode, 0.1, true) } return [:] } receiverChannel.enableWirelessNotifications() return receiverChannel.waitForConnectionSnapshots(timeout: timeout, until: shouldContinue) } func waitForReceiverConnectionChange( using receiverChannel: LogitechReceiverChannel, timeout: TimeInterval, until shouldContinue: @escaping () -> Bool ) -> [UInt8: ReceiverConnectionSnapshot] { receiverChannel.enableWirelessNotifications() return receiverChannel.waitForConnectionSnapshots(timeout: timeout, until: shouldContinue) } private func metadata(using transport: LogitechHIDPPTransport) -> VendorSpecificDeviceMetadata? { let name = readFriendlyName(using: transport) ?? readName(using: transport) let batteryLevel = transport .isReceiverRoutedDevice ? readReceiverBatteryLevel(using: transport) : readBatteryLevel(using: transport) if name == nil, batteryLevel == nil { return nil } return VendorSpecificDeviceMetadata(name: name, batteryLevel: batteryLevel) } private func directTransport(for device: VendorSpecificDeviceContext) -> LogitechHIDPPTransport? { guard device.transport == PointerDeviceTransportName.bluetoothLowEnergy else { return nil } return LogitechHIDPPTransport(device: device, deviceIndex: nil) } private func receiverTransport(for device: VendorSpecificDeviceContext) -> LogitechHIDPPTransport? { guard device.transport == PointerDeviceTransportName.usb, let locationID = device.locationID, let receiverChannel = LogitechReceiverChannel.open(locationID: locationID), let slot = discoverReceiverSlot(for: device, using: receiverChannel)?.slot else { return nil } return LogitechHIDPPTransport(device: receiverChannel, deviceIndex: slot) } private func discoverReceiverSlot( for device: VendorSpecificDeviceContext, using receiver: LogitechReceiverChannel ) -> ReceiverSlotMatchCandidate? { guard let discovery = receiver.discoverMatchCandidates(baseName: device.product ?? device.name) else { return nil } let slots = discovery.0 let normalizedProduct = normalizeName(device.product ?? device.name) let normalizedSerial = normalizeSerial(device.serialNumber) let desiredProductID = device.productID if let serialMatch = slots.first(where: { normalizeSerial($0.serialNumber) == normalizedSerial && normalizedSerial != nil }) { return serialMatch } if let productIDMatch = slots.first(where: { candidate in guard let desiredProductID, let productID = candidate.productID else { return false } return productID == desiredProductID }) { return productIDMatch } if let exactNameMatch = slots.first(where: { guard let name = $0.name else { return false } return normalizeName(name) == normalizedProduct }) { return exactNameMatch } let desiredKinds = preferredReceiverDeviceKinds(for: device) if let kindMatch = slots.first(where: { desiredKinds.contains($0.kind) }) { return kindMatch } if slots.count == 1 { return slots[0] } return nil } fileprivate func discoverRoutedSlots(using receiver: LogitechReceiverChannel) -> [ReceiverSlotMetadata] { var results = [ReceiverSlotMetadata]() for slot in UInt8(1) ... UInt8(6) { guard let transport = LogitechHIDPPTransport(device: receiver, deviceIndex: slot) else { continue } let name = readFriendlyName(using: transport) ?? readName(using: transport) let batteryLevel = readReceiverBatteryLevel(using: transport) guard name != nil || batteryLevel != nil else { continue } results.append(.init(slot: slot, name: name, batteryLevel: batteryLevel)) } return results } private func preferredReceiverDeviceKinds(for device: VendorSpecificDeviceContext) -> Set { guard device.primaryUsagePage == kHIDPage_GenericDesktop else { return [] } switch device.primaryUsage { case kHIDUsage_GD_Mouse, kHIDUsage_GD_Pointer: return [DeviceKind.mouse.rawValue, DeviceKind.trackball.rawValue, DeviceKind.touchpad.rawValue] default: return [] } } fileprivate func readFriendlyName(using transport: LogitechHIDPPTransport) -> String? { guard let featureIndex = transport.featureIndex(for: .deviceFriendlyName), let lengthResponse = transport.request(featureIndex: featureIndex, function: 0x00, parameters: []), let length = lengthResponse.payload.first, length > 0 else { return nil } return readNameFragments( using: transport, featureIndex: featureIndex, length: Int(length), skipFirstPayloadByte: true ) } fileprivate func readName(using transport: LogitechHIDPPTransport) -> String? { guard let featureIndex = transport.featureIndex(for: .deviceName), let lengthResponse = transport.request(featureIndex: featureIndex, function: 0x00, parameters: []), let length = lengthResponse.payload.first, length > 0 else { return nil } return readNameFragments( using: transport, featureIndex: featureIndex, length: Int(length), skipFirstPayloadByte: false ) } private func readNameFragments( using transport: LogitechHIDPPTransport, featureIndex: UInt8, length: Int, skipFirstPayloadByte: Bool ) -> String? { var bytes = [UInt8]() var offset = 0 while offset < length { guard let response = transport.request( featureIndex: featureIndex, function: 0x01, parameters: [UInt8(offset)] ) else { return nil } let fragment = skipFirstPayloadByte ? Array(response.payload.dropFirst()) : response.payload if fragment.isEmpty { break } bytes.append(contentsOf: fragment) offset += fragment.count } guard !bytes.isEmpty else { return nil } let trimmed = Array(bytes.prefix(length).prefix { $0 != 0 }) return String(bytes: trimmed.isEmpty ? Array(bytes.prefix(length)) : trimmed, encoding: .utf8) } fileprivate func readBatteryLevel(using transport: LogitechHIDPPTransport) -> Int? { if let featureIndex = transport.featureIndex(for: .batteryStatus), let response = transport.request(featureIndex: featureIndex, function: 0x00, parameters: []), response.payload.count >= 3, let level = response.payload.first, (1 ... 100).contains(level) { return Int(level) } if let featureIndex = transport.featureIndex(for: .unifiedBattery), let response = transport.request(featureIndex: featureIndex, function: 0x00, parameters: []), response.payload.count >= 2, let status = transport.request(featureIndex: featureIndex, function: 0x01, parameters: []), status.payload.count >= 4 { let exactPercent = status.payload[0] let supportsStateOfCharge = (response.payload[1] & 0x02) != 0 if supportsStateOfCharge, (1 ... 100).contains(exactPercent) { return Int(exactPercent) } return ApproximateBatteryLevel(rawValue: status.payload[1])?.percent } if let featureIndex = transport.featureIndex(for: .batteryVoltage), let response = transport.request(featureIndex: featureIndex, function: 0x00, parameters: []), response.payload.count >= 2 { return estimateBatteryPercent(fromMillivolts: Int(response.payload[0]) << 8 | Int(response.payload[1])) } if let featureIndex = transport.featureIndex(for: .adcMeasurement), let response = transport.request(featureIndex: featureIndex, function: 0x00, parameters: []), response.payload.count >= 2 { return estimateBatteryPercent(fromMillivolts: Int(response.payload[0]) << 8 | Int(response.payload[1])) } return nil } fileprivate func readReceiverBatteryLevel(using transport: LogitechHIDPPTransport) -> Int? { readBatteryLevel(using: transport) } private func estimateBatteryPercent(fromMillivolts millivolts: Int) -> Int { let lowerBound = 3500 let upperBound = 4200 let clamped = max(lowerBound, min(upperBound, millivolts)) return Int(round(Double(clamped - lowerBound) / Double(upperBound - lowerBound) * 100)) } private func isReceiverVendorChannel(_ device: VendorSpecificDeviceContext) -> Bool { device.transport == PointerDeviceTransportName.usb && device.primaryUsagePage == 0xFF00 && device.primaryUsage == 0x01 } private func normalizeName(_ name: String) -> String { name.lowercased().trimmingCharacters(in: .whitespacesAndNewlines) } private func normalizeSerial(_ serialNumber: String?) -> String? { guard let serialNumber, !serialNumber.isEmpty else { return nil } return serialNumber.uppercased().replacingOccurrences(of: ":", with: "") } static func parseReceiverConnectionNotification( _ report: [UInt8] ) -> (slot: UInt8, snapshot: ReceiverConnectionSnapshot)? { guard report.count >= Constants.shortReportLength, report[0] == Constants.shortReportID else { return nil } switch report[2] { case 0x41: let flags = report[4] return ( slot: report[1], snapshot: .init(isConnected: (flags & 0x40) == 0, kind: flags & 0x0F) ) case 0x42: return ( slot: report[1], snapshot: .init(isConnected: (report[3] & 0x01) == 0, kind: nil) ) default: return nil } } static func parseConnectedDeviceCount(_ response: [UInt8]) -> Int? { guard response.count >= 6 else { return nil } return Int(response[5]) } } struct LogitechHIDPPTransport { private let device: VendorSpecificDeviceContext private let reportID: UInt8 private let reportLength: Int private let deviceIndex: UInt8 private let acceptedReplyIndices: Set let isReceiverRoutedDevice: Bool init?(device: VendorSpecificDeviceContext, deviceIndex: UInt8?) { let maxOutputReportSize = device.maxOutputReportSize ?? 0 if maxOutputReportSize >= LogitechHIDPPDeviceMetadataProvider.Constants.longReportLength { reportID = LogitechHIDPPDeviceMetadataProvider.Constants.longReportID reportLength = LogitechHIDPPDeviceMetadataProvider.Constants.longReportLength } else if maxOutputReportSize >= LogitechHIDPPDeviceMetadataProvider.Constants.shortReportLength { reportID = LogitechHIDPPDeviceMetadataProvider.Constants.shortReportID reportLength = LogitechHIDPPDeviceMetadataProvider.Constants.shortReportLength } else { return nil } self.device = device self.deviceIndex = deviceIndex ?? LogitechHIDPPDeviceMetadataProvider.Constants.receiverIndex isReceiverRoutedDevice = deviceIndex != nil acceptedReplyIndices = deviceIndex.map { Set([$0]) } ?? LogitechHIDPPDeviceMetadataProvider.Constants .directReplyIndices } func featureIndex(for featureID: LogitechHIDPPDeviceMetadataProvider.FeatureID) -> UInt8? { guard let response = request(featureIndex: 0x00, function: 0x00, parameters: featureID.bytes), let featureIndex = response.payload.first, featureIndex != 0 else { return nil } return featureIndex } func request(featureIndex: UInt8, function: UInt8, parameters: [UInt8]) -> LogitechHIDPPDeviceMetadataProvider .Response? { let address = (function << 4) | LogitechHIDPPDeviceMetadataProvider.Constants.softwareID var bytes = [UInt8](repeating: 0, count: reportLength) bytes[0] = reportID bytes[1] = deviceIndex bytes[2] = featureIndex bytes[3] = address for (index, parameter) in parameters.enumerated() where index + 4 < bytes.count { bytes[index + 4] = parameter } guard let response = device.performSynchronousOutputReportRequest( Data(bytes), timeout: LogitechHIDPPDeviceMetadataProvider.Constants.timeout, matching: { response in let reply = [UInt8](response) guard reply.count >= LogitechHIDPPDeviceMetadataProvider.Constants.shortReportLength, [LogitechHIDPPDeviceMetadataProvider.Constants.shortReportID, LogitechHIDPPDeviceMetadataProvider.Constants.longReportID].contains(reply[0]), acceptedReplyIndices.contains(reply[1]) else { return false } if reply[2] == 0xFF { return reply.count >= 6 && reply[3] == featureIndex && reply[4] == address } return reply[2] == featureIndex && reply[3] == address } ) else { return nil } let reply = [UInt8](response) guard reply.count >= 4, reply[2] != 0xFF else { return nil } return .init(payload: Array(reply.dropFirst(4))) } } final class LogitechReceiverChannel: VendorSpecificDeviceContext { private enum RequestStrategy: CaseIterable { case outputCallback case featureCallback case outputFeatureGet case featureFeatureGet case outputInputGet case featureInputGet var requestType: IOHIDReportType { switch self { case .outputCallback, .outputFeatureGet, .outputInputGet: return kIOHIDReportTypeOutput case .featureCallback, .featureFeatureGet, .featureInputGet: return kIOHIDReportTypeFeature } } var responseType: IOHIDReportType? { switch self { case .outputCallback, .featureCallback: return nil case .outputFeatureGet, .featureFeatureGet: return kIOHIDReportTypeFeature case .outputInputGet, .featureInputGet: return kIOHIDReportTypeInput } } } let vendorID: Int? let productID: Int? let product: String? let name: String let serialNumber: String? let transport: String? let locationID: Int? let primaryUsagePage: Int? let primaryUsage: Int? let maxInputReportSize: Int? let maxOutputReportSize: Int? let maxFeatureReportSize: Int? private let manager: IOHIDManager private let device: IOHIDDevice private var inputReportBuffer: UnsafeMutablePointer? private let pendingLock = NSLock() private let requestLock = NSLock() private let strategyLock = NSLock() private var pendingMatcher: ((Data) -> Bool)? private var pendingResponse: Data? private var pendingSemaphore: DispatchSemaphore? private var requestStrategy: RequestStrategy? private static let inputReportCallback: IOHIDReportCallback = { context, _, _, _, _, report, reportLength in guard let context else { return } let this = Unmanaged.fromOpaque(context).takeUnretainedValue() this.handleInputReport(Data(bytes: report, count: reportLength)) } static func open(locationID: Int) -> LogitechReceiverChannel? { let manager = IOHIDManagerCreate(kCFAllocatorDefault, IOOptionBits(kIOHIDOptionsTypeNone)) let matching: [String: Any] = [ kIOHIDVendorIDKey: LogitechHIDPPDeviceMetadataProvider.Constants.vendorID, "LocationID": locationID, "Transport": PointerDeviceTransportName.usb, kIOHIDPrimaryUsagePageKey: 0xFF00, kIOHIDPrimaryUsageKey: 0x01 ] IOHIDManagerSetDeviceMatching(manager, matching as CFDictionary) IOHIDManagerScheduleWithRunLoop(manager, CFRunLoopGetCurrent(), CFRunLoopMode.defaultMode.rawValue) IOHIDManagerOpen(manager, IOOptionBits(kIOHIDOptionsTypeNone)) guard let devices = IOHIDManagerCopyDevices(manager) as? Set, let hidDevice = devices.first else { IOHIDManagerUnscheduleFromRunLoop(manager, CFRunLoopGetCurrent(), CFRunLoopMode.defaultMode.rawValue) IOHIDManagerClose(manager, IOOptionBits(kIOHIDOptionsTypeNone)) return nil } return LogitechReceiverChannel(manager: manager, device: hidDevice) } init?(manager: IOHIDManager, device: IOHIDDevice) { self.manager = manager self.device = device vendorID = Self.getProperty(kIOHIDVendorIDKey, from: device) productID = Self.getProperty(kIOHIDProductIDKey, from: device) product = Self.getProperty(kIOHIDProductKey, from: device) name = product ?? "(unknown)" serialNumber = Self.getProperty(kIOHIDSerialNumberKey, from: device) transport = Self.getProperty("Transport", from: device) locationID = Self.getProperty("LocationID", from: device) primaryUsagePage = Self.getProperty(kIOHIDPrimaryUsagePageKey, from: device) primaryUsage = Self.getProperty(kIOHIDPrimaryUsageKey, from: device) maxInputReportSize = Self.getProperty("MaxInputReportSize", from: device) maxOutputReportSize = Self.getProperty("MaxOutputReportSize", from: device) maxFeatureReportSize = Self.getProperty("MaxFeatureReportSize", from: device) let openStatus = IOHIDDeviceOpen(device, IOOptionBits(kIOHIDOptionsTypeNone)) guard openStatus == kIOReturnSuccess else { IOHIDManagerUnscheduleFromRunLoop(manager, CFRunLoopGetCurrent(), CFRunLoopMode.defaultMode.rawValue) IOHIDManagerClose(manager, IOOptionBits(kIOHIDOptionsTypeNone)) return nil } let reportLength = max( maxInputReportSize ?? LogitechHIDPPDeviceMetadataProvider.Constants.longReportLength, LogitechHIDPPDeviceMetadataProvider.Constants.longReportLength ) inputReportBuffer = UnsafeMutablePointer.allocate(capacity: reportLength) guard let inputReportBuffer else { IOHIDDeviceClose(device, IOOptionBits(kIOHIDOptionsTypeNone)) IOHIDManagerUnscheduleFromRunLoop(manager, CFRunLoopGetCurrent(), CFRunLoopMode.defaultMode.rawValue) IOHIDManagerClose(manager, IOOptionBits(kIOHIDOptionsTypeNone)) return nil } IOHIDDeviceScheduleWithRunLoop(device, CFRunLoopGetCurrent(), CFRunLoopMode.defaultMode.rawValue) IOHIDDeviceRegisterInputReportCallback( device, inputReportBuffer, reportLength, Self.inputReportCallback, Unmanaged.passUnretained(self).toOpaque() ) } deinit { inputReportBuffer?.deallocate() IOHIDDeviceUnscheduleFromRunLoop(device, CFRunLoopGetCurrent(), CFRunLoopMode.defaultMode.rawValue) IOHIDDeviceClose(device, IOOptionBits(kIOHIDOptionsTypeNone)) IOHIDManagerUnscheduleFromRunLoop(manager, CFRunLoopGetCurrent(), CFRunLoopMode.defaultMode.rawValue) IOHIDManagerClose(manager, IOOptionBits(kIOHIDOptionsTypeNone)) } func discoverSlots() -> LogitechHIDPPDeviceMetadataProvider.ReceiverSlotDiscovery? { let connectedDeviceCount = readConnectionState().flatMap { response in LogitechHIDPPDeviceMetadataProvider.parseConnectedDeviceCount(response) } let connectionSnapshots = discoverConnectionSnapshots(expectedCount: connectedDeviceCount) let snapshotSummary = connectionSnapshots.keys .sorted() .compactMap { slot -> String? in guard let snapshot = connectionSnapshots[slot] else { return nil } let kind = snapshot.kind.map(String.init) ?? "(nil)" return "slot=\(slot) connected=\(snapshot.isConnected) kind=\(kind)" } .joined(separator: ", ") os_log( "Receiver slot discovery started: locationID=%{public}@ connectedCount=%{public}@ snapshots=%{public}@", log: LogitechHIDPPDeviceMetadataProvider.log, type: .info, locationID.map(String.init) ?? "(nil)", connectedDeviceCount.map(String.init) ?? "(nil)", snapshotSummary ) // Prioritize slots known to be connected, then scan remaining slots let connectedSlotNumbers = connectionSnapshots .filter(\.value.isConnected) .map(\.key) .sorted() let remainingSlots = (UInt8(1) ... UInt8(6)).filter { !connectedSlotNumbers.contains($0) } let orderedSlots = connectedSlotNumbers + remainingSlots var pairedSlots = [LogitechHIDPPDeviceMetadataProvider.ReceiverSlotInfo]() for slot in orderedSlots { guard let slotInfo = discoverSlotInfo(slot, connectionSnapshot: connectionSnapshots[slot]) else { continue } pairedSlots.append(slotInfo) os_log( "Receiver slot %u raw candidate: name=%{public}@ kind=%{public}u battery=%{public}@", log: LogitechHIDPPDeviceMetadataProvider.log, type: .info, slot, slotInfo.name ?? "(nil)", UInt32(slotInfo.kind), slotInfo.batteryLevel.map(String.init) ?? "(nil)" ) } let pairedSummary = pairedSlots.map { slot in let battery = slot.batteryLevel.map(String.init) ?? "(nil)" let name = slot.name ?? "(nil)" return "slot=\(slot.slot) kind=\(slot.kind) name=\(name) battery=\(battery)" } .joined(separator: ", ") os_log( "Receiver slot metadata discovered: locationID=%{public}@ paired=%{public}u slots=%{public}@", log: LogitechHIDPPDeviceMetadataProvider.log, type: .info, locationID.map(String.init) ?? "(nil)", UInt32(pairedSlots.count), pairedSummary ) return pairedSlots.isEmpty ? nil : .init(slots: pairedSlots, connectionSnapshots: connectionSnapshots) } func discoverSlotInfo( _ slot: UInt8, connectionSnapshot: LogitechHIDPPDeviceMetadataProvider.ReceiverConnectionSnapshot? = nil ) -> LogitechHIDPPDeviceMetadataProvider.ReceiverSlotInfo? { let metadataProvider = LogitechHIDPPDeviceMetadataProvider() let pairingResponse = hidpp10LongRequest( register: LogitechHIDPPDeviceMetadataProvider.Constants.receiverInfoRegister, subregister: UInt8(0x20 + Int(slot) - 1) ) let extendedPairingResponse = hidpp10LongRequest( register: LogitechHIDPPDeviceMetadataProvider.Constants.receiverInfoRegister, subregister: UInt8(0x30 + Int(slot) - 1) ) let nameResponse = hidpp10LongRequest( register: LogitechHIDPPDeviceMetadataProvider.Constants.receiverInfoRegister, subregister: UInt8(0x40 + Int(slot) - 1) ) guard pairingResponse != nil || nameResponse != nil else { return nil } let kind = connectionSnapshot?.kind ?? pairingResponse.flatMap(Self.parseReceiverKind) ?? 0 let routedTransport = LogitechHIDPPTransport(device: self, deviceIndex: slot) let routedName = routedTransport.flatMap { transport in metadataProvider.readFriendlyName(using: transport) ?? metadataProvider.readName(using: transport) } let batteryLevel = routedTransport.flatMap { metadataProvider.readReceiverBatteryLevel(using: $0) } let name = nameResponse.flatMap(Self.parseReceiverName) ?? routedName let productID = pairingResponse.flatMap(Self.parseReceiverProductID) let serialNumber = extendedPairingResponse.flatMap(Self.parseReceiverSerialNumber) return .init( slot: slot, kind: kind, name: name, productID: productID, serialNumber: serialNumber, batteryLevel: batteryLevel, hasLiveMetadata: routedName != nil || batteryLevel != nil ) } private func discoverConnectionSnapshots( expectedCount: Int? = nil ) -> [UInt8: LogitechHIDPPDeviceMetadataProvider.ReceiverConnectionSnapshot] { guard triggerConnectionNotifications() else { return [:] } var snapshots = [UInt8: LogitechHIDPPDeviceMetadataProvider.ReceiverConnectionSnapshot]() let deadline = Date().addingTimeInterval(0.5) while Date() < deadline { guard let report = waitForInputReport(timeout: 0.05, matching: { response in LogitechHIDPPDeviceMetadataProvider.parseReceiverConnectionNotification(Array(response)) != nil }) else { // No more notifications pending — if we already have enough connected snapshots, exit early if let expectedCount, snapshots.values.filter(\.isConnected).count >= expectedCount { break } continue } guard let notification = LogitechHIDPPDeviceMetadataProvider .parseReceiverConnectionNotification(Array(report)) else { continue } snapshots[notification.slot] = notification.snapshot // Exit early once we have all expected connected device snapshots if let expectedCount, snapshots.values.filter(\.isConnected).count >= expectedCount { break } } return snapshots } func discoverMatchCandidates(baseName: String) -> ( [LogitechHIDPPDeviceMetadataProvider.ReceiverSlotMatchCandidate], [UInt8: LogitechHIDPPDeviceMetadataProvider.ReceiverConnectionSnapshot] )? { enableWirelessNotifications() guard let discovery = discoverSlots() else { return nil } let slots = discovery.slots let provider = LogitechHIDPPDeviceMetadataProvider() let candidates = slots.map { slot in LogitechHIDPPDeviceMetadataProvider.ReceiverSlotMatchCandidate( slot: slot.slot, kind: slot.kind, name: slot.name ?? baseName, serialNumber: slot.serialNumber, productID: slot.productID, batteryLevel: slot.batteryLevel ?? LogitechHIDPPTransport(device: self, deviceIndex: slot.slot) .flatMap { provider.readReceiverBatteryLevel(using: $0) }, hasLiveMetadata: slot.hasLiveMetadata ) } guard !candidates.isEmpty else { return nil } return (candidates, discovery.connectionSnapshots) } func enableWirelessNotifications() { let currentFlags = readNotificationFlags() ?? 0 let desiredFlags = currentFlags | LogitechHIDPPDeviceMetadataProvider.Constants.receiverWirelessNotifications | LogitechHIDPPDeviceMetadataProvider.Constants.receiverSoftwarePresentNotifications if desiredFlags != currentFlags { _ = writeNotificationFlags(desiredFlags) } } func discoverPointingDeviceDiscovery(baseName: String) -> LogitechHIDPPDeviceMetadataProvider .ReceiverPointingDeviceDiscovery { guard let locationID, let discovery = discoverMatchCandidates(baseName: baseName) else { return .init(identities: [], connectionSnapshots: [:], liveReachableSlots: []) } let (slots, connectionSnapshots) = discovery let liveReachableSlots = Set(slots.compactMap { slot in slot.hasLiveMetadata ? slot.slot : nil }) let identities = slots.compactMap { slot -> ReceiverLogicalDeviceIdentity? in guard let kind = ReceiverLogicalDeviceKind(rawValue: slot.kind), kind.isPointingDevice else { return nil } return ReceiverLogicalDeviceIdentity( receiverLocationID: locationID, slot: slot.slot, kind: kind, name: slot.name ?? baseName, serialNumber: slot.serialNumber, productID: slot.productID, batteryLevel: slot.batteryLevel ) } return .init( identities: identities, connectionSnapshots: connectionSnapshots, liveReachableSlots: liveReachableSlots ) } func waitForConnectionSnapshots( timeout: TimeInterval, until shouldContinue: (() -> Bool)? = nil ) -> [UInt8: LogitechHIDPPDeviceMetadataProvider.ReceiverConnectionSnapshot] { guard let report = waitForInputReport( timeout: timeout, matching: { response in LogitechHIDPPDeviceMetadataProvider.parseReceiverConnectionNotification(Array(response)) != nil }, until: shouldContinue ), let initialNotification = LogitechHIDPPDeviceMetadataProvider .parseReceiverConnectionNotification(Array(report)) else { return [:] } var snapshots = [initialNotification.slot: initialNotification.snapshot] let deadline = Date().addingTimeInterval(0.1) while Date() < deadline { guard let followup = waitForInputReport( timeout: 0.02, matching: { response in LogitechHIDPPDeviceMetadataProvider.parseReceiverConnectionNotification(Array(response)) != nil }, until: shouldContinue ), let notification = LogitechHIDPPDeviceMetadataProvider .parseReceiverConnectionNotification(Array(followup)) else { continue } snapshots[notification.slot] = notification.snapshot } return snapshots } func waitForHIDPPNotification( timeout: TimeInterval, matching: @escaping ([UInt8]) -> Bool, until shouldContinue: (() -> Bool)? = nil ) -> [UInt8]? { waitForInputReport( timeout: timeout, matching: { matching(Array($0)) }, until: shouldContinue ) .map(Array.init) } func readNotificationFlags() -> UInt32? { guard let response = hidpp10ShortRequest( subID: 0x81, register: LogitechHIDPPDeviceMetadataProvider.Constants.receiverNotificationFlagsRegister, parameters: [0, 0, 0] ) else { return nil } return UInt32(response[4]) << 16 | UInt32(response[5]) << 8 | UInt32(response[6]) } func writeNotificationFlags(_ value: UInt32) -> Bool { hidpp10ShortRequest( subID: 0x80, register: LogitechHIDPPDeviceMetadataProvider.Constants.receiverNotificationFlagsRegister, parameters: [UInt8((value >> 16) & 0xFF), UInt8((value >> 8) & 0xFF), UInt8(value & 0xFF)] ) != nil } func performSynchronousOutputReportRequest( _ report: Data, timeout: TimeInterval, matching: @escaping (Data) -> Bool ) -> Data? { if let strategy = currentRequestStrategy(), let response = performRequest(report, timeout: timeout, matching: matching, strategy: strategy) { return response } for strategy in RequestStrategy.allCases { guard let response = performRequest(report, timeout: timeout, matching: matching, strategy: strategy) else { continue } setCurrentRequestStrategy(strategy) return response } clearCurrentRequestStrategy() return nil } private func performRequest( _ report: Data, timeout: TimeInterval, matching: @escaping (Data) -> Bool, strategy: RequestStrategy ) -> Data? { guard !report.isEmpty else { return nil } if let responseType = strategy.responseType { return performGetReportRequest( report, timeout: timeout, matching: matching, requestType: strategy.requestType, responseType: responseType ) } return performCallbackRequest(report, timeout: timeout, matching: matching, reportType: strategy.requestType) } private func performCallbackRequest( _ report: Data, timeout: TimeInterval, matching: @escaping (Data) -> Bool, reportType: IOHIDReportType ) -> Data? { requestLock.lock() defer { requestLock.unlock() } let semaphore = DispatchSemaphore(value: 0) pendingLock.lock() pendingMatcher = matching pendingResponse = nil pendingSemaphore = semaphore pendingLock.unlock() let status = sendReport(report, type: reportType) guard status == kIOReturnSuccess else { clearPendingRequest() return nil } return waitForPendingResponse(timeout: timeout) } private func performGetReportRequest( _ report: Data, timeout: TimeInterval, matching: @escaping (Data) -> Bool, requestType: IOHIDReportType, responseType: IOHIDReportType ) -> Data? { requestLock.lock() defer { requestLock.unlock() } clearPendingRequest() guard sendReport(report, type: requestType) == kIOReturnSuccess else { return nil } let deadline = Date().addingTimeInterval(timeout) while Date() < deadline { if let response = getMatchingReport(type: responseType, matching: matching) { return response } CFRunLoopRunInMode(.defaultMode, 0.01, true) } return nil } private func sendReport(_ report: Data, type: IOHIDReportType) -> IOReturn { report.withUnsafeBytes { rawBuffer -> IOReturn in guard let baseAddress = rawBuffer.baseAddress?.assumingMemoryBound(to: UInt8.self) else { return kIOReturnBadArgument } return IOHIDDeviceSetReport(device, type, CFIndex(report[0]), baseAddress, report.count) } } private func getMatchingReport(type: IOHIDReportType, matching: @escaping (Data) -> Bool) -> Data? { for candidate in candidateReportDescriptors() { guard let response = getReport(type: type, reportID: candidate.reportID, length: candidate.length), matching(response) else { continue } return response } return nil } private func getReport(type: IOHIDReportType, reportID: UInt8, length: Int) -> Data? { guard length >= LogitechHIDPPDeviceMetadataProvider.Constants.shortReportLength else { return nil } var buffer = [UInt8](repeating: 0, count: length) buffer[0] = reportID var reportLength = CFIndex(length) let status = buffer.withUnsafeMutableBytes { rawBuffer -> IOReturn in guard let baseAddress = rawBuffer.baseAddress?.assumingMemoryBound(to: UInt8.self) else { return kIOReturnBadArgument } return IOHIDDeviceGetReport(device, type, CFIndex(reportID), baseAddress, &reportLength) } guard status == kIOReturnSuccess, reportLength >= LogitechHIDPPDeviceMetadataProvider.Constants.shortReportLength else { return nil } return Data(buffer.prefix(reportLength)) } private func candidateReportDescriptors() -> [(reportID: UInt8, length: Int)] { var descriptors = [(UInt8, Int)]() let shortLength = max( LogitechHIDPPDeviceMetadataProvider.Constants.shortReportLength, max(maxInputReportSize ?? 0, maxFeatureReportSize ?? 0) ) descriptors.append((LogitechHIDPPDeviceMetadataProvider.Constants.shortReportID, shortLength)) let longLength = max( LogitechHIDPPDeviceMetadataProvider.Constants.longReportLength, max(maxInputReportSize ?? 0, maxFeatureReportSize ?? 0) ) descriptors.append((LogitechHIDPPDeviceMetadataProvider.Constants.longReportID, longLength)) return descriptors } private func currentRequestStrategy() -> RequestStrategy? { strategyLock.lock() defer { strategyLock.unlock() } return requestStrategy } private func setCurrentRequestStrategy(_ strategy: RequestStrategy) { strategyLock.lock() requestStrategy = strategy strategyLock.unlock() } private func clearCurrentRequestStrategy() { strategyLock.lock() requestStrategy = nil strategyLock.unlock() } private func waitForInputReport( timeout: TimeInterval, matching: @escaping (Data) -> Bool, until shouldContinue: (() -> Bool)? = nil ) -> Data? { requestLock.lock() defer { requestLock.unlock() } let semaphore = DispatchSemaphore(value: 0) pendingLock.lock() pendingMatcher = matching pendingResponse = nil pendingSemaphore = semaphore pendingLock.unlock() return waitForPendingResponse(timeout: timeout, until: shouldContinue) } private func waitForPendingResponse(timeout: TimeInterval, until shouldContinue: (() -> Bool)? = nil) -> Data? { let deadline = Date().addingTimeInterval(timeout) while Date() < deadline { if let shouldContinue, !shouldContinue() { clearPendingRequest() return nil } pendingLock.lock() let response = pendingResponse pendingLock.unlock() if let response { clearPendingRequest() return response } CFRunLoopRunInMode(.defaultMode, 0.01, true) } clearPendingRequest() return nil } private func handleInputReport(_ report: Data) { pendingLock.lock() defer { pendingLock.unlock() } guard let reportMatcher = pendingMatcher, reportMatcher(report) else { return } pendingResponse = report pendingMatcher = nil pendingSemaphore?.signal() } private func clearPendingRequest() { pendingLock.lock() pendingMatcher = nil pendingResponse = nil pendingSemaphore = nil pendingLock.unlock() } private func readConnectionState() -> [UInt8]? { hidpp10ShortRequest( subID: 0x81, register: LogitechHIDPPDeviceMetadataProvider.Constants.receiverConnectionStateRegister, parameters: [0, 0, 0] ) } private func triggerConnectionNotifications() -> Bool { hidpp10ShortRequest( subID: 0x80, register: LogitechHIDPPDeviceMetadataProvider.Constants.receiverConnectionStateRegister, parameters: [0x02, 0x00, 0x00] ) != nil } private func hidpp10ShortRequest(subID: UInt8, register: UInt8, parameters: [UInt8]) -> [UInt8]? { var bytes = [UInt8](repeating: 0, count: LogitechHIDPPDeviceMetadataProvider.Constants.shortReportLength) bytes[0] = LogitechHIDPPDeviceMetadataProvider.Constants.shortReportID bytes[1] = LogitechHIDPPDeviceMetadataProvider.Constants.receiverIndex bytes[2] = subID bytes[3] = register for (index, parameter) in parameters.prefix(3).enumerated() { bytes[4 + index] = parameter } let response = performSynchronousOutputReportRequest( Data(bytes), timeout: LogitechHIDPPDeviceMetadataProvider.Constants.timeout ) { report in let reply = [UInt8](report) guard reply.count >= LogitechHIDPPDeviceMetadataProvider.Constants.shortReportLength else { return false } guard [ LogitechHIDPPDeviceMetadataProvider.Constants.shortReportID, LogitechHIDPPDeviceMetadataProvider.Constants.longReportID ].contains(reply[0]), reply[1] == LogitechHIDPPDeviceMetadataProvider.Constants.receiverIndex else { return false } if reply[2] == 0x8F { return reply[3] == subID && reply[4] == register } return reply[2] == subID && reply[3] == register } guard let response else { return nil } let responseBytes = Array(response) return responseBytes[2] == 0x8F ? nil : responseBytes } private func hidpp10LongRequest(register: UInt8, subregister: UInt8) -> [UInt8]? { var bytes = [UInt8](repeating: 0, count: LogitechHIDPPDeviceMetadataProvider.Constants.shortReportLength) bytes[0] = LogitechHIDPPDeviceMetadataProvider.Constants.shortReportID bytes[1] = LogitechHIDPPDeviceMetadataProvider.Constants.receiverIndex bytes[2] = 0x83 bytes[3] = register bytes[4] = subregister let request = Data(bytes) let response = performSynchronousOutputReportRequest( request, timeout: LogitechHIDPPDeviceMetadataProvider.Constants.timeout ) { report in let reply = [UInt8](report) guard reply.count >= 5 else { return false } guard [ LogitechHIDPPDeviceMetadataProvider.Constants.shortReportID, LogitechHIDPPDeviceMetadataProvider.Constants.longReportID ].contains(reply[0]), reply[1] == LogitechHIDPPDeviceMetadataProvider.Constants.receiverIndex else { return false } if reply[2] == 0x8F { return reply[3] == 0x83 && reply[4] == register } return reply[2] == 0x83 && reply[3] == register && reply[4] == subregister } guard let response else { return nil } let responseBytes = Array(response) return responseBytes[2] == 0x8F ? nil : responseBytes } private static func parseReceiverName(_ response: [UInt8]) -> String? { guard response.count >= 6 else { return nil } let length = Int(response[5]) let bytes = Array(response.dropFirst(6).prefix(length)) return String(bytes: bytes, encoding: .utf8) } private static func parseReceiverKind(_ response: [UInt8]) -> UInt8? { guard response.count >= 13 else { return nil } let candidateIndices = [11, 12] for index in candidateIndices where index < response.count { let kind = response[index] & 0x0F if kind != 0 { return kind } } return nil } private static func parseReceiverProductID(_ response: [UInt8]) -> Int? { guard response.count >= 9 else { return nil } return Int(response[7]) << 8 | Int(response[8]) } private static func parseReceiverSerialNumber(_ response: [UInt8]) -> String? { guard response.count >= 10 else { return nil } return response[6 ... 9].map { String(format: "%02X", $0) }.joined() } private static func getProperty(_ key: String, from device: IOHIDDevice) -> T? { guard let value = IOHIDDeviceGetProperty(device, key as CFString) else { return nil } return value as? T } } private extension LogitechHIDPPDeviceMetadataProvider.FeatureID { var bytes: [UInt8] { [UInt8(rawValue >> 8), UInt8(rawValue & 0xFF)] } } final class LogitechReprogrammableControlsMonitor { private static let log = OSLog(subsystem: Bundle.main.bundleIdentifier!, category: "LogitechReprogrammableControls") private enum Constants { static let notificationTimeout: TimeInterval = 0.25 static let stopTimeout: TimeInterval = 3 } private struct ControlInfo { let controlID: UInt16 let taskID: UInt16 let position: UInt8 let group: UInt8 let groupMask: UInt8 let flags: LogitechHIDPPDeviceMetadataProvider.ReprogControlsV4.ControlFlags } private struct ReportingInfo { let flags: LogitechHIDPPDeviceMetadataProvider.ReprogControlsV4.ReportingFlags let mappedControlID: UInt16 } struct TargetDevice { let slot: UInt8 let identity: ReceiverLogicalDeviceIdentity? } private struct MonitorTarget { let slot: UInt8 let identity: ReceiverLogicalDeviceIdentity? let transport: LogitechHIDPPTransport let featureIndex: UInt8 let controls: [ControlInfo] let notificationEndpoint: HIDPPNotificationHandling } private let device: Device private let provider = LogitechHIDPPDeviceMetadataProvider() private let stateLock = NSLock() private var subscriptions = Set() private var directDeviceReportObservationToken: ObservationToken? private var workerThread: Thread? private var running = false private var pressedButtons = Set() private var needsReconfiguration = false private var needsForcedReconfiguration = false init(device: Device) { self.device = device } static func supports(device: Device) -> Bool { guard let vendorID = device.vendorID, vendorID == LogitechHIDPPDeviceMetadataProvider.Constants.vendorID, [PointerDeviceTransportName.usb, PointerDeviceTransportName.bluetoothLowEnergy] .contains(device.pointerDevice.transport) else { return false } return true } func start() { stateLock.lock() defer { stateLock.unlock() } guard !running else { return } running = true let thread = Thread { [weak self] in self?.workerMain() } thread.name = "linearmouse.logitech-controls.\(device.id)" workerThread = thread thread.start() observeConfigurationChangesIfNeeded() } func stop() { stateLock.lock() running = false let thread = workerThread workerThread = nil stateLock.unlock() thread?.cancel() subscriptions.removeAll() directDeviceReportObservationToken = nil } private func workerMain() { defer { releaseButtonIfNeeded() } guard let monitorTarget = resolveMonitorTarget() else { finishVirtualButtonRecordingPreparationIfNeeded(sessionID: SettingsState.shared .virtualButtonRecordingSessionID) os_log( "Skip Logitech controls monitor because initialization failed: device=%{public}@", log: Self.log, type: .info, String(describing: device) ) return } let locationID = device.pointerDevice.locationID ?? 0 let slot = monitorTarget.slot let transport = monitorTarget.transport let featureIndex = monitorTarget.featureIndex let allControls = monitorTarget.controls let targetIdentity = monitorTarget.identity let targetName = targetIdentity?.name ?? device.productName ?? device.name var pendingReportingRestoreByControlID = [UInt16: ReportingInfo]() monitorTarget.notificationEndpoint.enableNotifications() logAvailableControls(transport: transport, featureIndex: featureIndex, slot: slot, locationID: locationID) while shouldContinueRunning() { if !pendingReportingRestoreByControlID.isEmpty { pendingReportingRestoreByControlID = restoreReportingState( pendingReportingRestoreByControlID, using: transport, featureIndex: featureIndex, locationID: locationID, slot: slot, reason: "retry pending restore" ) } let desiredControlIDs = desiredDivertedControlIDs(availableControls: allControls, identity: targetIdentity) let isRecording = SettingsState.shared.recording let recordingSessionID = isRecording ? SettingsState.shared.virtualButtonRecordingSessionID : nil let monitoredControls = isRecording ? allControls : allControls.filter { desiredControlIDs.contains($0.controlID) } let monitoredControlIDs = Set(monitoredControls.map(\.controlID)) let reservedVirtualButtonNumber = LogitechHIDPPDeviceMetadataProvider.ReprogControlsV4.reservedVirtualButtonNumber if !isRecording { let controlsToRestore = pendingReportingRestoreByControlID .filter { !desiredControlIDs.contains($0.key) } if !controlsToRestore.isEmpty { let failedRestoreByControlID = restoreReportingState( controlsToRestore, using: transport, featureIndex: featureIndex, locationID: locationID, slot: slot, reason: "apply native reporting to unmonitored controls" ) for controlID in Set(controlsToRestore.keys).subtracting(failedRestoreByControlID.keys) { pendingReportingRestoreByControlID.removeValue(forKey: controlID) } pendingReportingRestoreByControlID.merge(failedRestoreByControlID) { _, new in new } } } if monitoredControls.isEmpty { finishVirtualButtonRecordingPreparationIfNeeded(sessionID: recordingSessionID) os_log( "Pause Logitech control diversion until configuration changes: locationID=%{public}d slot=%{public}u device=%{public}@ recording=%{public}@", log: Self.log, type: .info, locationID, slot, targetName, isRecording ? "true" : "false" ) guard waitForReconfigurationOrStop() else { return } continue } let originalReportingByControlID = monitoredControls .reduce(into: [UInt16: ReportingInfo]()) { result, control in guard let reportingInfo = readReportingInfo( for: control.controlID, using: transport, featureIndex: featureIndex ) else { return } result[control.controlID] = reportingInfo } let activeControlIDs = monitoredControls.compactMap { control -> UInt16? in guard setDivertedWithRetry(true, for: control.controlID, using: transport, featureIndex: featureIndex) else { os_log( "Failed to enable Logitech control diversion: locationID=%{public}d slot=%{public}u cid=0x%{public}04X", log: Self.log, type: .error, locationID, slot, control.controlID ) return nil } return control.controlID } guard !activeControlIDs.isEmpty else { finishVirtualButtonRecordingPreparationIfNeeded(sessionID: recordingSessionID) os_log( "Failed to enable any Logitech control diversion: locationID=%{public}d slot=%{public}u device=%{public}@", log: Self.log, type: .error, locationID, slot, targetName ) guard waitForReconfigurationOrStop() else { return } continue } let activeReportingByControlID = activeControlIDs .reduce(into: [UInt16: ReportingInfo]()) { result, controlID in guard let reportingInfo = readReportingInfo( for: controlID, using: transport, featureIndex: featureIndex ) else { return } result[controlID] = reportingInfo } let controlSummary = monitoredControls.map { control in let originalReporting = originalReportingByControlID[control.controlID] let activeReporting = activeReportingByControlID[control.controlID] return String( format: "cid=0x%04X button=%d tid=0x%04X flags=%@ reporting=%@ mapped=0x%04X", control.controlID, reservedVirtualButtonNumber, control.taskID, describeControlFlags(control.flags), describeReportingFlags(activeReporting?.flags ?? originalReporting?.flags ?? []), activeReporting?.mappedControlID ?? originalReporting?.mappedControlID ?? control.controlID ) } .joined(separator: " | ") os_log( "Logitech controls monitor enabled: locationID=%{public}d slot=%{public}u device=%{public}@ controls=%{public}@", log: Self.log, type: .info, locationID, slot, targetName, controlSummary ) finishVirtualButtonRecordingPreparationIfNeeded(sessionID: recordingSessionID) var pressedControls = Set() defer { releaseButtonIfNeeded() let failedRestoreByControlID = restoreReportingState( originalReportingByControlID, using: transport, featureIndex: featureIndex, locationID: locationID, slot: slot, reason: "restore original reporting" ) pendingReportingRestoreByControlID.merge(failedRestoreByControlID) { _, new in new } for controlID in Set(originalReportingByControlID.keys).subtracting(failedRestoreByControlID.keys) { pendingReportingRestoreByControlID.removeValue(forKey: controlID) } } while shouldContinueRunning() { let reconfigResult = consumeReconfigurationRequest() if reconfigResult.needed { if reconfigResult.forced { os_log( "Restart Logitech control monitor (forced, e.g. device reconnect): locationID=%{public}d slot=%{public}u device=%{public}@", log: Self.log, type: .info, locationID, slot, targetName ) break } let newDesiredControlIDs = desiredDivertedControlIDs( availableControls: allControls, identity: targetIdentity ) let newIsRecording = SettingsState.shared.recording if newDesiredControlIDs != desiredControlIDs || newIsRecording != isRecording { os_log( "Restart Logitech control monitor to refresh diverted controls: locationID=%{public}d slot=%{public}u device=%{public}@", log: Self.log, type: .info, locationID, slot, targetName ) break } } guard let report = monitorTarget.notificationEndpoint.waitForHIDPPNotification( timeout: Constants.notificationTimeout, matching: { response in Self.isDivertedButtonsNotification(response, featureIndex: featureIndex, slot: slot) }, until: { [weak self] in self?.shouldContinueRunning() == true } ) else { continue } let activeControls = Self.parseDivertedButtonsNotification(report).intersection(monitoredControlIDs) let changedControls = activeControls.symmetricDifference(pressedControls).sorted() pressedControls = activeControls for controlID in changedControls { let isPressed = activeControls.contains(controlID) os_log( "Logitech reprogrammable control event: locationID=%{public}d slot=%{public}u device=%{public}@ cid=0x%{public}04X button=%{public}d state=%{public}@ active=%{public}@", log: Self.log, type: .info, locationID, slot, targetName, controlID, reservedVirtualButtonNumber, isPressed ? "down" : "up", activeControls.map { String(format: "0x%04X", $0) }.sorted().joined(separator: ",") ) device.markActive(reason: "Received Logitech reprogrammable control event") let modifierFlags = ModifierState.shared.currentFlags let controlIdentity = LogitechControlIdentity( controlID: Int(controlID), productID: targetIdentity?.productID, serialNumber: targetIdentity?.serialNumber ) if isRecording { if isPressed { DispatchQueue.main.async { SettingsState.shared.recordedVirtualButtonEvent = .init( button: .logitechControl(controlIdentity), modifierFlags: modifierFlags ) } } continue } let mouseLocation = CGEvent(source: nil)?.location ?? .zero let mouseLocationPid = mouseLocation.topmostWindowOwnerPid ?? NSWorkspace.shared.frontmostApplication?.processIdentifier let display = ScreenManager.shared.currentScreenNameSnapshot let logitechContext = LogitechEventContext( device: device, pid: mouseLocationPid, display: display, mouseLocation: mouseLocation, controlIdentity: controlIdentity, isPressed: isPressed, modifierFlags: modifierFlags ) let handledInternally = EventThread.shared.performAndWait { EventTransformerManager.shared.handleLogitechControlEvent(logitechContext) } ?? false guard !handledInternally else { continue } postSyntheticButton( button: reservedVirtualButtonNumber, down: isPressed ) } } } } private func waitForReconfigurationOrStop() -> Bool { while shouldContinueRunning() { if consumeReconfigurationRequest().needed { return true } Thread.sleep(forTimeInterval: Constants.notificationTimeout) } return false } private func finishVirtualButtonRecordingPreparationIfNeeded(sessionID: UUID?) { guard let sessionID else { return } DispatchQueue.main.async { SettingsState.shared.finishVirtualButtonRecordingPreparation( for: self.device.id, sessionID: sessionID ) } } private func findMonitoredControls(using transport: LogitechHIDPPTransport, featureIndex: UInt8) -> [ControlInfo] { let controls = fetchControls(using: transport, featureIndex: featureIndex) return controls .filter(Self.shouldMonitor) .sorted { lhs, rhs in if lhs.controlID != rhs.controlID { return lhs.controlID < rhs.controlID } return lhs.taskID < rhs.taskID } } private func resolveMonitorTarget() -> MonitorTarget? { if device.pointerDevice.transport == PointerDeviceTransportName.bluetoothLowEnergy { return buildDirectMonitorTarget() } guard let receiverChannel = provider.openReceiverChannel(for: device.pointerDevice) else { return nil } return resolveMonitorTarget(using: receiverChannel) } private func buildDirectMonitorTarget() -> MonitorTarget? { guard let transport = LogitechHIDPPTransport(device: device.pointerDevice, deviceIndex: nil), let featureIndex = transport.featureIndex(for: .reprogControlsV4) else { return nil } let controls = findMonitoredControls(using: transport, featureIndex: featureIndex) guard !controls.isEmpty else { return nil } let identity = ReceiverLogicalDeviceIdentity( receiverLocationID: device.pointerDevice.locationID ?? 0, slot: 0, kind: .mouse, name: device.productName ?? device.name, serialNumber: device.serialNumber, productID: device.productID, batteryLevel: device.batteryLevel ) return MonitorTarget( slot: LogitechHIDPPDeviceMetadataProvider.Constants.receiverIndex, identity: identity, transport: transport, featureIndex: featureIndex, controls: controls, notificationEndpoint: directNotificationEndpoint() ) } private func directNotificationEndpoint() -> HIDPPNotificationEndpoint { directDeviceReportObservationToken = nil let endpoint = HIDPPNotificationEndpoint() directDeviceReportObservationToken = device.pointerDevice.observeReport { _, report in endpoint.handleInputReport(report) } return endpoint } private func resolveTargetDevice( using receiverChannel: LogitechReceiverChannel, discovery: LogitechHIDPPDeviceMetadataProvider.ReceiverPointingDeviceDiscovery ) -> TargetDevice? { let desiredProductID = device.pointerDevice.productID let desiredSerial = device.pointerDevice .serialNumber? .lowercased() .trimmingCharacters(in: .whitespacesAndNewlines) let desiredName = (device.pointerDevice.product ?? device.pointerDevice.name) .lowercased() .trimmingCharacters(in: .whitespacesAndNewlines) // Try to match device to a slot using discovery results directly, // avoiding a second full slot scan. let matched: ReceiverLogicalDeviceIdentity? = // Match by serial number discovery.identities.first { guard let serial = $0.serialNumber?.lowercased().trimmingCharacters(in: .whitespacesAndNewlines), let desired = desiredSerial, !desired.isEmpty else { return false } return serial == desired } // Match by product ID ?? discovery.identities.first { guard let pid = $0.productID, let desired = desiredProductID else { return false } return pid == desired } // Match by name ?? discovery.identities.first { $0.name.lowercased().trimmingCharacters(in: .whitespacesAndNewlines) == desiredName } if let matched { return TargetDevice(slot: matched.slot, identity: matched) } // Fallback: use the original slot matching via HID++ pairing info if let slot = provider.receiverSlot(for: device.pointerDevice, using: receiverChannel) { let identity = discovery.identities.first { $0.slot == slot } return TargetDevice(slot: slot, identity: identity) } return nil } private func resolveMonitorTarget(using receiverChannel: LogitechReceiverChannel) -> MonitorTarget? { let discovery = provider.receiverPointingDeviceDiscovery(for: device.pointerDevice, using: receiverChannel) if let targetDevice = resolveTargetDevice(using: receiverChannel, discovery: discovery), let target = buildMonitorTarget( slot: targetDevice.slot, identity: targetDevice.identity, using: receiverChannel ) { return target } // Only scan connected slots instead of all 6 let connectedSlots = Set(discovery.connectionSnapshots.compactMap { slot, snapshot in snapshot.isConnected ? slot : nil }) // Prefer connected slots; fall back to identity slots or all 1...6 // when connection snapshots are unavailable. let identitySlots = Array(Set(discovery.identities.map(\.slot))).sorted() let candidateSlots: [UInt8] if !connectedSlots.isEmpty { candidateSlots = identitySlots.isEmpty ? Array(connectedSlots.sorted()) : identitySlots.filter { connectedSlots.contains($0) } } else if !identitySlots.isEmpty { candidateSlots = identitySlots } else { candidateSlots = Array(UInt8(1) ... UInt8(6)) } let scannedTargets = candidateSlots.compactMap { slot in buildMonitorTarget( slot: slot, identity: discovery.identities.first { $0.slot == slot }, using: receiverChannel ) } if scannedTargets.count == 1 { let target = scannedTargets[0] os_log( "Resolved Logitech monitor target by slot scan: receiver=%{public}@ slot=%{public}u name=%{public}@", log: Self.log, type: .info, device.productName ?? device.name, target.slot, target.identity?.name ?? "(nil)" ) return target } let candidatesDescription = scannedTargets.map { target in let name = target.identity?.name ?? "(nil)" let firstControl = target.controls.first return String( format: "slot=%u name=%@ firstCID=0x%04X count=%u", target.slot, name, firstControl?.controlID ?? 0, target.controls.count ) } .joined(separator: ", ") os_log( "Failed to resolve Logitech monitor target: receiver=%{public}@ discoveryCount=%{public}u candidates=%{public}@", log: Self.log, type: .info, device.productName ?? device.name, UInt32(discovery.identities.count), candidatesDescription ) return nil } private func buildMonitorTarget( slot: UInt8, identity: ReceiverLogicalDeviceIdentity?, using receiverChannel: LogitechReceiverChannel ) -> MonitorTarget? { guard let transport = LogitechHIDPPTransport(device: receiverChannel, deviceIndex: slot), let featureIndex = transport.featureIndex(for: .reprogControlsV4) else { return nil } let controls = findMonitoredControls(using: transport, featureIndex: featureIndex) guard !controls.isEmpty else { return nil } return MonitorTarget( slot: slot, identity: identity, transport: transport, featureIndex: featureIndex, controls: controls, notificationEndpoint: receiverChannel ) } private static func shouldMonitor(_ control: ControlInfo) -> Bool { guard control.flags.contains(.mouseButton), !control.flags.contains(.virtual) else { return false } let isDivertable = control.flags.contains(.divertable) || control.flags.contains(.persistentlyDivertable) guard isDivertable else { return false } guard !LogitechHIDPPDeviceMetadataProvider.ReprogControlsV4.nativeControlIDs .contains(control.controlID) else { return false } if LogitechHIDPPDeviceMetadataProvider.ReprogControlsV4.gestureButtonControlIDs.contains(control.controlID) || LogitechHIDPPDeviceMetadataProvider.ReprogControlsV4.gestureButtonTaskIDs.contains(control.taskID) { return true } if control.flags.contains(.rawXY) { return true } return control.controlID >= 0x00C0 || control.taskID >= 0x0090 } private func observeConfigurationChangesIfNeeded() { guard subscriptions.isEmpty else { return } ConfigurationState.shared .$configuration .dropFirst() .sink { [weak self] _ in self?.requestReconfiguration() } .store(in: &subscriptions) SettingsState.shared .$recording .dropFirst() .sink { [weak self] _ in self?.requestReconfiguration() } .store(in: &subscriptions) ScreenManager.shared .$currentScreenName .dropFirst() .sink { [weak self] _ in self?.requestReconfiguration() } .store(in: &subscriptions) NSWorkspace.shared .notificationCenter .publisher(for: NSWorkspace.didActivateApplicationNotification) .sink { [weak self] _ in self?.requestReconfiguration() } .store(in: &subscriptions) } func requestReconfiguration() { stateLock.lock() needsReconfiguration = true stateLock.unlock() } func requestForcedReconfiguration() { stateLock.lock() needsReconfiguration = true needsForcedReconfiguration = true stateLock.unlock() } private func consumeReconfigurationRequest() -> (needed: Bool, forced: Bool) { stateLock.lock() defer { stateLock.unlock() } guard needsReconfiguration else { return (false, false) } let forced = needsForcedReconfiguration needsReconfiguration = false needsForcedReconfiguration = false return (true, forced) } private func desiredDivertedControlIDs( availableControls: [ControlInfo], identity: ReceiverLogicalDeviceIdentity? ) -> Set { if SettingsState.shared.recording { return Set(availableControls.map(\.controlID)) } let mouseLocation = CGEvent(source: nil)?.location ?? .zero let mouseLocationPid = mouseLocation.topmostWindowOwnerPid ?? NSWorkspace.shared.frontmostApplication?.processIdentifier let scheme = ConfigurationState.shared.configuration.matchScheme( withDevice: device, withPid: mouseLocationPid, withDisplay: ScreenManager.shared.currentScreenNameSnapshot ) let directMappings: [UInt16] = (scheme.buttons.mappings ?? []) .compactMap { (mapping: Scheme.Buttons.Mapping) -> UInt16? in guard let logiButton = mapping.button?.logitechControl else { return nil } guard matches(logiButton: logiButton, identity: identity) else { return nil } return logiButton.controlIDValue } let autoScrollControlID: UInt16? = { guard scheme.buttons.autoScroll.enabled ?? false, let logiButton = scheme.buttons.autoScroll.trigger?.button?.logitechControl, matches(logiButton: logiButton, identity: identity) else { return nil } return logiButton.controlIDValue }() let gestureControlID: UInt16? = { guard scheme.buttons.gesture.enabled ?? false, let logiButton = scheme.buttons.gesture.trigger?.button?.logitechControl, matches(logiButton: logiButton, identity: identity) else { return nil } return logiButton.controlIDValue }() return Set(directMappings + [autoScrollControlID, gestureControlID].compactMap(\.self)) .intersection(availableControls.map(\.controlID)) } private func matches(logiButton: LogitechControlIdentity, identity: ReceiverLogicalDeviceIdentity?) -> Bool { if let configuredSerialNumber = logiButton.serialNumber { guard let serialNumber = identity?.serialNumber else { return false } return configuredSerialNumber.caseInsensitiveCompare(serialNumber) == .orderedSame } if let configuredProductID = logiButton.productID { guard let productID = identity?.productID else { return false } return configuredProductID == productID } return logiButton.serialNumber == nil && logiButton.productID == nil } private func fetchControls(using transport: LogitechHIDPPTransport, featureIndex: UInt8) -> [ControlInfo] { guard let countResponse = transport.request( featureIndex: featureIndex, function: LogitechHIDPPDeviceMetadataProvider.ReprogControlsV4.getControlCountFunction, parameters: [] ), let count = countResponse.payload.first else { return [] } return (0 ..< count).compactMap { readControlInfo(index: $0, using: transport, featureIndex: featureIndex) } } private func readControlInfo( index: UInt8, using transport: LogitechHIDPPTransport, featureIndex: UInt8 ) -> ControlInfo? { guard let response = transport.request( featureIndex: featureIndex, function: LogitechHIDPPDeviceMetadataProvider.ReprogControlsV4.getControlInfoFunction, parameters: [index] ) else { return nil } let payload = response.payload guard payload.count >= 9 else { return nil } let controlID = UInt16(payload[0]) << 8 | UInt16(payload[1]) let taskID = UInt16(payload[2]) << 8 | UInt16(payload[3]) let flagsRaw = UInt16(payload[4]) | (UInt16(payload[8]) << 8) return ControlInfo( controlID: controlID, taskID: taskID, position: payload[5], group: payload[6], groupMask: payload[7], flags: .init(rawValue: flagsRaw) ) } private func readReportingInfo( for controlID: UInt16, using transport: LogitechHIDPPTransport, featureIndex: UInt8 ) -> ReportingInfo? { guard let response = transport.request( featureIndex: featureIndex, function: LogitechHIDPPDeviceMetadataProvider.ReprogControlsV4.getControlReportingFunction, parameters: controlID.bytes ), response.payload.count >= 3 else { return nil } let mappedControlID: UInt16 if response.payload.count >= 5 { let mapped = UInt16(response.payload[3]) << 8 | UInt16(response.payload[4]) mappedControlID = mapped == 0 ? controlID : mapped } else { mappedControlID = controlID } let flagsRaw = UInt16(response.payload[2]) | (response.payload.count >= 6 ? UInt16(response.payload[5]) << 8 : 0) return ReportingInfo( flags: .init(rawValue: flagsRaw), mappedControlID: mappedControlID ) } private func setDiverted( _ enabled: Bool, for controlID: UInt16, using transport: LogitechHIDPPTransport, featureIndex: UInt8 ) -> Bool { let flags = enabled ? UInt8(LogitechHIDPPDeviceMetadataProvider.ReprogControlsV4.ReportingFlags.diverted.rawValue) : 0 let changeBits: UInt8 = enabled ? 0x03 : 0x02 guard let response = transport.request( featureIndex: featureIndex, function: LogitechHIDPPDeviceMetadataProvider.ReprogControlsV4.setControlReportingFunction, parameters: controlID.bytes + [UInt8(changeBits | flags), 0x00, 0x00] ), response.payload.count >= 2 else { return false } let didEchoControlID = response.payload[0] == UInt8(controlID >> 8) && response.payload[1] == UInt8(controlID & 0xFF) if !didEchoControlID { os_log( "Logitech setCidReporting did not echo control ID: cid=0x%{public}04X payload=%{public}@", log: Self.log, type: .info, controlID, response.payload.map { String(format: "%02X", $0) }.joined(separator: " ") ) // Read back the reporting state to verify diversion actually took effect guard let verifyReporting = readReportingInfo( for: controlID, using: transport, featureIndex: featureIndex ) else { os_log( "Logitech setCidReporting verification failed (read-back error): cid=0x%{public}04X", log: Self.log, type: .error, controlID ) return false } let actuallyDiverted = verifyReporting.flags.contains(.diverted) guard actuallyDiverted == enabled else { os_log( "Logitech setCidReporting verification mismatch: cid=0x%{public}04X wanted=%{public}@ actual=%{public}@", log: Self.log, type: .error, controlID, enabled ? "diverted" : "native", actuallyDiverted ? "diverted" : "native" ) return false } } return true } private func setDivertedWithRetry( _ enabled: Bool, for controlID: UInt16, using transport: LogitechHIDPPTransport, featureIndex: UInt8, maxAttempts: Int = 3, retryDelay: TimeInterval = 0.05 ) -> Bool { guard maxAttempts >= 1 else { return false } for attempt in 1 ... maxAttempts { if setDiverted(enabled, for: controlID, using: transport, featureIndex: featureIndex) { return true } guard attempt < maxAttempts, shouldContinueRunning() else { break } os_log( "Logitech setCidReporting retry %{public}d/%{public}d: cid=0x%{public}04X", log: Self.log, type: .info, attempt, maxAttempts, controlID ) Thread.sleep(forTimeInterval: retryDelay) } return false } private func restoreReportingState( _ reportingByControlID: [UInt16: ReportingInfo], using transport: LogitechHIDPPTransport, featureIndex: UInt8, locationID: Int, slot: UInt8, reason: StaticString ) -> [UInt16: ReportingInfo] { reportingByControlID.reduce(into: [UInt16: ReportingInfo]()) { result, entry in let (controlID, reportingInfo) = entry let shouldBeDiverted = reportingInfo.flags.contains(.diverted) guard setDiverted(shouldBeDiverted, for: controlID, using: transport, featureIndex: featureIndex) else { os_log( "%{public}s failed: locationID=%{public}d slot=%{public}u cid=0x%{public}04X target=%{public}@", log: Self.log, type: .error, String(describing: reason), locationID, slot, controlID, shouldBeDiverted ? "diverted" : "native" ) result[controlID] = reportingInfo return } guard let currentReportingInfo = readReportingInfo( for: controlID, using: transport, featureIndex: featureIndex ) else { os_log( "%{public}s verification failed: locationID=%{public}d slot=%{public}u cid=0x%{public}04X", log: Self.log, type: .error, String(describing: reason), locationID, slot, controlID ) result[controlID] = reportingInfo return } let isDiverted = currentReportingInfo.flags.contains(.diverted) guard isDiverted == shouldBeDiverted else { os_log( "%{public}s verification mismatch: locationID=%{public}d slot=%{public}u cid=0x%{public}04X target=%{public}@ actual=%{public}@ reporting=%{public}@", log: Self.log, type: .error, String(describing: reason), locationID, slot, controlID, shouldBeDiverted ? "diverted" : "native", isDiverted ? "diverted" : "native", describeReportingFlags(currentReportingInfo.flags) ) result[controlID] = reportingInfo return } } } private func shouldContinueRunning() -> Bool { stateLock.lock() defer { stateLock.unlock() } return running && !Thread.current.isCancelled } private func postSyntheticButton(button: Int, down: Bool) { stateLock.lock() let shouldPost = down ? pressedButtons.insert(button).inserted : pressedButtons.remove(button) != nil stateLock.unlock() guard shouldPost else { return } SyntheticMouseButtonEventEmitter.post(button: button, down: down) } private func releaseButtonIfNeeded() { stateLock.lock() let buttonsToRelease = pressedButtons pressedButtons.removeAll() stateLock.unlock() for button in buttonsToRelease { SyntheticMouseButtonEventEmitter.post(button: button, down: false) } } static func isDivertedButtonsNotification(_ report: [UInt8], featureIndex: UInt8, slot: UInt8) -> Bool { guard report.count >= 4, [LogitechHIDPPDeviceMetadataProvider.Constants.shortReportID, LogitechHIDPPDeviceMetadataProvider.Constants.longReportID].contains(report[0]), report[1] == slot, report[2] == featureIndex else { return false } return (report[3] >> 4) == 0x00 } static func parseDivertedButtonsNotification(_ report: [UInt8]) -> Set { let payload = Array(report.dropFirst(4)) guard payload.count >= 2 else { return [] } var controls = Set() var index = 0 while index + 1 < payload.count { let controlID = UInt16(payload[index]) << 8 | UInt16(payload[index + 1]) guard controlID != 0 else { break } controls.insert(controlID) index += 2 } return controls } private func logAvailableControls( transport: LogitechHIDPPTransport, featureIndex: UInt8, slot: UInt8, locationID: Int ) { let controls = fetchControls(using: transport, featureIndex: featureIndex) guard !controls.isEmpty else { os_log( "No Logitech reprogrammable controls discovered: locationID=%{public}d slot=%{public}u", log: Self.log, type: .info, locationID, slot ) return } let summary = controls.map { control -> String in let reporting = readReportingInfo(for: control.controlID, using: transport, featureIndex: featureIndex) return String( format: "cid=0x%04X tid=0x%04X pos=%u group=%u mask=0x%02X flags=%@ reporting=%@ mapped=0x%04X", control.controlID, control.taskID, control.position, control.group, control.groupMask, describeControlFlags(control.flags), describeReportingFlags(reporting?.flags ?? []), reporting?.mappedControlID ?? control.controlID ) } .joined(separator: " | ") os_log( "Logitech REPROG_CONTROLS_V4 dump: locationID=%{public}d slot=%{public}u controls=%{public}@", log: Self.log, type: .info, locationID, slot, summary ) } private func describeControlFlags(_ flags: LogitechHIDPPDeviceMetadataProvider.ReprogControlsV4 .ControlFlags) -> String { var parts = [String]() if flags.contains(.mouseButton) { parts.append("mse") } if flags.contains(.reprogrammable) { parts.append("reprogrammable") } if flags.contains(.divertable) { parts.append("divertable") } if flags.contains(.persistentlyDivertable) { parts.append("persistently_divertable") } if flags.contains(.virtual) { parts.append("virtual") } if flags.contains(.rawXY) { parts.append("raw_xy") } if flags.contains(.forceRawXY) { parts.append("force_raw_xy") } return parts.isEmpty ? "none" : parts.joined(separator: ",") } private func describeReportingFlags(_ flags: LogitechHIDPPDeviceMetadataProvider.ReprogControlsV4 .ReportingFlags) -> String { var parts = [String]() if flags.contains(.diverted) { parts.append("diverted") } if flags.contains(.persistentlyDiverted) { parts.append("persistently_diverted") } if flags.contains(.rawXYDiverted) { parts.append("raw_xy_diverted") } if flags.contains(.forceRawXYDiverted) { parts.append("force_raw_xy_diverted") } return parts.isEmpty ? "default" : parts.joined(separator: ",") } } private protocol HIDPPNotificationHandling: AnyObject { func enableNotifications() func waitForHIDPPNotification( timeout: TimeInterval, matching: @escaping ([UInt8]) -> Bool, until shouldContinue: (() -> Bool)? ) -> [UInt8]? } private final class HIDPPNotificationEndpoint: HIDPPNotificationHandling { private static let maxBufferedReports = 64 private let lock = NSLock() private let semaphore = DispatchSemaphore(value: 0) private var bufferedReports = [[UInt8]]() func enableNotifications() {} func handleInputReport(_ report: Data) { let bytes = [UInt8](report) guard let reportID = bytes.first, [LogitechHIDPPDeviceMetadataProvider.Constants.shortReportID, LogitechHIDPPDeviceMetadataProvider.Constants.longReportID].contains(reportID) else { return } lock.lock() bufferedReports.append(bytes) if bufferedReports.count > Self.maxBufferedReports { bufferedReports.removeFirst(bufferedReports.count - Self.maxBufferedReports) } lock.unlock() semaphore.signal() } func waitForHIDPPNotification( timeout: TimeInterval, matching: @escaping ([UInt8]) -> Bool, until shouldContinue: (() -> Bool)? = nil ) -> [UInt8]? { let deadline = Date().addingTimeInterval(timeout) while Date() < deadline { if let shouldContinue, !shouldContinue() { return nil } if let report = dequeueFirstMatchingReport(matching: matching) { return report } let remaining = deadline.timeIntervalSinceNow guard remaining > 0 else { break } _ = semaphore.wait(timeout: .now() + min(remaining, 0.01)) } return dequeueFirstMatchingReport(matching: matching) } private func dequeueFirstMatchingReport(matching: ([UInt8]) -> Bool) -> [UInt8]? { lock.lock() defer { lock.unlock() } guard let index = bufferedReports.firstIndex(where: matching) else { return nil } return bufferedReports.remove(at: index) } } extension LogitechReceiverChannel: HIDPPNotificationHandling { func enableNotifications() { enableWirelessNotifications() } } private extension UInt16 { var bytes: [UInt8] { [UInt8(self >> 8), UInt8(self & 0xFF)] } } ================================================ FILE: LinearMouse/Device/VendorSpecific/PointerDevice+VendorSpecificDeviceContext.swift ================================================ // MIT License // Copyright (c) 2021-2026 LinearMouse import PointerKit extension PointerDevice: VendorSpecificDeviceContext {} ================================================ FILE: LinearMouse/Device/VendorSpecific/VendorSpecificDeviceMetadata.swift ================================================ // MIT License // Copyright (c) 2021-2026 LinearMouse import Foundation protocol VendorSpecificDeviceContext { var vendorID: Int? { get } var productID: Int? { get } var product: String? { get } var name: String { get } var serialNumber: String? { get } var transport: String? { get } var locationID: Int? { get } var primaryUsagePage: Int? { get } var primaryUsage: Int? { get } var maxInputReportSize: Int? { get } var maxOutputReportSize: Int? { get } var maxFeatureReportSize: Int? { get } func performSynchronousOutputReportRequest( _ report: Data, timeout: TimeInterval, matching: @escaping (Data) -> Bool ) -> Data? } struct VendorSpecificDeviceMatcher { let vendorID: Int? let productIDs: Set? let transports: Set? func matches(device: VendorSpecificDeviceContext) -> Bool { if let vendorID, device.vendorID != vendorID { return false } if let productIDs { guard let productID = device.productID, productIDs.contains(productID) else { return false } } if let transports { guard let transport = device.transport, transports.contains(transport) else { return false } } return true } } struct VendorSpecificDeviceMetadata: Equatable { let name: String? let batteryLevel: Int? } protocol VendorSpecificDeviceMetadataProvider { var matcher: VendorSpecificDeviceMatcher { get } func metadata(for device: VendorSpecificDeviceContext) -> VendorSpecificDeviceMetadata? } extension VendorSpecificDeviceMetadataProvider { func matches(device: VendorSpecificDeviceContext) -> Bool { matcher.matches(device: device) } } enum VendorSpecificDeviceMetadataRegistry { static let providers: [VendorSpecificDeviceMetadataProvider] = [ LogitechHIDPPDeviceMetadataProvider() ] static func metadata(for device: VendorSpecificDeviceContext) -> VendorSpecificDeviceMetadata? { for provider in providers where provider.matches(device: device) { if let metadata = provider.metadata(for: device) { return metadata } } return nil } } ================================================ FILE: LinearMouse/EventTap/EventTap.swift ================================================ // MIT License // Copyright (c) 2021-2026 LinearMouse import Foundation import ObservationToken import os.log enum EventTap {} extension EventTap { private static let log = OSLog(subsystem: Bundle.main.bundleIdentifier!, category: "EventTap") typealias Callback = (_ proxy: CGEventTapProxy, _ event: CGEvent) -> CGEvent? private class ContextHolder { var tap: CFMachPort? let callback: Callback init(_ callback: @escaping Callback) { self.callback = callback } } private static let callbackInvoker: CGEventTapCallBack = { proxy, type, event, refcon -> Unmanaged? in // If no refcon (aka userInfo) is passed in, just bypass the event. guard let refcon else { return Unmanaged.passUnretained(event) } // Get the tap and the callback from contextHolder. let contextHolder = Unmanaged.fromOpaque(refcon).takeUnretainedValue() let tap = contextHolder.tap let callback = contextHolder.callback switch type { case .tapDisabledByUserInput: return Unmanaged.passUnretained(event) case .tapDisabledByTimeout: os_log("EventTap disabled by timeout, re-enable it", log: log, type: .error, String(describing: type)) guard let tap else { os_log("Cannot find the tap", log: log, type: .error, String(describing: type)) return Unmanaged.passUnretained(event) } CGEvent.tapEnable(tap: tap, enable: true) return Unmanaged.passUnretained(event) default: let originalEvent = event // If the callback returns nil, ignore the event. guard let event = callback(proxy, event) else { return nil } // If the callback returns a different event (e.g. a copy), // use passRetained to transfer ownership to the caller. if event === originalEvent { return Unmanaged.passUnretained(event) } return Unmanaged.passRetained(event) } } /** Create an `EventTap` to observe the `events` and add it to the `runLoop`. - Parameters: - events: The event types to observe. - runLoop: The target `RunLoop` to run the event tap. - callback: The callback of the event tap. */ static func observe( _ events: [CGEventType], place: CGEventTapPlacement = .headInsertEventTap, at runLoop: RunLoop = .current, callback: @escaping Callback ) throws -> ObservationToken { // Create a context holder. The lifetime of contextHolder should be the same as ObservationToken's. let contextHolder = ContextHolder(callback) // Create event tap. let eventsOfInterest = events.reduce(CGEventMask(0)) { $0 | (1 << $1.rawValue) } guard let tap = CGEvent.tapCreate( tap: .cghidEventTap, place: place, options: .defaultTap, eventsOfInterest: eventsOfInterest, callback: callbackInvoker, userInfo: Unmanaged.passUnretained(contextHolder).toOpaque() ) else { throw EventTapError.failedToCreate } // Attach tap to contextHolder. contextHolder.tap = tap // Create and add run loop source to the run loop. let runLoopSource = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, tap, 0) let cfRunLoop = runLoop.getCFRunLoop() CFRunLoopAddSource(cfRunLoop, runLoopSource, .commonModes) // Periodically check if the tap is still enabled and re-enable it if needed. // This recovers from cases where the system silently disables the tap // (e.g. due to an invalid event or other transient errors). let healthCheckTimer = Timer(timeInterval: 5, repeats: true) { _ in guard CFMachPortIsValid(tap) else { return } if !CGEvent.tapIsEnabled(tap: tap) { os_log("EventTap found disabled, re-enabling", log: log, type: .error) CGEvent.tapEnable(tap: tap, enable: true) } } runLoop.add(healthCheckTimer, forMode: .common) return ObservationToken { // The lifetime of contextHolder needs to be extended until the observation token is cancelled. withExtendedLifetime(contextHolder) { // Timer.invalidate() must be called from the thread where the timer was installed. // Dispatch it to the target RunLoop; the remaining teardown calls are thread-safe. CFRunLoopPerformBlock(cfRunLoop, CFRunLoopMode.commonModes.rawValue) { healthCheckTimer.invalidate() } CFRunLoopWakeUp(cfRunLoop) CGEvent.tapEnable(tap: tap, enable: false) CFRunLoopRemoveSource(cfRunLoop, runLoopSource, .commonModes) CFMachPortInvalidate(tap) } } } } enum EventTapError: Error { case failedToCreate } ================================================ FILE: LinearMouse/EventTap/EventThread.swift ================================================ // MIT License // Copyright (c) 2021-2026 LinearMouse import Foundation import os.log private final class EventThreadResultBox { var value: Value? } /// Manages a dedicated background thread with its own RunLoop for CGEvent processing. /// /// All event transformer state access (transform, tick, deactivate) must happen on this thread. /// Use ``perform(_:)`` to dispatch work from other threads, and ``scheduleTimer(interval:repeats:handler:)`` /// to create timers that fire on this thread. final class EventThread { static let shared = EventThread() private static let log = OSLog(subsystem: Bundle.main.bundleIdentifier!, category: "EventThread") /// The RunLoop of the background thread. Nil when the thread is not running. /// Exposed for `EventTap` to attach its `CFMachPort` source. private(set) var runLoop: RunLoop? /// Called on the event thread just before it stops. /// Set by `GlobalEventTap` to wire up cleanup (e.g. cache invalidation) without tight coupling. var onWillStop: (() -> Void)? private var thread: Thread? private let runLoopReady = DispatchSemaphore(value: 0) init() {} /// Whether the current thread is the event processing thread. var isCurrent: Bool { Thread.current === thread } // MARK: - Lifecycle func start() { guard thread == nil else { return } let thread = Thread { [weak self] in guard let self else { return } let rl = RunLoop.current // Keep the RunLoop alive even without event sources. rl.add(Port(), forMode: .common) self.runLoop = rl self.runLoopReady.signal() CFRunLoopRun() } thread.name = "com.linearmouse.event-thread" thread.qualityOfService = .userInteractive self.thread = thread thread.start() runLoopReady.wait() } /// Stop the event thread synchronously. /// /// Fires `onWillStop` on the event thread, waits for the RunLoop to exit, /// then returns. This makes `stop(); start()` safe — the old thread is fully /// torn down before a new one is created. func stop() { precondition(!isCurrent, "EventThread.stop() must not be called from the event thread") guard let cfRunLoop = runLoop?.getCFRunLoop() else { return } // Queue the willStop callback and CFRunLoopStop via FIFO ordering. // All previously queued blocks (e.g. timer invalidations) complete first. // thread/runLoop are kept alive until onWillStop finishes so that isCurrent // and perform() still work correctly during teardown. let done = DispatchSemaphore(value: 0) CFRunLoopPerformBlock(cfRunLoop, CFRunLoopMode.commonModes.rawValue) { [weak self] in self?.onWillStop?() // Clear state after onWillStop so isCurrent was valid during teardown. self?.thread?.cancel() self?.thread = nil self?.runLoop = nil } CFRunLoopPerformBlock(cfRunLoop, CFRunLoopMode.commonModes.rawValue) { CFRunLoopStop(cfRunLoop) done.signal() } CFRunLoopWakeUp(cfRunLoop) // Wait for the event thread to finish. The teardown callback only posts // non-blocking work back to the main queue. done.wait() } // MARK: - Dispatch /// Schedule a block to run on the event thread. /// Returns `false` if the event thread is not running (block is not enqueued). @discardableResult func perform(_ block: @escaping () -> Void) -> Bool { guard let cfRunLoop = runLoop?.getCFRunLoop() else { return false } CFRunLoopPerformBlock(cfRunLoop, CFRunLoopMode.commonModes.rawValue, block) CFRunLoopWakeUp(cfRunLoop) return true } /// Execute a block on the event thread and wait for the result. /// Returns `nil` if the event thread is not running. func performAndWait(_ block: @escaping () -> T) -> T? { if isCurrent { return block() } guard let cfRunLoop = runLoop?.getCFRunLoop() else { return nil } let done = DispatchSemaphore(value: 0) let result = EventThreadResultBox() CFRunLoopPerformBlock(cfRunLoop, CFRunLoopMode.commonModes.rawValue) { result.value = block() done.signal() } CFRunLoopWakeUp(cfRunLoop) done.wait() return result.value } // MARK: - Timer /// Create a repeating or one-shot timer on the event thread's RunLoop. /// Returns `nil` if the event thread is not running. func scheduleTimer( interval: TimeInterval, repeats: Bool, handler: @escaping () -> Void ) -> EventThreadTimer? { if isCurrent { return scheduleTimerOnCurrentThread(interval: interval, repeats: repeats, handler: handler) } guard let timer = performAndWait({ self.scheduleTimerOnCurrentThread(interval: interval, repeats: repeats, handler: handler) }) else { return nil } return timer } private func scheduleTimerOnCurrentThread( interval: TimeInterval, repeats: Bool, handler: @escaping () -> Void ) -> EventThreadTimer? { guard let runLoop else { return nil } let timer = Timer(timeInterval: interval, repeats: repeats) { _ in handler() } runLoop.add(timer, forMode: .common) return EventThreadTimer(timer: timer, eventThread: self) } } // MARK: - EventThreadTimer /// Lightweight wrapper around `Timer` that ensures invalidation happens on the correct thread. /// /// When invalidated from the event thread, the underlying timer is stopped synchronously. /// When invalidated from any other thread, invalidation is dispatched to the event thread. /// On `deinit`, the timer is automatically invalidated — callers don't need manual cleanup. final class EventThreadTimer { private var timer: Timer? private weak var eventThread: EventThread? init(timer: Timer, eventThread: EventThread) { self.timer = timer self.eventThread = eventThread } deinit { invalidate() } /// Invalidate the underlying timer. Safe to call from any thread and idempotent. func invalidate() { guard let timer else { return } self.timer = nil if let eventThread, eventThread.isCurrent { // Already on the event thread — invalidate synchronously. timer.invalidate() } else if let eventThread { // Dispatch to the event thread where the timer was installed. eventThread.perform { timer.invalidate() } } // If eventThread is nil (already deallocated), the RunLoop is gone // and the timer is implicitly dead. } var isValid: Bool { timer?.isValid ?? false } } ================================================ FILE: LinearMouse/EventTap/EventType.swift ================================================ // MIT License // Copyright (c) 2021-2026 LinearMouse class EventType { static let all: [CGEventType] = [ .scrollWheel, .leftMouseDown, .leftMouseUp, .leftMouseDragged, .rightMouseDown, .rightMouseUp, .rightMouseDragged, .otherMouseDown, .otherMouseUp, .otherMouseDragged, .keyDown, .keyUp, .flagsChanged ] static let mouseMoved: CGEventType = .mouseMoved } ================================================ FILE: LinearMouse/EventTap/GlobalEventTap.swift ================================================ // MIT License // Copyright (c) 2021-2026 LinearMouse import AppKit import Foundation import ObservationToken import os.log class GlobalEventTap { private static let log = OSLog(subsystem: Bundle.main.bundleIdentifier!, category: "GlobalEventTap") static let shared = GlobalEventTap() private var observationToken: ObservationToken? private lazy var watchdog = GlobalEventTapWatchdog() private let eventThread = EventThread.shared init() {} private func callback(_ event: CGEvent) -> CGEvent? { ModifierState.shared.update(with: event) let mouseEventView = MouseEventView(event) let eventTransformer = EventTransformerManager.shared.get( withCGEvent: event, withSourcePid: mouseEventView.sourcePid, withTargetPid: mouseEventView.targetPid, withMouseLocationPid: mouseEventView.mouseLocationOwnerPid, withDisplay: ScreenManager.shared.currentScreenNameSnapshot ) return eventTransformer.transform(event) } func start() { guard observationToken == nil else { return } guard AccessibilityPermission.enabled else { let alert = NSAlert() alert.messageText = NSLocalizedString( "Failed to create GlobalEventTap: Accessibility permission not granted", comment: "" ) alert.runModal() return } var eventTypes: [CGEventType] = EventType.all if SchemeState.shared.schemes.contains(where: { $0.pointer.redirectsToScroll ?? false }) || SchemeState.shared.schemes.contains(where: { $0.buttons.$autoScroll?.enabled ?? false }) || SchemeState.shared.schemes.contains(where: { $0.buttons.$gesture?.enabled ?? false }) { eventTypes.append(EventType.mouseMoved) } eventThread.onWillStop = { EventTransformerManager.shared.resetForRestart() } eventThread.start() guard let observationResult = eventThread.performAndWait({ Result { try EventTap.observe(eventTypes) { [weak self] in self?.callback($1) } } }) else { eventThread.stop() return } switch observationResult { case let .success(token): observationToken = token case let .failure(error): eventThread.stop() NSAlert(error: error).runModal() return } watchdog.start() } func stop() { // Release the observation token, which dispatches timer invalidation // to the event RunLoop (see EventTap.observe). observationToken = nil // EventThread.stop() fires onWillStop (which calls resetForRestart) // then stops the RunLoop, all in FIFO order. eventThread.stop() watchdog.stop() } } ================================================ FILE: LinearMouse/EventTap/GlobalEventTapWatchdog.swift ================================================ // MIT License // Copyright (c) 2021-2026 LinearMouse import AppKit import os.log class GlobalEventTapWatchdog { private static let log = OSLog(subsystem: Bundle.main.bundleIdentifier!, category: "GlobalEventTapWatchdog") init() {} deinit { stop() } var timer: Timer? func start() { timer = Timer.scheduledTimer(withTimeInterval: 10, repeats: true) { [weak self] _ in guard let self else { return } self.testAccessibilityPermission() } } func stop() { timer?.invalidate() timer = nil } func testAccessibilityPermission() { do { try EventTap.observe([.scrollWheel]) { _, event in event }.removeLifetime() } catch { stop() Application.restart() } } } ================================================ FILE: LinearMouse/EventTransformer/AutoScrollTransformer.swift ================================================ // MIT License // Copyright (c) 2021-2026 LinearMouse import AppKit import ApplicationServices import Foundation import os.log private let autoScrollIndicatorSize = CGSize(width: 48, height: 48) final class AutoScrollTransformer { static let log = OSLog(subsystem: Bundle.main.bundleIdentifier!, category: "AutoScroll") private static let deadZone: Double = 10 private static let maxScrollStep: Double = 160 private static let timerInterval: TimeInterval = 1.0 / 60.0 private static let maxAccessibilityParentDepth = 20 private static let accessibilityProbeRadius: CGFloat = 4 private static let excludedAccessibilityRoles: Set = [ "AXMenuBar", "AXMenuBarItem", "AXMenu", "AXMenuItem", "AXMenuButton", "AXPopUpButton", "AXTabGroup", "AXToolbar" ] private static let excludedAccessibilitySubroles: Set = [ "AXTabButton", "AXMenuItem", "AXSortButton" ] private static let webContentAccessibilityRoles: Set = [ "AXWebArea", "AXScrollArea" ] private static let pressableAccessibilityRoles: Set = [ "AXLink", "AXButton", "AXCheckBox", "AXRadioButton", "AXPopUpButton", "AXMenuButton", "AXComboBox", "AXDisclosureTriangle", "AXSwitch" ] private let trigger: Scheme.Buttons.Mapping private let modes: [Scheme.Buttons.AutoScroll.Mode] private let speed: Double private let preserveNativeMiddleClick: Bool private enum Session { case toggle case hold case pendingToggleOrHold } private enum State { case idle case pendingPreservedClick(anchor: CGPoint, current: CGPoint, downEvent: CGEvent) case active(anchor: CGPoint, current: CGPoint, session: Session) } private var state: State = .idle private var suppressTriggerUp = false private var suppressedExitMouseButton: CGMouseButton? private var timer: EventThreadTimer? private let indicatorController = AutoScrollIndicatorWindowController() init( trigger: Scheme.Buttons.Mapping, modes: [Scheme.Buttons.AutoScroll.Mode], speed: Double, preserveNativeMiddleClick: Bool ) { self.trigger = trigger self.modes = modes self.speed = speed self.preserveNativeMiddleClick = preserveNativeMiddleClick } deinit { DispatchQueue.main.async { [indicatorController] in indicatorController.hide() } } } extension AutoScrollTransformer: EventTransformer { func transform(_ event: CGEvent) -> CGEvent? { if case let .active(_, _, session) = state, session == .toggle, isAnyMouseDownEvent(event), !matchesTriggerButton(event) { suppressedExitMouseButton = MouseEventView(event).mouseButton deactivate() return nil } if let suppressedExitMouseButton, isMouseUpEvent(event, for: suppressedExitMouseButton) { self.suppressedExitMouseButton = nil return nil } switch event.type { case triggerMouseDownEventType: return handleTriggerDown(event) case triggerMouseUpEventType: return handleTriggerUp(event) case triggerMouseDraggedEventType, .mouseMoved: return handlePointerMoved(event) default: return event } } private var triggerMouseDownEventType: CGEventType { triggerMouseButton.fixedCGEventType(of: .otherMouseDown) } private var triggerMouseUpEventType: CGEventType { triggerMouseButton.fixedCGEventType(of: .otherMouseUp) } private var triggerMouseDraggedEventType: CGEventType { triggerMouseButton.fixedCGEventType(of: .otherMouseDragged) } private var triggerMouseButton: CGMouseButton { let defaultButton = UInt32(CGMouseButton.center.rawValue) let buttonNumber = trigger.button?.syntheticMouseButtonNumber ?? Int(defaultButton) return CGMouseButton(rawValue: UInt32(buttonNumber)) ?? .center } private var triggerIsLogitechControl: Bool { trigger.button?.logitechControl != nil } private func handleTriggerDown(_ event: CGEvent) -> CGEvent? { guard matchesTriggerButton(event) else { return event } if case let .active(_, _, session) = state, session == .toggle { guard hasToggleMode else { return nil } deactivate() suppressTriggerUp = true return nil } guard matchesActivationTrigger(event) else { return event } let activationHit = activationHit(for: event) switch activationHit { case .excludedChrome: return event case .pressable: guard shouldPreserveNativeMiddleClick else { break } if hasHoldMode { let point = pointerLocation(for: event) let downEvent = event.copy() ?? event state = .pendingPreservedClick(anchor: point, current: point, downEvent: downEvent) suppressTriggerUp = true return nil } return event case .nonPressable, .unknown, nil: break } activate(at: pointerLocation(for: event), session: activationSession) suppressTriggerUp = true return nil } private func handleTriggerUp(_ event: CGEvent) -> CGEvent? { guard matchesTriggerButton(event) else { return event } guard suppressTriggerUp else { return event } switch state { case let .pendingPreservedClick(anchor, current, downEvent): if !exceedsDeadZone(from: anchor, to: current) { postDeferredNativeClick(from: downEvent) } state = .idle case let .active(anchor, current, session): switch session { case .hold: deactivate() case .pendingToggleOrHold: if exceedsDeadZone(from: anchor, to: current) { deactivate() } else { state = .active(anchor: anchor, current: current, session: .toggle) } case .toggle: break } case .idle: break } suppressTriggerUp = false return nil } private func handlePointerMoved(_ event: CGEvent) -> CGEvent? { switch state { case let .pendingPreservedClick(anchor, _, downEvent): let point = pointerLocation(for: event) if event.type == triggerMouseDraggedEventType, exceedsDeadZone(from: anchor, to: point) { activate(at: anchor, session: .hold) state = .active(anchor: anchor, current: point, session: .hold) let delta = CGVector(dx: point.x - anchor.x, dy: point.y - anchor.y) DispatchQueue.main.async { [indicatorController] in indicatorController.update(delta: delta) } return nil } state = .pendingPreservedClick(anchor: anchor, current: point, downEvent: downEvent) if event.type == triggerMouseDraggedEventType, suppressTriggerUp { return nil } return event case let .active(anchor, _, session): let point = pointerLocation(for: event) let resolvedSession: Session let isDragOrLogitechMove = event.type == triggerMouseDraggedEventType || (triggerIsLogitechControl && event.type == .mouseMoved) if session == .pendingToggleOrHold, isDragOrLogitechMove, exceedsDeadZone(from: anchor, to: point) { resolvedSession = .hold } else { resolvedSession = session } state = .active(anchor: anchor, current: point, session: resolvedSession) let delta = CGVector(dx: point.x - anchor.x, dy: point.y - anchor.y) DispatchQueue.main.async { [indicatorController] in indicatorController.update(delta: delta) } if event.type == triggerMouseDraggedEventType, suppressTriggerUp { return nil } return event case .idle: return event } } var isAutoscrollActive: Bool { if case .active = state { return true } return false } private func matchesActivationTrigger(_ event: CGEvent) -> Bool { guard matchesTriggerButton(event) else { return false } return trigger.matches(modifierFlags: event.flags) } private func matchesTriggerButton(_ event: CGEvent) -> Bool { guard let eventButton = MouseEventView(event).mouseButton else { return false } return eventButton == triggerMouseButton } private func isAnyMouseDownEvent(_ event: CGEvent) -> Bool { switch event.type { case .leftMouseDown, .rightMouseDown, .otherMouseDown: return true default: return false } } private func isMouseUpEvent(_ event: CGEvent, for button: CGMouseButton) -> Bool { guard let eventButton = MouseEventView(event).mouseButton else { return false } switch event.type { case .leftMouseUp, .rightMouseUp, .otherMouseUp: return eventButton == button default: return false } } private func activate(at point: CGPoint, session: Session) { os_log( "Auto scroll activated (modes=%{public}@, button=%{public}d)", log: Self.log, type: .info, modes.map(\.rawValue).joined(separator: ","), Int(triggerMouseButton.rawValue) ) suppressedExitMouseButton = nil state = .active(anchor: point, current: point, session: session) DispatchQueue.main.async { [indicatorController] in indicatorController.show(at: point) indicatorController.update(delta: .zero) } startTimerIfNeeded() } private func startTimerIfNeeded() { guard timer == nil else { return } timer = EventThread.shared.scheduleTimer( interval: Self.timerInterval, repeats: true ) { [weak self] in self?.tick() } } private func tick() { guard case let .active(anchor, current, _) = state else { return } let horizontal = scrollAmount(for: anchor.x - current.x) let vertical = scrollAmount(for: current.y - anchor.y) guard horizontal != 0 || vertical != 0 else { return } postContinuousScrollEvent(horizontal: horizontal, vertical: vertical) } private func scrollAmount(for delta: Double) -> Double { let adjusted = abs(delta) - Self.deadZone guard adjusted > 0 else { return 0 } let base = adjusted * speed * 0.12 let boost = sqrt(adjusted) * speed * 0.6 let value = min(Self.maxScrollStep, base + boost) return delta.sign == .minus ? -value : value } private func postContinuousScrollEvent(horizontal: Double, vertical: Double) { guard let event = CGEvent( scrollWheelEvent2Source: nil, units: .pixel, wheelCount: 2, wheel1: 0, wheel2: 0, wheel3: 0 ) else { return } event.setDoubleValueField(.scrollWheelEventPointDeltaAxis1, value: vertical) event.setDoubleValueField(.scrollWheelEventFixedPtDeltaAxis1, value: vertical) event.setDoubleValueField(.scrollWheelEventPointDeltaAxis2, value: horizontal) event.setDoubleValueField(.scrollWheelEventFixedPtDeltaAxis2, value: horizontal) event.flags = [] event.post(tap: .cgSessionEventTap) } private var hasToggleMode: Bool { modes.contains(.toggle) } private var hasHoldMode: Bool { modes.contains(.hold) } private var activationSession: Session { switch (hasToggleMode, hasHoldMode) { case (true, true): .pendingToggleOrHold case (false, true): .hold default: .toggle } } private func pointerLocation(for event: CGEvent) -> CGPoint { event.unflippedLocation } private func exceedsDeadZone(from anchor: CGPoint, to point: CGPoint) -> Bool { abs(point.x - anchor.x) > Self.deadZone || abs(point.y - anchor.y) > Self.deadZone } private var shouldPreserveNativeMiddleClick: Bool { guard preserveNativeMiddleClick, hasToggleMode, triggerMouseButton == .center, trigger.modifierFlags.isEmpty else { return false } return true } private func hitTestPoint(for event: CGEvent) -> CGPoint { event.location } private func activationHit(for event: CGEvent) -> ActivationHit? { guard AccessibilityPermission.enabled else { return nil } // Use the event snapshot position instead of re-sampling the current cursor location. // This keeps the AX hit-test anchored to the original click we are classifying. let point = hitTestPoint(for: event) let initialProbe = ActivationProbe(point: point, hit: hitAccessibilityElement(at: point)) let resolvedProbe = refineActivationProbe(from: initialProbe) logAccessibilityHit(initial: initialProbe, resolved: resolvedProbe) return resolvedProbe.hit } private func refineActivationProbe(from initialProbe: ActivationProbe) -> ActivationProbe { guard initialProbe.hit.requiresAdditionalSampling else { return initialProbe } // Browser accessibility trees can return a generic container chain for a point that // is visually still inside a link. Probe a few nearby points and trust any result // that clearly says "do not start autoscroll". var bestProbe = initialProbe for point in accessibilityProbePoints(around: initialProbe.point) { let sampledProbe = ActivationProbe(point: point, hit: hitAccessibilityElement(at: point)) if sampledProbe.hit.suppressesAutoscroll { return sampledProbe } if sampledProbe.hit.priority > bestProbe.hit.priority { bestProbe = sampledProbe } } return bestProbe } private func accessibilityProbePoints(around point: CGPoint) -> [CGPoint] { let offsets = [ CGPoint.zero, CGPoint(x: -Self.accessibilityProbeRadius, y: 0), CGPoint(x: Self.accessibilityProbeRadius, y: 0), CGPoint(x: 0, y: -Self.accessibilityProbeRadius), CGPoint(x: 0, y: Self.accessibilityProbeRadius), CGPoint(x: -Self.accessibilityProbeRadius, y: -Self.accessibilityProbeRadius), CGPoint(x: -Self.accessibilityProbeRadius, y: Self.accessibilityProbeRadius), CGPoint(x: Self.accessibilityProbeRadius, y: -Self.accessibilityProbeRadius), CGPoint(x: Self.accessibilityProbeRadius, y: Self.accessibilityProbeRadius) ] return offsets.map { offset in CGPoint(x: point.x + offset.x, y: point.y + offset.y) } } private func hitAccessibilityElement(at point: CGPoint) -> ActivationHit { let systemWideElement = AXUIElementCreateSystemWide() var hitElement: AXUIElement? let hitError = AXUIElementCopyElementAtPosition(systemWideElement, Float(point.x), Float(point.y), &hitElement) guard hitError == .success else { return .unknown(reason: "hitTest.\(describe(error: hitError))", path: []) } guard let hitElement else { return .nonPressable(path: []) } var currentElement: AXUIElement? = hitElement var path: [String] = [] var isInsideWebContent = false for _ in 0 ..< Self.maxAccessibilityParentDepth { guard let element = currentElement else { return .nonPressable(path: path) } let role: String? switch requiredStringValue(of: kAXRoleAttribute as CFString, on: element) { case let .success(value): role = value case let .failure(error): return .unknown(reason: "role.\(describe(error: error))", path: path) } let subrole: String? switch optionalStringValue(of: kAXSubroleAttribute as CFString, on: element) { case let .success(value): subrole = value case let .failure(error): return .unknown(reason: "subrole.\(describe(error: error))", path: path) } let actions: [String] switch optionalActionNames(of: element) { case let .success(value): actions = value case let .failure(error): return .unknown(reason: "actions.\(describe(error: error))", path: path) } path.append(accessibilityPathEntry(role: role, subrole: subrole, actions: actions)) if let role, Self.webContentAccessibilityRoles.contains(role) { isInsideWebContent = true } // Once we have entered web content, ignore higher-level browser chrome ancestors // like tab groups or toolbars. Safari and Chromium often expose those above the // page content, and treating them as excluded chrome would block autoscroll on // normal page clicks. if !isInsideWebContent, isExcludedActivationElement(role: role, subrole: subrole) { return .excludedChrome(path: path) } if Self.isPressableActivationElement(role: role, actions: actions) { return .pressable(path: path) } switch optionalElementValue(of: kAXParentAttribute as CFString, on: element) { case let .success(value): currentElement = value case let .failure(error): return .unknown(reason: "parent.\(describe(error: error))", path: path) } } return .unknown(reason: "depthLimit", path: path) } private func isExcludedActivationElement(role: String?, subrole: String?) -> Bool { if let role, Self.excludedAccessibilityRoles.contains(role) { return true } if let subrole, Self.excludedAccessibilitySubroles.contains(subrole) { return true } return false } static func isPressableActivationElement(role: String?, actions: [String]) -> Bool { guard let role, pressableAccessibilityRoles.contains(role) else { return false } if role == "AXLink" { return true } return actions.contains(kAXPressAction as String) } private func requiredStringValue( of attribute: CFString, on element: AXUIElement ) -> AccessibilityQueryResult { var value: CFTypeRef? let error = AXUIElementCopyAttributeValue(element, attribute, &value) guard error == .success else { return .failure(error) } return .success(value as? String) } private func optionalStringValue( of attribute: CFString, on element: AXUIElement ) -> AccessibilityQueryResult { var value: CFTypeRef? let error = AXUIElementCopyAttributeValue(element, attribute, &value) switch error { case .success: return .success(value as? String) case .noValue, .attributeUnsupported: return .success(nil) default: return .failure(error) } } private func optionalElementValue( of attribute: CFString, on element: AXUIElement ) -> AccessibilityQueryResult { var value: CFTypeRef? let error = AXUIElementCopyAttributeValue(element, attribute, &value) switch error { case .success: return .success(value as! AXUIElement?) case .noValue, .attributeUnsupported: return .success(nil) default: return .failure(error) } } private func optionalActionNames(of element: AXUIElement) -> AccessibilityQueryResult<[String]> { var actions: CFArray? let error = AXUIElementCopyActionNames(element, &actions) switch error { case .success: return .success(actions as? [String] ?? []) case .noValue, .actionUnsupported, .attributeUnsupported: return .success([]) default: return .failure(error) } } private func accessibilityPathEntry(role: String?, subrole: String?, actions: [String]) -> String { let roleDescription = role ?? "?" let subroleDescription = subrole.map { "/\($0)" } ?? "" let pressDescription = actions.contains(kAXPressAction as String) ? "[press]" : "" return "\(roleDescription)\(subroleDescription)\(pressDescription)" } private func describe(error: AXError) -> String { switch error { case .success: "success" case .failure: "failure" case .illegalArgument: "illegalArgument" case .invalidUIElement: "invalidUIElement" case .invalidUIElementObserver: "invalidUIElementObserver" case .cannotComplete: "cannotComplete" case .attributeUnsupported: "attributeUnsupported" case .actionUnsupported: "actionUnsupported" case .notificationUnsupported: "notificationUnsupported" case .notImplemented: "notImplemented" case .notificationAlreadyRegistered: "notificationAlreadyRegistered" case .notificationNotRegistered: "notificationNotRegistered" case .apiDisabled: "apiDisabled" case .noValue: "noValue" case .parameterizedAttributeUnsupported: "parameterizedAttributeUnsupported" case .notEnoughPrecision: "notEnoughPrecision" @unknown default: "unknown(\(error.rawValue))" } } private func logAccessibilityHit(initial: ActivationProbe, resolved: ActivationProbe) { let initialPointDescription = String(format: "(%.1f, %.1f)", initial.point.x, initial.point.y) let resolvedPointDescription = String(format: "(%.1f, %.1f)", resolved.point.x, resolved.point.y) let initialPathDescription = initial.hit.path.isEmpty ? "-" : initial.hit.path.joined(separator: " -> ") let resolvedPathDescription = resolved.hit.path.isEmpty ? "-" : resolved.hit.path.joined(separator: " -> ") if initial.hit.summary == resolved.hit.summary, initial.hit.path == resolved.hit.path, initial.point == resolved.point { os_log( "Auto scroll AX hit result=%{public}@ point=%{public}@ path=%{public}@", log: Self.log, type: .info, resolved.hit.summary, resolvedPointDescription, resolvedPathDescription ) return } os_log( "Auto scroll AX hit initial=%{public}@ initialPoint=%{public}@ initialPath=%{public}@ resolved=%{public}@ resolvedPoint=%{public}@ resolvedPath=%{public}@", log: Self.log, type: .info, initial.hit.summary, initialPointDescription, initialPathDescription, resolved.hit.summary, resolvedPointDescription, resolvedPathDescription ) } private func postDeferredNativeClick(from downEvent: CGEvent) { guard let eventButton = MouseEventView(downEvent).mouseButton else { return } let location = downEvent.location let flags = downEvent.flags guard let mouseDownEvent = CGEvent( mouseEventSource: nil, mouseType: eventButton.fixedCGEventType(of: .leftMouseDown), mouseCursorPosition: location, mouseButton: eventButton ) else { return } guard let mouseUpEvent = CGEvent( mouseEventSource: nil, mouseType: eventButton.fixedCGEventType(of: .leftMouseUp), mouseCursorPosition: location, mouseButton: eventButton ) else { return } mouseDownEvent.flags = flags mouseUpEvent.flags = flags mouseDownEvent.post(tap: .cgSessionEventTap) mouseUpEvent.post(tap: .cgSessionEventTap) } } private enum ActivationHit { case pressable(path: [String]) case excludedChrome(path: [String]) case nonPressable(path: [String]) case unknown(reason: String, path: [String]) var path: [String] { switch self { case let .pressable(path): path case let .excludedChrome(path): path case let .nonPressable(path): path case let .unknown(_, path): path } } var summary: String { switch self { case .pressable: "pressable" case .excludedChrome: "excludedChrome" case .nonPressable: "nonPressable" case let .unknown(reason, _): "unknown.\(reason)" } } var suppressesAutoscroll: Bool { switch self { case .pressable, .excludedChrome: true case .nonPressable, .unknown: false } } var requiresAdditionalSampling: Bool { switch self { case .nonPressable, .unknown: true case .pressable, .excludedChrome: false } } var priority: Int { switch self { case .pressable, .excludedChrome: 3 case .nonPressable: 2 case .unknown: 1 } } } private enum AccessibilityQueryResult { case success(Value) case failure(AXError) } private struct ActivationProbe { let point: CGPoint let hit: ActivationHit } extension AutoScrollTransformer: LogitechControlEventHandling { func handleLogitechControlEvent(_ context: LogitechEventContext) -> Bool { guard let triggerLogitechControl = trigger.button?.logitechControl, context.controlIdentity.matches(triggerLogitechControl) else { return false } if context.isPressed { // If already active in toggle mode, deactivate on re-press if case let .active(_, _, session) = state, session == .toggle { guard hasToggleMode else { return true } deactivate() return true } guard trigger.matches(modifierFlags: context.modifierFlags) else { return true } activate(at: context.mouseLocation, session: activationSession) return true } switch state { case let .active(anchor, current, session): switch session { case .hold: deactivate() case .pendingToggleOrHold: if exceedsDeadZone(from: anchor, to: current) { deactivate() } else { state = .active(anchor: anchor, current: current, session: .toggle) } case .toggle: break } default: break } return true } } extension AutoScrollTransformer: Deactivatable { func deactivate() { if isAutoscrollActive { os_log("Auto scroll deactivated", log: Self.log, type: .info) } state = .idle suppressTriggerUp = false DispatchQueue.main.async { [indicatorController] in indicatorController.hide() } timer?.invalidate() timer = nil } } extension AutoScrollTransformer { func matchesConfiguration( trigger: Scheme.Buttons.Mapping, modes: [Scheme.Buttons.AutoScroll.Mode], speed: Double, preserveNativeMiddleClick: Bool ) -> Bool { self.trigger == trigger && self.modes == modes && abs(self.speed - speed) < 0.0001 && self.preserveNativeMiddleClick == preserveNativeMiddleClick } } private final class AutoScrollIndicatorWindowController { private lazy var window: NSPanel = { let panel = NSPanel( contentRect: CGRect(origin: .zero, size: autoScrollIndicatorSize), styleMask: [.borderless, .nonactivatingPanel], backing: .buffered, defer: false ) panel.isOpaque = false panel.backgroundColor = .clear panel.hasShadow = false panel.level = .statusBar panel.ignoresMouseEvents = true panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .stationary] panel.contentView = AutoScrollIndicatorView(frame: CGRect(origin: .zero, size: autoScrollIndicatorSize)) return panel }() func show(at point: CGPoint) { let origin = CGPoint( x: point.x - autoScrollIndicatorSize.width / 2, y: point.y - autoScrollIndicatorSize.height / 2 ) window.setFrame(CGRect(origin: origin, size: autoScrollIndicatorSize), display: true) window.orderFrontRegardless() } func update(delta: CGVector) { (window.contentView as? AutoScrollIndicatorView)?.delta = delta } func hide() { window.orderOut(nil) } } private final class AutoScrollIndicatorView: NSView { var delta: CGVector = .zero { didSet { needsDisplay = true } } override var isOpaque: Bool { false } override func draw(_ dirtyRect: NSRect) { super.draw(dirtyRect) let bounds = bounds let circleRect = bounds.insetBy(dx: 4, dy: 4) let ringPath = NSBezierPath(ovalIn: circleRect) if let context = NSGraphicsContext.current?.cgContext { context.saveGState() context.setShadow( offset: CGSize(width: 0, height: -1), blur: 10, color: NSColor.black.withAlphaComponent(0.18).cgColor ) let gradient = NSGradient( colors: [ NSColor(white: 1.0, alpha: 0.97), NSColor(white: 0.93, alpha: 0.95) ] ) gradient?.draw(in: ringPath, angle: 90) context.restoreGState() } NSColor(white: 0.12, alpha: 0.48).setStroke() ringPath.lineWidth = 1 ringPath.stroke() let innerRingPath = NSBezierPath(ovalIn: circleRect.insetBy(dx: 1.5, dy: 1.5)) NSColor.white.withAlphaComponent(0.45).setStroke() innerRingPath.lineWidth = 1 innerRingPath.stroke() let center = CGPoint(x: bounds.midX, y: bounds.midY) let horizontalIntensity = CGFloat(min(1, max(0, (abs(delta.dx) - 10) / 44))) let verticalIntensity = CGFloat(min(1, max(0, (abs(delta.dy) - 10) / 44))) drawArrow( at: CGPoint(x: center.x, y: bounds.maxY - 13), direction: .up, intensity: delta.dy > 0 ? verticalIntensity : 0 ) drawArrow( at: CGPoint(x: bounds.maxX - 13, y: center.y), direction: .right, intensity: delta.dx > 0 ? horizontalIntensity : 0 ) drawArrow( at: CGPoint(x: center.x, y: bounds.minY + 13), direction: .down, intensity: delta.dy < 0 ? verticalIntensity : 0 ) drawArrow( at: CGPoint(x: bounds.minX + 13, y: center.y), direction: .left, intensity: delta.dx < 0 ? horizontalIntensity : 0 ) let crosshair = NSBezierPath() crosshair.move(to: CGPoint(x: center.x, y: bounds.minY + 11)) crosshair.line(to: CGPoint(x: center.x, y: bounds.maxY - 11)) crosshair.move(to: CGPoint(x: bounds.minX + 11, y: center.y)) crosshair.line(to: CGPoint(x: bounds.maxX - 11, y: center.y)) NSColor(white: 0.1, alpha: 0.14).setStroke() crosshair.lineWidth = 1 crosshair.stroke() let centerShadowRect = CGRect(x: center.x - 5, y: center.y - 5, width: 10, height: 10) let centerShadowPath = NSBezierPath(ovalIn: centerShadowRect) NSColor.black.withAlphaComponent(0.14).setFill() centerShadowPath.fill() let dotRect = CGRect(x: center.x - 4, y: center.y - 4, width: 8, height: 8) let dotPath = NSBezierPath(ovalIn: dotRect) NSColor(white: 0.07, alpha: 0.96).setFill() dotPath.fill() let highlightRect = CGRect(x: center.x - 1.5, y: center.y + 1, width: 3, height: 2) let highlightPath = NSBezierPath(ovalIn: highlightRect) NSColor.white.withAlphaComponent(0.28).setFill() highlightPath.fill() } private func drawArrow(at center: CGPoint, direction: Direction, intensity: CGFloat) { let path = NSBezierPath() switch direction { case .up: path.move(to: CGPoint(x: center.x, y: center.y + 6)) path.line(to: CGPoint(x: center.x - 4.5, y: center.y - 3)) path.line(to: CGPoint(x: center.x + 4.5, y: center.y - 3)) case .right: path.move(to: CGPoint(x: center.x + 6, y: center.y)) path.line(to: CGPoint(x: center.x - 3, y: center.y + 4.5)) path.line(to: CGPoint(x: center.x - 3, y: center.y - 4.5)) case .down: path.move(to: CGPoint(x: center.x, y: center.y - 6)) path.line(to: CGPoint(x: center.x - 4.5, y: center.y + 3)) path.line(to: CGPoint(x: center.x + 4.5, y: center.y + 3)) case .left: path.move(to: CGPoint(x: center.x - 6, y: center.y)) path.line(to: CGPoint(x: center.x + 3, y: center.y + 4.5)) path.line(to: CGPoint(x: center.x + 3, y: center.y - 4.5)) } path.close() let alpha = 0.26 + Double(intensity) * 0.68 NSColor(white: 0.04, alpha: alpha).setFill() path.fill() NSColor.white.withAlphaComponent(0.18 + Double(intensity) * 0.12).setStroke() path.lineWidth = 0.7 path.stroke() } private enum Direction { case up case right case down case left } } ================================================ FILE: LinearMouse/EventTransformer/ButtonActionsTransformer.swift ================================================ // MIT License // Copyright (c) 2021-2026 LinearMouse import AppKit import DockKit import Foundation import GestureKit import KeyKit import os.log class ButtonActionsTransformer { static let log = OSLog(subsystem: Bundle.main.bundleIdentifier!, category: "ButtonActions") let mappings: [Scheme.Buttons.Mapping] let universalBackForward: Scheme.Buttons.UniversalBackForward? private enum TimerSlot { case standard case logitech } var repeatTimer: EventThreadTimer? private var logitechRepeatTimer: EventThreadTimer? private var heldKeysByButton = [Scheme.Buttons.Mapping.Button: [Key]]() private static let defaultKeySimulator = KeySimulator() let keySimulator: KeySimulating init( mappings: [Scheme.Buttons.Mapping], universalBackForward: Scheme.Buttons.UniversalBackForward? = nil, keySimulator: KeySimulating? = nil ) { self.mappings = mappings self.universalBackForward = universalBackForward self.keySimulator = keySimulator ?? Self.defaultKeySimulator } private func timer(for slot: TimerSlot) -> EventThreadTimer? { switch slot { case .standard: repeatTimer case .logitech: logitechRepeatTimer } } private func setTimer(_ slot: TimerSlot, _ timer: EventThreadTimer?) { switch slot { case .standard: repeatTimer = timer case .logitech: logitechRepeatTimer = timer } } } extension ButtonActionsTransformer: EventTransformer, LogitechControlEventHandling { var mouseDownEventTypes: [CGEventType] { [.leftMouseDown, .rightMouseDown, .otherMouseDown] } var mouseUpEventTypes: [CGEventType] { [.leftMouseUp, .rightMouseUp, .otherMouseUp] } var mouseDraggedEventTypes: [CGEventType] { [.leftMouseDragged, .rightMouseDragged, .otherMouseDragged] } var scrollWheelsEventTypes: [CGEventType] { [.scrollWheel] } var keyTypes: [CGEventType] { [.keyDown, .keyUp] } var allEventTypesOfInterest: [CGEventType] { [mouseDownEventTypes, mouseUpEventTypes, mouseDraggedEventTypes, scrollWheelsEventTypes, keyTypes] .flatMap(\.self) } func transform(_ event: CGEvent) -> CGEvent? { guard allEventTypesOfInterest.contains(event.type) else { return event } if event.isGestureCleanupRelease { return event } guard !SettingsState.shared.recording else { return event } if keyTypes.contains(event.type), let newFlags = keySimulator.modifiedCGEventFlags(of: event) { os_log( "Update CGEventFlags from %{public}llu to %{public}llu", log: Self.log, type: .info, event.flags.rawValue, newFlags.rawValue ) event.flags = newFlags } // FIXME: Temporary fix for "repeat on hold" if !mouseDraggedEventTypes.contains(event.type) { repeatTimer?.invalidate() repeatTimer = nil } guard let mapping = findMapping(of: event) else { return event } guard let action = mapping.action else { return event } if case .arg0(.auto) = action { return event } if event.type == .scrollWheel { queueActions(event: event.copy(), action: action) } else { if handleKeyPressHold(event: event, mapping: mapping, action: action) { return nil } // FIXME: `NSEvent.keyRepeatDelay` and `NSEvent.keyRepeatInterval` are not kept up to date // TODO: Support override `repeatDelay` and `repeatInterval` let keyRepeatDelay = mapping.repeat == true ? KeyboardSettingsSnapshot.shared.keyRepeatDelay : 0 let keyRepeatInterval = mapping.repeat == true ? KeyboardSettingsSnapshot.shared.keyRepeatInterval : 0 let keyRepeatEnabled = keyRepeatDelay > 0 && keyRepeatInterval > 0 if !keyRepeatEnabled { if handleButtonSwaps(event: event, action: action) { return event } } // Actions are executed when button is down if key repeat is enabled; otherwise, actions are // executed when button is up. let eventsOfInterest = keyRepeatEnabled ? mouseDownEventTypes : mouseUpEventTypes guard eventsOfInterest.contains(event.type) else { return nil } queueActions( event: event.copy(), action: action, keyRepeatEnabled: keyRepeatEnabled, keyRepeatDelay: keyRepeatDelay, keyRepeatInterval: keyRepeatInterval ) } return nil } private func findMapping(of event: CGEvent) -> Scheme.Buttons.Mapping? { mappings.last { $0.match(with: event) } } /// Find the best Logitech mapping for the given control event context (read-only, thread-safe). func findLogitechMapping( for context: LogitechEventContext ) -> (mapping: Scheme.Buttons.Mapping, action: Scheme.Buttons.Mapping.Action)? { guard !SettingsState.shared.recording else { return nil } let matchingMappings = mappings.filter { mapping in guard let logiButton = mapping.button?.logitechControl, context.controlIdentity.matches(logiButton) else { return false } return mapping.matches(modifierFlags: context.modifierFlags) } guard let mapping = matchingMappings.enumerated() .max(by: { lhs, rhs in let lhsSpecificity = lhs.element.button?.logitechControl?.specificityScore ?? 0 let rhsSpecificity = rhs.element.button?.logitechControl?.specificityScore ?? 0 if lhsSpecificity == rhsSpecificity { return lhs.offset < rhs.offset } return lhsSpecificity < rhsSpecificity })?.element, let action = mapping.action else { return nil } if case .arg0(.auto) = action { return nil } return (mapping, action) } func handleLogitechControlEvent(_ context: LogitechEventContext) -> Bool { guard let (mapping, action) = findLogitechMapping(for: context) else { return false } if handleLogitechKeyPressHold(mapping: mapping, action: action, context: context) { return true } logitechRepeatTimer?.invalidate() logitechRepeatTimer = nil let keyRepeatDelay = mapping.repeat == true ? KeyboardSettingsSnapshot.shared.keyRepeatDelay : 0 let keyRepeatInterval = mapping.repeat == true ? KeyboardSettingsSnapshot.shared.keyRepeatInterval : 0 let keyRepeatEnabled = keyRepeatDelay > 0 && keyRepeatInterval > 0 let shouldExecute = keyRepeatEnabled ? context.isPressed : !context.isPressed guard shouldExecute else { return true } queueLogitechActions( event: nil, action: action, targetBundleIdentifier: context.pid?.bundleIdentifier, keyRepeatEnabled: keyRepeatEnabled, keyRepeatDelay: keyRepeatDelay, keyRepeatInterval: keyRepeatInterval ) return true } private func shouldHoldKeys( for mapping: Scheme.Buttons.Mapping, action: Scheme.Buttons.Mapping.Action ) -> Bool { guard case let .arg1(.keyPress(keys)) = action else { return false } return mapping.hold == true || keys.allSatisfy(\.isModifier) } private func handleLogitechKeyPressHold( mapping: Scheme.Buttons.Mapping, action: Scheme.Buttons.Mapping.Action, context: LogitechEventContext ) -> Bool { guard let button = mapping.button, shouldHoldKeys(for: mapping, action: action), case let .arg1(.keyPress(keys)) = action else { return false } if context.isPressed { pressAndStoreHeldKeys(keys, for: button) } else { releaseHeldKeys(for: button, fallbackKeys: keys) } return true } private func queueActions( event: CGEvent?, action: Scheme.Buttons.Mapping.Action, keyRepeatEnabled: Bool = false, keyRepeatDelay: TimeInterval = 0, keyRepeatInterval: TimeInterval = 0 ) { let targetBundleIdentifier = event.flatMap { MouseEventView($0).targetPid?.bundleIdentifier } scheduleRepeatActions( slot: .standard, action: action, targetBundleIdentifier: targetBundleIdentifier, keyRepeatEnabled: keyRepeatEnabled, keyRepeatDelay: keyRepeatDelay, keyRepeatInterval: keyRepeatInterval ) } private func queueLogitechActions( event _: CGEvent?, action: Scheme.Buttons.Mapping.Action, targetBundleIdentifier: String?, keyRepeatEnabled: Bool = false, keyRepeatDelay: TimeInterval = 0, keyRepeatInterval: TimeInterval = 0 ) { scheduleRepeatActions( slot: .logitech, action: action, targetBundleIdentifier: targetBundleIdentifier, keyRepeatEnabled: keyRepeatEnabled, keyRepeatDelay: keyRepeatDelay, keyRepeatInterval: keyRepeatInterval ) } private func scheduleRepeatActions( slot: TimerSlot, action: Scheme.Buttons.Mapping.Action, targetBundleIdentifier: String?, keyRepeatEnabled: Bool, keyRepeatDelay: TimeInterval, keyRepeatInterval: TimeInterval ) { DispatchQueue.main.async { [self] in executeIgnoreErrors(action: action, targetBundleIdentifier: targetBundleIdentifier) } guard keyRepeatEnabled else { return } setTimer(slot, EventThread.shared.scheduleTimer( interval: keyRepeatDelay, repeats: false ) { [weak self] in guard let self else { return } DispatchQueue.main.async { [self] in self.executeIgnoreErrors(action: action, targetBundleIdentifier: targetBundleIdentifier) } self.setTimer(slot, EventThread.shared.scheduleTimer( interval: keyRepeatInterval, repeats: true ) { [weak self] in guard let self else { return } DispatchQueue.main.async { [self] in self.executeIgnoreErrors(action: action, targetBundleIdentifier: targetBundleIdentifier) } }) }) } private func executeIgnoreErrors( action: Scheme.Buttons.Mapping.Action, targetBundleIdentifier: String? ) { do { os_log( "Execute action: %{public}@", log: Self.log, type: .info, String(describing: action) ) try execute(action: action, targetBundleIdentifier: targetBundleIdentifier) } catch { os_log( "Failed to execute: %{public}@: %{public}@", log: Self.log, type: .error, String(describing: action), String(describing: error) ) } } // swiftlint:disable:next cyclomatic_complexity private func execute( action: Scheme.Buttons.Mapping.Action, targetBundleIdentifier: String? ) throws { switch action { case .arg0(.none), .arg0(.auto): return case .arg0(.missionControlSpaceLeft): try postSymbolicHotKey(.spaceLeft) case .arg0(.missionControlSpaceRight): try postSymbolicHotKey(.spaceRight) case .arg0(.missionControl): missionControl() case .arg0(.appExpose): appExpose() case .arg0(.launchpad): launchpad() case .arg0(.showDesktop): showDesktop() case .arg0(.lookUpAndDataDetectors): try postSymbolicHotKey(.lookUpWordInDictionary) case .arg0(.smartZoom): GestureEvent(zoomToggleSource: nil)?.post(tap: .cgSessionEventTap) case .arg0(.displayBrightnessUp): postSystemDefinedKey(.brightnessUp) case .arg0(.displayBrightnessDown): postSystemDefinedKey(.brightnessDown) case .arg0(.mediaVolumeUp): postSystemDefinedKey(.soundUp) case .arg0(.mediaVolumeDown): postSystemDefinedKey(.soundDown) case .arg0(.mediaMute): postSystemDefinedKey(.mute) case .arg0(.mediaPlayPause): postSystemDefinedKey(.play) case .arg0(.mediaNext): postSystemDefinedKey(.next) case .arg0(.mediaPrevious): postSystemDefinedKey(.previous) case .arg0(.mediaFastForward): postSystemDefinedKey(.fast) case .arg0(.mediaRewind): postSystemDefinedKey(.rewind) case .arg0(.keyboardBrightnessUp): postSystemDefinedKey(.illuminationUp) case .arg0(.keyboardBrightnessDown): postSystemDefinedKey(.illuminationDown) case .arg0(.mouseWheelScrollUp): postScrollEvent(horizontal: 0, vertical: 3) case .arg0(.mouseWheelScrollDown): postScrollEvent(horizontal: 0, vertical: -3) case .arg0(.mouseWheelScrollLeft): postScrollEvent(horizontal: 3, vertical: 0) case .arg0(.mouseWheelScrollRight): postScrollEvent(horizontal: -3, vertical: 0) case .arg0(.mouseButtonLeft): postClickEvent(mouseButton: .left) case .arg0(.mouseButtonLeftDouble): postClickEvent(mouseButton: .left) postClickEvent(mouseButton: .left, clickState: 2) case .arg0(.mouseButtonMiddle): postClickEvent(mouseButton: .center) case .arg0(.mouseButtonRight): postClickEvent(mouseButton: .right) case .arg0(.mouseButtonBack): postMouseButtonAction(mouseButton: .back, targetBundleIdentifier: targetBundleIdentifier) case .arg0(.mouseButtonForward): postMouseButtonAction(mouseButton: .forward, targetBundleIdentifier: targetBundleIdentifier) case let .arg1(.run(command)): let task = Process() task.launchPath = "/bin/bash" task.arguments = ["-c", command] task.launch() case let .arg1(.mouseWheelScrollUp(distance)): postScrollEvent(direction: .up, distance: distance) case let .arg1(.mouseWheelScrollDown(distance)): postScrollEvent(direction: .down, distance: distance) case let .arg1(.mouseWheelScrollLeft(distance)): postScrollEvent(direction: .left, distance: distance) case let .arg1(.mouseWheelScrollRight(distance)): postScrollEvent(direction: .right, distance: distance) case let .arg1(.keyPress(keys)): try keySimulator.press(keys: keys, tap: .cgSessionEventTap) keySimulator.reset() } } private func postMouseButtonAction( mouseButton: CGMouseButton, targetBundleIdentifier: String? ) { guard !UniversalBackForwardTransformer.postNavigationSwipeIfNeeded( for: mouseButton, universalBackForward: universalBackForward, targetBundleIdentifier: targetBundleIdentifier ) else { return } postClickEvent(mouseButton: mouseButton) } private func postScrollEvent(horizontal: Int32, vertical: Int32) { guard let event = CGEvent( scrollWheelEvent2Source: nil, units: .line, wheelCount: 2, wheel1: vertical, wheel2: horizontal, wheel3: 0 ) else { return } event.flags = [] event.post(tap: .cgSessionEventTap) } private func postContinuousScrollEvent(horizontal: Double, vertical: Double) { guard let event = CGEvent( scrollWheelEvent2Source: nil, units: .pixel, wheelCount: 2, wheel1: 0, wheel2: 0, wheel3: 0 ) else { return } event.setDoubleValueField(.scrollWheelEventPointDeltaAxis1, value: vertical) event.setDoubleValueField(.scrollWheelEventFixedPtDeltaAxis1, value: vertical) event.setDoubleValueField(.scrollWheelEventPointDeltaAxis2, value: horizontal) event.setDoubleValueField(.scrollWheelEventFixedPtDeltaAxis2, value: horizontal) event.flags = [] event.post(tap: .cgSessionEventTap) } private enum ScrollEventDirection { case up, down, left, right } private func postScrollEvent( direction: ScrollEventDirection, distance: Scheme.Scrolling.Distance ) { switch distance { case .auto: switch direction { case .up: postScrollEvent(horizontal: 0, vertical: 3) case .down: postScrollEvent(horizontal: 0, vertical: -3) case .left: postScrollEvent(horizontal: 3, vertical: 0) case .right: postScrollEvent(horizontal: -3, vertical: 0) } case let .line(value): let value = Int32(value) switch direction { case .up: postScrollEvent(horizontal: 0, vertical: value) case .down: postScrollEvent(horizontal: 0, vertical: -value) case .left: postScrollEvent(horizontal: value, vertical: 0) case .right: postScrollEvent(horizontal: -value, vertical: 0) } case let .pixel(value): let value = value.asTruncatedDouble switch direction { case .up: postContinuousScrollEvent(horizontal: 0, vertical: value) case .down: postContinuousScrollEvent(horizontal: 0, vertical: -value) case .left: postContinuousScrollEvent(horizontal: value, vertical: 0) case .right: postContinuousScrollEvent(horizontal: -value, vertical: 0) } } } private func handleButtonSwaps(event: CGEvent, action: Scheme.Buttons.Mapping.Action) -> Bool { guard [mouseDownEventTypes, mouseUpEventTypes, mouseDraggedEventTypes] .flatMap(\.self) .contains(event.type) else { return false } let mouseEventView = MouseEventView(event) switch action { case .arg0(.mouseButtonLeft): mouseEventView.modifierFlags = [] mouseEventView.mouseButton = .left case .arg0(.mouseButtonMiddle): mouseEventView.modifierFlags = [] mouseEventView.mouseButton = .center case .arg0(.mouseButtonRight): mouseEventView.modifierFlags = [] mouseEventView.mouseButton = .right case .arg0(.mouseButtonBack): mouseEventView.modifierFlags = [] mouseEventView.mouseButton = .back case .arg0(.mouseButtonForward): mouseEventView.modifierFlags = [] mouseEventView.mouseButton = .forward default: return false } os_log( "Set mouse button to %{public}@", log: Self.log, type: .info, String(describing: mouseEventView.mouseButtonDescription) ) return true } private func handleKeyPressHold( event: CGEvent, mapping: Scheme.Buttons.Mapping, action: Scheme.Buttons.Mapping.Action ) -> Bool { guard let button = mapping.button, [mouseDownEventTypes, mouseUpEventTypes, mouseDraggedEventTypes] .flatMap(\.self) .contains(event.type), shouldHoldKeys(for: mapping, action: action), case let .arg1(.keyPress(keys)) = action else { return false } if mouseDraggedEventTypes.contains(event.type) { return true } if mouseDownEventTypes.contains(event.type) { pressAndStoreHeldKeys(keys, for: button) return true } if mouseUpEventTypes.contains(event.type) { releaseHeldKeys(for: button, fallbackKeys: keys) return true } return false } private func pressAndStoreHeldKeys(_ keys: [Key], for button: Scheme.Buttons.Mapping.Button) { // Treat the down→up cycle as atomic: once a button is tracked, ignore any subsequent // pressed=true reports until release. Otherwise a stuttering pressed=true that resolves // to a different mapping (e.g. modifier flags changed mid-hold and matched another rule) // would overwrite `heldKeysByButton[button]` without releasing the originally pressed // keys, leaving them stuck until something else clears them. if heldKeysByButton[button] != nil { return } heldKeysByButton[button] = keys os_log("Down keys: %{public}@", log: Self.log, type: .info, String(describing: keys)) try? keySimulator.down(keys: keys, tap: .cgSessionEventTap) } private func releaseHeldKeys(for button: Scheme.Buttons.Mapping.Button, fallbackKeys: [Key]) { let keys = heldKeysByButton.removeValue(forKey: button) ?? fallbackKeys os_log("Up keys: %{public}@", log: Self.log, type: .info, String(describing: keys)) try? keySimulator.up(keys: keys.reversed(), tap: .cgSessionEventTap) // Only clear KeySimulator's tracked modifier flags once nothing is held; otherwise an // overlapping hold on another button would have its modifier state forgotten, which then // leaks into the next synthetic event we emit (event.flags would be missing the still-held // modifier and the OS would interpret it as released). if heldKeysByButton.isEmpty { keySimulator.reset() } } private func postClickEvent(mouseButton: CGMouseButton, clickState: Int64? = nil) { guard let location = CGEvent(source: nil)?.location else { return } guard let mouseDownEvent = CGEvent( mouseEventSource: nil, mouseType: mouseButton.fixedCGEventType(of: .leftMouseDown), mouseCursorPosition: location, mouseButton: mouseButton ) else { return } guard let mouseUpEvent = CGEvent( mouseEventSource: nil, mouseType: mouseButton.fixedCGEventType(of: .leftMouseUp), mouseCursorPosition: location, mouseButton: mouseButton ) else { return } if let clickState { mouseDownEvent.setIntegerValueField(.mouseEventClickState, value: clickState) mouseUpEvent.setIntegerValueField(.mouseEventClickState, value: clickState) } mouseDownEvent.post(tap: .cgSessionEventTap) mouseUpEvent.post(tap: .cgSessionEventTap) } } extension ButtonActionsTransformer: Deactivatable { func deactivate() { if let repeatTimer { os_log("ButtonActionsTransformer is inactive, invalidate the repeat timer", log: Self.log, type: .info) repeatTimer.invalidate() self.repeatTimer = nil } if let logitechRepeatTimer { logitechRepeatTimer.invalidate() self.logitechRepeatTimer = nil } let heldKeys = heldKeysByButton.values heldKeysByButton.removeAll() for keys in heldKeys { try? keySimulator.up(keys: keys.reversed(), tap: .cgSessionEventTap) } keySimulator.reset() } } ================================================ FILE: LinearMouse/EventTransformer/ClickDebouncingTransformer.swift ================================================ // MIT License // Copyright (c) 2021-2026 LinearMouse import Foundation import os.log class ClickDebouncingTransformer: EventTransformer { private static let log = OSLog(subsystem: Bundle.main.bundleIdentifier!, category: "ClickDebouncing") private let button: CGMouseButton private let timeout: TimeInterval private let resetTimerOnMouseUp: Bool init(for button: CGMouseButton, timeout: TimeInterval, resetTimerOnMouseUp: Bool) { self.button = button self.timeout = timeout self.resetTimerOnMouseUp = resetTimerOnMouseUp } private var mouseDownEventType: CGEventType { button.fixedCGEventType(of: .leftMouseDown) } private var mouseUpEventType: CGEventType { button.fixedCGEventType(of: .leftMouseUp) } private var lastClickedAtInNanoseconds: UInt64 = 0 func transform(_ event: CGEvent) -> CGEvent? { guard [mouseDownEventType, mouseUpEventType].contains(event.type) else { return event } let mouseEventView = MouseEventView(event) guard mouseEventView.mouseButton == button else { return event } switch event.type { case mouseDownEventType: let intervalSinceLastClick = intervalSinceLastClick touchLastClickedAt() if intervalSinceLastClick <= timeout { os_log( "Mouse down ignored because interval since last click %{public}f <= %{public}f", log: Self.log, type: .info, intervalSinceLastClick, timeout ) return nil } return event case mouseUpEventType: if resetTimerOnMouseUp { touchLastClickedAt() } return event default: break } return event } private func touchLastClickedAt() { lastClickedAtInNanoseconds = DispatchTime.now().uptimeNanoseconds } private var intervalSinceLastClick: TimeInterval { let nanosecondsPerSecond = 1e9 return Double(DispatchTime.now().uptimeNanoseconds - lastClickedAtInNanoseconds) / nanosecondsPerSecond } } ================================================ FILE: LinearMouse/EventTransformer/EventTransformer.swift ================================================ // MIT License // Copyright (c) 2021-2026 LinearMouse import Foundation import LRUCache import os.log protocol EventTransformer { func transform(_ event: CGEvent) -> CGEvent? } protocol LogitechControlEventHandling { func handleLogitechControlEvent(_ context: LogitechEventContext) -> Bool } extension [EventTransformer]: EventTransformer { func transform(_ event: CGEvent) -> CGEvent? { var event: CGEvent? = event for eventTransformer in self { event = event.flatMap { eventTransformer.transform($0) } } return event } } extension [EventTransformer]: LogitechControlEventHandling { func handleLogitechControlEvent(_ context: LogitechEventContext) -> Bool { contains { eventTransformer in (eventTransformer as? LogitechControlEventHandling)?.handleLogitechControlEvent(context) == true } } } protocol Deactivatable { func deactivate() func reactivate() } extension Deactivatable { func deactivate() {} func reactivate() {} } extension [EventTransformer]: Deactivatable { func deactivate() { for eventTransformer in self { if let eventTransformer = eventTransformer as? Deactivatable { eventTransformer.deactivate() } } } func reactivate() { for eventTransformer in self { if let eventTransformer = eventTransformer as? Deactivatable { eventTransformer.reactivate() } } } } ================================================ FILE: LinearMouse/EventTransformer/EventTransformerManager.swift ================================================ // MIT License // Copyright (c) 2021-2026 LinearMouse import Combine import Defaults import Foundation import LRUCache import os.log class EventTransformerManager { static let shared = EventTransformerManager() static let log = OSLog(subsystem: Bundle.main.bundleIdentifier!, category: "EventTransformerManager") @Default(.bypassEventsFromOtherApplications) var bypassEventsFromOtherApplications private var eventTransformerCache = LRUCache(countLimit: 16) private var activeCacheKey: CacheKey? private var sharedAutoScrollTransformer: AutoScrollTransformer? struct CacheKey: Hashable { var deviceMatcher: DeviceMatcher? var pid: pid_t? var screen: String? } private var subscriptions = Set() init() { ConfigurationState.shared .$configuration .removeDuplicates() .sink { [weak self] _ in guard let self else { return } if EventThread.shared.performAndWait({ self.resetState() }) == nil { self.resetState() } } .store(in: &subscriptions) } /// Called from `EventThread.onWillStop` on the event thread. func resetForRestart() { resetState() } private let sourceBundleIdentifierBypassSet: Set = [ "cc.ffitch.shottr" ] func get( withCGEvent cgEvent: CGEvent, withSourcePid sourcePid: pid_t?, withTargetPid targetPid: pid_t?, withMouseLocationPid mouseLocationPid: pid_t?, withDisplay display: String? ) -> EventTransformer { if EventThread.shared.isCurrent { return getOnCurrentThread( withCGEvent: cgEvent, withSourcePid: sourcePid, withTargetPid: targetPid, withMouseLocationPid: mouseLocationPid, withDisplay: display ) } if let transformer = EventThread.shared.performAndWait({ self.getOnCurrentThread( withCGEvent: cgEvent, withSourcePid: sourcePid, withTargetPid: targetPid, withMouseLocationPid: mouseLocationPid, withDisplay: display ) }) { return transformer } return getOnCurrentThread( withCGEvent: cgEvent, withSourcePid: sourcePid, withTargetPid: targetPid, withMouseLocationPid: mouseLocationPid, withDisplay: display ) } private func getOnCurrentThread( withCGEvent cgEvent: CGEvent, withSourcePid sourcePid: pid_t?, withTargetPid targetPid: pid_t?, withMouseLocationPid mouseLocationPid: pid_t?, withDisplay display: String? ) -> EventTransformer { if sourcePid != nil, bypassEventsFromOtherApplications, !cgEvent.isLinearMouseSyntheticEvent { os_log( "Return noop transformer because this event is sent by %{public}s", log: Self.log, type: .info, sourcePid?.bundleIdentifier ?? "(unknown)" ) return [] } if let sourceBundleIdentifier = sourcePid?.bundleIdentifier, sourceBundleIdentifierBypassSet.contains(sourceBundleIdentifier) { os_log( "Return noop transformer because the source application %{public}s is in the bypass set", log: Self.log, type: .info, sourceBundleIdentifier ) return [] } let pid = mouseLocationPid ?? targetPid let device = DeviceManager.shared.deviceFromCGEvent(cgEvent) return get(withDevice: device, withPid: pid, withDisplay: display, updateActiveCacheKey: true) } func get(withDevice device: Device?, withPid pid: pid_t?, withDisplay display: String?) -> EventTransformer { if EventThread.shared.isCurrent { return getOnCurrentThread(withDevice: device, withPid: pid, withDisplay: display) } if let transformer = EventThread.shared.performAndWait({ self.getOnCurrentThread(withDevice: device, withPid: pid, withDisplay: display) }) { return transformer } return getOnCurrentThread(withDevice: device, withPid: pid, withDisplay: display) } private func getOnCurrentThread( withDevice device: Device?, withPid pid: pid_t?, withDisplay display: String? ) -> EventTransformer { get(withDevice: device, withPid: pid, withDisplay: display, updateActiveCacheKey: false) } func handleLogitechControlEvent(_ context: LogitechEventContext) -> Bool { if EventThread.shared.isCurrent { return handleLogitechControlEventOnCurrentThread(context) } if let handled = EventThread.shared.performAndWait({ self.handleLogitechControlEventOnCurrentThread(context) }) { return handled } return handleLogitechControlEventOnCurrentThread(context) } private func handleLogitechControlEventOnCurrentThread(_ context: LogitechEventContext) -> Bool { let transformer = get(withDevice: context.device, withPid: context.pid, withDisplay: context.display) return (transformer as? LogitechControlEventHandling)?.handleLogitechControlEvent(context) ?? false } private func get( withDevice device: Device?, withPid pid: pid_t?, withDisplay display: String?, updateActiveCacheKey: Bool ) -> EventTransformer { let prevActiveCacheKey = activeCacheKey if updateActiveCacheKey { activeCacheKey = nil } defer { if updateActiveCacheKey, let prevActiveCacheKey, prevActiveCacheKey != activeCacheKey { transition( from: eventTransformerCache.value(forKey: prevActiveCacheKey), to: activeCacheKey.flatMap { eventTransformerCache.value(forKey: $0) } ) } } let cacheKey = CacheKey( deviceMatcher: device.map { DeviceMatcher(of: $0) }, pid: pid, screen: display ) if updateActiveCacheKey { activeCacheKey = cacheKey } if let eventTransformer = eventTransformerCache.value(forKey: cacheKey) { return eventTransformer } let scheme = ConfigurationState.shared.configuration.matchScheme( withDevice: device, withPid: pid, withDisplay: display ) // TODO: Patch EventTransformer instead of rebuilding it os_log( "Initialize EventTransformer with scheme: %{public}@ (device=%{public}@, pid=%{public}@, screen=%{public}@)", log: Self.log, type: .info, String(describing: scheme), String(describing: device), String(describing: pid), String(describing: display) ) var eventTransformer: [EventTransformer] = [] if let reverse = scheme.scrolling.$reverse { let vertical = reverse.vertical ?? false let horizontal = reverse.horizontal ?? false if vertical || horizontal { eventTransformer.append(ReverseScrollingTransformer(vertically: vertical, horizontally: horizontal)) } } let smoothed = Scheme.Scrolling.Bidirectional( vertical: scheme.scrolling.smoothed.vertical?.isEnabled == true ? scheme.scrolling.smoothed.vertical : nil, horizontal: scheme.scrolling.smoothed.horizontal?.isEnabled == true ? scheme.scrolling.smoothed .horizontal : nil ) let hasSmoothedScrolling = smoothed.vertical != nil || smoothed.horizontal != nil if let modifiers = scheme.scrolling.$modifiers, hasSmoothedScrolling { eventTransformer.append(ModifierActionsTransformer(modifiers: modifiers)) } if hasSmoothedScrolling { eventTransformer.append(SmoothedScrollingTransformer(smoothed: smoothed)) } if let distance = scheme.scrolling.distance.horizontal { if smoothed.horizontal == nil { eventTransformer.append(LinearScrollingHorizontalTransformer(distance: distance)) } } if let distance = scheme.scrolling.distance.vertical { if smoothed.vertical == nil { eventTransformer.append(LinearScrollingVerticalTransformer(distance: distance)) } } let acceleration = Scheme.Scrolling.Bidirectional( vertical: smoothed.vertical == nil ? scheme.scrolling.acceleration.vertical : nil, horizontal: smoothed.horizontal == nil ? scheme.scrolling.acceleration.horizontal : nil ) let speed = Scheme.Scrolling.Bidirectional( vertical: smoothed.vertical == nil ? scheme.scrolling.speed.vertical : nil, horizontal: smoothed.horizontal == nil ? scheme.scrolling.speed.horizontal : nil ) if acceleration.vertical ?? 1 != 1 || acceleration.horizontal ?? 1 != 1 || speed.vertical ?? 0 != 0 || speed.horizontal ?? 0 != 0 { eventTransformer .append(ScrollingAccelerationSpeedAdjustmentTransformer( acceleration: acceleration, speed: speed )) } if let timeout = scheme.buttons.clickDebouncing.timeout, timeout > 0, let buttons = scheme.buttons.clickDebouncing.buttons { let resetTimerOnMouseUp = scheme.buttons.clickDebouncing.resetTimerOnMouseUp ?? false for button in buttons { eventTransformer.append(ClickDebouncingTransformer( for: button, timeout: TimeInterval(timeout) / 1000, resetTimerOnMouseUp: resetTimerOnMouseUp )) } } if let modifiers = scheme.scrolling.$modifiers, !hasSmoothedScrolling { eventTransformer.append(ModifierActionsTransformer(modifiers: modifiers)) } if scheme.buttons.switchPrimaryButtonAndSecondaryButtons == true { eventTransformer.append(SwitchPrimaryAndSecondaryButtonsTransformer()) } if let autoScrollTransformer = autoScrollTransformer(for: scheme.buttons.$autoScroll) { eventTransformer.append(autoScrollTransformer) } if let gesture = scheme.buttons.$gesture, gesture.enabled ?? false, let trigger = gesture.trigger, trigger.button != nil { eventTransformer.append(GestureButtonTransformer( trigger: trigger, threshold: Double(gesture.threshold ?? 50), deadZone: Double(gesture.deadZone ?? 40), cooldownMs: gesture.cooldownMs ?? 500, actions: gesture.actions )) } if let mappings = scheme.buttons.mappings { eventTransformer.append(ButtonActionsTransformer( mappings: mappings, universalBackForward: scheme.buttons.universalBackForward )) } if let universalBackForward = scheme.buttons.universalBackForward, universalBackForward != .none { eventTransformer.append(UniversalBackForwardTransformer(universalBackForward: universalBackForward)) } if let redirectsToScroll = scheme.pointer.redirectsToScroll, redirectsToScroll { eventTransformer.append(PointerRedirectsToScrollTransformer()) } eventTransformerCache.setValue(eventTransformer, forKey: cacheKey) return eventTransformer } private func autoScrollTransformer(for autoScroll: Scheme.Buttons.AutoScroll?) -> AutoScrollTransformer? { if let sharedAutoScrollTransformer, sharedAutoScrollTransformer.isAutoscrollActive { return sharedAutoScrollTransformer } guard let autoScroll, autoScroll.enabled ?? false, let trigger = autoScroll.trigger, trigger.valid else { sharedAutoScrollTransformer?.deactivate() sharedAutoScrollTransformer = nil return nil } let modes = autoScroll.normalizedModes let speed = autoScroll.speed?.asTruncatedDouble ?? 1 let preserveNativeMiddleClick = autoScroll.preserveNativeMiddleClick ?? true if let sharedAutoScrollTransformer, sharedAutoScrollTransformer.matchesConfiguration( trigger: trigger, modes: modes, speed: speed, preserveNativeMiddleClick: preserveNativeMiddleClick ) { return sharedAutoScrollTransformer } sharedAutoScrollTransformer?.deactivate() sharedAutoScrollTransformer = nil let transformer = AutoScrollTransformer( trigger: trigger, modes: modes, speed: speed, preserveNativeMiddleClick: preserveNativeMiddleClick ) sharedAutoScrollTransformer = transformer return transformer } private func transition(from previous: EventTransformer?, to current: EventTransformer?) { let preservedAutoScrollTransformer = sharedAutoScrollTransformer?.isAutoscrollActive == true ? sharedAutoScrollTransformer : nil deactivate(previous, excluding: preservedAutoScrollTransformer) reactivate(current, excluding: preservedAutoScrollTransformer) } private func resetState() { let oldAutoScroll = sharedAutoScrollTransformer sharedAutoScrollTransformer = nil activeCacheKey = nil eventTransformerCache.removeAllValues() oldAutoScroll?.deactivate() } private func deactivate( _ transformer: EventTransformer?, excluding preservedAutoScrollTransformer: AutoScrollTransformer? ) { guard let transformer else { return } if let transformers = transformer as? [EventTransformer] { for transformer in transformers { if let preservedAutoScrollTransformer, let autoScrollTransformer = transformer as? AutoScrollTransformer, autoScrollTransformer === preservedAutoScrollTransformer { continue } (transformer as? Deactivatable)?.deactivate() } return } if let preservedAutoScrollTransformer, let autoScrollTransformer = transformer as? AutoScrollTransformer, autoScrollTransformer === preservedAutoScrollTransformer { return } (transformer as? Deactivatable)?.deactivate() } private func reactivate( _ transformer: EventTransformer?, excluding preservedAutoScrollTransformer: AutoScrollTransformer? ) { guard let transformer else { return } if let transformers = transformer as? [EventTransformer] { for transformer in transformers { if let preservedAutoScrollTransformer, let autoScrollTransformer = transformer as? AutoScrollTransformer, autoScrollTransformer === preservedAutoScrollTransformer { continue } (transformer as? Deactivatable)?.reactivate() } return } if let preservedAutoScrollTransformer, let autoScrollTransformer = transformer as? AutoScrollTransformer, autoScrollTransformer === preservedAutoScrollTransformer { return } (transformer as? Deactivatable)?.reactivate() } } ================================================ FILE: LinearMouse/EventTransformer/GestureButtonTransformer.swift ================================================ // MIT License // Copyright (c) 2021-2026 LinearMouse import AppKit import DockKit import Foundation import KeyKit import os.log class GestureButtonTransformer { static let log = OSLog(subsystem: Bundle.main.bundleIdentifier!, category: "GestureButton") // Configuration private let trigger: Scheme.Buttons.Mapping private let triggerMouseButton: CGMouseButton private let threshold: Double private let deadZone: Double private let cooldownMs: Int private let actions: Scheme.Buttons.Gesture.Actions /// State machine private enum State { case idle case tracking(startTime: UInt64, deltaX: Double, deltaY: Double) case triggered case cooldown(until: UInt64, released: Bool) } private var state: State = .idle init( trigger: Scheme.Buttons.Mapping, threshold: Double, deadZone: Double, cooldownMs: Int, actions: Scheme.Buttons.Gesture.Actions ) { self.trigger = trigger let defaultButton = UInt32(CGMouseButton.center.rawValue) let buttonNumber = trigger.button?.syntheticMouseButtonNumber ?? Int(defaultButton) triggerMouseButton = CGMouseButton(rawValue: UInt32(buttonNumber)) ?? .center self.threshold = threshold self.deadZone = deadZone self.cooldownMs = cooldownMs self.actions = actions } } extension GestureButtonTransformer: EventTransformer { func transform(_ event: CGEvent) -> CGEvent? { // Check if we're in cooldown if case let .cooldown(until, released) = state { if DispatchTime.now().uptimeNanoseconds < until { // Still in cooldown - consume our button events if matchesTriggerButton(event) { if event.type == mouseUpEventType, !released { os_log("Releasing trigger button during cooldown", log: Self.log, type: .debug) state = .cooldown(until: until, released: true) event.isGestureCleanupRelease = true return event } os_log("Event consumed during cooldown", log: Self.log, type: .debug) return nil } return event } // Cooldown expired, return to idle state = .idle } // Route based on event type switch event.type { case mouseDownEventType: return handleButtonDown(event) case mouseDraggedEventType, .mouseMoved: return handleDragged(event) case mouseUpEventType: return handleButtonUp(event) default: return event } } private var mouseDownEventType: CGEventType { triggerMouseButton.fixedCGEventType(of: .otherMouseDown) } private var mouseUpEventType: CGEventType { triggerMouseButton.fixedCGEventType(of: .otherMouseUp) } private var mouseDraggedEventType: CGEventType { triggerMouseButton.fixedCGEventType(of: .otherMouseDragged) } private func matchesTriggerButton(_ event: CGEvent) -> Bool { guard let eventButton = MouseEventView(event).mouseButton else { return false } return eventButton == triggerMouseButton } private func matchesActivationTrigger(_ event: CGEvent) -> Bool { guard matchesTriggerButton(event) else { return false } return trigger.matches(modifierFlags: event.flags) } private func handleButtonDown(_ event: CGEvent) -> CGEvent? { guard matchesActivationTrigger(event) else { return event } // Start tracking state = .tracking(startTime: DispatchTime.now().uptimeNanoseconds, deltaX: 0, deltaY: 0) // os_log("Started tracking gesture", log: Self.log, type: .info) // Pass through the button down event return event } private func handleDragged(_ event: CGEvent) -> CGEvent? { guard case .tracking(let startTime, var deltaX, var deltaY) = state else { return event } let isMouseMoved = event.type == .mouseMoved // For drag events, verify button match. // mouseMoved events don't carry a button number but are used to track // movement when the trigger is a Logitech HID++ control (which generates // synthetic button events that don't produce OS-level drag events). if !isMouseMoved { guard matchesTriggerButton(event) else { return event } } // Accumulate deltas let eventDeltaX = event.getDoubleValueField(.mouseEventDeltaX) let eventDeltaY = event.getDoubleValueField(.mouseEventDeltaY) deltaX += eventDeltaX deltaY += eventDeltaY // os_log("Accumulated delta: (%.2f, %.2f)", log: Self.log, type: .debug, deltaX, deltaY) // Check for timeout (3 seconds) let elapsed = DispatchTime.now().uptimeNanoseconds - startTime if elapsed > 3_000_000_000 { // os_log("Gesture timeout, resetting", log: Self.log, type: .info) state = .idle return event } // Check if threshold is met if let action = detectGesture(deltaX: deltaX, deltaY: deltaY) { os_log("Gesture detected: %{public}@", log: Self.log, type: .info, String(describing: action)) // Execute the gesture do { try executeGesture(action) state = .triggered // Enter cooldown let cooldownNanos = UInt64(cooldownMs) * 1_000_000 state = .cooldown(until: DispatchTime.now().uptimeNanoseconds + cooldownNanos, released: false) os_log("Entering cooldown for %d ms", log: Self.log, type: .info, cooldownMs) } catch { os_log("Failed to execute gesture: %{public}@", log: Self.log, type: .error, error.localizedDescription) state = .idle } // Consume the event return nil } // Update state with new deltas state = .tracking(startTime: startTime, deltaX: deltaX, deltaY: deltaY) // Consume drag events while tracking; pass through mouseMoved events return isMouseMoved ? event : nil } private func handleButtonUp(_ event: CGEvent) -> CGEvent? { guard matchesTriggerButton(event) else { return event } // If we were tracking but didn't trigger, reset to idle if case .tracking = state { // os_log("Button released before threshold, resetting", log: Self.log, type: .info) state = .idle // Pass through the button up event so it can be used as a normal click return event } // If we triggered, enter cooldown and pass the release through if case .triggered = state { let cooldownNanos = UInt64(cooldownMs) * 1_000_000 state = .cooldown(until: DispatchTime.now().uptimeNanoseconds + cooldownNanos, released: true) event.isGestureCleanupRelease = true return event } return event } private func detectGesture(deltaX: Double, deltaY: Double) -> Scheme.Buttons.Gesture.GestureAction? { let absDeltaX = abs(deltaX) let absDeltaY = abs(deltaY) // Calculate magnitude let magnitude = sqrt(deltaX * deltaX + deltaY * deltaY) guard magnitude >= threshold else { return nil } // os_log( // "Gesture check: deltaX=%.1f, deltaY=%.1f, magnitude=%.1f, deadZone=%.1f", // log: Self.log, // type: .info, // deltaX, // deltaY, // magnitude, // deadZone // ) // Determine dominant axis if absDeltaX > absDeltaY { // Horizontal gesture guard absDeltaY < deadZone else { // os_log( // "Horizontal gesture rejected: absDeltaY=%.1f >= deadZone=%.1f", // log: Self.log, // type: .info, // absDeltaY, // deadZone // ) return nil } // Use defaults if actions not configured return deltaX > 0 ? (actions.right ?? .spaceRight) : (actions.left ?? .spaceLeft) } // Vertical gesture guard absDeltaX < deadZone else { // os_log( // "Vertical gesture rejected: absDeltaX=%.1f >= deadZone=%.1f", // log: Self.log, // type: .info, // absDeltaX, // deadZone // ) return nil } // Use defaults if actions not configured return deltaY > 0 ? (actions.down ?? .appExpose) : (actions.up ?? .missionControl) } private func executeGesture(_ action: Scheme.Buttons.Gesture.GestureAction) throws { switch action { case .none: break case .spaceLeft: try postSymbolicHotKey(.spaceLeft) case .spaceRight: try postSymbolicHotKey(.spaceRight) case .missionControl: missionControl() case .appExpose: appExpose() case .showDesktop: showDesktop() case .launchpad: launchpad() } } } extension GestureButtonTransformer: LogitechControlEventHandling { func handleLogitechControlEvent(_ context: LogitechEventContext) -> Bool { guard let triggerLogitechControl = trigger.button?.logitechControl, context.controlIdentity.matches(triggerLogitechControl) else { return false } if case let .cooldown(until, _) = state { if DispatchTime.now().uptimeNanoseconds < until { return true } state = .idle } if context.isPressed { guard trigger.matches(modifierFlags: context.modifierFlags) else { return true } state = .tracking(startTime: DispatchTime.now().uptimeNanoseconds, deltaX: 0, deltaY: 0) os_log("Started tracking gesture (Logitech control)", log: Self.log, type: .info) } else { switch state { case .tracking: state = .idle case .cooldown: break default: break } } return true } } extension GestureButtonTransformer: Deactivatable { func deactivate() { state = .idle } } ================================================ FILE: LinearMouse/EventTransformer/LinearScrollingHorizontalTransformer.swift ================================================ // MIT License // Copyright (c) 2021-2026 LinearMouse import Foundation import os.log class LinearScrollingHorizontalTransformer: EventTransformer { private static let log = OSLog(subsystem: Bundle.main.bundleIdentifier!, category: "LinearScrollingHorizontal") private let distance: Scheme.Scrolling.Distance init(distance: Scheme.Scrolling.Distance) { self.distance = distance } func transform(_ event: CGEvent) -> CGEvent? { guard event.type == .scrollWheel else { return event } if event.isLinearMouseSyntheticEvent { return event } if case .auto = distance { return event } let view = ScrollWheelEventView(event) guard view.deltaXSignum != 0 else { return event } guard view.momentumPhase == .none else { return nil } let (continuous, oldValue) = (view.continuous, view.matrixValue) let deltaXSignum = view.deltaXSignum switch distance { case .auto: return event case let .line(value): view.continuous = false view.deltaX = deltaXSignum * Int64(value) view.deltaY = 0 case let .pixel(value): view.continuous = true view.deltaXPt = Double(deltaXSignum) * value.asTruncatedDouble view.deltaXFixedPt = Double(deltaXSignum) * value.asTruncatedDouble view.deltaYPt = 0 view.deltaYFixedPt = 0 } os_log( "continuous=%{public}@, oldValue=%{public}@, newValue=%{public}@", log: Self.log, type: .info, String(describing: continuous), String(describing: oldValue), String(describing: view.matrixValue) ) return event } } ================================================ FILE: LinearMouse/EventTransformer/LinearScrollingVerticalTransformer.swift ================================================ // MIT License // Copyright (c) 2021-2026 LinearMouse import Foundation import os.log class LinearScrollingVerticalTransformer: EventTransformer { private static let log = OSLog(subsystem: Bundle.main.bundleIdentifier!, category: "LinearScrollingVertical") private let distance: Scheme.Scrolling.Distance init(distance: Scheme.Scrolling.Distance) { self.distance = distance } func transform(_ event: CGEvent) -> CGEvent? { guard event.type == .scrollWheel else { return event } if event.isLinearMouseSyntheticEvent { return event } if case .auto = distance { return event } let view = ScrollWheelEventView(event) guard view.deltaYSignum != 0 else { return event } guard view.momentumPhase == .none else { return nil } let (continuous, oldValue) = (view.continuous, view.matrixValue) let deltaYSignum = view.deltaYSignum switch distance { case .auto: return event case let .line(value): view.continuous = false view.deltaY = deltaYSignum * Int64(value) view.deltaX = 0 case let .pixel(value): view.continuous = true view.deltaYPt = Double(deltaYSignum) * value.asTruncatedDouble view.deltaYFixedPt = Double(deltaYSignum) * value.asTruncatedDouble view.deltaXPt = 0 view.deltaXFixedPt = 0 } os_log( "continuous=%{public}@, oldValue=%{public}@, newValue=%{public}@", log: Self.log, type: .info, String(describing: continuous), String(describing: oldValue), String(describing: view.matrixValue) ) return event } } ================================================ FILE: LinearMouse/EventTransformer/LogitechEventContext.swift ================================================ // MIT License // Copyright (c) 2021-2026 LinearMouse import Foundation struct LogitechEventContext { let device: Device? let pid: pid_t? let display: String? let mouseLocation: CGPoint let controlIdentity: LogitechControlIdentity let isPressed: Bool let modifierFlags: CGEventFlags } ================================================ FILE: LinearMouse/EventTransformer/ModifierActionsTransformer.swift ================================================ // MIT License // Copyright (c) 2021-2026 LinearMouse import Foundation import GestureKit import KeyKit import os.log class ModifierActionsTransformer { private static let log = OSLog( subsystem: Bundle.main.bundleIdentifier!, category: "ModifierActionsTransformer" ) private static let keySimulator = KeySimulator() typealias Modifiers = Scheme.Scrolling.Bidirectional typealias Action = Scheme.Scrolling.Modifiers.Action private let modifiers: Modifiers private var pinchZoomBegan = false init(modifiers: Modifiers) { self.modifiers = modifiers } } extension ModifierActionsTransformer: EventTransformer { func transform(_ event: CGEvent) -> CGEvent? { if pinchZoomBegan { return handlePinchZoom(event) } guard event.type == .scrollWheel else { return event } let scrollWheelEventView = ScrollWheelEventView(event) guard let modifiers = scrollWheelEventView.deltaYSignum != 0 ? modifiers.vertical : modifiers.horizontal else { return event } let actions: [(CGEventFlags.Element, Action?)] = [ (.maskCommand, modifiers.command), (.maskShift, modifiers.shift), (.maskAlternate, modifiers.option), (.maskControl, modifiers.control) ] var event = event for case let (flag, action) in actions where event.flags.contains(flag) { if let action, action != .auto { guard let handledEvent = handleModifierKeyAction(for: event, action: action) else { return nil } event = handledEvent event.flags.remove(flag) } } return event } private func handleModifierKeyAction(for event: CGEvent, action: Action) -> CGEvent? { let scrollWheelEventView = ScrollWheelEventView(event) switch action { case .auto, .ignore: break case .preventDefault: return nil case .alterOrientation: scrollWheelEventView.swapXY() case let .changeSpeed(scale: scale): scrollWheelEventView.scale(factor: scale.asTruncatedDouble) case .zoom: let scrollWheelEventView = ScrollWheelEventView(event) let deltaSignum = scrollWheelEventView.deltaYSignum != 0 ? scrollWheelEventView .deltaYSignum : scrollWheelEventView.deltaXSignum if deltaSignum == 0 { return event } if deltaSignum > 0 { try? Self.keySimulator.press(.command, .numpadPlus, tap: .cgSessionEventTap) } else { try? Self.keySimulator.press(.command, .numpadMinus, tap: .cgSessionEventTap) } return nil case .pinchZoom: return handlePinchZoom(event) } return event } private func handlePinchZoom(_ event: CGEvent) -> CGEvent? { guard event.type == .scrollWheel || event.type == .flagsChanged else { return event } if event.type == .flagsChanged { pinchZoomBegan = false GestureEvent(zoomSource: nil, phase: .ended, magnification: 0)?.post(tap: .cgSessionEventTap) os_log("pinch zoom ended", log: Self.log, type: .info) return event } if !pinchZoomBegan { GestureEvent(zoomSource: nil, phase: .began, magnification: 0)?.post(tap: .cgSessionEventTap) pinchZoomBegan = true os_log("pinch zoom began", log: Self.log, type: .info) } let scrollWheelEventView = ScrollWheelEventView(event) let magnification = Double(scrollWheelEventView.deltaYPt) * 0.005 GestureEvent(zoomSource: nil, phase: .changed, magnification: magnification)?.post(tap: .cgSessionEventTap) os_log("pinch zoom changed: magnification=%f", log: Self.log, type: .info, magnification) return nil } } extension ModifierActionsTransformer: Deactivatable { func deactivate() { if pinchZoomBegan { pinchZoomBegan = false GestureEvent(zoomSource: nil, phase: .ended, magnification: 0)?.post(tap: .cgSessionEventTap) os_log("ModifierActionsTransformer is inactive, pinch zoom ended", log: Self.log, type: .info) } } } ================================================ FILE: LinearMouse/EventTransformer/PointerRedirectsToScrollTransformer.swift ================================================ // MIT License // Copyright (c) 2021-2026 LinearMouse import CoreGraphics import Foundation class PointerRedirectsToScrollTransformer: EventTransformer { func transform(_ event: CGEvent) -> CGEvent? { guard event.type == .mouseMoved else { return event } // Despite making this function return nil, the mouseMoved event // still causes the cursor to move, so we need to manually move // the cursor to maintain a fixed position during scrolling. CGWarpMouseCursorPosition(event.location) let deltaX = event.getDoubleValueField(.mouseEventDeltaX) let deltaY = event.getDoubleValueField(.mouseEventDeltaY) let scrollX = -deltaX let scrollY = -deltaY if let scrollEvent = CGEvent( scrollWheelEvent2Source: nil, units: .pixel, wheelCount: 2, wheel1: Int32(scrollY), wheel2: Int32(scrollX), wheel3: 0 ) { scrollEvent.post(tap: .cghidEventTap) } return nil } } ================================================ FILE: LinearMouse/EventTransformer/ReverseScrollingTransformer.swift ================================================ // MIT License // Copyright (c) 2021-2026 LinearMouse import Foundation class ReverseScrollingTransformer: EventTransformer { private let vertically: Bool private let horizontally: Bool init(vertically: Bool = false, horizontally: Bool = false) { self.vertically = vertically self.horizontally = horizontally } func transform(_ event: CGEvent) -> CGEvent? { guard event.type == .scrollWheel else { return event } if event.isLinearMouseSyntheticEvent { return event } let view = ScrollWheelEventView(event) view.negate(vertically: vertically, horizontally: horizontally) return event } } ================================================ FILE: LinearMouse/EventTransformer/ScrollingAccelerationSpeedAdjustmentTransformer.swift ================================================ // MIT License // Copyright (c) 2021-2026 LinearMouse import Foundation import os.log class ScrollingAccelerationSpeedAdjustmentTransformer: EventTransformer { private static let log = OSLog( subsystem: Bundle.main.bundleIdentifier!, category: "ScrollingAccelerationSpeedAdjustment" ) private let acceleration: Scheme.Scrolling.Bidirectional private let speed: Scheme.Scrolling.Bidirectional init( acceleration: Scheme.Scrolling.Bidirectional, speed: Scheme.Scrolling.Bidirectional ) { self.acceleration = acceleration self.speed = speed } func transform(_ event: CGEvent) -> CGEvent? { guard event.type == .scrollWheel else { return event } if event.isLinearMouseSyntheticEvent { return event } let scrollWheelEventView = ScrollWheelEventView(event) let deltaYSignum = scrollWheelEventView.deltaYSignum let deltaXSignum = scrollWheelEventView.deltaXSignum if deltaYSignum != 0, let acceleration = acceleration.vertical?.asTruncatedDouble, acceleration != 1 { scrollWheelEventView.scale(factorY: acceleration) os_log("deltaY: acceleration=%{public}f", log: Self.log, type: .info, acceleration) } if deltaXSignum != 0, let acceleration = acceleration.horizontal?.asTruncatedDouble, acceleration != 1 { scrollWheelEventView.scale(factorX: acceleration) os_log("deltaX: acceleration=%{public}f", log: Self.log, type: .info, acceleration) } if deltaYSignum != 0, let speed = speed.vertical?.asTruncatedDouble, speed != 0 { let targetPt = scrollWheelEventView.deltaYPt + Double(deltaYSignum) * speed scrollWheelEventView.deltaY = deltaYSignum * max(1, Int64(abs(targetPt) / 10)) scrollWheelEventView.deltaYPt = targetPt scrollWheelEventView.deltaYFixedPt = targetPt / 10 // TODO: Test if ioHidScrollY needs to be modified. os_log("deltaY: speed=%{public}f", log: Self.log, type: .info, speed) } if deltaXSignum != 0, let speed = speed.horizontal?.asTruncatedDouble, speed != 0 { let targetPt = scrollWheelEventView.deltaXPt + Double(deltaXSignum) * speed scrollWheelEventView.deltaX = deltaXSignum * max(1, Int64(abs(targetPt) / 10)) scrollWheelEventView.deltaXPt = targetPt scrollWheelEventView.deltaXFixedPt = targetPt / 10 os_log("deltaX: speed=%{public}f", log: Self.log, type: .info, speed) } os_log("newValue=%{public}@", log: Self.log, type: .info, String(describing: scrollWheelEventView.matrixValue)) return event } } ================================================ FILE: LinearMouse/EventTransformer/SmoothedScrollingEngine.swift ================================================ // MIT License // Copyright (c) 2021-2026 LinearMouse import CoreGraphics import Foundation final class SmoothedScrollingEngine { enum Phase { case touchBegan case touchChanged case touchEnded case momentumBegan case momentumChanged case momentumEnded } enum Axis { case horizontal case vertical } struct Emission { var deltaX: Double var deltaY: Double var phase: Phase } private enum SessionState { case idle case touching case momentum } private enum AxisBehavior { case passthrough case smoothed(AxisTuning) } private struct AxisTuning { private static let legacyUpperBound = 3.0 let configuration: Scheme.Scrolling.Smoothed init(configuration: Scheme.Scrolling.Smoothed) { self.configuration = configuration } private var presetProfile: Scheme.Scrolling.Smoothed.PresetProfile { configuration.resolvedPresetProfile } private var response: Double { (configuration.response?.asTruncatedDouble ?? 0.45) .clamped(to: Scheme.Scrolling.Smoothed.responseRange) } private var speed: Double { (configuration.speed?.asTruncatedDouble ?? 1) .clamped(to: Scheme.Scrolling.Smoothed.speedRange) } private var acceleration: Double { (configuration.acceleration?.asTruncatedDouble ?? 1.2) .clamped(to: Scheme.Scrolling.Smoothed.accelerationRange) } private var inertia: Double { (configuration.inertia?.asTruncatedDouble ?? 0.65) .clamped(to: Scheme.Scrolling.Smoothed.inertiaRange) } func desiredVelocity(for input: Double) -> Double { guard input != 0 else { return 0 } let profile = presetProfile let baseMagnitude = abs(input) let normalizedMagnitude = (baseMagnitude / (baseMagnitude + 24)).clamped(to: 0 ... 1) let curvedMagnitude = pow(normalizedMagnitude, profile.inputExponent) let magnitude = baseMagnitude * curvedMagnitude let speedBoost = 0.85 + speed * 0.4 let accelerationBoost = 1 + acceleration * profile.accelerationGain let velocity = magnitude * profile.velocityScale * speedBoost * accelerationBoost return input.sign == .minus ? -velocity : velocity } private func reengagementDominance(inputVelocity: Double, currentVelocity: Double) -> Double { let inputMagnitude = abs(inputVelocity) let currentMagnitude = abs(currentVelocity) guard inputMagnitude > 0, currentMagnitude > 0 else { return 0 } return (currentMagnitude / inputMagnitude).clamped(to: 0 ... 1) } private func tailRecovery(inputVelocity: Double, currentVelocity: Double) -> Double { let dominance = reengagementDominance(inputVelocity: inputVelocity, currentVelocity: currentVelocity) return ((0.75 - dominance) / 0.75).clamped(to: 0 ... 1) } func reengagedDesiredVelocity(for input: Double, currentVelocity: Double) -> Double { let inputVelocity = desiredVelocity(for: input) guard currentVelocity != 0 else { return inputVelocity } let sameDirection = inputVelocity.sign == currentVelocity.sign if sameDirection { let carryFactor = (0.06 + response * 0.06 + acceleration * 0.01).clamped(to: 0.06 ... 0.16) let ceilingFactor = (1.01 + presetProfile.response * 0.08 + response * 0.06).clamped(to: 1.02 ... 1.12) let carriedMagnitude = min( abs(currentVelocity) + abs(inputVelocity) * carryFactor, max(abs(currentVelocity), abs(inputVelocity)) * ceilingFactor ) let recovery = pow(tailRecovery(inputVelocity: inputVelocity, currentVelocity: currentVelocity), 0.8) let targetMagnitude = carriedMagnitude + (abs(inputVelocity) - carriedMagnitude) * recovery return currentVelocity.sign == .minus ? -targetMagnitude : targetMagnitude } let brakingBlend = (0.50 + response * 0.20).clamped(to: 0.50 ... 0.82) return currentVelocity + (inputVelocity - currentVelocity) * brakingBlend } func blendFactor(for dt: TimeInterval) -> Double { let scaled = presetProfile.response * 0.75 + response * 0.8 return (scaled * dt * 60).clamped(to: 0.0 ... 1.0) } func reengagementBlendFactor(for dt: TimeInterval, desiredVelocity: Double, currentVelocity: Double) -> Double { let baseBlend = blendFactor(for: dt) let softenedBlend = (baseBlend * (0.10 + response * 0.08)).clamped(to: 0.0 ... 0.12) let recovery = pow(tailRecovery(inputVelocity: desiredVelocity, currentVelocity: currentVelocity), 0.55) return (softenedBlend + (baseBlend - softenedBlend) * recovery).clamped(to: softenedBlend ... baseBlend) } func reengagementKickFactor(desiredVelocity: Double, currentVelocity: Double) -> Double { guard desiredVelocity != 0, currentVelocity != 0, desiredVelocity.sign == currentVelocity.sign else { return 0 } let recovery = pow(tailRecovery(inputVelocity: desiredVelocity, currentVelocity: currentVelocity), 0.8) let baseKick = (0.04 + response * 0.04).clamped(to: 0.04 ... 0.08) let tailKick = (0.14 + response * 0.05 + acceleration * 0.02).clamped(to: 0.14 ... 0.28) return (baseKick + tailKick * recovery).clamped(to: 0.04 ... 0.24) } func momentumDecay(for dt: TimeInterval) -> Double { let profile = presetProfile let legacyInertiaBoost = ((min(inertia, Self.legacyUpperBound) - 0.65) * 0.05) .clamped(to: -0.08 ... 0.10) let extendedInertiaBoost = max(inertia - Self.legacyUpperBound, 0) * 0.01 let decayCeiling = inertia > Self.legacyUpperBound ? 0.99 : 0.98 let dtScale = max(dt * 60, 0.25) let decay = (profile.decay + legacyInertiaBoost + extendedInertiaBoost) .clamped(to: 0.72 ... decayCeiling) return pow(decay, dtScale) } } private let horizontalBehavior: AxisBehavior private let verticalBehavior: AxisBehavior private var sessionState: SessionState = .idle private var lastTickTimestamp: TimeInterval? private var lastInputTimestamp: TimeInterval? private var pendingInputX = 0.0 private var pendingInputY = 0.0 private var desiredVelocityX = 0.0 private var desiredVelocityY = 0.0 private var velocityX = 0.0 private var velocityY = 0.0 private var touchHasBegun = false private var pendingMomentumBegin = false private var reengagedFromMomentum = false private let inputGrace: TimeInterval = 1.0 / 25.0 private let stopThreshold = 0.5 private let axisActivityThreshold = 0.01 init(smoothed: Scheme.Scrolling.Bidirectional) { horizontalBehavior = smoothed.horizontal.map { .smoothed(.init(configuration: $0)) } ?? .passthrough verticalBehavior = smoothed.vertical.map { .smoothed(.init(configuration: $0)) } ?? .passthrough } var isRunning: Bool { switch sessionState { case .idle: return pendingInputX != 0 || pendingInputY != 0 case .touching, .momentum: return true } } var exclusiveActiveAxis: Axis? { let horizontalActive = axisIsActive( pendingInput: pendingInputX, desiredVelocity: desiredVelocityX, velocity: velocityX ) let verticalActive = axisIsActive( pendingInput: pendingInputY, desiredVelocity: desiredVelocityY, velocity: velocityY ) switch (horizontalActive, verticalActive) { case (true, false): return .horizontal case (false, true): return .vertical default: return nil } } func resetOtherAxis(ifExclusiveIncomingAxis incomingAxis: Axis) { guard let activeAxis = exclusiveActiveAxis, activeAxis != incomingAxis else { return } switch activeAxis { case .horizontal: pendingInputX = 0 desiredVelocityX = 0 velocityX = 0 case .vertical: pendingInputY = 0 desiredVelocityY = 0 velocityY = 0 } if abs(velocityX) <= stopThreshold, abs(velocityY) <= stopThreshold, pendingInputX == 0, pendingInputY == 0 { pendingMomentumBegin = false reengagedFromMomentum = false if sessionState == .momentum { sessionState = .idle touchHasBegun = false } } } func feed(deltaX: Double, deltaY: Double, timestamp: TimeInterval) { pendingInputX += deltaX pendingInputY += deltaY lastInputTimestamp = timestamp if sessionState == .idle, deltaX != 0 || deltaY != 0 { sessionState = .touching touchHasBegun = false pendingMomentumBegin = false } else if sessionState == .momentum, deltaX != 0 || deltaY != 0 { sessionState = .touching touchHasBegun = false pendingMomentumBegin = false reengagedFromMomentum = true } if lastTickTimestamp == nil { lastTickTimestamp = timestamp } } func advance(to timestamp: TimeInterval) -> Emission? { let previousTick = lastTickTimestamp ?? timestamp let dt = (timestamp - previousTick).clamped(to: 1.0 / 240.0 ... 1.0 / 24.0) lastTickTimestamp = timestamp let hasPendingInput = pendingInputX != 0 || pendingInputY != 0 let hasFreshInput = lastInputTimestamp.map { timestamp - $0 <= inputGrace } ?? false let shouldBlendMomentumReengagement = reengagedFromMomentum && hasPendingInput let emissionX = advanceAxis( behavior: horizontalBehavior, pendingInput: &pendingInputX, desiredVelocity: &desiredVelocityX, velocity: &velocityX, hasPendingInput: hasPendingInput, hasFreshInput: hasFreshInput, reengagedFromMomentum: shouldBlendMomentumReengagement, dt: dt ) let emissionY = advanceAxis( behavior: verticalBehavior, pendingInput: &pendingInputY, desiredVelocity: &desiredVelocityY, velocity: &velocityY, hasPendingInput: hasPendingInput, hasFreshInput: hasFreshInput, reengagedFromMomentum: shouldBlendMomentumReengagement, dt: dt ) reengagedFromMomentum = false let hasMovement = abs(emissionX) >= 0.01 || abs(emissionY) >= 0.01 let shouldContinueMomentum = abs(velocityX) > stopThreshold || abs(velocityY) > stopThreshold switch sessionState { case .idle: return nil case .touching: if hasFreshInput { guard hasMovement else { return nil } let phase: CGScrollPhase = touchHasBegun ? .changed : .began touchHasBegun = true return .init( deltaX: emissionX, deltaY: emissionY, phase: phase == .began ? .touchBegan : .touchChanged ) } if shouldContinueMomentum { sessionState = .momentum pendingMomentumBegin = true touchHasBegun = false return .init(deltaX: 0, deltaY: 0, phase: .touchEnded) } sessionState = .idle velocityX = 0 velocityY = 0 desiredVelocityX = 0 desiredVelocityY = 0 touchHasBegun = false return .init(deltaX: emissionX, deltaY: emissionY, phase: .touchEnded) case .momentum: guard hasMovement || shouldContinueMomentum else { sessionState = .idle velocityX = 0 velocityY = 0 desiredVelocityX = 0 desiredVelocityY = 0 touchHasBegun = false pendingMomentumBegin = false return .init(deltaX: 0, deltaY: 0, phase: .momentumEnded) } if pendingMomentumBegin { pendingMomentumBegin = false return .init(deltaX: emissionX, deltaY: emissionY, phase: .momentumBegan) } return .init(deltaX: emissionX, deltaY: emissionY, phase: .momentumChanged) } } private func advanceAxis( behavior: AxisBehavior, pendingInput: inout Double, desiredVelocity: inout Double, velocity: inout Double, hasPendingInput: Bool, hasFreshInput: Bool, reengagedFromMomentum: Bool, dt: TimeInterval ) -> Double { switch behavior { case .passthrough: defer { pendingInput = 0 } return pendingInput case let .smoothed(tuning): if pendingInput != 0 { desiredVelocity = reengagedFromMomentum ? tuning.reengagedDesiredVelocity(for: pendingInput, currentVelocity: velocity) : tuning.desiredVelocity(for: pendingInput) if reengagedFromMomentum { let kick = tuning.reengagementKickFactor( desiredVelocity: desiredVelocity, currentVelocity: velocity ) velocity += (desiredVelocity - velocity) * kick } pendingInput = 0 } if hasFreshInput || hasPendingInput { let blend = reengagedFromMomentum ? tuning.reengagementBlendFactor( for: dt, desiredVelocity: desiredVelocity, currentVelocity: velocity ) : tuning.blendFactor(for: dt) velocity += (desiredVelocity - velocity) * blend } else { velocity *= tuning.momentumDecay(for: dt) } return velocity * dt } } private func axisIsActive(pendingInput: Double, desiredVelocity: Double, velocity: Double) -> Bool { abs(pendingInput) >= axisActivityThreshold || abs(desiredVelocity) >= axisActivityThreshold || abs(velocity) >= axisActivityThreshold } } ================================================ FILE: LinearMouse/EventTransformer/SmoothedScrollingTransformer.swift ================================================ // MIT License // Copyright (c) 2021-2026 LinearMouse import Foundation import os.log final class SmoothedScrollingTransformer: EventTransformer, Deactivatable { private static let log = OSLog( subsystem: Bundle.main.bundleIdentifier!, category: "SmoothedScrolling" ) private static let timerInterval: TimeInterval = 1.0 / 120.0 private let smoothed: Scheme.Scrolling.Bidirectional private let now: () -> TimeInterval private let eventSink: (CGEvent) -> Void private let delivery = SmoothedScrollEventDelivery() private var engine: SmoothedScrollingEngine private var timer: EventThreadTimer? private var lastFlags: CGEventFlags = [] init( smoothed: Scheme.Scrolling.Bidirectional, now: @escaping () -> TimeInterval = { ProcessInfo.processInfo.systemUptime }, eventSink: @escaping (CGEvent) -> Void = { $0.post(tap: .cgSessionEventTap) } ) { self.smoothed = smoothed self.now = now self.eventSink = eventSink engine = SmoothedScrollingEngine(smoothed: smoothed) } func transform(_ event: CGEvent) -> CGEvent? { guard event.type == .scrollWheel else { return event } let view = ScrollWheelEventView(event) if event.isLinearMouseSyntheticEvent { return event } let deltaX = delivery.deltaXInPixels(from: view) let deltaY = delivery.deltaYInPixels(from: view) let hasNativePhase = view.scrollPhase != nil let hasNativeMomentum = view.momentumPhase != .none let smoothsX = smoothed.horizontal != nil let smoothsY = smoothed.vertical != nil let handlesX = smoothsX && deltaX != 0 let handlesY = smoothsY && deltaY != 0 let interceptsX = smoothsX && deltaX != 0 let interceptsY = smoothsY && deltaY != 0 if view.continuous, hasNativePhase || hasNativeMomentum { return transformNativeContinuousGesture( event, view: view, deltaX: deltaX, deltaY: deltaY, handlesX: handlesX, handlesY: handlesY ) } guard interceptsX || interceptsY else { return event } if handlesX || handlesY { lastFlags = event.flags if handlesX != handlesY { engine.resetOtherAxis(ifExclusiveIncomingAxis: handlesX ? .horizontal : .vertical) } engine.feed( deltaX: handlesX ? deltaX : 0, deltaY: handlesY ? deltaY : 0, timestamp: now() ) startTimerIfNeeded() } let passthroughEvent = event.copy() ?? event let passthroughView = ScrollWheelEventView(passthroughEvent) if interceptsX { delivery.zeroHorizontal(on: passthroughView) } if interceptsY { delivery.zeroVertical(on: passthroughView) } return delivery.deltaXInPixels(from: passthroughView) == 0 && delivery.deltaYInPixels(from: passthroughView) == 0 ? nil : passthroughEvent } func deactivate() { stopTimer() engine = SmoothedScrollingEngine(smoothed: smoothed) lastFlags = [] } private func startTimerIfNeeded() { guard timer == nil else { return } timer = EventThread.shared.scheduleTimer( interval: Self.timerInterval, repeats: true ) { [weak self] in self?.tick() } } private func transformNativeContinuousGesture( _ event: CGEvent, view: ScrollWheelEventView, deltaX: Double, deltaY: Double, handlesX: Bool, handlesY: Bool ) -> CGEvent? { let interceptsX = smoothed.horizontal != nil let interceptsY = smoothed.vertical != nil guard interceptsX || interceptsY else { return event } lastFlags = event.flags if handlesX || handlesY { if handlesX != handlesY { engine.resetOtherAxis(ifExclusiveIncomingAxis: handlesX ? .horizontal : .vertical) } engine.feed( deltaX: handlesX ? deltaX : 0, deltaY: handlesY ? deltaY : 0, timestamp: now() ) } if let emission = engine.advance(to: now()) { delivery.apply(phases: delivery.phasesFor(emission.phase), to: view) if interceptsX { delivery.setHorizontal(handlesX ? emission.deltaX : 0, on: view) } if interceptsY { delivery.setVertical(handlesY ? emission.deltaY : 0, on: view) } } else { if interceptsX, !handlesX { delivery.zeroHorizontal(on: view) } if interceptsY, !handlesY { delivery.zeroVertical(on: view) } } let shouldReset = view.scrollPhase == .ended || view.momentumPhase == .end if shouldReset { engine = SmoothedScrollingEngine(smoothed: smoothed) stopTimer() } return event } private func stopTimer() { timer?.invalidate() timer = nil } func tick() { guard let emission = engine.advance(to: now()) else { if !engine.isRunning { stopTimer() } return } post(emission: emission) if !engine.isRunning { stopTimer() } } private func post(emission: SmoothedScrollingEngine.Emission) { guard let event = CGEvent( scrollWheelEvent2Source: nil, units: .pixel, wheelCount: 2, wheel1: 0, wheel2: 0, wheel3: 0 ) else { return } let view = ScrollWheelEventView(event) view.continuous = true delivery.setHorizontal(emission.deltaX, on: view) delivery.setVertical(emission.deltaY, on: view) delivery.apply(phases: delivery.phasesFor(emission.phase), to: view) event.isLinearMouseSyntheticEvent = true event.flags = lastFlags eventSink(event) os_log( "post smoothed scroll deltaX=%{public}.3f deltaY=%{public}.3f phase=%{public}@ momentum=%{public}@", log: Self.log, type: .info, emission.deltaX, emission.deltaY, String(describing: view.scrollPhase), String(describing: view.momentumPhase) ) } } private struct SmoothedScrollEventDelivery { private static let inputLineStepInPoints = 36.0 private static let outputLineStepInPoints = 12.0 func apply( phases: (scrollPhase: CGScrollPhase?, momentumPhase: CGMomentumScrollPhase), to view: ScrollWheelEventView ) { view.scrollPhase = phases.scrollPhase view.momentumPhase = phases.momentumPhase } func phasesFor(_ phase: SmoothedScrollingEngine .Phase) -> (scrollPhase: CGScrollPhase?, momentumPhase: CGMomentumScrollPhase) { switch phase { case .touchBegan: return (.began, .none) case .touchChanged: return (.changed, .none) case .touchEnded: return (.ended, .none) case .momentumBegan: return (nil, .begin) case .momentumChanged: return (nil, .continuous) case .momentumEnded: return (nil, .end) } } func deltaXInPixels(from view: ScrollWheelEventView) -> Double { if !view.continuous { if view.deltaX != 0 { return Double(view.deltaX) * Self.inputLineStepInPoints } if view.deltaXPt != 0 { return view.deltaXPt * Self.inputLineStepInPoints } if view.deltaXFixedPt != 0 { return view.deltaXFixedPt * Self.inputLineStepInPoints * 10 } } if view.deltaXPt != 0 { return view.deltaXPt } if view.deltaXFixedPt != 0 { return view.deltaXFixedPt } return Double(view.deltaX) * Self.inputLineStepInPoints } func deltaYInPixels(from view: ScrollWheelEventView) -> Double { if !view.continuous { if view.deltaY != 0 { return Double(view.deltaY) * Self.inputLineStepInPoints } if view.deltaYPt != 0 { return view.deltaYPt * Self.inputLineStepInPoints } if view.deltaYFixedPt != 0 { return view.deltaYFixedPt * Self.inputLineStepInPoints * 10 } } if view.deltaYPt != 0 { return view.deltaYPt } if view.deltaYFixedPt != 0 { return view.deltaYFixedPt } return Double(view.deltaY) * Self.inputLineStepInPoints } func setHorizontal(_ value: Double, on view: ScrollWheelEventView) { view.deltaX = integerDelta(for: value) view.deltaXPt = pointDelta(for: value) view.deltaXFixedPt = value view.ioHidScrollX = value } func setVertical(_ value: Double, on view: ScrollWheelEventView) { view.deltaY = integerDelta(for: value) view.deltaYPt = pointDelta(for: value) view.deltaYFixedPt = value view.ioHidScrollY = value } func zeroHorizontal(on view: ScrollWheelEventView) { setHorizontal(0, on: view) } func zeroVertical(on view: ScrollWheelEventView) { setVertical(0, on: view) } private func integerDelta(for value: Double) -> Int64 { Int64((value / Self.outputLineStepInPoints).rounded(.towardZero)) } private func pointDelta(for value: Double) -> Double { guard value != 0 else { return 0 } return Double(Int64(value.rounded(.towardZero))) } } ================================================ FILE: LinearMouse/EventTransformer/SwitchPrimaryAndSecondaryButtonsTransformer.swift ================================================ // MIT License // Copyright (c) 2021-2026 LinearMouse import Foundation import os.log class SwitchPrimaryAndSecondaryButtonsTransformer { static let log = OSLog( subsystem: Bundle.main.bundleIdentifier!, category: "SwitchPrimaryAndSecondaryButtonsTransformer" ) init() {} } extension SwitchPrimaryAndSecondaryButtonsTransformer: EventTransformer { func transform(_ event: CGEvent) -> CGEvent? { let mouseEventView = MouseEventView(event) guard var mouseButton = mouseEventView.mouseButton else { return event } switch mouseButton { case .left: mouseButton = .right case .right: mouseButton = .left default: return event } mouseEventView.mouseButton = mouseButton event.type = mouseButton.fixedCGEventType(of: event.type) os_log( "Switched primary and secondary button: %{public}s", log: Self.log, type: .info, String(describing: mouseButton) ) return event } } ================================================ FILE: LinearMouse/EventTransformer/UniversalBackForwardTransformer.swift ================================================ // MIT License // Copyright (c) 2021-2026 LinearMouse import Foundation import GestureKit import os.log class UniversalBackForwardTransformer: EventTransformer { private static let log = OSLog(subsystem: Bundle.main.bundleIdentifier!, category: "UniversalBackForward") private static let includes = [ "com.apple.*", "com.binarynights.ForkLift*", "org.mozilla.firefox", "com.operasoftware.Opera" ] enum Replacement: Equatable { case mouseButton(CGMouseButton) case navigationSwipe(NavigationSwipeDirection) } enum NavigationSwipeDirection: Equatable { case left case right var hidDirection: IOHIDSwipeMask { switch self { case .left: return .swipeLeft case .right: return .swipeRight } } } private let universalBackForward: Scheme.Buttons.UniversalBackForward init(universalBackForward: Scheme.Buttons.UniversalBackForward) { self.universalBackForward = universalBackForward } static func interestedButtons(for universalBackForward: Scheme.Buttons.UniversalBackForward) -> Set { switch universalBackForward { case .none: return [] case .both: return [.back, .forward] case .backOnly: return [.back] case .forwardOnly: return [.forward] } } static func supportsTargetBundleIdentifier(_ bundleIdentifier: String?) -> Bool { guard let bundleIdentifier else { return false } return Self.includes.contains { if $0.hasSuffix("*") { return bundleIdentifier.hasPrefix($0.dropLast()) } return bundleIdentifier == $0 } } static func replacement( for mouseButton: CGMouseButton, universalBackForward: Scheme.Buttons.UniversalBackForward?, targetBundleIdentifier: String? ) -> Replacement { guard let universalBackForward, interestedButtons(for: universalBackForward).contains(mouseButton), supportsTargetBundleIdentifier(targetBundleIdentifier) else { return .mouseButton(mouseButton) } switch mouseButton { case .back: return .navigationSwipe(.left) case .forward: return .navigationSwipe(.right) default: return .mouseButton(mouseButton) } } @discardableResult static func postNavigationSwipeIfNeeded( for mouseButton: CGMouseButton, universalBackForward: Scheme.Buttons.UniversalBackForward?, targetBundleIdentifier: String? ) -> Bool { guard case let .navigationSwipe(direction) = replacement( for: mouseButton, universalBackForward: universalBackForward, targetBundleIdentifier: targetBundleIdentifier ) else { return false } guard let event = GestureEvent( navigationSwipeSource: nil, direction: direction.hidDirection ) else { return false } event.post(tap: .cgSessionEventTap) return true } func transform(_ event: CGEvent) -> CGEvent? { if event.isGestureCleanupRelease { return event } let view = MouseEventView(event) guard let mouseButton = view.mouseButton else { return event } let targetBundleIdentifier = view.targetPid?.bundleIdentifier let targetBundleIdentifierString = targetBundleIdentifier ?? "(nil)" guard case let .navigationSwipe(direction) = Self.replacement( for: mouseButton, universalBackForward: universalBackForward, targetBundleIdentifier: targetBundleIdentifier ) else { return event } // We'll simulate swipes when back/forward button down // and eats corresponding mouse up events. switch event.type { case .otherMouseDown: break case .otherMouseUp: return nil default: return event } os_log("Convert to swipe: %{public}@", log: Self.log, type: .info, targetBundleIdentifierString) GestureEvent(navigationSwipeSource: nil, direction: direction.hidDirection)? .post(tap: .cgSessionEventTap) return nil } } ================================================ FILE: LinearMouse/EventView/EventView.swift ================================================ // MIT License // Copyright (c) 2021-2026 LinearMouse class EventView { let event: CGEvent init(_ event: CGEvent) { self.event = event } var modifierFlags: CGEventFlags { get { event.flags.intersection([.maskCommand, .maskShift, .maskAlternate, .maskControl]) } set { event.flags = event.flags .subtracting([.maskCommand, .maskShift, .maskAlternate, .maskControl]) .union(newValue) } } var modifiers: [String] { [ (CGEventFlags.maskCommand, "command"), (CGEventFlags.maskShift, "shift"), (CGEventFlags.maskAlternate, "option"), (CGEventFlags.maskControl, "control") ] .filter { modifierFlags.contains($0.0) } .map(\.1) } } ================================================ FILE: LinearMouse/EventView/MouseEventView.swift ================================================ // MIT License // Copyright (c) 2021-2026 LinearMouse import AppKit import Foundation class MouseEventView: EventView { var mouseButton: CGMouseButton? { get { guard let mouseButtonNumber = UInt32(exactly: event.getIntegerValueField(.mouseEventButtonNumber)) else { return nil } return CGMouseButton(rawValue: mouseButtonNumber)! } set { guard let newValue else { return } event.type = newValue.fixedCGEventType(of: event.type) event.setIntegerValueField(.mouseEventButtonNumber, value: Int64(newValue.rawValue)) } } var mouseButtonDescription: String { guard let mouseButton else { return "(nil)" } return (modifiers + ["