Repository: Caldis/Mos Branch: master Commit: 757f3509c126 Files: 259 Total size: 2.6 MB Directory structure: gitextract_eih3fj_1/ ├── .github/ │ └── ISSUE_TEMPLATE/ │ ├── bug-report-------.md │ └── feature-request-------.md ├── .gitignore ├── .last-release-commit ├── .skills/ │ └── release-preparation/ │ ├── SKILL.md │ └── scripts/ │ ├── create_gh_draft.sh │ ├── prepare_zip.sh │ └── update_appcast.sh ├── CHANGELOG.md ├── CLAUDE.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── ISSUE_TEMPLATE.md ├── LICENSE ├── LOCALIZATION.md ├── Mos/ │ ├── AppDelegate.swift │ ├── Assets.xcassets/ │ │ ├── AppIcon.appiconset/ │ │ │ └── Contents.json │ │ ├── AppStatusBarIcon.imageset/ │ │ │ └── Contents.json │ │ ├── Contents.json │ │ ├── Donate/ │ │ │ ├── AlipayLogo.imageset/ │ │ │ │ └── Contents.json │ │ │ ├── AlipayQR.imageset/ │ │ │ │ └── Contents.json │ │ │ ├── BuyMeACoffee.imageset/ │ │ │ │ └── Contents.json │ │ │ ├── Contents.json │ │ │ ├── MEOW.imageset/ │ │ │ │ └── Contents.json │ │ │ ├── PaypalLogo.imageset/ │ │ │ │ └── Contents.json │ │ │ ├── WechatpayLogo.imageset/ │ │ │ │ └── Contents.json │ │ │ └── WechatpayQR.imageset/ │ │ │ └── Contents.json │ │ ├── Guidance/ │ │ │ ├── Contents.json │ │ │ ├── drag-arrow.imageset/ │ │ │ │ └── Contents.json │ │ │ ├── manual-remove.imageset/ │ │ │ │ └── Contents.json │ │ │ └── sparkle.imageset/ │ │ │ └── Contents.json │ │ ├── Monitor/ │ │ │ ├── Contents.json │ │ │ └── SF.arrow.clockwise.imageset/ │ │ │ └── Contents.json │ │ ├── Preferences/ │ │ │ ├── Contents.json │ │ │ ├── SF.app.background.dotted.imageset/ │ │ │ │ └── Contents.json │ │ │ ├── SF.arrow.triangle.capsulepath.imageset/ │ │ │ │ └── Contents.json │ │ │ ├── SF.button.horizontal.top.press.imageset/ │ │ │ │ └── Contents.json │ │ │ ├── SF.circle.dotted.circle.imageset/ │ │ │ │ └── Contents.json │ │ │ ├── SF.ellipsis.circle.imageset/ │ │ │ │ └── Contents.json │ │ │ ├── SF.gearshape.imageset/ │ │ │ │ └── Contents.json │ │ │ ├── SF.lasso.badge.sparkles.imageset/ │ │ │ │ └── Contents.json │ │ │ ├── SF.minus.imageset/ │ │ │ │ └── Contents.json │ │ │ ├── SF.person.crop.circle.dashed.imageset/ │ │ │ │ └── Contents.json │ │ │ ├── SF.plus.imageset/ │ │ │ │ └── Contents.json │ │ │ ├── SF.scribble.imageset/ │ │ │ │ └── Contents.json │ │ │ ├── SF.tray.imageset/ │ │ │ │ └── Contents.json │ │ │ └── SF.wand.and.rays.inverse.imageset/ │ │ │ └── Contents.json │ │ └── StatusItem/ │ │ ├── Contents.json │ │ ├── SF.escape.imageset/ │ │ │ └── Contents.json │ │ ├── SF.gauge.imageset/ │ │ │ └── Contents.json │ │ ├── SF.hidpp.imageset/ │ │ │ └── Contents.json │ │ └── SF.square.stack.3d.down.right.imageset/ │ │ └── Contents.json │ ├── Base.lproj/ │ │ └── Main.storyboard │ ├── ButtonCore/ │ │ ├── ButtonCore.swift │ │ ├── ButtonFilter.swift │ │ └── ButtonUtils.swift │ ├── Components/ │ │ ├── AdaptivePopover.swift │ │ ├── BrandTag.swift │ │ ├── DraggingImageView.swift │ │ └── PrimaryButton.swift │ ├── Extension/ │ │ ├── CGEvent+Extensions.swift │ │ └── NSColor+Extensions.swift │ ├── Info.plist │ ├── InputEvent/ │ │ ├── MosInputEvent.swift │ │ └── MosInputProcessor.swift │ ├── Keys/ │ │ ├── KeyCode.swift │ │ ├── KeyPopover.swift │ │ ├── KeyPreview.swift │ │ └── KeyRecorder.swift │ ├── Localizable.xcstrings │ ├── LogitechHID/ │ │ ├── LogitechDeviceSession.swift │ │ ├── LogitechHIDDebugPanel.swift │ │ └── LogitechHIDManager.swift │ ├── Managers/ │ │ ├── StatusItemManager.swift │ │ ├── UpdateManager.swift │ │ └── WindowManager.swift │ ├── MosRelease.entitlements │ ├── Options/ │ │ └── Options.swift │ ├── ScrollCore/ │ │ ├── Interpolator.swift │ │ ├── ScrollCore.swift │ │ ├── ScrollDispatchContext.swift │ │ ├── ScrollEvent.swift │ │ ├── ScrollFilter.swift │ │ ├── ScrollPhase.swift │ │ ├── ScrollPoster.swift │ │ └── ScrollUtils.swift │ ├── Shortcut/ │ │ ├── ShortcutExecutor.swift │ │ ├── ShortcutManager.swift │ │ └── SystemShortcut.swift │ ├── Utils/ │ │ ├── Archieve.swift │ │ ├── Constants.swift │ │ ├── EnhanceArray.swift │ │ ├── Interceptor.swift │ │ └── Utils.swift │ ├── Windows/ │ │ ├── IntroductionWindow/ │ │ │ ├── IntroductionViewController.swift │ │ │ └── IntroductionWindowController.swift │ │ ├── MonitorWindow/ │ │ │ ├── Logger.swift │ │ │ ├── MonitorViewController.swift │ │ │ └── MonitorWindowController.swift │ │ ├── PreferencesWindow/ │ │ │ ├── AboutView/ │ │ │ │ ├── PreferencesAboutViewController.swift │ │ │ │ ├── PreferencesContributorsViewController.swift │ │ │ │ └── PreferencesDonateViewController.swift │ │ │ ├── ApplicationView/ │ │ │ │ ├── Application.swift │ │ │ │ └── PreferencesApplicationViewController.swift │ │ │ ├── ButtonsView/ │ │ │ │ ├── ButtonTableCellView.swift │ │ │ │ ├── PreferencesButtonsViewController.swift │ │ │ │ └── RecordedEvent.swift │ │ │ ├── GeneralView/ │ │ │ │ └── PreferencesGeneralViewController.swift │ │ │ ├── PreferencesTabViewController.swift │ │ │ ├── PreferencesWindowController.swift │ │ │ ├── ScrollingView/ │ │ │ │ ├── PreferencesScrollingViewController.swift │ │ │ │ ├── PreferencesScrollingWithApplicationViewController.swift │ │ │ │ ├── ScrollOptionsContextProviding.swift │ │ │ │ ├── ScrollReverseDetailSettingsPopoverViewController.swift │ │ │ │ └── ScrollSmoothDetailSettingsPopoverViewController.swift │ │ │ └── UpdateView/ │ │ │ └── PreferencesUpdatesViewController.swift │ │ └── WelcomeWindow/ │ │ ├── WelcomeViewController.swift │ │ └── WelcomeWindowController.swift │ └── mul.lproj/ │ └── Main.xcstrings ├── Mos.xcodeproj/ │ ├── project.pbxproj │ ├── project.xcworkspace/ │ │ ├── contents.xcworkspacedata │ │ └── xcshareddata/ │ │ ├── IDEWorkspaceChecks.plist │ │ ├── WorkspaceSettings.xcsettings │ │ └── swiftpm/ │ │ └── Package.resolved │ └── xcshareddata/ │ └── xcschemes/ │ ├── Debug.xcscheme │ └── Profile.xcscheme ├── README.enUS.md ├── README.id.md ├── README.md ├── README.ru.md ├── Resource/ │ ├── Icon/ │ │ └── SFIcon/ │ │ └── IconGrops.sketch │ └── designs.sketch ├── dmg/ │ ├── README.md │ ├── archive/ │ │ ├── Intro.psd │ │ └── dmgBackground.psd │ └── create-dmg.command ├── docs/ │ ├── .nojekyll │ ├── 404/ │ │ └── index.html │ ├── 404.html │ ├── CNAME │ ├── __next.__PAGE__.txt │ ├── __next._full.txt │ ├── __next._head.txt │ ├── __next._index.txt │ ├── __next._tree.txt │ ├── _next/ │ │ └── static/ │ │ ├── 4xlWUdmYkOQZmVpTO2yEI/ │ │ │ ├── _buildManifest.js │ │ │ ├── _clientMiddlewareManifest.json │ │ │ └── _ssgManifest.js │ │ └── chunks/ │ │ ├── 12c422bc11762090.js │ │ ├── 19717b1d18b1c046.js │ │ ├── 2f0565a65fa02d97.js │ │ ├── 543732a8392562b4.css │ │ ├── a6dad97d9634a72d.js │ │ ├── b4611851359555bd.js │ │ ├── b492b32695e3b282.js │ │ ├── c62ecb7f1a587007.js │ │ ├── d95bda38d1a9ce53.js │ │ ├── f1a98b44d1dc31f1.js │ │ └── turbopack-98aeb181aa636c3c.js │ ├── _not-found/ │ │ ├── __next._full.txt │ │ ├── __next._head.txt │ │ ├── __next._index.txt │ │ ├── __next._not-found.__PAGE__.txt │ │ ├── __next._not-found.txt │ │ ├── __next._tree.txt │ │ ├── index.html │ │ └── index.txt │ ├── appcast.xml │ ├── index.html │ ├── index.txt │ ├── llms.txt │ ├── release-notes/ │ │ ├── 4.0.0-beta-20260108.1.en.html │ │ ├── 4.0.0-beta-20260108.1.zh.html │ │ ├── 4.0.0-beta-20260201.1.en.html │ │ ├── 4.0.0-beta-20260201.1.md │ │ ├── 4.0.0-beta-20260201.1.zh.html │ │ ├── 4.0.0-beta-20260203.1.en.html │ │ ├── 4.0.0-beta-20260203.1.zh.html │ │ ├── beta.en.html │ │ └── beta.zh.html │ ├── robots.txt │ ├── sitemap.xml │ └── superpowers/ │ ├── plans/ │ │ └── 2026-03-16-logitech-hid-integration.md │ └── specs/ │ └── 2026-03-16-logitech-hid-integration-design.md ├── tools/ │ ├── hidpp-divert-debug.swift │ ├── hidpp-divert-fix.swift │ ├── hidpp-full-test.swift │ ├── hidpp-probe.swift │ ├── hidpp-undivert-test.swift │ └── hidpp-verify-final.swift └── website/ ├── .nvmrc ├── CNAME ├── README.md ├── app/ │ ├── components/ │ │ ├── BentoCard/ │ │ │ └── BentoCard.tsx │ │ ├── CopyButton/ │ │ │ └── CopyButton.tsx │ │ ├── EasingPlayground/ │ │ │ └── EasingPlayground.tsx │ │ ├── FlowField/ │ │ │ └── FlowField.tsx │ │ ├── HeroCurvePanel/ │ │ │ └── HeroCurvePanel.tsx │ │ ├── LanguageSelector/ │ │ │ └── LanguageSelector.tsx │ │ ├── Magnetic/ │ │ │ └── Magnetic.tsx │ │ ├── Modal/ │ │ │ ├── Modal.tsx │ │ │ └── hooks.ts │ │ └── Reveal/ │ │ └── Reveal.tsx │ ├── globals.css │ ├── home-client.tsx │ ├── i18n/ │ │ ├── context.tsx │ │ ├── de.ts │ │ ├── el.ts │ │ ├── en.ts │ │ ├── format.ts │ │ ├── id.ts │ │ ├── ja.ts │ │ ├── ko.ts │ │ ├── pl.ts │ │ ├── ru.ts │ │ ├── tr.ts │ │ ├── uk.ts │ │ ├── zh-Hant.ts │ │ └── zh.ts │ ├── layout.tsx │ ├── page.tsx │ ├── providers.tsx │ ├── robots.ts │ ├── services/ │ │ ├── github.ts │ │ └── utils.ts │ ├── site.ts │ └── sitemap.ts ├── archive/ │ ├── v0/ │ │ ├── index.css │ │ ├── index.html │ │ ├── index.js │ │ ├── indexCN.html │ │ └── reset.css │ └── v1/ │ ├── assets/ │ │ └── fonts/ │ │ └── ubuntu/ │ │ └── font.css │ ├── index.html │ ├── index.template.html │ ├── package.json │ ├── src/ │ │ ├── i18n/ │ │ │ ├── image.js │ │ │ └── text.js │ │ ├── index.css │ │ ├── index.js │ │ ├── parallaxButton/ │ │ │ ├── index.css │ │ │ └── index.js │ │ ├── releases/ │ │ │ └── index.js │ │ └── reset.css │ └── webpack.config.js ├── backup/ │ └── openclaw-memory-2026-02-11-0132.tgz ├── docs/ │ └── plans/ │ ├── 2026-02-25-taste-redesign-design.md │ └── 2026-02-25-taste-redesign.md ├── eslint.config.mjs ├── next-env.d.ts ├── next.config.ts ├── package.json ├── pnpm-workspace.yaml ├── postcss.config.mjs ├── public/ │ └── llms.txt ├── scripts/ │ └── publish-docs.sh ├── tailwind.config.ts ├── tsconfig.json └── tsconfig.tsbuildinfo ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/ISSUE_TEMPLATE/bug-report-------.md ================================================ --- name: Bug report | 故障报告 about: To help us improve | 帮助我们变得更好 title: '' labels: '' assignees: '' --- **Describe the bug | 问题描述** **To Reproduce | 如何重现** **Expected behavior | 期望结果** **Screenshots | 相关截图** **System Info | 系统信息:** - Mouse: [e.g. Logitech MX Master] - OS: [e.g. iOS] - Browser [e.g. chrome, safari] - Version [e.g. v69] - Application [e.g. Safari, Chrome, Whole System] **Additional context | 额外说明** ================================================ FILE: .github/ISSUE_TEMPLATE/feature-request-------.md ================================================ --- name: Feature request | 功能请求 about: Suggest an idea for this project | 提出你的新点子 title: '' labels: '' assignees: '' --- **这个问题是否源自某个缺陷?如果是,可于此处描述一下 | Is your feature request related to a problem? Please describe.** **说说你的新点子 | Describe the solution you'd like** **还有别的替代方案吗? | Describe alternatives you've considered** **额外说明 | Additional context** ================================================ FILE: .gitignore ================================================ # docs website/.idea website/.next website/node_modules website/out # macOS .DS_Store # XCode xcuserdata *.xcuserstate *.xcuserdated # Sparkle signing keys (keep private!) sparkle_private_key.txt # build build/*.zip /build /spec ================================================ FILE: .last-release-commit ================================================ 428607d00ab65498e548b2d942bf675069cf40a2 ================================================ FILE: .skills/release-preparation/SKILL.md ================================================ --- name: release-preparation description: Use when preparing a new Mos release (stable, beta, or alpha) - covers version bump, xcodebuild archive, Developer ID signing, notarization, zip packaging, changelog generation, Sparkle appcast signing, and GitHub release draft creation. Trigger this skill whenever the user mentions releasing, publishing, shipping a new version, bumping version, updating appcast, or creating a release build of Mos. --- # Release Preparation Full release pipeline: bump version → build → sign → notarize → package zip → generate changelog → sign appcast → create GH draft. ## Inputs | Input | Source | |-------|--------| | Mos.app | Built via xcodebuild archive + Developer ID export (Step 0) | | Version / Build | `MARKETING_VERSION` / `CURRENT_PROJECT_VERSION` in `Mos.xcodeproj/project.pbxproj` | | Channel | User specifies: `stable`, `beta`, or `alpha` | | Signing key | macOS Keychain — Apple Developer ID (code signing) + Sparkle EdDSA (appcast) | | Notarization | macOS Keychain profile `notarytool` (stores Apple ID + app-specific password) | ## Flow ```dot digraph release { rankdir=TB; node [shape=box]; "0. Build, sign & notarize" -> "1. Package zip"; "1. Package zip" -> "2. Generate changelog"; "2. Generate changelog" -> "3. Interactive confirm"; "3. Interactive confirm" -> "4. Sign & update appcast"; "4. Sign & update appcast" -> "5. Commit appcast"; "5. Commit appcast" -> "6. Create GH draft"; "6. Create GH draft" -> "7. Verify"; "7. Verify" -> "8. User publishes + git push"; } ``` ### Step 0: Build, Sign & Notarize 1. **Bump version** in `Mos.xcodeproj/project.pbxproj`: - `MARKETING_VERSION` — e.g., `4.0.2` (appears twice, use `replace_all`) - `CURRENT_PROJECT_VERSION` — build number in `YYYYMMDD.N` format (appears twice, use `replace_all`). **CRITICAL**: Every release MUST have a unique `CURRENT_PROJECT_VERSION`. Sparkle uses this value (`sparkle:version`) to detect updates — if two releases share the same build number, users on the older version will never see the update. Always bump this even for hotfix releases. - Commit the version bump. 2. **Archive**: ```bash xcodebuild archive \ -scheme Debug \ -project Mos.xcodeproj \ -configuration Release \ -archivePath /tmp/Mos.xcarchive ``` Note: The scheme is named "Debug" but `-configuration Release` ensures Release build settings. 3. **Export with Developer ID** (Direct Distribution): ```bash cat > /tmp/ExportOptions.plist << 'PLIST' methoddeveloper-id teamIDN7Z52F27XK signingStyleautomatic PLIST xcodebuild -exportArchive \ -archivePath /tmp/Mos.xcarchive \ -exportOptionsPlist /tmp/ExportOptions.plist \ -exportPath /tmp/MosExport \ -allowProvisioningUpdates ``` If export fails with "network connection was lost", retry — Apple's notarization service can be flaky. 4. **Notarize** (`xcodebuild -exportArchive` does NOT auto-notarize via CLI — must always do manually): ```bash # Note: this zip is only for notarization submission, not for distribution. # AppleDouble flags (--norsrc --noextattr) are not needed here — only in prepare_zip.sh for the release zip. ditto -c -k --keepParent /tmp/MosExport/Mos.app /tmp/Mos-notarize.zip xcrun notarytool submit /tmp/Mos-notarize.zip --keychain-profile "notarytool" --wait xcrun stapler staple /tmp/MosExport/Mos.app ``` 5. **Verify**: ```bash codesign -dvv /tmp/MosExport/Mos.app 2>&1 | grep Authority # Should show: Developer ID Application: BIAO CHEN (N7Z52F27XK) spctl --assess --type execute --verbose /tmp/MosExport/Mos.app # Should show: accepted, source=Notarized Developer ID ``` The notarized app at `/tmp/MosExport/Mos.app` is used for Step 1. ### Step 1: Package Zip ```bash bash .skills/release-preparation/scripts/prepare_zip.sh /tmp/MosExport/Mos.app [--channel beta] ``` Returns JSON with `zip_path`, `version`, `build`, `tag`, `zip_name`, `length`. **IMPORTANT — AppleDouble / Gatekeeper**: The script uses `ditto -c -k --norsrc --noextattr --keepParent` to create the zip. The `--norsrc --noextattr` flags are **mandatory** — without them, `ditto` serializes macOS extended attributes as AppleDouble (`._*`) files inside the zip. When users extract via Finder/Archive Utility (not `ditto -x -k`), these `._*` entries appear as real files in embedded framework root directories (e.g., `Sparkle.framework/._Autoupdate`), causing Gatekeeper to reject with "unsealed contents present in the root directory of an embedded framework". This issue is **not detectable** by `spctl --assess` on the build machine because `ditto -x -k` correctly reconverts `._*` files back to xattrs — only Finder extraction triggers the failure. After packaging, verify the zip is clean: ```bash # Must show NO ._* entries zipinfo -1 "$ZIP_PATH" | grep '/\._' && echo "ERROR: AppleDouble files found!" || echo "OK: no AppleDouble files" ``` ### Step 2: Generate Changelog **This is AI work, not scripted.** Follow these rules: 1. Find last release tag: ```bash gh release list --repo Caldis/Mos --limit 1 --json tagName,isPrerelease git rev-parse # get exact commit SHA ``` 2. Get changes: `git log ..HEAD --no-merges --oneline` excluding `website/`, `docs/`, `.issues-archive/`, `CLAUDE.md`, `LOCALIZATION.md`, `build/`, `dmg/`, `CRASH_FIX_DESIGN*`. 3. Categorize into: 新功能/New Features, 优化/Improvements, 修复/Fixes. 4. Find contributors: cross-reference `git log --format="%an"` with `gh api repos/.../commits/ --jq '.author.login'`. Inline credit in the relevant section (e.g., "修复鼠标中键映射问题, 感谢 @GonzFC"), NOT in a separate section. 5. Match tone of `CHANGELOG.md` — bilingual (Chinese first, `---` separator, then English). 6. Write both formats: - **Markdown** → `~/Desktop/release-notes-{version}.md` (for GH release body). Must include wiki links header before each language section: ```markdown > 如果应用无法启动或遇到权限问题, 请参考 [Wiki: 如果应用无法正常运行](https://github.com/Caldis/Mos/wiki/%E5%A6%82%E6%9E%9C%E5%BA%94%E7%94%A8%E6%97%A0%E6%B3%95%E6%AD%A3%E5%B8%B8%E8%BF%90%E8%A1%8C) ## 修复 - ... --- > If the application fails to start or encounters permission issues, please refer [Wiki: If the App not work properly](https://github.com/Caldis/Mos/wiki/If-the-App-not-work-properly) ## Fixes - ... ``` - **HTML** → `/tmp/changelog-{version}.html` (for appcast CDATA). Use `

