Vibecode BetterTouchTool
track this build5 phases, 15 steps, beginner friendly0%The core loop is small enough for a capable coding agent to produce a useful local version in one sitting. For BetterTouchTool, map trackpad, mouse, keyboard, and window gestures to explicit local actions. The hard boundary is huge input-device coverage, scripting depth, community presets, and os maintenance, plus os integration polish and maintenance.
You are building a lean indie version of BetterTouchTool. Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt. ===== README.md ===== # BetterTouchTool · indie build A background agent that watches trackpad gestures, mouse buttons and keyboard shortcuts, matches them against rules scoped to the frontmost application, and runs an action: a keystroke, a shell command, a window arrangement or a sequence of them, with a conflict checker so two rules never fight over the same input. Estimated effort: **weekend**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Language | Swift 6 and SwiftUI in Xcode | input events, the Accessibility API and window management are all AppKit | | App type | A background agent with LSUIElement true | it listens; it does not need a window most of the time | | Input capture | A CGEvent tap for keyboard and mouse, NSEvent monitors for gestures | an event tap can consume an event, which a monitor cannot, and consuming is what makes a remap work | | Window control | The Accessibility API against other applications | the only sanctioned way to move a window your process does not own | | Storage | JSON in Application Support, versioned | rules are documents people will want to share, diff and back up | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **A Mac running macOS 14 or newer with a trackpad** · free if you already own one - Why: Gesture recognition cannot be developed against a mouse, and the whole build is macOS input handling. - Get it: Any recent Mac. A laptop trackpad or a Magic Trackpad both work. - Verify: About This Mac reports macOS 14 or higher and a trackpad is present - [ ] **Xcode 16 or newer** · free - Why: Builds and signs the agent. - Get it: Mac App Store or developer.apple.com/xcode. - Verify: xcodebuild -version prints 16 or higher - [ ] **Willingness to grant Accessibility to your own build** · free - Why: Nothing here works without it, and it is the permission that reads every keystroke. Understand what you are granting before asking anyone else to. - Get it: System Settings, then Privacy and Security, then Accessibility. During development it must be re-granted whenever the binary path or signature changes. - Verify: AXIsProcessTrusted returns true and the event tap installs - [ ] **Three applications with different input behaviour** · free - Why: Phase 3 and 4 need real variety: a text editor, a browser and something that refuses window manipulation. Testing against one application hides most of the bugs. - Get it: Anything already installed. Include at least one that ignores Accessibility window commands, because handling refusal is part of the work. - Verify: All three respond differently to the same rule and each case is handled - [ ] **Apple Developer Program membership** (optional) · 99 USD per year - Why: Product phase only. Without it there is no notarization and a Gatekeeper warning everywhere else. - Get it: Enrol at developer.apple.com/programs. - Verify: A Developer ID Application certificate appears in Xcode settings ## Quick start ```sh xcrun notarytool submit Input.zip --keychain-profile AC --wait xcrun stapler staple Input.app ``` Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - Broad input device coverage across stream decks, tablets and controllers. - The scripting depth accumulated over a decade. - The community preset library. - Guaranteed compatibility across macOS releases. - huge input-device coverage, scripting depth, community presets, and OS maintenance - years of OS-version edge cases - large extension ecosystem - cross-device sync - commercial support and rapid compatibility fixes If one of those is essential to you, that is the reason to keep paying for BetterTouchTool, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · BetterTouchTool The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a macOS input remapper that turns trackpad gestures, mouse buttons and key combinations into actions you define to replace BetterTouchTool. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Stack (fixed, do not substitute) - Language: Swift 6 and SwiftUI in Xcode · input events, the Accessibility API and window management are all AppKit - App type: A background agent with LSUIElement true · it listens; it does not need a window most of the time - Input capture: A CGEvent tap for keyboard and mouse, NSEvent monitors for gestures · an event tap can consume an event, which a monitor cannot, and consuming is what makes a remap work - Window control: The Accessibility API against other applications · the only sanctioned way to move a window your process does not own - Storage: JSON in Application Support, versioned · rules are documents people will want to share, diff and back up ### Data model (create this before Phase 1) - triggers(id, kind, spec_json, app_scope) · kind is gesture, mouse_button, key_combo or window_event; app_scope is a bundle identifier or all - actions(id, kind, payload_json) · kind is send_keys, run_shell, move_window, launch_app, sequence or delay - rules(id, trigger_id, action_id, enabled, priority, notes) - profiles(id, name, active) · a named rule set, for example docked versus laptop - conflicts(rule_a, rule_b, scope) · computed, not stored by the user, and surfaced before a rule is saved - Rules resolve most specific first: an application-scoped rule beats a global one, and priority breaks the remaining ties. ### Phase 1 · An event tap that can consume Build: Request Accessibility, install a CGEvent tap for keyboard and mouse events, log what arrives, and prove you can swallow one specific combination so the frontmost application never sees it. Add a watchdog that re-enables the tap when the system disables it. Trap: macOS disables an event tap that takes too long to return, and it does so silently. Your remaps simply stop working with no error anywhere. Listen for the tap-disabled notification and re-enable, and never do slow work inside the callback: post it to another queue and return immediately. Done when: A chosen key combination is consumed and does not reach the frontmost application, verified in a text editor. Deliberately blocking the callback long enough to trip the timeout results in the tap being re-enabled automatically rather than staying dead. Denying Accessibility produces your own explanation rather than silence. Do not build yet: No actions, no gestures, no rules. One event observed and consumed. ### Phase 2 · Trackpad gestures Build: Capture gesture events and recognise three and four finger swipes, taps and pinches. Require a minimum distance and a maximum duration so a slow drift is not a swipe. Add hysteresis so one physical gesture never fires twice. Trap: Without a cooldown after recognition, the tail of a swipe re-triggers the same gesture, and the user gets two window moves from one flick. Lock out the same gesture for a short interval after it fires, and test with deliberately sloppy input rather than careful demonstrations. Done when: A three finger swipe fires once per physical gesture across twenty attempts, including deliberately sloppy ones. A slow two-inch drift with three fingers does not register as a swipe. Gestures that macOS itself uses are either left alone or are consciously overridden with that noted in the interface. Do not build yet: No actions bound yet, so recognition only logs. ### Phase 3 · Actions Build: Implement the action kinds: send a keystroke, run a shell command, move or resize the frontmost window, launch an application, and run a sequence with delays. Run shell commands off the main thread with a timeout. Trap: Sending a keystroke from inside the event tap callback can feed straight back into your own tap and loop. Tag events you generate with a user data field and ignore them when they come back around, or the first remap you write will hang the machine. Done when: A gesture sends a keystroke that the frontmost application receives. A gesture moves the frontmost window to the left half of the screen, including for an application you did not write. A shell action that hangs is killed at its timeout and the interface stays responsive. A remap that sends its own trigger does not loop. Do not build yet: No scoping or conflict detection yet. ### Phase 4 · Scoping and conflicts Build: Scope every rule to an application or to everything. Resolve most specific first. Detect conflicts when a rule is saved and show them, rather than letting the user discover that one of two rules mysteriously never fires. Trap: Two rules on the same trigger in the same scope produce behaviour that depends on iteration order, which will change when you refactor. Detect the collision at save time and make the user resolve it. Done when: An application-scoped rule overrides a global rule for the same trigger and the global rule still applies elsewhere. Saving a rule that collides with an existing one shows both and refuses to save silently. Switching profiles changes the active rule set in one action. Do not build yet: No import or export yet. ### Phase 5 · Sharing configurations Build: Export rules as versioned JSON. Import with validation, refusing anything referencing an unknown action kind rather than importing it half-working. Show what an import will change before applying it. Trap: An imported rule that runs a shell command is arbitrary code from whoever wrote it. Show every shell action in the import preview and require explicit approval for those specifically, because a rule set is exactly the kind of file people share without reading. Done when: Exported rules import into a fresh installation and behave identically. An import containing an unknown action kind is refused with a clear message rather than partially applied. Shell actions are listed individually in the preview and require separate approval. Do not build yet: Signing and distribution come next. ### Phase 6 · Signing, notarization and updates Build: Hardened runtime, Developer ID signing, notarization, stapling and Sparkle updates. Document the Accessibility requirement honestly, because you are asking for the permission that reads every keystroke. Trap: Signing invalidates the Accessibility grant that testers gave your unsigned build, and macOS will not prompt again. Put the removal instructions in the release notes. Done when: A notarized build opens on a Mac that never ran it with no Gatekeeper warning and requests Accessibility once. An appcast update installs and relaunches. The README states plainly what the permission allows the application to see. Do not build yet: Nothing after this. ### Out of scope (and why) - The breadth of input device support. Stream decks, drawing tablets, game controllers and specific mouse models are each their own integration, and there are dozens. - The scripting depth of the commercial product, which has accumulated hundreds of action types over a decade. - The community preset library, which is other people's work and cannot be written into existence. - Compatibility across major macOS releases. Event taps and the Accessibility API both shift, and that maintenance is now yours. ### README must contain - That Accessibility permits reading every keystroke, and exactly what this application does with them - That an event tap can be disabled by the system and how the watchdog handles it - The gesture thresholds and the cooldown, and where to change them - That imported rule sets can contain shell commands and are approved individually - That re-signing invalidates the Accessibility grant and how to clear the stale entry ===== AGENTS.md ===== # Agent instructions · BetterTouchTool indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Swift 6 and SwiftUI in Xcode, A background agent with LSUIElement true, A CGEvent tap for keyboard and mouse, NSEvent monitors for gestures, The Accessibility API against other applications, JSON in Application Support, versioned. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - macOS disables an event tap that takes too long to return, and it does so silently. Your remaps simply stop working with no error anywhere. Listen for the tap-disabled notification and re-enable, and never do slow work inside the callback: post it to another queue and return immediately. - Without a cooldown after recognition, the tail of a swipe re-triggers the same gesture, and the user gets two window moves from one flick. Lock out the same gesture for a short interval after it fires, and test with deliberately sloppy input rather than careful demonstrations. - Sending a keystroke from inside the event tap callback can feed straight back into your own tap and loop. Tag events you generate with a user data field and ignore them when they come back around, or the first remap you write will hang the machine. - Two rules on the same trigger in the same scope produce behaviour that depends on iteration order, which will change when you refactor. Detect the collision at save time and make the user resolve it. - An imported rule that runs a shell command is arbitrary code from whoever wrote it. Show every shell action in the import preview and require explicit approval for those specifically, because a rule set is exactly the kind of file people share without reading. ===== BUILD_PLAN.md ===== # Build plan · BetterTouchTool A background agent that watches trackpad gestures, mouse buttons and keyboard shortcuts, matches them against rules scoped to the frontmost application, and runs an action: a keystroke, a shell command, a window arrangement or a sequence of them, with a conflict checker so two rules never fight over the same input. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · An event tap that can consume You can observe an input event and stop it reaching the application underneath. ### Steps 1. Request Accessibility and explain it first Check AXIsProcessTrustedWithOptions and show your own panel before the system prompt. This permission lets you read every keystroke, so say so. Files: `Permissions.swift` 2. Install the event tap CGEvent.tapCreate at the session level for keyboard and mouse events. Return nil from the callback to consume an event. Files: `EventTap.swift` 3. Add the disabled watchdog Handle tapDisabledByTimeout and tapDisabledByUserInput by re-enabling. Without this the application dies quietly under load. Files: `EventTap.swift` ### Done when - [ ] A chosen key combination never reaches a text editor - [ ] A deliberately slow callback trips the timeout and the tap is re-enabled - [ ] Refusing Accessibility shows an explanation and a working Settings link - [ ] The callback does no slow work on the tap thread ### Watch out - macOS disables an event tap that takes too long to return, and it does so silently. Your remaps simply stop working with no error anywhere. Listen for the tap-disabled notification and re-enable, and never do slow work inside the callback: post it to another queue and return immediately. ## Phase 2 · Trackpad gestures Multi-finger gestures are recognised reliably and do not fire by accident. ### Steps 1. Capture gesture events NSEvent monitors for magnify, swipe and touches. Read touch phases directly for finger counts rather than trusting the swipe event alone. Files: `Gestures.swift` 2. Add thresholds and hysteresis Minimum distance, maximum duration, and a cooldown after firing. These three numbers are the difference between reliable and infuriating. Files: `Gestures.swift` 3. Test with sloppy input Twenty deliberately imprecise gestures. Careful gestures always work and tell you nothing. ### Done when - [ ] A three finger swipe fires exactly once across twenty sloppy attempts - [ ] A slow drift does not register as a swipe - [ ] System gestures are either untouched or the override is disclosed - [ ] Finger count is read from touches rather than inferred ### Watch out - Without a cooldown after recognition, the tail of a swipe re-triggers the same gesture, and the user gets two window moves from one flick. Lock out the same gesture for a short interval after it fires, and test with deliberately sloppy input rather than careful demonstrations. ## Phase 3 · Actions A recognised input runs something useful, including moving windows that belong to other applications. ### Steps 1. Send keystrokes without feedback loops Post events with a distinctive user data value and ignore that value in your tap callback. Files: `Actions/SendKeys.swift` 2. Move windows through the Accessibility API Get the focused window of the frontmost application and set its position and size attributes. Handle applications that refuse. Files: `Actions/Window.swift` 3. Run shell commands safely Off the main thread, with a hard timeout and the process group killed on expiry. Files: `Actions/Shell.swift` ### Done when - [ ] A keystroke action reaches the frontmost application - [ ] A window action moves a window belonging to another application - [ ] A hanging shell command is killed and the interface stays responsive - [ ] A remap sending its own trigger does not loop ### Watch out - Sending a keystroke from inside the event tap callback can feed straight back into your own tap and loop. Tag events you generate with a user data field and ignore them when they come back around, or the first remap you write will hang the machine. ## Phase 4 · Scoping and conflicts Rules apply where you meant them to, and two rules never silently fight. ### Steps 1. Implement specificity resolution Application scope beats global; priority breaks remaining ties. Write it once and test it with a table of cases. Files: `Resolver.swift` 2. Detect conflicts at save time Compare trigger and scope against existing rules. Show both rules and let the user choose. Files: `Conflicts.swift` 3. Add profiles A named active set. Useful the first time you plug into an external display. Files: `Profiles.swift` ### Done when - [ ] An application-scoped rule overrides a global one only in that application - [ ] A colliding rule is refused with both rules shown - [ ] Switching profiles changes the active set in one action - [ ] The resolver is covered by a table of test cases ### Watch out - Two rules on the same trigger in the same scope produce behaviour that depends on iteration order, which will change when you refactor. Detect the collision at save time and make the user resolve it. ## Phase 5 · Sharing configurations A rule set can be exported, read by a human and imported without breaking anything. ### Steps 1. Version the export format A schema version in the file. Import checks it and refuses a newer version rather than guessing. Files: `Export.swift` 2. Validate before applying Parse the whole file, validate every rule, then apply atomically. A half-applied import is worse than a refused one. Files: `Import.swift` 3. Preview shell actions prominently List every command that would be installed and require explicit approval. Files: `ImportPreview.swift` ### Done when - [ ] An exported set imports into a fresh installation and behaves the same - [ ] An unknown action kind is refused rather than partially applied - [ ] Shell actions are listed and separately approved on import - [ ] A failed import leaves the existing rules untouched ### Watch out - An imported rule that runs a shell command is arbitrary code from whoever wrote it. Show every shell action in the import preview and require explicit approval for those specifically, because a rule set is exactly the kind of file people share without reading. ## Not in this build - Broad input device coverage across stream decks, tablets and controllers. - The scripting depth accumulated over a decade. - The community preset library. - Guaranteed compatibility across macOS releases. ## After v1, if you want it - Window snapping to halves and quarters, which is the most requested action every time - A recording mode that captures a gesture and offers to bind it - Per-display profiles that switch when an external monitor is connected ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. Where rules and profiles are stored. Version controlled if you want history. RULES_PATH=~/Library/Application Support/Input/rules.json # Required. Minimum travel in points before a swipe counts. Lower values cause accidental firing. GESTURE_MIN_DISTANCE=60 # Required. Lockout after a gesture fires, so one physical gesture cannot trigger twice. GESTURE_COOLDOWN_MS=350 # Required. How long a shell action may run before it is killed. SHELL_TIMEOUT_MS=3000 # Optional. Product phase only. SPARKLE_FEED_URL=https://example.com/appcast.xml
You are building a lean indie version of BetterTouchTool. Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt. ===== README.md ===== # BetterTouchTool · indie build A background agent that watches trackpad gestures, mouse buttons and keyboard shortcuts, matches them against rules scoped to the frontmost application, and runs an action: a keystroke, a shell command, a window arrangement or a sequence of them, with a conflict checker so two rules never fight over the same input. Estimated effort: **weekend**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Language | Swift 6 and SwiftUI in Xcode | input events, the Accessibility API and window management are all AppKit | | App type | A background agent with LSUIElement true | it listens; it does not need a window most of the time | | Input capture | A CGEvent tap for keyboard and mouse, NSEvent monitors for gestures | an event tap can consume an event, which a monitor cannot, and consuming is what makes a remap work | | Window control | The Accessibility API against other applications | the only sanctioned way to move a window your process does not own | | Storage | JSON in Application Support, versioned | rules are documents people will want to share, diff and back up | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **A Mac running macOS 14 or newer with a trackpad** · free if you already own one - Why: Gesture recognition cannot be developed against a mouse, and the whole build is macOS input handling. - Get it: Any recent Mac. A laptop trackpad or a Magic Trackpad both work. - Verify: About This Mac reports macOS 14 or higher and a trackpad is present - [ ] **Xcode 16 or newer** · free - Why: Builds and signs the agent. - Get it: Mac App Store or developer.apple.com/xcode. - Verify: xcodebuild -version prints 16 or higher - [ ] **Willingness to grant Accessibility to your own build** · free - Why: Nothing here works without it, and it is the permission that reads every keystroke. Understand what you are granting before asking anyone else to. - Get it: System Settings, then Privacy and Security, then Accessibility. During development it must be re-granted whenever the binary path or signature changes. - Verify: AXIsProcessTrusted returns true and the event tap installs - [ ] **Three applications with different input behaviour** · free - Why: Phase 3 and 4 need real variety: a text editor, a browser and something that refuses window manipulation. Testing against one application hides most of the bugs. - Get it: Anything already installed. Include at least one that ignores Accessibility window commands, because handling refusal is part of the work. - Verify: All three respond differently to the same rule and each case is handled - [ ] **Apple Developer Program membership** (optional) · 99 USD per year - Why: Product phase only. Without it there is no notarization and a Gatekeeper warning everywhere else. - Get it: Enrol at developer.apple.com/programs. - Verify: A Developer ID Application certificate appears in Xcode settings ## Quick start ```sh xcrun notarytool submit Input.zip --keychain-profile AC --wait xcrun stapler staple Input.app ``` Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - Broad input device coverage across stream decks, tablets and controllers. - The scripting depth accumulated over a decade. - The community preset library. - Guaranteed compatibility across macOS releases. - huge input-device coverage, scripting depth, community presets, and OS maintenance - years of OS-version edge cases - large extension ecosystem - cross-device sync - commercial support and rapid compatibility fixes If one of those is essential to you, that is the reason to keep paying for BetterTouchTool, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · BetterTouchTool The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a macOS input remapper that turns trackpad gestures, mouse buttons and key combinations into actions you define to replace BetterTouchTool. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Stack (fixed, do not substitute) - Language: Swift 6 and SwiftUI in Xcode · input events, the Accessibility API and window management are all AppKit - App type: A background agent with LSUIElement true · it listens; it does not need a window most of the time - Input capture: A CGEvent tap for keyboard and mouse, NSEvent monitors for gestures · an event tap can consume an event, which a monitor cannot, and consuming is what makes a remap work - Window control: The Accessibility API against other applications · the only sanctioned way to move a window your process does not own - Storage: JSON in Application Support, versioned · rules are documents people will want to share, diff and back up ### Data model (create this before Phase 1) - triggers(id, kind, spec_json, app_scope) · kind is gesture, mouse_button, key_combo or window_event; app_scope is a bundle identifier or all - actions(id, kind, payload_json) · kind is send_keys, run_shell, move_window, launch_app, sequence or delay - rules(id, trigger_id, action_id, enabled, priority, notes) - profiles(id, name, active) · a named rule set, for example docked versus laptop - conflicts(rule_a, rule_b, scope) · computed, not stored by the user, and surfaced before a rule is saved - Rules resolve most specific first: an application-scoped rule beats a global one, and priority breaks the remaining ties. ### Phase 1 · An event tap that can consume Build: Request Accessibility, install a CGEvent tap for keyboard and mouse events, log what arrives, and prove you can swallow one specific combination so the frontmost application never sees it. Add a watchdog that re-enables the tap when the system disables it. Trap: macOS disables an event tap that takes too long to return, and it does so silently. Your remaps simply stop working with no error anywhere. Listen for the tap-disabled notification and re-enable, and never do slow work inside the callback: post it to another queue and return immediately. Done when: A chosen key combination is consumed and does not reach the frontmost application, verified in a text editor. Deliberately blocking the callback long enough to trip the timeout results in the tap being re-enabled automatically rather than staying dead. Denying Accessibility produces your own explanation rather than silence. Do not build yet: No actions, no gestures, no rules. One event observed and consumed. ### Phase 2 · Trackpad gestures Build: Capture gesture events and recognise three and four finger swipes, taps and pinches. Require a minimum distance and a maximum duration so a slow drift is not a swipe. Add hysteresis so one physical gesture never fires twice. Trap: Without a cooldown after recognition, the tail of a swipe re-triggers the same gesture, and the user gets two window moves from one flick. Lock out the same gesture for a short interval after it fires, and test with deliberately sloppy input rather than careful demonstrations. Done when: A three finger swipe fires once per physical gesture across twenty attempts, including deliberately sloppy ones. A slow two-inch drift with three fingers does not register as a swipe. Gestures that macOS itself uses are either left alone or are consciously overridden with that noted in the interface. Do not build yet: No actions bound yet, so recognition only logs. ### Phase 3 · Actions Build: Implement the action kinds: send a keystroke, run a shell command, move or resize the frontmost window, launch an application, and run a sequence with delays. Run shell commands off the main thread with a timeout. Trap: Sending a keystroke from inside the event tap callback can feed straight back into your own tap and loop. Tag events you generate with a user data field and ignore them when they come back around, or the first remap you write will hang the machine. Done when: A gesture sends a keystroke that the frontmost application receives. A gesture moves the frontmost window to the left half of the screen, including for an application you did not write. A shell action that hangs is killed at its timeout and the interface stays responsive. A remap that sends its own trigger does not loop. Do not build yet: No scoping or conflict detection yet. ### Phase 4 · Scoping and conflicts Build: Scope every rule to an application or to everything. Resolve most specific first. Detect conflicts when a rule is saved and show them, rather than letting the user discover that one of two rules mysteriously never fires. Trap: Two rules on the same trigger in the same scope produce behaviour that depends on iteration order, which will change when you refactor. Detect the collision at save time and make the user resolve it. Done when: An application-scoped rule overrides a global rule for the same trigger and the global rule still applies elsewhere. Saving a rule that collides with an existing one shows both and refuses to save silently. Switching profiles changes the active rule set in one action. Do not build yet: No import or export yet. ### Phase 5 · Sharing configurations Build: Export rules as versioned JSON. Import with validation, refusing anything referencing an unknown action kind rather than importing it half-working. Show what an import will change before applying it. Trap: An imported rule that runs a shell command is arbitrary code from whoever wrote it. Show every shell action in the import preview and require explicit approval for those specifically, because a rule set is exactly the kind of file people share without reading. Done when: Exported rules import into a fresh installation and behave identically. An import containing an unknown action kind is refused with a clear message rather than partially applied. Shell actions are listed individually in the preview and require separate approval. Do not build yet: Signing and distribution come next. ### Phase 6 · Signing, notarization and updates Build: Hardened runtime, Developer ID signing, notarization, stapling and Sparkle updates. Document the Accessibility requirement honestly, because you are asking for the permission that reads every keystroke. Trap: Signing invalidates the Accessibility grant that testers gave your unsigned build, and macOS will not prompt again. Put the removal instructions in the release notes. Done when: A notarized build opens on a Mac that never ran it with no Gatekeeper warning and requests Accessibility once. An appcast update installs and relaunches. The README states plainly what the permission allows the application to see. Do not build yet: Nothing after this. ### Out of scope (and why) - The breadth of input device support. Stream decks, drawing tablets, game controllers and specific mouse models are each their own integration, and there are dozens. - The scripting depth of the commercial product, which has accumulated hundreds of action types over a decade. - The community preset library, which is other people's work and cannot be written into existence. - Compatibility across major macOS releases. Event taps and the Accessibility API both shift, and that maintenance is now yours. ### README must contain - That Accessibility permits reading every keystroke, and exactly what this application does with them - That an event tap can be disabled by the system and how the watchdog handles it - The gesture thresholds and the cooldown, and where to change them - That imported rule sets can contain shell commands and are approved individually - That re-signing invalidates the Accessibility grant and how to clear the stale entry ===== AGENTS.md ===== # Agent instructions · BetterTouchTool indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Swift 6 and SwiftUI in Xcode, A background agent with LSUIElement true, A CGEvent tap for keyboard and mouse, NSEvent monitors for gestures, The Accessibility API against other applications, JSON in Application Support, versioned. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - macOS disables an event tap that takes too long to return, and it does so silently. Your remaps simply stop working with no error anywhere. Listen for the tap-disabled notification and re-enable, and never do slow work inside the callback: post it to another queue and return immediately. - Without a cooldown after recognition, the tail of a swipe re-triggers the same gesture, and the user gets two window moves from one flick. Lock out the same gesture for a short interval after it fires, and test with deliberately sloppy input rather than careful demonstrations. - Sending a keystroke from inside the event tap callback can feed straight back into your own tap and loop. Tag events you generate with a user data field and ignore them when they come back around, or the first remap you write will hang the machine. - Two rules on the same trigger in the same scope produce behaviour that depends on iteration order, which will change when you refactor. Detect the collision at save time and make the user resolve it. - An imported rule that runs a shell command is arbitrary code from whoever wrote it. Show every shell action in the import preview and require explicit approval for those specifically, because a rule set is exactly the kind of file people share without reading. ===== BUILD_PLAN.md ===== # Build plan · BetterTouchTool A background agent that watches trackpad gestures, mouse buttons and keyboard shortcuts, matches them against rules scoped to the frontmost application, and runs an action: a keystroke, a shell command, a window arrangement or a sequence of them, with a conflict checker so two rules never fight over the same input. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · An event tap that can consume You can observe an input event and stop it reaching the application underneath. ### Steps 1. Request Accessibility and explain it first Check AXIsProcessTrustedWithOptions and show your own panel before the system prompt. This permission lets you read every keystroke, so say so. Files: `Permissions.swift` 2. Install the event tap CGEvent.tapCreate at the session level for keyboard and mouse events. Return nil from the callback to consume an event. Files: `EventTap.swift` 3. Add the disabled watchdog Handle tapDisabledByTimeout and tapDisabledByUserInput by re-enabling. Without this the application dies quietly under load. Files: `EventTap.swift` ### Done when - [ ] A chosen key combination never reaches a text editor - [ ] A deliberately slow callback trips the timeout and the tap is re-enabled - [ ] Refusing Accessibility shows an explanation and a working Settings link - [ ] The callback does no slow work on the tap thread ### Watch out - macOS disables an event tap that takes too long to return, and it does so silently. Your remaps simply stop working with no error anywhere. Listen for the tap-disabled notification and re-enable, and never do slow work inside the callback: post it to another queue and return immediately. ## Phase 2 · Trackpad gestures Multi-finger gestures are recognised reliably and do not fire by accident. ### Steps 1. Capture gesture events NSEvent monitors for magnify, swipe and touches. Read touch phases directly for finger counts rather than trusting the swipe event alone. Files: `Gestures.swift` 2. Add thresholds and hysteresis Minimum distance, maximum duration, and a cooldown after firing. These three numbers are the difference between reliable and infuriating. Files: `Gestures.swift` 3. Test with sloppy input Twenty deliberately imprecise gestures. Careful gestures always work and tell you nothing. ### Done when - [ ] A three finger swipe fires exactly once across twenty sloppy attempts - [ ] A slow drift does not register as a swipe - [ ] System gestures are either untouched or the override is disclosed - [ ] Finger count is read from touches rather than inferred ### Watch out - Without a cooldown after recognition, the tail of a swipe re-triggers the same gesture, and the user gets two window moves from one flick. Lock out the same gesture for a short interval after it fires, and test with deliberately sloppy input rather than careful demonstrations. ## Phase 3 · Actions A recognised input runs something useful, including moving windows that belong to other applications. ### Steps 1. Send keystrokes without feedback loops Post events with a distinctive user data value and ignore that value in your tap callback. Files: `Actions/SendKeys.swift` 2. Move windows through the Accessibility API Get the focused window of the frontmost application and set its position and size attributes. Handle applications that refuse. Files: `Actions/Window.swift` 3. Run shell commands safely Off the main thread, with a hard timeout and the process group killed on expiry. Files: `Actions/Shell.swift` ### Done when - [ ] A keystroke action reaches the frontmost application - [ ] A window action moves a window belonging to another application - [ ] A hanging shell command is killed and the interface stays responsive - [ ] A remap sending its own trigger does not loop ### Watch out - Sending a keystroke from inside the event tap callback can feed straight back into your own tap and loop. Tag events you generate with a user data field and ignore them when they come back around, or the first remap you write will hang the machine. ## Phase 4 · Scoping and conflicts Rules apply where you meant them to, and two rules never silently fight. ### Steps 1. Implement specificity resolution Application scope beats global; priority breaks remaining ties. Write it once and test it with a table of cases. Files: `Resolver.swift` 2. Detect conflicts at save time Compare trigger and scope against existing rules. Show both rules and let the user choose. Files: `Conflicts.swift` 3. Add profiles A named active set. Useful the first time you plug into an external display. Files: `Profiles.swift` ### Done when - [ ] An application-scoped rule overrides a global one only in that application - [ ] A colliding rule is refused with both rules shown - [ ] Switching profiles changes the active set in one action - [ ] The resolver is covered by a table of test cases ### Watch out - Two rules on the same trigger in the same scope produce behaviour that depends on iteration order, which will change when you refactor. Detect the collision at save time and make the user resolve it. ## Phase 5 · Sharing configurations A rule set can be exported, read by a human and imported without breaking anything. ### Steps 1. Version the export format A schema version in the file. Import checks it and refuses a newer version rather than guessing. Files: `Export.swift` 2. Validate before applying Parse the whole file, validate every rule, then apply atomically. A half-applied import is worse than a refused one. Files: `Import.swift` 3. Preview shell actions prominently List every command that would be installed and require explicit approval. Files: `ImportPreview.swift` ### Done when - [ ] An exported set imports into a fresh installation and behaves the same - [ ] An unknown action kind is refused rather than partially applied - [ ] Shell actions are listed and separately approved on import - [ ] A failed import leaves the existing rules untouched ### Watch out - An imported rule that runs a shell command is arbitrary code from whoever wrote it. Show every shell action in the import preview and require explicit approval for those specifically, because a rule set is exactly the kind of file people share without reading. ## Not in this build - Broad input device coverage across stream decks, tablets and controllers. - The scripting depth accumulated over a decade. - The community preset library. - Guaranteed compatibility across macOS releases. ## After v1, if you want it - Window snapping to halves and quarters, which is the most requested action every time - A recording mode that captures a gesture and offers to bind it - Per-display profiles that switch when an external monitor is connected ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. Where rules and profiles are stored. Version controlled if you want history. RULES_PATH=~/Library/Application Support/Input/rules.json # Required. Minimum travel in points before a swipe counts. Lower values cause accidental firing. GESTURE_MIN_DISTANCE=60 # Required. Lockout after a gesture fires, so one physical gesture cannot trigger twice. GESTURE_COOLDOWN_MS=350 # Required. How long a shell action may run before it is killed. SHELL_TIMEOUT_MS=3000 # Optional. Product phase only. SPARKLE_FEED_URL=https://example.com/appcast.xml
You are building a production product version of BetterTouchTool. Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt. ===== PRODUCT.md ===== # BetterTouchTool · product brief ## Problem The core loop is small enough for a capable coding agent to produce a useful local version in one sitting. For BetterTouchTool, map trackpad, mouse, keyboard, and window gestures to explicit local actions. The hard boundary is huge input-device coverage, scripting depth, community presets, and os maintenance, plus os integration polish and maintenance. ## Product outcome An input remapper that consumes events reliably, survives the system disabling its tap, never fires one gesture twice, refuses conflicting rules at save time, and asks for its serious permission with an honest explanation. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - macOS 14 or later - Xcode - only the permissions required by the selected feature ## Explicit non-goals for v1 - Broad input device coverage across stream decks, tablets and controllers. - The scripting depth accumulated over a decade. - The community preset library. - Guaranteed compatibility across macOS releases. - huge input-device coverage, scripting depth, community presets, and OS maintenance - years of OS-version edge cases - large extension ecosystem - cross-device sync - commercial support and rapid compatibility fixes ## Success criteria - The tap watchdog is proved to recover from a deliberate timeout - Twenty sloppy gestures fire exactly twenty times - A remap sending its own trigger is confirmed not to loop - Import refuses an unknown action kind and lists shell actions separately - spctl accepts the notarized build on a Mac that never ran it ===== BRIEF.md ===== # Build brief · BetterTouchTool The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a macOS input remapper that turns trackpad gestures, mouse buttons and key combinations into actions you define to replace BetterTouchTool. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Stack (fixed, do not substitute) - Language: Swift 6 and SwiftUI in Xcode · input events, the Accessibility API and window management are all AppKit - App type: A background agent with LSUIElement true · it listens; it does not need a window most of the time - Input capture: A CGEvent tap for keyboard and mouse, NSEvent monitors for gestures · an event tap can consume an event, which a monitor cannot, and consuming is what makes a remap work - Window control: The Accessibility API against other applications · the only sanctioned way to move a window your process does not own - Storage: JSON in Application Support, versioned · rules are documents people will want to share, diff and back up ### Data model (create this before Phase 1) - triggers(id, kind, spec_json, app_scope) · kind is gesture, mouse_button, key_combo or window_event; app_scope is a bundle identifier or all - actions(id, kind, payload_json) · kind is send_keys, run_shell, move_window, launch_app, sequence or delay - rules(id, trigger_id, action_id, enabled, priority, notes) - profiles(id, name, active) · a named rule set, for example docked versus laptop - conflicts(rule_a, rule_b, scope) · computed, not stored by the user, and surfaced before a rule is saved - Rules resolve most specific first: an application-scoped rule beats a global one, and priority breaks the remaining ties. ### Phase 1 · An event tap that can consume Build: Request Accessibility, install a CGEvent tap for keyboard and mouse events, log what arrives, and prove you can swallow one specific combination so the frontmost application never sees it. Add a watchdog that re-enables the tap when the system disables it. Trap: macOS disables an event tap that takes too long to return, and it does so silently. Your remaps simply stop working with no error anywhere. Listen for the tap-disabled notification and re-enable, and never do slow work inside the callback: post it to another queue and return immediately. Done when: A chosen key combination is consumed and does not reach the frontmost application, verified in a text editor. Deliberately blocking the callback long enough to trip the timeout results in the tap being re-enabled automatically rather than staying dead. Denying Accessibility produces your own explanation rather than silence. Do not build yet: No actions, no gestures, no rules. One event observed and consumed. ### Phase 2 · Trackpad gestures Build: Capture gesture events and recognise three and four finger swipes, taps and pinches. Require a minimum distance and a maximum duration so a slow drift is not a swipe. Add hysteresis so one physical gesture never fires twice. Trap: Without a cooldown after recognition, the tail of a swipe re-triggers the same gesture, and the user gets two window moves from one flick. Lock out the same gesture for a short interval after it fires, and test with deliberately sloppy input rather than careful demonstrations. Done when: A three finger swipe fires once per physical gesture across twenty attempts, including deliberately sloppy ones. A slow two-inch drift with three fingers does not register as a swipe. Gestures that macOS itself uses are either left alone or are consciously overridden with that noted in the interface. Do not build yet: No actions bound yet, so recognition only logs. ### Phase 3 · Actions Build: Implement the action kinds: send a keystroke, run a shell command, move or resize the frontmost window, launch an application, and run a sequence with delays. Run shell commands off the main thread with a timeout. Trap: Sending a keystroke from inside the event tap callback can feed straight back into your own tap and loop. Tag events you generate with a user data field and ignore them when they come back around, or the first remap you write will hang the machine. Done when: A gesture sends a keystroke that the frontmost application receives. A gesture moves the frontmost window to the left half of the screen, including for an application you did not write. A shell action that hangs is killed at its timeout and the interface stays responsive. A remap that sends its own trigger does not loop. Do not build yet: No scoping or conflict detection yet. ### Phase 4 · Scoping and conflicts Build: Scope every rule to an application or to everything. Resolve most specific first. Detect conflicts when a rule is saved and show them, rather than letting the user discover that one of two rules mysteriously never fires. Trap: Two rules on the same trigger in the same scope produce behaviour that depends on iteration order, which will change when you refactor. Detect the collision at save time and make the user resolve it. Done when: An application-scoped rule overrides a global rule for the same trigger and the global rule still applies elsewhere. Saving a rule that collides with an existing one shows both and refuses to save silently. Switching profiles changes the active rule set in one action. Do not build yet: No import or export yet. ### Phase 5 · Sharing configurations Build: Export rules as versioned JSON. Import with validation, refusing anything referencing an unknown action kind rather than importing it half-working. Show what an import will change before applying it. Trap: An imported rule that runs a shell command is arbitrary code from whoever wrote it. Show every shell action in the import preview and require explicit approval for those specifically, because a rule set is exactly the kind of file people share without reading. Done when: Exported rules import into a fresh installation and behave identically. An import containing an unknown action kind is refused with a clear message rather than partially applied. Shell actions are listed individually in the preview and require separate approval. Do not build yet: Signing and distribution come next. ### Phase 6 · Signing, notarization and updates Build: Hardened runtime, Developer ID signing, notarization, stapling and Sparkle updates. Document the Accessibility requirement honestly, because you are asking for the permission that reads every keystroke. Trap: Signing invalidates the Accessibility grant that testers gave your unsigned build, and macOS will not prompt again. Put the removal instructions in the release notes. Done when: A notarized build opens on a Mac that never ran it with no Gatekeeper warning and requests Accessibility once. An appcast update installs and relaunches. The README states plainly what the permission allows the application to see. Do not build yet: Nothing after this. ### Out of scope (and why) - The breadth of input device support. Stream decks, drawing tablets, game controllers and specific mouse models are each their own integration, and there are dozens. - The scripting depth of the commercial product, which has accumulated hundreds of action types over a decade. - The community preset library, which is other people's work and cannot be written into existence. - Compatibility across major macOS releases. Event taps and the Accessibility API both shift, and that maintenance is now yours. ### README must contain - That Accessibility permits reading every keystroke, and exactly what this application does with them - That an event tap can be disabled by the system and how the watchdog handles it - The gesture thresholds and the cooldown, and where to change them - That imported rule sets can contain shell commands and are approved individually - That re-signing invalidates the Accessibility grant and how to clear the stale entry ===== ARCHITECTURE.md ===== # Architecture · BetterTouchTool ## Stack | Part | Choice | Why | | --- | --- | --- | | Language | Swift 6 and SwiftUI in Xcode | input events, the Accessibility API and window management are all AppKit | | App type | A background agent with LSUIElement true | it listens; it does not need a window most of the time | | Input capture | A CGEvent tap for keyboard and mouse, NSEvent monitors for gestures | an event tap can consume an event, which a monitor cannot, and consuming is what makes a remap work | | Window control | The Accessibility API against other applications | the only sanctioned way to move a window your process does not own | | Storage | JSON in Application Support, versioned | rules are documents people will want to share, diff and back up | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Tap | Capturing and consuming events, and staying alive. | Not swappable; the event tap is the only API that can consume. The watchdog is the part that must never be removed for simplicity. | | Gestures | Recognition, thresholds and hysteresis. | Pure logic over touch data and testable from recorded input. The three tuning numbers are the product's feel. | | Actions | Everything a rule can do. | Each kind is independent, so adding one touches nothing else. The loop guard on generated events applies to all of them. | | Resolver | Scope and priority resolution. | A pure function with a test table. Any rewrite must keep the specificity ordering. | | Config | Persistence, import and export. | JSON is a deliberate choice so rule sets are readable and diffable before they are imported. | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `RULES_PATH` · required · Where rules and profiles are stored. Version controlled if you want history. - `GESTURE_MIN_DISTANCE` · required · Minimum travel in points before a swipe counts. Lower values cause accidental firing. - `GESTURE_COOLDOWN_MS` · required · Lockout after a gesture fires, so one physical gesture cannot trigger twice. - `SHELL_TIMEOUT_MS` · required · How long a shell action may run before it is killed. - `SPARKLE_FEED_URL` · optional · Product phase only. ## Production baseline - Security: least privilege, input validation at every boundary, secret redaction in logs, rate limits on abuse-prone paths, no invented security primitives. - Data: explicit schema and migrations, transactional writes where integrity matters, backup and restore procedures that have been exercised. - Integrations: adapters around third-party providers, idempotent webhook or job processing, bounded retries, timeouts. - Observability: structured logs with request or operation ids, an error-tracking hook, and health and readiness checks where a server exists. - Quality: unit tests for domain rules, integration tests at module boundaries, one end-to-end test of the critical path. ## Decision records For each dependency in the stack table, keep a short note: why it was chosen, its failure mode, and how it is replaced. Do not add infrastructure until a requirement in `PRODUCT.md` justifies it. ===== AGENTS.md ===== # Agent instructions · BetterTouchTool product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: Swift 6 and SwiftUI in Xcode, A background agent with LSUIElement true, A CGEvent tap for keyboard and mouse, NSEvent monitors for gestures, The Accessibility API against other applications, JSON in Application Support, versioned. - Implement milestone by milestone from `MILESTONES.md`; keep each change reviewable and leave the application runnable at every commit. - Treat authentication, payments, encryption, imports, webhooks and destructive actions as high-risk boundaries when present. - Never invent cryptography or silently weaken a requirement to make a check pass. - Put every external service behind an interface with a deterministic fake for tests. - Add migrations and rollback or recovery notes for every persistent data change. - Log useful operational context without credentials, tokens, passwords or personal data. - Update documentation and run every check before completing a milestone. ## Known traps - macOS disables an event tap that takes too long to return, and it does so silently. Your remaps simply stop working with no error anywhere. Listen for the tap-disabled notification and re-enable, and never do slow work inside the callback: post it to another queue and return immediately. - Without a cooldown after recognition, the tail of a swipe re-triggers the same gesture, and the user gets two window moves from one flick. Lock out the same gesture for a short interval after it fires, and test with deliberately sloppy input rather than careful demonstrations. - Sending a keystroke from inside the event tap callback can feed straight back into your own tap and loop. Tag events you generate with a user data field and ignore them when they come back around, or the first remap you write will hang the machine. - Two rules on the same trigger in the same scope produce behaviour that depends on iteration order, which will change when you refactor. Detect the collision at save time and make the user resolve it. - An imported rule that runs a shell command is arbitrary code from whoever wrote it. Show every shell action in the import preview and require explicit approval for those specifically, because a rule set is exactly the kind of file people share without reading. - Signing invalidates the Accessibility grant that testers gave your unsigned build, and macOS will not prompt again. Put the removal instructions in the release notes. ===== MILESTONES.md ===== # Delivery milestones · BetterTouchTool Estimated effort: **weekend** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · An event tap that can consume You can observe an input event and stop it reaching the application underneath. ### Steps 1. Request Accessibility and explain it first Check AXIsProcessTrustedWithOptions and show your own panel before the system prompt. This permission lets you read every keystroke, so say so. Files: `Permissions.swift` 2. Install the event tap CGEvent.tapCreate at the session level for keyboard and mouse events. Return nil from the callback to consume an event. Files: `EventTap.swift` 3. Add the disabled watchdog Handle tapDisabledByTimeout and tapDisabledByUserInput by re-enabling. Without this the application dies quietly under load. Files: `EventTap.swift` ### Done when - [ ] A chosen key combination never reaches a text editor - [ ] A deliberately slow callback trips the timeout and the tap is re-enabled - [ ] Refusing Accessibility shows an explanation and a working Settings link - [ ] The callback does no slow work on the tap thread ### Watch out - macOS disables an event tap that takes too long to return, and it does so silently. Your remaps simply stop working with no error anywhere. Listen for the tap-disabled notification and re-enable, and never do slow work inside the callback: post it to another queue and return immediately. ## M2 · Trackpad gestures Multi-finger gestures are recognised reliably and do not fire by accident. ### Steps 1. Capture gesture events NSEvent monitors for magnify, swipe and touches. Read touch phases directly for finger counts rather than trusting the swipe event alone. Files: `Gestures.swift` 2. Add thresholds and hysteresis Minimum distance, maximum duration, and a cooldown after firing. These three numbers are the difference between reliable and infuriating. Files: `Gestures.swift` 3. Test with sloppy input Twenty deliberately imprecise gestures. Careful gestures always work and tell you nothing. ### Done when - [ ] A three finger swipe fires exactly once across twenty sloppy attempts - [ ] A slow drift does not register as a swipe - [ ] System gestures are either untouched or the override is disclosed - [ ] Finger count is read from touches rather than inferred ### Watch out - Without a cooldown after recognition, the tail of a swipe re-triggers the same gesture, and the user gets two window moves from one flick. Lock out the same gesture for a short interval after it fires, and test with deliberately sloppy input rather than careful demonstrations. ## M3 · Actions A recognised input runs something useful, including moving windows that belong to other applications. ### Steps 1. Send keystrokes without feedback loops Post events with a distinctive user data value and ignore that value in your tap callback. Files: `Actions/SendKeys.swift` 2. Move windows through the Accessibility API Get the focused window of the frontmost application and set its position and size attributes. Handle applications that refuse. Files: `Actions/Window.swift` 3. Run shell commands safely Off the main thread, with a hard timeout and the process group killed on expiry. Files: `Actions/Shell.swift` ### Done when - [ ] A keystroke action reaches the frontmost application - [ ] A window action moves a window belonging to another application - [ ] A hanging shell command is killed and the interface stays responsive - [ ] A remap sending its own trigger does not loop ### Watch out - Sending a keystroke from inside the event tap callback can feed straight back into your own tap and loop. Tag events you generate with a user data field and ignore them when they come back around, or the first remap you write will hang the machine. ## M4 · Scoping and conflicts Rules apply where you meant them to, and two rules never silently fight. ### Steps 1. Implement specificity resolution Application scope beats global; priority breaks remaining ties. Write it once and test it with a table of cases. Files: `Resolver.swift` 2. Detect conflicts at save time Compare trigger and scope against existing rules. Show both rules and let the user choose. Files: `Conflicts.swift` 3. Add profiles A named active set. Useful the first time you plug into an external display. Files: `Profiles.swift` ### Done when - [ ] An application-scoped rule overrides a global one only in that application - [ ] A colliding rule is refused with both rules shown - [ ] Switching profiles changes the active set in one action - [ ] The resolver is covered by a table of test cases ### Watch out - Two rules on the same trigger in the same scope produce behaviour that depends on iteration order, which will change when you refactor. Detect the collision at save time and make the user resolve it. ## M5 · Sharing configurations A rule set can be exported, read by a human and imported without breaking anything. ### Steps 1. Version the export format A schema version in the file. Import checks it and refuses a newer version rather than guessing. Files: `Export.swift` 2. Validate before applying Parse the whole file, validate every rule, then apply atomically. A half-applied import is worse than a refused one. Files: `Import.swift` 3. Preview shell actions prominently List every command that would be installed and require explicit approval. Files: `ImportPreview.swift` ### Done when - [ ] An exported set imports into a fresh installation and behaves the same - [ ] An unknown action kind is refused rather than partially applied - [ ] Shell actions are listed and separately approved on import - [ ] A failed import leaves the existing rules untouched ### Watch out - An imported rule that runs a shell command is arbitrary code from whoever wrote it. Show every shell action in the import preview and require explicit approval for those specifically, because a rule set is exactly the kind of file people share without reading. ## M6 · Signing, notarization and updates (production only) Others can install it, and you can ship the fix the next macOS release will need. ### Steps 1. Sign and notarize Hardened runtime, Developer ID Application, notarytool, stapler. ```sh xcrun notarytool submit Input.zip --keychain-profile AC --wait xcrun stapler staple Input.app ``` 2. Ship Sparkle updates EdDSA key pair with the private half outside the repository, appcast over HTTPS. Files: `appcast.xml` 3. Be explicit about the permission One paragraph saying the application can observe every keystroke and what it does and does not do with them. Files: `README.md` ### Done when - [ ] spctl reports the notarized build accepted - [ ] A fresh Mac opens it with no Gatekeeper warning - [ ] An appcast update installs and relaunches - [ ] The README states what Accessibility permits ### Watch out - Signing invalidates the Accessibility grant that testers gave your unsigned build, and macOS will not prompt again. Put the removal instructions in the release notes. ===== OPERATIONS.md ===== # Operations · BetterTouchTool ## Backup The rules file is the entire configuration and is small enough to keep in a version controlled directory, which also gives you history when a change makes things worse. ## Restore Replace the rules file and relaunch. Rules referencing applications that are not installed should be skipped rather than treated as errors, so a restore onto a different machine degrades gracefully. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Log tap disable events. A rising count means something in the callback is too slow, and it is the failure that presents as the application mysteriously not working. ## Incident checklist A macOS update changing event tap or Accessibility behaviour is the recurring risk. Keep the previous signed build available and set a minimum supported version in the appcast. 1. Contain the issue without destroying evidence or user data. 2. Record the timeline and affected scope. 3. Rotate exposed secrets and revoke compromised sessions or credentials. 4. Restore from a verified backup when needed. 5. Document the root cause, the remediation and the regression test. ## Release gate - [ ] The tap watchdog is proved to recover from a deliberate timeout - [ ] Twenty sloppy gestures fire exactly twenty times - [ ] A remap sending its own trigger is confirmed not to loop - [ ] Import refuses an unknown action kind and lists shell actions separately - [ ] spctl accepts the notarized build on a Mac that never ran it ## Launch constraint Do not market omitted BetterTouchTool capabilities as implemented. The non-goals in `PRODUCT.md` remain user-visible limitations until they are deliberately delivered. ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. Where rules and profiles are stored. Version controlled if you want history. RULES_PATH=~/Library/Application Support/Input/rules.json # Required. Minimum travel in points before a swipe counts. Lower values cause accidental firing. GESTURE_MIN_DISTANCE=60 # Required. Lockout after a gesture fires, so one physical gesture cannot trigger twice. GESTURE_COOLDOWN_MS=350 # Required. How long a shell action may run before it is killed. SHELL_TIMEOUT_MS=3000 # Optional. Product phase only. SPARKLE_FEED_URL=https://example.com/appcast.xml
# BetterTouchTool · indie build A background agent that watches trackpad gestures, mouse buttons and keyboard shortcuts, matches them against rules scoped to the frontmost application, and runs an action: a keystroke, a shell command, a window arrangement or a sequence of them, with a conflict checker so two rules never fight over the same input. Estimated effort: **weekend**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Language | Swift 6 and SwiftUI in Xcode | input events, the Accessibility API and window management are all AppKit | | App type | A background agent with LSUIElement true | it listens; it does not need a window most of the time | | Input capture | A CGEvent tap for keyboard and mouse, NSEvent monitors for gestures | an event tap can consume an event, which a monitor cannot, and consuming is what makes a remap work | | Window control | The Accessibility API against other applications | the only sanctioned way to move a window your process does not own | | Storage | JSON in Application Support, versioned | rules are documents people will want to share, diff and back up | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **A Mac running macOS 14 or newer with a trackpad** · free if you already own one - Why: Gesture recognition cannot be developed against a mouse, and the whole build is macOS input handling. - Get it: Any recent Mac. A laptop trackpad or a Magic Trackpad both work. - Verify: About This Mac reports macOS 14 or higher and a trackpad is present - [ ] **Xcode 16 or newer** · free - Why: Builds and signs the agent. - Get it: Mac App Store or developer.apple.com/xcode. - Verify: xcodebuild -version prints 16 or higher - [ ] **Willingness to grant Accessibility to your own build** · free - Why: Nothing here works without it, and it is the permission that reads every keystroke. Understand what you are granting before asking anyone else to. - Get it: System Settings, then Privacy and Security, then Accessibility. During development it must be re-granted whenever the binary path or signature changes. - Verify: AXIsProcessTrusted returns true and the event tap installs - [ ] **Three applications with different input behaviour** · free - Why: Phase 3 and 4 need real variety: a text editor, a browser and something that refuses window manipulation. Testing against one application hides most of the bugs. - Get it: Anything already installed. Include at least one that ignores Accessibility window commands, because handling refusal is part of the work. - Verify: All three respond differently to the same rule and each case is handled - [ ] **Apple Developer Program membership** (optional) · 99 USD per year - Why: Product phase only. Without it there is no notarization and a Gatekeeper warning everywhere else. - Get it: Enrol at developer.apple.com/programs. - Verify: A Developer ID Application certificate appears in Xcode settings ## Quick start ```sh xcrun notarytool submit Input.zip --keychain-profile AC --wait xcrun stapler staple Input.app ``` Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - Broad input device coverage across stream decks, tablets and controllers. - The scripting depth accumulated over a decade. - The community preset library. - Guaranteed compatibility across macOS releases. - huge input-device coverage, scripting depth, community presets, and OS maintenance - years of OS-version edge cases - large extension ecosystem - cross-device sync - commercial support and rapid compatibility fixes If one of those is essential to you, that is the reason to keep paying for BetterTouchTool, and the README should say so rather than pretend.
# Build brief · BetterTouchTool The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a macOS input remapper that turns trackpad gestures, mouse buttons and key combinations into actions you define to replace BetterTouchTool. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Stack (fixed, do not substitute) - Language: Swift 6 and SwiftUI in Xcode · input events, the Accessibility API and window management are all AppKit - App type: A background agent with LSUIElement true · it listens; it does not need a window most of the time - Input capture: A CGEvent tap for keyboard and mouse, NSEvent monitors for gestures · an event tap can consume an event, which a monitor cannot, and consuming is what makes a remap work - Window control: The Accessibility API against other applications · the only sanctioned way to move a window your process does not own - Storage: JSON in Application Support, versioned · rules are documents people will want to share, diff and back up ### Data model (create this before Phase 1) - triggers(id, kind, spec_json, app_scope) · kind is gesture, mouse_button, key_combo or window_event; app_scope is a bundle identifier or all - actions(id, kind, payload_json) · kind is send_keys, run_shell, move_window, launch_app, sequence or delay - rules(id, trigger_id, action_id, enabled, priority, notes) - profiles(id, name, active) · a named rule set, for example docked versus laptop - conflicts(rule_a, rule_b, scope) · computed, not stored by the user, and surfaced before a rule is saved - Rules resolve most specific first: an application-scoped rule beats a global one, and priority breaks the remaining ties. ### Phase 1 · An event tap that can consume Build: Request Accessibility, install a CGEvent tap for keyboard and mouse events, log what arrives, and prove you can swallow one specific combination so the frontmost application never sees it. Add a watchdog that re-enables the tap when the system disables it. Trap: macOS disables an event tap that takes too long to return, and it does so silently. Your remaps simply stop working with no error anywhere. Listen for the tap-disabled notification and re-enable, and never do slow work inside the callback: post it to another queue and return immediately. Done when: A chosen key combination is consumed and does not reach the frontmost application, verified in a text editor. Deliberately blocking the callback long enough to trip the timeout results in the tap being re-enabled automatically rather than staying dead. Denying Accessibility produces your own explanation rather than silence. Do not build yet: No actions, no gestures, no rules. One event observed and consumed. ### Phase 2 · Trackpad gestures Build: Capture gesture events and recognise three and four finger swipes, taps and pinches. Require a minimum distance and a maximum duration so a slow drift is not a swipe. Add hysteresis so one physical gesture never fires twice. Trap: Without a cooldown after recognition, the tail of a swipe re-triggers the same gesture, and the user gets two window moves from one flick. Lock out the same gesture for a short interval after it fires, and test with deliberately sloppy input rather than careful demonstrations. Done when: A three finger swipe fires once per physical gesture across twenty attempts, including deliberately sloppy ones. A slow two-inch drift with three fingers does not register as a swipe. Gestures that macOS itself uses are either left alone or are consciously overridden with that noted in the interface. Do not build yet: No actions bound yet, so recognition only logs. ### Phase 3 · Actions Build: Implement the action kinds: send a keystroke, run a shell command, move or resize the frontmost window, launch an application, and run a sequence with delays. Run shell commands off the main thread with a timeout. Trap: Sending a keystroke from inside the event tap callback can feed straight back into your own tap and loop. Tag events you generate with a user data field and ignore them when they come back around, or the first remap you write will hang the machine. Done when: A gesture sends a keystroke that the frontmost application receives. A gesture moves the frontmost window to the left half of the screen, including for an application you did not write. A shell action that hangs is killed at its timeout and the interface stays responsive. A remap that sends its own trigger does not loop. Do not build yet: No scoping or conflict detection yet. ### Phase 4 · Scoping and conflicts Build: Scope every rule to an application or to everything. Resolve most specific first. Detect conflicts when a rule is saved and show them, rather than letting the user discover that one of two rules mysteriously never fires. Trap: Two rules on the same trigger in the same scope produce behaviour that depends on iteration order, which will change when you refactor. Detect the collision at save time and make the user resolve it. Done when: An application-scoped rule overrides a global rule for the same trigger and the global rule still applies elsewhere. Saving a rule that collides with an existing one shows both and refuses to save silently. Switching profiles changes the active rule set in one action. Do not build yet: No import or export yet. ### Phase 5 · Sharing configurations Build: Export rules as versioned JSON. Import with validation, refusing anything referencing an unknown action kind rather than importing it half-working. Show what an import will change before applying it. Trap: An imported rule that runs a shell command is arbitrary code from whoever wrote it. Show every shell action in the import preview and require explicit approval for those specifically, because a rule set is exactly the kind of file people share without reading. Done when: Exported rules import into a fresh installation and behave identically. An import containing an unknown action kind is refused with a clear message rather than partially applied. Shell actions are listed individually in the preview and require separate approval. Do not build yet: Signing and distribution come next. ### Phase 6 · Signing, notarization and updates Build: Hardened runtime, Developer ID signing, notarization, stapling and Sparkle updates. Document the Accessibility requirement honestly, because you are asking for the permission that reads every keystroke. Trap: Signing invalidates the Accessibility grant that testers gave your unsigned build, and macOS will not prompt again. Put the removal instructions in the release notes. Done when: A notarized build opens on a Mac that never ran it with no Gatekeeper warning and requests Accessibility once. An appcast update installs and relaunches. The README states plainly what the permission allows the application to see. Do not build yet: Nothing after this. ### Out of scope (and why) - The breadth of input device support. Stream decks, drawing tablets, game controllers and specific mouse models are each their own integration, and there are dozens. - The scripting depth of the commercial product, which has accumulated hundreds of action types over a decade. - The community preset library, which is other people's work and cannot be written into existence. - Compatibility across major macOS releases. Event taps and the Accessibility API both shift, and that maintenance is now yours. ### README must contain - That Accessibility permits reading every keystroke, and exactly what this application does with them - That an event tap can be disabled by the system and how the watchdog handles it - The gesture thresholds and the cooldown, and where to change them - That imported rule sets can contain shell commands and are approved individually - That re-signing invalidates the Accessibility grant and how to clear the stale entry
# Agent instructions · BetterTouchTool indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Swift 6 and SwiftUI in Xcode, A background agent with LSUIElement true, A CGEvent tap for keyboard and mouse, NSEvent monitors for gestures, The Accessibility API against other applications, JSON in Application Support, versioned. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - macOS disables an event tap that takes too long to return, and it does so silently. Your remaps simply stop working with no error anywhere. Listen for the tap-disabled notification and re-enable, and never do slow work inside the callback: post it to another queue and return immediately. - Without a cooldown after recognition, the tail of a swipe re-triggers the same gesture, and the user gets two window moves from one flick. Lock out the same gesture for a short interval after it fires, and test with deliberately sloppy input rather than careful demonstrations. - Sending a keystroke from inside the event tap callback can feed straight back into your own tap and loop. Tag events you generate with a user data field and ignore them when they come back around, or the first remap you write will hang the machine. - Two rules on the same trigger in the same scope produce behaviour that depends on iteration order, which will change when you refactor. Detect the collision at save time and make the user resolve it. - An imported rule that runs a shell command is arbitrary code from whoever wrote it. Show every shell action in the import preview and require explicit approval for those specifically, because a rule set is exactly the kind of file people share without reading.
# Build plan · BetterTouchTool A background agent that watches trackpad gestures, mouse buttons and keyboard shortcuts, matches them against rules scoped to the frontmost application, and runs an action: a keystroke, a shell command, a window arrangement or a sequence of them, with a conflict checker so two rules never fight over the same input. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · An event tap that can consume You can observe an input event and stop it reaching the application underneath. ### Steps 1. Request Accessibility and explain it first Check AXIsProcessTrustedWithOptions and show your own panel before the system prompt. This permission lets you read every keystroke, so say so. Files: `Permissions.swift` 2. Install the event tap CGEvent.tapCreate at the session level for keyboard and mouse events. Return nil from the callback to consume an event. Files: `EventTap.swift` 3. Add the disabled watchdog Handle tapDisabledByTimeout and tapDisabledByUserInput by re-enabling. Without this the application dies quietly under load. Files: `EventTap.swift` ### Done when - [ ] A chosen key combination never reaches a text editor - [ ] A deliberately slow callback trips the timeout and the tap is re-enabled - [ ] Refusing Accessibility shows an explanation and a working Settings link - [ ] The callback does no slow work on the tap thread ### Watch out - macOS disables an event tap that takes too long to return, and it does so silently. Your remaps simply stop working with no error anywhere. Listen for the tap-disabled notification and re-enable, and never do slow work inside the callback: post it to another queue and return immediately. ## Phase 2 · Trackpad gestures Multi-finger gestures are recognised reliably and do not fire by accident. ### Steps 1. Capture gesture events NSEvent monitors for magnify, swipe and touches. Read touch phases directly for finger counts rather than trusting the swipe event alone. Files: `Gestures.swift` 2. Add thresholds and hysteresis Minimum distance, maximum duration, and a cooldown after firing. These three numbers are the difference between reliable and infuriating. Files: `Gestures.swift` 3. Test with sloppy input Twenty deliberately imprecise gestures. Careful gestures always work and tell you nothing. ### Done when - [ ] A three finger swipe fires exactly once across twenty sloppy attempts - [ ] A slow drift does not register as a swipe - [ ] System gestures are either untouched or the override is disclosed - [ ] Finger count is read from touches rather than inferred ### Watch out - Without a cooldown after recognition, the tail of a swipe re-triggers the same gesture, and the user gets two window moves from one flick. Lock out the same gesture for a short interval after it fires, and test with deliberately sloppy input rather than careful demonstrations. ## Phase 3 · Actions A recognised input runs something useful, including moving windows that belong to other applications. ### Steps 1. Send keystrokes without feedback loops Post events with a distinctive user data value and ignore that value in your tap callback. Files: `Actions/SendKeys.swift` 2. Move windows through the Accessibility API Get the focused window of the frontmost application and set its position and size attributes. Handle applications that refuse. Files: `Actions/Window.swift` 3. Run shell commands safely Off the main thread, with a hard timeout and the process group killed on expiry. Files: `Actions/Shell.swift` ### Done when - [ ] A keystroke action reaches the frontmost application - [ ] A window action moves a window belonging to another application - [ ] A hanging shell command is killed and the interface stays responsive - [ ] A remap sending its own trigger does not loop ### Watch out - Sending a keystroke from inside the event tap callback can feed straight back into your own tap and loop. Tag events you generate with a user data field and ignore them when they come back around, or the first remap you write will hang the machine. ## Phase 4 · Scoping and conflicts Rules apply where you meant them to, and two rules never silently fight. ### Steps 1. Implement specificity resolution Application scope beats global; priority breaks remaining ties. Write it once and test it with a table of cases. Files: `Resolver.swift` 2. Detect conflicts at save time Compare trigger and scope against existing rules. Show both rules and let the user choose. Files: `Conflicts.swift` 3. Add profiles A named active set. Useful the first time you plug into an external display. Files: `Profiles.swift` ### Done when - [ ] An application-scoped rule overrides a global one only in that application - [ ] A colliding rule is refused with both rules shown - [ ] Switching profiles changes the active set in one action - [ ] The resolver is covered by a table of test cases ### Watch out - Two rules on the same trigger in the same scope produce behaviour that depends on iteration order, which will change when you refactor. Detect the collision at save time and make the user resolve it. ## Phase 5 · Sharing configurations A rule set can be exported, read by a human and imported without breaking anything. ### Steps 1. Version the export format A schema version in the file. Import checks it and refuses a newer version rather than guessing. Files: `Export.swift` 2. Validate before applying Parse the whole file, validate every rule, then apply atomically. A half-applied import is worse than a refused one. Files: `Import.swift` 3. Preview shell actions prominently List every command that would be installed and require explicit approval. Files: `ImportPreview.swift` ### Done when - [ ] An exported set imports into a fresh installation and behaves the same - [ ] An unknown action kind is refused rather than partially applied - [ ] Shell actions are listed and separately approved on import - [ ] A failed import leaves the existing rules untouched ### Watch out - An imported rule that runs a shell command is arbitrary code from whoever wrote it. Show every shell action in the import preview and require explicit approval for those specifically, because a rule set is exactly the kind of file people share without reading. ## Not in this build - Broad input device coverage across stream decks, tablets and controllers. - The scripting depth accumulated over a decade. - The community preset library. - Guaranteed compatibility across macOS releases. ## After v1, if you want it - Window snapping to halves and quarters, which is the most requested action every time - A recording mode that captures a gesture and offers to bind it - Per-display profiles that switch when an external monitor is connected
# Copy to .env and fill in. Never commit .env; this file documents it. # Required. Where rules and profiles are stored. Version controlled if you want history. RULES_PATH=~/Library/Application Support/Input/rules.json # Required. Minimum travel in points before a swipe counts. Lower values cause accidental firing. GESTURE_MIN_DISTANCE=60 # Required. Lockout after a gesture fires, so one physical gesture cannot trigger twice. GESTURE_COOLDOWN_MS=350 # Required. How long a shell action may run before it is killed. SHELL_TIMEOUT_MS=3000 # Optional. Product phase only. SPARKLE_FEED_URL=https://example.com/appcast.xml
# BetterTouchTool · product brief ## Problem The core loop is small enough for a capable coding agent to produce a useful local version in one sitting. For BetterTouchTool, map trackpad, mouse, keyboard, and window gestures to explicit local actions. The hard boundary is huge input-device coverage, scripting depth, community presets, and os maintenance, plus os integration polish and maintenance. ## Product outcome An input remapper that consumes events reliably, survives the system disabling its tap, never fires one gesture twice, refuses conflicting rules at save time, and asks for its serious permission with an honest explanation. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - macOS 14 or later - Xcode - only the permissions required by the selected feature ## Explicit non-goals for v1 - Broad input device coverage across stream decks, tablets and controllers. - The scripting depth accumulated over a decade. - The community preset library. - Guaranteed compatibility across macOS releases. - huge input-device coverage, scripting depth, community presets, and OS maintenance - years of OS-version edge cases - large extension ecosystem - cross-device sync - commercial support and rapid compatibility fixes ## Success criteria - The tap watchdog is proved to recover from a deliberate timeout - Twenty sloppy gestures fire exactly twenty times - A remap sending its own trigger is confirmed not to loop - Import refuses an unknown action kind and lists shell actions separately - spctl accepts the notarized build on a Mac that never ran it
# Build brief · BetterTouchTool The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a macOS input remapper that turns trackpad gestures, mouse buttons and key combinations into actions you define to replace BetterTouchTool. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Stack (fixed, do not substitute) - Language: Swift 6 and SwiftUI in Xcode · input events, the Accessibility API and window management are all AppKit - App type: A background agent with LSUIElement true · it listens; it does not need a window most of the time - Input capture: A CGEvent tap for keyboard and mouse, NSEvent monitors for gestures · an event tap can consume an event, which a monitor cannot, and consuming is what makes a remap work - Window control: The Accessibility API against other applications · the only sanctioned way to move a window your process does not own - Storage: JSON in Application Support, versioned · rules are documents people will want to share, diff and back up ### Data model (create this before Phase 1) - triggers(id, kind, spec_json, app_scope) · kind is gesture, mouse_button, key_combo or window_event; app_scope is a bundle identifier or all - actions(id, kind, payload_json) · kind is send_keys, run_shell, move_window, launch_app, sequence or delay - rules(id, trigger_id, action_id, enabled, priority, notes) - profiles(id, name, active) · a named rule set, for example docked versus laptop - conflicts(rule_a, rule_b, scope) · computed, not stored by the user, and surfaced before a rule is saved - Rules resolve most specific first: an application-scoped rule beats a global one, and priority breaks the remaining ties. ### Phase 1 · An event tap that can consume Build: Request Accessibility, install a CGEvent tap for keyboard and mouse events, log what arrives, and prove you can swallow one specific combination so the frontmost application never sees it. Add a watchdog that re-enables the tap when the system disables it. Trap: macOS disables an event tap that takes too long to return, and it does so silently. Your remaps simply stop working with no error anywhere. Listen for the tap-disabled notification and re-enable, and never do slow work inside the callback: post it to another queue and return immediately. Done when: A chosen key combination is consumed and does not reach the frontmost application, verified in a text editor. Deliberately blocking the callback long enough to trip the timeout results in the tap being re-enabled automatically rather than staying dead. Denying Accessibility produces your own explanation rather than silence. Do not build yet: No actions, no gestures, no rules. One event observed and consumed. ### Phase 2 · Trackpad gestures Build: Capture gesture events and recognise three and four finger swipes, taps and pinches. Require a minimum distance and a maximum duration so a slow drift is not a swipe. Add hysteresis so one physical gesture never fires twice. Trap: Without a cooldown after recognition, the tail of a swipe re-triggers the same gesture, and the user gets two window moves from one flick. Lock out the same gesture for a short interval after it fires, and test with deliberately sloppy input rather than careful demonstrations. Done when: A three finger swipe fires once per physical gesture across twenty attempts, including deliberately sloppy ones. A slow two-inch drift with three fingers does not register as a swipe. Gestures that macOS itself uses are either left alone or are consciously overridden with that noted in the interface. Do not build yet: No actions bound yet, so recognition only logs. ### Phase 3 · Actions Build: Implement the action kinds: send a keystroke, run a shell command, move or resize the frontmost window, launch an application, and run a sequence with delays. Run shell commands off the main thread with a timeout. Trap: Sending a keystroke from inside the event tap callback can feed straight back into your own tap and loop. Tag events you generate with a user data field and ignore them when they come back around, or the first remap you write will hang the machine. Done when: A gesture sends a keystroke that the frontmost application receives. A gesture moves the frontmost window to the left half of the screen, including for an application you did not write. A shell action that hangs is killed at its timeout and the interface stays responsive. A remap that sends its own trigger does not loop. Do not build yet: No scoping or conflict detection yet. ### Phase 4 · Scoping and conflicts Build: Scope every rule to an application or to everything. Resolve most specific first. Detect conflicts when a rule is saved and show them, rather than letting the user discover that one of two rules mysteriously never fires. Trap: Two rules on the same trigger in the same scope produce behaviour that depends on iteration order, which will change when you refactor. Detect the collision at save time and make the user resolve it. Done when: An application-scoped rule overrides a global rule for the same trigger and the global rule still applies elsewhere. Saving a rule that collides with an existing one shows both and refuses to save silently. Switching profiles changes the active rule set in one action. Do not build yet: No import or export yet. ### Phase 5 · Sharing configurations Build: Export rules as versioned JSON. Import with validation, refusing anything referencing an unknown action kind rather than importing it half-working. Show what an import will change before applying it. Trap: An imported rule that runs a shell command is arbitrary code from whoever wrote it. Show every shell action in the import preview and require explicit approval for those specifically, because a rule set is exactly the kind of file people share without reading. Done when: Exported rules import into a fresh installation and behave identically. An import containing an unknown action kind is refused with a clear message rather than partially applied. Shell actions are listed individually in the preview and require separate approval. Do not build yet: Signing and distribution come next. ### Phase 6 · Signing, notarization and updates Build: Hardened runtime, Developer ID signing, notarization, stapling and Sparkle updates. Document the Accessibility requirement honestly, because you are asking for the permission that reads every keystroke. Trap: Signing invalidates the Accessibility grant that testers gave your unsigned build, and macOS will not prompt again. Put the removal instructions in the release notes. Done when: A notarized build opens on a Mac that never ran it with no Gatekeeper warning and requests Accessibility once. An appcast update installs and relaunches. The README states plainly what the permission allows the application to see. Do not build yet: Nothing after this. ### Out of scope (and why) - The breadth of input device support. Stream decks, drawing tablets, game controllers and specific mouse models are each their own integration, and there are dozens. - The scripting depth of the commercial product, which has accumulated hundreds of action types over a decade. - The community preset library, which is other people's work and cannot be written into existence. - Compatibility across major macOS releases. Event taps and the Accessibility API both shift, and that maintenance is now yours. ### README must contain - That Accessibility permits reading every keystroke, and exactly what this application does with them - That an event tap can be disabled by the system and how the watchdog handles it - The gesture thresholds and the cooldown, and where to change them - That imported rule sets can contain shell commands and are approved individually - That re-signing invalidates the Accessibility grant and how to clear the stale entry
# Architecture · BetterTouchTool ## Stack | Part | Choice | Why | | --- | --- | --- | | Language | Swift 6 and SwiftUI in Xcode | input events, the Accessibility API and window management are all AppKit | | App type | A background agent with LSUIElement true | it listens; it does not need a window most of the time | | Input capture | A CGEvent tap for keyboard and mouse, NSEvent monitors for gestures | an event tap can consume an event, which a monitor cannot, and consuming is what makes a remap work | | Window control | The Accessibility API against other applications | the only sanctioned way to move a window your process does not own | | Storage | JSON in Application Support, versioned | rules are documents people will want to share, diff and back up | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Tap | Capturing and consuming events, and staying alive. | Not swappable; the event tap is the only API that can consume. The watchdog is the part that must never be removed for simplicity. | | Gestures | Recognition, thresholds and hysteresis. | Pure logic over touch data and testable from recorded input. The three tuning numbers are the product's feel. | | Actions | Everything a rule can do. | Each kind is independent, so adding one touches nothing else. The loop guard on generated events applies to all of them. | | Resolver | Scope and priority resolution. | A pure function with a test table. Any rewrite must keep the specificity ordering. | | Config | Persistence, import and export. | JSON is a deliberate choice so rule sets are readable and diffable before they are imported. | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `RULES_PATH` · required · Where rules and profiles are stored. Version controlled if you want history. - `GESTURE_MIN_DISTANCE` · required · Minimum travel in points before a swipe counts. Lower values cause accidental firing. - `GESTURE_COOLDOWN_MS` · required · Lockout after a gesture fires, so one physical gesture cannot trigger twice. - `SHELL_TIMEOUT_MS` · required · How long a shell action may run before it is killed. - `SPARKLE_FEED_URL` · optional · Product phase only. ## Production baseline - Security: least privilege, input validation at every boundary, secret redaction in logs, rate limits on abuse-prone paths, no invented security primitives. - Data: explicit schema and migrations, transactional writes where integrity matters, backup and restore procedures that have been exercised. - Integrations: adapters around third-party providers, idempotent webhook or job processing, bounded retries, timeouts. - Observability: structured logs with request or operation ids, an error-tracking hook, and health and readiness checks where a server exists. - Quality: unit tests for domain rules, integration tests at module boundaries, one end-to-end test of the critical path. ## Decision records For each dependency in the stack table, keep a short note: why it was chosen, its failure mode, and how it is replaced. Do not add infrastructure until a requirement in `PRODUCT.md` justifies it.
# Agent instructions · BetterTouchTool product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: Swift 6 and SwiftUI in Xcode, A background agent with LSUIElement true, A CGEvent tap for keyboard and mouse, NSEvent monitors for gestures, The Accessibility API against other applications, JSON in Application Support, versioned. - Implement milestone by milestone from `MILESTONES.md`; keep each change reviewable and leave the application runnable at every commit. - Treat authentication, payments, encryption, imports, webhooks and destructive actions as high-risk boundaries when present. - Never invent cryptography or silently weaken a requirement to make a check pass. - Put every external service behind an interface with a deterministic fake for tests. - Add migrations and rollback or recovery notes for every persistent data change. - Log useful operational context without credentials, tokens, passwords or personal data. - Update documentation and run every check before completing a milestone. ## Known traps - macOS disables an event tap that takes too long to return, and it does so silently. Your remaps simply stop working with no error anywhere. Listen for the tap-disabled notification and re-enable, and never do slow work inside the callback: post it to another queue and return immediately. - Without a cooldown after recognition, the tail of a swipe re-triggers the same gesture, and the user gets two window moves from one flick. Lock out the same gesture for a short interval after it fires, and test with deliberately sloppy input rather than careful demonstrations. - Sending a keystroke from inside the event tap callback can feed straight back into your own tap and loop. Tag events you generate with a user data field and ignore them when they come back around, or the first remap you write will hang the machine. - Two rules on the same trigger in the same scope produce behaviour that depends on iteration order, which will change when you refactor. Detect the collision at save time and make the user resolve it. - An imported rule that runs a shell command is arbitrary code from whoever wrote it. Show every shell action in the import preview and require explicit approval for those specifically, because a rule set is exactly the kind of file people share without reading. - Signing invalidates the Accessibility grant that testers gave your unsigned build, and macOS will not prompt again. Put the removal instructions in the release notes.
# Delivery milestones · BetterTouchTool Estimated effort: **weekend** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · An event tap that can consume You can observe an input event and stop it reaching the application underneath. ### Steps 1. Request Accessibility and explain it first Check AXIsProcessTrustedWithOptions and show your own panel before the system prompt. This permission lets you read every keystroke, so say so. Files: `Permissions.swift` 2. Install the event tap CGEvent.tapCreate at the session level for keyboard and mouse events. Return nil from the callback to consume an event. Files: `EventTap.swift` 3. Add the disabled watchdog Handle tapDisabledByTimeout and tapDisabledByUserInput by re-enabling. Without this the application dies quietly under load. Files: `EventTap.swift` ### Done when - [ ] A chosen key combination never reaches a text editor - [ ] A deliberately slow callback trips the timeout and the tap is re-enabled - [ ] Refusing Accessibility shows an explanation and a working Settings link - [ ] The callback does no slow work on the tap thread ### Watch out - macOS disables an event tap that takes too long to return, and it does so silently. Your remaps simply stop working with no error anywhere. Listen for the tap-disabled notification and re-enable, and never do slow work inside the callback: post it to another queue and return immediately. ## M2 · Trackpad gestures Multi-finger gestures are recognised reliably and do not fire by accident. ### Steps 1. Capture gesture events NSEvent monitors for magnify, swipe and touches. Read touch phases directly for finger counts rather than trusting the swipe event alone. Files: `Gestures.swift` 2. Add thresholds and hysteresis Minimum distance, maximum duration, and a cooldown after firing. These three numbers are the difference between reliable and infuriating. Files: `Gestures.swift` 3. Test with sloppy input Twenty deliberately imprecise gestures. Careful gestures always work and tell you nothing. ### Done when - [ ] A three finger swipe fires exactly once across twenty sloppy attempts - [ ] A slow drift does not register as a swipe - [ ] System gestures are either untouched or the override is disclosed - [ ] Finger count is read from touches rather than inferred ### Watch out - Without a cooldown after recognition, the tail of a swipe re-triggers the same gesture, and the user gets two window moves from one flick. Lock out the same gesture for a short interval after it fires, and test with deliberately sloppy input rather than careful demonstrations. ## M3 · Actions A recognised input runs something useful, including moving windows that belong to other applications. ### Steps 1. Send keystrokes without feedback loops Post events with a distinctive user data value and ignore that value in your tap callback. Files: `Actions/SendKeys.swift` 2. Move windows through the Accessibility API Get the focused window of the frontmost application and set its position and size attributes. Handle applications that refuse. Files: `Actions/Window.swift` 3. Run shell commands safely Off the main thread, with a hard timeout and the process group killed on expiry. Files: `Actions/Shell.swift` ### Done when - [ ] A keystroke action reaches the frontmost application - [ ] A window action moves a window belonging to another application - [ ] A hanging shell command is killed and the interface stays responsive - [ ] A remap sending its own trigger does not loop ### Watch out - Sending a keystroke from inside the event tap callback can feed straight back into your own tap and loop. Tag events you generate with a user data field and ignore them when they come back around, or the first remap you write will hang the machine. ## M4 · Scoping and conflicts Rules apply where you meant them to, and two rules never silently fight. ### Steps 1. Implement specificity resolution Application scope beats global; priority breaks remaining ties. Write it once and test it with a table of cases. Files: `Resolver.swift` 2. Detect conflicts at save time Compare trigger and scope against existing rules. Show both rules and let the user choose. Files: `Conflicts.swift` 3. Add profiles A named active set. Useful the first time you plug into an external display. Files: `Profiles.swift` ### Done when - [ ] An application-scoped rule overrides a global one only in that application - [ ] A colliding rule is refused with both rules shown - [ ] Switching profiles changes the active set in one action - [ ] The resolver is covered by a table of test cases ### Watch out - Two rules on the same trigger in the same scope produce behaviour that depends on iteration order, which will change when you refactor. Detect the collision at save time and make the user resolve it. ## M5 · Sharing configurations A rule set can be exported, read by a human and imported without breaking anything. ### Steps 1. Version the export format A schema version in the file. Import checks it and refuses a newer version rather than guessing. Files: `Export.swift` 2. Validate before applying Parse the whole file, validate every rule, then apply atomically. A half-applied import is worse than a refused one. Files: `Import.swift` 3. Preview shell actions prominently List every command that would be installed and require explicit approval. Files: `ImportPreview.swift` ### Done when - [ ] An exported set imports into a fresh installation and behaves the same - [ ] An unknown action kind is refused rather than partially applied - [ ] Shell actions are listed and separately approved on import - [ ] A failed import leaves the existing rules untouched ### Watch out - An imported rule that runs a shell command is arbitrary code from whoever wrote it. Show every shell action in the import preview and require explicit approval for those specifically, because a rule set is exactly the kind of file people share without reading. ## M6 · Signing, notarization and updates (production only) Others can install it, and you can ship the fix the next macOS release will need. ### Steps 1. Sign and notarize Hardened runtime, Developer ID Application, notarytool, stapler. ```sh xcrun notarytool submit Input.zip --keychain-profile AC --wait xcrun stapler staple Input.app ``` 2. Ship Sparkle updates EdDSA key pair with the private half outside the repository, appcast over HTTPS. Files: `appcast.xml` 3. Be explicit about the permission One paragraph saying the application can observe every keystroke and what it does and does not do with them. Files: `README.md` ### Done when - [ ] spctl reports the notarized build accepted - [ ] A fresh Mac opens it with no Gatekeeper warning - [ ] An appcast update installs and relaunches - [ ] The README states what Accessibility permits ### Watch out - Signing invalidates the Accessibility grant that testers gave your unsigned build, and macOS will not prompt again. Put the removal instructions in the release notes.
# Operations · BetterTouchTool ## Backup The rules file is the entire configuration and is small enough to keep in a version controlled directory, which also gives you history when a change makes things worse. ## Restore Replace the rules file and relaunch. Rules referencing applications that are not installed should be skipped rather than treated as errors, so a restore onto a different machine degrades gracefully. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Log tap disable events. A rising count means something in the callback is too slow, and it is the failure that presents as the application mysteriously not working. ## Incident checklist A macOS update changing event tap or Accessibility behaviour is the recurring risk. Keep the previous signed build available and set a minimum supported version in the appcast. 1. Contain the issue without destroying evidence or user data. 2. Record the timeline and affected scope. 3. Rotate exposed secrets and revoke compromised sessions or credentials. 4. Restore from a verified backup when needed. 5. Document the root cause, the remediation and the regression test. ## Release gate - [ ] The tap watchdog is proved to recover from a deliberate timeout - [ ] Twenty sloppy gestures fire exactly twenty times - [ ] A remap sending its own trigger is confirmed not to loop - [ ] Import refuses an unknown action kind and lists shell actions separately - [ ] spctl accepts the notarized build on a Mac that never ran it ## Launch constraint Do not market omitted BetterTouchTool capabilities as implemented. The non-goals in `PRODUCT.md` remain user-visible limitations until they are deliberately delivered.
# Copy to .env and fill in. Never commit .env; this file documents it. # Required. Where rules and profiles are stored. Version controlled if you want history. RULES_PATH=~/Library/Application Support/Input/rules.json # Required. Minimum travel in points before a swipe counts. Lower values cause accidental firing. GESTURE_MIN_DISTANCE=60 # Required. Lockout after a gesture fires, so one physical gesture cannot trigger twice. GESTURE_COOLDOWN_MS=350 # Required. How long a shell action may run before it is killed. SHELL_TIMEOUT_MS=3000 # Optional. Product phase only. SPARKLE_FEED_URL=https://example.com/appcast.xml
$ choose a build depth, inspect the files, then open the complete pack in your agent
People still pay for BetterTouchTool because small utilities are technically reproducible, but customers pay for polish and immediate fixes when an OS update breaks a daily habit. The recurring cost buys permissions, accessibility APIs, sandboxing, signing, notarization, OS regressions, updates, backups, and support, not just the visible interface.
xhuge input-device coverage, scripting depth, community presets, and OS maintenance
xyears of OS-version edge cases
xlarge extension ecosystem
xcross-device sync
xcommercial support and rapid compatibility fixes
BetterTouchTool pricing
| plan | monthly | annual (per mo) | what you get |
|---|---|---|---|
| standard licence | custom | — | 1 user on all personally owned Macs; 2 years of updates, then the last eligible version keeps working. |
| lifetime licence | custom | — | 1 user on all personally owned Macs; lifetime BetterTouchTool updates and other folivora macOS apps released within the next 2 years. |
free tierno free tier
billingone-time purchase; no monthly or annual plan; trial available
hidden costsThe $15 licence stops receiving updates after 2 years; taxes and country-specific pricing may change the checkout total.
verified 2026-08-14 · source ↗
Vibecode BetterTouchTool
Yes. A competent AI coding agent (Claude Code, Codex, Cursor) can build a usable personal BetterTouchTool replacement in one session with the prompt on this page. It runs on your own machine or server with no subscription.
How much does BetterTouchTool cost?
BetterTouchTool's pricing is usage-based or varies by plan · Sold as a time-limited or lifetime one-time licence; canonical monthly amount is null..
What do I lose by replacing BetterTouchTool?
Honestly: huge input-device coverage, scripting depth, community presets, and OS maintenance; years of OS-version edge cases; large extension ecosystem; cross-device sync; commercial support and rapid compatibility fixes. If any of those are load-bearing for you, keep paying.
Is there an open-source alternative to BetterTouchTool?
Yes: Espanso (Active open-source cross-platform text expansion utility with system integration.). Using prior art is also vibecoding; the prompt is for when you want it exactly your way.