{"id":50721290,"url":"https://github.com/scottlamb/macos-trackpad-companion","last_synced_at":"2026-06-10T00:01:49.299Z","repository":{"id":361955309,"uuid":"1226143402","full_name":"scottlamb/macos-trackpad-companion","owner":"scottlamb","description":null,"archived":false,"fork":false,"pushed_at":"2026-06-02T00:03:56.000Z","size":554,"stargazers_count":0,"open_issues_count":0,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-06-02T02:08:43.151Z","etag":null,"topics":[],"latest_commit_sha":null,"homepage":null,"language":"Rust","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":null,"status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/scottlamb.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":null,"code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-05-01T02:52:41.000Z","updated_at":"2026-06-02T00:04:00.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/scottlamb/macos-trackpad-companion","commit_stats":null,"previous_names":["scottlamb/macos-trackpad-companion"],"tags_count":null,"template":false,"template_full_name":null,"purl":"pkg:github/scottlamb/macos-trackpad-companion","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/scottlamb%2Fmacos-trackpad-companion","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/scottlamb%2Fmacos-trackpad-companion/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/scottlamb%2Fmacos-trackpad-companion/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/scottlamb%2Fmacos-trackpad-companion/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/scottlamb","download_url":"https://codeload.github.com/scottlamb/macos-trackpad-companion/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/scottlamb%2Fmacos-trackpad-companion/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":34130642,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-06-09T02:00:06.510Z","response_time":63,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":[],"created_at":"2026-06-10T00:01:49.063Z","updated_at":"2026-06-10T00:01:49.289Z","avatar_url":"https://github.com/scottlamb.png","language":"Rust","funding_links":[],"categories":[],"sub_categories":[],"readme":"# macos-trackpad-companion\n\nUserspace bridge from a PTP (Microsoft Precision Touchpad / Windows\nPrecision Touchpad) HID device to native macOS gesture events. Reads\ntouch frames from any matched PTP digitizer, runs them through a gesture\nstate machine, and posts CGEvents — cursor, click, phased smooth scroll,\nand (via private CGEvent gesture types) pinch, rotate, 3-finger swipe.\n\nLinux and Windows handle PTP devices natively; this companion exists\nbecause macOS has no built-in PTP consumer. macOS does have similar\nsupport for Apple's own Magic Trackpads, but their driver will only\ntalk to USB devices using Apple's USB VID.\n\n**Prototype-quality**, vibe-coded. The code is gross. But it works decently\nwell for me and has served as a base for refining gesture recognition. It has\ntests inspired by real usage logs. Eventually I hope to distill what I've\nlearned into a nice spec and develop a high-quality codebase from it.\n\n## Build \u0026 run\n\n```sh\ncd companion\ncargo build --release\n./target/release/companion -v\n```\n\nCLI flags (intentionally tiny — everything else lives in the config file):\n\n| Flag | Default | Meaning |\n| --- | --- | --- |\n| `--config PATH` | XDG default | TOML config path. See **Configuration** below. |\n| `-v`, `-vv` | info | Increase log level. Overrides `[log].level` from the file. |\n\n## Configuration\n\nAll tuning lives in a TOML file at\n`$XDG_CONFIG_HOME/macos-trackpad-companion/config.toml`, falling back to\n`~/.config/macos-trackpad-companion/config.toml` when `XDG_CONFIG_HOME`\nis unset. A missing file is fine — defaults take over. Unknown keys are\nrejected so typos surface at startup.\n\n```toml\n[device]                    # optional — match a specific USB device\n# vid = 0x1234              #   (omit either field for any PTP digitizer)\n# pid = 0x5678\n\n[log]\nlevel = \"info\"              # error | warn | info | debug | trace\n# file  = \"~/Library/Logs/macos-trackpad-companion.log\"\n                            # if set, logs are appended here instead of stderr;\n                            # `~/` is expanded and parent dirs are created.\n\n[cursor]\nsensitivity   = 25.0        # px per mm of finger motion at accel_ref\naccel_exponent = 1.0        # 1.0 = linear; \u003e1 boosts fast flicks\naccel_ref     = 80.0        # mm/s — velocity at which sensitivity is the linear feel\n\n[scroll]\nsensitivity = 20.0          # px per mm\nnatural     = true          # finger-down → content-down (macOS default since 10.7)\n\n# Each gesture has an `enable` key with three forms:\n#   enable = \"on\"                                  # always\n#   enable = \"off\"                                 # never\n#   enable = { only   = [\"com.apple.Safari\"] }     # frontmost-app allowlist\n#   enable = { except = [\"com.apple.Terminal\"] }   # frontmost-app denylist\n# `only` and `except` are mutually exclusive. Bundle IDs are matched\n# against the app owning the topmost normal window under the cursor,\n# sampled at gesture *start* and held for the rest of the touch (so a\n# mid-gesture window switch can't kill its own gesture). Under-cursor\n# rather than frontmost because that's how macOS itself routes\n# pinch/rotate/scroll/click — Mission Control / Spaces 3F/4F swipes\n# are system-wide and ignore window targeting, but the same filter\n# still expresses \"don't fire this gesture when my cursor is parked\n# over Terminal.\"\n#\n# To learn the bundle ID for an app, any of these work:\n#   osascript -e 'id of app \"Safari\"'                       # by user-facing name\n#   mdls -name kMDItemCFBundleIdentifier -r /Applications/Safari.app\n#   lsappinfo info -only bundleid -app Safari               # currently running\n#   lsappinfo info -only bundleid `lsappinfo front`         # whatever is frontmost now\n\n[gestures.pinch]\nenable = \"on\"\n\n[gestures.rotate]\nenable = \"on\"\n\n[gestures.swipe.horizontal]   # left/right 3F/4F → Spaces / Full-Screen Apps\nenable  = \"on\"\nbackend = \"synthetic\"         # synthetic | notification | off\n                              #   (notification is silently `off` on this axis —\n                              #    no Dock notification exists for switching spaces)\n\n[gestures.swipe.vertical]     # up/down 3F/4F → Mission Control / App Exposé\nenable  = \"on\"\nbackend = \"synthetic\"\n```\n\n## Permissions\n\nThe first run on a fresh macOS install will prompt for two privacy\npermissions; without them the companion exits with an actionable error.\n\n- **Input Monitoring** — required to read raw HID input reports from\n  the trackpad. macOS surfaces error `0xE00002C5` from `IOHIDManagerOpen`\n  if this isn't granted.\n- **Accessibility** — required to post synthetic CGEvents (cursor moves,\n  clicks, scroll, gestures). Granted via System Settings → Privacy \u0026\n  Security → Accessibility.\n\n## Reading the logs\n\nWhen a two-finger gesture locks (the moment the companion commits to\neither scroll or pinch+rotate), an `INFO` line records all three\ncandidate scores and the geometric inputs that drove the choice:\n\n```\n2F lock=pinch+rotate scores[pinch=1.56 rot=1.35 pan=0.42 disq:margin] common=0.42mm diff=1.45mm align=0.62 balance=0.45\n```\n\nA score `≥ 1.00` means that signal crossed its lock threshold. Pan is\nmutually exclusive with pinch+rotate and only wins if it both crosses\n*and* dominates; otherwise the pair locks.\n\nTags after a score say *why* it didn't compete:\n\n| Tag | Meaning |\n| --- | --- |\n| `disq:margin` | Pan: centroid translation didn't beat differential motion by 20% — most of the motion is asymmetric, not translational. |\n| `disq:participation` | Pan: margin OK, but neither finger balance (slower ≥ 30% of faster) nor alignment (motion vectors near-parallel) qualified. |\n| `gated:noise` | Pinch/rot: one finger sat in the 0.3–1.0 mm noise band where differential signal is dominated by jitter; lock deferred. |\n| `gated:policy` | Pinch/rot: the under-cursor app's `enable` policy blocked this gesture, so the score was zeroed for selection. |\n\nTrailing fields:\n\n- `common` — magnitude of the shared (centroid) translation in mm.\n- `diff` — magnitude of the per-finger differential motion in mm.\n- `align` — cosine of the angle between the two fingers' motion vectors. ~1.0 = parallel, 0 = perpendicular, \u003c0 = anti-parallel.\n- `balance` — slower finger's motion / faster finger's motion. 1.0 = symmetric, 0 = one finger anchored.\n\nFor the contrasting case, `2F lock=scroll` uses the same format with\n`pan` first.\n\n## Wire-format contract\n\nThe companion parses the device's HID report descriptor at runtime, so\nfirmware is free to choose VID/PID, contact count, and physical/logical\ncoordinate scale. To remain compatible:\n\n- Expose a Digitizer Application Collection at usage page `0x0D`,\n  usage `0x05` (Touch Pad).\n- Inside it, declare N nested Logical collections of usage page `0x0D`,\n  usage `0x22` (Finger). Each finger collection must input these fields,\n  in this order, with these sizes:\n  - Confidence — Digitizer 0x47 — 1 bit\n  - Tip Switch — Digitizer 0x42 — 1 bit\n  - 6 bits padding (so the contact-id falls on a byte boundary)\n  - Contact Identifier — Digitizer 0x51 — 8 bits\n  - X — Generic Desktop 0x30 — 16 bits. Set Logical Max to your\n    coordinate space *and* Physical Max + Unit + Unit Exponent so the\n    companion can derive mm/pixel. SI Linear cm (Unit `0x11`) and\n    English Linear inches (Unit `0x13`) are both supported. Without\n    physical units, descriptor parse fails (gesture thresholds and\n    cursor sensitivity are expressed in mm).\n  - Y — Generic Desktop 0x31 — 16 bits, same\n- After the finger collections, declare:\n  - Scan Time — Digitizer 0x56 — 16 bits (100 µs ticks per spec)\n  - Contact Count — Digitizer 0x54 — 8 bits\n  - Button 1 — Button 0x01 — 1 bit (then 7 bits padding)\n\nThis produces a **6-byte-per-contact** layout. The companion's\n`Layout::validate` rejects anything else; if you change the per-contact\nfield set, update both ends.\n\nThe Microsoft \"PTPHQA\" feature report is needed for Windows certification\nbut ignored by macOS, so it's optional from the companion's perspective.\n\n## Reference firmware\n\nA working PTP firmware lives at commit `7f3ee1c:firmware/src/main.rs` in\nthis repo. It produces a composite USB device:\n\n- Interface 0 — boot Mouse (gives macOS a working cursor before the\n  companion is running, and is a sane fallback everywhere)\n- Interface 1 — PTP digitizer (5 contacts, 65×40 mm, logical 3936×2424,\n  PTPHQA blob, all four feature reports)\n\nThe companion's unit test\n`descriptor::tests::parses_wpt_descriptor` reproduces the bytes that\n7f3ee1c emits. That descriptor is the canonical \"this works\" reference.\n\nDon't put the Mouse collection in the *same* HID interface as the\ndigitizer — macOS can route by primary usage and bind a different driver\nthat intercepts cursor before the digitizer becomes visible. Keep them\non separate interfaces.\n\n## Module map\n\n| File | Responsibility |\n| --- | --- |\n| `descriptor.rs` | Walks a HID report descriptor and extracts the touch-report `Layout` (contact count, X/Y max, field offsets). |\n| `report.rs` | Decodes one input-report buffer into a `Frame` of normalized contacts. |\n| `gesture.rs` | Pure state machine — classifies 1F/2F/3F/4F gestures, locks 2F mode on first significant motion. Tested without I/O. |\n| `output.rs` | macOS event synthesis. Public CGEvent for cursor/click/scroll, private CGEvent type/field IDs for pinch/rotate/swipe. |\n| `hid.rs` | IOHIDManager FFI: device matching, descriptor + input-report subscription, run-loop pumping. |\n| `main.rs` | CLI parsing, logging, wiring. |\n\n## Caveats\n\n- **Private CGEvent gesture types are reverse-engineered.** Pinch,\n  rotate, and swipe injection use undocumented CGEvent types (18, 19,\n  20, 30, 31) and field IDs (110, 113, 115, 132). These are stable on\n  recent macOS versions and used by BetterTouchTool, Karabiner-Elements,\n  and similar tools — but they're not in any public Apple header and\n  could break on a future macOS update. Pass `--no-private-gestures` to\n  disable them; cursor / click / phased scroll all use public CGEvent\n  APIs and won't be affected.\n- **Two-finger ambiguity is resolved by first-significant-motion lock.**\n  Once the centroid moves, the distance changes by 4%, or the angle\n  changes by 6°, that mode wins for the duration of the touch. The\n  thresholds in `gesture.rs` may need tuning once we have hardware.\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fscottlamb%2Fmacos-trackpad-companion","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fscottlamb%2Fmacos-trackpad-companion","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fscottlamb%2Fmacos-trackpad-companion/lists"}