` + `

  • 进一步尝试修复平滑滚动导致的崩溃问题 (感谢 #868, #826, #699, #687, #665, #510, #512, #499, #368, #597, #859 提供的日志)

Fixes

  • Fix crash caused by smooth scrolling (#868, #826, #699, #687, #665, #510, #512, #499, #368, #597, #859)
]]> Sun, 08 Mar 2026 15:35:15 +0000 Mos 4.0.1 新功能
  • 添加 UU 远程桌面(UU远程桌面)支持,防止主机与客户端同时安装 Mos 时出现双重平滑问题,感谢 @jijiamoer (#879)

修复

  • 修复滚动事件派发问题,引入 ScrollDispatchContext 重构 ScrollPoster,解决部分场景下滚动异常 (#868, #826)
  • 修复 macOS 26+ 上 LaunchPad 检测逻辑,避免影响 Dock 文件夹弹出视图,感谢 @Lezheng2333 反馈 (#878)

New Features

  • Added UU Remote Desktop (UU远程桌面) support to prevent double-smoothing when both host and client have Mos installed, thanks @jijiamoer (#879)

Fixes

  • Fixed scroll event dispatching by introducing ScrollDispatchContext and refactoring ScrollPoster, resolving scroll anomalies in certain scenarios (#868, #826)
  • Fixed LaunchPad detection on macOS 26+, avoiding interference with Dock folder popup views, thanks @Lezheng2333 for reporting (#878)
]]>
Sat, 08 Mar 2026 00:00:00 +0000
Mos 4.0.0 新功能
  • 全新「按钮」模块:将鼠标按键绑定到任意系统快捷操作。
  • 新增「模拟触控板 (Beta)」滚动模式。
  • 滚动方向反转现在支持垂直、水平方向独立设置。
  • 新增应用内更新检查(power by Sparkle)。
  • 新增远程桌面滚动事件检测, 如果发起端和受控端都安装了 Mos, 也不会冲突。
  • 滚动功能键现在支持自定义录制绑定。

优化

  • 欢迎引导窗口 UI 翻新。
  • 全新应用图标与状态栏图标。
  • 滚动选项面板整合优化。
  • 适配 macOS 26 视觉风格。
  • 翻译系统迁移至 String Catalogs。
  • 新增法语翻译, 感谢 @EricPujolBRGM
  • 新增波兰语翻译, 感谢 @MaciejkaG
  • 新增印尼语翻译, 感谢 @harun-alrosyid
  • 日语本地化补全, 感谢 @ulyssas
  • 日语、印尼语文本校对, 感谢 @ulyssas @harun-alrosyid
  • 波兰语、英文文本校对, 感谢 @MaciejkaG

修复

  • 修复多个滚动平滑相关问题。
  • 修复鼠标按键映射与辅助功能授权状态异常。
  • 修复非美式键盘快捷键问题, 感谢 @salakis
  • 修复鼠标中键映射问题, 感谢 @GonzFC
  • 修复其他已知问题。

New Features

  • Brand-new Buttons module: bind mouse buttons to any system shortcut.
  • Added Simulate Trackpad scrolling mode.
  • Scroll direction reversal can now be set independently for vertical and horizontal axes.
  • Added in-app update checking via Sparkle, with an optional beta channel.
  • Added remote desktop scroll event detection, if both remote host and client are installed Mos still work fine.
  • Scroll hotkeys can now be recorded and bound to custom keys.

Improvements

  • Refreshed the welcome guide UI.
  • New app icon and menu bar icon.
  • Consolidated scroll options panel.
  • Adapted visual style for macOS 26.
  • Migrated translations to String Catalogs.
  • Added French translation, thanks to @EricPujolBRGM
  • Added Polish translation, thanks to @MaciejkaG
  • Added Indonesian translation, thanks to @harun-alrosyid
  • Japanese localization completion, thanks to @ulyssas
  • Japanese & Indonesian text proofreading, thanks to @ulyssas @harun-alrosyid
  • Polish & English text proofreading, thanks to @MaciejkaG

Fixes

  • Fixed multiple smooth scrolling issues.
  • Fixed mouse button mapping and accessibility authorization state issues.
  • Fixed shortcuts on non-US keyboard layouts, thanks to @salakis
  • Fixed middle mouse button mapping, thanks to @GonzFC
  • Fixed other known issues.
]]>
Thu, 20 Feb 2026 00:00:00 +0000
================================================ FILE: docs/index.html ================================================ Mos | Turn the mouse into flow
Skip to content
Smooth scrolling for mouse wheels on macOS

Turn the mouseinto flow.

Mos is a free, open-source macOS utility that makes wheel scrolling feel closer to a trackpad, without getting in your way. Tune curves, split axes, and override behavior per app.

Requires macOS 10.13+
Free · Open source
Scroll to explore

Deterministic scroll. Tunable feel.

Mos turns raw wheel deltas into predictable motion. Keep the same feel across apps, and override it when you need to.

Curves & Acceleration
Shape the feel.

Smoothness is a curve. Adjust step, gain, and duration, and see how raw wheel deltas turn into controlled motion.

Step
33.60
Quantization floor for wheel deltas.
Gain
×2.70
Scales distance per tick and how fast the curve ramps.
Duration
4.35
Smoothing time constant (higher means longer tail).
ScrollCore curve
Independent Axes
Split X and Y.

Treat vertical and horizontal scroll as separate axes. Toggle smoothing and reverse independently for each.

Y
Smooth
Reverse
X
Smooth
Reverse
Per-app Profiles
Different apps, different feel.

Let each app inherit defaults, or override scroll and button rules. Precision where it matters, smooth everywhere else.

Xcode
smooth
Safari
smooth
Figma
smooth
Terminal
Notion
smooth
Chrome
smooth
Buttons & Shortcuts
Bind, record, repeat.

Record mouse or keyboard events and bind them to system shortcuts. Use the live monitor to see what your devices are sending.

Quick Bind
Button 4
Mission Control
Button 5
Next Space
Wheel Click
App Switcher
recording…

Download Mos. Tune your scroll.

Install in seconds, tweak it when you need to, and keep your scroll behavior consistent across the apps you live in.

Homebrew
brew install --cask mos
Tip: If you’re on beta, your cask might be mos@beta.
Latest release · Requires macOS 10.13+
================================================ FILE: docs/index.txt ================================================ 1:"$Sreact.fragment" 3:I[96923,["/_next/static/chunks/19717b1d18b1c046.js"],"Providers"] 4:I[33720,["/_next/static/chunks/c62ecb7f1a587007.js","/_next/static/chunks/2f0565a65fa02d97.js"],"default"] 5:I[16458,["/_next/static/chunks/c62ecb7f1a587007.js","/_next/static/chunks/2f0565a65fa02d97.js"],"default"] 6:I[13154,["/_next/static/chunks/19717b1d18b1c046.js"],""] 7:I[61096,["/_next/static/chunks/19717b1d18b1c046.js","/_next/static/chunks/d95bda38d1a9ce53.js","/_next/static/chunks/b492b32695e3b282.js"],"default"] 8:I[45924,["/_next/static/chunks/c62ecb7f1a587007.js","/_next/static/chunks/2f0565a65fa02d97.js"],"OutletBoundary"] 9:"$Sreact.suspense" b:I[45924,["/_next/static/chunks/c62ecb7f1a587007.js","/_next/static/chunks/2f0565a65fa02d97.js"],"ViewportBoundary"] d:I[45924,["/_next/static/chunks/c62ecb7f1a587007.js","/_next/static/chunks/2f0565a65fa02d97.js"],"MetadataBoundary"] f:I[70566,[],"default"] :HL["/_next/static/chunks/543732a8392562b4.css","style"] :HL["/_next/static/media/0c795a286deabae8-s.p.b6c48e4e.woff2","font",{"crossOrigin":"","type":"font/woff2"}] :HL["/_next/static/media/0c89a48fa5027cee-s.p.4564287c.woff2","font",{"crossOrigin":"","type":"font/woff2"}] :HL["/_next/static/media/23b7a97ae3b5c134-s.p.2902b61f.woff2","font",{"crossOrigin":"","type":"font/woff2"}] :HL["/_next/static/media/99e609270109b47d-s.p.64b9304e.woff2","font",{"crossOrigin":"","type":"font/woff2"}] :HL["/_next/static/media/effe91970fc4db64-s.p.19510058.woff2","font",{"crossOrigin":"","type":"font/woff2"}] 2:["$","style",null,{"children":"html.js .reveal{opacity:1!important;transform:none!important;filter:none!important}"}] 0:{"P":null,"b":"4xlWUdmYkOQZmVpTO2yEI","c":["",""],"q":"","i":false,"f":[[["",{"children":["__PAGE__",{}]},"$undefined","$undefined",true],[["$","$1","c",{"children":[[["$","link","0",{"rel":"stylesheet","href":"/_next/static/chunks/543732a8392562b4.css","precedence":"next","crossOrigin":"$undefined","nonce":"$undefined"}],["$","script","script-0",{"src":"/_next/static/chunks/19717b1d18b1c046.js","async":true,"nonce":"$undefined"}]],["$","html",null,{"lang":"en","className":"js","children":[["$","head",null,{"children":[["$","script",null,{"type":"application/ld+json","dangerouslySetInnerHTML":{"__html":"{\"@context\":\"https://schema.org\",\"@graph\":[{\"@type\":\"WebSite\",\"@id\":\"https://mos.caldis.me/#website\",\"url\":\"https://mos.caldis.me/\",\"name\":\"Mos\",\"description\":\"Mos is a free macOS utility that makes mouse wheel scrolling smooth, with per-app settings, independent axis tuning, and button/shortcut bindings.\",\"inLanguage\":\"en\"},{\"@type\":\"SoftwareApplication\",\"@id\":\"https://mos.caldis.me/#software\",\"name\":\"Mos\",\"url\":\"https://mos.caldis.me/\",\"operatingSystem\":\"macOS\",\"applicationCategory\":\"UtilitiesApplication\",\"description\":\"Mos is a free macOS utility that makes mouse wheel scrolling smooth, with per-app settings, independent axis tuning, and button/shortcut bindings.\",\"downloadUrl\":\"https://github.com/Caldis/Mos/releases/latest\",\"softwareHelp\":\"https://github.com/Caldis/Mos/wiki\",\"sameAs\":[\"https://github.com/Caldis/Mos\"],\"license\":\"https://creativecommons.org/licenses/by-nc/4.0/\",\"offers\":{\"@type\":\"Offer\",\"price\":\"0\",\"priceCurrency\":\"USD\"}}]}"}}],["$","noscript",null,{"children":"$2"}]]}],["$","body",null,{"className":"syne_d93b7706-module__Y_KGdq__variable space_grotesk_8677877b-module__OT3qGa__variable ibm_plex_mono_e0f5f7ea-module__w-hdTq__variable antialiased","children":[["$","$L3",null,{"children":["$","$L4",null,{"parallelRouterKey":"children","error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":[[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":404}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],[]],"forbidden":"$undefined","unauthorized":"$undefined"}]}],["$","$L6",null,{"src":"https://www.googletagmanager.com/gtag/js?id=G-9M7WPLB8BR","strategy":"afterInteractive"}],["$","$L6",null,{"id":"ga4","strategy":"afterInteractive","children":"\n window.dataLayer = window.dataLayer || [];\n function gtag(){dataLayer.push(arguments);}\n gtag('js', new Date());\n gtag('config', 'G-9M7WPLB8BR');\n "}]]}]]}]]}],{"children":[["$","$1","c",{"children":[["$","$L7",null,{}],[["$","script","script-0",{"src":"/_next/static/chunks/d95bda38d1a9ce53.js","async":true,"nonce":"$undefined"}],["$","script","script-1",{"src":"/_next/static/chunks/b492b32695e3b282.js","async":true,"nonce":"$undefined"}]],["$","$L8",null,{"children":["$","$9",null,{"name":"Next.MetadataOutlet","children":"$@a"}]}]]}],{},null,false,false]},null,false,false],["$","$1","h",{"children":[null,["$","$Lb",null,{"children":"$Lc"}],["$","div",null,{"hidden":true,"children":["$","$Ld",null,{"children":["$","$9",null,{"name":"Next.Metadata","children":"$Le"}]}]}],["$","meta",null,{"name":"next-size-adjust","content":""}]]}],false]],"m":"$undefined","G":["$f",[]],"S":true} c:[["$","meta","0",{"charSet":"utf-8"}],["$","meta","1",{"name":"viewport","content":"width=device-width, initial-scale=1"}]] 10:I[46886,["/_next/static/chunks/c62ecb7f1a587007.js","/_next/static/chunks/2f0565a65fa02d97.js"],"IconMark"] a:null e:[["$","title","0",{"children":"Mos | Turn the mouse into flow"}],["$","meta","1",{"name":"description","content":"Mos is a free macOS utility that makes mouse wheel scrolling smooth, with per-app settings, independent axis tuning, and button/shortcut bindings."}],["$","link","2",{"rel":"canonical","href":"https://mos.caldis.me/"}],["$","meta","3",{"property":"og:title","content":"Mos | Turn the mouse into flow"}],["$","meta","4",{"property":"og:description","content":"Mos is a free macOS utility that makes mouse wheel scrolling smooth, with per-app settings, independent axis tuning, and button/shortcut bindings."}],["$","meta","5",{"property":"og:url","content":"https://mos.caldis.me/"}],["$","meta","6",{"property":"og:site_name","content":"Mos"}],["$","meta","7",{"property":"og:image","content":"https://mos.caldis.me/og.svg"}],["$","meta","8",{"property":"og:image:width","content":"1200"}],["$","meta","9",{"property":"og:image:height","content":"630"}],["$","meta","10",{"property":"og:image:alt","content":"Mos | Turn the mouse into flow"}],["$","meta","11",{"property":"og:type","content":"website"}],["$","meta","12",{"name":"twitter:card","content":"summary_large_image"}],["$","meta","13",{"name":"twitter:title","content":"Mos | Turn the mouse into flow"}],["$","meta","14",{"name":"twitter:description","content":"Mos is a free macOS utility that makes mouse wheel scrolling smooth, with per-app settings, independent axis tuning, and button/shortcut bindings."}],["$","meta","15",{"name":"twitter:image","content":"https://mos.caldis.me/og.svg"}],["$","link","16",{"rel":"icon","href":"/favicon.ico?favicon.6def09c8.ico","sizes":"256x256","type":"image/x-icon"}],["$","$L10","17",{}]] ================================================ FILE: docs/llms.txt ================================================ # Mos (macOS) Mos is a free macOS utility that makes mouse wheel scrolling smooth, with per-app settings, independent axis tuning, and button/shortcut bindings. ## Official links - Homepage: https://mos.caldis.me/ - GitHub: https://github.com/Caldis/Mos - Docs (Wiki): https://github.com/Caldis/Mos/wiki - Releases: https://github.com/Caldis/Mos/releases - Latest release: https://github.com/Caldis/Mos/releases/latest ## Install - Homebrew (beta cask): `brew install --cask mos@beta` ## Notes - License: CC BY-NC 4.0 (https://creativecommons.org/licenses/by-nc/4.0/) - Mos is not intended to be distributed via the Mac App Store (per project license notes). ================================================ FILE: docs/release-notes/4.0.0-beta-20260108.1.en.html ================================================ Mos Release Notes

New Feature

  • Added in-app update checking via Sparkle, with an optional beta channel toggle.

Improvements

  • Updated French localization.

Fixes

  • Allowed binding the middle mouse button without modifier keys.
  • Fixed some shortcut issues on non‑US keyboard layouts.
================================================ FILE: docs/release-notes/4.0.0-beta-20260108.1.zh.html ================================================ Mos Release Notes

新功能

  • 新增应用内更新检查(Sparkle),并支持 Beta 渠道开关。

改进

  • 更新法语本地化翻译。

修复

  • 允许在不按修饰键的情况下绑定鼠标中键。
  • 修复部分非 US 键盘布局下的快捷键问题。
================================================ FILE: docs/release-notes/4.0.0-beta-20260201.1.en.html ================================================ Mos Release Notes

New Feature

  • Added in-app update checking via Sparkle, with an optional beta channel toggle.
  • Scroll hotkeys can now be bound to any key.

Improvements

  • Refined other localization strings.

Fixes

  • Fixed shortcut issues on non‑US keyboard layouts.
  • Fixed smooth scrolling not working properly with Remote Desktop or some special devices.
================================================ FILE: docs/release-notes/4.0.0-beta-20260201.1.md ================================================ > 这是测试版本, 一些功能或内容可能会在正式版本中变更 > > 如果应用无法启动或遇到权限问题, 请参考 [Wiki: 如果应用无法正常运行](https://github.com/Caldis/Mos/wiki/%E5%A6%82%E6%9E%9C%E5%BA%94%E7%94%A8%E6%97%A0%E6%B3%95%E6%AD%A3%E5%B8%B8%E8%BF%90%E8%A1%8C) ### 新功能 - 新增应用内更新检查(Sparkle),并支持可选的 Beta 渠道。 - 滚动功能键现在支持绑定任意热键。 ### 改进 - 补充并修正部分本地化内容。 ### 修复 - 修复部分非 US 键盘布局下的快捷键问题。 - 修复远程桌面或部分特殊设备的滚动输入无法被正确平滑的问题。 --- > This is a beta version, some features or content may change in the official release. > > If the application fails to start or encounters permission issues, please refer [Wiki: If the App not work properly](https://github.com/Caldis/Mos/wiki/If-the-App-not-work-properly) ### New Feature - Added in-app update checking via Sparkle, with an optional beta channel toggle. - Scroll hotkeys can now be bound to any key. ### Improvements - Refined other localization strings. ### Fixes - Fixed shortcut issues on non‑US keyboard layouts. - Fixed smooth scrolling not working properly with Remote Desktop or some special devices. ================================================ FILE: docs/release-notes/4.0.0-beta-20260201.1.zh.html ================================================ Mos Release Notes

新功能

  • 新增应用内更新检查(Sparkle),并支持可选的 Beta 渠道。
  • 滚动功能键现在支持绑定任意热键。

改进

  • 补充并修正部分本地化内容。

修复

  • 修复部分非 US 键盘布局下的快捷键问题。
  • 修复远程桌面或部分特殊设备的滚动输入无法被正确平滑的问题。
================================================ FILE: docs/release-notes/4.0.0-beta-20260203.1.en.html ================================================ Mos Release Notes

Improvements

  • Updated localization.
================================================ FILE: docs/release-notes/4.0.0-beta-20260203.1.zh.html ================================================ Mos Release Notes

改进

  • 更新本地化翻译。
================================================ FILE: docs/release-notes/beta.en.html ================================================ Mos Release Notes

Mos 4.0.0-beta (20260203.1)

Improvements

  • Updated localization.

Mos 4.0.0-beta (20260201.1)

New Feature

  • Added in-app update checking via Sparkle, with an optional beta channel toggle.
  • Scroll hotkeys can now be bound to any key.

Improvements

  • Refined other localization strings.

Fixes

  • Fixed shortcut issues on non‑US keyboard layouts.
  • Fixed smooth scrolling not working properly with Remote Desktop or some special devices.

Mos 4.0.0-beta (20260108.1)

New Feature

  • Added in-app update checking via Sparkle, with an optional beta channel toggle.

Improvements

  • Updated French localization.

Fixes

  • Allowed binding the middle mouse button without modifier keys.
  • Fixed some shortcut issues on non‑US keyboard layouts.
================================================ FILE: docs/release-notes/beta.zh.html ================================================ Mos Release Notes

Mos 4.0.0-beta (20260203.1)

改进

  • 更新本地化翻译。

Mos 4.0.0-beta (20260201.1)

新功能

  • 新增应用内更新检查(Sparkle),并支持可选的 Beta 渠道。
  • 滚动功能键现在支持绑定任意热键。

改进

  • 补充并修正部分本地化内容。

修复

  • 修复部分非 US 键盘布局下的快捷键问题。
  • 修复远程桌面或部分特殊设备的滚动输入无法被正确平滑的问题。

Mos 4.0.0-beta (20260108.1)

新功能

  • 新增应用内更新检查(Sparkle),并支持 Beta 渠道开关。

改进

  • 更新法语本地化翻译。

修复

  • 允许在不按修饰键的情况下绑定鼠标中键。
  • 修复部分非 US 键盘布局下的快捷键问题。
================================================ FILE: docs/robots.txt ================================================ User-Agent: * Allow: / Sitemap: https://mos.caldis.me/sitemap.xml ================================================ FILE: docs/sitemap.xml ================================================ https://mos.caldis.me/ monthly 1 ================================================ FILE: docs/superpowers/plans/2026-03-16-logitech-hid-integration.md ================================================ # Logitech HID++ 2.0 Hardware Button Integration Plan > **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Add Logitech HID++ 2.0 hardware button identification to Mos, allowing users to bind Logitech-specific buttons (gesture, DPI, etc.) to system shortcuts. **Architecture:** Introduce MosInputEvent abstraction layer (Mode C) that unifies CGEventTap and HID++ event sources. Both sources feed into MosInputProcessor independently. LogitechHIDManager communicates with Logitech devices via IOKit HIDManager and HID++ 2.0 protocol. **Tech Stack:** Swift, IOKit (IOHIDManager), HID++ 2.0 protocol, existing Mos Interceptor/ButtonCore/KeyRecorder infrastructure. **Spec:** `docs/superpowers/specs/2026-03-16-logitech-hid-integration-design.md` **Important:** This is a macOS Xcode project (Mos.xcodeproj). New .swift files must be added to the Xcode project's target. After creating each new file, run `open Mos.xcodeproj` and add it via File > Add Files, or use the `ruby` script in the verification step. macOS Deployment Target is 10.13+. --- ## File Structure ### New Files | File | Directory | Responsibility | |------|-----------|----------------| | `MosInputEvent.swift` | `Mos/InputEvent/` | MosInputEvent struct, MosInputPhase, MosInputSource, MosInputDevice, DeviceFilter, LogitechCIDMap | | `MosInputProcessor.swift` | `Mos/InputEvent/` | MosInputProcessor singleton, MosInputResult enum | | `LogitechHIDManager.swift` | `Mos/LogitechHID/` | IOKit HIDManager wrapper, device enumeration, lifecycle, notification constants | | `LogitechDeviceSession.swift` | `Mos/LogitechHID/` | HID++ 2.0 protocol: feature discovery, button divert, report parsing, event dispatch | ### Modified Files | File | Key Changes | |------|-------------| | `Mos/Windows/PreferencesWindow/ButtonsView/RecordedEvent.swift` | Add `deviceFilter` to RecordedEvent, add `matchesMosInput()`, add `init(from: MosInputEvent)`, add `ScrollHotkey.init(from: MosInputEvent)` | | `Mos/Keys/KeyCode.swift` | Add Logitech button code display mappings (1000+ range) | | `Mos/ButtonCore/ButtonCore.swift` | Refactor callback to use MosInputProcessor | | `Mos/Keys/KeyRecorder.swift` | Change delegate protocol from @objc to Swift protocol, add HID++ event listening during recording, update handleRecordedEvent for dual-source | | `Mos/Windows/PreferencesWindow/ButtonsView/PreferencesButtonsViewController.swift` | Update delegate method signatures from CGEvent to MosInputEvent | | `Mos/Windows/PreferencesWindow/ScrollingView/PreferencesScrollingViewController.swift` | Update delegate method signatures from CGEvent to MosInputEvent | | `Mos/AppDelegate.swift` | Add LogitechHIDManager start/stop lifecycle calls | --- ## Chunk 1: Data Model Layer ### Task 1: Create MosInputEvent **Files:** - Create: `Mos/InputEvent/MosInputEvent.swift` - [ ] **Step 1: Create directory and file** ```bash mkdir -p /Users/caldis/Desktop/Code/Mos/Mos/InputEvent ``` Write `Mos/InputEvent/MosInputEvent.swift`: ```swift // // MosInputEvent.swift // Mos // 统一输入事件 - 抽象 CGEventTap 和 HID++ 两种事件源 // Created by Mos on 2026/3/16. // Copyright © 2026 Caldis. All rights reserved. // import Cocoa // MARK: - MosInputPhase /// 事件阶段 enum MosInputPhase { case down case up } // MARK: - MosInputSource /// 事件来源 - 携带源头特有的数据 /// 注意: 因为 cgEvent 关联值包含 CGEvent (非 Codable), MosInputEvent 整体不可序列化 /// 只有从中提取的 RecordedEvent 走持久化路径 enum MosInputSource { /// 来自 CGEventTap, 携带原始 CGEvent 用于 pass-through/consume case cgEvent(CGEvent) /// 来自 Logitech HID++ 协议 case hidPlusPlus } // MARK: - MosInputDevice /// 设备信息 (可序列化, 用于 DeviceFilter 匹配和 UI 展示) struct MosInputDevice: Codable, Equatable { let vendorId: UInt16 // USB Vendor ID (Logitech = 0x046D) let productId: UInt16 // USB Product ID let name: String // 人类可读名称 (如 "MX Master 3S") } // MARK: - DeviceFilter /// 设备过滤器 - 用于 ButtonBinding 中限制触发设备 struct DeviceFilter: Codable, Equatable { let vendorId: UInt16? // nil = 不限厂商 let productId: UInt16? // nil = 不限型号 func matches(_ device: MosInputDevice?) -> Bool { guard let device = device else { return false } if let vid = vendorId, vid != device.vendorId { return false } if let pid = productId, pid != device.productId { return false } return true } } // MARK: - LogitechCIDMap /// Logitech CID -> Mos 按钮码映射 /// 标准 CGEvent 鼠标按钮: 0~31, Logitech HID++ 专有: 1000+ struct LogitechCIDMap { private static let cidToCode: [UInt16: UInt16] = [ 0x00C3: 1000, // Gesture Button 0x00C4: 1001, // SmartShift 0x00D7: 1002, // DPI Change Button ] static func toMosCode(_ cid: UInt16) -> UInt16 { if let known = cidToCode[cid] { return known } let mapped = UInt32(2000) + UInt32(cid) return mapped <= UInt32(UInt16.max) ? UInt16(mapped) : UInt16(cid & 0x0FFF) + 2000 } static func displayName(forCode code: UInt16) -> String { switch code { case 1000: return "Gesture" case 1001: return "SmartShift" case 1002: return "DPI" default: return "Logi(\(code))" } } /// 判断按钮码是否属于 Logitech HID++ 专有范围 static func isLogitechCode(_ code: UInt16) -> Bool { return code >= 1000 } } // MARK: - MosInputEvent /// 统一输入事件 (运行时对象, 不可序列化) struct MosInputEvent { let type: EventType // .keyboard 或 .mouse (复用现有枚举) let code: UInt16 // 按键码 / 按钮码 let modifiers: CGEventFlags // 修饰键状态 let phase: MosInputPhase // 按下 / 抬起 let source: MosInputSource // 事件来源 let device: MosInputDevice? // 设备信息 (CGEventTap 来源为 nil) /// 从 CGEvent 构造 /// 注意: .flagsChanged 事件也属于键盘域 (修饰键按下/抬起), 必须和 keyDown/keyUp 同类处理 /// 这与 ScrollHotkey.init(from: CGEvent) 和 RecordedEvent.init(from: CGEvent) 中的判断一致 init(fromCGEvent event: CGEvent) { if event.isKeyboardEvent || event.type == .flagsChanged { self.type = .keyboard self.code = event.keyCode } else { self.type = .mouse self.code = event.mouseCode } self.modifiers = event.flags self.phase = event.isKeyDown ? .down : .up self.source = .cgEvent(event) self.device = nil } /// 从 HID++ 数据构造 init(type: EventType, code: UInt16, modifiers: CGEventFlags, phase: MosInputPhase, source: MosInputSource, device: MosInputDevice?) { self.type = type self.code = code self.modifiers = modifiers self.phase = phase self.source = source self.device = device } // MARK: - Display /// 构造展示用名称组件 var displayComponents: [String] { var components: [String] = [] // 修饰键 if modifiers.rawValue & CGEventFlags.maskShift.rawValue != 0 { components.append("⇧") } if modifiers.rawValue & CGEventFlags.maskControl.rawValue != 0 { components.append("⌃") } if modifiers.rawValue & CGEventFlags.maskAlternate.rawValue != 0 { components.append("⌥") } if modifiers.rawValue & CGEventFlags.maskCommand.rawValue != 0 { components.append("⌘") } // 按键名称 switch type { case .keyboard: components.append(KeyCode.keyMap[code] ?? "Key(\(code))") case .mouse: if LogitechCIDMap.isLogitechCode(code) { components.append(LogitechCIDMap.displayName(forCode: code)) } else { components.append(KeyCode.mouseMap[code] ?? "Mouse(\(code))") } } return components } /// 是否为键盘事件 var isKeyboardEvent: Bool { type == .keyboard } /// 是否为鼠标事件 var isMouseEvent: Bool { type == .mouse } /// 是否有修饰键 var hasModifiers: Bool { return modifiers.rawValue & KeyCode.modifiersMask != 0 } /// 事件是否可录制 (combination 模式) var isRecordable: Bool { switch type { case .keyboard: if KeyCode.functionKeys.contains(code) { return true } if !hasModifiers { return false } return true case .mouse: if LogitechCIDMap.isLogitechCode(code) { return true } if KeyCode.mouseMainKeys.contains(code) { return hasModifiers } return true } } /// 事件是否可录制 (singleKey 模式) /// 注意: 修饰键 (.flagsChanged) 只在 key-down 时录制, key-up 忽略 /// 这与原 KeyRecorder.isRecordableAsSingleKey 中 event.isKeyDown && event.isModifiers 逻辑一致 var isRecordableAsSingleKey: Bool { switch type { case .keyboard: if KeyCode.modifierKeys.contains(code) { return phase == .down } return true case .mouse: if KeyCode.mouseMainKeys.contains(code) { return false } return true } } } ``` - [ ] **Step 2: Verify file compiles** ```bash cd /Users/caldis/Desktop/Code/Mos && xcodebuild -project Mos.xcodeproj -scheme Mos -configuration Debug build 2>&1 | tail -5 ``` Note: The file must first be added to Xcode project. If building from CLI fails because the file isn't in the project, add it manually or verify syntax by checking for obvious errors. - [ ] **Step 3: Commit** ```bash git add Mos/InputEvent/MosInputEvent.swift git commit -m "feat: add MosInputEvent unified input event abstraction" ``` --- ### Task 2: Create MosInputProcessor **Files:** - Create: `Mos/InputEvent/MosInputProcessor.swift` - [ ] **Step 1: Write MosInputProcessor** ```swift // // MosInputProcessor.swift // Mos // 统一事件处理器 - 接收 MosInputEvent, 匹配 ButtonBinding, 执行动作 // Created by Mos on 2026/3/16. // Copyright © 2026 Caldis. All rights reserved. // import Cocoa // MARK: - MosInputResult /// 事件处理结果 enum MosInputResult { case consumed // 事件已处理,不再传递 case passthrough // 事件未匹配,继续传递 } // MARK: - MosInputProcessor /// 统一事件处理器 (无状态单例) /// 从 ButtonUtils 获取绑定配置, 匹配 MosInputEvent, 执行 ShortcutExecutor class MosInputProcessor { static let shared = MosInputProcessor() init() { NSLog("Module initialized: MosInputProcessor") } /// 处理输入事件 /// - Parameter event: 统一输入事件 /// - Returns: .consumed 表示事件已处理, .passthrough 表示未匹配 func process(_ event: MosInputEvent) -> MosInputResult { // 只处理按下事件 (避免 down+up 触发两次) guard event.phase == .down else { return .passthrough } let bindings = ButtonUtils.shared.getButtonBindings() guard let binding = bindings.first(where: { $0.triggerEvent.matchesMosInput(event) && $0.isEnabled }) else { return .passthrough } ShortcutExecutor.shared.execute(named: binding.systemShortcutName) return .consumed } } ``` - [ ] **Step 2: Commit** ```bash git add Mos/InputEvent/MosInputProcessor.swift git commit -m "feat: add MosInputProcessor unified event processor" ``` --- ### Task 3: Extend RecordedEvent with MosInputEvent support **Files:** - Modify: `Mos/Windows/PreferencesWindow/ButtonsView/RecordedEvent.swift` - [ ] **Step 1: Add `deviceFilter` field to RecordedEvent** In `RecordedEvent.swift`, after line 99 (`let displayComponents: [String]`), add: ```swift // 设备过滤器 (optional, 向后兼容: 旧数据解码为 nil, 匹配所有设备) let deviceFilter: DeviceFilter? ``` - [ ] **Step 2: Add `matchesMosInput` method** In `RecordedEvent.swift`, after the existing `matches(_ event: CGEvent)` method (line 147), add: ```swift /// 匹配 MosInputEvent (供 MosInputProcessor 使用) func matchesMosInput(_ event: MosInputEvent) -> Bool { // 1. 修饰键匹配 guard UInt(event.modifiers.rawValue) == modifiers else { return false } // 2. 类型匹配 guard event.type == type else { return false } // 3. 按键码匹配 switch type { case .keyboard: guard event.phase == .down else { return false } guard code == event.code else { return false } case .mouse: guard code == event.code else { return false } } // 4. 设备过滤 (可选) if let filter = deviceFilter { guard filter.matches(event.device) else { return false } } return true } ``` - [ ] **Step 3: Add `init(from: MosInputEvent)` constructor** After the existing `init(from event: CGEvent)` (line 127), add: ```swift /// 从 MosInputEvent 构造 init(from event: MosInputEvent, deviceFilter: DeviceFilter? = nil) { self.type = event.type self.code = event.code self.modifiers = UInt(event.modifiers.rawValue) self.deviceFilter = deviceFilter self.displayComponents = event.displayComponents } ``` - [ ] **Step 4: Update existing `init(from event: CGEvent)` to include deviceFilter** Change the existing CGEvent init to also set `deviceFilter = nil`: ```swift init(from event: CGEvent) { self.modifiers = UInt(event.flags.rawValue) if event.isKeyboardEvent { self.type = .keyboard self.code = event.keyCode } else { self.type = .mouse self.code = event.mouseCode } self.displayComponents = event.displayComponents self.deviceFilter = nil } ``` - [ ] **Step 5: Add `ScrollHotkey.init(from: MosInputEvent)` extension** At the bottom of the file, add: ```swift // MARK: - ScrollHotkey + MosInputEvent extension ScrollHotkey { /// 从 MosInputEvent 构造 init(from event: MosInputEvent) { self.type = event.type self.code = event.code } } ``` - [ ] **Step 6: Commit** ```bash git add Mos/Windows/PreferencesWindow/ButtonsView/RecordedEvent.swift git commit -m "feat: extend RecordedEvent with MosInputEvent support and DeviceFilter" ``` --- ### Task 4: Extend KeyCode with Logitech button names **Files:** - Modify: `Mos/Keys/KeyCode.swift:157-167` - [ ] **Step 1: Add Logitech mouse map entries** In `KeyCode.swift`, extend the `mouseMap` dictionary (line 157) to include Logitech codes. Add after the existing entries (before the closing `]` on line 165): ```swift // Logitech HID++ 专有按键 1000: "Gesture", 1001: "SmartShift", 1002: "DPI", ``` - [ ] **Step 2: Commit** ```bash git add Mos/Keys/KeyCode.swift git commit -m "feat: add Logitech HID++ button names to KeyCode map" ``` --- ### Task 5: Refactor ButtonCore to use MosInputProcessor **Files:** - Modify: `Mos/ButtonCore/ButtonCore.swift:34-50` - [ ] **Step 1: Replace buttonEventCallBack implementation** Replace the entire `buttonEventCallBack` closure (lines 34-50) with: ```swift let buttonEventCallBack: CGEventTapCallBack = { (proxy, type, event, refcon) in let mosEvent = MosInputEvent(fromCGEvent: event) let result = MosInputProcessor.shared.process(mosEvent) switch result { case .consumed: return nil case .passthrough: return Unmanaged.passUnretained(event) } } ``` - [ ] **Step 2: Verify existing button bindings still work** Build and run. Test that existing button bindings (e.g., mouse button 3 -> Mission Control) still trigger correctly. The behavior should be identical to before. - [ ] **Step 3: Commit** ```bash git add Mos/ButtonCore/ButtonCore.swift git commit -m "refactor: ButtonCore callback uses MosInputProcessor" ``` --- ### Task 6: Refactor KeyRecorder delegate and recording **Files:** - Modify: `Mos/Keys/KeyRecorder.swift` This is the most complex modification. Three changes: 1. Delegate protocol: `@objc protocol` -> Swift protocol + extension default 2. `handleRecordedEvent`: support both CGEvent and MosInputEvent from notification 3. Add HID++ event observer during recording - [ ] **Step 1: Change delegate protocol (lines 21-32)** Replace: ```swift @objc protocol KeyRecorderDelegate: AnyObject { /// 录制完成回调 func onEventRecorded(_ recorder: KeyRecorder, didRecordEvent event: CGEvent, isDuplicate: Bool) @objc optional func validateRecordedEvent(_ recorder: KeyRecorder, event: CGEvent) -> Bool } ``` With: ```swift protocol KeyRecorderDelegate: AnyObject { /// 录制完成回调 func onEventRecorded(_ recorder: KeyRecorder, didRecordEvent event: MosInputEvent, isDuplicate: Bool) /// 验证录制的事件是否为重复 func validateRecordedEvent(_ recorder: KeyRecorder, event: MosInputEvent) -> Bool } /// 默认实现 (替代 @objc optional 语义) extension KeyRecorderDelegate { func validateRecordedEvent(_ recorder: KeyRecorder, event: MosInputEvent) -> Bool { return true } } ``` - [ ] **Step 2: Add HID++ event observer property (after line 48)** Add to the private properties section: ```swift private var hidEventObserver: NSObjectProtocol? // HID++ 事件监听 (录制期间) ``` - [ ] **Step 3: Update `startRecording` to add HID++ listener** At the end of the `do` block in `startRecording` (after the interceptor creation, before `keyPopover = KeyPopover()`), add: ```swift // 监听 HID++ 事件 (如果 LogitechHIDManager 已启动) hidEventObserver = NotificationCenter.default.addObserver( forName: LogitechHIDManager.buttonEventNotification, object: nil, queue: .main ) { [weak self] notification in guard let self = self, self.isRecording, !self.isRecorded else { return } guard let mosEvent = notification.userInfo?["event"] as? MosInputEvent else { return } guard mosEvent.phase == .down else { return } NotificationCenter.default.post( name: KeyRecorder.FINISH_NOTI_NAME, object: mosEvent ) } ``` - [ ] **Step 4: Update `stopRecording` to remove HID++ listener** In `stopRecording()`, after the line removing `CANCEL_NOTI_NAME` observer (line 287), add: ```swift if let observer = hidEventObserver { NotificationCenter.default.removeObserver(observer) hidEventObserver = nil } ``` - [ ] **Step 5: Rewrite `handleRecordedEvent` to support dual-source (lines 204-240)** Replace the entire method: ```swift @objc private func handleRecordedEvent(_ notification: NSNotification) { guard isRecording else { return } // 统一转换为 MosInputEvent let mosEvent: MosInputEvent if let cgEvent = notification.object as? CGEvent { mosEvent = MosInputEvent(fromCGEvent: cgEvent) } else if let hidEvent = notification.object as? MosInputEvent { mosEvent = hidEvent } else { NSLog("[EventRecorder] Unknown event type in notification") return } // 检查事件有效性 (根据录制模式) let isValid = recordingMode == .singleKey ? mosEvent.isRecordableAsSingleKey : mosEvent.isRecordable guard isValid else { NSLog("[EventRecorder] Invalid event ignored") keyPopover?.keyPreview.shakeWarning() invalidKeyPressCount += 1 if invalidKeyPressCount >= invalidKeyThreshold { keyPopover?.showEscHint() } return } guard !isRecorded else { return } isRecorded = true let isNew = self.delegate?.validateRecordedEvent(self, event: mosEvent) ?? true let isDuplicate = !isNew let status: KeyPreview.Status = isNew ? .recorded : .duplicate keyPopover?.keyPreview .update(from: mosEvent.displayComponents, status: status) self.delegate?.onEventRecorded(self, didRecordEvent: mosEvent, isDuplicate: isDuplicate) DispatchQueue.main.asyncAfter(deadline: .now() + 0.7) { [weak self] in self?.stopRecording() } } ``` - [ ] **Step 6: Update `handleModifierFlagsChanged` to use MosInputEvent for singleKey recording** In `handleModifierFlagsChanged` (line 172), the method posts to `FINISH_NOTI_NAME` with a CGEvent. This is fine -- `handleRecordedEvent` now handles both types. No change needed here since it posts a CGEvent that gets converted. - [ ] **Step 7: Remove the private `isRecordableAsSingleKey` method (lines 249-270)** This logic is now in `MosInputEvent.isRecordableAsSingleKey`. Delete the method: ```swift // DELETE: private func isRecordableAsSingleKey(_ event: CGEvent) -> Bool { ... } ``` - [ ] **Step 8: Commit** ```bash git add Mos/Keys/KeyRecorder.swift git commit -m "refactor: KeyRecorder supports MosInputEvent dual-source recording" ``` --- ### Task 7: Update PreferencesButtonsViewController delegate **Files:** - Modify: `Mos/Windows/PreferencesWindow/ButtonsView/PreferencesButtonsViewController.swift:228-243` - [ ] **Step 1: Update delegate methods** Replace the `KeyRecorderDelegate` extension (lines 228-243): ```swift // MARK: - EventRecorderDelegate extension PreferencesButtonsViewController: KeyRecorderDelegate { func validateRecordedEvent(_ recorder: KeyRecorder, event: MosInputEvent) -> Bool { let recordedEvent = RecordedEvent(from: event) return !buttonBindings.contains(where: { $0.triggerEvent == recordedEvent }) } func onEventRecorded(_ recorder: KeyRecorder, didRecordEvent event: MosInputEvent, isDuplicate: Bool) { DispatchQueue.main.asyncAfter(deadline: .now() + 0.66) { [weak self] in self?.addRecordedEvent(event, isDuplicate: isDuplicate) } } } ``` - [ ] **Step 2: Update `addRecordedEvent` method signature (line 92)** Change from `private func addRecordedEvent(_ event: CGEvent, isDuplicate: Bool)` to: ```swift private func addRecordedEvent(_ event: MosInputEvent, isDuplicate: Bool) { let recordedEvent = RecordedEvent(from: event) if isDuplicate { if let existing = buttonBindings.first(where: { $0.triggerEvent == recordedEvent }) { highlightExistingRow(with: existing.id) } return } let binding = ButtonBinding(triggerEvent: recordedEvent, systemShortcutName: "", isEnabled: false) buttonBindings.append(binding) tableView.reloadData() toggleNoDataHint() syncViewWithOptions() } ``` - [ ] **Step 3: Commit** ```bash git add Mos/Windows/PreferencesWindow/ButtonsView/PreferencesButtonsViewController.swift git commit -m "refactor: PreferencesButtonsViewController uses MosInputEvent" ``` --- ### Task 8: Update PreferencesScrollingViewController delegate **Files:** - Modify: `Mos/Windows/PreferencesWindow/ScrollingView/PreferencesScrollingViewController.swift:328-347` - [ ] **Step 1: Update delegate method** Replace the `KeyRecorderDelegate` extension (lines 328-347): ```swift // MARK: - KeyRecorderDelegate extension PreferencesScrollingViewController: KeyRecorderDelegate { func onEventRecorded(_ recorder: KeyRecorder, didRecordEvent event: MosInputEvent, isDuplicate: Bool) { guard let popup = currentRecordingPopup else { return } let hotkey = ScrollHotkey(from: event) if popup === dashKeyBindButton { getTargetApplicationScrollOptions().dash = hotkey } else if popup === toggleKeyBindButton { getTargetApplicationScrollOptions().toggle = hotkey } else if popup === disableKeyBindButton { getTargetApplicationScrollOptions().block = hotkey } currentRecordingPopup = nil syncViewWithOptions() } } ``` - [ ] **Step 2: Commit** ```bash git add Mos/Windows/PreferencesWindow/ScrollingView/PreferencesScrollingViewController.swift git commit -m "refactor: PreferencesScrollingViewController uses MosInputEvent" ``` --- ### Task 9: Verify all existing functionality - [ ] **Step 1: Build project** ```bash cd /Users/caldis/Desktop/Code/Mos && xcodebuild -project Mos.xcodeproj -scheme Mos -configuration Debug build 2>&1 | tail -20 ``` Fix any compilation errors. - [ ] **Step 2: Manual testing checklist** Run the app and verify: - Scroll smoothing works as before - Existing button bindings trigger correctly - Recording new button bindings works (both combination and singleKey mode) - Scroll hotkey recording works (dash/toggle/block) - Per-app scroll settings work - ESC cancels recording - Duplicate detection works (blue highlight for repeat bindings) - [ ] **Step 3: Commit if any fixes were needed** ```bash git add -A && git commit -m "fix: resolve compilation issues from MosInputEvent migration" ``` --- ## Chunk 2: Logitech HID Module ### Task 10: Create LogitechHIDManager **Files:** - Create: `Mos/LogitechHID/LogitechHIDManager.swift` - [ ] **Step 1: Create directory** ```bash mkdir -p /Users/caldis/Desktop/Code/Mos/Mos/LogitechHID ``` - [ ] **Step 2: Write LogitechHIDManager** ```swift // // LogitechHIDManager.swift // Mos // Logitech HID 设备管理器 - 通过 IOKit 枚举和监控 Logitech 设备 // Created by Mos on 2026/3/16. // Copyright © 2026 Caldis. All rights reserved. // import Foundation import IOKit import IOKit.hid class LogitechHIDManager { static let shared = LogitechHIDManager() init() { NSLog("Module initialized: LogitechHIDManager") } // MARK: - Constants static let logitechVendorId: Int = 0x046D static let buttonEventNotification = NSNotification.Name("LogitechHIDButtonEvent") // MARK: - State private var hidManager: IOHIDManager? private var sessions: [IOHIDDevice: LogitechDeviceSession] = [:] private(set) var isActive = false // MARK: - Lifecycle func start() { guard !isActive else { return } NSLog("[LogitechHID] Starting") hidManager = IOHIDManagerCreate(kCFAllocatorDefault, IOOptionBits(kIOHIDOptionsTypeNone)) guard let manager = hidManager else { NSLog("[LogitechHID] Failed to create IOHIDManager") return } // 只匹配 Logitech 设备 let matchDict: [String: Any] = [ kIOHIDVendorIDKey as String: LogitechHIDManager.logitechVendorId ] IOHIDManagerSetDeviceMatching(manager, matchDict as CFDictionary) // 注册回调 (使用 C 函数指针 + context) let context = Unmanaged.passUnretained(self).toOpaque() IOHIDManagerRegisterDeviceMatchingCallback(manager, Self.deviceMatchedCallback, context) IOHIDManagerRegisterDeviceRemovalCallback(manager, Self.deviceRemovedCallback, context) // Schedule 到 main RunLoop (HID++ 事件低频, 避免线程同步) IOHIDManagerScheduleWithRunLoop(manager, CFRunLoopGetMain(), CFRunLoopMode.commonModes.rawValue) let result = IOHIDManagerOpen(manager, IOOptionBits(kIOHIDOptionsTypeNone)) if result != kIOReturnSuccess { NSLog("[LogitechHID] Failed to open IOHIDManager: 0x%08x", result) return } isActive = true NSLog("[LogitechHID] Started") } func stop() { guard isActive else { return } NSLog("[LogitechHID] Stopping") // 清理所有设备会话 for (_, session) in sessions { session.teardown() } sessions.removeAll() if let manager = hidManager { IOHIDManagerClose(manager, IOOptionBits(kIOHIDOptionsTypeNone)) IOHIDManagerUnscheduleFromRunLoop(manager, CFRunLoopGetMain(), CFRunLoopMode.commonModes.rawValue) } hidManager = nil isActive = false NSLog("[LogitechHID] Stopped") } // MARK: - Device Callbacks (C function pointers) private static let deviceMatchedCallback: IOHIDDeviceCallback = { context, result, sender, device in guard let context = context else { return } let manager = Unmanaged.fromOpaque(context).takeUnretainedValue() manager.deviceConnected(device) } private static let deviceRemovedCallback: IOHIDDeviceCallback = { context, result, sender, device in guard let context = context else { return } let manager = Unmanaged.fromOpaque(context).takeUnretainedValue() manager.deviceDisconnected(device) } // MARK: - Device Management private func deviceConnected(_ device: IOHIDDevice) { // 读取设备信息 let vendorId = IOHIDDeviceGetProperty(device, kIOHIDVendorIDKey as CFString) as? Int ?? 0 let productId = IOHIDDeviceGetProperty(device, kIOHIDProductIDKey as CFString) as? Int ?? 0 let productName = IOHIDDeviceGetProperty(device, kIOHIDProductKey as CFString) as? String ?? "Unknown" NSLog("[LogitechHID] Device connected: %@ (VID: 0x%04X, PID: 0x%04X)", productName, vendorId, productId) // 避免重复会话 guard sessions[device] == nil else { return } // 创建会话 let session = LogitechDeviceSession(hidDevice: device) sessions[device] = session session.setup() } private func deviceDisconnected(_ device: IOHIDDevice) { guard let session = sessions.removeValue(forKey: device) else { return } NSLog("[LogitechHID] Device disconnected: %@", session.deviceInfo.name) session.teardown() } // MARK: - Query /// 获取当前已连接的 Logitech 设备列表 var connectedDevices: [MosInputDevice] { return sessions.values.map { $0.deviceInfo } } } ``` - [ ] **Step 3: Commit** ```bash git add Mos/LogitechHID/LogitechHIDManager.swift git commit -m "feat: add LogitechHIDManager for IOKit device enumeration" ``` --- ### Task 11: Create LogitechDeviceSession **Files:** - Create: `Mos/LogitechHID/LogitechDeviceSession.swift` - [ ] **Step 1: Write LogitechDeviceSession** ```swift // // LogitechDeviceSession.swift // Mos // 单个 Logitech 设备的 HID++ 2.0 通信会话 // 实现 Feature Discovery, Button Divert, 事件解析 // Created by Mos on 2026/3/16. // Copyright © 2026 Caldis. All rights reserved. // import Foundation import IOKit import IOKit.hid class LogitechDeviceSession { // MARK: - Public let hidDevice: IOHIDDevice let deviceInfo: MosInputDevice // MARK: - HID++ State private var featureIndex: [UInt16: UInt8] = [:] private var divertedCIDs: Set = [] private var lastActiveCIDs: Set = [] private var deviceIndex: UInt8 = 0x01 // MARK: - Report Buffer // 必须用堆指针, Swift Array 是 value type, copy-on-write 时地址会变 private var reportBufferPtr: UnsafeMutablePointer? private static let reportBufferSize = 64 // MARK: - Async Discovery private var pendingDiscovery: [UInt16: (UInt8?) -> Void] = [:] private var discoveryTimer: Timer? private static let discoveryTimeout: TimeInterval = 5.0 // MARK: - HID++ Constants private static let featureIRoot: UInt16 = 0x0000 private static let featureReprogV4: UInt16 = 0x1B04 private static let hidppShortReportId: UInt8 = 0x10 private static let hidppLongReportId: UInt8 = 0x11 private static let hidppErrorFeatureIdx: UInt8 = 0xFF // MARK: - Init init(hidDevice: IOHIDDevice) { self.hidDevice = hidDevice self.deviceInfo = MosInputDevice( vendorId: UInt16(IOHIDDeviceGetProperty(hidDevice, kIOHIDVendorIDKey as CFString) as? Int ?? 0), productId: UInt16(IOHIDDeviceGetProperty(hidDevice, kIOHIDProductIDKey as CFString) as? Int ?? 0), name: IOHIDDeviceGetProperty(hidDevice, kIOHIDProductKey as CFString) as? String ?? "Unknown" ) } deinit { reportBufferPtr?.deallocate() } // MARK: - Setup / Teardown func setup() { NSLog("[LogitechHID:%@] Setting up session", deviceInfo.name) // 分配稳定的 report buffer reportBufferPtr = .allocate(capacity: Self.reportBufferSize) reportBufferPtr!.initialize(repeating: 0, count: Self.reportBufferSize) // 注册 Input Report 回调 let context = Unmanaged.passUnretained(self).toOpaque() IOHIDDeviceRegisterInputReportCallback( hidDevice, reportBufferPtr!, Self.reportBufferSize, Self.inputReportCallback, context ) // Feature Discovery: 查找 REPROG_CONTROLS_V4 discoverFeature(featureId: Self.featureReprogV4) { [weak self] index in guard let self = self, let index = index else { NSLog("[LogitechHID] Device does not support REPROG_CONTROLS_V4, skipping button divert") return } self.featureIndex[Self.featureReprogV4] = index NSLog("[LogitechHID:%@] REPROG_CONTROLS_V4 at index 0x%02X", self.deviceInfo.name, index) self.queryAndDivertButtons(featureIndex: index) } } func teardown() { NSLog("[LogitechHID:%@] Tearing down session", deviceInfo.name) discoveryTimer?.invalidate() discoveryTimer = nil pendingDiscovery.removeAll() // 取消 divert (恢复按键的默认行为) if let reprogIdx = featureIndex[Self.featureReprogV4] { for cid in divertedCIDs { setControlReporting(featureIndex: reprogIdx, cid: cid, divert: false) } } divertedCIDs.removeAll() lastActiveCIDs.removeAll() } // MARK: - Input Report Callback (C function pointer) static let inputReportCallback: IOHIDReportCallback = { context, result, sender, type, reportID, report, reportLength in guard let context = context else { return } let session = Unmanaged.fromOpaque(context).takeUnretainedValue() let data = Array(UnsafeBufferPointer(start: report, count: reportLength)) session.handleInputReport(data) } // MARK: - HID++ Send private func sendShortRequest(featureIndex: UInt8, functionId: UInt8, params: [UInt8] = []) { var report = [UInt8](repeating: 0, count: 7) report[0] = Self.hidppShortReportId report[1] = deviceIndex report[2] = featureIndex report[3] = (functionId << 4) | 0x01 // FuncID | SwID for (i, p) in params.prefix(3).enumerated() { report[4 + i] = p } let result = IOHIDDeviceSetReport( hidDevice, IOHIDReportType(kIOHIDReportTypeOutput), CFIndex(report[0]), report, report.count ) if result != kIOReturnSuccess { NSLog("[LogitechHID:%@] SetReport failed: 0x%08x", deviceInfo.name, result) } } // MARK: - Feature Discovery private func discoverFeature(featureId: UInt16, completion: @escaping (UInt8?) -> Void) { let params: [UInt8] = [UInt8(featureId >> 8), UInt8(featureId & 0xFF)] sendShortRequest(featureIndex: 0x00, functionId: 0, params: params) pendingDiscovery[featureId] = completion discoveryTimer?.invalidate() discoveryTimer = Timer.scheduledTimer(withTimeInterval: Self.discoveryTimeout, repeats: false) { [weak self] _ in guard let self = self else { return } if let pending = self.pendingDiscovery.removeValue(forKey: featureId) { NSLog("[LogitechHID:%@] Feature discovery timed out for 0x%04X", self.deviceInfo.name, featureId) pending(nil) } } } // MARK: - Button Divert private func queryAndDivertButtons(featureIndex: UInt8) { // GetControlCount: function 0 sendShortRequest(featureIndex: featureIndex, functionId: 0) // 响应在 handleInputReport 中处理 (会触发后续的 GetControlInfo + SetControlReporting) } private func setControlReporting(featureIndex: UInt8, cid: UInt16, divert: Bool) { // SetControlReporting: function 3 // Params: CID_MSB, CID_LSB, flags (bit 0 = divert) let flags: UInt8 = divert ? 0x01 : 0x00 let params: [UInt8] = [UInt8(cid >> 8), UInt8(cid & 0xFF), flags] sendShortRequest(featureIndex: featureIndex, functionId: 3, params: params) if divert { divertedCIDs.insert(cid) } else { divertedCIDs.remove(cid) } NSLog("[LogitechHID:%@] CID 0x%04X divert=%@", deviceInfo.name, cid, divert ? "ON" : "OFF") } // MARK: - Report Parsing func handleInputReport(_ report: [UInt8]) { guard report.count >= 7 else { return } guard report[0] == Self.hidppShortReportId || report[0] == Self.hidppLongReportId else { return } let featureIdx = report[2] // Error report if featureIdx == Self.hidppErrorFeatureIdx { let errorCode = report.count > 6 ? report[6] : 0 NSLog("[LogitechHID:%@] Error report: featureIdx=0x%02X errorCode=0x%02X", deviceInfo.name, report[3], errorCode) // 清理对应的 pending discovery for (featureId, callback) in pendingDiscovery { callback(nil) pendingDiscovery.removeValue(forKey: featureId) } return } // IRoot response (feature discovery) if featureIdx == 0x00 { handleDiscoveryResponse(report) return } // REPROG_CONTROLS_V4 events if let reprogIdx = featureIndex[Self.featureReprogV4], featureIdx == reprogIdx { handleReprogEvent(report) return } } private func handleDiscoveryResponse(_ report: [UInt8]) { // IRoot.GetFeature response: params[0] = featureIndex, params[1] = featureType let discoveredIndex = report[4] // 尝试匹配 pending discovery // 由于我们发送了 feature ID 作为参数, 这里简化处理: 取第一个 pending if let (featureId, callback) = pendingDiscovery.first { discoveryTimer?.invalidate() pendingDiscovery.removeValue(forKey: featureId) if discoveredIndex == 0 { // Index 0 = not found callback(nil) } else { callback(discoveredIndex) } } } private func handleReprogEvent(_ report: [UInt8]) { let functionId = report[3] >> 4 // divertedButtonsEvent notification (function varies by firmware, typically event index 0) // Parse CID pairs from params var activeCIDs: Set = [] var offset = 4 while offset + 1 < report.count { let cid = (UInt16(report[offset]) << 8) | UInt16(report[offset + 1]) if cid == 0 { break } activeCIDs.insert(cid) offset += 2 } // 差分检测 let newlyPressed = activeCIDs.subtracting(lastActiveCIDs) let newlyReleased = lastActiveCIDs.subtracting(activeCIDs) lastActiveCIDs = activeCIDs for cid in newlyPressed { dispatchButtonEvent(cid: cid, isDown: true) } for cid in newlyReleased { dispatchButtonEvent(cid: cid, isDown: false) } } // MARK: - Event Dispatch private func dispatchButtonEvent(cid: UInt16, isDown: Bool) { let currentFlags = CGEventSource.flagsState(.combinedSessionState) let mosEvent = MosInputEvent( type: .mouse, code: LogitechCIDMap.toMosCode(cid), modifiers: currentFlags, phase: isDown ? .down : .up, source: .hidPlusPlus, device: deviceInfo ) // 处理事件 let _ = MosInputProcessor.shared.process(mosEvent) // 发送通知 (供 KeyRecorder 录制监听) NotificationCenter.default.post( name: LogitechHIDManager.buttonEventNotification, object: nil, userInfo: ["event": mosEvent] ) } } ``` - [ ] **Step 2: Commit** ```bash git add Mos/LogitechHID/LogitechDeviceSession.swift git commit -m "feat: add LogitechDeviceSession for HID++ 2.0 protocol communication" ``` --- ### Task 12: Integrate LogitechHIDManager into AppDelegate **Files:** - Modify: `Mos/AppDelegate.swift` - [ ] **Step 1: Add LogitechHIDManager.start() after ButtonCore.enable()** In `startWithAccessibilityPermissionsChecker` (lines 69-96), add `LogitechHIDManager.shared.start()` after every `ButtonCore.shared.enable()` call. Line 76 (after first `ButtonCore.shared.enable()`): ```swift LogitechHIDManager.shared.start() ``` Line 82 (after second `ButtonCore.shared.enable()`): ```swift LogitechHIDManager.shared.start() ``` - [ ] **Step 2: Add LogitechHIDManager.stop() in applicationWillTerminate** In `applicationWillTerminate` (line 62), add before `ScrollCore.shared.disable()`: ```swift LogitechHIDManager.shared.stop() ``` - [ ] **Step 3: Add LogitechHIDManager lifecycle in session callbacks** In `sessionDidActive` (line 99), add after `ButtonCore.shared.enable()`: ```swift LogitechHIDManager.shared.start() ``` In `sessionDidResign` (line 103), add before `ScrollCore.shared.disable()`: ```swift LogitechHIDManager.shared.stop() ``` - [ ] **Step 4: Commit** ```bash git add Mos/AppDelegate.swift git commit -m "feat: integrate LogitechHIDManager lifecycle into AppDelegate" ``` --- ### Task 13: Final build and integration test - [ ] **Step 1: Ensure all new files are in Xcode project** New files that must be added to the Mos target in Xcode: - `Mos/InputEvent/MosInputEvent.swift` - `Mos/InputEvent/MosInputProcessor.swift` - `Mos/LogitechHID/LogitechHIDManager.swift` - `Mos/LogitechHID/LogitechDeviceSession.swift` - [ ] **Step 2: Full build** ```bash cd /Users/caldis/Desktop/Code/Mos && xcodebuild -project Mos.xcodeproj -scheme Mos -configuration Debug build 2>&1 | tail -20 ``` - [ ] **Step 3: Manual testing - existing features** - Scroll smoothing: mouse wheel in any app - Scroll reverse: toggle in preferences - Button binding: record mouse button 3, bind to Mission Control, verify trigger - Scroll hotkey: set dash key, verify amplification works - Per-app settings: add exception for an app, verify independent scroll config - ESC cancels recording - Monitor window: verify scroll event visualization - [ ] **Step 4: Manual testing - Logitech HID (requires Logitech mouse)** - Check Console.app logs for `[LogitechHID] Device connected: ...` - If Logitech Options+ is running, quit it first - Verify gesture button press appears in logs as `[LogitechHID] CID 0x00C3 divert=ON` - Open Preferences > Buttons, click Add, press gesture button -> should appear as "Gesture" - Bind gesture button to a system shortcut, verify it triggers - [ ] **Step 5: Final commit** ```bash git add -A && git commit -m "feat: complete Logitech HID++ 2.0 hardware button integration" ``` ================================================ FILE: docs/superpowers/specs/2026-03-16-logitech-hid-integration-design.md ================================================ # Logitech HID++ 2.0 硬件按键集成设计 ## 概述 为 Mos 新增 Logitech 鼠标硬件层面的按键识别能力。通过 IOKit HIDManager 实现 HID++ 2.0 协议通信,捕获 CGEventTap 无法感知的 Logitech 专有按键(手势按钮、DPI 按钮等),并以最小侵入方式整合到现有按键绑定系统中。 ### 目标 - 识别并捕获 Logitech HID++ 鼠标上的专有硬件按键 - 用户可将这些按键绑定到系统快捷键(复用现有 ButtonBinding 机制) - 兼容未来新增的 Logitech HID++ 设备 - 不影响现有滚动/按键/分应用/Remote 检测等全部功能 - 现有用户配置数据自动兼容,无需迁移 ### 非目标 - 不替代 CGEventTap(两者互补) - 不处理 Logitech 滚动数据(ScrollCore 保持不变) - 不支持非 Logitech 的 HID 设备 - 不实现 DPI 控制、电池状态等非按键功能 --- ## 背景知识 ### HID++ 2.0 协议 HID++ 2.0 是 Logitech 的私有扩展协议,基于标准 USB HID 的 vendor-specific usage page 传输。跨平台(Windows/macOS/Linux),非 Apple 专有。 **关键概念:** - **IRoot (Feature 0x0000)**: 所有 HID++ 2.0 设备必有的根特征,用于动态发现其他特征的 index - **REPROG_CONTROLS_V4 (Feature 0x1B04)**: 按键重编程特征,支持按键 divert(将按键事件从标准 OS 路径转移到 HID++ 通道) - **报文格式**: Short (7 bytes, Report ID 0x10), Long (20 bytes, Report ID 0x11) - **Feature Discovery**: 通过 IRoot 的 GetFeature() 查询 Feature ID → Feature Index 映射 ### 为什么需要 HID++ Logitech 鼠标上的某些按键(手势按钮、DPI 切换等)不会产生标准 CGEvent。这些按键的信号只在 HID++ 通道中可见。CGEventTap 对这类按键完全透明。 ### macOS 访问路径 macOS 上通过 IOKit 框架的 `IOHIDManager` API 访问底层 HID 设备。无需第三方库(Mouser 项目用的 Python `hidapi` 底层也是 IOKit)。 --- ## 架构设计 ### 设计原则 1. **互补而非替代**: HID++ 是 CGEventTap 的补充事件源,不改变现有事件处理流程 2. **统一抽象**: 引入 MosInputEvent 统一两种来源的事件表示 3. **状态隔离**: 各模块只拥有自己的状态,单向依赖,无循环引用 4. **向后兼容**: 新增字段全部 optional,旧数据自动兼容 ### 分层架构 ``` +---------------------------------------------------------------+ | Event Source Layer | | (互相完全不知道对方存在) | | | | +-------------------------+ +-----------------------------+ | | | CGEventTap Adapter | | LogitechHIDManager | | | | (现有 Interceptor) | | (新增, IOKit HIDManager) | | | | | | | | | | 职责: | | 职责: | | | | - 拦截系统级事件 | | - 枚举 Logitech HID 设备 | | | | - 转换为 MosInputEvent | | - HID++ 2.0 协议通信 | | | | - 处理 consume/pass | | - 按钮 divert 和事件监听 | | | | | | - 设备连接/断开生命周期 | | | +------------+------------+ +-------------+---------------+ | | | MosInputEvent | MosInputEvent | +---------------+-----------------------------+-----------------+ | | v v +---------------------------------------------------------------+ | Processing Layer | | | | +----------------------------------------------------------+ | | | MosInputProcessor (单例, 无状态) | | | | | | | | 输入: MosInputEvent | | | | 输出: MosInputResult (.consumed / .passthrough) | | | | | | | | 逻辑: | | | | 1. ButtonUtils.shared.getButtonBindings() | | | | 2. 遍历匹配 triggerEvent.matches(mosEvent) | | | | 3. 匹配成功: ShortcutExecutor.execute() -> .consumed | | | | 4. 无匹配: .passthrough | | | +----------------------------------------------------------+ | +---------------------------------------------------------------+ | v +---------------------------------------------------------------+ | Action Layer (现有, 不变) | | | | ShortcutExecutor -> 合成 CGEvent -> post 到系统 | +---------------------------------------------------------------+ ``` ### 控制流详解 #### CGEventTap 路径 (同步,必须立即返回) ```swift // ButtonCore.buttonEventCallBack 改造: let buttonEventCallBack: CGEventTapCallBack = { (proxy, type, event, refcon) in let mosEvent = MosInputEvent(fromCGEvent: event) let result = MosInputProcessor.shared.process(mosEvent) switch result { case .consumed: return nil case .passthrough: return Unmanaged.passUnretained(event) } } ``` #### HID++ 路径 (同步,无 pass-through) ```swift // LogitechHIDManager 内部: func onHIDButtonEvent(cid: UInt16, isDown: Bool, device: MosInputDevice) { let mosEvent = MosInputEvent( type: .mouse, code: LogitechCIDMap.toMosCode(cid), modifiers: currentModifierFlags(), phase: isDown ? .down : .up, source: .hidPlusPlus, device: device ) // 结果被忽略 -- HID++ 按键不存在 "pass-through" 语义 // 因为这些按键本来就不会产生系统事件 let _ = MosInputProcessor.shared.process(mosEvent) } ``` --- ## 数据模型 ### MosInputEvent (新增) **设计约束**: `MosInputEvent` 是纯运行时对象,不遵循 Codable。因为 `MosInputSource.cgEvent(CGEvent)` 中的 CGEvent 不可序列化。只有从中提取的 `RecordedEvent` 走持久化路径。 ```swift /// 事件阶段 enum MosInputPhase { case down case up } /// 事件来源 enum MosInputSource { /// 来自 CGEventTap, 携带原始 CGEvent 用于 pass-through/consume case cgEvent(CGEvent) /// 来自 Logitech HID++ 协议 case hidPlusPlus } /// 设备信息 (可序列化, 用于 DeviceFilter 匹配和 UI 展示) struct MosInputDevice: Codable, Equatable { let vendorId: UInt16 // USB Vendor ID (Logitech = 0x046D) let productId: UInt16 // USB Product ID let name: String // 人类可读名称 (如 "MX Master 3S") } /// 统一输入事件 (运行时对象, 不可序列化) struct MosInputEvent { let type: EventType // .keyboard 或 .mouse (复用现有枚举) let code: UInt16 // 按键码 / 按钮码 let modifiers: CGEventFlags // 修饰键状态 let phase: MosInputPhase // 按下 / 抬起 let source: MosInputSource // 事件来源 let device: MosInputDevice? // 设备信息 (CGEventTap 来源为 nil) /// 从 CGEvent 构造 init(fromCGEvent event: CGEvent) { if event.isKeyboardEvent { self.type = .keyboard self.code = event.keyCode } else { self.type = .mouse self.code = event.mouseCode } self.modifiers = event.flags self.phase = event.isKeyDown ? .down : .up self.source = .cgEvent(event) self.device = nil } /// 从 HID++ 数据构造 init(type: EventType, code: UInt16, modifiers: CGEventFlags, phase: MosInputPhase, source: MosInputSource, device: MosInputDevice?) { self.type = type self.code = code self.modifiers = modifiers self.phase = phase self.source = source self.device = device } /// 构造展示用名称组件 static func buildDisplayComponents(_ event: MosInputEvent) -> [String] { var components: [String] = [] // 修饰键 let flags = NSEvent.ModifierFlags(rawValue: UInt(event.modifiers.rawValue)) if flags.contains(.control) { components.append(KeyCode.modifierMap[0x3B] ?? "^") } if flags.contains(.option) { components.append(KeyCode.modifierMap[0x3A] ?? "~") } if flags.contains(.shift) { components.append(KeyCode.modifierMap[0x38] ?? "$") } if flags.contains(.command) { components.append(KeyCode.modifierMap[0x37] ?? "@") } // 按键名称 switch event.type { case .keyboard: components.append(KeyCode.keyMap[event.code] ?? "Key \(event.code)") case .mouse: if event.code >= 1000 { components.append(LogitechCIDMap.displayName(forCode: event.code)) } else { components.append(KeyCode.mouseMap[event.code] ?? "Button \(event.code)") } } return components } } ``` ### MosInputProcessor (新增) ```swift /// 处理结果 enum MosInputResult { case consumed // 事件已处理,不再传递 case passthrough // 事件未匹配,继续传递 } /// 统一事件处理器 (无状态单例) class MosInputProcessor { static let shared = MosInputProcessor() func process(_ event: MosInputEvent) -> MosInputResult { let bindings = ButtonUtils.shared.getButtonBindings() guard let binding = bindings.first(where: { $0.triggerEvent.matchesMosInput(event) && $0.isEnabled }) else { return .passthrough } ShortcutExecutor.shared.execute(named: binding.systemShortcutName) return .consumed } } ``` ### RecordedEvent 扩展 (修改) ```swift struct RecordedEvent: Codable, Equatable { // 现有字段 (不变) let type: EventType let code: UInt16 let modifiers: UInt let displayComponents: [String] // 新增字段 (optional, 向后兼容) let deviceFilter: DeviceFilter? // 新增: 匹配 MosInputEvent func matchesMosInput(_ event: MosInputEvent) -> Bool { // 1. 修饰键匹配 guard UInt(event.modifiers.rawValue) == modifiers else { return false } // 2. 类型匹配 guard event.type == type else { return false } // 3. 按键码匹配 switch type { case .keyboard: guard event.phase == .down else { return false } guard code == event.code else { return false } case .mouse: guard code == event.code else { return false } } // 4. 设备过滤 (可选) if let filter = deviceFilter { guard filter.matches(event.device) else { return false } } return true } // 保留: 旧的 CGEvent 匹配 (供 ScrollCore 热键使用, 不改) func matches(_ event: CGEvent) -> Bool { // 现有逻辑完全不变 } // 新增: 从 MosInputEvent 构造 init(from event: MosInputEvent, deviceFilter: DeviceFilter? = nil) { self.type = event.type self.code = event.code self.modifiers = UInt(event.modifiers.rawValue) self.deviceFilter = deviceFilter self.displayComponents = MosInputEvent.buildDisplayComponents(event) } } /// ScrollHotkey 扩展: 从 MosInputEvent 构造 extension ScrollHotkey { init(from event: MosInputEvent) { self.type = event.type self.code = event.code } } ``` ### DeviceFilter (新增) ```swift struct DeviceFilter: Codable, Equatable { let vendorId: UInt16? // nil = 不限厂商 let productId: UInt16? // nil = 不限型号 func matches(_ device: MosInputDevice?) -> Bool { guard let device = device else { return false } if let vid = vendorId, vid != device.vendorId { return false } if let pid = productId, pid != device.productId { return false } return true } } ``` ### 按钮编号空间 ```swift // 标准 CGEvent 鼠标按钮: 0 ~ 31 (系统分配) // 标准键盘虚拟键码: 0 ~ 127 (由 EventType 区分, 不冲突) // Logitech HID++ 专有: 1000+ (我们分配) /// Logitech CID -> Mos 按钮码映射 struct LogitechCIDMap { // CID 参考: Logitech HID++ 2.0 REPROG_CONTROLS_V4 规范 private static let cidToCode: [UInt16: UInt16] = [ 0x00C3: 1000, // Gesture Button 0x00C4: 1001, // SmartShift 0x00D7: 1002, // DPI Change Button // 后续设备新增 CID 在此追加, 从 1003 递增 ] static func toMosCode(_ cid: UInt16) -> UInt16 { if let known = cidToCode[cid] { return known } // 未知 CID: 映射到 2000+ 区段, 但限制在 UInt16 范围内 let mapped = UInt32(2000) + UInt32(cid) return mapped <= UInt32(UInt16.max) ? UInt16(mapped) : UInt16(cid & 0x0FFF) + 2000 } static func displayName(forCode code: UInt16) -> String { switch code { case 1000: return "Gesture" case 1001: return "SmartShift" case 1002: return "DPI" default: return "Logi \(code)" } } } ``` --- ## LogitechHIDManager 模块设计 ### 职责 1. 通过 IOKit `IOHIDManager` 枚举和监控 Logitech HID 设备 2. 实现 HID++ 2.0 协议子集(IRoot feature discovery + REPROG_CONTROLS_V4 button divert) 3. 解析 HID++ 通知,将按键事件转换为 MosInputEvent 4. 管理设备连接/断开生命周期 ### 设备发现 ```swift class LogitechHIDManager { static let shared = LogitechHIDManager() private var hidManager: IOHIDManager? private var connectedDevices: [IOHIDDevice: LogitechDeviceSession] = [:] /// Logitech USB Vendor ID private static let logitechVendorId: Int = 0x046D func start() { hidManager = IOHIDManagerCreate(kCFAllocatorDefault, IOOptionBits(kIOHIDOptionsTypeNone)) guard let manager = hidManager else { return } // 只匹配 Logitech 设备 let matchDict: [String: Any] = [ kIOHIDVendorIDKey as String: LogitechHIDManager.logitechVendorId ] IOHIDManagerSetDeviceMatching(manager, matchDict as CFDictionary) // 注册连接/断开回调 IOHIDManagerRegisterDeviceMatchingCallback(manager, deviceConnected, nil) IOHIDManagerRegisterDeviceRemovalCallback(manager, deviceDisconnected, nil) // Schedule 到 main RunLoop (低频事件, 避免线程同步) IOHIDManagerScheduleWithRunLoop(manager, CFRunLoopGetMain(), CFRunLoopMode.commonModes.rawValue) IOHIDManagerOpen(manager, IOOptionBits(kIOHIDOptionsTypeNone)) } func stop() { guard let manager = hidManager else { return } // 清理所有设备会话 for (_, session) in connectedDevices { session.teardown() } connectedDevices.removeAll() IOHIDManagerClose(manager, IOOptionBits(kIOHIDOptionsTypeNone)) hidManager = nil } } ``` ### 设备会话 每个已连接的 Logitech HID++ 设备维护一个独立会话: ```swift /// 单个 Logitech 设备的 HID++ 会话 class LogitechDeviceSession { let hidDevice: IOHIDDevice let deviceInfo: MosInputDevice // HID++ 状态 private var featureIndex: [UInt16: UInt8] = [:] // Feature ID -> Feature Index private var divertedCIDs: Set = [] // 已 divert 的 CID 集合 private var lastActiveCIDs: Set = [] // 上一帧活跃的 CID (用于差分检测) private var deviceIndex: UInt8 = 0x01 // HID++ device index (连接时探测) // Report buffer: 必须用堆分配指针, 保证在 IOKit 回调生命周期内地址稳定 // Swift [UInt8] 是 value type, copy-on-write 时地址会变, 导致 IOKit 回调访问野指针 private var reportBufferPtr: UnsafeMutablePointer? private static let reportBufferSize = 64 // 足够容纳 Long report (20 bytes) + 余量 // HID++ 2.0 常量 private static let featureIRoot: UInt16 = 0x0000 private static let featureReprogV4: UInt16 = 0x1B04 // 异步请求超时保护 private var discoveryTimer: Timer? private var pendingDiscovery: [UInt16: (UInt8?) -> Void] = [:] private static let discoveryTimeout: TimeInterval = 5.0 init(hidDevice: IOHIDDevice) { self.hidDevice = hidDevice self.deviceInfo = MosInputDevice( vendorId: UInt16(IOHIDDeviceGetProperty(hidDevice, kIOHIDVendorIDKey as CFString) as? Int ?? 0), productId: UInt16(IOHIDDeviceGetProperty(hidDevice, kIOHIDProductIDKey as CFString) as? Int ?? 0), name: IOHIDDeviceGetProperty(hidDevice, kIOHIDProductKey as CFString) as? String ?? "Unknown" ) } deinit { reportBufferPtr?.deallocate() } /// 初始化 HID++ 通信: 探测 Device Index -> Feature Discovery -> Button Divert func setup() { // 1. 分配稳定的 report buffer reportBufferPtr = .allocate(capacity: Self.reportBufferSize) reportBufferPtr!.initialize(repeating: 0, count: Self.reportBufferSize) // 2. 注册 Input Report 回调 (使用堆指针) let context = Unmanaged.passUnretained(self).toOpaque() IOHIDDeviceRegisterInputReportCallback( hidDevice, reportBufferPtr!, Self.reportBufferSize, Self.inputReportCallback, context ) // 3. 探测 device index // 蓝牙直连通常是 0x01, Unifying Receiver 按配对槽位 0x01-0x06 // 发送 ping (IRoot.GetFeature with dummy feature) 到候选 index, 看谁响应 probeDeviceIndex { [weak self] index in guard let self = self, let index = index else { NSLog("[LogitechHID] Failed to probe device index, using default 0x01") return } self.deviceIndex = index // 4. Feature Discovery self.discoverFeature(featureId: Self.featureReprogV4) { [weak self] featureIdx in guard let self = self, let featureIdx = featureIdx else { NSLog("[LogitechHID] Device does not support REPROG_CONTROLS_V4, skipping") return } self.featureIndex[Self.featureReprogV4] = featureIdx // 5. Divert 可重编程的按键 self.divertButtons(featureIndex: featureIdx) } } } func teardown() { // 取消超时定时器 discoveryTimer?.invalidate() discoveryTimer = nil pendingDiscovery.removeAll() // 取消所有 divert for cid in divertedCIDs { undivertButton(cid: cid) } divertedCIDs.removeAll() } /// 探测正确的 HID++ device index /// 依次尝试 0x01 ~ 0x06, 发送 IRoot ping, 看哪个 index 收到有效响应 private func probeDeviceIndex(completion: @escaping (UInt8?) -> Void) { // 简化策略: 先尝试 0x01 (蓝牙直连最常见) // 如果超时无响应, 尝试下一个 index // 最多尝试到 0x06 // 完整实现在 LogitechDeviceSession.swift 中 completion(0x01) // 伪代码, 实际需要异步探测 } } ``` ``` ### HID++ 2.0 消息收发 ```swift extension LogitechDeviceSession { // 报文结构 // Short: [ReportID(0x10), DeviceIndex, FeatureIndex, FuncID/SwID, Param0, Param1, Param2] // Long: [ReportID(0x11), DeviceIndex, FeatureIndex, FuncID/SwID, Param0...Param15] /// 发送 HID++ 请求 /// - 使用实例的 deviceIndex (连接时探测确定, 非硬编码) /// - 检查 IOReturn 返回值, 失败时记录日志 private func sendRequest(featureIndex: UInt8, functionId: UInt8, params: [UInt8] = []) { var report = [UInt8](repeating: 0, count: 7) // Short report report[0] = 0x10 // Report ID report[1] = deviceIndex // 探测得到的 device index report[2] = featureIndex report[3] = (functionId << 4) | 0x01 // FuncID | SwID for (i, p) in params.prefix(3).enumerated() { report[4 + i] = p } let result = IOHIDDeviceSetReport( hidDevice, IOHIDReportType(kIOHIDReportTypeOutput), // Swift 需要显式类型转换 CFIndex(report[0]), report, report.count ) if result != kIOReturnSuccess { NSLog("[LogitechHID] SetReport failed: 0x%08x", result) } } /// Feature Discovery: IRoot.GetFeature(featureId) -> featureIndex /// 带超时保护: discoveryTimeout 秒内无响应则回调 nil private func discoverFeature(featureId: UInt16, completion: @escaping (UInt8?) -> Void) { let params: [UInt8] = [UInt8(featureId >> 8), UInt8(featureId & 0xFF)] sendRequest(featureIndex: 0x00, functionId: 0, params: params) pendingDiscovery[featureId] = completion // 超时保护 discoveryTimer = Timer.scheduledTimer(withTimeInterval: Self.discoveryTimeout, repeats: false) { [weak self] _ in guard let self = self else { return } if let pending = self.pendingDiscovery.removeValue(forKey: featureId) { NSLog("[LogitechHID] Feature discovery timed out for 0x%04X", featureId) pending(nil) } } } /// Button Divert: REPROG_CONTROLS_V4.SetControlReporting(CID, divert=1) /// 动态发现始终优先; knownDevices 仅作为调试参考, 不影响 divert 决策 private func divertButtons(featureIndex: UInt8) { queryControlList(featureIndex: featureIndex) { [weak self] controls in for control in controls where control.isDivertable { self?.divertButton(featureIndex: featureIndex, cid: control.cid) } } } } ``` ### 事件解析与分发 ```swift extension LogitechDeviceSession { // C 函数指针, 作为 IOHIDDeviceRegisterInputReportCallback 的 callback static let inputReportCallback: IOHIDReportCallback = { context, result, sender, type, reportID, report, reportLength in guard let context = context else { return } let session = Unmanaged.fromOpaque(context).takeUnretainedValue() let reportData = Array(UnsafeBufferPointer(start: report, count: reportLength)) session.handleInputReport(reportData) } /// 处理 Input Report func handleInputReport(_ report: [UInt8]) { // 验证是 HID++ 报文 guard report.count >= 7, (report[0] == 0x10 || report[0] == 0x11) else { return } let featureIdx = report[2] let funcAndSw = report[3] // HID++ Error Report 检测 (Feature Index = 0xFF) // 格式: [ReportID, DevIdx, 0xFF, ErrorFeatureIdx, ErrorFuncID, SoftwareID, ErrorCode] if featureIdx == 0xFF { let errorFeatureIdx = report[3] let errorCode = report.count > 6 ? report[6] : 0 NSLog("[LogitechHID] Error report: featureIdx=0x%02X errorCode=0x%02X", errorFeatureIdx, errorCode) // 如果有 pending discovery 对应此 feature, 回调 nil handleErrorReport(report) return } // 检查是否为 pending feature discovery 的响应 if featureIdx == 0x00 { handleDiscoveryResponse(report) return } // 检查是否为 REPROG_CONTROLS_V4 通知 guard let reprogIdx = featureIndex[Self.featureReprogV4], featureIdx == reprogIdx else { return } // 解析按键事件 // REPROG_CONTROLS_V4 divertedButtonsEvent 通知格式: // Params: [CID_MSB, CID_LSB, CID_MSB, CID_LSB, ...] (成对 CID, 0x0000 结束) var activeCIDs: Set = [] var offset = 4 while offset + 1 < report.count { let cid = (UInt16(report[offset]) << 8) | UInt16(report[offset + 1]) if cid == 0 { break } activeCIDs.insert(cid) offset += 2 } // 差分检测: 按下/抬起 let newlyPressed = activeCIDs.subtracting(lastActiveCIDs) let newlyReleased = lastActiveCIDs.subtracting(activeCIDs) lastActiveCIDs = activeCIDs for cid in newlyPressed { dispatchButtonEvent(cid: cid, isDown: true) } for cid in newlyReleased { dispatchButtonEvent(cid: cid, isDown: false) } } private func dispatchButtonEvent(cid: UInt16, isDown: Bool) { // 使用 CGEventSource.flagsState 获取修饰键 (比 NSEvent.current 更可靠) // NSEvent.current 只在 sendEvent 期间有效, IOKit 回调中可能为 nil // CGEventSource.flagsState(.combinedSessionState) 在 macOS 10.13+ 可用 let currentFlags = CGEventSource.flagsState(.combinedSessionState) let mosEvent = MosInputEvent( type: .mouse, code: LogitechCIDMap.toMosCode(cid), modifiers: currentFlags, phase: isDown ? .down : .up, source: .hidPlusPlus, device: deviceInfo ) // 处理结果: HID++ 事件无 pass-through 语义 let _ = MosInputProcessor.shared.process(mosEvent) // 同时发送通知 (供 KeyRecorder 录制期间监听) NotificationCenter.default.post( name: LogitechHIDManager.buttonEventNotification, object: mosEvent ) } } ``` ### 设备兼容性策略 ```swift /// 已知设备的按键定义 (可通过 plist/JSON 配置文件外置, 未来不用改代码) struct LogitechDeviceProfile { let productId: UInt16 let name: String let divertableCIDs: [UInt16] // 需要 divert 的 CID 列表 static let knownDevices: [UInt16: LogitechDeviceProfile] = [ 0xB034: LogitechDeviceProfile( productId: 0xB034, name: "MX Master 3S", divertableCIDs: [0x00C3, 0x00C4, 0x00D7] ), // 后续设备在此追加 ] } ``` **未知设备处理**: 对于 `knownDevices` 中没有的 Logitech 设备,使用动态发现: 1. 检查设备是否支持 REPROG_CONTROLS_V4 feature 2. 如果支持,查询其可 divert 的按键列表 3. 自动 divert 所有标记为 divertable 的按键 这意味着未来新 Logitech 设备只要支持 HID++ 2.0 和 REPROG_CONTROLS_V4,无需修改代码即可自动兼容。 **优先级**: 动态发现始终优先。`knownDevices` 仅作为调试参考和显示名称来源,不影响 divert 决策。如果动态发现返回的可 divert 按键多于 `knownDevices` 中定义的,按动态发现结果执行。 --- ## 应用生命周期集成 ```swift // AppDelegate 或等效的启动入口 func applicationDidFinishLaunching() { // ... 现有初始化 ... ScrollCore.shared.enable() ButtonCore.shared.enable() LogitechHIDManager.shared.start() // 在 ButtonCore 之后启动 } func applicationWillTerminate() { LogitechHIDManager.shared.stop() // 在 ButtonCore 之前停止 ButtonCore.shared.disable() ScrollCore.shared.disable() } ``` LogitechHIDManager 的启停独立于 ButtonCore,但顺序上: - 启动时: ButtonCore 先启用 (确保 MosInputProcessor 的下游就绪), 然后启动 LogitechHID - 停止时: 先停 LogitechHID (停止产生事件), 然后停 ButtonCore --- ## 状态隔离 ### 状态归属矩阵 | 模块 | 拥有的状态 | 不知道的事 | |------|-----------|----------| | **CGEventTap Adapter** (ButtonCore) | Interceptor 实例、EventTap 生命周期 | LogitechHIDManager 存在 | | **LogitechHIDManager** | IOHIDManager、已连接设备列表 | CGEventTap 存在、ButtonBinding 配置 | | **LogitechDeviceSession** | HID++ 会话状态、feature index 缓存、divert 状态、lastActiveCIDs | 处理层如何消费事件 | | **MosInputProcessor** | 无状态 | 事件来自哪里 | | **Options / ButtonUtils** | ButtonBinding 持久化配置 | 事件如何产生 | | **KeyRecorder** | 录制会话状态 | 设备协议实现 | | **ShortcutExecutor** | 无状态 | 触发来源 | ### 依赖方向 (单向, 无环) ``` LogitechHIDManager --+ +--> MosInputProcessor --> ButtonUtils/Options --> ShortcutExecutor CGEventTap Adapter --+ ``` ### 线程模型 ``` Main RunLoop +-- CGEventTap 回调 (系统调度, 同步) +-- IOKit HIDManager 回调 (schedule 到 Main RunLoop, 同步) +-- MosInputProcessor.process() (被上述两者同步调用, 无锁竞争) ``` IOKit schedule 到 Main RunLoop 的理由: - HID++ 按键事件极低频(用户按键级别, 每秒个位数) - 避免跨线程同步复杂性 - ShortcutExecutor 内部的 CGEvent.post() 需要在 main thread --- ## KeyRecorder 适配 ### 改动方案 KeyRecorder 需要同时捕获 CGEventTap 和 HID++ 两种事件源: ```swift class KeyRecorder: NSObject { // 新增: HID++ 事件监听 (录制期间临时启用) private var hidEventObserver: NSObjectProtocol? func startRecording(from sourceView: NSView, mode: KeyRecordingMode = .combination) { // ... 现有 CGEventTap interceptor 启动逻辑不变 ... // 新增: 监听 HID++ 事件 hidEventObserver = NotificationCenter.default.addObserver( forName: LogitechHIDManager.buttonEventNotification, object: nil, queue: .main ) { [weak self] notification in guard let mosEvent = notification.object as? MosInputEvent else { return } // 复用现有的录制完成通知机制 NotificationCenter.default.post( name: KeyRecorder.FINISH_NOTI_NAME, object: mosEvent // 注意: 这里传的是 MosInputEvent 而非 CGEvent ) } } func stopRecording() { // ... 现有清理逻辑 ... // 新增: 移除 HID++ 监听 if let observer = hidEventObserver { NotificationCenter.default.removeObserver(observer) hidEventObserver = nil } } } ``` ### Delegate 协议变更 **问题**: 现有 `KeyRecorderDelegate` 是 `@objc protocol` (因为使用了 `@objc optional`)。但 `MosInputEvent` 是 struct,不能在 `@objc` 方法中使用。 **解决方案**: 去掉 `@objc`,用 protocol extension 提供默认实现替代 `@objc optional`: ```swift // 改造前: // @objc protocol KeyRecorderDelegate: AnyObject { // func onEventRecorded(_ recorder: KeyRecorder, didRecordEvent event: CGEvent, isDuplicate: Bool) // @objc optional func validateRecordedEvent(_ recorder: KeyRecorder, event: CGEvent) -> Bool // } // 改造后: protocol KeyRecorderDelegate: AnyObject { func onEventRecorded(_ recorder: KeyRecorder, didRecordEvent event: MosInputEvent, isDuplicate: Bool) func validateRecordedEvent(_ recorder: KeyRecorder, event: MosInputEvent) -> Bool } // 默认实现 (替代 @objc optional 语义) extension KeyRecorderDelegate { func validateRecordedEvent(_ recorder: KeyRecorder, event: MosInputEvent) -> Bool { return true // 默认: 视为新录制 } } ``` ### handleRecordedEvent 改造 现有代码第 208 行使用 `notification.object as! CGEvent` 强制转换。HID++ 事件作为 MosInputEvent 通过同一 notification 传入时会崩溃。改造后的完整逻辑: ```swift @objc private func handleRecordedEvent(_ notification: NSNotification) { guard isRecording, !isRecorded else { return } // 统一转换为 MosInputEvent (兼容两种来源) let mosEvent: MosInputEvent if let cgEvent = notification.object as? CGEvent { mosEvent = MosInputEvent(fromCGEvent: cgEvent) } else if let hidEvent = notification.object as? MosInputEvent { mosEvent = hidEvent } else { NSLog("[EventRecorder] Unknown event type: \(type(of: notification.object))") return } // 验证有效性 (根据录制模式) let isValid = recordingMode == .singleKey ? isRecordableAsSingleKey(mosEvent) // 需要新增 MosInputEvent 版本的验证 : isRecordable(mosEvent) guard isValid else { keyPopover?.keyPreview.shakeWarning() invalidKeyPressCount += 1 if invalidKeyPressCount >= invalidKeyThreshold { keyPopover?.showEscHint() } return } isRecorded = true let isNew = self.delegate?.validateRecordedEvent(self, event: mosEvent) ?? true let isDuplicate = !isNew let status: KeyPreview.Status = isNew ? .recorded : .duplicate keyPopover?.keyPreview .update(from: MosInputEvent.buildDisplayComponents(mosEvent), status: status) self.delegate?.onEventRecorded(self, didRecordEvent: mosEvent, isDuplicate: isDuplicate) DispatchQueue.main.asyncAfter(deadline: .now() + 0.7) { [weak self] in self?.stopRecording() } } ``` **注意**: `MosInputEvent` 是 struct (不继承 NSObject),通过 `NotificationCenter` 传递时需要注意: `notification.object` 会被 box 为 `Any?`。使用 `as?` 类型检查即可安全判断。如果 box 行为有问题,备选方案是将 MosInputEvent 包装在一个轻量 NSObject wrapper class 中。 --- ## 现有功能兼容性 | 功能 | 影响 | 说明 | |------|------|------| | 滚动平滑 (ScrollCore) | 无 | 不改, 继续用 CGEventTap | | 滚动翻转 | 无 | 同上 | | 滚动热键 (dash/toggle/block) | 无 | 继续用 ScrollHotkey + CGEvent 匹配 | | 按钮绑定 (ButtonCore) | 主要改动 | callback 内转为 MosInputProcessor | | 分应用配置 | 无 | Application.buttons 数据结构已就绪 | | Remote 检测 | 无 | 只作用于 scrollWheel CGEvent | | Trackpad 过滤 | 无 | 只作用于 scrollWheel CGEvent | | Monitor 窗口 | 无 | 只可视化滚动数据 | | 快捷键执行 | 无 | ShortcutExecutor 接口不变 | | KeyRecorder | 需适配 | delegate 参数类型变更 | | 配置持久化 | 向后兼容 | RecordedEvent 新增 optional 字段 | ### ButtonFilter 与 HID++ 事件 当前 `ButtonFilter` 存在但未被 `ButtonCore.buttonEventCallBack` 调用(是预留接口)。`MosInputProcessor` 同样不集成 ButtonFilter。如果未来 ButtonFilter 被启用,需要同时将其应用于 HID++ 事件路径。目前两者保持一致: 都不经过 ButtonFilter。 ### Phase 2: ScrollCore 热键统一 当前 ScrollCore 的热键系统 (dash/toggle/block) 使用 `ScrollHotkey.matches(_ event: CGEvent)` 直接匹配 CGEvent。如果未来用户需要将 Logitech 手势按钮用作滚动热键,需要在 LogitechHIDManager 和 ScrollCore 之间增加桥接层,将 HID++ 按键事件转化为 ScrollCore 的热键状态更新。这不在本次设计范围内。 ### UserDefaults 数据兼容 RecordedEvent 新增 `deviceFilter: DeviceFilter?`: - 旧数据反序列化时该字段缺失 -> `decodeIfPresent` 返回 nil -> 匹配任何设备 -> 行为与改动前完全一致 - 无需数据迁移逻辑 --- ## 文件变更清单 ### 新增文件 (4个) | 文件 | 位置 | 职责 | |------|------|------| | `MosInputEvent.swift` | `Mos/InputEvent/` | MosInputEvent, MosInputPhase, MosInputSource, MosInputDevice, DeviceFilter | | `MosInputProcessor.swift` | `Mos/InputEvent/` | MosInputProcessor, MosInputResult | | `LogitechHIDManager.swift` | `Mos/LogitechHID/` | IOKit HIDManager 封装, 设备枚举, 生命周期 | | `LogitechDeviceSession.swift` | `Mos/LogitechHID/` | HID++ 2.0 协议通信, Feature Discovery, Button Divert, 事件解析 | ### 修改文件 (6个) | 文件 | 变更内容 | |------|---------| | `RecordedEvent.swift` | 新增 `deviceFilter` 字段 + `matchesMosInput()` + `init(from: MosInputEvent)` + `ScrollHotkey.init(from: MosInputEvent)` | | `ButtonCore.swift` | callback 内部改为构造 MosInputEvent 并调用 MosInputProcessor | | `KeyRecorder.swift` | delegate 从 `@objc protocol` 改为 Swift protocol + extension 默认实现; 参数类型改为 MosInputEvent; `handleRecordedEvent` 增加类型分支; 录制时增加 HID++ 监听 | | `KeyCode.swift` | 新增 Logitech 按钮码显示名称映射 (1000+ 段) | | `PreferencesButtonsViewController` | delegate 方法签名跟随变更, `RecordedEvent(from: MosInputEvent)` | | `PreferencesScrollingViewController` | delegate 方法签名跟随变更, `ScrollHotkey(from: MosInputEvent)` | ### 不修改文件 ScrollCore, ScrollPoster, Interpolator, ScrollFilter, ScrollUtils, ScrollEvent, Interceptor, ShortcutExecutor, SystemShortcut, ButtonFilter, ButtonUtils, Application, Options, Monitor 窗口 --- ## 风险与缓解 ### 1. Logitech Options+ 冲突 **风险**: Logitech Options+ 也会访问 HID++ 设备,两者争夺 HID 句柄。 **缓解**: IOHIDManager 的 `kIOHIDOptionsTypeNone` 模式允许多个进程同时打开设备。但 divert 操作可能冲突 -- 需要在文档中告知用户: 如使用 Mos 的 Logitech 按键绑定功能,建议退出 Logitech Options+。 ### 2. 设备兼容性 **风险**: 不同 Logitech 设备的 HID++ 实现细节可能不同。 **缓解**: 使用动态 Feature Discovery 而非硬编码 feature index。对于不支持 REPROG_CONTROLS_V4 的设备,graceful fallback -- 不 divert 任何按键,不影响 CGEventTap 路径。 ### 3. 蓝牙 vs USB Receiver **风险**: Unifying Receiver 和蓝牙连接的设备在 HID 层的表现不同。 **缓解**: Unifying Receiver 作为一个复合 HID 设备出现,需要正确处理 device index。初版优先支持蓝牙直连(更简单),USB Receiver 作为后续增强。 ### 4. macOS 版本兼容 **风险**: IOKit API 在不同 macOS 版本上的行为。 **缓解**: IOHIDManager 从 macOS 10.5 就存在,API 稳定。项目最低支持 10.13,完全兼容。 ### 5. 权限 **风险**: 访问 HID 设备可能需要额外权限。 **缓解**: Mos 已经要求 Accessibility 权限。IOHIDManager 访问 HID 设备在 macOS 上通常不需要额外权限(除了某些 kext 级别的操作)。需要实际测试确认。 ================================================ FILE: tools/hidpp-divert-debug.swift ================================================ #!/usr/bin/env swift // 对比测试: 批量 divert vs 顺序 divert, 并验证 divert 状态 import Foundation import IOKit import IOKit.hid let VID = 0x046D func hex(_ d: [UInt8], n: Int? = nil) -> String { d.prefix(n ?? d.count).map { String(format: "%02X", $0) }.joined(separator: " ") } class Ctx { var reports: [[UInt8]] = [] } let ctx = Ctx() let ctxPtr = Unmanaged.passRetained(ctx).toOpaque() let cb: IOHIDReportCallback = { c, _, _, _, _, r, l in guard let c = c else { return } let x = Unmanaged.fromOpaque(c).takeUnretainedValue() let d = Array(UnsafeBufferPointer(start: r, count: l)) if d.count >= 7 && (d[0] == 0x10 || d[0] == 0x11) { x.reports.append(d) } } let mgr = IOHIDManagerCreate(kCFAllocatorDefault, IOOptionBits(kIOHIDOptionsTypeNone)) IOHIDManagerSetDeviceMatching(mgr, [kIOHIDVendorIDKey as String: VID] as CFDictionary) IOHIDManagerScheduleWithRunLoop(mgr, CFRunLoopGetCurrent(), CFRunLoopMode.defaultMode.rawValue) IOHIDManagerOpen(mgr, IOOptionBits(kIOHIDOptionsTypeNone)) RunLoop.current.run(until: Date(timeIntervalSinceNow: 0.5)) guard let devs = IOHIDManagerCopyDevices(mgr) as? Set, let dev = devs.first(where: { (IOHIDDeviceGetProperty($0, kIOHIDTransportKey as CFString) as? String ?? "").contains("Bluetooth") }) else { print("No BLE device"); exit(1) } print("Device: \(IOHIDDeviceGetProperty(dev, kIOHIDProductKey as CFString) as? String ?? "?")") IOHIDDeviceOpen(dev, IOOptionBits(kIOHIDOptionsTypeNone)) let buf = UnsafeMutablePointer.allocate(capacity: 64) buf.initialize(repeating: 0, count: 64) IOHIDDeviceRegisterInputReportCallback(dev, buf, 64, cb, ctxPtr) func send(_ d: [UInt8]) { IOHIDDeviceSetReport(dev, kIOHIDReportTypeOutput, CFIndex(d[0]), d, d.count) } func pkt(_ fi: UInt8, _ fn: UInt8, _ p: [UInt8] = []) -> [UInt8] { var r = [UInt8](repeating: 0, count: 20); r[0] = 0x11; r[1] = 0xFF; r[2] = fi; r[3] = (fn << 4) | 0x01 for (i, v) in p.prefix(16).enumerated() { r[4+i] = v }; return r } func wait(_ t: TimeInterval = 2.0) -> [UInt8]? { ctx.reports.removeAll() let dl = Date(timeIntervalSinceNow: t) while Date() < dl && ctx.reports.isEmpty { RunLoop.current.run(until: Date(timeIntervalSinceNow: 0.05)) } return ctx.reports.first } // Step 1: Discover REPROG print("\n=== Feature Discovery ===") send(pkt(0x00, 0, [0x1B, 0x04])) guard let r = wait(), r[4] != 0 else { print("REPROG not found"); exit(1) } let reprogIdx = r[4] print("REPROG at index 0x\(String(format: "%02X", reprogIdx))") // Step 2: Get control count send(pkt(reprogIdx, 0)) guard let cr = wait() else { print("No count response"); exit(1) } let count = Int(cr[4]) print("Controls: \(count)") // Step 3: Enumerate divertable controls struct Ctrl { let cid: UInt16; let name: String; let divertable: Bool } var ctrls: [Ctrl] = [] let names: [UInt16: String] = [0x0052:"Middle",0x0053:"Back",0x0056:"Forward",0x00C3:"Gesture",0x00C4:"SmartShift",0x00D7:"DPI"] for i in 0..> 8), UInt8(c.cid & 0xFF), 0x01])) let ack = wait(1.0) print(" Divert \(c.name): \(ack != nil ? "ACK" : "timeout")") } // Step 5: 验证 divert 状态 (GetControlReporting, function 2) print("\n=== Verify divert status (GetControlReporting) ===") for c in divertable { send(pkt(reprogIdx, 2, [UInt8(c.cid >> 8), UInt8(c.cid & 0xFF)])) if let vr = wait(1.0) { let reportedFlags = vr[6] let isDiverted = (reportedFlags & 0x01) != 0 print(" \(c.name) (CID=\(String(format: "0x%04X", c.cid))): flags=0x\(String(format: "%02X", reportedFlags)) diverted=\(isDiverted)") } else { print(" \(c.name): no response") } } // Step 6: 捕获按键 (10 秒) print("\n=== Button capture (10s) - PRESS BUTTONS NOW ===") ctx.reports.removeAll() var lastCIDs: Set = [] let end = Date(timeIntervalSinceNow: 10.0) while Date() < end { RunLoop.current.run(until: Date(timeIntervalSinceNow: 0.05)) for rpt in ctx.reports { if rpt[2] == reprogIdx { let fn = rpt[3] >> 4 if fn == 0 { // divertedButtonsEvent var active: Set = [] var off = 4 while off + 1 < rpt.count { let c = (UInt16(rpt[off]) << 8) | UInt16(rpt[off+1]); if c == 0 { break }; active.insert(c); off += 2 } for c in active.subtracting(lastCIDs) { print(" DOWN: \(names[c] ?? "?") (0x\(String(format: "%04X", c)))") } for c in lastCIDs.subtracting(active) { print(" UP: \(names[c] ?? "?") (0x\(String(format: "%04X", c)))") } lastCIDs = active } else { print(" [func\(fn)] \(hex(rpt, n: 10))") } } else { let fi = rpt[2] print(" [feat=0x\(String(format: "%02X", fi))] \(hex(rpt, n: 10))") } } ctx.reports.removeAll() } // Step 7: Undivert print("\n=== Undivert ===") for c in divertable { send(pkt(reprogIdx, 3, [UInt8(c.cid >> 8), UInt8(c.cid & 0xFF), 0x00])) let _ = wait(0.5) } print("Done") buf.deallocate() IOHIDDeviceClose(dev, IOOptionBits(kIOHIDOptionsTypeNone)) IOHIDManagerClose(mgr, IOOptionBits(kIOHIDOptionsTypeNone)) ================================================ FILE: tools/hidpp-divert-fix.swift ================================================ #!/usr/bin/env swift // 对比: CID+flags+0000 vs CID+flags+CID (self-mapping) import Foundation import IOKit import IOKit.hid let VID = 0x046D class Ctx { var reports: [[UInt8]] = [] } let ctx = Ctx() let ctxPtr = Unmanaged.passRetained(ctx).toOpaque() let cb: IOHIDReportCallback = { c, _, _, _, _, r, l in guard let c = c else { return } let x = Unmanaged.fromOpaque(c).takeUnretainedValue() let d = Array(UnsafeBufferPointer(start: r, count: l)) if d.count >= 7 && (d[0] == 0x10 || d[0] == 0x11) { x.reports.append(d) } } let mgr = IOHIDManagerCreate(kCFAllocatorDefault, IOOptionBits(kIOHIDOptionsTypeNone)) IOHIDManagerSetDeviceMatching(mgr, [kIOHIDVendorIDKey as String: VID] as CFDictionary) IOHIDManagerScheduleWithRunLoop(mgr, CFRunLoopGetCurrent(), CFRunLoopMode.defaultMode.rawValue) IOHIDManagerOpen(mgr, IOOptionBits(kIOHIDOptionsTypeNone)) RunLoop.current.run(until: Date(timeIntervalSinceNow: 0.5)) guard let devs = IOHIDManagerCopyDevices(mgr) as? Set, let dev = devs.first(where: { (IOHIDDeviceGetProperty($0, kIOHIDTransportKey as CFString) as? String ?? "").contains("Bluetooth") }) else { print("No BLE device"); exit(1) } print("Device: \(IOHIDDeviceGetProperty(dev, kIOHIDProductKey as CFString) as? String ?? "?")") IOHIDDeviceOpen(dev, IOOptionBits(kIOHIDOptionsTypeNone)) let buf = UnsafeMutablePointer.allocate(capacity: 64) buf.initialize(repeating: 0, count: 64) IOHIDDeviceRegisterInputReportCallback(dev, buf, 64, cb, ctxPtr) func send(_ d: [UInt8]) { IOHIDDeviceSetReport(dev, kIOHIDReportTypeOutput, CFIndex(d[0]), d, d.count) } func pkt(_ fi: UInt8, _ fn: UInt8, _ p: [UInt8] = []) -> [UInt8] { var r = [UInt8](repeating: 0, count: 20); r[0] = 0x11; r[1] = 0xFF; r[2] = fi; r[3] = (fn << 4) | 0x01 for (i, v) in p.prefix(16).enumerated() { r[4+i] = v }; return r } func wait(_ t: TimeInterval = 2.0) -> [UInt8]? { ctx.reports.removeAll() let dl = Date(timeIntervalSinceNow: t) while Date() < dl && ctx.reports.isEmpty { RunLoop.current.run(until: Date(timeIntervalSinceNow: 0.05)) } return ctx.reports.first } let names: [UInt16: String] = [0x0052:"Middle",0x0053:"Back",0x0056:"Forward",0x00C3:"Gesture",0x00C4:"SmartShift",0x00D7:"DPI"] // Discover send(pkt(0x00, 0, [0x1B, 0x04])) guard let r = wait(), r[4] != 0 else { print("REPROG not found"); exit(1) } let ri = r[4] print("REPROG at 0x\(String(format: "%02X", ri))") // Test CID: SmartShift (0x00C4) - 最容易验证 (按下后滚轮应该不再切换) let cid: UInt16 = 0x00C4 let cidH = UInt8(cid >> 8) let cidL = UInt8(cid & 0xFF) print("\n=== Test 1: SetControlReporting with targetCID=0x0000 (current method) ===") send(pkt(ri, 3, [cidH, cidL, 0x01, 0x00, 0x00])) // CID + flags + target=0x0000 let _ = wait(1.0) // Verify send(pkt(ri, 2, [cidH, cidL])) // GetControlReporting if let vr = wait(1.0) { let flags = vr[6] let target = (UInt16(vr[7]) << 8) | UInt16(vr[8]) print(" GetControlReporting: flags=0x\(String(format: "%02X", flags)) target=0x\(String(format: "%04X", target)) diverted=\((flags & 0x01) != 0)") } // Reset send(pkt(ri, 3, [cidH, cidL, 0x00, 0x00, 0x00])) let _ = wait(0.5) print("\n=== Test 2: SetControlReporting with targetCID=same CID (Solaar method) ===") send(pkt(ri, 3, [cidH, cidL, 0x01, cidH, cidL])) // CID + flags + target=same CID let _ = wait(1.0) // Verify send(pkt(ri, 2, [cidH, cidL])) if let vr = wait(1.0) { let flags = vr[6] let target = (UInt16(vr[7]) << 8) | UInt16(vr[8]) print(" GetControlReporting: flags=0x\(String(format: "%02X", flags)) target=0x\(String(format: "%04X", target)) diverted=\((flags & 0x01) != 0)") } print("\n=== Test 3: SetControlReporting with flags=0x03 (divert + persistDivert) ===") send(pkt(ri, 3, [cidH, cidL, 0x03, cidH, cidL])) let _ = wait(1.0) send(pkt(ri, 2, [cidH, cidL])) if let vr = wait(1.0) { let flags = vr[6] let target = (UInt16(vr[7]) << 8) | UInt16(vr[8]) print(" GetControlReporting: flags=0x\(String(format: "%02X", flags)) target=0x\(String(format: "%04X", target)) diverted=\((flags & 0x01) != 0)") } print("\n=== Test 4: Read current mapping FIRST, then modify flags only ===") // First read current state send(pkt(ri, 2, [cidH, cidL])) if let current = wait(1.0) { let curFlags = current[6] let curTarget = [current[7], current[8]] print(" Current: flags=0x\(String(format: "%02X", curFlags)) target=0x\(String(format: "%02X%02X", curTarget[0], curTarget[1]))") // Set divert bit ON, preserve existing target let newFlags = curFlags | 0x01 send(pkt(ri, 3, [cidH, cidL, newFlags, curTarget[0], curTarget[1]])) let _ = wait(1.0) // Verify send(pkt(ri, 2, [cidH, cidL])) if let vr = wait(1.0) { let flags = vr[6] let target = (UInt16(vr[7]) << 8) | UInt16(vr[8]) print(" After divert: flags=0x\(String(format: "%02X", flags)) target=0x\(String(format: "%04X", target)) diverted=\((flags & 0x01) != 0)") } } // If any test set diverted=true, capture buttons for 10s print("\n=== Button capture (10s) - PRESS SmartShift BUTTON ===") ctx.reports.removeAll() var lastCIDs: Set = [] let end = Date(timeIntervalSinceNow: 10.0) while Date() < end { RunLoop.current.run(until: Date(timeIntervalSinceNow: 0.05)) for rpt in ctx.reports { if rpt[2] == ri && (rpt[3] >> 4) == 0 { var active: Set = [] var off = 4 while off + 1 < rpt.count { let c = (UInt16(rpt[off]) << 8) | UInt16(rpt[off+1]); if c == 0 { break }; active.insert(c); off += 2 } for c in active.subtracting(lastCIDs) { let n = names[c] ?? String(format: "0x%04X", c); print(" DOWN: \(n)") } for c in lastCIDs.subtracting(active) { let n = names[c] ?? String(format: "0x%04X", c); print(" UP: \(n)") } lastCIDs = active } else { print(" [feat=0x\(String(format: "%02X", rpt[2])) fn=\(rpt[3] >> 4)] \(rpt.prefix(10).map { String(format: "%02X", $0) }.joined(separator: " "))") } } ctx.reports.removeAll() } // Cleanup send(pkt(ri, 3, [cidH, cidL, 0x00, cidH, cidL])) let _ = wait(0.5) print("\nDone") buf.deallocate() IOHIDDeviceClose(dev, IOOptionBits(kIOHIDOptionsTypeNone)) IOHIDManagerClose(mgr, IOOptionBits(kIOHIDOptionsTypeNone)) ================================================ FILE: tools/hidpp-full-test.swift ================================================ #!/usr/bin/env swift // HID++ 2.0 全量测试 - 一次运行覆盖: 发现 → 枚举 → Divert → 捕获按键 // swift tools/hidpp-full-test.swift import Foundation import IOKit import IOKit.hid import CoreGraphics let LOGITECH_VID = 0x046D func hex(_ data: [UInt8], n: Int? = nil) -> String { data.prefix(n ?? data.count).map { String(format: "%02X", $0) }.joined(separator: " ") } // MARK: - Report context class Ctx { var reports: [[UInt8]] = [] func clear() { reports.removeAll() } func waitForHIDPP(timeout: TimeInterval = 3.0) -> [UInt8]? { let deadline = Date(timeIntervalSinceNow: timeout) while Date() < deadline && reports.isEmpty { RunLoop.current.run(until: Date(timeIntervalSinceNow: 0.05)) } return reports.first } } let ctx = Ctx() let ctxPtr = Unmanaged.passRetained(ctx).toOpaque() let rxCallback: IOHIDReportCallback = { context, _, _, _, _, report, len in guard let context = context else { return } let c = Unmanaged.fromOpaque(context).takeUnretainedValue() let data = Array(UnsafeBufferPointer(start: report, count: len)) if data.count >= 7 && (data[0] == 0x10 || data[0] == 0x11) { c.reports.append(data) } } // MARK: - Enumerate & find BLE device let mgr = IOHIDManagerCreate(kCFAllocatorDefault, IOOptionBits(kIOHIDOptionsTypeNone)) IOHIDManagerSetDeviceMatching(mgr, [kIOHIDVendorIDKey as String: LOGITECH_VID] as CFDictionary) IOHIDManagerScheduleWithRunLoop(mgr, CFRunLoopGetCurrent(), CFRunLoopMode.defaultMode.rawValue) IOHIDManagerOpen(mgr, IOOptionBits(kIOHIDOptionsTypeNone)) RunLoop.current.run(until: Date(timeIntervalSinceNow: 0.5)) guard let devs = IOHIDManagerCopyDevices(mgr) as? Set, let bleDev = devs.first(where: { (IOHIDDeviceGetProperty($0, kIOHIDTransportKey as CFString) as? String ?? "").contains("Bluetooth") }) else { print("FAIL: No BLE Logitech device"); exit(1) } let devName = IOHIDDeviceGetProperty(bleDev, kIOHIDProductKey as CFString) as? String ?? "?" print("Device: \(devName)") IOHIDDeviceOpen(bleDev, IOOptionBits(kIOHIDOptionsTypeNone)) let buf = UnsafeMutablePointer.allocate(capacity: 64) buf.initialize(repeating: 0, count: 64) IOHIDDeviceRegisterInputReportCallback(bleDev, buf, 64, rxCallback, ctxPtr) // MARK: - hidapi-compatible send func send(_ data: [UInt8]) -> IOReturn { return IOHIDDeviceSetReport(bleDev, kIOHIDReportTypeOutput, CFIndex(data[0]), data, data.count) } func sendAndReceive(_ data: [UInt8], timeout: TimeInterval = 3.0) -> [UInt8]? { ctx.clear() let r = send(data) guard r == kIOReturnSuccess else { print(" TX FAILED: \(String(format: "0x%08x", r))") return nil } return ctx.waitForHIDPP(timeout: timeout) } func makePacket(featureIdx: UInt8, funcId: UInt8, params: [UInt8] = []) -> [UInt8] { var pkt = [UInt8](repeating: 0, count: 20) pkt[0] = 0x11 pkt[1] = 0xFF // BLE device index pkt[2] = featureIdx pkt[3] = (funcId << 4) | 0x01 for (i, p) in params.prefix(16).enumerated() { pkt[4 + i] = p } return pkt } // ============================================================ print("\n========== PHASE 1: IRoot Ping ==========") let pingPkt = makePacket(featureIdx: 0x00, funcId: 1) if let resp = sendAndReceive(pingPkt) { print("OK: HID++ Protocol \(resp[4]).\(resp[5])") } else { print("FAIL: No ping response - aborting") exit(1) } // ============================================================ print("\n========== PHASE 2: Feature Discovery ==========") let featureIds: [(UInt16, String)] = [ (0x0001, "FEATURE_SET"), (0x1B04, "REPROG_CONTROLS_V4"), (0x2110, "SMART_SHIFT"), (0x2201, "ADJUSTABLE_DPI"), (0x1000, "BATTERY_STATUS"), (0x0003, "DEVICE_FW_VERSION"), (0x0005, "DEVICE_NAME"), ] var featureMap: [UInt16: UInt8] = [:] for (fid, fname) in featureIds { let pkt = makePacket(featureIdx: 0x00, funcId: 0, params: [UInt8(fid >> 8), UInt8(fid & 0xFF)]) if let resp = sendAndReceive(pkt, timeout: 2.0) { let idx = resp[4] if idx != 0 { featureMap[fid] = idx print(" \(fname) (0x\(String(format: "%04X", fid))) -> index 0x\(String(format: "%02X", idx))") } else { print(" \(fname) (0x\(String(format: "%04X", fid))) -> NOT SUPPORTED") } } else { print(" \(fname) -> NO RESPONSE") } } // ============================================================ print("\n========== PHASE 3: Device Name ==========") if let nameIdx = featureMap[0x0005] { // GetDeviceNameCount (func 0) let countPkt = makePacket(featureIdx: nameIdx, funcId: 0) if let resp = sendAndReceive(countPkt) { let nameLen = Int(resp[4]) var nameBytes: [UInt8] = [] // GetDeviceName (func 1, param = offset) var offset = 0 while offset < nameLen { let namePkt = makePacket(featureIdx: nameIdx, funcId: 1, params: [UInt8(offset)]) if let resp = sendAndReceive(namePkt, timeout: 1.0) { let chunk = Array(resp[4.. 12 ? resp[12] : 0 let name = cidNames[cid] ?? "Unknown" let ctrl = ControlInfo(index: i, cid: cid, taskId: taskId, flags1: f1, flags2: f2, name: name) controls.append(ctrl) let flagStr = [ ctrl.isReprogrammable ? "reprog" : nil, ctrl.isDivertable ? "DIVERTABLE" : nil, ctrl.isPersistDivert ? "persist" : nil, ].compactMap { $0 }.joined(separator: ",") print(" [\(i)] CID=0x\(String(format: "%04X", cid)) \(name.padding(toLength: 18, withPad: " ", startingAt: 0)) flags1=0x\(String(format: "%02X", f1)) [\(flagStr)]") } // ============================================================ print("\n========== PHASE 6: Divert All Divertable Controls ==========") let divertable = controls.filter { $0.isDivertable } print(" Divertable: \(divertable.count)/\(controls.count)") for ctrl in divertable { // SetControlReporting: func 3, params = CID(2) + flags(1) // flag bit 0 = temporaryDivert let divertPkt = makePacket(featureIdx: reprogIdx, funcId: 3, params: [UInt8(ctrl.cid >> 8), UInt8(ctrl.cid & 0xFF), 0x01]) let divertResp = sendAndReceive(divertPkt, timeout: 1.0) print(" Divert CID=0x\(String(format: "%04X", ctrl.cid)) (\(ctrl.name)): \(divertResp != nil ? "OK" : "no ack")") } // ============================================================ print("\n========== PHASE 7: Button Capture (20 seconds) ==========") print(">>> 请在 20 秒内按下鼠标上的所有按键 (中键/前进/后退/手势/SmartShift/DPI) <<<\n") ctx.clear() let captureEnd = Date(timeIntervalSinceNow: 20.0) var lastCIDs: Set = [] var capturedEvents: [(String, UInt16, String)] = [] // (direction, cid, name) while Date() < captureEnd { RunLoop.current.run(until: Date(timeIntervalSinceNow: 0.05)) for report in ctx.reports { // 只处理来自 REPROG_CONTROLS_V4 的事件 guard report[2] == reprogIdx else { // 其他 feature 的通知也记录 let feat = report[2] let funcId = report[3] >> 4 print(" [Other] Feature=0x\(String(format: "%02X", feat)) Func=\(funcId) Data=\(hex(report, n: 12))") continue } // 解析 CID pairs var activeCIDs: Set = [] var offset = 4 while offset + 1 < report.count { let cid = (UInt16(report[offset]) << 8) | UInt16(report[offset + 1]) if cid == 0 { break } activeCIDs.insert(cid) offset += 2 } let pressed = activeCIDs.subtracting(lastCIDs) let released = lastCIDs.subtracting(activeCIDs) lastCIDs = activeCIDs for cid in pressed { let name = cidNames[cid] ?? "Unknown(0x\(String(format: "%04X", cid)))" print(" >>> BUTTON DOWN: CID=0x\(String(format: "%04X", cid)) = \(name)") capturedEvents.append(("DOWN", cid, name)) } for cid in released { let name = cidNames[cid] ?? "Unknown(0x\(String(format: "%04X", cid)))" print(" >>> BUTTON UP: CID=0x\(String(format: "%04X", cid)) = \(name)") capturedEvents.append(("UP", cid, name)) } } ctx.clear() } // ============================================================ print("\n========== PHASE 8: Undivert ==========") for ctrl in divertable { let undivertPkt = makePacket(featureIdx: reprogIdx, funcId: 3, params: [UInt8(ctrl.cid >> 8), UInt8(ctrl.cid & 0xFF), 0x00]) let _ = sendAndReceive(undivertPkt, timeout: 1.0) } print(" All controls undiverted") // ============================================================ print("\n========== RESULTS ==========") print("Device: \(devName)") print("Controls: \(controlCount) total, \(divertable.count) divertable") print("Captured events: \(capturedEvents.count)") if capturedEvents.isEmpty { print("\n NO BUTTON EVENTS CAPTURED") print(" Possible causes:") print(" - SetControlReporting divert didn't take effect") print(" - Button events use a different notification format") print(" - Buttons not pressed during capture window") } else { let uniqueCIDs = Set(capturedEvents.map { $0.1 }) print("\nUnique buttons detected:") for cid in uniqueCIDs.sorted() { let name = cidNames[cid] ?? "Unknown" let downs = capturedEvents.filter { $0.0 == "DOWN" && $0.1 == cid }.count print(" CID=0x\(String(format: "%04X", cid)) \(name): \(downs) presses") } } print("\n========== DONE ==========") buf.deallocate() IOHIDDeviceClose(bleDev, IOOptionBits(kIOHIDOptionsTypeNone)) IOHIDManagerClose(mgr, IOOptionBits(kIOHIDOptionsTypeNone)) ================================================ FILE: tools/hidpp-probe.swift ================================================ #!/usr/bin/env swift // HID++ 2.0 BLE 探测工具 v3 - 精确复现 hidapi 行为 // swift tools/hidpp-probe.swift import Foundation import IOKit import IOKit.hid let LOGITECH_VID = 0x046D func hex(_ data: [UInt8], n: Int? = nil) -> String { data.prefix(n ?? data.count).map { String(format: "%02X", $0) }.joined(separator: " ") } class Ctx { var reports: [[UInt8]] = [] } let ctx = Ctx() let ctxPtr = Unmanaged.passRetained(ctx).toOpaque() let rxCallback: IOHIDReportCallback = { context, result, sender, type, reportID, report, reportLength in guard let context = context else { return } let c = Unmanaged.fromOpaque(context).takeUnretainedValue() let data = Array(UnsafeBufferPointer(start: report, count: reportLength)) if data.count >= 7 && (data[0] == 0x10 || data[0] == 0x11) { c.reports.append(data) print(" >> HID++ RX: \(hex(data, n: min(data.count, 20)))") } } // Enumerate let mgr = IOHIDManagerCreate(kCFAllocatorDefault, IOOptionBits(kIOHIDOptionsTypeNone)) IOHIDManagerSetDeviceMatching(mgr, [kIOHIDVendorIDKey as String: LOGITECH_VID] as CFDictionary) IOHIDManagerScheduleWithRunLoop(mgr, CFRunLoopGetCurrent(), CFRunLoopMode.defaultMode.rawValue) IOHIDManagerOpen(mgr, IOOptionBits(kIOHIDOptionsTypeNone)) RunLoop.current.run(until: Date(timeIntervalSinceNow: 0.5)) guard let devs = IOHIDManagerCopyDevices(mgr) as? Set, let bleDev = devs.first(where: { let tr = IOHIDDeviceGetProperty($0, kIOHIDTransportKey as CFString) as? String ?? "" return tr.contains("Bluetooth") }) else { print("No BLE Logitech device"); exit(1) } let name = IOHIDDeviceGetProperty(bleDev, kIOHIDProductKey as CFString) as? String ?? "?" print("Target: \(name) (BLE)\n") IOHIDDeviceOpen(bleDev, IOOptionBits(kIOHIDOptionsTypeNone)) let buf = UnsafeMutablePointer.allocate(capacity: 64) buf.initialize(repeating: 0, count: 64) IOHIDDeviceRegisterInputReportCallback(bleDev, buf, 64, rxCallback, ctxPtr) // hidapi 精确行为: report_id != 0 时, data 包含 report ID, length = 全长 // IOHIDDeviceSetReport(dev, OUTPUT, data[0], data, length) func hidapiWrite(_ device: IOHIDDevice, _ data: [UInt8]) -> IOReturn { let reportId = CFIndex(data[0]) return IOHIDDeviceSetReport(device, kIOHIDReportTypeOutput, reportId, data, data.count) } func waitResponse(sec: TimeInterval = 3.0) -> Bool { let deadline = Date(timeIntervalSinceNow: sec) while Date() < deadline && ctx.reports.isEmpty { RunLoop.current.run(until: Date(timeIntervalSinceNow: 0.1)) } return !ctx.reports.isEmpty } // MARK: - Test 1: hidapi 精确行为, IRoot.GetFeature(0x1B04) print("=== Test 1: hidapi-exact, IRoot.GetFeature(0x1B04) ===\n") for devIdx: UInt8 in [0xFF, 0x01, 0x00] { ctx.reports.removeAll() var pkt = [UInt8](repeating: 0, count: 20) pkt[0] = 0x11; pkt[1] = devIdx; pkt[2] = 0x00; pkt[3] = 0x01; pkt[4] = 0x1B; pkt[5] = 0x04 let r = hidapiWrite(bleDev, pkt) print("devIdx=\(String(format: "0x%02X", devIdx)): TX \(hex(pkt, n: 8))... -> \(r == kIOReturnSuccess ? "OK" : String(format: "0x%08x", r))") if !waitResponse() { print(" No response\n") } else { print() } } // MARK: - Test 2: IRoot.Ping (GetProtocolVersion) print("=== Test 2: IRoot.Ping ===\n") for devIdx: UInt8 in [0xFF, 0x01, 0x00] { ctx.reports.removeAll() var pkt = [UInt8](repeating: 0, count: 20) pkt[0] = 0x11; pkt[1] = devIdx; pkt[2] = 0x00; pkt[3] = 0x11 // func 1, swId 1 let r = hidapiWrite(bleDev, pkt) print("devIdx=\(String(format: "0x%02X", devIdx)): TX \(hex(pkt, n: 8))... -> \(r == kIOReturnSuccess ? "OK" : String(format: "0x%08x", r))") if !waitResponse() { print(" No response\n") } else { print() } } // MARK: - Test 3: 对比 - 不含 report ID (我们之前的方法) print("=== Test 3: no-id in payload (19 bytes) ===\n") for devIdx: UInt8 in [0xFF] { ctx.reports.removeAll() var pkt = [UInt8](repeating: 0, count: 20) pkt[0] = 0x11; pkt[1] = devIdx; pkt[2] = 0x00; pkt[3] = 0x01; pkt[4] = 0x1B; pkt[5] = 0x04 let payload = Array(pkt.dropFirst()) // 19 bytes, no report ID let r = IOHIDDeviceSetReport(bleDev, kIOHIDReportTypeOutput, CFIndex(pkt[0]), payload, payload.count) print("no-id 19B: TX -> \(r == kIOReturnSuccess ? "OK" : String(format: "0x%08x", r))") if !waitResponse(sec: 2.0) { print(" No response\n") } else { print() } } // MARK: - Test 4: SetReport with kIOHIDOptionsTypeSeizeDevice print("=== Test 4: Seize device then write ===\n") IOHIDDeviceClose(bleDev, IOOptionBits(kIOHIDOptionsTypeNone)) let seizeResult = IOHIDDeviceOpen(bleDev, IOOptionBits(kIOHIDOptionsTypeSeizeDevice)) print("IOHIDDeviceOpen(Seize): \(seizeResult == kIOReturnSuccess ? "OK" : String(format: "0x%08x", seizeResult))") if seizeResult == kIOReturnSuccess { IOHIDDeviceRegisterInputReportCallback(bleDev, buf, 64, rxCallback, ctxPtr) ctx.reports.removeAll() var pkt = [UInt8](repeating: 0, count: 20) pkt[0] = 0x11; pkt[1] = 0xFF; pkt[2] = 0x00; pkt[3] = 0x01; pkt[4] = 0x1B; pkt[5] = 0x04 let r = hidapiWrite(bleDev, pkt) print("Seized write: TX -> \(r == kIOReturnSuccess ? "OK" : String(format: "0x%08x", r))") if !waitResponse() { print(" No response\n") } else { print() } IOHIDDeviceClose(bleDev, IOOptionBits(kIOHIDOptionsTypeNone)) // 重新正常打开 IOHIDDeviceOpen(bleDev, IOOptionBits(kIOHIDOptionsTypeNone)) } // MARK: - Test 5: 被动监听 10s (请按按键) print("=== Test 5: Passive listen 10s - PRESS BUTTONS NOW ===\n") IOHIDDeviceRegisterInputReportCallback(bleDev, buf, 64, rxCallback, ctxPtr) ctx.reports.removeAll() let end = Date(timeIntervalSinceNow: 10.0) while Date() < end { RunLoop.current.run(until: Date(timeIntervalSinceNow: 0.2)) } print("\nHID++ reports in 10s: \(ctx.reports.count)") print("\n=== Done ===") buf.deallocate() IOHIDDeviceClose(bleDev, IOOptionBits(kIOHIDOptionsTypeNone)) IOHIDManagerClose(mgr, IOOptionBits(kIOHIDOptionsTypeNone)) ================================================ FILE: tools/hidpp-undivert-test.swift ================================================ #!/usr/bin/env swift // 测试如何正确清除 persistDivert import Foundation import IOKit import IOKit.hid let VID = 0x046D class Ctx { var reports: [[UInt8]] = [] } let ctx = Ctx() let ctxPtr = Unmanaged.passRetained(ctx).toOpaque() let cb: IOHIDReportCallback = { c, _, _, _, _, r, l in guard let c = c else { return } Unmanaged.fromOpaque(c).takeUnretainedValue().reports.append(Array(UnsafeBufferPointer(start: r, count: l))) } let mgr = IOHIDManagerCreate(kCFAllocatorDefault, IOOptionBits(kIOHIDOptionsTypeNone)) IOHIDManagerSetDeviceMatching(mgr, [kIOHIDVendorIDKey as String: VID] as CFDictionary) IOHIDManagerScheduleWithRunLoop(mgr, CFRunLoopGetCurrent(), CFRunLoopMode.defaultMode.rawValue) IOHIDManagerOpen(mgr, IOOptionBits(kIOHIDOptionsTypeNone)) RunLoop.current.run(until: Date(timeIntervalSinceNow: 0.5)) guard let devs = IOHIDManagerCopyDevices(mgr) as? Set, let dev = devs.first(where: { (IOHIDDeviceGetProperty($0, kIOHIDTransportKey as CFString) as? String ?? "").contains("Bluetooth") }) else { print("No BLE device"); exit(1) } IOHIDDeviceOpen(dev, IOOptionBits(kIOHIDOptionsTypeNone)) let buf = UnsafeMutablePointer.allocate(capacity: 64) buf.initialize(repeating: 0, count: 64) IOHIDDeviceRegisterInputReportCallback(dev, buf, 64, cb, ctxPtr) func send(_ d: [UInt8]) { IOHIDDeviceSetReport(dev, kIOHIDReportTypeOutput, CFIndex(d[0]), d, d.count) } func pkt(_ fi: UInt8, _ fn: UInt8, _ p: [UInt8] = []) -> [UInt8] { var r = [UInt8](repeating: 0, count: 20); r[0] = 0x11; r[1] = 0xFF; r[2] = fi; r[3] = (fn << 4) | 0x01 for (i, v) in p.prefix(16).enumerated() { r[4+i] = v }; return r } func wait(_ t: TimeInterval = 2.0) -> [UInt8]? { ctx.reports.removeAll() let dl = Date(timeIntervalSinceNow: t) while Date() < dl && ctx.reports.isEmpty { RunLoop.current.run(until: Date(timeIntervalSinceNow: 0.05)) } return ctx.reports.first } func getStatus(_ ri: UInt8, _ cid: UInt16) -> (UInt8, UInt16) { send(pkt(ri, 2, [UInt8(cid >> 8), UInt8(cid & 0xFF)])) if let vr = wait(1.0) { return (vr[6], (UInt16(vr[7]) << 8) | UInt16(vr[8])) } return (0xFF, 0) } // Discover REPROG send(pkt(0x00, 0, [0x1B, 0x04])) guard let r = wait(), r[4] != 0 else { print("REPROG not found"); exit(1) } let ri = r[4] let cid: UInt16 = 0x00C4 // SmartShift let h = UInt8(cid >> 8), l = UInt8(cid & 0xFF) // Check current status let (curFlags, curTarget) = getStatus(ri, cid) print("Current: flags=0x\(String(format: "%02X", curFlags)) diverted=\((curFlags & 0x01) != 0)") // Try each undivert method let tests: [(String, [UInt8])] = [ ("flags=0x00, target=self", [h, l, 0x00, h, l]), ("flags=0x00, target=0x00", [h, l, 0x00, 0x00, 0x00]), ("flags=0x02, target=self (clear persist only)", [h, l, 0x02, h, l]), ("flags=0x00, no target (3 bytes)", [h, l, 0x00]), ] for (desc, params) in tests { // First ensure it's diverted send(pkt(ri, 3, [h, l, 0x03, h, l])) let _ = wait(0.5) let (bf, _) = getStatus(ri, cid) guard (bf & 0x01) != 0 else { print("\n\(desc): SKIP - couldn't set divert first") continue } // Try to undivert send(pkt(ri, 3, params)) let _ = wait(0.5) let (af, at) = getStatus(ri, cid) let cleared = (af & 0x01) == 0 print("\n\(desc):") print(" After: flags=0x\(String(format: "%02X", af)) target=0x\(String(format: "%04X", at)) diverted=\(!cleared) -> \(cleared ? "CLEARED" : "STILL DIVERTED")") } // Final cleanup: try all methods to ensure clean state print("\n=== Final cleanup ===") send(pkt(ri, 3, [h, l, 0x00, h, l])); let _ = wait(0.3) send(pkt(ri, 3, [h, l, 0x00, 0x00, 0x00])); let _ = wait(0.3) let (final, _) = getStatus(ri, cid) print("Final: flags=0x\(String(format: "%02X", final)) diverted=\((final & 0x01) != 0)") print("\nPress SmartShift to check if original function is restored (5s)...") let end = Date(timeIntervalSinceNow: 5.0) while Date() < end { RunLoop.current.run(until: Date(timeIntervalSinceNow: 0.1)) for rpt in ctx.reports where rpt.count >= 7 && (rpt[0] == 0x10 || rpt[0] == 0x11) { let fi = rpt[2] if fi != ri { print(" SmartShift notification received -> original function RESTORED") } else { print(" REPROG event -> still diverted") } } ctx.reports.removeAll() } print("Done") buf.deallocate() IOHIDDeviceClose(dev, IOOptionBits(kIOHIDOptionsTypeNone)) IOHIDManagerClose(mgr, IOOptionBits(kIOHIDOptionsTypeNone)) ================================================ FILE: tools/hidpp-verify-final.swift ================================================ #!/usr/bin/env swift // 最终验证: flags=0x03 + targetCID=self + button capture import Foundation import IOKit import IOKit.hid let VID = 0x046D class Ctx { var reports: [[UInt8]] = [] } let ctx = Ctx() let ctxPtr = Unmanaged.passRetained(ctx).toOpaque() let cb: IOHIDReportCallback = { c, _, _, _, _, r, l in guard let c = c else { return } Unmanaged.fromOpaque(c).takeUnretainedValue().reports.append(Array(UnsafeBufferPointer(start: r, count: l))) } let mgr = IOHIDManagerCreate(kCFAllocatorDefault, IOOptionBits(kIOHIDOptionsTypeNone)) IOHIDManagerSetDeviceMatching(mgr, [kIOHIDVendorIDKey as String: VID] as CFDictionary) IOHIDManagerScheduleWithRunLoop(mgr, CFRunLoopGetCurrent(), CFRunLoopMode.defaultMode.rawValue) IOHIDManagerOpen(mgr, IOOptionBits(kIOHIDOptionsTypeNone)) RunLoop.current.run(until: Date(timeIntervalSinceNow: 0.5)) guard let devs = IOHIDManagerCopyDevices(mgr) as? Set, let dev = devs.first(where: { (IOHIDDeviceGetProperty($0, kIOHIDTransportKey as CFString) as? String ?? "").contains("Bluetooth") }) else { print("No BLE device"); exit(1) } IOHIDDeviceOpen(dev, IOOptionBits(kIOHIDOptionsTypeNone)) let buf = UnsafeMutablePointer.allocate(capacity: 64) buf.initialize(repeating: 0, count: 64) IOHIDDeviceRegisterInputReportCallback(dev, buf, 64, cb, ctxPtr) func send(_ d: [UInt8]) { IOHIDDeviceSetReport(dev, kIOHIDReportTypeOutput, CFIndex(d[0]), d, d.count) } func pkt(_ fi: UInt8, _ fn: UInt8, _ p: [UInt8] = []) -> [UInt8] { var r = [UInt8](repeating: 0, count: 20); r[0] = 0x11; r[1] = 0xFF; r[2] = fi; r[3] = (fn << 4) | 0x01 for (i, v) in p.prefix(16).enumerated() { r[4+i] = v }; return r } func wait(_ t: TimeInterval = 2.0) -> [UInt8]? { ctx.reports.removeAll() let dl = Date(timeIntervalSinceNow: t) while Date() < dl && ctx.reports.isEmpty { RunLoop.current.run(until: Date(timeIntervalSinceNow: 0.05)) } return ctx.reports.first } let names: [UInt16:String] = [0x0052:"Middle",0x0053:"Back",0x0056:"Forward",0x00C3:"Gesture",0x00C4:"SmartShift",0x00D7:"DPI"] // Discover send(pkt(0x00, 0, [0x1B, 0x04])); let ri = wait()![4] send(pkt(ri, 0)); let count = Int(wait()![4]) print("REPROG at 0x\(String(format: "%02X", ri)), \(count) controls") // Enumerate divertable var divertable: [(UInt16, String)] = [] for i in 0..> 8), l = UInt8(cid & 0xFF) send(pkt(ri, 3, [h, l, 0x03, h, l])) let _ = wait(0.5) // Verify send(pkt(ri, 2, [h, l])) if let vr = wait(0.5) { let diverted = (vr[6] & 0x01) != 0 print(" \(name): diverted=\(diverted)") } } // Capture print("\n>>> PRESS ALL BUTTONS (15s) <<<\n") ctx.reports.removeAll() var lastCIDs: Set = [] let end = Date(timeIntervalSinceNow: 15.0) while Date() < end { RunLoop.current.run(until: Date(timeIntervalSinceNow: 0.05)) for rpt in ctx.reports where rpt.count >= 7 && (rpt[0] == 0x10 || rpt[0] == 0x11) { let fi = rpt[2], fn = rpt[3] >> 4 if fi == ri && fn == 0 { var active: Set = [] var off = 4 while off+1 < rpt.count { let c = (UInt16(rpt[off])<<8)|UInt16(rpt[off+1]); if c==0{break}; active.insert(c); off+=2 } for c in active.subtracting(lastCIDs) { print("DOWN: \(names[c] ?? String(format:"0x%04X",c))") } for c in lastCIDs.subtracting(active) { print("UP: \(names[c] ?? String(format:"0x%04X",c))") } lastCIDs = active } else { print("[f=0x\(String(format:"%02X",fi)) fn=\(fn)]") } } ctx.reports.removeAll() } // Undivert for (cid, _) in divertable { let h = UInt8(cid >> 8), l = UInt8(cid & 0xFF) send(pkt(ri, 3, [h, l, 0x00, h, l])); let _ = wait(0.3) } print("\nDone - undiverted") buf.deallocate() ================================================ FILE: website/.nvmrc ================================================ 22 ================================================ FILE: website/CNAME ================================================ mos.caldis.me ================================================ FILE: website/README.md ================================================ ## Mos Home Page This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). ## Getting Started First, run the development server: ```bash npm run dev ``` Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. ## Deploy ```bash npm run build ``` ## Transition Just edit the wording with your language in `app/i18n` and rise an pull request. ================================================ FILE: website/app/components/BentoCard/BentoCard.tsx ================================================ "use client"; import { motion, useMotionTemplate, useMotionValue, useReducedMotion, useSpring, useTransform, } from "framer-motion"; import { MouseEvent, ReactNode } from "react"; const SPRING = { stiffness: 150, damping: 30 }; export function BentoCard({ children, className = "", }: { children: ReactNode; className?: string; }) { const shouldReduceMotion = useReducedMotion(); const mouseX = useMotionValue(0.5); const mouseY = useMotionValue(0.5); const rawRotateX = useTransform(mouseY, [0, 1], [4, -4]); const rawRotateY = useTransform(mouseX, [0, 1], [-6, 6]); const rotateX = useSpring(rawRotateX, SPRING); const rotateY = useSpring(rawRotateY, SPRING); const spotX = useTransform(mouseX, [0, 1], [0, 100]); const spotY = useTransform(mouseY, [0, 1], [0, 100]); const spotlight = useMotionTemplate`radial-gradient(480px circle at ${spotX}% ${spotY}%, rgba(255,255,255,0.08), transparent 60%)`; const handleMouseMove = (e: MouseEvent) => { if (shouldReduceMotion) return; const rect = e.currentTarget.getBoundingClientRect(); mouseX.set((e.clientX - rect.left) / rect.width); mouseY.set((e.clientY - rect.top) / rect.height); }; const handleMouseLeave = () => { mouseX.set(0.5); mouseY.set(0.5); }; return ( {/* Cursor-following spotlight */} {children} ); } ================================================ FILE: website/app/components/CopyButton/CopyButton.tsx ================================================ "use client"; import { ReactNode, useCallback, useState } from "react"; async function copyToClipboard(value: string) { try { await navigator.clipboard.writeText(value); return true; } catch { // Fallback for older browsers / permissions. try { const el = document.createElement("textarea"); el.value = value; el.setAttribute("readonly", ""); el.style.position = "fixed"; el.style.top = "-9999px"; document.body.appendChild(el); el.select(); const ok = document.execCommand("copy"); document.body.removeChild(el); return ok; } catch { return false; } } } export function CopyButton({ value, children, className = "", copiedLabel = "Copied", }: { value: string; children: ReactNode; className?: string; copiedLabel?: string; }) { const [copied, setCopied] = useState(false); const onCopy = useCallback(async () => { const ok = await copyToClipboard(value); if (!ok) return; setCopied(true); window.setTimeout(() => setCopied(false), 1400); }, [value]); return ( ); } ================================================ FILE: website/app/components/EasingPlayground/EasingPlayground.tsx ================================================ "use client"; import { useEffect, useMemo, useRef, useState } from "react"; import { useI18n } from "@/app/i18n/context"; function clamp(n: number, min: number, max: number) { return Math.min(max, Math.max(min, n)); } function roundTo(n: number, decimals: number) { const p = Math.pow(10, decimals); return Math.round(n * p) / p; } // Ported from Mos: // Mos/Utils/Constants.swift -> OPTIONS_SCROLL_DEFAULT.generateDurationTransition(with:) function generateDurationTransition(duration: number) { // Slider upper bound is 5.0; Mos adds +0.2 so the result never hits 0. const upperLimit = 5.0 + 0.2; const d = clamp(duration, 0, 5.0); const val = 1 - Math.sqrt(d / upperLimit); return roundTo(val, 3); } // Ported from Mos: // Mos/ScrollCore/ScrollFilter.swift function scrollFilterFill(window: number[], nextValue: number) { const first = window[1] ?? 0; const diff = nextValue - first; return [ first, first + 0.23 * diff, first + 0.5 * diff, first + 0.77 * diff, nextValue, ]; } function niceCeil(n: number) { const x = Math.max(1e-6, n); const p = Math.pow(10, Math.floor(Math.log10(x))); const s = x / p; let m = 1; if (s <= 1) m = 1; else if (s <= 2) m = 2; else if (s <= 5) m = 5; else m = 10; return m * p; } function computeSim(step: number, gain: number, duration: number) { // A compact simulation of Mos' ScrollPoster + Interpolator.lerp + ScrollFilter. // We visualize the posted vertical deltas (deadZone-clamped) over time. const manualContinuationThreshold = 0.18; // ScrollPoster.manualContinuationThreshold const deadZone = 1.0; // OPTIONS_SCROLL_DEFAULT.deadZone const burstTicks = 10; // A short "wheel burst" to resemble the in-app monitor. const maxFrames = 90; const fps = 60; const dt = 1 / fps; const trans = generateDurationTransition(duration); let current = 0; let buffer = 0; let deltaPrev = 0; let t = 0; let lastManualTime = 0; let manualInputEnded = true; let filterWindow = [0.0, 0.0]; const samples: number[] = []; for (let frame = 0; frame < maxFrames; frame += 1) { if (frame < burstTicks) { const y = step; if (y * deltaPrev > 0) { buffer += y * gain; } else { buffer = y * gain; current = 0; } deltaPrev = y; lastManualTime = t; manualInputEnded = false; } // Interpolator.lerp(src: current, dest: buffer, trans: durationTransition) const delta = (buffer - current) * trans; current += delta; // ScrollFilter.fill(with:) filterWindow = scrollFilterFill(filterWindow, delta); const filtered = filterWindow[0] ?? 0; const out = Math.abs(filtered) > deadZone ? filtered : 0; // Track when manual input ends (used for stopping conditions), // but do NOT inject the "TrackingEnd = 0" marker frame in the graph. if (!manualInputEnded && t - lastManualTime > manualContinuationThreshold) { manualInputEnded = true; } samples.push(out); t += dt; } const maxAbs = Math.max(1, ...samples.map((v) => Math.abs(v))); return { samples, trans, maxAbs }; } function computeTargetYMax(step: number, gain: number, duration: number) { const { maxAbs } = computeSim(step, gain, duration); return Math.min(6000, niceCeil(maxAbs * 1.06)); } function stepToDotQuant(step: number) { // User-facing behavior: // - smaller STEP => dot advances in more discrete jumps // - larger STEP => dot motion feels more continuous ("higher fps") const min = 0.01; const max = 100; const s = clamp(step, min, max); const n = (Math.log(s) - Math.log(min)) / Math.max(1e-6, Math.log(max) - Math.log(min)); // 0..1 const inv = 1 - clamp(n, 0, 1); // Quantize u (0..1): higher value => chunkier jumps. const qMin = 0.0025; // very smooth const qMax = 0.045; // visibly discrete return qMin + inv * (qMax - qMin); } type EasingPlaygroundProps = { className?: string; }; export function EasingPlayground({ className = "" }: EasingPlaygroundProps) { const { t } = useI18n(); // Match Mos defaults (OPTIONS_SCROLL_DEFAULT) const DEFAULT_STEP = 33.6; const DEFAULT_GAIN = 2.7; const DEFAULT_DURATION = 4.35; const [step, setStep] = useState(DEFAULT_STEP); const [gain, setGain] = useState(DEFAULT_GAIN); const [duration, setDuration] = useState(DEFAULT_DURATION); const dotRef = useRef(null); // Start with a sensible default range (matches typical Mos monitor values), // then expand upwards when parameters produce larger peaks. const [yMax, setYMax] = useState(() => Math.max(60, computeTargetYMax(DEFAULT_STEP, DEFAULT_GAIN, DEFAULT_DURATION)) ); const sim = useMemo(() => computeSim(step, gain, duration), [duration, gain, step]); const graph = useMemo(() => { const VW = 860; const VH = 280; const padL = 56; const padR = 18; const padT = 18; const padB = 44; const w = VW - padL - padR; const h = VH - padT - padB; const samples = sim.samples; const N = Math.max(2, samples.length); const mapX = (i: number) => padL + (clamp(i, 0, N - 1) / (N - 1)) * w; const mapY = (v: number) => { const y = clamp(v, 0, yMax); return padT + h - (y / yMax) * h; }; const points = samples.map((v, i) => ({ x: mapX(i), y: mapY(v), })); let d = ""; for (let i = 0; i < points.length; i += 1) { const p = points[i]!; d += i === 0 ? `M ${p.x.toFixed(2)} ${p.y.toFixed(2)}` : ` L ${p.x.toFixed(2)} ${p.y.toFixed(2)}`; } const baselineY = mapY(0); const fill = `${d} L ${mapX(points.length - 1).toFixed(2)} ${baselineY.toFixed(2)} L ${mapX(0).toFixed(2)} ${baselineY.toFixed(2)} Z`; return { VW, VH, padL, padR, padT, padB, points, d, fill, baselineY }; }, [sim.samples, yMax]); useEffect(() => { const reduced = window.matchMedia?.("(prefers-reduced-motion: reduce)")?.matches ?? false; if (reduced) return; const dot = dotRef.current; if (!dot) return; let raf = 0; const start = performance.now(); const travel = 1260; const hold = 320; const fade = 140; const quant = stepToDotQuant(step); const tick = (now: number) => { const elapsed = now - start; const cycle = travel + hold; const t = elapsed % cycle; const pts = graph.points; const u = clamp(t / Math.max(1, travel), 0, 1); const uq = clamp(Math.round(u / quant) * quant, 0, 1); const pos = uq * (pts.length - 1); const i0 = Math.floor(pos); const i1 = Math.min(pts.length - 1, i0 + 1); const k = clamp(pos - i0, 0, 1); const p0 = pts[i0] ?? pts[0]!; const p1 = pts[i1] ?? pts[pts.length - 1]!; const xPx = p0.x + (p1.x - p0.x) * k; const yPx = p0.y + (p1.y - p0.y) * k; let alpha = 1; if (t < fade) alpha = t / fade; const fadeOutStart = travel + hold - fade; if (t > fadeOutStart) alpha = 1 - (t - fadeOutStart) / fade; dot.setAttribute("cx", xPx.toFixed(2)); dot.setAttribute("cy", yPx.toFixed(2)); dot.setAttribute("opacity", clamp(alpha, 0, 1).toFixed(2)); raf = window.requestAnimationFrame(tick); }; raf = window.requestAnimationFrame(tick); return () => window.cancelAnimationFrame(raf); }, [graph.points, step]); return (
{/* Grid */} {Array.from({ length: 7 }).map((_, i) => { const x = graph.padL + (i / 6) * (graph.VW - graph.padL - graph.padR); return ( ); })} {Array.from({ length: 5 }).map((_, i) => { const y = graph.padT + (i / 4) * (graph.VH - graph.padT - graph.padB); return ( ); })} {/* Fill under curve */} {/* Curve */} {/* Animated dot */}
{t.easing.step.label}
{step.toFixed(2)}
{ const v = Number(e.target.value); setStep(v); setYMax((prev) => Math.max(prev, computeTargetYMax(v, gain, duration))); }} aria-label={t.easing.step.aria} />
{t.easing.step.help}
{t.easing.gain.label}
×{gain.toFixed(2)}
{ const v = Number(e.target.value); setGain(v); setYMax((prev) => Math.max(prev, computeTargetYMax(step, v, duration))); }} aria-label={t.easing.gain.aria} />
{t.easing.gain.help}
{t.easing.duration.label}
{duration.toFixed(2)}
{ const v = Number(e.target.value); setDuration(v); setYMax((prev) => Math.max(prev, computeTargetYMax(step, gain, v))); }} aria-label={t.easing.duration.aria} />
{t.easing.duration.help}
{t.easing.footer}
); } ================================================ FILE: website/app/components/FlowField/FlowField.tsx ================================================ "use client"; import { useEffect, useRef } from "react"; type FlowFieldProps = { className?: string; }; type Particle = { x: number; y: number; vx: number; vy: number; seed: number; color: 0 | 1 | 2; }; function clamp(n: number, min: number, max: number) { return Math.min(max, Math.max(min, n)); } function rand(seed: number) { // Deterministic-ish hash to avoid needing a PRNG package. const x = Math.sin(seed) * 10000; return x - Math.floor(x); } export function FlowField({ className = "" }: FlowFieldProps) { const canvasRef = useRef(null); const rafRef = useRef(null); useEffect(() => { const canvas = canvasRef.current; if (!canvas) return; const reduced = window.matchMedia?.("(prefers-reduced-motion: reduce)")?.matches ?? false; type NetworkInformation = { saveData?: boolean }; type NavigatorWithConnection = Navigator & { connection?: NetworkInformation }; const saveData = (navigator as NavigatorWithConnection).connection?.saveData ?? false; if (reduced || saveData) return; const ctx = canvas.getContext("2d", { alpha: true }); if (!ctx) return; const pointer = { x: -10_000, y: -10_000, active: false }; const colors = [ "rgba(255,255,255,0.12)", "rgba(255,255,255,0.08)", "rgba(255,255,255,0.05)", ] as const; let cssW = 1; let cssH = 1; let particles: Particle[] = []; const resize = () => { const rect = canvas.getBoundingClientRect(); cssW = Math.max(1, Math.floor(rect.width)); cssH = Math.max(1, Math.floor(rect.height)); const dpr = Math.max(1, window.devicePixelRatio || 1); canvas.width = Math.floor(cssW * dpr); canvas.height = Math.floor(cssH * dpr); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); const coarse = window.matchMedia?.("(pointer: coarse)")?.matches ?? false; const density = coarse ? 0.35 : 0.7; const count = clamp(Math.floor((cssW * cssH) / 5000 * density), 140, 620); particles = Array.from({ length: count }, (_, i) => { const s = i * 12.345; return { x: rand(s) * cssW, y: rand(s + 1) * cssH, vx: 0, vy: 0, seed: rand(s + 2) * 1000, color: (i % 3) as 0 | 1 | 2, }; }); ctx.clearRect(0, 0, cssW, cssH); }; const onPointerMove = (event: PointerEvent) => { const rect = canvas.getBoundingClientRect(); pointer.x = event.clientX - rect.left; pointer.y = event.clientY - rect.top; pointer.active = true; }; const onBlur = () => { pointer.active = false; pointer.x = -10_000; pointer.y = -10_000; }; // Canvas is pointer-events:none (so it never blocks UI), so we track pointer globally. window.addEventListener("pointermove", onPointerMove, { passive: true }); window.addEventListener("blur", onBlur); window.addEventListener("resize", resize, { passive: true }); resize(); let t0 = performance.now(); let running = true; const fieldAngle = (x: number, y: number, t: number, seed: number) => { // Cheap “flowy” field: 3 trig layers blended. const n1 = Math.sin(x * 0.0022 + (t + seed) * 0.00065); const n2 = Math.cos(y * 0.0020 - (t - seed) * 0.00055); const n3 = Math.sin((x + y) * 0.0014 + (t + seed) * 0.00032); const n = (n1 + n2 + n3) / 3; return n * Math.PI * 2.2; }; const tick = (now: number) => { if (!running) return; const dt = clamp(now - t0, 6, 28); t0 = now; const scrollMax = Math.max(1, document.documentElement.scrollHeight - window.innerHeight); const scroll = clamp(window.scrollY / scrollMax, 0, 1); // Persistent trails. ctx.fillStyle = `rgba(0, 0, 0, ${0.08 + scroll * 0.05})`; ctx.fillRect(0, 0, cssW, cssH); ctx.save(); ctx.globalCompositeOperation = "lighter"; ctx.lineWidth = 1; const speed = 0.8 + scroll * 1.4; const influenceR = 180; for (const p of particles) { const px = p.x; const py = p.y; const a = fieldAngle(px, py, now, p.seed); const ax = Math.cos(a) * 0.55; const ay = Math.sin(a) * 0.55; p.vx = p.vx * 0.84 + ax; p.vy = p.vy * 0.84 + ay; if (pointer.active) { const dx = px - pointer.x; const dy = py - pointer.y; const d = Math.sqrt(dx * dx + dy * dy) || 1; if (d < influenceR) { const f = (1 - d / influenceR) * 1.15; // Swirl around pointer for “alive” feel. p.vx += (-dy / d) * f; p.vy += (dx / d) * f; } } const nx = px + p.vx * speed * (dt / 16); const ny = py + p.vy * speed * (dt / 16); p.x = nx; p.y = ny; ctx.strokeStyle = colors[p.color]; ctx.beginPath(); ctx.moveTo(px, py); ctx.lineTo(nx, ny); ctx.stroke(); const margin = 40; if (nx < -margin || nx > cssW + margin || ny < -margin || ny > cssH + margin) { const s = p.seed + now * 0.001; p.x = rand(s) * cssW; p.y = rand(s + 1) * cssH; p.vx = 0; p.vy = 0; } } ctx.restore(); if (!running) return; rafRef.current = window.requestAnimationFrame(tick); }; rafRef.current = window.requestAnimationFrame(tick); const onVisibilityChange = () => { if (document.hidden) { running = false; if (rafRef.current) window.cancelAnimationFrame(rafRef.current); rafRef.current = null; return; } if (!running) { running = true; t0 = performance.now(); rafRef.current = window.requestAnimationFrame(tick); } }; document.addEventListener("visibilitychange", onVisibilityChange); return () => { if (rafRef.current) window.cancelAnimationFrame(rafRef.current); window.removeEventListener("pointermove", onPointerMove); window.removeEventListener("blur", onBlur); window.removeEventListener("resize", resize); document.removeEventListener("visibilitychange", onVisibilityChange); }; }, []); return (