diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md
index b0aa2f1..4a70a8e 100644
--- a/.github/ISSUE_TEMPLATE/bug_report.md
+++ b/.github/ISSUE_TEMPLATE/bug_report.md
@@ -8,7 +8,8 @@ assignees: ""
## What happened
-
+
## Environment
@@ -16,10 +17,24 @@ assignees: ""
- How you installed it (built from source, DMG, Xcode ⌘R):
- macOS version (Apple menu → About This Mac):
- Mac (Apple silicon or Intel):
-- Mouse connection (USB cable, Unifying receiver, Bluetooth):
-- Mouse firmware (Settings → Device):
- Logi Options+, Logitech Options, Solaar or LogiOps installed or running:
+## Your mice
+
+
+
+- Mouse model(s), as shown in Settings → Mice:
+- Number of mice connected at the same time:
+- How each one connects, and the receiver layout. For example: "MX Vertical on a Unifying
+ receiver (slot 1), MX Master 3S on a Bolt receiver, Lift over Bluetooth". Say how many
+ receivers you have, which mice share one, and whether a keyboard is paired to the same
+ receiver:
+- Is the same mouse reachable in two ways at once (for example a cable and its receiver, or
+ Bluetooth and a receiver)? yes / no
+- Mouse firmware (Settings → Mice → the mouse):
+- The mouse the problem is about shows an "Untested model" badge: yes / no
+
## Permissions
- Accessibility shown as allowed in Settings → Permissions: yes / no
@@ -32,19 +47,24 @@ assignees: ""
2.
3.
-## `make probe` output
+## `make probe ARGS=--all` output
-
+
```
```
-## Log from the Device pane
+## Device report and log
-
+
```
```
diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md
index 894c7ac..12dcbe2 100644
--- a/.github/ISSUE_TEMPLATE/feature_request.md
+++ b/.github/ISSUE_TEMPLATE/feature_request.md
@@ -6,6 +6,9 @@ labels: enhancement
assignees: ""
---
+
+
## What do you want to do
@@ -16,19 +19,24 @@ assignees: ""
## Idea
-
+
## Hardware and environment
-- Mouse model and connection (USB cable, Unifying receiver, Bluetooth):
+- Mouse model(s) it is about:
+- Number of mice you use at the same time:
+- Connections and receiver layout (for example "MX Master 3S on a Bolt receiver, MX Vertical
+ over Bluetooth"):
- macOS version:
-- If it needs a mouse feature: the output of `make probe` (the feature table shows what the
- mouse supports):
+- If it needs a mouse feature: the output of `make probe ARGS=--all` (the feature table
+ shows what the mouse supports; replace unit IDs and serial numbers with XXXX if you prefer):
```
```
## Scope check
-- [ ] This is about the Logitech MX Vertical (other mice are out of scope)
+- [ ] It is about Logitech mice that speak HID++ 2.0 and have programmable buttons (see
+ `docs/DEVICES.md`)
- [ ] It doesn't need a kernel extension, a driver or Logitech software
diff --git a/.github/ISSUE_TEMPLATE/new_mouse.md b/.github/ISSUE_TEMPLATE/new_mouse.md
new file mode 100644
index 0000000..5f0bee5
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/new_mouse.md
@@ -0,0 +1,76 @@
+---
+name: Request support for a mouse
+about: Your Logitech mouse isn't listed, shows up as unsupported, or you tried an untested model
+title: "New mouse: "
+labels: new mouse
+assignees: ""
+---
+
+
+
+## The mouse
+
+- Name and model number (on the box or the underside):
+- Is it listed in `docs/DEVICES.md`? yes (status: ...) / no
+
+## What Upright shows
+
+
+
+- [ ] It is listed as a mouse, with its buttons in Settings → Buttons
+- [ ] It is listed as a mouse with an "Untested model" badge
+- [ ] It is under "Other Logitech devices", with this reason:
+- [ ] It doesn't appear anywhere
+
+## Connection and receivers
+
+- Number of mice connected at the same time (of any model):
+- How this mouse connects: USB cable / Bluetooth / Unifying receiver / Bolt receiver / Nano receiver
+- Receiver layout: which receiver it is in, the slot number if you know it, the other mice and
+ keyboards sharing that receiver, and how many receivers there are. For example: "Bolt
+ receiver with this mouse and an MX Keys; a Unifying receiver with an MX Vertical":
+- Is it also paired by Bluetooth or cable at the same time? yes / no
+- macOS version:
+- Mac (Apple silicon or Intel):
+- Upright version:
+
+## `make probe ARGS=--all` output
+
+
+
+```
+```
+
+## Device report
+
+
+
+```
+```
+
+## Does it work?
+
+
+
+- [ ] A remapped button does what I set
+- [ ] The mapping stops when I turn off "Remap this mouse" or quit Upright
+- [ ] The mouse reconnects after sleep or after turning it off and on
+- [ ] Pointer speed, battery and Easy-Switch show up (if the mouse has them)
+- [ ] Nothing changed for my other mice
+- Anything that is wrong or strange:
+
+## Helping out
+
+- [ ] I can test builds or pull requests on this mouse
+- [ ] I'd like to add the model myself (see "Adding a model from an `upright-probe --all` dump"
+ in CONTRIBUTING.md)
diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
index e3c8345..c01b336 100644
--- a/.github/pull_request_template.md
+++ b/.github/pull_request_template.md
@@ -6,17 +6,27 @@
- [ ] `make test` passes
- [ ] `make app` builds (needed if you touched `Upright/` or the Xcode project)
-- [ ] New Swift files are added to `Upright.xcodeproj/project.pbxproj`
-- [ ] Tried it with a real MX Vertical. Connection: USB cable / Unifying / Bluetooth
+- [ ] New Swift files are added to `Upright.xcodeproj/project.pbxproj` exactly once (the check
+ in CONTRIBUTING.md prints nothing)
+- [ ] `make snapshots` renders the changed screens (if you touched a view)
+- [ ] Tried it on real hardware. Mouse model(s) and connection (USB cable / Unifying / Bolt / Nano
+ / Bluetooth), and how many mice at once:
+- [ ] Simulator only (no hardware). Which profiles or scenarios:
- macOS version:
-
+
## Checklist
- [ ] Code outside `UprightKit/Sources/UprightKit/Platform/` uses Foundation only (it runs on Linux in CI)
-- [ ] Tests cover the change, using the simulated mouse rather than real hardware
-- [ ] New HID++ messages are documented in `docs/HIDPP.md`
+- [ ] Tests cover the change, using the simulator (`SimulatedReceiver` / `SimulatedDirect` with
+ `DeviceProfiles`) rather than real hardware
+- [ ] Every test method in a `@MainActor` test class is `async` (Linux CI fails otherwise)
+- [ ] New HID++ messages are documented in `docs/HIDPP.md`, with whether they were tested on hardware
+- [ ] Anything about a model other than the MX Vertical is marked as untested on hardware
+ (`.publishedDump`, `docs/DEVICES.md`) unless it was
+- [ ] A catalog change keeps existing model keys, and `docs/DEVICES.md` matches `DeviceCatalog`
- [ ] User-facing behavior changes are reflected in `README.md` / `INSTALL.md`
- [ ] Added a line under **Unreleased** in `CHANGELOG.md`
- [ ] No saved-data format was broken (mappings and settings still load; old data is migrated)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 936de91..cd0ae47 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,8 +6,50 @@ All notable changes to Upright are listed here. The format follows
## [Unreleased]
+## [1.1.0] - 2026-10-14
+
### Added
+- **Multi-mouse support.** Upright now works with Logitech HID++ 2.0 mice other than the MX
+ Vertical, and with several mice at once, over any mix of USB cable, Bluetooth and Unifying,
+ Bolt or Nano receivers, including several mice on one receiver. A mouse is served when it
+ answers HID++ 2.0 as a pointer and offers programmable buttons (`REPROG_CONTROLS_V4`), not
+ because of its name or product ID. Keyboards and other non-mice are never opened.
+ **Only the MX Vertical is verified on hardware.** Every other model, Bolt and Nano receivers,
+ and two real mice at once are verified in the simulator only.
+- **Device catalog** with 23 models (MX Vertical, the MX Master, MX Anywhere, MX Ergo, Lift,
+ M720, M585/M590, Signature M650 L and M550, ERGO M575, Marathon M705, Pebble M350, M510 (v2)
+ and M185/M235/M310 families), built from published Solaar and libratbag data and marked
+ **published data, untested on hardware**. See [docs/DEVICES.md](docs/DEVICES.md), which is
+ derived from `DeviceCatalog.swift`.
+- **Receivers:** Bolt (`C548`, Bolt pairing layout) and Nano receivers join Unifying. Lightspeed
+ receivers (G and PRO mice, which have no programmable-button feature) and the 27 MHz receiver
+ are never opened.
+- **Mice pane** (replaces Device): connected mice, mice seen before ("Not Connected") and a
+ collapsed "Other Logitech devices" list with the reason a device isn't configured. Per mouse: a
+ nickname, **Remap this mouse**, its pointer speed, Easy-Switch, battery, device info and
+ **Copy Device Report** for issues.
+- **Per-model mappings.** A **Configure** picker in Settings → Buttons chooses the model being
+ edited, including models that aren't connected. Each model has its own buttons and per-app
+ profiles; two mice of one model share them. "Copy setup from…" and "Use this setup for all my
+ mice" are available.
+- **Menu-bar panel with several mice:** one row per mouse with its connection and battery,
+ "Reconnect All Mice", and button, pointer-speed and Easy-Switch sections for the mouse last
+ used. The battery percentage next to the icon shows the lowest battery, and low-battery
+ notifications name the mouse.
+- **A mouse seen over two links** (for example receiver and cable) is recognized by its unit ID
+ and used once, the other link kept on standby.
+- **Demo Mode:** `Upright --demo` runs the real interface against simulated mice (an MX
+ Vertical, an MX Master 3S and a Lift) with its own preferences, no event taps and no
+ permission prompts. `Upright --demo --export-snapshots
` (and `make snapshots`) renders
+ the interface offscreen into light and dark PNGs plus a `manifest.json`; the screenshots in
+ `media/snapshots/` and the README come from it.
+- **`upright-probe`:** `--all` also reads interfaces Upright doesn't use (read-only), output lines
+ carry a per-mouse label such as `[MX Master 3S · Bolt slot 2]`, and `--divert` can be limited to
+ mice whose label contains some text.
+- The mouse simulator (`SimulatedReceiver`, `SimulatedDirect`, `DeviceProfiles`) now lives in
+ UprightKit, so Demo Mode and the tests share it.
+
- **Per-app profiles.** A global mapping table plus per-app overrides, chosen by the frontmost
app's bundle ID. A button without an override uses the global action; an override can also
give a button back to the mouse in that app.
@@ -23,8 +65,8 @@ All notable changes to Upright are listed here. The format follows
- **Battery:** approximate levels for mice that report only a coarse level, low-battery
notifications at 15% and 5%, and an optional percentage next to the menu-bar icon.
- **Scrolling pane:** reverse mouse-wheel direction independently of the trackpad (wheel
- events only), a speed multiplier, an "only while the MX Vertical is connected" option and
- experimental smooth scrolling. All off by default.
+ events only), a speed multiplier, an "only while a Logitech mouse is connected" option and
+ experimental smooth scrolling. All off by default. They apply to every mouse wheel.
- **Export and import** of mappings and profiles as JSON, with a schema version.
- **First-run onboarding** for new installs.
- **Reset Permission** button (Settings → Permissions and onboarding) that clears a stale
@@ -37,6 +79,20 @@ All notable changes to Upright are listed here. The format follows
### Changed
+- **Mappings are saved per model** in a new format (schema v3), and a mouse model without a
+ saved setup keeps every button native. See "Migration and downgrade notes" below.
+- Upright is described as remapping Logitech mice over HID++, built and tested on the MX
+ Vertical. Documentation, the About pane and the permission texts no longer say it only
+ supports the MX Vertical.
+- **Settings → Device is now Settings → Mice.**
+- **Scrolling:** the option is now "Only while a Logitech mouse is connected" (any supported
+ mouse, not just the MX Vertical). The saved setting keeps its name, so nothing is reset. Scroll
+ settings remain global to every mouse wheel, because macOS doesn't say which mouse a scroll
+ came from.
+- Bluetooth matching now includes the mouse interface of any Logitech mouse, which is where
+ Bluetooth mice carry HID++. Interfaces Upright can't use are never opened.
+- Pointer-speed controls and actions are shown only for mice that offer `ADJUSTABLE_DPI`
+ (`0x2201`); Upright uses no other speed feature.
- **Fresh installs divert no buttons.** Back, Forward and the Top button keep their native
behavior. Users upgrading from 1.0 keep their previous mappings (including the old defaults
if they never changed them).
@@ -59,6 +115,32 @@ All notable changes to Upright are listed here. The format follows
- Documentation no longer says the app uses system materials, and now states that the MX
Vertical pairs over Unifying or Bluetooth only (not Bolt).
+### Migration and downgrade notes
+
+- **From 1.x, or from a build that saved the single-setup format (schema v2).** On first launch the
+ saved setup moves into the **MX Vertical's** setup in the new schema v3 library
+ (`mappingProfiles.v3`), because only an MX Vertical could have produced it. Other models start
+ with every button native. Upgrading without saved data keeps the old defaults on the MX
+ Vertical, and a fresh install remaps nothing.
+- **Nothing is deleted.** The v2 data (`mappingProfiles.v2`) is copied once, unchanged, to
+ `mappingProfiles.v2.preV3Backup`, and the v2 key itself is never written or removed again.
+ The 1.x key is not touched either. Data that can't be read is backed up under
+ `mappingProfiles.corruptBackup.*` instead of being reset.
+- **Pointer speed.** The single saved pointer speed is copied once to the MX Vertical's
+ per-mouse preferences, and the old key is left in place.
+- **Going back to an older build.** An older build reads the frozen v2 data, which is your MX
+ Vertical setup **as it was when you upgraded**. It does not see anything you changed in the new
+ version, and nothing you change in the older build reaches the new one: once a v3 library
+ exists, it always wins. Setups for other models are only in the v3 library and are ignored by
+ older builds.
+- **Export and import.** Exported files now hold every model's setup. They also carry the MX
+ Vertical's setup as top-level `global` and `apps` so an older build can still import them.
+ Importing a file from an older build asks which model it should go to, shows what it will
+ replace, and backs up your current setup first so the import can be undone. Importing a v3 file
+ replaces only the models the file contains.
+- **Per-mouse data** (nickname, remap switch, pointer speed, last seen) stays on this Mac and is
+ never exported.
+
## [1.0.0]
Initial release.
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index d61e752..d601e99 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -1,51 +1,118 @@
# Contributing to Upright
-Thanks for helping. Bug reports with real hardware output are the most useful thing you can
-send: see the issue templates in `.github/ISSUE_TEMPLATE`.
+Thanks for helping. The most useful thing you can send is real hardware output: Upright is
+tested on a single mouse (the MX Vertical), and every other model is built from published data
+and a simulator until someone with the mouse says otherwise. Pick the issue template that fits
+(`.github/ISSUE_TEMPLATE`):
+
+- **Bug report**: something doesn't work. Attach `make probe ARGS=--all` output.
+- **Request support for a mouse** (`new_mouse.md`): your mouse isn't in
+ [`docs/DEVICES.md`](docs/DEVICES.md), or is there as "untested" and you tried it.
+- **Feature request**: a new action or setting.
## Layout
```
Upright/ the macOS app (SwiftUI + AppKit)
- App/ AppModel, AppSettings, DeviceManager, ScrollController, …
+ App/ AppModel, DeviceManager, LinkSource, DemoMode, SnapshotExporter, …
Views/ menu-bar panel, settings panes, onboarding
UprightKit/ Swift package: protocol, device, mapping, scrolling, battery
Sources/UprightKit/
Platform/ IOKit glue (macOS only, behind #if canImport(IOKit))
- Device/ HIDPP/ Mapping/ Scrolling/ Battery/ pure Swift, Foundation only
+ Device/ HIDPPLink (hub), EndpointController, DeviceSession, DeviceRegistry,
+ ConfigurationPlanner, DeviceCatalog, InterfaceClassifier, …
+ HIDPP/ Mapping/ Scrolling/ Battery/
+ Simulation/ software mice and receivers (also used by Demo Mode)
+ (everything outside Platform/ is pure Swift, Foundation only)
Sources/upright-probe/ command-line tool behind `make probe`
- Tests/UprightKitTests/ unit tests, including the simulated mouse
-docs/HIDPP.md every HID++ message Upright sends
+ Tests/UprightKitTests/ unit tests, including the end-to-end fleet tests
+docs/HIDPP.md every HID++ message Upright sends, and the hub/endpoint architecture
+docs/DEVICES.md supported models and their verification status
+docs/dev/ design notes of the multi-mouse work (background reading)
+media/snapshots/ pictures of the real UI rendered by Demo Mode (`make snapshots`)
scripts/ DMG packaging and icon generation
.github/workflows/ CI and release
```
+Read [`docs/HIDPP.md`](docs/HIDPP.md) first if you touch anything under `Device/` or `HIDPP/`:
+it explains the hub (`HIDPPLink`, one per HID interface) and the endpoint
+(`EndpointController`, one per mouse), the identity model, the capability gate and the request
+gate.
+
## Build and test
```bash
-make test # swift test --package-path UprightKit
-make app # Release build → build/Build/Products/Release/Upright.app
-make verify # check the signature of the built app
-make open # build and launch
-make probe # try the HID++ layer on a real mouse; ARGS=--divert to watch button events
+make test # swift test --package-path UprightKit
+make app # Release build → build/Build/Products/Release/Upright.app
+make verify # check the signature of the built app
+make open # build and launch
+make probe # try the HID++ layer on a real mouse
+make snapshots # render the real UI with simulated mice into media/snapshots/
```
-Please run `make test` before opening a pull request, and `make app` if you touched the app
-target.
+`make probe` passes `ARGS` to `upright-probe`:
+
+```bash
+make probe ARGS=--all # also read interfaces Upright doesn't use (keyboards,
+ # Lightspeed receivers, unknown mice): reads only
+make probe ARGS=--divert # divert every mouse's buttons and print the events
+make probe ARGS='--divert="bolt slot 2"' # only the mice whose label contains that text
+make probe ARGS=--trace # print the raw HID++ reports in both directions
+make probe ARGS=--help
+```
+
+Quit Upright first, even without `--divert`: both use HID++ software ID `0x0A` on the same
+shared interface (each could take the other's replies), the probe turns on a receiver's
+notification flags, and two programs diverting the same buttons fight over them. Every line the
+probe prints is prefixed with its device, for example `[MX Master 3S · Bolt slot 2]`.
+
+Please run `make test` before opening a pull request, `make app` if you touched the app
+target, and `make snapshots` if you touched a view (the pictures in the README come from it).
## The app compiles UprightKit directly
The Xcode target does **not** link the package. It references the files in
`UprightKit/Sources/UprightKit` and compiles them into the app module, so app code uses Kit
-types without `import UprightKit`. Two consequences:
+types without `import UprightKit`. The package picks up new files on its own, so `swift test`
+passing doesn't prove the app builds. Two consequences:
-- **A new Swift file must be added to `Upright.xcodeproj/project.pbxproj`** (to the Upright
- target's Sources build phase), whether it lives in `Upright/` or in
- `UprightKit/Sources/UprightKit/`. Add it in Xcode (drag it into the group and tick the
- Upright target) and commit the project file. The package picks up new files on its own, so
- `swift test` passing doesn't prove the app builds.
-- Kit types that the app uses need `public` access for the package's tests and the probe, and
- stay internal-safe for the app. Don't rely on `import UprightKit` inside `Upright/`.
+- Kit types that the app uses need `public` access for the package's tests and the probe. Don't
+ rely on `import UprightKit` inside `Upright/`.
+- **Every Swift file must be registered once in the Upright target.** This is the pbxproj
+ checklist.
+
+### The pbxproj checklist
+
+For a **new** `.swift` file under `Upright/` or `UprightKit/Sources/UprightKit/`
+(tests and `Sources/upright-probe/` are not in the Xcode project):
+
+1. In Xcode, drag the file into the matching group (the groups mirror the folders), with
+ **Upright** ticked as the target. Or edit `Upright.xcodeproj/project.pbxproj` by hand and add
+ all four of: a `PBXFileReference`, a `PBXBuildFile`, the file in its `PBXGroup`, and one
+ entry in the Upright target's `PBXSourcesBuildPhase`.
+2. Check the file is registered **exactly once**. Zero registrations compile fine in
+ `swift test` and then fail in `make app` with "cannot find type in scope". Two give
+ duplicate-symbol errors.
+3. Run the check below. It must print nothing.
+4. Commit the project file with the source file.
+
+For a **rename or delete**, remove or rename all four entries too. Use `git mv`, and rename the
+file in Xcode's navigator so it keeps the reference.
+
+```bash
+# From the repository root. Prints each Swift file not registered exactly once in the Upright
+# target's Sources phase, and each Sources entry whose file no longer exists.
+phase=$(awk '/Begin PBXSourcesBuildPhase/,/End PBXSourcesBuildPhase/' Upright.xcodeproj/project.pbxproj)
+for f in $(find Upright UprightKit/Sources/UprightKit -name '*.swift'); do
+ n=$(grep -c " $(basename "$f") in Sources" <<<"$phase")
+ [ "$n" -eq 1 ] || echo "$f: registered $n times"
+done
+for name in $(grep -o '[A-Za-z0-9_+]*\.swift in Sources' <<<"$phase" | sed 's/ in Sources//'); do
+ find Upright UprightKit/Sources/UprightKit -name "$name" | grep -q . || echo "$name: in the project but not on disk"
+done
+```
+
+File names must be unique across `Upright/` and the Kit, because both compile into one module.
## Keep the Kit portable
@@ -53,32 +120,222 @@ The tests run on Linux in CI, so everything in `UprightKit/Sources/UprightKit` *
`Platform/`** must use Foundation only: no AppKit, CoreGraphics, IOKit, Combine or
`os.Logger`. IOKit-dependent code belongs in `Platform/` behind `#if canImport(IOKit)`. Anything
that needs AppKit or CGEvent (posting events, event taps, notifications) goes in `Upright/App/`
-behind a small protocol if the Kit needs to call it (see `ActionPerforming`).
+behind a small protocol if the Kit needs to call it (see `ActionPerforming`, `LinkSource`).
-Put decisions in the Kit and side effects in the app. That keeps them testable.
+Put decisions in the Kit and side effects in the app. That keeps them testable. The registry
+(which endpoint is active), the planner (what each endpoint should be doing), the classifier
+(which interface to open) and the catalog are all in the Kit for that reason.
## Tests
-- Use the **simulator**, not hardware. `SimulatedMXVertical` (in the test target) answers
- HID++ like a real mouse, directly or behind a receiver slot. Add a scenario to
- `ControllerTests` or `DeviceFeatureTests` for device behavior; extend the simulator if it
- lacks a feature.
-- Pure logic (mappings, profiles, scrolling, gestures, battery policy, persistence) gets plain
- unit tests next to the existing ones.
-- Tests must not post synthetic events, touch real devices, or depend on timing beyond what the
- controller's configurable delays allow. Set short delays (`reconnectDelay`, `retryBaseDelay`,
- `probeTimeout`) in tests.
+Use the **simulator**, not hardware. Tests must not post synthetic events, touch real devices,
+or depend on timing beyond what the configurable delays allow.
+
+### Simulator profiles
+
+`UprightKit/Sources/UprightKit/Simulation/` holds software mice and receivers that answer HID++
+the way published device dumps say real ones do. It is public and Foundation-only, so tests and
+Demo Mode use the same code:
+
+| Type | What it is |
+| --- | --- |
+| `SimulatedDeviceProfile` | One device as plain values: name, kind, protocol version, product IDs, unit ID and serial, feature table (with versions and flags), control table, firmware entities, DPI list, battery feature, Easy-Switch hosts. |
+| `DeviceProfiles` | Ready-made profiles: `mxVertical` (plus `mxVerticalSecondUnit`), `mxMaster3S`, `lift`, `mxMaster3`, `m720`, `mxErgo`, `m185`, plus the cases that must *not* be served: `g502Lightspeed` (no `0x1B04`), `mxKeys` (a keyboard with many divertable keys), `m510HIDPP10` (HID++ 1.0), `mouseWithoutUnitID`. `DeviceProfiles.all` lists them. |
+| `SimulatedReceiver` | A receiver with slots 1…6: `.unifying([slot: profile])`, `.bolt(…)` and `.nano(…)`, each with its own pairing layout. Pair, unpair, re-pair, sleep and wake slots, hold buttons, move the pointer, report batteries, drop requests, add latency. |
+| `SimulatedDirect` | One device on a USB or Bluetooth link (device index `FF`). |
+
+Both transports record what the host sent (`sentReports`, `requestCount(feature:function:slot:)`,
+`setReportingCount`) and the highest number of overlapping requests (`requestsInFlightMax`), so a
+test can assert what went over the wire and what state a "device" ended up in
+(`diverted(slot:)`, `dpi(slot:)`, …).
+
+A test builds a hub on a simulator and checks the result:
+
+```swift
+@MainActor
+final class MyTests: XCTestCase {
+ func testSecondMouseKeepsItsOwnButtons() async {
+ let receiver = SimulatedReceiver.unifying([1: DeviceProfiles.mxVertical, 2: DeviceProfiles.mxMaster3])
+ let hub = makeLink(receiver) // TestSupport.swift: ControllerTuning.fastTests
+ hub.configurationProvider = { _, snapshot in
+ EndpointConfiguration(divert: Set(snapshot.configurableControls.map(\.cid)))
+ }
+ hub.start()
+ await eventually("both connected") { isConnected(hub, at: 1) && isConnected(hub, at: 2) }
+ XCTAssertEqual(receiver.diverted(slot: 2), Set(DeviceProfiles.mxMaster3.controls.filter(\.isConfigurable).map(\.cid)))
+ }
+}
+```
+
+- `FleetHarness` (test target) wires real hubs, `DeviceRegistry`, `ConfigurationPlanner`,
+ `MappingLibrary`, preferences and `RemapEngine` the way `DeviceManager` and `AppModel` do,
+ minus IOKit and AppKit. Use it for anything that spans devices (`EndToEndRoutingTests`).
+- `MultiDeviceControllerTests` covers hub behavior: several mice per receiver, the capability
+ gate, per-slot backoff, re-pairing, the request gate and teardown.
+- `SimulatedMXVertical` is a facade that the older single-mouse `ControllerTests` use. New tests
+ should use `SimulatedReceiver` or `SimulatedDirect` with `DeviceProfiles` directly.
+- Short delays (`ControllerTuning.fastTests`, in `TestSupport.swift`) keep the suite fast.
+- To simulate a new device, add a profile to `DeviceProfiles.swift`: copy its numbers from a
+ published dump or an `upright-probe --all` output (see below). Unit IDs and serial numbers of
+ simulated devices must be obviously fake (`0x?A2B3C4D` and `DEMO-00NN`), because Demo Mode
+ shows them.
+
+### Other tests
+
+- Pure logic (mappings, profiles, scrolling, gestures, battery policy, persistence, the
+ catalog and classifier) gets plain unit tests next to the existing ones.
- If you change how mappings are stored, add a persistence test and keep migrating old data:
- unreadable data is backed up, never dropped.
+ unreadable data is backed up, never dropped. Mappings are schema v3 (one setup per model);
+ v2 data migrates into the MX Vertical model.
+- Test classes that touch main-actor code are `@MainActor`, and **every test method in them must
+ be `async`** (see [CI lessons](#ci-lessons)).
+
+## Demo Mode and snapshots
+
+Demo Mode runs the real UI against simulated mice, with no hardware and no permission:
+
+```bash
+make app
+build/Build/Products/Release/Upright.app/Contents/MacOS/Upright --demo
+```
+
+It builds a `DemoMode.Fleet`: an MX Vertical on a simulated Unifying receiver, an MX Master 3S
+on a simulated Bolt receiver and a Lift over simulated Bluetooth, driven by `SimulatedLinkSource`
+(`Upright/App/LinkSource.swift`) instead of IOKit. It has its own defaults suite
+(`io.github.upright.Upright.demo`, wiped at start), installs no event taps, posts no events,
+asks for no permission and leaves the login item and battery notifications alone, so it can run
+next to a normal Upright and never touches your settings. All names and serials are made up.
+`DemoMode.Scenario` (in `Upright/App/DemoMode.swift`) holds the setup the demo shows: button
+mappings for each model, app profiles, scrolling and pointer speeds.
+
+`make snapshots` renders the interface offscreen into `media/snapshots/`:
+
+```bash
+make snapshots # replaces the PNGs and manifest.json in media/snapshots/
+make snapshots SNAPSHOTS=/tmp/ui # somewhere else
+```
+
+It builds the app, then runs `Upright --demo --export-snapshots `, which writes
+`..png` at 2x (`menubar.label` at 6x) and a `manifest.json` with each image's
+size and the rectangles of its named elements, then quits. It touches no real device and none of
+your settings, and runs fine while Upright is running.
+
+To add a state, add a `State` to `SnapshotExporter.States.all` and name the views the manifest
+should locate with `.snapshotAnchor("name")` (a no-op outside an export). A new view that shows
+mouse data should be reachable in Demo Mode, so change the scenario rather than special-casing
+the view. `UPRIGHT_SNAPSHOT_ONLY= make snapshots` renders only the states you are
+working on. The design is in [`docs/dev/DEMO-MODE-SPEC.md`](docs/dev/DEMO-MODE-SPEC.md).
+
+## Adding a model from an `upright-probe --all` dump
+
+Upright serves a mouse because of what it answers (HID++ 2.0, a pointer, `0x1B04` with
+remappable controls), not because it is in a table, so a mouse that is missing from the catalog
+may already work. The catalog adds its name, the order and wording of its controls, a suggested
+setup and its product IDs. You need the mouse (or someone's dump) and `make probe ARGS=--all`.
+
+1. **Get the dump.** Quit Upright, connect the mouse the way you use it, wake it, and run
+ `make probe ARGS=--all`. Every line starts with the device's label. For each device you get
+ an `Identity` block, the feature table and a control table (abridged, with made-up numbers):
+
+ ```
+ [MX Master 3S · Bolt slot 1] ✓ MX Master 3S: Upright supports this device
+ [MX Master 3S · Bolt slot 1] Connection: Bolt slot 1, device index 0x01, HID++ 4.5
+ [MX Master 3S · Bolt slot 1] Identity:
+ [MX Master 3S · Bolt slot 1] Kind: mouse
+ [MX Master 3S · Bolt slot 1] Model: 046D:B034 (not in Upright's catalog)
+ [MX Master 3S · Bolt slot 1] Model IDs (0x0003): Bluetooth LE B034
+ [MX Master 3S · Bolt slot 1] Wireless PID from the receiver: B034
+ [MX Master 3S · Bolt slot 1] Controls (REPROG_CONTROLS_V4 v5, 8 controls, 5 remappable by Upright):
+ [MX Master 3S · Bolt slot 1] * CID 0x0052 TID 0x0000 Middle Button [mouse, reprogrammable, divertable, persistent, raw XY] (flags …)
+ ```
+
+ `--all` also lists what Upright leaves alone and why (a Lightspeed receiver, a keyboard, a
+ HID++ 1.0 device), and prints each receiver's `Pairing table` with its layout.
+
+ The output includes the unit ID and serial number. Black them out if you like before
+ sharing, but keep a note of whether the unit ID is the same across connections if you ran
+ the probe over two of them.
+
+2. **Read the numbers.** The *Model IDs* give the product IDs per transport (`Bluetooth`,
+ `Bluetooth LE`, `wireless`, `USB`). The first of Bluetooth, Bluetooth LE, wireless, USB is
+ the model's canonical ID, and `ModelKey("046D:XXXX")` is built from it. Controls marked `*`
+ are the ones Upright can remap. A control name of "unnamed" means Solaar's table has no entry
+ for that CID.
+
+3. **Add the catalog entry** in `Device/DeviceCatalog.swift` with the `entry(…)` helper and list
+ it in `DeviceCatalog.models`:
+
+ ```swift
+ static let mxMaster3S = entry("MX Master 3S", bluetooth: [0xB034], wireless: [0xB034],
+ controls: [back, forward, gesture, smartShift, wheel])
+ ```
+
+ - The key becomes the first Bluetooth ID, else the first wireless one, else the USB one.
+ **That must be the canonical ID the mouse reports.** The same value is already the key under
+ which an unlisted mouse's mappings were saved, so a matching entry keeps users' setups.
+ Never change an existing model's key.
+ - `controls` is the display order (use `KnownControl` CIDs, or define a new constant). Every
+ CID needs a name in `ControlCatalog.solaarNames` and wording in `ControlCatalog.genericText`
+ or the model's own `text:` (`CatalogTests.testEveryControlOrderCIDIsNamed` checks it). A
+ model-specific name goes in `text:`; see the MX Ergo's "Precision Button".
+ - Do not list Left and Right click. Product IDs must be unique across the catalog.
+ - New entries are `.publishedDump`. Only the maintainer sets `.hardware`, after testing
+ (`CatalogTests.testOnlyTheMXVerticalIsVerifiedOnHardware`).
+ - A new **receiver** goes in `DeviceCatalog.receivers` with its pairing layout (`.unifying`,
+ `.bolt`). A mouse that is only reachable behind a receiver the classifier doesn't open
+ (Lightspeed, 27 MHz) needs the proof first: show a dump with `0x1B04`.
+ - Direct mice with a product ID outside the USB range `C07D`-`C094` or the Bluetooth range
+ `B012`-`B0FF` are accepted because they are in the catalog
+ (`InterfaceClassifier.classifyDirect`). A keyboard-range ID is accepted the same way, so
+ double-check that the device is really a pointer.
+
+4. **Add a simulator profile** to `Simulation/DeviceProfiles.swift` from the same dump, and add
+ it to `DeviceProfiles.all`. Copy the feature table with its versions and flags, the control
+ table (flags, raw XY bit, group), the firmware entities and the battery feature; invent the DPI
+ list if you don't have one; use a fake unit ID and `DEMO-00NN` serial. A device that is
+ interesting because it is *not* served (no `0x1B04`, HID++ 1.0, a keyboard) is a profile too.
+
+5. **Add tests.** Extend the expectations in `CatalogTests.testResearchedModelsArePresentWithTheirIDs`
+ (it also checks the number of models), add the profile to
+ `SimulatorTests.testMouseProfilesAnswerFromTheirTables`, and, if the model has a quirk, a
+ hub test in `MultiDeviceControllerTests`. `make test` must pass.
+
+6. **Update the docs.** Add the model to [`docs/DEVICES.md`](docs/DEVICES.md) as *Published data
+ (untested on hardware)*, add a line under **Unreleased** in `CHANGELOG.md`, and mention the
+ dump in the pull request. Don't write that a model "works" before someone has run it.
+
+If the dump shows something the hub gets wrong (a different device index, a pairing layout
+that doesn't parse, controls that refuse `setCidReporting`), describe it in the pull request and
+attach the `--trace` output; it is more valuable than the catalog line.
+
+## CI lessons
+
+CI builds the app with Xcode 16.2 (Swift 6.0) on macOS 15, runs `swift test` there, and runs it
+again on Ubuntu with Swift 6.0. Your local toolchain may be newer, and macOS doesn't catch
+everything Linux does. The
+project stays in Swift 5 language mode and targets macOS 13: don't use language or SDK features
+newer than that, and gate newer APIs with `#available`. Known traps:
+
+- **Integer literals in mixed expressions.** Swift 6.0 can't infer `0xE000 | 50` as `UInt16`
+ inside a `[UInt16]` literal. Type such expressions explicitly: `UInt16(0xE000) | 50`.
+- **Every test method in a `@MainActor` `XCTestCase` class must be `async`.** Synchronous ones
+ fail to compile in the generated `__allTests__` table of Linux SwiftPM 6.0. macOS doesn't
+ complain, so a green local run proves nothing. (Classes that aren't `@MainActor` can keep
+ synchronous tests.)
+- **Foundation only outside `Platform/`.** The Linux build has no AppKit, IOKit or Combine, so
+ a stray import breaks CI and nothing else. `InterfaceClassifier.hex` avoids `String(format:)`
+ to be exact on Linux too; do the same where a test compares formatted text.
+- New Kit value types are `Sendable`, and types that hold callbacks are `@MainActor`, as the
+ existing ones are.
+- Anything that passes only because of timing is a flaky test on a loaded CI machine. Use
+ `eventually(…)` with a generous timeout and the fast tuning, not `sleep`.
## Compatibility
- Deployment target is macOS 13. Gate newer APIs with `#available`.
-- CI builds with Xcode 16.2 (Swift 6.0) and the project stays in Swift 5 language mode. Don't
- use language or SDK features newer than that.
-- Raw `Codable` formats (`ButtonAction`, `DeviceAction`, settings) are saved data. Don't rename
- cases or payload labels; add new cases instead and bump `MappingProfiles.schemaVersion` only
- for incompatible changes.
+- Raw `Codable` formats (`ButtonAction`, `DeviceAction`, `MappingLibrary`, settings) are saved
+ data. Don't rename cases or payload labels; add new cases instead and bump the schema version
+ only for incompatible changes. A model key (`046D:XXXX`) is part of that data too.
## Style
@@ -89,21 +346,24 @@ Put decisions in the Kit and side effects in the app. That keeps them testable.
- No force unwraps in app code paths. HID++ replies can be short or wrong; check lengths.
- Keep the app permission-light. Ask for Accessibility only where an action truly needs it
(`requiresAccessibility`), and never prompt at launch.
+- A device that isn't a supported mouse is never written to. If you add a code path that
+ writes, make sure it sits behind the capability gate.
- Don't add dependencies.
## Protocol work
Write new HID++ support from public documentation and the credited projects, as the README
describes. Don't copy code from GPL projects and don't use Logitech binaries. Record every new
-message in `docs/HIDPP.md`.
+message in `docs/HIDPP.md`, and say whether it was tested on hardware.
## Pull requests
1. Keep a pull request to one change.
-2. Update `README.md`, `INSTALL.md` or `docs/HIDPP.md` when behavior or protocol use changes,
- and add a line under **Unreleased** in `CHANGELOG.md`.
-3. Fill in the pull request template, including what you tested and on which mouse
- connection.
+2. Update `README.md`, `INSTALL.md`, `docs/HIDPP.md` or `docs/DEVICES.md` when behavior,
+ protocol use or device support changes, and add a line under **Unreleased** in
+ `CHANGELOG.md`.
+3. Fill in the pull request template, including what you tested and on which mouse and
+ connection. If you couldn't test on hardware, say so; the simulator is a good answer.
## Releasing
diff --git a/INSTALL.md b/INSTALL.md
index 0b0a8e1..f24d494 100644
--- a/INSTALL.md
+++ b/INSTALL.md
@@ -1,15 +1,23 @@
# Installing Upright on your Mac
-Upright remaps the buttons on a Logitech MX Vertical. You build it yourself from this folder.
-That takes a few minutes and needs no Apple account.
+Upright remaps the buttons on Logitech mice, and works with several mice at once. You build it
+yourself from this folder. That takes a few minutes and needs no Apple account.
+
+It was built and tested on the **MX Vertical**, the only model verified on real hardware. Other
+Logitech HID++ 2.0 mice with programmable buttons (for example the MX Master and Lift families)
+are expected to work, but are **unverified on hardware**; see the list in the
+[README](README.md#supported-devices) and [docs/DEVICES.md](docs/DEVICES.md).
## What you need
- macOS 13 Ventura or newer
-- **Xcode 15 or newer**, free from the Mac App Store. Open it once after installing so it can
+- **Xcode 15.3 or newer**, free from the Mac App Store. Open it once after installing so it can
finish setting up.
-- Your MX Vertical, connected by its Unifying receiver, its USB cable, or Bluetooth. (The MX
- Vertical doesn't work with a Bolt receiver.)
+- A supported Logitech mouse, connected by a Unifying receiver, a USB cable or Bluetooth. Bolt and
+ Nano receivers are also read, but are unverified on hardware. (The MX Vertical doesn't work with
+ a Bolt receiver. Lightspeed receivers, used by G and PRO gaming mice, are never opened.)
+- You can connect several mice at once, in any mix. Each one is listed in the menu-bar panel and
+ in Settings → Mice.
## 1. Unzip
@@ -45,8 +53,8 @@ quit, launch the copy in Applications and turn it on again.
## 4. Allow Accessibility
-Upright needs this to send keyboard shortcuts, media keys and clicks for your buttons, and to
-change scrolling.
+Upright needs this to send keyboard shortcuts, media keys and clicks for your mice's buttons, and
+to change scrolling. It never reads what you type.
1. Click **Allow…**, either in the welcome window, the menu-bar panel or Settings → Buttons.
2. In System Settings → Privacy & Security → **Accessibility**, turn **Upright** on.
@@ -54,15 +62,28 @@ change scrolling.
Until you allow it, mappings that need it are not applied and your buttons keep their normal
behavior. Actions that don't send input (Mission Control, opening apps and links, Shortcuts,
-pointer speed) work without it. Upright asks for **Input Monitoring** only if macOS hides the
-mouse from it, which can happen over Bluetooth.
+pointer speed) work without it. Upright asks for **Input Monitoring** only if macOS hides a
+mouse from it, which can happen over Bluetooth. The Permissions pane then names the blocked
+device.
If Accessibility shows as on but Upright says it isn't allowed (this happens after you rebuild
or replace the app), open Settings → Permissions and click **Reset Permission**.
-**What the buttons do at first.** A fresh install remaps nothing: Back, Forward and the top
-button keep their normal behavior. To change them, open the menu-bar icon → **Settings…** →
-**Buttons**. If you are upgrading from an earlier version, your old mappings are kept.
+**What the buttons do at first.** A fresh install remaps nothing: every button on every mouse
+keeps its normal behavior (on the MX Vertical, Back, Forward and the top button). To change
+them, open the menu-bar icon → **Settings…** → **Buttons**, choose your mouse model in
+**Configure**, and pick an action for each button. Mappings belong to the mouse **model**, so two
+mice of one model share a setup, while different models each have their own. Scroll settings
+(Settings → Scrolling) apply to every mouse wheel.
+
+If you are upgrading from an earlier version, your old mappings are kept and become the MX
+Vertical's setup. Other models start with every button native. If you go back to an older build
+later, it sees the MX Vertical setup you had when you upgraded, not later changes (see the
+[changelog](CHANGELOG.md)).
+
+**No mouse handy?** Run `build/Build/Products/Release/Upright.app/Contents/MacOS/Upright --demo`
+to look around the interface with simulated mice. It touches no real device and none of your
+settings.
## If macOS blocks the app
@@ -104,10 +125,10 @@ press ⌘Q while the panel is open. Your buttons go back to normal straight away
tccutil reset ListenEvent io.github.upright.Upright
```
-Upright never writes anything permanent to the mouse, so nothing needs to be undone there.
+Upright never writes anything permanent to a mouse, so nothing needs to be undone there.
## Troubleshooting
-Mouse not found, Bluetooth problems, buttons that don't remap, scrolling that doesn't change
-and more are covered in the
+Mouse not found, a mouse listed under "Other Logitech devices", Bluetooth problems, buttons
+that don't remap, scrolling that doesn't change and more are covered in the
[Troubleshooting section of the README](README.md#troubleshooting).
diff --git a/Makefile b/Makefile
index f6c0c53..50451a7 100644
--- a/Makefile
+++ b/Makefile
@@ -1,7 +1,7 @@
BUILD_DIR := build
APP := $(BUILD_DIR)/Build/Products/Release/Upright.app
-.PHONY: test app verify dmg probe open clean
+.PHONY: test app verify dmg probe open snapshots clean
## Run the UprightKit unit tests (works on macOS and Linux).
test:
@@ -24,10 +24,25 @@ verify:
dmg: app
scripts/make_dmg.sh $(APP) dist/Upright.dmg
-## Talk to a connected MX Vertical from the terminal. Pass ARGS=--divert to watch button events.
+## Talk to connected Logitech mice from the terminal: prints each mouse's identity, HID++ features and controls,
+## every line prefixed with its device, e.g. [MX Master 3S · Bolt slot 2].
+## make probe ARGS=--all also read interfaces Upright doesn't use (keyboards, Lightspeed receivers, unknown mice)
+## make probe ARGS=--divert watch button events of every mouse (Ctrl-C restores the buttons)
+## make probe ARGS='--divert="bolt slot 2"' the same for the mice whose label contains that text
+## make probe ARGS=--help all options
probe:
swift run --package-path UprightKit upright-probe $(ARGS)
+## Render the real UI offscreen with simulated mice (Demo Mode) into media/snapshots/:
+## ..png at 2x (menubar.label at 6x) plus manifest.json with each image's size
+## and named rects. Touches no real device and none of your Upright settings; works while Upright
+## runs. Override the folder with SNAPSHOTS=.
+SNAPSHOTS ?= media/snapshots
+snapshots: app
+ mkdir -p "$(SNAPSHOTS)"
+ rm -f "$(SNAPSHOTS)"/*.png "$(SNAPSHOTS)/manifest.json"
+ "$(APP)/Contents/MacOS/Upright" --demo --export-snapshots "$(SNAPSHOTS)"
+
## Launch the freshly built app.
open: app
open $(APP)
diff --git a/README.md b/README.md
index 93fab99..ad8baef 100644
--- a/README.md
+++ b/README.md
@@ -1,25 +1,50 @@
# Upright
-**Open-source button remapping for the Logitech MX Vertical on macOS, without Logi Options+.**
-
-Upright is a small native SwiftUI menu-bar app. It remaps the MX Vertical's thumb buttons, the
-top (DPI) button and the wheel click, and adds pointer-speed, scrolling and battery features. It
-talks to the mouse directly over the publicly documented **HID++ 2.0** protocol, the same
-approach used by Solaar, LogiOps and libratbag. It doesn't use a kernel extension or a
-background daemon, and it doesn't include any Logitech software.
+**Open-source button remapping for Logitech mice on macOS, without Logi Options+.**
+
+Upright is a small native SwiftUI menu-bar app. **It remaps Logitech mice over HID++**: the
+thumb buttons, the top (DPI) button, the wheel click and any other button a mouse lets software
+take over. It also adds pointer-speed, scrolling and battery features. It talks to the mouse
+directly over the publicly documented **HID++ 2.0** protocol, the same approach used by Solaar,
+LogiOps and libratbag. It doesn't use a kernel extension or a background daemon, and it doesn't
+include any Logitech software.
+
+Upright works with any Logitech HID++ 2.0 mouse that exposes programmable buttons
+(`REPROG_CONTROLS_V4`), and with **several mice at once**, over any mix of USB cable, Bluetooth
+and Unifying, Bolt or Nano receivers. It was built and tested on the **MX Vertical**, which is the
+only model verified on real hardware. Every other model is built from published device data and
+tested against Upright's mouse simulator only. See [Supported devices](#supported-devices).
+
+
+
+
+
+
+*Both pictures are rendered by [Demo Mode](#demo-mode) with simulated mice (an MX Vertical, an
+MX Master 3S and a Lift, with made-up names and batteries). They show the real interface, not
+real hardware.*
## Features
- **Remap buttons.** Back, Forward, the Top (DPI) button, the Wheel Click and any other
- divertable button the mouse reports. Actions:
+ divertable button a mouse reports. Actions:
- built-in system actions: Mission Control, App Exposé, Show Desktop, switching Spaces,
Back/Forward, tabs, media keys, Spotlight, screenshots, Lock Screen,
Copy/Paste/Undo/Redo, middle click and mouse buttons 4/5
- any recorded keyboard shortcut
- **Open App**, **Open URL** and **Run Shortcut** (a Shortcuts.app shortcut, by name)
- - **Pointer speed**: cycle through your presets, or jump to a fixed DPI
- - **Easy-Switch**: move the mouse to computer 1, 2 or 3
+ - **Pointer speed**: cycle through your presets, or jump to a fixed DPI (mice with
+ `ADJUSTABLE_DPI`, `0x2201`, only)
+ - **Easy-Switch**: move the mouse to another paired computer (mice that report the Easy-Switch
+ feature)
- **Do Nothing**, or **Default** to leave the button to the mouse
+- **Several mice at once.** Every connected mouse is listed in the menu-bar panel and in
+ **Settings → Mice**, with its connection, state and battery. See
+ [Using several mice](#using-several-mice).
+- **Per-model mappings.** Each mouse model has its own button setup and its own per-app profiles.
+ Choose which model you are editing with the **Configure** picker in **Settings → Buttons**. You
+ can set up a catalog model before you own it, copy a setup from another model, or use one setup
+ for all your mice.
- **Gestures.** Set a button to *Gestures* to get five actions: a click, plus hold and move up,
down, left or right. The mouse reports raw movement while the button is held, so the cursor
doesn't move. If a button can't report raw movement, Upright falls back to watching the
@@ -27,25 +52,33 @@ background daemon, and it doesn't include any Logitech software.
- **Per-app profiles.** Keep a global table and add apps (by bundle ID) that override single
buttons while they are frontmost. A button without an override uses the global action, and
an override can also hand a button back to the mouse in that app.
+- **Per-mouse settings.** Give a mouse a nickname, switch **Remap this mouse** off to hand all its
+ buttons back, and keep its pointer speed. Upright remembers these for each physical mouse and
+ lists mice it has seen before under **Not Connected**.
- **Pointer speed (DPI).** Set the speed with a slider, keep a list of presets, and pick a
- speed to apply whenever the mouse reconnects. Speeds snap to what the mouse supports.
+ speed to apply whenever a mouse reconnects. Speeds snap to what the mouse supports. Mice without
+ `0x2201` don't get pointer-speed controls.
- **Device info.** Firmware, serial number, battery, connection type, the Easy-Switch channel
- (with host names when the mouse stores them), the HID++ feature table and the control list.
-- **Battery.** Reads the level when the mouse connects and about every 10 minutes. Mice that
+ (with host names when the mouse stores them), the HID++ feature table and the control list, plus
+ a **Copy Device Report** button for bug reports and new-model requests.
+- **Battery.** Reads the level when a mouse connects and about every 10 minutes. Mice that
report only a coarse level (critical, low, good, full) show an approximate value such as
- "~50%". Low-battery notifications at 15% and 5% (on by default; macOS asks for notification
- permission the first time), and an optional percentage next to the menu-bar icon.
+ "~50%". Low-battery notifications at 15% and 5% per mouse (on by default; macOS asks for
+ notification permission the first time), and an optional percentage next to the menu-bar icon
+ (with several mice it shows the lowest).
- **Scrolling.** Reverse the mouse wheel independently of the trackpad, change the speed
- (0.5x to 3x), and try the experimental smooth scrolling. All off by default. See
- [Scrolling](#scrolling) for what is and isn't affected.
-- **Export and import** of all mappings and profiles as a JSON file.
+ (0.5x to 3x), and try the experimental smooth scrolling. All off by default. These settings
+ apply to **every** mouse wheel; see [Scrolling](#scrolling).
+- **Export and import** of all mappings and profiles, for every model, as a JSON file.
- **First-run onboarding** for new installs: connect, allow Accessibility, choose a setup.
-- **Robust reconnection.** Button diversion is reapplied after the mouse sleeps or is switched
- off and on, after the Mac wakes, and when a receiver or cable is replugged. A mouse that
- doesn't answer is retried with backoff and never given up on while its HID interface is open.
+- **Robust reconnection.** Button diversion is reapplied after a mouse sleeps or is switched
+ off and on, after the Mac wakes, and when a receiver or cable is replugged. Each mouse is
+ retried on its own with backoff, so a sleeping mouse doesn't hold up another one.
- **Leaves nothing behind.** Buttons are only *temporarily* diverted. When Upright quits it
- hands them back, and power-cycling the mouse always restores factory behavior. Nothing is
- written to the mouse's memory.
+ hands them back, and power-cycling a mouse always restores factory behavior. Nothing is
+ written to a mouse's memory.
+- **Demo Mode.** `--demo` runs the real interface against simulated mice, and
+ `--export-snapshots` renders it to PNG files. See [Demo Mode](#demo-mode).
- **Feels native.** A menu-bar panel, a System Settings-style window, SF Symbols, light and
dark mode. Open at Login uses `SMAppService`.
- **Explains permissions.** It shows what it needs and why. Until Accessibility is granted,
@@ -53,26 +86,120 @@ background daemon, and it doesn't include any Logitech software.
### Defaults
-A fresh install **remaps nothing**: Back, Forward and the Top button keep their native
-behavior (mouse buttons 4 and 5, and DPI cycling). Pick actions in **Settings → Buttons**.
+A fresh install **remaps nothing**: every button keeps its native behavior (for the MX Vertical,
+Back and Forward are mouse buttons 4 and 5, and the top button cycles DPI). Pick actions in
+**Settings → Buttons**, or accept the suggested setup for your model during onboarding.
+
+If you used an earlier version, your saved mappings are migrated into the MX Vertical's setup and
+kept. Anything Upright can't read is backed up in its preferences instead of being thrown away.
+See the [changelog](CHANGELOG.md) for the details of the migration and of going back to an older
+build.
+
+## Supported devices
+
+> **Only the MX Vertical has been tested on real hardware.** Every other model below is built from
+> published device data (Solaar and libratbag dumps) and has been tested only against Upright's
+> mouse simulator. "Published data" means exactly that: it is expected to work, and **unverified
+> on hardware**. If you try one, please report what you see (see [Report your
+> mouse](#report-your-mouse)).
+
+The table below is derived from the model catalog,
+[`DeviceCatalog.swift`](UprightKit/Sources/UprightKit/Device/DeviceCatalog.swift). The same data
+with every product ID is in [docs/DEVICES.md](docs/DEVICES.md).
+
+| Model | Connections in the catalog | Remappable controls in the catalog | Status |
+| --- | --- | --- | --- |
+| MX Vertical | USB cable, Bluetooth, Unifying receiver | Back, Forward, Top (DPI) button, Wheel click | Verified on hardware |
+| MX Master | Bluetooth, Unifying / Nano receiver | Back, Forward, Gesture button, SmartShift button, Wheel click | Published data (untested on hardware) |
+| MX Master 2S | Bluetooth, Unifying / Nano receiver | Back, Forward, Gesture button, SmartShift button, Wheel click | Published data (untested on hardware) |
+| MX Master 3 | Bluetooth, Unifying / Nano receiver | Back, Forward, Gesture button, SmartShift button, Wheel click | Published data (untested on hardware) |
+| MX Master 3 for Business | Bluetooth, Bolt receiver | Back, Forward, Gesture button, SmartShift button, Wheel click | Published data (untested on hardware) |
+| MX Master 3S | Bluetooth, Bolt receiver | Back, Forward, Gesture button, SmartShift button, Wheel click | Published data (untested on hardware) |
+| MX Master 4 | Bluetooth | Back, Forward, Gesture button, Haptic Sense button, SmartShift button, Wheel click | Published data (untested on hardware) |
+| MX Anywhere 2 | Bluetooth, Unifying / Nano receiver | Back, Forward, Wheel click, Tilt left, Tilt right | Published data (untested on hardware) |
+| MX Anywhere 2S | Bluetooth, Unifying / Nano receiver | Back, Forward, Wheel click, Tilt left, Tilt right | Published data (untested on hardware) |
+| MX Anywhere 3 | Bluetooth, Unifying / Nano receiver | Back, Forward, SmartShift button, Wheel click | Published data (untested on hardware) |
+| MX Anywhere 3 for Business | Bluetooth, Bolt receiver | Back, Forward, SmartShift button, Wheel click | Published data (untested on hardware) |
+| MX Ergo | Bluetooth, Unifying / Nano receiver | Back, Forward, Precision button (DPI), Wheel click, Tilt left, Tilt right | Published data (untested on hardware) |
+| Lift | Bluetooth, Bolt receiver | Back, Forward, Top (DPI) button, Wheel click | Published data (untested on hardware) |
+| Lift for Business | Bluetooth, Bolt receiver | Back, Forward, Top (DPI) button, Wheel click | Published data (untested on hardware) |
+| M720 Triathlon | Bluetooth, Unifying / Nano receiver | Back, Forward, Gesture button (multi-platform), Wheel click, Tilt left, Tilt right | Published data (untested on hardware) |
+| M585 / M590 | Bluetooth, Unifying / Nano receiver | Back, Forward, Wheel click, Tilt left, Tilt right | Published data (untested on hardware) |
+| Signature M650 L | Bluetooth, Bolt receiver | Back, Forward, Wheel click | Published data (untested on hardware) |
+| Signature M550 | Bluetooth, Bolt receiver | Wheel click | Published data (untested on hardware) |
+| ERGO M575 | Bluetooth, Unifying / Nano receiver | Back, Forward, Wheel click | Published data (untested on hardware) |
+| Marathon M705 | Unifying / Nano receiver | Back, Forward, Wheel click, Tilt left, Tilt right | Published data (untested on hardware) |
+| Pebble M350 | Bluetooth, Unifying / Nano receiver | Wheel click | Published data (untested on hardware) |
+| M510 (v2) | Unifying / Nano receiver | Back, Forward, Wheel click, Tilt left, Tilt right | Published data (untested on hardware) |
+| M185 / M235 / M310 | Unifying / Nano receiver | Wheel click | Published data (untested on hardware) |
+
+| Status | Meaning |
+| --- | --- |
+| **Verified on hardware** | Tested on a real mouse. Only the MX Vertical. |
+| **Published data (untested on hardware)** | In the catalog, built from published dumps. Simulator-tested only. |
+| **Untested** | Not in the catalog. Any other Logitech mouse that answers HID++ 2.0 and exposes programmable buttons is served too, but nothing is known about it. |
+
+The catalog supplies names, control lists and suggested setups. Whether a mouse is served is
+decided by what it answers (HID++ 2.0, a pointer, and `0x1B04` with at least one control Upright can
+divert), not by its name or product ID. A mouse that doesn't qualify shows up under
+**Settings → Mice → Other Logitech devices** with the reason, and keeps working as usual.
+Keyboards are never opened. G-series and PRO gaming mice keep their buttons in onboard profiles
+and are not expected to work.
-If you used an earlier version, your saved mappings are migrated and kept. Anything Upright
-can't read is backed up in its preferences instead of being thrown away.
+### Receivers
-## Connections
+| Receiver | Status |
+| --- | --- |
+| **Unifying** (`046D:C52B`, `C532`) | Supported. A mouse is found in any of the 6 slots. The MX Vertical is verified on hardware (1.0) behind one; other mice are unverified. |
+| **Bolt** (`046D:C548`) | Implemented from Solaar's documentation and **simulator-tested only**. Not verified on a real Bolt receiver. The MX Vertical can't pair with Bolt. |
+| **Nano** (12 product IDs, see [docs/DEVICES.md](docs/DEVICES.md#receivers)) | Opened and read with the Unifying pairing layout. **Unverified on hardware.** |
+| **Lightspeed** (G and PRO mice) | **Never opened.** The mice behind Lightspeed receivers in the research data have no programmable-button feature (`0x1B04`), the `C53A` receiver also carries a PowerPlay chip, and Logitech G HUB owns them. Upright leaves them completely alone. |
+| **27 MHz** (pre-Unifying, `C517`) | Never opened. Those devices speak HID++ 1.0 only. |
+
+### Connections
| Connection | Status |
| --- | --- |
-| USB cable (`046D:C08A`) | Supported |
-| Unifying receiver (`046D:C52B`, `C532`) | Supported. The mouse is found in any of the 6 slots. |
-| Bluetooth (`046D:B020`) | Best effort. Depends on macOS exposing the HID++ collection, and may need Input Monitoring. |
-| Bolt receiver (`046D:C548`) | **Not applicable.** The MX Vertical can't pair with a Bolt receiver (Unifying and Bluetooth only). Upright still watches Bolt receivers in case a future model needs them. |
+| USB cable | Supported. The MX Vertical (`046D:C08A`) is verified on hardware (1.0); other models are unverified. |
+| Bluetooth | Best effort. Depends on macOS exposing the mouse's HID++ collection, and may need Input Monitoring. The MX Vertical (`046D:B020`) is the model it was built for; other models are unverified. |
+| Receivers | See above. |
+
+### Report your mouse
+
+If you own a model other than the MX Vertical, you can help move it from "published data" to
+"verified": use **Settings → Mice → Copy Device Report** while the mouse is connected, then quit
+Upright and run `make probe ARGS=--all` (no button is diverted; Upright and the probe must not run
+at the same time), then open an issue on GitHub with both
+(see `.github/ISSUE_TEMPLATE`).
+
+## Using several mice
+
+You can connect any number of supported mice at the same time, over any mix of cable, Bluetooth
+and receivers, including several mice on one receiver. Upright opens each one separately.
+
+- **Each mouse is its own row** in the menu-bar panel and in **Settings → Mice**, with its own
+ state, battery, pointer speed, nickname and **Remap this mouse** switch. A mouse that is asleep
+ or isn't answering doesn't hold up the others.
+- **Button mappings belong to the model, not to the unit.** Two MX Masters share one setup; an MX
+ Master and an MX Vertical each have their own. When two or more units of a model are connected,
+ the Buttons pane says "Applies to every ".
+- **The same mouse over two links** (for example a mouse that is on its receiver and also on a
+ cable) is recognized by its unit ID and listed once: one connection is used and the other is
+ kept as a standby, so an action is never sent twice. This relies on the mouse reporting the same
+ unit ID on both links, which is **not yet verified on hardware**.
+- **The menu-bar panel's button summary, pointer speed and Easy-Switch sections** act on one
+ mouse at a time and show its name in the section header: the mouse that last sent a button
+ press, else the one selected in **Settings → Mice**, else the first connected.
+- **Verification.** Multi-mouse behavior is covered by the simulator and by end-to-end tests with
+ several simulated mice. Upright 1.0 was tested on hardware with a single MX Vertical; this
+ multi-mouse build has not yet been run on real hardware. **Two real mice at once is unverified.**
## Requirements
- macOS 13 Ventura or later (Apple silicon or Intel)
-- Xcode 15 or later to build (CI builds with Xcode 16.2)
-- A Logitech MX Vertical
+- Xcode 15.3 or later to build (CI builds with Xcode 16.2)
+- A Logitech mouse that speaks HID++ 2.0 with programmable buttons. The MX Vertical is the one
+ verified on hardware; see [Supported devices](#supported-devices).
## Build and run
@@ -94,7 +221,8 @@ make app # Release build, universal, ad-hoc signed with the hardened runtim
make verify # codesign --verify on the built app, and print its flags
make dmg # …and package it as dist/Upright.dmg
make open # build and launch
-make probe # talk to a connected MX Vertical from the terminal (see below)
+make probe # talk to connected Logitech mice from the terminal (see below)
+make snapshots # render the UI with simulated mice into media/snapshots/ (see Demo Mode)
make clean # remove build/, dist/ and UprightKit/.build
```
@@ -141,11 +269,13 @@ xattr -dr com.apple.quarantine /Applications/Upright.app
| Permission | Needed for | When |
| --- | --- | --- |
| **Accessibility** | Posting the keyboard shortcuts, media keys and clicks your buttons are mapped to, and changing scrolling (it needs an event tap) | Whenever a mapping sends input or a scroll setting is on. Mission Control actions, Open App/URL, Run Shortcut, pointer speed, Easy-Switch and "Do Nothing" work without it. |
-| **Input Monitoring** | Opening the mouse's HID++ interface | Only if macOS hides the mouse from Upright, which can happen over Bluetooth. Upright doesn't ask for it at launch. The Permissions pane and the menu-bar panel say so when it happens. |
+| **Input Monitoring** | Opening a mouse's HID++ interface | Only if macOS hides a mouse from Upright, which can happen over Bluetooth. Upright doesn't ask for it at launch. The Permissions pane and the menu-bar panel say so when it happens. |
-- Upright never reads keystrokes. It only opens Logitech HID interfaces (the vendor usage pages
- `0xFF00` and `0xFF43`, plus the MX Vertical's own mouse interface over Bluetooth and USB), and
- only posts the actions you assign.
+- Upright never reads keystrokes. It only opens Logitech HID interfaces: the vendor usage pages
+ `0xFF00` and `0xFF43` (receivers, cables and Bluetooth LE) and the mouse interface of Logitech
+ mice, where Bluetooth mice carry HID++. Keyboards, Lightspeed receivers and other devices that
+ Upright can't use are not opened, and it only posts the actions you assign. It never configures
+ a mouse that doesn't answer as a supported pointer.
- Until Accessibility is granted, buttons with keyboard, media or click actions are **not
diverted**, so they keep working as normal mouse buttons. The menu-bar panel and the Buttons
pane show an **Allow…** button that opens the right System Settings page. The UI updates by
@@ -174,46 +304,51 @@ continuous scroll events, and Upright never touches those.
- **Reverse** vertical and horizontal scrolling separately from the trackpad's Natural Scrolling.
- **Speed** multiplies the distance of each wheel step (0.5x to 3x).
- **Smooth scrolling** (experimental) turns each wheel step into a short eased glide.
-- **Only while the MX Vertical is connected** (on by default). macOS doesn't tell an event tap
- which mouse a scroll came from. With this on, Upright rewrites wheel events only while an MX
- Vertical is connected, but it can't tell that mouse from another one. So while the MX
- Vertical is connected, **any other mouse wheel is changed too**. With the option off, every
- mouse wheel is always changed.
+- **Only while a Logitech mouse is connected** (on by default). macOS doesn't tell an event tap
+ which mouse a scroll came from, so **these settings are global to every mouse wheel**, with
+ no per-mouse or per-model setting. With this option on, Upright rewrites wheel events only
+ while at least one supported Logitech mouse is connected, but it can't tell that mouse from
+ another one: while one is connected, a third-party mouse wheel is changed too. With the option
+ off, every mouse wheel is always changed.
This needs Accessibility. Without it, scrolling works as usual and the pane says why.
-## Testing with a real MX Vertical
+## Testing with real hardware
-The core of Upright is unit-tested against a simulated MX Vertical (see below). These steps
-verify the real hardware path. If something fails, the output from step 1 and the log from
-step 6 make a great bug report.
+The core of Upright is unit-tested against simulated mice (see below). These steps verify the
+real hardware path, and were written for the MX Vertical, the one model verified on hardware.
+They work for any mouse; a model other than the MX Vertical is **unverified**, so a report of
+what you see is valuable. If something fails, the output from step 1 and the log from step 6
+make a great bug report.
1. **Probe from the terminal (no app needed).**
```bash
make probe # or: swift run --package-path UprightKit upright-probe
- make probe ARGS=--divert # also divert every remappable button and print events
+ make probe ARGS=--all # also read interfaces Upright doesn't use (read-only)
+ make probe ARGS=--divert # also divert every remappable button of every mouse and print events
```
- You should see the receiver or cable interface, then `✓ MX Vertical over …`, followed by the
- battery, firmware, serial number, pointer speed, Easy-Switch channels, the HID++ feature
- table and the control list. Remappable controls are marked with `*`, and controls that can
+ You should see each receiver or cable interface, then, for every mouse, a verdict such as
+ `✓ MX Vertical: Upright supports this device` with each line prefixed by the mouse's label
+ (for example `[MX Master 3S · Bolt slot 2]`), followed by the battery, firmware, serial
+ number, pointer speed, Easy-Switch channels, the HID++ feature table and the control list. Remappable controls are marked with `*`, and controls that can
report raw movement (needed for hold-and-move gestures) carry a `raw XY` flag. With
`--divert`, pressing a thumb button prints `● Back Button (0x0053) pressed/released`
instead of navigating, and **Ctrl-C** gives the buttons back. If nothing is found, click a
mouse button to wake it. If macOS blocks access, allow your terminal app under Privacy &
Security → Input Monitoring.
2. **Launch the app** (`make open` or ⌘R in Xcode). The menu-bar panel should show
- *Connected via Unifying receiver* (or USB cable / Bluetooth) and the battery level.
+ a row for each mouse with its connection (for example *Unifying · Connected*) and its battery level.
3. **Grant Accessibility** when asked. The orange callout disappears.
4. **Defaults.** On a fresh install, nothing is remapped. In Safari or Finder the thumb buttons
still go back and forward as mouse buttons 4 and 5, and the top button still cycles DPI.
-5. **Remap.** Open **Settings → Buttons**, set *Top Button* to **Keyboard Shortcut…** and
+5. **Remap.** Open **Settings → Buttons**, choose your mouse in **Configure**, set *Top Button* to **Keyboard Shortcut…** and
press e.g. ⇧⌘5. Pressing the top button should now open the screenshot toolbar. Set a button
to **Default** to give it back to the mouse. For the top button, that brings back DPI
cycling.
-6. **Sleep and reconnects.** Watch **Settings → Device → Activity** while you:
- - switch the mouse off and on (expect *Slot n link lost*, then *link restored* and
+6. **Sleep and reconnects.** Watch **Settings → Mice → Activity** while you:
+ - switch the mouse off and on (for a receiver, expect *Slot n link lost*, then *link restored* and
*Reapplied configuration*),
- put the Mac to sleep and wake it (expect *Mac woke up; reapplying button configuration*),
- unplug and replug the receiver or cable.
@@ -222,7 +357,32 @@ step 6 make a great bug report.
every packet.
7. **Quit** Upright. The buttons should immediately behave like stock mouse buttons again.
-Something not working? See [Troubleshooting](#troubleshooting).
+Something not working? See [Troubleshooting](#troubleshooting). With several mice, repeat steps
+4 to 7 for each one, and check that pressing a button on one mouse never triggers the other's
+mapping.
+
+### Demo Mode
+
+Demo Mode runs the real interface against **simulated** mice, so you can look around (or make
+screenshots) without a mouse, a receiver or any permission:
+
+```bash
+make app
+build/Build/Products/Release/Upright.app/Contents/MacOS/Upright --demo
+```
+
+It simulates an MX Vertical on a Unifying receiver, an MX Master 3S on a Bolt receiver and a Lift
+over Bluetooth. Every name, serial number and battery level is made up. It never opens IOKit,
+installs event taps, asks for permissions or posts input, and it keeps its own preferences
+(`io.github.upright.Upright.demo`, cleared at each start), so it never touches your real settings
+and can run next to a normal Upright. Because the mice are simulated, this shows the interface, not
+how any real model behaves.
+
+`--export-snapshots ` (which implies `--demo`) renders the interface offscreen into
+``, light and dark, then quits: one PNG per state plus a `manifest.json` with each image's
+size and the rectangles of its named elements. `make snapshots` does this into `media/snapshots/`
+(override the folder with `SNAPSHOTS=`). The pictures at the top of this page were made this
+way.
### Automated tests
@@ -234,21 +394,42 @@ swift test --package-path UprightKit
```
The tests cover HID++ framing and response/error matching, control-table and event decoding,
-mapping persistence and migration, profiles, gestures, scroll transforms and battery alerts.
-They also drive `MXVerticalController` end to end against `SimulatedMXVertical`, a software
-mouse that answers HID++ the way the real one does, either directly or behind a receiver slot.
-Those scenarios include receiver slot discovery, diversion, button events, sleeping and
-waking, a mouse that shows up later, the `0x1D4B` reconnect event, Mac wake, transport removal
-mid-press, retry with backoff and restore on quit.
+mapping persistence and migration (schema v1, v2 and v3), profiles, gestures, scroll transforms,
+battery alerts, the device catalog, interface classification and the configuration planner.
+They also drive the hub (`HIDPPLink`) and its per-mouse `EndpointController`s end to end against
+`UprightKit/Sources/UprightKit/Simulation`, a set of software mice and receivers that answer
+HID++ the way published device dumps say real ones do (an MX Vertical, MX Master 3S, Lift, MX
+Master 3, M720 Triathlon, MX Ergo, M185 family, a G502 Lightspeed, an MX Keys keyboard and more),
+either directly or behind a Unifying or Bolt receiver slot. Those scenarios include receiver
+slot discovery, several mice on one receiver and on several links, diversion, button events,
+sleeping and waking, a mouse that shows up later, the `0x1D4B` reconnect event, Mac wake,
+transport removal mid-press, retry with backoff, restore on quit and a randomized
+fleet test. **Passing these tests says the logic is right for the simulated device; it doesn't
+make a model verified on hardware.**
## Troubleshooting
**Upright says "Not connected" or can't find the mouse.**
-Plug in the Unifying receiver or the USB cable, or pair over Bluetooth, then click a mouse
-button to wake the mouse. Open Settings → Device → Activity to see what Upright found. Use
-**Reconnect** on the Device pane to make it look again. Remember that the MX Vertical can't
-use a Bolt receiver. Run `make probe` (see above): if the probe can't find the mouse either,
-the problem is below Upright.
+Plug in the receiver or the USB cable, or pair over Bluetooth, then click a mouse button to
+wake the mouse. Open Settings → Mice → Activity to see what Upright found. Use **Reconnect**
+on the mouse's page, or **Reconnect All Mice** in the menu-bar panel, to make it look again.
+Remember that the MX Vertical can't use a Bolt receiver, and that Lightspeed and 27 MHz
+receivers are never opened. Run `make probe ARGS=--all` (see above): if the probe can't find the
+mouse either, the problem is below Upright.
+
+**My mouse isn't in the list, or is under "Other Logitech devices".**
+Upright serves a mouse only if it answers HID++ 2.0 as a pointer and offers programmable
+buttons (`0x1B04`). The reason is shown next to the device (for example "No programmable
+buttons" or "Older HID++ 1.0 device"). Models in the catalog are
+[unverified on hardware](#supported-devices) apart from the MX Vertical, so a catalogued
+mouse can still turn out not to work: use **Copy Device Report** and open an issue.
+
+**Two mice do the wrong thing, or one of them is ignored.**
+Mappings belong to the model, so two mice of one model share a setup (see
+[Using several mice](#using-several-mice)). Check that the **Configure** picker in Settings →
+Buttons is on the model you mean, and that **Remap this mouse** is on in Settings → Mice. If
+the same mouse is connected two ways, Upright uses one connection and shows the other as "also
+on …".
**Bluetooth: the mouse is paired but Upright never sees HID++.**
macOS has to expose the mouse's HID++ collection to apps, and it sometimes doesn't. Check
@@ -277,75 +458,98 @@ General pane.
**Scrolling settings have no effect.**
Scrolling needs Accessibility. The Scrolling pane says "Accessibility access needed" when it is
-missing, and "Paused until the MX Vertical connects" when **Only while the MX Vertical is
-connected** is on and the mouse isn't connected. Trackpad and Magic Mouse scrolling is never
+missing, and "Paused until a Logitech mouse connects" when **Only while a Logitech mouse is
+connected** is on and no mouse is connected. These settings apply to every mouse wheel. Trackpad and Magic Mouse scrolling is never
changed. If smooth scrolling feels wrong, turn it off; it is experimental.
**Gestures move the cursor.**
Your mouse didn't offer raw movement for that button (the probe doesn't mark it `raw XY`), so
Upright watches the cursor instead. Gestures still work, but the pointer moves while the
-button is held. The Buttons pane shows a note when this applies.
+button is held. The Buttons pane shows a note when this applies. In this fallback Upright
+watches the cursor, which isn't tied to any one mouse, so it can't attribute the movement to a
+particular mouse.
**Another app is fighting over the buttons.**
Logi Options+, Logitech Options, Solaar and LogiOps also divert buttons. Only one can win.
Quit them (and remove them from your login items) while using Upright.
-**Collect diagnostics.** Run `make probe` and copy its output. Then open Settings → Device →
-Activity, turn on **Raw HID++ reports** if asked, reproduce the problem, and use **Copy**. Include
-both in a bug report (see `.github/ISSUE_TEMPLATE`).
+**Collect diagnostics.** Run `make probe ARGS=--all` and copy its output. Then open Settings →
+Mice, use **Copy Device Report** for the mouse, and open its Activity log (turn on **Raw HID++
+reports** if asked, reproduce the problem, and use **Copy**). Include them in a bug report (see
+`.github/ISSUE_TEMPLATE`), together with the model, how many mice are connected and how.
## Architecture
```
┌──────────────────────────── Upright.app (SwiftUI, AppKit) ─────────────────────────────┐
│ MenuBarView · OnboardingView │
-│ SettingsView (Buttons / Scrolling / Device / General / Permissions / About) │
-│ AppModel ── mappings + per-app profiles (MappingStore), remap switch, routing │
+│ SettingsView (Buttons / Scrolling / Mice / General / Permissions / About) │
+│ AppModel ── per-model mappings + per-app profiles (MappingStore), per-mouse │
+│ preferences, remap switches, configuration planning, routing │
│ AppSettings ── scroll, battery and pointer-speed preferences │
-│ DeviceManager ── IOKit matching, sleep/wake observers, status and activity log │
+│ DeviceManager ── opens interfaces from a LinkSource (IOKit, or simulated in Demo Mode),│
+│ sleep/wake observers, one record per mouse, status and activity log │
│ SystemActionPerformer ── CGEvent keys, media keys, clicks, apps, URLs, Shortcuts │
│ FrontmostAppMonitor · ScrollController (event tap) · BatteryNotifier │
-│ PermissionsMonitor · LoginItemController (SMAppService) │
+│ PermissionsMonitor · LoginItemController (SMAppService) · DemoMode · SnapshotExporter │
└────────────────────────────────────────────┬───────────────────────────────────────────┘
│ compiled into the app from
┌───────────────────────────── UprightKit (Swift package) ───────────────────────────────┐
│ Platform/ HIDDeviceMonitor, IOKitHIDTransport (IOHIDManager, macOS only) │
-│ Device/ MXVerticalController ── discovery, receiver notifications, reconnect logic │
-│ DeviceSession ── features, controls, diversion, events, device info │
-│ ButtonStateTracker, MXVertical (IDs), KnownControl (CIDs) │
+│ Device/ DeviceCatalog, ControlCatalog ── models, receivers, control names │
+│ InterfaceClassifier ── which HID interfaces to open, and as what │
+│ HIDPPLink ── one per opened interface: receiver slots or a direct mouse │
+│ EndpointController ── keeps ONE mouse configured (probe, retry, reconnect) │
+│ DeviceSession ── features, controls, diversion, events, device info │
+│ DeviceRegistry ── every mouse, its role, same-unit dedupe │
+│ ConfigurationPlanner ── what each mouse should be doing │
+│ DeviceIdentity (ModelKey, DeviceID), DevicePreferences, ReceiverDirectory │
│ HIDPP/ HIDPPMessage, HIDPPClient (request/response matching, busy retry), │
│ HIDPPDevice (root, feature set, name, battery), ReprogControls (0x1B04), │
│ DeviceFeatures (firmware, DPI, Easy-Switch) │
│ Mapping/ ButtonAction, SystemAction, KeyCombo, ButtonMappings, MappingProfiles, │
-│ MappingStore, GestureSet, GestureRecognizer, RemapEngine │
+│ MappingLibrary (schema v3, per model), MappingStore, GestureSet, │
+│ GestureRecognizer, RemapEngine (events carry their source mouse) │
│ Scrolling/ ScrollSettings, ScrollTransform, SmoothScrollAnimator │
│ Battery/ BatteryAlertPolicy │
+│ Simulation/ SimulatedReceiver, SimulatedDirect, DeviceProfiles (software mice) │
└────────────────────────────────────────────────────────────────────────────────────────┘
```
**How a button press gets remapped:**
-1. `HIDDeviceMonitor` matches Logitech interfaces on usage page `0xFF00` or `0xFF43`, plus the
- MX Vertical's mouse interface (needed over Bluetooth). `MXVertical.classify` tells receivers
- (`C52B`/`C532`/`C548`) from a directly connected mouse (`C08A`/`B020`).
-2. `MXVerticalController` finds the mouse. For a receiver, it enables wireless notifications
- (HID++ 1.0 register `0x00`), reads the pairing list (register `0xB5`) when it can, pings
- slots with `Root.getProtocolVersion`, and reads each answering device's name. A direct
- connection uses device index `0xFF`.
-3. `DeviceSession` resolves feature indices, reads the `REPROG_CONTROLS_V4` control table, and
- calls `setCidReporting(cid, divert)` for every button the **frontmost app's effective
- mappings** need. `AppModel` re-evaluates this when the frontmost app, the mappings or the
- Accessibility state change.
-4. The mouse now sends `divertedButtonsEvent` reports (the list of CIDs currently held) instead
- of acting on those buttons. `ButtonStateTracker` turns them into press and release events.
-5. `RemapEngine` looks up the action and asks `SystemActionPerformer` to post it, or hands a
- pointer-speed or Easy-Switch action back to `DeviceManager`. Keys and clicks follow the
- physical button (down on press, up on release). One-shot actions fire on press. A button
- mapped to gestures also reports raw movement, which `GestureRecognizer` turns into a
- direction (or a tap, if the button is released first).
-
-**Staying configured.** Temporary diversion is lost when the mouse power-cycles, so the
-controller reapplies it (and the kept pointer speed) when:
+1. `HIDDeviceMonitor` matches Logitech interfaces on usage page `0xFF00` or `0xFF43`, plus
+ Logitech mice's own mouse interface (needed over Bluetooth). `InterfaceClassifier` decides
+ what each one is: a receiver (Unifying, Bolt or Nano, with its pairing layout), a directly
+ connected mouse (cable or Bluetooth), or something never opened (a keyboard, a Lightspeed or
+ 27 MHz receiver, an unknown device).
+2. `DeviceManager` runs one `HIDPPLink` per opened interface. For a receiver, the link enables
+ wireless notifications (HID++ 1.0 register `0x00`), reads the pairing list (register `0xB5`,
+ in the Unifying or Bolt layout) when it can, asks for announcements or pings slots with
+ `Root.getProtocolVersion` when it can't, and starts one `EndpointController` per paired
+ device that isn't a keyboard or another non-pointer. A direct connection is one endpoint at
+ device index `0xFF`.
+3. Each endpoint identifies its device (name and kind, and the model and unit IDs from
+ `DEVICE_FW_VERSION`, `0x0003`), resolves feature indices, and reads the `REPROG_CONTROLS_V4`
+ control table. A device with no HID++ 2.0, no pointer kind or no configurable control ends in
+ a terminal "unsupported" state and is never written to. `DeviceCatalog` turns the IDs into a
+ `ModelKey`, which is the same on every connection.
+4. `ConfigurationPlanner` decides what each endpoint should do from the model's **effective
+ mappings** for the frontmost app, the remap switches, the Accessibility state and the mouse's
+ own pointer speed. `AppModel` re-evaluates it when any of those change. The endpoint calls
+ `setCidReporting(cid, divert)` for every button the plan needs.
+5. The mouse now sends `divertedButtonsEvent` reports (the list of CIDs currently held)
+ instead of acting on those buttons. `ButtonStateTracker` turns them into press and release
+ events that carry the mouse they came from.
+6. `RemapEngine` looks up the action in that mouse's model and asks `SystemActionPerformer` to
+ post it, or hands a pointer-speed or Easy-Switch action back to `DeviceManager`. Keys and
+ clicks follow the physical button (down on press, up on release). One-shot actions fire on
+ press. A button mapped to gestures also reports raw movement, which `GestureRecognizer` turns
+ into a direction (or a tap, if the button is released first). Held buttons and gesture state
+ are kept per mouse, so two mice never share them.
+
+**Staying configured.** Temporary diversion is lost when a mouse power-cycles, so each endpoint
+reapplies it (and the kept pointer speed) when:
- the receiver reports a link-established notification (`0x41`) for the mouse's slot,
- the mouse sends a `WIRELESS_DEVICE_STATUS` (`0x1D4B`) reconnection event,
@@ -353,14 +557,16 @@ controller reapplies it (and the kept pointer speed) when:
- IOKit re-matches a replugged receiver or cable,
- a mouse that was asleep starts sending reports again.
-Signals that arrive close together are coalesced into one pass. If the mouse stops answering,
-the controller keeps the session, shows *Asleep* and retries with exponential backoff (1 s,
-doubling to 30 s). All controller work runs on the main actor through a serial operation
-queue, so probes and reconfigurations never interleave.
+Signals that arrive close together are coalesced into one pass. If a mouse stops answering, its
+endpoint keeps the session, shows *Asleep* and retries with exponential backoff (1 s, doubling
+to 30 s), independently of the other mice. Controller work runs on the main actor through a
+serial operation queue per endpoint, so probes and reconfigurations of one mouse never
+interleave. Requests to one HID interface go through a gate that allows one in flight (with a
+lane for interactive changes), because it is unverified whether receivers tolerate more.
**Why the app compiles UprightKit's sources directly.** The Xcode project references the files
in `UprightKit/Sources/UprightKit` instead of linking the package. That keeps the project
-buildable on any Xcode 15+ without package resolution. Meanwhile `swift test` exercises exactly
+buildable on any Xcode 15.3+ without package resolution. Meanwhile `swift test` exercises exactly
the same code, on Linux too. New Kit files must therefore be added to `project.pbxproj`; see
[CONTRIBUTING.md](CONTRIBUTING.md).
@@ -368,18 +574,35 @@ See [`docs/HIDPP.md`](docs/HIDPP.md) for the exact messages Upright sends.
## Scope and limitations
-- Upright targets the **MX Vertical** only. Receivers are scanned, but other devices on them are
- ignored.
-- No pairing, no firmware updates. Pointer speed is the only mouse setting Upright changes
+- **Mouse support is by capability, and verified for one model.** Upright serves Logitech
+ HID++ 2.0 mice with programmable buttons. Only the MX Vertical is verified on real hardware;
+ every other model, every other receiver type than Unifying and anything involving two physical
+ mice is verified in the simulator only. See [Supported devices](#supported-devices) and
+ [docs/DEVICES.md](docs/DEVICES.md).
+- **Mappings are per model, not per physical mouse.** Two mice of one model share a button setup
+ and per-app profiles. Pointer speed, nickname and the remap switch are per mouse.
+- **Scroll settings are global.** macOS scroll events carry no source, so reverse, speed and
+ smooth scrolling apply to every mouse wheel, not just to a Logitech one. See
+ [Scrolling](#scrolling).
+- **Cursor-fallback gestures.** A gesture button that can't report raw movement is handled by
+ watching the cursor. The pointer moves during the gesture, and the movement can't be tied to
+ one mouse.
+- **Pointer speed only through `ADJUSTABLE_DPI` (`0x2201`).** A mouse without that feature has no
+ pointer-speed controls in Upright, and pointer-speed actions are hidden for it. Upright
+ doesn't use other speed features.
+- **No pairing, no firmware updates.** Pointer speed is the only mouse setting Upright changes
besides button diversion. Many mice forget a speed set this way when they reconnect, so
- Upright re-applies the speed it keeps each time the mouse connects. Setting the top button to **Default** keeps its built-in
- DPI cycling.
+ Upright re-applies the speed it keeps each time the mouse connects. Setting a DPI button to
+ **Default** keeps its built-in DPI cycling.
- Scroll features act on macOS scroll events. Upright doesn't change the wheel's own hardware
settings (SmartShift, ratchet or hi-res mode).
+- **Not served:** Lightspeed (G and PRO) and 27 MHz receivers, keyboards and other non-mouse
+ devices, and HID++ 1.0 devices. They are never opened or written to.
- No kernel extension, no driver, no App Store build.
-- Bluetooth depends on macOS exposing the mouse's vendor HID collection. If the probe can't
- reach the mouse over Bluetooth, use the receiver or cable.
-- Wheel scrolling changes can't tell mice apart; see [Scrolling](#scrolling).
+- Bluetooth depends on macOS exposing a mouse's vendor HID collection. If the probe can't
+ reach the mouse over Bluetooth, use a receiver or cable.
+- Another program that diverts the same buttons (Logi Options+, Solaar, LogiOps) can fight with
+ Upright. Upright doesn't detect them.
## Attribution
@@ -410,5 +633,5 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) and the [changelog](CHANGELOG.md).
## License
-[MIT](LICENSE). Logitech, MX Vertical, Unifying, Bolt and Logi Options+ are trademarks of
+[MIT](LICENSE). Logitech, MX, Unifying, Bolt and Logi Options+ are trademarks of
Logitech. Upright is an independent project and is not affiliated with or endorsed by Logitech.
diff --git a/Upright.xcodeproj/project.pbxproj b/Upright.xcodeproj/project.pbxproj
index 04e3aeb..b63cf4f 100644
--- a/Upright.xcodeproj/project.pbxproj
+++ b/Upright.xcodeproj/project.pbxproj
@@ -30,7 +30,7 @@
A7035ECB836DDB1F70ECE23E /* ButtonMappings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7ED7F195DD6A0AC6DD7483E5 /* ButtonMappings.swift */; };
B0237D3616875AAE4DBEE4A3 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = DCE498C0536AFA6C6A77EB68 /* Assets.xcassets */; };
B32E2975128C916881502231 /* AboutPane.swift in Sources */ = {isa = PBXBuildFile; fileRef = 997C24297E913D6E1AF25808 /* AboutPane.swift */; };
- BCB6FA3AB36A66E5B1CD0A1F /* MXVerticalController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D37ED4DF59948372259E16DD /* MXVerticalController.swift */; };
+ BCB6FA3AB36A66E5B1CD0A1F /* EndpointController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D37ED4DF59948372259E16DD /* EndpointController.swift */; };
BD4020CC5205950150BA67A4 /* DeviceSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52C8EAB4A319611D97F9C903 /* DeviceSession.swift */; };
C69B7BCA3584AB53157039F3 /* PermissionsMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51200E3EC13ECE6FE38D7322 /* PermissionsMonitor.swift */; };
CE1C711802458CB28A80E18E /* SettingsWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3DEEC269F06A06FB9D38D923 /* SettingsWindowController.swift */; };
@@ -61,6 +61,26 @@
7F40D02201E6460F34A1FA7B /* ScrollTransform.swift in Sources */ = {isa = PBXBuildFile; fileRef = 633894E7919123114D99EA9A /* ScrollTransform.swift */; };
05B84C11DE61095A301EFB5E /* BatteryAlertPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D8569275370AADE73A85AA3 /* BatteryAlertPolicy.swift */; };
2D8F40A7CC1FD8C1ECE0B956 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 58500BF091140DDD7E749A27 /* Localizable.xcstrings */; };
+ 27EC7CDA6719F8C5C6462FC8 /* DeviceIdentity.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED3BD7988990AA6B46BBA0A0 /* DeviceIdentity.swift */; };
+ 8A46E151D6027628ABB15006 /* HIDPPLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83F6E1AE3E307D1C210DF3B8 /* HIDPPLink.swift */; };
+ 576B8F7D33489E2B568B7C33 /* ReceiverDirectory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 365D919A9D58C624CF3F4698 /* ReceiverDirectory.swift */; };
+ 59AE20FD5CBEE5D7832FF358 /* DeviceCatalog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7FCCB65946A415E1895E1877 /* DeviceCatalog.swift */; };
+ 4C3BCB5FFEEC9B6A380E7196 /* ControlCatalog.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9E6EF95032190B12A8020F4 /* ControlCatalog.swift */; };
+ F6E9F0D5E4AF10428AD611F3 /* InterfaceClassifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30D4E4074B638B495A6A3ED5 /* InterfaceClassifier.swift */; };
+ 1F4DDF2EA65D1EE3F888DC3E /* DeviceRegistry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9C24CD6587D503449919A135 /* DeviceRegistry.swift */; };
+ E253951E30C93731CE5CCD97 /* ConfigurationPlanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9BC7AF3432C813F8A5AD732 /* ConfigurationPlanner.swift */; };
+ B8868DAC173C98F25D433DFE /* DevicePreferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A679A57023516C7036B5304 /* DevicePreferences.swift */; };
+ ADC99D6D920D8E3D85E3FA6D /* MappingLibrary.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58CD3C8604BFD731B3266405 /* MappingLibrary.swift */; };
+ 6F63C9552C3D4972269456DE /* SimulatedReceiver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 645152AAB8A959A10E3FC067 /* SimulatedReceiver.swift */; };
+ 5A47F446A52ED2FE03C20961 /* SimulatedDeviceProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = D40438FDF4D32550B59BA790 /* SimulatedDeviceProfile.swift */; };
+ D53A1D69560C9193F6375944 /* DeviceProfiles.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7DD3F59209DFCA513B8ECCD /* DeviceProfiles.swift */; };
+ B0A1E0000000000000000001 /* AppEnvironment.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0A1E0000000000000000002 /* AppEnvironment.swift */; };
+ B0A1E0000000000000000003 /* LinkSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0A1E0000000000000000004 /* LinkSource.swift */; };
+ 3A5124622A8E5486FD725B8F /* DemoMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE13987809A1653431CA533B /* DemoMode.swift */; };
+ 4A14A5DD1456FFAC56E27D16 /* SnapshotExporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 39771585E0905625EE9AD4FE /* SnapshotExporter.swift */; };
+ CFE95841A53296C76E38D1FA /* SnapshotAnchor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 67A4A6FB17F2B29099B50EAC /* SnapshotAnchor.swift */; };
+ C1C2E9657AE92DFA4431B7ED /* DeviceListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5AB699E3891E71B7E0483A1 /* DeviceListView.swift */; };
+ AC3D574CE0DD4BDA46A8F786 /* ModelPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9459C8467C4A76DC920328B3 /* ModelPicker.swift */; };
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
@@ -93,7 +113,7 @@
B969D8B5009543D641EEB50B /* UprightApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UprightApp.swift; sourceTree = ""; };
BBDFFF771C0DE11E7DF89756 /* HIDPPTransport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HIDPPTransport.swift; sourceTree = ""; };
C3CD83B2323D740C78867EFB /* HIDPPError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HIDPPError.swift; sourceTree = ""; };
- D37ED4DF59948372259E16DD /* MXVerticalController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MXVerticalController.swift; sourceTree = ""; };
+ D37ED4DF59948372259E16DD /* EndpointController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EndpointController.swift; sourceTree = ""; };
D392257AFB5E408CF65C3A21 /* IOKitHID.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IOKitHID.swift; sourceTree = ""; };
DCE498C0536AFA6C6A77EB68 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; };
EAE0AE15A05BBAD2A85FEFE6 /* DeviceManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceManager.swift; sourceTree = ""; };
@@ -119,6 +139,26 @@
633894E7919123114D99EA9A /* ScrollTransform.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScrollTransform.swift; sourceTree = ""; };
7D8569275370AADE73A85AA3 /* BatteryAlertPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BatteryAlertPolicy.swift; sourceTree = ""; };
58500BF091140DDD7E749A27 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = ""; };
+ ED3BD7988990AA6B46BBA0A0 /* DeviceIdentity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceIdentity.swift; sourceTree = ""; };
+ 83F6E1AE3E307D1C210DF3B8 /* HIDPPLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HIDPPLink.swift; sourceTree = ""; };
+ 365D919A9D58C624CF3F4698 /* ReceiverDirectory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReceiverDirectory.swift; sourceTree = ""; };
+ 7FCCB65946A415E1895E1877 /* DeviceCatalog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceCatalog.swift; sourceTree = ""; };
+ E9E6EF95032190B12A8020F4 /* ControlCatalog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ControlCatalog.swift; sourceTree = ""; };
+ 30D4E4074B638B495A6A3ED5 /* InterfaceClassifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InterfaceClassifier.swift; sourceTree = ""; };
+ 9C24CD6587D503449919A135 /* DeviceRegistry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceRegistry.swift; sourceTree = ""; };
+ B9BC7AF3432C813F8A5AD732 /* ConfigurationPlanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfigurationPlanner.swift; sourceTree = ""; };
+ 9A679A57023516C7036B5304 /* DevicePreferences.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DevicePreferences.swift; sourceTree = ""; };
+ 58CD3C8604BFD731B3266405 /* MappingLibrary.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MappingLibrary.swift; sourceTree = ""; };
+ 645152AAB8A959A10E3FC067 /* SimulatedReceiver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimulatedReceiver.swift; sourceTree = ""; };
+ D40438FDF4D32550B59BA790 /* SimulatedDeviceProfile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimulatedDeviceProfile.swift; sourceTree = ""; };
+ E7DD3F59209DFCA513B8ECCD /* DeviceProfiles.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceProfiles.swift; sourceTree = ""; };
+ B0A1E0000000000000000002 /* AppEnvironment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppEnvironment.swift; sourceTree = ""; };
+ B0A1E0000000000000000004 /* LinkSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkSource.swift; sourceTree = ""; };
+ CE13987809A1653431CA533B /* DemoMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DemoMode.swift; sourceTree = ""; };
+ 39771585E0905625EE9AD4FE /* SnapshotExporter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SnapshotExporter.swift; sourceTree = ""; };
+ 67A4A6FB17F2B29099B50EAC /* SnapshotAnchor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SnapshotAnchor.swift; sourceTree = ""; };
+ B5AB699E3891E71B7E0483A1 /* DeviceListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceListView.swift; sourceTree = ""; };
+ 9459C8467C4A76DC920328B3 /* ModelPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModelPicker.swift; sourceTree = ""; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
@@ -141,6 +181,7 @@
C110137C9980F9208BE059DC /* Mapping */,
6B774AC9BDF0D0197E7F0032 /* Platform */,
45899E710FE7DFADD7E85678 /* Scrolling */,
+ 445BD16AB392BAE1C75A5A04 /* Simulation */,
);
name = UprightKit;
path = UprightKit/Sources/UprightKit;
@@ -203,11 +244,15 @@
2F53FBF5620FAA73BC3A99CB /* AppModel.swift */,
766EE0AB39B7FB46D7AA53A3 /* AppSettings.swift */,
267982B81F3F104CE213696C /* BatteryNotifier.swift */,
+ B0A1E0000000000000000002 /* AppEnvironment.swift */,
+ B0A1E0000000000000000004 /* LinkSource.swift */,
+ CE13987809A1653431CA533B /* DemoMode.swift */,
EAE0AE15A05BBAD2A85FEFE6 /* DeviceManager.swift */,
FB6805FD19F67DDAFBA489A1 /* FrontmostAppMonitor.swift */,
8D2E8581C7E341B8E8C64551 /* LoginItemController.swift */,
51200E3EC13ECE6FE38D7322 /* PermissionsMonitor.swift */,
ED3A2EC703AFF290EA168A0D /* ScrollController.swift */,
+ 39771585E0905625EE9AD4FE /* SnapshotExporter.swift */,
1A0269D1C24A78737E3350B3 /* SystemActionPerformer.swift */,
B969D8B5009543D641EEB50B /* UprightApp.swift */,
);
@@ -218,9 +263,18 @@
isa = PBXGroup;
children = (
6F675652F8DAE79063657B71 /* ButtonStateTracker.swift */,
+ B9BC7AF3432C813F8A5AD732 /* ConfigurationPlanner.swift */,
+ E9E6EF95032190B12A8020F4 /* ControlCatalog.swift */,
+ 7FCCB65946A415E1895E1877 /* DeviceCatalog.swift */,
+ ED3BD7988990AA6B46BBA0A0 /* DeviceIdentity.swift */,
+ 9A679A57023516C7036B5304 /* DevicePreferences.swift */,
+ 9C24CD6587D503449919A135 /* DeviceRegistry.swift */,
52C8EAB4A319611D97F9C903 /* DeviceSession.swift */,
+ 83F6E1AE3E307D1C210DF3B8 /* HIDPPLink.swift */,
+ 30D4E4074B638B495A6A3ED5 /* InterfaceClassifier.swift */,
7CF538CC41DE88392458B6B7 /* MXVertical.swift */,
- D37ED4DF59948372259E16DD /* MXVerticalController.swift */,
+ D37ED4DF59948372259E16DD /* EndpointController.swift */,
+ 365D919A9D58C624CF3F4698 /* ReceiverDirectory.swift */,
);
path = Device;
sourceTree = "";
@@ -233,6 +287,7 @@
CC1BC2079D9F95E2C057D6C1 /* GestureRecognizer.swift */,
76B4A8E1A747B6E1AD914A9B /* GestureSet.swift */,
12AD4FC3D5D5D7249968FD4D /* KeyCombo.swift */,
+ 58CD3C8604BFD731B3266405 /* MappingLibrary.swift */,
94ED828840698FEAA23AE873 /* MappingProfiles.swift */,
0C437667DE6EE25F7E57FF85 /* MappingStore.swift */,
169D6EC9F98C734C0473CB6A /* RemapEngine.swift */,
@@ -248,10 +303,12 @@
EB591826E3750807BA440035 /* ButtonRow.swift */,
4D6D974FB15FA4A97BDCF386 /* ButtonsPane.swift */,
855AD2FC9A58EBF0A8281695 /* Components.swift */,
+ B5AB699E3891E71B7E0483A1 /* DeviceListView.swift */,
5B4B7B7A3E5BFFBC9AF33601 /* DevicePane.swift */,
F8021A393EB09A242A06E549 /* GeneralPane.swift */,
E362408D13E8224AD0601770 /* GestureEditor.swift */,
83D545EF65625567F7C5502B /* MenuBarView.swift */,
+ 9459C8467C4A76DC920328B3 /* ModelPicker.swift */,
FDA15D900CC353617466C3D8 /* OnboardingView.swift */,
9903C83138439330FB350894 /* PermissionsPane.swift */,
333B39D7A2EF6B2C5B9269E3 /* PointerSpeedSection.swift */,
@@ -260,6 +317,7 @@
0AB3759151C30F6EE5A20245 /* SettingsView.swift */,
3DEEC269F06A06FB9D38D923 /* SettingsWindowController.swift */,
14F79AC6294C781265A79167 /* ShortcutRecorder.swift */,
+ 67A4A6FB17F2B29099B50EAC /* SnapshotAnchor.swift */,
);
path = Views;
sourceTree = "";
@@ -282,6 +340,16 @@
path = Scrolling;
sourceTree = "";
};
+ 445BD16AB392BAE1C75A5A04 /* Simulation */ = {
+ isa = PBXGroup;
+ children = (
+ E7DD3F59209DFCA513B8ECCD /* DeviceProfiles.swift */,
+ D40438FDF4D32550B59BA790 /* SimulatedDeviceProfile.swift */,
+ 645152AAB8A959A10E3FC067 /* SimulatedReceiver.swift */,
+ );
+ path = Simulation;
+ sourceTree = "";
+ };
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
@@ -364,9 +432,20 @@
0188E8C7443C1EB6906B170B /* ButtonsPane.swift in Sources */,
107352CE355BADB90B696A61 /* ButtonStateTracker.swift in Sources */,
01CC78E876CECDBC21DA1288 /* Components.swift in Sources */,
+ E253951E30C93731CE5CCD97 /* ConfigurationPlanner.swift in Sources */,
+ 4C3BCB5FFEEC9B6A380E7196 /* ControlCatalog.swift in Sources */,
+ B0A1E0000000000000000001 /* AppEnvironment.swift in Sources */,
+ B0A1E0000000000000000003 /* LinkSource.swift in Sources */,
+ 3A5124622A8E5486FD725B8F /* DemoMode.swift in Sources */,
+ 59AE20FD5CBEE5D7832FF358 /* DeviceCatalog.swift in Sources */,
00344AC5F37AB10330195394 /* DeviceFeatures.swift in Sources */,
+ 27EC7CDA6719F8C5C6462FC8 /* DeviceIdentity.swift in Sources */,
+ C1C2E9657AE92DFA4431B7ED /* DeviceListView.swift in Sources */,
E48470EA377FC68CF5E7C7AA /* DeviceManager.swift in Sources */,
A11D6D25E1D0A3D84B15D586 /* DevicePane.swift in Sources */,
+ B8868DAC173C98F25D433DFE /* DevicePreferences.swift in Sources */,
+ D53A1D69560C9193F6375944 /* DeviceProfiles.swift in Sources */,
+ 1F4DDF2EA65D1EE3F888DC3E /* DeviceRegistry.swift in Sources */,
BD4020CC5205950150BA67A4 /* DeviceSession.swift in Sources */,
D9D0DEA6AAAE81C709F1E9CC /* FrontmostAppMonitor.swift in Sources */,
5B95B2F21BE064E361B692FE /* GeneralPane.swift in Sources */,
@@ -377,21 +456,26 @@
4719E0F8795C5E135ADD642C /* HIDPPDevice.swift in Sources */,
5D7C4185D945ADDBC6BFEDFB /* HIDPPError.swift in Sources */,
99FF7DD0022D48A1794D8ED5 /* HIDPPFeature.swift in Sources */,
+ 8A46E151D6027628ABB15006 /* HIDPPLink.swift in Sources */,
DEB5E15C91640D0637A58055 /* HIDPPMessage.swift in Sources */,
E558C6CD8E37F67D67E4171A /* HIDPPTransport.swift in Sources */,
+ F6E9F0D5E4AF10428AD611F3 /* InterfaceClassifier.swift in Sources */,
80E184D21965D5C2B3DACFE7 /* IOKitHID.swift in Sources */,
89F2E072AC3D5D153D254964 /* KeyCombo.swift in Sources */,
D72C7E2A0D4A7CFDC52101D0 /* LoginItemController.swift in Sources */,
+ ADC99D6D920D8E3D85E3FA6D /* MappingLibrary.swift in Sources */,
ED65B0BD97917F2499745ECD /* MappingProfiles.swift in Sources */,
84E25632C8CCD97B523C4789 /* MappingStore.swift in Sources */,
7F65C76EFB8E46BF1DB35DE9 /* MenuBarView.swift in Sources */,
+ AC3D574CE0DD4BDA46A8F786 /* ModelPicker.swift in Sources */,
111E5A423C212EA716AB42E5 /* MXVertical.swift in Sources */,
- BCB6FA3AB36A66E5B1CD0A1F /* MXVerticalController.swift in Sources */,
+ BCB6FA3AB36A66E5B1CD0A1F /* EndpointController.swift in Sources */,
62DA65C5FE1B769E669BD22B /* OnboardingView.swift in Sources */,
C69B7BCA3584AB53157039F3 /* PermissionsMonitor.swift in Sources */,
FFC048A19AF99992CDA594FB /* PermissionsPane.swift in Sources */,
80067A00AEB3B9A12B0DD488 /* PointerSpeedSection.swift in Sources */,
7D59F1C3695CFF21D3F7AA6B /* ProfileSection.swift in Sources */,
+ 576B8F7D33489E2B568B7C33 /* ReceiverDirectory.swift in Sources */,
7394E1C710027A6C2E9CD8C5 /* RemapEngine.swift in Sources */,
EF3CCA4C17E4D19DCF2CA16C /* ReprogControls.swift in Sources */,
A609BD14043B7C53AD35630C /* ScrollController.swift in Sources */,
@@ -401,7 +485,11 @@
1F98199E19123E88F4B47AD4 /* SettingsView.swift in Sources */,
CE1C711802458CB28A80E18E /* SettingsWindowController.swift in Sources */,
A4D2FA9F7076A35DB00A872F /* ShortcutRecorder.swift in Sources */,
+ 5A47F446A52ED2FE03C20961 /* SimulatedDeviceProfile.swift in Sources */,
+ 6F63C9552C3D4972269456DE /* SimulatedReceiver.swift in Sources */,
A900012B34EBACD73BE00081 /* SmoothScrollAnimator.swift in Sources */,
+ CFE95841A53296C76E38D1FA /* SnapshotAnchor.swift in Sources */,
+ 4A14A5DD1456FFAC56E27D16 /* SnapshotExporter.swift in Sources */,
8302EA4B40F0498D9E5C451E /* SystemActionPerformer.swift in Sources */,
84D11A058F3AD7D53E57B4E0 /* UprightApp.swift in Sources */,
);
@@ -454,7 +542,7 @@
CODE_SIGN_IDENTITY = "-";
CODE_SIGN_STYLE = Manual;
COMBINE_HIDPI_IMAGES = YES;
- CURRENT_PROJECT_VERSION = 1;
+ CURRENT_PROJECT_VERSION = 2;
DEVELOPMENT_TEAM = "";
ENABLE_HARDENED_RUNTIME = YES;
GENERATE_INFOPLIST_FILE = YES;
@@ -468,7 +556,7 @@
);
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MACOSX_DEPLOYMENT_TARGET = 13.0;
- MARKETING_VERSION = 1.0.0;
+ MARKETING_VERSION = 1.1.0;
PRODUCT_BUNDLE_IDENTIFIER = io.github.upright.Upright;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = YES;
@@ -514,7 +602,7 @@
CODE_SIGN_INJECT_BASE_ENTITLEMENTS = NO;
CODE_SIGN_STYLE = Manual;
COMBINE_HIDPI_IMAGES = YES;
- CURRENT_PROJECT_VERSION = 1;
+ CURRENT_PROJECT_VERSION = 2;
DEVELOPMENT_TEAM = "";
ENABLE_HARDENED_RUNTIME = YES;
GENERATE_INFOPLIST_FILE = YES;
@@ -528,7 +616,7 @@
);
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MACOSX_DEPLOYMENT_TARGET = 13.0;
- MARKETING_VERSION = 1.0.0;
+ MARKETING_VERSION = 1.1.0;
PRODUCT_BUNDLE_IDENTIFIER = io.github.upright.Upright;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = YES;
diff --git a/Upright/App/AppEnvironment.swift b/Upright/App/AppEnvironment.swift
new file mode 100644
index 0000000..28a9346
--- /dev/null
+++ b/Upright/App/AppEnvironment.swift
@@ -0,0 +1,92 @@
+import Foundation
+
+/// Where AppModel's side effects go: the real Mac (`.live`) or nowhere (`.demo`).
+///
+/// Demo Mode (`Upright --demo`, see `DemoMode` and docs/dev/DEMO-MODE-SPEC.md) runs the real UI
+/// driven by simulated mice and must leave the Mac and the user's setup untouched:
+/// - its own defaults suite (`DemoMode.defaultsSuiteName`), emptied at start, so the user's
+/// `io.github.upright.Upright` domain is never read or written;
+/// - no IOKit: DeviceManager gets a simulated link source;
+/// - no event taps or event monitors: no scroll tap, no drag rewriter (it lives in the action
+/// performer, which is a no-op), no cursor-movement monitor for gesture fallback;
+/// - no synthetic events: button actions go to `NoOpActionPerformer`;
+/// - no permission prompts: `PermissionsMonitor` reports everything granted;
+/// - no login-item changes and no battery notifications.
+enum AppEnvironment: Equatable, Sendable {
+ case live
+ case demo
+
+ /// Decided once, from `DemoMode` (configured first thing at launch) or, if something asks
+ /// before that, from the launch arguments directly.
+ static let current: AppEnvironment = resolve(arguments: CommandLine.arguments)
+
+ static func resolve(arguments: [String]) -> AppEnvironment {
+ if DemoMode.isActive { return .demo }
+ return arguments.contains("--demo") || arguments.contains("--export-snapshots") ? .demo : .live
+ }
+
+ var isDemo: Bool { self == .demo }
+
+ /// Session event taps and global event monitors (scroll rewriting, the drag rewriter,
+ /// cursor-movement gesture fallback).
+ var usesEventTaps: Bool { self == .live }
+
+ /// Battery notifications (and the notification-permission prompt).
+ var postsNotifications: Bool { self == .live }
+
+ /// Registering or unregistering the login item.
+ var managesLoginItem: Bool { self == .live }
+
+ /// Asking macOS for Accessibility or Input Monitoring.
+ var checksPermissions: Bool { self == .live }
+
+ /// The defaults this environment reads and writes. Demo Mode's suite is wiped every time it
+ /// is created, so a demo run always starts from a fresh install.
+ func makeDefaults() -> UserDefaults {
+ switch self {
+ case .live:
+ return .standard
+ case .demo:
+ let name = DemoMode.defaultsSuiteName
+ // Only nil for the app's own bundle ID or the global domain, which this never is.
+ // Never fall back to the user's domain.
+ guard let suite = UserDefaults(suiteName: name) else {
+ preconditionFailure("Demo Mode needs its own defaults suite (\(name))")
+ }
+ suite.removePersistentDomain(forName: name)
+ return suite
+ }
+ }
+}
+
+/// Button actions in Demo Mode: nothing is posted, launched or opened. Keeps a short history so
+/// a demo UI could show what would have happened.
+@MainActor
+final class NoOpActionPerformer: ActionPerforming {
+ struct Entry: Equatable {
+ let effect: ActionEffect
+ let phase: ActionPhase
+ let date: Date
+ }
+
+ private(set) var history: [Entry] = []
+ private static let maxHistory = 50
+
+ func perform(_ effect: ActionEffect, phase: ActionPhase) {
+ history.append(Entry(effect: effect, phase: phase, date: Date()))
+ if history.count > Self.maxHistory {
+ history.removeFirst(history.count - Self.maxHistory)
+ }
+ }
+}
+
+extension AppEnvironment {
+ /// Live: Logitech interfaces from IOKit. Demo: the simulated fleet, never IOKit.
+ @MainActor
+ func makeDeviceManager() -> DeviceManager {
+ switch self {
+ case .live: return DeviceManager(source: IOKitLinkSource())
+ case .demo: return DeviceManager(source: SimulatedLinkSource.demo())
+ }
+ }
+}
diff --git a/Upright/App/AppModel.swift b/Upright/App/AppModel.swift
index fad11ef..ab1a717 100644
--- a/Upright/App/AppModel.swift
+++ b/Upright/App/AppModel.swift
@@ -2,27 +2,40 @@ import Combine
import SwiftUI
import os
-/// App-wide state: the user's mappings, the remapping switch, and the services that act on them.
+/// App-wide state: every model's mappings, every mouse's preferences, the remapping switch, and
+/// the services that act on them.
+///
+/// Routing (design §C steps 5–10):
+/// - Mappings are per model (`MappingLibrary`). The engine asks, at each press, for the table of
+/// the model of the mouse that pressed (`mappingsProvider`), with the frontmost app's profile.
+/// - Each endpoint's configuration (diversion, raw XY, pointer speed) comes from
+/// `ConfigurationPlanner`: pulled by the hub when the mouse identifies (`configurationProvider`),
+/// then pushed again (`resync`) when anything it depends on changes, but only when it differs
+/// from what was last applied to that address (`appliedConfig`).
+/// - Gesture distance and the raw/cursor movement source are per device: that mouse's DPI and
+/// whether that mouse reports raw XY for the control.
+/// - Pointer-speed changes are stored for that unit only (`DevicePreferences.preferredDPI`).
@MainActor
final class AppModel: ObservableObject {
- static let shared = AppModel()
+ static let shared = AppModel(environment: .current)
private static let remappingEnabledKey = "remappingEnabled"
- /// The global table plus per-app profiles. Saved and applied on every change.
- @Published var profiles: MappingProfiles {
+ /// Every model's setup (a global table plus per-app profiles each). Saved and applied on every change.
+ @Published var library: MappingLibrary {
didSet {
- guard profiles != oldValue else { return }
- store.save(profiles)
+ guard library != oldValue else { return }
+ store.save(library)
applyMappings()
}
}
@Published var isRemappingEnabled: Bool {
didSet {
+ guard isRemappingEnabled != oldValue else { return }
defaults.set(isRemappingEnabled, forKey: Self.remappingEnabledKey)
engine.isEnabled = isRemappingEnabled
- syncDiversion()
+ resyncAll()
}
}
@@ -31,62 +44,135 @@ final class AppModel: ObservableObject {
@Published var selectedPane: SettingsPane = .buttons
- /// A button press the mouse reported to Upright, for live feedback in the UI.
+ /// The mouse the Mice pane and the menu act on. Follows the unit across reconnects (a new
+ /// address for the same `DeviceID`), and falls back to the first connected mouse when it goes.
+ /// Choosing a mouse also points `selectedModel` at its model.
+ @Published var selectedDevice: DeviceAddress? {
+ didSet {
+ guard selectedDevice != oldValue else { return }
+ if let selectedDevice {
+ selectedDeviceID = record(selectedDevice)?.deviceID
+ } else {
+ selectedDeviceID = nil
+ }
+ guard !isAdjustingSelection, let selectedDevice, let model = model(of: selectedDevice) else { return }
+ selectedModel = model
+ }
+ }
+
+ /// The model the Buttons pane edits. Until the user picks one (here or by choosing a mouse),
+ /// it follows the selected mouse's model, else stays on the MX Vertical.
+ @Published var selectedModel: ModelKey = .mxVertical {
+ didSet {
+ if !isAdjustingSelection, selectedModel != oldValue { modelWasChosen = true }
+ }
+ }
+
+ /// The last mouse that sent a press (handled, i.e. from an active endpoint).
+ @Published private(set) var focusedDevice: DeviceAddress?
+
+ /// A button press a mouse reported to Upright, for live feedback in the UI.
struct PressedControl: Equatable {
let cid: UInt16
+ /// The mouse that sent it.
+ let source: DeviceAddress
+ /// That mouse's model when it was pressed (nil if it hadn't identified).
+ let model: ModelKey?
let date: Date
+
+ init(cid: UInt16, source: DeviceAddress = .unassigned, model: ModelKey? = nil, date: Date = Date()) {
+ self.cid = cid
+ self.source = source
+ self.model = model
+ self.date = date
+ }
}
/// The latest diverted-button press, whether or not remapping is on or the button is mapped.
/// Buttons left to the mouse don't report.
@Published private(set) var lastPressedControl: PressedControl?
+ let environment: AppEnvironment
+ /// The defaults everything in this model reads and writes (Demo Mode has its own suite).
+ let defaults: UserDefaults
let devices: DeviceManager
let permissions: PermissionsMonitor
let loginItem: LoginItemController
let frontmostMonitor: FrontmostAppMonitor
- /// Scroll, battery and pointer-speed preferences. Changes also republish this model.
+ /// Scroll, battery, pointer-speed presets and per-mouse preferences. Changes also republish this model.
let settings: AppSettings
/// Applies `settings.scrollSettings` to mouse-wheel events.
let scroll: ScrollController
let batteryNotifier: BatteryNotifier
private let engine: RemapEngine
- private let defaults: UserDefaults
private let store: MappingStore
private let logger: Logger
private var cancellables = Set()
- private var appliedDiversion: Set?
- private var appliedRawXY: Set?
+ /// What each endpoint was last told to do, by address.
+ private(set) var appliedConfig: [DeviceAddress: EndpointConfiguration] = [:]
/// Feeds cursor movement to gestures on buttons that can't report raw movement.
private let movementMonitor = PointerMovementMonitor()
private var started = false
-
- init(defaults: UserDefaults = .standard, performer: ActionPerforming? = nil) {
+ /// The unit behind `selectedDevice`, to find it again on a new address.
+ private var selectedDeviceID: DeviceID?
+ /// The user picked `selectedModel` (directly or by choosing a mouse); stop following devices.
+ private var modelWasChosen = false
+ private var isAdjustingSelection = false
+ /// What was last recorded in `devicePreferences` per unit, so `lastSeen` isn't rewritten on
+ /// every battery reading.
+ private var recordedUnits: [DeviceID: RecordedUnit] = [:]
+ /// The last battery reading fed to the notifier, per address.
+ private var batteryReadings: [DeviceAddress: BatteryStatus] = [:]
+ /// The last snapshot of every endpoint, kept after it goes so preferences can note when it left.
+ private var lastSnapshots: [DeviceAddress: DeviceSnapshot] = [:]
+
+ private struct RecordedUnit: Equatable {
+ let name: String
+ let controls: [UInt16]
+ let connection: ConnectionKind
+ }
+
+ convenience init(defaults: UserDefaults = .standard, performer: ActionPerforming? = nil) {
+ self.init(environment: .live, defaults: defaults, performer: performer)
+ }
+
+ /// `defaults` and `devices` default to the environment's; `performer` to the system one
+ /// (live) or a no-op (demo).
+ init(
+ environment: AppEnvironment,
+ defaults: UserDefaults? = nil,
+ devices: DeviceManager? = nil,
+ performer: ActionPerforming? = nil
+ ) {
+ self.environment = environment
+ let defaults = defaults ?? environment.makeDefaults()
self.defaults = defaults
let logger = Logger(subsystem: "Upright", category: "Mappings")
self.logger = logger
store = MappingStore(defaults: defaults, log: { logger.notice("\($0, privacy: .public)") })
// Load before anything writes "remappingEnabled", which also marks an upgrade.
- let profiles = store.load()
+ let loaded = store.loadLibrary()
+ for issue in loaded.report.issues {
+ logger.notice("Mappings: \(issue, privacy: .public)")
+ }
let enabled = defaults.object(forKey: Self.remappingEnabledKey) as? Bool ?? true
- self.profiles = profiles
- self.isRemappingEnabled = enabled
- devices = DeviceManager()
- permissions = PermissionsMonitor(defaults: defaults)
- loginItem = LoginItemController()
+ library = loaded.library
+ isRemappingEnabled = enabled
+ self.devices = devices ?? environment.makeDeviceManager()
+ permissions = PermissionsMonitor(defaults: defaults, environment: environment)
+ loginItem = LoginItemController(environment: environment)
frontmostMonitor = FrontmostAppMonitor()
+ // AppSettings also copies the 2.x pointer speed into the MX Vertical's seed (once).
let settings = AppSettings(defaults: defaults)
self.settings = settings
scroll = ScrollController()
batteryNotifier = BatteryNotifier(isEnabled: settings.batteryAlertsEnabled)
- engine = RemapEngine(mappings: profiles.global, performer: performer ?? SystemActionPerformer())
+ let performer = performer ?? (environment.isDemo ? NoOpActionPerformer() : SystemActionPerformer())
+ engine = RemapEngine(mappings: ButtonMappings(), performer: performer)
engine.isEnabled = enabled
- engine.onDeviceAction = { [weak self] action in self?.performDeviceAction(action) }
- movementMonitor.onDelta = { [weak engine] dx, dy in engine?.handlePointerDelta(dx: dx, dy: dy, source: .cursor) }
- engine.gestureSource = { [weak self] cid in
- self?.supportsRawXY(cid) == false ? .cursor : .rawSensor
- }
+ installEngineProviders()
+ movementMonitor.onDelta = { [weak engine] dx, dy in engine?.handleCursorDelta(dx: dx, dy: dy) }
// Views that observe the model also refresh for settings and scroll-tap changes.
Publishers.Merge(settings.objectWillChange, scroll.objectWillChange)
@@ -94,17 +180,43 @@ final class AppModel: ObservableObject {
.store(in: &cancellables)
}
+ private func installEngineProviders() {
+ engine.mappingsProvider = { [weak self] source in
+ guard let self else { return ButtonMappings() }
+ return self.library.effectiveMappings(model: self.model(of: source), bundleID: self.frontmostApp?.bundleID)
+ }
+ // Raw movement is in sensor counts, so the gesture distance follows that mouse's speed.
+ engine.rawThresholdProvider = { [weak self] source in
+ GestureRecognizer.rawThreshold(dpi: self?.snapshot(of: source)?.dpi?.current)
+ }
+ engine.gestureSourceFor = { [weak self] source, cid in
+ guard let control = self?.snapshot(of: source)?.controls.first(where: { $0.cid == cid }) else { return .rawSensor }
+ return control.supportsRawXY ? .rawSensor : .cursor
+ }
+ engine.onSourcedDeviceAction = { [weak self] action, source in
+ self?.perform(action, on: source)
+ }
+ }
+
func start() {
guard !started else { return }
started = true
+ devices.configurationProvider = { [weak self] address, snapshot in
+ self?.pullConfiguration(for: address, snapshot: snapshot) ?? .native
+ }
devices.onButtonEvent = { [weak self] event in self?.handle(event) }
- devices.onPointerDelta = { [weak self] dx, dy in self?.engine.handlePointerDelta(dx: dx, dy: dy, source: .rawSensor) }
+ devices.onPointerDelta = { [weak self] delta in self?.engine.handlePointerDelta(delta) }
+ devices.onEndpointsRemoved = { [weak self] addresses in self?.endpointsRemoved(addresses) }
+ devices.onDeviceDPIChange = { [weak self] address, dpi in self?.storePreferredDPI(dpi, for: address) }
+ devices.nameProvider = { [weak self] id in self?.settings.devicePreferences.devices[id]?.trimmedNickname }
+ devices.rememberedControls = { [weak self] model in self?.rememberedControls(of: model) }
+
permissions.$isAccessibilityTrusted
.removeDuplicates()
.sink { [weak self] _ in
// The publisher fires before the property updates; read the new value on the next turn.
- Task { @MainActor in self?.syncDiversion() }
+ Task { @MainActor in self?.resyncAll() }
}
.store(in: &cancellables)
@@ -113,15 +225,15 @@ final class AppModel: ObservableObject {
.dropFirst()
.sink { [weak self] status in
guard status == .granted else { return }
- Task { @MainActor in self?.devices.reconnect() }
+ Task { @MainActor in self?.devices.reconnect(nil) }
}
.store(in: &cancellables)
- // Poll for Input Monitoring quickly only while its absence is actually hiding the mouse.
- devices.$status
- .map { $0 == .accessDenied }
+ // Poll for Input Monitoring quickly only while its absence is actually hiding a mouse.
+ devices.$blockedInterfaces
+ .map { !$0.isEmpty }
.removeDuplicates()
- .sink { [weak self] denied in self?.permissions.needsInputMonitoring = denied }
+ .sink { [weak self] blocked in self?.permissions.needsInputMonitoring = blocked }
.store(in: &cancellables)
frontmostMonitor.$current
@@ -131,45 +243,50 @@ final class AppModel: ObservableObject {
}
.store(in: &cancellables)
- startScrollAndBattery()
+ // Records publish before they change; act on the next turn, when they have.
+ devices.$records
+ .sink { [weak self] _ in
+ Task { @MainActor in self?.devicesDidChange() }
+ }
+ .store(in: &cancellables)
- // Restore the kept pointer speed before the first connection, then persist what the mouse keeps.
- devices.setDesiredDPI(settings.preferredDPI)
- devices.$desiredDPI
+ // Nicknames, "Remap this mouse" and speeds change what each mouse should be doing.
+ settings.$devicePreferences
.removeDuplicates()
.dropFirst()
- .sink { [weak self] dpi in self?.settings.preferredDPI = dpi }
+ .sink { [weak self] _ in
+ Task { @MainActor in
+ self?.devices.refreshNames()
+ self?.resyncAll()
+ }
+ }
.store(in: &cancellables)
+ startScrollAndBattery()
+
permissions.start()
frontmostMonitor.start()
applyMappings()
devices.start()
}
- /// Installs or removes the scroll tap as settings, Accessibility and the connection change,
- /// and feeds battery readings to the notifier. Publishers deliver new values before the
- /// properties update, so this works only with the values passed in.
+ /// Installs or removes the scroll tap as settings, Accessibility and the mice change, and
+ /// lets the battery notifier ask for permission. Publishers deliver new values before the
+ /// properties update, so this works only with the values passed in. Demo Mode installs no tap
+ /// and posts no notifications.
private func startScrollAndBattery() {
- let connected = devices.$status
- .map { Self.snapshot(of: $0) != nil }
+ let present = devices.$summary
+ .map(\.anyPresent)
.removeDuplicates()
- Publishers.CombineLatest3(settings.$scrollSettings, permissions.$isAccessibilityTrusted.removeDuplicates(), connected)
- .sink { [weak self] scrollSettings, trusted, connected in
- self?.scroll.update(settings: scrollSettings, accessibilityTrusted: trusted, mxVerticalConnected: connected)
+ let tapsAllowed = environment.usesEventTaps
+ Publishers.CombineLatest3(settings.$scrollSettings, permissions.$isAccessibilityTrusted.removeDuplicates(), present)
+ .sink { [weak self] scrollSettings, trusted, present in
+ self?.scroll.update(settings: scrollSettings, accessibilityTrusted: trusted && tapsAllowed, mousePresent: present)
}
.store(in: &cancellables)
+ guard environment.postsNotifications else { return }
batteryNotifier.start()
- devices.$status
- .compactMap { Self.snapshot(of: $0) }
- .map { BatteryReading(battery: $0.battery, deviceName: $0.name) }
- .removeDuplicates()
- .sink { [weak self] reading in
- self?.batteryNotifier.update(battery: reading.battery, deviceName: reading.deviceName)
- }
- .store(in: &cancellables)
-
settings.$batteryAlertsEnabled
.removeDuplicates()
.sink { [weak self] enabled in
@@ -182,81 +299,302 @@ final class AppModel: ObservableObject {
.store(in: &cancellables)
}
- private struct BatteryReading: Equatable {
- let battery: BatteryStatus?
- let deviceName: String
+ // MARK: Devices
+
+ /// The record of a live endpoint.
+ func record(_ address: DeviceAddress) -> DeviceRegistry.Record? {
+ devices.records.first { $0.address == address }
+ }
+
+ /// The model of a live endpoint, once it has identified.
+ func model(of address: DeviceAddress) -> ModelKey? {
+ record(address)?.model ?? snapshot(of: address)?.identity?.model
+ }
+
+ /// The snapshot of an endpoint: live (connected or asleep), else the last one it identified
+ /// with while it reconnects. During a connect the hub reports before the registry knows it.
+ func snapshot(of address: DeviceAddress) -> DeviceSnapshot? {
+ devices.snapshot(address) ?? lastSnapshots[address]
}
- private static func snapshot(of status: DeviceManager.Status) -> DeviceSnapshot? {
- switch status {
- case .connected(let snapshot), .asleep(let snapshot): return snapshot
- default: return nil
+ /// The first served, connected mouse in list order (`DeviceManager.primaryAddress`).
+ var primaryDevice: DeviceAddress? {
+ devices.primaryAddress
+ }
+
+ /// The controls last seen on a mouse of `model` (most recently seen first), for its rows
+ /// while none is connected.
+ private func rememberedControls(of model: ModelKey) -> [UInt16]? {
+ settings.devicePreferences.rememberedDevices()
+ .first { ($0.preferences.lastModel ?? $0.id.model) == model && !$0.preferences.lastControls.isEmpty }?
+ .preferences.lastControls
+ }
+
+ /// The mouse the menu's button summary, pointer speed and Easy-Switch act on: the last one
+ /// pressed, else the selected one, else the first connected.
+ var menuDevice: DeviceAddress? {
+ for candidate in [focusedDevice, selectedDevice] {
+ if let candidate, record(candidate) != nil { return candidate }
+ }
+ return primaryDevice
+ }
+
+ /// Models with a mouse connected or asleep right now, in list order.
+ var connectedModels: [ModelKey] {
+ unique(devices.records.filter { $0.role != .unsupported && $0.snapshot != nil }.compactMap(\.model))
+ }
+
+ /// Models of mice seen before that aren't here now, most recently seen first.
+ var rememberedModels: [ModelKey] {
+ let present = Set(connectedModels)
+ let connectedIDs = Set(devices.records.compactMap(\.deviceID))
+ let remembered = settings.devicePreferences.rememberedDevices(excluding: connectedIDs)
+ .compactMap { $0.preferences.lastModel ?? $0.id.model }
+ return unique(remembered).filter { !present.contains($0) }
+ }
+
+ /// Every model worth offering in the Buttons pane: connected, then remembered, then any other
+ /// model with a setup (by name). Always includes `selectedModel`.
+ var knownModels: [ModelKey] {
+ var models = connectedModels + rememberedModels
+ let listed = Set(models)
+ let others = library.models.keys.filter { !listed.contains($0) }
+ .sorted { modelName($0).localizedStandardCompare(modelName($1)) == .orderedAscending }
+ models += others
+ if !models.contains(selectedModel) { models.append(selectedModel) }
+ return models
+ }
+
+ /// The name shown for a model: the library's or catalog's, else what a connected mouse of it
+ /// reports, else its key.
+ func modelName(_ model: ModelKey) -> String {
+ if library.modelNames[model] != nil || DeviceCatalog.model(model) != nil {
+ return library.name(of: model)
+ }
+ if let reported = devices.records.first(where: { $0.model == model })?.snapshot?.name {
+ return reported
+ }
+ if let remembered = settings.devicePreferences.devices.values.first(where: { $0.lastModel == model })?.lastName {
+ return remembered
}
+ return library.name(of: model)
+ }
+
+ /// The live endpoints of a model (served ones first), e.g. for "Applies to every MX Vertical".
+ func addresses(of model: ModelKey) -> [DeviceAddress] {
+ devices.records.filter { $0.model == model && $0.role != .unsupported }.map(\.address)
+ }
+
+ /// The device whose controls best describe `model`: the selected mouse if it is one, else
+ /// the first live one of that model.
+ func representativeDevice(of model: ModelKey) -> DeviceAddress? {
+ if let selectedDevice, self.model(of: selectedDevice) == model { return selectedDevice }
+ return addresses(of: model).first
+ }
+
+ /// The controls to show for a model (live mouse, else remembered, else catalog).
+ func controls(for model: ModelKey) -> [ControlDescriptor] {
+ devices.controls(for: model, address: representativeDevice(of: model))
+ }
+
+ // MARK: Per-mouse preferences
+
+ /// One mouse's preferences. Writes to a mouse without a stable ID are ignored (it can't be
+ /// remembered), and the Mice pane says so.
+ func preferences(_ id: DeviceID) -> Binding {
+ Binding(
+ get: { self.devicePreferences(for: id) },
+ set: { self.settings.devicePreferences.setPreferences($0, for: id) }
+ )
+ }
+
+ func devicePreferences(for id: DeviceID) -> DevicePreferences {
+ settings.devicePreferences.preferences(for: id, model: id.model ?? .mxVertical)
+ }
+
+ /// The preferences of a live endpoint (its unit's, or its model's seed when it can't be remembered).
+ func devicePreferences(for address: DeviceAddress) -> DevicePreferences {
+ guard let snapshot = snapshot(of: address) else {
+ return record(address)?.deviceID.map(devicePreferences(for:)) ?? DevicePreferences()
+ }
+ return preferences(for: address, snapshot: snapshot)
+ }
+
+ /// Forgets a mouse: nickname, speed, remap switch and what was last seen of it.
+ func forget(_ id: DeviceID) {
+ settings.devicePreferences.forget(id)
+ recordedUnits[id] = nil
+ }
+
+ /// Sets (or with nil, hands back to the mouse) one mouse's pointer speed, now and on every
+ /// reconnect. Other mice keep theirs.
+ func setPreferredDPI(_ dpi: Int?, for address: DeviceAddress) {
+ // Stored first, so the resync it causes asks for the same speed.
+ storePreferredDPI(dpi, for: address)
+ devices.setDPI(dpi, on: address)
+ }
+
+ private func preferences(for address: DeviceAddress, snapshot: DeviceSnapshot) -> DevicePreferences {
+ let model = snapshot.identity?.model ?? snapshot.modelKey
+ let id = snapshot.identity?.deviceID(at: address) ?? .transient(address)
+ return settings.devicePreferences.preferences(for: id, model: model)
+ }
+
+ /// A speed chosen on the mouse, by Cycle Pointer Speed or in the UI becomes that unit's own
+ /// preference. Nothing is broadcast to other mice.
+ private func storePreferredDPI(_ dpi: Int?, for address: DeviceAddress) {
+ guard let snapshot = snapshot(of: address),
+ let id = snapshot.identity?.deviceID(at: address), id.isStable
+ else { return }
+ var prefs = settings.devicePreferences.preferences(for: id, model: snapshot.identity?.model ?? snapshot.modelKey)
+ guard prefs.preferredDPI != dpi else { return }
+ prefs.preferredDPI = dpi
+ settings.devicePreferences.setPreferences(prefs, for: id)
}
- // MARK: Global mappings (what the Buttons pane edits)
+ // MARK: Mappings of a model (what the Buttons pane edits)
- /// The global table. Per-app profiles override it button by button.
+ /// The selected model's setup (compat for views that predate multi-mouse).
+ var profiles: MappingProfiles {
+ get { library[selectedModel] }
+ set { setProfiles(newValue, for: selectedModel) }
+ }
+
+ /// The selected model's global table. Per-app profiles override it button by button.
var mappings: ButtonMappings {
- get { profiles.global }
+ get { library[selectedModel].global }
set { profiles.global = newValue }
}
- func binding(for cid: UInt16) -> Binding {
+ func profiles(for model: ModelKey) -> MappingProfiles {
+ library[model]
+ }
+
+ func setProfiles(_ profiles: MappingProfiles, for model: ModelKey) {
+ guard library[model] != profiles else { return }
+ library[model] = profiles
+ }
+
+ func binding(for cid: UInt16, model: ModelKey) -> Binding {
Binding(
- get: { self.profiles.global[cid] },
- set: { self.profiles.global[cid] = $0 }
+ get: { self.library[model].global[cid] },
+ set: { self.library[model].global[cid] = $0 }
)
}
- /// Resets the global table to the fresh-install setup (nothing remapped). App profiles stay.
+ /// `binding(for:model:)` for the selected model.
+ func binding(for cid: UInt16) -> Binding {
+ binding(for: cid, model: selectedModel)
+ }
+
+ /// Resets a model's global table to the fresh-install setup (nothing remapped). App profiles stay.
+ func restoreDefaultMappings(model: ModelKey) {
+ library[model].global = .defaults
+ }
+
func restoreDefaultMappings() {
- profiles.global = .defaults
+ restoreDefaultMappings(model: selectedModel)
}
- /// Clears the global table and removes every app profile.
+ /// Clears a model's global table and removes its app profiles: every button native.
+ func resetMappings(model: ModelKey) {
+ library.models[model] = nil
+ }
+
+ /// Clears every model's setup.
func resetAll() {
- profiles = MappingProfiles()
+ var cleared = library
+ cleared.models = [:]
+ library = cleared
+ }
+
+ /// Replaces `target`'s setup (global table and app profiles) with a copy of `source`'s.
+ func copySetup(from source: ModelKey, to target: ModelKey) {
+ guard source != target else { return }
+ var updated = library
+ updated[target] = library[source]
+ nameModel(target, in: &updated)
+ library = updated
+ }
+
+ /// "Use this setup for all my mice": copies `source`'s setup to every other known model.
+ func applySetupToAllModels(from source: ModelKey) {
+ var updated = library
+ for target in knownModels where target != source {
+ updated[target] = library[source]
+ nameModel(target, in: &updated)
+ }
+ library = updated
+ }
+
+ /// Keeps a readable name next to a model's setup (written to exports).
+ private func nameModel(_ model: ModelKey, in library: inout MappingLibrary) {
+ guard library.modelNames[model] == nil, DeviceCatalog.model(model) == nil else { return }
+ let name = modelName(model)
+ if name != model.rawValue { library.modelNames[model] = name }
}
// MARK: Per-app profiles
- /// The mappings in effect right now, for the frontmost app.
+ /// The table in effect for a model right now, for the frontmost app.
+ func effectiveMappings(model: ModelKey) -> ButtonMappings {
+ library.effectiveMappings(model: model, bundleID: frontmostApp?.bundleID)
+ }
+
+ /// The table in effect right now on one mouse.
+ func effectiveMappings(for address: DeviceAddress) -> ButtonMappings {
+ library.effectiveMappings(model: model(of: address), bundleID: frontmostApp?.bundleID)
+ }
+
+ /// The table in effect right now on the menu's mouse (`menuDevice`), else for the selected model.
var effectiveMappings: ButtonMappings {
- profiles.effectiveMappings(forBundleID: frontmostApp?.bundleID)
+ effectiveMappings(model: menuDevice.flatMap(model(of:)) ?? selectedModel)
}
/// An app's override for a button: nil inherits the global mapping, `.deviceDefault` leaves
/// the button to the mouse in that app.
- func overrideBinding(for cid: UInt16, bundleID: String) -> Binding {
+ func overrideBinding(for cid: UInt16, model: ModelKey, bundleID: String) -> Binding {
Binding(
- get: { self.profiles[app: bundleID]?[cid] },
- set: { self.profiles[app: bundleID]?[cid] = $0 }
+ get: { self.library[model][app: bundleID]?[cid] },
+ set: { self.library[model][app: bundleID]?[cid] = $0 }
)
}
+ /// `overrideBinding(for:model:bundleID:)` for the selected model.
+ func overrideBinding(for cid: UInt16, bundleID: String) -> Binding {
+ overrideBinding(for: cid, model: selectedModel, bundleID: bundleID)
+ }
+
+ func addAppProfile(bundleID: String, displayName: String, model: ModelKey) {
+ library[model].addApp(bundleID: bundleID, displayName: displayName)
+ }
+
func addAppProfile(bundleID: String, displayName: String) {
- profiles.addApp(bundleID: bundleID, displayName: displayName)
+ addAppProfile(bundleID: bundleID, displayName: displayName, model: selectedModel)
+ }
+
+ func removeAppProfile(bundleID: String, model: ModelKey) {
+ library[model].removeApp(bundleID: bundleID)
}
func removeAppProfile(bundleID: String) {
- profiles.removeApp(bundleID: bundleID)
+ removeAppProfile(bundleID: bundleID, model: selectedModel)
+ }
+
+ func setAppProfileEnabled(_ enabled: Bool, bundleID: String, model: ModelKey) {
+ library[model][app: bundleID]?.isEnabled = enabled
}
func setAppProfileEnabled(_ enabled: Bool, bundleID: String) {
- profiles[app: bundleID]?.isEnabled = enabled
+ setAppProfileEnabled(enabled, bundleID: bundleID, model: selectedModel)
}
// MARK: Export / import
- /// Pretty-printed JSON of every mapping and profile, with a schema version.
+ /// Pretty-printed JSON of every model's setup (schema 3, plus the MX Vertical's at the top
+ /// level for 2.x builds). Never per-Mac device preferences.
func exportData() -> Data {
- do {
- return try MappingStore.exportData(profiles)
- } catch {
- logger.error("Export failed: \(error.localizedDescription, privacy: .public)")
- return Data()
- }
+ store.export(library)
}
struct ImportSummary {
@@ -264,32 +602,63 @@ final class AppModel: ObservableObject {
let skipped: [String]
/// Imported actions that open apps, links or shortcuts, for the user to look over.
let actionsToReview: [MappingStore.ReviewItem]
+ /// The models whose setup the import replaced.
+ let importedModels: [ModelKey]
+ let format: MappingStore.ImportPreview.Format
/// What the import replaced, for `undoImport`. Also backed up in UserDefaults.
- let previousProfiles: MappingProfiles
+ let previousLibrary: MappingLibrary
+ }
+
+ /// Reads an exported file without changing anything. A schema 3 file is imported model by
+ /// model; a 2.x or 1.x file (made for the MX Vertical) goes to `targetModel`, by default the
+ /// selected model. Throws `MappingStore.ImportError` when the file can't be used.
+ func previewImport(_ data: Data, targetModel: ModelKey? = nil) throws -> MappingStore.ImportPreview {
+ try store.prepareImport(data, targetModel: targetModel ?? selectedModel, protecting: protectedControls)
+ }
+
+ /// Applies a previewed import, after backing up the current library.
+ @discardableResult
+ func applyImport(_ preview: MappingStore.ImportPreview) -> ImportSummary {
+ preview.skipped.forEach { logger.notice("Import: \($0, privacy: .public)") }
+ let previous = library
+ library = store.applyImport(preview, to: previous).library
+ return ImportSummary(
+ skipped: preview.skipped,
+ actionsToReview: preview.actionsToReview,
+ importedModels: preview.importedModels,
+ format: preview.format,
+ previousLibrary: previous
+ )
}
- /// Replaces all mappings with an exported file's, after backing up the current ones.
- /// Throws `MappingStore.ImportError` (and changes nothing) when the file can't be used.
+ /// `previewImport` then `applyImport`. Throws (and changes nothing) when the file can't be used.
@discardableResult
- func importData(_ data: Data) throws -> ImportSummary {
- let report = MappingDecodingReport()
- let outcome = try store.prepareImport(data, replacing: profiles, report: report)
- report.issues.forEach { logger.notice("Import: \($0, privacy: .public)") }
- let summary = ImportSummary(skipped: report.issues, actionsToReview: outcome.actionsToReview, previousProfiles: profiles)
- profiles = outcome.profiles
- return summary
+ func importData(_ data: Data, targetModel: ModelKey? = nil) throws -> ImportSummary {
+ applyImport(try previewImport(data, targetModel: targetModel))
}
/// Puts back what an import replaced.
func undoImport(_ summary: ImportSummary) {
- profiles = summary.previousProfiles
+ library = summary.previousLibrary
+ }
+
+ /// Per model, the clicking buttons a connected mouse can't give up (flagged as mouse buttons
+ /// and not divertable), which an import must never map.
+ private var protectedControls: [ModelKey: Set] {
+ var protected: [ModelKey: Set] = [:]
+ for record in devices.records {
+ guard let model = record.model, let snapshot = record.snapshot else { continue }
+ let cids = snapshot.controls.filter { $0.isMouseButton && !$0.flags.contains(.divertable) }.map(\.cid)
+ protected[model, default: []].formUnion(cids)
+ }
+ return protected
}
// MARK: Accessibility
- /// True when at least one mapping (global or in an enabled app profile) needs Accessibility.
+ /// True when at least one mapping (any model, global or in an enabled app profile) needs Accessibility.
var needsAccessibility: Bool {
- profiles.allActiveActions.contains { $0.requiresAccessibility }
+ library.allActiveActions.contains { $0.requiresAccessibility }
}
var isMissingAccessibility: Bool {
@@ -298,41 +667,82 @@ final class AppModel: ObservableObject {
// MARK: Gestures
- /// True when a button mapped to gestures right now can't report raw movement, so gestures on
- /// it follow the cursor instead (which moves while the button is held).
- var gesturesNeedRawXYFallback: Bool {
- effectiveMappings
+ /// True when a button of `model` mapped to gestures right now can't report raw movement on
+ /// that mouse, so gestures on it follow the cursor instead.
+ func gesturesNeedRawXYFallback(model: ModelKey) -> Bool {
+ effectiveMappings(model: model)
.gestureControls(accessibilityGranted: permissions.isAccessibilityTrusted)
- .contains { !supportsRawXY($0) }
+ .contains { !supportsRawXY($0, model: model) }
}
- /// Whether the connected mouse can report raw movement for a control. Unknown (no mouse yet) counts as yes.
- func supportsRawXY(_ cid: UInt16) -> Bool {
- guard let snapshot = devices.snapshot else { return true }
+ /// The same for one mouse.
+ func gesturesNeedRawXYFallback(for address: DeviceAddress) -> Bool {
+ effectiveMappings(for: address)
+ .gestureControls(accessibilityGranted: permissions.isAccessibilityTrusted)
+ .contains { !supportsRawXY($0, on: address) }
+ }
+
+ /// `gesturesNeedRawXYFallback(model:)` for the selected model.
+ var gesturesNeedRawXYFallback: Bool {
+ gesturesNeedRawXYFallback(model: selectedModel)
+ }
+
+ /// Whether a mouse can report raw movement for a control. Unknown (not identified) counts as yes.
+ func supportsRawXY(_ cid: UInt16, on address: DeviceAddress) -> Bool {
+ guard let snapshot = snapshot(of: address) else { return true }
return snapshot.controls.first { $0.cid == cid }?.supportsRawXY ?? false
}
+ /// Whether a model's mouse (the selected one, else the first live one) can report raw
+ /// movement for a control. No mouse of that model here counts as yes.
+ func supportsRawXY(_ cid: UInt16, model: ModelKey) -> Bool {
+ guard let address = representativeDevice(of: model) else { return true }
+ return supportsRawXY(cid, on: address)
+ }
+
+ /// `supportsRawXY(_:model:)` for the selected model.
+ func supportsRawXY(_ cid: UInt16) -> Bool {
+ supportsRawXY(cid, model: selectedModel)
+ }
+
+ // MARK: Device actions
+
+ /// Runs a device action (from a button or the UI) on one mouse. A set speed is remembered
+ /// for that mouse; Cycle Pointer Speed's new speed is remembered when the mouse reports it.
+ func perform(_ action: DeviceAction, on address: DeviceAddress) {
+ if case .setDPI(let dpi) = action, action.isValid {
+ storePreferredDPI(dpi, for: address)
+ }
+ devices.perform(action, on: address, presets: settings.dpiPresets)
+ }
+
// MARK: Routing
private func handle(_ event: ButtonEvent) {
if event.phase == .press {
- lastPressedControl = PressedControl(cid: event.cid, date: Date())
- // Raw movement is in sensor counts, so the gesture distance follows the pointer speed.
- engine.rawGestureThreshold = GestureRecognizer.rawThreshold(dpi: devices.snapshot?.dpi?.current)
+ // DeviceManager drops presses of standby/unsupported endpoints; double-check.
+ guard devices.isActive(event.source) else { return }
+ focusedDevice = event.source
+ lastPressedControl = PressedControl(cid: event.cid, source: event.source, model: model(of: event.source), date: Date())
}
engine.handle(event)
updateMovementMonitor()
- // A button released after an app switch may have been kept diverted only so this release arrived.
- if event.phase == .release,
- appliedDiversion?.contains(event.cid) == true || appliedRawXY?.contains(event.cid) == true {
- syncDiversion()
+ // A button released after an app switch (or a role change) may have been kept diverted
+ // only so this release arrived.
+ if event.phase == .release, let applied = appliedConfig[event.source],
+ applied.divert.contains(event.cid) || applied.rawXY.contains(event.cid) {
+ resync(event.source)
}
}
- /// Watches cursor movement only while a gesture button without raw XY is held.
+ /// Watches cursor movement only while a gesture that follows the cursor is held (never in
+ /// Demo Mode).
private func updateMovementMonitor() {
- let needsFallback = engine.gestureControls.contains { !supportsRawXY($0) }
- if needsFallback { movementMonitor.start() } else { movementMonitor.stop() }
+ if environment.usesEventTaps, engine.needsCursorDeltas {
+ movementMonitor.start()
+ } else {
+ movementMonitor.stop()
+ }
}
private func frontmostAppDidChange(_ app: FrontmostApp?) {
@@ -342,37 +752,144 @@ final class AppModel: ObservableObject {
}
private func applyMappings() {
- engine.mappings = effectiveMappings
- syncDiversion()
- }
-
- /// Diverts what the frontmost app's mappings need, plus any button still held, so its release
- /// reaches the engine even if the new app leaves that button to the mouse. Gesture buttons also
- /// report raw movement, and keep doing so while a gesture is in progress.
- private func syncDiversion() {
- let trusted = permissions.isAccessibilityTrusted
- let mappings = effectiveMappings
- let cids = isRemappingEnabled
- ? mappings.divertedControls(accessibilityGranted: trusted).union(engine.pressedControls)
- : []
- let rawXY = isRemappingEnabled
- ? mappings.gestureControls(accessibilityGranted: trusted).union(engine.gestureControls)
- : []
+ resyncAll()
+ }
+
+ // MARK: Configuration
+
+ /// The hub asks when a mouse identifies, before anything is diverted. The registry hasn't
+ /// seen it identify yet, so its role is the one it would get.
+ private func pullConfiguration(for address: DeviceAddress, snapshot: DeviceSnapshot) -> EndpointConfiguration {
+ let isActive = devices.prospectiveRole(address, snapshot: snapshot) == .active
+ let configuration = configuration(for: address, snapshot: snapshot, isActive: isActive)
+ appliedConfig[address] = configuration
+ lastSnapshots[address] = snapshot
+ return configuration
+ }
+
+ /// What one endpoint should be doing now.
+ func configuration(for address: DeviceAddress, snapshot: DeviceSnapshot, isActive: Bool) -> EndpointConfiguration {
+ let model = snapshot.identity?.model ?? snapshot.modelKey
+ let prefs = preferences(for: address, snapshot: snapshot)
+ let inputs = ConfigurationInputs(
+ remappingEnabled: isRemappingEnabled,
+ accessibilityGranted: permissions.isAccessibilityTrusted,
+ deviceRemapEnabled: prefs.remapEnabled,
+ isActive: isActive,
+ preferredDPI: prefs.preferredDPI,
+ held: engine.pressedControls(for: address),
+ gesturesHeld: engine.gestureControls(for: address)
+ )
+ let mappings = library.effectiveMappings(model: model, bundleID: frontmostApp?.bundleID)
+ return ConfigurationPlanner.configuration(mappings: mappings, snapshot: snapshot, inputs: inputs)
+ }
+
+ /// Pushes a changed configuration to one endpoint. Only identified endpoints (connected or
+ /// asleep) are pushed; one that is still connecting pulls its own.
+ private func resync(_ address: DeviceAddress) {
+ guard let record = record(address), record.snapshot != nil, let snapshot = snapshot(of: address) else { return }
+ let configuration = configuration(for: address, snapshot: snapshot, isActive: record.role == .active)
+ guard appliedConfig[address] != configuration else { return }
+ appliedConfig[address] = configuration
+ devices.setConfiguration(configuration, for: address)
+ }
+
+ /// Recomputes every endpoint (app switch, mapping edit, Accessibility, remap switch,
+ /// preferences, role changes).
+ private func resyncAll() {
updateMovementMonitor()
- if rawXY != appliedRawXY {
- appliedRawXY = rawXY
- devices.setDesiredRawXY(rawXY)
+ for record in devices.records {
+ resync(record.address)
}
- guard cids != appliedDiversion else { return }
- appliedDiversion = cids
- devices.setDesiredDiversion(cids)
}
- private func performDeviceAction(_ action: DeviceAction) {
- switch action {
- case .cycleDPI: devices.cycleDPI(presets: settings.dpiPresets)
- case .setDPI, .switchHost: devices.perform(action)
+ // MARK: Fleet changes
+
+ private func devicesDidChange() {
+ let records = devices.records
+ for record in records {
+ if let snapshot = record.snapshot { lastSnapshots[record.address] = snapshot }
}
+ rememberUnits(records)
+ feedBatteryNotifier(records)
+ updateSelection(records)
+ resyncAll()
+ }
+
+ /// Endpoints that went away: end their holds, forget what they were told, note when they left.
+ private func endpointsRemoved(_ addresses: [DeviceAddress]) {
+ for address in addresses {
+ engine.releaseAll(source: address)
+ appliedConfig[address] = nil
+ batteryReadings[address] = nil
+ if let snapshot = lastSnapshots.removeValue(forKey: address),
+ let id = snapshot.identity?.deviceID(at: address), id.isStable,
+ settings.devicePreferences.devices[id] != nil {
+ settings.devicePreferences.record(id, snapshot: snapshot)
+ }
+ if focusedDevice == address { focusedDevice = nil }
+ }
+ if environment.postsNotifications { batteryNotifier.endpointsRemoved(addresses) }
+ updateMovementMonitor()
+ }
+
+ /// Records what was seen of each identified unit (name, controls, connection, time) when it
+ /// first shows up or changes, for remembered mice and their controls while offline.
+ private func rememberUnits(_ records: [DeviceRegistry.Record]) {
+ var store = settings.devicePreferences
+ for record in records where record.role != .unsupported {
+ guard let snapshot = record.snapshot, let id = record.deviceID, id.isStable else { continue }
+ let seen = RecordedUnit(
+ name: snapshot.name,
+ controls: snapshot.configurableControls.map(\.cid),
+ connection: snapshot.connection
+ )
+ guard recordedUnits[id] != seen else { continue }
+ recordedUnits[id] = seen
+ store.record(id, snapshot: snapshot)
+ }
+ if store != settings.devicePreferences { settings.devicePreferences = store }
+ }
+
+ private func feedBatteryNotifier(_ records: [DeviceRegistry.Record]) {
+ guard environment.postsNotifications else { return }
+ for record in records where record.role != .unsupported {
+ guard case .connected(let snapshot) = record.state, let battery = snapshot.battery,
+ batteryReadings[record.address] != battery
+ else { continue }
+ batteryReadings[record.address] = battery
+ batteryNotifier.update(address: record.address, deviceID: record.deviceID, deviceName: record.displayName, battery: battery)
+ }
+ batteryNotifier.prune(keeping: Set(records.map(\.address)))
+ }
+
+ /// Keeps `selectedDevice` on a live mouse (the same unit on its new address, else the first
+ /// connected one) and, until the user picks a model, `selectedModel` on its model.
+ private func updateSelection(_ records: [DeviceRegistry.Record]) {
+ isAdjustingSelection = true
+ defer { isAdjustingSelection = false }
+
+ if let selected = selectedDevice, let current = records.first(where: { $0.address == selected }) {
+ if let id = current.deviceID { selectedDeviceID = id }
+ } else if let id = selectedDeviceID, id.isStable,
+ let moved = records.first(where: { $0.deviceID == id && $0.role == .active }) ?? records.first(where: { $0.deviceID == id }) {
+ selectedDevice = moved.address
+ } else {
+ // Gone: the first connected mouse, else nothing. The unit is still looked for when
+ // nothing else is here, so it is selected again when it comes back.
+ let unit = selectedDeviceID
+ selectedDevice = primaryDevice
+ if selectedDevice == nil { selectedDeviceID = unit }
+ }
+
+ if !modelWasChosen, let model = selectedDevice.flatMap(model(of:)), model != selectedModel {
+ selectedModel = model
+ }
+ }
+
+ private func unique(_ models: [ModelKey]) -> [ModelKey] {
+ var seen = Set()
+ return models.filter { seen.insert($0).inserted }
}
}
@@ -408,6 +925,7 @@ private final class PointerMovementMonitor {
}
func stop() {
+ guard isRunning else { return }
monitors.forEach(NSEvent.removeMonitor)
monitors.removeAll()
}
diff --git a/Upright/App/AppSettings.swift b/Upright/App/AppSettings.swift
index f3f8926..518c928 100644
--- a/Upright/App/AppSettings.swift
+++ b/Upright/App/AppSettings.swift
@@ -3,6 +3,11 @@ import Foundation
import os
/// App preferences outside the button mappings, saved to UserDefaults on every change.
+///
+/// Per-mouse settings (nickname, "Remap this mouse", pointer speed, what was last seen of it)
+/// are in `devicePreferences`. Upright 2.x's single `preferredDPI` is copied once into the MX
+/// Vertical's seed there, so the first MX Vertical seen keeps today's speed; the old key is
+/// left in place for downgrades and nothing reads it after that.
@MainActor
final class AppSettings: ObservableObject {
private enum Key {
@@ -12,6 +17,7 @@ final class AppSettings: ObservableObject {
static let preferredDPI = "preferredDPI"
static let dpiPresets = "dpiPresets"
static let hasCompletedOnboarding = "hasCompletedOnboarding"
+ static let devicePreferences = DevicePreferencesStore.defaultsKey
}
static let defaultDPIPresets = [800, 1200, 1600, 2400]
@@ -35,7 +41,9 @@ final class AppSettings: ObservableObject {
didSet { defaults.set(showBatteryInMenuBar, forKey: Key.showBatteryInMenuBar) }
}
- /// The pointer speed to apply when the mouse connects; nil leaves the mouse's own setting.
+ /// Upright 2.x's one pointer speed for "the" mouse. Legacy: it only seeds the MX Vertical's
+ /// `devicePreferences` (once, at first launch of this version) and is kept for downgrades.
+ /// Per-mouse speeds live in `devicePreferences`; set them through `AppModel`.
@Published var preferredDPI: Int? {
didSet {
if let preferredDPI, DeviceAction.dpiRange.contains(preferredDPI) {
@@ -61,6 +69,15 @@ final class AppSettings: ObservableObject {
didSet { defaults.set(hasCompletedOnboarding, forKey: Key.hasCompletedOnboarding) }
}
+ /// Every mouse's own settings (by `DeviceID`) and the per-model seeds for mice seen for the
+ /// first time. Saved under `devicePreferences.v1` on every change.
+ @Published var devicePreferences: DevicePreferencesStore {
+ didSet {
+ guard devicePreferences != oldValue else { return }
+ devicePreferences.save(to: defaults)
+ }
+ }
+
private let defaults: UserDefaults
private let logger = Logger(subsystem: "Upright", category: "Settings")
@@ -70,7 +87,8 @@ final class AppSettings: ObservableObject {
batteryAlertsEnabled = defaults.object(forKey: Key.batteryAlertsEnabled) as? Bool ?? true
showBatteryInMenuBar = defaults.object(forKey: Key.showBatteryInMenuBar) as? Bool ?? false
let dpi = defaults.object(forKey: Key.preferredDPI) as? Int
- preferredDPI = dpi.flatMap { DeviceAction.dpiRange.contains($0) ? $0 : nil }
+ let legacyDPI = dpi.flatMap { DeviceAction.dpiRange.contains($0) ? $0 : nil }
+ preferredDPI = legacyDPI
let presets = Self.sanitized(defaults.array(forKey: Key.dpiPresets) as? [Int] ?? [])
dpiPresets = presets.isEmpty ? Self.defaultDPIPresets : presets
if let completed = defaults.object(forKey: Key.hasCompletedOnboarding) as? Bool {
@@ -82,6 +100,15 @@ final class AppSettings: ObservableObject {
hasCompletedOnboarding = isUpgrade
defaults.set(isUpgrade, forKey: Key.hasCompletedOnboarding)
}
+
+ // The legacy speed seeds the MX Vertical once; an existing seed means it already ran.
+ // Data it can't read in full is backed up before anything below saves over it.
+ let logger = Logger(subsystem: "Upright", category: "Settings")
+ var store = DevicePreferencesStore.load(from: defaults, log: { logger.notice("\($0, privacy: .public)") })
+ let hadSeed = store.models[.mxVertical] != nil
+ store.migrateLegacy(preferredDPI: legacyDPI)
+ devicePreferences = store
+ if !hadSeed { store.save(to: defaults) }
}
/// Puts scrolling back to how macOS does it.
diff --git a/Upright/App/BatteryNotifier.swift b/Upright/App/BatteryNotifier.swift
index 79a13b7..271cc85 100644
--- a/Upright/App/BatteryNotifier.swift
+++ b/Upright/App/BatteryNotifier.swift
@@ -2,14 +2,21 @@ import Foundation
import UserNotifications
import os
-/// Posts a notification when the mouse battery gets low (15%) and again when critical (5%),
-/// once each per discharge. See `BatteryAlertPolicy` for when it re-arms.
+/// Posts a notification when a mouse battery gets low (15%) and again when critical (5%),
+/// once each per discharge and per mouse. See `BatteryAlertPolicy` for when it re-arms.
+///
+/// Every connected mouse has its own policy, keyed by its runtime `DeviceAddress`. A mouse with a
+/// stable `DeviceID` also leaves its policy behind when its endpoint goes away (sleep, unplug,
+/// re-pair on another slot), so coming back doesn't repeat an alert for the same discharge.
@MainActor
final class BatteryNotifier: NSObject {
/// Readings are always tracked; notifications are posted only while this is on.
var isEnabled: Bool
- private var policy = BatteryAlertPolicy()
+ /// Policies of the endpoints that exist now.
+ private var policies: [DeviceAddress: BatteryAlertPolicy] = [:]
+ /// The last policy state of every stable unit seen, used to seed a new endpoint of that unit.
+ private var remembered: [DeviceID: BatteryAlertPolicy] = [:]
private var center: UNUserNotificationCenter { .current() }
private let logger = Logger(subsystem: "Upright", category: "Battery")
@@ -23,13 +30,48 @@ final class BatteryNotifier: NSObject {
center.delegate = self
}
- /// Feeds the latest reading of the connected mouse. Nil (no mouse or no reading) is ignored,
- /// so a reconnect doesn't repeat an alert for the same discharge.
- func update(battery: BatteryStatus?, deviceName: String) {
- guard let battery, let alert = policy.update(battery) else { return }
- logger.notice("Battery at \(battery.percentage)%, alert: \(String(describing: alert), privacy: .public)")
+ /// Feeds the latest reading of one mouse. `deviceName` is its display name (nickname first)
+ /// and goes into the notification text. Nil (no reading) is ignored, so a reconnect doesn't
+ /// repeat an alert for the same discharge. A transient or missing `deviceID` is fine: the
+ /// policy then lives only as long as the address.
+ func update(address: DeviceAddress, deviceID: DeviceID?, deviceName: String, battery: BatteryStatus?) {
+ guard let battery else { return }
+ let stableID = deviceID.flatMap { $0.isStable ? $0 : nil }
+ var policy = policies[address] ?? stableID.flatMap { remembered[$0] } ?? BatteryAlertPolicy()
+ let alert = policy.update(battery)
+ policies[address] = policy
+ if let stableID { remembered[stableID] = policy }
+ guard let alert else { return }
+ logger.notice("\(deviceName, privacy: .private) battery at \(battery.percentage)%, alert: \(String(describing: alert), privacy: .public)")
guard isEnabled else { return }
- post(alert, deviceName: deviceName)
+ post(alert, deviceName: deviceName, identifier: Self.notificationIdentifier(address: address, deviceID: stableID))
+ }
+
+ /// Drops the policy of an endpoint that went away. A stable unit keeps its remembered state.
+ func endpointRemoved(_ address: DeviceAddress) {
+ policies[address] = nil
+ }
+
+ func endpointsRemoved(_ addresses: [DeviceAddress]) {
+ addresses.forEach(endpointRemoved)
+ }
+
+ /// Drops the policies of every address not in `live`, for callers that diff the record list
+ /// instead of listening for removals.
+ func prune(keeping live: Set) {
+ policies = policies.filter { live.contains($0.key) }
+ }
+
+ /// Compatibility for the single-mouse caller in `AppModel` until it passes addresses.
+ func update(battery: BatteryStatus?, deviceName: String) {
+ update(address: .unassigned, deviceID: nil, deviceName: deviceName, battery: battery)
+ }
+
+ /// `upright.battery.` for a stable unit, else `upright.battery..`.
+ /// A critical alert replaces the low one of the same mouse in Notification Center.
+ static func notificationIdentifier(address: DeviceAddress, deviceID: DeviceID?) -> String {
+ if let deviceID, deviceID.isStable { return "upright.battery.\(deviceID.rawValue)" }
+ return "upright.battery.\(address.link).\(address.index)"
}
/// Shows the permission prompt if macOS hasn't asked yet. Call when the user turns alerts on.
@@ -46,7 +88,7 @@ final class BatteryNotifier: NSObject {
}
}
- private func post(_ alert: BatteryAlertPolicy.Alert, deviceName: String) {
+ private func post(_ alert: BatteryAlertPolicy.Alert, deviceName: String, identifier: String) {
let content = UNMutableNotificationContent()
switch alert {
case .low(let percentage):
@@ -57,8 +99,8 @@ final class BatteryNotifier: NSObject {
content.body = String(localized: "\(percentage)% left. Charge it now to keep using it.")
}
content.sound = .default
- // One identifier: a critical alert replaces the low one in Notification Center.
- let request = UNNotificationRequest(identifier: "upright.battery", content: content, trigger: nil)
+ content.threadIdentifier = "upright.battery"
+ let request = UNNotificationRequest(identifier: identifier, content: content, trigger: nil)
Task {
guard await authorize() else { return }
do {
diff --git a/Upright/App/DemoMode.swift b/Upright/App/DemoMode.swift
new file mode 100644
index 0000000..beb6410
--- /dev/null
+++ b/Upright/App/DemoMode.swift
@@ -0,0 +1,265 @@
+import AppKit
+import Foundation
+
+/// Launch options for Demo Mode. `--demo` runs the real UI driven by simulated mice, isolated
+/// from IOKit, event taps, permission prompts and the user's settings. `--export-snapshots `
+/// (which implies `--demo`) renders the UI offscreen, writes PNGs and `manifest.json` to ``,
+/// then quits. See docs/dev/DEMO-MODE-SPEC.md.
+///
+/// This file also holds the demo scenario both modes show: the fleet (`DemoMode.Fleet`) and the
+/// setup a typical user would have made (`DemoMode.Scenario`), so screenshots and a hands-on demo
+/// look like a Mac that has been using Upright for a while. Everything is made up: fake serials
+/// (`DEMO-00NN`), standard Apple apps only, nothing read from the user's Mac.
+enum DemoMode {
+ nonisolated(unsafe) private(set) static var isActive = false
+ nonisolated(unsafe) private(set) static var exportDirectory: URL?
+
+ /// Demo Mode's own defaults domain, wiped at start; the user's is never touched.
+ static let defaultsSuiteName = "io.github.upright.Upright.demo"
+
+ static var isExporting: Bool { exportDirectory != nil }
+
+ /// Export only: the app profile (bundle ID) a newly shown Buttons pane starts on; nil starts
+ /// on All Apps. `ButtonsPane` seeds its profile selection from it.
+ nonisolated(unsafe) static var snapshotProfileBundleID: String?
+
+ /// Call first thing at launch with `CommandLine.arguments`.
+ ///
+ /// - `--demo`: Demo Mode.
+ /// - `--export-snapshots ` or `--export-snapshots=`: export into `` (relative
+ /// paths resolve against the current directory), then quit. Implies `--demo`.
+ static func configure(arguments: [String]) {
+ exportDirectory = nil
+ var arguments = arguments
+ if !arguments.isEmpty { arguments.removeFirst() } // the executable
+ for (index, argument) in arguments.enumerated() {
+ if argument == "--export-snapshots", arguments.indices.contains(index + 1) {
+ exportDirectory = directoryURL(arguments[index + 1])
+ } else if argument.hasPrefix("--export-snapshots=") {
+ let path = String(argument.dropFirst("--export-snapshots=".count))
+ if !path.isEmpty { exportDirectory = directoryURL(path) }
+ }
+ }
+ isActive = arguments.contains("--demo") || exportDirectory != nil
+ }
+
+ private static func directoryURL(_ path: String) -> URL {
+ let expanded = (path as NSString).expandingTildeInPath
+ let base = URL(fileURLWithPath: FileManager.default.currentDirectoryPath, isDirectory: true)
+ return URL(fileURLWithPath: expanded, isDirectory: true, relativeTo: base).standardizedFileURL
+ }
+}
+
+// MARK: - Fleet
+
+extension DemoMode {
+ /// The simulated mice: an MX Vertical on a Unifying receiver, an MX Master 3S on a Bolt
+ /// receiver and a Lift over Bluetooth, one per connection kind. Never touches IOKit.
+ @MainActor
+ final class Fleet {
+ let source: SimulatedLinkSource
+ let unifying: SimulatedReceiver
+ let bolt: SimulatedReceiver
+ let bluetooth: SimulatedDirect
+
+ /// Each mouse's receiver slot (`SimulatedDirect.index` for the Bluetooth one).
+ static let verticalSlot: UInt8 = 1
+ static let masterSlot: UInt8 = 1
+
+ init() {
+ unifying = SimulatedReceiver.unifying([Self.verticalSlot: DeviceProfiles.mxVertical])
+ bolt = SimulatedReceiver.bolt([Self.masterSlot: DeviceProfiles.mxMaster3S])
+ bluetooth = SimulatedDirect(profile: DeviceProfiles.lift, connection: .bluetooth)
+ source = SimulatedLinkSource([
+ .receiver(unifying, registryID: 0xDE30_0011),
+ .receiver(bolt, registryID: 0xDE30_0012),
+ .direct(bluetooth, registryID: 0xDE30_0013),
+ ])
+ // The Bolt receiver answers a little later, so the two receivers' mice identify (and
+ // are listed) in the same order every run: Lift (a direct link starts at once), MX
+ // Vertical, MX Master 3S. `settle()` removes the delay.
+ bolt.latency = 0.004
+ }
+
+ /// Every mouse answers right away again (after they have connected).
+ func settle() {
+ bolt.latency = 0
+ }
+
+ /// The three mice, in the order they appear in the UI.
+ enum Mouse: CaseIterable {
+ case vertical, master, lift
+
+ var profile: SimulatedDeviceProfile {
+ switch self {
+ case .vertical: return DeviceProfiles.mxVertical
+ case .master: return DeviceProfiles.mxMaster3S
+ case .lift: return DeviceProfiles.lift
+ }
+ }
+
+ /// The model the mouse identifies as (the catalog's key for any of its product IDs).
+ var modelKey: ModelKey {
+ let pids = [profile.bluetoothPID, profile.wirelessPID, profile.usbPID].compactMap { $0 }
+ return pids.lazy.compactMap { DeviceCatalog.model(forPID: $0)?.key }.first
+ ?? ModelKey(productID: pids.first ?? 0)
+ }
+ }
+
+ /// The mouse broadcasts a new battery level, as a real one does when it changes.
+ func setBattery(_ percentage: Int, of mouse: Mouse, charging: Bool = false) {
+ switch mouse {
+ case .vertical: unifying.reportBattery(percentage: percentage, charging: charging, slot: Self.verticalSlot)
+ case .master: bolt.reportBattery(percentage: percentage, charging: charging, slot: Self.masterSlot)
+ case .lift: bluetooth.reportBattery(percentage: percentage, charging: charging)
+ }
+ }
+
+ func batteryLevel(of mouse: Mouse) -> Int? {
+ switch mouse {
+ case .vertical: return unifying.batteryLevel(slot: Self.verticalSlot)
+ case .master: return bolt.batteryLevel(slot: Self.masterSlot)
+ case .lift: return bluetooth.batteryLevel
+ }
+ }
+ }
+}
+
+// MARK: - Scenario
+
+extension DemoMode {
+ /// What the demo shows besides the mice: button setups (with a gesture button and app
+ /// profiles), scrolling, pointer speeds and battery levels.
+ enum Scenario {
+ static let safari = AppReference(bundleID: "com.apple.Safari", displayName: "Safari")
+ static let music = AppReference(bundleID: "com.apple.Music", displayName: "Music")
+ static let finder = AppReference(bundleID: "com.apple.finder", displayName: "Finder")
+
+ /// Battery levels the mice report once connected.
+ static let batteryLevels: [Fleet.Mouse: Int] = [.vertical: 85, .master: 60, .lift: 45]
+ /// The low-battery variant: the Lift is almost empty.
+ static let lowBatteryLevel = 8
+
+ /// Pointer speeds kept per mouse.
+ static let pointerSpeeds: [Fleet.Mouse: Int] = [.vertical: 1600, .master: 1000, .lift: 1200]
+
+ /// The MX Vertical's Top Button as a gesture button, with every slot set.
+ static let topButtonGestures = GestureSet(
+ tap: .system(.missionControl),
+ up: .system(.showDesktop),
+ down: .system(.appExpose),
+ left: .system(.spaceLeft),
+ right: .system(.spaceRight)
+ )
+
+ /// Top Button actions for the `buttons.global.N` variants.
+ static let topButtonVariants: [ButtonAction] = [
+ .system(.missionControl),
+ .system(.spaceLeft),
+ .system(.playPause),
+ .launchApp(AppReference(bundleID: "com.apple.Notes", displayName: "Notes")),
+ .openURL(URL(string: "https://github.com/dpdev69/upright")!),
+ .runShortcut(String(localized: "Start Focus Session", comment: "Name of a Shortcuts.app shortcut shown in Demo Mode")),
+ ]
+
+ /// Every model's setup: a global table each, Safari and Music profiles for the MX Vertical,
+ /// a Safari profile for the MX Master 3S.
+ static var library: MappingLibrary {
+ var library = MappingLibrary()
+
+ var vertical = MappingProfiles()
+ vertical.global[KnownControl.middleClick] = .system(.missionControl)
+ vertical.global[KnownControl.forward] = .system(.navigateForward)
+ vertical.global[KnownControl.dpiSwitch] = .gestures(topButtonGestures)
+ vertical.upsert(AppProfile(bundleID: safari.bundleID, displayName: safari.displayName, overrides: [
+ KnownControl.back: .system(.previousTab),
+ KnownControl.forward: .system(.nextTab),
+ KnownControl.middleClick: .keyCombo(KeyCombo(keyCode: 13, modifiers: .command, keyLabel: "W")),
+ ]))
+ vertical.upsert(AppProfile(bundleID: music.bundleID, displayName: music.displayName, overrides: [
+ KnownControl.back: .system(.previousTrack),
+ KnownControl.forward: .system(.nextTrack),
+ KnownControl.middleClick: .system(.playPause),
+ ]))
+ library[Fleet.Mouse.vertical.modelKey] = vertical
+
+ var master = MappingProfiles()
+ master.global[KnownControl.gestureButton] = .gestures(.standard)
+ master.global[KnownControl.middleClick] = .system(.missionControl)
+ master.upsert(AppProfile(bundleID: safari.bundleID, displayName: safari.displayName, overrides: [
+ KnownControl.back: .system(.previousTab),
+ KnownControl.forward: .system(.nextTab),
+ ]))
+ library[Fleet.Mouse.master.modelKey] = master
+
+ var lift = MappingProfiles()
+ lift.global[KnownControl.dpiSwitch] = .device(.cycleDPI)
+ lift.global[KnownControl.middleClick] = .system(.appExpose)
+ library[Fleet.Mouse.lift.modelKey] = lift
+
+ return library
+ }
+
+ /// Reverse and smooth scrolling on, a little faster than macOS.
+ static let scrollSettings = ScrollSettings(reverseVertical: true, speed: 1.25, smoothScrolling: true)
+
+ /// Applies the setup to a Demo Mode model. Never call it on a live one: it replaces the
+ /// mappings and settings.
+ @MainActor
+ static func apply(to model: AppModel) {
+ precondition(model.environment.isDemo, "The demo scenario only goes into a Demo Mode model")
+ model.library = library
+ model.isRemappingEnabled = true
+ model.selectedModel = Fleet.Mouse.vertical.modelKey
+ model.settings.scrollSettings = scrollSettings
+ model.settings.showBatteryInMenuBar = true
+ model.settings.batteryAlertsEnabled = true
+ model.settings.hasCompletedOnboarding = true
+ }
+ }
+
+ /// `--demo` (not exporting): seeds the scenario into the running Demo Mode model and adds the
+ /// app's real wake message to the activity log. Call after `AppModel.start()`.
+ @MainActor
+ static func didStart(model: AppModel) {
+ guard isActive, !isExporting, model.environment.isDemo else { return }
+ Scenario.apply(to: model)
+ Task { @MainActor in
+ try? await Task.sleep(nanoseconds: 2_000_000_000)
+ model.devices.systemDidWake()
+ }
+ }
+}
+
+// MARK: - Frontmost app
+
+extension DemoMode {
+ /// Makes Upright believe `app` became the frontmost app, so the per-app profile UI can be shown
+ /// without switching apps. The notification is posted on this process's own workspace
+ /// notification center only; nothing outside Upright sees it. The app must be a standard
+ /// Apple app (privacy: the user's own apps are never shown).
+ @MainActor
+ static func pretendFrontmost(_ app: AppReference) {
+ let running = DemoRunningApplication(app)
+ NSWorkspace.shared.notificationCenter.post(
+ name: NSWorkspace.didActivateApplicationNotification,
+ object: NSWorkspace.shared,
+ userInfo: [NSWorkspace.applicationUserInfoKey: running]
+ )
+ }
+}
+
+/// A stand-in `NSRunningApplication` for `DemoMode.pretendFrontmost`: it only answers its bundle
+/// identifier and name.
+private final class DemoRunningApplication: NSRunningApplication, @unchecked Sendable {
+ private let reference: AppReference
+
+ init(_ reference: AppReference) {
+ self.reference = reference
+ super.init()
+ }
+
+ override var bundleIdentifier: String? { reference.bundleID }
+ override var localizedName: String? { reference.displayName }
+ override var activationPolicy: NSApplication.ActivationPolicy { .regular }
+}
diff --git a/Upright/App/DeviceManager.swift b/Upright/App/DeviceManager.swift
index 7624dbc..799ac82 100644
--- a/Upright/App/DeviceManager.swift
+++ b/Upright/App/DeviceManager.swift
@@ -1,8 +1,18 @@
import AppKit
-import IOKit.hid
-/// Finds MX Vertical connections through IOKit and keeps one `MXVerticalController` per
-/// HID++ interface. Publishes a single, user-facing status for the UI.
+/// Opens every supported Logitech HID interface a `LinkSource` reports (IOKit by default, the
+/// simulator in Demo Mode), runs one `HIDPPLink` per interface, and publishes every mouse on
+/// them as a `DeviceRegistry.Record` (§B11, §C steps 2, 6, 7, 11 and 12).
+///
+/// - Events: button events and raw movement carry their `DeviceAddress`. Presses and movement
+/// from endpoints that aren't active (standby twins, unidentified or unsupported devices) are
+/// dropped; a release always follows a press that was delivered.
+/// - Configuration is pulled: a link asks `configurationProvider` right after a mouse identifies,
+/// before anything is diverted. AppModel pushes later changes with `setConfiguration(_:for:)`.
+/// - Recovery is per interface: a link whose mouse stops answering is re-opened on its own (a
+/// few times, with backoff); when no other interface has a mouse, the source is restarted, as
+/// Upright always did. Unsupported devices never trigger it.
+/// - The activity log labels every line with its device: "MX Master 3S · Bolt slot 2: …".
@MainActor
final class DeviceManager: ObservableObject {
enum Status: Equatable {
@@ -18,87 +28,98 @@ final class DeviceManager: ObservableObject {
struct LogEntry: Identifiable, Equatable {
let id: Int
let date: Date
+ /// Prefixed with the label of the device or interface the line is about.
let text: String
let isTraffic: Bool
+ /// The device the line is about; nil for a line about an interface or the app.
+ var address: DeviceAddress?
+ /// The interface the line is about; nil for a line about the app.
+ var link: UInt32?
}
- @Published private(set) var status: Status = .searching
+ /// Every endpoint: pointers first by first-seen order, unsupported devices last.
+ @Published private(set) var records: [DeviceRegistry.Record] = []
+ @Published private(set) var summary = FleetSummary()
+ /// Names of the interfaces macOS refused to open (Input Monitoring).
+ @Published private(set) var blockedInterfaces: [String] = []
@Published private(set) var log: [LogEntry] = []
/// Raw HID++ traffic is only formatted (and its handler installed) while this is on.
@Published var logsTraffic = false {
didSet {
guard logsTraffic != oldValue else { return }
- entries.values.forEach { installTrafficLogging(on: $0.controller) }
+ entries.values.forEach { installTrafficLogging(on: $0.link) }
}
}
- /// The pointer speed kept across reconnects (nil leaves the mouse's own setting alone).
- /// Changes when set here, by Cycle Pointer Speed, or when the mouse's top button changed it
- /// natively and `refreshDeviceInfo()` or a reconnect noticed. Persist it and restore it with
- /// `setDesiredDPI`.
- @Published private(set) var desiredDPI: Int?
+ var needsInputMonitoring: Bool { !blockedInterfaces.isEmpty }
+ /// Asked synchronously when a mouse identifies, before anything is diverted. Only active
+ /// endpoints are asked (see `prospectiveRole`); others get `.native`. Nil means `.native`.
+ var configurationProvider: (@MainActor (DeviceAddress, DeviceSnapshot) -> EndpointConfiguration)?
var onButtonEvent: ((ButtonEvent) -> Void)?
- /// Raw movement (dx, dy) while a control set with `setDesiredRawXY` is held.
- var onPointerDelta: ((Int, Int) -> Void)?
+ /// Raw movement from a held raw-XY control, stamped with its device.
+ var onPointerDelta: ((PointerDelta) -> Void)?
+ /// Endpoints that went away (their held buttons were released first). Addresses are never reused.
+ var onEndpointsRemoved: (([DeviceAddress]) -> Void)?
+ /// The pointer speed one mouse keeps changed without the app asking (its own DPI button, or
+ /// Cycle Pointer Speed). Only that mouse; nothing is broadcast.
+ var onDeviceDPIChange: ((DeviceAddress, Int?) -> Void)?
+ /// Nicknames by device ID (`DevicePreferences.nickname`). Call `refreshNames()` after one changes.
+ var nameProvider: ((DeviceID) -> String?)? {
+ get { registry.nameProvider }
+ set {
+ registry.nameProvider = newValue
+ registry.refreshNames()
+ }
+ }
+ /// The controls last seen on a model (`DevicePreferences.lastControls`), for
+ /// `controls(for:address:)` while no mouse of that model is connected.
+ var rememberedControls: ((ModelKey) -> [UInt16]?)?
private struct Entry {
- let transport: IOKitHIDTransport
- let controller: MXVerticalController
- let kind: TransportKind
- }
-
- private let monitor = HIDDeviceMonitor()
- private var entries: [IOHIDDevice: Entry] = [:]
- private var desiredDiversion: Set = []
- private var desiredRawXY: Set = []
- /// Only set when macOS actually refused access: IOHIDManagerOpen or a device open returned
- /// "not permitted". An empty device list just means no mouse is connected.
- private var accessDenied = false
- private var failedRecoveries = 0
- private var recoveryTask: Task?
+ let interface: LinkInterface
+ let transport: HIDPPTransport
+ let link: HIDPPLink
+ }
+
+ private let source: LinkSource
+ private let registry = DeviceRegistry()
+ private let tuning: ControllerTuning
+ private var entries: [AnyHashable: Entry] = [:]
+ private var linkIDs: [UInt32: AnyHashable] = [:]
+ private var nextLink: UInt32 = 1
+ /// Interfaces macOS refused, by interface ID.
+ private var blocked: [AnyHashable: String] = [:]
+ /// Watching as a whole was refused and nothing could be opened.
+ private var watchingDenied = false
+ /// Presses delivered to `onButtonEvent` and not released yet.
+ private var deliveredPresses: Set = []
+ /// Recovery state by interface identity, so it survives re-matching an interface.
+ private var recoveryAttempts: [String: Int] = [:]
+ private var recoveryTasks: [String: Task] = [:]
+ private var started = false
private var nextLogID = 0
private var observers: [NSObjectProtocol] = []
- private static let maxLogEntries = 400
+ private static let maxLogEntries = 600
private static let maxRecoveries = 3
- var snapshot: DeviceSnapshot? {
- switch status {
- case .connected(let snapshot), .asleep(let snapshot): return snapshot
- default: return nil
- }
- }
-
- var isConnected: Bool {
- if case .connected = status { return true }
- return false
- }
-
- /// The controls to show in the UI: what the mouse reports, or the MX Vertical's known
- /// buttons when no mouse has been seen yet.
- var controls: [ControlDescriptor] {
- let cids = snapshot?.configurableControls.map(\.cid) ?? KnownControl.mxVerticalDefaults
- let order = KnownControl.mxVerticalDefaults
- return cids
- .sorted { lhs, rhs in
- let l = order.firstIndex(of: lhs) ?? Int.max
- let r = order.firstIndex(of: rhs) ?? Int.max
- return l == r ? lhs < rhs : l < r
- }
- .map(ControlDescriptor.init(cid:))
+ /// `source` nil: the simulated fleet in Demo Mode, otherwise IOKit.
+ init(source: LinkSource? = nil, tuning: ControllerTuning = .init()) {
+ self.source = source ?? (DemoMode.isActive ? SimulatedLinkSource.demo() : IOKitLinkSource())
+ self.tuning = tuning
+ registry.onChange = { [weak self] in self?.refreshPublished() }
}
func start() {
- monitor.onMatch = { [weak self] device in
- MainActorDelivery.run { self?.attach(device) }
- }
- monitor.onRemove = { [weak self] device in
- MainActorDelivery.run { self?.detach(device, removed: true) }
+ guard !started else { return }
+ started = true
+ source.onMatch = { [weak self] interface in self?.attach(interface) }
+ source.onRemove = { [weak self] id in self?.interfaceRemoved(id) }
+ source.onIgnored = { [weak self] descriptor, reason in
+ self?.append(String(localized: "Ignoring \(descriptor.summary): \(reason)"))
}
- // No Input Monitoring prompt here: the vendor HID++ interfaces usually open without it.
- // The UI offers `requestInputMonitoringAccess()` when access is actually refused.
- openMonitor()
- append("Watching for MX Vertical over USB, Unifying receivers and Bluetooth")
+ append(source.watchingDescription)
+ startSource()
let center = NSWorkspace.shared.notificationCenter
for name in [NSWorkspace.didWakeNotification, NSWorkspace.screensDidWakeNotification, NSWorkspace.sessionDidBecomeActiveNotification] {
@@ -106,88 +127,129 @@ final class DeviceManager: ObservableObject {
MainActorDelivery.run { self?.systemDidWake() }
})
}
- recomputeStatus()
+ refreshPublished()
}
- func setDesiredDiversion(_ cids: Set) {
- desiredDiversion = cids
- entries.values.forEach { $0.controller.setDesiredDiversion(cids) }
+ /// Hands every mouse's buttons back. Called when the app quits.
+ func shutdown() {
+ recoveryTasks.values.forEach { $0.cancel() }
+ recoveryTasks.removeAll()
+ entries.values.forEach { $0.link.shutdown() }
}
- /// Controls that report raw movement through `onPointerDelta` while held (diverted as well).
- func setDesiredRawXY(_ cids: Set) {
- desiredRawXY = cids
- entries.values.forEach { $0.controller.setDesiredRawXY(cids) }
+ // MARK: Queries
+
+ func snapshot(_ a: DeviceAddress) -> DeviceSnapshot? {
+ registry.record(a)?.snapshot
}
- // MARK: Device actions
+ func record(_ a: DeviceAddress) -> DeviceRegistry.Record? {
+ registry.record(a)
+ }
- func perform(_ action: DeviceAction) {
- guard action.isValid else {
- append("Ignoring invalid device action: \(action.title)")
- return
- }
- switch action {
- case .cycleDPI: cycleDPI()
- case .setDPI(let dpi): setDPI(dpi)
- case .switchHost(let host): switchToHost(host - 1)
- }
+ /// False for standby, unsupported and unidentified endpoints.
+ func isActive(_ a: DeviceAddress) -> Bool {
+ registry.isActive(a)
}
- /// Sets the pointer speed now (snapped to what the mouse supports) and keeps it across reconnects.
- func setDPI(_ value: Int) {
- setDesiredDPI(value)
+ /// The role an endpoint that is identifying right now will get; for a configuration provider,
+ /// which is asked before the endpoint's record says it is connected.
+ func prospectiveRole(_ a: DeviceAddress, snapshot: DeviceSnapshot) -> DeviceRegistry.Role {
+ registry.prospectiveRole(a, snapshot: snapshot)
}
- /// Restores or clears the kept pointer speed. A value is applied as soon as a mouse is connected.
- func setDesiredDPI(_ value: Int?) {
- desiredDPI = value
- entries.values.forEach { $0.controller.setDesiredDPI(value) }
+ func refreshNames() {
+ registry.refreshNames()
}
- /// Steps to the next pointer-speed preset; empty `presets` uses four evenly spaced speeds.
- func cycleDPI(presets: [Int] = []) {
- guard let controller = connectedController else {
- append("Cycle Pointer Speed: no mouse is connected")
- return
+ /// The controls of `model`, in display order: what a connected mouse of that model reports
+ /// (the one at `address` first), else what one reported last time (`rememberedControls`),
+ /// else the catalog's list.
+ func controls(for model: ModelKey, address: DeviceAddress?) -> [ControlDescriptor] {
+ func reported(_ record: DeviceRegistry.Record?) -> [UInt16]? {
+ guard let record, record.role != .unsupported, record.model == model, let snapshot = record.snapshot else { return nil }
+ return snapshot.configurableControls.map(\.cid)
}
- controller.cycleDPI(presets: presets)
+ let cids = reported(address.flatMap(registry.record))
+ ?? registry.records.lazy.compactMap(reported).first
+ ?? rememberedControls?(model).flatMap { $0.isEmpty ? nil : $0 }
+ ?? DeviceCatalog.model(model)?.controlOrder
+ ?? []
+ return DeviceCatalog.sortedControls(cids, for: model).map { ControlDescriptor(cid: $0, model: model) }
}
- /// Easy-Switch to a zero-based channel. The mouse disconnects from this Mac right away.
- func switchToHost(_ index: Int) {
- guard let controller = connectedController else {
- append("Easy-Switch: no mouse is connected")
+ /// The device the single-mouse UI shows: the first active connected mouse, else the first
+ /// active one that is asleep, else the first that isn't unsupported.
+ var primaryAddress: DeviceAddress? {
+ let active = records.filter { $0.role == .active }
+ return active.first(where: \.isConnected)?.address
+ ?? active.first(where: { $0.snapshot != nil })?.address
+ ?? records.first(where: { $0.role != .unsupported })?.address
+ }
+
+ // MARK: Per-device actions
+
+ /// Runs a device action on one mouse. `.unassigned` means the primary mouse.
+ func perform(_ action: DeviceAction, on a: DeviceAddress, presets: [Int]) {
+ guard action.isValid else {
+ append(String(localized: "Ignoring invalid device action: \(action.title)"), address: a)
return
}
- controller.switchToHost(index)
+ switch action {
+ case .cycleDPI:
+ guard let (link, address) = serving(a, for: action.title) else { return }
+ link.cycleDPI(at: address.index, presets: presets)
+ case .setDPI(let dpi):
+ setDPI(dpi, on: a)
+ case .switchHost(let host):
+ switchToHost(host - 1, on: a)
+ }
+ }
+
+ /// Sets the pointer speed of one mouse (snapped to what it supports) and keeps it across
+ /// reconnects; nil stops keeping one and leaves the mouse's own setting alone. A speed chosen
+ /// here is not reported through `onDeviceDPIChange`: the caller already knows it.
+ func setDPI(_ dpi: Int?, on a: DeviceAddress) {
+ guard let (link, address) = serving(a, for: String(localized: "Pointer Speed")) else { return }
+ link.setDPI(dpi, at: address.index)
+ refreshDesiredDPI()
}
- /// Re-reads battery, pointer speed and Easy-Switch channel (e.g. when the menu opens). The top
- /// button changes the speed without telling us when it isn't diverted.
- func refreshDeviceInfo() {
- entries.values
- .filter { if case .connected = $0.controller.state { return true } else { return false } }
- .forEach { $0.controller.refreshDeviceInfo() }
+ /// Easy-Switch one mouse to a zero-based channel. It disconnects from this Mac right away.
+ func switchToHost(_ host: Int, on a: DeviceAddress) {
+ guard let (link, address) = serving(a, for: String(localized: "Easy-Switch")) else { return }
+ link.switchToHost(host, at: address.index)
}
- private var connectedController: MXVerticalController? {
- entries.values.first { if case .connected = $0.controller.state { return true } else { return false } }?.controller
+ func setConfiguration(_ c: EndpointConfiguration, for a: DeviceAddress) {
+ guard let link = link(of: a) else { return }
+ link.setConfiguration(c, at: a.index)
+ refreshDesiredDPI()
}
- /// A controller changed its kept speed (cycle, or a change noticed on the mouse).
- private func controller(_ controller: MXVerticalController, didChangeDesiredDPI value: Int?) {
- guard value != desiredDPI, entries.values.contains(where: { $0.controller === controller }) else { return }
- desiredDPI = value
- entries.values.filter { $0.controller !== controller }.forEach { $0.controller.setDesiredDPI(value) }
+ /// Identifies one mouse again; nil drops every interface and matches them again
+ /// (Reconnect All). A Bluetooth re-pair keeps the serial, but the old interface stops answering.
+ func reconnect(_ a: DeviceAddress? = nil) {
+ guard let a else {
+ append(String(localized: "Reconnecting all devices"))
+ recoveryAttempts.removeAll()
+ restartSource()
+ return
+ }
+ guard let id = linkIDs[a.link], let entry = entries[id] else { return }
+ append(String(localized: "Reconnecting"), address: a)
+ recoveryAttempts[entry.interface.identityKey] = nil
+ entry.link.reconnect(index: a.index)
}
- /// Drops every open interface and matches the mouse again. A Bluetooth re-pair
- /// keeps the same serial but the old IOHIDDevice stops answering.
- func reconnect() {
- append("Reconnecting")
- failedRecoveries = 0
- restartMonitoring()
+ /// Re-reads battery, pointer speed and Easy-Switch channel (e.g. when the menu opens): one
+ /// mouse, or all. A mouse's DPI button changes the speed without telling us.
+ func refreshDeviceInfo(_ a: DeviceAddress? = nil) {
+ if let a {
+ link(of: a)?.refreshDeviceInfo(at: a.index)
+ } else {
+ orderedEntries.forEach { $0.link.refreshDeviceInfo(at: nil) }
+ }
}
/// Shows the Input Monitoring prompt the first time (macOS only asks once) and reconnects
@@ -195,53 +257,19 @@ final class DeviceManager: ObservableObject {
/// is granted; when it isn't, the caller should point the user to System Settings.
@discardableResult
func requestInputMonitoringAccess() -> Bool {
- let granted = HIDDeviceMonitor.requestInputMonitoringAccess()
- if granted, accessDenied || entries.isEmpty {
- reconnect()
+ let granted = source.requestInputMonitoringAccess()
+ if granted, needsInputMonitoring || entries.isEmpty {
+ reconnect(nil)
}
return granted
}
- /// The open HID interface can be the pre-repair device. Matching again picks up the live one.
- /// Controllers keep retrying on their own; this only re-matches a few times.
- private func scheduleRecovery() {
- guard recoveryTask == nil, failedRecoveries < Self.maxRecoveries else { return }
- failedRecoveries += 1
- let delay = UInt64(failedRecoveries) * 700_000_000
- append("HID++ did not answer; looking for the mouse again")
- recoveryTask = Task { @MainActor [weak self] in
- try? await Task.sleep(nanoseconds: delay)
- guard let self, !Task.isCancelled else { return }
- self.recoveryTask = nil
- guard !self.isConnected else { return }
- self.restartMonitoring()
- }
- }
-
- private func restartMonitoring() {
- recoveryTask?.cancel()
- recoveryTask = nil
- for device in Array(entries.keys) {
- detach(device, removed: false)
- }
- monitor.stop()
- accessDenied = false
- openMonitor()
- recomputeStatus()
- }
-
- private func openMonitor() {
- let result = monitor.start()
- guard result != kIOReturnSuccess else { return }
- append(String(format: "IOHIDManagerOpen returned 0x%08X", UInt32(bitPattern: result)))
- if (result == IOKitReturn.notPermitted || result == IOKitReturn.notPrivileged), entries.isEmpty {
- accessDenied = true
- }
- }
-
- /// Hands all buttons back to the mouse. Called when the app quits.
- func shutdown() {
- entries.values.forEach { $0.controller.shutdown() }
+ /// After the Mac wakes (also called by Demo Mode): re-applies every mouse's configuration;
+ /// receivers re-assert their notification flags.
+ func systemDidWake() {
+ guard !entries.isEmpty else { return }
+ append(String(localized: "Mac woke up; reapplying button configuration"))
+ orderedEntries.forEach { $0.link.refresh() }
}
func clearLog() {
@@ -254,147 +282,467 @@ final class DeviceManager: ObservableObject {
return log.map { "\(formatter.string(from: $0.date)) \($0.text)" }.joined(separator: "\n")
}
- // MARK: Device lifecycle
+ // MARK: Source and interfaces
+
+ private func startSource() {
+ watchingDenied = false
+ do {
+ try source.start()
+ } catch let error as LinkSourceError {
+ append(error.message)
+ if error.isPermissionDenied, entries.isEmpty { watchingDenied = true }
+ } catch {
+ append("\(error)")
+ }
+ refreshBlocked()
+ }
+
+ private func restartSource() {
+ recoveryTasks.values.forEach { $0.cancel() }
+ recoveryTasks.removeAll()
+ for id in orderedEntries.map(\.interface.id) {
+ detach(id, removed: false)
+ }
+ source.stop()
+ blocked.removeAll()
+ startSource()
+ refreshPublished()
+ }
- private func attach(_ device: IOHIDDevice) {
- guard entries[device] == nil else { return }
- let info = HIDDeviceMonitor.info(for: device)
- guard let kind = MXVertical.classify(vendorID: info.vendorID, productID: info.productID) else { return }
- guard let superseded = HIDInterfaceInfo.twinsToReplace(adding: info, open: entries.mapValues(\.transport.info)) else { return }
+ private func attach(_ interface: LinkInterface) {
+ let id = interface.id
+ guard entries[id] == nil else { return }
+ guard let superseded = InterfaceClassifier.twinsToReplace(adding: interface.descriptor, open: entries.mapValues(\.interface.descriptor)) else { return }
superseded.forEach { detach($0, removed: false) }
- append("Found \(info.summary)")
- let transport = IOKitHIDTransport(device: device)
+ append(String(localized: "Found \(interface.descriptor.summary)"))
+ let transport: HIDPPTransport
do {
- try transport.open()
- } catch let error as IOKitHIDError {
- append(error.description)
- if error.isPermissionDenied { accessDenied = true }
- recomputeStatus()
- return
+ transport = try source.open(interface)
} catch {
- append("\(error)")
+ let failure = error as? LinkSourceError ?? .failed("\(error)")
+ append(failure.message)
+ if failure.isPermissionDenied { blocked[id] = interface.name }
+ refreshBlocked()
+ refreshStatus()
return
}
- accessDenied = false
-
- let controller = MXVerticalController(transport: transport, kind: kind)
- controller.onLog = { [weak self] text in self?.append(text) }
- controller.onButtonEvent = { [weak self] event in self?.onButtonEvent?(event) }
- controller.onPointerDelta = { [weak self] dx, dy in self?.onPointerDelta?(dx, dy) }
- controller.onDesiredDPIChange = { [weak self, weak controller] value in
- guard let self, let controller else { return }
- self.controller(controller, didChangeDesiredDPI: value)
- }
- controller.onStateChange = { [weak self, weak controller] state in
- guard let self, let controller else { return }
- self.controller(controller, didChangeTo: state)
- }
- installTrafficLogging(on: controller)
- controller.setDesiredDiversion(desiredDiversion)
- controller.setDesiredRawXY(desiredRawXY)
- controller.setDesiredDPI(desiredDPI)
- entries[device] = Entry(transport: transport, controller: controller, kind: kind)
- controller.start()
- recomputeStatus()
- }
-
- /// `removed` is true when IOKit removed the device; otherwise it is still present and its
- /// handle must be closed.
- private func detach(_ device: IOHIDDevice, removed: Bool) {
- guard let entry = entries.removeValue(forKey: device) else { return }
- append(removed ? "\(entry.transport.info.product) was disconnected" : "Closing \(entry.transport.info.product)")
- let controller = entry.controller
- // A stale controller must not drive status or recovery for the entries that remain.
- controller.onStateChange = nil
- controller.onDesiredDPIChange = nil
- controller.client.onTraffic = nil
- // Releases held keys (still delivered through onButtonEvent) and hands buttons back.
- controller.shutdown()
- controller.onButtonEvent = nil
- controller.onPointerDelta = nil
- controller.onLog = nil
- if removed {
- entry.transport.deviceRemoved()
- } else {
- entry.transport.close()
+ blocked[id] = nil
+ // Something opened, so watching works after all; refusals stay per interface.
+ watchingDenied = false
+ refreshBlocked()
+
+ let number = nextLink
+ nextLink += 1
+ let link = HIDPPLink(
+ transport: transport,
+ kind: interface.kind,
+ link: number,
+ tuning: tuning,
+ productID: interface.productID,
+ productName: interface.descriptor.productName.isEmpty ? nil : interface.descriptor.productName
+ )
+ entries[id] = Entry(interface: interface, transport: transport, link: link)
+ linkIDs[number] = id
+ wire(link)
+ link.start()
+ refreshPublished()
+ }
+
+ private func interfaceRemoved(_ id: AnyHashable) {
+ let wasBlocked = blocked.removeValue(forKey: id) != nil
+ detach(id, removed: true)
+ if wasBlocked { refreshBlocked() }
+ }
+
+ /// `removed` is true when the source reported the interface gone; otherwise it is still
+ /// present and is closed.
+ private func detach(_ id: AnyHashable, removed: Bool) {
+ guard let entry = entries[id] else { return }
+ let link = entry.link
+ let number = link.link
+ append(removed ? String(localized: "\(entry.interface.name) was disconnected") : String(localized: "Closing \(entry.interface.name)"), link: number)
+ entries[id] = nil
+ linkIDs[number] = nil
+ // A closing link must not drive status, configuration or recovery any more.
+ link.configurationProvider = nil
+ link.onEndpointChange = nil
+ link.onEndpointRemoved = nil
+ link.onDeviceDPIChange = nil
+ link.onDiscoveryChange = nil
+ link.client.onTraffic = nil
+ // Releases held buttons (still delivered through onButtonEvent) and hands buttons back.
+ link.shutdown()
+ link.onButtonEvent = nil
+ link.onPointerDelta = nil
+ link.onLog = nil
+ source.close(entry.interface, transport: entry.transport, removed: removed)
+
+ let gone = registry.removeLink(number)
+ endpointsRemoved(gone)
+ refreshPublished()
+ }
+
+ private var orderedEntries: [Entry] {
+ entries.values.sorted { $0.link.link < $1.link.link }
+ }
+
+ private func link(of a: DeviceAddress) -> HIDPPLink? {
+ linkIDs[a.link].flatMap { entries[$0] }?.link
+ }
+
+ /// The link serving `a` (`.unassigned`: the primary mouse), or nil after logging why not.
+ private func serving(_ a: DeviceAddress, for action: String) -> (HIDPPLink, DeviceAddress)? {
+ let address = a == .unassigned ? primaryAddress : a
+ guard let address, let link = link(of: address), link.endpoints[address.index] != nil else {
+ append(String(localized: "\(action): no mouse is connected"), address: a == .unassigned ? nil : a)
+ return nil
}
- recomputeStatus()
+ return (link, address)
}
- private func controller(_ controller: MXVerticalController, didChangeTo state: MXVerticalController.State) {
- guard !controller.isShutDown, entries.values.contains(where: { $0.controller === controller }) else { return }
- recomputeStatus()
+ // MARK: Hub callbacks
+
+ private func wire(_ link: HIDPPLink) {
+ let number = link.link
+ link.configurationProvider = { [weak self] address, snapshot in
+ self?.configuration(for: address, snapshot: snapshot) ?? .native
+ }
+ link.onEndpointChange = { [weak self, weak link] address, state in
+ guard let self, let link else { return }
+ self.endpoint(address, on: link, didChangeTo: state)
+ }
+ link.onEndpointRemoved = { [weak self] address, _ in
+ guard let self else { return }
+ self.registry.remove(address)
+ self.endpointsRemoved([address])
+ }
+ link.onButtonEvent = { [weak self] event in self?.deliver(event) }
+ link.onPointerDelta = { [weak self] delta in self?.deliver(delta) }
+ link.onDeviceDPIChange = { [weak self] address, dpi in
+ guard let self else { return }
+ self.refreshDesiredDPI()
+ self.onDeviceDPIChange?(address, dpi)
+ }
+ link.onLog = { [weak self] address, text in self?.append(text, link: number, address: address) }
+ // Only a receiver without mice reports discovery in `status`.
+ link.onDiscoveryChange = { [weak self] in self?.refreshStatus() }
+ installTrafficLogging(on: link)
+ }
+
+ private func configuration(for address: DeviceAddress, snapshot: DeviceSnapshot) -> EndpointConfiguration {
+ // A standby twin stays native; held buttons can't exist on a session that just started.
+ guard registry.prospectiveRole(address, snapshot: snapshot) == .active else { return .native }
+ return configurationProvider?(address, snapshot) ?? .native
+ }
+
+ private func endpoint(_ address: DeviceAddress, on link: HIDPPLink, didChangeTo state: EndpointController.State) {
+ guard !link.isShutDown, linkIDs[link.link] != nil else { return }
+ registry.update(address, connection: link.kind.connection, state: state)
+ refreshStatus()
+ guard !link.isClosed, let id = linkIDs[link.link], let entry = entries[id] else { return }
switch state {
case .connected:
- failedRecoveries = 0
+ recoveryAttempts[entry.interface.identityKey] = nil
case .failed:
- scheduleRecovery()
+ scheduleRecovery(of: entry)
case .waitingForDevice:
// A directly connected mouse that doesn't answer is often a dead pre-repair interface.
- if case .direct = controller.kind { scheduleRecovery() }
- case .probing, .asleep:
+ if case .direct = link.kind { scheduleRecovery(of: entry) }
+ case .probing, .asleep, .unsupported:
break
}
}
- private func installTrafficLogging(on controller: MXVerticalController) {
+ private func endpointsRemoved(_ addresses: [DeviceAddress]) {
+ guard !addresses.isEmpty else { return }
+ let gone = Set(addresses)
+ deliveredPresses = deliveredPresses.filter { !gone.contains($0.source) }
+ onEndpointsRemoved?(addresses)
+ }
+
+ private func deliver(_ event: ButtonEvent) {
+ let key = ControlKey(source: event.source, cid: event.cid)
+ switch event.phase {
+ case .press:
+ guard registry.isActive(event.source) else { return }
+ deliveredPresses.insert(key)
+ case .release:
+ guard deliveredPresses.remove(key) != nil || registry.isActive(event.source) else { return }
+ }
+ onButtonEvent?(event)
+ }
+
+ private func deliver(_ delta: PointerDelta) {
+ guard registry.isActive(delta.source) || deliveredPresses.contains(where: { $0.source == delta.source }) else { return }
+ onPointerDelta?(delta)
+ }
+
+ private func installTrafficLogging(on link: HIDPPLink) {
guard logsTraffic else {
- controller.client.onTraffic = nil
+ link.client.onTraffic = nil
return
}
- controller.client.onTraffic = { [weak self] direction, bytes in
+ let number = link.link
+ link.client.onTraffic = { [weak self] direction, bytes in
MainActorDelivery.run {
guard let self, self.logsTraffic else { return }
let line = (direction == .outgoing ? "→ " : "← ") + bytes.prefix(20).map { String(format: "%02X", $0) }.joined(separator: " ")
- self.append(line, isTraffic: true)
+ self.append(line, isTraffic: true, link: number)
}
}
}
- private func systemDidWake() {
- guard !entries.isEmpty else { return }
- append("Mac woke up; reapplying button configuration")
- entries.values.forEach { $0.controller.refresh() }
- }
-
- private func recomputeStatus() {
- func rank(_ state: MXVerticalController.State) -> Int {
- switch state {
- case .connected: return 5
- case .asleep: return 4
- case .probing: return 3
- case .waitingForDevice: return 2
- case .failed: return 1
+ // MARK: Recovery
+
+ /// The open interface can be the pre-repair device; matching again picks up the live one.
+ /// Endpoints keep retrying on their own; this only re-opens an interface a few times.
+ private func scheduleRecovery(of entry: Entry) {
+ let key = entry.interface.identityKey
+ let attempts = recoveryAttempts[key, default: 0]
+ guard recoveryTasks[key] == nil, attempts < Self.maxRecoveries else { return }
+ recoveryAttempts[key] = attempts + 1
+ let delay = UInt64(attempts + 1) * 700_000_000
+ append(String(localized: "HID++ did not answer; looking for the device again"), link: entry.link.link)
+ recoveryTasks[key] = Task { @MainActor [weak self] in
+ try? await Task.sleep(nanoseconds: delay)
+ guard let self, !Task.isCancelled else { return }
+ self.recoveryTasks[key] = nil
+ self.recover(key)
+ }
+ }
+
+ private func recover(_ key: String) {
+ // Gone: the source reports it again when it comes back.
+ guard let entry = orderedEntries.first(where: { $0.interface.identityKey == key }) else { return }
+ guard !entry.link.endpoints.values.contains(where: { Self.isConnected($0.state) }) else { return }
+ let othersPresent = entries.values.contains { other in
+ other.link !== entry.link && other.link.endpoints.values.contains { Self.isPresent($0.state) }
+ }
+ if othersPresent {
+ // Other mice are working: re-open only this interface.
+ let interface = entry.interface
+ detach(interface.id, removed: false)
+ attach(interface)
+ } else {
+ restartSource()
+ }
+ }
+
+ // MARK: Published state
+
+ private func refreshPublished() {
+ let newRecords = registry.records
+ if newRecords != records { records = newRecords }
+ let newSummary = registry.summary
+ if newSummary != summary { summary = newSummary }
+ refreshStatus()
+ refreshDesiredDPI()
+ }
+
+ private func refreshBlocked() {
+ var names = blocked.values.sorted()
+ if watchingDenied && names.isEmpty {
+ names = [String(localized: "Logitech HID devices")]
+ }
+ if names != blockedInterfaces { blockedInterfaces = names }
+ refreshStatus()
+ }
+
+ private func refreshStatus() {
+ let new = computeStatus()
+ if new != status { status = new }
+ }
+
+ private func computeStatus() -> Status {
+ if let primary = records.first(where: { $0.role == .active && $0.isConnected }), let snapshot = primary.snapshot {
+ return .connected(snapshot)
+ }
+ var best: (rank: Int, status: Status)?
+ func consider(_ rank: Int, _ status: Status) {
+ if rank > (best?.rank ?? 0) { best = (rank, status) }
+ }
+ for record in records where record.role != .unsupported {
+ switch record.state {
+ case .connected(let snapshot): consider(5, .connected(snapshot))
+ case .asleep(let snapshot): consider(4, .asleep(snapshot))
+ case .probing: consider(3, .probing(record.connection))
+ case .waitingForDevice: consider(2, .waiting(record.connection))
+ case .failed(let message): consider(1, .failed(message))
+ case .unsupported: break
}
}
- guard let best = entries.values.max(by: { rank($0.controller.state) < rank($1.controller.state) }) else {
- status = accessDenied ? .accessDenied : .searching
- return
+ // A receiver that serves no mouse (yet): still discovering, or waiting for one to wake.
+ for entry in orderedEntries where !entry.link.isClosed {
+ let served = entry.link.endpoints.values.contains { !$0.isUnsupported }
+ let isReceiver = { if case .receiver = entry.link.kind { return true } else { return false } }()
+ guard !served, isReceiver || entry.link.endpoints.isEmpty else { continue }
+ let connection = entry.link.kind.connection
+ if entry.link.isDiscovering {
+ consider(3, .probing(connection))
+ } else {
+ consider(2, .waiting(connection))
+ }
}
- switch best.controller.state {
- case .connected(let snapshot): status = .connected(snapshot)
- case .asleep(let snapshot): status = .asleep(snapshot)
- case .probing: status = .probing(best.kind.connection)
- case .waitingForDevice: status = .waiting(best.kind.connection)
- case .failed(let message): status = .failed(message)
+ if let best { return best.status }
+ return needsInputMonitoring ? .accessDenied : .searching
+ }
+
+ private static func isConnected(_ state: EndpointController.State) -> Bool {
+ if case .connected = state { return true }
+ return false
+ }
+
+ private static func isPresent(_ state: EndpointController.State) -> Bool {
+ switch state {
+ case .connected, .asleep: return true
+ default: return false
}
}
- private func append(_ text: String, isTraffic: Bool = false) {
+ // MARK: Log
+
+ private func append(_ text: String, isTraffic: Bool = false, link: UInt32? = nil, address: DeviceAddress? = nil) {
+ let number = link ?? address?.link
+ let label: String?
+ if let address, address != .unassigned {
+ label = deviceLabel(address)
+ } else if let number {
+ label = linkLabel(number)
+ } else {
+ label = nil
+ }
nextLogID += 1
- log.append(LogEntry(id: nextLogID, date: Date(), text: text, isTraffic: isTraffic))
+ log.append(LogEntry(
+ id: nextLogID,
+ date: Date(),
+ text: label.map { "\($0): \(text)" } ?? text,
+ isTraffic: isTraffic,
+ address: address == .unassigned ? nil : address,
+ link: number
+ ))
if log.count > Self.maxLogEntries {
log.removeFirst(log.count - Self.maxLogEntries)
}
}
+
+ private func entry(forLink number: UInt32) -> Entry? {
+ linkIDs[number].flatMap { entries[$0] }
+ }
+
+ /// "MX Master 3S · Bolt slot 2", "MX Vertical · Bluetooth".
+ private func deviceLabel(_ a: DeviceAddress) -> String {
+ let entry = entry(forLink: a.link)
+ let record = registry.record(a)
+ let name: String
+ if let record, record.deviceID != nil || record.model != nil {
+ // Nickname or catalog name, numbered when two mice share it.
+ name = record.displayName
+ } else if let endpoint = entry?.link.endpoints[a.index] {
+ // Not in the registry yet (identifying): what the device said, else its product ID.
+ let identified = endpoint.snapshot.map { snapshot in
+ snapshot.identity.flatMap { DeviceCatalog.model($0.model)?.name } ?? snapshot.name
+ }
+ let pid = endpoint.transportPID ?? (a.isDirect ? entry?.interface.productID : nil)
+ name = identified
+ ?? pid.flatMap { DeviceCatalog.model(forPID: $0)?.name }
+ ?? (a.isDirect ? entry?.interface.name : nil)
+ ?? String(localized: "Logitech device")
+ } else {
+ name = String(localized: "Logitech device")
+ }
+ guard let connection = entry?.interface.kind.connection ?? record?.connection else {
+ return name
+ }
+ let place = a.isDirect
+ ? connection.shortTitle
+ : String(localized: "\(connection.shortTitle) slot \(Int(a.index))", comment: "Where a mouse is connected, e.g. Bolt slot 2")
+ return "\(name) · \(place)"
+ }
+
+ /// "Bolt receiver" for a receiver; a direct link is labelled by its mouse.
+ private func linkLabel(_ number: UInt32) -> String? {
+ guard let entry = entry(forLink: number) else { return nil }
+ switch entry.link.kind {
+ case .receiver(let connection): return connection.title
+ case .direct: return deviceLabel(DeviceAddress(link: number, index: DeviceAddress.direct))
+ }
+ }
+
+ // MARK: Compatibility (single-mouse UI, until the Wave 5 views)
+
+ /// The primary mouse's status, or what the interfaces are doing while none is connected.
+ @Published private(set) var status: Status = .searching
+ /// The primary mouse's kept pointer speed (nil: the mouse decides).
+ @Published private(set) var desiredDPI: Int?
+
+ var snapshot: DeviceSnapshot? {
+ switch status {
+ case .connected(let snapshot), .asleep(let snapshot): return snapshot
+ default: return nil
+ }
+ }
+
+ var isConnected: Bool {
+ if case .connected = status { return true }
+ return false
+ }
+
+ /// The primary mouse's controls, or the MX Vertical's known buttons when none was seen yet.
+ var controls: [ControlDescriptor] {
+ let primary = primaryAddress.flatMap(registry.record)
+ return controls(for: primary?.model ?? .mxVertical, address: primary?.address)
+ }
+
+ /// Sets the primary mouse's pointer speed. Reported through `onDeviceDPIChange` so the
+ /// owner keeps it as that mouse's preference.
+ func setDPI(_ value: Int) {
+ setDesiredDPI(value)
+ }
+
+ /// Sets or clears the primary mouse's kept pointer speed (see `setDPI(_:)`).
+ func setDesiredDPI(_ value: Int?) {
+ guard let address = primaryAddress else {
+ append(String(localized: "Pointer Speed: no mouse is connected"))
+ return
+ }
+ setDPI(value, on: address)
+ onDeviceDPIChange?(address, endpoint(at: address)?.desiredDPI ?? value)
+ }
+
+ /// Easy-Switch the primary mouse to a zero-based channel.
+ func switchToHost(_ index: Int) {
+ switchToHost(index, on: primaryAddress ?? .unassigned)
+ }
+
+ private func endpoint(at a: DeviceAddress) -> EndpointController? {
+ link(of: a)?.endpoints[a.index]
+ }
+
+ private func refreshDesiredDPI() {
+ let value = primaryAddress.flatMap { endpoint(at: $0)?.desiredDPI }
+ if value != desiredDPI { desiredDPI = value }
+ }
+}
+
+extension ConnectionKind {
+ /// "Bolt", "USB": the link part of a device label.
+ fileprivate var shortTitle: String {
+ switch self {
+ case .usb: return String(localized: "USB", comment: "Short connection name in a device label")
+ case .bluetooth: return String(localized: "Bluetooth", comment: "Short connection name in a device label")
+ case .unifying: return "Unifying"
+ case .bolt: return "Bolt"
+ case .nano: return "Nano"
+ }
+ }
}
extension DeviceManager.Status {
var title: String {
switch self {
case .connected(let snapshot), .asleep(let snapshot): return snapshot.name
- default: return MXVertical.displayName
+ default: return String(localized: "Logitech mouse")
}
}
@@ -405,7 +753,7 @@ extension DeviceManager.Status {
case .probing(let kind): return String(localized: "Connecting via \(kind.title)…")
case .waiting(let kind):
switch kind {
- case .unifying, .bolt: return String(localized: "Receiver found — click a mouse button to wake it")
+ case .unifying, .bolt, .nano: return String(localized: "Receiver found — click a mouse button to wake it")
case .usb, .bluetooth: return String(localized: "Mouse found — click a button to wake it")
}
case .connected(let snapshot): return String(localized: "Connected via \(snapshot.connection.title)")
diff --git a/Upright/App/LinkSource.swift b/Upright/App/LinkSource.swift
new file mode 100644
index 0000000..fd7d532
--- /dev/null
+++ b/Upright/App/LinkSource.swift
@@ -0,0 +1,257 @@
+import Foundation
+import IOKit.hid
+
+/// One HID interface a `LinkSource` found: a receiver or a directly connected mouse that
+/// `DeviceManager` may open as an `HIDPPLink`.
+struct LinkInterface: Identifiable {
+ /// Stable for as long as the source reports the interface present.
+ let id: AnyHashable
+ let descriptor: HIDInterfaceDescriptor
+ /// What the interface is: decided by the source (the IOKit source asks `InterfaceClassifier`).
+ let kind: TransportKind
+
+ /// The interface's product name, or a generic one.
+ var name: String {
+ let name = descriptor.productName.trimmingCharacters(in: .whitespacesAndNewlines)
+ return name.isEmpty ? String(localized: "Logitech device") : name
+ }
+
+ var productID: UInt16? { UInt16(exactly: descriptor.productID) }
+
+ /// Survives re-matching the same interface (recovery counters are keyed by it).
+ var identityKey: String { InterfaceClassifier.identityKey(descriptor) }
+}
+
+/// Why a source couldn't start watching, or an interface couldn't be opened.
+enum LinkSourceError: Error {
+ /// macOS refused access (Input Monitoring). `message` is user-facing.
+ case permissionDenied(String)
+ case failed(String)
+
+ var message: String {
+ switch self {
+ case .permissionDenied(let message), .failed(let message): return message
+ }
+ }
+
+ var isPermissionDenied: Bool {
+ if case .permissionDenied = self { return true }
+ return false
+ }
+}
+
+/// Where `DeviceManager` gets its HID interfaces from (amendment A2). The IOKit source is the
+/// default; Demo Mode uses `SimulatedLinkSource`, which never touches IOKit.
+///
+/// Callbacks run on the main actor. `onMatch` may be called again for an interface that was
+/// already reported (after `stop()`/`start()`); `DeviceManager` ignores repeats.
+@MainActor
+protocol LinkSource: AnyObject {
+ var onMatch: ((LinkInterface) -> Void)? { get set }
+ /// The interface with this ID went away (its transport is already dead).
+ var onRemove: ((AnyHashable) -> Void)? { get set }
+ /// An interface the source won't offer, with the reason. Once per interface.
+ var onIgnored: ((HIDInterfaceDescriptor, String) -> Void)? { get set }
+
+ /// The first activity-log line, e.g. "Watching for Logitech mice…".
+ var watchingDescription: String { get }
+
+ /// Starts watching. Matches may arrive during the call or later. Throws when watching as a
+ /// whole was refused; interfaces found anyway are still reported.
+ func start() throws
+ func stop()
+
+ /// Opens an interface for HID++. Throws `LinkSourceError`.
+ func open(_ interface: LinkInterface) throws -> HIDPPTransport
+ /// Balances `open`. `removed`: the interface is already gone, so only clean up.
+ func close(_ interface: LinkInterface, transport: HIDPPTransport, removed: Bool)
+
+ /// Shows the Input Monitoring prompt if the user hasn't decided yet; returns whether access
+ /// is granted. Sources that need no permission return true without asking.
+ func requestInputMonitoringAccess() -> Bool
+}
+
+// MARK: - IOKit
+
+/// Logitech HID interfaces from IOKit (`HIDDeviceMonitor`). Nothing touches IOKit until
+/// `start()`, so creating one is free.
+@MainActor
+final class IOKitLinkSource: LinkSource {
+ var onMatch: ((LinkInterface) -> Void)?
+ var onRemove: ((AnyHashable) -> Void)?
+ var onIgnored: ((HIDInterfaceDescriptor, String) -> Void)?
+
+ private var monitor: HIDDeviceMonitor?
+ /// Delivered devices by interface ID, until removed.
+ private var devices: [AnyHashable: IOHIDDevice] = [:]
+
+ var watchingDescription: String {
+ String(localized: "Watching for Logitech mice over USB, Unifying, Bolt and Nano receivers, and Bluetooth")
+ }
+
+ func start() throws {
+ let monitor = self.monitor ?? HIDDeviceMonitor()
+ self.monitor = monitor
+ monitor.onMatch = { [weak self] device in
+ MainActorDelivery.run { self?.deliver(device) }
+ }
+ monitor.onRemove = { [weak self] device in
+ MainActorDelivery.run {
+ guard let self else { return }
+ let id = AnyHashable(device)
+ self.devices[id] = nil
+ self.onRemove?(id)
+ }
+ }
+ monitor.onIgnored = { [weak self] info, reason in
+ MainActorDelivery.run { self?.onIgnored?(info.descriptor, reason) }
+ }
+ // No Input Monitoring prompt here: watching opens nothing, and only the interfaces the
+ // classifier accepts are opened (in `open(_:)`), each reporting its own permission
+ // failure. The UI offers `requestInputMonitoringAccess()` when access is refused.
+ monitor.start()
+ }
+
+ func stop() {
+ monitor?.stop()
+ devices.removeAll()
+ }
+
+ func open(_ interface: LinkInterface) throws -> HIDPPTransport {
+ guard let device = devices[interface.id] else {
+ throw LinkSourceError.failed(String(localized: "Unknown HID interface"))
+ }
+ let transport = IOKitHIDTransport(device: device)
+ do {
+ try transport.open()
+ } catch let error as IOKitHIDError {
+ throw error.isPermissionDenied ? LinkSourceError.permissionDenied(error.description) : LinkSourceError.failed(error.description)
+ }
+ return transport
+ }
+
+ func close(_ interface: LinkInterface, transport: HIDPPTransport, removed: Bool) {
+ guard let transport = transport as? IOKitHIDTransport else { return }
+ if removed {
+ transport.deviceRemoved()
+ } else {
+ transport.close()
+ }
+ }
+
+ func requestInputMonitoringAccess() -> Bool {
+ HIDDeviceMonitor.requestInputMonitoringAccess()
+ }
+
+ private func deliver(_ device: IOHIDDevice) {
+ let descriptor = HIDInterfaceDescriptor(device: device)
+ guard let kind = InterfaceClassifier.classify(descriptor).transportKind else { return }
+ let id = AnyHashable(device)
+ devices[id] = device
+ onMatch?(LinkInterface(id: id, descriptor: descriptor, kind: kind))
+ }
+}
+
+// MARK: - Simulation
+
+/// Simulated receivers and mice from `K/Simulation`, for Demo Mode. Never touches IOKit, asks
+/// for no permission, and opening an interface always succeeds.
+///
+/// The transports stay alive for the whole process: closing an interface keeps its simulated
+/// devices (and their state), so Reconnect reopens the same ones. `transports` lets Demo Mode
+/// drive the devices (battery levels, button presses, sleep).
+@MainActor
+final class SimulatedLinkSource: LinkSource {
+ struct Interface {
+ let transport: SimulatedHIDPPTransport
+ let descriptor: HIDInterfaceDescriptor
+ }
+
+ var onMatch: ((LinkInterface) -> Void)?
+ var onRemove: ((AnyHashable) -> Void)?
+ var onIgnored: ((HIDInterfaceDescriptor, String) -> Void)?
+
+ /// In delivery order: the order the devices first appear in.
+ let interfaces: [Interface]
+ private var isRunning = false
+ private var generation = 0
+
+ init(_ interfaces: [Interface]) {
+ self.interfaces = interfaces
+ }
+
+ /// The Demo Mode fleet: an MX Vertical over Bluetooth, then an MX Master 3S and a Lift on a
+ /// Bolt receiver (slots 1 and 2). A direct link starts its mouse right away and a receiver
+ /// lists its slots in order, so the devices keep this order in every run.
+ static func demo() -> SimulatedLinkSource {
+ SimulatedLinkSource([
+ .direct(SimulatedDirect(profile: DeviceProfiles.mxVertical, connection: .bluetooth), registryID: 0xDE30_0001),
+ .receiver(SimulatedReceiver.bolt([1: DeviceProfiles.mxMaster3S, 2: DeviceProfiles.lift]), registryID: 0xDE30_0002),
+ ])
+ }
+
+ /// All simulated transports, in delivery order.
+ var transports: [SimulatedHIDPPTransport] { interfaces.map(\.transport) }
+
+ var watchingDescription: String {
+ String(localized: "Demo Mode: showing simulated mice; no real device is opened")
+ }
+
+ func start() throws {
+ guard !isRunning else { return }
+ isRunning = true
+ generation += 1
+ let generation = generation
+ // Delivered on the next turn, like IOKit's matching callbacks, so late replies to a
+ // previous link's last requests don't reach the new one.
+ Task { @MainActor [weak self] in
+ guard let self, self.isRunning, self.generation == generation else { return }
+ for (offset, interface) in self.interfaces.enumerated() where !interface.transport.isDisconnected {
+ self.onMatch?(LinkInterface(id: AnyHashable(offset), descriptor: interface.descriptor, kind: interface.transport.transportKind))
+ }
+ }
+ }
+
+ func stop() {
+ isRunning = false
+ }
+
+ func open(_ interface: LinkInterface) throws -> HIDPPTransport {
+ guard let offset = interface.id.base as? Int, interfaces.indices.contains(offset) else {
+ throw LinkSourceError.failed(String(localized: "Unknown HID interface"))
+ }
+ return interfaces[offset].transport
+ }
+
+ func close(_ interface: LinkInterface, transport: HIDPPTransport, removed: Bool) {}
+
+ func requestInputMonitoringAccess() -> Bool { true }
+}
+
+extension SimulatedLinkSource.Interface {
+ static func receiver(_ receiver: SimulatedReceiver, registryID: UInt64) -> Self {
+ Self(transport: receiver, descriptor: HIDInterfaceDescriptor(
+ vendorID: DeviceCatalog.vendorID,
+ productID: Int(receiver.productID),
+ transport: "USB",
+ usagePage: 0xFF00,
+ usage: 0x01,
+ registryID: registryID,
+ productName: DeviceCatalog.receivers[receiver.productID]?.name ?? receiver.connection.title
+ ))
+ }
+
+ static func direct(_ direct: SimulatedDirect, registryID: UInt64) -> Self {
+ let bluetooth = direct.connection == .bluetooth
+ return Self(transport: direct, descriptor: HIDInterfaceDescriptor(
+ vendorID: DeviceCatalog.vendorID,
+ productID: Int(direct.productID ?? 0),
+ transport: bluetooth ? "Bluetooth Low Energy" : "USB",
+ usagePage: bluetooth ? 0x01 : 0xFF00,
+ usage: bluetooth ? 0x02 : 0x01,
+ serial: direct.profile.serialNumber ?? "",
+ registryID: registryID,
+ productName: direct.profile.name
+ ))
+ }
+}
diff --git a/Upright/App/LoginItemController.swift b/Upright/App/LoginItemController.swift
index ab946da..f941ab6 100644
--- a/Upright/App/LoginItemController.swift
+++ b/Upright/App/LoginItemController.swift
@@ -2,18 +2,27 @@ import AppKit
import ServiceManagement
/// "Open at Login" via SMAppService (macOS 13+). No helper app or launch agent needed.
+///
+/// In Demo Mode (`AppEnvironment.managesLoginItem == false`) it never touches SMAppService: the
+/// toggle flips a local, in-memory status so the UI still responds, and the user's real login
+/// items are neither read nor changed (DEMO-MODE-SPEC: "no login item changes").
@MainActor
final class LoginItemController: ObservableObject {
- @Published private(set) var status: SMAppService.Status = SMAppService.mainApp.status
+ @Published private(set) var status: SMAppService.Status
@Published private(set) var errorMessage: String?
+ /// False in Demo Mode: SMAppService is never called.
+ let isManaged: Bool
+
/// Login items registered from outside /Applications (a build folder, Downloads, a translocated
/// copy) break when that copy moves or is deleted, so the UI should warn.
let isRunningFromApplicationsFolder: Bool
private var observers: [NSObjectProtocol] = []
- init(bundleURL: URL = Bundle.main.bundleURL) {
+ init(environment: AppEnvironment, bundleURL: URL = Bundle.main.bundleURL) {
+ isManaged = environment.managesLoginItem
+ status = environment.managesLoginItem ? SMAppService.mainApp.status : .notRegistered
isRunningFromApplicationsFolder = Self.isInApplicationsFolder(bundleURL)
// The user can change this in System Settings while Upright is running.
observers.append(NotificationCenter.default.addObserver(
@@ -28,6 +37,11 @@ final class LoginItemController: ObservableObject {
func setEnabled(_ enabled: Bool) {
errorMessage = nil
+ guard isManaged else {
+ // Demo Mode: pretend, so the toggle animates, but leave the real login item alone.
+ status = enabled ? .enabled : .notRegistered
+ return
+ }
do {
if enabled {
try SMAppService.mainApp.register()
@@ -41,11 +55,13 @@ final class LoginItemController: ObservableObject {
}
func refresh() {
+ guard isManaged else { return }
let current = SMAppService.mainApp.status
if current != status { status = current }
}
func openSystemSettings() {
+ guard isManaged else { return }
SMAppService.openSystemSettingsLoginItems()
}
diff --git a/Upright/App/PermissionsMonitor.swift b/Upright/App/PermissionsMonitor.swift
index b8d920d..094db1e 100644
--- a/Upright/App/PermissionsMonitor.swift
+++ b/Upright/App/PermissionsMonitor.swift
@@ -8,6 +8,9 @@ import os
/// - Accessibility: required to post the keyboard shortcuts, media keys and clicks that
/// buttons are mapped to.
/// - Input Monitoring: only needed if macOS refuses to open the mouse's HID++ interface.
+///
+/// In Demo Mode (`AppEnvironment.demo`) both read as granted, nothing is polled and nothing is
+/// ever asked: no system prompt, no System Settings, no `tccutil`, no IOKit access check.
@MainActor
final class PermissionsMonitor: ObservableObject {
enum InputMonitoringStatus: Equatable {
@@ -16,8 +19,8 @@ final class PermissionsMonitor: ObservableObject {
case notDetermined
}
- @Published private(set) var isAccessibilityTrusted = AXIsProcessTrusted()
- @Published private(set) var inputMonitoring: InputMonitoringStatus = .notDetermined
+ @Published private(set) var isAccessibilityTrusted: Bool
+ @Published private(set) var inputMonitoring: InputMonitoringStatus
/// Accessibility was granted on an earlier run but isn't now. Usually the app was rebuilt or
/// moved and macOS still lists the old copy as allowed; resetting the entry fixes it.
@Published private(set) var accessibilityGrantIsStale = false
@@ -34,20 +37,29 @@ final class PermissionsMonitor: ObservableObject {
private static let pollWhenSettled: TimeInterval = 5
private let defaults: UserDefaults
+ private let environment: AppEnvironment
private let launcher = ProcessLauncher()
private let logger = Logger(subsystem: "Upright", category: "Permissions")
private var timer: Timer?
private var observers: [NSObjectProtocol] = []
- init(defaults: UserDefaults = .standard) {
+ init(defaults: UserDefaults = .standard, environment: AppEnvironment = .live) {
self.defaults = defaults
- refresh()
+ self.environment = environment
+ if environment.checksPermissions {
+ isAccessibilityTrusted = AXIsProcessTrusted()
+ inputMonitoring = .notDetermined
+ refresh()
+ } else {
+ isAccessibilityTrusted = true
+ inputMonitoring = .granted
+ }
}
/// Starts watching for changes. There is no public change notification for these
/// permissions, so this polls: quickly while something is missing, gently otherwise.
func start() {
- guard observers.isEmpty else { return }
+ guard environment.checksPermissions, observers.isEmpty else { return }
observers.append(NotificationCenter.default.addObserver(
forName: NSApplication.didBecomeActiveNotification, object: nil, queue: .main
) { [weak self] _ in
@@ -58,6 +70,7 @@ final class PermissionsMonitor: ObservableObject {
}
func refresh() {
+ guard environment.checksPermissions else { return }
let trusted = AXIsProcessTrusted()
if trusted != isAccessibilityTrusted { isAccessibilityTrusted = trusted }
if trusted, !defaults.bool(forKey: Self.wasTrustedKey) {
@@ -105,6 +118,7 @@ final class PermissionsMonitor: ObservableObject {
/// Shows the system prompt the first time; after that macOS ignores the prompt request,
/// so this opens the Accessibility list instead. Never both at once.
func requestAccessibility() {
+ guard environment.checksPermissions else { return }
if !defaults.bool(forKey: Self.promptShownKey) {
promptForAccessibility()
} else if !AXIsProcessTrusted() {
@@ -114,12 +128,13 @@ final class PermissionsMonitor: ObservableObject {
}
func openAccessibilitySettings() {
+ guard environment.checksPermissions else { return }
open("x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility")
}
/// Removes Upright's (possibly stale) Accessibility entry, then asks for access again.
func resetAccessibilityPermission() {
- guard !isResettingAccessibility, let bundleID = Bundle.main.bundleIdentifier else { return }
+ guard environment.checksPermissions, !isResettingAccessibility, let bundleID = Bundle.main.bundleIdentifier else { return }
isResettingAccessibility = true
launcher.run("/usr/bin/tccutil", arguments: ["reset", "Accessibility", bundleID]) { [weak self] status in
guard let self else { return }
@@ -144,6 +159,7 @@ final class PermissionsMonitor: ObservableObject {
/// Shows the system prompt while access is undecided; otherwise opens the Input Monitoring list.
func requestInputMonitoring() {
+ guard environment.checksPermissions else { return }
refresh()
if inputMonitoring == .notDetermined {
HIDDeviceMonitor.requestInputMonitoringAccess()
@@ -154,6 +170,7 @@ final class PermissionsMonitor: ObservableObject {
}
func openInputMonitoringSettings() {
+ guard environment.checksPermissions else { return }
open("x-apple.systempreferences:com.apple.preference.security?Privacy_ListenEvent")
}
diff --git a/Upright/App/ScrollController.swift b/Upright/App/ScrollController.swift
index 672cab0..76e6b9f 100644
--- a/Upright/App/ScrollController.swift
+++ b/Upright/App/ScrollController.swift
@@ -6,8 +6,9 @@ import os
/// Applies `ScrollSettings` to mouse-wheel events through an active session event tap.
///
/// The tap exists only while it has something to do: a setting changes scrolling, Accessibility
-/// is granted (active taps need it), and an MX Vertical is connected unless the user opted out of
-/// that restriction. Trackpad and Magic Mouse (continuous) scrolling always passes through.
+/// is granted (active taps need it), and a supported mouse is connected or asleep unless the user
+/// opted out of that restriction. The tap can't tell mice apart, so it rewrites the wheel of every
+/// mouse, supported or not. Trackpad and Magic Mouse (continuous) scrolling always passes through.
@MainActor
final class ScrollController: ObservableObject {
/// True while the event tap is installed and rewriting wheel events.
@@ -17,10 +18,11 @@ final class ScrollController: ObservableObject {
private var loggedFailure = false
private let logger = Logger(subsystem: "Upright", category: "Scrolling")
- func update(settings: ScrollSettings, accessibilityTrusted: Bool, mxVerticalConnected: Bool) {
+ /// `mousePresent` is `FleetSummary.anyPresent`: any supported mouse that is connected or asleep.
+ func update(settings: ScrollSettings, accessibilityTrusted: Bool, mousePresent: Bool) {
let wanted = settings.transformsEvents
&& accessibilityTrusted
- && (mxVerticalConnected || !settings.onlyWhileMXVerticalConnected)
+ && (mousePresent || !settings.onlyWhileMouseConnected)
guard wanted else {
stop()
return
@@ -43,6 +45,15 @@ final class ScrollController: ObservableObject {
logger.info("Scroll event tap installed")
}
+ func update(settings: ScrollSettings, accessibilityTrusted: Bool, summary: FleetSummary) {
+ update(settings: settings, accessibilityTrusted: accessibilityTrusted, mousePresent: summary.anyPresent)
+ }
+
+ /// Compatibility for the single-mouse caller in `AppModel` until it passes the fleet summary.
+ func update(settings: ScrollSettings, accessibilityTrusted: Bool, mxVerticalConnected: Bool) {
+ update(settings: settings, accessibilityTrusted: accessibilityTrusted, mousePresent: mxVerticalConnected)
+ }
+
func stop() {
guard let tap else { return }
tap.stop()
diff --git a/Upright/App/SnapshotExporter.swift b/Upright/App/SnapshotExporter.swift
new file mode 100644
index 0000000..c613e76
--- /dev/null
+++ b/Upright/App/SnapshotExporter.swift
@@ -0,0 +1,1093 @@
+import AppKit
+import SwiftUI
+
+/// `Upright --demo --export-snapshots `: renders the real UI offscreen, driven by the
+/// simulated fleet (`DemoMode.Fleet`) with the demo setup (`DemoMode.Scenario`), and writes
+/// `..png` plus `manifest.json` (sizes and `snapshotAnchor` rects, in points with
+/// a top-left origin) to a directory. See docs/dev/DEMO-MODE-SPEC.md.
+///
+/// - Settings states use the settings window's own view tree in a window of its default size
+/// (780 × 560 points) that is never shown; the Mice pane's states are taller so the mouse list,
+/// pointer speed and presets fit in one image.
+/// - The menu bar panel is rendered at its natural size; the menu bar label with ImageRenderer at 6x.
+/// - Nothing is put on screen, no input event is posted, IOKit is never used and the user's
+/// defaults domain is never read or written (Demo Mode's own suite holds the demo setup).
+/// - Offscreen, AppKit draws an inactive app's windows and can't render materials that blend
+/// with the desktop (Liquid Glass, vibrancy). This process therefore reports itself active
+/// (`ActiveAppearance`), and `capture` stands in for the glass sidebar. The frontmost app the
+/// profile UI mentions is a standard Apple app announced in-process (`DemoMode.pretendFrontmost`).
+///
+/// Development: `UPRIGHT_SNAPSHOT_ONLY=` renders only matching states;
+/// `UPRIGHT_SNAPSHOT_DEBUG=1` logs each settings window's view hierarchy.
+@MainActor
+enum SnapshotExporter {
+ enum ExportError: Error, CustomStringConvertible {
+ case miceDidNotConnect(String)
+ case missingStates([String])
+ case writeFailed(String)
+
+ var description: String {
+ switch self {
+ case .miceDidNotConnect(let detail): return "The simulated mice did not connect: \(detail)"
+ case .missingStates(let ids): return "These essential states could not be rendered: \(ids.joined(separator: ", "))"
+ case .writeFailed(let detail): return "Could not write the snapshots: \(detail)"
+ }
+ }
+ }
+
+ /// The settings window's default content size (`SettingsWindowController`).
+ static let settingsSize = CGSize(width: 780, height: 560)
+ static let defaultScale: CGFloat = 2
+ static let labelScale: CGFloat = 6
+
+ /// `model` is the app's own Demo Mode model; the export builds a second one around its own
+ /// simulated fleet so it can set battery levels and other device state, and leaves `model`
+ /// alone.
+ static func run(model: AppModel, to directory: URL) async throws {
+ do {
+ try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
+ } catch {
+ throw ExportError.writeFailed("\(directory.path): \(error.localizedDescription)")
+ }
+ let session = try await Session.start()
+ defer { session.stop() }
+
+ var entries: [ManifestEntry] = []
+ var failures: [String] = []
+ // UPRIGHT_SNAPSHOT_ONLY=: only the states whose id starts with it (for development).
+ let only = ProcessInfo.processInfo.environment["UPRIGHT_SNAPSHOT_ONLY"]
+ for state in States.all where only.map(state.id.hasPrefix) ?? true {
+ for appearance in Appearance.allCases {
+ do {
+ let image = try await session.render(state, appearance: appearance)
+ let file = "\(state.id).\(appearance.rawValue).png"
+ try write(image.png, to: directory.appendingPathComponent(file))
+ entries.append(ManifestEntry(
+ id: state.id,
+ appearance: appearance.rawValue,
+ file: file,
+ scale: image.scale,
+ size: .init(w: image.size.width, h: image.size.height),
+ rects: image.rects.mapValues(ManifestEntry.Rect.init)
+ ))
+ log("\(file) \(Int(image.size.width))×\(Int(image.size.height)) pt \(image.rects.count) rects")
+ } catch {
+ log("\(state.id).\(appearance.rawValue): \(error)")
+ failures.append(state.id)
+ }
+ }
+ }
+
+ let encoder = JSONEncoder()
+ encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes]
+ do {
+ try write(try encoder.encode(entries), to: directory.appendingPathComponent("manifest.json"))
+ } catch let error as ExportError {
+ throw error
+ } catch {
+ throw ExportError.writeFailed("manifest.json: \(error.localizedDescription)")
+ }
+ log("Wrote \(entries.count) images and manifest.json to \(directory.path)")
+
+ let missing = Set(failures).intersection(States.all.filter(\.isEssential).map(\.id))
+ if !missing.isEmpty { throw ExportError.missingStates(missing.sorted()) }
+ }
+
+ private static func write(_ data: Data, to url: URL) throws {
+ do {
+ try data.write(to: url, options: .atomic)
+ } catch {
+ throw ExportError.writeFailed("\(url.lastPathComponent): \(error.localizedDescription)")
+ }
+ }
+
+ fileprivate static func log(_ text: String) {
+ FileHandle.standardError.write(Data("[snapshots] \(text)\n".utf8))
+ }
+}
+
+// MARK: - Manifest
+
+extension SnapshotExporter {
+ struct ManifestEntry: Encodable {
+ struct Size: Encodable {
+ let w: Double
+ let h: Double
+ }
+
+ struct Rect: Encodable {
+ let x: Double
+ let y: Double
+ let w: Double
+ let h: Double
+
+ init(_ rect: CGRect) {
+ func rounded(_ value: CGFloat) -> Double { (Double(value) * 100).rounded() / 100 }
+ x = rounded(rect.minX)
+ y = rounded(rect.minY)
+ w = rounded(rect.width)
+ h = rounded(rect.height)
+ }
+ }
+
+ let id: String
+ let appearance: String
+ let file: String
+ let scale: Double
+ let size: Size
+ let rects: [String: Rect]
+ }
+
+ enum Appearance: String, CaseIterable {
+ case light
+ case dark
+
+ var nsAppearance: NSAppearance {
+ NSAppearance(named: self == .light ? .aqua : .darkAqua)!
+ }
+
+ var colorScheme: ColorScheme { self == .light ? .light : .dark }
+ }
+
+ /// One rendered image: PNG data, its size in points, and the named rects in it.
+ struct RenderedImage {
+ let png: Data
+ let size: CGSize
+ let scale: Double
+ let rects: [String: CGRect]
+ }
+}
+
+// MARK: - States
+
+extension SnapshotExporter {
+ /// One UI state to capture.
+ struct State {
+ enum Surface {
+ /// The settings window on a pane.
+ case settings(SettingsPane)
+ /// The menu bar panel.
+ case menuPanel
+ /// The status item's label.
+ case menuLabel
+ }
+
+ /// Where the settings pane's form scrolls to before the capture.
+ enum Scroll {
+ case top
+ /// The anchor with this name, near the top of the pane.
+ case anchor(String)
+ case bottom
+ }
+
+ let id: String
+ let isEssential: Bool
+ let surface: Surface
+ var scroll: Scroll = .top
+ /// The settings window's content size; nil for the default.
+ var size: CGSize?
+ /// Ends the window this far below the named anchor (the window is resized after the
+ /// first layout).
+ var fitBelow: (anchor: String, padding: CGFloat)?
+ /// Puts the model and the mice into the state (runs before the view is built).
+ var prepare: @MainActor (Session) async -> Void
+ /// Runs once the view is laid out (e.g. picks a profile in the Buttons pane). Returns
+ /// false when the state couldn't be reached.
+ var interact: (@MainActor (Session, NSView) async -> Bool)?
+ /// Puts back what `prepare` changed that later states don't set themselves.
+ var cleanUp: @MainActor (Session) async -> Void = { _ in }
+
+ /// `prepare` has no default, so a single trailing closure is always `prepare` (with
+ /// defaults on every closure, Swift 5 mode matches it backwards, to `cleanUp`, which then
+ /// ran after the capture).
+ init(
+ id: String,
+ isEssential: Bool,
+ surface: Surface,
+ scroll: Scroll = .top,
+ size: CGSize? = nil,
+ fitBelow: (anchor: String, padding: CGFloat)? = nil,
+ prepare: @escaping @MainActor (Session) async -> Void,
+ interact: (@MainActor (Session, NSView) async -> Bool)? = nil,
+ cleanUp: @escaping @MainActor (Session) async -> Void = { _ in }
+ ) {
+ self.id = id
+ self.isEssential = isEssential
+ self.surface = surface
+ self.scroll = scroll
+ self.size = size
+ self.fitBelow = fitBelow
+ self.prepare = prepare
+ self.interact = interact
+ self.cleanUp = cleanUp
+ }
+ }
+
+ enum States {
+ typealias Mouse = DemoMode.Fleet.Mouse
+ typealias Scenario = DemoMode.Scenario
+
+ /// The Mice pane is taller than the window's default so the list of mice, the pointer
+ /// speed and the presets fit in one image (the window is resizable): laid out tall, then
+ /// cut to end below the presets.
+ static let micePaneSize = CGSize(width: 780, height: 1700)
+ static let micePaneFit: (anchor: String, padding: CGFloat) = ("dpiPresets", 150)
+
+ static var all: [State] {
+ var states: [State] = []
+
+ // Buttons
+ states.append(State(id: "buttons.global", isEssential: true, surface: .settings(.buttons)) { session in
+ session.showButtons(of: .vertical, frontmost: Scenario.finder)
+ })
+ for (index, action) in Scenario.topButtonVariants.enumerated() {
+ states.append(State(id: "buttons.global.\(index + 1)", isEssential: false, surface: .settings(.buttons)) { session in
+ session.showButtons(of: .vertical, frontmost: Scenario.finder)
+ session.model.library[Mouse.vertical.modelKey].global[KnownControl.dpiSwitch] = action
+ } cleanUp: { session in
+ session.model.library = Scenario.library
+ })
+ }
+ states.append(State(id: "buttons.profile", isEssential: true, surface: .settings(.buttons)) { session in
+ session.showButtons(of: .vertical, frontmost: Scenario.safari)
+ session.openProfile(Scenario.safari)
+ } interact: { session, _ in
+ await session.showsProfile(Scenario.safari)
+ } cleanUp: { session in
+ session.openProfile(nil)
+ })
+ states.append(State(id: "buttons.profile.2", isEssential: false, surface: .settings(.buttons)) { session in
+ session.showButtons(of: .vertical, frontmost: Scenario.music)
+ session.openProfile(Scenario.music)
+ } interact: { session, _ in
+ await session.showsProfile(Scenario.music)
+ } cleanUp: { session in
+ session.openProfile(nil)
+ })
+ states.append(State(id: "gesture.editor", isEssential: true, surface: .settings(.buttons), scroll: .anchor("row.00FD")) { session in
+ session.showButtons(of: .vertical, frontmost: Scenario.finder)
+ })
+
+ // Mice
+ states.append(State(id: "device.pointer", isEssential: true, surface: .settings(.device), size: micePaneSize, fitBelow: micePaneFit) { session in
+ await session.showMouse(.vertical, dpi: Scenario.pointerSpeeds[.vertical]!)
+ })
+ for dpi in [800, 1600, 3200] {
+ states.append(State(id: "device.pointer.\(dpi)", isEssential: false, surface: .settings(.device), size: micePaneSize, fitBelow: micePaneFit) { session in
+ await session.showMouse(.vertical, dpi: dpi)
+ } cleanUp: { session in
+ await session.setDPI(Scenario.pointerSpeeds[.vertical]!, of: .vertical)
+ })
+ }
+ states.append(State(id: "device.activity", isEssential: false, surface: .settings(.device), scroll: .bottom) { session in
+ await session.showActivityAfterWake()
+ })
+
+ // Scrolling, General
+ states.append(State(id: "scrolling", isEssential: true, surface: .settings(.scrolling)) { session in
+ session.pretendFrontmost(Scenario.finder)
+ })
+ states.append(State(id: "general.behavior", isEssential: true, surface: .settings(.general), scroll: .anchor("behavior.sleep")) { session in
+ session.pretendFrontmost(Scenario.finder)
+ })
+
+ // Menu bar
+ states.append(State(id: "menubar.panel", isEssential: true, surface: .menuPanel) { session in
+ session.showMenu(frontmost: Scenario.finder)
+ })
+ states.append(State(id: "menubar.panel.lowbattery", isEssential: false, surface: .menuPanel) { session in
+ session.showMenu(frontmost: Scenario.finder)
+ await session.setBattery(Scenario.lowBatteryLevel, of: .lift)
+ } cleanUp: { session in
+ await session.setBattery(Scenario.batteryLevels[.lift]!, of: .lift)
+ })
+ states.append(State(id: "menubar.label", isEssential: false, surface: .menuLabel) { session in
+ session.pretendFrontmost(Scenario.finder)
+ })
+ return states
+ }
+ }
+}
+
+// MARK: - Session
+
+extension SnapshotExporter {
+ /// The demo model, its fleet, and the offscreen rendering.
+ @MainActor
+ final class Session {
+ let fleet: DemoMode.Fleet
+ let model: AppModel
+ /// Live addresses of the three mice.
+ private(set) var addresses: [DemoMode.Fleet.Mouse: DeviceAddress] = [:]
+ /// The login item as an installed copy of Upright sees it (no "move to Applications" warning).
+ private let loginItem = LoginItemController(
+ environment: .demo,
+ bundleURL: URL(fileURLWithPath: "/Applications/Upright.app", isDirectory: true)
+ )
+
+ private init(fleet: DemoMode.Fleet, model: AppModel) {
+ self.fleet = fleet
+ self.model = model
+ }
+
+ static func start() async throws -> Session {
+ ActiveAppearance.enable()
+ let fleet = DemoMode.Fleet()
+ let model = AppModel(environment: .demo, devices: DeviceManager(source: fleet.source))
+ let session = Session(fleet: fleet, model: model)
+ DemoMode.Scenario.apply(to: model)
+ model.start()
+ session.pretendFrontmost(DemoMode.Scenario.finder)
+ try await session.waitForMice()
+ fleet.settle()
+ for mouse in DemoMode.Fleet.Mouse.allCases {
+ await session.setBattery(DemoMode.Scenario.batteryLevels[mouse]!, of: mouse)
+ await session.setDPI(DemoMode.Scenario.pointerSpeeds[mouse]!, of: mouse)
+ }
+ // The app's own wake message, as after a night's sleep.
+ model.devices.systemDidWake()
+ await session.settle(0.5)
+ model.selectedDevice = session.addresses[.vertical]
+ return session
+ }
+
+ func stop() {
+ model.devices.shutdown()
+ }
+
+ // MARK: Device state
+
+ private func waitForMice() async throws {
+ let deadline = Date().addingTimeInterval(20)
+ while Date() < deadline {
+ var found: [DemoMode.Fleet.Mouse: DeviceAddress] = [:]
+ for mouse in DemoMode.Fleet.Mouse.allCases {
+ let record = model.devices.records.first {
+ $0.model == mouse.modelKey && $0.role == .active && $0.isConnected
+ && $0.snapshot?.dpi != nil && $0.snapshot?.battery != nil
+ }
+ if let record { found[mouse] = record.address }
+ }
+ if found.count == DemoMode.Fleet.Mouse.allCases.count {
+ addresses = found
+ return
+ }
+ await settle(0.1)
+ }
+ let seen = model.devices.records.map { "\($0.displayName) (\($0.connection.title), \($0.stateText))" }
+ throw ExportError.miceDidNotConnect(seen.isEmpty ? "no device appeared" : seen.joined(separator: "; "))
+ }
+
+ func setBattery(_ percentage: Int, of mouse: DemoMode.Fleet.Mouse) async {
+ fleet.setBattery(percentage, of: mouse)
+ await waitUntil { [self] in
+ guard let address = addresses[mouse], let battery = model.devices.snapshot(address)?.battery else { return false }
+ return abs(battery.percentage - percentage) <= 10
+ }
+ }
+
+ func setDPI(_ dpi: Int, of mouse: DemoMode.Fleet.Mouse) async {
+ guard let address = addresses[mouse] else { return }
+ model.setPreferredDPI(dpi, for: address)
+ await waitUntil { [self] in model.devices.snapshot(address)?.dpi?.current == dpi }
+ }
+
+ // MARK: UI state
+
+ func pretendFrontmost(_ app: AppReference) {
+ DemoMode.pretendFrontmost(app)
+ }
+
+ func showButtons(of mouse: DemoMode.Fleet.Mouse, frontmost: AppReference) {
+ pretendFrontmost(frontmost)
+ model.selectedPane = .buttons
+ model.selectedDevice = addresses[mouse]
+ model.selectedModel = mouse.modelKey
+ }
+
+ func showMouse(_ mouse: DemoMode.Fleet.Mouse, dpi: Int) async {
+ pretendFrontmost(DemoMode.Scenario.finder)
+ model.selectedPane = .device
+ model.selectedDevice = addresses[mouse]
+ await setDPI(dpi, of: mouse)
+ }
+
+ /// The activity log of a typical morning: the mice connect, then the Mac wakes and
+ /// Upright reapplies every mouse's configuration (the app's own messages).
+ func showActivityAfterWake() async {
+ pretendFrontmost(DemoMode.Scenario.finder)
+ model.selectedPane = .device
+ model.devices.clearLog()
+ model.devices.reconnect(nil)
+ do {
+ try await waitForMice()
+ } catch {
+ SnapshotExporter.log("\(error)")
+ }
+ model.selectedDevice = addresses[.vertical]
+ await settle(0.5)
+ model.devices.systemDidWake()
+ await settle(1)
+ }
+
+ func showMenu(frontmost: AppReference) {
+ pretendFrontmost(frontmost)
+ model.selectedDevice = addresses[.vertical]
+ }
+
+ /// Opens the Buttons pane on an app's profile (`DemoMode.snapshotProfileBundleID`).
+ func openProfile(_ app: AppReference?) {
+ DemoMode.snapshotProfileBundleID = app?.bundleID
+ }
+
+ /// Whether the Buttons pane shows an app profile: its overridden rows are marked.
+ func showsProfile(_ app: AppReference) async -> Bool {
+ for _ in 0..<10 {
+ if SnapshotAnchorCollector.shared.frames.keys.contains(where: { $0.hasPrefix("override.") }) { return true }
+ await settle(0.1)
+ }
+ SnapshotExporter.log("The Buttons pane didn't open on the \(app.displayName) profile: ButtonsPane must seed its profile selection from DemoMode.snapshotProfileBundleID")
+ return false
+ }
+
+ // MARK: Rendering
+
+ func render(_ state: State, appearance: Appearance) async throws -> RenderedImage {
+ SnapshotAnchorCollector.shared.reset()
+ await state.prepare(self)
+ await settle(0.3)
+ do {
+ let image: RenderedImage
+ switch state.surface {
+ case .settings:
+ image = try await renderSettings(state, appearance: appearance)
+ case .menuPanel:
+ image = try await renderMenuPanel(appearance: appearance)
+ case .menuLabel:
+ image = try renderMenuLabel(appearance: appearance)
+ }
+ await state.cleanUp(self)
+ return image
+ } catch {
+ await state.cleanUp(self)
+ throw error
+ }
+ }
+
+ private func renderSettings(_ state: State, appearance: Appearance) async throws -> RenderedImage {
+ let size = state.size ?? SnapshotExporter.settingsSize
+ let window = Self.makeWindow(size: size, appearance: appearance, titled: true)
+ let rects = RectStore()
+ let controller = NSHostingController(rootView: AnyView(
+ settingsContent().modifier(AnchorResolver(store: rects))
+ ))
+ window.contentViewController = controller
+ window.setContentSize(size)
+ ActiveAppearance.announce(window)
+ defer { Self.dismantle(window) }
+
+ if case .settings(let pane) = state.surface { model.selectedPane = pane }
+ await layOut(window)
+ // The sidebar's list can pick its first row while it is set up; choose the pane again.
+ if case .settings(let pane) = state.surface, model.selectedPane != pane {
+ model.selectedPane = pane
+ await layOut(window)
+ }
+ if let fit = state.fitBelow {
+ // Content above the anchor can still grow as the mouse's details arrive: repeat
+ // until the window ends where it should.
+ for _ in 0..<4 {
+ guard let anchor = SnapshotAnchorCollector.shared.frames[fit.anchor] else {
+ throw RenderError.stateNotReached("\(state.id): no \(fit.anchor) on screen")
+ }
+ let height = min(size.height, (anchor.maxY + fit.padding).rounded())
+ if ProcessInfo.processInfo.environment["UPRIGHT_SNAPSHOT_DEBUG"] != nil {
+ SnapshotExporter.log("fit: \(fit.anchor) at \(anchor), slider \(String(describing: SnapshotAnchorCollector.shared.frames["dpiSlider"])), window \(window.contentView?.bounds.height ?? 0) -> \(height)")
+ }
+ guard abs(height - (window.contentView?.bounds.height ?? 0)) >= 2 else { break }
+ window.setContentSize(CGSize(width: size.width, height: height))
+ await layOut(window)
+ }
+ }
+ if ProcessInfo.processInfo.environment["UPRIGHT_SNAPSHOT_DEBUG"] != nil, let frame = window.contentView?.superview {
+ Self.dump(frame, depth: 0)
+ }
+ if let interact = state.interact {
+ guard await interact(self, window.contentView!) else {
+ throw RenderError.stateNotReached(state.id)
+ }
+ await layOut(window)
+ }
+ if case .top = state.scroll {} else {
+ await scroll(window, to: state.scroll)
+ }
+ return try capture(window: window, appearance: appearance, preferenceRects: rects.rects)
+ }
+
+ private func renderMenuPanel(appearance: Appearance) async throws -> RenderedImage {
+ let rects = RectStore()
+ let content = MenuBarView()
+ .environmentObject(model)
+ .environmentObject(model.devices)
+ .environmentObject(model.permissions)
+ .environmentObject(model.settings)
+ .modifier(AnchorResolver(store: rects))
+ let hosting = NSHostingView(rootView: AnyView(content))
+ let fitting = hosting.fittingSize
+ let size = CGSize(width: max(320, min(fitting.width, 440)).rounded(), height: fitting.height.rounded())
+ let window = Self.makeWindow(size: size, appearance: appearance, titled: false)
+ window.contentView = hosting
+ ActiveAppearance.announce(window)
+ defer { Self.dismantle(window) }
+ await layOut(window)
+ // The height can change once the mice's rows have their final content.
+ let settled = hosting.fittingSize
+ if abs(settled.height - size.height) >= 1 {
+ window.setContentSize(CGSize(width: size.width, height: settled.height.rounded()))
+ await layOut(window)
+ }
+ return try capture(window: window, appearance: appearance, preferenceRects: rects.rects, panelCornerRadius: 10)
+ }
+
+ private func renderMenuLabel(appearance: Appearance) throws -> RenderedImage {
+ let label = MenuBarLabel(devices: model.devices, settings: model.settings)
+ .font(.system(size: 13))
+ .foregroundStyle(.primary)
+ .padding(.horizontal, 6)
+ .frame(height: 22)
+ .environment(\.colorScheme, appearance.colorScheme)
+ let renderer = ImageRenderer(content: label)
+ renderer.scale = SnapshotExporter.labelScale
+ renderer.isOpaque = false
+ guard let cgImage = renderer.cgImage else { throw RenderError.captureFailed("menubar.label") }
+ let rep = NSBitmapImageRep(cgImage: cgImage)
+ let scale = SnapshotExporter.labelScale
+ let size = CGSize(width: CGFloat(cgImage.width) / scale, height: CGFloat(cgImage.height) / scale)
+ rep.size = size
+ guard let png = rep.representation(using: .png, properties: [:]) else { throw RenderError.captureFailed("menubar.label") }
+ return RenderedImage(png: png, size: size, scale: Double(scale), rects: [:])
+ }
+
+ /// The settings window's content: `SettingsWindowController.rootView(model:)`'s view tree
+ /// and environment, with the login item of an installed copy.
+ private func settingsContent() -> some View {
+ SettingsView()
+ .environmentObject(model)
+ .environmentObject(model.devices)
+ .environmentObject(model.permissions)
+ .environmentObject(loginItem)
+ .environmentObject(model.settings)
+ .environmentObject(model.scroll)
+ .environmentObject(model.frontmostMonitor)
+ }
+
+ // MARK: Windows
+
+ /// A window that is never ordered in, on the screen with the highest backing scale.
+ private static func makeWindow(size: CGSize, appearance: Appearance, titled: Bool) -> NSWindow {
+ let screen = NSScreen.screens.max { $0.backingScaleFactor < $1.backingScaleFactor }
+ let origin = screen?.frame.origin ?? .zero
+ let style: NSWindow.StyleMask = titled
+ ? [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView]
+ : [.borderless]
+ let window = SnapshotWindow(
+ contentRect: NSRect(origin: origin, size: size),
+ styleMask: style,
+ backing: .buffered,
+ defer: false,
+ screen: screen
+ )
+ window.isReleasedWhenClosed = false
+ window.appearance = appearance.nsAppearance
+ window.animationBehavior = .none
+ window.collectionBehavior = [.transient, .ignoresCycle]
+ if titled {
+ window.title = "Upright"
+ window.toolbar = NSToolbar(identifier: "UprightSnapshot")
+ window.toolbarStyle = .unified
+ } else {
+ window.isOpaque = false
+ window.backgroundColor = .clear
+ window.hasShadow = false
+ }
+ return window
+ }
+
+ private static func dump(_ view: NSView, depth: Int) {
+ guard depth < 40 else { return }
+ let frame = view.convert(view.bounds, to: nil)
+ var extra = ""
+ extra += " app=\(view.appearance?.name.rawValue ?? "-") eff=\(view.effectiveAppearance.name.rawValue)"
+ if let effect = view as? NSVisualEffectView { extra = " material=\(effect.material.rawValue) blending=\(effect.blendingMode.rawValue) state=\(effect.state.rawValue)" }
+ if let popUp = view as? NSPopUpButton { extra = " items=\(popUp.itemTitles)" }
+ if String(describing: type(of: view)).contains("Button") {
+ var chain: [String] = []
+ var cls: AnyClass? = type(of: view)
+ while let c = cls { chain.append(NSStringFromClass(c)); cls = class_getSuperclass(c) }
+ extra += " chain=\(chain.prefix(5)) menu=\(view.menu?.items.map(\.title) ?? [])"
+ if let control = view as? NSControl { extra += " action=\(control.action.map(NSStringFromSelector) ?? "-") target=\(control.target.map { String(describing: type(of: $0)) } ?? "-")" }
+ }
+ SnapshotExporter.log(String(repeating: " ", count: depth) + "\(type(of: view)) \(Int(frame.minX)),\(Int(frame.minY)) \(Int(frame.width))x\(Int(frame.height)) hidden=\(view.isHidden) layer=\(view.wantsLayer)" + extra)
+ view.subviews.forEach { dump($0, depth: depth + 1) }
+ }
+
+ private static func dismantle(_ window: NSWindow) {
+ window.contentViewController = nil
+ window.contentView = NSView()
+ window.close()
+ }
+
+ /// Lets SwiftUI and AppKit finish updating and laying out.
+ private func layOut(_ window: NSWindow) async {
+ // Liquid Glass gives its content a vibrant appearance, whose blending offscreen
+ // rendering can't do (text comes out black): plain appearance instead.
+ if let root = window.contentView?.superview {
+ for glass in Self.glassViews(in: root) {
+ let views = glass.subviews + Self.descendants(of: glass).filter { $0 is NSScrollView || $0 is NSClipView || $0 is NSTableView }
+ for view in views where view.appearance?.name != window.appearance?.name {
+ view.appearance = window.appearance
+ }
+ }
+ }
+ for _ in 0..<3 {
+ ActiveAppearance.announce(window)
+ window.contentView?.needsLayout = true
+ window.layoutIfNeeded()
+ window.contentView?.layoutSubtreeIfNeeded()
+ window.displayIfNeeded()
+ await settle(0.25)
+ }
+ }
+
+ func settle(_ seconds: Double) async {
+ try? await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000))
+ }
+
+ private func waitUntil(timeout: Double = 3, _ condition: @MainActor () -> Bool) async {
+ let deadline = Date().addingTimeInterval(timeout)
+ while !condition(), Date() < deadline {
+ await settle(0.05)
+ }
+ }
+
+ // MARK: Scrolling
+
+ /// Scrolls the pane's form (the largest scroll view) so the target is in view.
+ private func scroll(_ window: NSWindow, to target: State.Scroll) async {
+ guard let root = window.contentView, let scrollView = Self.mainScrollView(in: root),
+ let document = scrollView.documentView else { return }
+ let clip = scrollView.contentView
+ let maxY = max(0, document.frame.height - clip.bounds.height)
+ func scroll(toY y: CGFloat) {
+ let y = min(max(0, y), maxY)
+ let flippedY = document.isFlipped ? y : maxY - y
+ clip.scroll(to: NSPoint(x: clip.bounds.origin.x, y: flippedY))
+ scrollView.reflectScrolledClipView(clip)
+ }
+ func offset() -> CGFloat {
+ document.isFlipped ? clip.bounds.origin.y : maxY - clip.bounds.origin.y
+ }
+ switch target {
+ case .top:
+ scroll(toY: 0)
+ case .bottom:
+ scroll(toY: maxY)
+ await layOut(window)
+ // Nested scroll views too (the activity log shows its latest lines), twice: the
+ // log can still grow while the first scroll settles.
+ for _ in 0..<2 {
+ for inner in Self.scrollViews(in: document) {
+ guard let innerDocument = inner.documentView else { continue }
+ let innerClip = inner.contentView
+ let innerMax = max(0, innerDocument.frame.height - innerClip.bounds.height)
+ innerClip.scroll(to: NSPoint(x: innerClip.bounds.origin.x, y: innerDocument.isFlipped ? innerMax : 0))
+ inner.reflectScrolledClipView(innerClip)
+ }
+ await layOut(window)
+ }
+ case .anchor(let name):
+ // Rows are only made when they come into view: step down until the anchor shows,
+ // then put it near the top of the visible area.
+ let visibleTop = Self.contentTop(of: scrollView, in: root)
+ var y: CGFloat = 0
+ while SnapshotAnchorCollector.shared.frames[name] == nil, y < maxY {
+ y += clip.bounds.height * 0.5
+ scroll(toY: y)
+ await layOut(window)
+ }
+ if let frame = SnapshotAnchorCollector.shared.frames[name] {
+ scroll(toY: offset() + frame.minY - visibleTop - 8)
+ }
+ }
+ await layOut(window)
+ }
+
+ private static func scrollViews(in root: NSView) -> [NSScrollView] {
+ descendants(of: root).compactMap { $0 as? NSScrollView }.filter { !$0.isHidden && $0.bounds.width >= 1 }
+ }
+
+ private static func mainScrollView(in root: NSView) -> NSScrollView? {
+ scrollViews(in: root).max { a, b in
+ let fa = a.convert(a.bounds, to: root), fb = b.convert(b.bounds, to: root)
+ return fa.width * fa.height < fb.width * fb.height
+ }
+ }
+
+ /// Where the scroll view's visible content starts, below the toolbar (top-left origin).
+ private static func contentTop(of scrollView: NSScrollView, in root: NSView) -> CGFloat {
+ let frame = scrollView.convert(scrollView.bounds, to: root)
+ let top = root.isFlipped ? frame.minY : root.bounds.height - frame.maxY
+ let toolbar = scrollView.window.map { $0.frame.height - $0.contentLayoutRect.height } ?? 0
+ return max(top + scrollView.contentInsets.top, toolbar)
+ }
+
+ // MARK: Capture
+
+ private func capture(
+ window: NSWindow,
+ appearance: Appearance,
+ preferenceRects: [String: CGRect],
+ panelCornerRadius: CGFloat? = nil
+ ) throws -> RenderedImage {
+ guard let content = window.contentView else { throw RenderError.captureFailed("no content view") }
+ let isPanel = panelCornerRadius != nil
+ // The whole window, title bar and toolbar included, unless it is the menu panel.
+ let root = isPanel ? content : (content.superview ?? content)
+ let size = root.bounds.size
+ let scale = SnapshotExporter.defaultScale
+ guard size.width >= 1, size.height >= 1, let rep = Self.makeBitmap(size: size, scale: scale) else {
+ throw RenderError.captureFailed("bitmap \(size)")
+ }
+ guard let context = NSGraphicsContext(bitmapImageRep: rep) else { throw RenderError.captureFailed("graphics context") }
+
+ // Offscreen, AppKit can't sample what is behind a window: materials that blend with
+ // the desktop are drawn as within-window materials, and Liquid Glass panels (macOS 26)
+ // are drawn as a plain panel with their content on top.
+ let materials = Self.descendants(of: root).compactMap { $0 as? NSVisualEffectView }
+ let savedMaterials = materials.map { ($0, $0.blendingMode, $0.state) }
+ for effect in materials {
+ effect.blendingMode = .withinWindow
+ effect.state = .active
+ }
+ let glassPanels = Self.glassViews(in: root)
+ let titlebars = isPanel ? [] : Self.descendants(of: root).filter { NSStringFromClass(type(of: $0)) == "NSTitlebarContainerView" }
+ defer {
+ for (effect, blending, state) in savedMaterials {
+ effect.blendingMode = blending
+ effect.state = state
+ }
+ glassPanels.forEach { $0.isHidden = false }
+ titlebars.forEach { $0.isHidden = false }
+ }
+
+ NSGraphicsContext.saveGraphicsState()
+ NSGraphicsContext.current = context
+ appearance.nsAppearance.performAsCurrentDrawingAppearance {
+ let bounds = NSRect(origin: .zero, size: size)
+ let radius = panelCornerRadius ?? Self.windowCornerRadius
+ NSBezierPath(roundedRect: bounds, xRadius: radius, yRadius: radius).addClip()
+ // What the window draws behind its content.
+ NSColor.windowBackgroundColor.setFill()
+ bounds.fill()
+
+ // 1. Everything but the glass panels and the title bar.
+ glassPanels.forEach { $0.isHidden = true }
+ titlebars.forEach { $0.isHidden = true }
+ Self.draw(root, in: root, scale: scale)
+
+ // 1b. The toolbar's scroll edge: content scrolled under the title bar fades out
+ // (on screen it is blurred, which can't be rendered offscreen).
+ for titlebar in titlebars {
+ let band = titlebar.convert(titlebar.bounds, to: root)
+ NSColor.windowBackgroundColor.setFill()
+ band.fill()
+ let fade = NSRect(x: band.minX, y: band.minY - 14, width: band.width, height: 14)
+ NSGradient(starting: NSColor.windowBackgroundColor, ending: NSColor.windowBackgroundColor.withAlphaComponent(0))?
+ .draw(in: fade, angle: -90)
+ }
+
+ // 2. Each glass panel, then what it holds.
+ for glass in glassPanels {
+ glass.isHidden = false
+ Self.drawGlassBackground(glass.convert(glass.bounds, to: root))
+ Self.drawGlassContent(glass, in: root, scale: scale)
+ }
+
+ // 3. The title bar (window buttons, title, toolbar) on top.
+ for titlebar in titlebars {
+ titlebar.isHidden = false
+ Self.draw(titlebar, in: root, scale: scale)
+ }
+
+ // 4. The window's hairline edge, as macOS draws it, so the rounded corners read
+ // on a light background in light mode too.
+ if !isPanel {
+ let isDark = NSAppearance.currentDrawing().bestMatch(from: [.aqua, .darkAqua]) == .darkAqua
+ let edge = NSBezierPath(roundedRect: bounds.insetBy(dx: 0.5, dy: 0.5), xRadius: radius - 0.5, yRadius: radius - 0.5)
+ edge.lineWidth = 1
+ (isDark ? NSColor.white.withAlphaComponent(0.16) : NSColor.black.withAlphaComponent(0.14)).setStroke()
+ edge.stroke()
+ }
+ }
+ NSGraphicsContext.restoreGraphicsState()
+
+ guard let png = rep.representation(using: .png, properties: [:]) else {
+ throw RenderError.captureFailed("PNG encoding")
+ }
+
+ // Window-space frames from the collector (rows in table cells), else the root's
+ // anchor preferences; only those at least partly in the image.
+ let contentOffset = root === content ? .zero : Self.offset(of: content, in: root)
+ var rects = preferenceRects
+ for (name, frame) in SnapshotAnchorCollector.shared.frames {
+ rects[name] = frame
+ }
+ let imageBounds = CGRect(origin: .zero, size: size)
+ rects = rects
+ .mapValues { $0.offsetBy(dx: contentOffset.x, dy: contentOffset.y) }
+ .filter { $0.value.intersects(imageBounds) && $0.value.width > 0 && $0.value.height > 0 }
+ return RenderedImage(png: png, size: size, scale: Double(scale), rects: rects)
+ }
+
+ /// Window corners: macOS 26 rounds windows with a toolbar more than earlier versions.
+ private static var windowCornerRadius: CGFloat {
+ if ProcessInfo.processInfo.operatingSystemVersion.majorVersion >= 26 { return 16 }
+ return 10
+ }
+
+ private static func makeBitmap(size: CGSize, scale: CGFloat) -> NSBitmapImageRep? {
+ let rep = NSBitmapImageRep(
+ bitmapDataPlanes: nil,
+ pixelsWide: Int((size.width * scale).rounded()),
+ pixelsHigh: Int((size.height * scale).rounded()),
+ bitsPerSample: 8,
+ samplesPerPixel: 4,
+ hasAlpha: true,
+ isPlanar: false,
+ colorSpaceName: .deviceRGB,
+ bytesPerRow: 0,
+ bitsPerPixel: 0
+ )?.retagging(with: .sRGB)
+ rep?.size = size
+ return rep
+ }
+
+ /// Draws `view` (and its subviews) into the current context, where it sits in `root`.
+ private static func draw(_ view: NSView, in root: NSView, scale: CGFloat) {
+ let size = view.bounds.size
+ guard size.width >= 1, size.height >= 1, let rep = makeBitmap(size: size, scale: scale) else { return }
+ view.cacheDisplay(in: view.bounds, to: rep)
+ let frame = view.convert(view.bounds, to: root)
+ rep.draw(in: frame, from: .zero, operation: .sourceOver, fraction: 1, respectFlipped: false, hints: nil)
+ }
+
+ private static func descendants(of view: NSView) -> [NSView] {
+ view.subviews.flatMap { [$0] + descendants(of: $0) }
+ }
+
+ /// Liquid Glass views (`NSGlassEffectView`, macOS 26), outermost only.
+ private static func glassViews(in root: NSView) -> [NSView] {
+ guard let glassClass = NSClassFromString("NSGlassEffectView") else { return [] }
+ var found: [NSView] = []
+ func visit(_ view: NSView) {
+ if view.isKind(of: glassClass), !view.isHidden, view.bounds.width >= 1 {
+ found.append(view)
+ return
+ }
+ view.subviews.forEach(visit)
+ }
+ visit(root)
+ return found
+ }
+
+ /// What a glass panel holds: the visible part of each scroll view in it (a sidebar list),
+ /// on top of the panel. Their own backgrounds and the glass's fallback rendering would
+ /// cover the panel, so only the document views are drawn. Selected rows get an
+ /// accent-colored highlight, which offscreen rendering can't produce.
+ private static func drawGlassContent(_ glass: NSView, in root: NSView, scale: CGFloat) {
+ let isDark = NSAppearance.currentDrawing().bestMatch(from: [.aqua, .darkAqua]) == .darkAqua
+ let selections = descendants(of: glass).compactMap { $0 as? NSVisualEffectView }
+ .filter { $0.material == .selection && !$0.isHidden && $0.bounds.width >= 1 }
+ for selection in selections {
+ let frame = selection.convert(selection.bounds, to: root)
+ NSColor.controlAccentColor.withAlphaComponent(0.2).setFill()
+ NSBezierPath(roundedRect: frame, xRadius: 8, yRadius: 8).fill()
+ selection.isHidden = true
+ }
+ defer { selections.forEach { $0.isHidden = false } }
+ let scrollViews = descendants(of: glass).compactMap { $0 as? NSScrollView }
+ for scrollView in scrollViews where !scrollView.isHidden {
+ guard let document = scrollView.documentView else { continue }
+ let visible = document.visibleRect
+ guard visible.width >= 1, visible.height >= 1, let rep = makeBitmap(size: visible.size, scale: scale) else { continue }
+ document.cacheDisplay(in: visible, to: rep)
+ if isDark { lightenVibrantText(in: rep) }
+ rep.draw(in: document.convert(visible, to: root), from: .zero, operation: .sourceOver, fraction: 1, respectFlipped: false, hints: nil)
+ }
+ }
+
+ /// Sidebar text and symbols are vibrant: their color comes from blending with the glass
+ /// behind them, which offscreen rendering skips, so in dark mode they come out black.
+ /// Turns those dark, colorless pixels into the light label color they show on screen;
+ /// colored icon tiles and their white symbols are left alone.
+ private static func lightenVibrantText(in rep: NSBitmapImageRep) {
+ guard let data = rep.bitmapData, rep.samplesPerPixel == 4, rep.bitsPerSample == 8, !rep.isPlanar else { return }
+ let premultiplied = !rep.bitmapFormat.contains(.alphaNonpremultiplied)
+ let alphaFirst = rep.bitmapFormat.contains(.alphaFirst)
+ let target: Double = 0.92
+ for y in 0.. 0.02 else { continue }
+ var r = Double(pixel[ri]) / 255, g = Double(pixel[gi]) / 255, b = Double(pixel[bi]) / 255
+ if premultiplied { r /= alpha; g /= alpha; b /= alpha }
+ let high = max(r, g, b), low = min(r, g, b)
+ guard high < 0.35, high - low < 0.08 else { continue }
+ let value = premultiplied ? target * alpha : target
+ let byte = UInt8(max(0, min(255, (value * 255).rounded())))
+ pixel[ri] = byte
+ pixel[gi] = byte
+ pixel[bi] = byte
+ }
+ }
+ }
+
+ /// A glass sidebar as it looks over an empty desktop: a floating rounded panel.
+ private static func drawGlassBackground(_ frame: CGRect) {
+ let panel = frame.insetBy(dx: 8, dy: 8)
+ let path = NSBezierPath(roundedRect: panel, xRadius: 18, yRadius: 18)
+ let isDark = NSAppearance.currentDrawing().bestMatch(from: [.aqua, .darkAqua]) == .darkAqua
+ NSGraphicsContext.saveGraphicsState()
+ let shadow = NSShadow()
+ shadow.shadowColor = NSColor.black.withAlphaComponent(isDark ? 0.35 : 0.12)
+ shadow.shadowBlurRadius = 8
+ shadow.shadowOffset = NSSize(width: 0, height: -1)
+ shadow.set()
+ NSColor(white: isDark ? 0.17 : 0.955, alpha: 1).setFill()
+ path.fill()
+ NSGraphicsContext.restoreGraphicsState()
+ NSColor.separatorColor.withAlphaComponent(0.5).setStroke()
+ path.lineWidth = 0.5
+ path.stroke()
+ }
+
+ /// Where `content` sits in `container`, top-left origin.
+ private static func offset(of content: NSView, in container: NSView) -> CGPoint {
+ let frame = content.convert(content.bounds, to: container)
+ let y = container.isFlipped ? frame.minY : container.bounds.height - frame.maxY
+ return CGPoint(x: frame.minX, y: y)
+ }
+ }
+
+ enum RenderError: Error, CustomStringConvertible {
+ case stateNotReached(String)
+ case captureFailed(String)
+
+ var description: String {
+ switch self {
+ case .stateNotReached(let id): return "Could not put the UI into state \(id)"
+ case .captureFailed(let detail): return "Capture failed (\(detail))"
+ }
+ }
+ }
+}
+
+// MARK: - Active appearance
+
+/// Upright never activates while exporting (that would take focus from the user's app), but an
+/// inactive app draws its windows dimmed: grey window buttons, washed-out switches. For this
+/// export-only process, `NSApplication.isActive` answers true and each capture window is
+/// announced as key, so the images look like the settings window in use.
+@MainActor
+private enum ActiveAppearance {
+ private static var enabled = false
+
+ static func enable() {
+ guard !enabled else { return }
+ enabled = true
+ for selector in [#selector(getter: NSApplication.isActive), NSSelectorFromString("_isActiveApp")] {
+ guard let method = class_getInstanceMethod(NSApplication.self, selector) else { continue }
+ let alwaysActive: @convention(block) (AnyObject) -> Bool = { _ in true }
+ class_replaceMethod(NSApplication.self, selector, imp_implementationWithBlock(alwaysActive), method_getTypeEncoding(method))
+ }
+ for selector in [#selector(getter: NSApplication.keyWindow), #selector(getter: NSApplication.mainWindow)] {
+ guard let method = class_getInstanceMethod(NSApplication.self, selector) else { continue }
+ let captureWindow: @convention(block) (AnyObject) -> NSWindow? = { _ in ActiveAppearance.window }
+ class_replaceMethod(NSApplication.self, selector, imp_implementationWithBlock(captureWindow), method_getTypeEncoding(method))
+ }
+ NotificationCenter.default.post(name: NSApplication.didBecomeActiveNotification, object: NSApp)
+ }
+
+ /// The window being captured, which `NSApp.keyWindow` and `mainWindow` report.
+ nonisolated(unsafe) private static weak var window: NSWindow?
+
+ static func announce(_ window: NSWindow) {
+ self.window = window
+ for name in ["acquireKeyAppearance", "acquireMainAppearance"] where window.responds(to: NSSelectorFromString(name)) {
+ window.perform(NSSelectorFromString(name))
+ }
+ NotificationCenter.default.post(name: NSWindow.didBecomeMainNotification, object: window)
+ NotificationCenter.default.post(name: NSWindow.didBecomeKeyNotification, object: window)
+ }
+}
+
+// MARK: - Window
+
+/// The offscreen window draws as the key window would (colored window buttons, accent-colored
+/// controls), as the settings window does while the user works in it. It is never ordered in.
+private final class SnapshotWindow: NSWindow {
+ @objc(_hasActiveAppearance) var hasActiveAppearance: Bool { true }
+ @objc(_hasActiveAppearanceIgnoringKeyFocus) var hasActiveAppearanceIgnoringKeyFocus: Bool { true }
+ @objc(_hasKeyAppearance) var hasKeyAppearance: Bool { true }
+ @objc(_hasMainAppearance) var hasMainAppearance: Bool { true }
+ @objc(_hasActiveControls) var hasActiveControls: Bool { true }
+ @objc(hasKeyAppearance) var hasKeyAppearancePublic: Bool { true }
+ @objc(hasMainAppearance) var hasMainAppearancePublic: Bool { true }
+ override var isKeyWindow: Bool { true }
+ override var isVisible: Bool { true }
+ override var occlusionState: NSWindow.OcclusionState { .visible }
+ /// Never shown, so never kept to the screen: a tall pane renders at its full height on
+ /// any display.
+ override func constrainFrameRect(_ frameRect: NSRect, to screen: NSScreen?) -> NSRect { frameRect }
+ override var isMainWindow: Bool { true }
+ override var canBecomeKey: Bool { true }
+}
+
+// MARK: - Anchor resolution
+
+/// Holds the root's resolved anchor rects for one capture.
+@MainActor
+private final class RectStore {
+ var rects: [String: CGRect] = [:]
+}
+
+private struct SnapshotRectsKey: PreferenceKey {
+ static var defaultValue: [String: CGRect] { [:] }
+
+ static func reduce(value: inout [String: CGRect], nextValue: () -> [String: CGRect]) {
+ value.merge(nextValue()) { _, new in new }
+ }
+}
+
+/// Resolves every `snapshotAnchor` that reaches the root (its anchor preference) into a rect in
+/// the root's coordinates (top-left origin) and keeps them in `store`.
+private struct AnchorResolver: ViewModifier {
+ let store: RectStore
+
+ func body(content: Content) -> some View {
+ content
+ .overlayPreferenceValue(SnapshotAnchorKey.self) { anchors in
+ GeometryReader { proxy in
+ Color.clear.preference(key: SnapshotRectsKey.self, value: anchors.mapValues { proxy[$0] })
+ }
+ .allowsHitTesting(false)
+ }
+ .onPreferenceChange(SnapshotRectsKey.self) { rects in
+ MainActorDelivery.run { store.rects = rects }
+ }
+ }
+}
+
diff --git a/Upright/App/SystemActionPerformer.swift b/Upright/App/SystemActionPerformer.swift
index 42ec687..016f6b5 100644
--- a/Upright/App/SystemActionPerformer.swift
+++ b/Upright/App/SystemActionPerformer.swift
@@ -13,6 +13,11 @@ final class SystemActionPerformer: ActionPerforming {
/// How many held shortcuts currently need each modifier, so overlapping shortcuts that
/// share a modifier release it only after the last one lets go.
private var modifierHolds: [Int: Int] = [:]
+ /// Like `modifierHolds`, for synthetic mouse buttons and repeating media keys: two mice
+ /// holding the same button (or key) post one press, and the last release ends it.
+ private var mouseButtonHolds = HoldCounts()
+ private var mediaKeyHolds = HoldCounts()
+ /// Buttons with at least one hold, in the order they went down.
private var heldMouseButtons: [SyntheticMouseButton] = []
private var lastClick: (button: SyntheticMouseButton, time: TimeInterval, location: CGPoint, count: Int64)?
private var clickStates: [SyntheticMouseButton: Int64] = [:]
@@ -107,6 +112,9 @@ final class SystemActionPerformer: ActionPerforming {
// MARK: Mouse buttons
private func postMouseButton(_ button: SyntheticMouseButton, down: Bool) {
+ // Only the first press and the last release reach the system; the ones in between are
+ // another mouse holding the same button, or a release with nothing held.
+ guard down ? mouseButtonHolds.acquire(button) : mouseButtonHolds.release(button) else { return }
let location = CGEvent(source: nil)?.location ?? .zero
guard let event = CGEvent(
mouseEventSource: source,
@@ -118,11 +126,10 @@ final class SystemActionPerformer: ActionPerforming {
event.setIntegerValueField(.mouseEventClickState, value: down ? nextClickState(button, at: location) : clickStates[button] ?? 1)
event.flags = CGEventSource.flagsState(.combinedSessionState)
+ heldMouseButtons.removeAll { $0 == button }
if down {
- heldMouseButtons.removeAll { $0 == button }
heldMouseButtons.append(button)
} else {
- heldMouseButtons.removeAll { $0 == button }
clickStates[button] = nil
}
// While a button is held, pointer movement must arrive as drags of that button.
@@ -146,7 +153,8 @@ final class SystemActionPerformer: ActionPerforming {
// MARK: Media keys
private func startRepeating(_ key: MediaKey) {
- stopRepeating(key)
+ // A second mouse holding the same key joins the repeat that is already running.
+ guard mediaKeyHolds.acquire(key) else { return }
postMediaKey(key)
mediaRepeats[key] = Task { @MainActor [weak self] in
try? await Task.sleep(nanoseconds: Self.mediaRepeatDelay)
@@ -158,6 +166,7 @@ final class SystemActionPerformer: ActionPerforming {
}
private func stopRepeating(_ key: MediaKey) {
+ guard mediaKeyHolds.release(key) else { return }
mediaRepeats.removeValue(forKey: key)?.cancel()
}
@@ -207,6 +216,25 @@ final class SystemActionPerformer: ActionPerforming {
}
}
+/// How many holders currently need each key. `acquire` is true for the first holder and
+/// `release` for the last, which are the only moments that change what the system sees.
+fileprivate struct HoldCounts {
+ private var counts: [Key: Int] = [:]
+
+ mutating func acquire(_ key: Key) -> Bool {
+ let count = counts[key, default: 0] + 1
+ counts[key] = count
+ return count == 1
+ }
+
+ /// False while other holders remain, and for a key nobody holds.
+ mutating func release(_ key: Key) -> Bool {
+ guard let count = counts[key] else { return false }
+ counts[key] = count == 1 ? nil : count - 1
+ return count == 1
+ }
+}
+
/// Runs helper tools without a shell and keeps each `Process` alive until it exits, so it's
/// reaped instead of left as a zombie.
@MainActor
diff --git a/Upright/App/UprightApp.swift b/Upright/App/UprightApp.swift
index d0d3576..54fa6c2 100644
--- a/Upright/App/UprightApp.swift
+++ b/Upright/App/UprightApp.swift
@@ -1,7 +1,25 @@
import AppKit
import SwiftUI
+/// The entry point. Demo Mode is configured first, so `AppModel.shared` is built for the right
+/// environment (simulated mice and their own defaults with `--demo`).
+///
+/// `--export-snapshots ` never starts the SwiftUI app: no scene, no status item (whose
+/// visibility AppKit would save in the user's defaults), no windows and, as a menu bar app, no
+/// Dock icon. It renders the UI offscreen and quits.
@main
+@MainActor
+enum UprightLauncher {
+ static func main() {
+ DemoMode.configure(arguments: CommandLine.arguments)
+ if let directory = DemoMode.exportDirectory {
+ SnapshotExportRunner.run(to: directory)
+ } else {
+ UprightApp.main()
+ }
+ }
+}
+
@MainActor
struct UprightApp: App {
@NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
@@ -21,53 +39,16 @@ struct UprightApp: App {
}
}
-private struct MenuBarLabel: View {
- @ObservedObject var devices: DeviceManager
- @ObservedObject var settings: AppSettings
-
- var body: some View {
- let icon = Image(systemName: devices.snapshot == nil ? "computermouse" : "computermouse.fill")
- Group {
- if settings.showBatteryInMenuBar, let battery = devices.snapshot?.battery {
- // One Text, so the menu bar shows both the icon and the level.
- Text(icon) + Text(verbatim: " " + battery.levelText)
- } else {
- icon
- }
- }
- .accessibilityElement(children: .ignore)
- .accessibilityLabel(accessibilityText)
- }
-
- /// For example "Upright, connected, battery 80%".
- private var accessibilityText: String {
- var parts = ["Upright"]
- switch devices.status {
- case .connected: parts.append(String(localized: "connected"))
- case .asleep: parts.append(String(localized: "mouse asleep"))
- case .probing, .waiting: parts.append(String(localized: "connecting"))
- case .searching: parts.append(String(localized: "not connected"))
- case .accessDenied: parts.append(String(localized: "needs Input Monitoring access"))
- case .failed: parts.append(String(localized: "connection problem"))
- @unknown default: break
- }
- if let battery = devices.snapshot?.battery {
- parts.append(battery.accessibilityDescription)
- }
- return parts.joined(separator: ", ")
- }
-}
-
@MainActor
final class AppDelegate: NSObject, NSApplicationDelegate {
private static let hasLaunchedKey = "hasLaunchedBefore"
func applicationDidFinishLaunching(_ notification: Notification) {
- AppModel.shared.start()
-
- // Read by MappingStore to tell upgrades from fresh installs.
- UserDefaults.standard.set(true, forKey: Self.hasLaunchedKey)
- if !AppModel.shared.settings.hasCompletedOnboarding {
+ let model = AppModel.shared
+ model.start()
+ // Read by MappingStore to tell upgrades from fresh installs. Demo Mode writes its own suite.
+ model.defaults.set(true, forKey: Self.hasLaunchedKey)
+ if !model.settings.hasCompletedOnboarding {
OnboardingWindowController.shared.show()
}
}
@@ -85,3 +66,44 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
AppModel.shared.devices.shutdown()
}
}
+
+/// `--demo --export-snapshots `: a bare NSApplication (no SwiftUI scene) that runs the
+/// simulated fleet, renders the UI offscreen into the directory (`SnapshotExporter`), then
+/// exits 0 on success or 1 on failure.
+@MainActor
+enum SnapshotExportRunner {
+ private static var delegate: ExportDelegate?
+
+ static func run(to directory: URL) {
+ let app = NSApplication.shared
+ let delegate = ExportDelegate(directory: directory)
+ self.delegate = delegate
+ app.delegate = delegate
+ app.run()
+ }
+
+ private final class ExportDelegate: NSObject, NSApplicationDelegate {
+ let directory: URL
+
+ init(directory: URL) {
+ self.directory = directory
+ }
+
+ func applicationDidFinishLaunching(_ notification: Notification) {
+ let model = AppModel.shared
+ model.start()
+ let directory = directory
+ Task { @MainActor in
+ var status: Int32 = 0
+ do {
+ try await SnapshotExporter.run(model: model, to: directory)
+ } catch {
+ FileHandle.standardError.write(Data("Snapshot export failed: \(error)\n".utf8))
+ status = 1
+ }
+ model.devices.shutdown()
+ exit(status)
+ }
+ }
+ }
+}
diff --git a/Upright/Localizable.xcstrings b/Upright/Localizable.xcstrings
index 928719f..c8ce6c4 100644
--- a/Upright/Localizable.xcstrings
+++ b/Upright/Localizable.xcstrings
@@ -1,32 +1,113 @@
{
"sourceLanguage" : "en",
"strings" : {
- "…and %lld more." : {
+ "“Cycle Pointer Speed” steps through these speeds on whichever mouse the button belongs to, and each one can be assigned to a button in Buttons. Speeds are rounded to the nearest one that mouse supports." : {
},
- "“Cycle Pointer Speed” steps through these speeds, and each one can be assigned to a button in Buttons. Speeds are rounded to the nearest one the mouse supports." : {
+ "“Default” leaves a button to the mouse: %@." : {
+ },
+ "“Default” leaves a button to the mouse." : {
+
+ },
+ "%@ — click a mouse button to wake it" : {
+
+ },
+ "%@ (%lld mice)" : {
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "%1$@ (%2$lld mice)"
+ }
+ }
+ }
},
"%@ (off)" : {
+ },
+ "%@ #%lld" : {
+ "comment" : "A second mouse with the same name",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "%1$@ #%2$lld"
+ }
+ }
+ }
+ },
+ "%@ asleep" : {
+ "comment" : "Menu bar accessibility"
},
"%@ battery is almost empty" : {
},
"%@ battery is low" : {
+ },
+ "%@ can’t do this, so the button does nothing on it." : {
+
+ },
+ "%@ connected" : {
+ "comment" : "Menu bar accessibility"
+ },
+ "%@ connected, %@" : {
+ "comment" : "Menu bar accessibility: a mouse and its battery",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "%1$@ connected, %2$@"
+ }
+ }
+ }
+ },
+ "%@ connecting" : {
+ "comment" : "Menu bar accessibility"
+ },
+ "%@ has a connection problem" : {
+ "comment" : "Menu bar accessibility"
},
"%@ has its own button settings" : {
},
"%@ needs ⌘, ⌃ or ⌥ — or use a function key" : {
+ },
+ "%@ opens Mission Control." : {
+
+ },
+ "%@ slot %lld" : {
+ "comment" : "Where a mouse is connected, e.g. Bolt slot 2",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "%1$@ slot %2$lld"
+ }
+ }
+ }
+ },
+ "%@ was disconnected" : {
+
},
"%@, charging" : {
},
"%@, paused" : {
+ },
+ "%@, slot %lld" : {
+ "comment" : "Where a mouse is connected: a receiver and its slot",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "%1$@, slot %2$lld"
+ }
+ }
+ }
},
"%@: %@" : {
"comment" : "Gesture summary entry; direction and action name",
@@ -38,12 +119,41 @@
}
}
}
+ },
+ "%@: %@ → %@" : {
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "%1$@: %2$@ → %3$@"
+ }
+ }
+ }
+ },
+ "%@: %@." : {
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "%1$@: %2$@."
+ }
+ }
+ }
+ },
+ "%@: no mouse is connected" : {
+
},
"%1$@: skipped a link for control %2$d that uses “%3$@:” (only http, https and mailto links can be imported)" : {
"comment" : "Import report entry; %1$@ is “global” or an app’s bundle ID, %2$d a control number, %3$@ a URL scheme such as file"
},
"%lld DPI" : {
+ },
+ "%lld entries couldn’t be used and were left out." : {
+
+ },
+ "%lld mice connected" : {
+
},
"%lld%% left. Charge it now to keep using it." : {
@@ -68,6 +178,9 @@
},
"⌥⌘Esc opens Force Quit" : {
+ },
+ "1 mouse connected" : {
+
},
"About" : {
@@ -110,18 +223,27 @@
},
"Add Profile" : {
+ },
+ "Additional left-click button" : {
+ "comment" : "Mouse control description"
},
"Additional programmable control" : {
"comment" : "Mouse control description"
},
"After you switch Upright on in System Settings, this page updates by itself." : {
+ },
+ "AI Button" : {
+ "comment" : "Mouse control name"
},
"All apps" : {
},
"All Apps (Global)" : {
+ },
+ "All devices" : {
+
},
"Allow Accessibility" : {
@@ -134,34 +256,70 @@
},
"Allowed" : {
+ },
+ "also on %@" : {
+ "comment" : "The same mouse is also connected another way, e.g. also on USB"
+ },
+ "Alternate back button" : {
+ "comment" : "Mouse control description"
+ },
+ "Analog Horizontal Scroll" : {
+ "comment" : "Mouse control name"
},
"App Exposé" : {
"comment" : "Built-in action name"
+ },
+ "Applies to every %@" : {
+
+ },
+ "Applies to every mouse wheel. macOS doesn’t say which mouse a scroll came from. Trackpads are never changed." : {
+
},
"Applies to tilt and Shift-scroll." : {
},
"Approve Upright in Login Items to finish turning this on." : {
+ },
+ "Asleep" : {
+
+ },
+ "Asleep — move a mouse to wake it" : {
+
},
"Asleep — move the mouse to wake it" : {
+ },
+ "Asleep: %@" : {
+
},
"Back" : {
"comment" : "Built-in action name"
},
- "Back and Forward navigate in apps (⌘[ and ⌘]); the top button opens Mission Control." : {
-
+ "Back (Alternate)" : {
+ "comment" : "Mouse control name"
},
- "Back and Forward start working once Accessibility access is allowed." : {
-
+ "Back (Arrow Key)" : {
+ "comment" : "Mouse control name"
},
- "Back and Forward work as mouse buttons 4 and 5; the top button changes pointer speed." : {
+ "Back (Horizontal Scroll)" : {
+ "comment" : "Mouse control name"
+ },
+ "Back and Forward navigate in apps (⌘[ and ⌘])." : {
},
"Back Button" : {
"comment" : "Mouse control name"
},
+ "Back button of a multi-platform mouse" : {
+ "comment" : "Mouse control description"
+ },
+ "Back, sent as a horizontal scroll" : {
+ "comment" : "Mouse control description"
+ },
+ "Back, sent as an Alt or Windows arrow key" : {
+ "comment" : "Mouse control description"
+ },
"Basic" : {
},
@@ -191,6 +349,18 @@
},
"Built on public HID++ knowledge" : {
+ },
+ "Button 0x%04X" : {
+ "comment" : "Mouse control name; %04X is its hexadecimal ID"
+ },
+ "Button 6" : {
+ "comment" : "Mouse control name"
+ },
+ "Button 9" : {
+ "comment" : "Mouse control name"
+ },
+ "Button 10" : {
+ "comment" : "Mouse control name"
},
"Buttons" : {
@@ -206,6 +376,12 @@
},
"Cancel" : {
+ },
+ "Changes are saved and applied as soon as a mouse of this model connects." : {
+
+ },
+ "Changes the pointer speed (DPI)" : {
+ "comment" : "Mouse control description"
},
"Channel %lld · %@" : {
"localizations" : {
@@ -233,7 +409,7 @@
"Charging" : {
},
- "Check that you trust these actions, which open apps, links or shortcuts:" : {
+ "Check that you trust these actions" : {
},
"Choose" : {
@@ -268,6 +444,9 @@
},
"Click: %@" : {
"comment" : "Gesture summary entry; %@ is an action name"
+ },
+ "Closing %@" : {
+
},
"Command" : {
@@ -278,29 +457,44 @@
"Configuration" : {
},
- "Connect the mouse to change its pointer speed." : {
+ "Configure" : {
},
- "Connect the mouse to try a button." : {
+ "Connect a Logitech mouse" : {
},
- "Connect your MX Vertical" : {
+ "Connect a mouse to change its pointer speed." : {
},
- "Connect your MX Vertical to see every button it reports. Changes are saved and applied as soon as it connects." : {
+ "Connect a mouse to try a button." : {
+
+ },
+ "Connect the mouse to change its pointer speed or Easy-Switch channel." : {
+
+ },
+ "Connect your Logitech mouse" : {
},
"connected" : {
+ },
+ "Connected" : {
+
},
"Connected via %@" : {
+ },
+ "Connected: %@" : {
+
},
"connecting" : {
},
"Connecting via %@…" : {
+ },
+ "Connecting…" : {
+
},
"Connection" : {
@@ -322,29 +516,56 @@
},
"Controls" : {
+ },
+ "Copied" : {
+
+ },
+ "Copies the lines shown" : {
+
+ },
+ "Copies what this device reported (features, buttons, connection) for a bug report or a new-model request" : {
+
},
"Copy" : {
"comment" : "Built-in action name"
},
+ "Copy another model’s buttons and app profiles to this one." : {
+
+ },
+ "Copy Device Report" : {
+
+ },
+ "Copy Setup From" : {
+
+ },
+ "Cursor Button (Long Press)" : {
+ "comment" : "Mouse control name"
+ },
"Customize Buttons…" : {
},
"Cycle Pointer Speed" : {
"comment" : "Pointer speed action name"
},
+ "Cycles through pointer speeds (DPI)" : {
+ "comment" : "Mouse control description"
+ },
+ "Dedicated AI button" : {
+ "comment" : "Mouse control description"
+ },
"Default" : {
"comment" : "Button action name"
},
"Delete" : {
},
- "Details" : {
+ "Demo Mode: showing simulated mice; no real device is opened" : {
},
- "Details…" : {
+ "Details" : {
},
- "Device" : {
+ "Details…" : {
},
"Device index" : {
@@ -355,6 +576,9 @@
},
"Disconnected" : {
+ },
+ "Dismiss" : {
+
},
"Do Nothing" : {
"comment" : "Button action name"
@@ -367,9 +591,18 @@
},
"Down Arrow" : {
+ },
+ "DPI Change Button" : {
+ "comment" : "Mouse control name"
+ },
+ "DPI Switch Button" : {
+ "comment" : "Mouse control name"
},
"e.g. 1000" : {
+ },
+ "Each model’s buttons and app profiles are replaced by the file’s. Models the file doesn’t list keep their setup." : {
+
},
"Easy-Switch" : {
@@ -405,13 +638,19 @@
"Every button does what the mouse does on its own. You can still change the actions below; they apply when you turn remapping back on." : {
},
- "Every button goes back to what the mouse does on its own, and all app profiles are removed. Export first if you might want them back." : {
+ "Every button goes back to what the mouse does on its own. App profiles are kept. Other models aren’t changed." : {
},
- "Every button goes back to what the mouse does on its own. App profiles are kept." : {
+ "Every button in this app uses the global action again." : {
},
- "Every button in this app uses the global action again." : {
+ "Every button keeps the behavior the mouse gives it. Nothing is remapped." : {
+
+ },
+ "Every button of every mouse model goes back to what the mouse does on its own, and all app profiles are removed. Export first if you might want them back." : {
+
+ },
+ "Every button on this mouse does what it normally does." : {
},
"Experimental" : {
@@ -428,17 +667,47 @@
},
"Firmware" : {
"comment" : "Kind of firmware entry"
+ },
+ "Fn + Left Click" : {
+ "comment" : "Mouse control name"
+ },
+ "Fn + Second Left Click" : {
+ "comment" : "Mouse control name"
+ },
+ "Forget" : {
+
+ },
+ "Forget %@?" : {
+
+ },
+ "Forget This Mouse…" : {
+
},
"Forward" : {
"comment" : "Built-in action name"
},
+ "Forward (Arrow Key)" : {
+ "comment" : "Mouse control name"
+ },
+ "Forward (HID)" : {
+ "comment" : "Mouse control name"
+ },
"Forward Button" : {
"comment" : "Mouse control name"
},
+ "Forward button of a multi-platform mouse" : {
+ "comment" : "Mouse control description"
+ },
"Forward Delete" : {
},
- "Four speeds spread across what this mouse supports" : {
+ "Forward, sent as a standard mouse button" : {
+ "comment" : "Mouse control description"
+ },
+ "Forward, sent as an Alt or Windows arrow key" : {
+ "comment" : "Mouse control description"
+ },
+ "Found %@" : {
},
"Full" : {
@@ -446,6 +715,9 @@
},
"General" : {
+ },
+ "Gesture Button" : {
+ "comment" : "Mouse control name"
},
"Gestures" : {
"comment" : "Button action name"
@@ -455,15 +727,27 @@
},
"Gestures…" : {
+ },
+ "Give every other mouse you use the same buttons and app profiles." : {
+
},
"Give this button a different action in this app" : {
},
"Global: %@" : {
+ },
+ "Haptic Sense Button" : {
+ "comment" : "Mouse control name"
+ },
+ "Haptic thumb panel" : {
+ "comment" : "Mouse control description"
},
"Hardware" : {
"comment" : "Kind of firmware entry"
+ },
+ "HID++ did not answer; looking for the device again" : {
+
},
"HID++ features" : {
@@ -482,23 +766,54 @@
},
"Home" : {
+ },
+ "Horizontal scroll to the left, reported as a button" : {
+ "comment" : "Mouse control description"
+ },
+ "Horizontal scroll to the right, reported as a button" : {
+ "comment" : "Mouse control description"
+ },
+ "Horizontal scroll wheel" : {
+ "comment" : "Mouse control description"
},
"How to connect" : {
},
"https://example.com" : {
+ },
+ "Identifies this mouse again and reapplies its button configuration" : {
+
+ },
+ "Ignoring %@: %@" : {
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Ignoring %1$@: %2$@"
+ }
+ }
+ }
+ },
+ "Ignoring invalid device action: %@" : {
+
},
"Import" : {
},
"Import failed" : {
+ },
+ "Import into" : {
+
+ },
+ "Import Mappings" : {
+
},
"Import…" : {
},
- "Importing replaces your current mappings and app profiles." : {
+ "Importing replaces the setup of each mouse model in the file. You can review it first." : {
},
"In %@" : {
@@ -521,6 +836,15 @@
},
"Input Monitoring needed" : {
+ },
+ "Its buttons appear here as soon as it connects." : {
+
+ },
+ "Its buttons follow the setup in Buttons." : {
+
+ },
+ "Its buttons work as usual until you set them up." : {
+
},
"Its overrides are deleted. The app goes back to using the global actions." : {
@@ -539,12 +863,36 @@
},
"Keyboard Shortcut…" : {
+ },
+ "Keyboard; Upright only configures mice" : {
+ "comment" : "Why a Logitech device isn't supported"
+ },
+ "Last connection" : {
+
+ },
+ "Last seen %@" : {
+ "comment" : "When a remembered mouse was last connected, e.g. Last seen 3 days ago"
},
"Left" : {
"comment" : "Short gesture direction"
},
+ "Left + Right Click" : {
+ "comment" : "Mouse control name"
+ },
+ "Left and right buttons pressed together" : {
+ "comment" : "Mouse control description"
+ },
"Left Arrow" : {
+ },
+ "Left Button" : {
+ "comment" : "Mouse control name"
+ },
+ "Left click while the Fn key is held" : {
+ "comment" : "Mouse control description"
+ },
+ "Left out (can’t be used)" : {
+
},
"Less" : {
@@ -563,6 +911,21 @@
},
"LogiOps" : {
+ },
+ "Logitech device" : {
+ "comment" : "A device that hasn't said what it is yet"
+ },
+ "Logitech HID devices" : {
+
+ },
+ "Logitech mouse" : {
+ "comment" : "Name shown for a mouse that doesn't report its own name"
+ },
+ "Long press of the cursor button" : {
+ "comment" : "Mouse control description"
+ },
+ "Looks for every Logitech mouse again and reapplies their button configuration" : {
+
},
"Low battery alerts" : {
@@ -570,13 +933,25 @@
"Lower thumb button" : {
"comment" : "Mouse control description"
},
- "macOS doesn’t say which mouse a scroll came from. With this off, every mouse wheel is changed, but trackpads never are." : {
+ "Mac woke up; reapplying button configuration" : {
},
"macOS is blocking access" : {
},
- "macOS is hiding the MX Vertical, so button remaps are not applied. Allow Upright under Privacy & Security → Input Monitoring. The top button keeps changing pointer speed until then." : {
+ "macOS is blocking:" : {
+
+ },
+ "macOS is hiding %@, so button remaps are not applied there. Allow Upright under Privacy & Security → Input Monitoring. The buttons keep their normal behavior until then." : {
+
+ },
+ "macOS is hiding your Logitech mouse, so button remaps are not applied. Allow Upright under Privacy & Security → Input Monitoring. The buttons keep their normal behavior until then." : {
+
+ },
+ "Made for %@" : {
+
+ },
+ "Mapped buttons start working once Accessibility access is allowed." : {
},
"Mappings and app profiles" : {
@@ -590,6 +965,9 @@
},
"Media" : {
"comment" : "Action category"
+ },
+ "Mice" : {
+
},
"Middle Click" : {
"comment" : "Built-in action name"
@@ -630,11 +1008,23 @@
"Moves the mouse to Easy-Switch channel %lld; it disconnects from this Mac" : {
"comment" : "Action tooltip; %lld is the Easy-Switch channel number"
},
+ "Multi-Platform Back" : {
+ "comment" : "Mouse control name"
+ },
+ "Multi-Platform Forward" : {
+ "comment" : "Mouse control name"
+ },
+ "Multi-Platform Gesture Button" : {
+ "comment" : "Mouse control name"
+ },
"Mute" : {
"comment" : "Built-in action name"
},
"Name" : {
+ },
+ "Nano receiver" : {
+ "comment" : "How the mouse connects to the Mac"
},
"Navigation" : {
"comment" : "Action category"
@@ -650,6 +1040,9 @@
},
"needs Input Monitoring access" : {
+ },
+ "New: %@" : {
+
},
"Next Tab" : {
"comment" : "Built-in action name"
@@ -657,34 +1050,78 @@
"Next Track" : {
"comment" : "Built-in action name"
},
- "No MX Vertical connected" : {
+ "Nickname" : {
+
+ },
+ "No buttons Upright can remap" : {
+ "comment" : "Why a Logitech device isn't supported"
+ },
+ "No mice yet. Connected Logitech mice appear here." : {
+
+ },
+ "No mouse is connected yet, so this sets up the %@. Other mice keep their own setup." : {
+
+ },
+ "No other model has a setup yet." : {
+ },
+ "No programmable buttons" : {
+ "comment" : "Why a Logitech device isn't supported"
},
"None" : {
+ },
+ "Not a mouse; Upright only configures mice" : {
+ "comment" : "Why a Logitech device isn't supported"
+ },
+ "Not answering" : {
+
},
"not connected" : {
},
"Not connected" : {
+ },
+ "Not Connected" : {
+
+ },
+ "Not connected yet. You can set it up now; changes apply as soon as one connects." : {
+
+ },
+ "Not connected. Changes are saved and applied as soon as it connects." : {
+
},
"Not needed right now" : {
+ },
+ "Not supported" : {
+
},
"Nothing yet." : {
},
- "Notifies you when the mouse drops to 15% and again at 5%." : {
+ "Notifies you when a mouse drops to 15% and again at 5%." : {
},
"OK" : {
},
- "Only needed if macOS hides the mouse from Upright, which can happen over Bluetooth. Upright then shows the MX Vertical as blocked and its buttons keep their normal behavior until you allow it." : {
+ "Older HID++ %d.%d device; Upright needs HID++ 2.0" : {
+ "comment" : "Why a Logitech device isn't supported; %d.%d is its protocol version",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Older HID++ %1$d.%2$d device; Upright needs HID++ 2.0"
+ }
+ }
+ }
+ },
+ "Only needed if macOS hides a mouse from Upright, which can happen over Bluetooth. Upright then shows it as blocked and its buttons keep their normal behavior until you allow it." : {
},
- "Only while the MX Vertical is connected" : {
+ "Only while a Logitech mouse is connected" : {
},
"Open %@" : {
@@ -707,9 +1144,6 @@
},
"Open URL…" : {
- },
- "Open-source button remapping for the Logitech MX Vertical, without Logi Options+." : {
-
},
"Opens %@" : {
"comment" : "Button action tooltip; %@ is a URL"
@@ -730,12 +1164,24 @@
},
"Opens button settings" : {
+ },
+ "Opens this mouse in Settings" : {
+
},
"Option" : {
},
"Other" : {
"comment" : "Kind of firmware entry"
+ },
+ "Other Logitech devices (%lld)" : {
+
+ },
+ "Other Mice" : {
+
+ },
+ "Other Models" : {
+
},
"Override" : {
@@ -752,7 +1198,7 @@
"Paused — mouse defaults" : {
},
- "Paused until the MX Vertical connects." : {
+ "Paused until a Logitech mouse connects." : {
},
"Permissions" : {
@@ -761,10 +1207,10 @@
"Play / Pause" : {
"comment" : "Built-in action name"
},
- "Plug in the Unifying receiver, connect the USB cable, or pair over Bluetooth. Then click a mouse button to wake it." : {
+ "Plug in a Logitech Unifying, Bolt or Nano receiver, connect the USB cable, or pair the mouse over Bluetooth. Then click a mouse button to wake it." : {
},
- "Plug in the Unifying receiver, connect the USB cable, or pair the mouse over Bluetooth. Then click a mouse button to wake it." : {
+ "Plug in its receiver, connect the USB cable, or pair the mouse over Bluetooth. Then click a mouse button to wake it. Upright works with several mice at once." : {
},
"Pointer speed" : {
@@ -775,10 +1221,16 @@
},
"Pointer Speed %lld DPI" : {
"comment" : "Action name; %lld is the sensor resolution in DPI"
+ },
+ "Pointer Speed: no mouse is connected" : {
+
},
"Pointer-speed (DPI) button behind the wheel" : {
"comment" : "Mouse control description"
},
+ "Precision Button" : {
+ "comment" : "Mouse control name"
+ },
"Press the scroll wheel" : {
"comment" : "Mouse control description"
},
@@ -788,6 +1240,9 @@
"Previous Track" : {
"comment" : "Built-in action name"
},
+ "Primary click" : {
+ "comment" : "Mouse control description"
+ },
"Privacy" : {
},
@@ -809,7 +1264,13 @@
"Reconnect" : {
},
- "Reconnect Mouse" : {
+ "Reconnect All Mice" : {
+
+ },
+ "Reconnecting" : {
+
+ },
+ "Reconnecting all devices" : {
},
"Record Shortcut" : {
@@ -830,11 +1291,14 @@
"Redo" : {
"comment" : "Built-in action name"
},
- "Released under the MIT License. Logitech, MX Vertical, Unifying, Bolt and Logi Options+ are trademarks of Logitech. Upright is not affiliated with or endorsed by Logitech." : {
+ "Released under the MIT License. Logitech, MX, Unifying, Bolt, Logi Options+ and other Logitech product names are trademarks of Logitech. Upright is not affiliated with or endorsed by Logitech." : {
},
"Remap buttons" : {
+ },
+ "Remap this mouse" : {
+
},
"Remappable" : {
@@ -850,11 +1314,17 @@
},
"Remapping is on" : {
+ },
+ "Remapping is paused for every mouse (menu bar switch)." : {
+
+ },
+ "Remaps Logitech mice over HID++. Built and tested on the MX Vertical." : {
+
},
"Remove %lld DPI" : {
},
- "Remove every button action and app profile." : {
+ "Remove every button action and app profile, for every mouse model." : {
},
"Remove Profile" : {
@@ -868,6 +1338,25 @@
},
"Remove this preset" : {
+ },
+ "Replace Setup" : {
+
+ },
+ "Replace the %@ setup with a copy of the %@ setup?" : {
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Replace the %1$@ setup with a copy of the %2$@ setup?"
+ }
+ }
+ }
+ },
+ "Replace this model’s setup with a copy of another’s" : {
+
+ },
+ "Replaces the presets with four speeds spread across what this mouse supports" : {
+
},
"Required to post the keyboard shortcuts, media keys and clicks your buttons are mapped to, and to change scrolling. Mission Control, opening apps and links, pointer speed and “Do Nothing” work without it." : {
@@ -895,6 +1384,9 @@
},
"Restore Defaults…" : {
+ },
+ "Restore the default button actions of the %@?" : {
+
},
"Restore the default button actions?" : {
@@ -907,12 +1399,18 @@
},
"Reverse vertical scrolling" : {
+ },
+ "Review" : {
+
},
"Right" : {
"comment" : "Short gesture direction"
},
"Right Arrow" : {
+ },
+ "Right Button" : {
+ "comment" : "Mouse control name"
},
"Run “%@”" : {
"comment" : "Button action name; %@ is a Shortcuts.app shortcut name"
@@ -932,17 +1430,35 @@
"Runs the shortcut “%@”" : {
"comment" : "Button action tooltip; %@ is a shortcut name"
},
- "Save them to a file to back them up or move them to another Mac." : {
+ "Save the setup of every mouse model to a file to back it up or move it to another Mac." : {
},
"Screenshot Selection" : {
"comment" : "Built-in action name"
},
+ "Scroll Left (Button 7)" : {
+ "comment" : "Mouse control name"
+ },
+ "Scroll Right (Button 8)" : {
+ "comment" : "Mouse control name"
+ },
"Scrolling" : {
},
"Scrolling speed" : {
+ },
+ "Second Left Click" : {
+ "comment" : "Mouse control name"
+ },
+ "Second left click while the Fn key is held" : {
+ "comment" : "Mouse control description"
+ },
+ "Secondary click" : {
+ "comment" : "Mouse control description"
+ },
+ "Seen Before" : {
+
},
"Sends %@" : {
"comment" : "Button action tooltip; %@ is a key combination such as ⌘C"
@@ -950,17 +1466,39 @@
"Serial number" : {
},
- "Set pointer speed to %lld DPI" : {
-
+ "Set the pointer speed of %@ to %lld DPI" : {
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Set the pointer speed of %1$@ to %2$lld DPI"
+ }
+ }
+ }
},
"Set to %lld DPI" : {
+ },
+ "Set up" : {
+
+ },
+ "Set Up…" : {
+
},
"Sets the pointer speed to %lld DPI (or the nearest speed the mouse supports)" : {
"comment" : "Action tooltip; %lld is the sensor resolution in DPI"
+ },
+ "Setting up your %@. Other mice keep their own setup." : {
+
+ },
+ "Settings for this mouse can’t be remembered: it doesn’t report a serial number." : {
+
},
"Settings…" : {
+ },
+ "Setups in this file" : {
+
},
"Shift" : {
@@ -970,18 +1508,30 @@
},
"Shortcut name" : {
+ },
+ "Show" : {
+
},
"Show battery level in the menu bar" : {
},
"Show Desktop" : {
"comment" : "Built-in action name"
+ },
+ "Shown in the menu bar and in this list instead of the model name" : {
+
},
"Sleep and wake" : {
+ },
+ "SmartShift Button" : {
+ "comment" : "Mouse control name"
},
"Smooth scrolling" : {
+ },
+ "Software gesture button" : {
+ "comment" : "Mouse control description"
},
"Solaar" : {
@@ -1004,6 +1554,9 @@
"Spotlight" : {
"comment" : "Built-in action name"
},
+ "Start Focus Session" : {
+ "comment" : "Name of a Shortcuts.app shortcut shown in Demo Mode"
+ },
"Startup" : {
},
@@ -1020,7 +1573,7 @@
"Steps through the pointer-speed presets" : {
"comment" : "Pointer speed action tooltip"
},
- "Stop reapplying a speed when the mouse reconnects" : {
+ "Stop reapplying a speed when this mouse reconnects" : {
},
"Suggested" : {
@@ -1039,14 +1592,24 @@
"Switch" : {
},
- "Switch the mouse to Computer %lld?" : {
-
+ "Switch %@ to Computer %lld?" : {
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Switch %1$@ to Computer %2$lld?"
+ }
+ }
+ }
},
"Switch to Computer %lld" : {
"comment" : "Action name; %lld is the Easy-Switch channel number"
},
"Switch…" : {
+ },
+ "Switches the scroll wheel mode" : {
+ "comment" : "Mouse control description"
},
"Switching moves the mouse to another paired computer, and it disconnects from this Mac right away. Press the Easy-Switch button under the mouse to come back." : {
@@ -1059,9 +1622,36 @@
},
"That app can’t have a profile" : {
+ },
+ "the %@ goes back" : {
+ "comment" : "Native behavior of a mouse button; the argument is its name"
+ },
+ "the %@ goes forward" : {
+ "comment" : "Native behavior of a mouse button; the argument is its name"
+ },
+ "the %@ is a middle click" : {
+ "comment" : "Native behavior of a mouse button; the argument is its name"
+ },
+ "the %@ scrolls sideways" : {
+ "comment" : "Native behavior of a mouse button; the argument is its name"
+ },
+ "the %@ switches pointer speed (DPI)" : {
+ "comment" : "Native behavior of a mouse button; the argument is its name"
+ },
+ "the %@ switches the wheel between ratchet and free spin" : {
+ "comment" : "Native behavior of a mouse button; the argument is its name"
+ },
+ "The %@’s buttons and app profiles are replaced. Other models aren’t changed." : {
+
},
"The button is ignored" : {
"comment" : "Button action tooltip"
+ },
+ "The buttons and app profiles of these models are replaced with a copy: %@. Buttons a model doesn’t have are ignored on it." : {
+
+ },
+ "The file can’t be imported into that model." : {
+
},
"The file doesn’t contain any mappings Upright can use." : {
"comment" : "Import error"
@@ -1069,6 +1659,9 @@
"The file isn’t an Upright mappings file." : {
"comment" : "Import error"
},
+ "The mouse didn't answer HID++ requests. Try clicking a button to wake it, then choose Reconnect." : {
+ "comment" : "Connection status shown when setup fails"
+ },
"The mouse handles this button itself" : {
"comment" : "Button action tooltip"
},
@@ -1077,24 +1670,64 @@
},
"The mouse will disconnect from this Mac. Press the Easy-Switch button under the mouse to come back." : {
- },
- "The MX Vertical didn't answer HID++ requests. Try clicking a button to wake it, then choose Reconnect." : {
- "comment" : "Connection status shown when setup fails"
},
"The pointer moves while you gesture with this button." : {
},
- "These are the buttons your mouse reports as remappable. “Default” leaves a button to the mouse: the thumb buttons go back and forward, and the top button switches pointer speed (DPI)." : {
+ "These are the buttons a %@ is known to have. Changes are saved and applied as soon as one connects." : {
+
+ },
+ "These are the buttons your %@ reported when it was last connected. Changes are saved and applied as soon as it connects." : {
+
+ },
+ "These are the buttons your %@ reports as remappable." : {
+
+ },
+ "These models now use the setup from the file: %@. Other models kept theirs. Your previous setup was saved; choose Undo Import to put it back." : {
},
- "These entries couldn’t be used and were left out:" : {
+ "They open apps, links or shortcuts when you press the button." : {
+
+ },
+ "This file comes from an earlier version of Upright, which only supported the %@. Its buttons and app profiles replace the setup of the model you choose; other models keep theirs." : {
},
"This is a macOS screenshot shortcut" : {
+ },
+ "This is a second connection to %@. Upright uses its %@ connection and leaves this one as it is." : {
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "This is a second connection to %1$@. Upright uses its %2$@ connection and leaves this one as it is."
+ }
+ }
+ }
},
"This Mac" : {
+ },
+ "This mouse can’t do this, so the button does nothing on it." : {
+
+ },
+ "Thumb button" : {
+ "comment" : "Mouse control description"
+ },
+ "Thumb gesture button" : {
+ "comment" : "Mouse control description"
+ },
+ "Tilt Left" : {
+ "comment" : "Mouse control name"
+ },
+ "Tilt Right" : {
+ "comment" : "Mouse control name"
+ },
+ "Tilt the scroll wheel to the left" : {
+ "comment" : "Mouse control description"
+ },
+ "Tilt the scroll wheel to the right" : {
+ "comment" : "Mouse control description"
},
"Top Button" : {
"comment" : "Mouse control name"
@@ -1131,6 +1764,12 @@
},
"Unifying receiver" : {
"comment" : "How the mouse connects to the Mac"
+ },
+ "Unknown HID interface" : {
+
+ },
+ "Untested model" : {
+
},
"Up" : {
"comment" : "Short gesture direction"
@@ -1144,7 +1783,7 @@
"Upright" : {
},
- "Upright applies this speed again whenever the mouse reconnects. The top button can still change it unless you remap that button." : {
+ "Upright applies this speed again whenever this mouse reconnects. Other mice keep their own. A pointer-speed button on the mouse can still change it unless you remap that button." : {
},
"Upright couldn’t read a bundle identifier from it." : {
@@ -1152,9 +1791,18 @@
},
"Upright couldn’t start changing scrolling. Try turning a setting off and on again." : {
+ },
+ "Upright forgets this mouse’s nickname, pointer speed and remapping switch. The button setup for its model stays." : {
+
+ },
+ "Upright found these but can’t configure them. They keep working as usual." : {
+
},
"Upright isn’t in your Applications folder. Move it there first: if this copy is moved or deleted, it won’t open at login." : {
+ },
+ "Upright keeps each mouse’s name, pointer speed and remapping switch for when it comes back." : {
+
},
"Upright Mappings.json" : {
@@ -1165,7 +1813,7 @@
"Upright needs Accessibility access to send keyboard shortcuts, media keys and clicks for your mouse buttons. It doesn't read what you type." : {
},
- "Upright never reads what you type. It only listens to the HID++ channel of Logitech receivers and MX Vertical mice, and only posts the actions you assign." : {
+ "Upright never reads what you type. It only listens to the HID++ channel of Logitech receivers and supported Logitech mice, and only posts the actions you assign." : {
},
"Upright only uses temporary button diversion, so nothing is written to the mouse’s memory. Turning the mouse off and on always restores its factory behavior." : {
@@ -1174,29 +1822,44 @@
"Upright sends keyboard shortcuts, media keys and clicks on behalf of your mouse. Until you allow it, mapped buttons keep their normal behavior." : {
},
- "Upright talks to the mouse with the publicly documented HID++ 2.0 protocol, using temporary button diversion from the REPROG_CONTROLS_V4 feature (0x1B04). It does not include or depend on any Logitech software." : {
+ "Upright talks to your mouse with the publicly documented HID++ 2.0 protocol, using temporary button diversion from the REPROG_CONTROLS_V4 feature (0x1B04). It does not include or depend on any Logitech software." : {
+
+ },
+ "Upright was built and tested on the MX Vertical. This model should work, but it hasn’t been tested on real hardware yet. If something is off, copy the device report into an issue on GitHub." : {
},
"URL" : {
+ },
+ "USB" : {
+ "comment" : "Short connection name in a device label"
},
"USB cable" : {
"comment" : "How the mouse connects to the Mac"
},
"Use Default" : {
+ },
+ "Use for All Mice" : {
+
},
"Use Global Action" : {
},
"Use Suggested" : {
+ },
+ "Use the %@ setup for all your mice?" : {
+
},
"Use the global action again" : {
},
"Use this profile" : {
+ },
+ "Use This Setup for All My Mice…" : {
+
},
"Version %@ (%@)" : {
"localizations" : {
@@ -1208,11 +1871,20 @@
}
}
},
+ "Virtual Gesture Button" : {
+ "comment" : "Mouse control name"
+ },
"Volume Down" : {
"comment" : "Built-in action name"
},
"Volume Up" : {
"comment" : "Built-in action name"
+ },
+ "Waiting" : {
+
+ },
+ "Watching for Logitech mice over USB, Unifying, Bolt and Nano receivers, and Bluetooth" : {
+
},
"Welcome to Upright" : {
@@ -1220,7 +1892,7 @@
"Wheel Click" : {
"comment" : "Mouse control name"
},
- "When off, every button goes back to what the mouse does on its own." : {
+ "When off, every button of every mouse goes back to what the mouse does on its own." : {
},
"When Upright quits" : {
@@ -1231,14 +1903,17 @@
},
"Windows & Spaces" : {
"comment" : "Action category"
+ },
+ "With several mice, the menu bar shows the lowest battery." : {
+
},
"You can change any button later in Settings, and set different actions for individual apps." : {
},
- "You can continue without the mouse; Upright connects as soon as it appears." : {
+ "You can continue without a mouse; Upright connects as soon as one appears." : {
},
- "Your mappings and app profiles now match the file. Your previous setup was saved; choose Undo Import to put it back." : {
+ "Your current setup is saved first, so you can undo the import." : {
},
"Your shortcuts" : {
diff --git a/Upright/Views/AboutPane.swift b/Upright/Views/AboutPane.swift
index 5f95006..e16d6d7 100644
--- a/Upright/Views/AboutPane.swift
+++ b/Upright/Views/AboutPane.swift
@@ -23,7 +23,7 @@ struct AboutPane: View {
Text(version)
.foregroundStyle(.secondary)
.textSelection(.enabled)
- Text("Open-source button remapping for the Logitech MX Vertical, without Logi Options+.")
+ Text("Remaps Logitech mice over HID++. Built and tested on the MX Vertical.")
.multilineTextAlignment(.center)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
@@ -34,7 +34,7 @@ struct AboutPane: View {
}
Section {
- Text("Upright talks to the mouse with the publicly documented HID++ 2.0 protocol, using temporary button diversion from the REPROG_CONTROLS_V4 feature (0x1B04). It does not include or depend on any Logitech software.")
+ Text("Upright talks to your mouse with the publicly documented HID++ 2.0 protocol, using temporary button diversion from the REPROG_CONTROLS_V4 feature (0x1B04). It does not include or depend on any Logitech software.")
.fixedSize(horizontal: false, vertical: true)
link("Solaar", "https://github.com/pwr-Solaar/Solaar")
link("LogiOps", "https://github.com/PixlOne/logiops")
@@ -45,7 +45,7 @@ struct AboutPane: View {
}
Section {
- Text("Released under the MIT License. Logitech, MX Vertical, Unifying, Bolt and Logi Options+ are trademarks of Logitech. Upright is not affiliated with or endorsed by Logitech.")
+ Text("Released under the MIT License. Logitech, MX, Unifying, Bolt, Logi Options+ and other Logitech product names are trademarks of Logitech. Upright is not affiliated with or endorsed by Logitech.")
.font(.callout)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
diff --git a/Upright/Views/ActionPicker.swift b/Upright/Views/ActionPicker.swift
index b7ee576..682b9b1 100644
--- a/Upright/Views/ActionPicker.swift
+++ b/Upright/Views/ActionPicker.swift
@@ -7,11 +7,16 @@ import UniformTypeIdentifiers
///
/// With `allowsGestures` off (gesture slots), "Gestures…" is hidden and so is "Default": a slot's
/// button is always diverted, so `.deviceDefault` there means the same as "Do Nothing".
+///
+/// The pointer-speed and Easy-Switch items follow the mouse being set up (`actionTarget` in the
+/// environment): its Easy-Switch channel count, whether it has adjustable pointer speed, and its
+/// speed range.
struct ActionPicker: View {
@Binding var selection: ButtonAction
let allowsGestures: Bool
@ObservedObject private var settings = AppModel.shared.settings
+ @Environment(\.actionTarget) private var target
@State private var isRecording = false
@State private var wantsShortcut = false
@State private var textEntry: TextEntryKind?
@@ -72,24 +77,26 @@ struct ActionPicker: View {
}
}
- Menu("Pointer Speed") {
- Picker(selection: $selection) {
- Label("Cycle Pointer Speed", systemImage: "speedometer").tag(ButtonAction.device(.cycleDPI))
- ForEach(settings.dpiPresets, id: \.self) { dpi in
- Text("Set to \(dpi) DPI").tag(ButtonAction.device(.setDPI(dpi)))
+ if target.supportsPointerSpeed {
+ Menu("Pointer Speed") {
+ Picker(selection: $selection) {
+ Label("Cycle Pointer Speed", systemImage: "speedometer").tag(ButtonAction.device(.cycleDPI))
+ ForEach(presets, id: \.self) { dpi in
+ Text("Set to \(dpi) DPI").tag(ButtonAction.device(.setDPI(dpi)))
+ }
+ } label: {
+ Text("Pointer Speed")
}
- } label: {
- Text("Pointer Speed")
+ .pickerStyle(.inline)
+ .labelsHidden()
+ Divider()
+ Button("Edit Presets…") { AppModel.shared.selectedPane = .device }
}
- .pickerStyle(.inline)
- .labelsHidden()
- Divider()
- Button("Edit Presets…") { AppModel.shared.selectedPane = .device }
}
Menu("Easy-Switch") {
Picker(selection: $selection) {
- ForEach(Array(DeviceAction.hostRange), id: \.self) { host in
+ ForEach(Array(target.hostRange), id: \.self) { host in
Text("Switch to Computer \(host)").tag(ButtonAction.device(.switchHost(host)))
}
} label: {
@@ -136,7 +143,7 @@ struct ActionPicker: View {
.lineLimit(1)
}
.fixedSize()
- .help(selection.helpText)
+ .help(helpText)
.accessibilityLabel(Text("Action"))
.accessibilityValue(Text(displayTitle))
}
@@ -160,13 +167,27 @@ struct ActionPicker: View {
return selection.symbolName
}
+ /// The pointer-speed presets this mouse can be set to.
+ private var presets: [Int] {
+ settings.dpiPresets.filter(target.allowsDPI)
+ }
+
+ /// The action's help, plus a warning when the mouse being set up can't do it.
+ private var helpText: String {
+ guard !target.supports(selection) else { return selection.helpText }
+ let warning = target.deviceName.map { String(localized: "\($0) can’t do this, so the button does nothing on it.") }
+ ?? String(localized: "This mouse can’t do this, so the button does nothing on it.")
+ return selection.helpText + "\n" + warning
+ }
+
/// Whether the menu already has a checkable item for the action.
private func isListed(_ action: ButtonAction) -> Bool {
switch action {
case .deviceDefault: return allowsGestures
case .disabled, .system: return true
- case .device(.cycleDPI), .device(.switchHost): return true
- case .device(.setDPI(let dpi)): return settings.dpiPresets.contains(dpi)
+ case .device(.cycleDPI): return target.supportsPointerSpeed
+ case .device(.switchHost(let host)): return target.hostRange.contains(host)
+ case .device(.setDPI(let dpi)): return target.supportsPointerSpeed && presets.contains(dpi)
case .keyCombo, .launchApp, .openURL, .runShortcut, .gestures: return false
}
}
@@ -180,6 +201,70 @@ struct ActionPicker: View {
}
}
+// MARK: The mouse being set up
+
+/// What the mouse an action is picked for can do, for the pointer-speed and Easy-Switch items.
+/// Unknown values (no mouse of the model here) offer everything, as before multi-mouse.
+struct ActionTarget: Equatable {
+ /// The mouse's name, for help text; nil when no mouse is known.
+ var deviceName: String?
+ /// Its Easy-Switch channels; nil when unknown (3, like every Easy-Switch mouse).
+ var hostCount: Int?
+ /// Its pointer-speed range (0x2201); nil when unknown or not adjustable.
+ var dpi: DPIInfo?
+ /// False hides the Pointer Speed items (the mouse has no 0x2201).
+ var supportsPointerSpeed = true
+
+ static let unknown = ActionTarget()
+
+ /// The channels "Switch to Computer N" can name.
+ var hostRange: ClosedRange {
+ let upper = min(max(hostCount ?? DeviceAction.hostRange.upperBound, 1), DeviceAction.hostRange.upperBound)
+ return DeviceAction.hostRange.lowerBound...upper
+ }
+
+ /// Whether "Set to N DPI" is within the mouse's range (any valid speed when unknown).
+ func allowsDPI(_ value: Int) -> Bool {
+ guard DeviceAction.dpiRange.contains(value) else { return false }
+ guard let dpi, !dpi.supported.isEmpty else { return true }
+ return (dpi.minimum...dpi.maximum).contains(value)
+ }
+
+ /// Whether the mouse can carry out an action (only device actions can be out of reach).
+ func supports(_ action: ButtonAction) -> Bool {
+ switch action {
+ case .device(.cycleDPI): return supportsPointerSpeed
+ case .device(.setDPI(let value)): return supportsPointerSpeed && allowsDPI(value)
+ case .device(.switchHost(let host)): return hostRange.contains(host)
+ default: return true
+ }
+ }
+
+ /// What a connected mouse reports; `.unknown` without one.
+ init(snapshot: DeviceSnapshot?, name: String? = nil) {
+ guard let snapshot else { return }
+ deviceName = name ?? snapshot.name
+ hostCount = snapshot.hosts?.count
+ dpi = snapshot.dpi
+ supportsPointerSpeed = snapshot.dpi != nil
+ || snapshot.features.contains { $0.featureID == HIDPPFeature.adjustableDPI.rawValue }
+ }
+
+ init() {}
+}
+
+private struct ActionTargetKey: EnvironmentKey {
+ static let defaultValue = ActionTarget.unknown
+}
+
+extension EnvironmentValues {
+ /// The mouse the action pickers below are picking for.
+ var actionTarget: ActionTarget {
+ get { self[ActionTargetKey.self] }
+ set { self[ActionTargetKey.self] = newValue }
+ }
+}
+
// MARK: Choosing apps
enum AppChooser {
diff --git a/Upright/Views/ButtonRow.swift b/Upright/Views/ButtonRow.swift
index e8edd99..f55458c 100644
--- a/Upright/Views/ButtonRow.swift
+++ b/Upright/Views/ButtonRow.swift
@@ -1,6 +1,7 @@
import SwiftUI
-/// One remappable button in the global table: what it is, what it does, and a picker to change that.
+/// One remappable button in a model's global table: what it is, what it does, and a picker to
+/// change that. `control` carries the model's own wording (`ControlDescriptor(cid:model:)`).
struct ButtonRow: View {
let control: ControlDescriptor
@Binding var action: ButtonAction
@@ -8,11 +9,16 @@ struct ButtonRow: View {
var isHighlighted = false
var note: Text?
+ @Environment(\.actionTarget) private var target
+
var body: some View {
ControlRowLayout(control: control, isHighlighted: isHighlighted, note: note) {
if showsAccessibilityWarning && action.requiresAccessibility {
AccessibilityWarningIcon()
}
+ if !target.supports(action) {
+ UnsupportedActionIcon(deviceName: target.deviceName)
+ }
ActionPicker(selection: $action)
}
}
@@ -28,12 +34,17 @@ struct AppOverrideRow: View {
var isHighlighted = false
var note: Text?
+ @Environment(\.actionTarget) private var target
+
var body: some View {
ControlRowLayout(control: control, isHighlighted: isHighlighted, note: note) {
if let current = override {
if showsAccessibilityWarning && current.requiresAccessibility {
AccessibilityWarningIcon()
}
+ if !target.supports(current) {
+ UnsupportedActionIcon(deviceName: target.deviceName)
+ }
ActionPicker(selection: Binding(
get: { override ?? current },
set: { override = $0 }
@@ -108,3 +119,23 @@ struct AccessibilityWarningIcon: View {
.accessibilityLabel(Text("Needs Accessibility access"))
}
}
+
+/// Shown next to a device action the mouse being set up can't carry out (an Easy-Switch channel
+/// it doesn't have, a speed outside its range, or pointer speed on a mouse without it).
+struct UnsupportedActionIcon: View {
+ let deviceName: String?
+
+ var body: some View {
+ Image(systemName: "exclamationmark.circle")
+ .foregroundStyle(.orange)
+ .help(help)
+ .accessibilityLabel(Text(help))
+ }
+
+ private var help: String {
+ if let deviceName {
+ return String(localized: "\(deviceName) can’t do this, so the button does nothing on it.")
+ }
+ return String(localized: "This mouse can’t do this, so the button does nothing on it.")
+ }
+}
diff --git a/Upright/Views/ButtonsPane.swift b/Upright/Views/ButtonsPane.swift
index e53439b..a7b1666 100644
--- a/Upright/Views/ButtonsPane.swift
+++ b/Upright/Views/ButtonsPane.swift
@@ -1,35 +1,61 @@
import SwiftUI
+/// Sets up the buttons of one mouse model ("Configure: [model ▾]"): its global table, its
+/// app profiles and its gesture buttons. Every identical mouse shares its model's setup, and
+/// editing one model never changes another model's table: every binding here names the model
+/// it edits, captured when the row is built.
struct ButtonsPane: View {
@EnvironmentObject private var model: AppModel
@EnvironmentObject private var devices: DeviceManager
@EnvironmentObject private var permissions: PermissionsMonitor
- /// nil edits the global table; otherwise the app profile with this bundle ID.
- @State private var profileBundleID: String?
+ /// nil edits the global table; otherwise the app profile with this bundle ID. A snapshot
+ /// export can start the pane on an app profile (`DemoMode.snapshotProfileBundleID`).
+ @State private var profileBundleID: String? = DemoMode.isExporting ? DemoMode.snapshotProfileBundleID : nil
@State private var confirmation: Confirmation?
@State private var highlightedControl: UInt16?
@State private var highlightGeneration = 0
private enum Confirmation: Identifiable {
- case restoreDefaults
- case clearOverrides(String)
+ case restoreDefaults(ModelKey)
+ case clearOverrides(ModelKey, String)
+ case copySetup(from: ModelKey, to: ModelKey)
+ case applyToAll(from: ModelKey, targets: [ModelKey])
var id: String {
switch self {
- case .restoreDefaults: return "restore"
- case .clearOverrides(let bundleID): return "clear." + bundleID
+ case .restoreDefaults(let model): return "restore." + model.rawValue
+ case .clearOverrides(let model, let bundleID): return "clear." + model.rawValue + "." + bundleID
+ case .copySetup(let source, let target): return "copy." + source.rawValue + "." + target.rawValue
+ case .applyToAll(let source, _): return "all." + source.rawValue
}
}
}
+ private struct RowID: Hashable {
+ let model: ModelKey
+ let cid: UInt16
+ }
+
+ /// Where the rows of the selected model come from.
+ private enum ControlSource {
+ /// A mouse of the model is here and reported them.
+ case live
+ /// A mouse of the model reported them last time it was connected.
+ case remembered
+ /// The catalog's list for the model.
+ case catalog
+ }
+
var body: some View {
+ let selected = model.selectedModel
+ let controls = model.controls(for: selected)
Form {
Section {
- DeviceHeader()
+ header(for: selected)
}
- if devices.status == .accessDenied {
+ if devices.needsInputMonitoring {
Section {
InputMonitoringCallout()
}
@@ -47,11 +73,17 @@ struct ButtonsPane: View {
}
}
- ProfileSection(selectedBundleID: $profileBundleID)
+ ProfileSection(model: selected, selectedBundleID: $profileBundleID)
Section {
- ForEach(devices.controls) { control in
- row(for: control)
+ if controls.isEmpty {
+ emptyState
+ } else {
+ ForEach(controls) { control in
+ // A fresh row per model, so no picker state carries over to another model.
+ row(for: control, model: selected)
+ .id(RowID(model: selected, cid: control.cid))
+ }
}
} header: {
if let profile = selectedProfile {
@@ -60,24 +92,32 @@ struct ButtonsPane: View {
Text("Buttons")
}
} footer: {
- Text(footer)
+ Text(footer(for: selected, controls: controls))
.font(.callout)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
- ForEach(gestureControls) { control in
- if let gestures = gestureBinding(for: control.cid) {
+ ForEach(gestureControls(in: controls, model: selected)) { control in
+ if let gestures = gestureBinding(for: control.cid, model: selected) {
GestureEditor(gestures: gestures, title: control.name)
+ .id(RowID(model: selected, cid: control.cid))
}
}
Section {
Toggle("Remap buttons", isOn: $model.isRemappingEnabled)
- resetRow
+ resetRow(for: selected)
+ }
+
+ Section {
+ sharingRow(for: selected)
+ } header: {
+ Text("Other Mice")
}
}
.formStyle(.grouped)
+ .environment(\.actionTarget, actionTarget(for: selected))
.confirmationDialog(
confirmationTitle,
isPresented: Binding(get: { confirmation != nil }, set: { if !$0 { confirmation = nil } }),
@@ -85,94 +125,187 @@ struct ButtonsPane: View {
presenting: confirmation
) { item in
switch item {
- case .restoreDefaults:
- Button("Restore Defaults", role: .destructive) { model.restoreDefaultMappings() }
- case .clearOverrides(let bundleID):
- Button("Clear Overrides", role: .destructive) { model.profiles[app: bundleID]?.overrides = [:] }
+ case .restoreDefaults(let target):
+ Button("Restore Defaults", role: .destructive) { model.restoreDefaultMappings(model: target) }
+ case .clearOverrides(let target, let bundleID):
+ Button("Clear Overrides", role: .destructive) { model.library[target][app: bundleID]?.overrides = [:] }
+ case .copySetup(let source, let target):
+ Button("Replace Setup", role: .destructive) {
+ profileBundleID = nil
+ model.copySetup(from: source, to: target)
+ }
+ case .applyToAll(let source, _):
+ Button("Use for All Mice", role: .destructive) { model.applySetupToAllModels(from: source) }
}
} message: { item in
- switch item {
- case .restoreDefaults:
- Text("Every button goes back to what the mouse does on its own. App profiles are kept.")
- case .clearOverrides:
- Text("Every button in this app uses the global action again.")
- }
+ confirmationMessage(item)
}
.onChange(of: model.lastPressedControl) { pressed in
- guard let pressed else { return }
+ // Only presses of a mouse of the model on screen light up a row.
+ guard let pressed, pressed.model == model.selectedModel else { return }
flash(pressed.cid)
}
+ .onChange(of: model.selectedModel) { newModel in
+ highlightedControl = nil
+ // Keep the same app selected if the new model has a profile for it.
+ if let bundleID = profileBundleID, model.library[newModel][app: bundleID] == nil {
+ profileBundleID = nil
+ }
+ }
+ }
+
+ // MARK: Header
+
+ /// The model picker, what is connected of that model, and who the setup applies to.
+ private func header(for selected: ModelKey) -> some View {
+ HStack(alignment: .top, spacing: 12) {
+ SymbolTile(systemName: liveRecords(of: selected).isEmpty ? "computermouse" : "computermouse.fill", size: 32)
+ VStack(alignment: .leading, spacing: 4) {
+ ModelPicker(selection: $model.selectedModel)
+ .snapshotAnchor("modelPicker")
+ Text(modelStatus(for: selected))
+ .font(.callout)
+ .foregroundStyle(.secondary)
+ .fixedSize(horizontal: false, vertical: true)
+ if unitCount(of: selected) > 1 {
+ Label {
+ Text("Applies to every \(model.modelName(selected))")
+ } icon: {
+ Image(systemName: "square.on.square")
+ }
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .snapshotAnchor("appliesToEvery")
+ }
+ }
+ }
+ .padding(.vertical, 2)
+ }
+
+ /// Endpoints of a model that are identified (connected or asleep) and not unsupported.
+ private func liveRecords(of key: ModelKey) -> [DeviceRegistry.Record] {
+ devices.records.filter { $0.model == key && $0.role != .unsupported && $0.snapshot != nil }
+ }
+
+ /// Mice of a model being served now; a mouse on two links (standby) counts once.
+ private func unitCount(of key: ModelKey) -> Int {
+ devices.records.filter { $0.model == key && $0.role == .active }.count
+ }
+
+ private func modelStatus(for key: ModelKey) -> String {
+ let records = liveRecords(of: key).filter { if case .standby = $0.role { return false } else { return true } }
+ let connected = records.filter(\.isConnected).map(\.displayName)
+ if !connected.isEmpty {
+ return String(localized: "Connected: \(ListFormatter.localizedString(byJoining: connected))")
+ }
+ if !records.isEmpty {
+ return String(localized: "Asleep: \(ListFormatter.localizedString(byJoining: records.map(\.displayName)))")
+ }
+ if model.rememberedModels.contains(key) {
+ return String(localized: "Not connected. Changes are saved and applied as soon as it connects.")
+ }
+ return String(localized: "Not connected yet. You can set it up now; changes apply as soon as one connects.")
}
// MARK: Rows
@ViewBuilder
- private func row(for control: ControlDescriptor) -> some View {
+ private func row(for control: ControlDescriptor, model selected: ModelKey) -> some View {
let highlighted = highlightedControl == control.cid
+ let note = gestureNote(for: control.cid, model: selected)
+ let anchor = String(format: "row.%04X", Int(control.cid))
if let profile = selectedProfile {
- AppOverrideRow(
+ let overrideRow = AppOverrideRow(
control: control,
- override: model.overrideBinding(for: control.cid, bundleID: profile.bundleID),
- globalAction: model.profiles.global[control.cid],
+ override: model.overrideBinding(for: control.cid, model: selected, bundleID: profile.bundleID),
+ globalAction: model.library[selected].global[control.cid],
showsAccessibilityWarning: !permissions.isAccessibilityTrusted,
isHighlighted: highlighted,
- note: gestureNote(for: control.cid)
+ note: note
)
+ .snapshotAnchor(anchor)
+ if profile[control.cid] != nil {
+ overrideRow.snapshotAnchor(String(format: "override.%04X", Int(control.cid)))
+ } else {
+ overrideRow
+ }
} else {
ButtonRow(
control: control,
- action: model.binding(for: control.cid),
+ action: model.binding(for: control.cid, model: selected),
showsAccessibilityWarning: !permissions.isAccessibilityTrusted,
isHighlighted: highlighted,
- note: gestureNote(for: control.cid)
+ note: note
)
+ .snapshotAnchor(anchor)
}
}
+ private var emptyState: some View {
+ VStack(spacing: 6) {
+ Image(systemName: "computermouse")
+ .font(.system(size: 28))
+ .foregroundStyle(.secondary)
+ .accessibilityHidden(true)
+ Text("Connect a Logitech mouse")
+ .font(.headline)
+ Text("Its buttons appear here as soon as it connects.")
+ .font(.callout)
+ .foregroundStyle(.secondary)
+ .multilineTextAlignment(.center)
+ }
+ .frame(maxWidth: .infinity)
+ .padding(.vertical, 12)
+ }
+
+ /// The selected app profile of the model on screen.
private var selectedProfile: AppProfile? {
- profileBundleID.flatMap { model.profiles[app: $0] }
+ profileBundleID.flatMap { model.library[model.selectedModel][app: $0] }
}
- /// The action being edited for a button: the global one, or the selected app's override.
- private func editedAction(for cid: UInt16) -> ButtonAction? {
- if let profile = selectedProfile { return profile[cid] }
- return model.profiles.global[cid]
+ /// The action being edited for a button of a model: the global one, or the selected app's override.
+ private func editedAction(for cid: UInt16, model selected: ModelKey) -> ButtonAction? {
+ if let bundleID = selectedProfile?.bundleID { return model.library[selected][app: bundleID]?[cid] }
+ return model.library[selected].global[cid]
}
- /// Gesture buttons whose movement can't be read without moving the pointer say so.
- private func gestureNote(for cid: UInt16) -> Text? {
- guard editedAction(for: cid)?.isGestures == true, !model.supportsRawXY(cid) else { return nil }
+ /// Gesture buttons whose movement can't be read without moving the pointer say so (judged on
+ /// the selected mouse of the model, else the first one here).
+ private func gestureNote(for cid: UInt16, model selected: ModelKey) -> Text? {
+ guard editedAction(for: cid, model: selected)?.isGestures == true, !model.supportsRawXY(cid, model: selected) else { return nil }
return Text("The pointer moves while you gesture with this button.")
}
- private var gestureControls: [ControlDescriptor] {
- devices.controls.filter { editedAction(for: $0.cid)?.isGestures == true }
+ private func gestureControls(in controls: [ControlDescriptor], model selected: ModelKey) -> [ControlDescriptor] {
+ controls.filter { editedAction(for: $0.cid, model: selected)?.isGestures == true }
}
- private func gestureBinding(for cid: UInt16) -> Binding? {
- guard case .gestures(let current) = editedAction(for: cid) else { return nil }
+ private func gestureBinding(for cid: UInt16, model selected: ModelKey) -> Binding? {
+ guard case .gestures(let current) = editedAction(for: cid, model: selected) else { return nil }
let bundleID = selectedProfile?.bundleID
return Binding(
get: {
- let action: ButtonAction?
- if let bundleID {
- action = model.profiles[app: bundleID]?[cid]
- } else {
- action = model.profiles.global[cid]
- }
+ let action = bundleID.map { model.library[selected][app: $0]?[cid] } ?? model.library[selected].global[cid]
if case .gestures(let set) = action { return set }
return current
},
set: { newValue in
if let bundleID {
- model.profiles[app: bundleID]?[cid] = .gestures(newValue)
+ model.library[selected][app: bundleID]?[cid] = .gestures(newValue)
} else {
- model.profiles.global[cid] = .gestures(newValue)
+ model.library[selected].global[cid] = .gestures(newValue)
}
}
)
}
+ /// What the mouse being set up can do: the selected mouse if it is of this model, else the
+ /// first one here; unknown (everything offered) when none is.
+ private func actionTarget(for selected: ModelKey) -> ActionTarget {
+ guard let address = model.representativeDevice(of: selected) else { return .unknown }
+ return ActionTarget(snapshot: model.snapshot(of: address), name: model.record(address)?.displayName)
+ }
+
// MARK: Live feedback
private func flash(_ cid: UInt16) {
@@ -185,7 +318,7 @@ struct ButtonsPane: View {
}
}
- // MARK: Notes and footer
+ // MARK: Notes, reset and sharing
private var remappingOffNote: some View {
HStack(spacing: 10) {
@@ -208,14 +341,14 @@ struct ButtonsPane: View {
}
@ViewBuilder
- private var resetRow: some View {
+ private func resetRow(for selected: ModelKey) -> some View {
if let profile = selectedProfile {
HStack {
Text("Clear this app’s overrides so every button uses the global action.")
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
Spacer()
- Button("Clear Overrides…") { confirmation = .clearOverrides(profile.bundleID) }
+ Button("Clear Overrides…") { confirmation = .clearOverrides(selected, profile.bundleID) }
.disabled(profile.overrides.isEmpty)
}
} else {
@@ -224,28 +357,129 @@ struct ButtonsPane: View {
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
Spacer()
- Button("Restore Defaults…") { confirmation = .restoreDefaults }
- .disabled(model.mappings == .defaults)
+ Button("Restore Defaults…") { confirmation = .restoreDefaults(selected) }
+ .disabled(model.library[selected].global == .defaults)
}
}
}
+ /// "Copy setup from…" and "Use this setup for all my mice".
+ @ViewBuilder
+ private func sharingRow(for selected: ModelKey) -> some View {
+ let sources = model.library.models
+ .filter { $0.key != selected && !$0.value.isEmpty }
+ .map(\.key)
+ .sorted { model.modelName($0).localizedStandardCompare(model.modelName($1)) == .orderedAscending }
+ let targets = model.knownModels.filter { $0 != selected }
+ HStack {
+ Text("Copy another model’s buttons and app profiles to this one.")
+ .foregroundStyle(.secondary)
+ .fixedSize(horizontal: false, vertical: true)
+ Spacer()
+ Menu("Copy Setup From") {
+ ForEach(sources, id: \.self) { source in
+ Button(model.modelName(source)) {
+ confirmation = .copySetup(from: source, to: selected)
+ }
+ }
+ }
+ .fixedSize()
+ .disabled(sources.isEmpty)
+ .help(sources.isEmpty ? Text("No other model has a setup yet.") : Text("Replace this model’s setup with a copy of another’s"))
+ }
+ HStack {
+ Text("Give every other mouse you use the same buttons and app profiles.")
+ .foregroundStyle(.secondary)
+ .fixedSize(horizontal: false, vertical: true)
+ Spacer()
+ Button("Use This Setup for All My Mice…") {
+ confirmation = .applyToAll(from: selected, targets: targets)
+ }
+ .disabled(targets.isEmpty)
+ }
+ }
+
private var confirmationTitle: Text {
switch confirmation {
- case .clearOverrides(let bundleID):
- return Text("Clear the overrides for \(model.profiles[app: bundleID]?.displayName ?? bundleID)?")
- case .restoreDefaults, nil:
+ case .clearOverrides(let target, let bundleID):
+ return Text("Clear the overrides for \(model.library[target][app: bundleID]?.displayName ?? bundleID)?")
+ case .copySetup(let source, let target):
+ return Text("Replace the \(model.modelName(target)) setup with a copy of the \(model.modelName(source)) setup?")
+ case .applyToAll(let source, _):
+ return Text("Use the \(model.modelName(source)) setup for all your mice?")
+ case .restoreDefaults(let target):
+ return Text("Restore the default button actions of the \(model.modelName(target))?")
+ case nil:
return Text("Restore the default button actions?")
}
}
- private var footer: String {
+ private func confirmationMessage(_ item: Confirmation) -> Text {
+ switch item {
+ case .restoreDefaults:
+ return Text("Every button goes back to what the mouse does on its own. App profiles are kept. Other models aren’t changed.")
+ case .clearOverrides:
+ return Text("Every button in this app uses the global action again.")
+ case .copySetup(_, let target):
+ return Text("The \(model.modelName(target))’s buttons and app profiles are replaced. Other models aren’t changed.")
+ case .applyToAll(_, let targets):
+ let names = ListFormatter.localizedString(byJoining: targets.map(model.modelName))
+ return Text("The buttons and app profiles of these models are replaced with a copy: \(names). Buttons a model doesn’t have are ignored on it.")
+ }
+ }
+
+ // MARK: Footer
+
+ private func footer(for selected: ModelKey, controls: [ControlDescriptor]) -> String {
if selectedProfile != nil {
return String(localized: "Buttons without an override use the global action. Override with “Default” to leave a button to the mouse in this app only.")
}
- if devices.snapshot != nil {
- return String(localized: "These are the buttons your mouse reports as remappable. “Default” leaves a button to the mouse: the thumb buttons go back and forward, and the top button switches pointer speed (DPI).")
+ guard !controls.isEmpty else {
+ return String(localized: "Changes are saved and applied as soon as a mouse of this model connects.")
+ }
+ let name = model.modelName(selected)
+ let intro: String
+ switch controlSource(for: selected) {
+ case .live:
+ intro = String(localized: "These are the buttons your \(name) reports as remappable.")
+ case .remembered:
+ intro = String(localized: "These are the buttons your \(name) reported when it was last connected. Changes are saved and applied as soon as it connects.")
+ case .catalog:
+ intro = String(localized: "These are the buttons a \(name) is known to have. Changes are saved and applied as soon as one connects.")
+ }
+ let natives = controls.compactMap(Self.nativeBehavior)
+ let defaultNote = natives.isEmpty
+ ? String(localized: "“Default” leaves a button to the mouse.")
+ : String(localized: "“Default” leaves a button to the mouse: \(ListFormatter.localizedString(byJoining: natives)).")
+ return intro + " " + defaultNote
+ }
+
+ private func controlSource(for selected: ModelKey) -> ControlSource {
+ if !liveRecords(of: selected).isEmpty { return .live }
+ let remembered = model.settings.devicePreferences.devices.values.contains {
+ $0.lastModel == selected && !$0.lastControls.isEmpty
+ }
+ return remembered ? .remembered : .catalog
+ }
+
+ /// What a control does on its own, in the model's wording ("the Top Button switches pointer
+ /// speed (DPI)"); nil when it varies by mouse.
+ private static func nativeBehavior(of control: ControlDescriptor) -> String? {
+ switch control.cid {
+ case KnownControl.back, 0x0054, 0x0055, 0x00AC, 0x00CE:
+ return String(localized: "the \(control.name) goes back", comment: "Native behavior of a mouse button; the argument is its name")
+ case KnownControl.forward, 0x0057, 0x0058, 0x00CF:
+ return String(localized: "the \(control.name) goes forward", comment: "Native behavior of a mouse button; the argument is its name")
+ case KnownControl.dpiSwitch, KnownControl.dpiChange:
+ return String(localized: "the \(control.name) switches pointer speed (DPI)", comment: "Native behavior of a mouse button; the argument is its name")
+ case KnownControl.middleClick:
+ return String(localized: "the \(control.name) is a middle click", comment: "Native behavior of a mouse button; the argument is its name")
+ case KnownControl.tiltLeft, KnownControl.tiltRight:
+ return String(localized: "the \(control.name) scrolls sideways", comment: "Native behavior of a mouse button; the argument is its name")
+ case KnownControl.smartShift:
+ return String(localized: "the \(control.name) switches the wheel between ratchet and free spin", comment: "Native behavior of a mouse button; the argument is its name")
+ default:
+ return nil
}
- return String(localized: "Connect your MX Vertical to see every button it reports. Changes are saved and applied as soon as it connects.")
}
}
diff --git a/Upright/Views/Components.swift b/Upright/Views/Components.swift
index c764f74..319fe33 100644
--- a/Upright/Views/Components.swift
+++ b/Upright/Views/Components.swift
@@ -151,42 +151,232 @@ struct RowButtonStyle: ButtonStyle {
}
}
-/// Large header describing the mouse and its connection.
+/// Large header describing one mouse and its connection.
+///
+/// `DeviceHeader(record:)` shows that endpoint. `DeviceHeader()` shows the mouse selected in the
+/// Mice pane, else the first connected one; with no mouse at all it falls back to what the
+/// interfaces are doing (`DeviceManager.status`: searching, a receiver waiting for its mouse,
+/// macOS blocking access). That fallback is the only place views read the compat status.
struct DeviceHeader: View {
+ @EnvironmentObject private var model: AppModel
@EnvironmentObject private var devices: DeviceManager
+ private let fixedRecord: DeviceRegistry.Record?
+ private let followsSelection: Bool
+
+ /// The selected mouse, else the primary one, else the interfaces' status.
+ init() {
+ fixedRecord = nil
+ followsSelection = true
+ }
+
+ /// One endpoint; nil shows the interfaces' status.
+ init(record: DeviceRegistry.Record?) {
+ fixedRecord = record
+ followsSelection = false
+ }
+
+ var body: some View {
+ if let record {
+ DeviceHeaderContent(
+ title: record.displayName,
+ subtitle: record.stateDetail,
+ indicator: record.indicator,
+ isFilled: record.snapshot != nil,
+ battery: record.isConnected ? record.snapshot?.battery : nil
+ )
+ } else {
+ DeviceHeaderContent(
+ title: devices.status.title,
+ subtitle: devices.status.subtitle,
+ indicator: devices.status.indicator,
+ isFilled: false,
+ battery: nil
+ )
+ }
+ }
+
+ private var record: DeviceRegistry.Record? {
+ guard followsSelection else { return fixedRecord }
+ let candidates = [model.selectedDevice, devices.primaryAddress].compactMap { $0 }
+ for address in candidates {
+ if let record = devices.records.first(where: { $0.address == address }), record.role != .unsupported {
+ return record
+ }
+ }
+ return nil
+ }
+}
+
+/// The header's layout: a mouse glyph, a name, a status line and an optional battery level.
+struct DeviceHeaderContent: View {
+ let title: String
+ let subtitle: String
+ let indicator: StatusIndicator
+ var isFilled = true
+ var battery: BatteryStatus?
+ var badge: String?
+
var body: some View {
HStack(spacing: 14) {
- Image(systemName: devices.snapshot == nil ? "computermouse" : "computermouse.fill")
+ Image(systemName: isFilled ? "computermouse.fill" : "computermouse")
.font(.system(size: 30, weight: .regular))
.symbolRenderingMode(.hierarchical)
.foregroundStyle(.tint)
.frame(width: 52, height: 52)
.background(Circle().fill(.quaternary))
+ .accessibilityHidden(true)
VStack(alignment: .leading, spacing: 3) {
- Text(devices.status.title)
- .font(.title3.weight(.semibold))
+ HStack(spacing: 8) {
+ Text(verbatim: title)
+ .font(.title3.weight(.semibold))
+ if let badge {
+ BadgeLabel(text: badge)
+ }
+ }
HStack(spacing: 6) {
- StatusDot(indicator: devices.status.indicator)
- Text(devices.status.subtitle)
+ StatusDot(indicator: indicator)
+ Text(verbatim: subtitle)
.foregroundStyle(.secondary)
.lineLimit(2)
}
.font(.callout)
}
Spacer()
- if let battery = devices.snapshot?.battery {
+ if let battery {
BatteryLabel(battery: battery)
.font(.callout)
}
}
.padding(.vertical, 4)
+ .accessibilityElement(children: .combine)
+ }
+}
+
+/// A small capsule label, e.g. "Untested model".
+struct BadgeLabel: View {
+ let text: String
+ var tint: Color = .orange
+
+ var body: some View {
+ Text(verbatim: text)
+ .font(.caption.weight(.medium))
+ .foregroundStyle(tint)
+ .padding(.horizontal, 6)
+ .padding(.vertical, 1)
+ .background(Capsule().fill(tint.opacity(0.15)))
+ }
+}
+
+extension DeviceRegistry.Record {
+ var isStandby: Bool {
+ if case .standby = role { return true }
+ return false
+ }
+
+ /// Why the device isn't served, for the "Other Logitech devices" list.
+ var unsupportedReason: UnsupportedReason? {
+ if case .unsupported(let reason, _) = state { return reason }
+ return nil
+ }
+
+ /// The snapshot of a connected or asleep mouse, or what an unsupported device reported.
+ var anySnapshot: DeviceSnapshot? {
+ if case .unsupported(_, let partial) = state { return partial }
+ return snapshot
+ }
+
+ /// "Bolt receiver, slot 2", "USB cable", "Bluetooth".
+ var placeText: String {
+ guard !address.isDirect else { return connection.title }
+ return String(localized: "\(connection.title), slot \(Int(address.index))", comment: "Where a mouse is connected: a receiver and its slot")
+ }
+
+ /// A word or two: "Connected", "Asleep", "Connecting…".
+ var stateText: String {
+ switch state {
+ case .connected: return String(localized: "Connected")
+ case .asleep: return String(localized: "Asleep")
+ case .probing: return String(localized: "Connecting…")
+ case .waitingForDevice: return String(localized: "Waiting")
+ case .failed(let message):
+ return message == "Disconnected" ? String(localized: "Disconnected") : String(localized: "Not answering")
+ case .unsupported: return String(localized: "Not supported")
+ }
+ }
+
+ /// A status line for headers: how it is connected and what it is doing.
+ var stateDetail: String {
+ switch state {
+ case .connected:
+ return String(localized: "Connected via \(placeText)")
+ case .asleep:
+ return String(localized: "Asleep — move the mouse to wake it")
+ case .probing:
+ return String(localized: "Connecting via \(placeText)…")
+ case .waitingForDevice:
+ return String(localized: "\(placeText) — click a mouse button to wake it")
+ case .failed(let message):
+ return message == "Disconnected" ? String(localized: "Disconnected") : String(localized: "The mouse isn’t answering")
+ case .unsupported(let reason, _):
+ return reason.message
+ }
+ }
+
+ var indicator: StatusIndicator {
+ switch state {
+ case .connected: return isStandby ? .idle : .good
+ case .asleep, .probing, .waitingForDevice: return .pending
+ case .unsupported: return .idle
+ case .failed: return .problem
+ }
+ }
+
+ /// The model's catalog entry says it was tested on real hardware (only the MX Vertical).
+ var isUntestedModel: Bool {
+ guard let model else { return false }
+ return DeviceCatalog.model(model)?.verification != .hardware
+ }
+}
+
+extension ConnectionKind {
+ /// "USB", "Bluetooth", "Unifying", "Bolt", "Nano": compact, for rows and badges.
+ var compactTitle: String {
+ switch self {
+ case .usb: return String(localized: "USB", comment: "Short connection name")
+ case .bluetooth: return String(localized: "Bluetooth", comment: "Short connection name")
+ case .unifying: return "Unifying"
+ case .bolt: return "Bolt"
+ case .nano: return "Nano"
+ }
+ }
+}
+
+/// How to get a mouse talking to Upright, shown while none is connected.
+struct ConnectionHint: View {
+ var body: some View {
+ HStack(alignment: .top, spacing: 10) {
+ Image(systemName: "cable.connector")
+ .foregroundStyle(.secondary)
+ .accessibilityHidden(true)
+ VStack(alignment: .leading, spacing: 3) {
+ Text("How to connect")
+ .font(.headline)
+ Text("Plug in a Logitech Unifying, Bolt or Nano receiver, connect the USB cable, or pair the mouse over Bluetooth. Then click a mouse button to wake it.")
+ .font(.callout)
+ .foregroundStyle(.secondary)
+ .fixedSize(horizontal: false, vertical: true)
+ }
+ }
+ .accessibilityElement(children: .combine)
}
}
-/// Shown when macOS is hiding the mouse until Input Monitoring is allowed.
+/// Shown when macOS refuses to open a Logitech interface until Input Monitoring is allowed.
+/// Names the interfaces it refused (`DeviceManager.blockedInterfaces`).
struct InputMonitoringCallout: View {
@EnvironmentObject private var permissions: PermissionsMonitor
+ @EnvironmentObject private var devices: DeviceManager
var compact = false
var body: some View {
@@ -194,11 +384,12 @@ struct InputMonitoringCallout: View {
Image(systemName: "keyboard.badge.eye")
.font(compact ? .body : .title2)
.foregroundStyle(.orange)
+ .accessibilityHidden(true)
VStack(alignment: .leading, spacing: 4) {
Text("Input Monitoring needed")
.font(.headline)
if !compact {
- Text("macOS is hiding the MX Vertical, so button remaps are not applied. Allow Upright under Privacy & Security → Input Monitoring. The top button keeps changing pointer speed until then.")
+ Text(verbatim: message)
.font(.callout)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
@@ -211,6 +402,14 @@ struct InputMonitoringCallout: View {
.controlSize(compact ? .small : .regular)
}
}
+
+ private var message: String {
+ let names = ListFormatter.localizedString(byJoining: devices.blockedInterfaces)
+ if names.isEmpty {
+ return String(localized: "macOS is hiding your Logitech mouse, so button remaps are not applied. Allow Upright under Privacy & Security → Input Monitoring. The buttons keep their normal behavior until then.")
+ }
+ return String(localized: "macOS is hiding \(names), so button remaps are not applied there. Allow Upright under Privacy & Security → Input Monitoring. The buttons keep their normal behavior until then.")
+ }
}
/// Inline call to action shown when a mapping needs Accessibility access.
diff --git a/Upright/Views/DeviceListView.swift b/Upright/Views/DeviceListView.swift
new file mode 100644
index 0000000..6087549
--- /dev/null
+++ b/Upright/Views/DeviceListView.swift
@@ -0,0 +1,258 @@
+import SwiftUI
+
+/// What the Mice pane shows in its detail: a live endpoint, or a mouse seen before that isn't here.
+enum DeviceListSelection: Hashable {
+ case live(DeviceAddress)
+ case remembered(DeviceID)
+}
+
+/// A mouse seen before that isn't connected now (from `DevicePreferencesStore`).
+struct RememberedDevice: Identifiable, Hashable {
+ let id: DeviceID
+ let preferences: DevicePreferences
+ /// Nickname, else the catalog's model name, else the name it reported; numbered like live mice.
+ var displayName: String
+
+ var model: ModelKey? { preferences.lastModel ?? id.model }
+
+ var connection: ConnectionKind? {
+ preferences.lastConnection.flatMap(ConnectionKind.init(rawValue:))
+ }
+
+ /// "Last seen 3 days ago", or "Not connected" when it never was.
+ var lastSeenText: String {
+ guard let date = preferences.lastSeen else { return String(localized: "Not connected") }
+ let formatter = RelativeDateTimeFormatter()
+ formatter.unitsStyle = .full
+ let relative = formatter.localizedString(for: date, relativeTo: Date())
+ return String(localized: "Last seen \(relative)", comment: "When a remembered mouse was last connected, e.g. Last seen 3 days ago")
+ }
+
+ /// Remembered mice in the store, most recently seen first, minus the units in `live`.
+ /// Names that repeat a live or earlier name get " #2", " #3"….
+ static func list(store: DevicePreferencesStore, live records: [DeviceRegistry.Record]) -> [RememberedDevice] {
+ let connected = Set(records.compactMap(\.deviceID))
+ var counts: [String: Int] = [:]
+ for record in records where record.role != .unsupported && !record.isStandby {
+ counts[baseName(of: record.displayName), default: 0] += 1
+ }
+ return store.rememberedDevices(excluding: connected).map { entry in
+ let base = entry.preferences.trimmedNickname
+ ?? (entry.preferences.lastModel ?? entry.id.model).flatMap { DeviceCatalog.model($0)?.name }
+ ?? entry.preferences.lastName.flatMap { $0.trimmingCharacters(in: .whitespaces).isEmpty ? nil : $0 }
+ ?? String(localized: "Logitech mouse")
+ let number = counts[base, default: 0] + 1
+ counts[base] = number
+ let name = number == 1 ? base : String(localized: "\(base) #\(number)", comment: "A second mouse with the same name")
+ return RememberedDevice(id: entry.id, preferences: entry.preferences, displayName: name)
+ }
+ }
+
+ /// The registry's numbered name without its " #n".
+ private static func baseName(of name: String) -> String {
+ guard let range = name.range(of: " #", options: .backwards),
+ Int(name[range.upperBound...]) != nil
+ else { return name }
+ return String(name[.. [DeviceRegistry.Record] {
+ records.filter { $0.role == .standby(of: record.address) }
+ }
+
+ private func row(selection value: DeviceListSelection, @ViewBuilder content: () -> Content) -> some View {
+ let isSelected = selection == value
+ return Button {
+ selection = value
+ } label: {
+ content()
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .contentShape(Rectangle())
+ }
+ .buttonStyle(.plain)
+ .padding(.vertical, 3)
+ .padding(.horizontal, 6)
+ .background(
+ RoundedRectangle(cornerRadius: 6, style: .continuous)
+ .fill(Color.accentColor.opacity(isSelected ? 0.16 : 0))
+ )
+ .padding(.horizontal, -6)
+ .accessibilityAddTraits(isSelected ? [.isButton, .isSelected] : .isButton)
+ }
+}
+
+/// A connected (or connecting, asleep, failing) mouse: name, connection, state, battery.
+private struct LiveDeviceRow: View {
+ let record: DeviceRegistry.Record
+ let standbyLinks: [DeviceRegistry.Record]
+
+ var body: some View {
+ HStack(spacing: 10) {
+ Image(systemName: record.snapshot == nil ? "computermouse" : "computermouse.fill")
+ .font(.title3)
+ .symbolRenderingMode(.hierarchical)
+ .foregroundStyle(.tint)
+ .frame(width: 26)
+ .accessibilityHidden(true)
+ VStack(alignment: .leading, spacing: 2) {
+ Text(verbatim: record.displayName)
+ .lineLimit(1)
+ HStack(spacing: 5) {
+ StatusDot(indicator: record.indicator)
+ Image(systemName: record.connection.symbolName)
+ .accessibilityHidden(true)
+ Text(verbatim: subtitle)
+ .lineLimit(1)
+ }
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+ Spacer(minLength: 8)
+ if let battery = record.snapshot?.battery {
+ BatteryLabel(battery: battery)
+ .font(.callout)
+ .opacity(record.isConnected ? 1 : 0.6)
+ }
+ }
+ .accessibilityElement(children: .combine)
+ }
+
+ /// "Bolt · Connected", plus "also on USB" for a second link to the same mouse.
+ private var subtitle: String {
+ var parts = [record.connection.compactTitle, record.stateText]
+ if !standbyLinks.isEmpty {
+ let links = ListFormatter.localizedString(byJoining: standbyLinks.map(\.connection.compactTitle))
+ parts.append(String(localized: "also on \(links)", comment: "The same mouse is also connected another way, e.g. also on USB"))
+ }
+ return parts.joined(separator: " · ")
+ }
+}
+
+private struct RememberedDeviceRow: View {
+ let device: RememberedDevice
+
+ var body: some View {
+ HStack(spacing: 10) {
+ Image(systemName: "computermouse")
+ .font(.title3)
+ .foregroundStyle(.tertiary)
+ .frame(width: 26)
+ .accessibilityHidden(true)
+ VStack(alignment: .leading, spacing: 2) {
+ Text(verbatim: device.displayName)
+ .lineLimit(1)
+ HStack(spacing: 5) {
+ StatusDot(indicator: .idle)
+ if let connection = device.connection {
+ Image(systemName: connection.symbolName)
+ .accessibilityHidden(true)
+ }
+ Text(verbatim: device.lastSeenText)
+ .lineLimit(1)
+ }
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+ Spacer(minLength: 8)
+ }
+ .accessibilityElement(children: .combine)
+ }
+}
+
+private struct UnsupportedDeviceRow: View {
+ let record: DeviceRegistry.Record
+
+ var body: some View {
+ HStack(spacing: 10) {
+ Image(systemName: record.unsupportedReason.map(Self.symbol(for:)) ?? "questionmark.circle")
+ .font(.title3)
+ .foregroundStyle(.tertiary)
+ .frame(width: 26)
+ .accessibilityHidden(true)
+ VStack(alignment: .leading, spacing: 2) {
+ Text(verbatim: record.displayName)
+ .lineLimit(1)
+ Text(verbatim: [record.connection.compactTitle, record.unsupportedReason?.message ?? record.stateText].joined(separator: " · "))
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .lineLimit(2)
+ }
+ Spacer(minLength: 8)
+ }
+ .accessibilityElement(children: .combine)
+ }
+
+ private static func symbol(for reason: UnsupportedReason) -> String {
+ if case .notAPointer(.keyboard) = reason { return "keyboard" }
+ return "computermouse"
+ }
+}
diff --git a/Upright/Views/DevicePane.swift b/Upright/Views/DevicePane.swift
index 8262386..0002f75 100644
--- a/Upright/Views/DevicePane.swift
+++ b/Upright/Views/DevicePane.swift
@@ -1,43 +1,217 @@
import AppKit
import SwiftUI
-/// The connected mouse: identity, battery, pointer speed and Easy-Switch, then what it reported
-/// over HID++ and a live activity log.
+/// The Mice pane: every mouse Upright knows (connected, seen before, and other Logitech devices
+/// it found but can't configure), then the selected one in detail: identity, nickname, the
+/// "Remap this mouse" switch, battery, pointer speed and Easy-Switch, what it reported over
+/// HID++, and the activity log (labelled per device, with a device filter).
struct DevicePane: View {
+ @EnvironmentObject private var model: AppModel
@EnvironmentObject private var devices: DeviceManager
+ @EnvironmentObject private var settings: AppSettings
+
+ /// A remembered mouse or an unsupported device. AppModel's `selectedDevice` only tracks served
+ /// mice (it also picks the Buttons pane's model), so these are kept here.
+ @State private var pinnedSelection: DeviceListSelection?
+ @State private var pendingForget: RememberedDevice?
var body: some View {
+ let remembered = RememberedDevice.list(store: settings.devicePreferences, live: devices.records)
+ let current = resolvedSelection(remembered: remembered)
+
Form {
- Section {
- DeviceHeader()
- if let snapshot = devices.snapshot {
- DeviceInfoRows(snapshot: snapshot)
- }
- HStack {
- Spacer()
- Button("Reconnect") { devices.reconnect() }
+ if devices.needsInputMonitoring {
+ Section {
+ InputMonitoringCallout()
}
}
- if case .failed(let message) = devices.status {
+ DeviceListView(records: devices.records, remembered: remembered, selection: selectionBinding(remembered: remembered))
+
+ switch current {
+ case .live(let address):
+ if let record = devices.records.first(where: { $0.address == address }) {
+ LiveDeviceDetail(record: record, standbyOf: standbyTarget(of: record))
+ }
+ case .remembered(let id):
+ if let device = remembered.first(where: { $0.id == id }) {
+ RememberedDeviceDetail(device: device) { pendingForget = device }
+ }
+ case nil:
Section {
- ProblemDetails(summary: String(localized: "The mouse isn’t answering"), details: message)
+ DeviceHeader(record: nil)
+ ConnectionHint()
}
+ PointerSpeedSection(address: nil, dpi: nil)
}
- PointerSpeedSection(dpi: devices.snapshot?.dpi)
+ DeviceActivitySection()
+ }
+ .formStyle(.grouped)
+ .onAppear { devices.refreshDeviceInfo() }
+ .confirmationDialog(
+ Text("Forget \(pendingForget?.displayName ?? "")?"),
+ isPresented: Binding(get: { pendingForget != nil }, set: { if !$0 { pendingForget = nil } }),
+ titleVisibility: .visible,
+ presenting: pendingForget
+ ) { device in
+ Button("Forget", role: .destructive) {
+ model.forget(device.id)
+ if pinnedSelection == .remembered(device.id) { pinnedSelection = nil }
+ }
+ } message: { _ in
+ Text("Upright forgets this mouse’s nickname, pointer speed and remapping switch. The button setup for its model stays.")
+ }
+ }
+
+ // MARK: Selection
- if let snapshot = devices.snapshot, let hosts = snapshot.hosts, hosts.count > 1 {
- EasySwitchSection(hosts: hosts)
+ /// The pinned selection while it still exists (a remembered mouse that came back shows as
+ /// the live one), else the served mouse AppModel has selected.
+ private func resolvedSelection(remembered: [RememberedDevice]) -> DeviceListSelection? {
+ switch pinnedSelection {
+ case .remembered(let id):
+ if let live = devices.records.first(where: { $0.deviceID == id && $0.role == .active }) {
+ return .live(live.address)
}
+ if remembered.contains(where: { $0.id == id }) { return pinnedSelection }
+ case .live(let address):
+ if devices.records.contains(where: { $0.address == address }) { return pinnedSelection }
+ case nil:
+ break
+ }
+ guard let address = model.selectedDevice, devices.records.contains(where: { $0.address == address }) else {
+ return nil
+ }
+ return .live(address)
+ }
- if let snapshot = devices.snapshot {
+ private func selectionBinding(remembered: [RememberedDevice]) -> Binding {
+ Binding(
+ get: { resolvedSelection(remembered: remembered) },
+ set: { value in
+ if case .live(let address) = value,
+ let record = devices.records.first(where: { $0.address == address }),
+ record.role != .unsupported {
+ pinnedSelection = nil
+ model.selectedDevice = address
+ } else {
+ pinnedSelection = value
+ }
+ }
+ )
+ }
+
+ /// The active endpoint a standby link stands in for.
+ private func standbyTarget(of record: DeviceRegistry.Record) -> DeviceRegistry.Record? {
+ guard case .standby(let active) = record.role else { return nil }
+ return devices.records.first { $0.address == active }
+ }
+}
+
+// MARK: - Live device
+
+private struct LiveDeviceDetail: View {
+ let record: DeviceRegistry.Record
+ /// For a standby link: the endpoint of the same mouse that Upright uses.
+ let standbyOf: DeviceRegistry.Record?
+
+ @EnvironmentObject private var model: AppModel
+ @EnvironmentObject private var devices: DeviceManager
+ @State private var copiedReport = false
+
+ var body: some View {
+ let isServed = record.role != .unsupported
+
+ Section {
+ DeviceHeaderContent(
+ title: record.displayName,
+ subtitle: record.stateDetail,
+ indicator: record.indicator,
+ isFilled: record.snapshot != nil,
+ battery: record.isConnected ? record.snapshot?.battery : nil,
+ badge: isServed && record.isUntestedModel ? String(localized: "Untested model") : nil
+ )
+
+ if let standbyOf {
+ Label {
+ Text("This is a second connection to \(standbyOf.displayName). Upright uses its \(standbyOf.connection.compactTitle) connection and leaves this one as it is.")
+ .fixedSize(horizontal: false, vertical: true)
+ } icon: {
+ Image(systemName: "link")
+ }
+ .font(.callout)
+ .foregroundStyle(.secondary)
+ }
+
+ if isServed, let id = record.deviceID {
+ if id.isStable {
+ NicknameField(id: id, placeholder: baseName)
+ .id(id)
+ RemapThisMouseToggle(id: id)
+ } else {
+ Label("Settings for this mouse can’t be remembered: it doesn’t report a serial number.", systemImage: "info.circle")
+ .font(.callout)
+ .foregroundStyle(.secondary)
+ .fixedSize(horizontal: false, vertical: true)
+ }
+ }
+
+ if let snapshot = record.anySnapshot {
+ DeviceInfoRows(record: record, snapshot: snapshot)
+ } else {
+ LabeledContent("Connection") {
+ Label(record.placeText, systemImage: record.connection.symbolName)
+ }
+ }
+
+ HStack {
+ if copiedReport {
+ Label("Copied", systemImage: "checkmark")
+ .foregroundStyle(.secondary)
+ .transition(.opacity)
+ }
+ Spacer()
+ Button("Copy Device Report") { copyReport() }
+ .help("Copies what this device reported (features, buttons, connection) for a bug report or a new-model request")
+ if isServed {
+ Button("Reconnect") { devices.reconnect(record.address) }
+ .help("Identifies this mouse again and reapplies its button configuration")
+ }
+ }
+ } footer: {
+ if isServed && record.isUntestedModel {
+ Text("Upright was built and tested on the MX Vertical. This model should work, but it hasn’t been tested on real hardware yet. If something is off, copy the device report into an issue on GitHub.")
+ .font(.callout)
+ .foregroundStyle(.secondary)
+ .fixedSize(horizontal: false, vertical: true)
+ }
+ }
+
+ if case .failed(let message) = record.state, message != "Disconnected" {
+ Section {
+ ProblemDetails(summary: String(localized: "The mouse isn’t answering"), details: message)
+ }
+ }
+
+ if isServed, !record.isStandby, let snapshot = record.snapshot {
+ if let dpi = snapshot.dpi {
+ PointerSpeedSection(address: record.address, dpi: dpi)
+ }
+ if let hosts = snapshot.hosts, hosts.count > 1 {
+ EasySwitchSection(hosts: hosts, address: record.address, deviceName: record.displayName)
+ }
+ }
+
+ if let snapshot = record.anySnapshot {
+ if !snapshot.controls.isEmpty {
Section("Controls") {
ForEach(snapshot.controls) { control in
- ControlInfoRow(control: control)
+ ControlInfoRow(control: control, model: record.model)
}
}
-
+ }
+ if !snapshot.features.isEmpty {
Section("HID++ features") {
ForEach(snapshot.features) { feature in
LabeledContent {
@@ -49,47 +223,36 @@ struct DevicePane: View {
}
}
}
- } else {
- Section {
- EmptyStateView(
- title: String(localized: "No MX Vertical connected"),
- message: String(localized: "Plug in the Unifying receiver, connect the USB cable, or pair over Bluetooth. Then click a mouse button to wake it."),
- systemImage: "computermouse"
- )
- }
}
+ }
+ }
- Section {
- ActivityLog()
- } header: {
- HStack {
- Text("Activity")
- Spacer()
- Toggle("Raw HID++ reports", isOn: $devices.logsTraffic)
- .toggleStyle(.checkbox)
- .font(.caption)
- Button("Copy") {
- NSPasteboard.general.clearContents()
- NSPasteboard.general.setString(devices.logText, forType: .string)
- }
- .controlSize(.small)
- Button("Clear") { devices.clearLog() }
- .controlSize(.small)
- }
- }
+ /// The name without a nickname, for the nickname field's placeholder.
+ private var baseName: String {
+ if let model = record.model, let name = DeviceCatalog.model(model)?.name { return name }
+ return record.anySnapshot?.name ?? record.displayName
+ }
+
+ private func copyReport() {
+ NSPasteboard.general.clearContents()
+ NSPasteboard.general.setString(DeviceReport.text(for: record), forType: .string)
+ withAnimation { copiedReport = true }
+ DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
+ withAnimation { copiedReport = false }
}
- .formStyle(.grouped)
- .onAppear { devices.refreshDeviceInfo() }
}
}
private struct DeviceInfoRows: View {
+ let record: DeviceRegistry.Record
let snapshot: DeviceSnapshot
var body: some View {
- LabeledContent("Name", value: snapshot.name)
+ if snapshot.name != record.displayName {
+ LabeledContent("Name", value: snapshot.name)
+ }
LabeledContent("Connection") {
- Label(snapshot.connection.title, systemImage: snapshot.connection.symbolName)
+ Label(record.placeText, systemImage: record.connection.symbolName)
}
if let battery = snapshot.battery {
LabeledContent("Battery") {
@@ -116,18 +279,83 @@ private struct DeviceInfoRows: View {
/// "~50%" for coarse levels; "Charging" or "Full" while on power.
static func batteryText(_ battery: BatteryStatus) -> String {
- let level = battery.isApproximate
- ? "~\(battery.percentage)%"
- : "\(battery.percentage)%"
+ let level = battery.levelText
guard battery.isCharging else { return level }
if battery.percentage >= 100 { return String(localized: "Full") }
return String(localized: "\(level), charging")
}
}
-/// Easy-Switch channels, with buttons to move the mouse to another computer.
+/// The mouse's nickname, saved when editing ends (Return or leaving the field).
+private struct NicknameField: View {
+ let id: DeviceID
+ let placeholder: String
+
+ @EnvironmentObject private var model: AppModel
+ @State private var draft = ""
+ @FocusState private var isFocused: Bool
+
+ var body: some View {
+ TextField("Nickname", text: $draft, prompt: Text(verbatim: placeholder))
+ .focused($isFocused)
+ .onAppear { draft = model.devicePreferences(for: id).nickname ?? "" }
+ .onSubmit(commit)
+ .onChange(of: isFocused) { focused in
+ if !focused { commit() }
+ }
+ .onDisappear(perform: commit)
+ .help("Shown in the menu bar and in this list instead of the model name")
+ }
+
+ private func commit() {
+ let trimmed = draft.trimmingCharacters(in: .whitespacesAndNewlines)
+ let nickname = trimmed.isEmpty ? nil : trimmed
+ let binding = model.preferences(id)
+ guard binding.wrappedValue.trimmedNickname != nickname else { return }
+ var prefs = binding.wrappedValue
+ prefs.nickname = nickname
+ binding.wrappedValue = prefs
+ }
+}
+
+private struct RemapThisMouseToggle: View {
+ let id: DeviceID
+
+ @EnvironmentObject private var model: AppModel
+
+ var body: some View {
+ let binding = model.preferences(id)
+ VStack(alignment: .leading, spacing: 2) {
+ Toggle("Remap this mouse", isOn: Binding(
+ get: { binding.wrappedValue.remapEnabled },
+ set: { enabled in
+ var prefs = binding.wrappedValue
+ prefs.remapEnabled = enabled
+ binding.wrappedValue = prefs
+ }
+ ))
+ Text(caption(isOn: binding.wrappedValue.remapEnabled))
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .fixedSize(horizontal: false, vertical: true)
+ }
+ }
+
+ private func caption(isOn: Bool) -> String {
+ if !model.isRemappingEnabled {
+ return String(localized: "Remapping is paused for every mouse (menu bar switch).")
+ }
+ return isOn
+ ? String(localized: "Its buttons follow the setup in Buttons.")
+ : String(localized: "Every button on this mouse does what it normally does.")
+ }
+}
+
+/// Easy-Switch channels of one mouse, with buttons to move it to another computer.
private struct EasySwitchSection: View {
let hosts: HostInfo
+ let address: DeviceAddress
+ let deviceName: String
@EnvironmentObject private var devices: DeviceManager
@State private var pendingSwitch: Int?
@@ -162,18 +390,72 @@ private struct EasySwitchSection: View {
.fixedSize(horizontal: false, vertical: true)
}
.confirmationDialog(
- Text("Switch the mouse to Computer \((pendingSwitch ?? 0) + 1)?"),
+ Text("Switch \(deviceName) to Computer \((pendingSwitch ?? 0) + 1)?"),
isPresented: Binding(get: { pendingSwitch != nil }, set: { if !$0 { pendingSwitch = nil } }),
titleVisibility: .visible,
presenting: pendingSwitch
) { index in
- Button("Switch") { devices.switchToHost(index) }
+ Button("Switch") { devices.switchToHost(index, on: address) }
} message: { _ in
Text("The mouse will disconnect from this Mac. Press the Easy-Switch button under the mouse to come back.")
}
}
}
+// MARK: - Remembered device
+
+private struct RememberedDeviceDetail: View {
+ let device: RememberedDevice
+ let onForget: () -> Void
+
+ var body: some View {
+ Section {
+ DeviceHeaderContent(
+ title: device.displayName,
+ subtitle: device.lastSeenText,
+ indicator: .idle,
+ isFilled: false,
+ badge: isUntested ? String(localized: "Untested model") : nil
+ )
+ NicknameField(id: device.id, placeholder: baseName)
+ .id(device.id)
+ RemapThisMouseToggle(id: device.id)
+ if let connection = device.connection {
+ LabeledContent("Last connection") {
+ Label(connection.title, systemImage: connection.symbolName)
+ }
+ }
+ if let dpi = device.preferences.preferredDPI {
+ LabeledContent("Pointer speed") {
+ Text("\(dpi) DPI")
+ .monospacedDigit()
+ }
+ }
+ HStack {
+ Spacer()
+ Button("Forget This Mouse…", role: .destructive, action: onForget)
+ }
+ } footer: {
+ Text("Connect the mouse to change its pointer speed or Easy-Switch channel.")
+ .font(.callout)
+ .foregroundStyle(.secondary)
+ }
+ }
+
+ private var isUntested: Bool {
+ guard let model = device.model else { return false }
+ return DeviceCatalog.model(model)?.verification != .hardware
+ }
+
+ private var baseName: String {
+ device.model.flatMap { DeviceCatalog.model($0)?.name }
+ ?? device.preferences.lastName
+ ?? device.displayName
+ }
+}
+
+// MARK: - Shared rows
+
/// A short problem statement with the full technical message behind a disclosure.
private struct ProblemDetails: View {
let summary: String
@@ -197,11 +479,12 @@ private struct ProblemDetails: View {
private struct ControlInfoRow: View {
let control: ControlInfo
+ let model: ModelKey?
var body: some View {
- let descriptor = ControlDescriptor(cid: control.cid)
+ let descriptor = ControlDescriptor(cid: control.cid, model: model)
LabeledContent {
- Text(control.flags.names.joined(separator: " · "))
+ Text((control.flags.names + (control.supportsRawXY ? ["raw XY"] : [])).joined(separator: " · "))
.font(.caption)
.foregroundStyle(.secondary)
.multilineTextAlignment(.trailing)
@@ -222,18 +505,82 @@ private struct ControlInfoRow: View {
}
}
-private struct ActivityLog: View {
+// MARK: - Activity
+
+/// The activity log of every device, each line labelled with its device, with a device filter.
+private struct DeviceActivitySection: View {
@EnvironmentObject private var devices: DeviceManager
+ /// nil shows every line.
+ @State private var filter: DeviceAddress?
+
+ var body: some View {
+ let entries = filtered
+ Section {
+ HStack {
+ Picker("Show", selection: $filter) {
+ Text("All devices").tag(DeviceAddress?.none)
+ if !devices.records.isEmpty {
+ Divider()
+ }
+ ForEach(devices.records) { record in
+ Text(verbatim: "\(record.displayName) · \(record.connection.compactTitle)")
+ .tag(Optional(record.address))
+ }
+ }
+ .frame(maxWidth: 320)
+ Spacer()
+ Toggle("Raw HID++ reports", isOn: $devices.logsTraffic)
+ .toggleStyle(.checkbox)
+ .font(.caption)
+ }
+ ActivityLog(entries: entries)
+ } header: {
+ HStack {
+ Text("Activity")
+ Spacer()
+ Button("Copy") {
+ NSPasteboard.general.clearContents()
+ NSPasteboard.general.setString(Self.text(of: entries), forType: .string)
+ }
+ .controlSize(.small)
+ .help("Copies the lines shown")
+ Button("Clear") { devices.clearLog() }
+ .controlSize(.small)
+ }
+ }
+ .onChange(of: devices.records.map(\.address)) { addresses in
+ if let filter, !addresses.contains(filter) { self.filter = nil }
+ }
+ }
+
+ /// Lines about the chosen device, plus lines about the interface it is on (its receiver).
+ private var filtered: [DeviceManager.LogEntry] {
+ guard let filter else { return devices.log }
+ return devices.log.filter { entry in
+ if let address = entry.address { return address == filter }
+ return entry.link == filter.link
+ }
+ }
+
+ private static func text(of entries: [DeviceManager.LogEntry]) -> String {
+ let formatter = DateFormatter()
+ formatter.dateFormat = "HH:mm:ss.SSS"
+ return entries.map { "\(formatter.string(from: $0.date)) \($0.text)" }.joined(separator: "\n")
+ }
+}
+
+private struct ActivityLog: View {
+ let entries: [DeviceManager.LogEntry]
var body: some View {
ScrollViewReader { proxy in
ScrollView {
LazyVStack(alignment: .leading, spacing: 2) {
- if devices.log.isEmpty {
+ if entries.isEmpty {
Text("Nothing yet.")
.foregroundStyle(.secondary)
}
- ForEach(devices.log) { entry in
+ ForEach(entries) { entry in
LogEntryRow(entry: entry)
.id(entry.id)
}
@@ -244,7 +591,7 @@ private struct ActivityLog: View {
}
.frame(minHeight: 160, idealHeight: 200, maxHeight: 260)
.background(RoundedRectangle(cornerRadius: 6).fill(Color(nsColor: .textBackgroundColor)))
- .onChange(of: devices.log.last?.id) { id in
+ .onChange(of: entries.last?.id) { id in
if let id { proxy.scrollTo(id, anchor: .bottom) }
}
.accessibilityLabel(Text("Activity log"))
@@ -264,7 +611,7 @@ private struct LogEntryRow: View {
return formatter
}()
- private static let shortLength = 110
+ private static let shortLength = 140
var body: some View {
HStack(alignment: .firstTextBaseline, spacing: 8) {
@@ -292,3 +639,101 @@ private struct LogEntryRow: View {
return short.count < entry.text.count ? short + "…" : String(short)
}
}
+
+// MARK: - Device report
+
+/// Plain text describing one device for a GitHub issue: identity, connection, features and
+/// controls (CID, task ID, flags, raw XY). The unit ID and serial number are left out; the
+/// report only says whether the device reports them.
+enum DeviceReport {
+ static func text(for record: DeviceRegistry.Record) -> String {
+ var lines: [String] = []
+ let info = Bundle.main.infoDictionary
+ let version = info?["CFBundleShortVersionString"] as? String ?? "?"
+ let build = info?["CFBundleVersion"] as? String ?? "?"
+ lines.append("Upright device report")
+ lines.append("Upright \(version) (\(build)), macOS \(ProcessInfo.processInfo.operatingSystemVersionString)")
+ lines.append("")
+
+ let snapshot = record.anySnapshot
+ let catalog = record.model.flatMap(DeviceCatalog.model)
+ lines.append("Device: \(snapshot?.name ?? record.displayName)")
+ if let model = record.model {
+ let verification: String
+ switch catalog?.verification {
+ case .hardware: verification = "tested on hardware"
+ case .publishedDump: verification = "catalog: published data, untested"
+ case .unverified: verification = "catalog: unverified"
+ case nil: verification = "not in catalog"
+ }
+ lines.append("Model: \(catalog?.name ?? "?") (\(model.rawValue), \(verification))")
+ }
+ lines.append("Connection: \(record.connection.rawValue), index \(record.address.isDirect ? "0xFF (direct)" : "\(record.address.index)")")
+ lines.append("State: \(stateDescription(record))")
+
+ if let identity = snapshot?.identity {
+ lines.append("Kind: \(identity.kind.name)")
+ let ids = identity.modelIDs
+ lines.append("Model IDs: bt \(hex(ids.bluetooth)), btle \(hex(ids.bluetoothLE)), wpid \(hex(ids.wireless)), usb \(hex(ids.usb)); extended \(identity.extendedModelID.map { String(format: "0x%02X", $0) } ?? "-")")
+ lines.append("Transport PID: \(hex(identity.transportPID))")
+ lines.append("Unit ID: \(identity.unitID == nil ? "not reported" : "reported (not included)")")
+ lines.append("Serial number: \(identity.serialNumber == nil ? "not reported" : "reported (not included)")")
+ }
+
+ guard let snapshot else {
+ lines.append("")
+ lines.append("No HID++ data yet.")
+ return lines.joined(separator: "\n")
+ }
+ lines.append("HID++: \(snapshot.protocolVersion); REPROG_CONTROLS_V4 version \(snapshot.reprogVersion)")
+ for firmware in snapshot.firmware where !firmware.displayString.isEmpty {
+ lines.append("\(firmware.kind.title): \(firmware.displayString)")
+ }
+ if let dpi = snapshot.dpi {
+ let step = dpi.step.map { ", step \($0)" } ?? ""
+ lines.append("Pointer speed: \(dpi.current) DPI (default \(dpi.defaultValue); \(dpi.minimum)–\(dpi.maximum)\(step); \(dpi.supported.count) values)")
+ } else {
+ lines.append("Pointer speed: no ADJUSTABLE_DPI (0x2201)")
+ }
+ if let hosts = snapshot.hosts {
+ lines.append("Easy-Switch: \(hosts.count) channels, current \(hosts.current + 1)")
+ }
+ if let battery = snapshot.battery {
+ lines.append("Battery: \(battery.percentage)%\(battery.isApproximate ? " (approximate)" : "")\(battery.isCharging ? ", charging" : "")")
+ }
+
+ lines.append("")
+ lines.append("Features (index, ID, version, name):")
+ for feature in snapshot.features {
+ lines.append(String(format: " %2d 0x%04X v%d ", Int(feature.index), Int(feature.featureID), Int(feature.version)) + feature.name)
+ }
+
+ lines.append("")
+ lines.append("Controls (CID, task ID, flags, raw XY, position, group, group mask):")
+ for control in snapshot.controls {
+ let name = ControlDescriptor(cid: control.cid, model: record.model).name
+ let flags = control.flags.names.joined(separator: " ")
+ lines.append(String(
+ format: " 0x%04X tid 0x%04X [%@] rawXY %@ pos %d group %d gmask 0x%02X ",
+ Int(control.cid), Int(control.taskID), flags, control.supportsRawXY ? "yes" : "no",
+ Int(control.position), Int(control.group), Int(control.groupMask)
+ ) + name)
+ }
+ return lines.joined(separator: "\n")
+ }
+
+ private static func hex(_ value: UInt16?) -> String {
+ value.map { String(format: "0x%04X", $0) } ?? "-"
+ }
+
+ private static func stateDescription(_ record: DeviceRegistry.Record) -> String {
+ switch record.state {
+ case .connected: return record.isStandby ? "connected (standby: same unit on a better link)" : "connected"
+ case .asleep: return "asleep"
+ case .probing: return "probing"
+ case .waitingForDevice: return "waiting for device"
+ case .failed(let message): return "failed: \(message)"
+ case .unsupported(let reason, _): return "unsupported: \(reason)"
+ }
+ }
+}
diff --git a/Upright/Views/GeneralPane.swift b/Upright/Views/GeneralPane.swift
index 8c72dcb..813c827 100644
--- a/Upright/Views/GeneralPane.swift
+++ b/Upright/Views/GeneralPane.swift
@@ -8,6 +8,8 @@ struct GeneralPane: View {
@State private var confirmingReset = false
@State private var importResult: ImportResult?
+ /// A file read and validated, waiting for the user to review it.
+ @State private var pendingImport: PendingImport?
private struct ImportResult: Identifiable {
let id = UUID()
@@ -55,7 +57,7 @@ struct GeneralPane: View {
Toggle(isOn: $model.isRemappingEnabled) {
VStack(alignment: .leading, spacing: 2) {
Text("Remap buttons")
- Text("When off, every button goes back to what the mouse does on its own.")
+ Text("When off, every button of every mouse goes back to what the mouse does on its own.")
.font(.caption)
.foregroundStyle(.secondary)
}
@@ -68,12 +70,19 @@ struct GeneralPane: View {
Toggle(isOn: settingsBinding(\.batteryAlertsEnabled)) {
VStack(alignment: .leading, spacing: 2) {
Text("Low battery alerts")
- Text("Notifies you when the mouse drops to 15% and again at 5%.")
+ Text("Notifies you when a mouse drops to 15% and again at 5%.")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+ }
+ Toggle(isOn: settingsBinding(\.showBatteryInMenuBar)) {
+ VStack(alignment: .leading, spacing: 2) {
+ Text("Show battery level in the menu bar")
+ Text("With several mice, the menu bar shows the lowest battery.")
.font(.caption)
.foregroundStyle(.secondary)
}
}
- Toggle("Show battery level in the menu bar", isOn: settingsBinding(\.showBatteryInMenuBar))
} header: {
Text("Battery")
}
@@ -82,7 +91,7 @@ struct GeneralPane: View {
HStack {
VStack(alignment: .leading, spacing: 2) {
Text("Mappings and app profiles")
- Text("Save them to a file to back them up or move them to another Mac.")
+ Text("Save the setup of every mouse model to a file to back it up or move it to another Mac.")
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
@@ -92,7 +101,7 @@ struct GeneralPane: View {
Button("Export…", action: exportConfiguration)
}
HStack {
- Text("Remove every button action and app profile.")
+ Text("Remove every button action and app profile, for every mouse model.")
.foregroundStyle(.secondary)
Spacer()
Button("Reset All…", role: .destructive) { confirmingReset = true }
@@ -106,10 +115,12 @@ struct GeneralPane: View {
Text("Reapplied automatically")
.foregroundStyle(.secondary)
}
+ .snapshotAnchor("behavior.sleep")
LabeledContent("When Upright quits") {
Text("Buttons return to their defaults")
.foregroundStyle(.secondary)
}
+ .snapshotAnchor("behavior.quit")
} header: {
Text("Behavior")
} footer: {
@@ -117,6 +128,7 @@ struct GeneralPane: View {
.font(.callout)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
+ .snapshotAnchor("behavior.note")
}
Section {
@@ -131,7 +143,11 @@ struct GeneralPane: View {
.confirmationDialog("Reset all mappings?", isPresented: $confirmingReset, titleVisibility: .visible) {
Button("Reset All", role: .destructive) { model.resetAll() }
} message: {
- Text("Every button goes back to what the mouse does on its own, and all app profiles are removed. Export first if you might want them back.")
+ Text("Every button of every mouse model goes back to what the mouse does on its own, and all app profiles are removed. Export first if you might want them back.")
+ }
+ .sheet(item: $pendingImport) { pending in
+ ImportReviewSheet(pending: pending, onImport: finishImport)
+ .environmentObject(model)
}
.alert(
importResult?.title ?? "",
@@ -174,23 +190,20 @@ struct GeneralPane: View {
}
}
+ /// Reads and validates the file, then shows it for review; nothing changes until the user
+ /// confirms in the sheet.
private func importConfiguration() {
let panel = NSOpenPanel()
panel.allowedContentTypes = [.json]
panel.allowsMultipleSelection = false
- panel.message = String(localized: "Importing replaces your current mappings and app profiles.")
- panel.prompt = String(localized: "Import")
+ panel.message = String(localized: "Importing replaces the setup of each mouse model in the file. You can review it first.")
+ panel.prompt = String(localized: "Review")
guard panel.runModal() == .OK, let url = panel.url else { return }
do {
let data = try Data(contentsOf: url)
- let summary = try model.importData(data)
- importResult = ImportResult(
- title: summary.skipped.isEmpty
- ? String(localized: "Mappings imported")
- : String(localized: "Mappings imported with \(summary.skipped.count) skipped"),
- message: importMessage(summary),
- undo: summary
- )
+ let target = model.selectedModel
+ let preview = try model.previewImport(data, targetModel: target)
+ pendingImport = PendingImport(fileName: url.lastPathComponent, data: data, preview: preview, target: target)
} catch let error as MappingStore.ImportError {
importResult = ImportResult(title: String(localized: "Import failed"), message: error.description)
} catch {
@@ -198,34 +211,197 @@ struct GeneralPane: View {
}
}
- private func importMessage(_ summary: AppModel.ImportSummary) -> String {
- var parts = [String(localized: "Your mappings and app profiles now match the file. Your previous setup was saved; choose Undo Import to put it back.")]
- if !summary.actionsToReview.isEmpty {
- let imported = model.profiles
- let lines = summary.actionsToReview.map { "• " + reviewLine($0, in: imported) }
- parts.append(String(localized: "Check that you trust these actions, which open apps, links or shortcuts:") + "\n" + bulletList(lines))
- }
+ private func finishImport(_ preview: MappingStore.ImportPreview) {
+ pendingImport = nil
+ let summary = model.applyImport(preview)
+ let names = ListFormatter.localizedString(byJoining: summary.importedModels.map(model.modelName))
+ var message = String(localized: "These models now use the setup from the file: \(names). Other models kept theirs. Your previous setup was saved; choose Undo Import to put it back.")
if !summary.skipped.isEmpty {
- parts.append(String(localized: "These entries couldn’t be used and were left out:") + "\n" + bulletList(summary.skipped.map { "• " + $0 }))
+ message += "\n\n" + String(localized: "\(summary.skipped.count) entries couldn’t be used and were left out.")
+ }
+ let result = ImportResult(
+ title: summary.skipped.isEmpty
+ ? String(localized: "Mappings imported")
+ : String(localized: "Mappings imported with \(summary.skipped.count) skipped"),
+ message: message,
+ undo: summary
+ )
+ // Let the review sheet finish closing before the alert is presented.
+ Task { @MainActor in
+ try? await Task.sleep(nanoseconds: 350_000_000)
+ importResult = result
+ }
+ }
+}
+
+// MARK: Import review
+
+/// A file read and validated, with the model a 2.x or 1.x file goes to.
+struct PendingImport: Identifiable {
+ let id = UUID()
+ let fileName: String
+ let data: Data
+ var preview: MappingStore.ImportPreview
+ var target: ModelKey
+}
+
+/// Shows what an import would change, model by model, before anything is replaced. A 2.x or
+/// 1.x file (made for `ModelKey.mxVertical`) gets a picker for the model it goes to.
+private struct ImportReviewSheet: View {
+ @EnvironmentObject private var model: AppModel
+ @Environment(\.dismiss) private var dismiss
+
+ let onImport: (MappingStore.ImportPreview) -> Void
+ @State private var pending: PendingImport
+ @State private var retargetError: String?
+
+ init(pending: PendingImport, onImport: @escaping (MappingStore.ImportPreview) -> Void) {
+ self.onImport = onImport
+ _pending = State(initialValue: pending)
+ }
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 14) {
+ VStack(alignment: .leading, spacing: 4) {
+ Text("Import Mappings")
+ .font(.headline)
+ Text(verbatim: pending.fileName)
+ .font(.callout)
+ .foregroundStyle(.secondary)
+ .lineLimit(1)
+ .truncationMode(.middle)
+ }
+
+ Form {
+ modelsSection
+ if !pending.preview.actionsToReview.isEmpty {
+ Section {
+ ForEach(Array(pending.preview.actionsToReview.enumerated()), id: \.offset) { _, item in
+ reviewRow(item)
+ }
+ } header: {
+ Text("Check that you trust these actions")
+ } footer: {
+ Text("They open apps, links or shortcuts when you press the button.")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+ }
+ if !pending.preview.skipped.isEmpty {
+ Section {
+ ForEach(Array(pending.preview.skipped.enumerated()), id: \.offset) { _, line in
+ Text(verbatim: line)
+ .font(.callout)
+ .fixedSize(horizontal: false, vertical: true)
+ }
+ } header: {
+ Text("Left out (can’t be used)")
+ }
+ }
+ }
+ .formStyle(.grouped)
+ .frame(minHeight: 220, idealHeight: 320)
+
+ HStack {
+ Text("Your current setup is saved first, so you can undo the import.")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .fixedSize(horizontal: false, vertical: true)
+ Spacer()
+ Button("Cancel", role: .cancel) { dismiss() }
+ .keyboardShortcut(.cancelAction)
+ Button("Import") { onImport(pending.preview) }
+ .keyboardShortcut(.defaultAction)
+ .disabled(pending.preview.models.isEmpty)
+ }
}
- return parts.joined(separator: "\n\n")
+ .padding(20)
+ .frame(minWidth: 480, idealWidth: 540)
}
- private func bulletList(_ lines: [String]) -> String {
- let limit = 12
- let more = lines.count > limit ? "\n" + String(localized: "…and \(lines.count - limit) more.") : ""
- return lines.prefix(limit).joined(separator: "\n") + more
+ @ViewBuilder
+ private var modelsSection: some View {
+ switch pending.preview.format {
+ case .singleModel:
+ Section {
+ ModelPicker(selection: targetBinding, label: Text("Import into"))
+ .snapshotAnchor("importTarget")
+ if let retargetError {
+ Label(retargetError, systemImage: "exclamationmark.triangle")
+ .font(.caption)
+ .foregroundStyle(.orange)
+ }
+ } header: {
+ Text("Made for \(model.modelName(.mxVertical))")
+ } footer: {
+ Text("This file comes from an earlier version of Upright, which only supported the \(model.modelName(.mxVertical)). Its buttons and app profiles replace the setup of the model you choose; other models keep theirs.")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .fixedSize(horizontal: false, vertical: true)
+ }
+ case .library:
+ Section {
+ ForEach(pending.preview.importedModels, id: \.self) { key in
+ LabeledContent {
+ Text(model.connectedModels.contains(key) ? String(localized: "Connected") : String(localized: "Not connected"))
+ .foregroundStyle(.secondary)
+ } label: {
+ Text(verbatim: name(of: key))
+ }
+ }
+ } header: {
+ Text("Setups in this file")
+ } footer: {
+ Text("Each model’s buttons and app profiles are replaced by the file’s. Models the file doesn’t list keep their setup.")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .fixedSize(horizontal: false, vertical: true)
+ }
+ }
}
- /// "Safari · Back Button – Move Up: Opens https://example.com"
- private func reviewLine(_ item: MappingStore.ReviewItem, in profiles: MappingProfiles) -> String {
- let scope = item.bundleID.map { profiles[app: $0]?.displayName ?? $0 } ?? String(localized: "All apps")
- var control = ControlDescriptor(cid: item.cid).name
+ /// " · Safari · Back Button – Move Up" over "Opens https://example.com".
+ private func reviewRow(_ item: MappingStore.ReviewItem) -> some View {
+ let profiles = pending.preview.models[item.model]
+ let scope = item.bundleID.map { profiles?[app: $0]?.displayName ?? $0 } ?? String(localized: "All apps")
+ var control = ControlDescriptor(cid: item.cid, model: item.model).name
switch item.slot {
case .button: break
case .gestureTap: control += " – " + String(localized: "Click", comment: "Gesture slot: clicking the gesture button")
case .gesture(let direction): control += " – " + direction.title
}
- return "\(scope) · \(control): \(item.action.helpText)"
+ return VStack(alignment: .leading, spacing: 2) {
+ Text(verbatim: "\(name(of: item.model, fallback: item.modelName)) · \(scope) · \(control)")
+ .font(.callout)
+ Text(verbatim: item.action.helpText)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .textSelection(.enabled)
+ .fixedSize(horizontal: false, vertical: true)
+ }
+ }
+
+ private func name(of key: ModelKey, fallback: String? = nil) -> String {
+ if let name = pending.preview.modelNames[key] { return name }
+ let known = model.modelName(key)
+ if known == key.rawValue, let fallback { return fallback }
+ return known
+ }
+
+ /// Changing the target validates the file again for that model (its mice may protect
+ /// different buttons).
+ private var targetBinding: Binding {
+ Binding(
+ get: { pending.target },
+ set: { newTarget in
+ do {
+ pending.preview = try model.previewImport(pending.data, targetModel: newTarget)
+ pending.target = newTarget
+ retargetError = nil
+ } catch {
+ retargetError = String(localized: "The file can’t be imported into that model.")
+ }
+ }
+ )
}
}
diff --git a/Upright/Views/GestureEditor.swift b/Upright/Views/GestureEditor.swift
index cac2a87..7ac2550 100644
--- a/Upright/Views/GestureEditor.swift
+++ b/Upright/Views/GestureEditor.swift
@@ -1,6 +1,8 @@
import SwiftUI
/// Edits the five slots of a gesture button: a click and a hold-and-move in each direction.
+/// The slots' pickers follow the mouse being set up (`actionTarget` in the environment), and
+/// `title` is the control's name on the model being edited.
struct GestureEditor: View {
@Binding var gestures: GestureSet
private let title: String?
@@ -13,8 +15,10 @@ struct GestureEditor: View {
var body: some View {
Section {
row("Click", symbol: "hand.tap", selection: $gestures.tap)
+ .snapshotAnchor("gesture.tap")
ForEach(GestureDirection.allCases, id: \.self) { direction in
row(direction.label, symbol: direction.symbolName, selection: $gestures[direction])
+ .snapshotAnchor("gesture.\(direction.anchorName)")
}
} header: {
if let title {
@@ -37,6 +41,16 @@ struct GestureEditor: View {
}
private extension GestureDirection {
+ /// The direction's name in snapshot anchors (`gesture.up` …).
+ var anchorName: String {
+ switch self {
+ case .up: return "up"
+ case .down: return "down"
+ case .left: return "left"
+ case .right: return "right"
+ }
+ }
+
var label: LocalizedStringKey {
switch self {
case .up: return "Move Up"
diff --git a/Upright/Views/MenuBarView.swift b/Upright/Views/MenuBarView.swift
index 5769eb9..d9a6586 100644
--- a/Upright/Views/MenuBarView.swift
+++ b/Upright/Views/MenuBarView.swift
@@ -2,7 +2,11 @@ import AppKit
import Combine
import SwiftUI
-/// The panel that drops down from the menu bar icon.
+/// The panel that drops down from the menu bar icon: one row per mouse (name, connection,
+/// battery, state), pointer speed and Easy-Switch of the mouse in focus, and what its buttons do.
+///
+/// The mouse in focus (`AppModel.menuDevice`) is the last one pressed, else the one selected in
+/// Settings, else the first connected one.
struct MenuBarView: View {
@EnvironmentObject private var model: AppModel
@EnvironmentObject private var devices: DeviceManager
@@ -11,26 +15,53 @@ struct MenuBarView: View {
@StateObject private var panel = PanelWindow()
@State private var highlightedControl: UInt16?
+ /// Models whose "New:" banner was dismissed (saved in the app's defaults).
+ @State private var dismissedNewModels: Set = []
+
+ private static let dismissedNewModelsKey = "menuBar.dismissedNewModels"
var body: some View {
+ let mice = pointerRecords
VStack(alignment: .leading, spacing: 0) {
- header
+ header(mice: mice)
.padding(.horizontal, 14)
.padding(.top, 14)
.padding(.bottom, 12)
- callout
+ callout(mice: mice)
+
+ if let newModel = newModel(in: mice) {
+ NewModelBanner(name: newModel.name) {
+ dismissNewModel(newModel.key)
+ model.selectedModel = newModel.key
+ openSettings(pane: .buttons)
+ } onDismiss: {
+ dismissNewModel(newModel.key)
+ }
+ .modifier(CalloutBackground(tint: .accentColor))
+ }
- if let snapshot = devices.snapshot, snapshot.dpi != nil || (snapshot.hosts?.count ?? 0) > 1 {
+ if !mice.isEmpty {
Divider().padding(.horizontal, 10)
- DeviceQuickControls(snapshot: snapshot)
- .padding(.horizontal, 14)
- .padding(.vertical, 10)
+ deviceRows(mice)
+ .padding(.horizontal, 6)
+ .padding(.vertical, 6)
+ }
+
+ if let target = quickControlsTarget {
+ Divider().padding(.horizontal, 10)
+ DeviceQuickControls(
+ record: target.record,
+ snapshot: target.snapshot,
+ showsName: mice.count > 1
+ )
+ .padding(.horizontal, 14)
+ .padding(.vertical, 10)
}
Divider().padding(.horizontal, 10)
- mappingsSection
+ mappingsSection(showsModelName: Set(mice.compactMap(\.model)).count > 1)
.padding(.horizontal, 6)
.padding(.vertical, 6)
@@ -42,9 +73,10 @@ struct MenuBarView: View {
}
.keyboardShortcut(",", modifiers: .command)
- MenuItemButton(title: String(localized: "Reconnect Mouse"), systemImage: "arrow.clockwise") {
- devices.reconnect()
+ MenuItemButton(title: String(localized: "Reconnect All Mice"), systemImage: "arrow.clockwise") {
+ devices.reconnect(nil)
}
+ .help(String(localized: "Looks for every Logitech mouse again and reapplies their button configuration"))
MenuItemButton(title: String(localized: "Quit Upright"), systemImage: "power", shortcut: "⌘Q") {
NSApp.terminate(nil)
@@ -55,19 +87,29 @@ struct MenuBarView: View {
}
.frame(minWidth: 320, idealWidth: 340, maxWidth: 440)
.background(WindowReader { panel.window = $0 })
- .onAppear { devices.refreshDeviceInfo() }
+ .onAppear {
+ loadDismissedNewModels()
+ devices.refreshDeviceInfo()
+ }
.onReceive(panel.didBecomeKey) { devices.refreshDeviceInfo() }
.onChange(of: model.lastPressedControl) { pressed in
guard let pressed else { return }
+ // Only buttons of the mouse whose summary is shown flash.
+ guard pressed.source == model.menuDevice || (pressed.model != nil && pressed.model == menuModel) else { return }
flash(pressed.cid)
}
}
+ /// Served mice in list order; a second link to a mouse already listed is left out.
+ private var pointerRecords: [DeviceRegistry.Record] {
+ devices.records.filter { $0.role != .unsupported && !$0.isStandby }
+ }
+
// MARK: Header
- private var header: some View {
+ private func header(mice: [DeviceRegistry.Record]) -> some View {
HStack(alignment: .top, spacing: 11) {
- Image(systemName: devices.snapshot == nil ? "computermouse" : "computermouse.fill")
+ Image(systemName: devices.summary.anyConnected ? "computermouse.fill" : "computermouse")
.font(.system(size: 18))
.symbolRenderingMode(.hierarchical)
.foregroundStyle(.tint)
@@ -76,10 +118,9 @@ struct MenuBarView: View {
.accessibilityHidden(true)
VStack(alignment: .leading, spacing: 3) {
- Text(devices.status.title)
+ Text(verbatim: "Upright")
.font(.headline)
- .fixedSize(horizontal: false, vertical: true)
- statusLine
+ statusLine(mice: mice)
.font(.caption)
.foregroundStyle(.secondary)
}
@@ -95,77 +136,146 @@ struct MenuBarView: View {
}
}
- private var statusLine: some View {
- VStack(alignment: .leading, spacing: 3) {
- HStack(spacing: 5) {
+ @ViewBuilder
+ private func statusLine(mice: [DeviceRegistry.Record]) -> some View {
+ HStack(spacing: 5) {
+ if mice.isEmpty {
+ // No mouse yet: what the interfaces are doing (searching, a receiver waiting
+ // for its mouse, access refused). The one place the panel reads the compat status.
StatusDot(indicator: devices.status.indicator)
- if case .failed = devices.status {
- Text("Connection problem")
- Button(String(localized: "Details…")) { openSettings(pane: .device) }
- .buttonStyle(.link)
- .help(devices.status.subtitle)
- } else {
- Text(devices.status.subtitle)
- .fixedSize(horizontal: false, vertical: true)
- }
- }
- if let battery = devices.snapshot?.battery {
- HStack(spacing: 5) {
- BatteryLabel(battery: battery)
- if battery.isCharging {
- Text("Charging")
- }
- }
+ Text(devices.status.subtitle)
+ .fixedSize(horizontal: false, vertical: true)
+ } else if !devices.summary.problems.isEmpty, devices.summary.connectedCount == 0 {
+ StatusDot(indicator: .problem)
+ Text("Connection problem")
+ Button(String(localized: "Details…")) { openSettings(pane: .device, device: devices.summary.problems.first) }
+ .buttonStyle(.link)
+ } else {
+ StatusDot(indicator: devices.summary.anyConnected ? .good : .pending)
+ Text(verbatim: Self.fleetText(mice: mice, connected: devices.summary.connectedCount))
+ .fixedSize(horizontal: false, vertical: true)
}
}
}
+ /// "2 mice connected", "1 mouse connected", "Asleep — move a mouse to wake it", "Connecting…".
+ private static func fleetText(mice: [DeviceRegistry.Record], connected: Int) -> String {
+ if connected == 1 { return String(localized: "1 mouse connected") }
+ if connected > 1 { return String(localized: "\(connected) mice connected") }
+ if mice.contains(where: { if case .asleep = $0.state { return true } else { return false } }) {
+ return String(localized: "Asleep — move a mouse to wake it")
+ }
+ return String(localized: "Connecting…")
+ }
+
// MARK: Callouts
@ViewBuilder
- private var callout: some View {
- if devices.status == .accessDenied {
+ private func callout(mice: [DeviceRegistry.Record]) -> some View {
+ if devices.needsInputMonitoring {
InputMonitoringCallout(compact: true)
.modifier(CalloutBackground(tint: .orange))
} else if model.isMissingAccessibility {
AccessibilityCallout(compact: true)
.modifier(CalloutBackground(tint: .orange))
- } else if devices.status == .searching {
- ConnectHint()
+ } else if mice.isEmpty, devices.status == .searching {
+ ConnectionHint()
.modifier(CalloutBackground(tint: .secondary))
}
}
+ // MARK: New model
+
+ /// A connected model Upright has no setup for yet (other than the MX Vertical, which the
+ /// first-run window covers), until its banner is dismissed or the model gets a setup.
+ private func newModel(in mice: [DeviceRegistry.Record]) -> (key: ModelKey, name: String)? {
+ guard settings.hasCompletedOnboarding else { return nil }
+ for record in mice where record.role == .active && record.snapshot != nil {
+ guard let key = record.model, key != .mxVertical,
+ model.library.models[key] == nil,
+ !dismissedNewModels.contains(key.rawValue)
+ else { continue }
+ return (key, model.modelName(key))
+ }
+ return nil
+ }
+
+ private func loadDismissedNewModels() {
+ dismissedNewModels = Set(model.defaults.stringArray(forKey: Self.dismissedNewModelsKey) ?? [])
+ }
+
+ private func dismissNewModel(_ key: ModelKey) {
+ dismissedNewModels.insert(key.rawValue)
+ model.defaults.set(dismissedNewModels.sorted(), forKey: Self.dismissedNewModelsKey)
+ }
+
+ // MARK: Mice
+
+ private func deviceRows(_ mice: [DeviceRegistry.Record]) -> some View {
+ VStack(spacing: 1) {
+ ForEach(Array(mice.enumerated()), id: \.element.address) { index, record in
+ MenuDeviceRow(record: record, isInFocus: mice.count > 1 && record.address == model.menuDevice) {
+ openSettings(pane: .device, device: record.address)
+ }
+ .snapshotAnchor("battery.\(index + 1)")
+ }
+ }
+ }
+
+ /// The mouse in focus, when it has a pointer speed or several Easy-Switch channels.
+ private var quickControlsTarget: (record: DeviceRegistry.Record, snapshot: DeviceSnapshot)? {
+ guard let address = model.menuDevice,
+ let record = devices.records.first(where: { $0.address == address }),
+ record.role == .active,
+ let snapshot = record.snapshot,
+ snapshot.dpi != nil || (snapshot.hosts?.count ?? 0) > 1
+ else { return nil }
+ return (record, snapshot)
+ }
+
// MARK: Mappings
- private var mappingsSection: some View {
- VStack(alignment: .leading, spacing: 1) {
- mappingsHeader
+ /// The model whose buttons are summarized: the mouse in focus, else the Buttons pane's model.
+ private var menuModel: ModelKey {
+ model.menuDevice.flatMap(model.model(of:)) ?? model.selectedModel
+ }
+
+ private func mappingsSection(showsModelName: Bool) -> some View {
+ let shownModel = menuModel
+ let mappings = model.effectiveMappings(model: shownModel)
+ return VStack(alignment: .leading, spacing: 1) {
+ mappingsHeader(model: shownModel, showsModelName: showsModelName)
.padding(.horizontal, 8)
.padding(.top, 2)
.padding(.bottom, 4)
- let mappings = model.effectiveMappings
- ForEach(devices.controls) { control in
+ ForEach(model.controls(for: shownModel)) { control in
MappingSummaryRow(
control: control,
action: mappings[control.cid],
isPaused: !model.isRemappingEnabled,
isHighlighted: highlightedControl == control.cid
) {
+ if model.selectedModel != shownModel { model.selectedModel = shownModel }
openSettings(pane: .buttons)
}
}
}
}
- private var mappingsHeader: some View {
- HStack(spacing: 6) {
- if let app = activeProfileApp {
- Text("In \(app.name)")
- .help(String(localized: "\(app.name) has its own button settings"))
- } else {
- Text("Buttons")
+ private func mappingsHeader(model shownModel: ModelKey, showsModelName: Bool) -> some View {
+ HStack(spacing: 4) {
+ Group {
+ if let app = activeProfileApp(for: shownModel) {
+ Text("In \(app.name)")
+ .help(String(localized: "\(app.name) has its own button settings"))
+ } else {
+ Text("Buttons")
+ }
+ }
+ if showsModelName {
+ Text(verbatim: "· " + model.modelName(shownModel))
+ .lineLimit(1)
}
Spacer(minLength: 8)
if !model.isRemappingEnabled {
@@ -179,9 +289,11 @@ struct MenuBarView: View {
.accessibilityAddTraits(.isHeader)
}
- /// The frontmost app, when it has an enabled profile of its own.
- private var activeProfileApp: FrontmostApp? {
- guard let app = model.frontmostApp, model.profiles.enabledProfile(forBundleID: app.bundleID) != nil else { return nil }
+ /// The frontmost app, when it has an enabled profile of its own for `shownModel`.
+ private func activeProfileApp(for shownModel: ModelKey) -> FrontmostApp? {
+ guard let app = model.frontmostApp,
+ model.profiles(for: shownModel).enabledProfile(forBundleID: app.bundleID) != nil
+ else { return nil }
return app
}
@@ -196,24 +308,187 @@ struct MenuBarView: View {
// MARK: Actions
/// Closes this panel (a MenuBarExtra window doesn't close itself when a button in it is
- /// clicked), then opens Settings.
- private func openSettings(pane: SettingsPane? = nil) {
+ /// clicked), then opens Settings, optionally on one mouse in the Mice pane.
+ private func openSettings(pane: SettingsPane? = nil, device: DeviceAddress? = nil) {
panel.close()
- SettingsWindowController.shared.show(pane: pane)
+ SettingsWindowController.shared.show(pane: pane, device: device)
+ }
+}
+
+// MARK: - Menu bar label
+
+/// The status item: a filled mouse while any mouse is connected, and optionally the lowest
+/// battery level among connected mice (red when it is low).
+struct MenuBarLabel: View {
+ @ObservedObject var devices: DeviceManager
+ @ObservedObject var settings: AppSettings
+
+ var body: some View {
+ let summary = devices.summary
+ let icon = Image(systemName: summary.anyConnected ? "computermouse.fill" : "computermouse")
+ Group {
+ if settings.showBatteryInMenuBar, let battery = summary.lowestBattery?.status {
+ // One Text, so the menu bar shows both the icon and the level.
+ Text(icon) + Text(verbatim: " " + battery.levelText).foregroundColor(battery.isLow ? .red : nil)
+ } else {
+ icon
+ }
+ }
+ .accessibilityElement(children: .ignore)
+ .accessibilityLabel(Text(verbatim: accessibilityText))
+ }
+
+ /// Every mouse, for example "Upright, MX Vertical connected, battery 80%, MX Master 3S asleep".
+ private var accessibilityText: String {
+ var parts = ["Upright"]
+ let mice = devices.records.filter { $0.role != .unsupported && !$0.isStandby }
+ if mice.isEmpty {
+ // No mouse yet: what the interfaces are doing (the documented status fallback).
+ switch devices.status {
+ case .connected: parts.append(String(localized: "connected"))
+ case .asleep: parts.append(String(localized: "mouse asleep"))
+ case .probing, .waiting: parts.append(String(localized: "connecting"))
+ case .searching: parts.append(String(localized: "not connected"))
+ case .accessDenied: break
+ case .failed: parts.append(String(localized: "connection problem"))
+ }
+ }
+ for record in mice {
+ let name = record.displayName
+ switch record.state {
+ case .connected(let snapshot):
+ if let battery = snapshot.battery {
+ parts.append(String(localized: "\(name) connected, \(battery.accessibilityDescription)", comment: "Menu bar accessibility: a mouse and its battery"))
+ } else {
+ parts.append(String(localized: "\(name) connected", comment: "Menu bar accessibility"))
+ }
+ case .asleep:
+ parts.append(String(localized: "\(name) asleep", comment: "Menu bar accessibility"))
+ case .probing, .waitingForDevice:
+ parts.append(String(localized: "\(name) connecting", comment: "Menu bar accessibility"))
+ case .failed:
+ parts.append(String(localized: "\(name) has a connection problem", comment: "Menu bar accessibility"))
+ case .unsupported:
+ break
+ }
+ }
+ if devices.needsInputMonitoring {
+ parts.append(String(localized: "needs Input Monitoring access"))
+ }
+ return parts.joined(separator: ", ")
+ }
+}
+
+// MARK: - Device rows
+
+/// One mouse: connection glyph, name, connection and state, battery.
+private struct MenuDeviceRow: View {
+ let record: DeviceRegistry.Record
+ /// The mouse the controls below act on (marked when there are several).
+ let isInFocus: Bool
+ let onSelect: () -> Void
+
+ var body: some View {
+ Button(action: onSelect) {
+ HStack(spacing: 10) {
+ Image(systemName: record.connection.symbolName)
+ .foregroundStyle(record.isConnected ? Color.accentColor : Color.secondary)
+ .frame(width: 18)
+ .accessibilityHidden(true)
+ VStack(alignment: .leading, spacing: 1) {
+ Text(verbatim: record.displayName)
+ .fontWeight(isInFocus ? .semibold : .regular)
+ .lineLimit(1)
+ HStack(spacing: 4) {
+ StatusDot(indicator: record.indicator)
+ Text(verbatim: "\(record.connection.compactTitle) · \(record.stateText)")
+ .lineLimit(1)
+ }
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+ Spacer(minLength: 8)
+ if let battery = record.snapshot?.battery {
+ BatteryLabel(battery: battery)
+ .font(.callout)
+ .opacity(record.isConnected ? 1 : 0.6)
+ }
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+ .buttonStyle(RowButtonStyle())
+ .accessibilityElement(children: .ignore)
+ .accessibilityLabel(Text(verbatim: accessibilityText))
+ .accessibilityHint(Text("Opens this mouse in Settings"))
+ .accessibilityAddTraits(.isButton)
+ }
+
+ /// "MX Master 3S, Bolt receiver, connected, Battery 80%".
+ private var accessibilityText: String {
+ var parts = [record.displayName, record.connection.title, record.stateText]
+ if let battery = record.snapshot?.battery {
+ parts.append(battery.accessibilityDescription)
+ }
+ return parts.joined(separator: ", ")
+ }
+}
+
+/// "New: MX Master 3S. Its buttons work as usual. Set Up…"
+private struct NewModelBanner: View {
+ let name: String
+ let onSetUp: () -> Void
+ let onDismiss: () -> Void
+
+ var body: some View {
+ HStack(alignment: .top, spacing: 10) {
+ Image(systemName: "sparkles")
+ .foregroundStyle(.tint)
+ .accessibilityHidden(true)
+ VStack(alignment: .leading, spacing: 3) {
+ Text("New: \(name)")
+ .font(.headline)
+ Text("Its buttons work as usual until you set them up.")
+ .font(.callout)
+ .foregroundStyle(.secondary)
+ .fixedSize(horizontal: false, vertical: true)
+ Button("Set Up…", action: onSetUp)
+ .controlSize(.small)
+ .padding(.top, 2)
+ }
+ Spacer(minLength: 4)
+ Button(action: onDismiss) {
+ Image(systemName: "xmark")
+ .font(.caption.weight(.semibold))
+ .foregroundStyle(.secondary)
+ }
+ .buttonStyle(.borderless)
+ .help(String(localized: "Dismiss"))
+ .accessibilityLabel(Text("Dismiss"))
+ }
}
}
// MARK: - Device controls
-/// Pointer speed presets and the current Easy-Switch channel.
+/// Pointer speed presets and the current Easy-Switch channel of one mouse.
private struct DeviceQuickControls: View {
+ let record: DeviceRegistry.Record
let snapshot: DeviceSnapshot
+ /// Several mice: name the one these controls act on.
+ let showsName: Bool
- @EnvironmentObject private var devices: DeviceManager
+ @EnvironmentObject private var model: AppModel
@EnvironmentObject private var settings: AppSettings
var body: some View {
VStack(alignment: .leading, spacing: 8) {
+ if showsName {
+ Text(verbatim: record.displayName)
+ .font(.caption.weight(.medium))
+ .foregroundStyle(.secondary)
+ .accessibilityAddTraits(.isHeader)
+ }
+
if let dpi = snapshot.dpi {
VStack(alignment: .leading, spacing: 5) {
HStack {
@@ -253,8 +528,7 @@ private struct DeviceQuickControls: View {
private func presetButton(_ preset: Int, dpi: DPIInfo) -> some View {
let isCurrent = dpi.nearest(to: preset) == dpi.current
return Button {
- devices.setDPI(preset)
- settings.preferredDPI = preset
+ model.setPreferredDPI(preset, for: record.address)
} label: {
Text(verbatim: "\(preset)")
.monospacedDigit()
@@ -270,7 +544,7 @@ private struct DeviceQuickControls: View {
}
.accessibilityLabel(Text("\(preset) DPI"))
.accessibilityAddTraits(isCurrent ? .isSelected : [])
- .help(String(localized: "Set pointer speed to \(preset) DPI"))
+ .help(String(localized: "Set the pointer speed of \(record.displayName) to \(preset) DPI"))
}
private func channelText(_ hosts: HostInfo) -> String {
@@ -362,26 +636,6 @@ private struct MenuItemButton: View {
}
}
-/// How to get the mouse talking to Upright, shown while nothing is connected.
-private struct ConnectHint: View {
- var body: some View {
- HStack(alignment: .top, spacing: 10) {
- Image(systemName: "cable.connector")
- .foregroundStyle(.secondary)
- .accessibilityHidden(true)
- VStack(alignment: .leading, spacing: 3) {
- Text("How to connect")
- .font(.headline)
- Text("Plug in the Unifying receiver, connect the USB cable, or pair over Bluetooth. Then click a mouse button to wake it.")
- .font(.callout)
- .foregroundStyle(.secondary)
- .fixedSize(horizontal: false, vertical: true)
- }
- }
- .accessibilityElement(children: .combine)
- }
-}
-
private struct CalloutBackground: ViewModifier {
let tint: Color
diff --git a/Upright/Views/ModelPicker.swift b/Upright/Views/ModelPicker.swift
new file mode 100644
index 0000000..917fb12
--- /dev/null
+++ b/Upright/Views/ModelPicker.swift
@@ -0,0 +1,88 @@
+import SwiftUI
+
+/// The mouse models a setup can be made for, grouped the way the pickers list them: models with
+/// a mouse here now, then models seen before, then every other model (ones with a saved setup
+/// and the catalog's), so a mouse can be set up before it arrives.
+@MainActor
+struct ModelChoices {
+ var connected: [ModelKey]
+ var remembered: [ModelKey]
+ var other: [ModelKey]
+
+ /// `including` is always listed (under "Other models" when it is nowhere else).
+ init(model: AppModel, including selection: ModelKey? = nil) {
+ connected = model.connectedModels
+ let present = Set(connected)
+ remembered = model.rememberedModels.filter { !present.contains($0) }
+ let listed = present.union(remembered)
+ var seen = listed
+ var others = (Array(model.library.models.keys) + DeviceCatalog.models.map(\.key))
+ .filter { seen.insert($0).inserted }
+ if let selection, !seen.contains(selection) { others.append(selection) }
+ other = others.sorted { model.modelName($0).localizedStandardCompare(model.modelName($1)) == .orderedAscending }
+ }
+
+ var all: [ModelKey] { connected + remembered + other }
+}
+
+/// Chooses which mouse model a setup is for ("Configure: [model ▾]"). Editing a model's
+/// setup never changes another model's.
+struct ModelPicker: View {
+ @EnvironmentObject private var model: AppModel
+ @Binding var selection: ModelKey
+ var label: Text = Text("Configure")
+
+ var body: some View {
+ let choices = ModelChoices(model: model, including: selection)
+ Picker(selection: $selection) {
+ if !choices.connected.isEmpty {
+ Section {
+ ForEach(choices.connected, id: \.self) { key in
+ item(key, units: unitCount(of: key))
+ }
+ } header: {
+ Text("Connected")
+ }
+ }
+ if !choices.remembered.isEmpty {
+ Section {
+ ForEach(choices.remembered, id: \.self) { key in
+ item(key, units: 0)
+ }
+ } header: {
+ Text("Seen Before")
+ }
+ }
+ if !choices.other.isEmpty {
+ Section {
+ ForEach(choices.other, id: \.self) { key in
+ item(key, units: 0)
+ }
+ } header: {
+ Text("Other Models")
+ }
+ }
+ } label: {
+ label
+ }
+ .accessibilityValue(Text(verbatim: model.modelName(selection)))
+ }
+
+ @ViewBuilder
+ private func item(_ key: ModelKey, units: Int) -> some View {
+ let name = model.modelName(key)
+ Group {
+ if units > 1 {
+ Text("\(name) (\(units) mice)")
+ } else {
+ Text(verbatim: name)
+ }
+ }
+ .tag(key)
+ }
+
+ /// Mice of a model being served now (one per unit: a mouse on two links counts once).
+ private func unitCount(of key: ModelKey) -> Int {
+ model.devices.records.filter { $0.model == key && $0.role == .active }.count
+ }
+}
diff --git a/Upright/Views/OnboardingView.swift b/Upright/Views/OnboardingView.swift
index 22161c2..6c69d22 100644
--- a/Upright/Views/OnboardingView.swift
+++ b/Upright/Views/OnboardingView.swift
@@ -176,20 +176,34 @@ private struct StatusRow: View {
private struct ConnectStep: View {
@EnvironmentObject private var devices: DeviceManager
+ /// Mice Upright serves or is identifying, in list order. Unsupported devices aren't offered.
+ private var mice: [DeviceRegistry.Record] {
+ devices.records.filter { $0.role != .unsupported && $0.snapshot != nil }
+ }
+
var body: some View {
VStack(alignment: .leading, spacing: 16) {
StepHeader(
- title: "Connect your MX Vertical",
- message: "Plug in the Unifying receiver, connect the USB cable, or pair the mouse over Bluetooth. Then click a mouse button to wake it."
+ title: "Connect your Logitech mouse",
+ message: "Plug in its receiver, connect the USB cable, or pair the mouse over Bluetooth. Then click a mouse button to wake it. Upright works with several mice at once."
)
GroupBox {
- DeviceHeader()
- .padding(6)
+ VStack(alignment: .leading, spacing: 10) {
+ if mice.isEmpty {
+ SearchingRow()
+ } else {
+ ForEach(mice) { record in
+ DetectedMouseRow(record: record)
+ }
+ }
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .padding(6)
}
- if devices.status == .accessDenied {
+ if devices.needsInputMonitoring {
InputMonitoringCallout()
- } else if devices.snapshot == nil {
- Text("You can continue without the mouse; Upright connects as soon as it appears.")
+ } else if !devices.summary.anyPresent {
+ Text("You can continue without a mouse; Upright connects as soon as one appears.")
.font(.callout)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
@@ -198,6 +212,90 @@ private struct ConnectStep: View {
}
}
+/// The icon tile shared by the rows of the connect step.
+private struct MouseTile: View {
+ let isPresent: Bool
+
+ var body: some View {
+ Image(systemName: isPresent ? "computermouse.fill" : "computermouse")
+ .font(.system(size: 30, weight: .regular))
+ .symbolRenderingMode(.hierarchical)
+ .foregroundStyle(.tint)
+ .frame(width: 52, height: 52)
+ .background(Circle().fill(.quaternary))
+ .accessibilityHidden(true)
+ }
+}
+
+/// Shown while no mouse has identified: what the interfaces are doing (searching, connecting,
+/// waiting for a mouse to wake).
+private struct SearchingRow: View {
+ @EnvironmentObject private var devices: DeviceManager
+
+ var body: some View {
+ HStack(spacing: 14) {
+ MouseTile(isPresent: false)
+ VStack(alignment: .leading, spacing: 3) {
+ Text(devices.status.title)
+ .font(.title3.weight(.semibold))
+ HStack(spacing: 6) {
+ StatusDot(indicator: devices.status.indicator)
+ Text(devices.status.subtitle)
+ .foregroundStyle(.secondary)
+ .lineLimit(2)
+ }
+ .font(.callout)
+ }
+ Spacer(minLength: 0)
+ }
+ .accessibilityElement(children: .combine)
+ }
+}
+
+/// A mouse Upright found: its name, how it connects and its battery.
+private struct DetectedMouseRow: View {
+ let record: DeviceRegistry.Record
+
+ var body: some View {
+ HStack(spacing: 14) {
+ MouseTile(isPresent: true)
+ VStack(alignment: .leading, spacing: 3) {
+ Text(verbatim: record.displayName)
+ .font(.title3.weight(.semibold))
+ HStack(spacing: 6) {
+ StatusDot(indicator: record.isConnected ? .good : .pending)
+ Text(subtitle)
+ .foregroundStyle(.secondary)
+ .lineLimit(2)
+ }
+ .font(.callout)
+ }
+ Spacer(minLength: 0)
+ if let battery = record.snapshot?.battery {
+ Label {
+ Text(verbatim: (battery.isApproximate ? "~" : "") + percent(battery.percentage))
+ } icon: {
+ Image(systemName: battery.symbolName)
+ }
+ .font(.callout)
+ .foregroundStyle(battery.percentage <= 10 && !battery.isCharging ? Color.red : Color.secondary)
+ }
+ }
+ .accessibilityElement(children: .combine)
+ }
+
+ private var subtitle: String {
+ if record.isConnected {
+ return String(localized: "Connected via \(record.connection.title)")
+ }
+ return String(localized: "Asleep — move the mouse to wake it")
+ }
+
+ private func percent(_ value: Int) -> String {
+ (Double(value) / 100).formatted(.percent.precision(.fractionLength(0)))
+ }
+}
+
private struct AccessibilityStep: View {
@EnvironmentObject private var permissions: PermissionsMonitor
@@ -254,21 +352,25 @@ private struct SetupStep: View {
message: "You can change any button later in Settings, and set different actions for individual apps."
)
+ targetLine
+
VStack(spacing: 8) {
ChoiceCard(
title: "Keep mouse defaults",
- detail: "Back and Forward work as mouse buttons 4 and 5; the top button changes pointer speed.",
+ detail: String(localized: "Every button keeps the behavior the mouse gives it. Nothing is remapped."),
isSelected: choice == .mouseDefaults
) { apply(.mouseDefaults) }
- ChoiceCard(
- title: "Suggested",
- detail: "Back and Forward navigate in apps (⌘[ and ⌘]); the top button opens Mission Control.",
- isSelected: choice == .suggested
- ) { apply(.suggested) }
+ if !suggested.isEmpty {
+ ChoiceCard(
+ title: "Suggested",
+ detail: suggestedDetail,
+ isSelected: choice == .suggested
+ ) { apply(.suggested) }
+ }
}
if choice == .suggested, !permissions.isAccessibilityTrusted {
- Label("Back and Forward start working once Accessibility access is allowed.", systemImage: "exclamationmark.triangle.fill")
+ Label("Mapped buttons start working once Accessibility access is allowed.", systemImage: "exclamationmark.triangle.fill")
.font(.callout)
.foregroundStyle(.orange)
.fixedSize(horizontal: false, vertical: true)
@@ -278,28 +380,85 @@ private struct SetupStep: View {
}
}
+ /// The model this step sets up: the connected mouse's (`selectedModel` follows it).
+ private var target: ModelKey { model.selectedModel }
+
+ /// Names the mouse being set up, with a picker when mice of different models are connected.
+ @ViewBuilder
+ private var targetLine: some View {
+ let models = model.connectedModels
+ if models.count > 1 {
+ Picker("Set up", selection: $model.selectedModel) {
+ ForEach(models, id: \.self) { key in
+ Text(verbatim: model.modelName(key)).tag(key)
+ }
+ }
+ .pickerStyle(.menu)
+ .fixedSize()
+ } else if devices.summary.anyPresent {
+ Text("Setting up your \(model.modelName(target)). Other mice keep their own setup.")
+ .font(.callout)
+ .foregroundStyle(.secondary)
+ } else {
+ Text("No mouse is connected yet, so this sets up the \(model.modelName(target)). Other mice keep their own setup.")
+ .font(.callout)
+ .foregroundStyle(.secondary)
+ .fixedSize(horizontal: false, vertical: true)
+ }
+ }
+
+ /// The suggested mappings for the target model's buttons.
+ private var suggested: [UInt16: ButtonAction] {
+ ButtonMappings.suggested(for: target, controls: model.controls(for: target).map(\.cid)).mappedControls
+ }
+
private var choice: Choice {
- Self.suggested.allSatisfy { model.mappings[$0.key] == $0.value } ? .suggested : .mouseDefaults
+ let table = model.profiles(for: target).global
+ let wanted = suggested
+ return !wanted.isEmpty && wanted.allSatisfy { table[$0.key] == $0.value } ? .suggested : .mouseDefaults
}
+ /// Describes what Suggested does on this model, from the table it would apply.
+ private var suggestedDetail: String {
+ let table = suggested
+ var parts: [String] = []
+ if table[KnownControl.back] != nil, table[KnownControl.forward] != nil {
+ parts.append(String(localized: "Back and Forward navigate in apps (⌘[ and ⌘])."))
+ }
+ let others = table.keys.filter { $0 != KnownControl.back && $0 != KnownControl.forward }.sorted()
+ for cid in others {
+ guard let action = table[cid] else { continue }
+ let name = ControlDescriptor(cid: cid, model: target).name
+ if action == .system(.missionControl) {
+ parts.append(String(localized: "\(name) opens Mission Control."))
+ } else {
+ parts.append(String(localized: "\(name): \(action.title)."))
+ }
+ }
+ if table[KnownControl.back] != nil, table[KnownControl.forward] == nil {
+ parts.insert(String(localized: "\(ControlDescriptor(cid: KnownControl.back, model: target).name): \(ButtonAction.system(.navigateBack).title)."), at: 0)
+ } else if table[KnownControl.forward] != nil, table[KnownControl.back] == nil {
+ parts.insert(String(localized: "\(ControlDescriptor(cid: KnownControl.forward, model: target).name): \(ButtonAction.system(.navigateForward).title)."), at: 0)
+ }
+ return parts.joined(separator: " ")
+ }
+
+ /// Changes only the target model's global table; no other model's setup is touched.
private func apply(_ choice: Choice) {
- var mappings = model.mappings
- for (cid, action) in Self.suggested {
+ var profiles = model.profiles(for: target)
+ for (cid, action) in suggested {
switch choice {
case .suggested:
- mappings[cid] = action
+ profiles.global[cid] = action
case .mouseDefaults:
- if mappings[cid] == action { mappings[cid] = .deviceDefault }
+ if profiles.global[cid] == action { profiles.global[cid] = .deviceDefault }
}
}
- model.mappings = mappings
+ model.setProfiles(profiles, for: target)
}
-
- /// Back/Forward navigate, the top button opens Mission Control (what Upright 1.x shipped with).
- private static var suggested: [UInt16: ButtonAction] { ButtonMappings.legacyDefaults.mappedControls }
}
-/// Live feedback for the last button the mouse reported to Upright.
+/// Live feedback for the last button a mouse reported to Upright.
private struct TryItLine: View {
@EnvironmentObject private var model: AppModel
@EnvironmentObject private var devices: DeviceManager
@@ -312,11 +471,11 @@ private struct TryItLine: View {
.foregroundStyle(pressed == nil ? Color.secondary : Color.green)
.accessibilityHidden(true)
if let pressed {
- Text("\(ControlDescriptor(cid: pressed.cid).name): \(model.effectiveMappings[pressed.cid].title)")
+ Text(description(of: pressed))
.id(pressed.date)
.transition(.opacity)
- } else if devices.snapshot == nil {
- Text("Connect the mouse to try a button.")
+ } else if !devices.summary.anyPresent {
+ Text("Connect a mouse to try a button.")
} else {
Text("Try it: press a remapped button.")
}
@@ -331,11 +490,20 @@ private struct TryItLine: View {
private var pressed: AppModel.PressedControl? {
model.lastPressedControl.flatMap { $0.date >= shownSince ? $0 : nil }
}
+
+ /// For example "MX Master 3S: Back Button → ⌘[".
+ private func description(of press: AppModel.PressedControl) -> String {
+ let key = press.model ?? model.selectedModel
+ let device = model.record(press.source)?.displayName ?? model.modelName(key)
+ let control = ControlDescriptor(cid: press.cid, model: key).name
+ let action = model.effectiveMappings(model: key)[press.cid].title
+ return String(localized: "\(device): \(control) → \(action)")
+ }
}
private struct ChoiceCard: View {
let title: LocalizedStringKey
- let detail: LocalizedStringKey
+ let detail: String
let isSelected: Bool
let action: () -> Void
@@ -347,7 +515,7 @@ private struct ChoiceCard: View {
.font(.title3)
VStack(alignment: .leading, spacing: 3) {
Text(title).font(.headline)
- Text(detail)
+ Text(verbatim: detail)
.font(.callout)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
diff --git a/Upright/Views/PermissionsPane.swift b/Upright/Views/PermissionsPane.swift
index 5a331e1..d75fed3 100644
--- a/Upright/Views/PermissionsPane.swift
+++ b/Upright/Views/PermissionsPane.swift
@@ -47,7 +47,7 @@ struct PermissionsPane: View {
title: String(localized: "Input Monitoring"),
symbolName: "keyboard.badge.eye",
tint: .purple,
- explanation: String(localized: "Only needed if macOS hides the mouse from Upright, which can happen over Bluetooth. Upright then shows the MX Vertical as blocked and its buttons keep their normal behavior until you allow it."),
+ explanation: String(localized: "Only needed if macOS hides a mouse from Upright, which can happen over Bluetooth. Upright then shows it as blocked and its buttons keep their normal behavior until you allow it."),
state: inputMonitoringState
) {
// Prompts while undecided, otherwise opens System Settings; never both at once.
@@ -56,10 +56,13 @@ struct PermissionsPane: View {
permissions.requestInputMonitoring()
}
}
+ if !devices.blockedInterfaces.isEmpty {
+ BlockedInterfaceList(names: devices.blockedInterfaces)
+ }
}
Section {
- Label("Upright never reads what you type. It only listens to the HID++ channel of Logitech receivers and MX Vertical mice, and only posts the actions you assign.", systemImage: "lock.shield")
+ Label("Upright never reads what you type. It only listens to the HID++ channel of Logitech receivers and supported Logitech mice, and only posts the actions you assign.", systemImage: "lock.shield")
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
} header: {
@@ -71,11 +74,33 @@ struct PermissionsPane: View {
private var inputMonitoringState: PermissionState {
if permissions.inputMonitoring == .granted { return .granted }
- if devices.status == .accessDenied { return .required }
+ if devices.needsInputMonitoring { return .required }
return .optional
}
}
+/// The interfaces macOS refused to open, by name.
+private struct BlockedInterfaceList: View {
+ let names: [String]
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 4) {
+ Text("macOS is blocking:")
+ .font(.callout.weight(.medium))
+ ForEach(names, id: \.self) { name in
+ Label {
+ Text(verbatim: name)
+ } icon: {
+ Image(systemName: "lock.slash")
+ .foregroundStyle(.orange)
+ }
+ .font(.callout)
+ }
+ }
+ .accessibilityElement(children: .combine)
+ }
+}
+
enum PermissionState {
case granted
case required
diff --git a/Upright/Views/PointerSpeedSection.swift b/Upright/Views/PointerSpeedSection.swift
index 420e37c..fb18462 100644
--- a/Upright/Views/PointerSpeedSection.swift
+++ b/Upright/Views/PointerSpeedSection.swift
@@ -1,29 +1,38 @@
import SwiftUI
-/// Pointer speed (DPI): a slider snapped to the speeds the mouse supports, and the presets that
-/// "Cycle Pointer Speed" steps through.
+/// Pointer speed (DPI) of one mouse: a slider snapped to the speeds it supports, and the presets
+/// that "Cycle Pointer Speed" steps through (shared by every mouse, snapped to each one's speeds).
+///
+/// The speed set here is kept for that mouse only (`DevicePreferences.preferredDPI`) and applied
+/// again whenever it reconnects. Callers hide this section for a mouse without ADJUSTABLE_DPI
+/// (0x2201); with no mouse (`address` nil) only the presets can be edited.
struct PointerSpeedSection: View {
- /// nil while no mouse is connected; only the presets can be edited then.
+ let address: DeviceAddress?
+ /// nil while no mouse is selected or connected.
let dpi: DPIInfo?
@EnvironmentObject private var model: AppModel
- @EnvironmentObject private var devices: DeviceManager
@State private var draftIndex: Double?
@State private var newPreset = ""
+ init(address: DeviceAddress?, dpi: DPIInfo?) {
+ self.address = address
+ self.dpi = address == nil ? nil : dpi
+ }
+
var body: some View {
Section {
- if let dpi {
+ if let dpi, address != nil {
speedControls(dpi)
} else {
- Text("Connect the mouse to change its pointer speed.")
+ Text("Connect a mouse to change its pointer speed.")
.foregroundStyle(.secondary)
}
} header: {
Text("Pointer Speed")
} footer: {
if dpi != nil {
- Text("Upright applies this speed again whenever the mouse reconnects. The top button can still change it unless you remap that button.")
+ Text("Upright applies this speed again whenever this mouse reconnects. Other mice keep their own. A pointer-speed button on the mouse can still change it unless you remap that button.")
.font(.callout)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
@@ -31,6 +40,23 @@ struct PointerSpeedSection: View {
}
Section {
+ presetRows
+ .snapshotAnchor("dpiPresets")
+ presetEditor
+ } header: {
+ Text("Speed Presets")
+ } footer: {
+ Text("“Cycle Pointer Speed” steps through these speeds on whichever mouse the button belongs to, and each one can be assigned to a button in Buttons. Speeds are rounded to the nearest one that mouse supports.")
+ .font(.callout)
+ .foregroundStyle(.secondary)
+ .fixedSize(horizontal: false, vertical: true)
+ }
+ }
+
+ // MARK: Preset rows
+
+ private var presetRows: some View {
+ VStack(spacing: 0) {
ForEach(model.settings.dpiPresets, id: \.self) { preset in
LabeledContent {
Button {
@@ -46,34 +72,35 @@ struct PointerSpeedSection: View {
} label: {
Text("\(preset) DPI")
}
- }
- HStack {
- TextField("Add a speed", text: $newPreset, prompt: Text("e.g. 1000"))
- .textFieldStyle(.roundedBorder)
- .frame(maxWidth: 140)
- .onSubmit(addPreset)
- Button("Add", action: addPreset)
- .disabled(parsedPreset == nil)
- Spacer()
- if let dpi {
- Button("Use Suggested") {
- model.settings.dpiPresets = dpi.suggestedPresets()
- }
- .help("Four speeds spread across what this mouse supports")
+ .padding(.vertical, 4)
+ if preset != model.settings.dpiPresets.last {
+ Divider()
}
}
- if !newPreset.trimmingCharacters(in: .whitespaces).isEmpty, parsedPreset == nil {
- Text("Enter a number from \(DeviceAction.dpiRange.lowerBound) to \(DeviceAction.dpiRange.upperBound).")
- .font(.caption)
- .foregroundStyle(.orange)
+ }
+ }
+
+ @ViewBuilder
+ private var presetEditor: some View {
+ HStack {
+ TextField("Add a speed", text: $newPreset, prompt: Text("e.g. 1000"))
+ .textFieldStyle(.roundedBorder)
+ .frame(maxWidth: 140)
+ .onSubmit(addPreset)
+ Button("Add", action: addPreset)
+ .disabled(parsedPreset == nil)
+ Spacer()
+ if let dpi {
+ Button("Use Suggested") {
+ model.settings.dpiPresets = dpi.suggestedPresets()
+ }
+ .help("Replaces the presets with four speeds spread across what this mouse supports")
}
- } header: {
- Text("Speed Presets")
- } footer: {
- Text("“Cycle Pointer Speed” steps through these speeds, and each one can be assigned to a button in Buttons. Speeds are rounded to the nearest one the mouse supports.")
- .font(.callout)
- .foregroundStyle(.secondary)
- .fixedSize(horizontal: false, vertical: true)
+ }
+ if !newPreset.trimmingCharacters(in: .whitespaces).isEmpty, parsedPreset == nil {
+ Text("Enter a number from \(DeviceAction.dpiRange.lowerBound) to \(DeviceAction.dpiRange.upperBound).")
+ .font(.caption)
+ .foregroundStyle(.orange)
}
}
@@ -105,6 +132,7 @@ struct PointerSpeedSection: View {
}
.labelsHidden()
.accessibilityValue(Text("\(shownValue) DPI"))
+ .snapshotAnchor("dpiSlider")
}
Stepper {
Text("\(shownValue) DPI")
@@ -122,6 +150,8 @@ struct PointerSpeedSection: View {
} label: {
Text("Speed")
}
+ // A half-dragged slider doesn't carry over to another mouse.
+ .onChange(of: address) { _ in draftIndex = nil }
HStack {
Text("Supported: \(dpi.minimum)–\(dpi.maximum) DPI · Default \(dpi.defaultValue) DPI")
@@ -130,16 +160,25 @@ struct PointerSpeedSection: View {
Spacer()
Button("Use Default") { apply(dpi.defaultValue) }
.disabled(dpi.current == dpi.defaultValue)
- if devices.desiredDPI != nil {
- Button("Let the Mouse Decide") { devices.setDesiredDPI(nil) }
- .help("Stop reapplying a speed when the mouse reconnects")
+ if keepsSpeed {
+ Button("Let the Mouse Decide") {
+ if let address { model.setPreferredDPI(nil, for: address) }
+ }
+ .help("Stop reapplying a speed when this mouse reconnects")
}
}
}
- /// AppModel saves the kept speed (settings.preferredDPI) from DeviceManager.desiredDPI.
+ /// This mouse has a speed Upright keeps (its own preference, or its model's seed).
+ private var keepsSpeed: Bool {
+ guard let address else { return false }
+ return model.devicePreferences(for: address).preferredDPI != nil
+ }
+
+ /// Sets this mouse's speed and keeps it for this mouse only.
private func apply(_ value: Int) {
- devices.setDPI(value)
+ guard let address else { return }
+ model.setPreferredDPI(value, for: address)
}
/// The speeds the slider snaps to: what the mouse lists, or steps of 50 within its range.
diff --git a/Upright/Views/ProfileSection.swift b/Upright/Views/ProfileSection.swift
index 987172f..32dd76e 100644
--- a/Upright/Views/ProfileSection.swift
+++ b/Upright/Views/ProfileSection.swift
@@ -1,16 +1,29 @@
import AppKit
import SwiftUI
-/// Picks which mapping table the Buttons pane edits (global or one app's overrides), adds and
-/// removes app profiles, and says which profile is in effect right now.
+/// Picks which of a model's mapping tables the Buttons pane edits (global or one app's
+/// overrides), adds and removes that model's app profiles, and says which profile is in effect
+/// right now. App profiles are per model: adding one for an app never touches another model's.
struct ProfileSection: View {
- @EnvironmentObject private var model: AppModel
+ @EnvironmentObject private var appModel: AppModel
+ /// The model whose profiles are listed and edited.
+ let modelKey: ModelKey
/// nil edits the global table.
@Binding var selectedBundleID: String?
@State private var confirmingRemoval = false
@State private var addFailed = false
+ init(model: ModelKey, selectedBundleID: Binding) {
+ modelKey = model
+ _selectedBundleID = selectedBundleID
+ }
+
+ /// The model's setup (global table and app profiles).
+ private var profiles: MappingProfiles {
+ appModel.library[modelKey]
+ }
+
var body: some View {
Section {
HStack(spacing: 10) {
@@ -21,10 +34,10 @@ struct ProfileSection: View {
Image(systemName: "globe")
}
.tag(String?.none)
- if !model.profiles.apps.isEmpty {
+ if !profiles.apps.isEmpty {
Divider()
}
- ForEach(model.profiles.apps) { app in
+ ForEach(profiles.apps) { app in
Label {
Text(app.isEnabled ? app.displayName : String(localized: "\(app.displayName) (off)"))
} icon: {
@@ -35,13 +48,14 @@ struct ProfileSection: View {
} label: {
Text("Edit mappings for")
}
+ .snapshotAnchor("profilePicker")
addMenu
}
if let profile = selectedProfile {
Toggle(isOn: Binding(
get: { profile.isEnabled },
- set: { model.setAppProfileEnabled($0, bundleID: profile.bundleID) }
+ set: { appModel.setAppProfileEnabled($0, bundleID: profile.bundleID, model: modelKey) }
)) {
VStack(alignment: .leading, spacing: 2) {
Text("Use this profile")
@@ -73,7 +87,7 @@ struct ProfileSection: View {
Button("Remove Profile", role: .destructive) {
guard let bundleID = selectedBundleID else { return }
selectedBundleID = nil
- model.removeAppProfile(bundleID: bundleID)
+ appModel.removeAppProfile(bundleID: bundleID, model: modelKey)
}
} message: {
Text("Its overrides are deleted. The app goes back to using the global actions.")
@@ -83,7 +97,7 @@ struct ProfileSection: View {
} message: {
Text("Upright couldn’t read a bundle identifier from it.")
}
- .onChange(of: model.profiles.apps.map(\.bundleID)) { ids in
+ .onChange(of: profiles.apps.map(\.bundleID)) { ids in
if let selected = selectedBundleID, !ids.contains(where: { $0.caseInsensitiveCompare(selected) == .orderedSame }) {
selectedBundleID = nil
}
@@ -91,7 +105,7 @@ struct ProfileSection: View {
}
private var selectedProfile: AppProfile? {
- selectedBundleID.flatMap { model.profiles[app: $0] }
+ selectedBundleID.flatMap { profiles[app: $0] }
}
private var addMenu: some View {
@@ -130,9 +144,9 @@ struct ProfileSection: View {
@ViewBuilder
private var activeNow: some View {
- let frontmost = model.frontmostApp
+ let frontmost = appModel.frontmostApp
Group {
- if let active = model.profiles.enabledProfile(forBundleID: frontmost?.bundleID) {
+ if let active = profiles.enabledProfile(forBundleID: frontmost?.bundleID) {
Text("In effect now: the \(active.displayName) profile.")
} else if let frontmost {
Text("In effect now: the global actions (\(frontmost.name) has no active profile).")
@@ -142,6 +156,7 @@ struct ProfileSection: View {
}
.font(.callout)
.foregroundStyle(.secondary)
+ .snapshotAnchor("inEffect")
}
/// Regular apps that are running and don't have a profile yet, by name.
@@ -151,7 +166,7 @@ struct ProfileSection: View {
return NSWorkspace.shared.runningApplications
.filter { $0.activationPolicy == .regular }
.compactMap { app -> AppReference? in
- guard let id = app.bundleIdentifier, id != own, model.profiles[app: id] == nil,
+ guard let id = app.bundleIdentifier, id != own, profiles[app: id] == nil,
seen.insert(id.lowercased()).inserted else { return nil }
return AppReference(bundleID: id, displayName: app.localizedName ?? id)
}
@@ -159,8 +174,8 @@ struct ProfileSection: View {
}
private func add(_ app: AppReference) {
- model.addAppProfile(bundleID: app.bundleID, displayName: app.displayName)
- selectedBundleID = model.profiles[app: app.bundleID]?.bundleID ?? app.bundleID
+ appModel.addAppProfile(bundleID: app.bundleID, displayName: app.displayName, model: modelKey)
+ selectedBundleID = appModel.library[modelKey][app: app.bundleID]?.bundleID ?? app.bundleID
}
}
diff --git a/Upright/Views/ScrollPane.swift b/Upright/Views/ScrollPane.swift
index 70294a4..b10d5f2 100644
--- a/Upright/Views/ScrollPane.swift
+++ b/Upright/Views/ScrollPane.swift
@@ -24,6 +24,7 @@ struct ScrollPane: View {
.fixedSize(horizontal: false, vertical: true)
}
}
+ .snapshotAnchor("scroll.reverseVertical")
Toggle(isOn: binding(\.reverseHorizontal)) {
VStack(alignment: .leading, spacing: 2) {
Text("Reverse horizontal scrolling")
@@ -58,6 +59,7 @@ struct ScrollPane: View {
} label: {
Text("Speed")
}
+ .snapshotAnchor("scroll.speed")
Toggle(isOn: binding(\.smoothScrolling)) {
VStack(alignment: .leading, spacing: 2) {
@@ -70,15 +72,16 @@ struct ScrollPane: View {
.foregroundStyle(.secondary)
}
}
+ .snapshotAnchor("scroll.smooth")
} header: {
Text("Feel")
}
Section {
- Toggle(isOn: binding(\.onlyWhileMXVerticalConnected)) {
+ Toggle(isOn: binding(\.onlyWhileMouseConnected)) {
VStack(alignment: .leading, spacing: 2) {
- Text("Only while the MX Vertical is connected")
- Text("macOS doesn’t say which mouse a scroll came from. With this off, every mouse wheel is changed, but trackpads never are.")
+ Text("Only while a Logitech mouse is connected")
+ Text("Applies to every mouse wheel. macOS doesn’t say which mouse a scroll came from. Trackpads are never changed.")
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
@@ -107,6 +110,8 @@ struct ScrollPane: View {
/// Why the settings aren't in effect, when they should be but aren't.
private var status: AnyView? {
+ // Demo Mode never installs the scroll tap: the settings are shown as if in effect.
+ guard model.environment.usesEventTaps else { return nil }
let settings = model.settings.scrollSettings
guard settings.transformsEvents, !model.scroll.isActive else { return nil }
if !permissions.isAccessibilityTrusted {
@@ -129,9 +134,9 @@ struct ScrollPane: View {
}
)
}
- if settings.onlyWhileMXVerticalConnected, devices.snapshot == nil {
+ if settings.onlyWhileMouseConnected, !devices.summary.anyPresent {
return AnyView(
- Label("Paused until the MX Vertical connects.", systemImage: "pause.circle")
+ Label("Paused until a Logitech mouse connects.", systemImage: "pause.circle")
.foregroundStyle(.secondary)
)
}
diff --git a/Upright/Views/SettingsView.swift b/Upright/Views/SettingsView.swift
index 70afd71..9494a3c 100644
--- a/Upright/Views/SettingsView.swift
+++ b/Upright/Views/SettingsView.swift
@@ -1,5 +1,7 @@
import SwiftUI
+/// The Settings sidebar. `.device` is the Mice pane (every mouse, its speed and its log); the
+/// case keeps its name so links to it (e.g. "Edit Presets…") keep working.
enum SettingsPane: String, CaseIterable, Identifiable {
case buttons
case scrolling
@@ -14,7 +16,7 @@ enum SettingsPane: String, CaseIterable, Identifiable {
switch self {
case .buttons: return String(localized: "Buttons")
case .scrolling: return String(localized: "Scrolling")
- case .device: return String(localized: "Device")
+ case .device: return String(localized: "Mice")
case .general: return String(localized: "General")
case .permissions: return String(localized: "Permissions")
case .about: return String(localized: "About")
@@ -25,7 +27,7 @@ enum SettingsPane: String, CaseIterable, Identifiable {
switch self {
case .buttons: return "computermouse.fill"
case .scrolling: return "scroll.fill"
- case .device: return "waveform.path.ecg"
+ case .device: return "dot.radiowaves.left.and.right"
case .general: return "gearshape.fill"
case .permissions: return "hand.raised.fill"
case .about: return "info.circle.fill"
diff --git a/Upright/Views/SettingsWindowController.swift b/Upright/Views/SettingsWindowController.swift
index fd12fe4..155f179 100644
--- a/Upright/Views/SettingsWindowController.swift
+++ b/Upright/Views/SettingsWindowController.swift
@@ -15,9 +15,15 @@ final class SettingsWindowController: NSObject, NSWindowDelegate {
var isVisible: Bool { window?.isVisible == true }
- func show(pane: SettingsPane? = nil) {
+ /// Opens Settings, optionally on a pane and, for the Mice pane, with one mouse selected.
+ /// Never opens anything while Demo Mode is exporting snapshots.
+ func show(pane: SettingsPane? = nil, device: DeviceAddress? = nil) {
+ guard !DemoMode.isExporting else { return }
let model = AppModel.shared
if let pane { model.selectedPane = pane }
+ if let device, model.devices.records.contains(where: { $0.address == device && $0.role != .unsupported }) {
+ model.selectedDevice = device
+ }
let window = self.window ?? makeWindow()
self.window = window
@@ -35,7 +41,9 @@ final class SettingsWindowController: NSObject, NSWindowDelegate {
window?.performClose(nil)
}
- private static func rootView(model: AppModel) -> some View {
+ /// The settings content with everything its panes read from the environment. Also used by
+ /// the snapshot exporter, so exported images show the same view tree as the window.
+ static func rootView(model: AppModel) -> some View {
SettingsView()
.environmentObject(model)
.environmentObject(model.devices)
diff --git a/Upright/Views/SnapshotAnchor.swift b/Upright/Views/SnapshotAnchor.swift
new file mode 100644
index 0000000..0b4b191
--- /dev/null
+++ b/Upright/Views/SnapshotAnchor.swift
@@ -0,0 +1,90 @@
+import SwiftUI
+
+/// Named view bounds collected for the snapshot manifest.
+struct SnapshotAnchorKey: PreferenceKey {
+ static var defaultValue: [String: Anchor] { [:] }
+
+ static func reduce(value: inout [String: Anchor], nextValue: () -> [String: Anchor]) {
+ value.merge(nextValue()) { _, new in new }
+ }
+}
+
+extension View {
+ /// Names this view's bounds in the snapshot manifest (`rects` in manifest.json). A no-op
+ /// unless Demo Mode is exporting snapshots.
+ ///
+ /// The bounds travel two ways: as an anchor preference (`SnapshotAnchorKey`), which the
+ /// exporter resolves against its root view, and as a window-space frame reported to
+ /// `SnapshotAnchorCollector`. The second covers views that AppKit hosts on their own (rows of
+ /// a `Form` or `List` live in table cells, whose preferences don't reach the root).
+ @ViewBuilder
+ func snapshotAnchor(_ name: String) -> some View {
+ if DemoMode.isExporting {
+ transformAnchorPreference(key: SnapshotAnchorKey.self, value: .bounds) { value, anchor in
+ value[name] = anchor
+ }
+ .background(SnapshotFrameReporter(name: name))
+ } else {
+ self
+ }
+ }
+}
+
+/// The window-space frames of every `snapshotAnchor` on screen, by name (points, top-left origin
+/// of the window's content). Only filled while exporting. `reset()` starts a new capture: views
+/// of an earlier one that report late are ignored.
+@MainActor
+final class SnapshotAnchorCollector {
+ static let shared = SnapshotAnchorCollector()
+
+ /// Frames by name. Views that left the hierarchy are removed.
+ private(set) var frames: [String: CGRect] = [:]
+ /// How many views report each name right now (a name can appear twice during an update).
+ private var counts: [String: Int] = [:]
+ private(set) var generation = 0
+
+ func reset() {
+ generation += 1
+ frames.removeAll()
+ counts.removeAll()
+ }
+
+ fileprivate func appeared(_ name: String, frame: CGRect) -> Int {
+ counts[name, default: 0] += 1
+ frames[name] = frame
+ return generation
+ }
+
+ fileprivate func moved(_ name: String, frame: CGRect, generation: Int?) {
+ guard generation == self.generation else { return }
+ frames[name] = frame
+ }
+
+ fileprivate func disappeared(_ name: String, generation: Int?) {
+ guard generation == self.generation else { return }
+ let remaining = (counts[name] ?? 1) - 1
+ if remaining <= 0 {
+ counts[name] = nil
+ frames[name] = nil
+ } else {
+ counts[name] = remaining
+ }
+ }
+}
+
+private struct SnapshotFrameReporter: View {
+ let name: String
+ @State private var generation: Int?
+
+ var body: some View {
+ GeometryReader { proxy in
+ let frame = proxy.frame(in: .global)
+ Color.clear
+ .onAppear { generation = SnapshotAnchorCollector.shared.appeared(name, frame: frame) }
+ .onChange(of: frame) { SnapshotAnchorCollector.shared.moved(name, frame: $0, generation: generation) }
+ .onDisappear { SnapshotAnchorCollector.shared.disappeared(name, generation: generation) }
+ }
+ .accessibilityHidden(true)
+ .allowsHitTesting(false)
+ }
+}
diff --git a/UprightKit/Sources/UprightKit/Device/ButtonStateTracker.swift b/UprightKit/Sources/UprightKit/Device/ButtonStateTracker.swift
index 571d46a..c0243fc 100644
--- a/UprightKit/Sources/UprightKit/Device/ButtonStateTracker.swift
+++ b/UprightKit/Sources/UprightKit/Device/ButtonStateTracker.swift
@@ -3,23 +3,43 @@ public struct ButtonEvent: Hashable, Sendable {
public var cid: UInt16
public var phase: Phase
+ /// The endpoint that sent the event. Stamped by the endpoint controller; `.unassigned`
+ /// from code paths that don't know their device.
+ public var source: DeviceAddress = .unassigned
- public init(cid: UInt16, phase: Phase) {
+ public init(cid: UInt16, phase: Phase, source: DeviceAddress = .unassigned) {
self.cid = cid
self.phase = phase
+ self.source = source
+ }
+}
+
+/// Raw sensor movement (HID++ raw XY) from one endpoint while one of its raw-XY controls is held.
+public struct PointerDelta: Hashable, Sendable {
+ public var source: DeviceAddress
+ public var dx: Int
+ public var dy: Int
+
+ public init(source: DeviceAddress = .unassigned, dx: Int, dy: Int) {
+ self.source = source
+ self.dx = dx
+ self.dy = dy
}
}
/// Turns divertedButtonsEvent snapshots ("these CIDs are down right now") into
-/// individual press and release events.
+/// individual press and release events, stamped with `source`.
public struct ButtonStateTracker: Sendable {
public private(set) var pressed: [UInt16] = []
+ public var source: DeviceAddress
- public init() {}
+ public init(source: DeviceAddress = .unassigned) {
+ self.source = source
+ }
public mutating func update(pressedNow: [UInt16]) -> [ButtonEvent] {
- let releases = pressed.filter { !pressedNow.contains($0) }.map { ButtonEvent(cid: $0, phase: .release) }
- let presses = pressedNow.filter { !pressed.contains($0) }.map { ButtonEvent(cid: $0, phase: .press) }
+ let releases = pressed.filter { !pressedNow.contains($0) }.map { ButtonEvent(cid: $0, phase: .release, source: source) }
+ let presses = pressedNow.filter { !pressed.contains($0) }.map { ButtonEvent(cid: $0, phase: .press, source: source) }
pressed = pressedNow
return releases + presses
}
diff --git a/UprightKit/Sources/UprightKit/Device/ConfigurationPlanner.swift b/UprightKit/Sources/UprightKit/Device/ConfigurationPlanner.swift
new file mode 100644
index 0000000..99de246
--- /dev/null
+++ b/UprightKit/Sources/UprightKit/Device/ConfigurationPlanner.swift
@@ -0,0 +1,96 @@
+import Foundation
+
+/// Everything besides the mappings that decides one endpoint's configuration.
+public struct ConfigurationInputs: Hashable, Sendable {
+ /// The app-wide "Remap buttons" switch.
+ public var remappingEnabled: Bool
+ public var accessibilityGranted: Bool
+ /// This mouse's own "Remap this mouse" preference.
+ public var deviceRemapEnabled: Bool
+ /// False for standby, unsupported and pending endpoints (`DeviceRegistry.isActive`, or
+ /// `DeviceRegistry.prospectiveRole` for an endpoint that is still connecting).
+ public var isActive: Bool
+ /// This mouse's pointer speed preference; nil leaves the speed to the mouse.
+ public var preferredDPI: Int?
+ /// Controls of this device the engine sees held: kept diverted until released.
+ public var held: Set
+ /// Held controls of this device with a gesture in progress: kept reporting raw movement.
+ public var gesturesHeld: Set
+
+ public init(
+ remappingEnabled: Bool = true,
+ accessibilityGranted: Bool = false,
+ deviceRemapEnabled: Bool = true,
+ isActive: Bool = true,
+ preferredDPI: Int? = nil,
+ held: Set = [],
+ gesturesHeld: Set = []
+ ) {
+ self.remappingEnabled = remappingEnabled
+ self.accessibilityGranted = accessibilityGranted
+ self.deviceRemapEnabled = deviceRemapEnabled
+ self.isActive = isActive
+ self.preferredDPI = preferredDPI
+ self.held = held
+ self.gesturesHeld = gesturesHeld
+ }
+}
+
+/// Decides what one endpoint should be doing. Pure: the same inputs always give the same
+/// configuration, so AppModel can compare it with what it last applied.
+public enum ConfigurationPlanner {
+ /// The configuration for one endpoint.
+ ///
+ /// - Remapping off: nothing diverted, held buttons included (as Upright 2.x did).
+ /// - Inactive endpoint, or "Remap this mouse" off: only held controls stay diverted, so their
+ /// release still arrives; held gestures keep their raw movement. Nothing else is diverted
+ /// and the pointer speed is left alone, so a standby twin with nothing held is `.native`.
+ /// - Otherwise: the mapped controls plus the held ones. Gesture controls also report raw
+ /// movement while held, when the control can.
+ ///
+ /// Diversion is limited to the device's configurable controls, never the primary clicks
+ /// (`ControlCatalog.neverDivert`), so mappings made for another model never reach it.
+ /// `dpi` is the preferred speed snapped to `dpiInfo`'s supported list; without a list
+ /// (unknown device) it is passed through, and the device snaps it when it is applied.
+ ///
+ /// For an MX Vertical this is exactly what Upright 2.x's `AppModel.syncDiversion` put on the
+ /// wire (`ConfigurationPlannerTests.testMatchesTodaysSyncDiversionForMXVertical`).
+ public static func configuration(
+ mappings: ButtonMappings,
+ controls: [ControlInfo],
+ dpi dpiInfo: DPIInfo? = nil,
+ inputs: ConfigurationInputs
+ ) -> EndpointConfiguration {
+ let served = inputs.isActive && inputs.deviceRemapEnabled
+ let speed = inputs.isActive ? inputs.preferredDPI.map { dpiInfo?.nearest(to: $0) ?? $0 } : nil
+ guard inputs.remappingEnabled else { return EndpointConfiguration(dpi: speed) }
+
+ var divert = inputs.held
+ var gestures = inputs.gesturesHeld
+ if served {
+ divert.formUnion(mappings.divertedControls(accessibilityGranted: inputs.accessibilityGranted))
+ gestures.formUnion(mappings.gestureControls(accessibilityGranted: inputs.accessibilityGranted))
+ }
+
+ let configurable = controls.filter { $0.isConfigurable && !ControlCatalog.neverDivert.contains($0.cid) }
+ let configurableCIDs = Set(configurable.map(\.cid))
+ let rawCapable = Set(configurable.filter(\.supportsRawXY).map(\.cid))
+ return EndpointConfiguration(
+ divert: divert.union(gestures).intersection(configurableCIDs),
+ rawXY: gestures.intersection(rawCapable),
+ dpi: speed
+ )
+ }
+
+ /// The configuration for a connected endpoint, from its controls and pointer-speed list.
+ /// A mouse without ADJUSTABLE_DPI (0x2201) gets no speed.
+ public static func configuration(
+ mappings: ButtonMappings,
+ snapshot: DeviceSnapshot,
+ inputs: ConfigurationInputs
+ ) -> EndpointConfiguration {
+ var configuration = configuration(mappings: mappings, controls: snapshot.controls, dpi: snapshot.dpi, inputs: inputs)
+ if snapshot.dpi == nil { configuration.dpi = nil }
+ return configuration
+ }
+}
diff --git a/UprightKit/Sources/UprightKit/Device/ControlCatalog.swift b/UprightKit/Sources/UprightKit/Device/ControlCatalog.swift
new file mode 100644
index 0000000..29fee96
--- /dev/null
+++ b/UprightKit/Sources/UprightKit/Device/ControlCatalog.swift
@@ -0,0 +1,321 @@
+import Foundation
+
+/// Control IDs (CIDs) used by REPROG_CONTROLS_V4, as listed in Solaar's special_keys table
+/// and LogiOps' MX Vertical example configuration.
+public enum KnownControl {
+ public static let leftClick: UInt16 = 0x0050
+ public static let rightClick: UInt16 = 0x0051
+ public static let middleClick: UInt16 = 0x0052
+ public static let back: UInt16 = 0x0053
+ public static let forward: UInt16 = 0x0056
+ public static let tiltLeft: UInt16 = 0x005B
+ public static let tiltRight: UInt16 = 0x005D
+ public static let gestureButton: UInt16 = 0x00C3
+ public static let smartShift: UInt16 = 0x00C4
+ public static let multiPlatformGesture: UInt16 = 0x00D0
+ public static let virtualGesture: UInt16 = 0x00D7
+ public static let dpiChange: UInt16 = 0x00ED
+ public static let dpiSwitch: UInt16 = 0x00FD
+ public static let hapticSense: UInt16 = 0x01A0
+
+ public static let primaryClicks: Set = [leftClick, rightClick]
+}
+
+/// User-facing wording for a control.
+public struct ControlText: Hashable, Sendable {
+ public var name: String
+ public var detail: String
+ public var symbolName: String
+
+ public init(name: String, detail: String, symbolName: String) {
+ self.name = name
+ self.detail = detail
+ self.symbolName = symbolName
+ }
+}
+
+/// Names and rules for controls across models.
+public enum ControlCatalog {
+ /// Wording for a control: the model's own override, then the generic name of the CID,
+ /// then "Button 0x%04X".
+ public static func text(for cid: UInt16, model: ModelKey?) -> ControlText {
+ if let model, let text = DeviceCatalog.model(model)?.controlText[cid] {
+ return text
+ }
+ if let text = genericText[cid] {
+ return text
+ }
+ return ControlText(
+ name: String(format: NSLocalizedString("Button 0x%04X", comment: "Mouse control name; %04X is its hexadecimal ID"), Int(cid)),
+ detail: NSLocalizedString("Additional programmable control", comment: "Mouse control description"),
+ symbolName: "button.programmable"
+ )
+ }
+
+ /// Never diverted, whatever a device's flags say: losing the primary clicks locks the user out.
+ public static let neverDivert: Set = [KnownControl.leftClick, KnownControl.rightClick]
+
+ /// The CIDs that have a model-independent name: every entry of Solaar's CONTROL table
+ /// that a Logitech mouse is known to report (33), in ascending order.
+ public static var namedControls: [UInt16] { solaarNames.keys.sorted() }
+
+ /// Solaar's name for a control (its `special_keys.CONTROL` table), for logs, probe output and
+ /// device reports. Not localized; nil for a CID Solaar doesn't list.
+ public static func solaarName(for cid: UInt16) -> String? { solaarNames[cid] }
+
+ static let solaarNames: [UInt16: String] = [
+ 0x0050: "Left Button",
+ 0x0051: "Right Button",
+ 0x0052: "Middle Button",
+ 0x0053: "Back Button",
+ 0x0054: "Back",
+ 0x0055: "Back As Alt Win Arrow",
+ 0x0056: "Forward Button",
+ 0x0057: "Forward As HID",
+ 0x0058: "Forward As Alt Win Arrow",
+ 0x0059: "Button 6",
+ 0x005A: "Left Scroll As Button 7",
+ 0x005B: "Left Tilt",
+ 0x005C: "Right Scroll As Button 8",
+ 0x005D: "Right Tilt",
+ 0x005E: "Button 9",
+ 0x005F: "Button 10",
+ 0x0097: "Analog HScroll",
+ 0x00AC: "Back Hscroll",
+ 0x00B7: "Fn Left Click",
+ 0x00B8: "Second Left Click",
+ 0x00B9: "Fn Second Left Click",
+ 0x00C3: "Mouse Gesture Button",
+ 0x00C4: "Smart Shift",
+ 0x00CE: "MultiPlatform Back",
+ 0x00CF: "MultiPlatform Forward",
+ 0x00D0: "MultiPlatform Gesture Button",
+ 0x00D7: "Virtual Gesture Button",
+ 0x00D8: "Cursor Button Long Press",
+ 0x00ED: "DPI Change",
+ 0x00FD: "DPI Switch",
+ 0x0102: "LeftAndRightClick",
+ 0x01A0: "Haptic",
+ 0x01B4: "AI",
+ ]
+
+ /// Model-independent wording for the 33 CIDs in `solaarNames`. Models that describe a
+ /// control differently (the MX Vertical's "Top Button") override it in `DeviceModel.controlText`.
+ static let genericText: [UInt16: ControlText] = [
+ 0x0050: ControlText(
+ name: NSLocalizedString("Left Button", comment: "Mouse control name"),
+ detail: NSLocalizedString("Primary click", comment: "Mouse control description"),
+ symbolName: "cursorarrow.click"
+ ),
+ 0x0051: ControlText(
+ name: NSLocalizedString("Right Button", comment: "Mouse control name"),
+ detail: NSLocalizedString("Secondary click", comment: "Mouse control description"),
+ symbolName: "contextualmenu.and.cursorarrow"
+ ),
+ 0x0052: ControlText(
+ name: NSLocalizedString("Wheel Click", comment: "Mouse control name"),
+ detail: NSLocalizedString("Press the scroll wheel", comment: "Mouse control description"),
+ symbolName: "smallcircle.filled.circle"
+ ),
+ 0x0053: ControlText(
+ name: NSLocalizedString("Back Button", comment: "Mouse control name"),
+ detail: NSLocalizedString("Thumb button", comment: "Mouse control description"),
+ symbolName: "chevron.backward.circle"
+ ),
+ 0x0054: ControlText(
+ name: NSLocalizedString("Back (Alternate)", comment: "Mouse control name"),
+ detail: NSLocalizedString("Alternate back button", comment: "Mouse control description"),
+ symbolName: "chevron.backward.circle"
+ ),
+ 0x0055: ControlText(
+ name: NSLocalizedString("Back (Arrow Key)", comment: "Mouse control name"),
+ detail: NSLocalizedString("Back, sent as an Alt or Windows arrow key", comment: "Mouse control description"),
+ symbolName: "chevron.backward.circle"
+ ),
+ 0x0056: ControlText(
+ name: NSLocalizedString("Forward Button", comment: "Mouse control name"),
+ detail: NSLocalizedString("Thumb button", comment: "Mouse control description"),
+ symbolName: "chevron.forward.circle"
+ ),
+ 0x0057: ControlText(
+ name: NSLocalizedString("Forward (HID)", comment: "Mouse control name"),
+ detail: NSLocalizedString("Forward, sent as a standard mouse button", comment: "Mouse control description"),
+ symbolName: "chevron.forward.circle"
+ ),
+ 0x0058: ControlText(
+ name: NSLocalizedString("Forward (Arrow Key)", comment: "Mouse control name"),
+ detail: NSLocalizedString("Forward, sent as an Alt or Windows arrow key", comment: "Mouse control description"),
+ symbolName: "chevron.forward.circle"
+ ),
+ 0x0059: ControlText(
+ name: NSLocalizedString("Button 6", comment: "Mouse control name"),
+ detail: NSLocalizedString("Additional programmable control", comment: "Mouse control description"),
+ symbolName: "button.programmable"
+ ),
+ 0x005A: ControlText(
+ name: NSLocalizedString("Scroll Left (Button 7)", comment: "Mouse control name"),
+ detail: NSLocalizedString("Horizontal scroll to the left, reported as a button", comment: "Mouse control description"),
+ symbolName: "arrow.left.circle"
+ ),
+ 0x005B: ControlText(
+ name: NSLocalizedString("Tilt Left", comment: "Mouse control name"),
+ detail: NSLocalizedString("Tilt the scroll wheel to the left", comment: "Mouse control description"),
+ symbolName: "arrow.left.circle"
+ ),
+ 0x005C: ControlText(
+ name: NSLocalizedString("Scroll Right (Button 8)", comment: "Mouse control name"),
+ detail: NSLocalizedString("Horizontal scroll to the right, reported as a button", comment: "Mouse control description"),
+ symbolName: "arrow.right.circle"
+ ),
+ 0x005D: ControlText(
+ name: NSLocalizedString("Tilt Right", comment: "Mouse control name"),
+ detail: NSLocalizedString("Tilt the scroll wheel to the right", comment: "Mouse control description"),
+ symbolName: "arrow.right.circle"
+ ),
+ 0x005E: ControlText(
+ name: NSLocalizedString("Button 9", comment: "Mouse control name"),
+ detail: NSLocalizedString("Additional programmable control", comment: "Mouse control description"),
+ symbolName: "button.programmable"
+ ),
+ 0x005F: ControlText(
+ name: NSLocalizedString("Button 10", comment: "Mouse control name"),
+ detail: NSLocalizedString("Additional programmable control", comment: "Mouse control description"),
+ symbolName: "button.programmable"
+ ),
+ 0x0097: ControlText(
+ name: NSLocalizedString("Analog Horizontal Scroll", comment: "Mouse control name"),
+ detail: NSLocalizedString("Horizontal scroll wheel", comment: "Mouse control description"),
+ symbolName: "arrow.left.and.right.circle"
+ ),
+ 0x00AC: ControlText(
+ name: NSLocalizedString("Back (Horizontal Scroll)", comment: "Mouse control name"),
+ detail: NSLocalizedString("Back, sent as a horizontal scroll", comment: "Mouse control description"),
+ symbolName: "chevron.backward.circle"
+ ),
+ 0x00B7: ControlText(
+ name: NSLocalizedString("Fn + Left Click", comment: "Mouse control name"),
+ detail: NSLocalizedString("Left click while the Fn key is held", comment: "Mouse control description"),
+ symbolName: "cursorarrow.click"
+ ),
+ 0x00B8: ControlText(
+ name: NSLocalizedString("Second Left Click", comment: "Mouse control name"),
+ detail: NSLocalizedString("Additional left-click button", comment: "Mouse control description"),
+ symbolName: "cursorarrow.click.2"
+ ),
+ 0x00B9: ControlText(
+ name: NSLocalizedString("Fn + Second Left Click", comment: "Mouse control name"),
+ detail: NSLocalizedString("Second left click while the Fn key is held", comment: "Mouse control description"),
+ symbolName: "cursorarrow.click.2"
+ ),
+ 0x00C3: ControlText(
+ name: NSLocalizedString("Gesture Button", comment: "Mouse control name"),
+ detail: NSLocalizedString("Thumb gesture button", comment: "Mouse control description"),
+ symbolName: "hand.point.up.left"
+ ),
+ 0x00C4: ControlText(
+ name: NSLocalizedString("SmartShift Button", comment: "Mouse control name"),
+ detail: NSLocalizedString("Switches the scroll wheel mode", comment: "Mouse control description"),
+ symbolName: "slider.horizontal.3"
+ ),
+ 0x00CE: ControlText(
+ name: NSLocalizedString("Multi-Platform Back", comment: "Mouse control name"),
+ detail: NSLocalizedString("Back button of a multi-platform mouse", comment: "Mouse control description"),
+ symbolName: "chevron.backward.circle"
+ ),
+ 0x00CF: ControlText(
+ name: NSLocalizedString("Multi-Platform Forward", comment: "Mouse control name"),
+ detail: NSLocalizedString("Forward button of a multi-platform mouse", comment: "Mouse control description"),
+ symbolName: "chevron.forward.circle"
+ ),
+ 0x00D0: ControlText(
+ name: NSLocalizedString("Multi-Platform Gesture Button", comment: "Mouse control name"),
+ detail: NSLocalizedString("Thumb gesture button", comment: "Mouse control description"),
+ symbolName: "hand.point.up.left"
+ ),
+ 0x00D7: ControlText(
+ name: NSLocalizedString("Virtual Gesture Button", comment: "Mouse control name"),
+ detail: NSLocalizedString("Software gesture button", comment: "Mouse control description"),
+ symbolName: "hand.point.up.left"
+ ),
+ 0x00D8: ControlText(
+ name: NSLocalizedString("Cursor Button (Long Press)", comment: "Mouse control name"),
+ detail: NSLocalizedString("Long press of the cursor button", comment: "Mouse control description"),
+ symbolName: "cursorarrow"
+ ),
+ 0x00ED: ControlText(
+ name: NSLocalizedString("DPI Change Button", comment: "Mouse control name"),
+ detail: NSLocalizedString("Changes the pointer speed (DPI)", comment: "Mouse control description"),
+ symbolName: "speedometer"
+ ),
+ 0x00FD: ControlText(
+ name: NSLocalizedString("DPI Switch Button", comment: "Mouse control name"),
+ detail: NSLocalizedString("Cycles through pointer speeds (DPI)", comment: "Mouse control description"),
+ symbolName: "speedometer"
+ ),
+ 0x0102: ControlText(
+ name: NSLocalizedString("Left + Right Click", comment: "Mouse control name"),
+ detail: NSLocalizedString("Left and right buttons pressed together", comment: "Mouse control description"),
+ symbolName: "cursorarrow.click.2"
+ ),
+ 0x01A0: ControlText(
+ name: NSLocalizedString("Haptic Sense Button", comment: "Mouse control name"),
+ detail: NSLocalizedString("Haptic thumb panel", comment: "Mouse control description"),
+ symbolName: "waveform"
+ ),
+ 0x01B4: ControlText(
+ name: NSLocalizedString("AI Button", comment: "Mouse control name"),
+ detail: NSLocalizedString("Dedicated AI button", comment: "Mouse control description"),
+ symbolName: "sparkles"
+ ),
+ ]
+}
+
+/// User-facing description of a control.
+public struct ControlDescriptor: Hashable, Sendable, Identifiable {
+ public var cid: UInt16
+ public var name: String
+ public var detail: String
+ public var symbolName: String
+
+ public var id: UInt16 { cid }
+
+ /// The MX Vertical wording (what Upright has always shown).
+ public init(cid: UInt16) {
+ self.cid = cid
+ switch cid {
+ case KnownControl.back:
+ name = NSLocalizedString("Back Button", comment: "Mouse control name")
+ detail = NSLocalizedString("Lower thumb button", comment: "Mouse control description")
+ symbolName = "chevron.backward.circle"
+ case KnownControl.forward:
+ name = NSLocalizedString("Forward Button", comment: "Mouse control name")
+ detail = NSLocalizedString("Upper thumb button", comment: "Mouse control description")
+ symbolName = "chevron.forward.circle"
+ case KnownControl.dpiSwitch, KnownControl.dpiChange:
+ name = NSLocalizedString("Top Button", comment: "Mouse control name")
+ detail = NSLocalizedString("Pointer-speed (DPI) button behind the wheel", comment: "Mouse control description")
+ symbolName = "speedometer"
+ case KnownControl.middleClick:
+ name = NSLocalizedString("Wheel Click", comment: "Mouse control name")
+ detail = NSLocalizedString("Press the scroll wheel", comment: "Mouse control description")
+ symbolName = "smallcircle.filled.circle"
+ default:
+ name = String(format: NSLocalizedString("Control 0x%04X", comment: "Mouse control name; %04X is its hexadecimal ID"), cid)
+ detail = NSLocalizedString("Additional programmable control", comment: "Mouse control description")
+ symbolName = "button.programmable"
+ }
+ }
+
+ public var hexID: String { String(format: "0x%04X", cid) }
+}
+
+extension ControlDescriptor {
+ /// Wording for `cid` on `model`, from `ControlCatalog.text(for:model:)`.
+ public init(cid: UInt16, model: ModelKey? = nil) {
+ let text = ControlCatalog.text(for: cid, model: model)
+ self.cid = cid
+ name = text.name
+ detail = text.detail
+ symbolName = text.symbolName
+ }
+}
diff --git a/UprightKit/Sources/UprightKit/Device/DeviceCatalog.swift b/UprightKit/Sources/UprightKit/Device/DeviceCatalog.swift
new file mode 100644
index 0000000..1f425a8
--- /dev/null
+++ b/UprightKit/Sources/UprightKit/Device/DeviceCatalog.swift
@@ -0,0 +1,371 @@
+import Foundation
+
+// Model data comes from Solaar's device dumps and libratbag's device files, as collected in
+// scratchpad/research/devices.verified.json (entries with high or medium confidence only).
+// Only the MX Vertical has been tested on hardware; every other entry is `.publishedDump`.
+// The catalog is a convenience (names, suggested setups, receiver layouts): whether a device is
+// served is decided by what it answers (see `EndpointController`), never by this table.
+
+/// What Upright knows about a mouse model before it is connected.
+public struct DeviceModel: Hashable, Sendable, Identifiable {
+ /// How well a model's entry is backed.
+ public enum Verification: Hashable, Sendable {
+ /// Tested on real hardware (the MX Vertical).
+ case hardware
+ /// Built from a published device dump (Solaar, libratbag); untested here.
+ case publishedDump
+ case unverified
+ }
+
+ public var key: ModelKey
+ public var name: String
+ public var usbPIDs: [UInt16]
+ public var bluetoothPIDs: [UInt16]
+ /// Receiver wireless PIDs (Unifying, Nano, Lightspeed), or the Bxxx PID a Bolt slot reports.
+ public var wirelessPIDs: [UInt16]
+ /// Display order of the model's controls; unlisted controls follow by CID.
+ public var controlOrder: [UInt16]
+ /// Per-model wording that overrides the generic control names.
+ public var controlText: [UInt16: ControlText]
+ /// The onboarding template.
+ public var suggested: ButtonMappings
+ public var verification: Verification
+
+ public var id: ModelKey { key }
+
+ public init(
+ key: ModelKey,
+ name: String,
+ usbPIDs: [UInt16] = [],
+ bluetoothPIDs: [UInt16] = [],
+ wirelessPIDs: [UInt16] = [],
+ controlOrder: [UInt16] = [],
+ controlText: [UInt16: ControlText] = [:],
+ suggested: ButtonMappings = ButtonMappings(),
+ verification: Verification = .unverified
+ ) {
+ self.key = key
+ self.name = name
+ self.usbPIDs = usbPIDs
+ self.bluetoothPIDs = bluetoothPIDs
+ self.wirelessPIDs = wirelessPIDs
+ self.controlOrder = controlOrder
+ self.controlText = controlText
+ self.suggested = suggested
+ self.verification = verification
+ }
+
+ /// Every product ID the model is known by, on any transport, without repeats. A Bolt
+ /// device reports the same ID as its Bluetooth LE one.
+ public var allPIDs: [UInt16] {
+ var seen = Set()
+ return (bluetoothPIDs + wirelessPIDs + usbPIDs).filter { seen.insert($0).inserted }
+ }
+
+ /// Sorts `cids` for display: the model's `controlOrder` first, the rest by CID.
+ public func displayOrder(_ cids: [UInt16]) -> [UInt16] {
+ DeviceCatalog.sort(cids, by: controlOrder)
+ }
+}
+
+extension DeviceModel.Verification {
+ /// Another spelling of `.publishedDump`.
+ public static let publishedData: DeviceModel.Verification = .publishedDump
+}
+
+/// A Logitech receiver product. `layout == nil` means the receiver is never opened.
+public struct ReceiverModel: Hashable, Sendable {
+ /// The receiver's product line. Only the first three are opened.
+ public enum Family: String, Hashable, Sendable {
+ case unifying, bolt, nano, lightspeed
+ /// The pre-Unifying 27 MHz receivers (HID++ 1.0 devices).
+ case legacy27MHz
+ }
+
+ public var productID: UInt16
+ /// How the mouse is reported to the user. Only meaningful when `layout` isn't nil.
+ public var kind: ConnectionKind
+ public var layout: PairingLayout?
+ public var family: Family
+ public var name: String
+
+ public init(productID: UInt16, kind: ConnectionKind, layout: PairingLayout?, family: Family? = nil, name: String? = nil) {
+ let resolved: Family
+ switch (family, kind) {
+ case (let family?, _): resolved = family
+ case (nil, .bolt): resolved = .bolt
+ case (nil, .nano): resolved = .nano
+ default: resolved = .unifying
+ }
+ self.productID = productID
+ self.kind = kind
+ self.layout = layout
+ self.family = resolved
+ self.name = name ?? resolved.defaultName
+ }
+
+ /// Why the receiver is never opened, for the log; nil when it is served.
+ public var exclusionReason: String? {
+ guard layout == nil else { return nil }
+ switch family {
+ case .lightspeed:
+ return "Lightspeed receiver: not supported (no programmable-button feature on its mice)"
+ case .legacy27MHz:
+ return "27 MHz receiver: not supported (pre-Unifying HID++ 1.0 devices)"
+ case .unifying, .bolt, .nano:
+ return "receiver not supported"
+ }
+ }
+}
+
+extension ReceiverModel.Family {
+ var defaultName: String {
+ switch self {
+ case .unifying: return "Unifying receiver"
+ case .bolt: return "Bolt receiver"
+ case .nano: return "Nano receiver"
+ case .lightspeed: return "Lightspeed receiver"
+ case .legacy27MHz: return "27 MHz receiver"
+ }
+ }
+}
+
+/// A Logitech mouse known not to expose programmable buttons over HID++ (REPROG_CONTROLS_V4).
+/// Listed for documentation and logs only: such a mouse, if its interface is opened at all,
+/// ends in `.unsupported(.noReprogControls)` like any other.
+public struct KnownUnsupportedDevice: Hashable, Sendable {
+ public var name: String
+ public var usbPIDs: [UInt16]
+ public var bluetoothPIDs: [UInt16]
+ public var wirelessPIDs: [UInt16]
+ public var reason: String
+
+ public var allPIDs: [UInt16] { bluetoothPIDs + wirelessPIDs + usbPIDs }
+}
+
+public enum DeviceCatalog {
+ public static let vendorID: Int = 0x046D
+
+ /// Every model Upright knows by name, the MX Vertical first.
+ public static let models: [DeviceModel] = [
+ mxVertical,
+ mxMaster, mxMaster2S, mxMaster3, mxMaster3ForBusiness, mxMaster3S, mxMaster4,
+ mxAnywhere2, mxAnywhere2S, mxAnywhere3, mxAnywhere3ForBusiness,
+ mxErgo, lift, liftForBusiness,
+ m720, m585, m650L, m550, m575, m705, pebbleM350, m510v2, m185,
+ ]
+
+ public static let receivers: [UInt16: ReceiverModel] = {
+ var table: [UInt16: ReceiverModel] = [:]
+ for pid: UInt16 in [0xC52B, 0xC532] {
+ table[pid] = ReceiverModel(productID: pid, kind: .unifying, layout: .unifying)
+ }
+ table[0xC548] = ReceiverModel(productID: 0xC548, kind: .bolt, layout: .bolt)
+ // Nano receivers use the Unifying pairing-register layout (D8).
+ let nano: [UInt16] = [0xC52F, 0xC531, 0xC534, 0xC537, 0xC518, 0xC51A, 0xC51B, 0xC521, 0xC525, 0xC526, 0xC52E, 0xC535]
+ for pid in nano {
+ table[pid] = ReceiverModel(productID: pid, kind: .nano, layout: .unifying)
+ }
+ // Lightspeed receivers are never opened (D8): every G and PRO mouse in the research data
+ // lacks REPROG_CONTROLS_V4, the C53A also hosts a PowerPlay chip, and G HUB owns them.
+ let lightspeed: [UInt16] = [0xC539, 0xC53A, 0xC53D, 0xC53F, 0xC541, 0xC545, 0xC547, 0xC54D]
+ for pid in lightspeed {
+ table[pid] = ReceiverModel(productID: pid, kind: .unifying, layout: nil, family: .lightspeed)
+ }
+ // The 27 MHz receiver pairs pre-Unifying HID++ 1.0 devices.
+ table[0xC517] = ReceiverModel(productID: 0xC517, kind: .unifying, layout: nil, family: .legacy27MHz)
+ return table
+ }()
+
+ /// G-series and PRO gaming mice, which keep their buttons in onboard profiles.
+ public static let knownUnsupported: [KnownUnsupportedDevice] = {
+ let reason = "Gaming mouse: its buttons are stored in onboard profiles, not exposed as programmable controls"
+ func device(_ name: String, usb: [UInt16] = [], bluetooth: [UInt16] = [], wireless: [UInt16]) -> KnownUnsupportedDevice {
+ KnownUnsupportedDevice(name: name, usbPIDs: usb, bluetoothPIDs: bluetooth, wirelessPIDs: wireless, reason: reason)
+ }
+ return [
+ device("G Pro Wireless", usb: [0xC088], wireless: [0x4079]),
+ device("G304 / G305 Lightspeed", wireless: [0x4074]),
+ device("G502 Lightspeed", usb: [0xC08D], wireless: [0x407F]),
+ device("G502 X Plus", usb: [0xC095], wireless: [0x4099]),
+ device("G604", bluetooth: [0xB024], wireless: [0x4085]),
+ device("G703 Lightspeed", usb: [0xC087], wireless: [0x4070]),
+ device("G903 Lightspeed", usb: [0xC091], wireless: [0x4087]),
+ device("PRO X Wireless", usb: [0xC094], wireless: [0x4093]),
+ device("PRO X 2", usb: [0xC09B], wireless: [0x40A9]),
+ ]
+ }()
+
+ private static let modelsByKey: [ModelKey: DeviceModel] = {
+ Dictionary(models.map { ($0.key, $0) }, uniquingKeysWith: { first, _ in first })
+ }()
+
+ private static let modelsByPID: [UInt16: DeviceModel] = {
+ var table: [UInt16: DeviceModel] = [:]
+ for model in models {
+ for pid in model.allPIDs where table[pid] == nil { table[pid] = model }
+ }
+ return table
+ }()
+
+ public static func model(_ key: ModelKey) -> DeviceModel? {
+ modelsByKey[key]
+ }
+
+ /// The model any of whose USB, Bluetooth or wireless product IDs is `pid`.
+ public static func model(forPID pid: UInt16) -> DeviceModel? {
+ modelsByPID[pid]
+ }
+
+ /// The known gaming mouse with this product ID on any transport, if any.
+ public static func knownUnsupported(forPID pid: UInt16) -> KnownUnsupportedDevice? {
+ knownUnsupported.first { $0.allPIDs.contains(pid) }
+ }
+
+ /// The canonical key (D5): the first catalog model matching any reported 0x0003 model ID,
+ /// else the canonical 0x0003 ID itself, else the transport PID through catalog aliases,
+ /// else the device name. Never reads `identity.model`.
+ public static func modelKey(for identity: DeviceIdentity) -> ModelKey {
+ for pid in identity.modelIDs.all {
+ if let model = model(forPID: pid) { return model.key }
+ }
+ if let canonical = identity.modelIDs.canonical {
+ return ModelKey(productID: canonical)
+ }
+ if let pid = identity.transportPID {
+ return model(forPID: pid)?.key ?? ModelKey(productID: pid)
+ }
+ return ModelKey(name: identity.name)
+ }
+
+ /// Sorts `cids` for display: the model's `controlOrder` first (nil or an unknown model has
+ /// none), the rest by CID.
+ public static func sortedControls(_ cids: [UInt16], for model: ModelKey?) -> [UInt16] {
+ sort(cids, by: model.flatMap { Self.model($0) }?.controlOrder ?? [])
+ }
+
+ static func sort(_ cids: [UInt16], by order: [UInt16]) -> [UInt16] {
+ cids.sorted { lhs, rhs in
+ let l = order.firstIndex(of: lhs) ?? Int.max
+ let r = order.firstIndex(of: rhs) ?? Int.max
+ return l == r ? lhs < rhs : l < r
+ }
+ }
+
+ /// The onboarding template for a mouse with these controls: Back and Forward navigate,
+ /// a gesture button opens Mission Control, and everything else stays native. (The MX
+ /// Vertical keeps what Upright 1.x shipped, `ButtonMappings.legacyDefaults`.)
+ public static func suggestedMappings(forControls cids: [UInt16]) -> ButtonMappings {
+ var table: [UInt16: ButtonAction] = [:]
+ if cids.contains(KnownControl.back) { table[KnownControl.back] = .system(.navigateBack) }
+ if cids.contains(KnownControl.forward) { table[KnownControl.forward] = .system(.navigateForward) }
+ if let gesture = [KnownControl.gestureButton, KnownControl.multiPlatformGesture].first(where: cids.contains) {
+ table[gesture] = .system(.missionControl)
+ }
+ return ButtonMappings(table)
+ }
+
+ // MARK: Models
+
+ /// Builds a published-dump entry. The key is the canonical ID (D5): the model's first
+ /// Bluetooth PID, else its first wireless one, else its USB one. Later Bluetooth and wireless
+ /// PIDs are firmware revisions, which resolve to the same key.
+ private static func entry(
+ _ name: String,
+ bluetooth: [UInt16] = [],
+ wireless: [UInt16] = [],
+ usb: [UInt16] = [],
+ controls: [UInt16],
+ text: [UInt16: ControlText] = [:]
+ ) -> DeviceModel {
+ guard let canonical = bluetooth.first ?? wireless.first ?? usb.first else {
+ preconditionFailure("catalog entry \(name) has no product ID")
+ }
+ return DeviceModel(
+ key: ModelKey(productID: canonical),
+ name: name,
+ usbPIDs: usb,
+ bluetoothPIDs: bluetooth,
+ wirelessPIDs: wireless,
+ controlOrder: controls,
+ controlText: text,
+ suggested: suggestedMappings(forControls: controls),
+ verification: .publishedDump
+ )
+ }
+
+ private static let back = KnownControl.back
+ private static let forward = KnownControl.forward
+ private static let wheel = KnownControl.middleClick
+ private static let gesture = KnownControl.gestureButton
+ private static let smartShift = KnownControl.smartShift
+ private static let tiltLeft = KnownControl.tiltLeft
+ private static let tiltRight = KnownControl.tiltRight
+
+ /// Tested on hardware: the only entry that is.
+ static let mxVertical = DeviceModel(
+ key: .mxVertical,
+ name: "MX Vertical",
+ usbPIDs: [0xC08A],
+ bluetoothPIDs: [0xB020],
+ wirelessPIDs: [0x407B],
+ controlOrder: [KnownControl.back, KnownControl.forward, KnownControl.dpiSwitch, KnownControl.middleClick],
+ controlText: [
+ KnownControl.back: ControlDescriptor(cid: KnownControl.back).text,
+ KnownControl.forward: ControlDescriptor(cid: KnownControl.forward).text,
+ KnownControl.dpiSwitch: ControlDescriptor(cid: KnownControl.dpiSwitch).text,
+ KnownControl.dpiChange: ControlDescriptor(cid: KnownControl.dpiChange).text,
+ KnownControl.middleClick: ControlDescriptor(cid: KnownControl.middleClick).text,
+ ],
+ suggested: .legacyDefaults,
+ verification: .hardware
+ )
+
+ // MX Master family. The original has two revisions (4041/B012 and 4071/B01E) that share a key.
+ static let mxMaster = entry("MX Master", bluetooth: [0xB012, 0xB01E], wireless: [0x4041, 0x4071], controls: [back, forward, gesture, smartShift, wheel])
+ static let mxMaster2S = entry("MX Master 2S", bluetooth: [0xB019], wireless: [0x4069], controls: [back, forward, gesture, smartShift, wheel])
+ static let mxMaster3 = entry("MX Master 3", bluetooth: [0xB023], wireless: [0x4082], controls: [back, forward, gesture, smartShift, wheel])
+ static let mxMaster3ForBusiness = entry("MX Master 3 for Business", bluetooth: [0xB028], wireless: [0xB028], controls: [back, forward, gesture, smartShift, wheel])
+ static let mxMaster3S = entry("MX Master 3S", bluetooth: [0xB034], wireless: [0xB034], controls: [back, forward, gesture, smartShift, wheel])
+ static let mxMaster4 = entry("MX Master 4", bluetooth: [0xB042], controls: [back, forward, gesture, KnownControl.hapticSense, smartShift, wheel])
+
+ // MX Anywhere family. The 2 reports one of three Bluetooth IDs and one of three wireless IDs.
+ static let mxAnywhere2 = entry("MX Anywhere 2", bluetooth: [0xB013, 0xB018, 0xB01F], wireless: [0x404A, 0x4072, 0x4063], controls: [back, forward, wheel, tiltLeft, tiltRight])
+ static let mxAnywhere2S = entry("MX Anywhere 2S", bluetooth: [0xB01A], wireless: [0x406A], controls: [back, forward, wheel, tiltLeft, tiltRight])
+ static let mxAnywhere3 = entry("MX Anywhere 3", bluetooth: [0xB025], wireless: [0x4090], controls: [back, forward, smartShift, wheel])
+ static let mxAnywhere3ForBusiness = entry("MX Anywhere 3 for Business", bluetooth: [0xB02D], wireless: [0xB02D], controls: [back, forward, smartShift, wheel])
+
+ static let mxErgo = entry(
+ "MX Ergo",
+ bluetooth: [0xB01D], wireless: [0x406F],
+ controls: [back, forward, KnownControl.dpiChange, wheel, tiltLeft, tiltRight],
+ text: [
+ KnownControl.dpiChange: ControlText(
+ name: NSLocalizedString("Precision Button", comment: "Mouse control name"),
+ detail: NSLocalizedString("Changes the pointer speed (DPI)", comment: "Mouse control description"),
+ symbolName: "speedometer"
+ ),
+ ]
+ )
+
+ // Lift's DPI button sits behind the wheel, like the MX Vertical's.
+ private static let topButtonText: [UInt16: ControlText] = [KnownControl.dpiSwitch: ControlDescriptor(cid: KnownControl.dpiSwitch).text]
+ static let lift = entry("Lift", bluetooth: [0xB031], wireless: [0xB031], controls: [back, forward, KnownControl.dpiSwitch, wheel], text: topButtonText)
+ static let liftForBusiness = entry("Lift for Business", bluetooth: [0xB033], wireless: [0xB033], controls: [back, forward, KnownControl.dpiSwitch, wheel], text: topButtonText)
+
+ static let m720 = entry("M720 Triathlon", bluetooth: [0xB015], wireless: [0x405E], controls: [back, forward, KnownControl.multiPlatformGesture, wheel, tiltLeft, tiltRight])
+ static let m585 = entry("M585 / M590", bluetooth: [0xB01B], wireless: [0x406B], controls: [back, forward, wheel, tiltLeft, tiltRight])
+ static let m650L = entry("Signature M650 L", bluetooth: [0xB02A], wireless: [0xB02A], controls: [back, forward, wheel])
+ // The M550's thumb buttons are plain mouse buttons: only the wheel click is programmable.
+ static let m550 = entry("Signature M550", bluetooth: [0xB02B], wireless: [0xB02B], controls: [wheel])
+ static let m575 = entry("ERGO M575", bluetooth: [0xB027], wireless: [0x4096], controls: [back, forward, wheel])
+ static let m705 = entry("Marathon M705", wireless: [0x406D], controls: [back, forward, wheel, tiltLeft, tiltRight])
+ static let pebbleM350 = entry("Pebble M350", bluetooth: [0xB021], wireless: [0x4080], controls: [wheel])
+ static let m510v2 = entry("M510 (v2)", wireless: [0x4051], controls: [back, forward, wheel, tiltLeft, tiltRight])
+ // Left and Right report as divertable on this family but are never diverted (`ControlCatalog.neverDivert`).
+ static let m185 = entry("M185 / M235 / M310", wireless: [0x4055], controls: [wheel])
+}
+
+extension ControlDescriptor {
+ var text: ControlText { ControlText(name: name, detail: detail, symbolName: symbolName) }
+}
diff --git a/UprightKit/Sources/UprightKit/Device/DeviceIdentity.swift b/UprightKit/Sources/UprightKit/Device/DeviceIdentity.swift
new file mode 100644
index 0000000..b729b05
--- /dev/null
+++ b/UprightKit/Sources/UprightKit/Device/DeviceIdentity.swift
@@ -0,0 +1,336 @@
+import Foundation
+
+/// Where a live endpoint is reachable: the opened HID interface (`link`) and the device index
+/// on it. Unique among live endpoints and known before the device is identified, so it routes
+/// events, engine state and device actions. Never persisted; see `DeviceID` for that.
+public struct DeviceAddress: Hashable, Comparable, Sendable, CustomStringConvertible {
+ /// Per opened IOHIDDevice, monotonic, never reused within a process. 0 is unassigned.
+ public var link: UInt32
+ /// Receiver slot 1...6, or `DeviceAddress.direct` (0xFF) for a directly connected device.
+ public var index: UInt8
+
+ public init(link: UInt32, index: UInt8) {
+ self.link = link
+ self.index = index
+ }
+
+ /// Events from code paths that don't know their device (legacy single-mouse callers).
+ public static let unassigned = DeviceAddress(link: 0, index: 0xFF)
+ /// The device index of a directly connected device (USB cable or Bluetooth).
+ public static let direct: UInt8 = 0xFF
+
+ public var isDirect: Bool { index == Self.direct }
+
+ public static func < (lhs: DeviceAddress, rhs: DeviceAddress) -> Bool {
+ lhs.link != rhs.link ? lhs.link < rhs.link : lhs.index < rhs.index
+ }
+
+ /// "3.2" for slot 2 of link 3, "3.direct" for a direct link.
+ public var description: String {
+ isDirect ? "\(link).direct" : "\(link).\(index)"
+ }
+}
+
+/// Identifies a mouse model, independent of how it is connected. Mappings, catalog lookups and
+/// control names are keyed by it.
+///
+/// The canonical form is `"046D:XXXX"`, where XXXX is the model's first product ID among
+/// Bluetooth, Bluetooth LE, wireless (receiver) and USB as reported by DEVICE_FW_VERSION
+/// (0x0003). A device that reports none falls back to `"name:"`.
+public struct ModelKey: RawRepresentable, Hashable, Codable, Comparable, Sendable, CodingKeyRepresentable, CustomStringConvertible {
+ public var rawValue: String
+
+ /// Accepts `"VVVV:PPPP"` (hex, normalized to upper case) or `"name:"`.
+ public init?(rawValue: String) {
+ if rawValue.hasPrefix(Self.namePrefix) {
+ guard rawValue.count > Self.namePrefix.count else { return nil }
+ self.rawValue = rawValue
+ return
+ }
+ let parts = rawValue.split(separator: ":", omittingEmptySubsequences: false)
+ guard parts.count == 2,
+ parts.allSatisfy({ $0.count == 4 && $0.allSatisfy(\.isHexDigit) })
+ else { return nil }
+ self.rawValue = rawValue.uppercased()
+ }
+
+ /// A Logitech model by product ID: `"046D:%04X"`.
+ public init(productID: UInt16) {
+ rawValue = Self.hex4(DeviceCatalog.vendorID) + ":" + Self.hex4(Int(productID))
+ }
+
+ private static func hex4(_ value: Int) -> String {
+ let digits = String(value, radix: 16, uppercase: true)
+ return String(repeating: "0", count: max(0, 4 - digits.count)) + digits
+ }
+
+ /// The last-resort key for a device that reports no product IDs.
+ public init(name: String) {
+ let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
+ rawValue = Self.namePrefix + (trimmed.isEmpty ? "unknown" : trimmed)
+ }
+
+ public static let mxVertical = ModelKey(productID: 0xB020)
+
+ private static let namePrefix = "name:"
+
+ /// The product ID of a canonical key, or nil for a name key.
+ public var productID: UInt16? {
+ guard !rawValue.hasPrefix(Self.namePrefix), let pid = rawValue.split(separator: ":").last else { return nil }
+ return UInt16(pid, radix: 16)
+ }
+
+ public static func < (lhs: ModelKey, rhs: ModelKey) -> Bool { lhs.rawValue < rhs.rawValue }
+
+ public var description: String { rawValue }
+}
+
+/// The model product IDs a device reports in DEVICE_FW_VERSION (0x0003) function 0.
+public struct ModelIDs: Hashable, Sendable {
+ public var bluetooth: UInt16?
+ public var bluetoothLE: UInt16?
+ /// The "wireless PID" a Unifying, Nano or Lightspeed receiver reports for the device.
+ public var wireless: UInt16?
+ public var usb: UInt16?
+
+ public init(bluetooth: UInt16? = nil, bluetoothLE: UInt16? = nil, wireless: UInt16? = nil, usb: UInt16? = nil) {
+ self.bluetooth = bluetooth
+ self.bluetoothLE = bluetoothLE
+ self.wireless = wireless
+ self.usb = usb
+ }
+
+ /// The product ID a `ModelKey` is built from: the same on every transport.
+ public var canonical: UInt16? { bluetooth ?? bluetoothLE ?? wireless ?? usb }
+
+ /// Every reported ID, in canonical order.
+ public var all: [UInt16] { [bluetooth, bluetoothLE, wireless, usb].compactMap { $0 } }
+
+ public var isEmpty: Bool { all.isEmpty }
+}
+
+/// Identifies one physical unit across connections, for per-mouse preferences and same-unit
+/// deduplication. It never routes events (`DeviceAddress` does).
+///
+/// Forms: `"u::"` from 0x0003, `"s::"`, or
+/// `"t:."` when the device reports neither. Transient IDs are never persisted.
+public struct DeviceID: RawRepresentable, Hashable, Codable, Comparable, Sendable, CodingKeyRepresentable, CustomStringConvertible {
+ public var rawValue: String
+
+ public init(rawValue: String) {
+ self.rawValue = rawValue
+ }
+
+ public static func unit(_ unitID: UInt32, model: ModelKey) -> DeviceID {
+ let digits = String(unitID, radix: 16, uppercase: true)
+ return DeviceID(rawValue: "u:\(model.rawValue):" + String(repeating: "0", count: 8 - digits.count) + digits)
+ }
+
+ public static func serial(_ serial: String, model: ModelKey) -> DeviceID {
+ DeviceID(rawValue: "s:\(model.rawValue):\(serial)")
+ }
+
+ public static func transient(_ address: DeviceAddress) -> DeviceID {
+ DeviceID(rawValue: "t:\(address.link).\(address.index)")
+ }
+
+ /// False for transient IDs, which must not key anything that outlives the connection.
+ public var isStable: Bool { !rawValue.hasPrefix("t:") }
+
+ /// The model embedded in a stable ID.
+ public var model: ModelKey? {
+ guard rawValue.hasPrefix("u:") || rawValue.hasPrefix("s:") else { return nil }
+ let parts = rawValue.dropFirst(2).split(separator: ":", maxSplits: 2, omittingEmptySubsequences: false)
+ guard parts.count == 3 else { return nil }
+ return ModelKey(rawValue: String(parts[0]) + ":" + String(parts[1]))
+ }
+
+ public static func < (lhs: DeviceID, rhs: DeviceID) -> Bool { lhs.rawValue < rhs.rawValue }
+
+ public var description: String { rawValue }
+}
+
+/// What kind of device sits behind an endpoint, from DEVICE_NAME (0x0005) function 2 or the
+/// receiver's pairing information.
+public enum DeviceKind: Hashable, Sendable {
+ case keyboard, remote, numpad, mouse, touchpad, trackball, presenter, receiver
+ case other(UInt8)
+ case unknown
+
+ /// HID++ 2.0 DEVICE_NAME getDeviceType (0x0005 function 2).
+ public init(hidpp20 value: UInt8) {
+ switch value {
+ case 0: self = .keyboard
+ case 1: self = .remote
+ case 2: self = .numpad
+ case 3: self = .mouse
+ case 4: self = .touchpad
+ case 5: self = .trackball
+ case 6: self = .presenter
+ case 7: self = .receiver
+ default: self = .other(value)
+ }
+ }
+
+ /// HID++ 1.0 device kind nibble, from a pairing record or a 0x41 notification. Only the
+ /// low nibble is used.
+ public init(hidpp10Nibble value: UInt8) {
+ let nibble = value & 0x0F
+ switch nibble {
+ case 0: self = .unknown
+ case 1: self = .keyboard
+ case 2: self = .mouse
+ case 3: self = .numpad
+ case 4: self = .presenter
+ case 7: self = .remote
+ case 8: self = .trackball
+ case 9: self = .touchpad
+ default: self = .other(nibble)
+ }
+ }
+
+ public var isPointer: Bool {
+ switch self {
+ case .mouse, .touchpad, .trackball: return true
+ default: return false
+ }
+ }
+
+ /// Kinds that are never served (the anti-hijack gate). `.unknown` and `.other` are not
+ /// in this set: they are decided by the device's own answers.
+ public var isDefinitelyNotPointer: Bool {
+ switch self {
+ case .keyboard, .numpad, .presenter, .remote, .receiver: return true
+ default: return false
+ }
+ }
+
+ /// A short English name for logs and device reports.
+ public var name: String {
+ switch self {
+ case .keyboard: return "keyboard"
+ case .remote: return "remote control"
+ case .numpad: return "numpad"
+ case .mouse: return "mouse"
+ case .touchpad: return "touchpad"
+ case .trackball: return "trackball"
+ case .presenter: return "presenter"
+ case .receiver: return "receiver"
+ case .other(let value): return String(format: "kind 0x%02X", value)
+ case .unknown: return "unknown"
+ }
+ }
+}
+
+/// Who a device is, as far as Upright can tell from its HID++ answers.
+public struct DeviceIdentity: Hashable, Sendable {
+ public var name: String
+ public var kind: DeviceKind
+ /// Nil when the device doesn't report one, or reports zero.
+ public var unitID: UInt32?
+ public var serialNumber: String?
+ public var modelIDs: ModelIDs
+ public var extendedModelID: UInt8?
+ /// The wireless PID on a receiver, or the IOHID product ID on a direct link.
+ public var transportPID: UInt16?
+ public var model: ModelKey
+
+ /// `model` defaults to `DeviceCatalog.modelKey(for:)`, which never reads `model` itself.
+ public init(
+ name: String,
+ kind: DeviceKind = .unknown,
+ unitID: UInt32? = nil,
+ serialNumber: String? = nil,
+ modelIDs: ModelIDs = ModelIDs(),
+ extendedModelID: UInt8? = nil,
+ transportPID: UInt16? = nil,
+ model: ModelKey? = nil
+ ) {
+ self.name = name
+ self.kind = kind
+ self.unitID = unitID == 0 ? nil : unitID
+ self.serialNumber = serialNumber.flatMap { $0.isEmpty ? nil : $0 }
+ self.modelIDs = modelIDs
+ self.extendedModelID = extendedModelID
+ self.transportPID = transportPID
+ self.model = .mxVertical
+ self.model = model ?? DeviceCatalog.modelKey(for: self)
+ }
+
+ /// The unit ID, else the serial number, else a transient ID for this connection.
+ public func deviceID(at address: DeviceAddress) -> DeviceID {
+ if let unitID { return .unit(unitID, model: model) }
+ if let serialNumber { return .serial(serialNumber, model: model) }
+ return .transient(address)
+ }
+
+ /// Parses the parameters of a DEVICE_FW_VERSION getEntityCount reply (0x0003 function 0):
+ /// [entity count, unit ID (4), transport (2), model IDs (6), extended model ID].
+ /// Byte 6 holds the transport bits (Bluetooth 0x01, BLE 0x02, wireless 0x04, USB 0x08);
+ /// one big-endian ID per set bit is packed into bytes 7...12 in that order.
+ public static func parseFirmwareIDs(_ reply: [UInt8]) -> (unitID: UInt32?, modelIDs: ModelIDs, extendedModelID: UInt8?)? {
+ guard reply.count >= 13 else { return nil }
+ let unit = reply[1...4].reduce(UInt32(0)) { $0 << 8 | UInt32($1) }
+ let bits = reply[6]
+ var ids = ModelIDs()
+ var offset = 7
+ func next() -> UInt16? {
+ defer { offset += 2 }
+ guard offset + 1 <= 12 else { return nil }
+ let value = UInt16(reply[offset]) << 8 | UInt16(reply[offset + 1])
+ return value == 0 ? nil : value
+ }
+ if bits & 0x01 != 0 { ids.bluetooth = next() }
+ if bits & 0x02 != 0 { ids.bluetoothLE = next() }
+ if bits & 0x04 != 0 { ids.wireless = next() }
+ if bits & 0x08 != 0 { ids.usb = next() }
+ let extended: UInt8? = reply.count > 13 ? reply[13] : nil
+ return (unit == 0 ? nil : unit, ids, extended)
+ }
+}
+
+/// Why an endpoint is not served. Terminal: no retry, no writes, never diverted.
+public enum UnsupportedReason: Hashable, Sendable {
+ /// The device answers HID++ 1.0 only.
+ case hidpp10(major: UInt8, minor: UInt8)
+ /// A keyboard, numpad, presenter, remote or receiver.
+ case notAPointer(DeviceKind)
+ /// No REPROG_CONTROLS_V4 (0x1B04).
+ case noReprogControls
+ /// 0x1B04 is present but lists no control Upright can divert.
+ case noConfigurableControls
+
+ /// User-facing, localized.
+ public var message: String {
+ switch self {
+ case .hidpp10(let major, let minor):
+ return String(format: NSLocalizedString("Older HID++ %d.%d device; Upright needs HID++ 2.0", comment: "Why a Logitech device isn't supported; %d.%d is its protocol version"), Int(major), Int(minor))
+ case .notAPointer(let kind):
+ switch kind {
+ case .keyboard: return NSLocalizedString("Keyboard; Upright only configures mice", comment: "Why a Logitech device isn't supported")
+ default: return NSLocalizedString("Not a mouse; Upright only configures mice", comment: "Why a Logitech device isn't supported")
+ }
+ case .noReprogControls:
+ return NSLocalizedString("No programmable buttons", comment: "Why a Logitech device isn't supported")
+ case .noConfigurableControls:
+ return NSLocalizedString("No buttons Upright can remap", comment: "Why a Logitech device isn't supported")
+ }
+ }
+}
+
+/// What one endpoint should be doing: the controls diverted to Upright, those that also
+/// report raw movement while held, and the pointer speed to keep (nil leaves it alone).
+public struct EndpointConfiguration: Hashable, Sendable {
+ public var divert: Set = []
+ public var rawXY: Set = []
+ public var dpi: Int? = nil
+
+ public init(divert: Set = [], rawXY: Set = [], dpi: Int? = nil) {
+ self.divert = divert
+ self.rawXY = rawXY
+ self.dpi = dpi
+ }
+
+ /// Nothing diverted, pointer speed left to the mouse.
+ public static let native = EndpointConfiguration()
+}
diff --git a/UprightKit/Sources/UprightKit/Device/DevicePreferences.swift b/UprightKit/Sources/UprightKit/Device/DevicePreferences.swift
new file mode 100644
index 0000000..ca462c9
--- /dev/null
+++ b/UprightKit/Sources/UprightKit/Device/DevicePreferences.swift
@@ -0,0 +1,283 @@
+import Foundation
+
+/// Decodes one value without failing its container: an unreadable entry becomes `nil`, so a
+/// single bad value never loses the rest of a saved list or table.
+private struct Lossy: Decodable {
+ let value: Value?
+
+ init(from decoder: Decoder) {
+ value = try? Value(from: decoder)
+ }
+}
+
+/// Settings and memory for one physical mouse (or the seed for a model's first-seen units).
+///
+/// Decoding is tolerant: missing or unreadable fields take their defaults and unknown fields
+/// are ignored, so older and newer saves still load.
+public struct DevicePreferences: Codable, Hashable, Sendable {
+ public var nickname: String?
+ /// "Remap this mouse": off keeps every button native on this unit.
+ public var remapEnabled = true
+ /// The pointer speed to keep; nil leaves the mouse's own setting alone.
+ public var preferredDPI: Int?
+ public var lastName: String?
+ public var lastModel: ModelKey?
+ public var lastControls: [UInt16] = []
+ /// `ConnectionKind.rawValue` of the last connection.
+ public var lastConnection: String?
+ public var lastSeen: Date?
+
+ public init(
+ nickname: String? = nil,
+ remapEnabled: Bool = true,
+ preferredDPI: Int? = nil,
+ lastName: String? = nil,
+ lastModel: ModelKey? = nil,
+ lastControls: [UInt16] = [],
+ lastConnection: String? = nil,
+ lastSeen: Date? = nil
+ ) {
+ self.nickname = nickname
+ self.remapEnabled = remapEnabled
+ self.preferredDPI = preferredDPI
+ self.lastName = lastName
+ self.lastModel = lastModel
+ self.lastControls = lastControls
+ self.lastConnection = lastConnection
+ self.lastSeen = lastSeen
+ }
+
+ /// The nickname without surrounding whitespace, or nil when it is empty or blank.
+ public var trimmedNickname: String? {
+ guard let trimmed = nickname?.trimmingCharacters(in: .whitespacesAndNewlines), !trimmed.isEmpty else {
+ return nil
+ }
+ return trimmed
+ }
+
+ private enum CodingKeys: String, CodingKey {
+ case nickname, remapEnabled, preferredDPI, lastName, lastModel, lastControls, lastConnection, lastSeen
+ }
+
+ public init(from decoder: Decoder) throws {
+ let c = try decoder.container(keyedBy: CodingKeys.self)
+ func value(_ key: CodingKeys) -> T? {
+ (try? c.decodeIfPresent(T.self, forKey: key)) ?? nil
+ }
+ let dpi: Int? = value(.preferredDPI)
+ let controls: [Lossy]? = value(.lastControls)
+ self.init(
+ nickname: value(.nickname),
+ remapEnabled: value(.remapEnabled) ?? true,
+ preferredDPI: dpi.flatMap { $0 > 0 ? $0 : nil },
+ lastName: value(.lastName),
+ lastModel: value(.lastModel),
+ lastControls: controls?.compactMap(\.value) ?? [],
+ lastConnection: value(.lastConnection),
+ lastSeen: value(.lastSeen)
+ )
+ }
+}
+
+/// Every mouse's preferences, persisted under `defaultsKey`. Only stable device IDs are stored.
+///
+/// The JSON is `{"models": {"046D:B020": {...}}, "devices": {"u:046D:B020:1A2B3C4D": {...}}}`.
+/// Decoding skips entries whose key or value is unreadable, and drops transient device IDs.
+public struct DevicePreferencesStore: Codable, Hashable, Sendable {
+ public static let defaultsKey = "devicePreferences.v1"
+
+ /// Seeds for units of a model seen for the first time. Only `preferredDPI` is used.
+ public var models: [ModelKey: DevicePreferences]
+ public var devices: [DeviceID: DevicePreferences]
+
+ /// Transient IDs in `devices` are dropped: they must never outlive their connection.
+ public init(models: [ModelKey: DevicePreferences] = [:], devices: [DeviceID: DevicePreferences] = [:]) {
+ self.models = models
+ self.devices = devices.filter { $0.key.isStable }
+ }
+
+ // MARK: Lookup
+
+ /// The unit's own preferences, else a first-seen unit's defaults with its model's seed speed.
+ /// A unit that has an entry keeps it even when its speed is nil ("let the mouse decide").
+ /// Transient IDs never have an entry, so they always get the model seed.
+ public func preferences(for id: DeviceID, model: ModelKey) -> DevicePreferences {
+ if id.isStable, let own = devices[id] { return own }
+ return Self.firstSeen(seed: models[model])
+ }
+
+ /// Stores a unit's preferences. Transient IDs are ignored: they aren't remembered.
+ public mutating func setPreferences(_ preferences: DevicePreferences, for id: DeviceID) {
+ guard id.isStable else { return }
+ devices[id] = preferences
+ }
+
+ /// Units that have preferences but aren't in `connected`, most recently seen first
+ /// (never-seen last), for the "remembered mice" list.
+ public func rememberedDevices(excluding connected: Set = []) -> [(id: DeviceID, preferences: DevicePreferences)] {
+ devices
+ .filter { !connected.contains($0.key) }
+ .sorted { lhs, rhs in
+ switch (lhs.value.lastSeen, rhs.value.lastSeen) {
+ case let (l?, r?) where l != r: return l > r
+ case (_?, nil): return true
+ case (nil, _?): return false
+ default: return lhs.key < rhs.key
+ }
+ }
+ .map { (id: $0.key, preferences: $0.value) }
+ }
+
+ // MARK: Updates
+
+ /// Remembers what was last seen of a unit (name, controls, connection, time). A unit seen
+ /// for the first time starts from its model's seed speed; settings the user chose are never
+ /// touched. Transient IDs are ignored. `now` exists for tests.
+ public mutating func record(_ id: DeviceID, snapshot: DeviceSnapshot, now: Date = Date()) {
+ guard id.isStable else { return }
+ let model = id.model
+ var prefs = devices[id] ?? Self.firstSeen(seed: model.flatMap { models[$0] })
+ if let model { prefs.lastModel = model }
+ prefs.lastName = snapshot.name
+ prefs.lastControls = snapshot.configurableControls.map(\.cid)
+ prefs.lastConnection = snapshot.connection.rawValue
+ prefs.lastSeen = now
+ devices[id] = prefs
+ }
+
+ /// Copies Upright 2.x's single preferred speed into the MX Vertical seed, once: an existing
+ /// seed (even one without a speed) means it already ran. Values that aren't a positive speed
+ /// leave the seed without one.
+ public mutating func migrateLegacy(preferredDPI: Int?) {
+ guard models[.mxVertical] == nil else { return }
+ models[.mxVertical] = DevicePreferences(preferredDPI: preferredDPI.flatMap { $0 > 0 ? $0 : nil })
+ }
+
+ public mutating func forget(_ id: DeviceID) {
+ devices[id] = nil
+ }
+
+ private static func firstSeen(seed: DevicePreferences?) -> DevicePreferences {
+ DevicePreferences(preferredDPI: seed?.preferredDPI)
+ }
+
+ // MARK: Persistence
+
+ /// Raw copies of saved data this version couldn't read in full live under this prefix plus
+ /// an ISO 8601 time.
+ public static let backupKeyPrefix = "devicePreferences.v1.backup."
+
+ /// Reads the store saved in `defaults`; unreadable or missing data gives an empty store.
+ ///
+ /// Saved data that can't be read in full (not this format, unreadable entries that were
+ /// skipped, or top-level fields this version doesn't know and would drop on the next save)
+ /// is first copied, as it is, to a key under `backupKeyPrefix`, the way `MappingStore`
+ /// backs up mappings, so saving the store afterwards loses nothing. Each distinct saved
+ /// value is backed up once, however many launches read it. `now` exists for tests.
+ public static func load(
+ from defaults: UserDefaults = .standard,
+ log: (String) -> Void = { _ in },
+ now: Date = Date()
+ ) -> DevicePreferencesStore {
+ guard defaults.object(forKey: defaultsKey) != nil else { return DevicePreferencesStore() }
+ guard let data = defaults.data(forKey: defaultsKey) else {
+ backUpUnreadable(in: defaults, reason: "saved device preferences are not data", log: log, now: now)
+ return DevicePreferencesStore()
+ }
+ guard let store = try? JSONDecoder().decode(DevicePreferencesStore.self, from: data) else {
+ backUpUnreadable(in: defaults, reason: "saved device preferences could not be read", log: log, now: now)
+ return DevicePreferencesStore()
+ }
+ if let loss = losses(in: data, keeping: store) {
+ backUpUnreadable(in: defaults, reason: loss, log: log, now: now)
+ }
+ return store
+ }
+
+ /// Keys of every backup made so far, oldest first.
+ public static func backupKeys(in defaults: UserDefaults) -> [String] {
+ defaults.dictionaryRepresentation().keys.filter { $0.hasPrefix(backupKeyPrefix) }.sorted()
+ }
+
+ /// What reading `data` into `store` left out, or nil when nothing was.
+ private static func losses(in data: Data, keeping store: DevicePreferencesStore) -> String? {
+ guard let top = try? JSONDecoder().decode([String: JSONValue].self, from: data) else {
+ return "saved device preferences could not be read"
+ }
+ let known: Set = [CodingKeys.models.stringValue, CodingKeys.devices.stringValue]
+ let unknown = top.keys.filter { !known.contains($0) }.sorted()
+ if !unknown.isEmpty {
+ return "saved device preferences have fields this version doesn't know (\(unknown.joined(separator: ", ")))"
+ }
+ func entries(_ key: CodingKeys) -> Int {
+ switch top[key.stringValue] {
+ case nil, .null?: return 0
+ case .object(let entries)?: return entries.count
+ default: return 1
+ }
+ }
+ let skipped = entries(.models) - store.models.count + entries(.devices) - store.devices.count
+ guard skipped > 0 else { return nil }
+ return "skipped \(skipped) unreadable device preference entr\(skipped == 1 ? "y" : "ies")"
+ }
+
+ /// Copies the raw saved value to a new backup key, unless an existing backup already holds it.
+ private static func backUpUnreadable(in defaults: UserDefaults, reason: String, log: (String) -> Void, now: Date) {
+ guard let object = defaults.object(forKey: defaultsKey) else { return }
+ let data = defaults.data(forKey: defaultsKey)
+ let existing = backupKeys(in: defaults)
+ let alreadyKept = existing.contains { key in
+ if let data { return defaults.data(forKey: key) == data }
+ guard let kept = defaults.object(forKey: key) else { return false }
+ return String(describing: kept) == String(describing: object)
+ }
+ guard !alreadyKept else { return }
+ let stamp = ISO8601DateFormatter().string(from: now)
+ var key = backupKeyPrefix + stamp
+ var suffix = 1
+ while defaults.object(forKey: key) != nil {
+ suffix += 1
+ key = backupKeyPrefix + "\(stamp)-\(suffix)"
+ }
+ defaults.set(object, forKey: key)
+ log("Device preferences: \(reason); raw data backed up to \(key)")
+ }
+
+ public func save(to defaults: UserDefaults = .standard) {
+ guard let data = try? JSONEncoder().encode(self) else { return }
+ defaults.set(data, forKey: Self.defaultsKey)
+ }
+
+ private enum CodingKeys: String, CodingKey {
+ case models, devices
+ }
+
+ public init(from decoder: Decoder) throws {
+ let c = try decoder.container(keyedBy: CodingKeys.self)
+ let rawModels = (try? c.decodeIfPresent([String: Lossy].self, forKey: .models)) ?? nil
+ let rawDevices = (try? c.decodeIfPresent([String: Lossy].self, forKey: .devices)) ?? nil
+
+ var models: [ModelKey: DevicePreferences] = [:]
+ for (key, entry) in rawModels ?? [:] {
+ guard let model = ModelKey(rawValue: key), let prefs = entry.value else { continue }
+ models[model] = prefs
+ }
+ var devices: [DeviceID: DevicePreferences] = [:]
+ for (key, entry) in rawDevices ?? [:] {
+ let id = DeviceID(rawValue: key)
+ guard !key.isEmpty, id.isStable, let prefs = entry.value else { continue }
+ devices[id] = prefs
+ }
+ self.init(models: models, devices: devices)
+ }
+
+ public func encode(to encoder: Encoder) throws {
+ var c = encoder.container(keyedBy: CodingKeys.self)
+ var encodedModels: [String: DevicePreferences] = [:]
+ for (model, prefs) in models { encodedModels[model.rawValue] = prefs }
+ var encodedDevices: [String: DevicePreferences] = [:]
+ for (id, prefs) in devices where id.isStable { encodedDevices[id.rawValue] = prefs }
+ try c.encode(encodedModels, forKey: .models)
+ try c.encode(encodedDevices, forKey: .devices)
+ }
+}
diff --git a/UprightKit/Sources/UprightKit/Device/DeviceRegistry.swift b/UprightKit/Sources/UprightKit/Device/DeviceRegistry.swift
new file mode 100644
index 0000000..7b27a4c
--- /dev/null
+++ b/UprightKit/Sources/UprightKit/Device/DeviceRegistry.swift
@@ -0,0 +1,403 @@
+import Foundation
+
+/// Every endpoint Upright knows about, across all links, with its role (§C step 6).
+///
+/// Identity: a record is keyed by its `DeviceAddress`. Once the endpoint identifies, the record
+/// carries its `DeviceID` and `ModelKey`, and keeps them while the mouse sleeps or is retried.
+///
+/// Same unit on two links (D12): when several served endpoints share a stable `DeviceID`, one
+/// is `.active` and the others are `.standby(of:)` it. A connected endpoint beats one that isn't
+/// (a mouse switched to its Bluetooth channel leaves its receiver slot asleep), then the better
+/// link (`linkRank`: USB, then receiver, then Bluetooth), then the endpoint seen first. When the
+/// active endpoint leaves or stops answering, the next one is promoted. Transient IDs are never
+/// deduplicated, so two mice that report no unit ID both stay active.
+///
+/// Order: `ordinal` is first-seen order, kept per unit for the whole process, so a mouse that
+/// reconnects on a new link (a Bluetooth reconnect, a USB re-plug) keeps its place.
+@MainActor
+public final class DeviceRegistry {
+ public struct Record: Identifiable, Hashable, Sendable {
+ public var address: DeviceAddress
+ public var connection: ConnectionKind
+ public var state: EndpointController.State
+ /// Set once the endpoint identifies; kept while it sleeps or is retried.
+ public var deviceID: DeviceID?
+ public var model: ModelKey?
+ /// The nickname, else the catalog's model name, else the name the device reports.
+ /// Already disambiguated: a second unit with the same name gets " #2".
+ public var displayName: String
+ public var role: Role
+ /// First-seen order, stable for the process (per unit once identified).
+ public var ordinal: Int
+
+ public var id: DeviceAddress { address }
+
+ public init(
+ address: DeviceAddress,
+ connection: ConnectionKind,
+ state: EndpointController.State,
+ deviceID: DeviceID? = nil,
+ model: ModelKey? = nil,
+ displayName: String,
+ role: Role,
+ ordinal: Int
+ ) {
+ self.address = address
+ self.connection = connection
+ self.state = state
+ self.deviceID = deviceID
+ self.model = model
+ self.displayName = displayName
+ self.role = role
+ self.ordinal = ordinal
+ }
+
+ /// The snapshot of a connected or asleep endpoint.
+ public var snapshot: DeviceSnapshot? {
+ switch state {
+ case .connected(let snapshot), .asleep(let snapshot): return snapshot
+ default: return nil
+ }
+ }
+
+ public var isConnected: Bool {
+ if case .connected = state { return true }
+ return false
+ }
+ }
+
+ public enum Role: Hashable, Sendable {
+ /// Served: its events are handled and its configuration follows the mappings.
+ case active
+ /// The same unit is active on a better link; kept native, its events dropped.
+ case standby(of: DeviceAddress)
+ /// Not a supported mouse (terminal).
+ case unsupported
+ /// Not identified yet.
+ case pending
+ }
+
+ /// Nicknames, by device ID. Call `refreshNames()` after a nickname changes.
+ public var nameProvider: ((DeviceID) -> String?)?
+ /// Called after `records` changed (and only then).
+ public var onChange: (() -> Void)?
+
+ /// Pointers first by ordinal; unsupported last.
+ public private(set) var records: [Record] = []
+
+ /// Endpoint first-seen order: the ordinal of an unidentified endpoint and the final tie-break.
+ private var sequence: [DeviceAddress: Int] = [:]
+ private var nextSequence = 0
+ /// The ordinal of every stable unit seen in this process.
+ private var unitOrdinals: [DeviceID: Int] = [:]
+ /// Display names before nicknames and numbering.
+ private var baseNames: [DeviceAddress: String] = [:]
+ private var unsupported: Set = []
+
+ public init() {}
+
+ // MARK: Updates
+
+ public func update(_ a: DeviceAddress, connection: ConnectionKind, state: EndpointController.State) {
+ let observed = Self.observe(state)
+ update(a, connection: connection, state: state, snapshot: observed.snapshot, unsupported: observed.unsupported)
+ }
+
+ /// `update` with the state already decoded: the snapshot (or partial snapshot) it carries,
+ /// and whether it is the terminal unsupported verdict.
+ func update(
+ _ a: DeviceAddress,
+ connection: ConnectionKind,
+ state: EndpointController.State,
+ snapshot: DeviceSnapshot?,
+ unsupported reason: UnsupportedReason?
+ ) {
+ commit {
+ let index: Int
+ if let existing = records.firstIndex(where: { $0.address == a }) {
+ index = existing
+ } else {
+ let seen = takeSequence()
+ sequence[a] = seen
+ records.append(Record(address: a, connection: connection, state: state, displayName: "", role: .pending, ordinal: seen))
+ index = records.count - 1
+ }
+ records[index].connection = connection
+ records[index].state = state
+ if reason != nil { unsupported.insert(a) } else { unsupported.remove(a) }
+
+ guard let snapshot else { return }
+ // A partial snapshot without identity says nothing about the model; the compat
+ // `modelKey` (MX Vertical) only stands in for legacy fixtures of served mice.
+ let model = snapshot.identity?.model ?? (reason == nil ? snapshot.modelKey : nil)
+ let id = snapshot.identity?.deviceID(at: a) ?? (reason == nil ? DeviceID.transient(a) : nil)
+ records[index].model = model
+ records[index].deviceID = id
+ baseNames[a] = Self.baseName(model: model, reported: snapshot.name)
+ // A unit seen before in this process gets its old place back.
+ var ordinal = sequence[a] ?? records[index].ordinal
+ if let id, id.isStable {
+ ordinal = unitOrdinals[id] ?? ordinal
+ unitOrdinals[id] = ordinal
+ }
+ records[index].ordinal = ordinal
+ }
+ }
+
+ public func remove(_ a: DeviceAddress) {
+ guard records.contains(where: { $0.address == a }) else { return }
+ commit { forget([a]) }
+ }
+
+ /// Removes every endpoint of a closed link and returns their addresses.
+ @discardableResult
+ public func removeLink(_ link: UInt32) -> [DeviceAddress] {
+ let removed = records.filter { $0.address.link == link }.map(\.address)
+ guard !removed.isEmpty else { return [] }
+ commit { forget(removed) }
+ return removed
+ }
+
+ /// Re-reads nicknames from `nameProvider`.
+ public func refreshNames() {
+ commit {}
+ }
+
+ // MARK: Queries
+
+ public func record(_ a: DeviceAddress) -> Record? {
+ records.first { $0.address == a }
+ }
+
+ /// Every endpoint that identified as this unit, in `records` order.
+ public func addresses(of id: DeviceID) -> [DeviceAddress] {
+ records.filter { $0.deviceID == id }.map(\.address)
+ }
+
+ /// False for standby, unsupported and pending endpoints.
+ public func isActive(_ a: DeviceAddress) -> Bool {
+ record(a)?.role == .active
+ }
+
+ /// The role an endpoint would get if it connected now with `snapshot`, for
+ /// `HIDPPLink.configurationProvider`: the hub asks for a configuration before the
+ /// endpoint's `.connected` update reaches the registry. Changes nothing.
+ public func prospectiveRole(_ a: DeviceAddress, snapshot: DeviceSnapshot) -> Role {
+ var facts = records.filter { $0.address != a }.map(fact(for:))
+ facts.append(Fact(
+ address: a,
+ connection: snapshot.connection,
+ deviceID: snapshot.identity?.deviceID(at: a) ?? .transient(a),
+ isConnected: true,
+ isUnsupported: false,
+ sequence: sequence[a] ?? nextSequence
+ ))
+ return Self.roles(for: facts)[a] ?? .pending
+ }
+
+ public var summary: FleetSummary {
+ var summary = FleetSummary()
+ for record in records where record.role != .unsupported {
+ switch record.state {
+ case .connected(let snapshot):
+ summary.anyConnected = true
+ summary.anyPresent = true
+ // A standby endpoint is another link to a unit already counted.
+ if case .standby = record.role { continue }
+ summary.connectedCount += 1
+ if let battery = snapshot.battery, battery.percentage < (summary.lowestBattery?.status.percentage ?? Int.max) {
+ summary.lowestBattery = (record.address, battery)
+ }
+ case .asleep:
+ summary.anyPresent = true
+ case .failed:
+ summary.problems.append(record.address)
+ default:
+ break
+ }
+ }
+ return summary
+ }
+
+ /// usb 0 < receivers 1 < bluetooth 2: the better link wins same-unit dedupe.
+ public nonisolated static func linkRank(_ c: ConnectionKind) -> Int {
+ switch c {
+ case .usb: return 0
+ case .unifying, .bolt, .nano: return 1
+ case .bluetooth: return 2
+ }
+ }
+
+ // MARK: Roles
+
+ private struct Fact {
+ var address: DeviceAddress
+ var connection: ConnectionKind
+ var deviceID: DeviceID?
+ var isConnected: Bool
+ var isUnsupported: Bool
+ var sequence: Int
+ }
+
+ private func fact(for record: Record) -> Fact {
+ Fact(
+ address: record.address,
+ connection: record.connection,
+ deviceID: record.deviceID,
+ isConnected: record.isConnected,
+ isUnsupported: unsupported.contains(record.address),
+ sequence: sequence[record.address] ?? record.ordinal
+ )
+ }
+
+ private static func roles(for facts: [Fact]) -> [DeviceAddress: Role] {
+ // Connected first, then the better link, then the endpoint seen first.
+ func preferred(_ lhs: Fact, _ rhs: Fact) -> Bool {
+ if lhs.isConnected != rhs.isConnected { return lhs.isConnected }
+ let l = linkRank(lhs.connection), r = linkRank(rhs.connection)
+ return l != r ? l < r : lhs.sequence < rhs.sequence
+ }
+ var winners: [DeviceID: Fact] = [:]
+ for fact in facts where !fact.isUnsupported {
+ guard let id = fact.deviceID, id.isStable else { continue }
+ if let current = winners[id], !preferred(fact, current) { continue }
+ winners[id] = fact
+ }
+ var roles: [DeviceAddress: Role] = [:]
+ for fact in facts {
+ if fact.isUnsupported {
+ roles[fact.address] = .unsupported
+ } else if let id = fact.deviceID {
+ if id.isStable, let winner = winners[id], winner.address != fact.address {
+ roles[fact.address] = .standby(of: winner.address)
+ } else {
+ roles[fact.address] = .active
+ }
+ } else {
+ roles[fact.address] = .pending
+ }
+ }
+ return roles
+ }
+
+ // MARK: Bookkeeping
+
+ /// Runs a mutation, then recomputes roles, names and order, and reports a change if any.
+ private func commit(_ mutation: () -> Void) {
+ let before = records
+ mutation()
+ let roles = Self.roles(for: records.map(fact(for:)))
+ for index in records.indices {
+ records[index].role = roles[records[index].address] ?? .pending
+ }
+ assignDisplayNames()
+ records.sort { lhs, rhs in
+ let l = lhs.role == .unsupported, r = rhs.role == .unsupported
+ if l != r { return r }
+ if lhs.ordinal != rhs.ordinal { return lhs.ordinal < rhs.ordinal }
+ return (sequence[lhs.address] ?? 0) < (sequence[rhs.address] ?? 0)
+ }
+ if records != before { onChange?() }
+ }
+
+ private func forget(_ addresses: [DeviceAddress]) {
+ let gone = Set(addresses)
+ records.removeAll { gone.contains($0.address) }
+ for address in gone {
+ sequence[address] = nil
+ baseNames[address] = nil
+ unsupported.remove(address)
+ }
+ }
+
+ /// Nickname, else base name; then " #2", " #3"… for further units sharing a name, in
+ /// first-seen order. Two links to one unit share its number.
+ private func assignDisplayNames() {
+ var names: [DeviceAddress: String] = [:]
+ for record in records {
+ let nickname = record.deviceID
+ .flatMap { nameProvider?($0) }?
+ .trimmingCharacters(in: .whitespacesAndNewlines)
+ names[record.address] = nickname.flatMap { $0.isEmpty ? nil : $0 }
+ ?? baseNames[record.address]
+ ?? Self.placeholderName
+ }
+
+ func unit(_ record: Record) -> String {
+ if let id = record.deviceID, id.isStable { return id.rawValue }
+ return "@" + record.address.description
+ }
+ var unitsByName: [String: [String: (Int, Int)]] = [:]
+ for record in records {
+ let name = names[record.address] ?? Self.placeholderName
+ let rank = (record.ordinal, sequence[record.address] ?? record.ordinal)
+ if let known = unitsByName[name]?[unit(record)], known <= rank { continue }
+ unitsByName[name, default: [:]][unit(record)] = rank
+ }
+ for index in records.indices {
+ let record = records[index]
+ let name = names[record.address] ?? Self.placeholderName
+ let units = (unitsByName[name] ?? [:]).sorted { $0.value < $1.value }.map(\.key)
+ let number = (units.firstIndex(of: unit(record)) ?? 0) + 1
+ records[index].displayName = number == 1
+ ? name
+ : String(format: NSLocalizedString("%@ #%lld", comment: "A second mouse with the same name; %@ is the name, %lld its number"), name, Int64(number))
+ }
+ }
+
+ private func takeSequence() -> Int {
+ defer { nextSequence += 1 }
+ return nextSequence
+ }
+
+ // MARK: Decoding states
+
+ private static let placeholderName = NSLocalizedString("Logitech device", comment: "A device that hasn't said what it is yet")
+
+ private static func baseName(model: ModelKey?, reported: String) -> String {
+ if let model, let catalog = DeviceCatalog.model(model) { return catalog.name }
+ let trimmed = reported.trimmingCharacters(in: .whitespacesAndNewlines)
+ return trimmed.isEmpty ? placeholderName : trimmed
+ }
+
+ private static func observe(_ state: EndpointController.State) -> (snapshot: DeviceSnapshot?, unsupported: UnsupportedReason?) {
+ if case .connected(let snapshot) = state { return (snapshot, nil) }
+ if case .asleep(let snapshot) = state { return (snapshot, nil) }
+ if case .unsupported(let reason, let partial) = state { return (partial, reason) }
+ return (nil, nil)
+ }
+}
+
+/// The fleet at a glance, for the menu-bar label, scroll gating and alerts. Unsupported
+/// endpoints never count.
+public struct FleetSummary: Hashable, Sendable {
+ public var anyConnected = false
+ /// Connected or asleep.
+ public var anyPresent = false
+ /// Connected units (a standby link to a unit already counted isn't counted again).
+ public var connectedCount = 0
+ /// The connected mouse with the lowest battery (the first one on a tie).
+ public var lowestBattery: (address: DeviceAddress, status: BatteryStatus)?
+ /// Endpoints that need the user's attention (failed); unsupported devices are not problems.
+ public var problems: [DeviceAddress] = []
+
+ public init() {}
+
+ public static func == (lhs: FleetSummary, rhs: FleetSummary) -> Bool {
+ lhs.anyConnected == rhs.anyConnected
+ && lhs.anyPresent == rhs.anyPresent
+ && lhs.connectedCount == rhs.connectedCount
+ && lhs.lowestBattery?.address == rhs.lowestBattery?.address
+ && lhs.lowestBattery?.status == rhs.lowestBattery?.status
+ && lhs.problems == rhs.problems
+ }
+
+ public func hash(into hasher: inout Hasher) {
+ hasher.combine(anyConnected)
+ hasher.combine(anyPresent)
+ hasher.combine(connectedCount)
+ hasher.combine(lowestBattery?.address)
+ hasher.combine(lowestBattery?.status)
+ hasher.combine(problems)
+ }
+}
diff --git a/UprightKit/Sources/UprightKit/Device/DeviceSession.swift b/UprightKit/Sources/UprightKit/Device/DeviceSession.swift
index 395562f..adebfdc 100644
--- a/UprightKit/Sources/UprightKit/Device/DeviceSession.swift
+++ b/UprightKit/Sources/UprightKit/Device/DeviceSession.swift
@@ -15,6 +15,12 @@ public struct DeviceSnapshot: Hashable, Sendable {
public var serialNumber: String?
/// Easy-Switch channels, when the mouse has CHANGE_HOST (0x1814).
public var hosts: HostInfo?
+ /// Who the device is: kind, unit and model IDs, model key. Nil only in legacy test fixtures.
+ public var identity: DeviceIdentity?
+ /// The endpoint this was read from; `.unassigned` on legacy single-mouse paths.
+ public var address: DeviceAddress = .unassigned
+ /// The REPROG_CONTROLS_V4 (0x1B04) feature version; 0 when unknown.
+ public var reprogVersion: UInt8 = 0
public init(
name: String,
@@ -27,7 +33,10 @@ public struct DeviceSnapshot: Hashable, Sendable {
dpi: DPIInfo? = nil,
firmware: [FirmwareInfo] = [],
serialNumber: String? = nil,
- hosts: HostInfo? = nil
+ hosts: HostInfo? = nil,
+ identity: DeviceIdentity? = nil,
+ address: DeviceAddress = .unassigned,
+ reprogVersion: UInt8 = 0
) {
self.name = name
self.connection = connection
@@ -40,8 +49,14 @@ public struct DeviceSnapshot: Hashable, Sendable {
self.firmware = firmware
self.serialNumber = serialNumber
self.hosts = hosts
+ self.identity = identity
+ self.address = address
+ self.reprogVersion = reprogVersion
}
+ /// The model's key. Fixtures without an identity are MX Verticals, the only model before v3.
+ public var modelKey: ModelKey { identity?.model ?? .mxVertical }
+
public var configurableControls: [ControlInfo] { controls.filter(\.isConfigurable) }
/// The main firmware entry ("RBM 14.00.B0015"), for display.
@@ -54,72 +69,148 @@ public struct DeviceSnapshot: Hashable, Sendable {
}
}
-/// One live HID++ 2.0 conversation with an MX Vertical: discovers its features and controls,
-/// diverts the buttons that have mappings, and turns diverted-button and raw XY reports into events.
+/// One live HID++ 2.0 conversation with one mouse at one address: identifies it, decides whether
+/// Upright can serve it, diverts the buttons that have mappings, and turns diverted-button and
+/// raw XY reports into events stamped with its address.
@MainActor
public final class DeviceSession {
public let device: HIDPPDevice
public let connection: ConnectionKind
+ /// Stamped on every event and on the snapshot.
+ public let address: DeviceAddress
+ /// The receiver's wireless PID for the slot, or the interface's product ID on a direct link.
+ /// Keys the model when the device lists no model IDs (0x0003).
+ public let transportPID: UInt16?
+ /// What to call the device when it doesn't report a name: the IOHID product string of a
+ /// direct link, or the codename a receiver lists for the slot. Nil uses `defaultName`.
+ public let fallbackName: String?
+ /// The kind the receiver lists for the slot (pairing record or 0x41). Only used when the
+ /// device doesn't report its own kind (0x0005 getDeviceType), which is authoritative.
+ public let kindHint: DeviceKind?
+
public private(set) var snapshot: DeviceSnapshot?
public private(set) var divertedControls: Set = []
/// Diverted controls that report raw pointer movement while held.
public private(set) var rawXYControls: Set = []
public var onButtonEvent: ((ButtonEvent) -> Void)?
- /// Raw pointer movement (dx, dy) while a raw-XY control is held.
+ /// Raw pointer movement (dx, dy) while a raw-XY control is held. Kept for single-mouse
+ /// callers; `onSourcedPointerDelta` carries the address.
public var onPointerDelta: ((Int, Int) -> Void)?
+ /// Raw pointer movement while a raw-XY control is held, stamped with `address`.
+ public var onSourcedPointerDelta: ((PointerDelta) -> Void)?
/// The device reconnected (0x1D4B) and has dropped its temporary diversion state.
public var onReconfigurationNeeded: (() -> Void)?
public var onBatteryChange: ((BatteryStatus) -> Void)?
+ /// The name used when the device reports none and no fallback was given.
+ public nonisolated static var defaultName: String {
+ NSLocalizedString("Logitech mouse", comment: "Name shown for a mouse that doesn't report its own name")
+ }
+
private struct Reporting: Equatable {
var diverted: Bool
var rawXY: Bool
}
- private var tracker = ButtonStateTracker()
+ private var tracker: ButtonStateTracker
private var appliedState: [UInt16: Reporting] = [:]
private var reprogIndex: UInt8?
private var changeHostIndex: UInt8?
private var wirelessStatusIndex: UInt8?
private var batteryFeature: (index: UInt8, feature: HIDPPFeature)?
+ /// Set when a raw-XY control goes down on a 0x1B04 v5+ device; see `isSpuriousRawXYReport`.
+ private var awaitingFirstRawXYReport = false
+
+ /// The legacy single-mouse form: events are stamped `.unassigned`.
+ public convenience init(device: HIDPPDevice, connection: ConnectionKind) {
+ self.init(device: device, connection: connection, address: .unassigned)
+ }
- public init(device: HIDPPDevice, connection: ConnectionKind) {
+ public init(
+ device: HIDPPDevice,
+ connection: ConnectionKind,
+ address: DeviceAddress,
+ transportPID: UInt16? = nil,
+ fallbackName: String? = nil,
+ kindHint: DeviceKind? = nil
+ ) {
self.device = device
self.connection = connection
+ self.address = address
+ self.transportPID = transportPID
+ self.fallbackName = fallbackName.flatMap { $0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : $0 }
+ self.kindHint = kindHint
+ tracker = ButtonStateTracker(source: address)
}
- public func start() async throws -> DeviceSnapshot {
- let version = try await device.protocolVersion()
+ // MARK: Identification
+
+ /// Reads who the device is and what it can do, without changing anything on it. In order:
+ /// protocol version, feature table, name, kind (0x0005), unit and model IDs and serial
+ /// (0x0003), the capability gate with the control table (0x1B04), then battery, firmware,
+ /// pointer speed and Easy-Switch channels.
+ ///
+ /// The gate (D2): HID++ 2.0 or later; a pointer (0x0005 kind when the device reports one,
+ /// else `kindHint`, else a control flagged as a mouse button); 0x1B04 with at least one
+ /// configurable control. A device that fails it throws `HIDPPError.unsupportedDevice` with
+ /// what was read so far. That is terminal: nothing was written, and it must not be retried.
+ /// Errors meaning the device stopped answering are thrown as they are.
+ ///
+ /// `pingTimeout` bounds the first request (a sleeping receiver slot), nil uses the client's.
+ public func start(pingTimeout: TimeInterval? = nil) async throws -> DeviceSnapshot {
+ let version = try await device.protocolVersion(timeout: pingTimeout)
+ var snapshot = DeviceSnapshot(
+ name: fallbackName ?? Self.defaultName,
+ connection: connection,
+ deviceIndex: device.index,
+ protocolVersion: "\(version.major).\(version.minor)",
+ features: [],
+ controls: [],
+ battery: nil,
+ address: address
+ )
guard version.major >= 2 else {
- throw HIDPPError.unsupportedProtocol(major: version.major, minor: version.minor)
+ snapshot.identity = DeviceIdentity(name: snapshot.name, kind: kindHint ?? .unknown, transportPID: transportPID)
+ throw HIDPPError.unsupportedDevice(.hidpp10(major: version.major, minor: version.minor), partial: snapshot)
}
- let features = (try? await device.featureTable()) ?? []
- let name = (try? await device.name()) ?? MXVertical.displayName
+ snapshot.features = try await optional { try await device.featureTable() } ?? []
+ if let name = try await optional({ try await device.name() }),
+ !name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
+ snapshot.name = name
+ }
+ var identity = try await DeviceFeatures(device: device).identity(name: snapshot.name, transportPID: transportPID)
+ // DeviceKind(hidpp20:) never gives `.unknown`: it means 0x0005 getDeviceType didn't answer.
+ let kindIsReported = identity.kind != .unknown
+ if !kindIsReported, let kindHint { identity.kind = kindHint }
+ snapshot.identity = identity
+ snapshot.serialNumber = identity.serialNumber
+
+ if kindIsReported ? !identity.kind.isPointer : identity.kind.isDefinitelyNotPointer {
+ throw HIDPPError.unsupportedDevice(.notAPointer(identity.kind), partial: snapshot)
+ }
guard let reprog = try await device.featureIndex(for: .reprogControlsV4) else {
- throw HIDPPError.featureNotSupported(HIDPPFeature.reprogControlsV4.rawValue)
+ throw HIDPPError.unsupportedDevice(.noReprogControls, partial: snapshot)
}
- reprogIndex = reprog
- wirelessStatusIndex = (try? await device.featureIndex(for: .wirelessDeviceStatus)) ?? nil
- if let index = (try? await device.featureIndex(for: .unifiedBattery)) ?? nil {
- batteryFeature = (index, .unifiedBattery)
- } else if let index = (try? await device.featureIndex(for: .batteryStatus)) ?? nil {
- batteryFeature = (index, .batteryStatus)
+ snapshot.reprogVersion = try await optional { try await device.featureVersion(for: .reprogControlsV4) } ?? 0
+ snapshot.controls = try await ReprogControls(device: device).allControls()
+ if !kindIsReported, !identity.kind.isPointer, !snapshot.controls.contains(where: \.isMouseButton) {
+ throw HIDPPError.unsupportedDevice(.notAPointer(identity.kind), partial: snapshot)
+ }
+ guard !snapshot.configurableControls.isEmpty else {
+ throw HIDPPError.unsupportedDevice(.noConfigurableControls, partial: snapshot)
}
- let controls = try await ReprogControls(device: device).allControls()
- let battery = await device.battery()
-
- var snapshot = DeviceSnapshot(
- name: name,
- connection: connection,
- deviceIndex: device.index,
- protocolVersion: "\(version.major).\(version.minor)",
- features: features,
- controls: controls,
- battery: battery
- )
+ reprogIndex = reprog
+ wirelessStatusIndex = try await optional { try await device.featureIndex(for: .wirelessDeviceStatus) }
+ for feature in [HIDPPFeature.unifiedBattery, .batteryStatus, .batteryVoltage] {
+ if let index = try await optional({ try await device.featureIndex(for: feature) }) {
+ batteryFeature = (index, feature)
+ break
+ }
+ }
+ snapshot.battery = await device.battery()
try await readDeviceInfo(into: &snapshot)
self.snapshot = snapshot
return snapshot
@@ -127,17 +218,12 @@ public final class DeviceSession {
// MARK: Device information
- /// Firmware, serial number, pointer speed and Easy-Switch channels. Each is optional: a
- /// missing feature or a refused request leaves it empty, but a device that stops answering
- /// fails the whole read.
+ /// Firmware, pointer speed and Easy-Switch channels. Each is optional: a missing feature or
+ /// a refused request leaves it empty, but a device that stops answering fails the whole read.
private func readDeviceInfo(into snapshot: inout DeviceSnapshot) async throws {
let info = DeviceFeatures(device: device)
if try await has(.deviceFirmwareVersion) {
snapshot.firmware = try await optional { try await info.firmware() } ?? []
- let version = snapshot.feature(.deviceFirmwareVersion)?.version ?? 0
- if version >= 4 {
- snapshot.serialNumber = try await optional { try await info.serialNumber() }
- }
}
if try await has(.adjustableDPI) {
snapshot.dpi = try await optional { try await info.dpi() }
@@ -223,7 +309,7 @@ public final class DeviceSession {
let stillDiverted = tracker.pressed.filter(wanted.contains)
if stillDiverted.count != tracker.pressed.count {
- tracker.update(pressedNow: stillDiverted).forEach { onButtonEvent?($0) }
+ emit(tracker.update(pressedNow: stillDiverted))
}
let reprog = ReprogControls(device: device)
@@ -262,6 +348,8 @@ public final class DeviceSession {
snapshot?.battery = battery
}
+ // MARK: Events
+
/// Consumes device events addressed to this session. Returns false for anything else.
public func handle(_ message: HIDPPMessage) -> Bool {
guard message.deviceIndex == device.index, message.softwareID == 0 else { return false }
@@ -270,10 +358,12 @@ public final class DeviceSession {
switch message.function {
case 0:
let pressed = ReprogControls.pressedControls(fromEventParameters: message.parameters)
- tracker.update(pressedNow: pressed).forEach { onButtonEvent?($0) }
+ emit(tracker.update(pressedNow: pressed))
case 1:
+ guard !isSpuriousRawXYReport() else { break }
let delta = ReprogControls.rawXYDelta(fromEventParameters: message.parameters)
onPointerDelta?(delta.dx, delta.dy)
+ onSourcedPointerDelta?(PointerDelta(source: address, dx: delta.dx, dy: delta.dy))
default:
break
}
@@ -290,9 +380,15 @@ public final class DeviceSession {
}
if let batteryFeature, message.featureIndex == batteryFeature.index {
let previous = snapshot?.battery
- let status = batteryFeature.feature == .unifiedBattery
- ? BatteryStatus(unifiedBatteryParameters: message.parameters, reportsPercentage: !(previous?.isApproximate ?? false))
- : BatteryStatus(batteryStatusParameters: message.parameters, previous: previous)
+ let status: BatteryStatus?
+ switch batteryFeature.feature {
+ case .unifiedBattery:
+ status = BatteryStatus(unifiedBatteryParameters: message.parameters, reportsPercentage: !(previous?.isApproximate ?? false))
+ case .batteryVoltage:
+ status = BatteryStatus(batteryVoltageParameters: message.parameters)
+ default:
+ status = BatteryStatus(batteryStatusParameters: message.parameters, previous: previous)
+ }
if let status {
snapshot?.battery = status
onBatteryChange?(status)
@@ -304,7 +400,7 @@ public final class DeviceSession {
/// Emits release events for anything still held (the device went away mid-press).
public func releaseAll() {
- tracker.reset().forEach { onButtonEvent?($0) }
+ emit(tracker.reset())
}
/// The device may have lost diversion state (sleep, power cycle); re-send everything next time.
@@ -320,4 +416,23 @@ public final class DeviceSession {
softwareID: device.client.softwareID, clearRawXY: rawXYControls.contains($0))
}
}
+
+ private func emit(_ events: [ButtonEvent]) {
+ let rawXYPressed = events.contains { $0.phase == .press && rawXYControls.contains($0.cid) }
+ if rawXYPressed, (snapshot?.reprogVersion ?? 0) >= 5 {
+ awaitingFirstRawXYReport = true
+ } else if !tracker.pressed.contains(where: rawXYControls.contains) {
+ awaitingFirstRawXYReport = false
+ }
+ events.forEach { onButtonEvent?($0) }
+ }
+
+ /// The raw-XY quirk (D16): on REPROG_CONTROLS_V4 version 5 and later, the first raw-XY report
+ /// after a raw-XY control goes down carries a bogus movement (Solaar ignores it, citing the MX
+ /// Master 3S), so it is dropped. Not verified on hardware; earlier versions are unaffected.
+ private func isSpuriousRawXYReport() -> Bool {
+ guard awaitingFirstRawXYReport else { return false }
+ awaitingFirstRawXYReport = false
+ return true
+ }
}
diff --git a/UprightKit/Sources/UprightKit/Device/MXVerticalController.swift b/UprightKit/Sources/UprightKit/Device/EndpointController.swift
similarity index 51%
rename from UprightKit/Sources/UprightKit/Device/MXVerticalController.swift
rename to UprightKit/Sources/UprightKit/Device/EndpointController.swift
index e89280f..592137e 100644
--- a/UprightKit/Sources/UprightKit/Device/MXVerticalController.swift
+++ b/UprightKit/Sources/UprightKit/Device/EndpointController.swift
@@ -1,29 +1,65 @@
import Foundation
-/// Owns one HID interface (receiver or directly connected mouse) and keeps an MX Vertical on it
-/// configured: finds the mouse, applies button diversion, and re-applies it whenever the mouse
-/// wakes up, reconnects, or the Mac resumes from sleep.
+/// What an `EndpointController` needs from the hub (`HIDPPLink`) that owns it.
+@MainActor
+protocol EndpointHub: AnyObject {
+ func endpoint(_ endpoint: EndpointController, didChangeState state: EndpointController.State)
+ /// The endpoint identified its device and nothing is diverted yet: retire stale endpoints
+ /// of the same unit and return the configuration to apply.
+ func endpointDidIdentify(_ endpoint: EndpointController, snapshot: DeviceSnapshot) -> EndpointConfiguration
+ func endpoint(_ endpoint: EndpointController, didEmit event: ButtonEvent)
+ func endpoint(_ endpoint: EndpointController, didMove delta: PointerDelta)
+ /// `chosenByDevice`: adopted from the mouse or stepped by `cycleDPI`, not set by the app.
+ func endpoint(_ endpoint: EndpointController, didChangeDesiredDPI dpi: Int?, chosenByDevice: Bool)
+ /// A receiver slot is coming back after losing its link: make sure the receiver still
+ /// sends notifications (coalesced across slots).
+ func endpointNeedsReceiverNotifications(_ endpoint: EndpointController) async
+ func endpoint(_ endpoint: EndpointController, log text: String)
+ /// True for an observer (`upright-probe` without `--divert`): button reporting is only
+ /// written once this process has asked for a diversion, never just to make it native.
+ var leavesReportingAlone: Bool { get }
+}
+
+/// Keeps one mouse configured: a receiver slot (1...6) or a directly connected device (0xFF).
+///
+/// It identifies the device, applies the configuration its hub hands it, and re-applies it
+/// whenever the mouse wakes up, reconnects, or the Mac resumes from sleep. Discovery and
+/// message routing belong to the hub (`HIDPPLink`); everything here is scoped to one address.
///
/// Everything that talks to the device runs on one serial operation queue, so probes,
-/// reconfigurations and mapping changes never interleave. While the transport is open the
-/// controller never gives up: a mouse that stops answering is retried with exponential backoff,
-/// and any report it sends in the meantime triggers a reconfiguration right away. Closing the
-/// transport or calling `shutdown()` is terminal.
+/// reconfigurations and mapping changes for this mouse never interleave (requests to other
+/// mice interleave with them at the client's request gate). While the transport is open the
+/// controller never gives up: a mouse that stops answering is retried with exponential
+/// backoff, and any report it sends in the meantime triggers a reconfiguration right away.
+/// A device that isn't a supported mouse ends in the terminal `.unsupported` state and is
+/// never written to. Retiring the endpoint, closing the transport or `shutdown()` is terminal.
@MainActor
-public final class MXVerticalController {
+public final class EndpointController {
public enum State: Hashable, Sendable {
case probing
- /// No MX Vertical is answering yet (off, asleep, or paired elsewhere). Retried automatically.
+ /// The mouse isn't answering yet (off, asleep, or paired elsewhere). Retried automatically.
case waitingForDevice
case connected(DeviceSnapshot)
/// The mouse stopped answering; it is reconfigured as soon as it answers again.
case asleep(DeviceSnapshot)
+ /// Not a mouse Upright can serve. Terminal: no retry, no writes, never diverted.
+ case unsupported(UnsupportedReason, DeviceSnapshot?)
/// The device can't be used right now. `.failed("Disconnected")` (transport closed) is terminal.
case failed(String)
}
public let client: HIDPPClient
public let kind: TransportKind
+ public let address: DeviceAddress
+ /// The receiver's wireless PID for the slot, or the interface's product ID on a direct link.
+ public let transportPID: UInt16?
+ /// The kind the receiver lists for the slot; the device's own answer wins.
+ public let kindHint: DeviceKind?
+ /// The name to show when the device reports none (the IOHID product string of a direct link).
+ public let fallbackName: String?
+ /// Whether an unanswered probe is retried with backoff. False only for a receiver slot that
+ /// isn't known to be paired while the receiver sends link notifications (they announce it).
+ public internal(set) var retriesWhileWaiting = true
public private(set) var state: State = .probing {
didSet {
@@ -33,7 +69,7 @@ public final class MXVerticalController {
if case .connected = oldValue, !Self.isConnected(state) {
session?.releaseAll()
}
- onStateChange?(state)
+ hub?.endpoint(self, didChangeState: state)
}
}
public private(set) var desiredDiversion: Set = []
@@ -41,42 +77,29 @@ public final class MXVerticalController {
public private(set) var desiredRawXY: Set = []
/// The pointer speed to keep. Many mice forget a speed set over HID++ when they reconnect,
/// so it is re-applied when the mouse comes back at its default speed. A mouse that comes
- /// back at another speed had it changed with its top button, and that speed is adopted
+ /// back at another speed had it changed with its own button, and that speed is adopted
/// instead. Nil leaves the mouse's own setting alone.
- public private(set) var desiredDPI: Int? {
- didSet { if desiredDPI != oldValue { onDesiredDPIChange?(desiredDPI) } }
- }
- /// Set by `shutdown()` or when the transport closes. No further work runs afterwards.
+ public private(set) var desiredDPI: Int?
+ /// Set when the endpoint is retired, shut down, or its transport closes. No work runs afterwards.
public private(set) var isShutDown = false
- public var onStateChange: ((State) -> Void)?
- public var onButtonEvent: ((ButtonEvent) -> Void)?
- /// Raw movement (dx, dy) while a raw-XY control is held.
- public var onPointerDelta: ((Int, Int) -> Void)?
- /// `desiredDPI` changed: set through the API, by `cycleDPI`, or adopted from the mouse (by
- /// `refreshDPI()` or a reconnect) after the top button changed the speed natively.
- public var onDesiredDPIChange: ((Int?) -> Void)?
- public var onLog: ((String) -> Void)?
-
- /// How long to wait for a receiver slot to answer during discovery.
- public var probeTimeout: TimeInterval = 1.0
- /// Pause before reconfiguring a mouse that just woke up, so it can finish reconnecting.
- /// Wake-up signals that arrive within this window are coalesced into one pass.
- public var reconnectDelay: TimeInterval = 0.6
- /// First retry delay while the mouse doesn't answer; doubles per attempt up to `retryMaxDelay`.
- public var retryBaseDelay: TimeInterval = 1
- public var retryMaxDelay: TimeInterval = 30
- /// How often the battery level is re-read while connected (0 disables).
- public var batteryRefreshInterval: TimeInterval = 600
- /// How long to listen for slot announcements when a receiver can't list its pairings.
- public var announcementWait: TimeInterval = 0.3
+ var tuning: ControllerTuning
+ private weak var hub: EndpointHub?
private var session: DeviceSession?
private var started = false
+ /// The last pointer speed the hub's configuration asked for (edge-triggered: a speed the
+ /// mouse chose isn't overridden until the configuration itself changes).
+ private var configuredDPI: Int?
+ /// The unit last identified here, kept when the session is dropped.
+ private(set) var lastDeviceID: DeviceID?
+ /// When the configuration was last applied (announcement echo detection).
+ private(set) var configuredAt: Date?
+ /// The slot lost its link (or stopped answering) since the last reconfiguration.
+ private var linkWasLost = false
private enum OperationKey: Hashable {
- case probe, reconfigure, recover, diversion, battery, dpi, deviceInfo
- case connect(UInt8)
+ case probe, reconfigure, recover, diversion, battery, dpi, deviceInfo, connect
/// Every cycle request runs; they aren't coalesced like the other keys.
case cycleDPI(Int)
@@ -90,7 +113,7 @@ public final class MXVerticalController {
private struct Operation {
let key: OperationKey
- let body: @MainActor (MXVerticalController) async -> Void
+ let body: @MainActor (EndpointController) async -> Void
}
private var queue: [Operation] = []
@@ -103,42 +126,141 @@ public final class MXVerticalController {
private var batteryTimer: Task?
private var cycleRequests = 0
- // Receiver bookkeeping.
- /// Wireless PIDs per slot, from the pairing register and 0x41 notifications.
- private var slotPIDs: [UInt8: UInt16] = [:]
- /// Slots that answered with another device's name.
- private var otherDeviceSlots: Set = []
- private var notificationsEnabled = false
- private var heardAnnouncement = false
- private var announcementsRequestedAt: Date?
- private var configuredAt: Date?
-
- public init(transport: HIDPPTransport, kind: TransportKind) {
+ init(
+ client: HIDPPClient,
+ kind: TransportKind,
+ address: DeviceAddress,
+ hub: EndpointHub,
+ tuning: ControllerTuning = .init(),
+ transportPID: UInt16? = nil,
+ kindHint: DeviceKind? = nil,
+ fallbackName: String? = nil
+ ) {
+ self.client = client
self.kind = kind
- self.client = HIDPPClient(transport: transport)
- client.onUnsolicited = { [weak self] message in
- MainActorDelivery.run { self?.handle(message) }
- }
- client.onClose = { [weak self] in
- MainActorDelivery.run { self?.transportClosed() }
- }
+ self.address = address
+ self.hub = hub
+ self.tuning = tuning
+ self.transportPID = transportPID
+ self.kindHint = kindHint
+ self.fallbackName = fallbackName
}
public var snapshot: DeviceSnapshot? { session?.snapshot }
- // MARK: Public API
+ public var isUnsupported: Bool {
+ if case .unsupported = state { return true }
+ return false
+ }
- public func start() {
+ /// True while an operation is queued or running.
+ var isBusy: Bool { runner != nil }
+
+ // MARK: Lifecycle (driven by the hub)
+
+ /// Probes the device (a known receiver slot, or a direct link).
+ func start() {
guard !started else { return }
started = true
enqueue(.probe) { await $0.probe() }
}
- /// Re-applies configuration (after the Mac wakes, or on request). Falls back to a full
- /// re-probe when no mouse has been found yet.
- public func refresh() {
+ /// Starts on a slot the receiver announced, or that sent a report: waits `reconnectDelay`
+ /// for the mouse to finish connecting, then connects without a `.probing` flicker.
+ func startAnnounced() {
+ guard !started else { return }
+ started = true
+ scheduleConnect()
+ }
+
+ /// Delivers a message addressed to this endpoint (receiver-level 0x40/0x41 are the hub's).
+ func receive(_ message: HIDPPMessage) {
+ guard !isShutDown, !isUnsupported else { return }
+ if let session, session.handle(message) {
+ if case .asleep = state { scheduleReconfigure("The mouse is sending reports again") }
+ return
+ }
+ noteActivity(from: message)
+ }
+
+ /// The receiver reported the slot's link going up or down (0x41). `announcementEcho`: the
+ /// hub asked for announcements after this slot was configured, so a link-up is not news.
+ func linkChanged(up: Bool, announcementEcho: Bool) {
+ guard !isShutDown, !isUnsupported else { return }
+ if let session {
+ if up {
+ if announcementEcho { return }
+ session.invalidateAppliedState()
+ scheduleReconfigure("Slot \(address.index) link restored")
+ } else {
+ log("Slot \(address.index) link lost")
+ linkWasLost = true
+ session.releaseAll()
+ if let snapshot = session.snapshot { transition(.asleep(snapshot)) }
+ }
+ return
+ }
+ guard up else { return }
+ log(String(format: "Slot %d connected", Int(address.index)))
+ scheduleConnect()
+ }
+
+ /// Removes the endpoint: releases held buttons and, when the device may still be reachable,
+ /// hands its diverted buttons back without waiting for replies. Terminal.
+ func retire(sendingRestore: Bool) {
+ guard !isShutDown else { return }
+ stopAllWork()
+ if let session {
+ session.releaseAll()
+ if sendingRestore { session.restoreMessages().forEach(client.sendWithoutReply) }
+ }
+ dropSession()
+ }
+
+ /// Stops all work and hands every diverted button back to the mouse without waiting for
+ /// replies. Terminal. The hub closes the client afterwards.
+ func shutdown() {
+ retire(sendingRestore: true)
+ }
+
+ /// The transport closed: everything held is released. Terminal.
+ func transportDidClose() {
+ stopAllWork()
+ dropSession()
+ state = .failed("Disconnected")
+ }
+
+ /// Drops the session and identifies the device again. No effect on an unsupported device.
+ func reconnect() {
+ guard canWork, !isUnsupported else { return }
retryAttempt = 0
- scheduleReconfigure(nil)
+ cancelRetry()
+ reconfigureTimer?.cancel()
+ reconfigureTimer = nil
+ dropSession()
+ started = true
+ enqueue(.probe) { await $0.probe() }
+ }
+
+ // MARK: Configuration
+
+ /// Applies what the hub's configuration provider decided for this mouse. The pointer speed
+ /// is edge-triggered: it is applied when the configured value changes, so a speed chosen on
+ /// the mouse itself isn't reverted by an unrelated resync.
+ public func apply(_ configuration: EndpointConfiguration) {
+ setDesiredDiversion(configuration.divert)
+ setDesiredRawXY(configuration.rawXY)
+ if configuration.dpi != configuredDPI {
+ configuredDPI = configuration.dpi
+ if configuration.dpi != desiredDPI { setDesiredDPI(configuration.dpi) }
+ }
+ }
+
+ /// A pointer speed chosen in the app for this mouse (nil: leave the mouse's own setting).
+ /// It also becomes the configured speed, so an identical configuration doesn't re-apply it.
+ func setDPIFromApp(_ dpi: Int?) {
+ configuredDPI = dpi
+ setDesiredDPI(dpi)
}
public func setDesiredDiversion(_ cids: Set) {
@@ -147,8 +269,8 @@ public final class MXVerticalController {
scheduleDiversionUpdate()
}
- /// Controls that should report raw movement (dx, dy through `onPointerDelta`) instead of
- /// moving the pointer while held. They are diverted as well, whatever `desiredDiversion` says.
+ /// Controls that should report raw movement (as pointer deltas) instead of moving the
+ /// pointer while held. They are diverted as well, whatever `desiredDiversion` says.
/// Controls without the raw XY capability are only diverted.
public func setDesiredRawXY(_ cids: Set) {
guard cids != desiredRawXY else { return }
@@ -174,7 +296,7 @@ public final class MXVerticalController {
/// Like `setDPI`; nil stops re-applying a speed and leaves the mouse's setting alone.
public func setDesiredDPI(_ value: Int?) {
- desiredDPI = value.map { snapshot?.dpi?.nearest(to: $0) ?? $0 }
+ updateDesiredDPI(value.map { snapshot?.dpi?.nearest(to: $0) ?? $0 }, chosenByDevice: false)
guard desiredDPI != nil else { return }
enqueue(.dpi) { controller in
await controller.withConnectedSession("change the pointer speed") { session in
@@ -189,20 +311,20 @@ public final class MXVerticalController {
cycleRequests += 1
enqueue(.cycleDPI(cycleRequests)) { controller in
await controller.withConnectedSession("change the pointer speed") { session in
- // The top button may have changed the speed natively since we last looked.
+ // The mouse's own button may have changed the speed since we last looked.
try await session.refreshDPI()
guard let dpi = session.snapshot?.dpi else {
controller.log("This mouse can't change its pointer speed")
return
}
- controller.desiredDPI = dpi.nextPreset(after: dpi.current, presets: presets)
+ controller.updateDesiredDPI(dpi.nextPreset(after: dpi.current, presets: presets), chosenByDevice: true)
try await controller.applyDesiredDPI(to: session)
}
}
}
/// Re-reads the pointer speed. A speed that differs from `desiredDPI` was changed on the
- /// mouse (its top button cycles speeds natively when not diverted) and becomes the new
+ /// mouse (its DPI button cycles speeds natively when not diverted) and becomes the new
/// `desiredDPI`. Meant for when the UI is about to show it.
public func refreshDPI() {
enqueue(.dpi) { controller in
@@ -212,6 +334,12 @@ public final class MXVerticalController {
}
}
+ private func updateDesiredDPI(_ value: Int?, chosenByDevice: Bool) {
+ guard value != desiredDPI else { return }
+ desiredDPI = value
+ hub?.endpoint(self, didChangeDesiredDPI: value, chosenByDevice: chosenByDevice)
+ }
+
// MARK: Device information
/// Re-reads battery, pointer speed and the active Easy-Switch channel.
@@ -245,16 +373,11 @@ public final class MXVerticalController {
client.sendWithoutReply(message)
}
- /// Stops all work, hands every diverted button back to the mouse without waiting for
- /// replies, then closes the client. Terminal.
- public func shutdown() {
- guard !isShutDown else { return }
- stopAllWork()
- if let session {
- session.releaseAll()
- session.restoreMessages().forEach(client.sendWithoutReply)
- }
- client.close()
+ /// Re-applies configuration (after the Mac wakes, or on request). Falls back to a full
+ /// re-probe when the mouse hasn't been identified yet.
+ public func refresh() {
+ retryAttempt = 0
+ scheduleReconfigure(nil)
}
/// Waits until every queued operation has finished. Mainly useful in tests.
@@ -266,7 +389,7 @@ public final class MXVerticalController {
// MARK: Operation queue
- private func enqueue(_ key: OperationKey, _ body: @escaping @MainActor (MXVerticalController) async -> Void) {
+ private func enqueue(_ key: OperationKey, _ body: @escaping @MainActor (EndpointController) async -> Void) {
guard !isShutDown else { return }
// Anything already waiting with the same key will do the same work.
if queue.contains(where: { $0.key == key }) { return }
@@ -310,11 +433,11 @@ public final class MXVerticalController {
/// Debounces wake-up signals (0x41 link-up, 0x1D4B, Mac wake, a report from a sleeping
/// mouse) so a burst of them results in a single reconfiguration.
private func scheduleReconfigure(_ reason: String?) {
- guard !isShutDown else { return }
+ guard !isShutDown, !isUnsupported else { return }
cancelRetry()
guard reconfigureTimer == nil else { return }
if let reason { log(reason) }
- let delay = reconnectDelay
+ let delay = tuning.reconnectDelay
reconfigureTimer = Task { [weak self] in
if delay > 0 { try? await Task.sleep(nanoseconds: Self.nanoseconds(delay)) }
guard let self, !Task.isCancelled else { return }
@@ -325,10 +448,10 @@ public final class MXVerticalController {
/// Exponential backoff, capped at `retryMaxDelay`. Never gives up while the transport is open.
private func scheduleRetry() {
- guard !isShutDown, retryTimer == nil else { return }
- let delay = min(retryBaseDelay * pow(2, Double(min(retryAttempt, 16))), retryMaxDelay)
+ guard !isShutDown, !isUnsupported, retryTimer == nil else { return }
+ let delay = min(tuning.retryBaseDelay * pow(2, Double(min(retryAttempt, 16))), tuning.retryMaxDelay)
retryAttempt += 1
- if delay < retryMaxDelay || retryAttempt <= 1 || retryAttempt % 10 == 0 {
+ if delay < tuning.retryMaxDelay || retryAttempt <= 1 || retryAttempt % 10 == 0 {
log(String(format: "Trying again in %.1f s", delay))
}
retryTimer = Task { [weak self] in
@@ -345,8 +468,8 @@ public final class MXVerticalController {
}
private func startBatteryRefresh() {
- guard batteryTimer == nil, batteryRefreshInterval > 0, !isShutDown else { return }
- let interval = batteryRefreshInterval
+ guard batteryTimer == nil, tuning.batteryRefreshInterval > 0, !isShutDown else { return }
+ let interval = tuning.batteryRefreshInterval
batteryTimer = Task { [weak self] in
while !Task.isCancelled {
try? await Task.sleep(nanoseconds: Self.nanoseconds(interval))
@@ -373,86 +496,31 @@ public final class MXVerticalController {
}
private func probe(retrying: Bool = false) async {
- guard canWork, session == nil else { return }
+ guard canWork, session == nil, !isUnsupported else { return }
cancelRetry()
// Retries stay in the waiting state so the UI doesn't flicker on every attempt.
- if !retrying { transition(.probing) }
- switch kind {
- case .direct(let connection):
- if !retrying { log("Probing MX Vertical over \(connection.title)") }
- switch await connect(index: 0xFF) {
- case .configured, .asleep, .closed:
- return
- case .unreachable(let error):
- log("The MX Vertical isn't answering yet (\(error))")
- transition(.waitingForDevice)
- case .failed(let error):
- log("Setup failed: \(error)")
- transition(.failed(NSLocalizedString("The MX Vertical didn't answer HID++ requests. Try clicking a button to wake it, then choose Reconnect.", comment: "Connection status shown when setup fails")))
+ if !retrying {
+ transition(.probing)
+ switch kind {
+ case .direct(let connection): log("Probing the mouse over \(connection.title)")
+ case .receiver(let connection): log("Probing slot \(address.index) of the \(connection.title)")
}
- scheduleRetry()
- case .receiver(let connection):
- if !retrying { log("Probing \(connection.title)") }
- await probeReceiver(retrying: retrying)
}
- }
-
- private func probeReceiver(retrying: Bool) async {
- await enableReceiverNotifications()
- if !retrying || slotPIDs.isEmpty {
- if !(await readPairedDevices()), await requestSlotAnnouncements() {
- log("Receiver announced its paired devices")
- }
+ switch await connect() {
+ case .configured, .asleep, .closed, .unsupported:
+ return
+ case .unreachable(let error):
+ if !retrying || !isReceiver { log("The mouse isn't answering yet (\(error))") }
+ transition(.waitingForDevice)
+ case .failed(let error):
+ log("Setup failed: \(error)")
+ transition(isReceiver ? .waitingForDevice : .failed(Self.setupFailedMessage))
}
- guard canWork, session == nil else { return }
+ if retriesWhileWaiting { scheduleRetry() }
+ }
- let knownSlots = mxSlots
- let slots = knownSlots.isEmpty ? (UInt8(1)...6).filter { !otherDeviceSlots.contains($0) } : knownSlots
- for slot in slots {
- guard canWork else { return }
- guard session == nil else { return }
- let device = HIDPPDevice(client: client, index: slot)
- do {
- let version = try await device.protocolVersion(timeout: probeTimeout)
- guard version.major >= 2 else {
- log("Slot \(slot): HID++ \(version.major).\(version.minor) device, skipping")
- otherDeviceSlots.insert(slot)
- continue
- }
- guard let name = try? await device.name() else {
- log("Slot \(slot): could not read the device name")
- continue
- }
- guard MXVertical.matches(name: name) else {
- log("Slot \(slot): \(name), skipping")
- otherDeviceSlots.insert(slot)
- continue
- }
- log("Slot \(slot): \(name)")
- switch await connect(index: slot) {
- case .configured, .asleep, .closed:
- return
- case .unreachable(let error):
- log("Slot \(slot) stopped answering: \(error)")
- case .failed(let error):
- log("Slot \(slot): setup failed: \(error)")
- }
- } catch HIDPPError.transportClosed {
- return
- } catch let error as HIDPPError where error.meansDeviceUnreachable {
- continue
- } catch {
- log("Slot \(slot): \(error)")
- }
- }
- guard canWork, session == nil else { return }
- if !retrying { log("No MX Vertical is answering on the receiver yet") }
- transition(.waitingForDevice)
- // The receiver announces the mouse when it wakes (0x41). Poll only when we know which
- // slot to ask, or when the receiver can't send notifications.
- if !mxSlots.isEmpty || !notificationsEnabled {
- scheduleRetry()
- }
+ private static var setupFailedMessage: String {
+ NSLocalizedString("The mouse didn't answer HID++ requests. Try clicking a button to wake it, then choose Reconnect.", comment: "Connection status shown when setup fails")
}
private enum ConnectOutcome {
@@ -463,27 +531,46 @@ public final class MXVerticalController {
/// Nothing answered; no session was created.
case unreachable(Error)
case failed(Error)
+ /// Not a supported mouse (terminal).
+ case unsupported
case closed
}
- private func connect(index: UInt8) async -> ConnectOutcome {
- let candidate = DeviceSession(device: HIDPPDevice(client: client, index: index), connection: kind.connection)
+ private func connect() async -> ConnectOutcome {
+ let candidate = DeviceSession(
+ device: HIDPPDevice(client: client, index: address.index),
+ connection: kind.connection,
+ address: address,
+ transportPID: transportPID,
+ fallbackName: fallbackName,
+ kindHint: kindHint
+ )
+ let snapshot: DeviceSnapshot
do {
- let snapshot = try await candidate.start()
- if isReceiver, !MXVertical.matches(name: snapshot.name) {
- otherDeviceSlots.insert(index)
- throw HIDPPError.notMXVertical(snapshot.name)
- }
- guard canWork else { return .closed }
- adopt(candidate)
- log("Connected to \(snapshot.name) (HID++ \(snapshot.protocolVersion), \(snapshot.controls.count) controls)")
+ snapshot = try await candidate.start(pingTimeout: isReceiver ? tuning.probeTimeout : nil)
} catch HIDPPError.transportClosed {
return .closed
+ } catch HIDPPError.unsupportedDevice(let reason, let partial) {
+ guard canWork else { return .closed }
+ becomeUnsupported(reason, partial: partial)
+ return .unsupported
} catch let error as HIDPPError where error.meansDeviceUnreachable {
return .unreachable(error)
} catch {
- return .failed(error)
+ return canWork ? .failed(error) : .closed
}
+ guard canWork else { return .closed }
+ adopt(candidate)
+ lastDeviceID = snapshot.identity?.deviceID(at: address)
+ log("Connected to \(snapshot.name) (HID++ \(snapshot.protocolVersion), \(snapshot.controls.count) controls)")
+
+ // Pulled before anything is diverted, so no event is ever anonymous.
+ let configuration = hub?.endpointDidIdentify(self, snapshot: snapshot) ?? .native
+ guard session === candidate, canWork else { return .closed }
+ desiredDiversion = configuration.divert
+ desiredRawXY = configuration.rawXY
+ configuredDPI = configuration.dpi
+ updateDesiredDPI(configuration.dpi.map { snapshot.dpi?.nearest(to: $0) ?? $0 }, chosenByDevice: false)
do {
try await applyDesiredDiversion(to: candidate, force: true)
@@ -501,17 +588,28 @@ public final class MXVerticalController {
return .asleep
} catch {
if session === candidate { dropSession() }
- return .failed(error)
+ return canWork ? .failed(error) : .closed
}
}
+ private func becomeUnsupported(_ reason: UnsupportedReason, partial: DeviceSnapshot?) {
+ let name = partial?.name ?? "The device"
+ log("\(name): not supported (\(reason.message)); leaving it alone")
+ cancelRetry()
+ transition(.unsupported(reason, partial))
+ }
+
private func reconfigure() async {
- guard canWork else { return }
+ guard canWork, !isUnsupported else { return }
guard let session else {
await probe(retrying: state == .waitingForDevice)
return
}
- if isReceiver { await enableReceiverNotifications() }
+ if isReceiver, linkWasLost {
+ linkWasLost = false
+ await hub?.endpointNeedsReceiverNotifications(self)
+ guard self.session === session, canWork else { return }
+ }
do {
try await applyDesiredDiversion(to: session, force: true)
guard self.session === session, canWork else { return }
@@ -536,45 +634,47 @@ public final class MXVerticalController {
}
}
- /// Connects to a slot the receiver announced (link-up) or that sent a report. Moves the
- /// session when the MX Vertical was re-paired to another slot.
- private func connectAnnounced(_ slot: UInt8) async {
- guard canWork else { return }
- if reconnectDelay > 0 {
- try? await Task.sleep(nanoseconds: Self.nanoseconds(reconnectDelay))
+ /// Connects to a slot the receiver announced (link-up) or that sent a report.
+ private func connectAnnounced() async {
+ guard canWork, !isUnsupported else { return }
+ if tuning.reconnectDelay > 0 {
+ try? await Task.sleep(nanoseconds: Self.nanoseconds(tuning.reconnectDelay))
guard canWork else { return }
}
- if let current = session {
- if current.device.index == slot { return }
- if case .connected = state, (try? await current.device.protocolVersion(timeout: probeTimeout)) != nil {
- log("Another MX Vertical came up on slot \(slot); staying on slot \(current.device.index)")
- return
- }
- guard canWork, session === current else { return }
- log("The MX Vertical moved from slot \(current.device.index) to slot \(slot)")
- dropSession()
- }
- switch await connect(index: slot) {
- case .configured, .asleep, .closed:
+ guard session == nil else { return }
+ switch await connect() {
+ case .configured, .asleep, .closed, .unsupported:
return
case .unreachable(let error):
- log("Slot \(slot) isn't answering yet: \(error)")
+ log("Slot \(address.index) isn't answering yet: \(error)")
transition(.waitingForDevice)
scheduleRetry()
case .failed(let error):
- log("Slot \(slot): \(error)")
+ log("Slot \(address.index): \(error)")
if session == nil { transition(.waitingForDevice) }
}
}
private func applyDesiredDiversion(to session: DeviceSession, force: Bool) async throws {
- let failures = try await session.applyDiversion(desiredDiversion, rawXY: desiredRawXY, force: force)
+ let wanted = desiredDiversion
+ let rawXY = desiredRawXY
+ // An observer that diverts nothing (and never did through this session) writes nothing:
+ // forcing every control native would undo another process's diversion (Upright running
+ // next to upright-probe). The app never sets this, so it still clears a diversion left
+ // behind by a crashed earlier session.
+ if hub?.leavesReportingAlone == true, wanted.isEmpty, rawXY.isEmpty,
+ session.divertedControls.isEmpty, session.rawXYControls.isEmpty {
+ return
+ }
+ let failures = try await HIDPPClient.$lane.withValue(.interactive) {
+ try await session.applyDiversion(wanted, rawXY: rawXY, force: force)
+ }
log("Diverted controls: \(Self.describe(session.divertedControls))")
- if !desiredRawXY.isEmpty || !session.rawXYControls.isEmpty {
+ if !rawXY.isEmpty || !session.rawXYControls.isEmpty {
log("Raw XY controls: \(Self.describe(session.rawXYControls))")
}
let configurable = Set(session.snapshot?.configurableControls.map(\.cid) ?? [])
- let unsupported = desiredRawXY.intersection(configurable).subtracting(session.rawXYControls).subtracting(failures.keys)
+ let unsupported = rawXY.intersection(configurable).subtracting(session.rawXYControls).subtracting(failures.keys)
if !unsupported.isEmpty {
log("Controls without raw XY support (diverted only): \(Self.describe(unsupported))")
}
@@ -591,7 +691,10 @@ public final class MXVerticalController {
return
}
do {
- if let applied = try await session.setDPI(desired) {
+ let applied = try await HIDPPClient.$lane.withValue(.interactive) {
+ try await session.setDPI(desired)
+ }
+ if let applied {
log("Pointer speed: \(applied) DPI")
}
} catch let error as HIDPPError where error.meansDeviceUnreachable || error == .transportClosed {
@@ -601,7 +704,7 @@ public final class MXVerticalController {
}
}
- /// After a (re)connect: keeps a speed chosen with the mouse's top button, or re-applies
+ /// After a (re)connect: keeps a speed chosen with the mouse's own button, or re-applies
/// `desiredDPI` when the mouse forgot it.
///
/// A speed that is neither `desiredDPI`, the mouse's default, nor `lastKnown` (what this
@@ -625,7 +728,7 @@ public final class MXVerticalController {
if dpi.current == desired { return }
if dpi.current != dpi.defaultValue, dpi.current != lastKnown {
log("Keeping pointer speed \(dpi.current) DPI chosen on the mouse")
- desiredDPI = dpi.current
+ updateDesiredDPI(dpi.current, chosenByDevice: true)
return
}
try await applyDesiredDPI(to: session)
@@ -638,7 +741,7 @@ public final class MXVerticalController {
guard adopting, let desired = desiredDPI, desired == desiredBeforeRead,
let current = session.snapshot?.dpi?.current, current != desired else { return }
log("Pointer speed changed on the mouse to \(current) DPI")
- desiredDPI = current
+ updateDesiredDPI(current, chosenByDevice: true)
}
/// Runs a non-link operation against the connected session and publishes the result.
@@ -655,68 +758,23 @@ public final class MXVerticalController {
guard self.session === session else { return }
becameUnreachable(session, error)
} catch {
+ guard canWork else { return }
log("Could not \(action): \(error)")
}
}
- /// HID++ 1.0 register 0x00: enable wireless (0x000100) and software-present (0x000800) notifications.
- private func enableReceiverNotifications() async {
- let write = HIDPPMessage.receiverRegisterWrite(0x00, parameters: [0x00, 0x09, 0x00])
- do {
- _ = try await client.send(write, timeout: probeTimeout)
- notificationsEnabled = true
- } catch {
- log("Receiver notifications unavailable: \(error)")
- }
- }
-
- /// Unifying receivers list their pairings in register 0xB5, sub-registers 0x20...0x25:
- /// [sub-register, destination ID, report interval, wireless PID (big-endian), ...].
- /// Reading them doesn't wake any device. Returns false when no slot could be read
- /// (Bolt receivers use a different layout; the MX Vertical pairs over Unifying only).
- private func readPairedDevices() async -> Bool {
- guard case .receiver(.unifying) = kind else { return false }
- var listed = false
- for slot in UInt8(1)...6 {
- let subRegister = 0x20 + slot - 1
- do {
- let reply = try await client.send(.receiverLongRegisterRead(0xB5, parameters: [subRegister]), timeout: probeTimeout)
- guard reply.parameters[0] == subRegister else { continue }
- listed = true
- let pid = UInt16(reply.parameters[3]) << 8 | UInt16(reply.parameters[4])
- if pid != 0 { slotPIDs[slot] = pid }
- } catch HIDPPError.hidpp10(let code) where code == 0x01 || code == 0x02 {
- return false // Register not supported.
- } catch HIDPPError.hidpp10 {
- continue // Empty slot.
- } catch {
- return listed
- }
- }
- return listed
- }
-
- /// Register 0x02 = 0x02 makes the receiver send a 0x41 notification for every paired device.
- private func requestSlotAnnouncements() async -> Bool {
- heardAnnouncement = false
- do {
- _ = try await client.send(.receiverRegisterWrite(0x02, parameters: [0x02, 0x00, 0x00]), timeout: probeTimeout)
- } catch {
- return false
- }
- announcementsRequestedAt = Date()
- if announcementWait > 0 {
- try? await Task.sleep(nanoseconds: Self.nanoseconds(announcementWait))
- }
- return heardAnnouncement
- }
-
// MARK: Session lifecycle
private func adopt(_ new: DeviceSession) {
replaceSession(with: new)
- new.onButtonEvent = { [weak self] event in self?.onButtonEvent?(event) }
- new.onPointerDelta = { [weak self] dx, dy in self?.onPointerDelta?(dx, dy) }
+ new.onButtonEvent = { [weak self] event in
+ guard let self else { return }
+ self.hub?.endpoint(self, didEmit: event)
+ }
+ new.onSourcedPointerDelta = { [weak self] delta in
+ guard let self else { return }
+ self.hub?.endpoint(self, didMove: delta)
+ }
new.onReconfigurationNeeded = { [weak self] in
self?.scheduleReconfigure("Mouse reported a reconnect")
}
@@ -730,6 +788,7 @@ public final class MXVerticalController {
old.releaseAll()
old.onButtonEvent = nil
old.onPointerDelta = nil
+ old.onSourcedPointerDelta = nil
old.onReconfigurationNeeded = nil
old.onBatteryChange = nil
}
@@ -753,134 +812,43 @@ public final class MXVerticalController {
/// until it powers off), release anything held, and retry with backoff.
private func becameUnreachable(_ session: DeviceSession, _ error: Error) {
log("The mouse isn't answering (\(error)); will retry")
+ linkWasLost = true
session.releaseAll()
session.invalidateAppliedState()
if let snapshot = session.snapshot { transition(.asleep(snapshot)) }
scheduleRetry()
}
- private func transportClosed() {
- let wasShutDown = isShutDown
- stopAllWork()
- dropSession()
- if !wasShutDown { log("The HID connection closed") }
- state = .failed("Disconnected")
- }
-
// MARK: Events
- private func handle(_ message: HIDPPMessage) {
- guard !isShutDown else { return }
- if isReceiver {
- let slot = message.deviceIndex
- guard (1...6).contains(slot) else { return }
- switch message.subID {
- case 0x41:
- handleConnectionNotification(message)
- return
- case 0x40:
- handleDisconnectionNotification(message)
- return
- default:
- break
- }
- }
- if let session, session.handle(message) {
- if case .asleep = state { scheduleReconfigure("The mouse is sending reports again") }
- return
- }
- noteActivity(from: message)
- }
-
/// A report from the mouse while it isn't configured means it's awake: act on it now
/// instead of waiting for the next retry.
private func noteActivity(from message: HIDPPMessage) {
// Late replies and errors for our own requests carry our software ID or an error sub-ID.
guard message.softwareID == 0, message.subID != 0x8F, message.subID != 0xFF else { return }
+ guard message.deviceIndex == address.index else { return }
switch state {
case .waitingForDevice, .asleep, .failed: break
- case .probing, .connected: return
+ case .probing, .connected, .unsupported: return
}
guard !isWorkingOnLink else { return }
- if isReceiver {
- let slot = message.deviceIndex
- guard !otherDeviceSlots.contains(slot) else { return }
- if let session {
- guard session.device.index == slot else { return }
- } else {
- log("Slot \(slot) sent a report; checking for an MX Vertical there")
- scheduleConnect(slot)
- return
- }
- } else if message.deviceIndex != 0xFF {
+ if isReceiver, session == nil {
+ log("Slot \(address.index) sent a report; checking the device there")
+ scheduleConnect()
return
}
scheduleReconfigure("The mouse sent a report; reconfiguring")
}
- /// HID++ 1.0 device-connection notification: params[0] bit 6 set = link lost,
- /// params[1...2] = wireless PID (little-endian).
- private func handleConnectionNotification(_ message: HIDPPMessage) {
- let slot = message.deviceIndex
- let linkUp = message.parameters[0] & 0x40 == 0
- let wirelessPID = UInt16(message.parameters[2]) << 8 | UInt16(message.parameters[1])
- heardAnnouncement = true
- if wirelessPID != 0 {
- slotPIDs[slot] = wirelessPID
- if wirelessPID == MXVertical.wirelessProductID { otherDeviceSlots.remove(slot) }
- }
-
- if let session, session.device.index == slot {
- if linkUp {
- if isEchoOfAnnouncementRequest { return }
- session.invalidateAppliedState()
- scheduleReconfigure("Slot \(slot) link restored")
- } else {
- log("Slot \(slot) link lost")
- session.releaseAll()
- if let snapshot = session.snapshot { transition(.asleep(snapshot)) }
- }
- return
- }
-
- let mightBeMX = wirelessPID == MXVertical.wirelessProductID || (wirelessPID == 0 && !otherDeviceSlots.contains(slot))
- guard linkUp, mightBeMX else { return }
- log(String(format: "Slot %d connected (wireless PID %04X)", slot, wirelessPID))
- scheduleConnect(slot)
- }
-
- /// HID++ 1.0 device-disconnection notification; address bit 1 = un-paired.
- private func handleDisconnectionNotification(_ message: HIDPPMessage) {
- let slot = message.deviceIndex
- guard message.address & 0x02 != 0 else { return }
- slotPIDs[slot] = nil
- otherDeviceSlots.remove(slot)
- guard let session, session.device.index == slot else { return }
- log("The MX Vertical in slot \(slot) was unpaired")
- dropSession()
- transition(.waitingForDevice)
- }
-
- private func scheduleConnect(_ slot: UInt8) {
+ private func scheduleConnect() {
cancelRetry()
- enqueue(.connect(slot)) { await $0.connectAnnounced(slot) }
- }
-
- /// Receivers answer an announcement request with a 0x41 for every paired device, including
- /// the one we just configured. Those aren't real reconnects.
- private var isEchoOfAnnouncementRequest: Bool {
- guard case .connected = state, let requested = announcementsRequestedAt, let configuredAt else { return false }
- return configuredAt > requested && Date().timeIntervalSince(requested) < 2
+ enqueue(.connect) { await $0.connectAnnounced() }
}
// MARK: Helpers
private var canWork: Bool { !isShutDown && !client.isClosed }
- private var mxSlots: [UInt8] {
- slotPIDs.filter { $0.value == MXVertical.wirelessProductID }.keys.sorted()
- }
-
private var isReceiver: Bool {
if case .receiver = kind { return true }
return false
@@ -906,10 +874,10 @@ public final class MXVerticalController {
}
private func log(_ text: String) {
- onLog?(text)
+ hub?.endpoint(self, log: text)
}
- private static func nanoseconds(_ interval: TimeInterval) -> UInt64 {
+ static func nanoseconds(_ interval: TimeInterval) -> UInt64 {
UInt64(max(0, interval) * 1_000_000_000)
}
@@ -917,3 +885,210 @@ public final class MXVerticalController {
cids.isEmpty ? "none" : cids.sorted().map { String(format: "0x%04X", $0) }.joined(separator: ", ")
}
}
+
+// MARK: - Legacy facade
+
+/// The single-mouse controller of Upright 2.x, kept so `DeviceManager` and `upright-probe`
+/// build unchanged until they move to `HIDPPLink` (Wave 4). A facade over a private hub:
+///
+/// - Every mouse on the interface is served (there is no name gate any more); each gets the
+/// one desired diversion, raw XY set and pointer speed set here.
+/// - `state` and `snapshot` follow the primary endpoint (connected, else asleep, else probing,
+/// else waiting, lowest slot first). Unsupported devices are never primary; with no other
+/// endpoint the state is `.probing` during discovery and `.waitingForDevice` afterwards.
+/// - Events from every endpoint are delivered stamped `.unassigned`, as before.
+/// - `cycleDPI` and `switchToHost` act on the primary endpoint.
+///
+/// Its `State` has no `.unsupported` case, so code switching over it exhaustively keeps
+/// compiling; `EndpointController.State` is the multi-device state.
+@MainActor
+public final class MXVerticalController {
+ public enum State: Hashable, Sendable {
+ case probing
+ /// No mouse is answering yet (off, asleep, or paired elsewhere). Retried automatically.
+ case waitingForDevice
+ case connected(DeviceSnapshot)
+ /// The mouse stopped answering; it is reconfigured as soon as it answers again.
+ case asleep(DeviceSnapshot)
+ /// The device can't be used right now. `.failed("Disconnected")` (transport closed) is terminal.
+ case failed(String)
+ }
+
+ /// The hub behind the facade (link number 0).
+ public let link: HIDPPLink
+ public var client: HIDPPClient { link.client }
+ public var kind: TransportKind { link.kind }
+
+ public private(set) var state: State = .probing {
+ didSet {
+ guard state != oldValue else { return }
+ onStateChange?(state)
+ }
+ }
+ public private(set) var desiredDiversion: Set = []
+ public private(set) var desiredRawXY: Set = []
+ /// The pointer speed kept across reconnects; see `EndpointController.desiredDPI`.
+ public private(set) var desiredDPI: Int? {
+ didSet { if desiredDPI != oldValue { onDesiredDPIChange?(desiredDPI) } }
+ }
+ /// Set by `shutdown()` or when the transport closes. No further work runs afterwards.
+ public var isShutDown: Bool { link.isShutDown || link.isClosed }
+
+ public var onStateChange: ((State) -> Void)?
+ public var onButtonEvent: ((ButtonEvent) -> Void)?
+ /// Raw movement (dx, dy) while a raw-XY control is held.
+ public var onPointerDelta: ((Int, Int) -> Void)?
+ /// `desiredDPI` changed: set through the API, by `cycleDPI`, or adopted from the mouse.
+ public var onDesiredDPIChange: ((Int?) -> Void)?
+ public var onLog: ((String) -> Void)?
+
+ public var probeTimeout: TimeInterval {
+ get { link.tuning.probeTimeout }
+ set { link.tuning.probeTimeout = newValue }
+ }
+ public var reconnectDelay: TimeInterval {
+ get { link.tuning.reconnectDelay }
+ set { link.tuning.reconnectDelay = newValue }
+ }
+ public var retryBaseDelay: TimeInterval {
+ get { link.tuning.retryBaseDelay }
+ set { link.tuning.retryBaseDelay = newValue }
+ }
+ public var retryMaxDelay: TimeInterval {
+ get { link.tuning.retryMaxDelay }
+ set { link.tuning.retryMaxDelay = newValue }
+ }
+ public var batteryRefreshInterval: TimeInterval {
+ get { link.tuning.batteryRefreshInterval }
+ set { link.tuning.batteryRefreshInterval = newValue }
+ }
+ public var announcementWait: TimeInterval {
+ get { link.tuning.announcementWait }
+ set { link.tuning.announcementWait = newValue }
+ }
+
+ public init(transport: HIDPPTransport, kind: TransportKind) {
+ link = HIDPPLink(transport: transport, kind: kind, link: 0)
+ link.configurationProvider = { [weak self] _, _ in
+ guard let self else { return .native }
+ return EndpointConfiguration(divert: self.desiredDiversion, rawXY: self.desiredRawXY, dpi: self.desiredDPI)
+ }
+ link.onEndpointChange = { [weak self] _, _ in self?.recomputeState() }
+ link.onEndpointRemoved = { [weak self] _, _ in self?.recomputeState() }
+ link.onDiscoveryChange = { [weak self] in self?.recomputeState() }
+ link.onButtonEvent = { [weak self] event in
+ var event = event
+ event.source = .unassigned
+ self?.onButtonEvent?(event)
+ }
+ link.onPointerDelta = { [weak self] delta in self?.onPointerDelta?(delta.dx, delta.dy) }
+ link.onAnyDesiredDPIChange = { [weak self] _, dpi in self?.desiredDPI = dpi }
+ link.onLog = { [weak self] _, text in self?.onLog?(text) }
+ }
+
+ public var snapshot: DeviceSnapshot? { primary?.snapshot }
+
+ public func start() {
+ link.start()
+ recomputeState()
+ }
+
+ /// Re-applies configuration (after the Mac wakes, or on request).
+ public func refresh() {
+ link.refresh()
+ }
+
+ public func setDesiredDiversion(_ cids: Set) {
+ guard cids != desiredDiversion else { return }
+ desiredDiversion = cids
+ link.orderedEndpoints.forEach { $0.setDesiredDiversion(cids) }
+ }
+
+ /// Controls that should report raw movement (through `onPointerDelta`) while held; they
+ /// are diverted as well.
+ public func setDesiredRawXY(_ cids: Set) {
+ guard cids != desiredRawXY else { return }
+ desiredRawXY = cids
+ link.orderedEndpoints.forEach { $0.setDesiredRawXY(cids) }
+ }
+
+ public func setDPI(_ value: Int) {
+ setDesiredDPI(value)
+ }
+
+ public func setDesiredDPI(_ value: Int?) {
+ desiredDPI = value.map { snapshot?.dpi?.nearest(to: $0) ?? $0 }
+ link.orderedEndpoints.forEach { $0.setDesiredDPI(value) }
+ }
+
+ public func cycleDPI(presets: [Int] = []) {
+ primary?.cycleDPI(presets: presets)
+ }
+
+ public func refreshDPI() {
+ link.orderedEndpoints.forEach { $0.refreshDPI() }
+ }
+
+ public func refreshDeviceInfo() {
+ link.refreshDeviceInfo(at: nil)
+ }
+
+ public func switchToHost(_ index: Int) {
+ guard let primary else {
+ onLog?("No connected mouse to switch")
+ return
+ }
+ primary.switchToHost(index)
+ }
+
+ /// Hands every diverted button back to its mouse without waiting for replies. Terminal.
+ public func shutdown() {
+ link.shutdown()
+ recomputeState()
+ }
+
+ public func waitUntilIdle() async {
+ await link.waitUntilIdle()
+ }
+
+ // MARK: Primary endpoint
+
+ private var primary: EndpointController? {
+ var best: (endpoint: EndpointController, rank: Int)?
+ for endpoint in link.orderedEndpoints {
+ guard let rank = Self.rank(endpoint.state) else { continue }
+ if best == nil || rank > best!.rank { best = (endpoint, rank) }
+ }
+ return best?.endpoint
+ }
+
+ private static func rank(_ state: EndpointController.State) -> Int? {
+ switch state {
+ case .connected: return 5
+ case .asleep: return 4
+ case .probing: return 3
+ case .waitingForDevice: return 2
+ case .failed: return 1
+ case .unsupported: return nil
+ }
+ }
+
+ private func recomputeState() {
+ if link.isClosed {
+ state = .failed("Disconnected")
+ return
+ }
+ guard let primary else {
+ state = !link.isStarted || link.isDiscovering ? .probing : .waitingForDevice
+ return
+ }
+ switch primary.state {
+ case .probing: state = .probing
+ case .waitingForDevice: state = .waitingForDevice
+ case .connected(let snapshot): state = .connected(snapshot)
+ case .asleep(let snapshot): state = .asleep(snapshot)
+ case .failed(let message): state = .failed(message)
+ case .unsupported: state = .waitingForDevice
+ }
+ }
+}
diff --git a/UprightKit/Sources/UprightKit/Device/HIDPPLink.swift b/UprightKit/Sources/UprightKit/Device/HIDPPLink.swift
new file mode 100644
index 0000000..3f47aa5
--- /dev/null
+++ b/UprightKit/Sources/UprightKit/Device/HIDPPLink.swift
@@ -0,0 +1,582 @@
+import Foundation
+
+/// Timing and concurrency knobs shared by a hub and its endpoint controllers.
+public struct ControllerTuning: Hashable, Sendable {
+ /// How long to wait for a receiver slot to answer during discovery.
+ public var probeTimeout: TimeInterval = 1.0
+ /// Pause before reconfiguring a mouse that just woke up; wake-ups inside it are coalesced.
+ public var reconnectDelay: TimeInterval = 0.6
+ /// First retry delay while a mouse doesn't answer; doubles per attempt up to `retryMaxDelay`.
+ public var retryBaseDelay: TimeInterval = 1
+ public var retryMaxDelay: TimeInterval = 30
+ /// How often the battery level is re-read while connected (0 disables).
+ public var batteryRefreshInterval: TimeInterval = 600
+ /// How long to listen for slot announcements when a receiver can't list its pairings.
+ public var announcementWait: TimeInterval = 0.3
+ /// Requests in flight per transport (D10). 1 until hardware shows receivers tolerate more.
+ public var maxInFlight: Int = 1
+
+ public init() {}
+}
+
+/// One opened HID interface (a receiver or a directly connected mouse): owns the HID++
+/// client, the receiver's pairing directory, and one `EndpointController` per mouse, and
+/// routes messages to them by device index (D1).
+///
+/// A direct link has one endpoint at 0xFF. On a receiver the hub turns on notifications once
+/// (read-modify-write of register 0x00, D11), reads the pairing table and starts an endpoint
+/// for every occupied slot that isn't a keyboard or another non-pointer. When the table can't
+/// be read it asks for announcements (0x41), and pings slots 1...6 once when none arrive.
+/// Later 0x41 notifications start, wake, or replace endpoints; 0x40 un-pairing removes one;
+/// a report from an unknown slot starts one (a missed link-up heals itself).
+///
+/// Each endpoint keeps its own retry, backoff and reconfiguration state, so a sleeping mouse
+/// never holds up another one beyond the client's request gate (D10). Unsupported devices end
+/// in `.unsupported`, stay listed, and are never written to. When an endpoint identifies a
+/// unit that another slot of this receiver still holds (a re-pair), the old endpoint is
+/// retired (D12).
+@MainActor
+public final class HIDPPLink {
+ /// Why an endpoint went away.
+ public enum RemovalReason: Hashable, Sendable {
+ /// The receiver reported the slot as un-paired.
+ case unpaired
+ /// The same unit identified on another slot of this receiver (a re-pair).
+ case retired(movedTo: DeviceAddress)
+ /// A different device (another wireless PID) now occupies the slot.
+ case replaced
+ /// The HID interface went away or the hub shut down.
+ case transportClosed
+ }
+
+ public let link: UInt32
+ public let kind: TransportKind
+ /// Changes apply to every endpoint, including those already running.
+ public var tuning: ControllerTuning {
+ didSet {
+ client.maxInFlight = tuning.maxInFlight
+ endpoints.values.forEach { $0.tuning = tuning }
+ }
+ }
+ public let client: HIDPPClient
+ /// The interface's product ID and name (direct links: the model fallback and the name shown
+ /// when the mouse reports none).
+ public let productID: UInt16?
+ public let productName: String?
+
+ /// Live endpoints by device index (1...6, or 0xFF on a direct link).
+ public private(set) var endpoints: [UInt8: EndpointController] = [:]
+ /// `endpoints` sorted by device index.
+ public var orderedEndpoints: [EndpointController] {
+ endpoints.keys.sorted().compactMap { endpoints[$0] }
+ }
+
+ /// For observers such as `upright-probe` without `--divert`: endpoints whose configuration
+ /// diverts nothing leave button reporting untouched instead of forcing every control
+ /// native. Upright leaves this false, so a mouse it serves natively still has a diversion
+ /// left by a crashed earlier session cleared.
+ public var leavesReportingAlone = false
+
+ /// Asked synchronously, right after an endpoint is identified and before anything is
+ /// diverted, for the configuration to apply. Nil means `.native`.
+ public var configurationProvider: (@MainActor (DeviceAddress, DeviceSnapshot) -> EndpointConfiguration)?
+ /// Every state change of every endpoint, starting with `.probing` when it is created.
+ public var onEndpointChange: ((DeviceAddress, EndpointController.State) -> Void)?
+ /// After the endpoint released its held buttons (and, when it may still be reachable,
+ /// handed its buttons back). The address is never reused.
+ public var onEndpointRemoved: ((DeviceAddress, RemovalReason) -> Void)?
+ public var onButtonEvent: ((ButtonEvent) -> Void)?
+ public var onPointerDelta: ((PointerDelta) -> Void)?
+ /// The pointer speed an endpoint keeps changed without being asked: adopted from a change
+ /// made on that mouse, or stepped by `cycleDPI`. Only that mouse; nothing is broadcast.
+ public var onDeviceDPIChange: ((DeviceAddress, Int?) -> Void)?
+ public var onLog: ((DeviceAddress?, String) -> Void)?
+
+ /// Set by `shutdown()`. No further work runs afterwards.
+ public private(set) var isShutDown = false
+ /// True while a receiver reads its pairing table, waits for announcements or pings slots.
+ public private(set) var isDiscovering = false {
+ didSet { if isDiscovering != oldValue { onDiscoveryChange?() } }
+ }
+ public private(set) var isStarted = false
+ /// The transport closed (or the hub shut down). Terminal.
+ public var isClosed: Bool { closed || client.isClosed }
+
+ /// For the legacy single-mouse facade.
+ var onDiscoveryChange: (() -> Void)?
+ /// Every change of an endpoint's kept pointer speed, whoever made it (legacy facade).
+ var onAnyDesiredDPIChange: ((DeviceAddress, Int?) -> Void)?
+
+ private var closed = false
+ private var notificationsEnabled = false
+ /// Wireless PIDs per slot, from the pairing table and 0x41 notifications.
+ private var slotPIDs: [UInt8: UInt16] = [:]
+ /// Slots the receiver lists as a keyboard or another non-pointer (slot → wireless PID).
+ /// No endpoint is started for them until the slot is un-paired or another device appears.
+ private var skippedSlots: [UInt8: UInt16] = [:]
+ private var heardAnnouncement = false
+ private var announcementsRequestedAt: Date?
+ private var discoveryTask: Task?
+ private var notificationsTask: Task?
+ private var rediscoveryTimer: Task?
+ private var rediscoveryAttempt = 0
+
+ public init(
+ transport: HIDPPTransport,
+ kind: TransportKind,
+ link: UInt32,
+ tuning: ControllerTuning = .init(),
+ productID: UInt16? = nil,
+ productName: String? = nil
+ ) {
+ self.kind = kind
+ self.link = link
+ self.tuning = tuning
+ self.productID = productID
+ self.productName = productName
+ client = HIDPPClient(transport: transport)
+ client.maxInFlight = tuning.maxInFlight
+ client.onUnsolicited = { [weak self] message in
+ MainActorDelivery.run { self?.route(message) }
+ }
+ client.onClose = { [weak self] in
+ MainActorDelivery.run { self?.transportClosed() }
+ }
+ }
+
+ public func address(of index: UInt8) -> DeviceAddress {
+ DeviceAddress(link: link, index: index)
+ }
+
+ // MARK: Public API
+
+ public func start() {
+ guard !isStarted, canWork else { return }
+ isStarted = true
+ switch kind {
+ case .direct:
+ spawn(DeviceAddress.direct, transportPID: productID, kindHint: nil).start()
+ case .receiver(let connection):
+ log(nil, "Probing the \(connection.title)")
+ startDiscovery(initial: true)
+ }
+ }
+
+ /// Re-applies configuration: one slot, or every endpoint (after the Mac wakes; a receiver
+ /// also re-asserts its notification flags, once).
+ public func refresh(index: UInt8? = nil) {
+ guard canWork, isStarted else { return }
+ if let index {
+ endpoints[index]?.refresh()
+ return
+ }
+ if isReceiver {
+ Task { await self.ensureNotifications() }
+ if !hasPointerEndpoint { startDiscovery(initial: false) }
+ }
+ orderedEndpoints.forEach { $0.refresh() }
+ }
+
+ /// Hands every endpoint's buttons back to its mouse, then closes the client. Terminal.
+ public func shutdown() {
+ guard !isShutDown else { return }
+ isShutDown = true
+ rediscoveryTimer?.cancel()
+ rediscoveryTimer = nil
+ discoveryTask?.cancel()
+ orderedEndpoints.forEach { $0.shutdown() }
+ client.close()
+ transportClosed()
+ }
+
+ public func setConfiguration(_ c: EndpointConfiguration, at index: UInt8) {
+ endpoints[index]?.apply(c)
+ }
+
+ public func cycleDPI(at index: UInt8, presets: [Int]) {
+ guard let endpoint = endpoints[index] else {
+ log(nil, "Cycle Pointer Speed: no mouse at \(address(of: index))")
+ return
+ }
+ endpoint.cycleDPI(presets: presets)
+ }
+
+ /// Sets (or with nil, stops keeping) the pointer speed of the mouse at `index`.
+ public func setDPI(_ dpi: Int?, at index: UInt8) {
+ endpoints[index]?.setDPIFromApp(dpi)
+ }
+
+ /// Easy-Switch the mouse at `index` to a zero-based channel.
+ public func switchToHost(_ host: Int, at index: UInt8) {
+ guard let endpoint = endpoints[index] else {
+ log(nil, "Easy-Switch: no mouse at \(address(of: index))")
+ return
+ }
+ endpoint.switchToHost(host)
+ }
+
+ /// Re-reads battery, pointer speed and Easy-Switch channel: one slot, or every endpoint.
+ public func refreshDeviceInfo(at index: UInt8?) {
+ if let index {
+ endpoints[index]?.refreshDeviceInfo()
+ } else {
+ orderedEndpoints.forEach { $0.refreshDeviceInfo() }
+ }
+ }
+
+ /// Identifies one endpoint again, or every endpoint on this link (a receiver also re-reads
+ /// its pairing table). Unsupported endpoints stay as they are.
+ public func reconnect(index: UInt8?) {
+ guard canWork, isStarted else { return }
+ if let index {
+ endpoints[index]?.reconnect()
+ return
+ }
+ orderedEndpoints.forEach { $0.reconnect() }
+ if isReceiver { startDiscovery(initial: false) }
+ }
+
+ /// Returns once discovery has finished and no endpoint has queued work (tests).
+ public func waitUntilIdle() async {
+ while true {
+ if let task = discoveryTask {
+ await task.value
+ continue
+ }
+ if let task = notificationsTask {
+ await task.value
+ continue
+ }
+ if let busy = orderedEndpoints.first(where: \.isBusy) {
+ await busy.waitUntilIdle()
+ continue
+ }
+ return
+ }
+ }
+
+ // MARK: Receiver discovery
+
+ private var layout: PairingLayout {
+ if case .receiver(.bolt) = kind { return .bolt }
+ return .unifying
+ }
+
+ private var isReceiver: Bool {
+ if case .receiver = kind { return true }
+ return false
+ }
+
+ private var hasPointerEndpoint: Bool {
+ endpoints.values.contains { !$0.isUnsupported }
+ }
+
+ private func startDiscovery(initial: Bool) {
+ guard canWork, discoveryTask == nil else { return }
+ rediscoveryTimer?.cancel()
+ rediscoveryTimer = nil
+ isDiscovering = true
+ discoveryTask = Task {
+ await self.discover(initial: initial)
+ self.discoveryTask = nil
+ self.isDiscovering = false
+ }
+ }
+
+ private func discover(initial: Bool) async {
+ await ensureNotifications()
+ guard canWork else { return }
+
+ let records: [UInt8: PairingRecord]?
+ do {
+ records = try await ReceiverDirectory.readPairings(using: client, layout: layout, timeout: tuning.probeTimeout)
+ } catch {
+ return
+ }
+ guard canWork else { return }
+
+ if let records {
+ for slot in ReceiverDirectory.slots {
+ guard let record = records[slot] else { continue }
+ slotPIDs[slot] = record.wirelessPID
+ startPaired(record)
+ }
+ } else {
+ heardAnnouncement = false
+ announcementsRequestedAt = Date()
+ do {
+ try await ReceiverDirectory.requestAnnouncements(using: client, timeout: tuning.probeTimeout)
+ } catch HIDPPError.transportClosed {
+ return
+ } catch {
+ log(nil, "The receiver can't announce its devices: \(error)")
+ }
+ if tuning.announcementWait > 0 {
+ try? await Task.sleep(nanoseconds: EndpointController.nanoseconds(tuning.announcementWait))
+ }
+ guard canWork else { return }
+ if heardAnnouncement {
+ log(nil, "Receiver announced its paired devices")
+ } else {
+ await pingSlots()
+ }
+ }
+ guard canWork else { return }
+ if hasPointerEndpoint {
+ rediscoveryAttempt = 0
+ } else {
+ if initial { log(nil, "No mouse is answering on the receiver yet") }
+ // Without notifications nothing announces a mouse that wakes later: poll.
+ if !notificationsEnabled { scheduleRediscovery() }
+ }
+ }
+
+ private func startPaired(_ record: PairingRecord) {
+ let slot = record.slot
+ guard endpoints[slot] == nil else { return }
+ if record.kind.isDefinitelyNotPointer {
+ if skippedSlots[slot] != record.wirelessPID {
+ log(address(of: slot), String(format: "Slot %d: %@ (wireless PID %04X), not a mouse; leaving it alone", Int(slot), record.kind.name, record.wirelessPID))
+ }
+ skippedSlots[slot] = record.wirelessPID
+ return
+ }
+ skippedSlots[slot] = nil
+ let endpoint = spawn(slot, transportPID: record.wirelessPID, kindHint: Self.hint(record.kind))
+ endpoint.retriesWhileWaiting = true
+ endpoint.start()
+ }
+
+ /// Last resort when the receiver neither lists nor announces its devices.
+ private func pingSlots() async {
+ for slot in ReceiverDirectory.slots where endpoints[slot] == nil && skippedSlots[slot] == nil {
+ guard canWork else { return }
+ do {
+ _ = try await HIDPPDevice(client: client, index: slot).protocolVersion(timeout: tuning.probeTimeout)
+ } catch HIDPPError.transportClosed {
+ return
+ } catch {
+ continue
+ }
+ guard canWork, endpoints[slot] == nil else { continue }
+ let endpoint = spawn(slot, transportPID: slotPIDs[slot], kindHint: nil)
+ endpoint.retriesWhileWaiting = true
+ endpoint.start()
+ }
+ }
+
+ private func scheduleRediscovery() {
+ guard canWork, rediscoveryTimer == nil else { return }
+ let delay = min(tuning.retryBaseDelay * pow(2, Double(min(rediscoveryAttempt, 16))), tuning.retryMaxDelay)
+ rediscoveryAttempt += 1
+ rediscoveryTimer = Task { [weak self] in
+ try? await Task.sleep(nanoseconds: EndpointController.nanoseconds(delay))
+ guard let self, !Task.isCancelled else { return }
+ self.rediscoveryTimer = nil
+ self.startDiscovery(initial: false)
+ }
+ }
+
+ /// Read-modify-write of the receiver's notification flags (wireless and software-present
+ /// on, every other flag kept). Concurrent callers share one pass.
+ private func ensureNotifications() async {
+ guard isReceiver, canWork else { return }
+ if let task = notificationsTask {
+ await task.value
+ return
+ }
+ let task = Task {
+ await self.writeNotificationFlags()
+ self.notificationsTask = nil
+ }
+ notificationsTask = task
+ await task.value
+ }
+
+ private func writeNotificationFlags() async {
+ guard canWork else { return }
+ do {
+ try await ReceiverDirectory.enableNotifications(using: client, timeout: tuning.probeTimeout)
+ notificationsEnabled = true
+ } catch HIDPPError.transportClosed {
+ return
+ } catch {
+ log(nil, "Receiver notifications unavailable: \(error)")
+ }
+ }
+
+ // MARK: Routing
+
+ private func route(_ message: HIDPPMessage) {
+ guard canWork else { return }
+ guard isReceiver else {
+ endpoints[DeviceAddress.direct]?.receive(message)
+ return
+ }
+ if let slot = ReceiverDirectory.isUnpair(message) {
+ handleUnpair(slot)
+ return
+ }
+ // Other disconnection notifications carry nothing to act on.
+ if message.subID == 0x40 { return }
+ if let connection = ReceiverDirectory.parseConnection(message) {
+ handleConnection(slot: connection.slot, wirelessPID: connection.wirelessPID, kind: connection.kind, linkUp: connection.linkUp)
+ return
+ }
+ let slot = message.deviceIndex
+ guard ReceiverDirectory.slots.contains(slot) else { return }
+ if let endpoint = endpoints[slot] {
+ endpoint.receive(message)
+ return
+ }
+ // A device report (not a late reply, error or receiver notification) from a slot
+ // nobody serves: the link-up was missed. Check what is there.
+ guard isStarted, message.softwareID == 0, message.subID < 0x40, skippedSlots[slot] == nil else { return }
+ log(address(of: slot), "Slot \(slot) sent a report; checking the device there")
+ spawn(slot, transportPID: slotPIDs[slot], kindHint: nil).startAnnounced()
+ }
+
+ /// 0x41: a slot's link went up or down, or the receiver answered an announcement request.
+ private func handleConnection(slot: UInt8, wirelessPID: UInt16, kind: DeviceKind, linkUp: Bool) {
+ heardAnnouncement = true
+ if wirelessPID != 0 {
+ slotPIDs[slot] = wirelessPID
+ if let skipped = skippedSlots[slot], skipped != wirelessPID { skippedSlots[slot] = nil }
+ }
+ if let endpoint = endpoints[slot] {
+ let known = endpoint.transportPID ?? endpoint.snapshot?.identity?.transportPID
+ // An unsupported endpoint whose wireless PID was never learned is checked again
+ // once (reads only): it may be the same device, or a mouse paired in its place.
+ let replaced = wirelessPID != 0 && (known.map { $0 != wirelessPID } ?? endpoint.isUnsupported)
+ guard replaced else {
+ endpoint.linkChanged(up: linkUp, announcementEcho: isAnnouncementEcho(for: endpoint))
+ return
+ }
+ log(endpoint.address, String(format: "Slot %d now holds another device (wireless PID %04X)", Int(slot), wirelessPID))
+ // No restore: the old device is gone, and the slot now addresses another one.
+ remove(endpoint, reason: .replaced, restore: false)
+ }
+ guard linkUp, skippedSlots[slot] == nil else { return }
+ if kind.isDefinitelyNotPointer {
+ log(address(of: slot), String(format: "Slot %d: %@ (wireless PID %04X), not a mouse; leaving it alone", Int(slot), kind.name, wirelessPID))
+ skippedSlots[slot] = wirelessPID
+ return
+ }
+ log(address(of: slot), String(format: "Slot %d connected (wireless PID %04X)", Int(slot), wirelessPID))
+ let endpoint = spawn(slot, transportPID: wirelessPID == 0 ? nil : wirelessPID, kindHint: Self.hint(kind))
+ endpoint.retriesWhileWaiting = true
+ endpoint.startAnnounced()
+ }
+
+ private func handleUnpair(_ slot: UInt8) {
+ slotPIDs[slot] = nil
+ skippedSlots[slot] = nil
+ guard let endpoint = endpoints[slot] else { return }
+ log(endpoint.address, "The device in slot \(slot) was unpaired")
+ remove(endpoint, reason: .unpaired, restore: false)
+ }
+
+ /// Receivers answer an announcement request with a 0x41 for every paired device, including
+ /// the ones already configured. Those aren't real reconnects (per slot).
+ private func isAnnouncementEcho(for endpoint: EndpointController) -> Bool {
+ guard case .connected = endpoint.state,
+ let requested = announcementsRequestedAt,
+ let configured = endpoint.configuredAt
+ else { return false }
+ return configured > requested && Date().timeIntervalSince(requested) < 2
+ }
+
+ // MARK: Endpoints
+
+ @discardableResult
+ private func spawn(_ index: UInt8, transportPID: UInt16?, kindHint: DeviceKind?) -> EndpointController {
+ let endpoint = EndpointController(
+ client: client,
+ kind: kind,
+ address: address(of: index),
+ hub: self,
+ tuning: tuning,
+ transportPID: transportPID,
+ kindHint: kindHint,
+ fallbackName: isReceiver ? nil : productName
+ )
+ endpoints[index] = endpoint
+ onEndpointChange?(endpoint.address, endpoint.state)
+ return endpoint
+ }
+
+ /// Releases the endpoint's held buttons, optionally hands its buttons back, and forgets it.
+ private func remove(_ endpoint: EndpointController, reason: RemovalReason, restore: Bool) {
+ let index = endpoint.address.index
+ guard endpoints[index] === endpoint else { return }
+ endpoint.retire(sendingRestore: restore)
+ endpoints[index] = nil
+ onEndpointRemoved?(endpoint.address, reason)
+ }
+
+ private func transportClosed() {
+ guard !closed else { return }
+ closed = true
+ rediscoveryTimer?.cancel()
+ rediscoveryTimer = nil
+ discoveryTask?.cancel()
+ if !isShutDown { log(nil, "The HID connection closed") }
+ let all = orderedEndpoints
+ all.forEach { $0.transportDidClose() }
+ endpoints.removeAll()
+ all.forEach { onEndpointRemoved?($0.address, .transportClosed) }
+ isDiscovering = false
+ onDiscoveryChange?()
+ }
+
+ // MARK: Helpers
+
+ private var canWork: Bool { !isShutDown && !closed && !client.isClosed }
+
+ private static func hint(_ kind: DeviceKind) -> DeviceKind? {
+ kind == .unknown ? nil : kind
+ }
+
+ private func log(_ address: DeviceAddress?, _ text: String) {
+ onLog?(address, text)
+ }
+}
+
+extension HIDPPLink: EndpointHub {
+ func endpoint(_ endpoint: EndpointController, didChangeState state: EndpointController.State) {
+ guard endpoints[endpoint.address.index] === endpoint else { return }
+ onEndpointChange?(endpoint.address, state)
+ }
+
+ func endpointDidIdentify(_ endpoint: EndpointController, snapshot: DeviceSnapshot) -> EndpointConfiguration {
+ // A re-pair: the same unit is still listed on another slot of this receiver.
+ if let id = endpoint.lastDeviceID, id.isStable {
+ for other in orderedEndpoints where other !== endpoint && other.lastDeviceID == id {
+ log(endpoint.address, "\(snapshot.name) moved from slot \(other.address.index) to slot \(endpoint.address.index)")
+ remove(other, reason: .retired(movedTo: endpoint.address), restore: true)
+ }
+ }
+ return configurationProvider?(endpoint.address, snapshot) ?? .native
+ }
+
+ func endpoint(_ endpoint: EndpointController, didEmit event: ButtonEvent) {
+ onButtonEvent?(event)
+ }
+
+ func endpoint(_ endpoint: EndpointController, didMove delta: PointerDelta) {
+ onPointerDelta?(delta)
+ }
+
+ func endpoint(_ endpoint: EndpointController, didChangeDesiredDPI dpi: Int?, chosenByDevice: Bool) {
+ if chosenByDevice { onDeviceDPIChange?(endpoint.address, dpi) }
+ onAnyDesiredDPIChange?(endpoint.address, dpi)
+ }
+
+ func endpointNeedsReceiverNotifications(_ endpoint: EndpointController) async {
+ await ensureNotifications()
+ }
+
+ func endpoint(_ endpoint: EndpointController, log text: String) {
+ log(endpoint.address, text)
+ }
+}
diff --git a/UprightKit/Sources/UprightKit/Device/InterfaceClassifier.swift b/UprightKit/Sources/UprightKit/Device/InterfaceClassifier.swift
new file mode 100644
index 0000000..847087f
--- /dev/null
+++ b/UprightKit/Sources/UprightKit/Device/InterfaceClassifier.swift
@@ -0,0 +1,286 @@
+import Foundation
+
+// Decides which matched HID interfaces Upright opens (design D9 and the twin rules).
+// Foundation-only and Linux-safe; the IOKit side (Platform/IOKitHID.swift) only builds descriptors.
+
+/// The IOKit properties of one HID interface, as plain values (Linux-safe).
+public struct HIDInterfaceDescriptor: Hashable, Sendable {
+ public var vendorID: Int
+ public var productID: Int
+ /// The IOHID `Transport` string: "USB", "Bluetooth", "Bluetooth Low Energy", ...
+ /// Empty when unknown; the classifier then judges by product ID alone.
+ public var transport: String
+ public var usagePage: Int
+ public var usage: Int
+ public var locationID: Int
+ public var serial: String
+ /// IORegistryEntryGetRegistryEntryID of the device service; unique per interface.
+ public var registryID: UInt64
+ public var productName: String
+
+ public init(
+ vendorID: Int,
+ productID: Int,
+ transport: String = "",
+ usagePage: Int = 0,
+ usage: Int = 0,
+ locationID: Int = 0,
+ serial: String = "",
+ registryID: UInt64 = 0,
+ productName: String = ""
+ ) {
+ self.vendorID = vendorID
+ self.productID = productID
+ self.transport = transport
+ self.usagePage = usagePage
+ self.usage = usage
+ self.locationID = locationID
+ self.serial = serial
+ self.registryID = registryID
+ self.productName = productName
+ }
+
+ /// Receivers and the USB cable publish a dedicated vendor-page HID++ interface.
+ public var isVendorHIDPP: Bool {
+ usagePage == 0xFF00 || usagePage == 0xFF43
+ }
+
+ /// True for a descriptor built from a product ID alone (no usage information).
+ public var hasUnknownUsage: Bool {
+ usagePage == 0 && usage == 0
+ }
+
+ /// The serial number without surrounding whitespace; empty when the device has none.
+ public var normalizedSerial: String {
+ serial.trimmingCharacters(in: .whitespacesAndNewlines)
+ }
+
+ /// One line for logs: "MX Vertical [046D:B020] via Bluetooth Low Energy, usage 0001:0002".
+ public var summary: String {
+ let name = productName.isEmpty ? "Logitech device" : productName
+ let via = transport.isEmpty ? "" : " via " + transport
+ return "\(name) [\(InterfaceClassifier.hex(vendorID, width: 4)):\(InterfaceClassifier.hex(productID, width: 4))]\(via), usage \(InterfaceClassifier.hex(usagePage, width: 4)):\(InterfaceClassifier.hex(usage, width: 4))"
+ }
+}
+
+/// One entry of the matching dictionary the IOKit monitor registers. `usage == nil` matches
+/// every usage on the page.
+public struct HIDMatchCriterion: Hashable, Sendable {
+ public var vendorID: Int
+ public var usagePage: Int
+ public var usage: Int?
+
+ public init(vendorID: Int, usagePage: Int, usage: Int? = nil) {
+ self.vendorID = vendorID
+ self.usagePage = usagePage
+ self.usage = usage
+ }
+}
+
+/// What to do with a matched HID interface.
+public enum InterfaceClass: Hashable, Sendable {
+ /// A receiver: open it and serve the mice in its slots.
+ case receiver(ConnectionKind, PairingLayout)
+ /// A mouse connected by cable or Bluetooth (device index 0xFF).
+ case direct(ConnectionKind)
+ /// Never opened; the reason is logged once.
+ case ignored(String)
+
+ public var transportKind: TransportKind? {
+ switch self {
+ case .receiver(let kind, _): return .receiver(kind)
+ case .direct(let kind): return .direct(kind)
+ case .ignored: return nil
+ }
+ }
+
+ /// Why the interface is left alone; nil when it is opened.
+ public var ignoredReason: String? {
+ if case .ignored(let reason) = self { return reason }
+ return nil
+ }
+}
+
+public enum InterfaceClassifier {
+ // MARK: Matching
+
+ /// What the IOKit monitor asks macOS to match: the vendor pages that carry HID++ on
+ /// receivers, the USB cable and Bluetooth LE, plus every Logitech mouse (Bluetooth mice
+ /// carry HID++ inside their mouse interface). `classify` then decides which to open.
+ public static let matchCriteria: [HIDMatchCriterion] = [
+ HIDMatchCriterion(vendorID: DeviceCatalog.vendorID, usagePage: 0xFF00),
+ HIDMatchCriterion(vendorID: DeviceCatalog.vendorID, usagePage: 0xFF43),
+ HIDMatchCriterion(vendorID: DeviceCatalog.vendorID, usagePage: 0x01, usage: 0x02),
+ ]
+
+ // MARK: Product ID tables
+
+ /// Lightspeed receivers (G and PRO mice). Every Lightspeed mouse in the verified device
+ /// data lacks the programmable-button feature (0x1B04); G HUB owns these receivers, and
+ /// C53A also carries a PowerPlay chip. Never opened. Re-enabling one means removing it here
+ /// and adding it to `DeviceCatalog.receivers` with a pairing layout.
+ public static let lightspeedReceiverPIDs: Set = [
+ 0xC539, 0xC53A, 0xC53D, 0xC53F, 0xC541, 0xC545, 0xC547, 0xC54D,
+ ]
+
+ /// 27 MHz (pre-Unifying) receivers speak HID++ 1.0 only.
+ public static let legacyReceiverPIDs: Set = [0xC517]
+
+ public static let lightspeedReason = "Lightspeed receiver: not supported (no programmable-button feature on its mice)"
+ public static let legacyReceiverReason = "27 MHz receiver: not supported (HID++ 1.0 only)"
+
+ /// Receiverless HID++ 2.0 mice that Solaar recognizes without a table entry.
+ static let mouseUSBRange: ClosedRange = 0xC07D...0xC094
+ static let mouseBluetoothRange: ClosedRange = 0xB012...0xB0FF
+ /// Solaar treats these as receiverless HID++ 2.0 devices too, but they are keyboards.
+ /// Excluded so a keyboard is never opened (no Input Monitoring prompt, no clash with Options+).
+ static let keyboardUSBRange: ClosedRange = 0xC32B...0xC344
+ static let keyboardBluetoothRange: ClosedRange = 0xB317...0xB3FF
+ static let receiverRange: ClosedRange = 0xC500...0xC5FF
+
+ // MARK: Classification
+
+ public static func classify(_ d: HIDInterfaceDescriptor) -> InterfaceClass {
+ guard d.vendorID == DeviceCatalog.vendorID else { return .ignored("not a Logitech device") }
+ guard let pid = UInt16(exactly: d.productID) else { return .ignored("invalid product ID") }
+
+ // These win over the catalog: whatever the catalog says, they are never opened.
+ if lightspeedReceiverPIDs.contains(pid) { return .ignored(lightspeedReason) }
+ if legacyReceiverPIDs.contains(pid) { return .ignored(legacyReceiverReason) }
+
+ if let receiver = DeviceCatalog.receivers[pid] { return classifyReceiver(d, receiver) }
+ if receiverRange.contains(pid) { return .ignored("unrecognized Logitech receiver") }
+ return classifyDirect(d, pid: pid)
+ }
+
+ private static func classifyReceiver(_ d: HIDInterfaceDescriptor, _ receiver: ReceiverModel) -> InterfaceClass {
+ guard let layout = receiver.layout else { return .ignored("receiver not supported") }
+ switch Bus(transport: d.transport) {
+ case .usb, .unknown: break
+ case .bluetooth, .other: return .ignored("receiver on an unexpected transport")
+ }
+ // A receiver also publishes keyboard and mouse interfaces. Only the vendor-page one
+ // carries HID++, and the generic mouse matching would otherwise pick up the mouse one.
+ guard d.isVendorHIDPP || d.hasUnknownUsage else { return .ignored("receiver interface without HID++") }
+ return .receiver(receiver.kind, layout)
+ }
+
+ private static func classifyDirect(_ d: HIDInterfaceDescriptor, pid: UInt16) -> InterfaceClass {
+ let model = DeviceCatalog.model(forPID: pid)
+
+ // The IOHID Transport string says how the device is attached. Without it, fall back
+ // to where the catalog or the product ID ranges put the PID.
+ let connection: ConnectionKind
+ switch Bus(transport: d.transport) {
+ case .usb:
+ connection = .usb
+ case .bluetooth:
+ connection = .bluetooth
+ case .other:
+ return .ignored("unsupported transport \"\(d.transport)\"")
+ case .unknown:
+ if let model, model.usbPIDs.contains(pid) {
+ connection = .usb
+ } else if let model, isBluetoothPID(pid, of: model) {
+ connection = .bluetooth
+ } else if mouseUSBRange.contains(pid) || keyboardUSBRange.contains(pid) {
+ connection = .usb
+ } else if mouseBluetoothRange.contains(pid) || keyboardBluetoothRange.contains(pid) {
+ connection = .bluetooth
+ } else {
+ return .ignored("not a supported mouse")
+ }
+ }
+
+ // A catalog entry is verified mouse data, so it wins over the keyboard ranges.
+ if let model {
+ switch connection {
+ case .usb where model.usbPIDs.contains(pid): return .direct(.usb)
+ case .bluetooth where isBluetoothPID(pid, of: model): return .direct(.bluetooth)
+ default: break
+ }
+ }
+
+ switch connection {
+ case .usb:
+ if keyboardUSBRange.contains(pid) { return .ignored("keyboard") }
+ if mouseUSBRange.contains(pid) { return .direct(.usb) }
+ case .bluetooth:
+ if keyboardBluetoothRange.contains(pid) { return .ignored("keyboard") }
+ if mouseBluetoothRange.contains(pid) { return .direct(.bluetooth) }
+ case .unifying, .bolt, .nano:
+ break
+ }
+ return .ignored("not a supported mouse")
+ }
+
+ /// A Bolt receiver reports a mouse by its Bluetooth PID, so a Bxxx wireless PID counts too.
+ private static func isBluetoothPID(_ pid: UInt16, of model: DeviceModel) -> Bool {
+ model.bluetoothPIDs.contains(pid) || (model.wirelessPIDs.contains(pid) && pid & 0xF000 == 0xB000)
+ }
+
+ /// How an IOHID `Transport` string reads.
+ private enum Bus {
+ case usb, bluetooth, unknown, other
+
+ init(transport: String) {
+ let text = transport.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
+ if text.isEmpty {
+ self = .unknown
+ } else if text.contains("usb") {
+ self = .usb
+ } else if text.contains("bluetooth") {
+ self = .bluetooth
+ } else {
+ self = .other
+ }
+ }
+ }
+
+ // MARK: Identity and twins
+
+ /// Identity used to dedupe twin interfaces at match time:
+ /// vendor:product:page:usage plus the serial, else the location ID, else the registry ID.
+ /// The registry ID is unique per interface, so two Bluetooth mice that report neither a
+ /// serial nor a location never collapse into one.
+ public static func identityKey(_ d: HIDInterfaceDescriptor) -> String {
+ let base = [d.vendorID, d.productID, d.usagePage, d.usage].map { hex($0, width: 4) }.joined(separator: ":")
+ let serial = d.normalizedSerial
+ if !serial.isEmpty { return base + ":s:" + serial }
+ if d.locationID != 0 { return base + ":l:" + String(d.locationID, radix: 16) }
+ return base + ":r:" + String(d.registryID, radix: 16)
+ }
+
+ /// Bluetooth publishes the mouse and its HID++ collection as one interface. USB and
+ /// receivers also publish a dedicated vendor-page interface, which is the one to use when
+ /// both show up for the same device. Returns nil when `d` should not be opened, otherwise
+ /// the keys of open twins it supersedes (to be closed first).
+ ///
+ /// Twins share vendor, product and a nonzero location ID, and their serial numbers don't
+ /// contradict each other. An interface without a location is never a twin: it is opened.
+ public static func twinsToReplace(adding d: HIDInterfaceDescriptor, open: [K: HIDInterfaceDescriptor]) -> [K]? {
+ guard d.locationID != 0 else { return [] }
+ let twins = open.filter { isSamePhysicalDevice($0.value, d) }
+ if d.isVendorHIDPP {
+ if twins.values.contains(where: \.isVendorHIDPP) { return nil }
+ return twins.filter { !$0.value.isVendorHIDPP }.map(\.key)
+ }
+ return twins.isEmpty ? [] : nil
+ }
+
+ public static func isSamePhysicalDevice(_ a: HIDInterfaceDescriptor, _ b: HIDInterfaceDescriptor) -> Bool {
+ guard a.locationID != 0, a.vendorID == b.vendorID, a.productID == b.productID, a.locationID == b.locationID else { return false }
+ // Interfaces of one device report one serial; a missing one on either side is no contradiction.
+ let sa = a.normalizedSerial
+ let sb = b.normalizedSerial
+ return sa.isEmpty || sb.isEmpty || sa == sb
+ }
+
+ // MARK: Formatting
+
+ /// Upper-case hex, zero-padded to `width` (no `String(format:)`, so it is exact on Linux too).
+ static func hex(_ value: Int, width: Int) -> String {
+ let digits = String(UInt(clamping: value), radix: 16, uppercase: true)
+ return String(repeating: "0", count: max(0, width - digits.count)) + digits
+ }
+}
diff --git a/UprightKit/Sources/UprightKit/Device/MXVertical.swift b/UprightKit/Sources/UprightKit/Device/MXVertical.swift
index b1b8f6f..431301f 100644
--- a/UprightKit/Sources/UprightKit/Device/MXVertical.swift
+++ b/UprightKit/Sources/UprightKit/Device/MXVertical.swift
@@ -6,6 +6,7 @@ public enum ConnectionKind: String, Hashable, Sendable {
case bluetooth
case unifying
case bolt
+ case nano
public var title: String {
switch self {
@@ -13,6 +14,7 @@ public enum ConnectionKind: String, Hashable, Sendable {
case .bluetooth: return NSLocalizedString("Bluetooth", comment: "How the mouse connects to the Mac")
case .unifying: return NSLocalizedString("Unifying receiver", comment: "How the mouse connects to the Mac")
case .bolt: return NSLocalizedString("Bolt receiver", comment: "How the mouse connects to the Mac")
+ case .nano: return NSLocalizedString("Nano receiver", comment: "How the mouse connects to the Mac")
}
}
@@ -20,7 +22,7 @@ public enum ConnectionKind: String, Hashable, Sendable {
switch self {
case .usb: return "cable.connector"
case .bluetooth: return "dot.radiowaves.left.and.right"
- case .unifying, .bolt: return "antenna.radiowaves.left.and.right"
+ case .unifying, .bolt, .nano: return "antenna.radiowaves.left.and.right"
}
}
}
@@ -39,92 +41,8 @@ public enum TransportKind: Hashable, Sendable {
}
}
-/// Identification data for the Logitech MX Vertical. Product IDs match those published by
-/// Solaar and the Linux hid-logitech-dj driver.
+/// Display name of the original model. Device identification lives in `DeviceCatalog` and
+/// `InterfaceClassifier`.
public enum MXVertical {
- public static let vendorID = 0x046D
- public static let usbProductID = 0xC08A
- public static let bluetoothProductID = 0xB020
- /// The "wireless PID" a receiver reports for an MX Vertical in one of its slots.
- public static let wirelessProductID: UInt16 = 0x407B
-
- /// The MX Vertical pairs over Unifying or Bluetooth only. Bolt receivers are still watched
- /// (harmlessly) so a future Bolt-capable model, or a mislabeled one, is picked up.
- public static let receiverProductIDs: [Int: ConnectionKind] = [
- 0xC52B: .unifying,
- 0xC532: .unifying,
- 0xC548: .bolt,
- ]
-
- /// Classifies a Logitech HID interface by vendor and product ID.
- public static func classify(vendorID: Int, productID: Int) -> TransportKind? {
- guard vendorID == Self.vendorID else { return nil }
- if let receiver = receiverProductIDs[productID] { return .receiver(receiver) }
- switch productID {
- case usbProductID: return .direct(.usb)
- case bluetoothProductID: return .direct(.bluetooth)
- default: return nil
- }
- }
-
- public static func matches(name: String) -> Bool {
- name.lowercased().contains("mx vertical")
- }
-
public static let displayName = "MX Vertical"
}
-
-/// Control IDs (CIDs) used by REPROG_CONTROLS_V4, as listed in Solaar's special_keys table
-/// and LogiOps' MX Vertical example configuration.
-public enum KnownControl {
- public static let leftClick: UInt16 = 0x0050
- public static let rightClick: UInt16 = 0x0051
- public static let middleClick: UInt16 = 0x0052
- public static let back: UInt16 = 0x0053
- public static let forward: UInt16 = 0x0056
- public static let dpiChange: UInt16 = 0x00ED
- public static let dpiSwitch: UInt16 = 0x00FD
-
- public static let primaryClicks: Set = [leftClick, rightClick]
-
- /// The MX Vertical's remappable buttons, shown before a mouse has been detected.
- public static let mxVerticalDefaults: [UInt16] = [back, forward, dpiSwitch, middleClick]
-}
-
-/// User-facing description of a control.
-public struct ControlDescriptor: Hashable, Sendable, Identifiable {
- public var cid: UInt16
- public var name: String
- public var detail: String
- public var symbolName: String
-
- public var id: UInt16 { cid }
-
- public init(cid: UInt16) {
- self.cid = cid
- switch cid {
- case KnownControl.back:
- name = NSLocalizedString("Back Button", comment: "Mouse control name")
- detail = NSLocalizedString("Lower thumb button", comment: "Mouse control description")
- symbolName = "chevron.backward.circle"
- case KnownControl.forward:
- name = NSLocalizedString("Forward Button", comment: "Mouse control name")
- detail = NSLocalizedString("Upper thumb button", comment: "Mouse control description")
- symbolName = "chevron.forward.circle"
- case KnownControl.dpiSwitch, KnownControl.dpiChange:
- name = NSLocalizedString("Top Button", comment: "Mouse control name")
- detail = NSLocalizedString("Pointer-speed (DPI) button behind the wheel", comment: "Mouse control description")
- symbolName = "speedometer"
- case KnownControl.middleClick:
- name = NSLocalizedString("Wheel Click", comment: "Mouse control name")
- detail = NSLocalizedString("Press the scroll wheel", comment: "Mouse control description")
- symbolName = "smallcircle.filled.circle"
- default:
- name = String(format: NSLocalizedString("Control 0x%04X", comment: "Mouse control name; %04X is its hexadecimal ID"), cid)
- detail = NSLocalizedString("Additional programmable control", comment: "Mouse control description")
- symbolName = "button.programmable"
- }
- }
-
- public var hexID: String { String(format: "0x%04X", cid) }
-}
diff --git a/UprightKit/Sources/UprightKit/Device/ReceiverDirectory.swift b/UprightKit/Sources/UprightKit/Device/ReceiverDirectory.swift
new file mode 100644
index 0000000..fd6e5aa
--- /dev/null
+++ b/UprightKit/Sources/UprightKit/Device/ReceiverDirectory.swift
@@ -0,0 +1,201 @@
+import Foundation
+
+/// Where a receiver lists its pairings (HID++ 1.0 long register 0xB5). Layouts follow Solaar
+/// (`receiver.py`); the Bolt one is simulator-tested only.
+public enum PairingLayout: Hashable, Sendable {
+ /// Sub-register 0x20 + (slot - 1): [sub, destination ID, interval, wpid hi, wpid lo, ?, ?, kind].
+ /// Extended pairing information (serial number in bytes 1...4) at 0x30 + (slot - 1).
+ /// Nano receivers use it too.
+ case unifying
+ /// Sub-register 0x50 + slot (not slot - 1): [sub, kind, wpid lo, wpid hi, serial (4), ...].
+ case bolt
+
+ public func pairingSubRegister(slot: UInt8) -> UInt8 {
+ switch self {
+ case .unifying: return 0x20 + slot - 1
+ case .bolt: return 0x50 + slot
+ }
+ }
+
+ /// The sub-register holding the slot's serial number. Bolt keeps it in the pairing record.
+ public func serialSubRegister(slot: UInt8) -> UInt8 {
+ switch self {
+ case .unifying: return 0x30 + slot - 1
+ case .bolt: return 0x50 + slot
+ }
+ }
+}
+
+/// One occupied receiver slot.
+public struct PairingRecord: Hashable, Sendable {
+ public var slot: UInt8
+ public var wirelessPID: UInt16
+ public var kind: DeviceKind
+
+ public init(slot: UInt8, wirelessPID: UInt16, kind: DeviceKind) {
+ self.slot = slot
+ self.wirelessPID = wirelessPID
+ self.kind = kind
+ }
+}
+
+/// Receiver registers and notifications used by `HIDPPLink`: value-level parsing, the request
+/// messages, and the receiver-wide reads and writes built on them.
+public enum ReceiverDirectory {
+ /// Receiver slots. Always all six, whatever the receiver says its capacity is.
+ public static let slots: ClosedRange = 1...6
+
+ /// Long register 0xB5 (RECEIVER_INFO): pairing information by sub-register.
+ public static let pairingRegister: UInt8 = 0xB5
+ /// Register 0x00: notification flags, 3 bytes big-endian.
+ public static let notificationFlagsRegister: UInt8 = 0x00
+ /// Register 0x02: writing 0x02 makes the receiver send a 0x41 for every paired device.
+ public static let connectionRegister: UInt8 = 0x02
+
+ // MARK: Parsing
+
+ /// Parses the parameters of a pairing-information read for `slot`. Nil for an empty slot,
+ /// a reply for another sub-register, or a short reply.
+ public static func parsePairing(_ reply: [UInt8], slot: UInt8, layout: PairingLayout) -> PairingRecord? {
+ guard slots.contains(slot), reply.count >= 8, reply[0] == layout.pairingSubRegister(slot: slot) else { return nil }
+ let pid: UInt16
+ let kind: DeviceKind
+ switch layout {
+ case .unifying:
+ pid = UInt16(reply[3]) << 8 | UInt16(reply[4])
+ kind = DeviceKind(hidpp10Nibble: reply[7])
+ case .bolt:
+ pid = UInt16(reply[3]) << 8 | UInt16(reply[2])
+ kind = DeviceKind(hidpp10Nibble: reply[1])
+ }
+ guard pid != 0 else { return nil }
+ return PairingRecord(slot: slot, wirelessPID: pid, kind: kind)
+ }
+
+ /// The serial number ("1A2B3C4D") in the parameters of a `serialSubRegister(slot:)` read:
+ /// bytes 1...4 on Unifying and Nano, bytes 4...7 on Bolt. Nil when absent or blank.
+ public static func parseSerial(_ reply: [UInt8], slot: UInt8, layout: PairingLayout) -> String? {
+ guard slots.contains(slot), reply.count >= 8, reply[0] == layout.serialSubRegister(slot: slot) else { return nil }
+ let bytes: ArraySlice
+ switch layout {
+ case .unifying: bytes = reply[1...4]
+ case .bolt: bytes = reply[4...7]
+ }
+ guard bytes.contains(where: { $0 != 0 }), bytes.contains(where: { $0 != 0xFF }) else { return nil }
+ return bytes.map { String(format: "%02X", $0) }.joined()
+ }
+
+ /// A 0x41 device-connection notification: params[0] bit 6 set = link lost, low nibble = kind;
+ /// params[1...2] = wireless PID (little-endian). Unifying, Nano and Bolt share the layout.
+ public static func parseConnection(_ m: HIDPPMessage) -> (slot: UInt8, wirelessPID: UInt16, kind: DeviceKind, linkUp: Bool)? {
+ guard m.subID == 0x41, slots.contains(m.deviceIndex) else { return nil }
+ let p = m.parameters
+ let pid = UInt16(p[2]) << 8 | UInt16(p[1])
+ return (m.deviceIndex, pid, DeviceKind(hidpp10Nibble: p[0]), p[0] & 0x40 == 0)
+ }
+
+ /// The slot of a 0x40 device-disconnection notification with the un-paired bit, else nil.
+ public static func isUnpair(_ m: HIDPPMessage) -> UInt8? {
+ guard m.subID == 0x40, slots.contains(m.deviceIndex), m.address & 0x02 != 0 else { return nil }
+ return m.deviceIndex
+ }
+
+ /// Register 0x00 (notification flags) read-modify-write: keeps every flag already set and
+ /// adds wireless (0x000100) and software-present (0x000800) notifications.
+ public static func mergedNotificationFlags(current: [UInt8]) -> [UInt8] {
+ var flags = Array((current + [0, 0, 0]).prefix(3))
+ flags[1] |= 0x09
+ return flags
+ }
+
+ // MARK: Messages (all addressed to the receiver, device index 0xFF)
+
+ public static func pairingReadMessage(slot: UInt8, layout: PairingLayout) -> HIDPPMessage {
+ .receiverLongRegisterRead(pairingRegister, parameters: [layout.pairingSubRegister(slot: slot)])
+ }
+
+ public static func serialReadMessage(slot: UInt8, layout: PairingLayout) -> HIDPPMessage {
+ .receiverLongRegisterRead(pairingRegister, parameters: [layout.serialSubRegister(slot: slot)])
+ }
+
+ /// Short register read (sub-ID 0x81) of the notification flags.
+ public static var notificationFlagsReadMessage: HIDPPMessage {
+ HIDPPMessage(reportID: .short, deviceIndex: 0xFF, subID: 0x81, address: notificationFlagsRegister)
+ }
+
+ public static func notificationFlagsWriteMessage(_ flags: [UInt8]) -> HIDPPMessage {
+ .receiverRegisterWrite(notificationFlagsRegister, parameters: flags)
+ }
+
+ public static var announcementRequestMessage: HIDPPMessage {
+ .receiverRegisterWrite(connectionRegister, parameters: [0x02, 0x00, 0x00])
+ }
+
+ // MARK: Receiver operations
+
+ /// Reads the pairing record of every slot. Reading doesn't wake any device.
+ ///
+ /// Returns the occupied slots, or nil when the table can't be read: the receiver rejects the
+ /// register (invalid sub-ID or address) or no slot gave a well-formed answer. Any other HID++
+ /// 1.0 error means an empty slot. Stops at the first timeout, keeping what was read.
+ /// Only a closed transport throws.
+ public static func readPairings(using client: HIDPPClient, layout: PairingLayout, timeout: TimeInterval? = nil) async throws -> [UInt8: PairingRecord]? {
+ var records: [UInt8: PairingRecord] = [:]
+ var listed = false
+ for slot in slots {
+ do {
+ let reply = try await client.send(pairingReadMessage(slot: slot, layout: layout), timeout: timeout)
+ guard reply.parameters[0] == layout.pairingSubRegister(slot: slot) else { continue }
+ listed = true
+ records[slot] = parsePairing(reply.parameters, slot: slot, layout: layout)
+ } catch HIDPPError.transportClosed {
+ throw HIDPPError.transportClosed
+ } catch HIDPPError.hidpp10(let code) where code == 0x01 || code == 0x02 {
+ return nil
+ } catch HIDPPError.hidpp10 {
+ continue
+ } catch {
+ break
+ }
+ }
+ return listed ? records : nil
+ }
+
+ /// The slot's serial number from the receiver, or nil when it doesn't keep one.
+ /// Only a closed transport throws.
+ public static func readSerial(using client: HIDPPClient, slot: UInt8, layout: PairingLayout, timeout: TimeInterval? = nil) async throws -> String? {
+ do {
+ let reply = try await client.send(serialReadMessage(slot: slot, layout: layout), timeout: timeout)
+ return parseSerial(reply.parameters, slot: slot, layout: layout)
+ } catch HIDPPError.transportClosed {
+ throw HIDPPError.transportClosed
+ } catch {
+ return nil
+ }
+ }
+
+ /// Turns on wireless and software-present notifications without clearing flags another
+ /// program set: reads register 0x00 and writes back `mergedNotificationFlags`. A receiver that
+ /// refuses the read gets the two flags alone. Returns the flags written; throws when the
+ /// write fails.
+ @discardableResult
+ public static func enableNotifications(using client: HIDPPClient, timeout: TimeInterval? = nil) async throws -> [UInt8] {
+ var current: [UInt8] = []
+ do {
+ let reply = try await client.send(notificationFlagsReadMessage, timeout: timeout)
+ current = Array(reply.parameters.prefix(3))
+ } catch HIDPPError.transportClosed {
+ throw HIDPPError.transportClosed
+ } catch {
+ current = []
+ }
+ let flags = mergedNotificationFlags(current: current)
+ _ = try await client.send(notificationFlagsWriteMessage(flags), timeout: timeout)
+ return flags
+ }
+
+ /// Asks the receiver to announce every paired device with a 0x41 notification.
+ public static func requestAnnouncements(using client: HIDPPClient, timeout: TimeInterval? = nil) async throws {
+ _ = try await client.send(announcementRequestMessage, timeout: timeout)
+ }
+}
diff --git a/UprightKit/Sources/UprightKit/HIDPP/DeviceFeatures.swift b/UprightKit/Sources/UprightKit/HIDPP/DeviceFeatures.swift
index a587ed4..7e4d310 100644
--- a/UprightKit/Sources/UprightKit/HIDPP/DeviceFeatures.swift
+++ b/UprightKit/Sources/UprightKit/HIDPP/DeviceFeatures.swift
@@ -180,6 +180,51 @@ public struct DeviceFeatures {
_ = try await device.call(.adjustableDPI, function: 3, parameters: [0] + clamped.bigEndianBytes)
}
+ // MARK: Identity (0x0005, 0x0003)
+
+ /// Who the device is: its kind (0x0005 getDeviceType, unless `kind` is given), unit and model
+ /// IDs (0x0003 getEntityCount) and, from 0x0003 version 4, its serial number. `transportPID` is
+ /// the receiver's wireless PID or the interface's product ID; it keys the model when 0x0003
+ /// lists no model IDs.
+ ///
+ /// A part the device lacks or refuses stays empty, so this never fails on a working device;
+ /// a device that stops answering (or a closed transport) still throws.
+ public func identity(name: String, kind: DeviceKind? = nil, transportPID: UInt16? = nil) async throws -> DeviceIdentity {
+ var resolvedKind = kind ?? .unknown
+ if kind == nil, try await has(.deviceName) {
+ resolvedKind = try await optional { try await deviceKind() } ?? .unknown
+ }
+ var ids: (unitID: UInt32?, modelIDs: ModelIDs, extendedModelID: UInt8?)?
+ var serial: String?
+ if try await has(.deviceFirmwareVersion) {
+ ids = try await optional { try await firmwareIDs() }
+ if let version = try await optional({ try await device.featureVersion(for: .deviceFirmwareVersion) }), version >= 4 {
+ serial = try await optional { try await serialNumber() }
+ }
+ }
+ return DeviceIdentity(
+ name: name,
+ kind: resolvedKind,
+ unitID: ids?.unitID,
+ serialNumber: serial,
+ modelIDs: ids?.modelIDs ?? ModelIDs(),
+ extendedModelID: ids?.extendedModelID,
+ transportPID: transportPID
+ )
+ }
+
+ /// getDeviceType (0x0005 fn 2).
+ public func deviceKind() async throws -> DeviceKind {
+ let reply = try await device.call(.deviceName, function: 2)
+ return DeviceKind(hidpp20: reply[0])
+ }
+
+ /// The unit ID, model IDs and extended model ID from getEntityCount (0x0003 fn 0).
+ /// Nil when the reply is too short to hold them.
+ public func firmwareIDs() async throws -> (unitID: UInt32?, modelIDs: ModelIDs, extendedModelID: UInt8?)? {
+ DeviceIdentity.parseFirmwareIDs(try await device.call(.deviceFirmwareVersion, function: 0))
+ }
+
// MARK: 0x0003
/// Every firmware entity (getEntityCount, then getFwInfo per entity).
@@ -235,6 +280,24 @@ public struct DeviceFeatures {
return names
}
+ // MARK: Helpers
+
+ private func has(_ feature: HIDPPFeature) async throws -> Bool {
+ try await optional { try await device.featureIndex(for: feature) } != nil
+ }
+
+ /// Runs an optional read: device errors and missing features give nil, but a device that
+ /// stopped answering (or a closed transport) still throws.
+ private func optional(_ body: () async throws -> T?) async throws -> T? {
+ do {
+ return try await body()
+ } catch let error as HIDPPError where error.meansDeviceUnreachable || error == .transportClosed {
+ throw error
+ } catch {
+ return nil
+ }
+ }
+
/// setCurrentHost (fn 1), sent without waiting: the device leaves right away and never answers.
public nonisolated static func changeHostMessage(deviceIndex: UInt8, featureIndex: UInt8, host: Int, softwareID: UInt8) -> HIDPPMessage {
.request(deviceIndex: deviceIndex, featureIndex: featureIndex, function: 1, softwareID: softwareID, parameters: [UInt8(clamping: host)])
diff --git a/UprightKit/Sources/UprightKit/HIDPP/HIDPPClient.swift b/UprightKit/Sources/UprightKit/HIDPP/HIDPPClient.swift
index ce825d7..e6f720e 100644
--- a/UprightKit/Sources/UprightKit/HIDPP/HIDPPClient.swift
+++ b/UprightKit/Sources/UprightKit/HIDPP/HIDPPClient.swift
@@ -5,9 +5,28 @@ import Foundation
/// Replies are matched to outstanding requests by device index, sub-ID and address (see
/// `HIDPPMessage.match(to:)`). Anything that doesn't answer a pending request — button
/// events, receiver notifications, battery broadcasts — goes to `onUnsolicited`.
+///
+/// Requests pass a gate (D10): at most `maxInFlight` wait for their reply at once, the rest
+/// queue in FIFO order. A bare client doesn't limit them; `HIDPPLink` sets the limit from
+/// `ControllerTuning.maxInFlight` (1). `Lane.interactive` requests (button diversion, pointer speed) go ahead
+/// of `Lane.background` ones (probing, feature tables, battery, device information), so a
+/// sleeping receiver slot delays another slot's diversion by at most one request timeout.
+/// Fire-and-forget reports (`sendWithoutReply`) bypass the gate.
public final class HIDPPClient: @unchecked Sendable {
public enum Direction: Sendable { case outgoing, incoming }
+ /// The gate's priority lanes.
+ public enum Lane: Hashable, Sendable {
+ /// Changes the user is waiting for: setCidReporting, pointer speed.
+ case interactive
+ /// Everything else: probing, feature tables, battery and device information.
+ case background
+ }
+
+ /// The lane for requests sent from the current task without an explicit lane.
+ /// Set it for a whole operation with `HIDPPClient.$lane.withValue(.interactive) { ... }`.
+ @TaskLocal public static var lane: Lane = .background
+
public let transport: HIDPPTransport
/// Identifies our requests so replies can be told apart from device events (which use 0).
public let softwareID: UInt8 = 0x0A
@@ -15,6 +34,12 @@ public final class HIDPPClient: @unchecked Sendable {
/// How often a request the device answered with "busy" is sent again.
public var busyRetryLimit = 2
public var busyRetryDelay: TimeInterval = 0.05
+ /// Requests waiting for their reply at once (at least 1). Unlimited by default; hubs use 1
+ /// until hardware shows that receivers accept concurrent requests to different slots.
+ public var maxInFlight: Int {
+ get { gate.limit }
+ set { gate.limit = newValue }
+ }
/// Invoked on the transport's delivery thread.
public var onUnsolicited: ((HIDPPMessage) -> Void)?
@@ -31,6 +56,7 @@ public final class HIDPPClient: @unchecked Sendable {
private var pending: [UUID: Pending] = [:]
private var order: [UUID] = []
private var closed = false
+ private let gate = RequestGate()
public init(transport: HIDPPTransport) {
self.transport = transport
@@ -39,10 +65,15 @@ public final class HIDPPClient: @unchecked Sendable {
}
/// Sends `request` and waits for the matching reply. Busy errors are retried a few times.
- public func send(_ request: HIDPPMessage, timeout: TimeInterval? = nil) async throws -> HIDPPMessage {
+ /// `lane` defaults to the task's `HIDPPClient.lane`. The timeout counts from when the
+ /// request leaves the gate, not from when it was queued.
+ public func send(_ request: HIDPPMessage, timeout: TimeInterval? = nil, lane: Lane? = nil) async throws -> HIDPPMessage {
+ let lane = lane ?? Self.lane
var attempt = 0
while true {
do {
+ try await gate.acquire(lane)
+ defer { gate.release() }
return try await sendOnce(request, timeout: timeout)
} catch let error as HIDPPError where error.isBusy && attempt < busyRetryLimit {
attempt += 1
@@ -95,7 +126,8 @@ public final class HIDPPClient: @unchecked Sendable {
featureIndex: UInt8,
function: UInt8,
parameters: [UInt8] = [],
- timeout: TimeInterval? = nil
+ timeout: TimeInterval? = nil,
+ lane: Lane? = nil
) async throws -> [UInt8] {
let request = HIDPPMessage.request(
deviceIndex: deviceIndex,
@@ -104,7 +136,7 @@ public final class HIDPPClient: @unchecked Sendable {
softwareID: softwareID,
parameters: parameters
)
- return try await send(request, timeout: timeout).parameters
+ return try await send(request, timeout: timeout, lane: lane).parameters
}
/// Fails every pending request; further requests fail immediately.
@@ -116,6 +148,7 @@ public final class HIDPPClient: @unchecked Sendable {
pending.removeAll()
order.removeAll()
lock.unlock()
+ gate.close()
all.forEach { $0.continuation.resume(throwing: HIDPPError.transportClosed) }
onClose?()
}
@@ -156,3 +189,85 @@ public final class HIDPPClient: @unchecked Sendable {
entry?.continuation.resume(with: result)
}
}
+
+/// Admits at most `limit` requests at once; the others wait, interactive ones first, each lane
+/// in FIFO order. A released slot is handed straight to the next waiter.
+private final class RequestGate: @unchecked Sendable {
+ private let lock = NSLock()
+ private var inFlight = 0
+ private var _limit = Int.max
+ private var interactive: [CheckedContinuation] = []
+ private var background: [CheckedContinuation] = []
+ private var closed = false
+
+ var limit: Int {
+ get { lock.lock(); defer { lock.unlock() }; return _limit }
+ set {
+ lock.lock()
+ _limit = max(1, newValue)
+ var admitted: [CheckedContinuation] = []
+ while inFlight < _limit, let next = popWaiter() {
+ inFlight += 1
+ admitted.append(next)
+ }
+ lock.unlock()
+ admitted.forEach { $0.resume() }
+ }
+ }
+
+ /// Waits for a slot. Throws `transportClosed` once closed, and `CancellationError` when the
+ /// task was cancelled while it waited (the slot is given back).
+ func acquire(_ lane: HIDPPClient.Lane) async throws {
+ try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in
+ lock.lock()
+ if closed {
+ lock.unlock()
+ continuation.resume(throwing: HIDPPError.transportClosed)
+ return
+ }
+ if inFlight < _limit, interactive.isEmpty, background.isEmpty {
+ inFlight += 1
+ lock.unlock()
+ continuation.resume()
+ return
+ }
+ switch lane {
+ case .interactive: interactive.append(continuation)
+ case .background: background.append(continuation)
+ }
+ lock.unlock()
+ }
+ if Task.isCancelled {
+ release()
+ throw CancellationError()
+ }
+ }
+
+ func release() {
+ lock.lock()
+ if let next = popWaiter() {
+ lock.unlock()
+ next.resume()
+ return
+ }
+ inFlight = max(0, inFlight - 1)
+ lock.unlock()
+ }
+
+ func close() {
+ lock.lock()
+ closed = true
+ let waiting = interactive + background
+ interactive.removeAll()
+ background.removeAll()
+ lock.unlock()
+ waiting.forEach { $0.resume(throwing: HIDPPError.transportClosed) }
+ }
+
+ /// Requires `lock`.
+ private func popWaiter() -> CheckedContinuation? {
+ if !interactive.isEmpty { return interactive.removeFirst() }
+ if !background.isEmpty { return background.removeFirst() }
+ return nil
+ }
+}
diff --git a/UprightKit/Sources/UprightKit/HIDPP/HIDPPDevice.swift b/UprightKit/Sources/UprightKit/HIDPP/HIDPPDevice.swift
index 1a8a384..a51f4d8 100644
--- a/UprightKit/Sources/UprightKit/HIDPP/HIDPPDevice.swift
+++ b/UprightKit/Sources/UprightKit/HIDPP/HIDPPDevice.swift
@@ -1,12 +1,13 @@
import Foundation
/// A HID++ 2.0 device reachable through a client at a given device index.
-/// Caches feature-ID → feature-index lookups done through the root feature.
+/// Caches feature-ID → feature-index (and version) lookups done through the root feature.
@MainActor
public final class HIDPPDevice {
public let client: HIDPPClient
public let index: UInt8
private var featureIndices: [UInt16: UInt8] = [:]
+ private var featureVersions: [UInt16: UInt8] = [:]
public init(client: HIDPPClient, index: UInt8) {
self.client = client
@@ -37,12 +38,13 @@ public final class HIDPPDevice {
return lastPing
}
- /// Root.getFeature. Returns nil when the device lacks the feature.
+ /// Root.getFeature: [index, type, version]. Returns nil when the device lacks the feature.
public func featureIndex(for feature: UInt16) async throws -> UInt8? {
if feature == HIDPPFeature.root.rawValue { return 0 }
if let cached = featureIndices[feature] { return cached == 0 ? nil : cached }
let reply = try await client.call(deviceIndex: index, featureIndex: 0, function: 0, parameters: feature.bigEndianBytes)
featureIndices[feature] = reply[0]
+ if reply[0] != 0 { featureVersions[feature] = reply[2] }
return reply[0] == 0 ? nil : reply[0]
}
@@ -50,6 +52,13 @@ public final class HIDPPDevice {
try await featureIndex(for: feature.rawValue)
}
+ /// The feature's version, from the feature table if it was read, else Root.getFeature.
+ /// Nil when the device lacks the feature.
+ public func featureVersion(for feature: HIDPPFeature) async throws -> UInt8? {
+ guard try await featureIndex(for: feature) != nil else { return nil }
+ return featureVersions[feature.rawValue] ?? 0
+ }
+
/// Calls `function` on `feature`, resolving its index first.
public func call(_ feature: HIDPPFeature, function: UInt8, parameters: [UInt8] = []) async throws -> [UInt8] {
guard let featureIndex = try await featureIndex(for: feature) else {
@@ -71,6 +80,7 @@ public final class HIDPPDevice {
let id = reply.uint16(at: 0)
entries.append(FeatureEntry(index: i, featureID: id, version: reply[3], flags: reply[2]))
featureIndices[id] = i
+ featureVersions[id] = reply[3]
}
}
return entries
@@ -90,8 +100,8 @@ public final class HIDPPDevice {
return String(decoding: trimmed, as: UTF8.self)
}
- /// Battery via Unified Battery (0x1004) or Battery Status (0x1000), whichever exists.
- /// `previous` fills in the level when a charging 0x1000 device reports level 0.
+ /// Battery via Unified Battery (0x1004), Battery Status (0x1000) or Battery Voltage (0x1001),
+ /// the first that exists. `previous` fills in the level when a charging 0x1000 device reports level 0.
public func battery(previous: BatteryStatus? = nil) async -> BatteryStatus? {
if (try? await featureIndex(for: .unifiedBattery)) != nil {
// getCapabilities: [supported level flags, flags]; flags bit 1 = state of charge (%) supported.
@@ -105,6 +115,10 @@ public final class HIDPPDevice {
let reply = try? await call(.batteryStatus, function: 0) {
return BatteryStatus(batteryStatusParameters: reply, previous: previous)
}
+ if (try? await featureIndex(for: .batteryVoltage)) != nil,
+ let reply = try? await call(.batteryVoltage, function: 0) {
+ return BatteryStatus(batteryVoltageParameters: reply)
+ }
return nil
}
}
@@ -164,6 +178,41 @@ public struct BatteryStatus: Hashable, Sendable {
}
}
+ /// 0x1001 getBatteryVoltage / broadcast: [millivolts (2, big-endian), flags]. Flags bit 7 = on
+ /// external power; then bits 0-1 give the charge state (0 charging, 1 full, 2 not charging).
+ /// The level is estimated from the voltage, so it is marked approximate.
+ init?(batteryVoltageParameters p: [UInt8]) {
+ guard p.count >= 3 else { return nil }
+ let millivolts = Int(p.uint16(at: 0))
+ let flags = p[2]
+ isCharging = flags & 0x80 != 0
+ isApproximate = true
+ if millivolts > 0 {
+ percentage = Self.percentage(forMillivolts: millivolts)
+ } else if isCharging, flags & 0x03 == 0x01 {
+ percentage = 100
+ } else {
+ return nil
+ }
+ }
+
+ /// Lithium-ion discharge curve, linearly interpolated (Solaar's
+ /// `estimate_battery_level_percentage` table).
+ static func percentage(forMillivolts millivolts: Int) -> Int {
+ let curve: [(millivolts: Int, percentage: Int)] = [
+ (4186, 100), (4067, 90), (3989, 80), (3922, 70), (3859, 60), (3811, 50),
+ (3778, 40), (3751, 30), (3717, 20), (3671, 10), (3646, 5), (3579, 2), (3500, 0),
+ ]
+ guard let highest = curve.first, let lowest = curve.last else { return 0 }
+ if millivolts >= highest.millivolts { return highest.percentage }
+ if millivolts <= lowest.millivolts { return lowest.percentage }
+ for (high, low) in zip(curve, curve.dropFirst()) where millivolts >= low.millivolts {
+ let fraction = Double(millivolts - low.millivolts) / Double(high.millivolts - low.millivolts)
+ return Int((Double(low.percentage) + fraction * Double(high.percentage - low.percentage)).rounded())
+ }
+ return 0
+ }
+
/// Representative percentages for 0x1004 level flags (the values Solaar uses).
static func percentage(forLevelFlags flags: UInt8) -> Int? {
if flags & 0x08 != 0 { return 90 }
diff --git a/UprightKit/Sources/UprightKit/HIDPP/HIDPPError.swift b/UprightKit/Sources/UprightKit/HIDPP/HIDPPError.swift
index 63e8b41..201a84a 100644
--- a/UprightKit/Sources/UprightKit/HIDPP/HIDPPError.swift
+++ b/UprightKit/Sources/UprightKit/HIDPP/HIDPPError.swift
@@ -9,7 +9,9 @@ public enum HIDPPError: Error, Equatable, Sendable, CustomStringConvertible {
case featureNotSupported(UInt16)
case unsupportedProtocol(major: UInt8, minor: UInt8)
case malformedResponse
- case notMXVertical(String)
+ /// The endpoint answered but isn't a mouse Upright can serve. Terminal: no retry, no writes.
+ /// `partial` is what was learned before the gate failed (nil when too little was read).
+ case unsupportedDevice(UnsupportedReason, partial: DeviceSnapshot?)
public var description: String {
switch self {
@@ -29,11 +31,18 @@ public enum HIDPPError: Error, Equatable, Sendable, CustomStringConvertible {
return "The device speaks HID++ \(major).\(minor); HID++ 2.0 or newer is required."
case .malformedResponse:
return "The device sent a response that could not be decoded."
- case .notMXVertical(let name):
- return "Found “\(name)”, which is not an MX Vertical."
+ case .unsupportedDevice(let reason, let partial):
+ guard let name = partial?.name, !name.isEmpty else { return "Unsupported device: \(reason.message)." }
+ return "“\(name)” is not supported: \(reason.message)."
}
}
+ /// Why the device can't be served, for `unsupportedDevice`.
+ public var unsupportedReason: UnsupportedReason? {
+ if case .unsupportedDevice(let reason, _) = self { return reason }
+ return nil
+ }
+
/// True when the device isn't answering right now: a timeout, a report that couldn't be
/// sent, or a receiver saying nothing is reachable in a slot (empty, asleep, or off).
public var meansDeviceUnreachable: Bool {
diff --git a/UprightKit/Sources/UprightKit/HIDPP/HIDPPFeature.swift b/UprightKit/Sources/UprightKit/HIDPP/HIDPPFeature.swift
index 1ff7838..5e9ce4d 100644
--- a/UprightKit/Sources/UprightKit/HIDPP/HIDPPFeature.swift
+++ b/UprightKit/Sources/UprightKit/HIDPP/HIDPPFeature.swift
@@ -6,14 +6,25 @@ public enum HIDPPFeature: UInt16, CaseIterable, Sendable {
case deviceFirmwareVersion = 0x0003
case deviceName = 0x0005
case batteryStatus = 0x1000
+ case batteryVoltage = 0x1001
case unifiedBattery = 0x1004
case changeHost = 0x1814
case hostsInfo = 0x1815
+ /// Older REPROG_CONTROLS revisions. Upright needs v4 (0x1B04); these are named for reports.
+ case reprogControls = 0x1B00
+ case reprogControlsV2 = 0x1B01
+ case reprogControlsV2_2 = 0x1B02
+ case reprogControlsV3 = 0x1B03
case reprogControlsV4 = 0x1B04
+ case persistentRemappableAction = 0x1C00
case wirelessDeviceStatus = 0x1D4B
case smartShift = 0x2110
case hiResWheel = 0x2121
case adjustableDPI = 0x2201
+ /// Pointer speed on some gaming mice; Upright reads speed through 0x2201 only.
+ case extendedAdjustableDPI = 0x2202
+ /// Pointer-speed multiplier on mice without 0x2201 (M720, MX Ergo); not used for speed.
+ case pointerSpeed = 0x2205
public var name: String {
switch self {
@@ -22,20 +33,100 @@ public enum HIDPPFeature: UInt16, CaseIterable, Sendable {
case .deviceFirmwareVersion: return "Firmware Version"
case .deviceName: return "Device Name"
case .batteryStatus: return "Battery Status"
+ case .batteryVoltage: return "Battery Voltage"
case .unifiedBattery: return "Unified Battery"
case .changeHost: return "Easy-Switch"
case .hostsInfo: return "Hosts Info"
+ case .reprogControls: return "Reprog Controls"
+ case .reprogControlsV2: return "Reprog Controls v2"
+ case .reprogControlsV2_2: return "Reprog Controls v2.2"
+ case .reprogControlsV3: return "Reprog Controls v3"
case .reprogControlsV4: return "Reprog Controls v4"
+ case .persistentRemappableAction: return "Persistent Remappable Action"
case .wirelessDeviceStatus: return "Wireless Device Status"
case .smartShift: return "SmartShift"
case .hiResWheel: return "Hi-Res Wheel"
case .adjustableDPI: return "Adjustable DPI"
+ case .extendedAdjustableDPI: return "Extended Adjustable DPI"
+ case .pointerSpeed: return "Pointer Speed"
}
}
+ /// The feature's name for logs and device reports, or "Feature 0x%04X" when unknown.
public static func name(for id: UInt16) -> String {
- HIDPPFeature(rawValue: id)?.name ?? String(format: "Feature 0x%04X", id)
+ HIDPPFeature(rawValue: id)?.name ?? otherNames[id] ?? String(format: "Feature 0x%04X", id)
}
+
+ /// Names of features Upright doesn't use, so device reports stay readable
+ /// (IDs as listed in Solaar's `SupportedFeature`).
+ private static let otherNames: [UInt16: String] = [
+ 0x0002: "Feature Info",
+ 0x0004: "Unit ID",
+ 0x0006: "Device Groups",
+ 0x0007: "Friendly Name",
+ 0x0008: "Keep Alive",
+ 0x0011: "Property Access",
+ 0x0020: "Config Change",
+ 0x0021: "Crypto ID",
+ 0x0030: "Target Software",
+ 0x0080: "Wireless Signal Strength",
+ 0x00C0: "DFU Control (Legacy)",
+ 0x00C1: "DFU Control (Unsigned)",
+ 0x00C2: "DFU Control (Signed)",
+ 0x00C3: "DFU Control",
+ 0x00D0: "DFU",
+ 0x1010: "Charging Control",
+ 0x1300: "LED Control",
+ 0x1500: "Force Pairing",
+ 0x1800: "Generic Test",
+ 0x1802: "Device Reset",
+ 0x1805: "OOB State",
+ 0x1806: "Config Device Props",
+ 0x1816: "BLE Pro Pre-Pairing",
+ 0x1981: "Backlight",
+ 0x1982: "Backlight 2",
+ 0x1983: "Backlight 3",
+ 0x1990: "Illumination",
+ 0x19B0: "Haptic",
+ 0x19C0: "Force Sensing Button",
+ 0x1A00: "Presenter Control",
+ 0x1B05: "Full Key Customization",
+ 0x1B10: "Control List",
+ 0x1DF0: "Remaining Pairing",
+ 0x1E00: "Enable Hidden Features",
+ 0x1F1F: "Firmware Properties",
+ 0x1F20: "ADC Measurement",
+ 0x2001: "Left/Right Swap",
+ 0x2005: "Swap Button Cancel",
+ 0x2006: "Pointer Axis Orientation",
+ 0x2100: "Vertical Scrolling",
+ 0x2111: "SmartShift Enhanced",
+ 0x2120: "Hi-Res Scrolling",
+ 0x2130: "Low-Res Wheel",
+ 0x2150: "Thumb Wheel",
+ 0x2200: "Mouse Pointer",
+ 0x2230: "Angle Snapping",
+ 0x2240: "Surface Tuning",
+ 0x2250: "XY Stats",
+ 0x2251: "Wheel Stats",
+ 0x2400: "Hybrid Tracking",
+ 0x40A0: "Fn Inversion",
+ 0x4100: "Encryption",
+ 0x4220: "Lock Key State",
+ 0x4520: "Keyboard Layout",
+ 0x4531: "Multi-Platform",
+ 0x6100: "Touchpad Raw XY",
+ 0x6500: "Gesture",
+ 0x6501: "Gesture 2",
+ 0x8060: "Report Rate",
+ 0x8061: "Extended Report Rate",
+ 0x8070: "Color LED Effects",
+ 0x8071: "RGB Effects",
+ 0x8090: "Mode Status",
+ 0x8100: "Onboard Profiles",
+ 0x8110: "Mouse Button Spy",
+ 0x8111: "Latency Monitoring",
+ ]
}
/// One row of a device's feature table.
diff --git a/UprightKit/Sources/UprightKit/HIDPP/ReprogControls.swift b/UprightKit/Sources/UprightKit/HIDPP/ReprogControls.swift
index d546f4d..48aebd6 100644
--- a/UprightKit/Sources/UprightKit/HIDPP/ReprogControls.swift
+++ b/UprightKit/Sources/UprightKit/HIDPP/ReprogControls.swift
@@ -63,9 +63,14 @@ public struct ControlInfo: Hashable, Sendable, Identifiable {
/// movement while held instead of moving the cursor.
public var supportsRawXY: Bool { additionalFlags & 0x01 != 0 }
- /// Controls Upright offers for remapping: physical, divertable, and not a primary click.
+ /// Solaar's `mse` flag: a mouse button. With no device kind to go by, one such control is
+ /// what makes a device count as a pointer (D2).
+ public var isMouseButton: Bool { flags.contains(.mouseButton) }
+
+ /// Controls Upright offers for remapping: physical, divertable, and never a primary click
+ /// (`ControlCatalog.neverDivert`), even when a device flags one as divertable.
public var isConfigurable: Bool {
- flags.contains(.divertable) && !flags.contains(.virtual) && !KnownControl.primaryClicks.contains(cid)
+ flags.contains(.divertable) && !flags.contains(.virtual) && !ControlCatalog.neverDivert.contains(cid)
}
}
diff --git a/UprightKit/Sources/UprightKit/Mapping/ButtonMappings.swift b/UprightKit/Sources/UprightKit/Mapping/ButtonMappings.swift
index bf027af..f6e776b 100644
--- a/UprightKit/Sources/UprightKit/Mapping/ButtonMappings.swift
+++ b/UprightKit/Sources/UprightKit/Mapping/ButtonMappings.swift
@@ -20,6 +20,24 @@ public struct ButtonMappings: Codable, Hashable, Sendable {
KnownControl.dpiSwitch: .system(.missionControl),
])
+ /// The onboarding template for a model with these controls. The MX Vertical keeps what
+ /// Upright 1.x shipped (`legacyDefaults`); any other model gets `suggested(forControls:)`.
+ public static func suggested(for model: ModelKey, controls: [UInt16]) -> ButtonMappings {
+ model == .mxVertical ? legacyDefaults : suggested(forControls: controls)
+ }
+
+ /// Back → ⌘[ and Forward → ⌘] when the mouse has those buttons, Mission Control on a gesture
+ /// button (0xC3, else 0xD0); every other control stays native.
+ public static func suggested(forControls controls: [UInt16]) -> ButtonMappings {
+ var table: [UInt16: ButtonAction] = [:]
+ if controls.contains(KnownControl.back) { table[KnownControl.back] = .system(.navigateBack) }
+ if controls.contains(KnownControl.forward) { table[KnownControl.forward] = .system(.navigateForward) }
+ if let gesture = [KnownControl.gestureButton, KnownControl.multiPlatformGesture].first(where: controls.contains) {
+ table[gesture] = .system(.missionControl)
+ }
+ return ButtonMappings(table)
+ }
+
public subscript(cid: UInt16) -> ButtonAction {
get { actions[cid] ?? .deviceDefault }
set { actions[cid] = newValue == .deviceDefault ? nil : newValue }
diff --git a/UprightKit/Sources/UprightKit/Mapping/MappingLibrary.swift b/UprightKit/Sources/UprightKit/Mapping/MappingLibrary.swift
new file mode 100644
index 0000000..1edfd86
--- /dev/null
+++ b/UprightKit/Sources/UprightKit/Mapping/MappingLibrary.swift
@@ -0,0 +1,239 @@
+import Foundation
+
+/// Every model's mappings: schema v3. Each model holds the v2 shape (a global table plus
+/// per-app profiles); a model without an entry keeps every button native.
+///
+/// { "schemaVersion": 3,
+/// "models": { "046D:B020": { "name": "MX Vertical", "global": {…}, "apps": […] } },
+/// "devices": {} }
+///
+/// The `devices` section and unknown top-level keys are preserved but unused. Exported files
+/// (`MappingStore.exportData(_:)`) also carry top-level `global` and `apps` mirroring the MX
+/// Vertical's setup, so a 2.x build can import them; they are ignored whenever `models` exists.
+public struct MappingLibrary: Codable, Hashable, Sendable {
+ public static let schemaVersion = 3
+
+ public var models: [ModelKey: MappingProfiles]
+ /// Display names, written next to every model key so exports stay readable. A name without
+ /// a setup in `models` is kept on its own.
+ public var modelNames: [ModelKey: String]
+ /// The reserved `devices` section (a later per-unit override): preserved, unused.
+ public var reservedDevices: [String: JSONValue]
+ /// Top-level keys this version doesn't know, kept for forward compatibility.
+ public var unknownTopLevel: [String: JSONValue]
+
+ public init(
+ models: [ModelKey: MappingProfiles] = [:],
+ modelNames: [ModelKey: String] = [:],
+ reservedDevices: [String: JSONValue] = [:],
+ unknownTopLevel: [String: JSONValue] = [:]
+ ) {
+ self.models = models
+ self.modelNames = modelNames
+ self.reservedDevices = reservedDevices
+ self.unknownTopLevel = unknownTopLevel
+ }
+
+ /// Only an MX Vertical could have produced v2 data, so it becomes that model's setup.
+ public init(migrating v2: MappingProfiles) {
+ self.init(models: [.mxVertical: v2], modelNames: [.mxVertical: MXVertical.displayName])
+ }
+
+ /// A model's profiles; empty (every button native) when it has none.
+ public subscript(model: ModelKey) -> MappingProfiles {
+ get { models[model] ?? MappingProfiles(global: ButtonMappings(), apps: []) }
+ set { models[model] = newValue }
+ }
+
+ /// The table in effect for `model` while `bundleID` is frontmost. Empty for a nil or
+ /// unknown model.
+ public func effectiveMappings(model: ModelKey?, bundleID: String?) -> ButtonMappings {
+ guard let model, let profiles = models[model] else { return ButtonMappings() }
+ return profiles.effectiveMappings(forBundleID: bundleID)
+ }
+
+ /// Every configured action across every model (for the Accessibility requirement).
+ public var allActiveActions: [ButtonAction] {
+ models.keys.sorted().flatMap { models[$0]?.allActiveActions ?? [] }
+ }
+
+ /// The name shown for a model: the stored one, else the catalog's, else its key.
+ public func name(of model: ModelKey) -> String {
+ modelNames[model] ?? Self.catalogName(of: model)
+ }
+
+ /// The catalog's name for a model, else its key.
+ public static func catalogName(of model: ModelKey) -> String {
+ DeviceCatalog.model(model)?.name ?? model.rawValue
+ }
+
+ // MARK: Coding
+
+ /// What an encoding is for.
+ enum Format {
+ /// UserDefaults: stored names only, plus `devices` and unknown top-level keys.
+ case storage
+ /// A shareable file: every model with a setup and its name, plus the 2.x mirror of the
+ /// MX Vertical. Never `devices` or unknown keys, which describe this Mac.
+ case export
+ }
+
+ /// Encodes the export format (see `Format.export`).
+ struct ExportFile: Encodable {
+ let library: MappingLibrary
+
+ func encode(to encoder: Encoder) throws {
+ try library.encode(to: encoder, format: .export)
+ }
+ }
+
+ private static let knownKeys: Set = ["schemaVersion", "models", "devices", "global", "apps"]
+
+ private enum SectionKeys: String, CodingKey { case name, global, apps }
+
+ /// One model's section: `MappingProfiles` without `schemaVersion`, plus `name`. A section
+ /// with neither `global` nor `apps` only names the model.
+ private struct Section: Decodable {
+ var name: String?
+ var profiles: MappingProfiles?
+
+ init(from decoder: Decoder) throws {
+ let container = try decoder.container(keyedBy: SectionKeys.self)
+ let name = (try? container.decodeIfPresent(String.self, forKey: .name)).flatMap { $0 }
+ self.name = name?.isEmpty == false ? name : nil
+ let hasSetup = container.contains(.global) || container.contains(.apps)
+ profiles = hasSetup ? try MappingProfiles(from: decoder) : nil
+ }
+ }
+
+ /// Tolerant: unreadable model sections are skipped (and noted in the decoder's
+ /// `MappingDecodingReport`). A payload without `models` but with v2 `global`/`apps` is
+ /// read as v2 and migrated.
+ public init(from decoder: Decoder) throws {
+ let container = try decoder.container(keyedBy: AnyCodingKey.self)
+ let report = decoder.mappingReport
+ let version = (try? container.decodeIfPresent(Int.self, forKey: AnyCodingKey("schemaVersion"))).flatMap { $0 }
+ if let version, version > Self.schemaVersion {
+ report?.note("written by a newer version (schema \(version)); reading what this version understands")
+ }
+
+ var models: [ModelKey: MappingProfiles] = [:]
+ var names: [ModelKey: String] = [:]
+ if let table = try? container.nestedContainer(keyedBy: AnyCodingKey.self, forKey: AnyCodingKey("models")) {
+ var seen: Set = []
+ for key in table.allKeys.sorted(by: { $0.stringValue < $1.stringValue }) {
+ guard let model = ModelKey(rawValue: key.stringValue) else {
+ report?.note("models: skipped entry with invalid model “\(key.stringValue)”")
+ continue
+ }
+ do {
+ let section = try table.decode(Section.self, forKey: key)
+ if !seen.insert(model).inserted {
+ report?.note("models: merged duplicate entry for \(model.rawValue)")
+ }
+ if let profiles = section.profiles { models[model] = profiles }
+ if let name = section.name { names[model] = name }
+ } catch {
+ report?.note("models.\(key.stringValue): skipped unreadable section (\(error.briefDescription))")
+ }
+ }
+ } else if container.contains(AnyCodingKey("models")) {
+ report?.note("models: skipped unreadable table")
+ } else if container.contains(AnyCodingKey("global")) || container.contains(AnyCodingKey("apps")) {
+ models[.mxVertical] = try MappingProfiles(from: decoder)
+ names[.mxVertical] = MXVertical.displayName
+ }
+
+ var devices: [String: JSONValue] = [:]
+ if container.contains(AnyCodingKey("devices")) {
+ if let value = try? container.decode([String: JSONValue].self, forKey: AnyCodingKey("devices")) {
+ devices = value
+ } else {
+ report?.note("devices: skipped unreadable section")
+ }
+ }
+
+ var unknown: [String: JSONValue] = [:]
+ for key in container.allKeys where !Self.knownKeys.contains(key.stringValue) {
+ if let value = try? container.decode(JSONValue.self, forKey: key) {
+ unknown[key.stringValue] = value
+ }
+ }
+
+ self.init(models: models, modelNames: names, reservedDevices: devices, unknownTopLevel: unknown)
+ }
+
+ public func encode(to encoder: Encoder) throws {
+ try encode(to: encoder, format: .storage)
+ }
+
+ func encode(to encoder: Encoder, format: Format) throws {
+ var container = encoder.container(keyedBy: AnyCodingKey.self)
+ try container.encode(Self.schemaVersion, forKey: AnyCodingKey("schemaVersion"))
+
+ var table = container.nestedContainer(keyedBy: AnyCodingKey.self, forKey: AnyCodingKey("models"))
+ let keys = format == .storage ? Set(models.keys).union(modelNames.keys) : Set(models.keys)
+ for model in keys {
+ var section = table.nestedContainer(keyedBy: SectionKeys.self, forKey: AnyCodingKey(model.rawValue))
+ try section.encodeIfPresent(format == .storage ? modelNames[model] : name(of: model), forKey: .name)
+ if let profiles = models[model] {
+ try section.encode(profiles.global, forKey: .global)
+ try section.encode(profiles.apps, forKey: .apps)
+ }
+ }
+
+ switch format {
+ case .storage:
+ try container.encode(reservedDevices, forKey: AnyCodingKey("devices"))
+ for (key, value) in unknownTopLevel where !Self.knownKeys.contains(key) {
+ try container.encode(value, forKey: AnyCodingKey(key))
+ }
+ case .export:
+ if let mirror = models[.mxVertical] {
+ try container.encode(mirror.global, forKey: AnyCodingKey("global"))
+ try container.encode(mirror.apps, forKey: AnyCodingKey("apps"))
+ }
+ }
+ }
+}
+
+/// Any JSON value, for preserving data this version doesn't interpret.
+public enum JSONValue: Codable, Hashable, Sendable {
+ case null
+ case bool(Bool)
+ case number(Double)
+ case string(String)
+ case array([JSONValue])
+ case object([String: JSONValue])
+
+ public init(from decoder: Decoder) throws {
+ let container = try decoder.singleValueContainer()
+ if container.decodeNil() {
+ self = .null
+ } else if let value = try? container.decode(Bool.self) {
+ self = .bool(value)
+ } else if let value = try? container.decode(Double.self) {
+ self = .number(value)
+ } else if let value = try? container.decode(String.self) {
+ self = .string(value)
+ } else if let value = try? container.decode([JSONValue].self) {
+ self = .array(value)
+ } else if let value = try? container.decode([String: JSONValue].self) {
+ self = .object(value)
+ } else {
+ throw DecodingError.dataCorruptedError(in: container, debugDescription: "Not a JSON value")
+ }
+ }
+
+ public func encode(to encoder: Encoder) throws {
+ var container = encoder.singleValueContainer()
+ switch self {
+ case .null: try container.encodeNil()
+ case .bool(let value): try container.encode(value)
+ case .number(let value): try container.encode(value)
+ case .string(let value): try container.encode(value)
+ case .array(let value): try container.encode(value)
+ case .object(let value): try container.encode(value)
+ }
+ }
+}
diff --git a/UprightKit/Sources/UprightKit/Mapping/MappingProfiles.swift b/UprightKit/Sources/UprightKit/Mapping/MappingProfiles.swift
index 1d8ecb3..0ffc56d 100644
--- a/UprightKit/Sources/UprightKit/Mapping/MappingProfiles.swift
+++ b/UprightKit/Sources/UprightKit/Mapping/MappingProfiles.swift
@@ -55,9 +55,13 @@ public struct AppProfile: Codable, Hashable, Sendable, Identifiable {
}
}
-/// Everything the user has mapped: the global table plus per-app overrides.
+/// One model's setup: the global table plus per-app overrides.
+///
+/// This is the whole of what Upright 2.x saved and exported (schema 2). Since schema 3 it is the
+/// per-model payload of a `MappingLibrary`, whose sections carry it without `schemaVersion`.
public struct MappingProfiles: Codable, Hashable, Sendable {
- /// Version written with every save and export. Bump when the format changes incompatibly.
+ /// Version written with a standalone encoding (the 2.x format). A `MappingLibrary` writes its
+ /// own version instead.
public static let schemaVersion = 2
public var global: ButtonMappings
@@ -80,6 +84,9 @@ public struct MappingProfiles: Codable, Hashable, Sendable {
return profile
}
+ /// True when there is nothing to apply: no global mapping and no app profile.
+ public var isEmpty: Bool { global.isEmpty && apps.isEmpty }
+
/// Every action the user has configured in the global table or an enabled app profile.
public var allActiveActions: [ButtonAction] {
Array(global.mappedControls.values) + apps.filter(\.isEnabled).flatMap { $0.overrides.values }
diff --git a/UprightKit/Sources/UprightKit/Mapping/MappingStore.swift b/UprightKit/Sources/UprightKit/Mapping/MappingStore.swift
index dadfbfc..bb1f42d 100644
--- a/UprightKit/Sources/UprightKit/Mapping/MappingStore.swift
+++ b/UprightKit/Sources/UprightKit/Mapping/MappingStore.swift
@@ -1,9 +1,22 @@
import Foundation
-/// Loads and saves `MappingProfiles` in UserDefaults, migrating Upright 1.x mappings and
-/// backing up anything it can't read rather than silently discarding it.
+/// Loads and saves the `MappingLibrary` (schema 3) in UserDefaults, migrating Upright 2.x and 1.x
+/// mappings into the MX Vertical's setup and backing up anything it can't read rather than
+/// silently discarding it.
+///
+/// `loadLibrary()` takes the first of:
+/// 1. `profilesKey` (`mappingProfiles.v3`).
+/// 2. `v2Key` (2.x): copied once, byte for byte, to `preV3BackupKey`, then migrated into the MX
+/// Vertical. The v2 key is never written or deleted, so a downgrade reads the last 2.x setup.
+/// 3. `legacyKey` (1.x), migrated into the MX Vertical.
+/// 4. An upgrade without saved data: the 1.x default setup on the MX Vertical.
+/// 5. A fresh install: an empty library, so every mouse stays native.
public struct MappingStore {
- public static let profilesKey = "mappingProfiles.v2"
+ public static let profilesKey = "mappingProfiles.v3"
+ /// Where 2.x saved its one setup. Read for migration only.
+ public static let v2Key = "mappingProfiles.v2"
+ /// A copy of the 2.x data, written once before the first v3 save.
+ public static let preV3BackupKey = "mappingProfiles.v2.preV3Backup"
public static let legacyKey = ButtonMappings.defaultsKey
public static let backupKeyPrefix = "mappingProfiles.corruptBackup."
@@ -25,69 +38,156 @@ public struct MappingStore {
Self.priorLaunchKeys.contains { defaults.object(forKey: $0) != nil }
}
- /// Reads the saved profiles, migrating or falling back as needed. Whatever it returns is
- /// also written back, so later launches take the plain path.
- public func load() -> MappingProfiles {
+ // MARK: Load / save
+
+ /// How `loadLibrary()` arrived at its library.
+ public struct LoadReport: Hashable, Sendable {
+ public enum Origin: Hashable, Sendable {
+ /// Read from `profilesKey`.
+ case library
+ /// Migrated from 2.x (`v2Key`) into the MX Vertical.
+ case migratedV2
+ /// Migrated from 1.x (`legacyKey`) into the MX Vertical.
+ case migratedV1
+ /// An upgrade without saved data: the 1.x defaults on the MX Vertical.
+ case upgradeDefaults
+ case freshInstall
+ }
+
+ public var origin: Origin
+ /// What couldn't be read and was left out (also logged).
+ public var issues: [String]
+ /// Raw data backed up during this load. The one-time `preV3BackupKey` copy isn't listed.
+ public var backupKeys: [String]
+
+ public init(origin: Origin, issues: [String] = [], backupKeys: [String] = []) {
+ self.origin = origin
+ self.issues = issues
+ self.backupKeys = backupKeys
+ }
+ }
+
+ /// Reads the saved library, migrating or falling back as needed (see the type's comment).
+ /// A migrated, defaulted or cleaned-up library is also written back, so later launches take
+ /// the plain path.
+ public func loadLibrary() -> (library: MappingLibrary, report: LoadReport) {
let isUpgrade = hasPriorLaunch
+ var report = LoadReport(origin: .library)
if let data = defaults.data(forKey: Self.profilesKey) {
- let report = MappingDecodingReport()
- if let profiles = try? Self.decoder(report: report).decode(MappingProfiles.self, from: data) {
- if !report.isClean {
- report.issues.forEach { log("Mappings: \($0)") }
- backUp(data, reason: "skipped \(report.issues.count) unreadable entr\(report.issues.count == 1 ? "y" : "ies")")
- save(profiles)
- }
- return profiles
+ let decoding = MappingDecodingReport()
+ guard let library = try? Self.decoder(report: decoding).decode(MappingLibrary.self, from: data) else {
+ backUpUnreadable(data, reason: "saved mappings could not be read", into: &report)
+ let library = MappingLibrary()
+ save(library)
+ return (library, report)
}
- backUp(data, reason: "saved mappings could not be read")
- let profiles = MappingProfiles()
- save(profiles)
- return profiles
+ if !decoding.isClean {
+ backUpSkipped(data, decoding, into: &report)
+ save(library)
+ }
+ return (library, report)
}
- let profiles: MappingProfiles
- if let legacy = defaults.data(forKey: Self.legacyKey) {
- profiles = MappingProfiles(global: migrateLegacy(legacy))
+ let library: MappingLibrary
+ if let data = defaults.data(forKey: Self.v2Key) {
+ report.origin = .migratedV2
+ library = MappingLibrary(migrating: migrateV2(data, report: &report))
+ } else if let legacy = defaults.data(forKey: Self.legacyKey) {
+ report.origin = .migratedV1
+ library = MappingLibrary(migrating: MappingProfiles(global: migrateLegacy(legacy, report: &report)))
} else if isUpgrade {
// 1.x never saved untouched defaults, so an upgrading user without saved data was using them.
+ report.origin = .upgradeDefaults
log("Mappings: no saved 1.x mappings; keeping the 1.x default setup")
- profiles = MappingProfiles(global: .legacyDefaults)
+ library = MappingLibrary(migrating: MappingProfiles(global: .legacyDefaults))
} else {
- profiles = MappingProfiles()
+ report.origin = .freshInstall
+ library = MappingLibrary()
}
- save(profiles)
- return profiles
+ save(library)
+ return (library, report)
}
- public func save(_ profiles: MappingProfiles) {
+ /// The MX Vertical's setup, for callers that predate multi-mouse. Runs the same load (and
+ /// migration) as `loadLibrary()`.
+ public func load() -> MappingProfiles {
+ loadLibrary().library[.mxVertical]
+ }
+
+ public func save(_ library: MappingLibrary) {
do {
- defaults.set(try Self.encoder().encode(profiles), forKey: Self.profilesKey)
+ defaults.set(try Self.encoder().encode(library), forKey: Self.profilesKey)
} catch {
log("Mappings: could not save (\(error))")
}
}
- /// Keys of every backup made so far, oldest first.
- public var backupKeys: [String] {
- defaults.dictionaryRepresentation().keys.filter { $0.hasPrefix(Self.backupKeyPrefix) }.sorted()
+ /// Replaces only the MX Vertical's setup in the saved library, for callers that predate
+ /// multi-mouse. Every other model is kept.
+ public func save(_ profiles: MappingProfiles) {
+ var library = savedLibrary() ?? MappingLibrary()
+ library[.mxVertical] = profiles
+ save(library)
}
- private func migrateLegacy(_ data: Data) -> ButtonMappings {
- let report = MappingDecodingReport()
- guard let mappings = try? Self.decoder(report: report).decode(ButtonMappings.self, from: data) else {
+ /// The saved library as it is: no migration, logging or backups.
+ private func savedLibrary() -> MappingLibrary? {
+ guard let data = defaults.data(forKey: Self.profilesKey) else { return nil }
+ return try? Self.decoder(report: MappingDecodingReport()).decode(MappingLibrary.self, from: data)
+ }
+
+ private func migrateV2(_ data: Data, report: inout LoadReport) -> MappingProfiles {
+ if defaults.object(forKey: Self.preV3BackupKey) == nil {
+ defaults.set(data, forKey: Self.preV3BackupKey)
+ }
+ let decoding = MappingDecodingReport()
+ guard let profiles = try? Self.decoder(report: decoding).decode(MappingProfiles.self, from: data) else {
+ backUpUnreadable(data, reason: "saved 2.x mappings could not be read", into: &report)
+ return MappingProfiles()
+ }
+ if !decoding.isClean {
+ backUpSkipped(data, decoding, into: &report)
+ }
+ log("Mappings: moved the 2.x mappings to the MX Vertical; the 2.x copy is kept for downgrades")
+ return profiles
+ }
+
+ private func migrateLegacy(_ data: Data, report: inout LoadReport) -> ButtonMappings {
+ let decoding = MappingDecodingReport()
+ guard let mappings = try? Self.decoder(report: decoding).decode(ButtonMappings.self, from: data) else {
// 1.x fell back to its defaults when it couldn't read its table, so that's what this user had.
- backUp(data, reason: "1.x mappings could not be read")
+ backUpUnreadable(data, reason: "1.x mappings could not be read", into: &report)
return .legacyDefaults
}
- if !report.isClean {
- report.issues.forEach { log("Mappings: \($0)") }
- backUp(data, reason: "1.x mappings had unreadable entries")
+ if !decoding.isClean {
+ decoding.issues.forEach { log("Mappings: \($0)") }
+ report.issues += decoding.issues
+ report.backupKeys.append(backUp(data, reason: "1.x mappings had unreadable entries"))
}
log("Mappings: migrated \(mappings.mappedControls.count) mapping(s) from 1.x")
return mappings
}
+ private func backUpUnreadable(_ data: Data, reason: String, into report: inout LoadReport) {
+ report.issues.append(reason)
+ report.backupKeys.append(backUp(data, reason: reason))
+ }
+
+ private func backUpSkipped(_ data: Data, _ decoding: MappingDecodingReport, into report: inout LoadReport) {
+ let issues = decoding.issues
+ issues.forEach { log("Mappings: \($0)") }
+ report.issues += issues
+ report.backupKeys.append(backUp(data, reason: "skipped \(issues.count) unreadable entr\(issues.count == 1 ? "y" : "ies")"))
+ }
+
+ // MARK: Backups
+
+ /// Keys of every backup made so far, oldest first.
+ public var backupKeys: [String] {
+ defaults.dictionaryRepresentation().keys.filter { $0.hasPrefix(Self.backupKeyPrefix) }.sorted()
+ }
+
/// Stores raw mapping data under a new backup key and returns the key.
@discardableResult
public func backUp(_ data: Data, reason: String) -> String {
@@ -103,7 +203,18 @@ public struct MappingStore {
return key
}
- /// Backs up a set of profiles (in the saved format). Nil if they couldn't be encoded.
+ /// Backs up a library (in the saved format). Nil if it couldn't be encoded.
+ @discardableResult
+ public func backUp(_ library: MappingLibrary, reason: String) -> String? {
+ do {
+ return backUp(try Self.encoder().encode(library), reason: reason)
+ } catch {
+ log("Mappings: could not back up (\(error))")
+ return nil
+ }
+ }
+
+ /// Backs up one setup (in the 2.x format). Nil if it couldn't be encoded.
@discardableResult
public func backUp(_ profiles: MappingProfiles, reason: String) -> String? {
do {
@@ -114,23 +225,20 @@ public struct MappingStore {
}
}
- /// Reads a backup made by `backUp`. Nil if the key holds nothing readable.
- public func loadBackup(_ key: String) -> MappingProfiles? {
+ /// Reads a backup made by `backUp` (either format; 2.x data becomes the MX Vertical's).
+ /// Nil if the key holds nothing readable.
+ public func loadLibraryBackup(_ key: String) -> MappingLibrary? {
guard let data = defaults.data(forKey: key) else { return nil }
- return try? Self.decoder(report: MappingDecodingReport()).decode(MappingProfiles.self, from: data)
+ return try? Self.decoder(report: MappingDecodingReport()).decode(MappingLibrary.self, from: data)
}
- // MARK: Export / import
-
- /// What an import would change to, read before anything is replaced.
- public struct ImportOutcome: Sendable {
- public let profiles: MappingProfiles
- /// Where the profiles the import replaces were backed up.
- public let backupKey: String?
- /// Imported actions that open apps, links or shortcuts, for the user to look over.
- public let actionsToReview: [ReviewItem]
+ /// The MX Vertical's setup in a backup made by `backUp`. Nil if the key holds nothing readable.
+ public func loadBackup(_ key: String) -> MappingProfiles? {
+ loadLibraryBackup(key)?[.mxVertical]
}
+ // MARK: Export / import
+
/// An imported action that opens something outside Upright.
public struct ReviewItem: Hashable, Sendable {
public enum Slot: Hashable, Sendable {
@@ -139,15 +247,124 @@ public struct MappingStore {
case gesture(GestureDirection)
}
+ /// The model whose setup holds the action.
+ public let model: ModelKey
+ public let modelName: String
/// Nil for the global table.
public let bundleID: String?
public let cid: UInt16
public let slot: Slot
public let action: ButtonAction
+
+ /// `modelName` defaults to the catalog's name for `model`.
+ public init(
+ model: ModelKey = .mxVertical,
+ modelName: String? = nil,
+ bundleID: String?,
+ cid: UInt16,
+ slot: Slot,
+ action: ButtonAction
+ ) {
+ self.model = model
+ self.modelName = modelName ?? MappingLibrary.catalogName(of: model)
+ self.bundleID = bundleID
+ self.cid = cid
+ self.slot = slot
+ self.action = action
+ }
+ }
+
+ /// An import file read and validated, before anything is replaced.
+ public struct ImportPreview: Hashable, Sendable {
+ public enum Format: Hashable, Sendable {
+ /// A schema 3 file: a setup per model, each replacing that model's own.
+ case library
+ /// A 2.x file or a 1.x table: one setup, made for the MX Vertical, imported into the
+ /// model chosen in `prepareImport(_:targetModel:protecting:)`.
+ case singleModel
+ }
+
+ public let format: Format
+ /// The validated setups to import. Models the file leaves out (or whose setup had
+ /// nothing usable) aren't listed and keep their current setup.
+ public let models: [ModelKey: MappingProfiles]
+ /// Names the file gives its models.
+ public let modelNames: [ModelKey: String]
+ /// Imported actions that open apps, links or shortcuts, for the user to look over.
+ public let actionsToReview: [ReviewItem]
+ /// Entries that couldn't be used and were left out.
+ public let skipped: [String]
+
+ /// The imported models, sorted by key.
+ public var importedModels: [ModelKey] { models.keys.sorted() }
+
+ /// `library` with every imported model's setup replaced and every other model kept.
+ public func applied(to library: MappingLibrary) -> MappingLibrary {
+ var result = library
+ for (model, profiles) in models {
+ result[model] = profiles
+ }
+ for (model, name) in modelNames where result.modelNames[model] == nil {
+ result.modelNames[model] = name
+ }
+ return result
+ }
+ }
+
+ /// Reads an exported file without changing anything. A schema 3 file is imported model by
+ /// model; a 2.x file or 1.x table goes to `targetModel`. Entries are validated as by
+ /// `importData(_:report:)`; `protected` adds, per model, controls the mouse needs for
+ /// clicking (beyond `ControlCatalog.neverDivert`). Throws `ImportError` when the file
+ /// can't be used.
+ public func prepareImport(
+ _ data: Data,
+ targetModel: ModelKey,
+ protecting protected: [ModelKey: Set] = [:]
+ ) throws -> ImportPreview {
+ try Self.preview(
+ data,
+ targetModel: targetModel,
+ protected: protected,
+ knownNames: savedLibrary()?.modelNames ?? [:],
+ report: MappingDecodingReport()
+ )
+ }
+
+ /// Applies a previewed import to `current`, backing `current` up first so the import can be
+ /// undone.
+ public func applyImport(_ preview: ImportPreview, to current: MappingLibrary) -> (library: MappingLibrary, backupKey: String?) {
+ let key = backUp(current, reason: "replacing mappings with an imported file")
+ return (preview.applied(to: current), key)
+ }
+
+ /// Pretty-printed JSON of every model's setup and name, plus the MX Vertical's setup at the
+ /// top level for 2.x builds. Never the `devices` section. Empty (and logged) on failure.
+ public func export(_ library: MappingLibrary) -> Data {
+ do {
+ return try Self.exportData(library)
+ } catch {
+ log("Mappings: could not export (\(error))")
+ return Data()
+ }
+ }
+
+ public static func exportData(_ library: MappingLibrary) throws -> Data {
+ try encoder(pretty: true).encode(MappingLibrary.ExportFile(library: library))
}
- /// Reads an exported file meant to replace `current`, and backs `current` up first so the
- /// import can be undone. Throws (and backs up nothing) when the file can't be used.
+ // MARK: Single-model import (callers that predate multi-mouse)
+
+ /// What an import would change to, read before anything is replaced.
+ public struct ImportOutcome: Sendable {
+ public let profiles: MappingProfiles
+ /// Where the profiles the import replaces were backed up.
+ public let backupKey: String?
+ /// Imported actions that open apps, links or shortcuts, for the user to look over.
+ public let actionsToReview: [ReviewItem]
+ }
+
+ /// Reads an exported file's MX Vertical setup to replace `current`, and backs `current` up
+ /// first so the import can be undone. Throws (and backs up nothing) when the file can't be used.
public func prepareImport(
_ data: Data,
replacing current: MappingProfiles,
@@ -158,25 +375,56 @@ public struct MappingStore {
return ImportOutcome(profiles: imported, backupKey: key, actionsToReview: Self.actionsToReview(in: imported))
}
+ /// A file holding `profiles` as the MX Vertical's setup.
+ public static func exportData(_ profiles: MappingProfiles) throws -> Data {
+ try exportData(MappingLibrary(migrating: profiles))
+ }
+
+ /// Reads an exported file's MX Vertical setup (a 2.x file or 1.x table counts as one).
+ /// Unreadable or invalid entries are dropped and listed in the report; primary clicks are
+ /// never imported, so a shared file can't take away the left or right button, and links must
+ /// be http, https or mailto. Throws `noMappings` when nothing usable is left, so an empty
+ /// file can't wipe the current setup.
+ public static func importData(_ data: Data, report: MappingDecodingReport = MappingDecodingReport()) throws -> MappingProfiles {
+ let preview = try Self.preview(data, targetModel: .mxVertical, protected: [:], knownNames: [:], report: report)
+ guard let profiles = preview.models[.mxVertical] else { throw ImportError.noMappings }
+ return profiles
+ }
+
+ // MARK: Import helpers
+
/// Every action, including gesture slots, that opens an app, a link or a shortcut:
/// global first, then each app profile, by button.
- public static func actionsToReview(in profiles: MappingProfiles) -> [ReviewItem] {
+ public static func actionsToReview(
+ in profiles: MappingProfiles,
+ model: ModelKey = .mxVertical,
+ modelName: String? = nil
+ ) -> [ReviewItem] {
+ let modelName = modelName ?? MappingLibrary.catalogName(of: model)
func items(_ actions: [UInt16: ButtonAction], bundleID: String?) -> [ReviewItem] {
actions.sorted { $0.key < $1.key }.flatMap { cid, action -> [ReviewItem] in
+ func item(_ slot: ReviewItem.Slot, _ action: ButtonAction) -> ReviewItem {
+ ReviewItem(model: model, modelName: modelName, bundleID: bundleID, cid: cid, slot: slot, action: action)
+ }
if case .gestures(let set) = action {
let slots: [(ReviewItem.Slot, ButtonAction)] = [(.gestureTap, set.tap)]
+ GestureDirection.allCases.map { (.gesture($0), set[$0]) }
- return slots.filter { $0.1.opensExternally }.map {
- ReviewItem(bundleID: bundleID, cid: cid, slot: $0.0, action: $0.1)
- }
+ return slots.filter { $0.1.opensExternally }.map { item($0.0, $0.1) }
}
- return action.opensExternally ? [ReviewItem(bundleID: bundleID, cid: cid, slot: .button, action: action)] : []
+ return action.opensExternally ? [item(.button, action)] : []
}
}
return items(profiles.global.mappedControls, bundleID: nil)
+ profiles.apps.flatMap { items($0.overrides, bundleID: $0.bundleID) }
}
+ /// `actionsToReview(in:model:modelName:)` for every model, by model key.
+ public static func actionsToReview(in library: MappingLibrary) -> [ReviewItem] {
+ library.models.keys.sorted().flatMap { model in
+ actionsToReview(in: library[model], model: model, modelName: library.name(of: model))
+ }
+ }
+
public enum ImportError: Error, Equatable, CustomStringConvertible {
case unreadable
case noMappings
@@ -189,62 +437,122 @@ public struct MappingStore {
}
}
- /// Pretty-printed JSON with a schema version, suitable for sharing.
- public static func exportData(_ profiles: MappingProfiles) throws -> Data {
- try encoder(pretty: true).encode(profiles)
+ /// An import file before validation.
+ private enum ImportFile {
+ /// Schema 3: has `models`.
+ case library(MappingLibrary)
+ /// A 2.x file (has `schemaVersion`) or a bare 1.x table.
+ case singleModel(MappingProfiles)
}
- /// Reads an exported file (or a bare 1.x mapping table). Unreadable or invalid entries are
- /// dropped and listed in the report; primary clicks are never imported, so a shared file can't
- /// take away the left or right button, and links must be http, https or mailto. Throws
- /// `noMappings` when nothing usable is left, so an empty file can't wipe the current setup.
- public static func importData(_ data: Data, report: MappingDecodingReport = MappingDecodingReport()) throws -> MappingProfiles {
+ private static func readImport(_ data: Data, report: MappingDecodingReport) throws -> ImportFile {
guard let object = try? JSONSerialization.jsonObject(with: data), let dictionary = object as? [String: Any] else {
throw ImportError.unreadable
}
- var profiles: MappingProfiles
+ if dictionary["models"] != nil {
+ guard let library = try? decoder(report: report).decode(MappingLibrary.self, from: data) else {
+ throw ImportError.unreadable
+ }
+ return .library(library)
+ }
if dictionary["schemaVersion"] != nil {
- guard let decoded = try? decoder(report: report).decode(MappingProfiles.self, from: data) else {
+ guard let profiles = try? decoder(report: report).decode(MappingProfiles.self, from: data) else {
throw ImportError.unreadable
}
- profiles = decoded
- } else if dictionary["actions"] != nil, dictionary.count == 1 {
+ return .singleModel(profiles)
+ }
+ if dictionary["actions"] != nil, dictionary.count == 1 {
guard let legacy = try? decoder(report: report).decode(ButtonMappings.self, from: data) else {
throw ImportError.unreadable
}
- profiles = MappingProfiles(global: legacy)
- } else {
- throw ImportError.unreadable
+ return .singleModel(MappingProfiles(global: legacy))
+ }
+ throw ImportError.unreadable
+ }
+
+ private static func preview(
+ _ data: Data,
+ targetModel: ModelKey,
+ protected: [ModelKey: Set],
+ knownNames: [ModelKey: String],
+ report: MappingDecodingReport
+ ) throws -> ImportPreview {
+ let format: ImportPreview.Format
+ var sections: [ModelKey: MappingProfiles] = [:]
+ var fileNames: [ModelKey: String] = [:]
+ func displayName(_ model: ModelKey) -> String {
+ fileNames[model] ?? knownNames[model] ?? MappingLibrary.catalogName(of: model)
}
- profiles.global = ButtonMappings(validated(profiles.global.mappedControls, context: "global", report: report))
+ switch try readImport(data, report: report) {
+ case .library(let library):
+ format = .library
+ for (model, profiles) in library.models.sorted(by: { $0.key < $1.key }) {
+ if let name = library.modelNames[model], name != model.rawValue {
+ fileNames[model] = name
+ }
+ let usable = validated(profiles, label: displayName(model), protected: protected[model] ?? [], report: report)
+ if !usable.isEmpty { sections[model] = usable }
+ }
+ case .singleModel(let profiles):
+ format = .singleModel
+ let usable = validated(profiles, label: nil, protected: protected[targetModel] ?? [], report: report)
+ if !usable.isEmpty { sections[targetModel] = usable }
+ }
+ // Nothing to import (`{"schemaVersion":2}`, `{"actions":{}}`, or everything skipped): keep the current setup.
+ guard !sections.isEmpty else { throw ImportError.noMappings }
+
+ let review = sections.keys.sorted().flatMap { model in
+ actionsToReview(in: sections[model] ?? MappingProfiles(), model: model, modelName: displayName(model))
+ }
+ return ImportPreview(
+ format: format,
+ models: sections,
+ modelNames: fileNames.filter { sections[$0.key] != nil },
+ actionsToReview: review,
+ skipped: report.issues
+ )
+ }
+
+ /// `profiles` with unusable entries dropped and noted. `label` (a model name) prefixes the
+ /// notes of a schema 3 file; single-model files keep the 2.x wording.
+ private static func validated(
+ _ profiles: MappingProfiles,
+ label: String?,
+ protected: Set,
+ report: MappingDecodingReport
+ ) -> MappingProfiles {
+ func context(_ scope: String) -> String { label.map { "\($0) · \(scope)" } ?? scope }
+ var profiles = profiles
+ profiles.global = ButtonMappings(validated(profiles.global.mappedControls, context: context("global"), protected: protected, report: report))
profiles.apps = profiles.apps.compactMap { profile in
guard !profile.bundleID.trimmingCharacters(in: .whitespaces).isEmpty else {
- report.note("apps: skipped a profile without a bundle ID")
+ report.note("\(context("apps")): skipped a profile without a bundle ID")
return nil
}
var profile = profile
- profile.overrides = validated(profile.overrides, context: profile.bundleID, report: report)
+ profile.overrides = validated(profile.overrides, context: context(profile.bundleID), protected: protected, report: report)
return profile
}
- // Nothing to import (`{"schemaVersion":2}`, `{"actions":{}}`, or everything skipped): keep the current setup.
- if profiles.global.isEmpty, profiles.apps.isEmpty {
- throw ImportError.noMappings
- }
return profiles
}
private static func validated(
_ actions: [UInt16: ButtonAction],
context: String,
+ protected: Set,
report: MappingDecodingReport
) -> [UInt16: ButtonAction] {
actions.reduce(into: [:]) { result, entry in
let (cid, action) = entry
- if KnownControl.primaryClicks.contains(cid) {
+ if ControlCatalog.neverDivert.contains(cid) {
report.note("\(context): skipped mapping for primary click control \(cid)")
return
}
+ if protected.contains(cid) {
+ report.note("\(context): skipped mapping for control \(cid), which the mouse needs for clicking")
+ return
+ }
if !action.isValid {
report.note("\(context): skipped invalid action for control \(cid)")
return
diff --git a/UprightKit/Sources/UprightKit/Mapping/RemapEngine.swift b/UprightKit/Sources/UprightKit/Mapping/RemapEngine.swift
index 75e669e..61344bd 100644
--- a/UprightKit/Sources/UprightKit/Mapping/RemapEngine.swift
+++ b/UprightKit/Sources/UprightKit/Mapping/RemapEngine.swift
@@ -17,99 +17,216 @@ public protocol ActionPerforming: AnyObject {
func perform(_ effect: ActionEffect, phase: ActionPhase)
}
-/// Routes button events from the mouse to the configured actions.
+/// One physical button on one mouse. Engine state is keyed by it, so the same button held on
+/// two mice is two independent holds.
+public struct ControlKey: Hashable, Comparable, Sendable {
+ public var source: DeviceAddress
+ public var cid: UInt16
+
+ public init(source: DeviceAddress, cid: UInt16) {
+ self.source = source
+ self.cid = cid
+ }
+
+ public static func < (lhs: ControlKey, rhs: ControlKey) -> Bool {
+ lhs.source != rhs.source ? lhs.source < rhs.source : lhs.cid < rhs.cid
+ }
+}
+
+/// Routes button events from the mice to the configured actions.
+///
+/// Every hold, gesture and pressed button is tracked per `ControlKey`, i.e. per sending device
+/// (`ButtonEvent.source`). The mappings, gesture source and gesture distance are looked up for
+/// that device at press and kept until the release, so a press always ends the way it started.
@MainActor
public final class RemapEngine {
- /// The table for the current context (the frontmost app's effective mappings). Changing it
- /// while a button is held doesn't affect that button: its release always matches its press.
+ /// The table used when `mappingsProvider` is nil (single-mouse callers). Changing it while a
+ /// button is held doesn't affect that button: its release always matches its press.
public var mappings: ButtonMappings
+ /// The table for the device that pressed a button, asked at each press. Nil uses `mappings`.
+ public var mappingsProvider: (@MainActor (DeviceAddress) -> ButtonMappings)?
+
public var isEnabled = true {
didSet { if !isEnabled { releaseAll() } }
}
- /// Receives `.device` effects, which the mouse carries out rather than the performer.
+ /// Receives `.device` effects together with the device whose button fired them.
+ public var onSourcedDeviceAction: ((DeviceAction, DeviceAddress) -> Void)?
+ /// Receives `.device` effects without their device (single-mouse callers). Called in addition
+ /// to `onSourcedDeviceAction` when both are set, so set only one of them.
public var onDeviceAction: ((DeviceAction) -> Void)?
- /// Buttons physically held right now (as far as the engine has seen), whatever they're mapped to.
- /// Keep these diverted until released so their release still reaches the engine.
- public private(set) var pressedControls: Set = []
-
/// How far the mouse must move while a gesture button is held before a direction fires, in raw
- /// sensor counts. Set it from the mouse's pointer speed with `GestureRecognizer.rawThreshold(dpi:)`.
+ /// sensor counts. Used when `rawThresholdProvider` is nil. Set it from the mouse's pointer
+ /// speed with `GestureRecognizer.rawThreshold(dpi:)`.
public var rawGestureThreshold = GestureRecognizer.rawThreshold(dpi: nil)
+ /// The raw-count distance for the device that pressed a gesture button, asked at the press
+ /// (raw counts depend on that mouse's DPI). Nil uses `rawGestureThreshold`.
+ public var rawThresholdProvider: (@MainActor (DeviceAddress) -> Double)?
/// The same distance for buttons whose gestures follow the cursor, in points.
public var cursorGestureThreshold = GestureRecognizer.cursorThreshold
- /// Where a gesture button's movement comes from. Asked at each press; raw movement by default.
+ /// Where a gesture button's movement comes from, by CID. Used when `gestureSourceFor` is nil;
+ /// raw movement by default.
public var gestureSource: @MainActor (UInt16) -> PointerDeltaSource = { _ in .rawSensor }
+ /// Where a gesture button's movement comes from on a given device (whether that mouse reports
+ /// raw XY for the control). Asked at each press.
+ public var gestureSourceFor: (@MainActor (DeviceAddress, UInt16) -> PointerDeltaSource)?
- /// Held buttons whose press started a gesture. Keep these reporting movement until released.
- public var gestureControls: Set { Set(gestures.keys) }
+ /// A gesture in progress. The set, movement source and distance are captured at press, so a
+ /// profile switch or DPI change mid-hold doesn't change what the hold does.
+ struct GestureState: Hashable, Sendable {
+ var set: GestureSet
+ var deltaSource: PointerDeltaSource
+ var recognizer: GestureRecognizer
+ }
private let performer: ActionPerforming
- /// Effects that are "down" right now, keyed by CID.
- private var held: [UInt16: ActionEffect] = [:]
- /// Gestures in progress, keyed by CID. The set is captured at press so a profile switch
- /// mid-hold doesn't change what the hold does.
- private var gestures: [UInt16: (set: GestureSet, source: PointerDeltaSource, recognizer: GestureRecognizer)] = [:]
+ /// Effects that are "down" right now.
+ private(set) var held: [ControlKey: ActionEffect] = [:]
+ /// Gestures in progress.
+ private(set) var gestures: [ControlKey: GestureState] = [:]
+ /// Buttons physically held right now (as far as the engine has seen), whatever they're mapped to.
+ public private(set) var pressedKeys: Set = []
public init(mappings: ButtonMappings, performer: ActionPerforming) {
self.mappings = mappings
self.performer = performer
}
+ // MARK: Queries
+
+ /// Buttons held on `source`. Keep these diverted on that device until released so their
+ /// release still reaches the engine.
+ public func pressedControls(for source: DeviceAddress) -> Set {
+ Set(pressedKeys.lazy.filter { $0.source == source }.map(\.cid))
+ }
+
+ /// Held buttons on `source` whose press started a gesture. Keep these reporting movement
+ /// on that device until released.
+ public func gestureControls(for source: DeviceAddress) -> Set {
+ Set(gestures.keys.lazy.filter { $0.source == source }.map(\.cid))
+ }
+
+ /// Held buttons on every device, by CID alone (single-mouse callers).
+ public var pressedControls: Set { Set(pressedKeys.map(\.cid)) }
+
+ /// Gesture buttons held on every device, by CID alone (single-mouse callers).
+ public var gestureControls: Set { Set(gestures.keys.map(\.cid)) }
+
+ /// True while a gesture that follows the cursor is in progress, i.e. while cursor movement
+ /// should be watched and passed to `handleCursorDelta(dx:dy:)`.
+ public var needsCursorDeltas: Bool {
+ gestures.values.contains { $0.deltaSource == .cursor }
+ }
+
+ // MARK: Events
+
public func handle(_ event: ButtonEvent) {
+ let key = ControlKey(source: event.source, cid: event.cid)
switch event.phase {
case .press:
- // A second press without a release means the release was lost; finish the first one.
- if let stale = held.removeValue(forKey: event.cid) {
+ // A second press of the same button on the same mouse without a release means the
+ // release was lost; finish the first one.
+ if let stale = held.removeValue(forKey: key) {
performer.perform(stale, phase: .up)
}
- gestures.removeValue(forKey: event.cid)
- pressedControls.insert(event.cid)
+ gestures.removeValue(forKey: key)
+ pressedKeys.insert(key)
guard isEnabled else { return }
- let action = mappings[event.cid]
+ let action = mappings(for: key.source)[key.cid]
if case .gestures(let set) = action {
- let source = gestureSource(event.cid)
- let threshold = source == .rawSensor ? rawGestureThreshold : cursorGestureThreshold
- gestures[event.cid] = (set, source, GestureRecognizer(threshold: threshold))
+ let deltaSource = gestureSourceFor?(key.source, key.cid) ?? gestureSource(key.cid)
+ let threshold = deltaSource == .rawSensor
+ ? (rawThresholdProvider?(key.source) ?? rawGestureThreshold)
+ : cursorGestureThreshold
+ gestures[key] = GestureState(set: set, deltaSource: deltaSource, recognizer: GestureRecognizer(threshold: threshold))
return
}
guard let effect = action.effect else { return }
- if case .device(let action) = effect {
- onDeviceAction?(action)
+ if case .device(let deviceAction) = effect {
+ sendDeviceAction(deviceAction, from: key.source)
return
}
if effect.tracksButtonState {
- held[event.cid] = effect
+ held[key] = effect
}
performer.perform(effect, phase: .down)
case .release:
- pressedControls.remove(event.cid)
- if let gesture = gestures.removeValue(forKey: event.cid) {
- if gesture.recognizer.isTap { fire(gesture.set.tap) }
+ pressedKeys.remove(key)
+ if let gesture = gestures.removeValue(forKey: key) {
+ if gesture.recognizer.isTap { fire(gesture.set.tap, from: key.source) }
return
}
- guard let effect = held.removeValue(forKey: event.cid) else { return }
+ guard let effect = held.removeValue(forKey: key) else { return }
performer.perform(effect, phase: .up)
}
}
- /// Movement while buttons are held: raw XY from the mouse, or cursor movement as a fallback.
- /// Only gestures tracking that source use it. Each held gesture button fires at most one
- /// direction per press.
+ /// Raw sensor movement from one mouse. Only gestures held on that same mouse that track raw
+ /// movement use it. Each held gesture button fires at most one direction per press.
+ public func handlePointerDelta(_ delta: PointerDelta) {
+ feedGestures(dx: delta.dx, dy: delta.dy) { key, gesture in
+ gesture.deltaSource == .rawSensor && key.source == delta.source
+ }
+ }
+
+ /// Cursor movement, for gestures on buttons that can't report raw movement. macOS doesn't say
+ /// which mouse moved the cursor, so this reaches every cursor-tracked gesture in progress,
+ /// whatever device it was pressed on.
+ public func handleCursorDelta(dx: Int, dy: Int) {
+ feedGestures(dx: dx, dy: dy) { _, gesture in gesture.deltaSource == .cursor }
+ }
+
+ /// Movement without a device (single-mouse callers). Cursor movement is `handleCursorDelta`;
+ /// raw movement reaches every raw-tracked gesture on every device. Callers that know the
+ /// sending mouse use `handlePointerDelta(_:)` instead.
public func handlePointerDelta(dx: Int, dy: Int, source: PointerDeltaSource = .rawSensor) {
- for cid in gestures.keys.sorted() where gestures[cid]?.source == source {
- guard let direction = gestures[cid]?.recognizer.add(dx: dx, dy: dy),
- let set = gestures[cid]?.set
+ switch source {
+ case .cursor:
+ handleCursorDelta(dx: dx, dy: dy)
+ case .rawSensor:
+ feedGestures(dx: dx, dy: dy) { _, gesture in gesture.deltaSource == .rawSensor }
+ }
+ }
+
+ /// Ends every hold on every device: held effects go up, gestures are cancelled without firing.
+ public func releaseAll() {
+ endHolds { _ in true }
+ }
+
+ /// Ends every hold on one device (it disconnected, was retired or stopped answering), leaving
+ /// other devices' holds alone. Gestures are cancelled without firing.
+ public func releaseAll(source: DeviceAddress) {
+ endHolds { $0.source == source }
+ }
+
+ // MARK: Private
+
+ private func mappings(for source: DeviceAddress) -> ButtonMappings {
+ mappingsProvider?(source) ?? mappings
+ }
+
+ private func feedGestures(dx: Int, dy: Int, where matches: (ControlKey, GestureState) -> Bool) {
+ for key in gestures.keys.sorted() {
+ guard let gesture = gestures[key], matches(key, gesture),
+ let direction = gestures[key]?.recognizer.add(dx: dx, dy: dy)
else { continue }
- fire(set[direction])
+ fire(gesture.set[direction], from: key.source)
}
}
+ private func endHolds(where matches: (ControlKey) -> Bool) {
+ let keys = held.keys.filter(matches).sorted()
+ let effects = keys.compactMap { held.removeValue(forKey: $0) }
+ gestures = gestures.filter { !matches($0.key) }
+ pressedKeys = pressedKeys.filter { !matches($0) }
+ effects.forEach { performer.perform($0, phase: .up) }
+ }
+
/// Carries out a gesture slot as a complete press and release.
- private func fire(_ action: ButtonAction) {
+ private func fire(_ action: ButtonAction, from source: DeviceAddress) {
guard let effect = action.effect else { return }
if case .device(let deviceAction) = effect {
- onDeviceAction?(deviceAction)
+ sendDeviceAction(deviceAction, from: source)
return
}
performer.perform(effect, phase: .down)
@@ -118,11 +235,8 @@ public final class RemapEngine {
}
}
- public func releaseAll() {
- let effects = held
- held.removeAll()
- gestures.removeAll()
- pressedControls.removeAll()
- effects.values.forEach { performer.perform($0, phase: .up) }
+ private func sendDeviceAction(_ action: DeviceAction, from source: DeviceAddress) {
+ onSourcedDeviceAction?(action, source)
+ onDeviceAction?(action)
}
}
diff --git a/UprightKit/Sources/UprightKit/Platform/IOKitHID.swift b/UprightKit/Sources/UprightKit/Platform/IOKitHID.swift
index 2c6eae2..f4cbf04 100644
--- a/UprightKit/Sources/UprightKit/Platform/IOKitHID.swift
+++ b/UprightKit/Sources/UprightKit/Platform/IOKitHID.swift
@@ -31,73 +31,107 @@ enum IOKitReturn {
static let notPermitted = IOReturn(bitPattern: 0xE00002E2)
}
-/// Basic facts about a HID interface, read from its IORegistry properties.
-public struct HIDInterfaceInfo: Hashable, Sendable {
- public var vendorID: Int
- public var productID: Int
- public var product: String
- public var transport: String
- public var locationID: Int
- public var primaryUsagePage: Int
- public var primaryUsage: Int
- public var serialNumber: String
-
- init(device: IOHIDDevice) {
+extension HIDInterfaceDescriptor {
+ /// Reads the descriptor from a matched device's IORegistry properties.
+ public init(device: IOHIDDevice) {
func int(_ key: String) -> Int {
(IOHIDDeviceGetProperty(device, key as CFString) as? NSNumber)?.intValue ?? 0
}
func string(_ key: String) -> String {
(IOHIDDeviceGetProperty(device, key as CFString) as? String) ?? ""
}
- vendorID = int(kIOHIDVendorIDKey)
- productID = int(kIOHIDProductIDKey)
- product = string(kIOHIDProductKey)
- transport = string(kIOHIDTransportKey)
- locationID = int(kIOHIDLocationIDKey)
- primaryUsagePage = int(kIOHIDPrimaryUsagePageKey)
- primaryUsage = int(kIOHIDPrimaryUsageKey)
- serialNumber = string(kIOHIDSerialNumberKey)
+ self.init(
+ vendorID: int(kIOHIDVendorIDKey),
+ productID: int(kIOHIDProductIDKey),
+ transport: string(kIOHIDTransportKey),
+ usagePage: int(kIOHIDPrimaryUsagePageKey),
+ usage: int(kIOHIDPrimaryUsageKey),
+ locationID: int(kIOHIDLocationIDKey),
+ serial: string(kIOHIDSerialNumberKey),
+ registryID: Self.registryEntryID(of: device),
+ productName: string(kIOHIDProductKey)
+ )
}
- public var summary: String {
- String(format: "%@ [%04X:%04X] via %@, usage %04X:%04X", product, vendorID, productID, transport, primaryUsagePage, primaryUsage)
+ /// The IORegistry entry ID of the device's service: unique per interface, 0 when unavailable.
+ static func registryEntryID(of device: IOHIDDevice) -> UInt64 {
+ let service = IOHIDDeviceGetService(device)
+ guard service != 0 else { return 0 }
+ var entryID: UInt64 = 0
+ guard IORegistryEntryGetRegistryEntryID(service, &entryID) == KERN_SUCCESS else { return 0 }
+ return entryID
}
+}
- /// Receivers and the USB cable publish a dedicated vendor-page HID++ interface.
- public var isVendorHIDPP: Bool {
- primaryUsagePage == 0xFF00 || primaryUsagePage == 0xFF43
+/// Basic facts about a HID interface, read from its IORegistry properties.
+///
+/// A thin wrapper over `HIDInterfaceDescriptor`, which holds the values and drives
+/// `InterfaceClassifier`. The accessors keep the names earlier callers used.
+public struct HIDInterfaceInfo: Hashable, Sendable {
+ public var descriptor: HIDInterfaceDescriptor
+
+ public init(descriptor: HIDInterfaceDescriptor) {
+ self.descriptor = descriptor
+ }
+
+ init(device: IOHIDDevice) {
+ descriptor = HIDInterfaceDescriptor(device: device)
}
+ public var vendorID: Int { descriptor.vendorID }
+ public var productID: Int { descriptor.productID }
+ public var product: String { descriptor.productName }
+ public var transport: String { descriptor.transport }
+ public var locationID: Int { descriptor.locationID }
+ public var primaryUsagePage: Int { descriptor.usagePage }
+ public var primaryUsage: Int { descriptor.usage }
+ public var serialNumber: String { descriptor.serial }
+ public var registryID: UInt64 { descriptor.registryID }
+
+ public var summary: String { descriptor.summary }
+
+ /// Receivers and the USB cable publish a dedicated vendor-page HID++ interface.
+ public var isVendorHIDPP: Bool { descriptor.isVendorHIDPP }
+
+ /// Whether Upright opens this interface, and as what.
+ public var interfaceClass: InterfaceClass { InterfaceClassifier.classify(descriptor) }
+
public func isSamePhysicalDevice(as other: HIDInterfaceInfo) -> Bool {
- locationID != 0 && vendorID == other.vendorID && productID == other.productID && locationID == other.locationID
+ InterfaceClassifier.isSamePhysicalDevice(descriptor, other.descriptor)
}
- /// Bluetooth publishes the mouse and its HID++ collection as one interface. USB and
- /// receivers also publish a dedicated vendor-page interface, which is the one to use when
- /// both show up for the same device. Returns nil when `info` should not be opened,
- /// otherwise the keys of open twins it supersedes (to be closed first).
- public static func twinsToReplace(adding info: HIDInterfaceInfo, open: [Key: HIDInterfaceInfo]) -> [Key]? {
- guard info.locationID != 0 else { return [] }
- let twins = open.filter { $0.value.isSamePhysicalDevice(as: info) }
- if info.isVendorHIDPP {
- if twins.values.contains(where: \.isVendorHIDPP) { return nil }
- return twins.filter { !$0.value.isVendorHIDPP }.map(\.key)
- }
- return twins.isEmpty ? [] : nil
+ /// See `InterfaceClassifier.twinsToReplace`.
+ public static func twinsToReplace(adding info: HIDInterfaceInfo, open: [Key: HIDInterfaceInfo]) -> [Key]? {
+ InterfaceClassifier.twinsToReplace(adding: info.descriptor, open: open.mapValues(\.descriptor))
}
}
-/// Watches for Logitech HID interfaces that carry HID++ (vendor usage pages 0xFF00 on USB
-/// receivers and wired devices, 0xFF43 on Bluetooth Low Energy devices).
+/// Watches for Logitech HID interfaces that may carry HID++: the vendor usage pages (0xFF00 on
+/// USB receivers and wired devices, 0xFF43 on Bluetooth Low Energy devices) and every Logitech
+/// mouse interface (Bluetooth mice carry HID++ inside their mouse interface). The criteria live
+/// in `InterfaceClassifier.matchCriteria`.
+///
+/// Every match is classified with `InterfaceClassifier.classify`. Interfaces it ignores (a
+/// receiver's boot-mouse interface, a Lightspeed receiver, a keyboard, a mouse that isn't HID++)
+/// are never passed to `onMatch`; `onIgnored` reports each one once, with the reason, so the
+/// owner can log it. Set `deliversIgnoredInterfaces` to see them anyway (`upright-probe --all`).
+/// Callers still ask `HIDDeviceMonitor.interfaceClass(for:)` what an accepted device is, and use
+/// `InterfaceClassifier.twinsToReplace` to pick between interfaces of one device.
public final class HIDDeviceMonitor {
public var onMatch: ((IOHIDDevice) -> Void)?
public var onRemove: ((IOHIDDevice) -> Void)?
+ /// An interface the classifier ignores, with the reason. Called once per matched interface.
+ public var onIgnored: ((HIDInterfaceInfo, String) -> Void)?
+ /// When true, ignored interfaces are delivered through `onMatch` as well.
+ public var deliversIgnoredInterfaces = false
private let manager: IOHIDManager
/// macOS publishes two IOHIDDevice objects for one Bluetooth mouse. Opening both
/// interleaves HID++ replies, so the second match for the same device is ignored.
private var chosen: [String: IOHIDDevice] = [:]
private var ignored: Set = []
+ /// Interfaces the classifier turned away: never delivered, so their removal isn't either.
+ private var skipped: Set = []
private var isRunning = false
public init() {
@@ -108,33 +142,32 @@ public final class HIDDeviceMonitor {
stop()
}
- /// Starts monitoring on the main run loop. Returns the IOHIDManagerOpen result.
+ /// Starts monitoring on the main run loop.
///
/// Callbacks are scheduled in the common run loop modes so devices keep being matched (and
/// reports keep arriving) while a menu is open or a modal panel runs.
- @discardableResult
- public func start() -> IOReturn {
+ ///
+ /// The manager itself is never opened (no `IOHIDManagerOpen`): matching and removal callbacks
+ /// fire without it, and opening at the manager level would open every matched interface,
+ /// including the ones `classify` turns away (a receiver's boot-mouse interface, a combined
+ /// keyboard+mouse interface), which can raise the Input Monitoring prompt or fail the whole
+ /// manager with kIOReturnNotPermitted. Only accepted interfaces are opened, one by one, by
+ /// `IOKitHIDTransport.open()`, which reports permission failures per interface.
+ public func start() {
isRunning = true
// Receivers and the USB cable publish a dedicated HID++ interface (usage page
- // 0xFF00 or 0xFF43). Bluetooth does not: the MX Vertical is one mouse interface
- // (usage page 0x01) that also carries the HID++ collection. Matching only the
- // vendor page leaves that mouse unmatched, so its buttons are never remapped.
- let matching: [[String: Any]] = [
- [kIOHIDVendorIDKey: MXVertical.vendorID, kIOHIDDeviceUsagePageKey: 0xFF00],
- [kIOHIDVendorIDKey: MXVertical.vendorID, kIOHIDDeviceUsagePageKey: 0xFF43],
- [
- kIOHIDVendorIDKey: MXVertical.vendorID,
- kIOHIDProductIDKey: Int(MXVertical.bluetoothProductID),
- kIOHIDDeviceUsagePageKey: kHIDPage_GenericDesktop,
- kIOHIDDeviceUsageKey: kHIDUsage_GD_Mouse,
- ],
- [
- kIOHIDVendorIDKey: MXVertical.vendorID,
- kIOHIDProductIDKey: Int(MXVertical.usbProductID),
- kIOHIDDeviceUsagePageKey: kHIDPage_GenericDesktop,
- kIOHIDDeviceUsageKey: kHIDUsage_GD_Mouse,
- ],
- ]
+ // 0xFF00 or 0xFF43). Bluetooth mice do not: a Bluetooth MX mouse is one mouse
+ // interface (usage page 0x01) that also carries the HID++ collection. Matching only
+ // the vendor pages leaves those mice unmatched, so their buttons are never remapped.
+ // No product IDs here: which Logitech interfaces to open is decided by the classifier.
+ let matching: [[String: Any]] = InterfaceClassifier.matchCriteria.map { criterion in
+ var entry: [String: Any] = [
+ kIOHIDVendorIDKey: criterion.vendorID,
+ kIOHIDDeviceUsagePageKey: criterion.usagePage,
+ ]
+ if let usage = criterion.usage { entry[kIOHIDDeviceUsageKey] = usage }
+ return entry
+ }
IOHIDManagerSetDeviceMatchingMultiple(manager, matching as CFArray)
let context = Unmanaged.passUnretained(self).toOpaque()
@@ -147,8 +180,9 @@ public final class HIDDeviceMonitor {
Unmanaged.fromOpaque(context).takeUnretainedValue().deliverRemoval(device)
}, context)
+ // Scheduling the manager also schedules every device it enumerates (open or not), so
+ // input reports of the interfaces opened later arrive on the main run loop.
IOHIDManagerScheduleWithRunLoop(manager, CFRunLoopGetMain(), CFRunLoopMode.commonModes.rawValue)
- return IOHIDManagerOpen(manager, IOOptionBits(kIOHIDOptionsTypeNone))
}
public func stop() {
@@ -157,13 +191,19 @@ public final class HIDDeviceMonitor {
IOHIDManagerRegisterDeviceMatchingCallback(manager, nil, nil)
IOHIDManagerRegisterDeviceRemovalCallback(manager, nil, nil)
IOHIDManagerUnscheduleFromRunLoop(manager, CFRunLoopGetMain(), CFRunLoopMode.commonModes.rawValue)
- IOHIDManagerClose(manager, IOOptionBits(kIOHIDOptionsTypeNone))
chosen.removeAll()
ignored.removeAll()
+ skipped.removeAll()
}
private func deliverMatch(_ device: IOHIDDevice) {
- let key = identity(for: device)
+ let descriptor = HIDInterfaceDescriptor(device: device)
+ if case .ignored(let reason) = InterfaceClassifier.classify(descriptor), !deliversIgnoredInterfaces {
+ skipped.insert(device)
+ onIgnored?(HIDInterfaceInfo(descriptor: descriptor), reason)
+ return
+ }
+ let key = InterfaceClassifier.identityKey(descriptor)
if let existing = chosen[key] {
switch HIDInterfaceChoicePolicy.choose(
alreadyOpen: true,
@@ -189,6 +229,7 @@ public final class HIDDeviceMonitor {
}
private func deliverRemoval(_ device: IOHIDDevice) {
+ if skipped.remove(device) != nil { return }
if ignored.remove(device) != nil { return }
let key = chosen.first { $0.value == device }?.key
chosen = chosen.filter { $0.value != device }
@@ -204,20 +245,23 @@ public final class HIDDeviceMonitor {
return (raw as NSSet).contains(device)
}
- /// Same physical interface: serial when the device has one, otherwise location and usage.
+ /// Same physical interface: serial when the device has one, otherwise location, otherwise
+ /// the registry entry (see `InterfaceClassifier.identityKey`).
private func identity(for device: IOHIDDevice) -> String {
- let info = HIDInterfaceInfo(device: device)
- if !info.serialNumber.isEmpty {
- return "\(info.vendorID):\(info.productID):\(info.primaryUsagePage):\(info.primaryUsage):\(info.serialNumber)"
- }
- return "\(info.vendorID):\(info.productID):\(info.primaryUsagePage):\(info.primaryUsage):loc\(info.locationID)"
+ InterfaceClassifier.identityKey(HIDInterfaceDescriptor(device: device))
}
public static func info(for device: IOHIDDevice) -> HIDInterfaceInfo {
HIDInterfaceInfo(device: device)
}
- /// Devices that currently match, including ones already present when monitoring started.
+ /// What Upright does with `device`: open it as a receiver or a direct mouse, or ignore it.
+ public static func interfaceClass(for device: IOHIDDevice) -> InterfaceClass {
+ InterfaceClassifier.classify(HIDInterfaceDescriptor(device: device))
+ }
+
+ /// Devices that currently match the criteria (before classification), including ones already
+ /// present when monitoring started.
public func currentDevices() -> [HIDInterfaceInfo] {
guard let raw = IOHIDManagerCopyDevices(manager) else { return [] }
return (raw as NSSet).allObjects.map { HIDInterfaceInfo(device: $0 as! IOHIDDevice) }
@@ -238,9 +282,9 @@ public final class HIDDeviceMonitor {
/// HID++ over an IOHIDDevice that was matched by `HIDDeviceMonitor` (and is therefore
/// already scheduled on the main run loop). Reports are delivered on the main thread.
///
-/// The manager holds its own open on every matched device (balanced by `IOHIDManagerClose`).
-/// `open()` adds one more so permission failures surface per device; balance it with `close()`
-/// while the device is still present, or `deviceRemoved()` once IOKit has removed it.
+/// The monitor's manager is never opened, so `open()` is the only open on the device and
+/// permission failures surface per device; balance it with `close()` while the device is still
+/// present, or `deviceRemoved()` once IOKit has removed it.
public final class IOKitHIDTransport: HIDPPTransport {
public let device: IOHIDDevice
public let info: HIDInterfaceInfo
diff --git a/UprightKit/Sources/UprightKit/Scrolling/ScrollSettings.swift b/UprightKit/Sources/UprightKit/Scrolling/ScrollSettings.swift
index 267a4d2..aac1532 100644
--- a/UprightKit/Sources/UprightKit/Scrolling/ScrollSettings.swift
+++ b/UprightKit/Sources/UprightKit/Scrolling/ScrollSettings.swift
@@ -13,22 +13,33 @@ public struct ScrollSettings: Codable, Hashable, Sendable {
}
/// Experimental: turns each wheel notch into a short eased run of pixel scrolls.
public var smoothScrolling: Bool
- /// Only rewrite scrolling while an MX Vertical is connected. Events don't say which mouse sent
- /// them, so with this off every mouse wheel is affected.
- public var onlyWhileMXVerticalConnected: Bool
+ /// Only rewrite scrolling while a supported Logitech mouse is connected or asleep. Events
+ /// don't say which mouse sent them, so either way every mouse wheel is affected; this only
+ /// decides whether Upright steps aside when no supported mouse is around.
+ ///
+ /// Saved as `onlyWhileMXVerticalConnected` (see `CodingKeys`): the name predates support for
+ /// other mice and must not change, or existing saves would lose the setting.
+ public var onlyWhileMouseConnected: Bool
+
+ /// The old Swift name of `onlyWhileMouseConnected`.
+ @available(*, deprecated, renamed: "onlyWhileMouseConnected")
+ public var onlyWhileMXVerticalConnected: Bool {
+ get { onlyWhileMouseConnected }
+ set { onlyWhileMouseConnected = newValue }
+ }
public init(
reverseVertical: Bool = false,
reverseHorizontal: Bool = false,
speed: Double = 1,
smoothScrolling: Bool = false,
- onlyWhileMXVerticalConnected: Bool = true
+ onlyWhileMouseConnected: Bool = true
) {
self.reverseVertical = reverseVertical
self.reverseHorizontal = reverseHorizontal
self.speed = Self.clampedSpeed(speed)
self.smoothScrolling = smoothScrolling
- self.onlyWhileMXVerticalConnected = onlyWhileMXVerticalConnected
+ self.onlyWhileMouseConnected = onlyWhileMouseConnected
}
/// True when any setting changes wheel events, i.e. when an event tap is worth installing.
@@ -44,7 +55,9 @@ public struct ScrollSettings: Codable, Hashable, Sendable {
}
private enum CodingKeys: String, CodingKey {
- case reverseVertical, reverseHorizontal, speed, smoothScrolling, onlyWhileMXVerticalConnected
+ case reverseVertical, reverseHorizontal, speed, smoothScrolling
+ /// The stored key keeps its pre-multi-mouse name.
+ case onlyWhileMouseConnected = "onlyWhileMXVerticalConnected"
}
/// Missing or unreadable keys fall back to their defaults, so older or newer saves still load.
@@ -58,7 +71,7 @@ public struct ScrollSettings: Codable, Hashable, Sendable {
reverseHorizontal: value(.reverseHorizontal, false),
speed: value(.speed, 1.0),
smoothScrolling: value(.smoothScrolling, false),
- onlyWhileMXVerticalConnected: value(.onlyWhileMXVerticalConnected, true)
+ onlyWhileMouseConnected: value(.onlyWhileMouseConnected, true)
)
}
}
diff --git a/UprightKit/Sources/UprightKit/Simulation/DeviceProfiles.swift b/UprightKit/Sources/UprightKit/Simulation/DeviceProfiles.swift
new file mode 100644
index 0000000..297d155
--- /dev/null
+++ b/UprightKit/Sources/UprightKit/Simulation/DeviceProfiles.swift
@@ -0,0 +1,473 @@
+import Foundation
+
+/// Ready-made simulated devices, for tests and Demo Mode.
+///
+/// Sources: `scratchpad/research/devices.verified.json` (high and medium confidence entries
+/// only), which cites the Solaar device dumps (`Solaar/docs/devices/*.txt`) each profile
+/// copies its name, kind, protocol, product IDs, feature table (order, versions and flags
+/// where the dump prints them), control table and firmware strings from. The MX Keys and the
+/// HID++ 1.0 M510 aren't mice in that file's list; their dumps are cited directly.
+///
+/// Not published anywhere, so made up: DPI lists (read at runtime on real hardware), task IDs
+/// (0), battery levels, host names, and every unit ID and serial number. Unit IDs follow the
+/// obviously fake pattern `0x?A2B3C4D` and serials `DEMO-00NN`; nothing is a real device's.
+public enum DeviceProfiles {
+ // MARK: Mice
+
+ /// MX Vertical, the model Upright is tested on (Unifying, Bluetooth LE, USB).
+ ///
+ /// devices.verified.json "MX Vertical (Advanced Ergonomic Mouse)" (high): wpid 407B, btid
+ /// B020, usbid C08A; 0x0003 Transport IDs btleid B020, wpid 407B, usbid C08A. Feature table,
+ /// controls, name and firmware from "MX Vertical Wireless Mouse 407B.txt". That dump prints
+ /// no feature versions: 0x0003 v4 (serial readable) and 0x1B04 v4 are the values Upright's
+ /// tests have always used. The DPI list (400...4000 in steps of 50) is the tests' stand-in.
+ public static let mxVertical = SimulatedDeviceProfile(
+ name: "MX Vertical Advanced Ergonomic Mouse",
+ codename: "MX Vertical",
+ kind: .mouse,
+ wirelessPID: 0x407B,
+ bluetoothPID: 0xB020,
+ usbPID: 0xC08A,
+ unitID: 0x1A2B_3C4D,
+ modelIDs: ModelIDs(bluetoothLE: 0xB020, wireless: 0x407B, usb: 0xC08A),
+ serialNumber: "DEMO-0001",
+ features: [
+ .init(0x0000), .init(0x0001), .init(0x0003, version: 4), .init(0x0005), .init(0x1D4B), .init(0x0020),
+ .init(0x0021), .init(0x0007), .init(0x1000), .init(0x1002), .init(0x1B04, version: 4), .init(0x1C00),
+ .init(0x1814), .init(0x1815), .init(0x2250), .init(0x18B1, flags: 0x60), .init(0x2100), .init(0x2130),
+ .init(0x2201), .init(0x1802, flags: 0x60), .init(0x1803, flags: 0x60), .init(0x1806, flags: 0x60),
+ .init(0x1813, flags: 0x60), .init(0x1805, flags: 0x60), .init(0x1830, flags: 0x60), .init(0x1890, flags: 0x60),
+ .init(0x1891, flags: 0x60), .init(0x18A1, flags: 0x60), .init(0x1DF3, flags: 0x60), .init(0x1E00, flags: 0x40),
+ .init(0x1EB0, flags: 0x60), .init(0x1E22, flags: 0x60), .init(0x1861, flags: 0x60), .init(0x18C0, flags: 0x60),
+ ],
+ controls: [
+ control(0x0050, [.mouseButton, .reprogrammable], group: 1, mask: g1, analytics: true),
+ control(0x0051, [.mouseButton, .reprogrammable], group: 1, mask: g1, analytics: true),
+ control(0x0052, persistentButton, group: 2, mask: g1g2, rawXY: true, analytics: true),
+ control(0x0053, persistentButton, group: 2, mask: g1g2, rawXY: true, analytics: true),
+ control(0x0056, persistentButton, group: 2, mask: g1g2, rawXY: true, analytics: true),
+ control(0x00FD, persistentButton, group: 2, mask: g1g2, rawXY: true, analytics: true),
+ virtualGestureButton(group: 3),
+ ],
+ firmware: [.bootloader("BOT", 0x78, 0x00, build: 0x0009), .firmware("MPM", 0x16, 0x00, build: 0x0009), .other],
+ dpiList: [400, step(50), 4000],
+ defaultDPI: 1000,
+ battery: .status,
+ batteryLevel: 50,
+ hostCount: 3,
+ hostNames: demoHostNames
+ )
+
+ /// A second MX Vertical: same model, another unit (identical-model pairs).
+ public static let mxVerticalSecondUnit = mxVertical.unit(0x1A2B_3C4E, serialNumber: "DEMO-0002")
+
+ /// MX Master 3S (Bolt and Bluetooth LE only; a Bolt receiver reports its BLE PID).
+ ///
+ /// devices.verified.json "MX Master 3S" (high): btid B034, wpid B034 (Bolt); 0x0003 Transport
+ /// IDs btleid B034. Everything else, including every feature version (0x1B04 v5), from
+ /// "MX Master 3S B034.txt". The DPI list (200...8000 in steps of 50) is a stand-in; the dump
+ /// shows 1000 DPI in use.
+ public static let mxMaster3S = SimulatedDeviceProfile(
+ name: "MX Master 3S",
+ codename: "MX Master 3S",
+ kind: .mouse,
+ wirelessPID: 0xB034,
+ bluetoothPID: 0xB034,
+ unitID: 0x2A2B_3C4D,
+ modelIDs: ModelIDs(bluetoothLE: 0xB034),
+ serialNumber: "DEMO-0003",
+ features: [
+ .init(0x0000), .init(0x0001), .init(0x0003, version: 4), .init(0x0005), .init(0x1D4B), .init(0x0020),
+ .init(0x0021, version: 1), .init(0x0007), .init(0x1004, version: 3), .init(0x1B04, version: 5),
+ .init(0x1814, version: 1), .init(0x1815, version: 2), .init(0x2250, version: 1), .init(0x2201, version: 2),
+ .init(0x2110), .init(0x2121, version: 1), .init(0x2150), .init(0x2251), .init(0x00C3),
+ .init(0x1802, flags: 0x70), .init(0x1803, flags: 0x70), .init(0x1806, version: 8, flags: 0x70),
+ .init(0x1816, flags: 0x70), .init(0x1805, flags: 0x60), .init(0x1830, flags: 0x70),
+ .init(0x1891, version: 7, flags: 0x68), .init(0x18A1, flags: 0x70), .init(0x1E00, flags: 0x40),
+ .init(0x1E02, flags: 0x60), .init(0x1602), .init(0x1EB0, flags: 0x70), .init(0x1861, version: 1, flags: 0x70),
+ .init(0x9300, flags: 0x70), .init(0x9001, flags: 0x70), .init(0x1E22, flags: 0x70), .init(0x9205, flags: 0x70),
+ ],
+ controls: [
+ control(0x0050, [.mouseButton], group: 1, mask: g1, analytics: true),
+ control(0x0051, [.mouseButton], group: 1, mask: g1, analytics: true),
+ control(0x0052, divertableButton, group: 2, mask: g1g2, rawXY: true, analytics: true),
+ control(0x0053, divertableButton, group: 2, mask: g1g2, rawXY: true, analytics: true),
+ control(0x0056, divertableButton, group: 2, mask: g1g2, rawXY: true, analytics: true),
+ control(0x00C3, divertableButton, group: 2, mask: g1g2, rawXY: true, analytics: true),
+ control(0x00C4, divertableButton, group: 2, mask: g1g2, rawXY: true, analytics: true),
+ virtualGestureButton(group: 3),
+ ],
+ firmware: [.bootloader("BL1", 0x69, 0x00, build: 0x0003), .firmware("RBM", 0x22, 0x00, build: 0x0003), .other],
+ dpiList: [200, step(50), 8000],
+ defaultDPI: 1000,
+ battery: .unified,
+ batteryLevel: 25,
+ hostCount: 3,
+ hostNames: demoHostNames
+ )
+
+ /// Lift (Bolt and Bluetooth LE): the MX Vertical's buttons, nothing persistently divertable.
+ ///
+ /// devices.verified.json "Lift (LIFT VERTICAL ERGONOMIC MOUSE)" (high): btid B031, wpid B031
+ /// (Bolt); 0x0003 Transport IDs btleid B031. Table, controls (including the unnamed 0x0800
+ /// key flag on Back and Forward) and firmware from "LIFT VERTICAL ERGONOMIC MOUSE B031.txt".
+ /// That dump prints no versions; 0x0003 v4, 0x1004 v3, 0x1B04 v5, 0x1814 v1, 0x1815 v2 and
+ /// 0x2201 v2 come from the "LIFT For Business B033.txt" dump (same RBM 21 firmware family).
+ /// The DPI list (400...4000 in steps of 50) is a stand-in; the dump shows 2400 DPI in use.
+ public static let lift = SimulatedDeviceProfile(
+ name: "LIFT VERTICAL ERGONOMIC MOUSE",
+ codename: "LIFT",
+ kind: .mouse,
+ wirelessPID: 0xB031,
+ bluetoothPID: 0xB031,
+ unitID: 0x3A2B_3C4D,
+ modelIDs: ModelIDs(bluetoothLE: 0xB031),
+ serialNumber: "DEMO-0004",
+ features: [
+ .init(0x0000), .init(0x0001), .init(0x0003, version: 4), .init(0x0005), .init(0x1D4B), .init(0x0020),
+ .init(0x0021), .init(0x0007), .init(0x1004, version: 3), .init(0x1B04, version: 5), .init(0x1814, version: 1),
+ .init(0x1815, version: 2), .init(0x2250), .init(0x2130), .init(0x2201, version: 2), .init(0x00C3),
+ .init(0x1802, flags: 0x70), .init(0x1803, flags: 0x70), .init(0x1806, flags: 0x70), .init(0x1816, flags: 0x70),
+ .init(0x1805, flags: 0x60), .init(0x1830, flags: 0x70), .init(0x1891, flags: 0x68), .init(0x18A1, flags: 0x70),
+ .init(0x1E00, flags: 0x40), .init(0x1E02, flags: 0x60), .init(0x1E22, flags: 0x70), .init(0x1602),
+ .init(0x1EB0, flags: 0x70), .init(0x1861, flags: 0x70), .init(0x18B1, flags: 0x70),
+ ],
+ controls: [
+ control(0x0050, [.mouseButton], group: 1, mask: 0, analytics: true),
+ control(0x0051, [.mouseButton], group: 1, mask: 0, analytics: true),
+ control(0x0052, divertableButton, group: 3, mask: g1g2g3, rawXY: true, analytics: true),
+ control(0x0053, divertableButton, group: 2, mask: g1g2, rawXY: true, analytics: true, extra: 0x08),
+ control(0x0056, divertableButton, group: 2, mask: g1g2, rawXY: true, analytics: true, extra: 0x08),
+ control(0x00FD, divertableButton, group: 3, mask: g1g2g3, rawXY: true, analytics: true),
+ virtualGestureButton(group: 4),
+ ],
+ firmware: [.bootloader("BL1", 0x56, 0x00, build: 0x0008), .firmware("RBM", 0x21, 0x00, build: 0x0008), .other],
+ dpiList: [400, step(50), 4000],
+ defaultDPI: 1000,
+ battery: .unified,
+ batteryLevel: 100,
+ hostCount: 3,
+ hostNames: demoHostNames
+ )
+
+ /// MX Master 3 (Unifying and Bluetooth LE).
+ ///
+ /// devices.verified.json "MX Master 3" (high): btid B023, wpid 4082; 0x0003 Transport IDs
+ /// btleid B023, wpid 4082. Table, controls, name and firmware from "MX Master 3 Wireless
+ /// Mouse 4082.txt", which prints no versions (0x0003 v0: no readable serial). The DPI list
+ /// (200...4000 in steps of 50) is a stand-in; the dump shows 1600 DPI in use.
+ public static let mxMaster3 = SimulatedDeviceProfile(
+ name: "Wireless Mouse MX Master 3",
+ codename: "MX Master 3",
+ kind: .mouse,
+ wirelessPID: 0x4082,
+ bluetoothPID: 0xB023,
+ unitID: 0x4A2B_3C4D,
+ modelIDs: ModelIDs(bluetoothLE: 0xB023, wireless: 0x4082),
+ serialNumber: "DEMO-0005",
+ features: [
+ .init(0x0000), .init(0x0001), .init(0x0003), .init(0x0005), .init(0x1D4B), .init(0x0020), .init(0x0021),
+ .init(0x0007), .init(0x1000), .init(0x1B04), .init(0x1814), .init(0x2250), .init(0x2201), .init(0x2110),
+ .init(0x2121), .init(0x2150), .init(0x2251), .init(0x00C2), .init(0x1802, flags: 0x60), .init(0x1803, flags: 0x60),
+ .init(0x1806, flags: 0x60), .init(0x1813, flags: 0x60), .init(0x1805, flags: 0x60), .init(0x1830, flags: 0x60),
+ .init(0x1890, flags: 0x60), .init(0x1891, flags: 0x60), .init(0x18A1, flags: 0x60), .init(0x1DF3, flags: 0x60),
+ .init(0x1E00, flags: 0x40), .init(0x1EB0, flags: 0x60), .init(0x1861, flags: 0x60), .init(0x9001, flags: 0x60),
+ .init(0x9203, flags: 0x60), .init(0x9205, flags: 0x60),
+ ],
+ controls: [
+ control(0x0050, [.mouseButton], group: 1, mask: g1, analytics: true),
+ control(0x0051, [.mouseButton], group: 1, mask: g1, analytics: true),
+ control(0x0052, divertableButton, group: 3, mask: g1g2g3, rawXY: true, analytics: true),
+ control(0x0053, divertableButton, group: 2, mask: g1g2, rawXY: true, analytics: true),
+ control(0x0056, divertableButton, group: 2, mask: g1g2, rawXY: true, analytics: true),
+ control(0x00C3, divertableButton, group: 3, mask: g1g2g3, rawXY: true, analytics: true),
+ control(0x00C4, divertableButton, group: 3, mask: g1g2g3, rawXY: true, analytics: true),
+ virtualGestureButton(group: 4),
+ ],
+ firmware: [.bootloader("BOT", 0x95, 0x01, build: 0x0015), .firmware("MPM", 0x19, 0x01, build: 0x0015), .other],
+ dpiList: [200, step(50), 4000],
+ defaultDPI: 1000,
+ battery: .status,
+ batteryLevel: 100,
+ hostCount: 3,
+ hostNames: demoHostNames
+ )
+
+ /// M720 Triathlon: tilt buttons and a 0xD0 thumb button, but no ADJUSTABLE_DPI (only
+ /// 0x2205 pointer speed), so Upright shows no DPI controls for it.
+ ///
+ /// devices.verified.json "M720 Triathlon" (high): btid B015, wpid 405E; 0x0003 Transport IDs
+ /// btleid B015, wpid 405E. Table, controls, name and firmware from "M720 Triathlon
+ /// Multi-Device Mouse 405E.txt" (no versions printed).
+ public static let m720 = SimulatedDeviceProfile(
+ name: "M720 Triathlon Multi-Device Mouse",
+ codename: "M720 Triathlon",
+ kind: .mouse,
+ wirelessPID: 0x405E,
+ bluetoothPID: 0xB015,
+ unitID: 0x5A2B_3C4D,
+ modelIDs: ModelIDs(bluetoothLE: 0xB015, wireless: 0x405E),
+ serialNumber: "DEMO-0006",
+ features: [
+ .init(0x0000), .init(0x0001), .init(0x0003), .init(0x0005), .init(0x1D4B), .init(0x0020), .init(0x0021),
+ .init(0x0007), .init(0x1000), .init(0x1814), .init(0x1815), .init(0x1B04), .init(0x1C00), .init(0x2205),
+ .init(0x2100), .init(0x00C1), .init(0x00C2), .init(0x1802, flags: 0x60), .init(0x1803, flags: 0x60),
+ .init(0x1806, flags: 0x60), .init(0x1805, flags: 0x60), .init(0x1813, flags: 0x60), .init(0x1830, flags: 0x60),
+ .init(0x1861, flags: 0x60), .init(0x1890, flags: 0x60), .init(0x1891, flags: 0x60), .init(0x18A1, flags: 0x60),
+ .init(0x1DF3, flags: 0x60), .init(0x1E00, flags: 0x40), .init(0x1EB0, flags: 0x60), .init(0x18B1, flags: 0x60),
+ .init(0x1850, flags: 0x60), .init(0x1E22), .init(0x1F03, flags: 0x60), .init(0x18C0, flags: 0x60), .init(0x2121),
+ ],
+ controls: [
+ control(0x0050, [.mouseButton, .reprogrammable], group: 1, mask: g1),
+ control(0x0051, [.mouseButton, .reprogrammable], group: 1, mask: g1),
+ control(0x0052, persistentButton, group: 2, mask: g1g2, rawXY: true),
+ control(0x0053, persistentButton, group: 2, mask: g1g2, rawXY: true),
+ control(0x0056, persistentButton, group: 2, mask: g1g2, rawXY: true),
+ control(0x005B, persistentButton, group: 2, mask: g1g2, rawXY: true),
+ control(0x005D, persistentButton, group: 2, mask: g1g2, rawXY: true),
+ control(0x00D0, persistentButton, group: 2, mask: g1g2, rawXY: true),
+ virtualGestureButton(group: 3),
+ ],
+ firmware: [.bootloader("BOT", 0x34, 0x01, build: 0x0005), .firmware("MPM", 0x04, 0x10, build: 0x0013), .other],
+ battery: .status,
+ batteryLevel: 90,
+ hostCount: 3,
+ hostNames: demoHostNames
+ )
+
+ /// MX Ergo: a trackball (0x0005 kind 5) with tilt buttons and a precision-mode key, no
+ /// ADJUSTABLE_DPI.
+ ///
+ /// devices.verified.json "MX Ergo (Multi-Device Trackball)" (high): btid B01D, wpid 406F;
+ /// 0x0003 Transport IDs btleid B01D, wpid 406F. Table, controls, name and firmware from
+ /// "MX Ergo Multi-Device Trackball 406F.txt" (no versions printed).
+ public static let mxErgo = SimulatedDeviceProfile(
+ name: "MX Ergo Multi-Device Trackball",
+ codename: "MX Ergo",
+ kind: .trackball,
+ wirelessPID: 0x406F,
+ bluetoothPID: 0xB01D,
+ unitID: 0x6A2B_3C4D,
+ modelIDs: ModelIDs(bluetoothLE: 0xB01D, wireless: 0x406F),
+ serialNumber: "DEMO-0007",
+ features: [
+ .init(0x0000), .init(0x0001), .init(0x0003), .init(0x0005), .init(0x1D4B), .init(0x0007), .init(0x0020),
+ .init(0x0021), .init(0x1000), .init(0x1300), .init(0x1B04), .init(0x1C00), .init(0x2006), .init(0x2205),
+ .init(0x2100), .init(0x00C2), .init(0x1802, flags: 0x60), .init(0x1803, flags: 0x60), .init(0x1806, flags: 0x60),
+ .init(0x1805, flags: 0x60), .init(0x1813, flags: 0x60), .init(0x1814), .init(0x1815), .init(0x1830, flags: 0x60),
+ .init(0x1861, flags: 0x60), .init(0x1890, flags: 0x60), .init(0x1891, flags: 0x60), .init(0x18A1, flags: 0x60),
+ .init(0x1DF3, flags: 0x60), .init(0x1E00, flags: 0x40), .init(0x1EB0, flags: 0x60), .init(0x18B1, flags: 0x60),
+ .init(0x1850, flags: 0x60), .init(0x1F03, flags: 0x60), .init(0x18C0, flags: 0x60),
+ ],
+ controls: [
+ control(0x0050, [.mouseButton, .reprogrammable], group: 1, mask: g1),
+ control(0x0051, [.mouseButton, .reprogrammable], group: 1, mask: g1),
+ control(0x0052, persistentButton, group: 2, mask: g1g2, rawXY: true),
+ control(0x0053, persistentButton, group: 2, mask: g1g2, rawXY: true),
+ control(0x0056, persistentButton, group: 2, mask: g1g2, rawXY: true),
+ control(0x00ED, persistentButton, group: 2, mask: g1g2, rawXY: true),
+ control(0x005B, persistentButton, group: 2, mask: g1g2, rawXY: true),
+ control(0x005D, persistentButton, group: 2, mask: g1g2, rawXY: true),
+ virtualGestureButton(group: 3),
+ ],
+ firmware: [.bootloader("BOT", 0x49, 0x00, build: 0x0001), .firmware("MPM", 0x06, 0x03, build: 0x0022), .other],
+ battery: .status,
+ batteryLevel: 50,
+ hostCount: 3,
+ hostNames: demoHostNames
+ )
+
+ /// M185 / M235 / M310 (wpid 4055): a budget HID++ 2.0 mouse whose Left and Right buttons
+ /// are flagged divertable (Upright must still never divert them), no DPI, no Easy-Switch.
+ /// Nano receivers host 40xx mice like it.
+ ///
+ /// devices.verified.json "M185 / M235 / M310 (wpid 4055)" (medium); 0x0003 Transport IDs
+ /// wpid 4055 only. Table, controls, name and firmware from "Wireless Mouse M185,M235,M310
+ /// 4055.txt" (no versions printed, no bootloader entity).
+ public static let m185 = SimulatedDeviceProfile(
+ name: "Wireless Mouse",
+ codename: "M185/M235/M310",
+ kind: .mouse,
+ wirelessPID: 0x4055,
+ unitID: 0x7A2B_3C4D,
+ modelIDs: ModelIDs(wireless: 0x4055),
+ serialNumber: "DEMO-0008",
+ features: [
+ .init(0x0000), .init(0x0001), .init(0x0003), .init(0x0005), .init(0x0020), .init(0x1000),
+ .init(0x1810, flags: 0x60), .init(0x1830, flags: 0x60), .init(0x1802, flags: 0x60), .init(0x1862, flags: 0x60),
+ .init(0x1890, flags: 0x60), .init(0x18A0, flags: 0x60), .init(0x18B1, flags: 0x60), .init(0x1B04), .init(0x1D4B),
+ .init(0x1DF0, flags: 0x40), .init(0x1DF3, flags: 0x60), .init(0x1E00, flags: 0x40), .init(0x1EB0, flags: 0x60),
+ .init(0x1F03, flags: 0x60), .init(0x2130),
+ ],
+ controls: [
+ control(0x0050, [.mouseButton, .divertable], group: 1, mask: g1),
+ control(0x0051, [.mouseButton, .divertable], group: 1, mask: g1),
+ control(0x0052, divertableButton, group: 2, mask: g1g2, rawXY: true),
+ ],
+ firmware: [.firmware("RQM", 0x65, 0x00, build: 0x0003)],
+ battery: .status,
+ batteryLevel: 70
+ )
+
+ /// G502 Lightspeed: HID++ 4.2 with no REPROG_CONTROLS_V4 (its buttons live in onboard
+ /// profiles, 0x8100), so Upright reports it unsupported. Battery through 0x1001 (voltage).
+ ///
+ /// devices.verified.json "G502 Lightspeed (G502 Hero Wireless)" (medium): wpid 407F, usbid
+ /// C08D, Lightspeed receiver; 0x0003 Model ID 407FC08D0000. Table (with versions), name and
+ /// firmware from "G502 Lightspeed Wireless Gaming Mouse 407F.txt". The DPI list is a
+ /// stand-in; the dump shows 900 DPI in use.
+ public static let g502Lightspeed = SimulatedDeviceProfile(
+ name: "G502 LIGHTSPEED Wireless Gaming Mouse",
+ codename: "G502",
+ kind: .mouse,
+ protocolMajor: 4,
+ protocolMinor: 2,
+ wirelessPID: 0x407F,
+ usbPID: 0xC08D,
+ unitID: 0x9A2B_3C4D,
+ modelIDs: ModelIDs(wireless: 0x407F, usb: 0xC08D),
+ serialNumber: "DEMO-0010",
+ features: [
+ .init(0x0000), .init(0x0001), .init(0x0003, version: 2), .init(0x0005), .init(0x1D4B), .init(0x0020),
+ .init(0x1001, version: 2), .init(0x8070, version: 4), .init(0x1300), .init(0x8100), .init(0x8110), .init(0x8060),
+ .init(0x2201, version: 1), .init(0x1802, flags: 0x60), .init(0x1803, flags: 0x60), .init(0x1805, flags: 0x60),
+ .init(0x1806, version: 4, flags: 0x60), .init(0x1811, flags: 0x60), .init(0x1830, flags: 0x60),
+ .init(0x1890, version: 4, flags: 0x60), .init(0x1891, version: 4, flags: 0x60), .init(0x18A1, flags: 0x60),
+ .init(0x1801, flags: 0x60), .init(0x18B1, flags: 0x60), .init(0x1DF3, flags: 0x60), .init(0x1E00, flags: 0x40),
+ .init(0x1EB0, flags: 0x60), .init(0x1863, flags: 0x60), .init(0x1E22, flags: 0x60), .init(0x2121),
+ ],
+ firmware: [.bootloader("BOT", 0x92, 0x00, build: 0x0008), .firmware("MPM", 0x17, 0x00, build: 0x0008), .other],
+ dpiList: [100, step(50), 8000],
+ defaultDPI: 800,
+ battery: .voltage,
+ batteryLevel: 90
+ )
+
+ /// A mouse that reports no unit ID and no serial number (its `DeviceID` falls back to a
+ /// transient one): the MX Master 3 with both removed.
+ public static let mouseWithoutUnitID = mxMaster3.unit(nil, serialNumber: nil)
+
+ // MARK: Not mice
+
+ /// MX Keys: a keyboard (0x0005 kind 0, pairing kind 1) whose 0x1B04 table is full of
+ /// divertable keys. Upright must never write to it (the anti-hijack test).
+ ///
+ /// From "MX Keys Keyboard 408A.txt" (keyboards aren't in devices.verified.json): wpid 408A,
+ /// 0x0003 Transport IDs btleid B35B, wpid 408A, every feature version, name and firmware.
+ /// Key CIDs come from devices.verified.json `cidTableFull` by name; the six "… Fn" media keys
+ /// and "Lock PC" have no entry there and are left out.
+ public static let mxKeys = SimulatedDeviceProfile(
+ name: "MX Keys Wireless Keyboard",
+ codename: "MX Keys",
+ kind: .keyboard,
+ wirelessPID: 0x408A,
+ bluetoothPID: 0xB35B,
+ unitID: 0x8A2B_3C4D,
+ modelIDs: ModelIDs(bluetoothLE: 0xB35B, wireless: 0x408A),
+ serialNumber: "DEMO-0009",
+ features: [
+ .init(0x0000), .init(0x0001), .init(0x0003, version: 2), .init(0x0005), .init(0x1D4B), .init(0x0020),
+ .init(0x0007), .init(0x1000, version: 1), .init(0x1B04, version: 4), .init(0x1814, version: 1),
+ .init(0x1815, version: 1), .init(0x1982, version: 1), .init(0x40A3), .init(0x4100), .init(0x4220), .init(0x4521),
+ .init(0x4531, version: 1), .init(0x00C2), .init(0x1802, flags: 0x60), .init(0x1803, flags: 0x60),
+ .init(0x1806, version: 5, flags: 0x60), .init(0x1813, flags: 0x60), .init(0x1805, flags: 0x60),
+ .init(0x1830, flags: 0x60), .init(0x1890, version: 5, flags: 0x60), .init(0x1891, version: 5, flags: 0x60),
+ .init(0x18A1, flags: 0x60), .init(0x1DF3, flags: 0x60), .init(0x1E00, flags: 0x40), .init(0x1EB0, flags: 0x60),
+ .init(0x1861, flags: 0x60), .init(0x1A20, flags: 0x60),
+ ],
+ controls: [
+ control(0x00D1, [.nonStandard, .divertable, .persistentlyDivertable], analytics: true),
+ control(0x00D2, [.nonStandard, .divertable, .persistentlyDivertable], analytics: true),
+ control(0x00D3, [.nonStandard, .divertable, .persistentlyDivertable], analytics: true),
+ control(0x00C7, fnKey, position: 1, analytics: true),
+ control(0x00C8, fnKey, position: 2, analytics: true),
+ control(0x00E0, fnKey, position: 3, analytics: true),
+ control(0x00E1, fnKey, position: 4, analytics: true),
+ control(0x006E, fnKey, position: 5, analytics: true),
+ control(0x00E2, fnKey, position: 6, analytics: true),
+ control(0x00E3, fnKey, position: 7, analytics: true),
+ control(0x000A, [.nonStandard, .reprogrammable, .divertable, .persistentlyDivertable], analytics: true),
+ control(0x00BF, [.nonStandard, .reprogrammable, .divertable, .persistentlyDivertable], analytics: true),
+ control(0x00EA, [.nonStandard, .reprogrammable, .divertable, .persistentlyDivertable], analytics: true),
+ control(0x00EC, [.nonStandard, .divertable, .persistentlyDivertable], analytics: true),
+ control(0x00EB, [.nonStandard, .divertable, .persistentlyDivertable], analytics: true),
+ control(0x00DE, [.fnKey], analytics: true),
+ control(0x0034, [.nonStandard], analytics: true),
+ ],
+ firmware: [.bootloader("BL1", 0x08, 0x00, build: 0x0011), .firmware("MPK", 0x12, 0x00, build: 0x0011), .other],
+ battery: .status,
+ batteryLevel: 50,
+ hostCount: 3,
+ hostNames: demoHostNames
+ )
+
+ /// Wireless Mouse M510 (wpid 1025): HID++ 1.0 only, so every HID++ 2.0 request fails with
+ /// "invalid sub-ID" and Upright reports it unsupported.
+ ///
+ /// devices.verified.json "M510 (v2)" notes "older M510 wpid 1025 is HID++ 1.0"; name,
+ /// codename and protocol from "Wireless Mouse M510 1025.txt". Its unit ID is only what the
+ /// receiver lists as the slot's serial.
+ public static let m510HIDPP10 = SimulatedDeviceProfile(
+ name: "Wireless Mouse M510",
+ codename: "M510",
+ kind: .mouse,
+ protocolMajor: 1,
+ protocolMinor: 0,
+ wirelessPID: 0x1025,
+ unitID: 0xAA2B_3C4D
+ )
+
+ /// The three mice Demo Mode shows by default.
+ public static let demoMice: [SimulatedDeviceProfile] = [mxVertical, mxMaster3S, lift]
+
+ public static let all: [SimulatedDeviceProfile] = [
+ mxVertical, mxVerticalSecondUnit, mxMaster3S, lift, mxMaster3, m720, mxErgo, m185, g502Lightspeed,
+ mouseWithoutUnitID, mxKeys, m510HIDPP10,
+ ]
+
+ // MARK: Building blocks
+
+ /// Made up: real host names in the dumps are people's computers.
+ static let demoHostNames: [String?] = ["Demo Mac", nil, nil]
+
+ private static let g1: UInt8 = 0x01
+ private static let g1g2: UInt8 = 0x03
+ private static let g1g2g3: UInt8 = 0x07
+
+ /// "mse, reprogrammable, divertable"
+ private static let divertableButton: ControlFlags = [.mouseButton, .reprogrammable, .divertable]
+ /// "mse, reprogrammable, divertable, persistently divertable"
+ private static let persistentButton: ControlFlags = [.mouseButton, .reprogrammable, .divertable, .persistentlyDivertable]
+ /// "is FN, FN sensitive, reprogrammable, divertable, persistently divertable"
+ private static let fnKey: ControlFlags = [.fnKey, .fnSensitive, .reprogrammable, .divertable, .persistentlyDivertable]
+
+ /// A range step in getSensorDpiList (top three bits set).
+ private static func step(_ value: UInt16) -> UInt16 {
+ UInt16(0xE000) | value
+ }
+
+ /// One getCidInfo entry. Additional flags (Solaar's KeyFlag >> 8): raw XY 0x01, force raw
+ /// XY 0x02, analytics key events 0x04; `extra` carries bits the dump shows as unknown.
+ private static func control(
+ _ cid: UInt16,
+ _ flags: ControlFlags,
+ position: UInt8 = 0,
+ group: UInt8 = 0,
+ mask: UInt8 = 0,
+ rawXY: Bool = false,
+ forceRawXY: Bool = false,
+ analytics: Bool = false,
+ extra: UInt8 = 0
+ ) -> ControlInfo {
+ var additional = extra
+ if rawXY { additional |= 0x01 }
+ if forceRawXY { additional |= 0x02 }
+ if analytics { additional |= 0x04 }
+ return ControlInfo(cid: cid, taskID: 0, flags: flags, position: position, group: group, groupMask: mask, additionalFlags: additional)
+ }
+
+ /// 0x00D7 "divertable, virtual, raw XY, force raw XY", group mask empty.
+ private static func virtualGestureButton(group: UInt8) -> ControlInfo {
+ control(0x00D7, [.divertable, .virtual], group: group, rawXY: true, forceRawXY: true)
+ }
+}
diff --git a/UprightKit/Sources/UprightKit/Simulation/SimulatedDeviceProfile.swift b/UprightKit/Sources/UprightKit/Simulation/SimulatedDeviceProfile.swift
new file mode 100644
index 0000000..cde4562
--- /dev/null
+++ b/UprightKit/Sources/UprightKit/Simulation/SimulatedDeviceProfile.swift
@@ -0,0 +1,272 @@
+import Foundation
+
+/// Describes one simulated HID++ device: what it reports and how it answers. Profiles are
+/// plain values; the per-unit state (diversion, pointer speed, battery, link) lives in the
+/// transport that hosts them (`SimulatedReceiver`, `SimulatedDirect`).
+///
+/// Ready-made profiles are in `DeviceProfiles`. Unit IDs and serial numbers of simulated
+/// devices must be obviously fake (Demo Mode shows them).
+public struct SimulatedDeviceProfile: Hashable, Sendable {
+ /// One entry of the feature table.
+ public struct Feature: Hashable, Sendable {
+ public var id: UInt16
+ public var version: UInt8
+ /// Feature type flags as getFeature / getFeatureID report them (obsolete 0x80,
+ /// hidden 0x40, internal 0x20).
+ public var flags: UInt8
+
+ public init(_ id: UInt16, version: UInt8 = 0, flags: UInt8 = 0) {
+ self.id = id
+ self.version = version
+ self.flags = flags
+ }
+ }
+
+ /// Which battery feature the device reports.
+ public enum Battery: Hashable, Sendable {
+ /// UNIFIED_BATTERY (0x1004).
+ case unified
+ /// BATTERY_STATUS (0x1000).
+ case status
+ /// BATTERY_VOLTAGE (0x1001).
+ case voltage
+ case none
+
+ /// The feature ID, nil for `.none`.
+ public var featureID: UInt16? {
+ switch self {
+ case .unified: return 0x1004
+ case .status: return 0x1000
+ case .voltage: return 0x1001
+ case .none: return nil
+ }
+ }
+ }
+
+ /// One DEVICE_FW_VERSION (0x0003) entity, as getFwInfo reports it.
+ public struct Firmware: Hashable, Sendable {
+ public var kind: FirmwareInfo.Kind
+ /// Three ASCII characters ("RBM", "BOT"); empty for hardware entries.
+ public var prefix: String
+ /// BCD as reported: 0x16 is shown as "16".
+ public var number: UInt8
+ public var revision: UInt8
+ public var build: UInt16
+
+ public init(kind: FirmwareInfo.Kind, prefix: String = "", number: UInt8 = 0, revision: UInt8 = 0, build: UInt16 = 0) {
+ self.kind = kind
+ self.prefix = prefix
+ self.number = number
+ self.revision = revision
+ self.build = build
+ }
+
+ public static func firmware(_ prefix: String, _ number: UInt8, _ revision: UInt8, build: UInt16) -> Firmware {
+ Firmware(kind: .firmware, prefix: prefix, number: number, revision: revision, build: build)
+ }
+
+ public static func bootloader(_ prefix: String, _ number: UInt8, _ revision: UInt8, build: UInt16) -> Firmware {
+ Firmware(kind: .bootloader, prefix: prefix, number: number, revision: revision, build: build)
+ }
+
+ /// A hardware entry; `revision` is shown as a decimal number.
+ public static func hardware(_ revision: UInt8) -> Firmware {
+ Firmware(kind: .hardware, number: revision)
+ }
+
+ public static let other = Firmware(kind: .other)
+
+ /// getFwInfo reply parameters: [type, prefix (3), number, revision, build (2, BE)].
+ public var parameters: [UInt8] {
+ switch kind {
+ case .firmware, .bootloader:
+ var bytes: [UInt8] = [kind.rawValue]
+ bytes += Array((Array(prefix.utf8) + [0, 0, 0]).prefix(3))
+ bytes += [number, revision, UInt8(build >> 8), UInt8(build & 0xFF)]
+ return bytes
+ case .hardware:
+ return [kind.rawValue, number]
+ case .other:
+ return [kind.rawValue]
+ }
+ }
+ }
+
+ /// The 0x0005 name ("MX Master 3S").
+ public var name: String
+ /// What the receiver lists as the device's codename and what DEVICE_FRIENDLY_NAME (0x0007)
+ /// returns ("MX Vertical", "LIFT"). Defaults to `name`.
+ public var codename: String
+ public var kind: DeviceKind
+ public var protocolMajor: UInt8
+ public var protocolMinor: UInt8
+ /// What a Unifying/Nano receiver reports for this device in a slot (Bolt: the BLE PID).
+ public var wirelessPID: UInt16?
+ public var bluetoothPID: UInt16?
+ public var usbPID: UInt16?
+ /// 0x0003 function 0; also the serial a receiver lists for the slot. Nil reports zero.
+ public var unitID: UInt32?
+ public var modelIDs: ModelIDs
+ public var extendedModelID: UInt8
+ /// 0x0003 getDeviceSerialNumber (version 4 and later). Nil makes the function fail.
+ public var serialNumber: String?
+ /// In feature-table order; index 0 is always ROOT (0x0000).
+ public var features: [Feature]
+ public var controls: [ControlInfo]
+ /// 0x0003 entities, in order.
+ public var firmware: [Firmware]
+ /// getSensorDpiList values as the device encodes them (0xE000 | step marks a range step).
+ public var dpiList: [UInt16]
+ /// The sensor's default resolution; also where it starts and what it returns to when it
+ /// forgets a speed set over HID++.
+ public var defaultDPI: Int?
+ public var battery: Battery
+ /// The battery level the device starts with (percent).
+ public var batteryLevel: Int
+ public var hostCount: Int
+ /// HOSTS_INFO names by channel; missing or nil entries are unnamed.
+ public var hostNames: [String?]
+
+ public init(
+ name: String,
+ codename: String? = nil,
+ kind: DeviceKind = .mouse,
+ protocolMajor: UInt8 = 4,
+ protocolMinor: UInt8 = 5,
+ wirelessPID: UInt16? = nil,
+ bluetoothPID: UInt16? = nil,
+ usbPID: UInt16? = nil,
+ unitID: UInt32? = nil,
+ modelIDs: ModelIDs = ModelIDs(),
+ extendedModelID: UInt8 = 0,
+ serialNumber: String? = nil,
+ features: [Feature] = [],
+ controls: [ControlInfo] = [],
+ firmware: [Firmware] = [],
+ dpiList: [UInt16] = [],
+ defaultDPI: Int? = nil,
+ battery: Battery = .none,
+ batteryLevel: Int = 80,
+ hostCount: Int = 0,
+ hostNames: [String?] = []
+ ) {
+ self.name = name
+ self.codename = codename ?? name
+ self.kind = kind
+ self.protocolMajor = protocolMajor
+ self.protocolMinor = protocolMinor
+ self.wirelessPID = wirelessPID
+ self.bluetoothPID = bluetoothPID
+ self.usbPID = usbPID
+ self.unitID = unitID
+ self.modelIDs = modelIDs
+ self.extendedModelID = extendedModelID
+ self.serialNumber = serialNumber
+ self.features = features
+ self.controls = controls
+ self.firmware = firmware
+ self.dpiList = dpiList
+ self.defaultDPI = defaultDPI
+ self.battery = battery
+ self.batteryLevel = batteryLevel
+ self.hostCount = hostCount
+ self.hostNames = hostNames
+ }
+
+ // MARK: Lookups
+
+ /// The feature-table index of `id`, as the device reports it.
+ public func featureIndex(of id: UInt16) -> UInt8? {
+ features.firstIndex { $0.id == id }.map(UInt8.init)
+ }
+
+ public func feature(_ id: UInt16) -> Feature? {
+ features.first { $0.id == id }
+ }
+
+ public func control(_ cid: UInt16) -> ControlInfo? {
+ controls.first { $0.cid == cid }
+ }
+
+ /// Answers HID++ 2.0 feature requests; false for a HID++ 1.0 device.
+ public var speaksHIDPP20: Bool { protocolMajor >= 2 }
+
+ /// The values getSensorDpiList describes, ascending.
+ public var supportedDPI: [Int] {
+ DPIInfo.parseList(dpiList.flatMap { [UInt8($0 >> 8), UInt8($0 & 0xFF)] }).values
+ }
+
+ /// DEVICE_NAME getDeviceType (0x0005 function 2); nil when the device can't tell.
+ public var deviceTypeCode: UInt8? {
+ switch kind {
+ case .keyboard: return 0
+ case .remote: return 1
+ case .numpad: return 2
+ case .mouse: return 3
+ case .touchpad: return 4
+ case .trackball: return 5
+ case .presenter: return 6
+ case .receiver: return 7
+ case .other(let value): return value
+ case .unknown: return nil
+ }
+ }
+
+ /// The HID++ 1.0 kind nibble a receiver reports in pairing records and 0x41 notifications.
+ public var pairingKindNibble: UInt8 {
+ switch kind {
+ case .unknown, .receiver: return 0
+ case .keyboard: return 1
+ case .mouse: return 2
+ case .numpad: return 3
+ case .presenter: return 4
+ case .remote: return 7
+ case .trackball: return 8
+ case .touchpad: return 9
+ case .other(let value): return value & 0x0F
+ }
+ }
+
+ /// DEVICE_FW_VERSION getEntityCount reply (0x0003 function 0): [entity count, unit ID (4),
+ /// transport (2), model IDs (6), extended model ID, capabilities]. One big-endian ID per
+ /// transport bit (Bluetooth 0x01, BLE 0x02, wireless 0x04, USB 0x08), packed in bit order;
+ /// at most three fit.
+ public var firmwareIDsReply: [UInt8] {
+ let unit = unitID ?? 0
+ var bits: UInt8 = 0
+ var ids: [UInt8] = []
+ let transports: [(bit: UInt8, id: UInt16?)] = [
+ (0x01, modelIDs.bluetooth), (0x02, modelIDs.bluetoothLE), (0x04, modelIDs.wireless), (0x08, modelIDs.usb),
+ ]
+ for (bit, id) in transports {
+ guard let id else { continue }
+ bits |= bit
+ ids += [UInt8(id >> 8), UInt8(id & 0xFF)]
+ }
+ ids = Array((ids + [0, 0, 0, 0, 0, 0]).prefix(6))
+ let hasSerial = serialNumber != nil && (feature(0x0003)?.version ?? 0) >= 4
+ var reply: [UInt8] = [UInt8(clamping: firmware.count)]
+ reply += [UInt8(unit >> 24), UInt8((unit >> 16) & 0xFF), UInt8((unit >> 8) & 0xFF), UInt8(unit & 0xFF)]
+ reply += [0x00, bits]
+ reply += ids
+ reply += [extendedModelID, hasSerial ? 0x01 : 0x00]
+ return reply
+ }
+
+ // MARK: Variants
+
+ /// The same model with another unit ID and serial number: an identical second mouse.
+ public func unit(_ unitID: UInt32?, serialNumber: String?) -> SimulatedDeviceProfile {
+ var copy = self
+ copy.unitID = unitID
+ copy.serialNumber = serialNumber
+ return copy
+ }
+
+ /// The same device under another 0x0005 name (the codename is kept).
+ public func named(_ name: String) -> SimulatedDeviceProfile {
+ var copy = self
+ copy.name = name
+ return copy
+ }
+}
diff --git a/UprightKit/Sources/UprightKit/Simulation/SimulatedReceiver.swift b/UprightKit/Sources/UprightKit/Simulation/SimulatedReceiver.swift
new file mode 100644
index 0000000..58ea319
--- /dev/null
+++ b/UprightKit/Sources/UprightKit/Simulation/SimulatedReceiver.swift
@@ -0,0 +1,907 @@
+import Foundation
+
+/// A software HID++ interface hosting simulated devices (`SimulatedDeviceProfile`) by device
+/// index: a receiver with slots 1...6 (`SimulatedReceiver`) or one device connected directly at
+/// 0xFF (`SimulatedDirect`). Used by tests and by Demo Mode in place of an IOKit transport.
+///
+/// Devices answer HID++ 2.0 requests from their profile and keep per-unit state: diversion and
+/// raw XY per control, pointer speed, Easy-Switch channel, battery, and whether they're awake.
+/// Replies and reports arrive asynchronously on a private serial queue, like IOKit's, after the
+/// transport's `latency` plus the device's own.
+///
+/// "Slot" is the device index throughout: 1...6 on a receiver, 0xFF on a direct link.
+public class SimulatedHIDPPTransport: HIDPPTransport, @unchecked Sendable {
+ public var onReport: (([UInt8]) -> Void)?
+ public var onClose: (() -> Void)?
+
+ /// One simulated device's live state.
+ struct Unit {
+ var profile: SimulatedDeviceProfile
+ var awake = true
+ var diverted: Set = []
+ var rawXY: Set = []
+ var dpi: Int
+ var currentHost: UInt8 = 0
+ var batteryLevel: Int
+ var charging = false
+ var dropsRequests = false
+ var latency: TimeInterval = 0
+ var diversionFaults: [UInt16: DiversionFault] = [:]
+ var hostSwitches: [UInt8] = []
+
+ init(profile: SimulatedDeviceProfile) {
+ self.profile = profile
+ dpi = profile.defaultDPI ?? 0
+ batteryLevel = profile.batteryLevel
+ }
+
+ /// What a power cycle or reconnect clears: temporary diversion and the HID++ pointer speed.
+ mutating func forgetVolatileState() {
+ diverted.removeAll()
+ rawXY.removeAll()
+ dpi = profile.defaultDPI ?? 0
+ }
+ }
+
+ struct DiversionFault {
+ var code: UInt8
+ var remaining: Int
+ }
+
+ /// What one request produces.
+ struct Outcome {
+ /// The answer to the request.
+ var reply: HIDPPMessage?
+ /// Sent after the reply (announcements, the link loss after an Easy-Switch).
+ var notifications: [HIDPPMessage] = []
+ var latency: TimeInterval = 0
+ /// Nobody answers: the request times out on the host.
+ var dropped = false
+
+ static let ignored = Outcome(dropped: true)
+ }
+
+ private struct RequestKey: Hashable {
+ var index: UInt8
+ var feature: UInt16
+ var function: UInt8
+ }
+
+ let lock = NSLock()
+ private let queue = DispatchQueue(label: "upright.simulated-hidpp")
+
+ /// Devices by index. Guarded by `lock`, like every other stored property here.
+ var units: [UInt8: Unit] = [:]
+ private var _dropsRequests = false
+ private var _latency: TimeInterval = 0
+ private var _unansweredRequestWindow: TimeInterval = 0
+ private var _isDisconnected = false
+ private var _sent: [[UInt8]] = []
+ private var featureRequests: [RequestKey: Int] = [:]
+ private var pings: [UInt8: Int] = [:]
+ var registerWrites: [UInt8: Int] = [:]
+ var registerReads: [UInt8: Int] = [:]
+ private var inFlight = 0
+ private var inFlightMax = 0
+
+ /// What a hub should treat this interface as.
+ public let transportKind: TransportKind
+
+ init(units: [UInt8: SimulatedDeviceProfile], transportKind: TransportKind) {
+ self.units = units.mapValues(Unit.init(profile:))
+ self.transportKind = transportKind
+ }
+
+ func synchronized(_ body: () throws -> T) rethrows -> T {
+ lock.lock()
+ defer { lock.unlock() }
+ return try body()
+ }
+
+ // MARK: Hooks for receivers (called with `lock` held)
+
+ var isReceiver: Bool { false }
+
+ /// Answers a request addressed to the receiver itself (device index 0xFF).
+ func receiverOutcome(for request: HIDPPMessage) -> Outcome { .ignored }
+
+ /// The receiver's notification that `slot`'s link went up or down, if it sends one.
+ func linkNotification(slot: UInt8, up: Bool) -> HIDPPMessage? { nil }
+
+ // MARK: HIDPPTransport
+
+ public func send(_ bytes: [UInt8]) throws {
+ let outcome: Outcome? = synchronized {
+ guard !_isDisconnected else { return nil }
+ _sent.append(bytes)
+ guard let request = HIDPPMessage(bytes: bytes) else { return nil }
+ count(request)
+ let outcome = route(request)
+ if outcome.reply != nil {
+ inFlight += 1
+ inFlightMax = max(inFlightMax, inFlight)
+ } else if outcome.dropped, _unansweredRequestWindow > 0 {
+ inFlight += 1
+ inFlightMax = max(inFlightMax, inFlight)
+ queue.asyncAfter(deadline: .now() + _unansweredRequestWindow) { [self] in
+ synchronized { inFlight -= 1 }
+ }
+ }
+ return outcome
+ }
+ guard let outcome else { return }
+ if let reply = outcome.reply {
+ emit(reply, after: outcome.latency, answering: true)
+ }
+ for message in outcome.notifications {
+ emit(message, after: outcome.latency)
+ }
+ }
+
+ /// The interface goes away (receiver unplugged, Bluetooth link dropped for good).
+ public func disconnect() {
+ synchronized { _isDisconnected = true }
+ queue.async { [self] in onClose?() }
+ }
+
+ public var isDisconnected: Bool { synchronized { _isDisconnected } }
+
+ // MARK: Faults
+
+ /// While true nothing behind the interface answers device requests, so they time out.
+ /// Devices still send their own reports, and a receiver still answers register requests.
+ public var dropsRequests: Bool {
+ get { synchronized { _dropsRequests } }
+ set { synchronized { _dropsRequests = newValue } }
+ }
+
+ /// Delay before every reply and report, added to each device's own latency.
+ public var latency: TimeInterval {
+ get { synchronized { _latency } }
+ set { synchronized { _latency = newValue } }
+ }
+
+ /// While true the device in `slot` ignores requests (it still sends reports).
+ public func setDropsRequests(_ drops: Bool, slot: UInt8) {
+ synchronized { units[slot]?.dropsRequests = drops }
+ }
+
+ /// Extra delay before the replies and reports of the device in `slot`.
+ public func setLatency(_ latency: TimeInterval, slot: UInt8) {
+ synchronized { units[slot]?.latency = latency }
+ }
+
+ /// Makes setCidReporting for `cid` on the device in `slot` fail with HID++ 2.0 error
+ /// `code`, `times` times.
+ public func failDiversion(of cid: UInt16, code: UInt8, times: Int = .max, slot: UInt8) {
+ synchronized { units[slot]?.diversionFaults[cid] = DiversionFault(code: code, remaining: times) }
+ }
+
+ /// How long a request nobody answers counts towards `requestsInFlightMax` (set it a little
+ /// below the host's request timeout). 0, the default, doesn't count unanswered requests.
+ public var unansweredRequestWindow: TimeInterval {
+ get { synchronized { _unansweredRequestWindow } }
+ set { synchronized { _unansweredRequestWindow = newValue } }
+ }
+
+ // MARK: Device state
+
+ /// Indices with a device, ascending.
+ public var occupiedSlots: [UInt8] { synchronized { units.keys.sorted() } }
+
+ public func profile(slot: UInt8) -> SimulatedDeviceProfile? { synchronized { units[slot]?.profile } }
+ public func isAwake(slot: UInt8) -> Bool { synchronized { units[slot]?.awake ?? false } }
+ public func diverted(slot: UInt8) -> Set { synchronized { units[slot]?.diverted ?? [] } }
+ public func rawXY(slot: UInt8) -> Set { synchronized { units[slot]?.rawXY ?? [] } }
+ /// The sensor resolution the device is using.
+ public func dpi(slot: UInt8) -> Int? { synchronized { units[slot]?.dpi } }
+ /// The zero-based Easy-Switch channel.
+ public func currentHost(slot: UInt8) -> Int? { synchronized { units[slot].map { Int($0.currentHost) } } }
+ /// Every setCurrentHost the device received.
+ public func hostSwitches(slot: UInt8) -> [UInt8] { synchronized { units[slot]?.hostSwitches ?? [] } }
+ public func batteryLevel(slot: UInt8) -> Int? { synchronized { units[slot]?.batteryLevel } }
+
+ // MARK: Device behavior
+
+ /// Reports the currently held controls (divertedButtonsEvent). Like real mice, only the
+ /// diverted ones are listed; the report is sent even when none are.
+ public func hold(_ cids: [UInt16], slot: UInt8) {
+ report(slot: slot) { unit in
+ guard let reprog = unit.profile.featureIndex(of: 0x1B04) else { return nil }
+ let reported = cids.filter(unit.diverted.contains).prefix(4)
+ return HIDPPMessage(reportID: .long, deviceIndex: slot, subID: reprog, address: 0x00,
+ parameters: reported.flatMap { [UInt8($0 >> 8), UInt8($0 & 0xFF)] })
+ }
+ }
+
+ /// Every control is released.
+ public func release(slot: UInt8) {
+ hold([], slot: slot)
+ }
+
+ /// Raw movement while a raw-XY control is held (divertedRawMouseXYEvent).
+ public func move(dx: Int, dy: Int, slot: UInt8) {
+ report(slot: slot) { unit in
+ guard let reprog = unit.profile.featureIndex(of: 0x1B04) else { return nil }
+ let x = UInt16(bitPattern: Int16(clamping: dx))
+ let y = UInt16(bitPattern: Int16(clamping: dy))
+ return HIDPPMessage(reportID: .long, deviceIndex: slot, subID: reprog, address: 0x10,
+ parameters: [UInt8(x >> 8), UInt8(x & 0xFF), UInt8(y >> 8), UInt8(y & 0xFF)])
+ }
+ }
+
+ /// The device turns off or sleeps: requests fail, and a receiver reports the link loss.
+ public func goToSleep(slot: UInt8) {
+ let pending: (HIDPPMessage?, TimeInterval)? = synchronized {
+ guard units[slot] != nil else { return nil }
+ units[slot]?.awake = false
+ return (linkNotification(slot: slot, up: false), latency(of: slot))
+ }
+ if case let (message?, delay)? = pending { emit(message, after: delay) }
+ }
+
+ /// The device powers back on. `clearingDiversion` drops its temporary diversion and pointer
+ /// speed; `announce` makes a receiver report the link-up (0x41); `reportReconnect` also
+ /// sends the device's own WIRELESS_DEVICE_STATUS (0x1D4B) "reconnection" report.
+ public func wakeUp(slot: UInt8, clearingDiversion: Bool = true, announce: Bool = true, reportReconnect: Bool = false) {
+ let pending: ([HIDPPMessage], TimeInterval)? = synchronized {
+ guard units[slot] != nil else { return nil }
+ units[slot]?.awake = true
+ if clearingDiversion { units[slot]?.forgetVolatileState() }
+ var messages: [HIDPPMessage] = []
+ if announce, let notification = linkNotification(slot: slot, up: true) { messages.append(notification) }
+ if reportReconnect, let status = wirelessStatusEvent(slot: slot) { messages.append(status) }
+ return (messages, latency(of: slot))
+ }
+ if let pending { pending.0.forEach { emit($0, after: pending.1) } }
+ }
+
+ /// The device lost its configuration and says so with 0x1D4B "reconnection".
+ public func reportReconnect(slot: UInt8) {
+ report(slot: slot) { unit in
+ unit.forgetVolatileState()
+ return self.wirelessStatusEvent(slot: slot, profile: unit.profile)
+ }
+ }
+
+ /// The device silently loses its diversion state and pointer speed (no report is sent).
+ public func forgetDiversion(slot: UInt8) {
+ synchronized { units[slot]?.forgetVolatileState() }
+ }
+
+ /// The pointer speed changes on the device itself (its DPI button while not diverted);
+ /// nothing is reported.
+ public func setDPINatively(_ dpi: Int, slot: UInt8) {
+ synchronized { units[slot]?.dpi = dpi }
+ }
+
+ /// The battery level changes and the device broadcasts it through its battery feature.
+ public func reportBattery(percentage: Int, charging: Bool = false, slot: UInt8) {
+ report(slot: slot) { unit in
+ unit.batteryLevel = percentage
+ unit.charging = charging
+ guard let featureID = unit.profile.battery.featureID,
+ let index = unit.profile.featureIndex(of: featureID),
+ let parameters = Self.batteryParameters(unit) else { return nil }
+ return HIDPPMessage(reportID: .long, deviceIndex: slot, subID: index, address: 0x00, parameters: parameters)
+ }
+ }
+
+ // MARK: Traffic
+
+ /// Every report sent to the interface, in order.
+ public var sentReports: [[UInt8]] { synchronized { _sent } }
+
+ /// HID++ 2.0 requests for `feature` (by feature ID, looked up in the addressed device's own
+ /// table when it was sent), optionally only `function` and one slot.
+ public func requestCount(feature: UInt16, function: UInt8? = nil, slot: UInt8? = nil) -> Int {
+ synchronized {
+ featureRequests.filter { key, _ in
+ key.feature == feature && (function.map { key.function == $0 } ?? true) && (slot.map { key.index == $0 } ?? true)
+ }.values.reduce(0, +)
+ }
+ }
+
+ /// setCidReporting requests sent to every device.
+ public var setReportingCount: Int { requestCount(feature: 0x1B04, function: 3) }
+
+ /// setCidReporting requests sent to the device in `slot`.
+ public func setReportingCount(slot: UInt8) -> Int { requestCount(feature: 0x1B04, function: 3, slot: slot) }
+
+ /// Root.getProtocolVersion (ping) requests sent to `slot`, whether or not a device is there.
+ public func pingCount(slot: UInt8) -> Int { synchronized { pings[slot] ?? 0 } }
+
+ /// The most requests ever waiting for their reply at once. A request counts from the moment
+ /// it is sent until its reply is delivered (see `unansweredRequestWindow` for requests
+ /// nobody answers); one request gate per transport keeps it at 1. With no latency set,
+ /// replies may be delivered before the host gets to send another request: set `latency`
+ /// to make overlapping requests observable.
+ public var requestsInFlightMax: Int { synchronized { inFlightMax } }
+
+ /// Clears the sent-report log and every counter.
+ public func resetTraffic() {
+ synchronized {
+ _sent.removeAll()
+ featureRequests.removeAll()
+ pings.removeAll()
+ registerWrites.removeAll()
+ registerReads.removeAll()
+ inFlightMax = inFlight
+ }
+ }
+
+ // MARK: Delivery
+
+ fileprivate func emit(_ message: HIDPPMessage, after delay: TimeInterval, answering: Bool = false) {
+ let bytes = message.bytes
+ let deliver: @Sendable () -> Void = { [self] in
+ if answering { synchronized { inFlight -= 1 } }
+ onReport?(bytes)
+ }
+ if delay > 0 {
+ queue.asyncAfter(deadline: .now() + delay, execute: deliver)
+ } else {
+ queue.async(execute: deliver)
+ }
+ }
+
+ /// Lets the device in `slot` change its state and send one report.
+ private func report(slot: UInt8, _ make: (inout Unit) -> HIDPPMessage?) {
+ let pending: (HIDPPMessage, TimeInterval)? = synchronized {
+ guard var unit = units[slot] else { return nil }
+ let message = make(&unit)
+ units[slot] = unit
+ return message.map { ($0, latency(of: slot)) }
+ }
+ if let pending { emit(pending.0, after: pending.1) }
+ }
+
+ /// Requires `lock`.
+ func latency(of slot: UInt8) -> TimeInterval {
+ _latency + (units[slot]?.latency ?? 0)
+ }
+
+ /// 0x1D4B wirelessDeviceStatusBroadcastEvent: [status 1 = reconnection, request 1 =
+ /// software reconfiguration needed, reason]. Requires `lock`.
+ func wirelessStatusEvent(slot: UInt8) -> HIDPPMessage? {
+ units[slot].flatMap { wirelessStatusEvent(slot: slot, profile: $0.profile) }
+ }
+
+ private func wirelessStatusEvent(slot: UInt8, profile: SimulatedDeviceProfile) -> HIDPPMessage? {
+ guard let index = profile.featureIndex(of: 0x1D4B) else { return nil }
+ return HIDPPMessage(reportID: .long, deviceIndex: slot, subID: index, address: 0x00, parameters: [1, 1, 0])
+ }
+
+ // MARK: Requests (called with `lock` held)
+
+ private func count(_ request: HIDPPMessage) {
+ if isReceiver, request.deviceIndex == 0xFF {
+ switch request.subID {
+ case 0x80, 0x82: registerWrites[request.address, default: 0] += 1
+ case 0x81, 0x83: registerReads[request.address, default: 0] += 1
+ default: break
+ }
+ return
+ }
+ if request.featureIndex == 0, request.function == 1 {
+ pings[request.deviceIndex, default: 0] += 1
+ }
+ guard let profile = units[request.deviceIndex]?.profile, profile.speaksHIDPP20,
+ Int(request.featureIndex) < profile.features.count else { return }
+ let key = RequestKey(index: request.deviceIndex, feature: profile.features[Int(request.featureIndex)].id, function: request.function)
+ featureRequests[key, default: 0] += 1
+ }
+
+ private func route(_ request: HIDPPMessage) -> Outcome {
+ let index = request.deviceIndex
+ if isReceiver {
+ if index == 0xFF {
+ var outcome = receiverOutcome(for: request)
+ outcome.latency = _latency
+ return outcome
+ }
+ } else if index != 0xFF {
+ return .ignored
+ }
+ if _dropsRequests { return .ignored }
+ guard var unit = units[index] else {
+ return isReceiver ? Outcome(reply: Self.error10(request, code: 0x08), latency: _latency) : .ignored
+ }
+ guard unit.awake else {
+ return isReceiver ? Outcome(reply: Self.error10(request, code: 0x09), latency: _latency) : .ignored
+ }
+ if unit.dropsRequests { return .ignored }
+
+ var outcome = Outcome(latency: latency(of: index))
+ if !unit.profile.speaksHIDPP20 {
+ // HID++ 1.0 devices reject HID++ 2.0 feature requests, the ping included.
+ outcome.reply = Self.error10(request, code: 0x01)
+ return outcome
+ }
+ switch respond(to: request, unit: &unit) {
+ case .reply(let reply):
+ outcome.reply = reply
+ case .leaveForHost(let host):
+ // setCurrentHost: the device leaves for the other computer without answering.
+ unit.hostSwitches.append(host)
+ unit.currentHost = host
+ unit.awake = false
+ units[index] = unit
+ if let notification = linkNotification(slot: index, up: false) { outcome.notifications.append(notification) }
+ return outcome
+ }
+ units[index] = unit
+ return outcome
+ }
+
+ private enum Response {
+ case reply(HIDPPMessage)
+ case leaveForHost(UInt8)
+ }
+
+ /// One HID++ 2.0 device answering from its profile.
+ private func respond(to request: HIDPPMessage, unit: inout Unit) -> Response {
+ let p = request.parameters
+ let profile = unit.profile
+ guard Int(request.featureIndex) < profile.features.count else { return .reply(Self.error20(request, code: 0x06)) }
+ let feature = profile.features[Int(request.featureIndex)]
+
+ func with(_ parameters: [UInt8]) -> Response {
+ .reply(HIDPPMessage(reportID: request.reportID, deviceIndex: request.deviceIndex, subID: request.subID,
+ address: request.address, parameters: parameters))
+ }
+ func error(_ code: UInt8) -> Response { .reply(Self.error20(request, code: code)) }
+
+ switch (feature.id, request.function) {
+ // ROOT
+ case (0x0000, 0):
+ let id = p.uint16(at: 0)
+ guard let index = profile.featureIndex(of: id), let entry = profile.feature(id) else { return with([0, 0, 0]) }
+ return with([index, entry.flags, entry.version])
+ case (0x0000, 1):
+ return with([profile.protocolMajor, profile.protocolMinor, p[2]])
+
+ // FEATURE_SET
+ case (0x0001, 0):
+ return with([UInt8(clamping: profile.features.count - 1)])
+ case (0x0001, 1):
+ guard Int(p[0]) < profile.features.count else { return error(0x02) }
+ let entry = profile.features[Int(p[0])]
+ return with(entry.id.bigEndianBytes + [entry.flags, entry.version])
+
+ // DEVICE_FW_VERSION
+ case (0x0003, 0):
+ return with(profile.firmwareIDsReply)
+ case (0x0003, 1):
+ guard Int(p[0]) < profile.firmware.count else { return error(0x02) }
+ return with(profile.firmware[Int(p[0])].parameters)
+ case (0x0003, 2):
+ guard feature.version >= 4, let serial = profile.serialNumber else { return error(0x07) }
+ return with(Array(serial.utf8.prefix(12)))
+
+ // DEVICE_NAME, DEVICE_FRIENDLY_NAME
+ case (0x0005, 0):
+ return with([UInt8(clamping: profile.name.utf8.count)])
+ case (0x0005, 1):
+ return with(Array(profile.name.utf8.dropFirst(Int(p[0])).prefix(16)))
+ case (0x0005, 2):
+ guard let type = profile.deviceTypeCode else { return error(0x07) }
+ return with([type])
+ case (0x0007, 0):
+ let length = UInt8(clamping: profile.codename.utf8.count)
+ return with([length, 32, length])
+ case (0x0007, 1), (0x0007, 2):
+ return with([p[0]] + Array(profile.codename.utf8.dropFirst(Int(p[0])).prefix(15)))
+
+ // Battery
+ case (0x1000, 0), (0x1004, 1), (0x1001, 0):
+ guard feature.id == profile.battery.featureID else { return error(0x07) }
+ return Self.batteryParameters(unit).map(with) ?? error(0x07)
+ case (0x1004, 0):
+ // getCapabilities: every level flag; flags bit 1 = state of charge in percent.
+ return with([0x0F, 0x02])
+
+ // ADJUSTABLE_DPI
+ case (0x2201, 0):
+ return with([1])
+ case (0x2201, 1):
+ return with([p[0]] + profile.dpiList.flatMap(\.bigEndianBytes))
+ case (0x2201, 2):
+ return with([p[0]] + UInt16(clamping: unit.dpi).bigEndianBytes + UInt16(clamping: profile.defaultDPI ?? 0).bigEndianBytes)
+ case (0x2201, 3):
+ let dpi = Int(p.uint16(at: 1))
+ guard profile.supportedDPI.contains(dpi) else { return error(0x02) }
+ unit.dpi = dpi
+ return with(Array(p.prefix(3)))
+
+ // CHANGE_HOST, HOSTS_INFO
+ case (0x1814, 0):
+ return with([UInt8(clamping: profile.hostCount), unit.currentHost, 0])
+ case (0x1814, 1):
+ return .leaveForHost(p[0])
+ case (0x1815, 0):
+ return with([0x01, 0, UInt8(clamping: profile.hostCount), unit.currentHost])
+ case (0x1815, 1):
+ guard Int(p[0]) < profile.hostCount else { return error(0x02) }
+ let name = Self.hostName(profile, Int(p[0]))
+ return with([p[0], name == nil ? 0 : 1, 0x01, 1, UInt8(clamping: name?.utf8.count ?? 0), 32])
+ case (0x1815, 3):
+ guard Int(p[0]) < profile.hostCount, let name = Self.hostName(profile, Int(p[0])) else { return error(0x02) }
+ return with([p[0], p[1]] + Array(name.utf8.dropFirst(Int(p[1])).prefix(14)))
+
+ // REPROG_CONTROLS_V4
+ case (0x1B04, 0):
+ return with([UInt8(clamping: profile.controls.count)])
+ case (0x1B04, 1):
+ guard Int(p[0]) < profile.controls.count else { return error(0x02) }
+ let c = profile.controls[Int(p[0])]
+ return with(c.cid.bigEndianBytes + c.taskID.bigEndianBytes + [c.flags.rawValue, c.position, c.group, c.groupMask, c.additionalFlags])
+ case (0x1B04, 2):
+ let cid = p.uint16(at: 0)
+ guard profile.control(cid) != nil else { return error(0x02) }
+ let flags: UInt8 = (unit.diverted.contains(cid) ? 0x01 : 0) | (unit.rawXY.contains(cid) ? 0x10 : 0)
+ return with(cid.bigEndianBytes + [flags, 0, 0])
+ case (0x1B04, 3):
+ let cid = p.uint16(at: 0)
+ guard let control = profile.control(cid), control.flags.contains(.divertable) else { return error(0x02) }
+ if p[2] & 0x30 == 0x30, !control.supportsRawXY { return error(0x02) }
+ if let fault = unit.diversionFaults[cid], fault.remaining > 0 {
+ unit.diversionFaults[cid]?.remaining -= 1
+ return error(fault.code)
+ }
+ if p[2] & 0x02 != 0 {
+ if p[2] & 0x01 != 0 { unit.diverted.insert(cid) } else { unit.diverted.remove(cid) }
+ }
+ if p[2] & 0x20 != 0 {
+ if p[2] & 0x10 != 0 { unit.rawXY.insert(cid) } else { unit.rawXY.remove(cid) }
+ }
+ return with(Array(p.prefix(5)))
+
+ default:
+ return error(0x07)
+ }
+ }
+
+ private static func hostName(_ profile: SimulatedDeviceProfile, _ index: Int) -> String? {
+ profile.hostNames.indices.contains(index) ? profile.hostNames[index] : nil
+ }
+
+ /// The battery report of the device's battery feature (also its broadcast event).
+ private static func batteryParameters(_ unit: Unit) -> [UInt8]? {
+ let level = UInt8(clamping: max(0, min(100, unit.batteryLevel)))
+ switch unit.profile.battery {
+ case .status:
+ // [level %, next level %, status: 0 discharging, 1 recharging].
+ let next: UInt8 = level > 50 ? 50 : level > 20 ? 20 : 5
+ return [level, next, unit.charging ? 1 : 0]
+ case .unified:
+ // [state of charge %, level flags (8 full, 4 good, 2 low, 1 critical), charging, external power].
+ let flags: UInt8 = level >= 80 ? 0x08 : level >= 30 ? 0x04 : level > 5 ? 0x02 : 0x01
+ return [level, flags, unit.charging ? 1 : 0, unit.charging ? 1 : 0]
+ case .voltage:
+ // [millivolts (2, BE), flags: bit 7 charging].
+ let millivolts = millivolts(forPercentage: Int(level))
+ return [UInt8(millivolts >> 8), UInt8(millivolts & 0xFF), unit.charging ? 0x80 : 0x00]
+ case .none:
+ return nil
+ }
+ }
+
+ /// Inverts the voltage table Solaar uses to estimate a 0x1001 level.
+ private static func millivolts(forPercentage percentage: Int) -> UInt16 {
+ let table: [(millivolts: Int, percent: Int)] = [
+ (4186, 100), (4067, 90), (3989, 80), (3922, 70), (3859, 60), (3811, 50), (3778, 40),
+ (3751, 30), (3717, 20), (3671, 10), (3646, 5), (3579, 2), (3500, 0),
+ ]
+ for (upper, lower) in zip(table, table.dropFirst()) where percentage >= lower.percent {
+ let span = upper.percent - lower.percent
+ let fraction = span == 0 ? 0 : Double(percentage - lower.percent) / Double(span)
+ return UInt16(Double(lower.millivolts) + fraction * Double(upper.millivolts - lower.millivolts))
+ }
+ return 3500
+ }
+
+ static func error10(_ request: HIDPPMessage, code: UInt8) -> HIDPPMessage {
+ HIDPPMessage(reportID: .short, deviceIndex: request.deviceIndex, subID: 0x8F, address: request.subID, parameters: [request.address, code, 0])
+ }
+
+ static func error20(_ request: HIDPPMessage, code: UInt8) -> HIDPPMessage {
+ HIDPPMessage(reportID: .long, deviceIndex: request.deviceIndex, subID: 0xFF, address: request.subID, parameters: [request.address, code])
+ }
+}
+
+// MARK: - Receiver
+
+/// A software Unifying, Nano or Bolt receiver with simulated devices in slots 1...6.
+///
+/// Besides relaying device requests it answers the HID++ 1.0 registers Upright and Solaar
+/// use: notification flags (0x00, read and write), connection state and device announcements
+/// (0x02), pairing information (long register 0xB5: Unifying/Nano sub-registers 0x20+n−1,
+/// 0x30+n−1 and 0x40+n−1, Bolt 0x50+n and 0x60+n, receiver info 0x03) and the Bolt unique ID
+/// (0xFB). It reports link changes (0x41) and un-pairing (0x40).
+public final class SimulatedReceiver: SimulatedHIDPPTransport, @unchecked Sendable {
+ public let productID: UInt16
+ public let layout: PairingLayout
+ public let connection: ConnectionKind
+
+ private var _supportsPairingInfo = true
+ private var _notificationFlags: [UInt8] = [0, 0, 0]
+ private var _linkNotificationsNeedFlag = false
+ private var _maxSlots: UInt8 = 6
+
+ /// `connection` is Bolt for the Bolt layout, Unifying for C52B and C532, Nano otherwise.
+ public init(productID: UInt16 = 0xC52B, layout: PairingLayout = .unifying, slots: [UInt8: SimulatedDeviceProfile] = [:]) {
+ self.productID = productID
+ self.layout = layout
+ switch layout {
+ case .bolt: connection = .bolt
+ case .unifying: connection = [0xC52B, 0xC532].contains(productID) ? .unifying : .nano
+ }
+ super.init(units: slots, transportKind: .receiver(connection))
+ }
+
+ /// A Unifying receiver (C52B).
+ public static func unifying(_ slots: [UInt8: SimulatedDeviceProfile] = [:]) -> SimulatedReceiver {
+ SimulatedReceiver(productID: 0xC52B, layout: .unifying, slots: slots)
+ }
+
+ /// A Bolt receiver (C548).
+ public static func bolt(_ slots: [UInt8: SimulatedDeviceProfile] = [:]) -> SimulatedReceiver {
+ SimulatedReceiver(productID: 0xC548, layout: .bolt, slots: slots)
+ }
+
+ /// A Nano receiver (C534 by default), which uses the Unifying register layout.
+ public static func nano(_ slots: [UInt8: SimulatedDeviceProfile] = [:], productID: UInt16 = 0xC534) -> SimulatedReceiver {
+ SimulatedReceiver(productID: productID, layout: .unifying, slots: slots)
+ }
+
+ /// Paired devices by slot.
+ public var slots: [UInt8: SimulatedDeviceProfile] { synchronized { units.mapValues(\.profile) } }
+
+ override var isReceiver: Bool { true }
+
+ // MARK: Receiver behavior
+
+ /// Whether the receiver answers pairing-information reads (register 0xB5). Some Nano
+ /// receivers don't; hosts then fall back to announcements.
+ public var supportsPairingInfo: Bool {
+ get { synchronized { _supportsPairingInfo } }
+ set { synchronized { _supportsPairingInfo = newValue } }
+ }
+
+ /// Register 0x00 as last written (three bytes; byte 1 bit 0x01 = wireless notifications,
+ /// 0x08 = software present). Preset it to model flags another program set.
+ public var notificationFlags: [UInt8] {
+ get { synchronized { _notificationFlags } }
+ set { synchronized { _notificationFlags = Array((newValue + [0, 0, 0]).prefix(3)) } }
+ }
+
+ /// When true, link notifications (0x40, 0x41) are only sent while register 0x00 has wireless
+ /// notifications enabled. Off by default: they are always sent.
+ public var linkNotificationsNeedFlag: Bool {
+ get { synchronized { _linkNotificationsNeedFlag } }
+ set { synchronized { _linkNotificationsNeedFlag = newValue } }
+ }
+
+ /// What receiver information (0xB5 sub-register 0x03) reports. Slots 1...6 work regardless.
+ public var maxSlots: UInt8 {
+ get { synchronized { _maxSlots } }
+ set { synchronized { _maxSlots = newValue } }
+ }
+
+ /// Short or long register writes to `address` (sub-ID 0x80 or 0x82).
+ public func registerWriteCount(_ address: UInt8) -> Int { synchronized { registerWrites[address] ?? 0 } }
+
+ /// Short or long register reads of `address` (sub-ID 0x81 or 0x83).
+ public func registerReadCount(_ address: UInt8) -> Int { synchronized { registerReads[address] ?? 0 } }
+
+ // MARK: Pairing
+
+ /// Pairs a device to `slot` (replacing whatever was there). `announce` reports the link-up.
+ public func pair(_ profile: SimulatedDeviceProfile, slot: UInt8, announce: Bool = true) {
+ let pending: (HIDPPMessage?, TimeInterval) = synchronized {
+ units[slot] = Unit(profile: profile)
+ return (announce ? linkNotification(slot: slot, up: true) : nil, latency(of: slot))
+ }
+ if let message = pending.0 { emit(message, after: pending.1) }
+ }
+
+ /// Removes the pairing in `slot`; the receiver reports 0x40.
+ public func unpair(slot: UInt8) {
+ let pending: (HIDPPMessage?, TimeInterval)? = synchronized {
+ guard units.removeValue(forKey: slot) != nil else { return nil }
+ return (gatedLinkMessage(Self.unpairNotification(slot: slot)), latency(of: slot))
+ }
+ if case let (message?, delay)? = pending { emit(message, after: delay) }
+ }
+
+ /// Re-pairs the device in `from` to `to`, keeping its unit (and unit ID). It comes back
+ /// awake with its temporary state cleared. `announceUnpair` first reports 0x40 for `from`.
+ public func repair(from: UInt8, to: UInt8, announceUnpair: Bool = true) {
+ let pending: ([HIDPPMessage], TimeInterval)? = synchronized {
+ guard var unit = units.removeValue(forKey: from) else { return nil }
+ unit.awake = true
+ unit.forgetVolatileState()
+ units[to] = unit
+ var messages: [HIDPPMessage] = []
+ if announceUnpair, let unpaired = gatedLinkMessage(Self.unpairNotification(slot: from)) { messages.append(unpaired) }
+ if let linkUp = linkNotification(slot: to, up: true) { messages.append(linkUp) }
+ return (messages, latency(of: to))
+ }
+ if let pending { pending.0.forEach { emit($0, after: pending.1) } }
+ }
+
+ /// Sends a 0x41 for `slot` now, whatever the device is doing (a spurious or repeated
+ /// announcement).
+ public func announceLink(slot: UInt8, up: Bool) {
+ let pending: (HIDPPMessage, TimeInterval)? = synchronized {
+ units[slot].map { (connectionNotification(slot: slot, up: up, profile: $0.profile), latency(of: slot)) }
+ }
+ if let pending { emit(pending.0, after: pending.1) }
+ }
+
+ // MARK: Messages (called with `lock` held)
+
+ override func linkNotification(slot: UInt8, up: Bool) -> HIDPPMessage? {
+ guard let profile = units[slot]?.profile else { return nil }
+ return gatedLinkMessage(connectionNotification(slot: slot, up: up, profile: profile))
+ }
+
+ private func gatedLinkMessage(_ message: HIDPPMessage) -> HIDPPMessage? {
+ guard _linkNotificationsNeedFlag else { return message }
+ return _notificationFlags[1] & 0x01 != 0 ? message : nil
+ }
+
+ /// 0x41: address = protocol (Unifying 0x04, Bolt 0x10); params [flags | kind, wpid lo, wpid hi],
+ /// flags bit 6 = link not established.
+ private func connectionNotification(slot: UInt8, up: Bool, profile: SimulatedDeviceProfile) -> HIDPPMessage {
+ let pid = profile.wirelessPID ?? 0
+ let flags: UInt8 = (up ? 0x00 : 0x40) | profile.pairingKindNibble
+ return HIDPPMessage(reportID: .short, deviceIndex: slot, subID: 0x41, address: layout == .bolt ? 0x10 : 0x04,
+ parameters: [flags, UInt8(pid & 0xFF), UInt8(pid >> 8)])
+ }
+
+ private static func unpairNotification(slot: UInt8) -> HIDPPMessage {
+ HIDPPMessage(reportID: .short, deviceIndex: slot, subID: 0x40, address: 0x02, parameters: [])
+ }
+
+ /// A made-up receiver serial number.
+ private var serialBytes: [UInt8] { [0xDE, 0x30, UInt8(productID >> 8), UInt8(productID & 0xFF)] }
+
+ override func receiverOutcome(for request: HIDPPMessage) -> Outcome {
+ let p = request.parameters
+ func reply(_ parameters: [UInt8]) -> Outcome {
+ // Long-register reads (0x83) are answered with long reports, whatever the request's length.
+ Outcome(reply: HIDPPMessage(reportID: request.subID == 0x83 ? .long : request.reportID, deviceIndex: 0xFF,
+ subID: request.subID, address: request.address, parameters: parameters))
+ }
+ func error(_ code: UInt8) -> Outcome { Outcome(reply: Self.error10(request, code: code)) }
+
+ switch (request.subID, request.address) {
+ case (0x80, 0x00):
+ _notificationFlags = Array(p.prefix(3))
+ return Outcome(reply: request)
+ case (0x81, 0x00):
+ return reply(_notificationFlags)
+ case (0x80, 0x02) where p[0] == 0x02:
+ // Fake device arrival: a 0x41 for every paired device.
+ var outcome = Outcome(reply: request)
+ outcome.notifications = units.keys.sorted().compactMap { slot in
+ units[slot].map { connectionNotification(slot: slot, up: $0.awake, profile: $0.profile) }
+ }
+ return outcome
+ case (0x80, _), (0x82, _):
+ return Outcome(reply: request)
+ case (0x81, 0x02):
+ return reply([0x00, UInt8(units.count), 0x00])
+ case (0x83, 0xB5):
+ return pairingRegister(p).map(reply) ?? error(_supportsPairingInfo ? 0x03 : 0x02)
+ case (0x83, 0xFB) where layout == .bolt:
+ return reply(serialBytes)
+ case (0x81, _), (0x83, _):
+ return error(0x02)
+ default:
+ return error(0x01)
+ }
+ }
+
+ /// Long register 0xB5 sub-registers; nil answers "invalid value" (empty slot, unknown
+ /// sub-register) or "invalid address" when pairing information isn't supported.
+ private func pairingRegister(_ p: [UInt8]) -> [UInt8]? {
+ guard _supportsPairingInfo else { return nil }
+ let sub = p[0]
+ if sub == 0x03 {
+ return [0x03] + serialBytes + [0x00, _maxSlots]
+ }
+ switch layout {
+ case .unifying:
+ switch sub {
+ case 0x20...0x25:
+ // [sub, destination ID, report interval (ms), wpid (BE), 0, 0, kind].
+ guard let profile = units[sub - 0x1F]?.profile else { return nil }
+ let pid = profile.wirelessPID ?? 0
+ return [sub, 0x00, 0x08, UInt8(pid >> 8), UInt8(pid & 0xFF), 0, 0, profile.pairingKindNibble]
+ case 0x30...0x35:
+ // Extended: [sub, serial (4) = unit ID, report types (4), power switch location].
+ guard let profile = units[sub - 0x2F]?.profile else { return nil }
+ return [sub] + Self.bytes(profile.unitID ?? 0) + [0, 0, 0, 0, 0]
+ case 0x40...0x45:
+ // [sub, length, codename].
+ guard let profile = units[sub - 0x3F]?.profile else { return nil }
+ let name = Array(profile.codename.utf8.prefix(14))
+ return [sub, UInt8(name.count)] + name
+ default:
+ return nil
+ }
+ case .bolt:
+ switch sub {
+ case 0x51...0x56:
+ // [sub, kind, wpid lo, wpid hi, serial (4) = unit ID].
+ guard let profile = units[sub - 0x50]?.profile else { return nil }
+ let pid = profile.wirelessPID ?? 0
+ return [sub, profile.pairingKindNibble, UInt8(pid & 0xFF), UInt8(pid >> 8)] + Self.bytes(profile.unitID ?? 0)
+ case 0x61...0x66:
+ // [sub, page, length, codename].
+ guard let profile = units[sub - 0x60]?.profile else { return nil }
+ let name = Array(profile.codename.utf8.prefix(13))
+ return [sub, 0x01, UInt8(name.count)] + name
+ default:
+ return nil
+ }
+ }
+ }
+
+ private static func bytes(_ value: UInt32) -> [UInt8] {
+ [UInt8(value >> 24), UInt8((value >> 16) & 0xFF), UInt8((value >> 8) & 0xFF), UInt8(value & 0xFF)]
+ }
+}
+
+// MARK: - Direct
+
+/// One simulated device connected directly (USB cable or Bluetooth), at device index 0xFF.
+/// The per-device API of `SimulatedHIDPPTransport` works with `slot: 0xFF`; the members
+/// below are shorthands for it.
+public final class SimulatedDirect: SimulatedHIDPPTransport, @unchecked Sendable {
+ public static let index: UInt8 = 0xFF
+
+ public let profile: SimulatedDeviceProfile
+ public let connection: ConnectionKind
+
+ public init(profile: SimulatedDeviceProfile, connection: ConnectionKind = .usb) {
+ self.profile = profile
+ self.connection = connection
+ super.init(units: [Self.index: profile], transportKind: .direct(connection))
+ }
+
+ /// The product ID macOS would report for the interface: the USB or Bluetooth PID.
+ public var productID: UInt16? {
+ connection == .usb ? profile.usbPID : profile.bluetoothPID
+ }
+
+ public var isAwake: Bool { isAwake(slot: Self.index) }
+ public var diverted: Set { diverted(slot: Self.index) }
+ public var rawXY: Set { rawXY(slot: Self.index) }
+ public var dpi: Int? { dpi(slot: Self.index) }
+ public var currentHost: Int? { currentHost(slot: Self.index) }
+ public var hostSwitches: [UInt8] { hostSwitches(slot: Self.index) }
+ public var batteryLevel: Int? { batteryLevel(slot: Self.index) }
+ public var pingCount: Int { pingCount(slot: Self.index) }
+
+ public func hold(_ cids: [UInt16]) { hold(cids, slot: Self.index) }
+ public func release() { release(slot: Self.index) }
+ public func move(dx: Int, dy: Int) { move(dx: dx, dy: dy, slot: Self.index) }
+ /// The device stops answering (asleep, out of range); nothing is reported.
+ public func goToSleep() { goToSleep(slot: Self.index) }
+ public func wakeUp(clearingDiversion: Bool = true, reportReconnect: Bool = false) {
+ wakeUp(slot: Self.index, clearingDiversion: clearingDiversion, announce: false, reportReconnect: reportReconnect)
+ }
+ public func reportReconnect() { reportReconnect(slot: Self.index) }
+ public func forgetDiversion() { forgetDiversion(slot: Self.index) }
+ public func setDPINatively(_ dpi: Int) { setDPINatively(dpi, slot: Self.index) }
+ public func reportBattery(percentage: Int, charging: Bool = false) {
+ reportBattery(percentage: percentage, charging: charging, slot: Self.index)
+ }
+ public func failDiversion(of cid: UInt16, code: UInt8, times: Int = .max) {
+ failDiversion(of: cid, code: code, times: times, slot: Self.index)
+ }
+}
diff --git a/UprightKit/Sources/upright-probe/Probe.swift b/UprightKit/Sources/upright-probe/Probe.swift
index 693b554..16c80be 100644
--- a/UprightKit/Sources/upright-probe/Probe.swift
+++ b/UprightKit/Sources/upright-probe/Probe.swift
@@ -1,6 +1,9 @@
import Foundation
import UprightKit
+// The IOKit side of upright-probe: finds Logitech HID interfaces, opens them, and hands them to
+// ProbeRunner (ProbeReport.swift), which does the reading and printing.
+
#if canImport(IOKit)
import IOKit.hid
@@ -8,36 +11,38 @@ import IOKit.hid
struct Probe {
@MainActor
static func main() {
- let arguments = Set(CommandLine.arguments.dropFirst())
- if arguments.contains("-h") || arguments.contains("--help") {
- print("""
- usage: upright-probe [--divert]
-
- Finds a Logitech MX Vertical (USB cable, Unifying receiver, or Bluetooth),
- prints its HID++ 2.0 feature table and REPROG_CONTROLS_V4 control list, then keeps
- listening for receiver and device notifications.
-
- --divert Temporarily divert every remappable button and print press/release
- events. Buttons return to normal when you press Ctrl-C.
- """)
+ let options: ProbeOptions
+ do {
+ options = try ProbeOptions.parse(Array(CommandLine.arguments.dropFirst()))
+ } catch {
+ FileHandle.standardError.write(Data("upright-probe: \(error)\n\n\(ProbeOptions.usage)\n".utf8))
+ exit(2)
+ }
+ if options.showHelp {
+ print(ProbeOptions.usage)
return
}
- let session = ProbeSession(divertAll: arguments.contains("--divert"))
+ let session = ProbeSession(options: options)
session.start()
- signal(SIGINT, SIG_IGN)
- let interrupt = DispatchSource.makeSignalSource(signal: SIGINT, queue: .main)
- interrupt.setEventHandler {
- Task { @MainActor in
- session.shutdown()
- print("\nRestored default button behavior.")
- exit(0)
+ // Diversion lasts only while the probe runs; hand the buttons back however it is stopped.
+ var signalSources: [DispatchSourceSignal] = []
+ for number in [SIGINT, SIGTERM, SIGHUP] {
+ signal(number, SIG_IGN)
+ let source = DispatchSource.makeSignalSource(signal: number, queue: .main)
+ source.setEventHandler {
+ Task { @MainActor in
+ print()
+ session.finish()
+ exit(0)
+ }
}
+ source.resume()
+ signalSources.append(source)
}
- interrupt.resume()
- withExtendedLifetime((session, interrupt)) {
+ withExtendedLifetime((session, signalSources)) {
RunLoop.main.run()
}
}
@@ -45,141 +50,133 @@ struct Probe {
@MainActor
final class ProbeSession {
- private let divertAll: Bool
- private let monitor = HIDDeviceMonitor()
- private var entries: [IOHIDDevice: (transport: IOKitHIDTransport, controller: MXVerticalController)] = [:]
+ private struct OpenInterface {
+ var transport: IOKitHIDTransport
+ var info: HIDInterfaceInfo
+ var link: UInt32
+ }
- init(divertAll: Bool) {
- self.divertAll = divertAll
+ private let options: ProbeOptions
+ private let console: ProbeConsole
+ private let runner: ProbeRunner
+ private let monitor = HIDDeviceMonitor()
+ private var open: [IOHIDDevice: OpenInterface] = [:]
+ /// Interfaces that are listed but not read (they carry no HID++, or opening them failed),
+ /// so that their removal is still announced.
+ private var listed: [IOHIDDevice: UInt32] = [:]
+ private var nextLink: UInt32 = 1
+
+ init(options: ProbeOptions) {
+ self.options = options
+ console = ProbeConsole()
+ runner = ProbeRunner(options: options, console: console)
}
func start() {
+ // Without --all the monitor keeps back what Upright ignores and reports it through onIgnored.
+ monitor.deliversIgnoredInterfaces = options.all
monitor.onMatch = { [weak self] device in
MainActorDelivery.run { self?.attach(device) }
}
monitor.onRemove = { [weak self] device in
MainActorDelivery.run { self?.detach(device, removed: true) }
}
- let result = monitor.start()
- print(String(format: "Watching for Logitech HID++ interfaces (IOHIDManagerOpen: 0x%08X)", UInt32(bitPattern: result)))
- print("Input Monitoring access: \(Self.describe(HIDDeviceMonitor.inputMonitoringAccess))")
+ monitor.onIgnored = { [weak self] info, reason in
+ MainActorDelivery.run { self?.console.say("· Ignoring \(info.summary): \(reason)") }
+ }
+ monitor.start()
+ // Each interface is opened on its own when it is attached; the result is printed per link.
+ console.say("Watching for Logitech HID++ interfaces")
+ console.say("Input Monitoring access: \(Self.describe(HIDDeviceMonitor.inputMonitoringAccess))")
+ if options.all {
+ console.say("--all: interfaces Upright doesn't use are listed and read too (reads only).")
+ }
+ Task {
+ try? await Task.sleep(nanoseconds: 5_000_000_000)
+ if open.isEmpty && listed.isEmpty {
+ console.say("No Logitech HID++ interface found yet. Check the cable or receiver, wake the mouse, and that Input Monitoring is allowed for your terminal.")
+ }
+ }
}
- func shutdown() {
- entries.values.forEach { $0.controller.shutdown() }
+ /// Hands every diverted button back and closes everything.
+ func finish() {
+ runner.shutdown()
+ for entry in open.values { entry.transport.close() }
+ open.removeAll()
+ console.say(runner.hasDivertedButtons ? "Restored default button behavior." : "Stopped.")
}
private func attach(_ device: IOHIDDevice) {
- let info = HIDDeviceMonitor.info(for: device)
- guard let kind = MXVertical.classify(vendorID: info.vendorID, productID: info.productID) else {
- print("· Ignoring \(info.summary)")
- return
+ let hidInfo = HIDDeviceMonitor.info(for: device)
+ var info = ProbeLinkInfo.make(number: 0, descriptor: hidInfo.descriptor)
+
+ // Over USB a mouse publishes a vendor-page HID++ interface and a mouse interface, and a
+ // Bluetooth mouse can appear twice. Opening both interleaves HID++ replies, so use one
+ // per physical device.
+ if info.carriesHIDPP {
+ guard let superseded = HIDInterfaceInfo.twinsToReplace(adding: hidInfo, open: open.mapValues(\.info)) else {
+ console.say("· Ignoring twin interface \(hidInfo.summary)")
+ return
+ }
+ superseded.forEach { detach($0, removed: false) }
}
- // Over USB the mouse publishes a vendor-page HID++ interface and a mouse interface.
- // Opening both interleaves HID++ replies, so use one per physical device.
- guard let superseded = HIDInterfaceInfo.twinsToReplace(adding: info, open: entries.mapValues(\.transport.info)) else {
- print("· Ignoring twin interface \(info.summary)")
+
+ info.number = nextLink
+ nextLink += 1
+ runner.announce(info)
+
+ guard info.carriesHIDPP else {
+ listed[device] = info.number
return
}
- superseded.forEach { detach($0, removed: false) }
- print("→ Found \(info.summary)")
-
let transport = IOKitHIDTransport(device: device)
do {
try transport.open()
} catch {
- print(" ✗ \(error)")
+ var text = "✗ \(error)"
+ if (error as? IOKitHIDError)?.isPermissionDenied == true {
+ text += "\n upright-probe runs inside your terminal app: allow that app under Input Monitoring."
+ }
+ console.say(text, link: info.number)
+ listed[device] = info.number
return
}
-
- let controller = MXVerticalController(transport: transport, kind: kind)
- controller.onLog = { print(" \($0)") }
- controller.client.onTraffic = { direction, bytes in
- let arrow = direction == .outgoing ? ">>" : "<<"
- print(" \(arrow) " + bytes.prefix(20).map { String(format: "%02X", $0) }.joined(separator: " "))
- }
- controller.onButtonEvent = { event in
- let name = ControlDescriptor(cid: event.cid).name
- print(" ● \(name) (\(String(format: "0x%04X", event.cid))) \(event.phase == .press ? "pressed" : "released")")
- }
- controller.onStateChange = { [weak self, weak controller] state in
- guard let self, let controller else { return }
- self.report(state, controller: controller)
+ open[device] = OpenInterface(transport: transport, info: hidInfo, link: info.number)
+
+ if let kind = info.transportKind {
+ let link = HIDPPLink(
+ transport: transport,
+ kind: kind,
+ link: info.number,
+ productID: info.productID,
+ productName: info.productName.isEmpty ? nil : info.productName
+ )
+ runner.attachHub(link, info: info)
+ } else {
+ let client = HIDPPClient(transport: transport)
+ // One request at a time, as the app does.
+ client.maxInFlight = 1
+ runner.attachRaw(client: client, info: info)
}
- entries[device] = (transport, controller)
- controller.start()
}
private func detach(_ device: IOHIDDevice, removed: Bool) {
- guard let entry = entries.removeValue(forKey: device) else { return }
- entry.controller.onStateChange = nil
- entry.controller.shutdown()
+ if let number = listed.removeValue(forKey: device) {
+ console.say("← removed", link: number)
+ console.removeLink(number)
+ return
+ }
+ guard let entry = open.removeValue(forKey: device) else { return }
if removed {
+ // The hub sees the transport close and drops its endpoints (nothing to restore).
entry.transport.deviceRemoved()
- print("← \(entry.transport.info.product) removed")
+ console.say("← removed", link: entry.link)
+ runner.detach(link: entry.link)
} else {
+ console.say("← closed in favor of its HID++ interface", link: entry.link)
+ runner.detach(link: entry.link)
entry.transport.close()
- print("← \(entry.transport.info.product) closed in favor of its HID++ interface")
- }
- }
-
- private func report(_ state: MXVerticalController.State, controller: MXVerticalController) {
- switch state {
- case .probing:
- print(" … probing")
- case .waitingForDevice:
- print(" … receiver found, waiting for the MX Vertical to wake up (click a button)")
- case .asleep:
- print(" … mouse asleep")
- case .failed(let message):
- print(" ✗ \(message)")
- case .connected(let snapshot):
- print("""
-
- ✓ \(snapshot.name) over \(snapshot.connection.title), device index 0x\(String(format: "%02X", snapshot.deviceIndex)), HID++ \(snapshot.protocolVersion)
- """)
- if let battery = snapshot.battery {
- print(" Battery: \(battery.percentage)%\(battery.isApproximate ? " (approximate)" : "")\(battery.isCharging ? " (charging)" : "")")
- }
- for firmware in snapshot.firmware {
- print(" \(firmware.kind.title): \(firmware.displayString)")
- }
- if let serial = snapshot.serialNumber {
- print(" Serial number: \(serial)")
- }
- if let dpi = snapshot.dpi {
- let supported = dpi.step.map { "\(dpi.minimum)–\(dpi.maximum) in steps of \($0)" }
- ?? dpi.supported.map(String.init).joined(separator: ", ")
- print(" Pointer speed: \(dpi.current) DPI (default \(dpi.defaultValue); supported: \(supported.isEmpty ? "unknown" : supported))")
- } else {
- print(" Pointer speed: not adjustable over HID++ (no 0x2201)")
- }
- if let hosts = snapshot.hosts {
- print(" Easy-Switch: channel \(hosts.current + 1) of \(hosts.count)")
- for index in 0.. ProbeOptions {
+ var options = ProbeOptions()
+ for argument in arguments {
+ switch argument {
+ case "-h", "--help":
+ options.showHelp = true
+ case "--all":
+ options.all = true
+ case "--divert":
+ options.divert = true
+ case "--trace":
+ options.trace = true
+ default:
+ guard argument.hasPrefix("--divert=") else {
+ throw UsageError(description: "unknown argument \(argument)")
+ }
+ let filter = argument.dropFirst("--divert=".count).trimmingCharacters(in: .whitespaces)
+ guard !filter.isEmpty else {
+ throw UsageError(description: "--divert= needs a device name, for example --divert=\"MX Master\"")
+ }
+ options.divert = true
+ options.divertFilter = filter
+ }
+ }
+ return options
+ }
+
+ /// Whether a device is diverted. `label` is the device's label without the brackets,
+ /// for example "MX Master 3S · Bolt slot 2".
+ func shouldDivert(label: String) -> Bool {
+ guard divert else { return false }
+ guard let divertFilter else { return true }
+ return label.range(of: divertFilter, options: .caseInsensitive) != nil
+ }
+
+ static let usage = """
+ usage: upright-probe [--all] [--divert[=NAME]] [--trace]
+
+ Finds Logitech mice (USB cable, Unifying, Bolt or Nano receiver, Bluetooth) and prints each
+ one's HID++ identity, feature table and REPROG_CONTROLS_V4 control list, then keeps
+ listening for receiver and device notifications. Every line starts with the device it
+ belongs to, for example [MX Master 3S · Bolt slot 2].
+
+ --all Also list and read every Logitech HID++ interface Upright doesn't use:
+ Lightspeed and unknown receivers, keyboards and other devices behind a
+ receiver, HID++ 1.0 devices, and mice it doesn't recognize. These are only
+ read, never written to. Paste this output into new-mouse reports.
+ --divert Temporarily divert every remappable button of every supported mouse and
+ print press and release events, each with its mouse's prefix. Buttons
+ return to normal when you press Ctrl-C.
+ --divert=NAME The same, only for mice whose label contains NAME (case-insensitive),
+ for example --divert="master 3s" or --divert="bolt slot 2".
+ --trace Print the raw HID++ reports in both directions.
+
+ Interfaces Upright serves are handled the way the app handles them: a receiver gets its
+ notification flags turned on (read-modify-write), and --divert changes button reporting.
+ Without --divert, button reporting is never written.
+
+ Quit Upright first: both use HID++ software ID 0x0A on the same shared interface, so each
+ could take the other's replies.
+ """
+}
+
+// MARK: - Links
+
+extension ConnectionKind {
+ /// "Bolt", "USB", ...: the link part of a device label.
+ var probeTitle: String {
+ switch self {
+ case .usb: return "USB"
+ case .bluetooth: return "Bluetooth"
+ case .unifying: return "Unifying"
+ case .bolt: return "Bolt"
+ case .nano: return "Nano"
+ }
+ }
+}
+
+/// One matched HID interface as the probe sees it.
+struct ProbeLinkInfo {
+ /// The hub's link number: part of every `DeviceAddress` on this interface.
+ var number: UInt32
+ /// "Bolt", "Unifying", "USB", "Bluetooth", "Lightspeed", ...
+ var place: String
+ var isReceiver: Bool
+ /// How the device connects, for `DeviceSession`. A stand-in for interfaces Upright ignores.
+ var connection: ConnectionKind
+ var layout: PairingLayout
+ var productID: UInt16?
+ /// The IOHID product string; empty when there is none.
+ var productName: String
+ var summary: String
+ /// Whether the interface can carry HID++ at all. A receiver's boot-mouse interface can't.
+ var carriesHIDPP: Bool
+ /// Why Upright never opens this interface; nil when it does.
+ var ignoredReason: String?
+ /// What Upright opens it as; nil for ignored interfaces.
+ var transportKind: TransportKind?
+
+ static func make(number: UInt32, descriptor d: HIDInterfaceDescriptor) -> ProbeLinkInfo {
+ let classification = InterfaceClassifier.classify(d)
+ let pid = UInt16(exactly: d.productID)
+ let transport = d.transport.lowercased()
+
+ let place: String
+ let isReceiver: Bool
+ let connection: ConnectionKind
+ var layout = PairingLayout.unifying
+ switch classification {
+ case .receiver(let kind, let receiverLayout):
+ (place, isReceiver, connection, layout) = (kind.probeTitle, true, kind, receiverLayout)
+ case .direct(let kind):
+ (place, isReceiver, connection) = (kind.probeTitle, false, kind)
+ case .ignored:
+ if let receiver = pid.flatMap({ DeviceCatalog.receivers[$0] }) {
+ place = receiver.family.probeTitle
+ isReceiver = true
+ connection = receiver.kind
+ layout = receiver.layout ?? (receiver.family == .bolt ? .bolt : .unifying)
+ } else if let pid, (0xC500...0xC5FF).contains(pid) {
+ (place, isReceiver, connection) = ("unknown receiver", true, .unifying)
+ } else if transport.contains("bluetooth") {
+ (place, isReceiver, connection) = ("Bluetooth", false, .bluetooth)
+ } else {
+ (place, isReceiver, connection) = (transport.contains("usb") ? "USB" : "HID", false, .usb)
+ }
+ }
+
+ // A receiver's mouse and keyboard interfaces don't carry HID++, only its vendor-page one.
+ let carries: Bool
+ if classification.ignoredReason == nil {
+ carries = true
+ } else {
+ carries = d.isVendorHIDPP || d.hasUnknownUsage || (!isReceiver && d.usagePage == 0x01)
+ }
+ return ProbeLinkInfo(
+ number: number,
+ place: place,
+ isReceiver: isReceiver,
+ connection: connection,
+ layout: layout,
+ productID: pid,
+ productName: d.productName,
+ summary: d.summary,
+ carriesHIDPP: carries,
+ ignoredReason: classification.ignoredReason,
+ transportKind: classification.transportKind
+ )
+ }
+}
+
+extension ReceiverModel.Family {
+ var probeTitle: String {
+ switch self {
+ case .unifying: return "Unifying"
+ case .bolt: return "Bolt"
+ case .nano: return "Nano"
+ case .lightspeed: return "Lightspeed"
+ case .legacy27MHz: return "27 MHz"
+ }
+ }
+}
+
+// MARK: - Output
+
+/// Prints lines, each prefixed with the device (or link) it belongs to: "[MX Master 3S · Bolt slot 2]".
+@MainActor
+final class ProbeConsole {
+ private let sink: (String) -> Void
+ private(set) var links: [UInt32: ProbeLinkInfo] = [:]
+ private var names: [DeviceAddress: String] = [:]
+
+ init(sink: @escaping (String) -> Void = ProbeConsole.writeLine) {
+ self.sink = sink
+ }
+
+ nonisolated static func writeLine(_ text: String) {
+ FileHandle.standardOutput.write(Data((text + "\n").utf8))
+ }
+
+ func addLink(_ info: ProbeLinkInfo) {
+ links[info.number] = info
+ }
+
+ func removeLink(_ number: UInt32) {
+ links[number] = nil
+ names = names.filter { $0.key.link != number }
+ }
+
+ /// The name a device reported, used in its label from now on.
+ func setName(_ name: String, for address: DeviceAddress) {
+ names[address] = name
+ }
+
+ func forget(_ address: DeviceAddress) {
+ names[address] = nil
+ }
+
+ /// The link part of a device label: "Bolt slot 2", "USB".
+ func place(of address: DeviceAddress) -> String {
+ guard let link = links[address.link] else { return "link \(address.link)" }
+ return link.isReceiver && !address.isDirect ? "\(link.place) slot \(address.index)" : link.place
+ }
+
+ private func baseLabel(for address: DeviceAddress) -> String {
+ guard let link = links[address.link] else { return "link \(address.link)" }
+ if link.isReceiver && address.isDirect { return "\(link.place) receiver" }
+ let place = place(of: address)
+ let name = names[address] ?? (link.isReceiver || link.productName.isEmpty ? "Logitech device" : link.productName)
+ return "\(name) · \(place)"
+ }
+
+ /// "MX Master 3S · Bolt slot 2", "MX Vertical · USB", or "Bolt receiver" for the receiver itself.
+ /// Two devices that would read alike (two MX Verticals on USB) are told apart by link number.
+ func label(for address: DeviceAddress) -> String {
+ let base = baseLabel(for: address)
+ var others = Set(names.keys)
+ for number in links.keys { others.insert(DeviceAddress(link: number, index: DeviceAddress.direct)) }
+ let clashes = others.contains { $0.link != address.link && baseLabel(for: $0) == base }
+ return clashes ? "\(base) #\(address.link)" : base
+ }
+
+ /// A line about the probe itself.
+ func say(_ text: String) {
+ emit("probe", text)
+ }
+
+ /// A line about an interface: its device label on a direct link, "Bolt receiver" on a receiver.
+ func say(_ text: String, link: UInt32) {
+ say(text, at: DeviceAddress(link: link, index: DeviceAddress.direct))
+ }
+
+ func say(_ text: String, at address: DeviceAddress) {
+ emit(label(for: address), text)
+ }
+
+ private func emit(_ label: String, _ text: String) {
+ for line in text.split(separator: "\n", omittingEmptySubsequences: false) {
+ sink("[\(label)] \(line)")
+ }
+ }
+}
+
+// MARK: - Formatting
+
+enum ProbeVerdict: Equatable {
+ case supported
+ /// Fails Upright's device checks (the capability gate).
+ case unsupported(UnsupportedReason)
+ /// Passes the checks, but the interface it sits on is one Upright never opens.
+ case interfaceNotOpened(String)
+}
+
+enum ProbeFormat {
+ static func hex(_ value: T, _ width: Int) -> String {
+ let digits = String(value, radix: 16, uppercase: true)
+ return String(repeating: "0", count: max(0, width - digits.count)) + digits
+ }
+
+ static func pad(_ text: String, to width: Int) -> String {
+ text + String(repeating: " ", count: max(0, width - text.count))
+ }
+
+ /// The name the device reported, or "Logitech device" when it reported none.
+ static func displayName(_ snapshot: DeviceSnapshot) -> String {
+ snapshot.name == DeviceSession.defaultName ? "Logitech device" : snapshot.name
+ }
+
+ /// The name in a device's label: the catalog's short name for a known model ("Lift"), else
+ /// what the device reported. It stays the same from the first line to the last.
+ static func labelName(_ snapshot: DeviceSnapshot) -> String {
+ snapshot.identity.flatMap { DeviceCatalog.model($0.model)?.name } ?? displayName(snapshot)
+ }
+
+ static func batteryText(_ battery: BatteryStatus?) -> String {
+ guard let battery else { return "not reported" }
+ return "\(battery.percentage)%" + (battery.isApproximate ? " (approximate)" : "") + (battery.isCharging ? " (charging)" : "")
+ }
+
+ static func modelText(_ key: ModelKey) -> String {
+ guard let model = DeviceCatalog.model(key) else { return "\(key.rawValue) (not in Upright's catalog)" }
+ let how: String
+ switch model.verification {
+ case .hardware: how = "tested on hardware"
+ case .publishedDump: how = "from published dumps, untested"
+ case .unverified: how = "unverified"
+ }
+ return "\(key.rawValue) (\(model.name) in Upright's catalog, \(how))"
+ }
+
+ static func modelIDsText(_ ids: ModelIDs) -> String {
+ let parts: [(String, UInt16?)] = [("Bluetooth", ids.bluetooth), ("Bluetooth LE", ids.bluetoothLE), ("wireless", ids.wireless), ("USB", ids.usb)]
+ let present = parts.compactMap { name, pid in pid.map { "\(name) \(hex($0, 4))" } }
+ return present.isEmpty ? "none reported" : present.joined(separator: ", ")
+ }
+
+ static func dpiText(_ dpi: DPIInfo?) -> String {
+ guard let dpi else { return "not adjustable over HID++ (no 0x2201)" }
+ let supported = dpi.step.map { "\(dpi.minimum)–\(dpi.maximum) in steps of \($0)" }
+ ?? dpi.supported.map(String.init).joined(separator: ", ")
+ return "\(dpi.current) DPI (default \(dpi.defaultValue); supported: \(supported.isEmpty ? "unknown" : supported))"
+ }
+
+ /// The full device report, one entry per line (the console adds the prefix).
+ /// `place` is the link part of the label ("Bolt slot 2"); `isReceiver` says what the
+ /// transport PID in the identity means.
+ static func dump(_ s: DeviceSnapshot, verdict: ProbeVerdict, place: String, isReceiver: Bool) -> [String] {
+ var out: [String] = []
+ let name = displayName(s)
+ switch verdict {
+ case .supported:
+ out.append("✓ \(name): Upright supports this device")
+ case .interfaceNotOpened(let why):
+ out.append("✓ \(name): passes Upright's device checks, but Upright never opens this interface (\(why))")
+ case .unsupported(let reason):
+ out.append("✗ \(name): not supported by Upright: \(reason.message)")
+ }
+ out.append(" Connection: \(place), device index 0x\(hex(s.deviceIndex, 2)), HID++ \(s.protocolVersion)")
+ if (s.protocolVersion.split(separator: ".").first.flatMap { Int($0) } ?? 2) < 2 {
+ // HID++ 1.0 devices have no feature table; all there is to say comes from the receiver.
+ if let identity = s.identity {
+ out.append(" Kind: \(identity.kind.name) (from the receiver)")
+ if let pid = identity.transportPID { out.append(" Wireless PID from the receiver: \(hex(pid, 4))") }
+ }
+ out.append(" No feature table or control list: this is a HID++ 1.0 device.")
+ return out
+ }
+ out.append(" Battery: \(batteryText(s.battery))")
+ for firmware in s.firmware where !firmware.displayString.isEmpty {
+ out.append(" \(firmware.kind.title): \(firmware.displayString)")
+ }
+ if let identity = s.identity {
+ let deviceID = identity.deviceID(at: s.address)
+ out.append(" Identity:")
+ out.append(" Kind: \(identity.kind.name)")
+ out.append(" Model: \(modelText(identity.model))")
+ out.append(" Model IDs (0x0003): \(modelIDsText(identity.modelIDs))")
+ if let extended = identity.extendedModelID {
+ out.append(" Extended model ID: 0x\(hex(extended, 2))")
+ }
+ if let pid = identity.transportPID {
+ out.append(" \(isReceiver ? "Wireless PID from the receiver" : "Product ID of the interface"): \(hex(pid, 4))")
+ }
+ out.append(" Unit ID: \(identity.unitID.map { hex($0, 8) } ?? "none reported")")
+ out.append(" Serial number: \(identity.serialNumber ?? "none reported")")
+ out.append(" Device ID: \(deviceID.rawValue)" + (deviceID.isStable ? "" : " (transient: settings for this unit can't be remembered)"))
+ }
+ out.append(" Pointer speed: \(dpiText(s.dpi))")
+ if let hosts = s.hosts {
+ out.append(" Easy-Switch: channel \(hosts.current + 1) of \(hosts.count)")
+ for index in 0.. [String] {
+ if s.controls.isEmpty {
+ if let reprog = s.feature(.reprogControlsV4) {
+ return [" Controls: REPROG_CONTROLS_V4 v\(reprog.version) lists none"]
+ }
+ return [" Controls: no REPROG_CONTROLS_V4 (0x1B04)"]
+ }
+ // "Remappable by Upright" only means something for a device Upright serves.
+ let served = verdict == .supported
+ let divertable = s.controls.filter(\.isConfigurable).count
+ let count = served ? "\(divertable) remappable by Upright" : "\(divertable) divertable"
+ var out = [" Controls (REPROG_CONTROLS_V4 v\(s.reprogVersion), \(s.controls.count) controls, \(count)):"]
+ for control in s.controls {
+ let flags = control.flags.names + (control.supportsRawXY ? ["raw XY"] : [])
+ let name = ControlCatalog.solaarName(for: control.cid) ?? "unnamed"
+ out.append(
+ " \(control.isConfigurable ? "*" : " ") CID 0x\(hex(control.cid, 4)) TID 0x\(hex(control.taskID, 4)) \(pad(name, to: 24))"
+ + " [\(flags.joined(separator: ", "))]"
+ + " (flags 0x\(hex(control.flags.rawValue, 2)), extra 0x\(hex(control.additionalFlags, 2)), position \(control.position), group \(control.group)/0x\(hex(control.groupMask, 2)))"
+ )
+ }
+ out.append(" (* = \(served ? "remappable by Upright" : "divertable, though Upright doesn't use this device"); raw XY = can report pointer movement while held)")
+ return out
+ }
+
+ /// What changed between two snapshots of a connected device (battery, speed, Easy-Switch channel).
+ static func changes(from old: DeviceSnapshot, to new: DeviceSnapshot) -> [String] {
+ var out: [String] = []
+ if old.battery != new.battery {
+ out.append("Battery: \(batteryText(old.battery)) → \(batteryText(new.battery))")
+ }
+ if let before = old.dpi?.current, let after = new.dpi?.current, before != after {
+ out.append("Pointer speed: \(before) → \(after) DPI")
+ }
+ if let before = old.hosts?.current, let after = new.hosts?.current, before != after {
+ out.append("Easy-Switch: channel \(before + 1) → \(after + 1)")
+ }
+ return out
+ }
+}
+
+// MARK: - Reading a device
+
+enum ProbeReadOutcome {
+ /// Identified. `gate` is why Upright wouldn't serve it, nil when it would.
+ case device(DeviceSnapshot, gate: UnsupportedReason?)
+ case noAnswer(Error)
+}
+
+/// Reads a device without Upright's gate in the way, and without writing anything.
+@MainActor
+enum ProbeReader {
+ /// Identifies the device the way Upright does (`DeviceSession.start`, which only reads and
+ /// decides support), then reads whatever that stopped short of for an unsupported device:
+ /// controls, battery, firmware, pointer speed and Easy-Switch.
+ static func read(
+ client: HIDPPClient,
+ address: DeviceAddress,
+ connection: ConnectionKind,
+ transportPID: UInt16?,
+ fallbackName: String?,
+ kindHint: DeviceKind?,
+ pingTimeout: TimeInterval
+ ) async -> ProbeReadOutcome {
+ let device = HIDPPDevice(client: client, index: address.index)
+ let session = DeviceSession(device: device, connection: connection, address: address, transportPID: transportPID, fallbackName: fallbackName, kindHint: kindHint)
+ do {
+ return .device(try await session.start(pingTimeout: pingTimeout), gate: nil)
+ } catch HIDPPError.unsupportedDevice(let reason, let partial) {
+ let seed = partial ?? DeviceSnapshot(
+ name: fallbackName ?? DeviceSession.defaultName, connection: connection, deviceIndex: address.index,
+ protocolVersion: "?", features: [], controls: [], battery: nil, address: address
+ )
+ return .device(await complete(seed, client: client, index: address.index), gate: reason)
+ } catch {
+ return .noAnswer(error)
+ }
+ }
+
+ /// Fills in what the capability gate stopped before reading. Anything the device doesn't
+ /// answer stays empty.
+ static func complete(_ original: DeviceSnapshot, client: HIDPPClient, index: UInt8) async -> DeviceSnapshot {
+ var s = original
+ let major = s.protocolVersion.split(separator: ".").first.flatMap { Int($0) } ?? 0
+ guard major >= 2 else { return s }
+
+ let device = HIDPPDevice(client: client, index: index)
+ let info = DeviceFeatures(device: device)
+ if s.features.isEmpty {
+ s.features = (try? await device.featureTable()) ?? []
+ }
+ if s.controls.isEmpty, (try? await device.featureIndex(for: .reprogControlsV4)) != nil {
+ s.reprogVersion = (try? await device.featureVersion(for: .reprogControlsV4)) ?? 0
+ s.controls = (try? await ReprogControls(device: device).allControls()) ?? []
+ }
+ if s.battery == nil {
+ s.battery = await device.battery()
+ }
+ if s.firmware.isEmpty, (try? await device.featureIndex(for: .deviceFirmwareVersion)) != nil {
+ s.firmware = (try? await info.firmware()) ?? []
+ }
+ if s.dpi == nil, (try? await device.featureIndex(for: .adjustableDPI)) != nil {
+ s.dpi = try? await info.dpi()
+ }
+ if s.hosts == nil, (try? await device.featureIndex(for: .changeHost)) != nil, var hosts = try? await info.hosts() {
+ if (try? await device.featureIndex(for: .hostsInfo)) != nil {
+ hosts.names = (try? await info.hostNames(count: hosts.count)) ?? []
+ }
+ s.hosts = hosts
+ }
+ return s
+ }
+}
+
+// MARK: - Per-link logic
+
+/// Runs the probe on the links Probe.swift opens: serves the ones Upright serves (through
+/// `HIDPPLink`, exactly as the app does), reads the others without writing (`--all`), and
+/// prints everything through the console.
+@MainActor
+final class ProbeRunner {
+ let options: ProbeOptions
+ let console: ProbeConsole
+
+ /// Read timeout for a ping or a table read while scanning.
+ var scanTimeout: TimeInterval = 1.0
+
+ private var hubs: [UInt32: HIDPPLink] = [:]
+ private var clients: [UInt32: HIDPPClient] = [:]
+ private var scans: [UInt32: Task] = [:]
+ private var lastSnapshots: [DeviceAddress: DeviceSnapshot] = [:]
+ private var awake: Set = []
+ /// Devices whose buttons this run diverted.
+ private(set) var divertedAddresses: Set = []
+
+ init(options: ProbeOptions, console: ProbeConsole) {
+ self.options = options
+ self.console = console
+ }
+
+ var hasDivertedButtons: Bool { !divertedAddresses.isEmpty }
+
+ /// Registers a matched interface and says what Upright does with it.
+ func announce(_ info: ProbeLinkInfo) {
+ console.addLink(info)
+ var text = "→ \(info.summary)\n Upright: "
+ if let kind = info.transportKind {
+ switch kind {
+ case .receiver(let connection): text += "serves it as a \(connection.probeTitle) receiver"
+ case .direct(let connection): text += "serves it as a mouse connected by \(connection.probeTitle)"
+ }
+ } else {
+ text += "ignores it (\(info.ignoredReason ?? "not a supported device"))"
+ }
+ if !info.carriesHIDPP { text += "\n This interface doesn't carry HID++, so it isn't opened." }
+ console.say(text, link: info.number)
+ }
+
+ // MARK: Interfaces Upright serves
+
+ /// Hands a link Upright serves to its hub and prints what its endpoints report.
+ func attachHub(_ link: HIDPPLink, info: ProbeLinkInfo) {
+ console.addLink(info)
+ hubs[info.number] = link
+ trace(link.client, link: info.number)
+ link.onLog = { [weak self] address, text in
+ guard let self else { return }
+ if let address {
+ self.console.say(text, at: address)
+ } else {
+ self.console.say(text, link: info.number)
+ }
+ }
+ link.onEndpointChange = { [weak self] address, state in
+ self?.report(state, at: address, link: link, info: info)
+ }
+ link.onEndpointRemoved = { [weak self] address, reason in
+ self?.endpointRemoved(address, reason)
+ }
+ link.onButtonEvent = { [weak self] event in
+ self?.buttonEvent(event)
+ }
+ // Read-only unless this run diverts: a mouse the probe doesn't divert keeps whatever
+ // reporting it has (Upright's diversion included) instead of being forced native.
+ link.leavesReportingAlone = true
+ if options.divert {
+ link.configurationProvider = { [weak self] address, snapshot in
+ self?.configuration(for: address, snapshot: snapshot) ?? .native
+ }
+ }
+ link.start()
+ if options.all, info.isReceiver {
+ // The hub leaves keyboards and other non-pointers in a receiver's slots alone; read them.
+ scans[info.number] = Task { [weak self] in
+ await self?.scanReceiver(client: link.client, info: info, hub: link)
+ }
+ }
+ }
+
+ private func configuration(for address: DeviceAddress, snapshot: DeviceSnapshot) -> EndpointConfiguration {
+ console.setName(ProbeFormat.labelName(snapshot), for: address)
+ guard options.shouldDivert(label: console.label(for: address)) else { return .native }
+ divertedAddresses.insert(address)
+ return EndpointConfiguration(divert: Set(snapshot.configurableControls.map(\.cid)))
+ }
+
+ private func report(_ state: EndpointController.State, at address: DeviceAddress, link: HIDPPLink, info: ProbeLinkInfo) {
+ switch state {
+ case .probing:
+ console.say("… probing", at: address)
+ case .waitingForDevice:
+ console.say("… not answering yet (asleep, off, or connected to another computer); click a button to wake it", at: address)
+ case .asleep:
+ awake.remove(address)
+ console.say("… stopped answering (asleep or out of range); it is reconfigured when it wakes", at: address)
+ case .failed(let message):
+ console.say("✗ \(message)", at: address)
+ case .connected(let snapshot):
+ connected(snapshot, at: address, info: info)
+ case .unsupported(let reason, let partial):
+ unsupported(reason, partial: partial, at: address, link: link, info: info)
+ }
+ }
+
+ private func connected(_ snapshot: DeviceSnapshot, at address: DeviceAddress, info: ProbeLinkInfo) {
+ console.setName(ProbeFormat.labelName(snapshot), for: address)
+ let previous = lastSnapshots[address]
+ lastSnapshots[address] = snapshot
+ if let previous {
+ // Reconnected or refreshed: not worth the whole table again.
+ if awake.insert(address).inserted { console.say("✓ answering again", at: address) }
+ for change in ProbeFormat.changes(from: previous, to: snapshot) { console.say(change, at: address) }
+ return
+ }
+ awake.insert(address)
+ console.say(ProbeFormat.dump(snapshot, verdict: .supported, place: console.place(of: address), isReceiver: info.isReceiver).joined(separator: "\n"), at: address)
+ if divertedAddresses.contains(address) {
+ console.say("Diverting all \(snapshot.configurableControls.count) remappable buttons. Press them to see events; Ctrl-C restores them.", at: address)
+ } else if options.divert {
+ console.say("Not diverting this device (it doesn't match --divert=\(options.divertFilter ?? "")).", at: address)
+ }
+ }
+
+ private func unsupported(_ reason: UnsupportedReason, partial: DeviceSnapshot?, at address: DeviceAddress, link: HIDPPLink, info: ProbeLinkInfo) {
+ let seed = partial ?? DeviceSnapshot(
+ name: DeviceSession.defaultName, connection: info.connection, deviceIndex: address.index,
+ protocolVersion: "?", features: [], controls: [], battery: nil, address: address
+ )
+ if partial != nil { console.setName(ProbeFormat.labelName(seed), for: address) }
+ console.say("✗ not supported by Upright: \(reason.message). Reading its details (reads only)...", at: address)
+ // The device checks stop at the first failure; the report reads the rest (reads only).
+ Task { [weak self] in
+ let snapshot = await ProbeReader.complete(seed, client: link.client, index: address.index)
+ guard let self, link.endpoints[address.index] != nil else { return }
+ self.console.setName(ProbeFormat.labelName(snapshot), for: address)
+ self.console.say(ProbeFormat.dump(snapshot, verdict: .unsupported(reason), place: self.console.place(of: address), isReceiver: info.isReceiver).joined(separator: "\n"), at: address)
+ }
+ }
+
+ private func endpointRemoved(_ address: DeviceAddress, _ reason: HIDPPLink.RemovalReason) {
+ let why: String
+ switch reason {
+ case .unpaired: why = "the device was unpaired from the receiver"
+ case .retired(let newAddress): why = "the same unit now answers in slot \(newAddress.index)"
+ case .replaced: why = "another device took the slot"
+ case .transportClosed: why = "the HID connection closed"
+ }
+ console.say("← removed: \(why)", at: address)
+ lastSnapshots[address] = nil
+ awake.remove(address)
+ divertedAddresses.remove(address)
+ console.forget(address)
+ }
+
+ private func buttonEvent(_ event: ButtonEvent) {
+ let model = lastSnapshots[event.source]?.identity?.model
+ let name = ControlDescriptor(cid: event.cid, model: model).name
+ let state = event.phase == .press ? "pressed" : "released"
+ console.say("● \(name) (0x\(ProbeFormat.hex(event.cid, 4))) \(state)", at: event.source)
+ }
+
+ // MARK: Interfaces Upright doesn't serve (--all)
+
+ /// Reads a link Upright doesn't open: its receiver slots, or the one device behind it.
+ /// Nothing is written.
+ func attachRaw(client: HIDPPClient, info: ProbeLinkInfo) {
+ console.addLink(info)
+ clients[info.number] = client
+ trace(client, link: info.number)
+ scans[info.number] = Task { [weak self] in
+ guard let self else { return }
+ if info.isReceiver {
+ await self.scanReceiver(client: client, info: info, hub: nil)
+ } else {
+ await self.dumpDevice(client: client, info: info, index: DeviceAddress.direct, transportPID: info.productID, kindHint: nil)
+ }
+ }
+ }
+
+ /// Prints a receiver's pairing table and reads its devices: every occupied slot when `hub`
+ /// is nil, otherwise only the non-pointers (keyboards ...) the hub leaves alone.
+ private func scanReceiver(client: HIDPPClient, info: ProbeLinkInfo, hub: HIDPPLink?) async {
+ let records: [UInt8: PairingRecord]?
+ do {
+ records = try await ReceiverDirectory.readPairings(using: client, layout: info.layout, timeout: scanTimeout)
+ } catch {
+ return
+ }
+ guard !Task.isCancelled else { return }
+
+ let layoutName = info.layout == .bolt ? "Bolt" : "Unifying"
+ var slots: [(slot: UInt8, record: PairingRecord?)] = []
+ if let records {
+ if records.isEmpty {
+ console.say("Pairing table (\(layoutName) layout): nothing paired", link: info.number)
+ } else {
+ var text = "Pairing table (\(layoutName) layout):"
+ for slot in records.keys.sorted() {
+ guard let record = records[slot] else { continue }
+ let model = DeviceCatalog.model(forPID: record.wirelessPID)?.name
+ var line = "\n slot \(slot): wireless PID \(ProbeFormat.hex(record.wirelessPID, 4)), \(record.kind.name)"
+ if let model { line += ", \(model) in Upright's catalog" }
+ if let serial = try? await ReceiverDirectory.readSerial(using: client, slot: slot, layout: info.layout, timeout: scanTimeout) {
+ line += ", receiver-side serial \(serial)"
+ }
+ text += line
+ slots.append((slot, record))
+ }
+ console.say(text, link: info.number)
+ }
+ } else if hub == nil {
+ // The hub does its own announcements and pings; a bare scan has to look for itself.
+ console.say("The receiver can't list its pairings; pinging slots 1...6", link: info.number)
+ for slot in ReceiverDirectory.slots {
+ guard !Task.isCancelled else { return }
+ do {
+ _ = try await HIDPPDevice(client: client, index: slot).protocolVersion(timeout: scanTimeout)
+ slots.append((slot, nil))
+ } catch HIDPPError.transportClosed {
+ return
+ } catch {
+ continue
+ }
+ }
+ } else {
+ console.say("The receiver can't list its pairings", link: info.number)
+ }
+
+ for (slot, record) in slots {
+ guard !Task.isCancelled else { return }
+ if let hub {
+ guard record?.kind.isDefinitelyNotPointer == true, hub.endpoints[slot] == nil else { continue }
+ }
+ await dumpDevice(client: client, info: info, index: slot, transportPID: record?.wirelessPID, kindHint: record?.kind)
+ }
+ }
+
+ /// Identifies one device and prints its full report, whatever Upright thinks of it.
+ private func dumpDevice(client: HIDPPClient, info: ProbeLinkInfo, index: UInt8, transportPID: UInt16?, kindHint: DeviceKind?) async {
+ let address = DeviceAddress(link: info.number, index: index)
+ let outcome = await ProbeReader.read(
+ client: client, address: address, connection: info.connection, transportPID: transportPID,
+ fallbackName: info.isReceiver || info.productName.isEmpty ? nil : info.productName,
+ kindHint: kindHint, pingTimeout: scanTimeout
+ )
+ guard !Task.isCancelled, console.links[info.number] != nil else { return }
+ switch outcome {
+ case .noAnswer(let error):
+ console.say("✗ no HID++ answer (\(error)). If it is a wireless device, wake it (move or click) and run again.", at: address)
+ case .device(let snapshot, let gate):
+ console.setName(ProbeFormat.labelName(snapshot), for: address)
+ let verdict: ProbeVerdict = gate.map { .unsupported($0) } ?? info.ignoredReason.map { .interfaceNotOpened($0) } ?? .supported
+ console.say(ProbeFormat.dump(snapshot, verdict: verdict, place: console.place(of: address), isReceiver: info.isReceiver).joined(separator: "\n"), at: address)
+ }
+ }
+
+ // MARK: Shared
+
+ private func trace(_ client: HIDPPClient, link: UInt32) {
+ guard options.trace else { return }
+ client.onTraffic = { [weak self] direction, bytes in
+ MainActorDelivery.run {
+ guard let self else { return }
+ let arrow = direction == .outgoing ? ">>" : "<<"
+ let text = bytes.prefix(20).map { ProbeFormat.hex($0, 2) }.joined(separator: " ")
+ let index = bytes.count > 1 ? bytes[1] : DeviceAddress.direct
+ self.console.say(" \(arrow) \(text)", at: DeviceAddress(link: link, index: index))
+ }
+ }
+ }
+
+ /// Stops everything on one link. The transport is the caller's.
+ func detach(link number: UInt32) {
+ scans[number]?.cancel()
+ scans[number] = nil
+ hubs.removeValue(forKey: number)?.shutdown()
+ clients.removeValue(forKey: number)?.close()
+ console.removeLink(number)
+ }
+
+ /// Hands every diverted button back to its mouse and stops all links.
+ func shutdown() {
+ for hub in hubs.values {
+ hub.onEndpointChange = nil
+ hub.onEndpointRemoved = nil
+ }
+ for number in Set(hubs.keys).union(clients.keys).union(scans.keys) {
+ detach(link: number)
+ }
+ }
+}
diff --git a/UprightKit/Tests/UprightKitTests/CatalogTests.swift b/UprightKit/Tests/UprightKitTests/CatalogTests.swift
new file mode 100644
index 0000000..50e8b4f
--- /dev/null
+++ b/UprightKit/Tests/UprightKitTests/CatalogTests.swift
@@ -0,0 +1,375 @@
+import XCTest
+@testable import UprightKit
+
+final class CatalogTests: XCTestCase {
+ // MARK: Models
+
+ func testEveryCatalogPIDUniqueAcrossModels() {
+ var owner: [UInt16: ModelKey] = [:]
+ for model in DeviceCatalog.models {
+ for pid in model.allPIDs {
+ if let other = owner[pid] {
+ XCTFail("PID \(String(pid, radix: 16)) is claimed by both \(other) and \(model.key)")
+ }
+ owner[pid] = model.key
+ }
+ XCTAssertEqual(Set(model.allPIDs).count, model.allPIDs.count, "\(model.name) repeats a PID")
+ }
+ XCTAssertEqual(Set(DeviceCatalog.models.map(\.key)).count, DeviceCatalog.models.count, "model keys are unique")
+ XCTAssertEqual(Set(DeviceCatalog.models.map(\.name)).count, DeviceCatalog.models.count, "model names are unique")
+
+ // Receivers and gaming mice never collide with a model either.
+ for pid in DeviceCatalog.receivers.keys {
+ XCTAssertNil(DeviceCatalog.model(forPID: pid), "receiver \(String(pid, radix: 16)) is not a mouse")
+ }
+ for device in DeviceCatalog.knownUnsupported {
+ for pid in device.allPIDs {
+ XCTAssertNil(owner[pid], "\(device.name) is not served, but \(String(pid, radix: 16)) belongs to a model")
+ }
+ }
+ }
+
+ func testMXVerticalEntryMatchesShippedBehaviour() throws {
+ let model = try XCTUnwrap(DeviceCatalog.model(.mxVertical))
+ XCTAssertEqual(DeviceCatalog.models.first?.key, .mxVertical, "the MX Vertical is listed first")
+ XCTAssertEqual(model.key.rawValue, "046D:B020")
+ XCTAssertEqual(model.name, "MX Vertical")
+ XCTAssertEqual(model.usbPIDs, [0xC08A])
+ XCTAssertEqual(model.bluetoothPIDs, [0xB020])
+ XCTAssertEqual(model.wirelessPIDs, [0x407B])
+ XCTAssertEqual(model.verification, .hardware)
+ XCTAssertEqual(model.suggested, ButtonMappings.legacyDefaults)
+ XCTAssertEqual(model.controlOrder, [0x53, 0x56, 0xFD, 0x52], "Back, Forward, Top Button, Wheel Click")
+
+ for pid: UInt16 in [0xC08A, 0xB020, 0x407B] {
+ XCTAssertEqual(DeviceCatalog.model(forPID: pid)?.key, .mxVertical)
+ }
+ // Each control reads exactly as Upright has always shown it.
+ for cid in [KnownControl.back, KnownControl.forward, KnownControl.dpiSwitch, KnownControl.dpiChange, KnownControl.middleClick] {
+ XCTAssertEqual(ControlCatalog.text(for: cid, model: .mxVertical), ControlDescriptor(cid: cid).text)
+ }
+ }
+
+ func testEveryControlOrderCIDIsNamed() {
+ for model in DeviceCatalog.models {
+ XCTAssertFalse(model.controlOrder.isEmpty, "\(model.name) lists no controls")
+ XCTAssertEqual(Set(model.controlOrder).count, model.controlOrder.count, "\(model.name) repeats a control")
+ for cid in model.controlOrder {
+ XCTAssertNotNil(ControlCatalog.solaarName(for: cid), "\(model.name): 0x\(String(cid, radix: 16)) has no Solaar name")
+ XCTAssertNotNil(ControlCatalog.genericText[cid] ?? model.controlText[cid], "\(model.name): 0x\(String(cid, radix: 16)) has no wording")
+ XCTAssertFalse(ControlCatalog.neverDivert.contains(cid), "\(model.name) offers a primary click")
+ XCTAssertNotEqual(cid, KnownControl.virtualGesture, "\(model.name) offers the virtual gesture control")
+ XCTAssertFalse(ControlCatalog.text(for: cid, model: model.key).name.hasPrefix("Button 0x"), "\(model.name): 0x\(String(cid, radix: 16)) falls back")
+ }
+ for cid in model.controlText.keys {
+ XCTAssertNotNil(ControlCatalog.solaarName(for: cid), "\(model.name) overrides an unnamed control")
+ }
+ }
+ }
+
+ func testControlTextFallbacks() {
+ // Unknown control: the hex fallback, on any model.
+ let unknown = ControlCatalog.text(for: 0x0777, model: nil)
+ XCTAssertEqual(unknown.name, "Button 0x0777")
+ XCTAssertEqual(unknown.detail, "Additional programmable control")
+ XCTAssertEqual(unknown.symbolName, "button.programmable")
+ XCTAssertEqual(ControlCatalog.text(for: 0x0777, model: .mxVertical), unknown)
+
+ // Generic name when the model has no override, or isn't in the catalog at all.
+ let tilt = ControlCatalog.text(for: KnownControl.tiltLeft, model: nil)
+ XCTAssertEqual(tilt.name, "Tilt Left")
+ XCTAssertEqual(ControlCatalog.text(for: KnownControl.tiltLeft, model: .mxVertical), tilt)
+ XCTAssertEqual(ControlCatalog.text(for: KnownControl.tiltLeft, model: ModelKey(productID: 0x1234)), tilt)
+ XCTAssertEqual(ControlCatalog.text(for: KnownControl.tiltLeft, model: ModelKey(name: "Some Mouse")), tilt)
+
+ // The model's own wording beats the generic one.
+ XCTAssertEqual(ControlCatalog.text(for: KnownControl.back, model: nil).detail, "Thumb button")
+ XCTAssertEqual(ControlCatalog.text(for: KnownControl.back, model: .mxVertical).detail, "Lower thumb button")
+ XCTAssertEqual(ControlCatalog.text(for: KnownControl.dpiSwitch, model: nil).name, "DPI Switch Button")
+ XCTAssertEqual(ControlCatalog.text(for: KnownControl.dpiSwitch, model: .mxVertical).name, "Top Button")
+ XCTAssertEqual(ControlCatalog.text(for: KnownControl.dpiSwitch, model: DeviceCatalog.lift.key).name, "Top Button")
+ XCTAssertEqual(ControlCatalog.text(for: KnownControl.dpiChange, model: DeviceCatalog.mxErgo.key).name, "Precision Button")
+ XCTAssertEqual(ControlCatalog.text(for: KnownControl.dpiChange, model: DeviceCatalog.mxVertical.key).name, "Top Button")
+
+ // ControlDescriptor(cid:model:) goes through the same lookup.
+ XCTAssertEqual(ControlDescriptor(cid: KnownControl.tiltLeft, model: nil).name, "Tilt Left")
+ XCTAssertEqual(ControlDescriptor(cid: KnownControl.forward, model: DeviceCatalog.mxMaster3S.key).detail, "Thumb button")
+ XCTAssertEqual(ControlDescriptor(cid: KnownControl.forward, model: .mxVertical).detail, "Upper thumb button")
+ XCTAssertEqual(ControlDescriptor(cid: 0x0777, model: nil).name, "Button 0x0777")
+ XCTAssertEqual(ControlDescriptor(cid: KnownControl.tiltLeft, model: nil).hexID, "0x005B")
+ }
+
+ func testOldControlDescriptorInitKeepsMXVerticalWording() {
+ let expectations: [(UInt16, String, String, String)] = [
+ (KnownControl.back, "Back Button", "Lower thumb button", "chevron.backward.circle"),
+ (KnownControl.forward, "Forward Button", "Upper thumb button", "chevron.forward.circle"),
+ (KnownControl.dpiSwitch, "Top Button", "Pointer-speed (DPI) button behind the wheel", "speedometer"),
+ (KnownControl.dpiChange, "Top Button", "Pointer-speed (DPI) button behind the wheel", "speedometer"),
+ (KnownControl.middleClick, "Wheel Click", "Press the scroll wheel", "smallcircle.filled.circle"),
+ (0x1234, "Control 0x1234", "Additional programmable control", "button.programmable"),
+ // Controls the 1.x app never named keep the "Control 0x" wording here, whatever the catalog calls them.
+ (KnownControl.tiltLeft, "Control 0x005B", "Additional programmable control", "button.programmable"),
+ ]
+ for (cid, name, detail, symbol) in expectations {
+ let descriptor = ControlDescriptor(cid: cid)
+ XCTAssertEqual(descriptor.cid, cid)
+ XCTAssertEqual(descriptor.name, name)
+ XCTAssertEqual(descriptor.detail, detail)
+ XCTAssertEqual(descriptor.symbolName, symbol)
+ XCTAssertEqual(descriptor.id, cid)
+ }
+ XCTAssertEqual(ControlDescriptor(cid: 0x0053).hexID, "0x0053")
+ }
+
+ // MARK: Control names
+
+ func testAllSolaarControlNamesAreCovered() {
+ let expected: [UInt16] = [
+ 0x50, 0x51, 0x52, 0x53, 0x54, 0x55, 0x56, 0x57, 0x58, 0x59, 0x5A, 0x5B, 0x5C, 0x5D, 0x5E, 0x5F,
+ 0x97, 0xAC, 0xB7, 0xB8, 0xB9, 0xC3, 0xC4, 0xCE, 0xCF, 0xD0, 0xD7, 0xD8, 0xED, 0xFD, 0x102, 0x1A0, 0x1B4,
+ ]
+ XCTAssertEqual(expected.count, 33)
+ XCTAssertEqual(ControlCatalog.namedControls, expected)
+ XCTAssertEqual(Set(ControlCatalog.genericText.keys), Set(expected))
+ for cid in expected {
+ let text = ControlCatalog.text(for: cid, model: nil)
+ XCTAssertFalse(text.name.isEmpty)
+ XCTAssertFalse(text.detail.isEmpty)
+ XCTAssertFalse(text.symbolName.isEmpty)
+ XCTAssertFalse(text.name.hasPrefix("Button 0x"), "0x\(String(cid, radix: 16)) uses the fallback")
+ }
+ let names = expected.map { ControlCatalog.text(for: $0, model: nil).name }
+ XCTAssertEqual(Set(names).count, names.count, "generic names are distinct")
+
+ XCTAssertEqual(ControlCatalog.solaarName(for: 0x00C3), "Mouse Gesture Button")
+ XCTAssertEqual(ControlCatalog.solaarName(for: 0x00FD), "DPI Switch")
+ XCTAssertEqual(ControlCatalog.solaarName(for: 0x01B4), "AI")
+ XCTAssertNil(ControlCatalog.solaarName(for: 0x0777))
+ }
+
+ func testKnownControlIDsMatchSolaar() {
+ XCTAssertEqual(KnownControl.leftClick, 0x50)
+ XCTAssertEqual(KnownControl.rightClick, 0x51)
+ XCTAssertEqual(KnownControl.middleClick, 0x52)
+ XCTAssertEqual(KnownControl.back, 0x53)
+ XCTAssertEqual(KnownControl.forward, 0x56)
+ XCTAssertEqual(KnownControl.tiltLeft, 0x5B)
+ XCTAssertEqual(KnownControl.tiltRight, 0x5D)
+ XCTAssertEqual(KnownControl.gestureButton, 0xC3)
+ XCTAssertEqual(KnownControl.smartShift, 0xC4)
+ XCTAssertEqual(KnownControl.multiPlatformGesture, 0xD0)
+ XCTAssertEqual(KnownControl.virtualGesture, 0xD7)
+ XCTAssertEqual(KnownControl.dpiChange, 0xED)
+ XCTAssertEqual(KnownControl.dpiSwitch, 0xFD)
+ XCTAssertEqual(KnownControl.hapticSense, 0x1A0)
+ XCTAssertEqual(KnownControl.primaryClicks, [0x50, 0x51])
+ XCTAssertEqual(ControlCatalog.neverDivert, [0x50, 0x51])
+ }
+
+ // MARK: Model entries
+
+ func testResearchedModelsArePresentWithTheirIDs() throws {
+ // (name, canonical key, Bluetooth PIDs, wireless PIDs, USB PIDs)
+ let expectations: [(String, UInt16, [UInt16], [UInt16], [UInt16])] = [
+ ("MX Master", 0xB012, [0xB012, 0xB01E], [0x4041, 0x4071], []),
+ ("MX Master 2S", 0xB019, [0xB019], [0x4069], []),
+ ("MX Master 3", 0xB023, [0xB023], [0x4082], []),
+ ("MX Master 3 for Business", 0xB028, [0xB028], [0xB028], []),
+ ("MX Master 3S", 0xB034, [0xB034], [0xB034], []),
+ ("MX Master 4", 0xB042, [0xB042], [], []),
+ ("MX Anywhere 2", 0xB013, [0xB013, 0xB018, 0xB01F], [0x404A, 0x4072, 0x4063], []),
+ ("MX Anywhere 2S", 0xB01A, [0xB01A], [0x406A], []),
+ ("MX Anywhere 3", 0xB025, [0xB025], [0x4090], []),
+ ("MX Anywhere 3 for Business", 0xB02D, [0xB02D], [0xB02D], []),
+ ("MX Ergo", 0xB01D, [0xB01D], [0x406F], []),
+ ("Lift", 0xB031, [0xB031], [0xB031], []),
+ ("Lift for Business", 0xB033, [0xB033], [0xB033], []),
+ ("M720 Triathlon", 0xB015, [0xB015], [0x405E], []),
+ ("M585 / M590", 0xB01B, [0xB01B], [0x406B], []),
+ ("Signature M650 L", 0xB02A, [0xB02A], [0xB02A], []),
+ ("Signature M550", 0xB02B, [0xB02B], [0xB02B], []),
+ ("ERGO M575", 0xB027, [0xB027], [0x4096], []),
+ ("Marathon M705", 0x406D, [], [0x406D], []),
+ ("Pebble M350", 0xB021, [0xB021], [0x4080], []),
+ ("M510 (v2)", 0x4051, [], [0x4051], []),
+ ("M185 / M235 / M310", 0x4055, [], [0x4055], []),
+ ]
+ for (name, key, bluetooth, wireless, usb) in expectations {
+ let model = try XCTUnwrap(DeviceCatalog.models.first { $0.name == name }, name)
+ XCTAssertEqual(model.key, ModelKey(productID: key), name)
+ XCTAssertEqual(model.bluetoothPIDs, bluetooth, name)
+ XCTAssertEqual(model.wirelessPIDs, wireless, name)
+ XCTAssertEqual(model.usbPIDs, usb, name)
+ XCTAssertEqual(DeviceCatalog.model(model.key)?.name, name)
+ XCTAssertEqual(model.verification, .publishedDump, "\(name) hasn't been tested on hardware")
+ for pid in model.allPIDs {
+ XCTAssertEqual(DeviceCatalog.model(forPID: pid)?.key, model.key, "\(name) \(String(pid, radix: 16))")
+ }
+ }
+ XCTAssertEqual(DeviceCatalog.models.count, expectations.count + 1, "every model is covered above, plus the MX Vertical")
+ }
+
+ func testModelKeyIsTheCanonicalIDOfEveryEntry() {
+ for model in DeviceCatalog.models {
+ guard let canonical = model.bluetoothPIDs.first ?? model.wirelessPIDs.first ?? model.usbPIDs.first else {
+ XCTFail("\(model.name) has no PID")
+ continue
+ }
+ XCTAssertEqual(model.key, ModelKey(productID: canonical), "\(model.name): first Bluetooth, else wireless, else USB (D5)")
+ }
+ }
+
+ func testOnlyTheMXVerticalIsVerifiedOnHardware() {
+ XCTAssertEqual(DeviceCatalog.models.filter { $0.verification == .hardware }.map(\.key), [.mxVertical])
+ XCTAssertTrue(DeviceCatalog.models.dropFirst().allSatisfy { $0.verification == .publishedDump })
+ XCTAssertEqual(DeviceModel.Verification.publishedData, .publishedDump)
+ }
+
+ func testSuggestedPerModel() throws {
+ let navigation: ButtonMappings = ButtonMappings([
+ KnownControl.back: .system(.navigateBack),
+ KnownControl.forward: .system(.navigateForward),
+ ])
+ XCTAssertEqual(DeviceCatalog.mxVertical.suggested, .legacyDefaults)
+
+ // Back and Forward navigate; a gesture button opens Mission Control.
+ let master = ButtonMappings([
+ KnownControl.back: .system(.navigateBack),
+ KnownControl.forward: .system(.navigateForward),
+ KnownControl.gestureButton: .system(.missionControl),
+ ])
+ for model in [DeviceCatalog.mxMaster, DeviceCatalog.mxMaster2S, DeviceCatalog.mxMaster3, DeviceCatalog.mxMaster3S, DeviceCatalog.mxMaster4] {
+ XCTAssertEqual(model.suggested, master, model.name)
+ }
+ XCTAssertEqual(DeviceCatalog.m720.suggested, ButtonMappings([
+ KnownControl.back: .system(.navigateBack),
+ KnownControl.forward: .system(.navigateForward),
+ KnownControl.multiPlatformGesture: .system(.missionControl),
+ ]))
+ for model in [DeviceCatalog.mxAnywhere3, DeviceCatalog.lift, DeviceCatalog.mxErgo, DeviceCatalog.m575] {
+ XCTAssertEqual(model.suggested, navigation, model.name)
+ }
+ // Nothing to suggest without those buttons.
+ for model in [DeviceCatalog.m550, DeviceCatalog.pebbleM350, DeviceCatalog.m185] {
+ XCTAssertTrue(model.suggested.isEmpty, model.name)
+ }
+ XCTAssertEqual(DeviceCatalog.suggestedMappings(forControls: []), ButtonMappings())
+ XCTAssertEqual(DeviceCatalog.suggestedMappings(forControls: [KnownControl.back]), ButtonMappings([KnownControl.back: .system(.navigateBack)]))
+ // The suggestion only ever touches controls the model offers.
+ for model in DeviceCatalog.models.dropFirst() {
+ XCTAssertTrue(Set(model.suggested.mappedControls.keys).isSubset(of: Set(model.controlOrder)), model.name)
+ }
+ }
+
+ func testControlsSortInDisplayOrder() {
+ let vertical = DeviceCatalog.mxVertical
+ XCTAssertEqual(vertical.displayOrder([0x52, 0xFD, 0x56, 0x53]), [0x53, 0x56, 0xFD, 0x52])
+ // Controls the model doesn't list follow, by CID.
+ XCTAssertEqual(vertical.displayOrder([0x5D, 0x52, 0x5B, 0x53]), [0x53, 0x52, 0x5B, 0x5D])
+ XCTAssertEqual(DeviceCatalog.sortedControls([0x52, 0xFD, 0x56, 0x53], for: .mxVertical), [0x53, 0x56, 0xFD, 0x52])
+ XCTAssertEqual(DeviceCatalog.sortedControls([0xFD, 0x53, 0x52], for: nil), [0x52, 0x53, 0xFD])
+ XCTAssertEqual(DeviceCatalog.sortedControls([0xFD, 0x53, 0x52], for: ModelKey(productID: 0x1234)), [0x52, 0x53, 0xFD])
+ XCTAssertEqual(DeviceCatalog.sortedControls([], for: .mxVertical), [])
+ XCTAssertEqual(
+ DeviceCatalog.mxMaster4.displayOrder([0x52, 0xC4, 0x1A0, 0xC3, 0x56, 0x53]),
+ [0x53, 0x56, 0xC3, 0x1A0, 0xC4, 0x52]
+ )
+ }
+
+ // MARK: Receivers
+
+ func testReceiversFollowTheSupportedList() {
+ for pid: UInt16 in [0xC52B, 0xC532] {
+ XCTAssertEqual(DeviceCatalog.receivers[pid]?.kind, .unifying)
+ XCTAssertEqual(DeviceCatalog.receivers[pid]?.layout, .unifying)
+ XCTAssertEqual(DeviceCatalog.receivers[pid]?.family, .unifying)
+ }
+ XCTAssertEqual(DeviceCatalog.receivers[0xC548]?.kind, .bolt)
+ XCTAssertEqual(DeviceCatalog.receivers[0xC548]?.layout, .bolt)
+ XCTAssertEqual(DeviceCatalog.receivers[0xC548]?.family, .bolt)
+
+ // Nano receivers pair like Unifying ones.
+ let nano: [UInt16] = [0xC52F, 0xC531, 0xC534, 0xC537, 0xC518, 0xC51A, 0xC51B, 0xC521, 0xC525, 0xC526, 0xC52E, 0xC535]
+ for pid in nano {
+ XCTAssertEqual(DeviceCatalog.receivers[pid]?.kind, .nano, String(pid, radix: 16))
+ XCTAssertEqual(DeviceCatalog.receivers[pid]?.layout, .unifying, String(pid, radix: 16))
+ XCTAssertEqual(DeviceCatalog.receivers[pid]?.family, .nano, String(pid, radix: 16))
+ }
+ let served = DeviceCatalog.receivers.values.filter { $0.layout != nil }
+ XCTAssertEqual(served.count, 2 + 1 + nano.count)
+ XCTAssertTrue(served.allSatisfy { $0.exclusionReason == nil })
+ XCTAssertTrue(DeviceCatalog.receivers.allSatisfy { $0.key == $0.value.productID })
+ }
+
+ func testLightspeedAnd27MHzReceiversAreNeverOpened() {
+ let lightspeed: [UInt16] = [0xC539, 0xC53A, 0xC53D, 0xC53F, 0xC541, 0xC545, 0xC547, 0xC54D]
+ for pid in lightspeed {
+ let receiver = DeviceCatalog.receivers[pid]
+ XCTAssertNotNil(receiver, String(pid, radix: 16))
+ XCTAssertNil(receiver?.layout, String(pid, radix: 16))
+ XCTAssertEqual(receiver?.family, .lightspeed)
+ XCTAssertEqual(receiver?.exclusionReason, "Lightspeed receiver: not supported (no programmable-button feature on its mice)")
+ }
+ XCTAssertNil(DeviceCatalog.receivers[0xC517]?.layout)
+ XCTAssertEqual(DeviceCatalog.receivers[0xC517]?.family, .legacy27MHz)
+ XCTAssertNotNil(DeviceCatalog.receivers[0xC517]?.exclusionReason)
+ // Lenovo's 17EF:6042 is not matched.
+ XCTAssertNil(DeviceCatalog.receivers[0x6042])
+ XCTAssertEqual(DeviceCatalog.receivers.values.filter { $0.layout == nil }.count, lightspeed.count + 1)
+ }
+
+ func testGamingMiceAreListedAsKnownUnsupported() {
+ XCTAssertEqual(DeviceCatalog.knownUnsupported.count, 9)
+ XCTAssertEqual(DeviceCatalog.knownUnsupported(forPID: 0x407F)?.name, "G502 Lightspeed")
+ XCTAssertEqual(DeviceCatalog.knownUnsupported(forPID: 0xC094)?.name, "PRO X Wireless")
+ XCTAssertEqual(DeviceCatalog.knownUnsupported(forPID: 0xB024)?.name, "G604")
+ XCTAssertNil(DeviceCatalog.knownUnsupported(forPID: 0xB020))
+ for device in DeviceCatalog.knownUnsupported {
+ XCTAssertNil(DeviceCatalog.model(forPID: device.allPIDs.first ?? 0), device.name)
+ XCTAssertFalse(device.reason.isEmpty)
+ }
+ }
+
+ // MARK: Model keys (D5)
+
+ func testModelKeyForIdentityFollowsTheCanonicalRule() {
+ func key(_ ids: ModelIDs, transportPID: UInt16? = nil, name: String = "Test Mouse") -> ModelKey {
+ DeviceCatalog.modelKey(for: DeviceIdentity(name: name, modelIDs: ids, transportPID: transportPID))
+ }
+
+ // The MX Vertical is one model on every transport.
+ XCTAssertEqual(key(ModelIDs(bluetoothLE: 0xB020, wireless: 0x407B, usb: 0xC08A)), .mxVertical)
+ XCTAssertEqual(key(ModelIDs(usb: 0xC08A)), .mxVertical)
+ XCTAssertEqual(key(ModelIDs(wireless: 0x407B)), .mxVertical)
+ XCTAssertEqual(key(ModelIDs(bluetooth: 0xB020)), .mxVertical)
+ XCTAssertEqual(key(ModelIDs(), transportPID: 0x407B), .mxVertical)
+ XCTAssertEqual(key(ModelIDs(), transportPID: 0xC08A), .mxVertical)
+
+ // Catalog aliases resolve to the canonical key.
+ XCTAssertEqual(key(ModelIDs(bluetooth: 0xB01E, wireless: 0x4071)), ModelKey(productID: 0xB012))
+ XCTAssertEqual(key(ModelIDs(wireless: 0x4071)), ModelKey(productID: 0xB012))
+ XCTAssertEqual(key(ModelIDs(), transportPID: 0x4071), ModelKey(productID: 0xB012))
+ XCTAssertEqual(key(ModelIDs(wireless: 0x4063)), ModelKey(productID: 0xB013))
+ XCTAssertEqual(key(ModelIDs(bluetooth: 0xB01F)), ModelKey(productID: 0xB013))
+
+ // Models the catalog has never seen get the key the catalog would give them: the first of
+ // Bluetooth, Bluetooth LE, wireless, USB.
+ XCTAssertEqual(key(ModelIDs(bluetooth: 0xAAA1, bluetoothLE: 0xAAA2, wireless: 0xAAA3, usb: 0xAAA4)), ModelKey(productID: 0xAAA1))
+ XCTAssertEqual(key(ModelIDs(bluetoothLE: 0xAAA2, wireless: 0xAAA3, usb: 0xAAA4)), ModelKey(productID: 0xAAA2))
+ XCTAssertEqual(key(ModelIDs(wireless: 0xAAA3, usb: 0xAAA4)), ModelKey(productID: 0xAAA3))
+ XCTAssertEqual(key(ModelIDs(usb: 0xAAA4)), ModelKey(productID: 0xAAA4))
+ XCTAssertEqual(key(ModelIDs(usb: 0xAAA4), transportPID: 0xBBB1), ModelKey(productID: 0xAAA4), "0x0003 wins over the transport PID")
+
+ // No 0x0003 model IDs: the transport PID, else the name.
+ XCTAssertEqual(key(ModelIDs(), transportPID: 0xBBB1), ModelKey(productID: 0xBBB1))
+ XCTAssertEqual(key(ModelIDs(), name: " Mystery Mouse "), ModelKey(name: "mystery mouse"))
+ XCTAssertEqual(key(ModelIDs(), name: "Mystery Mouse").rawValue, "name:mystery mouse")
+
+ // A known ID anywhere in the list picks the catalog model.
+ XCTAssertEqual(key(ModelIDs(bluetooth: 0xAAA1, wireless: 0x407B)), .mxVertical)
+
+ // The identity initializer uses the same rule.
+ XCTAssertEqual(DeviceIdentity(name: "MX Vertical", modelIDs: ModelIDs(wireless: 0x407B)).model, .mxVertical)
+ XCTAssertEqual(DeviceIdentity(name: "Mystery", modelIDs: ModelIDs(usb: 0xAAA4)).model, ModelKey(productID: 0xAAA4))
+ }
+}
diff --git a/UprightKit/Tests/UprightKitTests/ConfigurationPlannerTests.swift b/UprightKit/Tests/UprightKitTests/ConfigurationPlannerTests.swift
new file mode 100644
index 0000000..cd2f3b5
--- /dev/null
+++ b/UprightKit/Tests/UprightKitTests/ConfigurationPlannerTests.swift
@@ -0,0 +1,480 @@
+import XCTest
+@testable import UprightKit
+
+// MARK: Upright 2.x, quoted
+
+/// What Upright 2.x's `AppModel.syncDiversion` (Upright/App/AppModel.swift at 9b0c592) asked the
+/// connected mouse for, re-derived line by line. The quoted source:
+///
+/// /// The mappings in effect right now, for the frontmost app.
+/// var effectiveMappings: ButtonMappings {
+/// profiles.effectiveMappings(forBundleID: frontmostApp?.bundleID)
+/// }
+///
+/// private func syncDiversion() {
+/// let trusted = permissions.isAccessibilityTrusted
+/// let mappings = effectiveMappings
+/// let cids = isRemappingEnabled
+/// ? mappings.divertedControls(accessibilityGranted: trusted).union(engine.pressedControls)
+/// : []
+/// let rawXY = isRemappingEnabled
+/// ? mappings.gestureControls(accessibilityGranted: trusted).union(engine.gestureControls)
+/// : []
+/// updateMovementMonitor()
+/// if rawXY != appliedRawXY {
+/// appliedRawXY = rawXY
+/// devices.setDesiredRawXY(rawXY)
+/// }
+/// guard cids != appliedDiversion else { return }
+/// appliedDiversion = cids
+/// devices.setDesiredDiversion(cids)
+/// }
+///
+/// The pointer speed went separately: `devices.setDesiredDPI(settings.preferredDPI)`, whatever
+/// the remapping switch says.
+private func todaysSyncDiversion(
+ profiles: MappingProfiles,
+ frontmostBundleID: String?,
+ trusted: Bool,
+ isRemappingEnabled: Bool,
+ pressedControls: Set,
+ gestureControls: Set
+) -> (cids: Set, rawXY: Set) {
+ let mappings = profiles.effectiveMappings(forBundleID: frontmostBundleID)
+ let cids = isRemappingEnabled
+ ? mappings.divertedControls(accessibilityGranted: trusted).union(pressedControls)
+ : []
+ let rawXY = isRemappingEnabled
+ ? mappings.gestureControls(accessibilityGranted: trusted).union(gestureControls)
+ : []
+ return (cids, rawXY)
+}
+
+/// What those sets became on the wire: 2.x's `MXVerticalController` handed them unchanged to
+/// `DeviceSession.applyDiversion(desiredDiversion, rawXY: desiredRawXY)`, quoted:
+///
+/// let wanted = wanted.union(rawXY)
+/// ...
+/// for control in snapshot.configurableControls {
+/// let divert = wanted.contains(control.cid)
+/// // Raw XY bits are only sent to controls that advertise the capability.
+/// let raw: Bool? = control.supportsRawXY ? divert && rawXY.contains(control.cid) : nil
+/// let target = Reporting(diverted: divert, rawXY: raw ?? false)
+/// ...
+/// if divert { divertedControls.insert(control.cid) } else { divertedControls.remove(control.cid) }
+/// if target.rawXY { rawXYControls.insert(control.cid) } else { rawXYControls.remove(control.cid) }
+/// }
+///
+/// and the speed to `DeviceSession.setDPI(desired)`, which sends `dpi.nearest(to: value)` and
+/// does nothing for a mouse without ADJUSTABLE_DPI.
+private func todaysWireState(
+ cids: Set,
+ rawXY: Set,
+ preferredDPI: Int?,
+ snapshot: DeviceSnapshot
+) -> EndpointConfiguration {
+ let wanted = cids.union(rawXY)
+ var diverted: Set = []
+ var raw: Set = []
+ for control in snapshot.configurableControls {
+ let divert = wanted.contains(control.cid)
+ let rawBit: Bool? = control.supportsRawXY ? divert && rawXY.contains(control.cid) : nil
+ if divert { diverted.insert(control.cid) }
+ if rawBit ?? false { raw.insert(control.cid) }
+ }
+ let dpi = preferredDPI.flatMap { preferred in snapshot.dpi.map { $0.nearest(to: preferred) } }
+ return EndpointConfiguration(divert: diverted, rawXY: raw, dpi: dpi)
+}
+
+// MARK: Fixtures
+
+private func dpiInfo(_ profile: SimulatedDeviceProfile) -> DPIInfo? {
+ let supported = profile.supportedDPI
+ guard !supported.isEmpty else { return nil }
+ return DPIInfo(current: profile.defaultDPI ?? supported[0], defaultValue: profile.defaultDPI ?? supported[0], supported: supported)
+}
+
+/// A connected snapshot of a simulated mouse, as `DeviceSession.start()` reads it.
+private func snapshot(_ profile: SimulatedDeviceProfile, connection: ConnectionKind = .unifying, index: UInt8 = 1) -> DeviceSnapshot {
+ DeviceSnapshot(
+ name: profile.name,
+ connection: connection,
+ deviceIndex: index,
+ protocolVersion: "\(profile.protocolMajor).\(profile.protocolMinor)",
+ features: [],
+ controls: profile.controls,
+ battery: BatteryStatus(percentage: profile.batteryLevel, isCharging: false),
+ dpi: dpiInfo(profile),
+ identity: DeviceIdentity(
+ name: profile.name,
+ kind: profile.kind,
+ unitID: profile.unitID,
+ serialNumber: profile.serialNumber,
+ modelIDs: profile.modelIDs,
+ transportPID: profile.wirelessPID
+ ),
+ address: DeviceAddress(link: 1, index: index)
+ )
+}
+
+private let combo = KeyCombo(keyCode: KeyCode.t, modifiers: [.command, .shift], keyLabel: "T")
+private let safari = "com.apple.Safari"
+private let xcode = "com.apple.dt.Xcode"
+private let mixedCase = "com.Example.MixedCase"
+
+/// Setups a 2.x user could have had: fresh, the 1.x defaults, per-app profiles (one disabled),
+/// every action kind with gestures, Accessibility-only actions, and junk an import could carry
+/// (primary clicks, the virtual gesture button, other models' controls).
+private var fixtures: [(name: String, profiles: MappingProfiles)] {
+ var withApps = MappingProfiles(global: .legacyDefaults)
+ withApps.upsert(AppProfile(bundleID: safari, displayName: "Safari", overrides: [
+ KnownControl.back: .system(.previousTab),
+ KnownControl.dpiSwitch: .deviceDefault,
+ ]))
+ withApps.upsert(AppProfile(bundleID: xcode, displayName: "Xcode", isEnabled: false, overrides: [
+ KnownControl.back: .system(.undo),
+ ]))
+
+ let gestures = GestureSet(
+ tap: .system(.missionControl),
+ up: .keyCombo(KeyCombo(keyCode: KeyCode.c, modifiers: [.command, .option])),
+ down: .launchApp(AppReference(bundleID: "com.apple.Terminal", displayName: "Terminal")),
+ left: .openURL(URL(string: "https://example.com/left")!),
+ right: .runShortcut("Make GIF")
+ )
+ var everyKind = MappingProfiles(global: ButtonMappings([
+ KnownControl.back: .system(.navigateBack),
+ KnownControl.forward: .keyCombo(combo),
+ KnownControl.middleClick: .disabled,
+ KnownControl.dpiSwitch: .gestures(gestures),
+ KnownControl.gestureButton: .device(.cycleDPI),
+ KnownControl.smartShift: .device(.setDPI(1200)),
+ KnownControl.tiltLeft: .device(.switchHost(2)),
+ KnownControl.tiltRight: .launchApp(AppReference(bundleID: safari, displayName: "Safari")),
+ KnownControl.multiPlatformGesture: .openURL(URL(string: "mailto:me@example.com")!),
+ KnownControl.dpiChange: .runShortcut("Toggle Focus"),
+ 0x0100: .system(.volumeUp),
+ ]))
+ everyKind.upsert(AppProfile(bundleID: safari, displayName: "Safari", overrides: [
+ KnownControl.back: .system(.previousTab),
+ KnownControl.dpiSwitch: .deviceDefault,
+ KnownControl.forward: .gestures(GestureSet(up: .system(.nextTab), down: .deviceDefault)),
+ ]))
+ everyKind.upsert(AppProfile(bundleID: mixedCase, displayName: "Mixed", overrides: [
+ KnownControl.middleClick: .system(.copy),
+ KnownControl.gestureButton: .deviceDefault,
+ ]))
+
+ let accessibilityOnly = MappingProfiles(global: ButtonMappings([
+ KnownControl.back: .keyCombo(combo),
+ KnownControl.forward: .system(.playPause),
+ KnownControl.middleClick: .system(.middleClick),
+ KnownControl.dpiSwitch: .gestures(GestureSet(up: .keyCombo(combo))),
+ ]))
+
+ var junk = MappingProfiles(global: ButtonMappings([
+ KnownControl.leftClick: .system(.copy),
+ KnownControl.rightClick: .gestures(.standard),
+ KnownControl.virtualGesture: .system(.missionControl),
+ KnownControl.middleClick: .gestures(.standard),
+ KnownControl.back: .gestures(GestureSet(tap: .system(.navigateBack))),
+ KnownControl.gestureButton: .gestures(.standard),
+ ]))
+ junk.upsert(AppProfile(bundleID: safari, displayName: "Safari", overrides: [
+ KnownControl.middleClick: .deviceDefault,
+ KnownControl.forward: .gestures(.standard),
+ ]))
+
+ return [
+ ("fresh", MappingProfiles()),
+ ("legacy defaults", MappingProfiles(global: .legacyDefaults)),
+ ("per-app profiles", withApps),
+ ("every kind", everyKind),
+ ("accessibility only", accessibilityOnly),
+ ("junk", junk),
+ ]
+}
+
+/// Every subset of `elements`.
+private func subsets(of elements: [UInt16]) -> [Set] {
+ (0..<(1 << elements.count)).map { mask in
+ Set(elements.indices.filter { mask & (1 << $0) != 0 }.map { elements[$0] })
+ }
+}
+
+final class ConfigurationPlannerTests: XCTestCase {
+ func testMatchesTodaysSyncDiversionForMXVertical() {
+ let vertical = snapshot(DeviceProfiles.mxVertical)
+ XCTAssertEqual(vertical.modelKey, .mxVertical)
+ XCTAssertEqual(Set(vertical.configurableControls.map(\.cid)), [0x52, 0x53, 0x56, 0xFD])
+
+ let bundles: [String?] = [nil, safari, xcode, mixedCase, mixedCase.lowercased(), "com.example.unknown"]
+ // What the engine can hold: the Vertical's buttons plus one it doesn't have.
+ let holdable: [UInt16] = [0x52, 0x53, 0x56, 0xFD, 0xC3]
+ var compared = 0
+ for (name, profiles) in fixtures {
+ // v2 data lands in the MX Vertical's model section (D6); the planner reads it from there.
+ let library = MappingLibrary(migrating: profiles)
+ for bundleID in bundles {
+ let mappings = library.effectiveMappings(model: vertical.modelKey, bundleID: bundleID)
+ XCTAssertEqual(mappings, profiles.effectiveMappings(forBundleID: bundleID), name)
+ for trusted in [false, true] {
+ let gestureMapped = mappings.gestureControls(accessibilityGranted: trusted)
+ for enabled in [false, true] {
+ for held in subsets(of: holdable) {
+ // The engine's gestures are held controls; two sets that aren't
+ // check the planner doesn't rely on that.
+ let gestureSets: Set> = [[], held, held.intersection(gestureMapped), [0xFD], [0x52, 0xC3]]
+ for gesturesHeld in gestureSets {
+ let today = todaysSyncDiversion(
+ profiles: profiles,
+ frontmostBundleID: bundleID,
+ trusted: trusted,
+ isRemappingEnabled: enabled,
+ pressedControls: held,
+ gestureControls: gesturesHeld
+ )
+ let expected = todaysWireState(cids: today.cids, rawXY: today.rawXY, preferredDPI: nil, snapshot: vertical)
+ let inputs = ConfigurationInputs(
+ remappingEnabled: enabled,
+ accessibilityGranted: trusted,
+ held: held,
+ gesturesHeld: gesturesHeld
+ )
+ let planned = ConfigurationPlanner.configuration(mappings: mappings, snapshot: vertical, inputs: inputs)
+ let context = "\(name) / \(bundleID ?? "nil") / trusted \(trusted) / enabled \(enabled) / held \(held.sorted()) / gestures \(gesturesHeld.sorted())"
+ XCTAssertEqual(planned, expected, context)
+ XCTAssertEqual(
+ ConfigurationPlanner.configuration(mappings: mappings, controls: vertical.controls, dpi: vertical.dpi, inputs: inputs),
+ planned,
+ context
+ )
+ // Nothing outside 2.x's own request ever gets diverted.
+ XCTAssertTrue(planned.divert.isSubset(of: today.cids.union(today.rawXY)), context)
+ XCTAssertTrue(planned.rawXY.isSubset(of: today.rawXY), context)
+ compared += 1
+ }
+ }
+ }
+ }
+ }
+ }
+ XCTAssertGreaterThan(compared, 10000)
+
+ // The pointer speed: 2.x applied the preference with the mouse's nearest supported value,
+ // with remapping on or off.
+ for preferred in [nil, 400, 1000, 1225, 1234, 4000, 9000] as [Int?] {
+ for enabled in [false, true] {
+ let expected = todaysWireState(cids: [], rawXY: [], preferredDPI: preferred, snapshot: vertical)
+ let planned = ConfigurationPlanner.configuration(
+ mappings: .defaults,
+ snapshot: vertical,
+ inputs: ConfigurationInputs(remappingEnabled: enabled, preferredDPI: preferred)
+ )
+ XCTAssertEqual(planned, expected, "\(preferred.map { "\($0)" } ?? "nil") DPI")
+ }
+ }
+ }
+
+ func testNewModelIsNative() {
+ let library = MappingLibrary(migrating: MappingProfiles(global: .legacyDefaults))
+ for profile in [DeviceProfiles.mxMaster3S, DeviceProfiles.lift, DeviceProfiles.m720, DeviceProfiles.mxErgo] {
+ let mouse = snapshot(profile, connection: .bolt)
+ XCTAssertNotEqual(mouse.modelKey, .mxVertical, profile.name)
+ for bundleID in [nil, safari] as [String?] {
+ let mappings = library.effectiveMappings(model: mouse.modelKey, bundleID: bundleID)
+ for trusted in [false, true] {
+ let inputs = ConfigurationInputs(accessibilityGranted: trusted)
+ XCTAssertEqual(ConfigurationPlanner.configuration(mappings: mappings, snapshot: mouse, inputs: inputs), .native, profile.name)
+ }
+ }
+ }
+ // The MX Vertical in the same library keeps its setup.
+ let vertical = snapshot(DeviceProfiles.mxVertical)
+ let mappings = library.effectiveMappings(model: vertical.modelKey, bundleID: nil)
+ XCTAssertEqual(ConfigurationPlanner.configuration(mappings: mappings, snapshot: vertical, inputs: .init(accessibilityGranted: true)).divert, [0x53, 0x56, 0xFD])
+ }
+
+ func testRemapDisabledKeepsOnlyHeldUntilRelease() {
+ let vertical = snapshot(DeviceProfiles.mxVertical)
+ let gestures = ButtonMappings([
+ KnownControl.back: .system(.navigateBack),
+ KnownControl.dpiSwitch: .gestures(GestureSet(up: .system(.missionControl))),
+ ])
+ var inputs = ConfigurationInputs(accessibilityGranted: true, deviceRemapEnabled: false, preferredDPI: 1600)
+
+ // "Remap this mouse" off while Back and the gesture button are held: both stay diverted
+ // (the gesture keeps its raw movement) so their releases arrive; nothing else is.
+ inputs.held = [KnownControl.back, KnownControl.dpiSwitch]
+ inputs.gesturesHeld = [KnownControl.dpiSwitch]
+ XCTAssertEqual(
+ ConfigurationPlanner.configuration(mappings: gestures, snapshot: vertical, inputs: inputs),
+ EndpointConfiguration(divert: [KnownControl.back, KnownControl.dpiSwitch], rawXY: [KnownControl.dpiSwitch], dpi: 1600)
+ )
+
+ // Back released, the gesture still going.
+ inputs.held = [KnownControl.dpiSwitch]
+ XCTAssertEqual(
+ ConfigurationPlanner.configuration(mappings: gestures, snapshot: vertical, inputs: inputs),
+ EndpointConfiguration(divert: [KnownControl.dpiSwitch], rawXY: [KnownControl.dpiSwitch], dpi: 1600)
+ )
+
+ // Everything released: native buttons; the pointer speed is not remapping and stays.
+ inputs.held = []
+ inputs.gesturesHeld = []
+ XCTAssertEqual(
+ ConfigurationPlanner.configuration(mappings: gestures, snapshot: vertical, inputs: inputs),
+ EndpointConfiguration(dpi: 1600)
+ )
+
+ // Turned back on: the mappings return.
+ inputs.deviceRemapEnabled = true
+ XCTAssertEqual(
+ ConfigurationPlanner.configuration(mappings: gestures, snapshot: vertical, inputs: inputs),
+ EndpointConfiguration(divert: [KnownControl.back, KnownControl.dpiSwitch], rawXY: [KnownControl.dpiSwitch], dpi: 1600)
+ )
+
+ // The app-wide switch off hands everything back at once, held buttons included (2.x).
+ inputs.remappingEnabled = false
+ inputs.held = [KnownControl.back]
+ XCTAssertEqual(
+ ConfigurationPlanner.configuration(mappings: gestures, snapshot: vertical, inputs: inputs),
+ EndpointConfiguration(dpi: 1600)
+ )
+ }
+
+ func testStandbyIsNative() {
+ let vertical = snapshot(DeviceProfiles.mxVertical, connection: .bluetooth, index: 0xFF)
+ let mappings = ButtonMappings.legacyDefaults
+ var inputs = ConfigurationInputs(accessibilityGranted: true, isActive: false, preferredDPI: 1600)
+ XCTAssertEqual(ConfigurationPlanner.configuration(mappings: mappings, snapshot: vertical, inputs: inputs), .native)
+ XCTAssertEqual(ConfigurationPlanner.configuration(mappings: mappings, controls: vertical.controls, inputs: inputs), .native)
+
+ // Demoted while Forward is held: only Forward stays diverted until it is released.
+ inputs.held = [KnownControl.forward]
+ XCTAssertEqual(
+ ConfigurationPlanner.configuration(mappings: mappings, snapshot: vertical, inputs: inputs),
+ EndpointConfiguration(divert: [KnownControl.forward])
+ )
+
+ // Promoted: the full setup and the pointer speed.
+ inputs.held = []
+ inputs.isActive = true
+ XCTAssertEqual(
+ ConfigurationPlanner.configuration(mappings: mappings, snapshot: vertical, inputs: inputs),
+ EndpointConfiguration(divert: [KnownControl.back, KnownControl.forward, KnownControl.dpiSwitch], dpi: 1600)
+ )
+ }
+
+ func testHeldKeepAliveIsPerDevice() {
+ // Back is mapped globally, but the new frontmost app leaves it to the mouse.
+ var profiles = MappingProfiles(global: .legacyDefaults)
+ profiles.upsert(AppProfile(bundleID: safari, displayName: "Safari", overrides: [KnownControl.back: .deviceDefault]))
+ let library = MappingLibrary(migrating: profiles)
+ let a = snapshot(DeviceProfiles.mxVertical, connection: .usb, index: 0xFF)
+ let b = snapshot(DeviceProfiles.mxVerticalSecondUnit, connection: .unifying, index: 2)
+ XCTAssertEqual(a.modelKey, b.modelKey)
+ let mappings = library.effectiveMappings(model: a.modelKey, bundleID: safari)
+
+ // A still holds Back from before the switch; B holds nothing.
+ let configA = ConfigurationPlanner.configuration(mappings: mappings, snapshot: a, inputs: ConfigurationInputs(accessibilityGranted: true, held: [KnownControl.back]))
+ let configB = ConfigurationPlanner.configuration(mappings: mappings, snapshot: b, inputs: ConfigurationInputs(accessibilityGranted: true))
+ XCTAssertEqual(configA.divert, [KnownControl.back, KnownControl.forward, KnownControl.dpiSwitch])
+ XCTAssertEqual(configB.divert, [KnownControl.forward, KnownControl.dpiSwitch])
+
+ // A releases Back: A follows the app too.
+ XCTAssertEqual(ConfigurationPlanner.configuration(mappings: mappings, snapshot: a, inputs: ConfigurationInputs(accessibilityGranted: true)), configB)
+ }
+
+ func testRawXYOnlyForCapableControls() {
+ // The Vertical's own table, except that the top button can't report raw movement.
+ var vertical = snapshot(DeviceProfiles.mxVertical)
+ vertical.controls = vertical.controls.map { control in
+ var control = control
+ if control.cid == KnownControl.dpiSwitch { control.additionalFlags &= ~UInt8(0x01) }
+ return control
+ }
+ let mappings = ButtonMappings([
+ KnownControl.middleClick: .gestures(.standard),
+ KnownControl.dpiSwitch: .gestures(.standard),
+ KnownControl.back: .system(.navigateBack),
+ ])
+ let config = ConfigurationPlanner.configuration(mappings: mappings, snapshot: vertical, inputs: ConfigurationInputs(accessibilityGranted: true))
+ XCTAssertEqual(config.divert, [KnownControl.middleClick, KnownControl.dpiSwitch, KnownControl.back])
+ XCTAssertEqual(config.rawXY, [KnownControl.middleClick])
+
+ // A held gesture on a control without raw XY is still only diverted.
+ let held = ConfigurationPlanner.configuration(
+ mappings: .defaults,
+ snapshot: vertical,
+ inputs: ConfigurationInputs(held: [KnownControl.dpiSwitch, KnownControl.middleClick], gesturesHeld: [KnownControl.dpiSwitch, KnownControl.middleClick])
+ )
+ XCTAssertEqual(held.divert, [KnownControl.dpiSwitch, KnownControl.middleClick])
+ XCTAssertEqual(held.rawXY, [KnownControl.middleClick])
+
+ // Raw XY is always a subset of the diverted controls.
+ XCTAssertTrue(config.rawXY.isSubset(of: config.divert))
+ }
+
+ func testNeverDivertPrimaryClicksEvenIfFlaggedDivertable() {
+ // The M185 flags Left and Right divertable; an imported setup maps them.
+ let m185 = snapshot(DeviceProfiles.m185, connection: .nano)
+ XCTAssertTrue(m185.controls.contains { $0.cid == KnownControl.leftClick && $0.flags.contains(.divertable) })
+ let mappings = ButtonMappings([
+ KnownControl.leftClick: .system(.copy),
+ KnownControl.rightClick: .gestures(.standard),
+ KnownControl.middleClick: .system(.missionControl),
+ ])
+ let inputs = ConfigurationInputs(
+ accessibilityGranted: true,
+ held: [KnownControl.leftClick, KnownControl.rightClick],
+ gesturesHeld: [KnownControl.rightClick]
+ )
+ let config = ConfigurationPlanner.configuration(mappings: mappings, snapshot: m185, inputs: inputs)
+ XCTAssertEqual(config, EndpointConfiguration(divert: [KnownControl.middleClick]))
+
+ // Even a table that claims Left is a plain configurable button can't get it diverted.
+ let claimed = [ControlInfo(cid: KnownControl.leftClick, taskID: 0, flags: [.mouseButton, .reprogrammable, .divertable], additionalFlags: 0x01)]
+ let forced = ConfigurationPlanner.configuration(mappings: mappings, controls: claimed, inputs: inputs)
+ XCTAssertEqual(forced, .native)
+
+ // The virtual gesture button (0xD7) and controls the mouse doesn't have aren't diverted either.
+ let vertical = snapshot(DeviceProfiles.mxVertical)
+ let foreign = ButtonMappings([
+ KnownControl.virtualGesture: .system(.missionControl),
+ KnownControl.gestureButton: .system(.missionControl),
+ KnownControl.smartShift: .gestures(.standard),
+ ])
+ XCTAssertEqual(ConfigurationPlanner.configuration(mappings: foreign, snapshot: vertical, inputs: .init()), .native)
+ }
+
+ func testDPISnappedToDeviceList() {
+ let vertical = snapshot(DeviceProfiles.mxVertical)
+ XCTAssertEqual(vertical.dpi?.minimum, 400)
+ XCTAssertEqual(vertical.dpi?.maximum, 4000)
+ func dpi(_ preferred: Int?, _ device: DeviceSnapshot) -> Int? {
+ ConfigurationPlanner.configuration(mappings: .defaults, snapshot: device, inputs: ConfigurationInputs(preferredDPI: preferred)).dpi
+ }
+ XCTAssertEqual(dpi(1234, vertical), 1250)
+ XCTAssertEqual(dpi(1225, vertical), 1200, "a tie takes the lower value")
+ XCTAssertEqual(dpi(100, vertical), 400)
+ XCTAssertEqual(dpi(9000, vertical), 4000)
+ XCTAssertNil(dpi(nil, vertical), "no preference leaves the mouse's own speed")
+
+ // Each device snaps to its own list.
+ let master = snapshot(DeviceProfiles.mxMaster3S, connection: .bolt)
+ XCTAssertEqual(dpi(9000, master), 8000)
+ XCTAssertEqual(dpi(100, master), 200)
+
+ // No ADJUSTABLE_DPI: no speed at all.
+ let m185 = snapshot(DeviceProfiles.m185, connection: .nano)
+ XCTAssertNil(m185.dpi)
+ XCTAssertNil(dpi(1200, m185))
+
+ // Without a list (controls only) the preference passes through; the mouse snaps it.
+ let passed = ConfigurationPlanner.configuration(mappings: .defaults, controls: vertical.controls, inputs: ConfigurationInputs(preferredDPI: 1234))
+ XCTAssertEqual(passed.dpi, 1234)
+ let listed = ConfigurationPlanner.configuration(mappings: .defaults, controls: vertical.controls, dpi: vertical.dpi, inputs: ConfigurationInputs(preferredDPI: 1234))
+ XCTAssertEqual(listed.dpi, 1250)
+ }
+}
diff --git a/UprightKit/Tests/UprightKitTests/ControllerTests.swift b/UprightKit/Tests/UprightKitTests/ControllerTests.swift
index 387431d..67b931e 100644
--- a/UprightKit/Tests/UprightKitTests/ControllerTests.swift
+++ b/UprightKit/Tests/UprightKitTests/ControllerTests.swift
@@ -41,6 +41,10 @@ final class ControllerTests: XCTestCase {
return false
}
+ private func isConnected(_ link: HIDPPLink, at index: UInt8) -> Bool {
+ UprightKitTests.isConnected(link, at: index)
+ }
+
private func isAsleep(_ controller: MXVerticalController) -> Bool {
if case .asleep = controller.state { return true }
return false
@@ -128,10 +132,12 @@ final class ControllerTests: XCTestCase {
controller.start()
await eventually("connected") { isConnected(controller) }
+ await controller.waitUntilIdle()
XCTAssertEqual(controller.snapshot?.deviceIndex, 4)
- let firstDeviceRequest = mouse.sentReports.first { (1...6).contains($0[1]) }
- XCTAssertEqual(firstDeviceRequest?[1], 4)
- XCTAssertEqual(mouse.pingCount(to: 1), 0)
+ // Only occupied slots are asked anything; the keyboard in slot 1 isn't even pinged.
+ for slot: UInt8 in [1, 2, 3, 5, 6] {
+ XCTAssertEqual(mouse.pingCount(to: slot), 0, "slot \(slot)")
+ }
XCTAssertEqual(mouse.registerWriteCount(0x02), 0)
}
@@ -153,12 +159,25 @@ final class ControllerTests: XCTestCase {
XCTAssertEqual(mouse.setReportingCount, SimulatedMXVertical.configurableCount)
}
- func testReceiverIgnoresOtherDevices() async {
- let mouse = SimulatedMXVertical(mode: .receiver(slot: 1), name: "MX Keys")
- let controller = makeController(mouse, kind: .receiver(.unifying))
- controller.start()
- await eventually("waiting") { controller.state == .waitingForDevice }
- XCTAssertNil(controller.snapshot)
+ func testReceiverIgnoresKeyboardButServesOtherMouse() async throws {
+ let receiver = SimulatedReceiver.unifying([1: DeviceProfiles.mxKeys, 2: DeviceProfiles.mxMaster3])
+ let link = makeLink(receiver)
+ link.client.defaultTimeout = 0.2
+ link.configurationProvider = { _, snapshot in
+ EndpointConfiguration(divert: Set(snapshot.configurableControls.map(\.cid)))
+ }
+ link.start()
+
+ await eventually("MX Master connected") { isConnected(link, at: 2) }
+ await link.waitUntilIdle()
+ let snapshot = try XCTUnwrap(link.endpoints[2]?.snapshot)
+ XCTAssertEqual(snapshot.modelKey, ModelKey(productID: 0xB023))
+ XCTAssertEqual(receiver.diverted(slot: 2), Set(snapshot.configurableControls.map(\.cid)))
+ // The keyboard is left alone: no endpoint, no request, nothing diverted.
+ XCTAssertNil(link.endpoints[1])
+ XCTAssertEqual(receiver.pingCount(slot: 1), 0)
+ XCTAssertEqual(receiver.setReportingCount(slot: 1), 0)
+ XCTAssertTrue(receiver.diverted(slot: 1).isEmpty)
}
// MARK: Sleep and wake
@@ -524,20 +543,28 @@ final class ControllerTests: XCTestCase {
XCTAssertEqual(events.last, ButtonEvent(cid: KnownControl.back, phase: .release))
}
- func testSecondLiveMXVerticalDoesNotStealTheSession() async {
+ func testSecondLiveMXVerticalGetsItsOwnSession() async {
let mouse = SimulatedMXVertical(mode: .receiver(slot: 2))
- mouse.addDevice(slot: 4, name: "MX Vertical", wirelessPID: MXVertical.wirelessProductID)
- let controller = makeController(mouse, kind: .receiver(.unifying))
- controller.setDesiredDiversion(defaultDiversion)
- controller.start()
- await eventually("connected") { isConnected(controller) }
- let slot = controller.snapshot?.deviceIndex
-
- // A link-up from the other mouse while ours still answers.
- mouse.onReport?(HIDPPMessage(reportID: .short, deviceIndex: slot == 2 ? 4 : 2, subID: 0x41, address: 0x04, parameters: [0x00, 0x7B, 0x40]).bytes)
+ mouse.addDevice(slot: 4, name: "MX Vertical", wirelessPID: DeviceProfiles.mxVertical.wirelessPID ?? 0)
+ let link = makeLink(mouse, kind: .receiver(.unifying))
+ link.client.defaultTimeout = 0.2
+ let diversion = defaultDiversion
+ link.configurationProvider = { _, _ in EndpointConfiguration(divert: diversion) }
+ link.start()
+
+ await eventually("both connected") { isConnected(link, at: 2) && isConnected(link, at: 4) }
+ XCTAssertEqual(mouse.receiver?.diverted(slot: 2), expectedDiversion)
+ XCTAssertEqual(mouse.receiver?.diverted(slot: 4), expectedDiversion)
+ XCTAssertNotEqual(link.endpoints[2]?.snapshot?.identity?.unitID, link.endpoints[4]?.snapshot?.identity?.unitID)
+
+ // A link-up from one mouse leaves the other's session alone.
+ await link.waitUntilIdle()
+ let before = mouse.receiver?.setReportingCount(slot: 2)
+ mouse.receiver?.announceLink(slot: 4, up: true)
await pause(0.2)
- await controller.waitUntilIdle()
- XCTAssertEqual(controller.snapshot?.deviceIndex, slot)
- XCTAssertTrue(isConnected(controller))
+ await link.waitUntilIdle()
+ XCTAssertTrue(isConnected(link, at: 2))
+ XCTAssertTrue(isConnected(link, at: 4))
+ XCTAssertEqual(mouse.receiver?.setReportingCount(slot: 2), before)
}
}
diff --git a/UprightKit/Tests/UprightKitTests/DeviceFeatureTests.swift b/UprightKit/Tests/UprightKitTests/DeviceFeatureTests.swift
index ef96ff9..4f04b94 100644
--- a/UprightKit/Tests/UprightKitTests/DeviceFeatureTests.swift
+++ b/UprightKit/Tests/UprightKitTests/DeviceFeatureTests.swift
@@ -106,6 +106,31 @@ final class DeviceFeatureParsingTests: XCTestCase {
XCTAssertFalse(DeviceAction.switchHost(4).isValid)
XCTAssertEqual(DeviceAction.switchHost(2).title, "Switch to Computer 2")
}
+
+ func testSnapshotDefaultsForFixturesWithoutIdentity() {
+ let snapshot = DeviceSnapshot(name: "MX Vertical", connection: .usb, deviceIndex: 0xFF, protocolVersion: "4.5",
+ features: [], controls: [], battery: nil)
+ XCTAssertNil(snapshot.identity)
+ XCTAssertEqual(snapshot.address, .unassigned)
+ XCTAssertEqual(snapshot.reprogVersion, 0)
+ XCTAssertEqual(snapshot.modelKey, .mxVertical)
+
+ let master = DeviceIdentity(name: "MX Master 3S", kind: .mouse, modelIDs: ModelIDs(bluetoothLE: 0xB034))
+ let identified = DeviceSnapshot(name: "MX Master 3S", connection: .bolt, deviceIndex: 2, protocolVersion: "4.5",
+ features: [], controls: [], battery: nil, identity: master,
+ address: DeviceAddress(link: 2, index: 2), reprogVersion: 5)
+ XCTAssertEqual(identified.modelKey, ModelKey(productID: 0xB034))
+ XCTAssertEqual(identified.address, DeviceAddress(link: 2, index: 2))
+ }
+
+ func testConfigurableControlsNeverIncludePrimaryClicks() {
+ let flags: ControlFlags = [.mouseButton, .reprogrammable, .divertable]
+ XCTAssertFalse(ControlInfo(cid: KnownControl.leftClick, taskID: 0, flags: flags).isConfigurable)
+ XCTAssertFalse(ControlInfo(cid: KnownControl.rightClick, taskID: 0, flags: flags).isConfigurable)
+ XCTAssertTrue(ControlInfo(cid: KnownControl.back, taskID: 0, flags: flags).isConfigurable)
+ XCTAssertTrue(ControlInfo(cid: KnownControl.back, taskID: 0, flags: flags).isMouseButton)
+ XCTAssertFalse(ControlInfo(cid: 0x00D1, taskID: 0, flags: [.nonStandard, .divertable]).isMouseButton)
+ }
}
@MainActor
@@ -461,3 +486,456 @@ final class DeviceFeatureControllerTests: XCTestCase {
XCTAssertFalse(logs.contains { $0.contains("refused") })
}
}
+
+/// `DeviceSession` on its own, against the simulator's profiles: identification, the capability
+/// gate, address stamping and the raw-XY quirk.
+@MainActor
+final class DeviceSessionTests: XCTestCase {
+ private func makeSession(
+ _ transport: SimulatedHIDPPTransport,
+ at index: UInt8,
+ link: UInt32 = 7,
+ transportPID: UInt16? = nil,
+ fallbackName: String? = nil,
+ kindHint: DeviceKind? = nil
+ ) -> DeviceSession {
+ let client = HIDPPClient(transport: transport)
+ client.defaultTimeout = 0.3
+ client.busyRetryDelay = 0.01
+ let session = DeviceSession(
+ device: HIDPPDevice(client: client, index: index),
+ connection: transport.transportKind.connection,
+ address: DeviceAddress(link: link, index: index),
+ transportPID: transportPID,
+ fallbackName: fallbackName,
+ kindHint: kindHint
+ )
+ client.onUnsolicited = { [weak session] message in
+ MainActorDelivery.run { _ = session?.handle(message) }
+ }
+ return session
+ }
+
+ /// Runs `start()` expecting the capability gate to refuse the device.
+ private func unsupported(
+ _ session: DeviceSession,
+ file: StaticString = #filePath,
+ line: UInt = #line
+ ) async -> (reason: UnsupportedReason, partial: DeviceSnapshot?)? {
+ do {
+ let snapshot = try await session.start()
+ XCTFail("expected unsupportedDevice, connected to \(snapshot.name)", file: file, line: line)
+ } catch HIDPPError.unsupportedDevice(let reason, let partial) {
+ return (reason, partial)
+ } catch {
+ XCTFail("expected unsupportedDevice, got \(error)", file: file, line: line)
+ }
+ return nil
+ }
+
+ /// (feature ID, function) of every HID++ 2.0 request sent to `index`, in order, resolving
+ /// feature indices through `profile`. Root getFeature lookups are left out.
+ private func requests(_ transport: SimulatedHIDPPTransport, to index: UInt8, profile: SimulatedDeviceProfile) -> [[UInt16]] {
+ transport.sentReports.compactMap { bytes in
+ guard let message = HIDPPMessage(bytes: bytes), message.deviceIndex == index,
+ Int(message.featureIndex) < profile.features.count else { return nil }
+ let feature = profile.features[Int(message.featureIndex)].id
+ if feature == 0x0000, message.function == 0 { return nil }
+ return [feature, UInt16(message.function)]
+ }
+ }
+
+ private static var unknownKindVertical: SimulatedDeviceProfile {
+ var profile = DeviceProfiles.mxVertical
+ profile.kind = .unknown
+ return profile
+ }
+
+ // MARK: Identity
+
+ func testSnapshotCarriesIdentityAddressAndReprogVersion() async throws {
+ let mouse = SimulatedDirect(profile: DeviceProfiles.mxVertical, connection: .bluetooth)
+ let session = makeSession(mouse, at: 0xFF, transportPID: 0xB020)
+ let snapshot = try await session.start()
+ let address = DeviceAddress(link: 7, index: 0xFF)
+
+ XCTAssertEqual(session.address, address)
+ XCTAssertEqual(snapshot.address, address)
+ XCTAssertEqual(snapshot.connection, .bluetooth)
+ XCTAssertEqual(snapshot.reprogVersion, 4)
+ XCTAssertEqual(snapshot.name, "MX Vertical Advanced Ergonomic Mouse")
+ XCTAssertEqual(snapshot.serialNumber, "DEMO-0001")
+ XCTAssertEqual(snapshot.modelKey, .mxVertical)
+
+ let identity = try XCTUnwrap(snapshot.identity)
+ XCTAssertEqual(identity.name, snapshot.name)
+ XCTAssertEqual(identity.kind, .mouse)
+ XCTAssertEqual(identity.unitID, 0x1A2B_3C4D)
+ XCTAssertEqual(identity.serialNumber, "DEMO-0001")
+ XCTAssertEqual(identity.modelIDs, ModelIDs(bluetoothLE: 0xB020, wireless: 0x407B, usb: 0xC08A))
+ XCTAssertEqual(identity.transportPID, 0xB020)
+ XCTAssertEqual(identity.model, .mxVertical)
+ XCTAssertEqual(identity.deviceID(at: address), .unit(0x1A2B_3C4D, model: .mxVertical))
+
+ // Everything the single-mouse snapshot had is still read.
+ XCTAssertEqual(snapshot.configurableControls.map(\.cid), [0x52, 0x53, 0x56, 0xFD])
+ XCTAssertEqual(snapshot.firmware.map(\.kind), [.bootloader, .firmware, .other])
+ XCTAssertEqual(snapshot.battery?.percentage, 50)
+ XCTAssertEqual(snapshot.dpi?.current, 1000)
+ XCTAssertEqual(snapshot.hosts?.count, 3)
+ XCTAssertEqual(session.snapshot, snapshot)
+ }
+
+ func testStartReadsInOrderAndWritesNothing() async throws {
+ let profile = DeviceProfiles.mxVertical
+ let mouse = SimulatedDirect(profile: profile)
+ _ = try await makeSession(mouse, at: 0xFF).start()
+ let sent = requests(mouse, to: 0xFF, profile: profile)
+
+ // Protocol, feature table, name, kind, IDs and serial, controls, battery, speed, hosts.
+ let milestones: [[UInt16]] = [
+ [0x0000, 1], [0x0001, 0], [0x0005, 0], [0x0005, 2], [0x0003, 0], [0x0003, 2],
+ [0x1B04, 0], [0x1000, 0], [0x2201, 1], [0x1814, 0],
+ ]
+ let positions = milestones.map { sent.firstIndex(of: $0) }
+ XCTAssertFalse(positions.contains(nil), "every milestone is read: \(sent)")
+ let found = positions.compactMap { $0 }
+ XCTAssertEqual(found, found.sorted(), "read in order: \(sent)")
+
+ let reads: Set<[UInt16]> = [
+ [0x0000, 1], [0x0001, 0], [0x0001, 1], [0x0005, 0], [0x0005, 1], [0x0005, 2], [0x0003, 0], [0x0003, 1],
+ [0x0003, 2], [0x1B04, 0], [0x1B04, 1], [0x1000, 0], [0x2201, 1], [0x2201, 2], [0x1814, 0], [0x1815, 0],
+ [0x1815, 1], [0x1815, 3],
+ ]
+ XCTAssertEqual(Set(sent).subtracting(reads), [], "only reads")
+ XCTAssertTrue(mouse.diverted.isEmpty)
+ XCTAssertEqual(mouse.dpi, 1000)
+ XCTAssertTrue(mouse.hostSwitches.isEmpty)
+ }
+
+ func testBoltMouseIdentifiesAsItsOwnModel() async throws {
+ let receiver = SimulatedReceiver.bolt([2: DeviceProfiles.mxMaster3S])
+ let session = makeSession(receiver, at: 2, link: 3, transportPID: 0xB034)
+ let snapshot = try await session.start()
+
+ XCTAssertEqual(snapshot.connection, .bolt)
+ XCTAssertEqual(snapshot.deviceIndex, 2)
+ XCTAssertEqual(snapshot.address, DeviceAddress(link: 3, index: 2))
+ XCTAssertEqual(snapshot.reprogVersion, 5)
+ XCTAssertEqual(snapshot.modelKey, ModelKey(productID: 0xB034))
+ XCTAssertEqual(snapshot.identity?.unitID, 0x2A2B_3C4D)
+ XCTAssertEqual(snapshot.identity?.kind, .mouse)
+ XCTAssertEqual(snapshot.battery?.percentage, 25)
+ XCTAssertEqual(snapshot.configurableControls.map(\.cid), [0x52, 0x53, 0x56, 0xC3, 0xC4])
+ }
+
+ func testIdenticalMiceShareTheirModelButNotTheirDeviceID() async throws {
+ let receiver = SimulatedReceiver.unifying([1: DeviceProfiles.mxVertical, 2: DeviceProfiles.mxVerticalSecondUnit])
+ let first = try await makeSession(receiver, at: 1, transportPID: 0x407B).start()
+ let second = try await makeSession(receiver, at: 2, transportPID: 0x407B).start()
+ let firstID = try XCTUnwrap(first.identity?.deviceID(at: first.address))
+ let secondID = try XCTUnwrap(second.identity?.deviceID(at: second.address))
+
+ XCTAssertEqual(first.modelKey, .mxVertical)
+ XCTAssertEqual(second.modelKey, .mxVertical)
+ XCTAssertNotEqual(firstID, secondID)
+ XCTAssertTrue(firstID.isStable)
+ XCTAssertTrue(secondID.isStable)
+ XCTAssertNotEqual(first.address, second.address)
+ }
+
+ func testMouseWithoutUnitIDOrSerialGetsATransientID() async throws {
+ let receiver = SimulatedReceiver.unifying([4: DeviceProfiles.mouseWithoutUnitID])
+ let snapshot = try await makeSession(receiver, at: 4, link: 9, transportPID: 0x4082).start()
+ let identity = try XCTUnwrap(snapshot.identity)
+
+ XCTAssertNil(identity.unitID)
+ XCTAssertNil(identity.serialNumber)
+ XCTAssertEqual(identity.deviceID(at: snapshot.address), .transient(DeviceAddress(link: 9, index: 4)))
+ XCTAssertFalse(identity.deviceID(at: snapshot.address).isStable)
+ XCTAssertEqual(snapshot.modelKey, ModelKey(productID: 0xB023), "the model is still known")
+ }
+
+ func testNameFallsBackToTheGivenNameThenLogitechMouse() async throws {
+ var nameless = DeviceProfiles.mxVertical
+ nameless.features.removeAll { $0.id == 0x0005 }
+ let named = try await makeSession(SimulatedDirect(profile: nameless), at: 0xFF, fallbackName: "MX Vertical").start()
+ XCTAssertEqual(named.name, "MX Vertical")
+ XCTAssertEqual(named.identity?.name, "MX Vertical")
+ XCTAssertEqual(named.identity?.kind, .unknown, "no 0x0005, no kind")
+ XCTAssertEqual(named.modelKey, .mxVertical, "0x0003 still keys the model")
+
+ let unnamed = try await makeSession(SimulatedDirect(profile: nameless), at: 0xFF, fallbackName: " ").start()
+ XCTAssertEqual(unnamed.name, DeviceSession.defaultName)
+ XCTAssertEqual(DeviceSession.defaultName, "Logitech mouse")
+
+ let empty = try await makeSession(SimulatedDirect(profile: DeviceProfiles.mxVertical.named("")), at: 0xFF).start()
+ XCTAssertEqual(empty.name, "Logitech mouse")
+ XCTAssertEqual(empty.identity?.kind, .mouse)
+ }
+
+ func testTrackballAndBudgetMouseAreServedWithoutTheirPrimaryClicks() async throws {
+ let ergo = try await makeSession(SimulatedReceiver.unifying([1: DeviceProfiles.mxErgo]), at: 1).start()
+ XCTAssertEqual(ergo.identity?.kind, .trackball)
+ XCTAssertFalse(ergo.configurableControls.isEmpty)
+
+ // The M185 flags Left and Right as divertable; they are never offered.
+ let m185 = try await makeSession(SimulatedReceiver.nano([1: DeviceProfiles.m185]), at: 1, transportPID: 0x4055).start()
+ XCTAssertEqual(m185.connection, .nano)
+ XCTAssertEqual(m185.configurableControls.map(\.cid), [0x52])
+ XCTAssertNil(m185.dpi)
+ }
+
+ // MARK: Capability gate
+
+ func testKeyboardIsUnsupportedBeforeItsControlsAreRead() async throws {
+ let receiver = SimulatedReceiver.unifying([3: DeviceProfiles.mxKeys])
+ let session = makeSession(receiver, at: 3, transportPID: 0x408A)
+ let outcome = await unsupported(session)
+ let result = try XCTUnwrap(outcome)
+
+ XCTAssertEqual(result.reason, .notAPointer(.keyboard))
+ let partial = try XCTUnwrap(result.partial)
+ XCTAssertEqual(partial.name, "MX Keys Wireless Keyboard")
+ XCTAssertEqual(partial.address, DeviceAddress(link: 7, index: 3))
+ XCTAssertEqual(partial.identity?.kind, .keyboard)
+ XCTAssertEqual(partial.identity?.unitID, 0x8A2B_3C4D)
+ XCTAssertFalse(partial.features.isEmpty)
+ XCTAssertTrue(partial.controls.isEmpty)
+ XCTAssertNil(session.snapshot)
+ XCTAssertEqual(receiver.requestCount(feature: 0x1B04, slot: 3), 0, "the gate stops before the control table")
+ XCTAssertEqual(receiver.setReportingCount(slot: 3), 0)
+ }
+
+ func testHIDPP10DeviceIsUnsupported() async throws {
+ let receiver = SimulatedReceiver.unifying([1: DeviceProfiles.m510HIDPP10])
+ let session = makeSession(receiver, at: 1, transportPID: 0x1025, fallbackName: "M510", kindHint: .mouse)
+ let outcome = await unsupported(session)
+ let result = try XCTUnwrap(outcome)
+
+ XCTAssertEqual(result.reason, .hidpp10(major: 1, minor: 0))
+ let partial = try XCTUnwrap(result.partial)
+ XCTAssertEqual(partial.protocolVersion, "1.0")
+ XCTAssertEqual(partial.name, "M510")
+ XCTAssertEqual(partial.identity?.kind, .mouse)
+ XCTAssertEqual(partial.identity?.transportPID, 0x1025)
+ XCTAssertEqual(receiver.requestCount(feature: 0x0001), 0, "nothing else is asked")
+ }
+
+ func testMouseWithoutReprogControlsIsUnsupported() async throws {
+ let mouse = SimulatedDirect(profile: DeviceProfiles.g502Lightspeed)
+ let outcome = await unsupported(makeSession(mouse, at: 0xFF, transportPID: 0xC08D))
+ let result = try XCTUnwrap(outcome)
+
+ XCTAssertEqual(result.reason, .noReprogControls)
+ let partial = try XCTUnwrap(result.partial)
+ XCTAssertEqual(partial.name, "G502 LIGHTSPEED Wireless Gaming Mouse")
+ XCTAssertEqual(partial.identity?.kind, .mouse)
+ XCTAssertEqual(partial.identity?.unitID, 0x9A2B_3C4D)
+ XCTAssertNotNil(partial.feature(.batteryVoltage))
+ XCTAssertNil(partial.feature(.reprogControlsV4))
+ }
+
+ func testPointerWithoutConfigurableControlsIsUnsupported() async throws {
+ let touchpad = SimulatedDeviceProfile(
+ name: "Test Touchpad",
+ kind: .touchpad,
+ wirelessPID: 0x4101,
+ unitID: 0x0D0D_0001,
+ modelIDs: ModelIDs(wireless: 0x4101),
+ features: ([0x0000, 0x0001, 0x0003, 0x0005, 0x1B04] as [UInt16]).map { SimulatedDeviceProfile.Feature($0) },
+ controls: [
+ ControlInfo(cid: 0x50, taskID: 0x38, flags: [.mouseButton, .divertable]),
+ ControlInfo(cid: 0x51, taskID: 0x39, flags: [.mouseButton, .divertable]),
+ ControlInfo(cid: 0xD7, taskID: 0, flags: [.divertable, .virtual]),
+ ]
+ )
+ let receiver = SimulatedReceiver.unifying([5: touchpad])
+ let outcome = await unsupported(makeSession(receiver, at: 5))
+ let result = try XCTUnwrap(outcome)
+
+ XCTAssertEqual(result.reason, .noConfigurableControls)
+ XCTAssertEqual(result.partial?.controls.map(\.cid), [0x50, 0x51, 0xD7])
+ XCTAssertEqual(result.partial?.identity?.kind, .touchpad)
+ XCTAssertEqual(receiver.setReportingCount(slot: 5), 0)
+ }
+
+ func testReportedKindWinsOverTheReceiversHint() async throws {
+ let mouse = try await makeSession(SimulatedDirect(profile: DeviceProfiles.mxVertical), at: 0xFF, kindHint: .keyboard).start()
+ XCTAssertEqual(mouse.identity?.kind, .mouse)
+
+ let keyboard = makeSession(SimulatedReceiver.unifying([1: DeviceProfiles.mxKeys]), at: 1, kindHint: .mouse)
+ let result = await unsupported(keyboard)
+ XCTAssertEqual(result?.reason, .notAPointer(.keyboard))
+ }
+
+ func testKindHintDecidesWhenTheDeviceDoesNotReportItsKind() async throws {
+ let hintedKeyboard = SimulatedReceiver.unifying([2: Self.unknownKindVertical])
+ let result = await unsupported(makeSession(hintedKeyboard, at: 2, kindHint: .keyboard))
+ XCTAssertEqual(result?.reason, .notAPointer(.keyboard))
+ XCTAssertEqual(result?.partial?.identity?.kind, .keyboard)
+ XCTAssertEqual(hintedKeyboard.requestCount(feature: 0x1B04, slot: 2), 0)
+
+ let hintedMouse = try await makeSession(SimulatedReceiver.unifying([2: Self.unknownKindVertical]), at: 2, kindHint: .mouse).start()
+ XCTAssertEqual(hintedMouse.identity?.kind, .mouse)
+
+ // Neither: a control flagged as a mouse button makes it a pointer.
+ let unhinted = try await makeSession(SimulatedReceiver.unifying([2: Self.unknownKindVertical]), at: 2, kindHint: .unknown).start()
+ XCTAssertEqual(unhinted.identity?.kind, .unknown)
+ XCTAssertEqual(unhinted.configurableControls.map(\.cid), [0x52, 0x53, 0x56, 0xFD])
+ }
+
+ func testWithoutAnyKindAMouseButtonIsRequired() async throws {
+ var keys = DeviceProfiles.mxKeys
+ keys.kind = .unknown
+ let receiver = SimulatedReceiver.unifying([1: keys])
+ let result = await unsupported(makeSession(receiver, at: 1))
+
+ XCTAssertEqual(result?.reason, .notAPointer(.unknown))
+ XCTAssertEqual(result?.partial?.controls.count, keys.controls.count, "read, but never written")
+ XCTAssertFalse(result?.partial?.configurableControls.isEmpty ?? true)
+ XCTAssertEqual(receiver.setReportingCount(slot: 1), 0)
+ }
+
+ func testSleepingDeviceIsUnreachableNotUnsupported() async {
+ let direct = SimulatedDirect(profile: DeviceProfiles.mxVertical)
+ direct.dropsRequests = true
+ do {
+ _ = try await makeSession(direct, at: 0xFF).start(pingTimeout: 0.1)
+ XCTFail("expected a timeout")
+ } catch let error as HIDPPError {
+ XCTAssertEqual(error, .timeout)
+ XCTAssertNil(error.unsupportedReason)
+ } catch {
+ XCTFail("unexpected \(error)")
+ }
+
+ let receiver = SimulatedReceiver.unifying([1: DeviceProfiles.mxVertical])
+ receiver.goToSleep(slot: 1)
+ do {
+ _ = try await makeSession(receiver, at: 1).start(pingTimeout: 0.1)
+ XCTFail("expected the receiver to report the slot unreachable")
+ } catch let error as HIDPPError {
+ XCTAssertTrue(error.meansDeviceUnreachable, "\(error)")
+ XCTAssertNil(error.unsupportedReason)
+ } catch {
+ XCTFail("unexpected \(error)")
+ }
+ }
+
+ // MARK: Events
+
+ func testEventsAreStampedWithTheSessionAddress() async throws {
+ let receiver = SimulatedReceiver.unifying([3: DeviceProfiles.mxVertical])
+ let session = makeSession(receiver, at: 3, link: 4)
+ let address = DeviceAddress(link: 4, index: 3)
+ var events: [ButtonEvent] = []
+ var deltas: [PointerDelta] = []
+ var legacyDeltas: [[Int]] = []
+ session.onButtonEvent = { events.append($0) }
+ session.onSourcedPointerDelta = { deltas.append($0) }
+ session.onPointerDelta = { legacyDeltas.append([$0, $1]) }
+ _ = try await session.start()
+ try await session.applyDiversion([KnownControl.back], rawXY: [KnownControl.dpiSwitch])
+
+ receiver.hold([KnownControl.dpiSwitch], slot: 3)
+ receiver.move(dx: -5, dy: 300, slot: 3)
+ receiver.hold([], slot: 3)
+ receiver.hold([KnownControl.back], slot: 3)
+ await eventually("three events") { events.count == 3 }
+ session.releaseAll()
+
+ XCTAssertEqual(events, [
+ ButtonEvent(cid: KnownControl.dpiSwitch, phase: .press, source: address),
+ ButtonEvent(cid: KnownControl.dpiSwitch, phase: .release, source: address),
+ ButtonEvent(cid: KnownControl.back, phase: .press, source: address),
+ ButtonEvent(cid: KnownControl.back, phase: .release, source: address),
+ ])
+ XCTAssertEqual(deltas, [PointerDelta(source: address, dx: -5, dy: 300)])
+ XCTAssertEqual(legacyDeltas, [[-5, 300]])
+ }
+
+ func testLegacySessionStampsUnassigned() async throws {
+ let mouse = SimulatedDirect(profile: DeviceProfiles.mxVertical)
+ let client = HIDPPClient(transport: mouse)
+ let session = DeviceSession(device: HIDPPDevice(client: client, index: 0xFF), connection: .usb)
+ client.onUnsolicited = { [weak session] message in MainActorDelivery.run { _ = session?.handle(message) } }
+ var events: [ButtonEvent] = []
+ session.onButtonEvent = { events.append($0) }
+ let snapshot = try await session.start()
+ try await session.applyDiversion([KnownControl.back])
+ mouse.hold([KnownControl.back])
+ await eventually("pressed") { events.count == 1 }
+
+ XCTAssertEqual(session.address, .unassigned)
+ XCTAssertEqual(snapshot.address, .unassigned)
+ XCTAssertEqual(events, [ButtonEvent(cid: KnownControl.back, phase: .press)])
+ }
+
+ func testFirstRawXYReportAfterAPressIsDroppedFromReprogV5() async throws {
+ let receiver = SimulatedReceiver.bolt([1: DeviceProfiles.mxMaster3S])
+ let session = makeSession(receiver, at: 1)
+ var events: [ButtonEvent] = []
+ var deltas: [[Int]] = []
+ var legacyDeltas: [[Int]] = []
+ session.onButtonEvent = { events.append($0) }
+ session.onSourcedPointerDelta = { deltas.append([$0.dx, $0.dy]) }
+ session.onPointerDelta = { legacyDeltas.append([$0, $1]) }
+ let snapshot = try await session.start()
+ XCTAssertEqual(snapshot.reprogVersion, 5)
+ try await session.applyDiversion([], rawXY: [KnownControl.gestureButton])
+ XCTAssertEqual(session.rawXYControls, [KnownControl.gestureButton])
+
+ for round in 0..<2 {
+ receiver.hold([KnownControl.gestureButton], slot: 1)
+ receiver.move(dx: 99, dy: -99, slot: 1)
+ receiver.move(dx: round * 10 + 1, dy: 2, slot: 1)
+ receiver.move(dx: round * 10 + 3, dy: 4, slot: 1)
+ receiver.hold([], slot: 1)
+ }
+ await eventually("two presses and releases") { events.count == 4 }
+ XCTAssertEqual(deltas, [[1, 2], [3, 4], [11, 2], [13, 4]], "the first report after each press is dropped")
+ XCTAssertEqual(legacyDeltas, deltas)
+ }
+
+ func testRawXYReportsBeforeReprogV5AreAllDelivered() async throws {
+ let receiver = SimulatedReceiver.unifying([1: DeviceProfiles.mxVertical])
+ let session = makeSession(receiver, at: 1)
+ var events: [ButtonEvent] = []
+ var deltas: [[Int]] = []
+ session.onButtonEvent = { events.append($0) }
+ session.onSourcedPointerDelta = { deltas.append([$0.dx, $0.dy]) }
+ let snapshot = try await session.start()
+ XCTAssertEqual(snapshot.reprogVersion, 4)
+ try await session.applyDiversion([], rawXY: [KnownControl.dpiSwitch])
+
+ receiver.hold([KnownControl.dpiSwitch], slot: 1)
+ receiver.move(dx: 1, dy: 2, slot: 1)
+ receiver.move(dx: 3, dy: 4, slot: 1)
+ receiver.hold([], slot: 1)
+ await eventually("press and release") { events.count == 2 }
+ XCTAssertEqual(deltas, [[1, 2], [3, 4]])
+ }
+
+ func testVoltageBatteryIsReadAndFollowed() async throws {
+ var profile = DeviceProfiles.mxVertical
+ profile.features = profile.features.map { $0.id == 0x1000 ? SimulatedDeviceProfile.Feature(0x1001, version: 2) : $0 }
+ profile.battery = .voltage
+ profile.batteryLevel = 50
+ let mouse = SimulatedDirect(profile: profile)
+ let session = makeSession(mouse, at: 0xFF)
+ var changes: [BatteryStatus] = []
+ session.onBatteryChange = { changes.append($0) }
+ let snapshot = try await session.start()
+
+ let battery = try XCTUnwrap(snapshot.battery)
+ XCTAssertTrue(battery.isApproximate)
+ XCTAssertEqual(Double(battery.percentage), 50, accuracy: 2)
+
+ mouse.reportBattery(percentage: 20, charging: true)
+ await eventually("battery broadcast") { changes.count == 1 }
+ XCTAssertEqual(Double(changes.first?.percentage ?? 0), 20, accuracy: 2)
+ XCTAssertEqual(changes.first?.isCharging, true)
+ XCTAssertEqual(session.snapshot?.battery, changes.first)
+ }
+}
diff --git a/UprightKit/Tests/UprightKitTests/DevicePreferencesTests.swift b/UprightKit/Tests/UprightKitTests/DevicePreferencesTests.swift
new file mode 100644
index 0000000..7daedbe
--- /dev/null
+++ b/UprightKit/Tests/UprightKitTests/DevicePreferencesTests.swift
@@ -0,0 +1,448 @@
+import XCTest
+@testable import UprightKit
+
+final class DevicePreferencesTests: XCTestCase {
+ private let verticalA = DeviceID.unit(0x1A2B_3C4D, model: .mxVertical)
+ private let verticalB = DeviceID.unit(0x5E6F_7081, model: .mxVertical)
+ private let master3S = ModelKey(productID: 0xB034)
+ private var masterUnit: DeviceID { .unit(0x0BAD_CAFE, model: master3S) }
+ private let transient = DeviceID.transient(DeviceAddress(link: 3, index: 2))
+
+ private func snapshot(
+ name: String = "MX Vertical",
+ connection: ConnectionKind = .bluetooth,
+ controls: [ControlInfo]? = nil
+ ) -> DeviceSnapshot {
+ let divertable: ControlFlags = [.mouseButton, .reprogrammable, .divertable]
+ return DeviceSnapshot(
+ name: name,
+ connection: connection,
+ deviceIndex: 0xFF,
+ protocolVersion: "4.5",
+ features: [],
+ controls: controls ?? [
+ ControlInfo(cid: 0x50, taskID: 0x38, flags: divertable), // left click: never configurable
+ ControlInfo(cid: 0x53, taskID: 0x3C, flags: divertable), // Back
+ ControlInfo(cid: 0x56, taskID: 0x3E, flags: divertable), // Forward
+ ControlInfo(cid: 0xFD, taskID: 0x00, flags: [.mouseButton, .virtual, .divertable]), // virtual
+ ControlInfo(cid: 0x99, taskID: 0x00, flags: [.mouseButton]), // not divertable
+ ],
+ battery: nil
+ )
+ }
+
+ private func roundTrip(_ store: DevicePreferencesStore) throws -> DevicePreferencesStore {
+ try JSONDecoder().decode(DevicePreferencesStore.self, from: JSONEncoder().encode(store))
+ }
+
+ // MARK: Legacy seed
+
+ func testLegacyPreferredDPISeedsMXVerticalOnce() {
+ var store = DevicePreferencesStore()
+ store.migrateLegacy(preferredDPI: 1600)
+ XCTAssertEqual(store.models[.mxVertical]?.preferredDPI, 1600)
+
+ // A later launch still has the legacy key in place, possibly with another value: no re-seed.
+ store.migrateLegacy(preferredDPI: 800)
+ XCTAssertEqual(store.models[.mxVertical]?.preferredDPI, 1600)
+ store.migrateLegacy(preferredDPI: nil)
+ XCTAssertEqual(store.models[.mxVertical]?.preferredDPI, 1600)
+
+ // The first MX Vertical identified, of any unit, inherits it...
+ XCTAssertEqual(store.preferences(for: verticalA, model: .mxVertical).preferredDPI, 1600)
+ XCTAssertEqual(store.preferences(for: verticalB, model: .mxVertical).preferredDPI, 1600)
+ XCTAssertEqual(store.preferences(for: transient, model: .mxVertical).preferredDPI, 1600)
+ // ...and recording it keeps the value on the unit.
+ store.record(verticalA, snapshot: snapshot())
+ XCTAssertEqual(store.devices[verticalA]?.preferredDPI, 1600)
+ }
+
+ func testLegacyWithoutAPreferredSpeedStillCountsAsMigrated() {
+ var store = DevicePreferencesStore()
+ store.migrateLegacy(preferredDPI: nil)
+ XCTAssertNotNil(store.models[.mxVertical])
+ XCTAssertNil(store.models[.mxVertical]?.preferredDPI)
+
+ store.migrateLegacy(preferredDPI: 1200)
+ XCTAssertNil(store.models[.mxVertical]?.preferredDPI)
+ }
+
+ func testLegacyIgnoresNonsensicalSpeeds() {
+ var zero = DevicePreferencesStore()
+ zero.migrateLegacy(preferredDPI: 0)
+ XCTAssertNil(zero.models[.mxVertical]?.preferredDPI)
+
+ var negative = DevicePreferencesStore()
+ negative.migrateLegacy(preferredDPI: -400)
+ XCTAssertNil(negative.models[.mxVertical]?.preferredDPI)
+ }
+
+ func testFirstSeenOtherModelKeepsOwnDPI() {
+ var store = DevicePreferencesStore()
+ store.migrateLegacy(preferredDPI: 1600)
+
+ // Only the MX Vertical inherits the old setting; another model's mouse keeps its own speed.
+ XCTAssertNil(store.preferences(for: masterUnit, model: master3S).preferredDPI)
+ XCTAssertNil(store.preferences(for: transient, model: master3S).preferredDPI)
+ store.record(masterUnit, snapshot: snapshot(name: "MX Master 3S", connection: .bolt))
+ XCTAssertNil(store.devices[masterUnit]?.preferredDPI)
+ XCTAssertEqual(store.devices[masterUnit]?.lastModel, master3S)
+ }
+
+ func testModelSeedAppliesOnlyToUnitsWithoutAnEntryAndOnlyCarriesSpeed() {
+ var seed = DevicePreferences(nickname: "Seed nick", remapEnabled: false, preferredDPI: 2000, lastName: "Seed")
+ seed.lastControls = [0x53]
+ var store = DevicePreferencesStore(models: [master3S: seed])
+
+ let first = store.preferences(for: masterUnit, model: master3S)
+ XCTAssertEqual(first, DevicePreferences(preferredDPI: 2000))
+ XCTAssertNil(first.trimmedNickname)
+ XCTAssertTrue(first.remapEnabled)
+
+ store.record(masterUnit, snapshot: snapshot(name: "MX Master 3S"))
+ XCTAssertNil(store.devices[masterUnit]?.nickname)
+ XCTAssertEqual(store.devices[masterUnit]?.remapEnabled, true)
+ XCTAssertEqual(store.devices[masterUnit]?.preferredDPI, 2000)
+ XCTAssertEqual(store.devices[masterUnit]?.lastControls, [0x53, 0x56])
+ }
+
+ func testOwnPreferencesWinOverTheSeedEvenWithoutASpeed() {
+ var store = DevicePreferencesStore()
+ store.migrateLegacy(preferredDPI: 1600)
+ store.record(verticalA, snapshot: snapshot())
+
+ // "Let the Mouse Decide" on this unit clears only its speed; the seed must not come back.
+ var prefs = store.preferences(for: verticalA, model: .mxVertical)
+ prefs.preferredDPI = nil
+ store.setPreferences(prefs, for: verticalA)
+ XCTAssertNil(store.preferences(for: verticalA, model: .mxVertical).preferredDPI)
+ XCTAssertEqual(store.preferences(for: verticalB, model: .mxVertical).preferredDPI, 1600)
+ }
+
+ // MARK: Recording
+
+ func testRecordStoresWhatWasLastSeen() throws {
+ var store = DevicePreferencesStore()
+ let now = Date(timeIntervalSince1970: 1_700_000_000)
+ store.record(verticalA, snapshot: snapshot(connection: .unifying), now: now)
+
+ let prefs = try XCTUnwrap(store.devices[verticalA])
+ XCTAssertEqual(prefs.lastName, "MX Vertical")
+ XCTAssertEqual(prefs.lastModel, .mxVertical)
+ XCTAssertEqual(prefs.lastControls, [0x53, 0x56]) // configurable only, in the device's order
+ XCTAssertEqual(prefs.lastConnection, "unifying")
+ XCTAssertEqual(prefs.lastSeen, now)
+ XCTAssertEqual(prefs.remapEnabled, true)
+ XCTAssertNil(prefs.nickname)
+ XCTAssertNil(prefs.preferredDPI)
+ }
+
+ func testRecordKeepsWhatTheUserChose() throws {
+ var store = DevicePreferencesStore()
+ store.record(verticalA, snapshot: snapshot(connection: .bluetooth), now: Date(timeIntervalSince1970: 100))
+ var prefs = store.preferences(for: verticalA, model: .mxVertical)
+ prefs.nickname = "Desk"
+ prefs.remapEnabled = false
+ prefs.preferredDPI = 1200
+ store.setPreferences(prefs, for: verticalA)
+
+ store.record(verticalA, snapshot: snapshot(connection: .usb, controls: []), now: Date(timeIntervalSince1970: 200))
+
+ let after = try XCTUnwrap(store.devices[verticalA])
+ XCTAssertEqual(after.nickname, "Desk")
+ XCTAssertEqual(after.remapEnabled, false)
+ XCTAssertEqual(after.preferredDPI, 1200)
+ XCTAssertEqual(after.lastConnection, "usb")
+ XCTAssertEqual(after.lastControls, [])
+ XCTAssertEqual(after.lastSeen, Date(timeIntervalSince1970: 200))
+ }
+
+ func testTwoUnitsOfOneModelHaveIndependentPreferences() {
+ var store = DevicePreferencesStore()
+ store.record(verticalA, snapshot: snapshot())
+ store.record(verticalB, snapshot: snapshot())
+ var a = store.preferences(for: verticalA, model: .mxVertical)
+ a.nickname = "Home"
+ a.remapEnabled = false
+ store.setPreferences(a, for: verticalA)
+
+ XCTAssertEqual(store.preferences(for: verticalA, model: .mxVertical).nickname, "Home")
+ XCTAssertNil(store.preferences(for: verticalB, model: .mxVertical).nickname)
+ XCTAssertTrue(store.preferences(for: verticalB, model: .mxVertical).remapEnabled)
+ XCTAssertEqual(store.devices.count, 2)
+ }
+
+ // MARK: Transient IDs
+
+ func testTransientIDsNotPersisted() throws {
+ var store = DevicePreferencesStore()
+ store.record(transient, snapshot: snapshot())
+ store.setPreferences(DevicePreferences(nickname: "Ghost"), for: transient)
+ XCTAssertTrue(store.devices.isEmpty)
+ XCTAssertEqual(store.preferences(for: transient, model: .mxVertical), DevicePreferences())
+
+ // Not even when handed in directly, set through the property, or sitting in saved data.
+ XCTAssertTrue(DevicePreferencesStore(devices: [transient: DevicePreferences(nickname: "Ghost")]).devices.isEmpty)
+ store.devices[transient] = DevicePreferences(nickname: "Ghost")
+ let encoded = try JSONEncoder().encode(store)
+ XCTAssertFalse(String(decoding: encoded, as: UTF8.self).contains("Ghost"))
+ XCTAssertTrue(try roundTrip(store).devices.isEmpty)
+
+ let saved = #"{"devices":{"t:3.2":{"nickname":"Ghost"},"u:046D:B020:1A2B3C4D":{"nickname":"Real"}}}"#
+ let decoded = try JSONDecoder().decode(DevicePreferencesStore.self, from: Data(saved.utf8))
+ XCTAssertEqual(Array(decoded.devices.keys), [verticalA])
+ XCTAssertEqual(decoded.devices[verticalA]?.nickname, "Real")
+ }
+
+ func testTransientIDsFallBackToTheModelSeed() {
+ var store = DevicePreferencesStore()
+ store.migrateLegacy(preferredDPI: 1000)
+ XCTAssertEqual(store.preferences(for: transient, model: .mxVertical).preferredDPI, 1000)
+ XCTAssertNil(store.preferences(for: transient, model: master3S).preferredDPI)
+ }
+
+ // MARK: Forget
+
+ func testForget() {
+ var store = DevicePreferencesStore()
+ store.migrateLegacy(preferredDPI: 1600)
+ store.record(verticalA, snapshot: snapshot())
+ store.record(verticalB, snapshot: snapshot())
+ var prefs = store.preferences(for: verticalA, model: .mxVertical)
+ prefs.preferredDPI = 400
+ store.setPreferences(prefs, for: verticalA)
+
+ store.forget(verticalA)
+ XCTAssertNil(store.devices[verticalA])
+ XCTAssertNotNil(store.devices[verticalB])
+ // The model seed is not a unit's data: it survives, and the forgotten unit starts over from it.
+ XCTAssertNotNil(store.models[.mxVertical])
+ XCTAssertEqual(store.preferences(for: verticalA, model: .mxVertical).preferredDPI, 1600)
+
+ store.forget(verticalA) // forgetting twice, or an unknown unit, is harmless
+ store.forget(transient)
+ XCTAssertEqual(store.devices.count, 1)
+ }
+
+ func testRememberedDevicesAreNewestFirstAndExcludeConnectedOnes() {
+ var store = DevicePreferencesStore()
+ store.record(verticalA, snapshot: snapshot(), now: Date(timeIntervalSince1970: 100))
+ store.record(verticalB, snapshot: snapshot(), now: Date(timeIntervalSince1970: 300))
+ store.record(masterUnit, snapshot: snapshot(name: "MX Master 3S"), now: Date(timeIntervalSince1970: 200))
+ let neverSeen = DeviceID.unit(0x1111_1111, model: .mxVertical)
+ store.setPreferences(DevicePreferences(nickname: "Spare"), for: neverSeen)
+
+ XCTAssertEqual(store.rememberedDevices().map(\.id), [verticalB, masterUnit, verticalA, neverSeen])
+ XCTAssertEqual(store.rememberedDevices(excluding: [verticalB]).map(\.id), [masterUnit, verticalA, neverSeen])
+ XCTAssertEqual(store.rememberedDevices(excluding: [verticalA, verticalB, masterUnit, neverSeen]).count, 0)
+ XCTAssertEqual(store.rememberedDevices().last?.preferences.nickname, "Spare")
+ }
+
+ func testTrimmedNickname() {
+ XCTAssertNil(DevicePreferences().trimmedNickname)
+ XCTAssertNil(DevicePreferences(nickname: "").trimmedNickname)
+ XCTAssertNil(DevicePreferences(nickname: " \n ").trimmedNickname)
+ XCTAssertEqual(DevicePreferences(nickname: " Desk mouse ").trimmedNickname, "Desk mouse")
+ }
+
+ // MARK: Coding
+
+ func testStoreRoundTripsThroughJSON() throws {
+ var store = DevicePreferencesStore()
+ store.migrateLegacy(preferredDPI: 1600)
+ store.record(verticalA, snapshot: snapshot(), now: Date(timeIntervalSince1970: 1_700_000_000))
+ var prefs = store.preferences(for: verticalA, model: .mxVertical)
+ prefs.nickname = "Desk"
+ prefs.remapEnabled = false
+ store.setPreferences(prefs, for: verticalA)
+ store.record(masterUnit, snapshot: snapshot(name: "MX Master 3S", connection: .bolt), now: Date(timeIntervalSince1970: 1_700_000_500))
+
+ XCTAssertEqual(try roundTrip(store), store)
+ }
+
+ func testStoredJSONUsesStringKeys() throws {
+ var store = DevicePreferencesStore()
+ store.migrateLegacy(preferredDPI: 1600)
+ store.setPreferences(DevicePreferences(nickname: "Desk"), for: verticalA)
+
+ let object = try XCTUnwrap(JSONSerialization.jsonObject(with: JSONEncoder().encode(store)) as? [String: Any])
+ let models = try XCTUnwrap(object["models"] as? [String: Any])
+ let devices = try XCTUnwrap(object["devices"] as? [String: Any])
+ XCTAssertEqual(Set(models.keys), ["046D:B020"])
+ XCTAssertEqual(Set(devices.keys), ["u:046D:B020:1A2B3C4D"])
+ XCTAssertEqual((models["046D:B020"] as? [String: Any])?["preferredDPI"] as? Int, 1600)
+ XCTAssertEqual((devices["u:046D:B020:1A2B3C4D"] as? [String: Any])?["nickname"] as? String, "Desk")
+ }
+
+ func testDecodingSkipsBadEntriesAndKeepsTheRest() throws {
+ let json = """
+ {
+ "futureTopLevel": [1, 2, 3],
+ "models": {
+ "046D:B020": {"preferredDPI": 1600, "futureField": true},
+ "not a model": {"preferredDPI": 900},
+ "046D:B034": "oops",
+ "name:some mouse": {"preferredDPI": 700}
+ },
+ "devices": {
+ "u:046D:B020:1A2B3C4D": {"nickname": "Desk", "remapEnabled": false, "lastModel": "046D:B020"},
+ "u:046D:B020:5E6F7081": 42,
+ "": {"nickname": "Empty key"},
+ "s:046D:B034:ABC123": {"lastName": "MX Master 3S", "lastControls": [83, 86, "x", 70000, -1]}
+ }
+ }
+ """
+ let store = try JSONDecoder().decode(DevicePreferencesStore.self, from: Data(json.utf8))
+
+ XCTAssertEqual(Set(store.models.keys), [.mxVertical, ModelKey(name: "some mouse")])
+ XCTAssertEqual(store.models[.mxVertical]?.preferredDPI, 1600)
+ XCTAssertEqual(Set(store.devices.keys), [verticalA, DeviceID.serial("ABC123", model: master3S)])
+ XCTAssertEqual(store.devices[verticalA]?.nickname, "Desk")
+ XCTAssertEqual(store.devices[verticalA]?.remapEnabled, false)
+ XCTAssertEqual(store.devices[verticalA]?.lastModel, .mxVertical)
+ // Unreadable list elements are dropped one by one.
+ XCTAssertEqual(store.devices[DeviceID.serial("ABC123", model: master3S)]?.lastControls, [83, 86])
+ }
+
+ func testDecodingGarbageGivesDefaults() throws {
+ let decoder = JSONDecoder()
+ XCTAssertEqual(try decoder.decode(DevicePreferencesStore.self, from: Data("{}".utf8)), DevicePreferencesStore())
+ XCTAssertEqual(
+ try decoder.decode(DevicePreferencesStore.self, from: Data(#"{"models":[1,2],"devices":"none"}"#.utf8)),
+ DevicePreferencesStore()
+ )
+ XCTAssertThrowsError(try decoder.decode(DevicePreferencesStore.self, from: Data("[]".utf8)))
+ }
+
+ func testPreferencesDecodeTolerantly() throws {
+ let decoder = JSONDecoder()
+ XCTAssertEqual(try decoder.decode(DevicePreferences.self, from: Data("{}".utf8)), DevicePreferences())
+
+ let messy = """
+ {"nickname": 5, "remapEnabled": "yes", "preferredDPI": "fast", "lastName": ["x"],
+ "lastModel": "garbage", "lastControls": "none", "lastConnection": 1, "lastSeen": "yesterday"}
+ """
+ XCTAssertEqual(try decoder.decode(DevicePreferences.self, from: Data(messy.utf8)), DevicePreferences())
+
+ let partial = #"{"nickname":"Desk","preferredDPI":1600,"lastModel":"046d:b034"}"#
+ let prefs = try decoder.decode(DevicePreferences.self, from: Data(partial.utf8))
+ XCTAssertEqual(prefs.nickname, "Desk")
+ XCTAssertEqual(prefs.preferredDPI, 1600)
+ XCTAssertEqual(prefs.lastModel, ModelKey(productID: 0xB034)) // keys are normalized to upper case
+ XCTAssertTrue(prefs.remapEnabled)
+ XCTAssertEqual(prefs.lastControls, [])
+ }
+
+ func testNonPositivePreferredSpeedDecodesAsNil() throws {
+ let decoder = JSONDecoder()
+ XCTAssertNil(try decoder.decode(DevicePreferences.self, from: Data(#"{"preferredDPI":0}"#.utf8)).preferredDPI)
+ XCTAssertNil(try decoder.decode(DevicePreferences.self, from: Data(#"{"preferredDPI":-800}"#.utf8)).preferredDPI)
+ }
+
+ func testPreferencesRoundTripIncludingDates() throws {
+ let prefs = DevicePreferences(
+ nickname: "Desk",
+ remapEnabled: false,
+ preferredDPI: 1600,
+ lastName: "MX Vertical",
+ lastModel: .mxVertical,
+ lastControls: [0x53, 0x56, 0xC3],
+ lastConnection: "bolt",
+ lastSeen: Date(timeIntervalSince1970: 1_700_000_000)
+ )
+ let decoded = try JSONDecoder().decode(DevicePreferences.self, from: JSONEncoder().encode(prefs))
+ XCTAssertEqual(decoded, prefs)
+ }
+
+ // MARK: UserDefaults
+
+ func testSaveAndLoadThroughUserDefaults() throws {
+ let suiteName = "io.github.upright.tests.devicePreferences.\(UUID().uuidString)"
+ let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName))
+ defer { defaults.removePersistentDomain(forName: suiteName) }
+
+ XCTAssertEqual(DevicePreferencesStore.load(from: defaults), DevicePreferencesStore())
+
+ var store = DevicePreferencesStore()
+ store.migrateLegacy(preferredDPI: 1600)
+ store.record(verticalA, snapshot: snapshot(), now: Date(timeIntervalSince1970: 1_700_000_000))
+ store.save(to: defaults)
+
+ XCTAssertNotNil(defaults.data(forKey: "devicePreferences.v1"))
+ XCTAssertEqual(DevicePreferencesStore.defaultsKey, "devicePreferences.v1")
+ XCTAssertEqual(DevicePreferencesStore.load(from: defaults), store)
+ }
+
+ func testUnreadableSavedDataLoadsAsEmpty() throws {
+ let suiteName = "io.github.upright.tests.devicePreferences.\(UUID().uuidString)"
+ let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName))
+ defer { defaults.removePersistentDomain(forName: suiteName) }
+
+ defaults.set(Data("not json".utf8), forKey: DevicePreferencesStore.defaultsKey)
+ XCTAssertEqual(DevicePreferencesStore.load(from: defaults), DevicePreferencesStore())
+ defaults.set("a string, not data", forKey: DevicePreferencesStore.defaultsKey)
+ XCTAssertEqual(DevicePreferencesStore.load(from: defaults), DevicePreferencesStore())
+ }
+
+ func testUnreadableSavedDataIsBackedUpOnceBeforeItIsReplaced() throws {
+ let suiteName = "io.github.upright.tests.devicePreferences.\(UUID().uuidString)"
+ let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName))
+ defer { defaults.removePersistentDomain(forName: suiteName) }
+
+ let raw = Data("[\"a future top-level array\"]".utf8)
+ defaults.set(raw, forKey: DevicePreferencesStore.defaultsKey)
+ var messages: [String] = []
+ let now = Date(timeIntervalSince1970: 1_700_000_000)
+ XCTAssertEqual(DevicePreferencesStore.load(from: defaults, log: { messages.append($0) }, now: now), DevicePreferencesStore())
+ let keys = DevicePreferencesStore.backupKeys(in: defaults)
+ XCTAssertEqual(keys.count, 1)
+ let key = try XCTUnwrap(keys.first)
+ XCTAssertTrue(key.hasPrefix("devicePreferences.v1.backup."))
+ XCTAssertEqual(defaults.data(forKey: key), raw)
+ XCTAssertEqual(messages.count, 1)
+
+ // Later launches reading the same blob don't pile up backups.
+ _ = DevicePreferencesStore.load(from: defaults, now: now.addingTimeInterval(60))
+ _ = DevicePreferencesStore.load(from: defaults, now: now.addingTimeInterval(120))
+ XCTAssertEqual(DevicePreferencesStore.backupKeys(in: defaults), keys)
+
+ // Saving over it (what AppSettings does next) leaves the backup in place.
+ var store = DevicePreferencesStore()
+ store.migrateLegacy(preferredDPI: 1600)
+ store.save(to: defaults)
+ XCTAssertEqual(defaults.data(forKey: key), raw)
+ XCTAssertEqual(DevicePreferencesStore.load(from: defaults), store)
+ XCTAssertEqual(DevicePreferencesStore.backupKeys(in: defaults), keys)
+ }
+
+ func testSkippedEntriesAndUnknownFieldsAreBackedUp() throws {
+ let suiteName = "io.github.upright.tests.devicePreferences.\(UUID().uuidString)"
+ let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName))
+ defer { defaults.removePersistentDomain(forName: suiteName) }
+
+ // One readable unit, one entry whose key isn't a model.
+ let skipped = Data(#"{"models":{"not a model":{"preferredDPI":800}},"devices":{"\#(verticalA.rawValue)":{"nickname":"Desk"}}}"#.utf8)
+ defaults.set(skipped, forKey: DevicePreferencesStore.defaultsKey)
+ var messages: [String] = []
+ let store = DevicePreferencesStore.load(from: defaults, log: { messages.append($0) })
+ XCTAssertEqual(store.devices[verticalA]?.nickname, "Desk")
+ XCTAssertTrue(store.models.isEmpty)
+ XCTAssertEqual(DevicePreferencesStore.backupKeys(in: defaults).count, 1)
+ XCTAssertTrue(messages.first?.contains("skipped 1") == true, "\(messages)")
+
+ // A newer version's extra top-level field would be dropped on save: backed up too.
+ let newer = Data(#"{"models":{},"devices":{},"groups":[1]}"#.utf8)
+ defaults.set(newer, forKey: DevicePreferencesStore.defaultsKey)
+ XCTAssertEqual(DevicePreferencesStore.load(from: defaults), DevicePreferencesStore())
+ let keys = DevicePreferencesStore.backupKeys(in: defaults)
+ XCTAssertEqual(keys.count, 2)
+ XCTAssertTrue(keys.contains { defaults.data(forKey: $0) == newer })
+
+ // Data saved by this version is read in full: no backup.
+ var clean = DevicePreferencesStore()
+ clean.record(verticalA, snapshot: snapshot(), now: Date(timeIntervalSince1970: 1_700_000_000))
+ clean.save(to: defaults)
+ XCTAssertEqual(DevicePreferencesStore.load(from: defaults), clean)
+ XCTAssertEqual(DevicePreferencesStore.backupKeys(in: defaults), keys)
+ }
+}
diff --git a/UprightKit/Tests/UprightKitTests/DeviceRegistryTests.swift b/UprightKit/Tests/UprightKitTests/DeviceRegistryTests.swift
new file mode 100644
index 0000000..e7c7947
--- /dev/null
+++ b/UprightKit/Tests/UprightKitTests/DeviceRegistryTests.swift
@@ -0,0 +1,490 @@
+import XCTest
+@testable import UprightKit
+
+/// A snapshot of a simulated device as `DeviceSession.start()` reads it at `address`.
+private func identified(
+ _ profile: SimulatedDeviceProfile,
+ connection: ConnectionKind,
+ at address: DeviceAddress,
+ battery: Int? = nil
+) -> DeviceSnapshot {
+ DeviceSnapshot(
+ name: profile.name,
+ connection: connection,
+ deviceIndex: address.index,
+ protocolVersion: "\(profile.protocolMajor).\(profile.protocolMinor)",
+ features: [],
+ controls: profile.controls,
+ battery: BatteryStatus(percentage: battery ?? profile.batteryLevel, isCharging: false),
+ identity: DeviceIdentity(
+ name: profile.name,
+ kind: profile.kind,
+ unitID: profile.unitID,
+ serialNumber: profile.serialNumber,
+ modelIDs: profile.modelIDs,
+ transportPID: address.isDirect ? (connection == .usb ? profile.usbPID : profile.bluetoothPID) : profile.wirelessPID
+ ),
+ address: address
+ )
+}
+
+@MainActor
+final class DeviceRegistryTests: XCTestCase {
+ private let receiver = DeviceAddress(link: 1, index: 1)
+ private let receiverSlot2 = DeviceAddress(link: 1, index: 2)
+ private let receiverSlot3 = DeviceAddress(link: 1, index: 3)
+ private let usb = DeviceAddress(link: 2, index: DeviceAddress.direct)
+ private let bluetooth = DeviceAddress(link: 3, index: DeviceAddress.direct)
+
+ private var changes = 0
+
+ private func makeRegistry() -> DeviceRegistry {
+ let registry = DeviceRegistry()
+ changes = 0
+ registry.onChange = { [weak self] in self?.changes += 1 }
+ return registry
+ }
+
+ private func connect(
+ _ registry: DeviceRegistry,
+ _ profile: SimulatedDeviceProfile,
+ _ connection: ConnectionKind,
+ at address: DeviceAddress,
+ battery: Int? = nil
+ ) {
+ registry.update(address, connection: connection, state: .connected(identified(profile, connection: connection, at: address, battery: battery)))
+ }
+
+ private func putToSleep(
+ _ registry: DeviceRegistry,
+ _ profile: SimulatedDeviceProfile,
+ _ connection: ConnectionKind,
+ at address: DeviceAddress
+ ) {
+ registry.update(address, connection: connection, state: .asleep(identified(profile, connection: connection, at: address)))
+ }
+
+ private func unitID(_ profile: SimulatedDeviceProfile) -> DeviceID {
+ identified(profile, connection: .usb, at: usb).identity!.deviceID(at: usb)
+ }
+
+ // MARK: Order
+
+ func testOrderIsFirstSeenAndStable() async {
+ let registry = makeRegistry()
+ registry.update(receiver, connection: .unifying, state: .probing)
+ registry.update(bluetooth, connection: .bluetooth, state: .probing)
+ XCTAssertEqual(registry.records.map(\.address), [receiver, bluetooth])
+ XCTAssertEqual(registry.records.map(\.role), [.pending, .pending])
+ XCTAssertFalse(registry.isActive(receiver))
+
+ // The Bluetooth mouse answers first; first-seen order still wins.
+ connect(registry, DeviceProfiles.mxMaster3S, .bluetooth, at: bluetooth)
+ connect(registry, DeviceProfiles.mxVertical, .unifying, at: receiver)
+ XCTAssertEqual(registry.records.map(\.address), [receiver, bluetooth])
+ let ordinals = registry.records.map(\.ordinal)
+ XCTAssertEqual(ordinals, ordinals.sorted())
+ XCTAssertEqual(Set(ordinals).count, 2)
+
+ // Sleep, battery news and retries don't move anyone.
+ putToSleep(registry, DeviceProfiles.mxVertical, .unifying, at: receiver)
+ connect(registry, DeviceProfiles.mxMaster3S, .bluetooth, at: bluetooth, battery: 10)
+ registry.update(receiver, connection: .unifying, state: .waitingForDevice)
+ XCTAssertEqual(registry.records.map(\.address), [receiver, bluetooth])
+ XCTAssertEqual(registry.records.map(\.ordinal), ordinals)
+
+ // The Bluetooth mouse drops off (its HID interface goes away), a Lift arrives, then the
+ // Master reconnects on a new link: once identified it is back in its first-seen place.
+ registry.removeLink(bluetooth.link)
+ let lift = DeviceAddress(link: 4, index: DeviceAddress.direct)
+ connect(registry, DeviceProfiles.lift, .bluetooth, at: lift)
+ let master = DeviceAddress(link: 5, index: DeviceAddress.direct)
+ registry.update(master, connection: .bluetooth, state: .probing)
+ XCTAssertEqual(registry.records.map(\.address), [receiver, lift, master])
+ connect(registry, DeviceProfiles.mxMaster3S, .bluetooth, at: master)
+ XCTAssertEqual(registry.records.map(\.address), [receiver, master, lift])
+ XCTAssertEqual(registry.record(master)?.ordinal, ordinals[1])
+ }
+
+ // MARK: Same unit (D12)
+
+ func testSameUnitOnUSBAndReceiverPrefersUSB() async {
+ let registry = makeRegistry()
+ let vertical = unitID(DeviceProfiles.mxVertical)
+ connect(registry, DeviceProfiles.mxVertical, .unifying, at: receiver)
+ XCTAssertEqual(registry.record(receiver)?.role, .active)
+ XCTAssertEqual(registry.record(receiver)?.deviceID, vertical)
+ XCTAssertEqual(registry.record(receiver)?.model, .mxVertical)
+
+ // The cable goes in: the same unit answers over USB as well.
+ connect(registry, DeviceProfiles.mxVertical, .usb, at: usb)
+ XCTAssertEqual(registry.record(usb)?.role, .active)
+ XCTAssertEqual(registry.record(receiver)?.role, .standby(of: usb))
+ XCTAssertTrue(registry.isActive(usb))
+ XCTAssertFalse(registry.isActive(receiver))
+ XCTAssertEqual(Set(registry.addresses(of: vertical)), [receiver, usb])
+
+ // And over Bluetooth: USB still wins.
+ connect(registry, DeviceProfiles.mxVertical, .bluetooth, at: bluetooth)
+ XCTAssertEqual(registry.record(bluetooth)?.role, .standby(of: usb))
+ XCTAssertEqual(registry.records.filter { $0.role == .active }.map(\.address), [usb])
+
+ // One mouse, one name, one place in the list, counted once.
+ XCTAssertEqual(Set(registry.records.map(\.displayName)), ["MX Vertical"])
+ XCTAssertEqual(Set(registry.records.map(\.ordinal)).count, 1)
+ XCTAssertEqual(registry.records.map(\.address), [receiver, usb, bluetooth])
+ XCTAssertEqual(registry.summary.connectedCount, 1)
+ XCTAssertEqual(registry.summary.lowestBattery?.address, usb)
+
+ // Receiver beats Bluetooth when there is no cable.
+ registry.removeLink(usb.link)
+ XCTAssertEqual(registry.record(receiver)?.role, .active)
+ XCTAssertEqual(registry.record(bluetooth)?.role, .standby(of: receiver))
+ }
+
+ func testStandbyPromotedWhenActiveLeaves() async {
+ let registry = makeRegistry()
+ connect(registry, DeviceProfiles.mxVertical, .unifying, at: receiver)
+ connect(registry, DeviceProfiles.mxVertical, .usb, at: usb)
+ XCTAssertFalse(registry.isActive(receiver))
+
+ // The cable comes out: the receiver endpoint is promoted.
+ changes = 0
+ XCTAssertEqual(registry.removeLink(usb.link), [usb])
+ XCTAssertEqual(registry.record(receiver)?.role, .active)
+ XCTAssertEqual(changes, 1)
+
+ // The mouse is switched to its Bluetooth channel on this Mac: the receiver slot goes to
+ // sleep and the Bluetooth endpoint, the only one answering, is served even though its
+ // link ranks lower.
+ connect(registry, DeviceProfiles.mxVertical, .bluetooth, at: bluetooth)
+ XCTAssertEqual(registry.record(bluetooth)?.role, .standby(of: receiver))
+ putToSleep(registry, DeviceProfiles.mxVertical, .unifying, at: receiver)
+ XCTAssertEqual(registry.record(bluetooth)?.role, .active)
+ XCTAssertEqual(registry.record(receiver)?.role, .standby(of: bluetooth))
+ XCTAssertEqual(registry.summary.connectedCount, 1)
+
+ // Back on the receiver while Bluetooth still answers: the better link takes over again.
+ connect(registry, DeviceProfiles.mxVertical, .unifying, at: receiver)
+ XCTAssertEqual(registry.record(receiver)?.role, .active)
+ XCTAssertEqual(registry.record(bluetooth)?.role, .standby(of: receiver))
+
+ // Removing the active endpoint by address promotes too.
+ registry.remove(receiver)
+ XCTAssertEqual(registry.record(bluetooth)?.role, .active)
+ XCTAssertEqual(registry.records.map(\.address), [bluetooth])
+ }
+
+ func testRePairOnSameReceiverPrefersTheAnsweringSlot() async {
+ let registry = makeRegistry()
+ connect(registry, DeviceProfiles.mxVertical, .unifying, at: receiver)
+ // Re-paired into slot 2: the old slot stops answering, the new one identifies as the
+ // same unit. The hub retires slot 1; until then slot 2 is the one served.
+ registry.update(receiver, connection: .unifying, state: .waitingForDevice)
+ connect(registry, DeviceProfiles.mxVertical, .unifying, at: receiverSlot2)
+ XCTAssertEqual(registry.record(receiverSlot2)?.role, .active)
+ XCTAssertEqual(registry.record(receiver)?.role, .standby(of: receiverSlot2))
+
+ // Both answering on the same link kind: the endpoint seen first wins.
+ connect(registry, DeviceProfiles.mxVertical, .unifying, at: receiver)
+ XCTAssertEqual(registry.record(receiver)?.role, .active)
+ XCTAssertEqual(registry.record(receiverSlot2)?.role, .standby(of: receiver))
+
+ registry.remove(receiver)
+ XCTAssertEqual(registry.record(receiverSlot2)?.role, .active)
+ }
+
+ func testTransientIDsNeverDeduped() async {
+ let registry = makeRegistry()
+ let profile = DeviceProfiles.mouseWithoutUnitID
+ connect(registry, profile, .unifying, at: receiver)
+ connect(registry, profile, .unifying, at: receiverSlot2)
+ connect(registry, profile, .usb, at: usb)
+
+ let records = registry.records
+ XCTAssertEqual(records.map(\.role), [.active, .active, .active])
+ XCTAssertTrue(records.allSatisfy { $0.deviceID?.isStable == false })
+ XCTAssertEqual(Set(records.compactMap(\.deviceID)).count, 3)
+ XCTAssertEqual(registry.summary.connectedCount, 3)
+
+ let name = DeviceCatalog.model(records[0].model!)?.name ?? profile.name
+ XCTAssertEqual(records.map(\.displayName), [name, "\(name) #2", "\(name) #3"])
+ }
+
+ // MARK: Unsupported
+
+ func testUnsupportedSortedLastAndNotAProblem() async {
+ let registry = makeRegistry()
+ // The MX Keys on the shared receiver is seen first, and found not to be a mouse.
+ let keyboard = identified(DeviceProfiles.mxKeys, connection: .unifying, at: receiver)
+ registry.update(receiver, connection: .unifying, state: .waitingForDevice, snapshot: keyboard, unsupported: .notAPointer(.keyboard))
+ // An HID++ 1.0 mouse that says nothing more.
+ registry.update(receiverSlot3, connection: .unifying, state: .waitingForDevice, snapshot: nil, unsupported: .hidpp10(major: 1, minor: 0))
+ connect(registry, DeviceProfiles.mxVertical, .unifying, at: receiverSlot2)
+ registry.update(usb, connection: .usb, state: .failed("The mouse refused the connection"))
+
+ XCTAssertEqual(registry.records.map(\.address), [receiverSlot2, usb, receiver, receiverSlot3])
+ XCTAssertEqual(registry.records.map(\.role), [.active, .pending, .unsupported, .unsupported])
+ XCTAssertFalse(registry.isActive(receiver))
+ XCTAssertFalse(registry.isActive(receiverSlot3))
+
+ let keys = registry.record(receiver)
+ XCTAssertEqual(keys?.displayName, DeviceProfiles.mxKeys.name)
+ XCTAssertNotNil(keys?.model)
+ XCTAssertNotEqual(keys?.model, .mxVertical)
+ let old = registry.record(receiverSlot3)
+ XCTAssertNil(old?.deviceID)
+ XCTAssertNil(old?.model, "no identity: not mistaken for an MX Vertical")
+ XCTAssertEqual(old?.displayName, "Logitech device")
+
+ let summary = registry.summary
+ XCTAssertEqual(summary.problems, [usb])
+ XCTAssertTrue(summary.anyConnected)
+ XCTAssertEqual(summary.connectedCount, 1)
+ XCTAssertEqual(summary.lowestBattery?.address, receiverSlot2)
+
+ // An unsupported endpoint never takes part in same-unit dedupe, even with a matching ID.
+ let twin = identified(DeviceProfiles.mxVertical, connection: .bluetooth, at: bluetooth)
+ registry.update(bluetooth, connection: .bluetooth, state: .waitingForDevice, snapshot: twin, unsupported: .noConfigurableControls)
+ XCTAssertEqual(registry.record(receiverSlot2)?.role, .active)
+ XCTAssertEqual(registry.record(bluetooth)?.role, .unsupported)
+ XCTAssertEqual(registry.records.last?.address, bluetooth)
+ }
+
+ func testUnsupportedStateIsDecodedThroughPublicUpdate() async {
+ let registry = makeRegistry()
+ let partial = identified(DeviceProfiles.mxKeys, connection: .bolt, at: receiver)
+ registry.update(receiver, connection: .bolt, state: .unsupported(.notAPointer(.keyboard), partial))
+ registry.update(receiverSlot2, connection: .unifying, state: .unsupported(.hidpp10(major: 1, minor: 0), nil))
+ registry.update(receiverSlot3, connection: .unifying, state: .probing)
+ connect(registry, DeviceProfiles.mxVertical, .usb, at: usb)
+
+ XCTAssertEqual(registry.records.map(\.address), [receiverSlot3, usb, receiver, receiverSlot2])
+ XCTAssertEqual(registry.records.map(\.role), [.pending, .active, .unsupported, .unsupported])
+ XCTAssertFalse(registry.isActive(receiver))
+ XCTAssertFalse(registry.isActive(receiverSlot2))
+
+ // The partial snapshot is kept: the keyboard is named and identified from it.
+ let keys = registry.record(receiver)
+ XCTAssertEqual(keys?.displayName, DeviceProfiles.mxKeys.name)
+ XCTAssertNotNil(keys?.model)
+ XCTAssertNotEqual(keys?.model, .mxVertical)
+ if case .unsupported(let reason, let kept)? = keys?.state {
+ XCTAssertEqual(reason, .notAPointer(.keyboard))
+ XCTAssertEqual(kept, partial)
+ } else {
+ XCTFail("expected the unsupported state, got \(String(describing: keys?.state))")
+ }
+ XCTAssertNil(registry.record(receiverSlot2)?.model)
+ XCTAssertEqual(registry.record(receiverSlot2)?.displayName, "Logitech device")
+ XCTAssertEqual(registry.summary.problems, [])
+
+ // A failure that merely says "unsupported", and probing, are not the verdict.
+ registry.update(receiver, connection: .bolt, state: .failed("unsupported"))
+ registry.update(receiverSlot2, connection: .unifying, state: .probing)
+ XCTAssertNotEqual(registry.record(receiver)?.role, .unsupported)
+ XCTAssertNotEqual(registry.record(receiverSlot2)?.role, .unsupported)
+ XCTAssertEqual(registry.records.filter { $0.role == .unsupported }, [])
+ }
+
+ // MARK: Summary
+
+ func testSummaryLowestBatteryAndAnyPresent() async {
+ let registry = makeRegistry()
+ XCTAssertEqual(registry.summary, FleetSummary())
+ XCTAssertFalse(registry.summary.anyPresent)
+
+ connect(registry, DeviceProfiles.mxVertical, .unifying, at: receiver, battery: 50)
+ connect(registry, DeviceProfiles.mxMaster3S, .bolt, at: usb, battery: 25)
+ var summary = registry.summary
+ XCTAssertTrue(summary.anyConnected)
+ XCTAssertTrue(summary.anyPresent)
+ XCTAssertEqual(summary.connectedCount, 2)
+ XCTAssertEqual(summary.lowestBattery?.address, usb)
+ XCTAssertEqual(summary.lowestBattery?.status.percentage, 25)
+
+ // A standby link to the Master isn't another mouse, whatever it reports.
+ connect(registry, DeviceProfiles.mxMaster3S, .bluetooth, at: bluetooth, battery: 5)
+ XCTAssertEqual(registry.record(bluetooth)?.role, .standby(of: usb))
+ summary = registry.summary
+ XCTAssertEqual(summary.connectedCount, 2)
+ XCTAssertEqual(summary.lowestBattery?.address, usb)
+ registry.removeLink(bluetooth.link)
+
+ // Only connected mice count for the battery; asleep ones still count as present.
+ putToSleep(registry, DeviceProfiles.mxMaster3S, .bolt, at: usb)
+ summary = registry.summary
+ XCTAssertEqual(summary.lowestBattery?.address, receiver)
+ XCTAssertEqual(summary.connectedCount, 1)
+
+ putToSleep(registry, DeviceProfiles.mxVertical, .unifying, at: receiver)
+ summary = registry.summary
+ XCTAssertFalse(summary.anyConnected)
+ XCTAssertTrue(summary.anyPresent)
+ XCTAssertEqual(summary.connectedCount, 0)
+ XCTAssertNil(summary.lowestBattery)
+
+ // Waiting without a session is neither connected nor present (2.x: "has a snapshot").
+ registry.update(receiver, connection: .unifying, state: .waitingForDevice)
+ registry.update(usb, connection: .bolt, state: .waitingForDevice)
+ XCTAssertFalse(registry.summary.anyPresent)
+ XCTAssertTrue(registry.summary.problems.isEmpty)
+
+ // A tie goes to the mouse listed first.
+ connect(registry, DeviceProfiles.mxMaster3S, .bolt, at: usb, battery: 40)
+ connect(registry, DeviceProfiles.mxVertical, .unifying, at: receiver, battery: 40)
+ XCTAssertEqual(registry.summary.lowestBattery?.address, receiver)
+ }
+
+ // MARK: Removal
+
+ func testRemoveLinkRemovesAllItsEndpoints() async {
+ let registry = makeRegistry()
+ connect(registry, DeviceProfiles.mxVertical, .unifying, at: receiver)
+ connect(registry, DeviceProfiles.mxMaster3, .unifying, at: receiverSlot2)
+ registry.update(receiverSlot3, connection: .unifying, state: .probing)
+ connect(registry, DeviceProfiles.lift, .bluetooth, at: bluetooth)
+
+ changes = 0
+ XCTAssertEqual(registry.removeLink(receiver.link), [receiver, receiverSlot2, receiverSlot3])
+ XCTAssertEqual(registry.records.map(\.address), [bluetooth])
+ XCTAssertEqual(changes, 1)
+
+ XCTAssertEqual(registry.removeLink(receiver.link), [])
+ registry.remove(receiver)
+ registry.remove(DeviceAddress(link: 9, index: 1))
+ XCTAssertEqual(changes, 1)
+ XCTAssertNil(registry.record(receiverSlot2))
+ XCTAssertTrue(registry.addresses(of: unitID(DeviceProfiles.mxVertical)).isEmpty)
+ }
+
+ // MARK: Identity, names, notifications
+
+ func testIdentityKeptWhileAsleepOrRetried() async {
+ let registry = makeRegistry()
+ let vertical = unitID(DeviceProfiles.mxVertical)
+ connect(registry, DeviceProfiles.mxVertical, .unifying, at: receiver)
+ for state in [EndpointController.State.waitingForDevice, .probing, .failed("Disconnected")] {
+ registry.update(receiver, connection: .unifying, state: state)
+ let record = registry.record(receiver)
+ XCTAssertEqual(record?.state, state)
+ XCTAssertEqual(record?.deviceID, vertical, "\(state)")
+ XCTAssertEqual(record?.model, .mxVertical, "\(state)")
+ XCTAssertEqual(record?.displayName, "MX Vertical", "\(state)")
+ XCTAssertEqual(record?.role, .active, "\(state)")
+ XCTAssertNil(record?.snapshot)
+ }
+ XCTAssertEqual(registry.summary.problems, [receiver])
+
+ // Another device in the slot (the hub normally removes the old one first) takes over the record.
+ connect(registry, DeviceProfiles.mxMaster3, .unifying, at: receiver)
+ XCTAssertEqual(registry.record(receiver)?.deviceID, unitID(DeviceProfiles.mxMaster3))
+ XCTAssertNotEqual(registry.record(receiver)?.model, .mxVertical)
+ }
+
+ func testLegacySnapshotWithoutIdentityIsATransientMXVertical() async {
+ let registry = makeRegistry()
+ var legacy = identified(DeviceProfiles.mxVertical, connection: .bluetooth, at: bluetooth)
+ legacy.identity = nil
+ registry.update(bluetooth, connection: .bluetooth, state: .connected(legacy))
+ let record = registry.record(bluetooth)
+ XCTAssertEqual(record?.model, .mxVertical)
+ XCTAssertEqual(record?.deviceID, .transient(bluetooth))
+ XCTAssertEqual(record?.role, .active)
+ }
+
+ func testProspectiveRoleForConnectingEndpoint() async {
+ let registry = makeRegistry()
+ let vertical = identified(DeviceProfiles.mxVertical, connection: .unifying, at: receiver)
+ XCTAssertEqual(registry.prospectiveRole(receiver, snapshot: vertical), .active)
+ XCTAssertTrue(registry.records.isEmpty)
+ XCTAssertEqual(changes, 0)
+
+ // Probing at that address already: still the one to serve.
+ registry.update(receiver, connection: .unifying, state: .probing)
+ XCTAssertEqual(registry.prospectiveRole(receiver, snapshot: vertical), .active)
+
+ // With the same unit on USB, the receiver endpoint would be a standby.
+ connect(registry, DeviceProfiles.mxVertical, .usb, at: usb)
+ XCTAssertEqual(registry.prospectiveRole(receiver, snapshot: vertical), .standby(of: usb))
+
+ // Asking changes nothing.
+ changes = 0
+ let before = registry.records
+ let cable = identified(DeviceProfiles.mxVertical, connection: .usb, at: usb)
+ XCTAssertEqual(registry.prospectiveRole(usb, snapshot: cable), .active)
+ XCTAssertEqual(registry.records, before)
+ XCTAssertEqual(changes, 0)
+
+ // A better link connecting would take over from an active receiver endpoint.
+ registry.removeLink(usb.link)
+ connect(registry, DeviceProfiles.mxVertical, .unifying, at: receiver)
+ let newCable = DeviceAddress(link: 7, index: DeviceAddress.direct)
+ XCTAssertEqual(registry.prospectiveRole(newCable, snapshot: identified(DeviceProfiles.mxVertical, connection: .usb, at: newCable)), .active)
+ XCTAssertEqual(registry.record(receiver)?.role, .active)
+
+ // Mice without a unit ID never wait for one another.
+ connect(registry, DeviceProfiles.mouseWithoutUnitID, .unifying, at: receiverSlot2)
+ let other = identified(DeviceProfiles.mouseWithoutUnitID, connection: .usb, at: usb)
+ XCTAssertEqual(registry.prospectiveRole(usb, snapshot: other), .active)
+ }
+
+ func testNicknamesAndDuplicateNames() async {
+ let registry = makeRegistry()
+ let first = unitID(DeviceProfiles.mxVertical)
+ let second = unitID(DeviceProfiles.mxVerticalSecondUnit)
+ connect(registry, DeviceProfiles.mxVertical, .unifying, at: receiver)
+ connect(registry, DeviceProfiles.mxVerticalSecondUnit, .unifying, at: receiverSlot2)
+ // The catalog's model name, not the long name the mouse reports.
+ XCTAssertEqual(registry.records.map(\.displayName), ["MX Vertical", "MX Vertical #2"])
+
+ // A model the catalog doesn't know keeps the name it reports.
+ var prototype = DeviceProfiles.mxMaster3S.named("Prototype Mouse")
+ prototype.modelIDs = ModelIDs(bluetoothLE: 0xABCD)
+ connect(registry, prototype, .bluetooth, at: bluetooth)
+ XCTAssertNil(DeviceCatalog.model(ModelKey(productID: 0xABCD)))
+ XCTAssertEqual(registry.record(bluetooth)?.displayName, "Prototype Mouse")
+
+ // Nicknames, read again on request; blank ones are ignored.
+ var nicknames: [DeviceID: String] = [second: "Desk", first: " "]
+ registry.nameProvider = { nicknames[$0] }
+ changes = 0
+ registry.refreshNames()
+ XCTAssertEqual(registry.records.map(\.displayName), ["MX Vertical", "Desk", "Prototype Mouse"])
+ XCTAssertEqual(changes, 1)
+ registry.refreshNames()
+ XCTAssertEqual(changes, 1)
+
+ // Without nicknames, numbers follow the units. Both leave with their receiver; the second
+ // comes back first and is just "MX Vertical"; when the first one returns on another link
+ // it takes its first-seen place and the plain name again.
+ nicknames = [:]
+ registry.refreshNames()
+ registry.removeLink(receiver.link)
+ connect(registry, DeviceProfiles.mxVerticalSecondUnit, .unifying, at: DeviceAddress(link: 4, index: 2))
+ XCTAssertEqual(registry.records.first?.displayName, "MX Vertical")
+ let back = DeviceAddress(link: 5, index: DeviceAddress.direct)
+ connect(registry, DeviceProfiles.mxVertical, .usb, at: back)
+ XCTAssertEqual(registry.records.map(\.address), [back, DeviceAddress(link: 4, index: 2), bluetooth])
+ XCTAssertEqual(registry.records.map(\.displayName), ["MX Vertical", "MX Vertical #2", "Prototype Mouse"])
+ }
+
+ func testOnChangeOnlyWhenRecordsChange() async {
+ let registry = makeRegistry()
+ connect(registry, DeviceProfiles.mxVertical, .unifying, at: receiver, battery: 50)
+ XCTAssertEqual(changes, 1)
+ connect(registry, DeviceProfiles.mxVertical, .unifying, at: receiver, battery: 50)
+ XCTAssertEqual(changes, 1)
+ connect(registry, DeviceProfiles.mxVertical, .unifying, at: receiver, battery: 45)
+ XCTAssertEqual(changes, 2)
+ registry.refreshNames()
+ XCTAssertEqual(changes, 2)
+ }
+
+ func testLinkRank() async {
+ XCTAssertLessThan(DeviceRegistry.linkRank(.usb), DeviceRegistry.linkRank(.unifying))
+ XCTAssertEqual(DeviceRegistry.linkRank(.unifying), DeviceRegistry.linkRank(.bolt))
+ XCTAssertEqual(DeviceRegistry.linkRank(.bolt), DeviceRegistry.linkRank(.nano))
+ XCTAssertLessThan(DeviceRegistry.linkRank(.nano), DeviceRegistry.linkRank(.bluetooth))
+ }
+}
diff --git a/UprightKit/Tests/UprightKitTests/EndToEndRoutingTests.swift b/UprightKit/Tests/UprightKitTests/EndToEndRoutingTests.swift
new file mode 100644
index 0000000..87c124f
--- /dev/null
+++ b/UprightKit/Tests/UprightKitTests/EndToEndRoutingTests.swift
@@ -0,0 +1,940 @@
+import Foundation
+import XCTest
+@testable import UprightKit
+
+/// Several mice on several interfaces, end to end (§G-2): simulated devices, real `HIDPPLink`s,
+/// the registry, the planner, the mapping library and the engine, wired as the app wires them
+/// (`FleetHarness`). Nothing done on one mouse may leak to another.
+@MainActor
+final class EndToEndRoutingTests: XCTestCase {
+ private static let editor = EndToEndRoutingTestsFixtures.editor
+ private static let other = EndToEndRoutingTestsFixtures.other
+ private static let master3S = ModelKey(productID: 0xB034)
+ private static let master3 = ModelKey(productID: 0xB023)
+ private static let lift = ModelKey(productID: 0xB031)
+
+ /// One key code per mapping, so the performer's log says which mapping fired.
+ private enum Key {
+ static let verticalBack: UInt16 = 0x00
+ static let verticalTap: UInt16 = 0x01
+ static let verticalUp: UInt16 = 0x02
+ static let verticalDown: UInt16 = 0x03
+ static let verticalLeft: UInt16 = 0x04
+ static let verticalRight: UInt16 = 0x05
+ static let masterBack: UInt16 = 0x10
+ static let masterEditorBack: UInt16 = 0x11
+ static let masterTap: UInt16 = 0x12
+ static let masterUp: UInt16 = 0x13
+ static let masterDown: UInt16 = 0x14
+ static let masterLeft: UInt16 = 0x15
+ static let masterRight: UInt16 = 0x16
+ static let master3Back: UInt16 = 0x20
+ static let liftBack: UInt16 = 0x30
+ }
+
+ private static func combo(_ code: UInt16) -> KeyCombo {
+ KeyCombo(keyCode: code, modifiers: .command)
+ }
+
+ private static func action(_ code: UInt16) -> ButtonAction {
+ .keyCombo(combo(code))
+ }
+
+ private static func effect(_ code: UInt16) -> ActionEffect {
+ .keyCombo(combo(code))
+ }
+
+ /// Every model's setup for these tests. The same CID does something different on each model,
+ /// and the editor app changes some of it.
+ private static func library() -> MappingLibrary {
+ var library = MappingLibrary()
+ library[.mxVertical] = MappingProfiles(
+ global: ButtonMappings([
+ KnownControl.back: action(Key.verticalBack),
+ KnownControl.dpiSwitch: .gestures(GestureSet(
+ tap: action(Key.verticalTap), up: action(Key.verticalUp), down: action(Key.verticalDown),
+ left: action(Key.verticalLeft), right: action(Key.verticalRight)
+ )),
+ KnownControl.middleClick: .device(.cycleDPI),
+ ]),
+ apps: [AppProfile(bundleID: editor, displayName: "Editor", overrides: [KnownControl.back: .deviceDefault])]
+ )
+ library[master3S] = MappingProfiles(
+ global: ButtonMappings([
+ KnownControl.back: action(Key.masterBack),
+ KnownControl.gestureButton: .gestures(GestureSet(
+ tap: action(Key.masterTap), up: action(Key.masterUp), down: action(Key.masterDown),
+ left: action(Key.masterLeft), right: action(Key.masterRight)
+ )),
+ KnownControl.smartShift: .device(.cycleDPI),
+ ]),
+ apps: [AppProfile(bundleID: editor, displayName: "Editor", overrides: [
+ KnownControl.back: action(Key.masterEditorBack),
+ KnownControl.gestureButton: .deviceDefault,
+ ])]
+ )
+ library[master3] = MappingProfiles(global: ButtonMappings([
+ KnownControl.back: action(Key.master3Back),
+ KnownControl.forward: .system(.volumeUp),
+ ]))
+ library[lift] = MappingProfiles(
+ global: ButtonMappings([
+ KnownControl.back: action(Key.liftBack),
+ KnownControl.forward: .disabled,
+ KnownControl.middleClick: .system(.middleClick),
+ ]),
+ apps: [AppProfile(bundleID: other, displayName: "Other", overrides: [KnownControl.back: .deviceDefault])]
+ )
+ return library
+ }
+
+ /// `library()` with more controls mapped (every kind of effect, gestures that fire device
+ /// actions and effects without a held state), so random presses reach the engine more often.
+ private static func fuzzLibrary() -> MappingLibrary {
+ var library = library()
+ library[.mxVertical].global[KnownControl.forward] = .system(.mouseForward)
+ library[master3S].global[KnownControl.middleClick] = action(0x40)
+ library[master3S].global[KnownControl.forward] = .system(.volumeDown)
+ library[master3].global[KnownControl.middleClick] = .gestures(GestureSet(
+ tap: action(0x41), up: action(0x42), down: .system(.mute), left: .device(.cycleDPI), right: .system(.missionControl)
+ ))
+ library[master3].global[KnownControl.gestureButton] = action(0x43)
+ library[master3].global[KnownControl.smartShift] = .device(.setDPI(1600))
+ library[lift].global[KnownControl.dpiSwitch] = .gestures(GestureSet(tap: action(0x44), up: action(0x45), left: action(0x46)))
+ return library
+ }
+
+ private func makeFleet(library: MappingLibrary? = nil, tuning: ControllerTuning = .fastTests) -> FleetHarness {
+ let fleet = FleetHarness(library: library ?? Self.library(), tuning: tuning)
+ fleet.dpiPresets = [800, 1600, 2400]
+ return fleet
+ }
+
+ private static let direct = DeviceAddress.direct
+
+ // MARK: Routing
+
+ func testTwoHubsUSBVerticalAndReceiverMasterNoCrossTalk() async throws {
+ let fleet = makeFleet()
+ let vertical = SimulatedDirect(profile: DeviceProfiles.mxVertical, connection: .usb)
+ let bolt = SimulatedReceiver.bolt([1: DeviceProfiles.mxMaster3S])
+ let a = fleet.attach(vertical).address(of: Self.direct)
+ let b = fleet.attach(bolt).address(of: 1)
+ await eventually("both connected") { fleet.isConnected(a) && fleet.isConnected(b) }
+ await fleet.waitUntilIdle()
+
+ // Two links, two active records, each mouse diverted for its own model only.
+ XCTAssertNotEqual(a.link, b.link)
+ XCTAssertEqual(fleet.registry.records.map(\.address), [a, b])
+ XCTAssertEqual(fleet.registry.records.map(\.model), [.mxVertical, Self.master3S])
+ XCTAssertEqual(fleet.registry.records.map(\.role), [.active, .active])
+ XCTAssertEqual(vertical.diverted, [KnownControl.back, KnownControl.dpiSwitch, KnownControl.middleClick])
+ XCTAssertEqual(vertical.rawXY, [KnownControl.dpiSwitch])
+ XCTAssertEqual(bolt.diverted(slot: 1), [KnownControl.back, KnownControl.gestureButton, KnownControl.smartShift])
+ XCTAssertEqual(bolt.rawXY(slot: 1), [KnownControl.gestureButton])
+ XCTAssertEqual(vertical.requestCount(feature: 0x1B04, function: 3),
+ DeviceProfiles.mxVertical.controls.filter(\.isConfigurable).count,
+ "the vertical was only told about its own controls")
+
+ // The same CID does what each mouse's model says.
+ bolt.hold([KnownControl.back], slot: 1)
+ bolt.release(slot: 1)
+ await eventually("B's back") { fleet.performer.count(Self.effect(Key.masterBack), .up) == 1 }
+ XCTAssertEqual(fleet.performer.count(Self.effect(Key.verticalBack), .down), 0)
+ XCTAssertEqual(fleet.focusedDevice, b)
+
+ // Both gesture buttons held: raw movement only decides the gesture of the mouse it came from.
+ fleet.performer.removeAll()
+ vertical.hold([KnownControl.dpiSwitch])
+ bolt.hold([KnownControl.gestureButton], slot: 1)
+ await eventually("both gestures held") {
+ fleet.engine.gestureControls(for: a) == [KnownControl.dpiSwitch]
+ && fleet.engine.gestureControls(for: b) == [KnownControl.gestureButton]
+ }
+ vertical.move(dx: 400, dy: 0)
+ await eventually("A swiped right") { fleet.performer.count(Self.effect(Key.verticalRight), .up) == 1 }
+ await fleet.settle()
+ XCTAssertEqual(fleet.performer.log.count, 2, "nothing fired on B")
+ // 0x1B04 v5: B's first raw report after the press is dropped (D16).
+ bolt.move(dx: 0, dy: -400, slot: 1)
+ bolt.move(dx: 0, dy: -400, slot: 1)
+ await eventually("B swiped up") { fleet.performer.count(Self.effect(Key.masterUp), .up) == 1 }
+ vertical.release()
+ bolt.release(slot: 1)
+ await eventually("both released") { fleet.engine.pressedKeys.isEmpty }
+ await fleet.settle()
+
+ XCTAssertEqual(fleet.performer.log.map(\.effect), [
+ Self.effect(Key.verticalRight), Self.effect(Key.verticalRight),
+ Self.effect(Key.masterUp), Self.effect(Key.masterUp),
+ ], "one direction each, no taps")
+ for event in fleet.events {
+ switch event.cid {
+ case KnownControl.dpiSwitch: XCTAssertEqual(event.source, a)
+ case KnownControl.gestureButton, KnownControl.back: XCTAssertEqual(event.source, b)
+ default: XCTFail("unexpected event \(event)")
+ }
+ }
+ XCTAssertTrue(fleet.droppedPresses.isEmpty)
+ XCTAssertTrue(fleet.performer.stuck.isEmpty)
+ }
+
+ func testBothHoldBackIndependentKeyEffects() async {
+ let fleet = makeFleet()
+ let vertical = SimulatedDirect(profile: DeviceProfiles.mxVertical, connection: .usb)
+ let unifying = SimulatedReceiver.unifying([1: DeviceProfiles.mxVerticalSecondUnit, 2: DeviceProfiles.mxMaster3])
+ let a = fleet.attach(vertical).address(of: Self.direct)
+ let receiver = fleet.attach(unifying)
+ let a2 = receiver.address(of: 1)
+ let c = receiver.address(of: 2)
+ await eventually("all connected") { fleet.isConnected(a) && fleet.isConnected(a2) && fleet.isConnected(c) }
+ await fleet.waitUntilIdle()
+ let verticalBack = Self.effect(Key.verticalBack)
+ let master3Back = Self.effect(Key.master3Back)
+
+ // Three mice hold Back at once; two of them are the same model with the same effect.
+ vertical.hold([KnownControl.back])
+ unifying.hold([KnownControl.back], slot: 1)
+ unifying.hold([KnownControl.back], slot: 2)
+ await eventually("three holds") { fleet.engine.held.count == 3 }
+ XCTAssertEqual(Set(fleet.engine.held.keys), [
+ ControlKey(source: a, cid: KnownControl.back),
+ ControlKey(source: a2, cid: KnownControl.back),
+ ControlKey(source: c, cid: KnownControl.back),
+ ])
+ XCTAssertEqual(fleet.performer.outstanding, [verticalBack: 2, master3Back: 1])
+
+ // Releasing the second vertical ends only its own hold.
+ unifying.release(slot: 1)
+ await eventually("A2 released") { fleet.engine.held.count == 2 }
+ XCTAssertEqual(Set(fleet.engine.held.keys), [
+ ControlKey(source: a, cid: KnownControl.back), ControlKey(source: c, cid: KnownControl.back),
+ ])
+ XCTAssertEqual(fleet.performer.outstanding, [verticalBack: 1, master3Back: 1])
+
+ unifying.release(slot: 2)
+ await eventually("C released") { fleet.engine.held.count == 1 }
+ XCTAssertEqual(fleet.performer.outstanding, [verticalBack: 1])
+ XCTAssertEqual(fleet.engine.pressedControls(for: a), [KnownControl.back])
+
+ vertical.release()
+ await eventually("A released") { fleet.engine.held.isEmpty }
+ await fleet.settle()
+ XCTAssertEqual(fleet.performer.log.map(\.phase), [.down, .down, .down, .up, .up, .up])
+ XCTAssertEqual(fleet.performer.count(verticalBack, .down), 2)
+ XCTAssertEqual(fleet.performer.count(verticalBack, .up), 2)
+ XCTAssertTrue(fleet.performer.stuck.isEmpty)
+ XCTAssertTrue(fleet.performer.violations.isEmpty, "\(fleet.performer.violations)")
+ }
+
+ func testDetachingADoesNotReleaseB() async {
+ let fleet = makeFleet()
+ let vertical = SimulatedDirect(profile: DeviceProfiles.mxVertical, connection: .usb)
+ let bolt = SimulatedReceiver.bolt([1: DeviceProfiles.mxMaster3S])
+ let linkA = fleet.attach(vertical).link
+ let b = fleet.attach(bolt).address(of: 1)
+ let a = fleet.address(link: linkA, index: Self.direct)
+ await eventually("both connected") { fleet.isConnected(a) && fleet.isConnected(b) }
+ await fleet.waitUntilIdle()
+
+ vertical.hold([KnownControl.back])
+ bolt.hold([KnownControl.back, KnownControl.gestureButton], slot: 1)
+ await eventually("all held") { fleet.engine.held.count == 2 && fleet.engine.gestureControls(for: b) == [KnownControl.gestureButton] }
+ let boltWrites = bolt.setReportingCount(slot: 1)
+ let boltDiverted = bolt.diverted(slot: 1)
+
+ // A's cable is pulled mid-hold.
+ await fleet.detach(linkA)
+ await fleet.settle()
+ XCTAssertEqual(fleet.removals.map { $0.address }, [a])
+ XCTAssertEqual(fleet.removals.map { $0.reason }, [.transportClosed])
+ XCTAssertEqual(fleet.performer.count(Self.effect(Key.verticalBack), .up), 1, "A's effect ended")
+ XCTAssertEqual(Set(fleet.engine.held.keys), [ControlKey(source: b, cid: KnownControl.back)], "B's hold is untouched")
+ XCTAssertEqual(fleet.engine.gestureControls(for: b), [KnownControl.gestureButton])
+ XCTAssertEqual(fleet.performer.count(Self.effect(Key.masterBack), .up), 0)
+ XCTAssertEqual(fleet.registry.records.map(\.address), [b])
+ XCTAssertTrue(fleet.isConnected(b))
+ XCTAssertEqual(bolt.setReportingCount(slot: 1), boltWrites, "B wasn't reconfigured")
+ XCTAssertEqual(bolt.diverted(slot: 1), boltDiverted)
+
+ // B still ends its own hold and gesture normally.
+ bolt.move(dx: -400, dy: 0, slot: 1) // dropped: first raw report after the press (D16)
+ bolt.move(dx: -400, dy: 0, slot: 1)
+ bolt.release(slot: 1)
+ await eventually("B released") { fleet.engine.pressedKeys.isEmpty }
+ await fleet.settle()
+ XCTAssertEqual(fleet.performer.count(Self.effect(Key.masterBack), .up), 1)
+ XCTAssertEqual(fleet.performer.count(Self.effect(Key.masterLeft), .up), 1)
+ XCTAssertTrue(fleet.performer.stuck.isEmpty)
+
+ // Plugged back in: a new address, but the unit keeps its place in the list.
+ let replugged = SimulatedDirect(profile: DeviceProfiles.mxVertical, connection: .usb)
+ let a2 = fleet.attach(replugged).address(of: Self.direct)
+ await eventually("A back") { fleet.isConnected(a2) }
+ await fleet.waitUntilIdle()
+ XCTAssertNotEqual(a2, a)
+ XCTAssertEqual(fleet.registry.records.map(\.address), [a2, b])
+ XCTAssertEqual(replugged.diverted, [KnownControl.back, KnownControl.dpiSwitch, KnownControl.middleClick])
+ }
+
+ // MARK: Pointer speed
+
+ func testCycleDPIOnBChangesOnlyB() async throws {
+ let fleet = makeFleet()
+ let vertical = SimulatedDirect(profile: DeviceProfiles.mxVertical, connection: .usb)
+ let bolt = SimulatedReceiver.bolt([1: DeviceProfiles.mxMaster3S])
+ let a = fleet.attach(vertical).address(of: Self.direct)
+ let b = fleet.attach(bolt).address(of: 1)
+ await eventually("both connected") { fleet.isConnected(a) && fleet.isConnected(b) }
+ await fleet.waitUntilIdle()
+ XCTAssertEqual(vertical.dpi, 1000)
+ XCTAssertEqual(bolt.dpi(slot: 1), 1000)
+ let idA = try XCTUnwrap(fleet.deviceID(of: a))
+ let idB = try XCTUnwrap(fleet.deviceID(of: b))
+
+ // B's SmartShift button cycles B's speed; A has a Cycle mapping too, and isn't touched.
+ bolt.hold([KnownControl.smartShift], slot: 1)
+ bolt.release(slot: 1)
+ await eventually("B at 1600") { bolt.dpi(slot: 1) == 1600 }
+ bolt.hold([KnownControl.smartShift], slot: 1)
+ bolt.release(slot: 1)
+ await eventually("B at 2400") { bolt.dpi(slot: 1) == 2400 }
+ await fleet.waitUntilIdle()
+ await fleet.settle()
+
+ XCTAssertEqual(vertical.dpi, 1000)
+ XCTAssertEqual(vertical.requestCount(feature: 0x2201, function: 3), 0, "no speed was written to A")
+ XCTAssertNil(fleet.endpoint(a)?.desiredDPI)
+ XCTAssertEqual(fleet.deviceActions.map { $0.source }, [b, b])
+ XCTAssertEqual(fleet.deviceDPIChanges.map { $0.address }, [b, b])
+ XCTAssertEqual(fleet.deviceDPIChanges.map { $0.dpi }, [1600, 2400])
+ XCTAssertEqual(fleet.preferences.devices[idB]?.preferredDPI, 2400, "kept for B's unit")
+ XCTAssertNil(fleet.preferences.devices[idA])
+ XCTAssertNil(fleet.preferences.models[Self.master3S], "not for the whole model")
+
+ // And A's own button cycles only A.
+ vertical.hold([KnownControl.middleClick])
+ vertical.release()
+ await eventually("A at 1600") { vertical.dpi == 1600 }
+ await fleet.waitUntilIdle()
+ XCTAssertEqual(bolt.dpi(slot: 1), 2400)
+ XCTAssertEqual(fleet.deviceActions.map { $0.source }, [b, b, a])
+ XCTAssertEqual(fleet.preferences.devices[idA]?.preferredDPI, 1600)
+ XCTAssertEqual(fleet.preferences.devices[idB]?.preferredDPI, 2400)
+ }
+
+ func testNativeDPIChangeOnANotPropagated() async throws {
+ let fleet = makeFleet()
+ // The MX Vertical seed is what Upright 2.x's single preferred speed migrates into.
+ fleet.preferences.models[.mxVertical] = DevicePreferences(preferredDPI: 1600)
+ fleet.preferences.models[Self.master3] = DevicePreferences(preferredDPI: 1200)
+ let vertical = SimulatedDirect(profile: DeviceProfiles.mxVertical, connection: .usb)
+ let unifying = SimulatedReceiver.unifying([1: DeviceProfiles.mxVerticalSecondUnit, 2: DeviceProfiles.mxMaster3])
+ let a = fleet.attach(vertical).address(of: Self.direct)
+ let receiver = fleet.attach(unifying)
+ let a2 = receiver.address(of: 1)
+ let c = receiver.address(of: 2)
+ await eventually("all connected") { fleet.isConnected(a) && fleet.isConnected(a2) && fleet.isConnected(c) }
+ await eventually("seed speeds applied") {
+ vertical.dpi == 1600 && unifying.dpi(slot: 1) == 1600 && unifying.dpi(slot: 2) == 1200
+ }
+ await fleet.waitUntilIdle()
+ XCTAssertTrue(fleet.deviceDPIChanges.isEmpty)
+ let idA = try XCTUnwrap(fleet.deviceID(of: a))
+ let idA2 = try XCTUnwrap(fleet.deviceID(of: a2))
+ let writesA2 = unifying.requestCount(feature: 0x2201, function: 3, slot: 1)
+ let writesC = unifying.requestCount(feature: 0x2201, function: 3, slot: 2)
+
+ // The DPI button on A changes A's speed natively; the app notices on its next look.
+ vertical.setDPINatively(2400)
+ fleet.refreshDeviceInfo()
+ await eventually("A's change adopted") { fleet.deviceDPIChanges.count == 1 }
+ await fleet.waitUntilIdle()
+ await fleet.settle()
+
+ XCTAssertEqual(fleet.deviceDPIChanges.map { $0.address }, [a])
+ XCTAssertEqual(fleet.deviceDPIChanges.map { $0.dpi }, [2400])
+ XCTAssertEqual(vertical.dpi, 2400)
+ XCTAssertEqual(unifying.dpi(slot: 1), 1600, "the other MX Vertical keeps its speed")
+ XCTAssertEqual(unifying.dpi(slot: 2), 1200)
+ XCTAssertEqual(unifying.requestCount(feature: 0x2201, function: 3, slot: 1), writesA2)
+ XCTAssertEqual(unifying.requestCount(feature: 0x2201, function: 3, slot: 2), writesC)
+ XCTAssertEqual(fleet.preferences.devices[idA]?.preferredDPI, 2400)
+ XCTAssertNil(fleet.preferences.devices[idA2])
+ XCTAssertEqual(fleet.preferences.models[.mxVertical]?.preferredDPI, 1600, "the model seed is untouched")
+ XCTAssertEqual(fleet.preferences.preferences(for: idA2, model: .mxVertical).preferredDPI, 1600)
+
+ // A resync for an unrelated reason doesn't put any mouse back to an old speed.
+ let writesA = vertical.requestCount(feature: 0x2201, function: 3)
+ fleet.frontmostBundleID = Self.editor
+ await fleet.waitUntilIdle()
+ await fleet.settle()
+ XCTAssertEqual(vertical.dpi, 2400)
+ XCTAssertEqual(vertical.requestCount(feature: 0x2201, function: 3), writesA)
+ XCTAssertEqual(unifying.requestCount(feature: 0x2201, function: 3, slot: 1), writesA2)
+ XCTAssertEqual(unifying.requestCount(feature: 0x2201, function: 3, slot: 2), writesC)
+ }
+
+ // MARK: Profiles
+
+ func testAppSwitchMidHoldOnAKeepsADivertedAndBFollowsNewApp() async {
+ let fleet = makeFleet()
+ let vertical = SimulatedDirect(profile: DeviceProfiles.mxVertical, connection: .usb)
+ let bolt = SimulatedReceiver.bolt([1: DeviceProfiles.mxMaster3S])
+ let a = fleet.attach(vertical).address(of: Self.direct)
+ let b = fleet.attach(bolt).address(of: 1)
+ await eventually("both connected") { fleet.isConnected(a) && fleet.isConnected(b) }
+ await fleet.waitUntilIdle()
+ XCTAssertTrue(vertical.diverted.contains(KnownControl.back))
+ XCTAssertEqual(bolt.diverted(slot: 1), [KnownControl.back, KnownControl.gestureButton, KnownControl.smartShift])
+
+ vertical.hold([KnownControl.back])
+ await eventually("A pressed") { fleet.performer.count(Self.effect(Key.verticalBack), .down) == 1 }
+
+ // The editor leaves Back native on the MX Vertical and gives the Master's Back another
+ // shortcut and its gesture button back to the mouse.
+ fleet.frontmostBundleID = Self.editor
+ await eventually("B follows the editor") {
+ bolt.diverted(slot: 1) == [KnownControl.back, KnownControl.smartShift] && bolt.rawXY(slot: 1).isEmpty
+ }
+ await fleet.waitUntilIdle()
+ await fleet.settle()
+ XCTAssertTrue(vertical.diverted.contains(KnownControl.back), "A keeps Back diverted while it is held")
+ XCTAssertEqual(fleet.appliedConfig[a]?.divert, [KnownControl.back, KnownControl.dpiSwitch, KnownControl.middleClick])
+
+ // B uses the editor's mapping right away.
+ bolt.hold([KnownControl.back], slot: 1)
+ bolt.release(slot: 1)
+ await eventually("B's editor back") { fleet.performer.count(Self.effect(Key.masterEditorBack), .up) == 1 }
+
+ // A's release ends the effect its press started, then Back goes back to the mouse.
+ vertical.release()
+ await eventually("A's back handed back") { !vertical.diverted.contains(KnownControl.back) }
+ await fleet.waitUntilIdle()
+ await fleet.settle()
+ XCTAssertEqual(fleet.performer.log, [
+ .init(effect: Self.effect(Key.verticalBack), phase: .down),
+ .init(effect: Self.effect(Key.masterEditorBack), phase: .down),
+ .init(effect: Self.effect(Key.masterEditorBack), phase: .up),
+ .init(effect: Self.effect(Key.verticalBack), phase: .up),
+ ])
+ XCTAssertEqual(vertical.diverted, [KnownControl.dpiSwitch, KnownControl.middleClick])
+
+ // Pressing Back on A now is native: nothing reaches the engine.
+ vertical.hold([KnownControl.back])
+ vertical.release()
+ await fleet.settle()
+ XCTAssertEqual(fleet.performer.log.count, 4)
+
+ // Leaving the editor diverts both Backs again.
+ fleet.frontmostBundleID = nil
+ await eventually("global again") {
+ vertical.diverted.contains(KnownControl.back) && bolt.diverted(slot: 1).contains(KnownControl.gestureButton)
+ }
+ XCTAssertTrue(fleet.performer.stuck.isEmpty)
+ }
+
+ // MARK: Fuzz
+
+ /// 1000 seeded random steps over a fleet of three interfaces plus a Bluetooth twin of one of
+ /// the mice: presses, releases and movement, sleep and wake, unpair, pair and re-pair,
+ /// unplugging and re-plugging, app switches, speed changes on the mice, and the "Remap this
+ /// mouse" switch. After every settled step no held effect may belong to an endpoint that is
+ /// gone; at the end, with every button let go, nothing may be held and every down must have
+ /// had its up. Every connected, awake mouse must also be configured as the planner says.
+ ///
+ /// The step plan depends only on the seed: every choice is made from the simulated hardware's
+ /// state, never from what the app has observed, so a failure replays with its seed (thread
+ /// timing can still shift details). `UPRIGHT_FUZZ_SEED` and `UPRIGHT_FUZZ_STEPS` replay or
+ /// extend a run; `UPRIGHT_FUZZ_TRACE` prints every step.
+ func testSeededLinkLossFuzzNeverLeavesStuckEffects() async {
+ let environment = ProcessInfo.processInfo.environment
+ let seed = environment["UPRIGHT_FUZZ_SEED"].flatMap { UInt64($0) } ?? 0x05EE_D0FF_1EE7
+ let steps = environment["UPRIGHT_FUZZ_STEPS"].flatMap { Int($0) } ?? 1000
+
+ var tuning = ControllerTuning.fastTests
+ tuning.probeTimeout = 0.05
+ tuning.retryBaseDelay = 0.02
+ tuning.retryMaxDelay = 0.1
+ tuning.announcementWait = 0.02
+ tuning.batteryRefreshInterval = 0
+ let fleet = makeFleet(library: Self.fuzzLibrary(), tuning: tuning)
+ var fuzz = FleetFuzzer(fleet: fleet, seed: seed)
+ let began = Date()
+
+ fuzz.plugEverything()
+ // Every mouse (the keyboard isn't one) on its own link, the Bluetooth twin included.
+ await eventually("the fleet connected", timeout: 5) { fleet.registry.records.filter(\.isConnected).count == 7 }
+ await fleet.waitUntilIdle()
+ fuzz.rememberUnits()
+
+ for step in 0.. UInt64 {
+ state &+= 0x9E37_79B9_7F4A_7C15
+ var z = state
+ z = (z ^ (z >> 30)) &* 0xBF58_476D_1CE4_E5B9
+ z = (z ^ (z >> 27)) &* 0x94D0_49BB_1331_11EB
+ return z ^ (z >> 31)
+ }
+
+ /// 0.. Int {
+ precondition(bound > 0)
+ return Int(next() % UInt64(bound))
+ }
+
+ mutating func chance(_ percent: Int) -> Bool {
+ below(100) < percent
+ }
+
+ mutating func pick(_ items: [T]) -> T? {
+ items.isEmpty ? nil : items[below(items.count)]
+ }
+
+ mutating func range(_ range: ClosedRange) -> Int {
+ range.lowerBound + below(range.count)
+ }
+}
+
+/// One interface of the fuzzed fleet, across unplugging and plugging back in. Holds the
+/// physical truth: which devices are paired where and which buttons a hand is holding.
+@MainActor
+private final class FuzzPort {
+ enum Kind {
+ case direct(ConnectionKind)
+ case receiver(PairingLayout)
+ }
+
+ let name: String
+ let kind: Kind
+ /// Slot → device (0xFF on a direct link). Survives unplugging, like a receiver's pairings.
+ var pairings: [UInt8: SimulatedDeviceProfile]
+ var simulator: SimulatedHIDPPTransport?
+ var link: UInt32?
+ /// Buttons a hand holds right now, per slot, in press order.
+ var held: [UInt8: [UInt16]] = [:]
+
+ init(name: String, kind: Kind, pairings: [UInt8: SimulatedDeviceProfile]) {
+ self.name = name
+ self.kind = kind
+ self.pairings = pairings
+ }
+
+ var isDirect: Bool {
+ if case .direct = kind { return true }
+ return false
+ }
+
+ var receiver: SimulatedReceiver? { simulator as? SimulatedReceiver }
+
+ var occupiedSlots: [UInt8] { pairings.keys.sorted() }
+
+ var freeSlots: [UInt8] {
+ isDirect ? [] : ReceiverDirectory.slots.filter { pairings[$0] == nil }
+ }
+
+ /// A fresh interface with the current pairings (devices come back awake, nothing diverted).
+ func makeSimulator() -> SimulatedHIDPPTransport {
+ switch kind {
+ case .direct(let connection):
+ return SimulatedDirect(profile: pairings[DeviceAddress.direct]!, connection: connection)
+ case .receiver(.bolt):
+ return SimulatedReceiver.bolt(pairings)
+ case .receiver(.unifying):
+ return SimulatedReceiver.unifying(pairings)
+ }
+ }
+}
+
+/// Drives a `FleetHarness` with seeded random steps and checks its invariants.
+@MainActor
+private struct FleetFuzzer {
+ struct Device {
+ var name: String
+ var simulator: SimulatedHIDPPTransport
+ var slot: UInt8
+ var address: DeviceAddress
+ }
+
+ let fleet: FleetHarness
+ let seed: UInt64
+ private var rng: SplitMix64
+ private let ports: [FuzzPort]
+ /// Devices un-paired from a receiver, waiting to be paired back to it.
+ private var unpaired: [(port: Int, profile: SimulatedDeviceProfile)] = []
+ /// Every unit with a stable ID, fixed at the start, so choosing one never depends on what
+ /// happens to be connected (thread timing) and the plan only depends on the seed.
+ private var units: [(id: DeviceID, model: ModelKey)] = []
+ private var history: [String] = []
+ private var stepNumber = 0
+
+ init(fleet: FleetHarness, seed: UInt64) {
+ self.fleet = fleet
+ self.seed = seed
+ rng = SplitMix64(seed: seed)
+ ports = [
+ FuzzPort(name: "USB", kind: .direct(.usb), pairings: [DeviceAddress.direct: DeviceProfiles.mxVertical]),
+ FuzzPort(name: "Bolt", kind: .receiver(.bolt), pairings: [1: DeviceProfiles.mxMaster3S, 2: DeviceProfiles.lift]),
+ FuzzPort(name: "Unifying", kind: .receiver(.unifying), pairings: [
+ 1: DeviceProfiles.mxVerticalSecondUnit, 3: DeviceProfiles.mxMaster3,
+ 4: DeviceProfiles.mxKeys, 5: DeviceProfiles.mouseWithoutUnitID,
+ ]),
+ // The Bolt slot 1 mouse again, over Bluetooth: same unit, so one of the two is standby.
+ FuzzPort(name: "BT", kind: .direct(.bluetooth), pairings: [DeviceAddress.direct: DeviceProfiles.mxMaster3S]),
+ ]
+ }
+
+ var fullHistory: String {
+ history.joined(separator: "\n")
+ }
+
+ var recentHistory: String {
+ "last steps:\n" + history.suffix(30).joined(separator: "\n")
+ }
+
+ mutating func plugEverything() {
+ for index in ports.indices { plug(index) }
+ }
+
+ mutating func rememberUnits() {
+ var seen: Set = []
+ units = fleet.registry.records
+ .compactMap { record -> (id: DeviceID, model: ModelKey)? in
+ guard let id = record.deviceID, id.isStable, let model = record.model, seen.insert(id).inserted else { return nil }
+ return (id, model)
+ }
+ .sorted { $0.id < $1.id }
+ }
+
+ // MARK: Steps
+
+ private enum Kind: CaseIterable {
+ case press, release, move, sleep, wake, unpair, pair, repair, unplug, plug, switchApp, nativeDPI, refreshInfo, macWake, toggleRemap
+ }
+
+ /// Takes one random step. Returns whether to let the fleet settle before the next one (most
+ /// steps; some follow each other immediately, so link loss lands mid-operation).
+ mutating func step() -> Bool {
+ stepNumber += 1
+ let weights: [(Kind, Int)] = [
+ (.press, 31), (.release, 22), (.move, 8), (.sleep, 5), (.wake, 7), (.unpair, 2), (.pair, 3), (.repair, 2),
+ (.unplug, 2), (.plug, 4), (.switchApp, 5), (.nativeDPI, 3), (.refreshInfo, 2), (.macWake, 1), (.toggleRemap, 1),
+ ]
+ var possible = weights.filter { isPossible($0.0) }
+ if possible.isEmpty { possible = [(.switchApp, 1)] }
+ var roll = rng.below(possible.reduce(0) { $0 + $1.1 })
+ var chosen = possible[0].0
+ for (kind, weight) in possible {
+ if roll < weight {
+ chosen = kind
+ break
+ }
+ roll -= weight
+ }
+ let description = perform(chosen)
+ let settles = rng.chance(85)
+ history.append("\(stepNumber): \(description)\(settles ? "" : " (no settle)")")
+ return settles
+ }
+
+ private func isPossible(_ kind: Kind) -> Bool {
+ switch kind {
+ case .press, .sleep, .nativeDPI: return !awakeSlots().isEmpty
+ case .release, .move: return !holdingSlots().isEmpty
+ case .wake: return !asleepSlots().isEmpty
+ case .unpair, .repair: return !receiverMice().isEmpty
+ case .pair: return unpaired.contains { ports[$0.port].simulator != nil && !ports[$0.port].freeSlots.isEmpty }
+ case .unplug: return ports.contains { $0.simulator != nil }
+ case .plug: return ports.contains { $0.simulator == nil }
+ case .toggleRemap: return !units.isEmpty
+ case .switchApp, .refreshInfo, .macWake: return true
+ }
+ }
+
+ private mutating func perform(_ kind: Kind) -> String {
+ switch kind {
+ case .press:
+ let (port, slot) = rng.pick(awakeSlots())!
+ let profile = ports[port].pairings[slot]!
+ let candidates = profile.controls.filter(\.isConfigurable).map(\.cid)
+ .filter { !(ports[port].held[slot] ?? []).contains($0) }
+ guard let cid = rng.pick(candidates), (ports[port].held[slot] ?? []).count < 3 else {
+ return "press on \(label(port, slot)): nothing more to press"
+ }
+ ports[port].held[slot, default: []].append(cid)
+ let simulator = ports[port].simulator!
+ simulator.hold(ports[port].held[slot]!, slot: slot)
+ let reported = simulator.diverted(slot: slot).contains(cid) ? "diverted" : "native"
+ return "press \(hex(cid)) on \(label(port, slot)) (\(reported); held \(describe(ports[port].held[slot]!)))"
+ case .release:
+ let (port, slot) = rng.pick(holdingSlots())!
+ var held = ports[port].held[slot]!
+ if rng.chance(20) {
+ held.removeAll()
+ } else {
+ held.remove(at: rng.below(held.count))
+ }
+ ports[port].held[slot] = held.isEmpty ? nil : held
+ ports[port].simulator!.hold(held, slot: slot)
+ return "release on \(label(port, slot)) (held \(describe(held)))"
+ case .move:
+ let (port, slot) = rng.pick(holdingSlots())!
+ let dx = rng.range(-300...300)
+ let dy = rng.range(-300...300)
+ ports[port].simulator!.move(dx: dx, dy: dy, slot: slot)
+ return "move \(dx),\(dy) on \(label(port, slot))"
+ case .sleep:
+ let (port, slot) = rng.pick(awakeSlots())!
+ let simulator = ports[port].simulator!
+ if ports[port].isDirect, ports[port].held[slot] != nil {
+ // A directly connected mouse only sleeps after the hand has let go; nothing tells
+ // the Mac it went. (Losing the link mid-hold is unplugging.)
+ simulator.hold([], slot: slot)
+ }
+ ports[port].held[slot] = nil
+ simulator.goToSleep(slot: slot)
+ return "sleep \(label(port, slot))"
+ case .wake:
+ let (port, slot) = rng.pick(asleepSlots())!
+ let clearing = rng.chance(70)
+ let simulator = ports[port].simulator!
+ if ports[port].isDirect {
+ // Mice report a reconnect when they lost their configuration.
+ simulator.wakeUp(slot: slot, clearingDiversion: clearing, announce: false, reportReconnect: clearing || rng.chance(30))
+ } else {
+ simulator.wakeUp(slot: slot, clearingDiversion: clearing, announce: true, reportReconnect: rng.chance(30))
+ }
+ return "wake \(label(port, slot))\(clearing ? " (forgot its diversion)" : "")"
+ case .unpair:
+ let (port, slot) = rng.pick(receiverMice())!
+ let profile = ports[port].pairings.removeValue(forKey: slot)!
+ ports[port].held[slot] = nil
+ unpaired.append((port, profile))
+ ports[port].receiver!.unpair(slot: slot)
+ return "unpair \(profile.codename) from \(label(port, slot))"
+ case .pair:
+ let candidates = unpaired.indices.filter { ports[unpaired[$0].port].simulator != nil && !ports[unpaired[$0].port].freeSlots.isEmpty }
+ let index = rng.pick(candidates)!
+ let (port, profile) = unpaired.remove(at: index)
+ let slot = rng.pick(ports[port].freeSlots)!
+ ports[port].pairings[slot] = profile
+ ports[port].receiver!.pair(profile, slot: slot, announce: true)
+ return "pair \(profile.codename) to \(label(port, slot))"
+ case .repair:
+ let (port, from) = rng.pick(receiverMice())!
+ guard let to = rng.pick(ports[port].freeSlots) else { return "re-pair on \(ports[port].name): no free slot" }
+ let profile = ports[port].pairings.removeValue(forKey: from)!
+ ports[port].pairings[to] = profile
+ ports[port].held[from] = nil
+ let announce = rng.chance(50)
+ ports[port].receiver!.repair(from: from, to: to, announceUnpair: announce)
+ return "re-pair \(profile.codename) \(label(port, from)) → slot \(to)\(announce ? " (0x40 first)" : "")"
+ case .unplug:
+ let port = rng.pick(ports.indices.filter { ports[$0].simulator != nil })!
+ ports[port].simulator!.disconnect()
+ ports[port].simulator = nil
+ ports[port].link = nil
+ ports[port].held.removeAll()
+ return "unplug \(ports[port].name)"
+ case .plug:
+ let port = rng.pick(ports.indices.filter { ports[$0].simulator == nil })!
+ plug(port)
+ return "plug \(ports[port].name) in as link \(ports[port].link!)"
+ case .switchApp:
+ let app = rng.pick([nil, EndToEndRoutingTestsFixtures.editor, EndToEndRoutingTestsFixtures.other] as [String?])!
+ fleet.frontmostBundleID = app
+ return "frontmost app: \(app ?? "none")"
+ case .nativeDPI:
+ let (port, slot) = rng.pick(awakeSlots())!
+ let supported = ports[port].pairings[slot]!.supportedDPI
+ guard let dpi = rng.pick([800, 1200, 1600, 2400].filter(supported.contains)) else {
+ return "speed on \(label(port, slot)): no speed to set"
+ }
+ ports[port].simulator!.setDPINatively(dpi, slot: slot)
+ return "speed \(dpi) set on \(label(port, slot))"
+ case .refreshInfo:
+ fleet.refreshDeviceInfo()
+ return "refresh device info"
+ case .macWake:
+ fleet.refreshAll()
+ return "Mac woke"
+ case .toggleRemap:
+ let (id, model) = rng.pick(units)!
+ var prefs = fleet.preferences.preferences(for: id, model: model)
+ prefs.remapEnabled.toggle()
+ fleet.preferences.setPreferences(prefs, for: id)
+ return "remap \(id.rawValue): \(prefs.remapEnabled ? "on" : "off")"
+ }
+ }
+
+ private mutating func plug(_ port: Int) {
+ let simulator = ports[port].makeSimulator()
+ ports[port].simulator = simulator
+ ports[port].held.removeAll()
+ ports[port].link = fleet.attach(simulator).link
+ }
+
+ /// Everyone lets go of every button.
+ mutating func releaseEverything() {
+ for port in ports {
+ guard let simulator = port.simulator else { continue }
+ for slot in port.held.keys.sorted() where simulator.isAwake(slot: slot) {
+ simulator.hold([], slot: slot)
+ }
+ port.held.removeAll()
+ }
+ history.append("everyone lets go")
+ }
+
+ // MARK: Candidates (sorted, so the plan only depends on the seed)
+
+ private func awakeSlots() -> [(Int, UInt8)] {
+ ports.indices.flatMap { port -> [(Int, UInt8)] in
+ guard let simulator = ports[port].simulator else { return [] }
+ return ports[port].occupiedSlots.filter { simulator.isAwake(slot: $0) }.map { (port, $0) }
+ }
+ }
+
+ private func asleepSlots() -> [(Int, UInt8)] {
+ ports.indices.flatMap { port -> [(Int, UInt8)] in
+ guard let simulator = ports[port].simulator else { return [] }
+ return ports[port].occupiedSlots.filter { !simulator.isAwake(slot: $0) }.map { (port, $0) }
+ }
+ }
+
+ private func holdingSlots() -> [(Int, UInt8)] {
+ ports.indices.flatMap { port -> [(Int, UInt8)] in
+ guard let simulator = ports[port].simulator else { return [] }
+ return ports[port].held.keys.sorted().filter { simulator.isAwake(slot: $0) }.map { (port, $0) }
+ }
+ }
+
+ /// Pointing devices paired to a plugged-in receiver (the keyboard stays where it is).
+ private func receiverMice() -> [(Int, UInt8)] {
+ ports.indices.flatMap { port -> [(Int, UInt8)] in
+ guard ports[port].receiver != nil else { return [] }
+ return ports[port].occupiedSlots.filter { ports[port].pairings[$0]?.kind != .keyboard }.map { (port, $0) }
+ }
+ }
+
+ // MARK: Invariants
+
+ /// After the fleet settled: nothing went wrong in the performer, and no hold belongs to an
+ /// endpoint that is gone.
+ func problemAfterSettling() -> String? {
+ if let violation = fleet.performer.violations.first { return violation }
+ let keys = Set(fleet.engine.held.keys).union(fleet.engine.gestures.keys).union(fleet.engine.pressedKeys)
+ if let orphan = keys.sorted().first(where: { fleet.endpoint($0.source) == nil }) {
+ return "a hold outlived its endpoint: \(orphan)"
+ }
+ return nil
+ }
+
+ /// Every connected endpoint carries the configuration the planner gives it now, and every
+ /// awake device behind one is diverted exactly that way.
+ func configurationProblems() -> [String] {
+ var problems: [String] = []
+ for record in fleet.registry.records where record.isConnected {
+ guard let snapshot = fleet.snapshot(of: record.address) else { continue }
+ let expected = fleet.configuration(for: record.address, snapshot: snapshot, isActive: record.role == .active)
+ if fleet.appliedConfig[record.address] != expected {
+ problems.append("\(record.displayName) at \(record.address): applied \(String(describing: fleet.appliedConfig[record.address])), planner says \(expected)")
+ }
+ guard let device = device(at: record.address), device.simulator.isAwake(slot: device.slot) else { continue }
+ if device.simulator.diverted(slot: device.slot) != expected.divert || device.simulator.rawXY(slot: device.slot) != expected.rawXY {
+ problems.append("\(device.name) at \(record.address): device diverts \(describe(device.simulator.diverted(slot: device.slot))) raw \(describe(device.simulator.rawXY(slot: device.slot))), expected \(describe(expected.divert)) raw \(describe(expected.rawXY))")
+ }
+ }
+ return problems
+ }
+
+ /// Devices whose endpoint is connected and who are awake.
+ func connectedAwakeDevices() -> [Device] {
+ fleet.registry.records.filter(\.isConnected).compactMap { device(at: $0.address) }
+ .filter { $0.simulator.isAwake(slot: $0.slot) }
+ }
+
+ private func device(at address: DeviceAddress) -> Device? {
+ guard let port = ports.first(where: { $0.link == address.link }), let simulator = port.simulator,
+ let profile = port.pairings[address.index] else { return nil }
+ return Device(name: "\(port.name) \(profile.codename)", simulator: simulator, slot: address.index, address: address)
+ }
+
+ // MARK: Descriptions
+
+ private func label(_ port: Int, _ slot: UInt8) -> String {
+ let device = ports[port].pairings[slot]?.codename ?? "empty"
+ return ports[port].isDirect ? "\(ports[port].name) \(device)" : "\(ports[port].name) slot \(slot) \(device)"
+ }
+
+ private func describe(_ cids: [UInt16]) -> String {
+ "[" + cids.map { String($0, radix: 16, uppercase: true) }.joined(separator: " ") + "]"
+ }
+
+ private func hex(_ cid: UInt16) -> String {
+ "0x" + String(cid, radix: 16, uppercase: true)
+ }
+
+ private func describe(_ cids: Set) -> String {
+ describe(cids.sorted())
+ }
+}
+
+/// Bundle IDs shared by the tests and the fuzzer.
+private enum EndToEndRoutingTestsFixtures {
+ static let editor = "com.example.editor"
+ static let other = "com.example.other"
+}
diff --git a/UprightKit/Tests/UprightKitTests/FleetHarness.swift b/UprightKit/Tests/UprightKitTests/FleetHarness.swift
new file mode 100644
index 0000000..c45fb83
--- /dev/null
+++ b/UprightKit/Tests/UprightKitTests/FleetHarness.swift
@@ -0,0 +1,387 @@
+import Foundation
+import XCTest
+@testable import UprightKit
+
+/// The app's multi-mouse wiring (§C steps 2 and 5–11), built from the Kit's own parts: real
+/// `HIDPPLink`s on simulated transports, `DeviceRegistry`, `ConfigurationPlanner`, a
+/// `MappingLibrary`, `DevicePreferencesStore` and `RemapEngine`. It mirrors what DeviceManager
+/// and AppModel do, minus IOKit and AppKit, so end-to-end routing can be tested on Linux.
+///
+/// - Each attached interface gets the next link number (never reused).
+/// - The configuration of an endpoint is pulled when it identifies (before anything is diverted)
+/// and pushed again whenever something it depends on changes: the frontmost app, the mappings,
+/// preferences, a role change in the registry, or the release of one of its buttons.
+/// - Presses from endpoints that aren't active (standby twins, pending, unsupported) are dropped.
+/// Releases and raw movement always reach the engine, which only applies them to holds the
+/// same endpoint started while it was active; dropping them could leave an effect stuck down
+/// when an endpoint loses the active role mid-hold.
+/// - A removed endpoint's holds are released (`engine.releaseAll(source:)`) and its record goes.
+/// - Pointer-speed changes made on a mouse (or by Cycle Pointer Speed) are stored for that unit
+/// only; nothing is broadcast to other mice.
+@MainActor
+final class FleetHarness {
+ let registry = DeviceRegistry()
+ let performer: BalanceCheckingPerformer
+ let engine: RemapEngine
+ let tuning: ControllerTuning
+
+ var library: MappingLibrary {
+ didSet { if library != oldValue { resyncAll() } }
+ }
+ var frontmostBundleID: String? {
+ didSet { if frontmostBundleID != oldValue { resyncAll() } }
+ }
+ var preferences = DevicePreferencesStore() {
+ didSet { if preferences != oldValue { resyncAll() } }
+ }
+ var remappingEnabled = true {
+ didSet { if remappingEnabled != oldValue { resyncAll() } }
+ }
+ var accessibilityGranted = true {
+ didSet { if accessibilityGranted != oldValue { resyncAll() } }
+ }
+ /// The global presets Cycle Pointer Speed steps through.
+ var dpiPresets: [Int] = []
+
+ /// Open hubs by link number.
+ private(set) var links: [UInt32: HIDPPLink] = [:]
+ /// The simulator behind every hub ever attached, by link number (kept after it closes).
+ private(set) var simulators: [UInt32: SimulatedHIDPPTransport] = [:]
+ /// What was last handed to each endpoint, by address.
+ private(set) var appliedConfig: [DeviceAddress: EndpointConfiguration] = [:]
+ /// Every button event a hub delivered, in order, including dropped ones.
+ private(set) var events: [ButtonEvent] = []
+ /// Presses dropped because their endpoint wasn't active.
+ private(set) var droppedPresses: [ButtonEvent] = []
+ /// `HIDPPLink.onDeviceDPIChange` notifications, in order.
+ private(set) var deviceDPIChanges: [(address: DeviceAddress, dpi: Int?)] = []
+ private(set) var removals: [(address: DeviceAddress, reason: HIDPPLink.RemovalReason)] = []
+ /// Device actions the engine fired, with the device that fired them.
+ private(set) var deviceActions: [(action: DeviceAction, source: DeviceAddress)] = []
+ /// The last device that sent a press that was handled.
+ private(set) var focusedDevice: DeviceAddress?
+ /// Bumped by every hub callback; with the simulators' traffic it tells `settle()` when
+ /// things have stopped moving.
+ private(set) var activity = 0
+ /// Every hub log line, prefixed with its address (when `keepsLog`).
+ private(set) var log: [String] = []
+ var keepsLog = false
+
+ private var nextLink: UInt32 = 1
+
+ init(library: MappingLibrary = MappingLibrary(), tuning: ControllerTuning = .fastTests) {
+ let performer = BalanceCheckingPerformer()
+ self.performer = performer
+ self.library = library
+ self.tuning = tuning
+ engine = RemapEngine(mappings: ButtonMappings(), performer: performer)
+ engine.mappingsProvider = { [weak self] source in
+ guard let self else { return ButtonMappings() }
+ return self.library.effectiveMappings(model: self.model(of: source), bundleID: self.frontmostBundleID)
+ }
+ engine.rawThresholdProvider = { [weak self] source in
+ GestureRecognizer.rawThreshold(dpi: self?.snapshot(of: source)?.dpi?.current)
+ }
+ engine.gestureSourceFor = { [weak self] source, cid in
+ guard let control = self?.snapshot(of: source)?.controls.first(where: { $0.cid == cid }) else { return .rawSensor }
+ return control.supportsRawXY ? .rawSensor : .cursor
+ }
+ engine.onSourcedDeviceAction = { [weak self] action, source in
+ self?.perform(action, on: source)
+ }
+ registry.onChange = { [weak self] in
+ self?.activity += 1
+ self?.resyncAll()
+ }
+ }
+
+ // MARK: Interfaces
+
+ /// Opens a hub on `simulator` with the next link number and starts it (DeviceManager.attach).
+ @discardableResult
+ func attach(_ simulator: SimulatedHIDPPTransport, start: Bool = true) -> HIDPPLink {
+ let number = nextLink
+ nextLink += 1
+ let direct = simulator as? SimulatedDirect
+ let hub = HIDPPLink(
+ transport: simulator,
+ kind: simulator.transportKind,
+ link: number,
+ tuning: tuning,
+ productID: direct?.productID ?? (simulator as? SimulatedReceiver)?.productID,
+ productName: direct?.profile.name
+ )
+ hub.client.defaultTimeout = tuning.probeTimeout
+ hub.client.busyRetryDelay = 0.01
+ let connection = simulator.transportKind.connection
+
+ hub.configurationProvider = { [weak self] address, snapshot in
+ guard let self else { return .native }
+ let role = self.registry.prospectiveRole(address, snapshot: snapshot)
+ let configuration = self.configuration(for: address, snapshot: snapshot, isActive: role == .active)
+ self.appliedConfig[address] = configuration
+ return configuration
+ }
+ hub.onEndpointChange = { [weak self] address, state in
+ guard let self else { return }
+ self.activity += 1
+ self.registry.update(address, connection: connection, state: state)
+ }
+ hub.onEndpointRemoved = { [weak self] address, reason in
+ guard let self else { return }
+ self.activity += 1
+ self.removals.append((address, reason))
+ self.engine.releaseAll(source: address)
+ self.appliedConfig[address] = nil
+ self.registry.remove(address)
+ }
+ hub.onButtonEvent = { [weak self] event in self?.handle(event) }
+ hub.onPointerDelta = { [weak self] delta in
+ self?.activity += 1
+ self?.engine.handlePointerDelta(delta)
+ }
+ hub.onDeviceDPIChange = { [weak self] address, dpi in self?.deviceDidChangeDPI(address, dpi) }
+ hub.onLog = { [weak self] address, text in
+ guard let self else { return }
+ self.activity += 1
+ if self.keepsLog { self.log.append("[\(address?.description ?? "link \(number)")] \(text)") }
+ }
+
+ links[number] = hub
+ simulators[number] = simulator
+ if start { hub.start() }
+ return hub
+ }
+
+ /// Unplugs the interface (the IOHIDDevice goes away) and waits until the hub has closed and
+ /// dropped every endpoint.
+ func detach(_ link: UInt32, file: StaticString = #filePath, line: UInt = #line) async {
+ guard let hub = links[link] else { return }
+ simulators[link]?.disconnect()
+ await eventually("link \(link) closed", file: file, line: line) { hub.isClosed && hub.endpoints.isEmpty }
+ forgetClosedLinks()
+ }
+
+ /// Drops hubs whose transport closed on its own, and any record they left behind.
+ func forgetClosedLinks() {
+ for (number, hub) in links.sorted(by: { $0.key < $1.key }) where hub.isClosed && hub.endpoints.isEmpty {
+ links[number] = nil
+ registry.removeLink(number)
+ }
+ }
+
+ /// Hands every mouse's buttons back and closes every hub (quitting the app).
+ func shutdown() {
+ for number in links.keys.sorted() {
+ links[number]?.shutdown()
+ }
+ forgetClosedLinks()
+ }
+
+ // MARK: Lookups
+
+ func address(link: UInt32, index: UInt8) -> DeviceAddress {
+ DeviceAddress(link: link, index: index)
+ }
+
+ func endpoint(_ address: DeviceAddress) -> EndpointController? {
+ links[address.link]?.endpoints[address.index]
+ }
+
+ /// The live snapshot of the endpoint at `address` (identified, connected or asleep).
+ func snapshot(of address: DeviceAddress) -> DeviceSnapshot? {
+ endpoint(address)?.snapshot
+ }
+
+ func isConnected(_ address: DeviceAddress) -> Bool {
+ if case .connected = endpoint(address)?.state { return true }
+ return false
+ }
+
+ func model(of address: DeviceAddress) -> ModelKey? {
+ registry.record(address)?.model ?? snapshot(of: address)?.modelKey
+ }
+
+ /// The stable unit ID of the endpoint at `address`, if it has one.
+ func deviceID(of address: DeviceAddress) -> DeviceID? {
+ guard let id = snapshot(of: address)?.identity?.deviceID(at: address), id.isStable else { return nil }
+ return id
+ }
+
+ // MARK: Driving
+
+ /// Re-reads battery, pointer speed and channel on every mouse (the menu opening).
+ func refreshDeviceInfo() {
+ for number in links.keys.sorted() {
+ links[number]?.refreshDeviceInfo(at: nil)
+ }
+ }
+
+ /// The Mac woke from sleep.
+ func refreshAll() {
+ for number in links.keys.sorted() {
+ links[number]?.refresh()
+ }
+ }
+
+ /// Waits until every hub has finished its queued work.
+ func waitUntilIdle() async {
+ for number in links.keys.sorted() {
+ await links[number]?.waitUntilIdle()
+ }
+ }
+
+ /// Waits until nothing has happened for `quietPolls` consecutive polls: no hub callback and
+ /// no request sent. Unlike `waitUntilIdle()` it doesn't wait out requests nobody answers.
+ func settle(quietPolls: Int = 3, pollInterval: TimeInterval = 0.001, timeout: TimeInterval = 3) async {
+ let deadline = Date().addingTimeInterval(timeout)
+ var last = activityStamp
+ var quiet = 0
+ while quiet < quietPolls, Date() < deadline {
+ await pause(pollInterval)
+ let now = activityStamp
+ if now == last {
+ quiet += 1
+ } else {
+ quiet = 0
+ last = now
+ }
+ }
+ }
+
+ private var activityStamp: Int {
+ simulators.values.reduce(activity) { $0 + $1.sentReports.count }
+ }
+
+ // MARK: Configuration (AppModel)
+
+ /// What the endpoint at `address` should be doing now (§C step 5 and 9).
+ func configuration(for address: DeviceAddress, snapshot: DeviceSnapshot, isActive: Bool) -> EndpointConfiguration {
+ let model = snapshot.identity?.model ?? snapshot.modelKey
+ let id = snapshot.identity?.deviceID(at: address) ?? .transient(address)
+ let prefs = preferences.preferences(for: id, model: model)
+ let inputs = ConfigurationInputs(
+ remappingEnabled: remappingEnabled,
+ accessibilityGranted: accessibilityGranted,
+ deviceRemapEnabled: prefs.remapEnabled,
+ isActive: isActive,
+ preferredDPI: prefs.preferredDPI,
+ held: engine.pressedControls(for: address),
+ gesturesHeld: engine.gestureControls(for: address)
+ )
+ let mappings = library.effectiveMappings(model: model, bundleID: frontmostBundleID)
+ return ConfigurationPlanner.configuration(mappings: mappings, snapshot: snapshot, inputs: inputs)
+ }
+
+ /// Pushes a changed configuration to one endpoint. Only endpoints the registry has seen
+ /// identify (connected or asleep) are pushed; one that is still connecting pulls its own.
+ func resync(_ address: DeviceAddress) {
+ guard let record = registry.record(address), record.snapshot != nil,
+ let link = links[address.link], let snapshot = link.endpoints[address.index]?.snapshot
+ else { return }
+ let configuration = configuration(for: address, snapshot: snapshot, isActive: record.role == .active)
+ guard appliedConfig[address] != configuration else { return }
+ appliedConfig[address] = configuration
+ link.setConfiguration(configuration, at: address.index)
+ }
+
+ func resyncAll() {
+ for record in registry.records {
+ resync(record.address)
+ }
+ }
+
+ // MARK: Events (DeviceManager + AppModel)
+
+ private func handle(_ event: ButtonEvent) {
+ activity += 1
+ events.append(event)
+ if event.phase == .press {
+ guard registry.isActive(event.source) else {
+ droppedPresses.append(event)
+ return
+ }
+ focusedDevice = event.source
+ }
+ engine.handle(event)
+ // A release may end a keep-alive diversion (held across an app switch or a role change).
+ if event.phase == .release { resync(event.source) }
+ }
+
+ /// DeviceManager.perform(_:on:presets:).
+ private func perform(_ action: DeviceAction, on source: DeviceAddress) {
+ activity += 1
+ deviceActions.append((action, source))
+ guard let link = links[source.link] else { return }
+ switch action {
+ case .cycleDPI:
+ link.cycleDPI(at: source.index, presets: dpiPresets)
+ case .setDPI(let dpi):
+ // Stored first, so the resync it causes asks for the same speed.
+ storePreferredDPI(dpi, for: source)
+ link.setDPI(dpi, at: source.index)
+ case .switchHost(let host):
+ link.switchToHost(host - 1, at: source.index)
+ }
+ }
+
+ /// A speed chosen on the mouse (or stepped by Cycle Pointer Speed) becomes that unit's own
+ /// preference. The endpoint's snapshot is used, not the registry: during a connect the
+ /// change arrives before the registry has seen the endpoint identify.
+ private func deviceDidChangeDPI(_ address: DeviceAddress, _ dpi: Int?) {
+ activity += 1
+ deviceDPIChanges.append((address, dpi))
+ storePreferredDPI(dpi, for: address)
+ }
+
+ private func storePreferredDPI(_ dpi: Int?, for address: DeviceAddress) {
+ guard let id = deviceID(of: address), let model = snapshot(of: address)?.modelKey else { return }
+ var prefs = preferences.preferences(for: id, model: model)
+ guard prefs.preferredDPI != dpi else { return }
+ prefs.preferredDPI = dpi
+ preferences.setPreferences(prefs, for: id)
+ }
+}
+
+/// Records what the engine performs and checks it as it happens: an effect that tracks its
+/// button goes up exactly once per down, and one that doesn't never goes up.
+@MainActor
+final class BalanceCheckingPerformer: ActionPerforming {
+ struct Entry: Hashable {
+ var effect: ActionEffect
+ var phase: ActionPhase
+ }
+
+ private(set) var log: [Entry] = []
+ /// Downs not yet matched by an up, per effect (tracking effects only).
+ private(set) var outstanding: [ActionEffect: Int] = [:]
+ /// Ups without a down, and ups of effects that don't track their button.
+ private(set) var violations: [String] = []
+
+ func perform(_ effect: ActionEffect, phase: ActionPhase) {
+ log.append(Entry(effect: effect, phase: phase))
+ guard effect.tracksButtonState else {
+ if phase == .up { violations.append("up for an effect without a held state: \(effect)") }
+ return
+ }
+ switch phase {
+ case .down:
+ outstanding[effect, default: 0] += 1
+ case .up:
+ let count = outstanding[effect, default: 0] - 1
+ if count < 0 { violations.append("up without a down: \(effect)") }
+ outstanding[effect] = count == 0 ? nil : count
+ }
+ }
+
+ /// Effects still down (or driven negative), by count.
+ var stuck: [ActionEffect: Int] { outstanding.filter { $0.value != 0 } }
+
+ func count(_ effect: ActionEffect, _ phase: ActionPhase) -> Int {
+ log.filter { $0.effect == effect && $0.phase == phase }.count
+ }
+
+ func removeAll() {
+ log.removeAll()
+ }
+}
diff --git a/UprightKit/Tests/UprightKitTests/HIDPPIdentityTests.swift b/UprightKit/Tests/UprightKitTests/HIDPPIdentityTests.swift
new file mode 100644
index 0000000..bd26cb7
--- /dev/null
+++ b/UprightKit/Tests/UprightKitTests/HIDPPIdentityTests.swift
@@ -0,0 +1,383 @@
+import XCTest
+@testable import UprightKit
+
+/// Identity values (D5, §D). 0x0003 and 0x0005 layouts are Solaar's (`hidpp20.py` get_ids, get_kind).
+final class DeviceIdentityTests: XCTestCase {
+ func testParseFirmwareIDsPacksByTransportBits() throws {
+ // MX Vertical: [entities, unit ID (4), transport (2), model IDs (6), extended model ID].
+ // Bits BLE | wireless | USB, so the three IDs fill the slots in that order.
+ let vertical = try XCTUnwrap(DeviceIdentity.parseFirmwareIDs(
+ [3, 0x1A, 0x2B, 0x3C, 0x4D, 0x00, 0x0E, 0xB0, 0x20, 0x40, 0x7B, 0xC0, 0x8A, 0x00, 0x01]))
+ XCTAssertEqual(vertical.unitID, 0x1A2B3C4D)
+ XCTAssertEqual(vertical.modelIDs, ModelIDs(bluetoothLE: 0xB020, wireless: 0x407B, usb: 0xC08A))
+ XCTAssertNil(vertical.modelIDs.bluetooth)
+ XCTAssertEqual(vertical.modelIDs.canonical, 0xB020)
+ XCTAssertEqual(vertical.extendedModelID, 0x00)
+
+ // Bluetooth | wireless (MX Master: BT B012, wpid 4041): the first slot is Bluetooth.
+ let master = try XCTUnwrap(DeviceIdentity.parseFirmwareIDs(
+ [2, 0x00, 0x00, 0xBE, 0xEF, 0x00, 0x05, 0xB0, 0x12, 0x40, 0x41, 0x00, 0x00, 0x07]))
+ XCTAssertEqual(master.unitID, 0xBEEF)
+ XCTAssertEqual(master.modelIDs, ModelIDs(bluetooth: 0xB012, wireless: 0x4041))
+ XCTAssertEqual(master.modelIDs.canonical, 0xB012)
+ XCTAssertEqual(master.extendedModelID, 0x07)
+
+ // USB only: the first slot is the USB ID.
+ let wired = try XCTUnwrap(DeviceIdentity.parseFirmwareIDs([1, 0, 0, 0, 1, 0, 0x08, 0xC0, 0x8A, 0, 0, 0, 0]))
+ XCTAssertEqual(wired.modelIDs, ModelIDs(usb: 0xC08A))
+
+ // A zero ID in its slot counts as absent; so does a zero unit ID.
+ let sparse = try XCTUnwrap(DeviceIdentity.parseFirmwareIDs([1, 0, 0, 0, 0, 0, 0x0C, 0x00, 0x00, 0xC0, 0x8A, 0, 0]))
+ XCTAssertEqual(sparse.modelIDs, ModelIDs(usb: 0xC08A))
+ XCTAssertNil(sparse.unitID)
+ XCTAssertNil(sparse.extendedModelID, "13 bytes carry no extended model ID")
+
+ // No transport bits: no model IDs.
+ XCTAssertEqual(DeviceIdentity.parseFirmwareIDs([1, 0, 0, 0, 1, 0, 0, 0xB0, 0x20, 0, 0, 0, 0])?.modelIDs.isEmpty, true)
+ XCTAssertNil(DeviceIdentity.parseFirmwareIDs([3, 0x1A, 0x2B, 0x3C, 0x4D, 0x00, 0x0E, 0xB0, 0x20, 0x40, 0x7B, 0xC0]))
+ }
+
+ private let verticalIDs = ModelIDs(bluetoothLE: 0xB020, wireless: 0x407B, usb: 0xC08A)
+
+ func testCanonicalModelKeyIsTransportIndependent() {
+ // Bluetooth, USB cable and Unifying slot: the same 0x0003 IDs, three transport PIDs.
+ for transport: UInt16 in [0xB020, 0xC08A, 0x407B] {
+ let identity = DeviceIdentity(name: "MX Vertical", kind: .mouse, unitID: 0x1A2B3C4D, modelIDs: verticalIDs, transportPID: transport)
+ XCTAssertEqual(identity.model, .mxVertical, String(format: "%04X", transport))
+ XCTAssertEqual(identity.model.rawValue, "046D:B020")
+ }
+
+ // A model the catalog doesn't know keys by its canonical ID on every transport.
+ let unknownIDs = ModelIDs(bluetoothLE: 0xBFFE, wireless: 0x4FFE)
+ for transport: UInt16 in [0xBFFE, 0x4FFE] {
+ XCTAssertEqual(DeviceIdentity(name: "New Mouse", modelIDs: unknownIDs, transportPID: transport).model.rawValue, "046D:BFFE")
+ }
+ // Bluetooth beats BLE beats wireless beats USB.
+ XCTAssertEqual(ModelIDs(bluetooth: 0xBFFD, bluetoothLE: 0xBFFE, wireless: 0x4FFE, usb: 0xCFFE).canonical, 0xBFFD)
+ XCTAssertEqual(ModelIDs(wireless: 0x4FFE, usb: 0xCFFE).canonical, 0x4FFE)
+ XCTAssertEqual(ModelIDs(usb: 0xCFFE).canonical, 0xCFFE)
+ }
+
+ func testCatalogAliasesMapToCanonical() {
+ // Without 0x0003 model IDs the transport PID is looked up in the catalog.
+ for transport: UInt16 in [0xB020, 0xC08A, 0x407B] {
+ XCTAssertEqual(DeviceIdentity(name: "MX Vertical", transportPID: transport).model, .mxVertical, String(format: "%04X", transport))
+ }
+ XCTAssertEqual(DeviceIdentity(name: "Mystery", transportPID: 0x4FFE).model.rawValue, "046D:4FFE")
+ XCTAssertEqual(DeviceIdentity(name: " Mystery Mouse ").model.rawValue, "name:mystery mouse")
+ }
+
+ func testDeviceIDFallbackChain() {
+ let address = DeviceAddress(link: 3, index: 2)
+
+ let unit = DeviceIdentity(name: "MX Vertical", unitID: 0x1A2B3C4D, serialNumber: "TEST0001", modelIDs: verticalIDs).deviceID(at: address)
+ XCTAssertEqual(unit.rawValue, "u:046D:B020:1A2B3C4D")
+ XCTAssertTrue(unit.isStable)
+ XCTAssertEqual(unit.model, .mxVertical)
+ XCTAssertEqual(DeviceIdentity(name: "MX Vertical", unitID: 0xBEEF, modelIDs: verticalIDs).deviceID(at: address).rawValue,
+ "u:046D:B020:0000BEEF")
+
+ let serial = DeviceIdentity(name: "MX Vertical", unitID: 0, serialNumber: "TEST0001", modelIDs: verticalIDs).deviceID(at: address)
+ XCTAssertEqual(serial.rawValue, "s:046D:B020:TEST0001")
+ XCTAssertTrue(serial.isStable)
+ XCTAssertEqual(serial.model, .mxVertical)
+
+ let transient = DeviceIdentity(name: "MX Vertical", serialNumber: "", modelIDs: verticalIDs).deviceID(at: address)
+ XCTAssertEqual(transient.rawValue, "t:3.2")
+ XCTAssertFalse(transient.isStable)
+ XCTAssertNil(transient.model)
+ XCTAssertNotEqual(transient, DeviceIdentity(name: "MX Vertical", modelIDs: verticalIDs).deviceID(at: DeviceAddress(link: 4, index: 2)))
+ }
+
+ func testDeviceKindTables() {
+ let hidpp20: [UInt8: DeviceKind] = [
+ 0: .keyboard, 1: .remote, 2: .numpad, 3: .mouse, 4: .touchpad, 5: .trackball, 6: .presenter, 7: .receiver,
+ ]
+ for (value, kind) in hidpp20 { XCTAssertEqual(DeviceKind(hidpp20: value), kind, "\(value)") }
+ XCTAssertEqual(DeviceKind(hidpp20: 9), .other(9))
+
+ let hidpp10: [UInt8: DeviceKind] = [
+ 0: .unknown, 1: .keyboard, 2: .mouse, 3: .numpad, 4: .presenter, 7: .remote, 8: .trackball, 9: .touchpad,
+ ]
+ for (value, kind) in hidpp10 { XCTAssertEqual(DeviceKind(hidpp10Nibble: value), kind, "\(value)") }
+ XCTAssertEqual(DeviceKind(hidpp10Nibble: 0x42), .mouse, "flag bits above the nibble are ignored")
+ XCTAssertEqual(DeviceKind(hidpp10Nibble: 5), .other(5))
+
+ for kind: DeviceKind in [.mouse, .trackball, .touchpad] {
+ XCTAssertTrue(kind.isPointer)
+ XCTAssertFalse(kind.isDefinitelyNotPointer)
+ }
+ for kind: DeviceKind in [.keyboard, .numpad, .presenter, .remote, .receiver] {
+ XCTAssertFalse(kind.isPointer)
+ XCTAssertTrue(kind.isDefinitelyNotPointer)
+ }
+ for kind: DeviceKind in [.unknown, .other(5)] {
+ XCTAssertFalse(kind.isPointer)
+ XCTAssertFalse(kind.isDefinitelyNotPointer, "decided by the device's own answers")
+ }
+ }
+}
+
+/// `DeviceFeatures.identity()`, `deviceKind()`, feature versions and the 0x1001 battery, against
+/// a scripted device.
+@MainActor
+final class HIDPPIdentityTests: XCTestCase {
+ private let verticalFirmwareIDs: [UInt8] = [3, 0x1A, 0x2B, 0x3C, 0x4D, 0x00, 0x0E, 0xB0, 0x20, 0x40, 0x7B, 0xC0, 0x8A, 0x00, 0x01]
+
+ private func makeFeatures(_ device: IdentityTestDevice) -> DeviceFeatures {
+ let client = HIDPPClient(transport: device)
+ client.defaultTimeout = 0.1
+ return DeviceFeatures(device: HIDPPDevice(client: client, index: device.index))
+ }
+
+ func testIdentityReadsKindIDsAndSerial() async throws {
+ let mouse = IdentityTestDevice(index: 2, features: [(0x0003, 4), (0x0005, 2)])
+ mouse.replies[0x0005] = [2: [3]]
+ mouse.replies[0x0003] = [0: verticalFirmwareIDs, 2: Array("TEST00012345".utf8)]
+
+ let identity = try await makeFeatures(mouse).identity(name: "MX Vertical", transportPID: 0x407B)
+ XCTAssertEqual(identity.name, "MX Vertical")
+ XCTAssertEqual(identity.kind, .mouse)
+ XCTAssertEqual(identity.unitID, 0x1A2B3C4D)
+ XCTAssertEqual(identity.modelIDs, ModelIDs(bluetoothLE: 0xB020, wireless: 0x407B, usb: 0xC08A))
+ XCTAssertEqual(identity.extendedModelID, 0x00)
+ XCTAssertEqual(identity.serialNumber, "TEST00012345")
+ XCTAssertEqual(identity.transportPID, 0x407B)
+ XCTAssertEqual(identity.model, .mxVertical)
+ XCTAssertEqual(identity.deviceID(at: DeviceAddress(link: 1, index: 2)).rawValue, "u:046D:B020:1A2B3C4D")
+ }
+
+ func testSerialNeedsFirmwareFeatureVersion4() async throws {
+ let mouse = IdentityTestDevice(features: [(0x0003, 3), (0x0005, 2)])
+ mouse.replies[0x0005] = [2: [5]]
+ mouse.replies[0x0003] = [0: verticalFirmwareIDs, 2: Array("TEST00012345".utf8)]
+
+ let identity = try await makeFeatures(mouse).identity(name: "MX Ergo")
+ XCTAssertEqual(identity.kind, .trackball)
+ XCTAssertNil(identity.serialNumber)
+ XCTAssertFalse(mouse.called(0x0003, function: 2), "getDeviceSerialNumber exists from version 4")
+ }
+
+ func testIdentityWithoutFirmwareFeatureKeysByTransportPID() async throws {
+ let mouse = IdentityTestDevice(features: [(0x0005, 2)])
+ mouse.replies[0x0005] = [2: [3]]
+ let features = makeFeatures(mouse)
+
+ let aliased = try await features.identity(name: "MX Vertical", transportPID: 0xC08A)
+ XCTAssertEqual(aliased.model, .mxVertical)
+ XCTAssertTrue(aliased.modelIDs.isEmpty)
+ XCTAssertNil(aliased.unitID)
+
+ let unknown = try await features.identity(name: "Mystery", transportPID: 0x4FFE)
+ XCTAssertEqual(unknown.model.rawValue, "046D:4FFE")
+ XCTAssertEqual(unknown.deviceID(at: DeviceAddress(link: 2, index: 0xFF)).rawValue, "t:2.255")
+
+ let named = try await features.identity(name: "Mystery")
+ XCTAssertEqual(named.model.rawValue, "name:mystery")
+ }
+
+ func testGivenKindSkipsGetDeviceTypeAndRefusalsLeaveItUnknown() async throws {
+ let mouse = IdentityTestDevice(features: [(0x0003, 4), (0x0005, 1)])
+ mouse.replies[0x0003] = [0: verticalFirmwareIDs]
+ mouse.errors[0x0005] = [2: 0x07]
+ mouse.errors[0x0003] = [2: 0x02]
+ let features = makeFeatures(mouse)
+
+ let refused = try await features.identity(name: "Old Firmware")
+ XCTAssertEqual(refused.kind, .unknown)
+ XCTAssertNil(refused.serialNumber, "a refused serial read leaves it empty")
+ XCTAssertEqual(refused.unitID, 0x1A2B3C4D)
+
+ mouse.clearRequests()
+ let given = try await features.identity(name: "Old Firmware", kind: .trackball)
+ XCTAssertEqual(given.kind, .trackball)
+ XCTAssertFalse(mouse.called(0x0005, function: 2))
+ }
+
+ func testIdentityThrowsWhenTheDeviceStopsAnswering() async {
+ let mouse = IdentityTestDevice(features: [(0x0003, 4), (0x0005, 2)])
+ mouse.replies[0x0005] = [2: [3]]
+ mouse.silent[0x0003] = [0]
+ do {
+ _ = try await makeFeatures(mouse).identity(name: "MX Vertical")
+ XCTFail("expected a timeout")
+ } catch {
+ XCTAssertEqual(error as? HIDPPError, .timeout)
+ }
+ }
+
+ func testDeviceKindAndFirmwareIDsThrowAsIs() async {
+ let keyboard = IdentityTestDevice(features: [(0x0003, 2)])
+ let features = makeFeatures(keyboard)
+ do {
+ _ = try await features.deviceKind()
+ XCTFail("expected featureNotSupported")
+ } catch {
+ XCTAssertEqual(error as? HIDPPError, .featureNotSupported(0x0005))
+ }
+ keyboard.errors[0x0003] = [0: 0x05]
+ do {
+ _ = try await features.firmwareIDs()
+ XCTFail("expected the device's error")
+ } catch {
+ XCTAssertEqual(error as? HIDPPError, .hidpp20(0x05))
+ }
+ }
+
+ func testFeatureVersionFromGetFeatureAndFeatureTable() async throws {
+ let mouse = IdentityTestDevice(features: [(0x0003, 4), (0x1B04, 5)])
+ let device = makeFeatures(mouse).device
+
+ let reprog = try await device.featureVersion(for: .reprogControlsV4)
+ XCTAssertEqual(reprog, 5)
+ let missing = try await device.featureVersion(for: .adjustableDPI)
+ XCTAssertNil(missing)
+
+ let table = try await device.featureTable()
+ XCTAssertEqual(table.map(\.version), [0, 0, 4, 5])
+ mouse.clearRequests()
+ let firmware = try await device.featureVersion(for: .deviceFirmwareVersion)
+ XCTAssertEqual(firmware, 4)
+ XCTAssertTrue(mouse.requests.isEmpty, "the feature table already cached it")
+ }
+
+ func testBatteryFallsBackToVoltage() async throws {
+ let gamingMouse = IdentityTestDevice(features: [(0x1001, 0)])
+ gamingMouse.replies[0x1001] = [0: [0x0F, 0x52, 0x00]] // 3922 mV, discharging
+ let battery = await makeFeatures(gamingMouse).device.battery()
+ XCTAssertEqual(battery, BatteryStatus(percentage: 70, isCharging: false, isApproximate: true))
+
+ let both = IdentityTestDevice(features: [(0x1000, 0), (0x1001, 0)])
+ both.replies[0x1000] = [0: [55, 50, 0]]
+ both.replies[0x1001] = [0: [0x0F, 0x52, 0x00]]
+ let preferred = await makeFeatures(both).device.battery()
+ XCTAssertEqual(preferred, BatteryStatus(percentage: 55, isCharging: false))
+ XCTAssertFalse(both.called(0x1001, function: 0))
+ }
+}
+
+final class BatteryVoltageTests: XCTestCase {
+ private func status(millivolts: UInt16, flags: UInt8) -> BatteryStatus? {
+ BatteryStatus(batteryVoltageParameters: millivolts.bigEndianBytes + [flags])
+ }
+
+ func testVoltageCurve() {
+ XCTAssertEqual(BatteryStatus.percentage(forMillivolts: 4200), 100)
+ XCTAssertEqual(BatteryStatus.percentage(forMillivolts: 4186), 100)
+ XCTAssertEqual(BatteryStatus.percentage(forMillivolts: 3922), 70)
+ XCTAssertEqual(BatteryStatus.percentage(forMillivolts: 3811), 50)
+ XCTAssertEqual(BatteryStatus.percentage(forMillivolts: 3885), 64, "60 + 10 × 26/63, rounded")
+ XCTAssertEqual(BatteryStatus.percentage(forMillivolts: 3600), 3, "2 + 3 × 21/67, rounded")
+ XCTAssertEqual(BatteryStatus.percentage(forMillivolts: 3500), 0)
+ XCTAssertEqual(BatteryStatus.percentage(forMillivolts: 3000), 0)
+ }
+
+ func testVoltageFlags() {
+ XCTAssertEqual(status(millivolts: 3922, flags: 0x00), BatteryStatus(percentage: 70, isCharging: false, isApproximate: true))
+ XCTAssertEqual(status(millivolts: 3922, flags: 0x80), BatteryStatus(percentage: 70, isCharging: true, isApproximate: true))
+ XCTAssertEqual(status(millivolts: 4100, flags: 0x81)?.isCharging, true, "full while on power")
+ XCTAssertEqual(status(millivolts: 3700, flags: 0x20)?.isCharging, false, "critical flag alone")
+ XCTAssertEqual(status(millivolts: 0, flags: 0x81), BatteryStatus(percentage: 100, isCharging: true, isApproximate: true))
+ XCTAssertNil(status(millivolts: 0, flags: 0x80))
+ XCTAssertNil(status(millivolts: 0, flags: 0x00))
+ XCTAssertNil(BatteryStatus(batteryVoltageParameters: [0x0F, 0x52]))
+ }
+}
+
+final class HIDPPFeatureNameTests: XCTestCase {
+ func testFeatureNames() {
+ XCTAssertEqual(HIDPPFeature.name(for: 0x1B00), "Reprog Controls")
+ XCTAssertEqual(HIDPPFeature.name(for: 0x1B01), "Reprog Controls v2")
+ XCTAssertEqual(HIDPPFeature.name(for: 0x1B02), "Reprog Controls v2.2")
+ XCTAssertEqual(HIDPPFeature.name(for: 0x1B03), "Reprog Controls v3")
+ XCTAssertEqual(HIDPPFeature.name(for: 0x1B04), "Reprog Controls v4")
+ XCTAssertEqual(HIDPPFeature.name(for: 0x1C00), "Persistent Remappable Action")
+ XCTAssertEqual(HIDPPFeature.name(for: 0x2202), "Extended Adjustable DPI")
+ XCTAssertEqual(HIDPPFeature.name(for: 0x2205), "Pointer Speed")
+ XCTAssertEqual(HIDPPFeature.name(for: 0x1001), "Battery Voltage")
+ XCTAssertEqual(HIDPPFeature.name(for: 0x8100), "Onboard Profiles")
+ XCTAssertEqual(HIDPPFeature.name(for: 0x1234), "Feature 0x1234")
+ XCTAssertEqual(FeatureEntry(index: 4, featureID: 0x2205, version: 0, flags: 0).name, "Pointer Speed")
+ }
+
+ func testUnsupportedDeviceError() {
+ let partial = DeviceSnapshot(name: "MX Keys", connection: .unifying, deviceIndex: 3, protocolVersion: "4.5",
+ features: [], controls: [], battery: nil)
+ let error = HIDPPError.unsupportedDevice(.notAPointer(.keyboard), partial: partial)
+ XCTAssertEqual(error.unsupportedReason, .notAPointer(.keyboard))
+ XCTAssertTrue(error.description.contains("MX Keys"))
+ XCTAssertFalse(error.meansDeviceUnreachable)
+ XCTAssertFalse(error.isBusy)
+ XCTAssertNotEqual(error, .unsupportedDevice(.notAPointer(.keyboard), partial: nil))
+ XCTAssertEqual(HIDPPError.unsupportedDevice(.noReprogControls, partial: nil).unsupportedReason, .noReprogControls)
+ XCTAssertNil(HIDPPError.timeout.unsupportedReason)
+ }
+}
+
+/// A HID++ 2.0 device answering from a feature list and per-function replies, synchronously.
+/// Root and Feature Set are always present (indices 0 and 1).
+private final class IdentityTestDevice: HIDPPTransport, @unchecked Sendable {
+ var onReport: (([UInt8]) -> Void)?
+ var onClose: (() -> Void)?
+
+ let index: UInt8
+ /// Feature IDs and versions by feature index.
+ let table: [(id: UInt16, version: UInt8)]
+ /// Reply parameters by feature ID, then function.
+ var replies: [UInt16: [UInt8: [UInt8]]] = [:]
+ /// HID++ 2.0 error codes by feature ID, then function.
+ var errors: [UInt16: [UInt8: UInt8]] = [:]
+ /// Functions that never answer, by feature ID.
+ var silent: [UInt16: Set] = [:]
+ private(set) var requests: [(feature: UInt16, function: UInt8)] = []
+
+ init(index: UInt8 = 0xFF, features: [(UInt16, UInt8)]) {
+ self.index = index
+ table = [(0x0000, 0), (0x0001, 0)] + features.map { (id: $0.0, version: $0.1) }
+ }
+
+ func called(_ feature: UInt16, function: UInt8) -> Bool {
+ requests.contains { $0.feature == feature && $0.function == function }
+ }
+
+ func clearRequests() {
+ requests.removeAll()
+ }
+
+ func send(_ bytes: [UInt8]) throws {
+ guard let request = HIDPPMessage(bytes: bytes), request.deviceIndex == index else { return }
+ if let reply = respond(to: request) { onReport?(reply.bytes) }
+ }
+
+ private func respond(to request: HIDPPMessage) -> HIDPPMessage? {
+ guard Int(request.featureIndex) < table.count else { return error(request, 0x06) }
+ let feature = table[Int(request.featureIndex)].id
+ let p = request.parameters
+ requests.append((feature, request.function))
+ switch (feature, request.function) {
+ case (0x0000, 0):
+ let id = p.uint16(at: 0)
+ guard let found = table.firstIndex(where: { $0.id == id }) else { return reply(request, [0, 0, 0]) }
+ return reply(request, [UInt8(found), 0, table[found].version])
+ case (0x0001, 0):
+ return reply(request, [UInt8(table.count - 1)])
+ case (0x0001, 1):
+ guard Int(p[0]) < table.count else { return error(request, 0x02) }
+ let entry = table[Int(p[0])]
+ return reply(request, entry.id.bigEndianBytes + [0, entry.version])
+ default:
+ if silent[feature]?.contains(request.function) == true { return nil }
+ if let code = errors[feature]?[request.function] { return error(request, code) }
+ guard let parameters = replies[feature]?[request.function] else { return error(request, 0x07) }
+ return reply(request, parameters)
+ }
+ }
+
+ private func reply(_ request: HIDPPMessage, _ parameters: [UInt8]) -> HIDPPMessage {
+ HIDPPMessage(reportID: .long, deviceIndex: request.deviceIndex, subID: request.subID, address: request.address, parameters: parameters)
+ }
+
+ private func error(_ request: HIDPPMessage, _ code: UInt8) -> HIDPPMessage {
+ HIDPPMessage(reportID: .long, deviceIndex: request.deviceIndex, subID: 0xFF, address: request.subID, parameters: [request.address, code])
+ }
+}
diff --git a/UprightKit/Tests/UprightKitTests/HIDPPMessageTests.swift b/UprightKit/Tests/UprightKitTests/HIDPPMessageTests.swift
index f89f593..56dbebd 100644
--- a/UprightKit/Tests/UprightKitTests/HIDPPMessageTests.swift
+++ b/UprightKit/Tests/UprightKitTests/HIDPPMessageTests.swift
@@ -64,15 +64,35 @@ final class HIDPPMessageTests: XCTestCase {
XCTAssertEqual(tracker.reset(), [])
}
+ /// The classification cases that used to pin `MXVertical.classify`, now against
+ /// `InterfaceClassifier`. What changed: nothing is gated on the product name, so an MX Master 3
+ /// over Bluetooth is served; the non-HID++ M100 (C077) and other vendors are still ignored.
func testDeviceClassification() {
- XCTAssertEqual(MXVertical.classify(vendorID: 0x046D, productID: 0xC52B), .receiver(.unifying))
- XCTAssertEqual(MXVertical.classify(vendorID: 0x046D, productID: 0xC548), .receiver(.bolt))
- XCTAssertEqual(MXVertical.classify(vendorID: 0x046D, productID: 0xC08A), .direct(.usb))
- XCTAssertEqual(MXVertical.classify(vendorID: 0x046D, productID: 0xB020), .direct(.bluetooth))
- XCTAssertNil(MXVertical.classify(vendorID: 0x046D, productID: 0xC077))
- XCTAssertNil(MXVertical.classify(vendorID: 0x05AC, productID: 0xC52B))
- XCTAssertTrue(MXVertical.matches(name: "MX Vertical Advanced Ergonomic Mouse"))
- XCTAssertFalse(MXVertical.matches(name: "MX Master 3"))
+ func classify(_ vendor: Int, _ product: Int, transport: String = "", page: Int = 0xFF00) -> InterfaceClass {
+ InterfaceClassifier.classify(HIDInterfaceDescriptor(vendorID: vendor, productID: product, transport: transport, usagePage: page))
+ }
+
+ XCTAssertEqual(classify(0x046D, 0xC52B), .receiver(.unifying, .unifying))
+ XCTAssertEqual(classify(0x046D, 0xC548), .receiver(.bolt, .bolt))
+ XCTAssertEqual(classify(0x046D, 0xC08A), .direct(.usb))
+ XCTAssertEqual(classify(0x046D, 0xB020), .direct(.bluetooth))
+ XCTAssertEqual(classify(0x046D, 0xC08A).transportKind, .direct(.usb))
+
+ // Inverted: an MX Master 3 (Bluetooth B023) was rejected by name before; it is a mouse now.
+ XCTAssertEqual(classify(0x046D, 0xB023, transport: "Bluetooth Low Energy", page: 0x01), .direct(.bluetooth))
+ // Inverted: Nano receivers were not watched; they are served, with the Unifying layout.
+ XCTAssertEqual(classify(0x046D, 0xC52F), .receiver(.nano, .unifying))
+
+ // Still ignored: a plain (non-HID++) Logitech mouse, another vendor, a Lightspeed receiver, a keyboard.
+ XCTAssertEqual(classify(0x046D, 0xC077, transport: "USB", page: 0x01).transportKind, nil)
+ XCTAssertEqual(classify(0x05AC, 0xC52B).transportKind, nil)
+ XCTAssertEqual(classify(0x046D, 0xC539).transportKind, nil)
+ XCTAssertEqual(classify(0x046D, 0xB350, transport: "Bluetooth Low Energy").transportKind, nil)
+ if case .ignored(let reason) = classify(0x046D, 0xC077, transport: "USB", page: 0x01) {
+ XCTAssertFalse(reason.isEmpty)
+ } else {
+ XCTFail("C077 must be ignored")
+ }
}
func testRePairReplacesADeadInterfaceAndLeavesALiveTwinAlone() {
diff --git a/UprightKit/Tests/UprightKitTests/InterfaceClassifierTests.swift b/UprightKit/Tests/UprightKitTests/InterfaceClassifierTests.swift
new file mode 100644
index 0000000..dc5a782
--- /dev/null
+++ b/UprightKit/Tests/UprightKitTests/InterfaceClassifierTests.swift
@@ -0,0 +1,310 @@
+import XCTest
+@testable import UprightKit
+
+/// D9 (which interfaces get opened), the keyboard-range exclusion, and the twin rules.
+final class InterfaceClassifierTests: XCTestCase {
+ // MARK: Fixtures
+
+ private func descriptor(
+ _ pid: Int,
+ transport: String = "",
+ page: Int = 0,
+ usage: Int = 0,
+ location: Int = 0,
+ serial: String = "",
+ registry: UInt64 = 0,
+ vendor: Int = 0x046D
+ ) -> HIDInterfaceDescriptor {
+ HIDInterfaceDescriptor(
+ vendorID: vendor,
+ productID: pid,
+ transport: transport,
+ usagePage: page,
+ usage: usage,
+ locationID: location,
+ serial: serial,
+ registryID: registry,
+ productName: "Test"
+ )
+ }
+
+ private func classify(_ pid: Int, transport: String = "", page: Int = 0xFF00, usage: Int = 1) -> InterfaceClass {
+ InterfaceClassifier.classify(descriptor(pid, transport: transport, page: page, usage: usage))
+ }
+
+ private func assertIgnored(_ class_: InterfaceClass, containing text: String? = nil, file: StaticString = #filePath, line: UInt = #line) {
+ guard case .ignored(let reason) = class_ else {
+ XCTFail("expected .ignored, got \(class_)", file: file, line: line)
+ return
+ }
+ if let text {
+ XCTAssertTrue(reason.lowercased().contains(text.lowercased()), "reason \"\(reason)\" should mention \"\(text)\"", file: file, line: line)
+ }
+ }
+
+ // MARK: Receivers
+
+ func testUnifyingBoltNanoAccepted() {
+ XCTAssertEqual(classify(0xC52B, transport: "USB"), .receiver(.unifying, .unifying))
+ XCTAssertEqual(classify(0xC532, transport: "USB"), .receiver(.unifying, .unifying))
+ XCTAssertEqual(classify(0xC548, transport: "USB"), .receiver(.bolt, .bolt))
+
+ // Nano receivers use the Unifying pairing layout (D8).
+ let nano = [0xC52F, 0xC531, 0xC534, 0xC537, 0xC518, 0xC51A, 0xC51B, 0xC521, 0xC525, 0xC526, 0xC52E, 0xC535]
+ for pid in nano {
+ XCTAssertEqual(classify(pid, transport: "USB"), .receiver(.nano, .unifying), String(pid, radix: 16))
+ }
+
+ // Both HID++ vendor pages are accepted, and so is a descriptor with no usage information.
+ XCTAssertEqual(classify(0xC52B, page: 0xFF43), .receiver(.unifying, .unifying))
+ XCTAssertEqual(InterfaceClassifier.classify(descriptor(0xC52B)), .receiver(.unifying, .unifying))
+ XCTAssertEqual(InterfaceClass.receiver(.bolt, .bolt).transportKind, .receiver(.bolt))
+ }
+
+ func testReceiverKeyboardAndMouseInterfacesAreNotOpened() {
+ // The generic Logitech mouse matching also matches a receiver's boot-mouse interface.
+ // Only the vendor-page interface carries HID++.
+ assertIgnored(classify(0xC52B, transport: "USB", page: 0x01, usage: 0x02), containing: "HID++")
+ assertIgnored(classify(0xC548, transport: "USB", page: 0x01, usage: 0x06), containing: "HID++")
+ assertIgnored(classify(0xC52B, transport: "USB", page: 0x0C, usage: 0x01))
+ }
+
+ func testReceiverOnABluetoothOrVirtualTransportIsIgnored() {
+ assertIgnored(classify(0xC52B, transport: "Bluetooth"))
+ assertIgnored(classify(0xC52B, transport: "Virtual"))
+ }
+
+ func testLightspeedAnd27MHzIgnored() {
+ let lightspeed = [0xC539, 0xC53A, 0xC53D, 0xC53F, 0xC541, 0xC545, 0xC547, 0xC54D]
+ for pid in lightspeed {
+ let result = classify(pid, transport: "USB")
+ assertIgnored(result, containing: "Lightspeed")
+ XCTAssertNil(result.transportKind)
+ }
+ assertIgnored(classify(0xC539, transport: "USB"), containing: "programmable-button")
+ assertIgnored(classify(0xC517, transport: "USB"), containing: "27 MHz")
+
+ // The exclusion does not depend on the interface: the mouse interface is skipped too.
+ assertIgnored(classify(0xC539, transport: "USB", page: 0x01, usage: 0x02), containing: "Lightspeed")
+
+ // Lenovo's Nano clone (17EF:6042) is not matched: the vendor is not Logitech.
+ assertIgnored(InterfaceClassifier.classify(descriptor(0x6042, transport: "USB", page: 0xFF00, vendor: 0x17EF)), containing: "Logitech")
+
+ // An unlisted receiver PID is not guessed at.
+ assertIgnored(classify(0xC5F0, transport: "USB"), containing: "receiver")
+ }
+
+ func testEveryCatalogReceiverWithoutALayoutIsIgnored() {
+ for receiver in DeviceCatalog.receivers.values where receiver.layout == nil {
+ assertIgnored(classify(Int(receiver.productID), transport: "USB"))
+ }
+ // And no catalog receiver may ever open a Lightspeed or 27 MHz PID.
+ let excluded = InterfaceClassifier.lightspeedReceiverPIDs.union(InterfaceClassifier.legacyReceiverPIDs)
+ for pid in excluded {
+ assertIgnored(classify(Int(pid), transport: "USB"))
+ }
+ }
+
+ // MARK: Direct mice
+
+ func testDirectMouseRangesAcceptedKeyboardRangesIgnored() {
+ // USB mice C07D...C094.
+ for pid in [0xC07D, 0xC08A, 0xC094] {
+ XCTAssertEqual(classify(pid, transport: "USB", page: 0xFF00), .direct(.usb), String(pid, radix: 16))
+ }
+ assertIgnored(classify(0xC07C, transport: "USB"), containing: "not a supported mouse")
+ assertIgnored(classify(0xC077, transport: "USB", page: 0x01, usage: 0x02), containing: "not a supported mouse")
+ assertIgnored(classify(0xC095, transport: "USB"), containing: "not a supported mouse")
+
+ // USB keyboards C32B...C344 are excluded, and the gap just outside is not a mouse either.
+ for pid in [0xC32B, 0xC330, 0xC344] {
+ assertIgnored(classify(pid, transport: "USB"), containing: "keyboard")
+ }
+ assertIgnored(classify(0xC32A, transport: "USB"), containing: "not a supported mouse")
+ assertIgnored(classify(0xC345, transport: "USB"), containing: "not a supported mouse")
+
+ // Bluetooth mice B012...B0FF, over classic Bluetooth and Bluetooth LE.
+ for pid in [0xB012, 0xB034, 0xB0FF] {
+ XCTAssertEqual(classify(pid, transport: "Bluetooth Low Energy", page: 0x01, usage: 0x02), .direct(.bluetooth), String(pid, radix: 16))
+ XCTAssertEqual(classify(pid, transport: "Bluetooth", page: 0xFF43), .direct(.bluetooth), String(pid, radix: 16))
+ }
+ assertIgnored(classify(0xB011, transport: "Bluetooth"), containing: "not a supported mouse")
+ assertIgnored(classify(0xB100, transport: "Bluetooth"), containing: "not a supported mouse")
+
+ // Bluetooth keyboards B317...B3FF.
+ for pid in [0xB317, 0xB350, 0xB3FF] {
+ assertIgnored(classify(pid, transport: "Bluetooth Low Energy", page: 0xFF43), containing: "keyboard")
+ }
+ assertIgnored(classify(0xB316, transport: "Bluetooth Low Energy"), containing: "not a supported mouse")
+
+ XCTAssertEqual(InterfaceClass.direct(.bluetooth).transportKind, .direct(.bluetooth))
+ XCTAssertNil(InterfaceClass.direct(.usb).ignoredReason)
+ XCTAssertEqual(InterfaceClass.ignored("x").ignoredReason, "x")
+ }
+
+ func testMXVerticalIsStillServedOverEveryDirectTransport() {
+ XCTAssertEqual(classify(0xC08A, transport: "USB"), .direct(.usb))
+ XCTAssertEqual(classify(0xC08A, transport: "USB", page: 0x01, usage: 0x02), .direct(.usb))
+ XCTAssertEqual(classify(0xB020, transport: "Bluetooth Low Energy", page: 0x01, usage: 0x02), .direct(.bluetooth))
+ // The 0x407B wireless PID only appears behind a receiver; it is not an IOKit product ID.
+ assertIgnored(classify(0x407B, transport: "USB"))
+ }
+
+ func testEveryCatalogUSBAndBluetoothPIDIsAcceptedOnItsTransport() {
+ // The catalog is verified mouse data and wins over the generic ranges.
+ for model in DeviceCatalog.models {
+ for pid in model.usbPIDs {
+ XCTAssertEqual(classify(Int(pid), transport: "USB", page: 0xFF00), .direct(.usb), "\(model.name) USB \(pid)")
+ XCTAssertEqual(classify(Int(pid), page: 0xFF00), .direct(.usb), "\(model.name) USB \(pid) without a transport string")
+ }
+ for pid in model.bluetoothPIDs {
+ XCTAssertEqual(classify(Int(pid), transport: "Bluetooth Low Energy", page: 0x01, usage: 0x02), .direct(.bluetooth), "\(model.name) BT \(pid)")
+ XCTAssertEqual(classify(Int(pid), page: 0x01, usage: 0x02), .direct(.bluetooth), "\(model.name) BT \(pid) without a transport string")
+ }
+ }
+ }
+
+ func testTransportStringDecidesTheConnectionAndMismatchesAreIgnored() {
+ XCTAssertEqual(classify(0xB020, transport: "Bluetooth"), .direct(.bluetooth))
+ XCTAssertEqual(classify(0xB020, transport: "Bluetooth Low Energy"), .direct(.bluetooth))
+ XCTAssertEqual(classify(0xB020, transport: " bluetooth low energy "), .direct(.bluetooth))
+ XCTAssertEqual(classify(0xC08A, transport: "USB"), .direct(.usb))
+
+ // A USB PID on a Bluetooth transport (and the reverse) is not a device we know.
+ assertIgnored(classify(0xC08A, transport: "Bluetooth Low Energy"))
+ assertIgnored(classify(0xB020, transport: "USB"))
+
+ // Transports that are neither (a virtual HID device claiming Logitech's IDs, an internal bus).
+ assertIgnored(classify(0xC08A, transport: "Virtual"), containing: "transport")
+ assertIgnored(classify(0xB020, transport: "SPI"), containing: "transport")
+
+ // No transport string: the PID decides.
+ XCTAssertEqual(classify(0xC08A), .direct(.usb))
+ XCTAssertEqual(classify(0xB020), .direct(.bluetooth))
+ XCTAssertEqual(classify(0xB034), .direct(.bluetooth))
+ XCTAssertEqual(classify(0xC084), .direct(.usb))
+ assertIgnored(classify(0xC32B))
+ }
+
+ func testVendorsOtherThanLogitechAreIgnored() {
+ for vendor in [0x05AC, 0x17EF, 0x1532, 0] {
+ assertIgnored(InterfaceClassifier.classify(descriptor(0xC52B, vendor: vendor)), containing: "Logitech")
+ assertIgnored(InterfaceClassifier.classify(descriptor(0xB020, vendor: vendor)), containing: "Logitech")
+ assertIgnored(InterfaceClassifier.classify(descriptor(0xC08A, vendor: vendor)), containing: "Logitech")
+ }
+ assertIgnored(InterfaceClassifier.classify(descriptor(-1)), containing: "invalid")
+ assertIgnored(InterfaceClassifier.classify(descriptor(0x1_0000)), containing: "invalid")
+ }
+
+ func testMatchCriteriaCoverVendorPagesAndGenericMouseWithoutProductIDs() {
+ let criteria = InterfaceClassifier.matchCriteria
+ XCTAssertTrue(criteria.allSatisfy { $0.vendorID == 0x046D })
+ XCTAssertTrue(criteria.contains(HIDMatchCriterion(vendorID: 0x046D, usagePage: 0xFF00)))
+ XCTAssertTrue(criteria.contains(HIDMatchCriterion(vendorID: 0x046D, usagePage: 0xFF43)))
+ XCTAssertTrue(criteria.contains(HIDMatchCriterion(vendorID: 0x046D, usagePage: 0x01, usage: 0x02)))
+ XCTAssertEqual(criteria.count, 3)
+ }
+
+ // MARK: Identity
+
+ func testIdentityKeyFallsBackFromSerialToLocationToRegistryID() {
+ let withSerial = descriptor(0xB020, page: 1, usage: 2, location: 0x1234, serial: "ABC123", registry: 77)
+ XCTAssertEqual(InterfaceClassifier.identityKey(withSerial), "046D:B020:0001:0002:s:ABC123")
+
+ let withLocation = descriptor(0xB020, page: 1, usage: 2, location: 0x1234, registry: 77)
+ XCTAssertEqual(InterfaceClassifier.identityKey(withLocation), "046D:B020:0001:0002:l:1234")
+
+ let bare = descriptor(0xB020, page: 1, usage: 2, registry: 0x4D)
+ XCTAssertEqual(InterfaceClassifier.identityKey(bare), "046D:B020:0001:0002:r:4d")
+
+ // A whitespace-only serial counts as none.
+ let blank = descriptor(0xB020, page: 1, usage: 2, location: 0x1234, serial: " ")
+ XCTAssertEqual(InterfaceClassifier.identityKey(blank), InterfaceClassifier.identityKey(withLocation))
+
+ // Interfaces of one device differ by page and usage, so they are never one identity.
+ let vendor = descriptor(0xB020, page: 0xFF43, usage: 1, location: 0x1234, serial: "ABC123")
+ XCTAssertNotEqual(InterfaceClassifier.identityKey(vendor), InterfaceClassifier.identityKey(withSerial))
+ }
+
+ func testBLETwinsWithEmptySerialStaySeparateByRegistryID() {
+ // Two identical BLE mice that report neither a serial nor a location.
+ let a = descriptor(0xB034, transport: "Bluetooth Low Energy", page: 1, usage: 2, registry: 0x1000_0AAA)
+ let b = descriptor(0xB034, transport: "Bluetooth Low Energy", page: 1, usage: 2, registry: 0x1000_0BBB)
+ XCTAssertNotEqual(InterfaceClassifier.identityKey(a), InterfaceClassifier.identityKey(b))
+
+ // Without a location there is no way to call them twins, so both are opened.
+ XCTAssertEqual(InterfaceClassifier.twinsToReplace(adding: b, open: ["a": a]), [])
+ XCTAssertEqual(InterfaceClassifier.twinsToReplace(adding: a, open: ["b": b]), [])
+ XCTAssertFalse(InterfaceClassifier.isSamePhysicalDevice(a, b))
+
+ // The same interface seen twice keeps one identity.
+ XCTAssertEqual(InterfaceClassifier.identityKey(a), InterfaceClassifier.identityKey(a))
+
+ // Two BLE mice with different serials are separate even if a location were shared.
+ let c = descriptor(0xB034, transport: "Bluetooth Low Energy", page: 1, usage: 2, location: 0x55, serial: "mouse-1")
+ let d = descriptor(0xB034, transport: "Bluetooth Low Energy", page: 1, usage: 2, location: 0x55, serial: "mouse-2")
+ XCTAssertNotEqual(InterfaceClassifier.identityKey(c), InterfaceClassifier.identityKey(d))
+ XCTAssertEqual(InterfaceClassifier.twinsToReplace(adding: d, open: ["c": c]), [])
+ }
+
+ // MARK: Twins
+
+ func testTwinInterfacesOfOneDeviceCollapse() {
+ let mouse = descriptor(0xC08A, transport: "USB", page: 0x01, usage: 0x02, location: 0x1400_0000)
+ let vendor = descriptor(0xC08A, transport: "USB", page: 0xFF00, usage: 1, location: 0x1400_0000)
+ let otherVendor = descriptor(0xC08A, transport: "USB", page: 0xFF00, usage: 4, location: 0x1400_0000)
+
+ // Nothing open: open it.
+ XCTAssertEqual(InterfaceClassifier.twinsToReplace(adding: mouse, open: [Int: HIDInterfaceDescriptor]()), [])
+ XCTAssertEqual(InterfaceClassifier.twinsToReplace(adding: vendor, open: [Int: HIDInterfaceDescriptor]()), [])
+
+ // The mouse interface is open and the vendor interface arrives: the vendor one wins.
+ XCTAssertEqual(InterfaceClassifier.twinsToReplace(adding: vendor, open: [7: mouse]), [7])
+
+ // The vendor interface is open and the mouse interface arrives: leave it alone.
+ XCTAssertNil(InterfaceClassifier.twinsToReplace(adding: mouse, open: [7: vendor]))
+
+ // Two vendor interfaces of one device: the first one stays.
+ XCTAssertNil(InterfaceClassifier.twinsToReplace(adding: otherVendor, open: [7: vendor]))
+
+ // Two plain mouse interfaces of one device: the first one stays.
+ XCTAssertNil(InterfaceClassifier.twinsToReplace(adding: mouse, open: [7: mouse]))
+
+ // A vendor interface supersedes every open non-vendor twin, and only those.
+ let others = descriptor(0xB020, transport: "Bluetooth", page: 0x01, usage: 0x02, location: 0x99)
+ let superseded = InterfaceClassifier.twinsToReplace(adding: vendor, open: [1: mouse, 2: mouse, 3: others])
+ XCTAssertEqual(Set(superseded ?? []), Set([1, 2]))
+ }
+
+ func testDifferentDevicesAreNeverTwins() {
+ let a = descriptor(0xC08A, transport: "USB", page: 0xFF00, usage: 1, location: 0x1400_0000, serial: "A")
+ // Another location.
+ let elsewhere = descriptor(0xC08A, transport: "USB", page: 0xFF00, usage: 1, location: 0x1500_0000, serial: "A")
+ XCTAssertEqual(InterfaceClassifier.twinsToReplace(adding: elsewhere, open: [1: a]), [])
+ // Another product at the same location.
+ let otherProduct = descriptor(0xC52B, transport: "USB", page: 0xFF00, usage: 1, location: 0x1400_0000)
+ XCTAssertEqual(InterfaceClassifier.twinsToReplace(adding: otherProduct, open: [1: a]), [])
+ // The same model and location but contradicting serials.
+ let otherSerial = descriptor(0xC08A, transport: "USB", page: 0xFF00, usage: 1, location: 0x1400_0000, serial: "B")
+ XCTAssertEqual(InterfaceClassifier.twinsToReplace(adding: otherSerial, open: [1: a]), [])
+ // A missing serial on either side is no contradiction.
+ let noSerial = descriptor(0xC08A, transport: "USB", page: 0x01, usage: 0x02, location: 0x1400_0000)
+ XCTAssertNil(InterfaceClassifier.twinsToReplace(adding: noSerial, open: [1: a]))
+ // A zero location never matches, even against an identical descriptor.
+ let noLocation = descriptor(0xC08A, transport: "USB", page: 0xFF00, usage: 1)
+ XCTAssertEqual(InterfaceClassifier.twinsToReplace(adding: noLocation, open: [1: noLocation]), [])
+ }
+
+ // MARK: Descriptor
+
+ func testDescriptorSummaryAndUsageHelpers() {
+ let d = HIDInterfaceDescriptor(vendorID: 0x046D, productID: 0xB020, transport: "Bluetooth Low Energy", usagePage: 1, usage: 2, productName: "MX Vertical")
+ XCTAssertEqual(d.summary, "MX Vertical [046D:B020] via Bluetooth Low Energy, usage 0001:0002")
+ XCTAssertEqual(HIDInterfaceDescriptor(vendorID: 0x046D, productID: 0xC52B).summary, "Logitech device [046D:C52B], usage 0000:0000")
+ XCTAssertTrue(HIDInterfaceDescriptor(vendorID: 1, productID: 2).hasUnknownUsage)
+ XCTAssertFalse(d.hasUnknownUsage)
+ XCTAssertTrue(descriptor(1, page: 0xFF00).isVendorHIDPP)
+ XCTAssertTrue(descriptor(1, page: 0xFF43).isVendorHIDPP)
+ XCTAssertFalse(descriptor(1, page: 0x01, usage: 0x02).isVendorHIDPP)
+ }
+}
diff --git a/UprightKit/Tests/UprightKitTests/MappingLibraryTests.swift b/UprightKit/Tests/UprightKitTests/MappingLibraryTests.swift
new file mode 100644
index 0000000..a041277
--- /dev/null
+++ b/UprightKit/Tests/UprightKitTests/MappingLibraryTests.swift
@@ -0,0 +1,700 @@
+import XCTest
+@testable import UprightKit
+
+private let master = ModelKey(productID: 0xB034)
+private let lift = ModelKey(productID: 0xB031)
+private let prototype = ModelKey(productID: 0xABCD)
+
+/// A 2.x save (`MappingProfiles` encoded by `MappingStore.encoder()`) with every action kind, a
+/// full gesture set, `.deviceDefault` and gesture overrides, a disabled profile and a mixed-case
+/// bundle ID.
+private let everyKindV2 = #"""
+{"apps":[{"bundleID":"com.apple.Safari","displayName":"Safari","isEnabled":true,"overrides":{"253":{"deviceDefault":{}},"83":{"system":{"_0":"previousTab"}},"86":{"gestures":{"_0":{"down":{"deviceDefault":{}},"left":{"disabled":{}},"right":{"disabled":{}},"tap":{"disabled":{}},"up":{"system":{"_0":"nextTab"}}}}}}},{"bundleID":"com.apple.dt.Xcode","displayName":"Xcode","isEnabled":false,"overrides":{"83":{"keyCombo":{"_0":{"keyCode":17,"keyLabel":"T","modifiers":12}}}}},{"bundleID":"com.Example.MixedCase","displayName":"Mixed","isEnabled":true,"overrides":{"195":{"deviceDefault":{}},"82":{"system":{"_0":"copy"}}}}],"global":{"actions":{"195":{"device":{"_0":{"cycleDPI":{}}}},"196":{"device":{"_0":{"setDPI":{"_0":1200}}}},"208":{"openURL":{"_0":"mailto:me@example.com"}},"237":{"runShortcut":{"_0":"Toggle Focus"}},"253":{"gestures":{"_0":{"down":{"launchApp":{"_0":{"bundleID":"com.apple.Terminal","displayName":"Terminal"}}},"left":{"openURL":{"_0":"https:\/\/example.com\/left?a=b"}},"right":{"runShortcut":{"_0":"Make GIF"}},"tap":{"system":{"_0":"missionControl"}},"up":{"keyCombo":{"_0":{"keyCode":8,"keyLabel":"C","modifiers":10}}}}}},"256":{"system":{"_0":"volumeUp"}},"257":{"system":{"_0":"middleClick"}},"258":{"system":{"_0":"appExpose"}},"82":{"disabled":{}},"83":{"system":{"_0":"navigateBack"}},"86":{"keyCombo":{"_0":{"keyCode":17,"keyLabel":"T","modifiers":12}}},"91":{"device":{"_0":{"switchHost":{"_0":2}}}},"93":{"launchApp":{"_0":{"bundleID":"com.apple.Safari","displayName":"Safari"}}}}},"schemaVersion":2}
+"""#
+
+/// `everyKindV2`, built in code.
+private var everyKindProfiles: MappingProfiles {
+ let combo = KeyCombo(keyCode: KeyCode.t, modifiers: [.command, .shift], keyLabel: "T")
+ let gestures = GestureSet(
+ tap: .system(.missionControl),
+ up: .keyCombo(KeyCombo(keyCode: KeyCode.c, modifiers: [.command, .option])),
+ down: .launchApp(AppReference(bundleID: "com.apple.Terminal", displayName: "Terminal")),
+ left: .openURL(URL(string: "https://example.com/left?a=b")!),
+ right: .runShortcut("Make GIF")
+ )
+ var profiles = MappingProfiles(global: ButtonMappings([
+ KnownControl.back: .system(.navigateBack),
+ KnownControl.forward: .keyCombo(combo),
+ KnownControl.middleClick: .disabled,
+ KnownControl.dpiSwitch: .gestures(gestures),
+ KnownControl.gestureButton: .device(.cycleDPI),
+ KnownControl.smartShift: .device(.setDPI(1200)),
+ KnownControl.tiltLeft: .device(.switchHost(2)),
+ KnownControl.tiltRight: .launchApp(AppReference(bundleID: "com.apple.Safari", displayName: "Safari")),
+ KnownControl.multiPlatformGesture: .openURL(URL(string: "mailto:me@example.com")!),
+ KnownControl.dpiChange: .runShortcut("Toggle Focus"),
+ 0x0100: .system(.volumeUp),
+ 0x0101: .system(.middleClick),
+ 0x0102: .system(.appExpose),
+ ]))
+ profiles.upsert(AppProfile(bundleID: "com.apple.Safari", displayName: "Safari", overrides: [
+ KnownControl.back: .system(.previousTab),
+ KnownControl.dpiSwitch: .deviceDefault,
+ KnownControl.forward: .gestures(GestureSet(up: .system(.nextTab), down: .deviceDefault)),
+ ]))
+ profiles.upsert(AppProfile(bundleID: "com.apple.dt.Xcode", displayName: "Xcode", isEnabled: false, overrides: [
+ KnownControl.back: .keyCombo(combo),
+ ]))
+ profiles.upsert(AppProfile(bundleID: "com.Example.MixedCase", displayName: "Mixed", overrides: [
+ KnownControl.middleClick: .system(.copy),
+ KnownControl.gestureButton: .deviceDefault,
+ ]))
+ return profiles
+}
+
+/// Every v2 payload in MappingPersistenceTests and ProfileTests, as 2.x would have read it from
+/// `mappingProfiles.v2`.
+private let existingV2Fixtures: [(name: String, json: String)] = [
+ ("newer schema with unknown entries", #"{"schemaVersion":4,"global":{"actions":{"83":{"system":{"_0":"teleport"}},"86":{"warp":{"_0":{}}},"253":{"system":{"_0":"missionControl"}}}},"apps":[{"bundleID":"com.example","overrides":{"83":{"system":{"_0":"copy"}}}},{"nope":true},{"bundleID":"com.example.b","overrides":{"83":{"hologram":{}}}}]}"#),
+ ("skipped entries", #"{"schemaVersion":2,"global":{"actions":{"83":{"system":{"_0":"teleport"}},"86":{"disabled":{}}}},"apps":[]}"#),
+ ("dangerous entries", #"{"schemaVersion":2,"global":{"actions":{"80":{"disabled":{}},"83":{"runShortcut":{"_0":""}},"86":{"device":{"_0":{"setDPI":{"_0":99999}}}},"253":{"device":{"_0":{"cycleDPI":{}}}}}},"apps":[{"bundleID":" ","overrides":{}}]}"#),
+ ("version only", #"{"schemaVersion":2}"#),
+ ("empty tables", #"{"schemaVersion":2,"global":{"actions":{}},"apps":[]}"#),
+ ("app only", #"{"schemaVersion":2,"apps":[{"bundleID":"com.apple.Safari","overrides":{}}]}"#),
+ ("unknown action only", #"{"schemaVersion":2,"global":{"actions":{"83":{"zap":{}}}}}"#),
+ ("links", #"""
+ {"schemaVersion":2,"global":{"actions":{
+ "83":{"openURL":{"_0":"file:///Users/me/Downloads/x.command"}},
+ "86":{"openURL":{"_0":"mailto:me@example.com"}},
+ "82":{"openURL":{"_0":"HTTPS://example.com"}},
+ "253":{"gestures":{"_0":{"up":{"openURL":{"_0":"http://example.com"}},"down":{"openURL":{"_0":"vnc://host"}}}}}
+ }},"apps":[{"bundleID":"com.apple.Safari","overrides":{"83":{"openURL":{"_0":"javascript:alert(1)"}}}}]}
+ """#),
+ ("duplicate profiles", #"{"schemaVersion":2,"global":{"actions":{}},"apps":[{"bundleID":"a","displayName":"A","isEnabled":true,"overrides":{}},{"bundleID":"A","displayName":"A2","isEnabled":true,"overrides":{}}]}"#),
+ ("every kind", everyKindV2),
+]
+
+/// The setups the existing suites build in code, saved as 2.x saved them.
+private var existingV2Setups: [(name: String, profiles: MappingProfiles)] {
+ let combo = KeyCombo(keyCode: KeyCode.t, modifiers: [.command, .shift], keyLabel: "T")
+ var profileTests = MappingProfiles(global: .legacyDefaults)
+ profileTests.upsert(AppProfile(bundleID: "com.apple.Safari", displayName: "Safari", overrides: [
+ KnownControl.back: .system(.previousTab),
+ KnownControl.dpiSwitch: .deviceDefault,
+ ]))
+ profileTests.upsert(AppProfile(bundleID: "com.apple.dt.Xcode", displayName: "Xcode", isEnabled: false, overrides: [
+ KnownControl.back: .system(.undo),
+ ]))
+ var roundTrip = MappingProfiles()
+ roundTrip.global[KnownControl.back] = .keyCombo(combo)
+ roundTrip.addApp(bundleID: "com.apple.Safari", displayName: "Safari")
+ roundTrip[app: "com.apple.Safari"]?[KnownControl.back] = .deviceDefault
+ var export = MappingProfiles(global: .legacyDefaults)
+ export.upsert(AppProfile(bundleID: "com.apple.Safari", displayName: "Safari", overrides: [
+ KnownControl.back: .openURL(URL(string: "https://example.com/path")!),
+ ]))
+ var review = MappingProfiles(global: ButtonMappings([
+ KnownControl.back: .system(.navigateBack),
+ KnownControl.forward: .runShortcut("Make GIF"),
+ KnownControl.dpiSwitch: .gestures(GestureSet(
+ tap: .openURL(URL(string: "https://example.com")!),
+ up: .system(.missionControl),
+ left: .launchApp(AppReference(bundleID: "com.apple.Terminal", displayName: "Terminal"))
+ )),
+ ]))
+ review.upsert(AppProfile(bundleID: "com.apple.Safari", displayName: "Safari", overrides: [
+ KnownControl.back: .launchApp(AppReference(bundleID: "com.apple.Terminal", displayName: "Terminal")),
+ ]))
+ return [
+ ("ProfileTests.profiles", profileTests),
+ ("testSaveAndLoadRoundTrip", roundTrip),
+ ("testExportIsPrettyVersionedJSONAndImportsBack", export),
+ ("testImportListsExternalActionsForReview", review),
+ ("legacy defaults", MappingProfiles(global: .legacyDefaults)),
+ ("fresh", MappingProfiles()),
+ ]
+}
+
+private let v1Table = #"{"actions":{"83":{"system":{"_0":"navigateBack"}},"86":{"keyCombo":{"_0":{"keyCode":17,"keyLabel":"T","modifiers":12}}},"82":{"disabled":{}}}}"#
+
+private struct SchemaVersion: Decodable {
+ let schemaVersion: Int
+}
+
+final class MappingLibraryTests: XCTestCase {
+ private var suites: [(name: String, defaults: UserDefaults)] = []
+ private var defaults: UserDefaults!
+ private var logged: [String] = []
+
+ override func setUpWithError() throws {
+ logged = []
+ defaults = try makeDefaults()
+ }
+
+ override func tearDown() {
+ suites.forEach { $0.defaults.removePersistentDomain(forName: $0.name) }
+ suites = []
+ }
+
+ private func makeDefaults() throws -> UserDefaults {
+ let name = "UprightLibraryTests-\(UUID().uuidString)"
+ let defaults = try XCTUnwrap(UserDefaults(suiteName: name))
+ suites.append((name, defaults))
+ return defaults
+ }
+
+ private func makeStore(_ defaults: UserDefaults? = nil) -> MappingStore {
+ MappingStore(defaults: defaults ?? self.defaults, log: { [unowned self] in logged.append($0) }, now: { Date(timeIntervalSince1970: 1_700_000_000) })
+ }
+
+ private func schemaVersion(_ data: Data?) throws -> Int {
+ try JSONDecoder().decode(SchemaVersion.self, from: XCTUnwrap(data)).schemaVersion
+ }
+
+ private func json(_ data: Data?) throws -> [String: Any] {
+ let data = try XCTUnwrap(data)
+ return try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any])
+ }
+
+ // MARK: Migration
+
+ func testV2MigratesToMXVerticalEquivalentForEveryBundle() throws {
+ // The literal fixture is exactly what 2.x saved for `everyKindProfiles`.
+ XCTAssertEqual(try MappingStore.decoder(report: MappingDecodingReport()).decode(MappingProfiles.self, from: Data(everyKindV2.utf8)), everyKindProfiles)
+
+ var fixtures = existingV2Fixtures.map { ($0.name, Data($0.json.utf8)) }
+ for setup in existingV2Setups {
+ fixtures.append((setup.name, try MappingStore.encoder().encode(setup.profiles)))
+ }
+ XCTAssertEqual(fixtures.count, 16)
+
+ for (name, data) in fixtures {
+ // What 2.x's load returned for this data.
+ let today = try MappingStore.decoder(report: MappingDecodingReport()).decode(MappingProfiles.self, from: data)
+
+ let defaults = try makeDefaults()
+ defaults.set(data, forKey: MappingStore.v2Key)
+ let (library, report) = makeStore(defaults).loadLibrary()
+ XCTAssertEqual(report.origin, .migratedV2, name)
+ XCTAssertEqual(Array(library.models.keys), [.mxVertical], name)
+ XCTAssertEqual(library[.mxVertical], today, name)
+
+ let (reloaded, again) = makeStore(defaults).loadLibrary()
+ XCTAssertEqual(again.origin, .library, name)
+ XCTAssertTrue(again.backupKeys.isEmpty, name)
+ XCTAssertEqual(reloaded, library, name)
+
+ let bundles: [String?] = [nil, "", "com.example.unknown"]
+ + today.apps.flatMap { [$0.bundleID, $0.bundleID.uppercased(), $0.bundleID.lowercased()] }
+ for bundleID in bundles {
+ let expected = today.effectiveMappings(forBundleID: bundleID)
+ for candidate in [library, reloaded] {
+ let effective = candidate.effectiveMappings(model: .mxVertical, bundleID: bundleID)
+ XCTAssertEqual(effective, expected, "\(name) / \(bundleID ?? "nil")")
+ for trusted in [false, true] {
+ XCTAssertEqual(effective.divertedControls(accessibilityGranted: trusted), expected.divertedControls(accessibilityGranted: trusted), name)
+ XCTAssertEqual(effective.gestureControls(accessibilityGranted: trusted), expected.gestureControls(accessibilityGranted: trusted), name)
+ }
+ // Every other model, connected or not, stays native.
+ XCTAssertTrue(candidate.effectiveMappings(model: master, bundleID: bundleID).isEmpty, name)
+ }
+ }
+ XCTAssertEqual(library.allActiveActions.count, today.allActiveActions.count, name)
+ XCTAssertEqual(Set(library.allActiveActions), Set(today.allActiveActions), name)
+ XCTAssertEqual(
+ library.allActiveActions.contains { $0.requiresAccessibility },
+ today.allActiveActions.contains { $0.requiresAccessibility },
+ name
+ )
+ }
+ }
+
+ func testV2WithUnreadableEntriesIsBackedUpThenMigrated() throws {
+ let raw = Data(existingV2Fixtures[1].json.utf8)
+ defaults.set(raw, forKey: MappingStore.v2Key)
+ let store = makeStore()
+ let (library, report) = store.loadLibrary()
+ XCTAssertEqual(library[.mxVertical].global, ButtonMappings([KnownControl.forward: .disabled]))
+ XCTAssertEqual(report.issues.count, 1, "\(report.issues)")
+ XCTAssertEqual(report.backupKeys, store.backupKeys)
+ XCTAssertEqual(store.backupKeys.count, 1)
+ XCTAssertEqual(store.backupKeys.first.flatMap { defaults.data(forKey: $0) }, raw)
+ XCTAssertEqual(defaults.data(forKey: MappingStore.preV3BackupKey), raw)
+
+ XCTAssertEqual(makeStore().loadLibrary().library, library)
+ XCTAssertEqual(store.backupKeys.count, 1)
+ }
+
+ func testUnreadableV2IsBackedUpAndMigratesEmpty() throws {
+ let garbage = Data("not json".utf8)
+ defaults.set(garbage, forKey: MappingStore.v2Key)
+ defaults.set(Data(v1Table.utf8), forKey: MappingStore.legacyKey)
+ let store = makeStore()
+ let (library, report) = store.loadLibrary()
+ // As in 2.x: unreadable saved mappings start over empty, without falling back to 1.x data.
+ XCTAssertEqual(report.origin, .migratedV2)
+ XCTAssertTrue(library.effectiveMappings(model: .mxVertical, bundleID: nil).isEmpty)
+ XCTAssertEqual(store.backupKeys.count, 1)
+ XCTAssertEqual(defaults.data(forKey: MappingStore.preV3BackupKey), garbage)
+ XCTAssertEqual(defaults.data(forKey: MappingStore.v2Key), garbage)
+ }
+
+ func testV1LegacyMigratesIntoMXVertical() throws {
+ defaults.set(Data(v1Table.utf8), forKey: MappingStore.legacyKey)
+ let (library, report) = makeStore().loadLibrary()
+ XCTAssertEqual(report.origin, .migratedV1)
+ XCTAssertEqual(Array(library.models.keys), [.mxVertical])
+ XCTAssertEqual(library[.mxVertical], MappingProfiles(global: try JSONDecoder().decode(ButtonMappings.self, from: Data(v1Table.utf8))))
+ XCTAssertEqual(library.name(of: .mxVertical), "MX Vertical")
+ // The 1.x key stays for downgrades; nothing is written under the 2.x keys.
+ XCTAssertEqual(defaults.data(forKey: MappingStore.legacyKey), Data(v1Table.utf8))
+ XCTAssertNil(defaults.object(forKey: MappingStore.v2Key))
+ XCTAssertNil(defaults.object(forKey: MappingStore.preV3BackupKey))
+ XCTAssertEqual(makeStore().loadLibrary().library, library)
+ }
+
+ func testUpgradeWithoutDataGivesLegacyDefaultsOnMXVertical() throws {
+ defaults.set(true, forKey: "hasLaunchedBefore")
+ let (library, report) = makeStore().loadLibrary()
+ XCTAssertEqual(report.origin, .upgradeDefaults)
+ XCTAssertEqual(library[.mxVertical], MappingProfiles(global: .legacyDefaults))
+ XCTAssertTrue(library.effectiveMappings(model: master, bundleID: nil).isEmpty)
+ XCTAssertNil(defaults.object(forKey: MappingStore.v2Key))
+ XCTAssertEqual(makeStore().loadLibrary().report.origin, .library)
+ }
+
+ func testFreshInstallIsEmptyForEveryModel() throws {
+ let store = makeStore()
+ XCTAssertFalse(store.hasPriorLaunch)
+ let (library, report) = store.loadLibrary()
+ XCTAssertEqual(report.origin, .freshInstall)
+ XCTAssertEqual(library, MappingLibrary())
+ for model in [ModelKey.mxVertical, master, lift, ModelKey(name: "Some Mouse")] {
+ XCTAssertTrue(library.effectiveMappings(model: model, bundleID: nil).isEmpty)
+ XCTAssertTrue(library.effectiveMappings(model: model, bundleID: "com.apple.Safari").isEmpty)
+ }
+ XCTAssertTrue(library.allActiveActions.isEmpty)
+
+ // Saved, so the next launch (now with prior-launch markers) doesn't switch to the 1.x defaults.
+ defaults.set(true, forKey: "hasLaunchedBefore")
+ let next = makeStore().loadLibrary()
+ XCTAssertEqual(next.report.origin, .library)
+ XCTAssertEqual(next.library, library)
+ XCTAssertNil(defaults.object(forKey: MappingStore.v2Key))
+ XCTAssertTrue(store.backupKeys.isEmpty)
+ }
+
+ func testV2KeyNeverWrittenAfterMigration() throws {
+ let original = try MappingStore.encoder().encode(everyKindProfiles)
+ defaults.set(original, forKey: MappingStore.v2Key)
+ let store = makeStore()
+ var library = store.loadLibrary().library
+
+ library[.mxVertical].global[KnownControl.back] = .system(.copy)
+ library[master] = MappingProfiles(global: ButtonMappings([KnownControl.forward: .disabled]))
+ store.save(library)
+ store.save(MappingProfiles(global: .legacyDefaults))
+ _ = store.load()
+ _ = store.loadLibrary()
+
+ XCTAssertEqual(defaults.data(forKey: MappingStore.v2Key), original)
+ XCTAssertEqual(defaults.data(forKey: MappingStore.preV3BackupKey), original)
+ let saved = store.loadLibrary().library
+ XCTAssertEqual(saved[.mxVertical].global, .legacyDefaults)
+ XCTAssertEqual(saved[master], library[master])
+
+ // Once a library exists it wins, even if a downgraded build changes the 2.x data later.
+ defaults.set(Data(#"{"schemaVersion":2,"global":{"actions":{}},"apps":[]}"#.utf8), forKey: MappingStore.v2Key)
+ XCTAssertEqual(makeStore().loadLibrary().library, saved)
+ }
+
+ func testPreV3BackupWrittenOnce() throws {
+ let first = try MappingStore.encoder().encode(MappingProfiles(global: .legacyDefaults))
+ defaults.set(first, forKey: MappingStore.v2Key)
+ let store = makeStore()
+ XCTAssertNil(defaults.object(forKey: MappingStore.preV3BackupKey))
+ _ = store.loadLibrary()
+ XCTAssertEqual(defaults.data(forKey: MappingStore.preV3BackupKey), first)
+ // It isn't one of the corrupt-data backups.
+ XCTAssertTrue(store.backupKeys.isEmpty)
+
+ // A second migration (the library was removed and 2.x data changed) keeps the first copy.
+ let second = try MappingStore.encoder().encode(MappingProfiles(global: ButtonMappings([KnownControl.back: .disabled])))
+ defaults.removeObject(forKey: MappingStore.profilesKey)
+ defaults.set(second, forKey: MappingStore.v2Key)
+ let library = store.loadLibrary().library
+ XCTAssertEqual(library[.mxVertical].global, ButtonMappings([KnownControl.back: .disabled]))
+ XCTAssertEqual(defaults.data(forKey: MappingStore.preV3BackupKey), first)
+ }
+
+ func testCorruptLibraryIsBackedUpAndStartsEmpty() throws {
+ let garbage = Data("{\"models\":".utf8)
+ defaults.set(garbage, forKey: MappingStore.profilesKey)
+ let v2 = try MappingStore.encoder().encode(MappingProfiles(global: .legacyDefaults))
+ defaults.set(v2, forKey: MappingStore.v2Key)
+ let store = makeStore()
+ let (library, report) = store.loadLibrary()
+ // First match wins: the 2.x data was already migrated once and isn't read again.
+ XCTAssertEqual(report.origin, .library)
+ XCTAssertEqual(library, MappingLibrary())
+ XCTAssertEqual(report.issues, ["saved mappings could not be read"])
+ XCTAssertEqual(report.backupKeys, [MappingStore.backupKeyPrefix + "1700000000"])
+ XCTAssertEqual(defaults.data(forKey: MappingStore.backupKeyPrefix + "1700000000"), garbage)
+ XCTAssertEqual(defaults.data(forKey: MappingStore.v2Key), v2)
+ XCTAssertFalse(logged.isEmpty)
+ XCTAssertEqual(makeStore().loadLibrary().library, MappingLibrary())
+ }
+
+ // MARK: Schema 3
+
+ func testRoundTripPreservesUnknownModelsDevicesAndTopLevel() throws {
+ let stored = #"""
+ {"schemaVersion":3,
+ "models":{
+ "046D:B020":{"name":"MX Vertical","global":{"actions":{"83":{"system":{"_0":"navigateBack"}}}},"apps":[]},
+ "046d:abcd":{"name":"Prototype","global":{"actions":{"86":{"disabled":{}}}},"apps":[{"bundleID":"com.apple.Safari","displayName":"Safari","isEnabled":true,"overrides":{"86":{"deviceDefault":{}}}}]},
+ "name:mystery mouse":{"global":{"actions":{"82":{"system":{"_0":"copy"}}}},"apps":[]},
+ "046D:B034":{"name":"Desk mouse"}
+ },
+ "devices":{"u:046D:B020:1A2B3C4D":{"nickname":"Desk","remap":false,"dpi":1600,"ratio":0.5,"tags":["a",null,true]}},
+ "futureFeature":{"enabled":true,"levels":[1,2,3]},
+ "note":"hello"}
+ """#
+ defaults.set(Data(stored.utf8), forKey: MappingStore.profilesKey)
+ let store = makeStore()
+ let (library, report) = store.loadLibrary()
+ XCTAssertEqual(report.origin, .library)
+ XCTAssertTrue(report.issues.isEmpty, "\(report.issues)")
+ XCTAssertTrue(store.backupKeys.isEmpty)
+
+ let mystery = ModelKey(name: "Mystery Mouse")
+ XCTAssertEqual(Set(library.models.keys), [.mxVertical, prototype, mystery])
+ XCTAssertEqual(library.modelNames, [.mxVertical: "MX Vertical", prototype: "Prototype", master: "Desk mouse"])
+ XCTAssertEqual(library.name(of: master), "Desk mouse")
+ XCTAssertEqual(library.name(of: mystery), "name:mystery mouse")
+ XCTAssertEqual(library.effectiveMappings(model: prototype, bundleID: "com.apple.Safari")[KnownControl.forward], .deviceDefault)
+ XCTAssertEqual(library.effectiveMappings(model: mystery, bundleID: nil)[KnownControl.middleClick], .system(.copy))
+ XCTAssertEqual(library.reservedDevices, ["u:046D:B020:1A2B3C4D": .object([
+ "nickname": .string("Desk"),
+ "remap": .bool(false),
+ "dpi": .number(1600),
+ "ratio": .number(0.5),
+ "tags": .array([.string("a"), .null, .bool(true)]),
+ ])])
+ XCTAssertEqual(library.unknownTopLevel, [
+ "futureFeature": .object(["enabled": .bool(true), "levels": .array([.number(1), .number(2), .number(3)])]),
+ "note": .string("hello"),
+ ])
+
+ store.save(library)
+ XCTAssertEqual(makeStore().loadLibrary().library, library)
+ XCTAssertEqual(try schemaVersion(defaults.data(forKey: MappingStore.profilesKey)), 3)
+ let saved = try json(defaults.data(forKey: MappingStore.profilesKey))
+ XCTAssertNotNil(saved["devices"])
+ XCTAssertNotNil(saved["futureFeature"])
+ XCTAssertNil(saved["global"], "the 2.x mirror is for exports only")
+ let models = try XCTUnwrap(saved["models"] as? [String: Any])
+ XCTAssertEqual(Set(models.keys), ["046D:B020", "046D:ABCD", "name:mystery mouse", "046D:B034"])
+ XCTAssertEqual((models["046D:B034"] as? [String: Any])?.keys.sorted(), ["name"])
+ XCTAssertNil((models["name:mystery mouse"] as? [String: Any])?["name"], "only stored names are saved")
+ }
+
+ func testNewerSchemaNoteAndBackup() throws {
+ let newer = Data(#"{"schemaVersion":4,"models":{"046D:B020":{"name":"MX Vertical","global":{"actions":{"83":{"system":{"_0":"navigateBack"}},"86":{"hologram":{}}}},"apps":[]}},"devices":{},"perUnitOverrides":{"x":1}}"#.utf8)
+ defaults.set(newer, forKey: MappingStore.profilesKey)
+ let store = makeStore()
+ let (library, report) = store.loadLibrary()
+ XCTAssertEqual(report.issues.count, 2, "\(report.issues)")
+ XCTAssertTrue(report.issues[0].contains("newer version (schema 4)"), report.issues[0])
+ XCTAssertEqual(store.backupKeys.count, 1)
+ XCTAssertEqual(store.backupKeys.first.flatMap { defaults.data(forKey: $0) }, newer)
+ XCTAssertEqual(library[.mxVertical].global, ButtonMappings([KnownControl.back: .system(.navigateBack)]))
+ XCTAssertEqual(library.unknownTopLevel, ["perUnitOverrides": .object(["x": .number(1)])])
+
+ // Rewritten as schema 3 (keeping what it understood), so the next launch reads it cleanly.
+ XCTAssertEqual(try schemaVersion(defaults.data(forKey: MappingStore.profilesKey)), 3)
+ let next = makeStore().loadLibrary()
+ XCTAssertTrue(next.report.issues.isEmpty)
+ XCTAssertEqual(next.library, library)
+ XCTAssertEqual(store.backupKeys.count, 1)
+ }
+
+ func testInvalidAndDuplicateModelKeysAreNoted() throws {
+ let data = Data(#"{"schemaVersion":3,"models":{"nonsense":{"global":{"actions":{}}},"046d:b020":{"global":{"actions":{"83":{"disabled":{}}}}},"046D:B020":{"global":{"actions":{"86":{"disabled":{}}}}},"046D:B034":7}}"#.utf8)
+ let report = MappingDecodingReport()
+ let library = try MappingStore.decoder(report: report).decode(MappingLibrary.self, from: data)
+ XCTAssertEqual(Array(library.models.keys), [.mxVertical])
+ XCTAssertEqual(report.issues.count, 3, "\(report.issues)")
+ }
+
+ func testCompatLoadAndSaveOnlyTouchMXVertical() throws {
+ let store = makeStore()
+ var library = MappingLibrary(migrating: MappingProfiles(global: .legacyDefaults))
+ library[master] = MappingProfiles(global: ButtonMappings([KnownControl.back: .system(.copy)]))
+ library.modelNames[master] = "Desk mouse"
+ library.reservedDevices = ["u:046D:B034:00000001": .object(["nickname": .string("Desk")])]
+ library.unknownTopLevel = ["future": .bool(true)]
+ store.save(library)
+
+ let replacement = MappingProfiles(global: ButtonMappings([KnownControl.forward: .disabled]))
+ store.save(replacement)
+ XCTAssertEqual(store.load(), replacement)
+ var expected = library
+ expected[.mxVertical] = replacement
+ XCTAssertEqual(store.loadLibrary().library, expected)
+ XCTAssertNil(defaults.object(forKey: MappingStore.v2Key))
+ }
+
+ func testUnknownModelResolvesToEmpty() throws {
+ var library = MappingLibrary(migrating: MappingProfiles(global: .legacyDefaults))
+ XCTAssertTrue(library.effectiveMappings(model: nil, bundleID: nil).isEmpty)
+ XCTAssertTrue(library.effectiveMappings(model: prototype, bundleID: "com.apple.Safari").isEmpty)
+ XCTAssertTrue(library.effectiveMappings(model: ModelKey(name: "Gadget"), bundleID: nil).isEmpty)
+ XCTAssertEqual(library[prototype], MappingProfiles())
+ XCTAssertNil(library.models[prototype], "reading a model doesn't add it")
+ XCTAssertEqual(library.name(of: prototype), "046D:ABCD")
+ XCTAssertEqual(library.name(of: .mxVertical), "MX Vertical")
+
+ library[prototype].global[KnownControl.back] = .disabled
+ XCTAssertEqual(library.effectiveMappings(model: prototype, bundleID: nil), ButtonMappings([KnownControl.back: .disabled]))
+ XCTAssertEqual(library.effectiveMappings(model: .mxVertical, bundleID: nil), .legacyDefaults)
+ }
+
+ func testAllActiveActionsUnionsModels() throws {
+ let combo = ButtonAction.keyCombo(KeyCombo(keyCode: KeyCode.t, modifiers: [.command]))
+ var vertical = MappingProfiles(global: ButtonMappings([KnownControl.back: .system(.missionControl)]))
+ vertical.upsert(AppProfile(bundleID: "com.apple.dt.Xcode", displayName: "Xcode", isEnabled: false, overrides: [KnownControl.back: combo]))
+ var library = MappingLibrary(migrating: vertical)
+ // Only the MX Vertical: exactly that setup's actions, so nothing needs Accessibility.
+ XCTAssertEqual(library.allActiveActions, vertical.allActiveActions)
+ XCTAssertFalse(library.allActiveActions.contains { $0.requiresAccessibility })
+
+ library[master] = MappingProfiles(global: ButtonMappings([KnownControl.forward: combo]))
+ XCTAssertEqual(library.allActiveActions, vertical.allActiveActions + [combo])
+ XCTAssertTrue(library.allActiveActions.contains { $0.requiresAccessibility })
+ }
+
+ func testSuggestedPerModel() throws {
+ let prototypeControls: [UInt16] = [KnownControl.leftClick, KnownControl.rightClick, KnownControl.middleClick,
+ KnownControl.back, KnownControl.forward, KnownControl.gestureButton, KnownControl.smartShift]
+ XCTAssertEqual(ButtonMappings.suggested(for: .mxVertical, controls: []), .legacyDefaults)
+ XCTAssertEqual(ButtonMappings.suggested(for: .mxVertical, controls: prototypeControls), .legacyDefaults)
+ XCTAssertEqual(ButtonMappings.suggested(for: prototype, controls: prototypeControls), ButtonMappings([
+ KnownControl.back: .system(.navigateBack),
+ KnownControl.forward: .system(.navigateForward),
+ KnownControl.gestureButton: .system(.missionControl),
+ ]))
+ XCTAssertEqual(ButtonMappings.suggested(for: prototype, controls: [KnownControl.multiPlatformGesture, KnownControl.back]), ButtonMappings([
+ KnownControl.back: .system(.navigateBack),
+ KnownControl.multiPlatformGesture: .system(.missionControl),
+ ]))
+ XCTAssertEqual(ButtonMappings.suggested(for: prototype, controls: [KnownControl.multiPlatformGesture, KnownControl.gestureButton]), ButtonMappings([
+ KnownControl.gestureButton: .system(.missionControl),
+ ]))
+ XCTAssertTrue(ButtonMappings.suggested(for: prototype, controls: [KnownControl.leftClick, KnownControl.rightClick, KnownControl.middleClick]).isEmpty)
+ XCTAssertTrue(ButtonMappings.suggested(for: master, controls: []).isEmpty)
+ // Back is ⌘[ and Forward ⌘], as on the MX Vertical.
+ XCTAssertEqual(SystemAction.navigateBack.effect, .keyCombo(KeyCombo(keyCode: KeyCode.leftBracket, modifiers: .command)))
+ XCTAssertEqual(SystemAction.navigateForward.effect, .keyCombo(KeyCombo(keyCode: KeyCode.rightBracket, modifiers: .command)))
+ }
+
+ // MARK: Export
+
+ func testExportHasCompatMirrorAndNames() throws {
+ var vertical = MappingProfiles(global: .legacyDefaults)
+ vertical.upsert(AppProfile(bundleID: "com.apple.Safari", displayName: "Safari", overrides: [
+ KnownControl.back: .openURL(URL(string: "https://example.com/path")!),
+ ]))
+ var library = MappingLibrary(migrating: vertical)
+ library[master] = MappingProfiles(global: ButtonMappings([KnownControl.gestureButton: .system(.missionControl)]))
+ library[prototype] = MappingProfiles(global: ButtonMappings([KnownControl.back: .disabled]))
+ library.modelNames[lift] = "Desk mouse"
+ library.reservedDevices = ["u:046D:B020:1A2B3C4D": .object(["nickname": .string("Desk")])]
+ library.unknownTopLevel = ["future": .bool(true)]
+
+ let data = makeStore().export(library)
+ XCTAssertEqual(data, try MappingStore.exportData(library))
+ let text = String(decoding: data, as: UTF8.self)
+ XCTAssertTrue(text.contains("\"schemaVersion\" : 3"), text)
+ XCTAssertTrue(text.contains("\n"))
+ XCTAssertTrue(text.contains("https://example.com/path"))
+
+ let file = try json(data)
+ XCTAssertNil(file["devices"], "per-Mac data isn't exported")
+ XCTAssertNil(file["future"])
+ let models = try XCTUnwrap(file["models"] as? [String: Any])
+ XCTAssertEqual(Set(models.keys), ["046D:B020", "046D:B034", "046D:ABCD"], "a name without a setup isn't exported")
+ XCTAssertEqual((models["046D:B020"] as? [String: Any])?["name"] as? String, "MX Vertical")
+ XCTAssertEqual((models["046D:B034"] as? [String: Any])?["name"] as? String, MappingLibrary.catalogName(of: master))
+ XCTAssertEqual((models["046D:ABCD"] as? [String: Any])?["name"] as? String, "046D:ABCD")
+
+ // A 2.x build reads the top-level mirror: the MX Vertical's setup, after one "newer version" note.
+ let report = MappingDecodingReport()
+ XCTAssertEqual(try MappingStore.decoder(report: report).decode(MappingProfiles.self, from: data), vertical)
+ XCTAssertEqual(report.issues.count, 1, "\(report.issues)")
+ // This build reads the sections and ignores the mirror.
+ XCTAssertEqual(try MappingStore.decoder(report: MappingDecodingReport()).decode(MappingLibrary.self, from: data).models, library.models)
+ XCTAssertEqual(try MappingStore.importData(data), vertical)
+
+ // Without an MX Vertical setup there is nothing to mirror.
+ library.models[.mxVertical] = nil
+ let other = try json(MappingStore.exportData(library))
+ XCTAssertNil(other["global"])
+ XCTAssertNil(other["apps"])
+ XCTAssertThrowsError(try MappingStore.importData(MappingStore.exportData(library))) { error in
+ XCTAssertEqual(error as? MappingStore.ImportError, .noMappings)
+ }
+ }
+
+ func testLibraryBackupsRoundTrip() throws {
+ let store = makeStore()
+ var library = MappingLibrary(migrating: everyKindProfiles)
+ library[master] = MappingProfiles(global: ButtonMappings([KnownControl.back: .disabled]))
+ library.reservedDevices = ["x": .null]
+ let key = try XCTUnwrap(store.backUp(library, reason: "test"))
+ XCTAssertEqual(store.loadLibraryBackup(key), library)
+ XCTAssertEqual(store.loadBackup(key), everyKindProfiles)
+
+ // 2.x-format backups read as the MX Vertical's setup.
+ let v2Key = try XCTUnwrap(store.backUp(MappingProfiles(global: .legacyDefaults), reason: "test"))
+ XCTAssertEqual(store.loadLibraryBackup(v2Key)?.models, [.mxVertical: MappingProfiles(global: .legacyDefaults)])
+ XCTAssertEqual(store.loadBackup(v2Key), MappingProfiles(global: .legacyDefaults))
+ XCTAssertNil(store.loadBackup("missing"))
+ }
+
+ // MARK: Import
+
+ func testImportV3MergesPerModel() throws {
+ var current = MappingLibrary(migrating: MappingProfiles(global: .legacyDefaults))
+ current[master] = MappingProfiles(global: ButtonMappings([KnownControl.back: .system(.copy)]))
+ current[lift] = MappingProfiles(global: ButtonMappings([KnownControl.forward: .system(.paste)]))
+ current.reservedDevices = ["u:046D:B020:1A2B3C4D": .object(["nickname": .string("Mine")])]
+ current.unknownTopLevel = ["future": .bool(true)]
+
+ let file = Data(#"""
+ {"schemaVersion":3,
+ "models":{
+ "046D:B020":{"name":"MX Vertical","global":{"actions":{"83":{"runShortcut":{"_0":"Make GIF"}}}},"apps":[{"bundleID":"com.apple.Safari","displayName":"Safari","isEnabled":true,"overrides":{"86":{"openURL":{"_0":"https://example.com"}}}}]},
+ "046D:B034":{"name":"MX Master 3S","global":{"actions":{}},"apps":[]},
+ "046D:ABCD":{"name":"Prototype","global":{"actions":{"80":{"disabled":{}},"83":{"launchApp":{"_0":{"bundleID":"com.apple.Terminal","displayName":"Terminal"}}}}},"apps":[]}
+ },
+ "devices":{"u:046D:B020:00000001":{"nickname":"Theirs"}},
+ "theirFlag":true,
+ "global":{"actions":{"83":{"disabled":{}}}},"apps":[]}
+ """#.utf8)
+
+ let store = makeStore()
+ let preview = try store.prepareImport(file, targetModel: .mxVertical)
+ XCTAssertTrue(store.backupKeys.isEmpty, "previewing changes nothing")
+ XCTAssertEqual(preview.format, .library)
+ // The empty MX Master section has nothing to import: that model keeps its setup.
+ XCTAssertEqual(preview.importedModels, [prototype, .mxVertical])
+ XCTAssertEqual(preview.modelNames, [prototype: "Prototype", .mxVertical: "MX Vertical"])
+ XCTAssertEqual(preview.skipped, ["Prototype · global: skipped mapping for primary click control 80"])
+ let shortcut = ButtonAction.runShortcut("Make GIF")
+ let link = ButtonAction.openURL(URL(string: "https://example.com")!)
+ let terminal = ButtonAction.launchApp(AppReference(bundleID: "com.apple.Terminal", displayName: "Terminal"))
+ XCTAssertEqual(preview.actionsToReview, [
+ .init(model: prototype, modelName: "Prototype", bundleID: nil, cid: KnownControl.back, slot: .button, action: terminal),
+ .init(model: .mxVertical, modelName: "MX Vertical", bundleID: nil, cid: KnownControl.back, slot: .button, action: shortcut),
+ .init(model: .mxVertical, modelName: "MX Vertical", bundleID: "com.apple.Safari", cid: KnownControl.forward, slot: .button, action: link),
+ ])
+
+ let (merged, backupKey) = store.applyImport(preview, to: current)
+ XCTAssertEqual(store.loadLibraryBackup(try XCTUnwrap(backupKey)), current)
+ XCTAssertEqual(merged[.mxVertical].global, ButtonMappings([KnownControl.back: shortcut]))
+ XCTAssertEqual(merged[.mxVertical][app: "com.apple.Safari"]?[KnownControl.forward], link)
+ XCTAssertEqual(merged[prototype].global, ButtonMappings([KnownControl.back: terminal]))
+ XCTAssertEqual(merged[master], current[master])
+ XCTAssertEqual(merged[lift], current[lift])
+ XCTAssertEqual(merged.name(of: prototype), "Prototype")
+ // The file's devices and unknown keys describe someone else's Mac; ours stay.
+ XCTAssertEqual(merged.reservedDevices, current.reservedDevices)
+ XCTAssertEqual(merged.unknownTopLevel, current.unknownTopLevel)
+ }
+
+ func testImportV2IntoChosenModel() throws {
+ let link = ButtonAction.openURL(URL(string: "https://example.com")!)
+ let shortcut = ButtonAction.runShortcut("Make GIF")
+ var profiles = MappingProfiles(global: ButtonMappings([KnownControl.back: link, KnownControl.leftClick: .disabled]))
+ profiles.upsert(AppProfile(bundleID: "com.apple.Safari", displayName: "Safari", overrides: [KnownControl.forward: shortcut]))
+ let file = try MappingStore.encoder().encode(profiles)
+ var expected = profiles
+ expected.global[KnownControl.leftClick] = .deviceDefault
+
+ let store = makeStore()
+ let preview = try store.prepareImport(file, targetModel: master)
+ XCTAssertEqual(preview.format, .singleModel)
+ XCTAssertEqual(preview.models, [master: expected])
+ XCTAssertTrue(preview.modelNames.isEmpty)
+ XCTAssertEqual(preview.skipped, ["global: skipped mapping for primary click control 80"])
+ let name = MappingLibrary.catalogName(of: master)
+ XCTAssertEqual(preview.actionsToReview, [
+ .init(model: master, modelName: name, bundleID: nil, cid: KnownControl.back, slot: .button, action: link),
+ .init(model: master, modelName: name, bundleID: "com.apple.Safari", cid: KnownControl.forward, slot: .button, action: shortcut),
+ ])
+ let current = MappingLibrary(migrating: MappingProfiles(global: .legacyDefaults))
+ let merged = preview.applied(to: current)
+ XCTAssertEqual(merged[.mxVertical], current[.mxVertical])
+ XCTAssertEqual(merged[master], expected)
+
+ // Review items use the name this Mac knows the target model by.
+ var saved = MappingLibrary()
+ saved.modelNames[lift] = "Desk mouse"
+ store.save(saved)
+ XCTAssertEqual(try store.prepareImport(file, targetModel: lift).actionsToReview.first?.modelName, "Desk mouse")
+
+ // A 1.x table works the same way; by default it goes to the MX Vertical.
+ let legacy = try store.prepareImport(Data(v1Table.utf8), targetModel: .mxVertical)
+ XCTAssertEqual(legacy.format, .singleModel)
+ XCTAssertEqual(legacy.importedModels, [.mxVertical])
+ XCTAssertEqual(legacy.models[.mxVertical]?.global, try JSONDecoder().decode(ButtonMappings.self, from: Data(v1Table.utf8)))
+ }
+
+ func testImportProtectsPrimaryAndDeviceControlsPerModel() throws {
+ let file = Data(#"{"schemaVersion":3,"models":{"046D:B034":{"global":{"actions":{"81":{"disabled":{}},"82":{"disabled":{}},"83":{"disabled":{}}}},"apps":[{"bundleID":"com.apple.Safari","overrides":{"82":{"system":{"_0":"copy"}}}}]},"046D:B020":{"global":{"actions":{"82":{"disabled":{}}}},"apps":[]}}}"#.utf8)
+ let preview = try makeStore().prepareImport(file, targetModel: .mxVertical, protecting: [master: [KnownControl.middleClick]])
+ XCTAssertEqual(preview.models[master]?.global, ButtonMappings([KnownControl.back: .disabled]))
+ XCTAssertEqual(preview.models[master]?[app: "com.apple.Safari"]?.overrides, [:])
+ XCTAssertEqual(preview.models[.mxVertical]?.global, ButtonMappings([KnownControl.middleClick: .disabled]), "protection is per model")
+ XCTAssertEqual(preview.skipped.count, 3, "\(preview.skipped)")
+ }
+
+ func testImportRejectsEmptyAndJunkLibraries() throws {
+ let store = makeStore()
+ let empty = [
+ #"{"schemaVersion":3,"models":{}}"#,
+ #"{"models":{"046D:B020":{"global":{"actions":{}},"apps":[]}}}"#,
+ #"{"schemaVersion":3,"models":{"046D:B020":{"global":{"actions":{"80":{"disabled":{}}}}}}}"#,
+ #"{"schemaVersion":3,"models":5,"global":{"actions":{"83":{"disabled":{}}}}}"#,
+ #"{"schemaVersion":3,"models":{"046D:B034":{"name":"MX Master 3S"}}}"#,
+ ]
+ for text in empty {
+ XCTAssertThrowsError(try store.prepareImport(Data(text.utf8), targetModel: .mxVertical), text) { error in
+ XCTAssertEqual(error as? MappingStore.ImportError, .noMappings, text)
+ }
+ }
+ for junk in ["[]", "junk", #"{"hello":"world"}"#] {
+ XCTAssertThrowsError(try store.prepareImport(Data(junk.utf8), targetModel: .mxVertical), junk) { error in
+ XCTAssertEqual(error as? MappingStore.ImportError, .unreadable, junk)
+ }
+ }
+ XCTAssertTrue(store.backupKeys.isEmpty)
+ }
+
+ func testReviewItemsForLibraryCarryModelNames() throws {
+ let shortcut = ButtonAction.runShortcut("Make GIF")
+ let link = ButtonAction.openURL(URL(string: "https://example.com")!)
+ var library = MappingLibrary(migrating: MappingProfiles(global: ButtonMappings([KnownControl.back: shortcut])))
+ library[prototype] = MappingProfiles(global: ButtonMappings([KnownControl.forward: link, KnownControl.middleClick: .system(.copy)]))
+ library.modelNames[prototype] = "Prototype"
+ XCTAssertEqual(MappingStore.actionsToReview(in: library), [
+ .init(model: prototype, modelName: "Prototype", bundleID: nil, cid: KnownControl.forward, slot: .button, action: link),
+ .init(model: .mxVertical, modelName: "MX Vertical", bundleID: nil, cid: KnownControl.back, slot: .button, action: shortcut),
+ ])
+ // Items made without a model are the MX Vertical's.
+ let item = MappingStore.ReviewItem(bundleID: nil, cid: KnownControl.back, slot: .button, action: shortcut)
+ XCTAssertEqual(item.model, .mxVertical)
+ XCTAssertEqual(item.modelName, "MX Vertical")
+ }
+}
diff --git a/UprightKit/Tests/UprightKitTests/MappingPersistenceTests.swift b/UprightKit/Tests/UprightKitTests/MappingPersistenceTests.swift
index 264e541..d59cabf 100644
--- a/UprightKit/Tests/UprightKitTests/MappingPersistenceTests.swift
+++ b/UprightKit/Tests/UprightKitTests/MappingPersistenceTests.swift
@@ -88,7 +88,7 @@ final class MappingPersistenceTests: XCTestCase {
}
func testUnknownSystemActionFromNewerVersionIsSkipped() throws {
- let json = #"{"schemaVersion":3,"global":{"actions":{"83":{"system":{"_0":"teleport"}},"86":{"warp":{"_0":{}}},"253":{"system":{"_0":"missionControl"}}}},"apps":[{"bundleID":"com.example","overrides":{"83":{"system":{"_0":"copy"}}}},{"nope":true},{"bundleID":"com.example.b","overrides":{"83":{"hologram":{}}}}]}"#
+ let json = #"{"schemaVersion":4,"global":{"actions":{"83":{"system":{"_0":"teleport"}},"86":{"warp":{"_0":{}}},"253":{"system":{"_0":"missionControl"}}}},"apps":[{"bundleID":"com.example","overrides":{"83":{"system":{"_0":"copy"}}}},{"nope":true},{"bundleID":"com.example.b","overrides":{"83":{"hologram":{}}}}]}"#
let report = MappingDecodingReport()
let decoded = try MappingStore.decoder(report: report).decode(MappingProfiles.self, from: Data(json.utf8))
XCTAssertEqual(decoded.global, ButtonMappings([KnownControl.dpiSwitch: .system(.missionControl)]))
@@ -201,7 +201,7 @@ final class MappingPersistenceTests: XCTestCase {
]))
let data = try MappingStore.exportData(profiles)
let text = String(decoding: data, as: UTF8.self)
- XCTAssertTrue(text.contains("\"schemaVersion\" : 2"), text)
+ XCTAssertTrue(text.contains("\"schemaVersion\" : 3"), text)
XCTAssertTrue(text.contains("\n"))
XCTAssertTrue(text.contains("https://example.com/path"))
XCTAssertEqual(try MappingStore.importData(data), profiles)
diff --git a/UprightKit/Tests/UprightKitTests/MultiDeviceControllerTests.swift b/UprightKit/Tests/UprightKitTests/MultiDeviceControllerTests.swift
new file mode 100644
index 0000000..327d75e
--- /dev/null
+++ b/UprightKit/Tests/UprightKitTests/MultiDeviceControllerTests.swift
@@ -0,0 +1,630 @@
+import XCTest
+@testable import UprightKit
+
+/// `HIDPPLink` with several devices behind one interface (§G-2): discovery, routing, the
+/// capability gate, per-slot state, re-pairing, the request gate and teardown.
+@MainActor
+final class MultiDeviceControllerTests: XCTestCase {
+ /// Diverts every configurable control of whatever connects.
+ nonisolated static func divertAll(_ snapshot: DeviceSnapshot) -> EndpointConfiguration {
+ EndpointConfiguration(divert: Set(snapshot.configurableControls.map(\.cid)))
+ }
+
+ private func makeHub(
+ _ simulator: SimulatedHIDPPTransport,
+ link: UInt32 = 1,
+ tuning: ControllerTuning = .fastTests,
+ configuration: @escaping @MainActor (DeviceSnapshot) -> EndpointConfiguration = MultiDeviceControllerTests.divertAll
+ ) -> HIDPPLink {
+ let hub = makeLink(simulator, link: link, tuning: tuning)
+ hub.client.defaultTimeout = tuning.probeTimeout
+ hub.client.busyRetryDelay = 0.01
+ hub.configurationProvider = { _, snapshot in configuration(snapshot) }
+ return hub
+ }
+
+ private func configurable(_ profile: SimulatedDeviceProfile) -> Set {
+ Set(profile.controls.filter(\.isConfigurable).map(\.cid))
+ }
+
+ private func state(_ hub: HIDPPLink, at index: UInt8) -> EndpointController.State? {
+ hub.endpoints[index]?.state
+ }
+
+ private func unsupportedReason(_ hub: HIDPPLink, at index: UInt8) -> UnsupportedReason? {
+ if case .unsupported(let reason, _) = state(hub, at: index) { return reason }
+ return nil
+ }
+
+ /// setCidReporting requests to device index `index`, read from the raw traffic (works for
+ /// slots whose device is gone, unlike the simulator's per-feature counters).
+ private func setReportingRequests(_ simulator: SimulatedHIDPPTransport, index: UInt8, reprogIndex: UInt8) -> [[UInt8]] {
+ simulator.sentReports.filter { $0.count >= 7 && $0[1] == index && $0[2] == reprogIndex && $0[3] >> 4 == 3 }
+ }
+
+ // MARK: Several mice
+
+ func testTwoMXVerticalsOnOneReceiverAreBothServed() async throws {
+ let receiver = SimulatedReceiver.unifying([1: DeviceProfiles.mxVertical, 2: DeviceProfiles.mxVerticalSecondUnit])
+ let hub = makeHub(receiver)
+ let events = Recorder()
+ hub.onButtonEvent = { events.append($0) }
+ hub.start()
+
+ await eventually("both connected") { isConnected(hub, at: 1) && isConnected(hub, at: 2) }
+ let expected = configurable(DeviceProfiles.mxVertical)
+ XCTAssertEqual(receiver.diverted(slot: 1), expected)
+ XCTAssertEqual(receiver.diverted(slot: 2), expected)
+ let first = try XCTUnwrap(hub.endpoints[1]?.snapshot?.identity)
+ let second = try XCTUnwrap(hub.endpoints[2]?.snapshot?.identity)
+ XCTAssertEqual(first.model, .mxVertical)
+ XCTAssertEqual(second.model, .mxVertical)
+ XCTAssertNotEqual(first.deviceID(at: hub.address(of: 1)), second.deviceID(at: hub.address(of: 2)))
+
+ receiver.hold([KnownControl.back], slot: 2)
+ await eventually("press from slot 2") { events.count == 1 }
+ XCTAssertEqual(events.records, [ButtonEvent(cid: KnownControl.back, phase: .press, source: hub.address(of: 2))])
+ }
+
+ func testMixedModelsOnUnifyingEachGetOwnDiversion() async {
+ let receiver = SimulatedReceiver.unifying([
+ 1: DeviceProfiles.mxVertical, 2: DeviceProfiles.mxMaster3, 3: DeviceProfiles.mxKeys,
+ ])
+ let hub = makeHub(receiver) { snapshot in
+ snapshot.modelKey == .mxVertical
+ ? EndpointConfiguration(divert: [KnownControl.back])
+ : EndpointConfiguration(divert: [KnownControl.forward, KnownControl.gestureButton])
+ }
+ hub.start()
+
+ await eventually("both mice connected") { isConnected(hub, at: 1) && isConnected(hub, at: 2) }
+ await hub.waitUntilIdle()
+ XCTAssertEqual(receiver.diverted(slot: 1), [KnownControl.back])
+ XCTAssertEqual(receiver.diverted(slot: 2), [KnownControl.forward, KnownControl.gestureButton])
+ XCTAssertEqual(hub.endpoints[2]?.snapshot?.modelKey, ModelKey(productID: 0xB023))
+ XCTAssertNil(hub.endpoints[3])
+ XCTAssertEqual(receiver.setReportingCount(slot: 3), 0)
+ XCTAssertTrue(receiver.diverted(slot: 3).isEmpty)
+ }
+
+ func testKeyboardOnSharedReceiverIsNeverWritten() async {
+ // Slot 1: listed as a keyboard. Slot 3: the same keyboard with no kind in the pairing
+ // table or 0x0005, so only its control table (no mouse buttons) gives it away.
+ var disguised = DeviceProfiles.mxKeys.unit(0x8A2B_3C4E, serialNumber: "DEMO-0011")
+ disguised.kind = .unknown
+ let receiver = SimulatedReceiver.unifying([1: DeviceProfiles.mxKeys, 2: DeviceProfiles.mxVertical, 3: disguised])
+ let hub = makeHub(receiver)
+ hub.start()
+
+ await eventually("mouse connected") { isConnected(hub, at: 2) }
+ await eventually("disguised keyboard rejected") { unsupportedReason(hub, at: 3) != nil }
+ XCTAssertEqual(unsupportedReason(hub, at: 3), .notAPointer(.unknown))
+ XCTAssertNil(hub.endpoints[1])
+
+ // Reports and link-ups from the keyboards change nothing.
+ receiver.reportBattery(percentage: 40, slot: 1)
+ receiver.announceLink(slot: 1, up: true)
+ receiver.announceLink(slot: 3, up: true)
+ receiver.reportBattery(percentage: 40, slot: 3)
+ await pause(0.2)
+ await hub.waitUntilIdle()
+ XCTAssertNil(hub.endpoints[1])
+ XCTAssertEqual(receiver.pingCount(slot: 1), 0)
+ XCTAssertEqual(receiver.setReportingCount(slot: 1), 0)
+ XCTAssertEqual(receiver.setReportingCount(slot: 3), 0)
+ XCTAssertTrue(receiver.diverted(slot: 1).isEmpty)
+ XCTAssertTrue(receiver.diverted(slot: 3).isEmpty)
+ XCTAssertEqual(receiver.diverted(slot: 2), configurable(DeviceProfiles.mxVertical))
+ }
+
+ // MARK: Observers (upright-probe)
+
+ func testObserverWithoutConfigurationNeverWritesReporting() async {
+ let receiver = SimulatedReceiver.unifying([1: DeviceProfiles.mxVertical, 2: DeviceProfiles.mxMaster3])
+ let hub = makeLink(receiver)
+ hub.client.defaultTimeout = ControllerTuning.fastTests.probeTimeout
+ hub.client.busyRetryDelay = 0.01
+ hub.leavesReportingAlone = true
+ hub.start()
+
+ await eventually("both connected") { isConnected(hub, at: 1) && isConnected(hub, at: 2) }
+ await hub.waitUntilIdle()
+ // A link-up reconfigures the slot; still nothing is written.
+ receiver.announceLink(slot: 1, up: true)
+ await pause(0.2)
+ await hub.waitUntilIdle()
+ XCTAssertEqual(receiver.setReportingCount(slot: 1), 0)
+ XCTAssertEqual(receiver.setReportingCount(slot: 2), 0)
+ }
+
+ func testObserverStillDivertsWhatItIsAskedTo() async {
+ let receiver = SimulatedReceiver.unifying([1: DeviceProfiles.mxVertical, 2: DeviceProfiles.mxMaster3])
+ let hub = makeHub(receiver) { snapshot in
+ snapshot.modelKey == .mxVertical ? MultiDeviceControllerTests.divertAll(snapshot) : .native
+ }
+ hub.leavesReportingAlone = true
+ hub.start()
+
+ await eventually("both connected") { isConnected(hub, at: 1) && isConnected(hub, at: 2) }
+ await hub.waitUntilIdle()
+ XCTAssertEqual(receiver.diverted(slot: 1), configurable(DeviceProfiles.mxVertical))
+ XCTAssertEqual(receiver.setReportingCount(slot: 2), 0)
+ }
+
+ func testAppHubForcesNativeReportingOnConnect() async {
+ // Not an observer: a natively served mouse still has every control written native, so
+ // a diversion left behind by a crashed earlier session is cleared.
+ let receiver = SimulatedReceiver.unifying([1: DeviceProfiles.mxVertical])
+ let hub = makeHub(receiver) { _ in .native }
+ hub.start()
+
+ await eventually("connected") { isConnected(hub, at: 1) }
+ await hub.waitUntilIdle()
+ XCTAssertGreaterThan(receiver.setReportingCount(slot: 1), 0)
+ XCTAssertTrue(receiver.diverted(slot: 1).isEmpty)
+ }
+
+ // MARK: Unsupported devices
+
+ func testHIDPP10DeviceIsUnsupportedAndNotRetried() async {
+ let receiver = SimulatedReceiver.unifying([1: DeviceProfiles.m510HIDPP10])
+ let hub = makeHub(receiver)
+ hub.start()
+
+ await eventually("unsupported") { unsupportedReason(hub, at: 1) != nil }
+ XCTAssertEqual(unsupportedReason(hub, at: 1), .hidpp10(major: 1, minor: 0))
+ await hub.waitUntilIdle()
+ let pings = receiver.pingCount(slot: 1)
+
+ receiver.announceLink(slot: 1, up: true)
+ hub.refresh()
+ hub.reconnect(index: 1)
+ await pause(0.4)
+ await hub.waitUntilIdle()
+ XCTAssertEqual(receiver.pingCount(slot: 1), pings, "terminal: never asked again")
+ XCTAssertEqual(unsupportedReason(hub, at: 1), .hidpp10(major: 1, minor: 0))
+ }
+
+ func testMouseWithoutReprogControlsIsTerminal() async {
+ let mouse = SimulatedDirect(profile: DeviceProfiles.g502Lightspeed)
+ let hub = makeHub(mouse)
+ var states: [EndpointController.State] = []
+ hub.onEndpointChange = { _, state in states.append(state) }
+ hub.start()
+
+ await eventually("unsupported") { unsupportedReason(hub, at: 0xFF) != nil }
+ XCTAssertEqual(unsupportedReason(hub, at: 0xFF), .noReprogControls)
+ if case .unsupported(_, let partial) = state(hub, at: 0xFF) {
+ XCTAssertEqual(partial?.name, "G502 LIGHTSPEED Wireless Gaming Mouse")
+ }
+ await hub.waitUntilIdle()
+ let sent = mouse.sentReports.count
+
+ hub.refresh()
+ hub.reconnect(index: nil)
+ mouse.hold([])
+ await pause(0.4)
+ await hub.waitUntilIdle()
+ XCTAssertEqual(mouse.sentReports.count, sent, "nothing more is sent")
+ XCTAssertEqual(mouse.setReportingCount, 0)
+ XCTAssertFalse(states.contains { if case .waitingForDevice = $0 { return true } else { return false } })
+ }
+
+ // MARK: Receivers
+
+ func testBoltReceiverPairingLayoutFindsBothMice() async {
+ let receiver = SimulatedReceiver.bolt([1: DeviceProfiles.mxMaster3S, 2: DeviceProfiles.lift])
+ let hub = makeHub(receiver)
+ hub.start()
+
+ await eventually("both connected") { isConnected(hub, at: 1) && isConnected(hub, at: 2) }
+ XCTAssertEqual(hub.endpoints[1]?.snapshot?.modelKey, ModelKey(productID: 0xB034))
+ XCTAssertEqual(hub.endpoints[2]?.snapshot?.modelKey, ModelKey(productID: 0xB031))
+ XCTAssertEqual(hub.endpoints[1]?.transportPID, 0xB034, "wireless PID from the Bolt pairing record")
+ XCTAssertEqual(hub.endpoints[1]?.snapshot?.connection, .bolt)
+ XCTAssertEqual(receiver.registerWriteCount(0x02), 0, "the pairing table was readable")
+ XCTAssertEqual(receiver.diverted(slot: 1), configurable(DeviceProfiles.mxMaster3S))
+ XCTAssertEqual(receiver.diverted(slot: 2), configurable(DeviceProfiles.lift))
+ }
+
+ func testNanoReceiverUsesUnifyingLayout() async {
+ let receiver = SimulatedReceiver.nano([2: DeviceProfiles.m185])
+ let hub = makeHub(receiver)
+ hub.start()
+
+ await eventually("connected") { isConnected(hub, at: 2) }
+ XCTAssertEqual(hub.kind, .receiver(.nano))
+ XCTAssertEqual(hub.endpoints[2]?.transportPID, 0x4055)
+ XCTAssertEqual(hub.endpoints[2]?.snapshot?.connection, .nano)
+ XCTAssertEqual(receiver.registerWriteCount(0x02), 0)
+ // Left and Right are flagged divertable on the M185, but never diverted.
+ XCTAssertEqual(receiver.diverted(slot: 2), [KnownControl.middleClick])
+ }
+
+ func testStartupProbesEverySlot() async {
+ let receiver = SimulatedReceiver.unifying([
+ 1: DeviceProfiles.mxVertical, 3: DeviceProfiles.mxMaster3, 6: DeviceProfiles.mxVerticalSecondUnit,
+ ])
+ let hub = makeHub(receiver)
+ hub.start()
+
+ await eventually("all three connected") {
+ isConnected(hub, at: 1) && isConnected(hub, at: 3) && isConnected(hub, at: 6)
+ }
+ await hub.waitUntilIdle()
+ XCTAssertEqual(hub.orderedEndpoints.map(\.address.index), [1, 3, 6])
+ for empty: UInt8 in [2, 4, 5] {
+ XCTAssertEqual(receiver.pingCount(slot: empty), 0, "slot \(empty) is empty in the pairing table")
+ }
+ }
+
+ // MARK: Per-slot state
+
+ func testSleepingSlotDoesNotDelayOtherSlotBeyondOneTimeout() async {
+ let receiver = SimulatedReceiver.unifying([1: DeviceProfiles.mxVertical, 2: DeviceProfiles.mxMaster3S])
+ receiver.setDropsRequests(true, slot: 1)
+ var tuning = ControllerTuning.fastTests
+ tuning.probeTimeout = 0.3
+ tuning.retryBaseDelay = 5
+ tuning.retryMaxDelay = 5
+ let hub = makeHub(receiver, tuning: tuning)
+ let began = Date()
+ hub.start()
+
+ await eventually("slot 2 connected") { isConnected(hub, at: 2) }
+ let elapsed = Date().timeIntervalSince(began)
+ XCTAssertLessThan(elapsed, 0.3 + 0.45, "slot 2 waited for at most one of slot 1's timeouts")
+ await eventually("slot 1 waiting") { state(hub, at: 1) == .waitingForDevice }
+
+ receiver.setDropsRequests(false, slot: 1)
+ receiver.hold([], slot: 1)
+ await eventually("slot 1 connected after it reported") { isConnected(hub, at: 1) }
+ }
+
+ func testBackoffAndCountersArePerSlot() async {
+ let receiver = SimulatedReceiver.unifying([1: DeviceProfiles.mxVertical, 2: DeviceProfiles.lift])
+ receiver.setDropsRequests(true, slot: 1)
+ receiver.setDropsRequests(true, slot: 2)
+ var tuning = ControllerTuning.fastTests
+ tuning.probeTimeout = 0.1
+ let hub = makeHub(receiver, tuning: tuning)
+ hub.start()
+
+ await eventually("both retried", timeout: 5) { receiver.pingCount(slot: 1) >= 2 && receiver.pingCount(slot: 2) >= 2 }
+ receiver.setDropsRequests(false, slot: 2)
+ await eventually("slot 2 connected by its own retry", timeout: 5) { isConnected(hub, at: 2) }
+ let slot2Pings = receiver.pingCount(slot: 2)
+ let slot1Pings = receiver.pingCount(slot: 1)
+
+ await pause(0.8)
+ XCTAssertEqual(receiver.pingCount(slot: 2), slot2Pings, "a connected slot isn't probed again")
+ XCTAssertGreaterThan(receiver.pingCount(slot: 1), slot1Pings, "slot 1 keeps retrying on its own")
+ XCTAssertEqual(state(hub, at: 1), .waitingForDevice)
+ XCTAssertTrue(isConnected(hub, at: 2))
+ }
+
+ func testWakePingPongKeepsBothSessions() async {
+ let receiver = SimulatedReceiver.unifying([1: DeviceProfiles.mxVertical, 2: DeviceProfiles.mxMaster3])
+ let hub = makeHub(receiver)
+ hub.start()
+ await eventually("both connected") { isConnected(hub, at: 1) && isConnected(hub, at: 2) }
+ let first = hub.endpoints[1]
+ let second = hub.endpoints[2]
+
+ for round in 0..<3 {
+ receiver.goToSleep(slot: 1)
+ await eventually("1 asleep, round \(round)") { isAsleep(hub, at: 1) }
+ receiver.wakeUp(slot: 1, clearingDiversion: true)
+ receiver.goToSleep(slot: 2)
+ await eventually("1 back, 2 asleep, round \(round)") { isConnected(hub, at: 1) && isAsleep(hub, at: 2) }
+ receiver.wakeUp(slot: 2, clearingDiversion: true)
+ await eventually("2 back, round \(round)") { isConnected(hub, at: 2) }
+ }
+ await hub.waitUntilIdle()
+ XCTAssertTrue(hub.endpoints[1] === first)
+ XCTAssertTrue(hub.endpoints[2] === second)
+ XCTAssertEqual(receiver.diverted(slot: 1), configurable(DeviceProfiles.mxVertical))
+ XCTAssertEqual(receiver.diverted(slot: 2), configurable(DeviceProfiles.mxMaster3))
+ }
+
+ // MARK: Pairing changes
+
+ func testRePairByUnitIDRetiresOldSlotWithUndivert() async throws {
+ let receiver = SimulatedReceiver.unifying([2: DeviceProfiles.mxVertical, 4: DeviceProfiles.mxMaster3])
+ let hub = makeHub(receiver)
+ var removed: [(DeviceAddress, HIDPPLink.RemovalReason)] = []
+ hub.onEndpointRemoved = { removed.append(($0, $1)) }
+ hub.start()
+ await eventually("connected") { isConnected(hub, at: 2) && isConnected(hub, at: 4) }
+ let reprog = try XCTUnwrap(DeviceProfiles.mxVertical.featureIndex(of: 0x1B04))
+ let before = setReportingRequests(receiver, index: 2, reprogIndex: reprog).count
+
+ receiver.repair(from: 2, to: 5, announceUnpair: false)
+ await eventually("moved to slot 5") { isConnected(hub, at: 5) }
+ XCTAssertNil(hub.endpoints[2])
+ XCTAssertEqual(removed.count, 1)
+ XCTAssertEqual(removed.first?.0, hub.address(of: 2))
+ XCTAssertEqual(removed.first?.1, .retired(movedTo: hub.address(of: 5)))
+ let undiverts = setReportingRequests(receiver, index: 2, reprogIndex: reprog).dropFirst(before)
+ XCTAssertEqual(undiverts.count, configurable(DeviceProfiles.mxVertical).count, "every diverted control handed back")
+ XCTAssertEqual(receiver.diverted(slot: 5), configurable(DeviceProfiles.mxVertical))
+ XCTAssertTrue(isConnected(hub, at: 4), "the other mouse is untouched")
+ }
+
+ func testUnpairRemovesOnlyThatSlot() async {
+ let receiver = SimulatedReceiver.unifying([1: DeviceProfiles.mxVertical, 2: DeviceProfiles.lift])
+ let hub = makeHub(receiver)
+ let events = Recorder()
+ hub.onButtonEvent = { events.append($0) }
+ var removed: [(DeviceAddress, HIDPPLink.RemovalReason)] = []
+ hub.onEndpointRemoved = { removed.append(($0, $1)) }
+ hub.start()
+ await eventually("connected") { isConnected(hub, at: 1) && isConnected(hub, at: 2) }
+
+ receiver.hold([KnownControl.back], slot: 1)
+ receiver.hold([KnownControl.forward], slot: 2)
+ await eventually("both pressed") { events.count == 2 }
+ receiver.unpair(slot: 1)
+ await eventually("slot 1 removed") { hub.endpoints[1] == nil }
+
+ XCTAssertEqual(removed.map(\.0), [hub.address(of: 1)])
+ XCTAssertEqual(removed.map(\.1), [.unpaired])
+ XCTAssertEqual(events.records.last, ButtonEvent(cid: KnownControl.back, phase: .release, source: hub.address(of: 1)))
+ XCTAssertFalse(events.records.contains(ButtonEvent(cid: KnownControl.forward, phase: .release, source: hub.address(of: 2))))
+ XCTAssertTrue(isConnected(hub, at: 2))
+ XCTAssertEqual(receiver.diverted(slot: 2), configurable(DeviceProfiles.lift))
+ }
+
+ func testDifferentWPIDInOccupiedSlotReplacesEndpoint() async {
+ let receiver = SimulatedReceiver.unifying([1: DeviceProfiles.mxVertical])
+ let hub = makeHub(receiver)
+ var removed: [(DeviceAddress, HIDPPLink.RemovalReason)] = []
+ hub.onEndpointRemoved = { removed.append(($0, $1)) }
+ hub.start()
+ await eventually("connected") { isConnected(hub, at: 1) }
+ let old = hub.endpoints[1]
+
+ receiver.pair(DeviceProfiles.lift, slot: 1, announce: true)
+ await eventually("lift connected") { hub.endpoints[1]?.snapshot?.modelKey == ModelKey(productID: 0xB031) && isConnected(hub, at: 1) }
+ XCTAssertFalse(hub.endpoints[1] === old)
+ XCTAssertEqual(removed.map(\.1), [.replaced])
+ XCTAssertEqual(old?.isShutDown, true)
+ // The vertical's restore messages would have reached the lift at the wrong feature index.
+ XCTAssertEqual(receiver.requestCount(feature: 0x1814, function: 3, slot: 1), 0)
+ XCTAssertEqual(receiver.diverted(slot: 1), configurable(DeviceProfiles.lift))
+ }
+
+ func testMissedLinkUpRecoveredFromReport() async {
+ let receiver = SimulatedReceiver.unifying([3: DeviceProfiles.mxMaster3])
+ receiver.supportsPairingInfo = false
+ receiver.goToSleep(slot: 3)
+ let hub = makeHub(receiver)
+ hub.start()
+ await hub.waitUntilIdle()
+ XCTAssertTrue(hub.endpoints.isEmpty, "announced as down, and the ping found nothing")
+
+ // It wakes without the receiver announcing it; only its report shows it's back.
+ receiver.wakeUp(slot: 3, clearingDiversion: true, announce: false)
+ receiver.hold([], slot: 3)
+ await eventually("connected from the report") { isConnected(hub, at: 3) }
+ XCTAssertEqual(receiver.diverted(slot: 3), configurable(DeviceProfiles.mxMaster3))
+ }
+
+ func testAnnouncementEchoSuppressionIsPerSlot() async {
+ let receiver = SimulatedReceiver.unifying([1: DeviceProfiles.mxVertical, 2: DeviceProfiles.lift])
+ receiver.supportsPairingInfo = false
+ let hub = makeHub(receiver)
+ hub.start()
+ await eventually("both connected") { isConnected(hub, at: 1) && isConnected(hub, at: 2) }
+ await hub.waitUntilIdle()
+ XCTAssertEqual(receiver.registerWriteCount(0x02), 1)
+ let slot1Writes = receiver.setReportingCount(slot: 1)
+
+ // Within the echo window: a repeated announcement for slot 1 is not a reconnect, but a
+ // real sleep and wake of slot 2 is.
+ receiver.announceLink(slot: 1, up: true)
+ receiver.goToSleep(slot: 2)
+ await eventually("2 asleep") { isAsleep(hub, at: 2) }
+ receiver.wakeUp(slot: 2, clearingDiversion: true)
+ await eventually("2 re-diverted") { receiver.diverted(slot: 2) == configurable(DeviceProfiles.lift) }
+ await pause(0.1)
+ await hub.waitUntilIdle()
+ XCTAssertEqual(receiver.setReportingCount(slot: 1), slot1Writes)
+ XCTAssertTrue(isConnected(hub, at: 1))
+ XCTAssertTrue(isConnected(hub, at: 2))
+ }
+
+ func testNotificationFlagsAreReadModifyWrittenOncePerLink() async {
+ let receiver = SimulatedReceiver.unifying([
+ 1: DeviceProfiles.mxVertical, 2: DeviceProfiles.lift, 3: DeviceProfiles.mxMaster3,
+ ])
+ receiver.notificationFlags = [0x00, 0x10, 0x04] // set by another program
+ let hub = makeHub(receiver)
+ hub.start()
+ await eventually("all connected") { isConnected(hub, at: 1) && isConnected(hub, at: 2) && isConnected(hub, at: 3) }
+ await hub.waitUntilIdle()
+ XCTAssertEqual(receiver.registerReadCount(0x00), 1)
+ XCTAssertEqual(receiver.registerWriteCount(0x00), 1, "once for the receiver, not once per slot")
+ XCTAssertEqual(receiver.notificationFlags, [0x00, 0x19, 0x04], "other flags kept")
+
+ // A per-slot reconfiguration (0x1D4B) leaves the receiver alone.
+ receiver.reportReconnect(slot: 2)
+ await eventually("slot 2 re-diverted") { receiver.diverted(slot: 2) == configurable(DeviceProfiles.lift) }
+ await hub.waitUntilIdle()
+ XCTAssertEqual(receiver.registerWriteCount(0x00), 1)
+
+ // A Mac wake re-asserts the flags once for the whole receiver.
+ hub.refresh()
+ await pause(0.05)
+ await hub.waitUntilIdle()
+ XCTAssertEqual(receiver.registerWriteCount(0x00), 2)
+ XCTAssertEqual(receiver.notificationFlags, [0x00, 0x19, 0x04])
+ }
+
+ // MARK: Device actions
+
+ func testEasySwitchOnBLeavesALone() async {
+ let receiver = SimulatedReceiver.unifying([1: DeviceProfiles.mxVertical, 2: DeviceProfiles.mxMaster3S])
+ let hub = makeHub(receiver)
+ hub.start()
+ await eventually("both connected") { isConnected(hub, at: 1) && isConnected(hub, at: 2) }
+ await hub.waitUntilIdle()
+ let slot1Writes = receiver.setReportingCount(slot: 1)
+
+ hub.switchToHost(1, at: 2)
+ await eventually("B left") { receiver.hostSwitches(slot: 2) == [1] }
+ await eventually("B asleep") { isAsleep(hub, at: 2) }
+ await pause(0.1)
+ await hub.waitUntilIdle()
+ XCTAssertEqual(receiver.hostSwitches(slot: 1), [])
+ XCTAssertTrue(isConnected(hub, at: 1))
+ XCTAssertEqual(receiver.setReportingCount(slot: 1), slot1Writes)
+ XCTAssertEqual(receiver.diverted(slot: 1), configurable(DeviceProfiles.mxVertical))
+ }
+
+ // MARK: Teardown
+
+ func testShutdownRestoresEverySlot() async {
+ let receiver = SimulatedReceiver.unifying([
+ 1: DeviceProfiles.mxVertical, 2: DeviceProfiles.lift, 4: DeviceProfiles.mxMaster3,
+ ])
+ let hub = makeHub(receiver)
+ var removed: [(DeviceAddress, HIDPPLink.RemovalReason)] = []
+ hub.onEndpointRemoved = { removed.append(($0, $1)) }
+ hub.start()
+ await eventually("all connected") { isConnected(hub, at: 1) && isConnected(hub, at: 2) && isConnected(hub, at: 4) }
+ XCTAssertFalse(receiver.diverted(slot: 4).isEmpty)
+
+ hub.shutdown()
+ for slot: UInt8 in [1, 2, 4] {
+ XCTAssertTrue(receiver.diverted(slot: slot).isEmpty, "slot \(slot)")
+ }
+ XCTAssertTrue(hub.endpoints.isEmpty)
+ XCTAssertEqual(removed.map(\.0), [hub.address(of: 1), hub.address(of: 2), hub.address(of: 4)])
+ XCTAssertTrue(removed.allSatisfy { $0.1 == .transportClosed })
+ XCTAssertTrue(hub.isShutDown)
+ }
+
+ func testDroppedEndpointSendsRestore() async throws {
+ let receiver = SimulatedReceiver.unifying([1: DeviceProfiles.mxVertical])
+ let hub = makeHub(receiver) { _ in
+ EndpointConfiguration(divert: [KnownControl.back], rawXY: [KnownControl.dpiSwitch])
+ }
+ let events = Recorder()
+ hub.onButtonEvent = { events.append($0) }
+ hub.start()
+ await eventually("connected") { isConnected(hub, at: 1) }
+ XCTAssertEqual(receiver.rawXY(slot: 1), [KnownControl.dpiSwitch])
+ let reprog = try XCTUnwrap(DeviceProfiles.mxVertical.featureIndex(of: 0x1B04))
+ let before = setReportingRequests(receiver, index: 1, reprogIndex: reprog).count
+
+ receiver.hold([KnownControl.dpiSwitch], slot: 1)
+ await eventually("pressed") { events.count == 1 }
+ // Re-paired: the endpoint in slot 1 is dropped while the mouse may still hear it.
+ receiver.repair(from: 1, to: 2, announceUnpair: false)
+ await eventually("moved") { isConnected(hub, at: 2) }
+
+ XCTAssertEqual(events.records[1], ButtonEvent(cid: KnownControl.dpiSwitch, phase: .release, source: hub.address(of: 1)))
+ let restores = Array(setReportingRequests(receiver, index: 1, reprogIndex: reprog).dropFirst(before))
+ var byCID: [UInt16: UInt8] = [:]
+ for report in restores {
+ let cid = UInt16(report[4]) << 8 | UInt16(report[5])
+ byCID[cid] = report[6]
+ }
+ XCTAssertEqual(Set(byCID.keys), [KnownControl.back, KnownControl.dpiSwitch])
+ XCTAssertEqual(byCID[KnownControl.back], ReprogControls.reportingFlags(diverted: false, rawXY: nil).rawValue)
+ XCTAssertEqual(byCID[KnownControl.dpiSwitch], ReprogControls.reportingFlags(diverted: false, rawXY: false).rawValue)
+ }
+
+ // MARK: Ordering and the request gate
+
+ func testConfigurationIsPulledBeforeFirstDiversion() async {
+ let receiver = SimulatedReceiver.unifying([2: DeviceProfiles.mxVertical, 5: DeviceProfiles.lift])
+ var pulls: [(DeviceAddress, DeviceSnapshot, Int)] = []
+ let hub = makeHub(receiver)
+ hub.configurationProvider = { address, snapshot in
+ pulls.append((address, snapshot, receiver.setReportingCount(slot: address.index)))
+ return snapshot.modelKey == .mxVertical ? EndpointConfiguration(divert: [KnownControl.forward]) : .native
+ }
+ hub.start()
+ await eventually("both connected") { isConnected(hub, at: 2) && isConnected(hub, at: 5) }
+
+ XCTAssertEqual(Set(pulls.map(\.0)), [hub.address(of: 2), hub.address(of: 5)])
+ for (address, snapshot, writesBefore) in pulls {
+ XCTAssertEqual(writesBefore, 0, "nothing written to \(address) before its configuration was known")
+ XCTAssertEqual(snapshot.address, address)
+ XCTAssertNotNil(snapshot.identity)
+ }
+ XCTAssertEqual(receiver.diverted(slot: 2), [KnownControl.forward])
+ XCTAssertTrue(receiver.diverted(slot: 5).isEmpty)
+ XCTAssertEqual(hub.endpoints[2]?.desiredDiversion, [KnownControl.forward])
+ }
+
+ func testOneRequestInFlightPerTransport() async {
+ let receiver = SimulatedReceiver.unifying([
+ 1: DeviceProfiles.mxVertical, 2: DeviceProfiles.mxMaster3, 3: DeviceProfiles.lift, 4: DeviceProfiles.m185,
+ ])
+ receiver.latency = 0.002
+ receiver.setDropsRequests(true, slot: 4)
+ receiver.unansweredRequestWindow = 0.15
+ var tuning = ControllerTuning.fastTests
+ tuning.probeTimeout = 0.2
+ let hub = makeHub(receiver, tuning: tuning)
+ hub.start()
+
+ // Serialized probing of three mice behind a slot that drops every request: ~1.5 s locally,
+ // over 3 s on CI runners.
+ await eventually("three connected", timeout: 15) { isConnected(hub, at: 1) && isConnected(hub, at: 2) && isConnected(hub, at: 3) }
+ hub.refreshDeviceInfo(at: nil)
+ hub.cycleDPI(at: 1, presets: [800, 1600])
+ hub.setConfiguration(EndpointConfiguration(divert: [KnownControl.back]), at: 2)
+ await pause(0.3)
+ await hub.waitUntilIdle()
+ XCTAssertEqual(receiver.requestsInFlightMax, 1)
+ XCTAssertEqual(receiver.diverted(slot: 2), [KnownControl.back])
+ }
+
+ func testFirstRawXYReportDroppedOnReprogV5() async {
+ let receiver = SimulatedReceiver.bolt([1: DeviceProfiles.mxMaster3S, 2: DeviceProfiles.lift])
+ let hub = makeHub(receiver) { snapshot in
+ EndpointConfiguration(rawXY: snapshot.modelKey == ModelKey(productID: 0xB034) ? [KnownControl.gestureButton] : [KnownControl.back])
+ }
+ let deltas = Recorder()
+ hub.onPointerDelta = { deltas.append($0) }
+ hub.start()
+ await eventually("both connected") { isConnected(hub, at: 1) && isConnected(hub, at: 2) }
+ XCTAssertEqual(hub.endpoints[1]?.snapshot?.reprogVersion, 5)
+ XCTAssertEqual(receiver.rawXY(slot: 1), [KnownControl.gestureButton])
+
+ receiver.hold([KnownControl.gestureButton], slot: 1)
+ receiver.move(dx: 99, dy: -99, slot: 1)
+ receiver.move(dx: 3, dy: 4, slot: 1)
+ receiver.hold([], slot: 1)
+ await eventually("one delta") { deltas.count == 1 }
+ await pause(0.05)
+ XCTAssertEqual(deltas.records, [PointerDelta(source: hub.address(of: 1), dx: 3, dy: 4)])
+ }
+
+ func testUnsupportedExclusionClearsOnNewWPID() async {
+ let receiver = SimulatedReceiver.unifying([1: DeviceProfiles.g502Lightspeed, 2: DeviceProfiles.mxKeys])
+ let hub = makeHub(receiver)
+ hub.start()
+ await eventually("g502 unsupported") { unsupportedReason(hub, at: 1) == .noReprogControls }
+ await hub.waitUntilIdle()
+ let pings = receiver.pingCount(slot: 1)
+
+ // The same device announcing itself again stays excluded.
+ receiver.announceLink(slot: 1, up: true)
+ await pause(0.1)
+ await hub.waitUntilIdle()
+ XCTAssertEqual(receiver.pingCount(slot: 1), pings)
+
+ // Another device paired in its place (and in the keyboard's) is served.
+ receiver.pair(DeviceProfiles.mxMaster3, slot: 1, announce: true)
+ receiver.pair(DeviceProfiles.lift, slot: 2, announce: true)
+ await eventually("new devices connected") { isConnected(hub, at: 1) && isConnected(hub, at: 2) }
+ XCTAssertEqual(hub.endpoints[1]?.snapshot?.modelKey, ModelKey(productID: 0xB023))
+ XCTAssertEqual(receiver.diverted(slot: 1), configurable(DeviceProfiles.mxMaster3))
+ XCTAssertEqual(receiver.diverted(slot: 2), configurable(DeviceProfiles.lift))
+ }
+}
diff --git a/UprightKit/Tests/UprightKitTests/ReceiverDirectoryTests.swift b/UprightKit/Tests/UprightKitTests/ReceiverDirectoryTests.swift
new file mode 100644
index 0000000..d7f274c
--- /dev/null
+++ b/UprightKit/Tests/UprightKitTests/ReceiverDirectoryTests.swift
@@ -0,0 +1,305 @@
+import XCTest
+@testable import UprightKit
+
+/// Pairing layouts and 0x40/0x41 formats are Solaar's (`receiver.py`, `notifications.py`).
+final class ReceiverDirectoryTests: XCTestCase {
+ // MARK: Pairing records
+
+ func testUnifyingPairingLayout() throws {
+ // [sub, destination ID, report interval, wpid hi, wpid lo, ?, ?, kind]
+ let record = try XCTUnwrap(ReceiverDirectory.parsePairing([0x20, 0x4B, 0x08, 0x40, 0x7B, 0x00, 0x00, 0x02], slot: 1, layout: .unifying))
+ XCTAssertEqual(record, PairingRecord(slot: 1, wirelessPID: 0x407B, kind: .mouse))
+
+ let third = try XCTUnwrap(ReceiverDirectory.parsePairing([0x22, 0x00, 0x08, 0x40, 0x82, 0x00, 0x00, 0x41], slot: 3, layout: .unifying))
+ XCTAssertEqual(third, PairingRecord(slot: 3, wirelessPID: 0x4082, kind: .keyboard), "only the low nibble is the kind")
+ XCTAssertEqual(PairingLayout.unifying.pairingSubRegister(slot: 1), 0x20)
+ XCTAssertEqual(PairingLayout.unifying.pairingSubRegister(slot: 6), 0x25)
+ }
+
+ func testBoltPairingLayout() throws {
+ // [sub, kind, wpid lo, wpid hi, serial (4)]: slot n is sub-register 0x50 + n, not n - 1.
+ let reply: [UInt8] = [0x52, 0x02, 0x34, 0xB0, 0x1A, 0x2B, 0x3C, 0x4D]
+ let record = try XCTUnwrap(ReceiverDirectory.parsePairing(reply, slot: 2, layout: .bolt))
+ XCTAssertEqual(record, PairingRecord(slot: 2, wirelessPID: 0xB034, kind: .mouse))
+ XCTAssertEqual(PairingLayout.bolt.pairingSubRegister(slot: 1), 0x51)
+ XCTAssertEqual(PairingLayout.bolt.pairingSubRegister(slot: 6), 0x56)
+
+ XCTAssertNil(ReceiverDirectory.parsePairing(reply, slot: 1, layout: .bolt), "0x52 is slot 2 on Bolt")
+ XCTAssertNil(ReceiverDirectory.parsePairing(reply, slot: 2, layout: .unifying), "Unifying slot 2 is 0x21")
+ XCTAssertNil(ReceiverDirectory.parsePairing([0x21, 0x02, 0x34, 0xB0, 0, 0, 0, 0x02], slot: 2, layout: .bolt))
+ }
+
+ func testPairingKindNibbles() {
+ func kind(_ nibble: UInt8, _ layout: PairingLayout) -> DeviceKind? {
+ let reply: [UInt8] = layout == .unifying
+ ? [0x20, 0, 0, 0x40, 0x10, 0, 0, nibble]
+ : [0x51, nibble, 0x10, 0x40, 0, 0, 0, 0]
+ return ReceiverDirectory.parsePairing(reply, slot: 1, layout: layout)?.kind
+ }
+ for layout in [PairingLayout.unifying, .bolt] {
+ XCTAssertEqual(kind(0x01, layout), .keyboard)
+ XCTAssertEqual(kind(0x02, layout), .mouse)
+ XCTAssertEqual(kind(0x03, layout), .numpad)
+ XCTAssertEqual(kind(0x04, layout), .presenter)
+ XCTAssertEqual(kind(0x07, layout), .remote)
+ XCTAssertEqual(kind(0x08, layout), .trackball)
+ XCTAssertEqual(kind(0x09, layout), .touchpad)
+ XCTAssertEqual(kind(0x00, layout), .unknown)
+ XCTAssertEqual(kind(0x62, layout), .mouse)
+ }
+ }
+
+ func testEmptyShortAndOutOfRangePairingRepliesAreNil() {
+ XCTAssertNil(ReceiverDirectory.parsePairing([0x20, 0, 0, 0, 0, 0, 0, 0x02], slot: 1, layout: .unifying), "wpid 0 is an empty slot")
+ XCTAssertNil(ReceiverDirectory.parsePairing([0x51, 0x02, 0, 0, 0, 0, 0, 0], slot: 1, layout: .bolt))
+ XCTAssertNil(ReceiverDirectory.parsePairing([0x20, 0, 0, 0x40, 0x7B], slot: 1, layout: .unifying), "short reply")
+ XCTAssertNil(ReceiverDirectory.parsePairing([0x21, 0, 0, 0x40, 0x7B, 0, 0, 2], slot: 1, layout: .unifying), "another slot's reply")
+ XCTAssertNil(ReceiverDirectory.parsePairing([0x1F, 0, 0, 0x40, 0x7B, 0, 0, 2], slot: 0, layout: .unifying))
+ XCTAssertNil(ReceiverDirectory.parsePairing([0x26, 0, 0, 0x40, 0x7B, 0, 0, 2], slot: 7, layout: .unifying))
+ }
+
+ func testNanoReceiversUseTheUnifyingLayout() {
+ for pid: UInt16 in [0xC52F, 0xC534] {
+ XCTAssertEqual(DeviceCatalog.receivers[pid]?.layout, .unifying, String(format: "%04X", pid))
+ }
+ XCTAssertEqual(DeviceCatalog.receivers[0xC548]?.layout, .bolt)
+ // Nano C534 hosting "Wireless Mouse M185 new" (wpid 4054) in slot 2.
+ let record = ReceiverDirectory.parsePairing([0x21, 0x00, 0x08, 0x40, 0x54, 0x00, 0x00, 0x02], slot: 2, layout: .unifying)
+ XCTAssertEqual(record, PairingRecord(slot: 2, wirelessPID: 0x4054, kind: .mouse))
+ }
+
+ // MARK: Serial numbers
+
+ func testSerialFromExtendedPairingAndBoltRecord() {
+ XCTAssertEqual(PairingLayout.unifying.serialSubRegister(slot: 1), 0x30)
+ XCTAssertEqual(PairingLayout.unifying.serialSubRegister(slot: 6), 0x35)
+ XCTAssertEqual(PairingLayout.bolt.serialSubRegister(slot: 3), 0x53)
+
+ let extended: [UInt8] = [0x31, 0x1A, 0x2B, 0x3C, 0x4D, 0x00, 0x00, 0x00, 0x00, 0x02]
+ XCTAssertEqual(ReceiverDirectory.parseSerial(extended, slot: 2, layout: .unifying), "1A2B3C4D")
+ XCTAssertNil(ReceiverDirectory.parseSerial(extended, slot: 1, layout: .unifying))
+
+ let bolt: [UInt8] = [0x53, 0x02, 0x34, 0xB0, 0xDE, 0xAD, 0x00, 0x01]
+ XCTAssertEqual(ReceiverDirectory.parseSerial(bolt, slot: 3, layout: .bolt), "DEAD0001")
+
+ XCTAssertNil(ReceiverDirectory.parseSerial([0x30, 0, 0, 0, 0, 0, 0, 0], slot: 1, layout: .unifying))
+ XCTAssertNil(ReceiverDirectory.parseSerial([0x30, 0xFF, 0xFF, 0xFF, 0xFF, 0, 0, 0], slot: 1, layout: .unifying))
+ XCTAssertNil(ReceiverDirectory.parseSerial([0x30, 0x1A, 0x2B], slot: 1, layout: .unifying))
+ }
+
+ // MARK: Notifications
+
+ private func notification(_ subID: UInt8, slot: UInt8, address: UInt8 = 0x04, _ parameters: [UInt8] = []) -> HIDPPMessage {
+ HIDPPMessage(reportID: .short, deviceIndex: slot, subID: subID, address: address, parameters: parameters)
+ }
+
+ func testConnectionNotification() throws {
+ let up = try XCTUnwrap(ReceiverDirectory.parseConnection(notification(0x41, slot: 2, [0x02, 0x7B, 0x40])))
+ XCTAssertEqual(up.slot, 2)
+ XCTAssertEqual(up.wirelessPID, 0x407B)
+ XCTAssertEqual(up.kind, .mouse)
+ XCTAssertTrue(up.linkUp)
+
+ let down = try XCTUnwrap(ReceiverDirectory.parseConnection(notification(0x41, slot: 2, [0x42, 0x7B, 0x40])))
+ XCTAssertFalse(down.linkUp)
+ XCTAssertEqual(down.kind, .mouse)
+
+ // Encrypted (0x20) and software-present (0x10) bits don't change the link state.
+ let encrypted = try XCTUnwrap(ReceiverDirectory.parseConnection(notification(0x41, slot: 5, [0x31, 0x82, 0x40])))
+ XCTAssertTrue(encrypted.linkUp)
+ XCTAssertEqual(encrypted.kind, .keyboard)
+ XCTAssertEqual(encrypted.wirelessPID, 0x4082)
+
+ // Bolt (protocol 0x10) reports its device's Bxxx PID the same way.
+ let bolt = try XCTUnwrap(ReceiverDirectory.parseConnection(notification(0x41, slot: 1, address: 0x10, [0x22, 0x34, 0xB0])))
+ XCTAssertEqual(bolt.wirelessPID, 0xB034)
+ XCTAssertTrue(bolt.linkUp)
+
+ // An announcement without a PID still parses; the hub decides what a 0 PID means.
+ XCTAssertEqual(ReceiverDirectory.parseConnection(notification(0x41, slot: 3, [0x00, 0x00, 0x00]))?.wirelessPID, 0)
+ }
+
+ func testConnectionNotificationIgnoresOtherMessages() {
+ XCTAssertNil(ReceiverDirectory.parseConnection(notification(0x40, slot: 2, address: 0x02)))
+ XCTAssertNil(ReceiverDirectory.parseConnection(notification(0x41, slot: 0xFF, [0x02, 0x7B, 0x40])))
+ XCTAssertNil(ReceiverDirectory.parseConnection(notification(0x41, slot: 0, [0x02, 0x7B, 0x40])))
+ XCTAssertNil(ReceiverDirectory.parseConnection(notification(0x41, slot: 7, [0x02, 0x7B, 0x40])))
+ XCTAssertNil(ReceiverDirectory.parseConnection(.request(deviceIndex: 1, featureIndex: 0x05, function: 0, softwareID: 0)))
+ }
+
+ func testUnpairNotification() {
+ XCTAssertEqual(ReceiverDirectory.isUnpair(notification(0x40, slot: 3, address: 0x02)), 3)
+ XCTAssertNil(ReceiverDirectory.isUnpair(notification(0x40, slot: 3, address: 0x00)), "not an un-pairing")
+ XCTAssertNil(ReceiverDirectory.isUnpair(notification(0x41, slot: 3, address: 0x02, [0x42, 0x7B, 0x40])))
+ XCTAssertNil(ReceiverDirectory.isUnpair(notification(0x40, slot: 0xFF, address: 0x02)))
+ }
+
+ func testNotificationFlagsMergeKeepsOtherBits() {
+ XCTAssertEqual(ReceiverDirectory.mergedNotificationFlags(current: [0x00, 0x00, 0x00]), [0x00, 0x09, 0x00])
+ XCTAssertEqual(ReceiverDirectory.mergedNotificationFlags(current: [0x00, 0x09, 0x00]), [0x00, 0x09, 0x00])
+ // Another program's flags (link quality 0x000400, battery 0x100000, 3D gesture 0x000001) survive.
+ XCTAssertEqual(ReceiverDirectory.mergedNotificationFlags(current: [0x10, 0x04, 0x01]), [0x10, 0x0D, 0x01])
+ XCTAssertEqual(ReceiverDirectory.mergedNotificationFlags(current: [0x00, 0x01, 0x00]), [0x00, 0x09, 0x00])
+ XCTAssertEqual(ReceiverDirectory.mergedNotificationFlags(current: []), [0x00, 0x09, 0x00])
+ XCTAssertEqual(ReceiverDirectory.mergedNotificationFlags(current: [0x01, 0x02, 0x03, 0x04]), [0x01, 0x0B, 0x03])
+ }
+
+ func testRequestMessages() {
+ XCTAssertEqual(ReceiverDirectory.pairingReadMessage(slot: 2, layout: .unifying).bytes, [0x10, 0xFF, 0x83, 0xB5, 0x21, 0x00, 0x00])
+ XCTAssertEqual(ReceiverDirectory.pairingReadMessage(slot: 2, layout: .bolt).bytes, [0x10, 0xFF, 0x83, 0xB5, 0x52, 0x00, 0x00])
+ XCTAssertEqual(ReceiverDirectory.serialReadMessage(slot: 2, layout: .unifying).bytes, [0x10, 0xFF, 0x83, 0xB5, 0x31, 0x00, 0x00])
+ XCTAssertEqual(ReceiverDirectory.notificationFlagsReadMessage.bytes, [0x10, 0xFF, 0x81, 0x00, 0x00, 0x00, 0x00])
+ XCTAssertEqual(ReceiverDirectory.notificationFlagsWriteMessage([0x10, 0x0D, 0x01]).bytes, [0x10, 0xFF, 0x80, 0x00, 0x10, 0x0D, 0x01])
+ XCTAssertEqual(ReceiverDirectory.announcementRequestMessage.bytes, [0x10, 0xFF, 0x80, 0x02, 0x02, 0x00, 0x00])
+ }
+
+ // MARK: Receiver operations
+
+ private func makeClient(_ receiver: PairingTestReceiver) -> HIDPPClient {
+ let client = HIDPPClient(transport: receiver)
+ client.defaultTimeout = 0.1
+ return client
+ }
+
+ func testReadPairingsListsOccupiedSlots() async throws {
+ let receiver = PairingTestReceiver()
+ receiver.pairingReplies[0x20] = [0x20, 0x00, 0x08, 0x40, 0x7B, 0x00, 0x00, 0x02]
+ receiver.pairingReplies[0x22] = [0x22, 0x00, 0x08, 0x40, 0x82, 0x00, 0x00, 0x01]
+ receiver.pairingReplies[0x23] = [0x23, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]
+
+ let records = try await ReceiverDirectory.readPairings(using: makeClient(receiver), layout: .unifying)
+ XCTAssertEqual(records, [
+ 1: PairingRecord(slot: 1, wirelessPID: 0x407B, kind: .mouse),
+ 3: PairingRecord(slot: 3, wirelessPID: 0x4082, kind: .keyboard),
+ ])
+ XCTAssertEqual(receiver.pairingReads, [0x20, 0x21, 0x22, 0x23, 0x24, 0x25], "every slot is read, whatever the capacity")
+ }
+
+ func testReadPairingsUsesTheBoltLayout() async throws {
+ let receiver = PairingTestReceiver()
+ receiver.pairingReplies[0x51] = [0x51, 0x02, 0x34, 0xB0, 0x01, 0x02, 0x03, 0x04]
+ receiver.pairingReplies[0x52] = [0x52, 0x02, 0x84, 0xB0, 0x05, 0x06, 0x07, 0x08]
+
+ let records = try await ReceiverDirectory.readPairings(using: makeClient(receiver), layout: .bolt)
+ XCTAssertEqual(records?.mapValues(\.wirelessPID), [1: 0xB034, 2: 0xB084])
+ XCTAssertEqual(receiver.pairingReads, [0x51, 0x52, 0x53, 0x54, 0x55, 0x56])
+ }
+
+ func testReadPairingsIsNilWhenTheRegisterIsUnsupportedOrSilent() async throws {
+ let unsupported = PairingTestReceiver()
+ unsupported.registerError = 0x02
+ let none = try await ReceiverDirectory.readPairings(using: makeClient(unsupported), layout: .unifying)
+ XCTAssertNil(none)
+ XCTAssertEqual(unsupported.pairingReads.count, 1)
+
+ let empty = PairingTestReceiver()
+ let nothingListed = try await ReceiverDirectory.readPairings(using: makeClient(empty), layout: .unifying)
+ XCTAssertNil(nothingListed, "no slot answered with a record")
+ }
+
+ func testReadPairingsStopsAtATimeoutKeepingWhatWasRead() async throws {
+ let receiver = PairingTestReceiver()
+ receiver.pairingReplies[0x20] = [0x20, 0x00, 0x08, 0x40, 0x7B, 0x00, 0x00, 0x02]
+ receiver.silentSubRegisters = [0x22]
+ receiver.pairingReplies[0x24] = [0x24, 0x00, 0x08, 0x40, 0x82, 0x00, 0x00, 0x02]
+
+ let records = try await ReceiverDirectory.readPairings(using: makeClient(receiver), layout: .unifying)
+ XCTAssertEqual(records?.keys.sorted(), [1])
+ XCTAssertEqual(receiver.pairingReads, [0x20, 0x21, 0x22])
+ }
+
+ func testReadPairingsThrowsWhenTheTransportCloses() async {
+ let receiver = PairingTestReceiver()
+ let client = makeClient(receiver)
+ client.close()
+ do {
+ _ = try await ReceiverDirectory.readPairings(using: client, layout: .unifying)
+ XCTFail("expected transportClosed")
+ } catch {
+ XCTAssertEqual(error as? HIDPPError, .transportClosed)
+ }
+ }
+
+ func testNotificationFlagsAreReadModifiedAndWritten() async throws {
+ let receiver = PairingTestReceiver()
+ receiver.notificationFlags = [0x10, 0x04, 0x00]
+ let written = try await ReceiverDirectory.enableNotifications(using: makeClient(receiver))
+ XCTAssertEqual(written, [0x10, 0x0D, 0x00])
+ XCTAssertEqual(receiver.notificationFlags, [0x10, 0x0D, 0x00])
+ XCTAssertEqual(receiver.sent.map(\.subID), [0x81, 0x80], "one read, then one write")
+ }
+
+ func testNotificationFlagsFallBackToAPlainWriteWhenUnreadable() async throws {
+ let receiver = PairingTestReceiver()
+ receiver.notificationFlags = nil
+ let written = try await ReceiverDirectory.enableNotifications(using: makeClient(receiver))
+ XCTAssertEqual(written, [0x00, 0x09, 0x00])
+ XCTAssertEqual(receiver.writes(to: 0x00), [[0x00, 0x09, 0x00]])
+ }
+
+ func testReadSerialAndRequestAnnouncements() async throws {
+ let receiver = PairingTestReceiver()
+ receiver.pairingReplies[0x31] = [0x31, 0x1A, 0x2B, 0x3C, 0x4D, 0x00, 0x00, 0x00]
+ let client = makeClient(receiver)
+ let serial = try await ReceiverDirectory.readSerial(using: client, slot: 2, layout: .unifying)
+ XCTAssertEqual(serial, "1A2B3C4D")
+ let missing = try await ReceiverDirectory.readSerial(using: client, slot: 3, layout: .unifying)
+ XCTAssertNil(missing)
+
+ try await ReceiverDirectory.requestAnnouncements(using: client)
+ XCTAssertEqual(receiver.writes(to: 0x02), [[0x02, 0x00, 0x00]])
+ }
+}
+
+/// A receiver answering HID++ 1.0 register traffic from tables, synchronously.
+private final class PairingTestReceiver: HIDPPTransport, @unchecked Sendable {
+ var onReport: (([UInt8]) -> Void)?
+ var onClose: (() -> Void)?
+
+ /// Long register 0xB5 replies by sub-register. Others answer HID++ 1.0 "invalid value".
+ var pairingReplies: [UInt8: [UInt8]] = [:]
+ /// Sub-registers that never answer.
+ var silentSubRegisters: Set = []
+ /// Answers every 0xB5 read with this HID++ 1.0 error.
+ var registerError: UInt8?
+ /// Register 0x00; nil refuses reads.
+ var notificationFlags: [UInt8]? = [0x00, 0x00, 0x00]
+ private(set) var sent: [HIDPPMessage] = []
+
+ var pairingReads: [UInt8] {
+ sent.filter { $0.subID == 0x83 && $0.address == 0xB5 }.map { $0.parameters[0] }
+ }
+
+ func writes(to register: UInt8) -> [[UInt8]] {
+ sent.filter { $0.subID == 0x80 && $0.address == register }.map { Array($0.parameters.prefix(3)) }
+ }
+
+ func send(_ bytes: [UInt8]) throws {
+ guard let request = HIDPPMessage(bytes: bytes) else { return }
+ sent.append(request)
+ if let reply = respond(to: request) { onReport?(reply.bytes) }
+ }
+
+ private func respond(to request: HIDPPMessage) -> HIDPPMessage? {
+ let p = request.parameters
+ switch (request.subID, request.address) {
+ case (0x81, 0x00):
+ guard let notificationFlags else { return error(request, 0x02) }
+ return HIDPPMessage(reportID: .short, deviceIndex: 0xFF, subID: 0x81, address: 0x00, parameters: notificationFlags)
+ case (0x80, let register):
+ if register == 0x00 { notificationFlags = Array(p.prefix(3)) }
+ return HIDPPMessage(reportID: .short, deviceIndex: 0xFF, subID: 0x80, address: register)
+ case (0x83, 0xB5):
+ if let registerError { return error(request, registerError) }
+ if silentSubRegisters.contains(p[0]) { return nil }
+ guard let reply = pairingReplies[p[0]] else { return error(request, 0x03) }
+ return HIDPPMessage(reportID: .long, deviceIndex: 0xFF, subID: 0x83, address: 0xB5, parameters: reply)
+ default:
+ return error(request, 0x01)
+ }
+ }
+
+ private func error(_ request: HIDPPMessage, _ code: UInt8) -> HIDPPMessage {
+ HIDPPMessage(reportID: .short, deviceIndex: request.deviceIndex, subID: 0x8F, address: request.subID, parameters: [request.address, code])
+ }
+}
diff --git a/UprightKit/Tests/UprightKitTests/RemapEngineMultiSourceTests.swift b/UprightKit/Tests/UprightKitTests/RemapEngineMultiSourceTests.swift
new file mode 100644
index 0000000..5945f34
--- /dev/null
+++ b/UprightKit/Tests/UprightKitTests/RemapEngineMultiSourceTests.swift
@@ -0,0 +1,371 @@
+import XCTest
+@testable import UprightKit
+
+/// Engine state is per device: the same button held on two mice is two independent holds.
+@MainActor
+final class RemapEngineMultiSourceTests: XCTestCase {
+ /// Shared mutable state for the engine's `@MainActor` provider closures.
+ @MainActor
+ private final class Box {
+ var value: Value
+ init(_ value: Value) { self.value = value }
+ }
+
+ private let a = DeviceAddress(link: 1, index: DeviceAddress.direct)
+ private let b = DeviceAddress(link: 2, index: 2)
+ private let back = KnownControl.back
+ private let top = KnownControl.dpiSwitch
+ private let backCombo = ActionEffect.keyCombo(KeyCombo(keyCode: KeyCode.leftBracket, modifiers: .command))
+ private let copyCombo = ActionEffect.keyCombo(KeyCombo(keyCode: KeyCode.c, modifiers: .command))
+ private let gestureSet = GestureSet(
+ tap: .system(.copy),
+ up: .system(.missionControl),
+ down: .system(.appExpose),
+ left: .system(.spaceLeft),
+ right: .device(.cycleDPI)
+ )
+
+ private func press(_ engine: RemapEngine, _ cid: UInt16, on source: DeviceAddress) {
+ engine.handle(ButtonEvent(cid: cid, phase: .press, source: source))
+ }
+
+ private func release(_ engine: RemapEngine, _ cid: UInt16, on source: DeviceAddress) {
+ engine.handle(ButtonEvent(cid: cid, phase: .release, source: source))
+ }
+
+ private func gestureEngine(_ performer: RecordingPerformer) -> RemapEngine {
+ let engine = RemapEngine(mappings: ButtonMappings([top: .gestures(gestureSet)]), performer: performer)
+ engine.rawGestureThreshold = 40
+ return engine
+ }
+
+ // MARK: Holds
+
+ func testSameCIDHeldOnTwoSourcesIsIndependent() async {
+ let performer = RecordingPerformer()
+ let engine = RemapEngine(mappings: .legacyDefaults, performer: performer)
+
+ press(engine, back, on: a)
+ press(engine, back, on: b)
+ XCTAssertEqual(performer.log.map(\.1), [.down, .down], "B's press doesn't end A's hold")
+ XCTAssertEqual(engine.pressedControls(for: a), [back])
+ XCTAssertEqual(engine.pressedControls(for: b), [back])
+
+ release(engine, back, on: a)
+ XCTAssertEqual(performer.log.map(\.1), [.down, .down, .up])
+ XCTAssertEqual(engine.pressedControls(for: a), [])
+ XCTAssertEqual(engine.pressedControls(for: b), [back], "B is still held")
+ XCTAssertEqual(engine.held.keys.sorted(), [ControlKey(source: b, cid: back)])
+
+ release(engine, back, on: b)
+ XCTAssertEqual(performer.log.map(\.0), [backCombo, backCombo, backCombo, backCombo])
+ XCTAssertEqual(performer.log.map(\.1), [.down, .down, .up, .up])
+ XCTAssertTrue(engine.held.isEmpty)
+ XCTAssertTrue(engine.pressedKeys.isEmpty)
+ }
+
+ func testInterleavedReleasesEndOnlyTheirOwnHold() async {
+ let performer = RecordingPerformer()
+ let engine = RemapEngine(mappings: .defaults, performer: performer)
+ engine.mappingsProvider = { [a] source in
+ source == a
+ ? ButtonMappings([KnownControl.back: .system(.navigateBack)])
+ : ButtonMappings([KnownControl.back: .system(.copy)])
+ }
+
+ press(engine, back, on: a)
+ press(engine, back, on: b)
+ release(engine, back, on: a)
+ XCTAssertEqual(performer.log.map(\.0), [backCombo, copyCombo, backCombo], "A's release ends A's effect, not B's")
+ XCTAssertEqual(performer.log.map(\.1), [.down, .down, .up])
+
+ press(engine, back, on: a)
+ release(engine, back, on: b)
+ release(engine, back, on: a)
+ XCTAssertEqual(performer.log.dropFirst(3).map(\.0), [backCombo, copyCombo, backCombo])
+ XCTAssertEqual(performer.log.dropFirst(3).map(\.1), [.down, .up, .up])
+ }
+
+ func testLostReleaseHeuristicIsPerSource() async {
+ let performer = RecordingPerformer()
+ let engine = RemapEngine(mappings: .legacyDefaults, performer: performer)
+
+ press(engine, back, on: a)
+ press(engine, back, on: b)
+ XCTAssertEqual(performer.log.map(\.1), [.down, .down], "a press on another mouse isn't a lost release")
+
+ // A second press on A without a release: A's first hold ends, B's stays.
+ press(engine, back, on: a)
+ XCTAssertEqual(performer.log.map(\.1), [.down, .down, .up, .down])
+ XCTAssertEqual(engine.held.count, 2)
+
+ release(engine, back, on: a)
+ release(engine, back, on: b)
+ XCTAssertEqual(performer.log.map(\.1), [.down, .down, .up, .down, .up, .up])
+ XCTAssertTrue(engine.held.isEmpty)
+ }
+
+ func testReleaseAllSourceLeavesOthersHeld() async {
+ let performer = RecordingPerformer()
+ let engine = RemapEngine(mappings: .legacyDefaults, performer: performer)
+ engine.rawGestureThreshold = 40
+ engine.mappingsProvider = { [a, gestureSet] source in
+ var mappings = ButtonMappings.legacyDefaults
+ if source == a { mappings[KnownControl.dpiSwitch] = .gestures(gestureSet) }
+ return mappings
+ }
+
+ press(engine, back, on: a)
+ press(engine, top, on: a)
+ press(engine, back, on: b)
+ press(engine, KnownControl.forward, on: b)
+ XCTAssertEqual(engine.gestureControls(for: a), [top])
+
+ engine.releaseAll(source: a)
+ XCTAssertEqual(performer.log.map(\.1), [.down, .down, .down, .up], "only A's held key goes up")
+ XCTAssertEqual(performer.log.last?.0, backCombo)
+ XCTAssertEqual(engine.pressedControls(for: a), [])
+ XCTAssertEqual(engine.gestureControls(for: a), [], "A's gesture is cancelled")
+ XCTAssertEqual(engine.pressedControls(for: b), [back, KnownControl.forward])
+ XCTAssertEqual(engine.held.keys.map(\.source), [b, b])
+
+ // A's late movement and release do nothing; B's release still ends B's hold.
+ engine.handlePointerDelta(PointerDelta(source: a, dx: 0, dy: -100))
+ release(engine, top, on: a)
+ release(engine, back, on: a)
+ XCTAssertEqual(performer.log.count, 4)
+ release(engine, back, on: b)
+ release(engine, KnownControl.forward, on: b)
+ XCTAssertEqual(performer.log.map(\.1), [.down, .down, .down, .up, .up, .up])
+ XCTAssertTrue(engine.held.isEmpty)
+ }
+
+ func testDisablingEngineReleasesEverySource() async {
+ let performer = RecordingPerformer()
+ let engine = RemapEngine(mappings: .legacyDefaults, performer: performer)
+
+ press(engine, back, on: a)
+ press(engine, back, on: b)
+ engine.isEnabled = false
+ XCTAssertEqual(performer.log.map(\.1), [.down, .down, .up, .up])
+ XCTAssertTrue(engine.pressedKeys.isEmpty)
+ XCTAssertTrue(engine.held.isEmpty)
+ }
+
+ // MARK: Gestures
+
+ func testGestureDeltasRouteBySource() async {
+ let performer = RecordingPerformer()
+ let engine = gestureEngine(performer)
+
+ press(engine, top, on: a)
+ press(engine, top, on: b)
+ XCTAssertEqual(engine.gestureControls(for: a), [top])
+ XCTAssertEqual(engine.gestureControls(for: b), [top])
+
+ engine.handlePointerDelta(PointerDelta(source: a, dx: 0, dy: -50))
+ XCTAssertEqual(performer.log.map(\.0), [.missionControl(.missionControl)], "only A's gesture moved")
+
+ engine.handlePointerDelta(PointerDelta(source: b, dx: 0, dy: 50))
+ XCTAssertEqual(performer.log.map(\.0), [.missionControl(.missionControl), .missionControl(.appExpose)])
+
+ // A delta from a third mouse with no gesture held reaches nobody.
+ engine.handlePointerDelta(PointerDelta(source: DeviceAddress(link: 3, index: 1), dx: -200, dy: 0))
+ release(engine, top, on: a)
+ release(engine, top, on: b)
+ XCTAssertEqual(performer.log.count, 2, "both gestures fired a direction, so neither release is a tap")
+ }
+
+ func testGestureMovementOnOneSourceLeavesOtherATap() async {
+ let performer = RecordingPerformer()
+ let engine = gestureEngine(performer)
+
+ press(engine, top, on: a)
+ press(engine, top, on: b)
+ engine.handlePointerDelta(PointerDelta(source: b, dx: 0, dy: -50))
+ release(engine, top, on: a)
+ release(engine, top, on: b)
+
+ XCTAssertEqual(performer.log.map(\.0), [.missionControl(.missionControl), copyCombo, copyCombo])
+ XCTAssertEqual(performer.log.map(\.1), [.down, .down, .up])
+ }
+
+ func testCursorDeltasOnlyReachCursorGestures() async {
+ let performer = RecordingPerformer()
+ let engine = gestureEngine(performer)
+ engine.gestureSourceFor = { [b] source, _ in source == b ? .cursor : .rawSensor }
+
+ press(engine, top, on: a)
+ press(engine, top, on: b)
+ XCTAssertTrue(engine.needsCursorDeltas)
+
+ engine.handlePointerDelta(PointerDelta(source: b, dx: 0, dy: -500))
+ XCTAssertTrue(performer.log.isEmpty, "raw deltas don't feed B's cursor-tracked gesture")
+
+ engine.handleCursorDelta(dx: -Int(GestureRecognizer.cursorThreshold), dy: 0)
+ let spaceLeft = ActionEffect.keyCombo(KeyCombo(keyCode: KeyCode.leftArrow, modifiers: .control))
+ XCTAssertEqual(performer.log.map(\.0), [spaceLeft, spaceLeft], "cursor movement reaches only B's gesture, not A's raw one")
+
+ release(engine, top, on: b)
+ XCTAssertFalse(engine.needsCursorDeltas)
+ engine.handlePointerDelta(PointerDelta(source: a, dx: 0, dy: 50))
+ release(engine, top, on: a)
+ XCTAssertEqual(performer.log.last?.0, .missionControl(.appExpose))
+ }
+
+ func testGestureSourceForTakesPrecedenceOverCompatGestureSource() async {
+ let performer = RecordingPerformer()
+ let engine = gestureEngine(performer)
+ engine.gestureSource = { _ in .cursor }
+ engine.gestureSourceFor = { _, _ in .rawSensor }
+
+ press(engine, top, on: a)
+ XCTAssertFalse(engine.needsCursorDeltas)
+ engine.handlePointerDelta(PointerDelta(source: a, dx: 0, dy: -50))
+ XCTAssertEqual(performer.log.map(\.0), [.missionControl(.missionControl)])
+ }
+
+ func testPerSourceThresholdCapturedAtPress() async {
+ let performer = RecordingPerformer()
+ let engine = gestureEngine(performer)
+ let thresholds = Box<[DeviceAddress: Double]>([a: 40, b: 400])
+ engine.rawThresholdProvider = { thresholds.value[$0] ?? 1 }
+
+ press(engine, top, on: a)
+ press(engine, top, on: b)
+ // A DPI change mid-hold doesn't move the goalposts of gestures already in progress.
+ thresholds.value = [a: 1000, b: 10]
+
+ engine.handlePointerDelta(PointerDelta(source: b, dx: 0, dy: -100))
+ XCTAssertTrue(performer.log.isEmpty, "B's gesture keeps the 400-count distance from its press")
+ engine.handlePointerDelta(PointerDelta(source: a, dx: 0, dy: -100))
+ XCTAssertEqual(performer.log.map(\.0), [.missionControl(.missionControl)], "A's gesture keeps 40")
+
+ release(engine, top, on: a)
+ release(engine, top, on: b)
+ XCTAssertEqual(performer.log.map(\.0), [.missionControl(.missionControl), copyCombo, copyCombo])
+
+ // The next press reads the new distances.
+ performer.log.removeAll()
+ press(engine, top, on: b)
+ engine.handlePointerDelta(PointerDelta(source: b, dx: 0, dy: -10))
+ release(engine, top, on: b)
+ XCTAssertEqual(performer.log.map(\.0), [.missionControl(.missionControl)])
+ }
+
+ func testCompatRawDeltaWithoutSourceReachesEveryRawGesture() async {
+ let performer = RecordingPerformer()
+ let engine = gestureEngine(performer)
+ engine.gestureSourceFor = { [b] source, _ in source == b ? .cursor : .rawSensor }
+
+ press(engine, top, on: a)
+ press(engine, top, on: b)
+ engine.handlePointerDelta(dx: 0, dy: -50)
+ XCTAssertEqual(performer.log.map(\.0), [.missionControl(.missionControl)], "unattributed raw movement skips cursor gestures")
+ engine.handlePointerDelta(dx: 0, dy: 50, source: .cursor)
+ XCTAssertEqual(performer.log.map(\.0), [.missionControl(.missionControl), .missionControl(.appExpose)])
+ }
+
+ // MARK: Device actions and mappings
+
+ func testDeviceActionCarriesSource() async {
+ let performer = RecordingPerformer()
+ let engine = RemapEngine(mappings: ButtonMappings([
+ back: .device(.cycleDPI),
+ top: .gestures(gestureSet),
+ ]), performer: performer)
+ engine.rawGestureThreshold = 40
+ var actions: [DeviceAction] = []
+ var sources: [DeviceAddress] = []
+ var compat: [DeviceAction] = []
+ engine.onSourcedDeviceAction = { action, source in
+ actions.append(action)
+ sources.append(source)
+ }
+
+ press(engine, back, on: b)
+ release(engine, back, on: b)
+ press(engine, top, on: a)
+ engine.handlePointerDelta(PointerDelta(source: a, dx: 60, dy: 0))
+ release(engine, top, on: a)
+ XCTAssertEqual(actions, [.cycleDPI, .cycleDPI])
+ XCTAssertEqual(sources, [b, a], "a press and a gesture slot each name their mouse")
+ XCTAssertTrue(performer.log.isEmpty)
+
+ // The single-mouse callback still fires when set.
+ engine.onDeviceAction = { compat.append($0) }
+ press(engine, back, on: a)
+ XCTAssertEqual(compat, [.cycleDPI])
+ XCTAssertEqual(sources, [b, a, a])
+ }
+
+ func testPerSourceMappingsProvider() async {
+ let performer = RecordingPerformer()
+ let engine = RemapEngine(mappings: ButtonMappings([back: .system(.paste)]), performer: performer)
+ let asked = Box<[DeviceAddress]>([])
+ engine.mappingsProvider = { [a] source in
+ asked.value.append(source)
+ return source == a ? .legacyDefaults : ButtonMappings([KnownControl.back: .system(.copy)])
+ }
+
+ press(engine, back, on: a)
+ release(engine, back, on: a)
+ press(engine, back, on: b)
+ release(engine, back, on: b)
+ XCTAssertEqual(asked.value, [a, b], "asked once per press, for the pressing mouse")
+ XCTAssertEqual(performer.log.map(\.0), [backCombo, backCombo, copyCombo, copyCombo], "`mappings` is unused while a provider is set")
+
+ // A provider change mid-hold doesn't affect the release (per mouse).
+ performer.log.removeAll()
+ press(engine, back, on: a)
+ engine.mappingsProvider = { _ in ButtonMappings([KnownControl.back: .system(.copy)]) }
+ release(engine, back, on: a)
+ XCTAssertEqual(performer.log.map(\.0), [backCombo, backCombo])
+
+ // Unmapped on one mouse stays native there while mapped on the other.
+ performer.log.removeAll()
+ engine.mappingsProvider = { [a] source in source == a ? .defaults : .legacyDefaults }
+ press(engine, back, on: a)
+ release(engine, back, on: a)
+ XCTAssertTrue(performer.log.isEmpty)
+ XCTAssertEqual(engine.pressedControls(for: a), [])
+
+ // Removing the provider falls back to `mappings`.
+ engine.mappingsProvider = nil
+ press(engine, back, on: b)
+ XCTAssertEqual(performer.log.first?.0, .keyCombo(KeyCombo(keyCode: KeyCode.v, modifiers: .command)))
+ }
+
+ func testCompatPressedControlsIsUnion() async {
+ let engine = RemapEngine(mappings: ButtonMappings([top: .gestures(gestureSet)]), performer: RecordingPerformer())
+
+ press(engine, back, on: a)
+ press(engine, top, on: b)
+ press(engine, back, on: b)
+ XCTAssertEqual(engine.pressedControls, [back, top])
+ XCTAssertEqual(engine.gestureControls, [top])
+ XCTAssertEqual(engine.pressedControls(for: a), [back])
+ XCTAssertEqual(engine.pressedControls(for: b), [back, top])
+ XCTAssertEqual(engine.gestureControls(for: a), [])
+ XCTAssertEqual(engine.gestureControls(for: b), [top])
+
+ release(engine, back, on: b)
+ XCTAssertEqual(engine.pressedControls, [back, top], "A still holds Back")
+ release(engine, back, on: a)
+ XCTAssertEqual(engine.pressedControls, [top])
+ engine.releaseAll()
+ XCTAssertEqual(engine.pressedControls, [])
+ XCTAssertEqual(engine.gestureControls, [])
+ }
+
+ func testControlKeyOrdersBySourceThenCID() async {
+ let keys = [
+ ControlKey(source: b, cid: 0x53),
+ ControlKey(source: a, cid: 0x56),
+ ControlKey(source: b, cid: 0x52),
+ ControlKey(source: a, cid: 0x53),
+ ]
+ XCTAssertEqual(keys.sorted().map(\.cid), [0x53, 0x56, 0x52, 0x53])
+ XCTAssertEqual(keys.sorted().map(\.source), [a, a, b, b])
+ }
+}
diff --git a/UprightKit/Tests/UprightKitTests/ScrollTransformTests.swift b/UprightKit/Tests/UprightKitTests/ScrollTransformTests.swift
index 2f64b1f..db39828 100644
--- a/UprightKit/Tests/UprightKitTests/ScrollTransformTests.swift
+++ b/UprightKit/Tests/UprightKitTests/ScrollTransformTests.swift
@@ -102,11 +102,11 @@ final class ScrollTransformTests: XCTestCase {
XCTAssertTrue(ScrollSettings(reverseVertical: true).transformsEvents)
XCTAssertTrue(ScrollSettings(speed: 1.25).transformsEvents)
XCTAssertTrue(ScrollSettings(smoothScrolling: true).transformsEvents)
- XCTAssertFalse(ScrollSettings(onlyWhileMXVerticalConnected: false).transformsEvents)
+ XCTAssertFalse(ScrollSettings(onlyWhileMouseConnected: false).transformsEvents)
}
func testSettingsRoundTripAndTolerantDecoding() throws {
- let settings = ScrollSettings(reverseVertical: true, speed: 1.5, smoothScrolling: true, onlyWhileMXVerticalConnected: false)
+ let settings = ScrollSettings(reverseVertical: true, speed: 1.5, smoothScrolling: true, onlyWhileMouseConnected: false)
let data = try JSONEncoder().encode(settings)
XCTAssertEqual(try JSONDecoder().decode(ScrollSettings.self, from: data), settings)
@@ -118,6 +118,38 @@ final class ScrollTransformTests: XCTestCase {
XCTAssertEqual(try JSONDecoder().decode(ScrollSettings.self, from: Data("{}".utf8)), .default)
XCTAssertEqual(try JSONDecoder().decode(ScrollSettings.self, from: Data(#"{"speed":99}"#.utf8)).speed, 3)
}
+
+ /// The Swift property was renamed for multi-mouse support, but saves keep the old key.
+ func testOnlyWhileMouseConnectedKeepsItsStoredKey() throws {
+ XCTAssertTrue(ScrollSettings.default.onlyWhileMouseConnected)
+
+ let old = try JSONDecoder().decode(ScrollSettings.self, from: Data(#"{"onlyWhileMXVerticalConnected":false}"#.utf8))
+ XCTAssertFalse(old.onlyWhileMouseConnected)
+ let oldTrue = try JSONDecoder().decode(ScrollSettings.self, from: Data(#"{"onlyWhileMXVerticalConnected":true}"#.utf8))
+ XCTAssertTrue(oldTrue.onlyWhileMouseConnected)
+
+ let encoded = try JSONEncoder().encode(ScrollSettings(onlyWhileMouseConnected: false))
+ let object = try XCTUnwrap(JSONSerialization.jsonObject(with: encoded) as? [String: Any])
+ XCTAssertEqual(object["onlyWhileMXVerticalConnected"] as? Bool, false)
+ XCTAssertNil(object["onlyWhileMouseConnected"])
+ XCTAssertEqual(
+ Set(object.keys),
+ ["reverseVertical", "reverseHorizontal", "speed", "smoothScrolling", "onlyWhileMXVerticalConnected"]
+ )
+
+ // A save under the new Swift name is not a thing: it is ignored and the default applies.
+ let wrongKey = try JSONDecoder().decode(ScrollSettings.self, from: Data(#"{"onlyWhileMouseConnected":false}"#.utf8))
+ XCTAssertTrue(wrongKey.onlyWhileMouseConnected)
+ }
+
+ func testDeprecatedAliasStillReadsAndWritesTheRenamedProperty() {
+ var settings = ScrollSettings(onlyWhileMouseConnected: false)
+ XCTAssertFalse(settings.onlyWhileMXVerticalConnected)
+ settings.onlyWhileMXVerticalConnected = true
+ XCTAssertTrue(settings.onlyWhileMouseConnected)
+ settings.onlyWhileMouseConnected = false
+ XCTAssertFalse(settings.onlyWhileMXVerticalConnected)
+ }
}
final class SmoothScrollAnimatorTests: XCTestCase {
diff --git a/UprightKit/Tests/UprightKitTests/SimulatedMXVertical.swift b/UprightKit/Tests/UprightKitTests/SimulatedMXVertical.swift
index 1f813f2..1b3cb76 100644
--- a/UprightKit/Tests/UprightKitTests/SimulatedMXVertical.swift
+++ b/UprightKit/Tests/UprightKitTests/SimulatedMXVertical.swift
@@ -1,55 +1,37 @@
import Foundation
@testable import UprightKit
-/// A software MX Vertical that answers HID++ requests the way the real mouse (and a
-/// Unifying receiver in front of it) does. Replies arrive asynchronously, like IOKit's.
+/// The single-mouse simulator the controller tests were written against, kept as a facade over
+/// the Kit's multi-device simulator (`SimulatedReceiver`, `SimulatedDirect`).
///
-/// In receiver mode other devices can share the receiver, the mouse can be re-paired to a
-/// different slot or unpaired, and the receiver answers pairing-info reads (register 0xB5)
-/// and device-arrival requests (register 0x02).
+/// "The mouse" is one MX Vertical: directly connected, or in a Unifying receiver slot. In
+/// receiver mode other devices can share the receiver (`addDevice`), and the mouse can be
+/// re-paired to another slot or unpaired; every member below acts on the mouse wherever it is.
+/// Its control table, features, firmware, serial, pointer speed and Easy-Switch hosts are the
+/// values these tests have always used; its identity (unit ID, model IDs) is
+/// `DeviceProfiles.mxVertical`'s.
///
-/// Besides button diversion the mouse answers firmware (0x0003), pointer speed (0x2201),
-/// Easy-Switch (0x1814, 0x1815) and raw XY reporting requests.
+/// New tests should use `SimulatedReceiver` / `SimulatedDirect` and `DeviceProfiles` directly.
final class SimulatedMXVertical: HIDPPTransport, @unchecked Sendable {
enum Mode {
case direct
case receiver(slot: UInt8)
}
- private struct Device {
- var name: String
- var wirelessPID: UInt16
- var awake = true
- var diverted: Set = []
- var rawXY: Set = []
- var dpi: Int = SimulatedMXVertical.defaultDPI
- var currentHost: UInt8 = 0
-
- /// What a power cycle or reconnect clears: temporary diversion and the HID++ pointer speed.
- mutating func forgetVolatileState() {
- diverted.removeAll()
- rawXY.removeAll()
- dpi = SimulatedMXVertical.defaultDPI
- }
- }
-
var onReport: (([UInt8]) -> Void)?
var onClose: (() -> Void)?
let mode: Mode
let name: String
- private let queue = DispatchQueue(label: "simulated-mx-vertical")
- private let lock = NSLock()
+ /// The feature table this mouse reports.
+ let features: [UInt16]
+ /// The simulator behind the facade.
+ let simulator: SimulatedHIDPPTransport
+ var receiver: SimulatedReceiver? { simulator as? SimulatedReceiver }
- /// Devices by device index (0xFF for a directly connected mouse).
- private var _devices: [UInt8: Device] = [:]
+ private let lock = NSLock()
+ /// Where the mouse answers (receiver slot or 0xFF); nil once unpaired.
private var _mouseIndex: UInt8?
- private var _sent: [[UInt8]] = []
- private var _dropsRequests = false
- private var _latency: TimeInterval = 0
- private var _supportsPairingInfo = true
- private var _cidErrors: [UInt16: (code: UInt8, remaining: Int)] = [:]
- private var _hostSwitches: [UInt8] = []
/// The original six features keep their indices (reprog at 4, wireless status at 5).
static let baseFeatures: [UInt16] = [0x0000, 0x0001, 0x0005, 0x1000, 0x1B04, 0x1D4B]
@@ -57,6 +39,7 @@ final class SimulatedMXVertical: HIDPPTransport, @unchecked Sendable {
static let allFeatures = baseFeatures + extendedFeatures
static let reprogIndex: UInt8 = 4
static let wirelessStatusIndex: UInt8 = 5
+ /// The wireless PID `addDevice` reports by default (an MX Keys).
static let otherDevicePID: UInt16 = 0x408A
/// Feature versions reported by the feature table (default 0).
static let featureVersions: [UInt16: UInt8] = [0x0003: 4, 0x1B04: 4]
@@ -79,9 +62,6 @@ final class SimulatedMXVertical: HIDPPTransport, @unchecked Sendable {
(0x00D7, 0xB4, 0x00), // virtual gesture button: virtual, divertable
]
- /// The feature table this mouse reports.
- let features: [UInt16]
-
/// The configurable controls (what the controller re-sends on every forced pass).
static let configurableCount = 4
@@ -89,372 +69,185 @@ final class SimulatedMXVertical: HIDPPTransport, @unchecked Sendable {
self.mode = mode
self.name = name
self.features = features
- let pid = MXVertical.matches(name: name) ? MXVertical.wirelessProductID : Self.otherDevicePID
- let index: UInt8
+ let profile = Self.isMXVertical(name)
+ ? Self.mxVerticalProfile(name: name, features: features, unit: DeviceProfiles.mxVertical)
+ : DeviceProfiles.mxKeys.named(name)
switch mode {
- case .direct: index = 0xFF
- case .receiver(let slot): index = slot
+ case .direct:
+ simulator = SimulatedDirect(profile: profile)
+ _mouseIndex = SimulatedDirect.index
+ case .receiver(let slot):
+ simulator = SimulatedReceiver.unifying([slot: profile])
+ _mouseIndex = slot
}
- _devices[index] = Device(name: name, wirelessPID: pid)
- _mouseIndex = index
+ simulator.onReport = { [weak self] bytes in self?.onReport?(bytes) }
+ simulator.onClose = { [weak self] in self?.onClose?() }
+ }
+
+ /// The test MX Vertical: `unit`'s identity with the control table, features and device
+ /// information above.
+ static func mxVerticalProfile(name: String, features: [UInt16], unit: SimulatedDeviceProfile) -> SimulatedDeviceProfile {
+ var profile = unit
+ profile.name = name
+ profile.features = features.map { SimulatedDeviceProfile.Feature($0, version: featureVersions[$0] ?? 0) }
+ profile.controls = controls.enumerated().map { index, control in
+ ControlInfo(cid: control.0, taskID: UInt16(index), flags: ControlFlags(rawValue: control.1),
+ position: UInt8(index), additionalFlags: control.2)
+ }
+ profile.firmware = [.firmware("RBM", 0x14, 0x00, build: 0x0015), .bootloader("BOT", 0x12, 0x03, build: 0), .hardware(0x48)]
+ profile.serialNumber = serialNumber
+ profile.dpiList = dpiList
+ profile.defaultDPI = defaultDPI
+ profile.battery = .status
+ profile.batteryLevel = 80
+ profile.hostCount = hostNames.count
+ profile.hostNames = hostNames
+ return profile
+ }
+
+ private static func isMXVertical(_ name: String) -> Bool {
+ name.lowercased().contains("mx vertical")
}
+ private var mouseIndex: UInt8? { lock.locked { _mouseIndex } }
+
/// Where the simulated mouse currently answers (its receiver slot, or 0xFF).
- var deviceIndex: UInt8 { lock.locked { _mouseIndex ?? 0 } }
- var diverted: Set { lock.locked { _mouseIndex.flatMap { _devices[$0]?.diverted } ?? [] } }
- var rawXY: Set { lock.locked { _mouseIndex.flatMap { _devices[$0]?.rawXY } ?? [] } }
+ var deviceIndex: UInt8 { mouseIndex ?? 0 }
+ var diverted: Set { mouseIndex.map { simulator.diverted(slot: $0) } ?? [] }
+ var rawXY: Set { mouseIndex.map { simulator.rawXY(slot: $0) } ?? [] }
/// The sensor resolution the mouse is using.
- var dpi: Int { lock.locked { _mouseIndex.flatMap { _devices[$0]?.dpi } ?? 0 } }
+ var dpi: Int { mouseIndex.flatMap { simulator.dpi(slot: $0) } ?? 0 }
/// Every setCurrentHost the mouse received.
- var hostSwitches: [UInt8] { lock.locked { _hostSwitches } }
- var sentReports: [[UInt8]] { lock.locked { _sent } }
+ var hostSwitches: [UInt8] { mouseIndex.map { simulator.hostSwitches(slot: $0) } ?? [] }
+ var sentReports: [[UInt8]] { simulator.sentReports }
/// While true the mouse ignores requests, so they time out (it still sends its own reports).
var dropsRequests: Bool {
- get { lock.locked { _dropsRequests } }
- set { lock.locked { _dropsRequests = newValue } }
+ get { simulator.dropsRequests }
+ set { simulator.dropsRequests = newValue }
}
/// Delay before every reply and report.
var latency: TimeInterval {
- get { lock.locked { _latency } }
- set { lock.locked { _latency = newValue } }
+ get { simulator.latency }
+ set { simulator.latency = newValue }
}
/// Whether the receiver answers pairing-info reads (register 0xB5).
var supportsPairingInfo: Bool {
- get { lock.locked { _supportsPairingInfo } }
- set { lock.locked { _supportsPairingInfo = newValue } }
+ get { receiver?.supportsPairingInfo ?? false }
+ set { receiver?.supportsPairingInfo = newValue }
}
// MARK: Test controls
- /// Pairs another device to the receiver.
+ /// Pairs another device to the receiver (without announcing it). An "MX Vertical" is a
+ /// second unit of the test mouse; any other name is an MX Keys keyboard with that name.
func addDevice(slot: UInt8, name: String, wirelessPID: UInt16 = SimulatedMXVertical.otherDevicePID) {
- lock.locked { _devices[slot] = Device(name: name, wirelessPID: wirelessPID) }
+ var profile = Self.isMXVertical(name)
+ ? Self.mxVerticalProfile(name: name, features: features, unit: DeviceProfiles.mxVerticalSecondUnit)
+ : DeviceProfiles.mxKeys.named(name)
+ profile.wirelessPID = wirelessPID
+ receiver?.pair(profile, slot: slot, announce: false)
}
/// The mouse turns off or sleeps: the receiver reports link loss, requests fail.
func goToSleep() {
- let index = lock.locked { () -> UInt8? in
- guard let index = _mouseIndex else { return nil }
- _devices[index]?.awake = false
- return index
- }
- if let index, isReceiver { emit(connectionNotification(slot: index, linkUp: false)) }
+ guard let index = mouseIndex else { return }
+ simulator.goToSleep(slot: index)
}
/// The mouse powers back on with its temporary diversion state cleared. `announce` sends
/// the receiver's 0x41 link-up; `reportReconnect` also sends the mouse's own 0x1D4B.
func wakeUp(clearingDiversion: Bool = true, announce: Bool = true, reportReconnect: Bool = false) {
- let index = lock.locked { () -> UInt8? in
- guard let index = _mouseIndex else { return nil }
- _devices[index]?.awake = true
- if clearingDiversion { _devices[index]?.forgetVolatileState() }
- return index
- }
- guard let index else { return }
- if announce, isReceiver { emit(connectionNotification(slot: index, linkUp: true)) }
- if reportReconnect { emit(wirelessStatusEvent(index: index)) }
+ guard let index = mouseIndex else { return }
+ simulator.wakeUp(slot: index, clearingDiversion: clearingDiversion, announce: announce, reportReconnect: reportReconnect)
}
/// The mouse sends 0x1D4B "reconnection" after losing its configuration.
func reportReconnect() {
- forgetDiversion()
- emit(wirelessStatusEvent(index: deviceIndex))
+ guard let index = mouseIndex else { return }
+ simulator.reportReconnect(slot: index)
}
/// The mouse silently loses its diversion state and pointer speed (no report is sent).
func forgetDiversion() {
- lock.locked {
- if let index = _mouseIndex { _devices[index]?.forgetVolatileState() }
- }
+ guard let index = mouseIndex else { return }
+ simulator.forgetDiversion(slot: index)
}
/// The top button cycles the pointer speed in firmware (when not diverted); nothing is reported.
func setDPINatively(_ dpi: Int) {
- lock.locked {
- if let index = _mouseIndex { _devices[index]?.dpi = dpi }
- }
+ guard let index = mouseIndex else { return }
+ simulator.setDPINatively(dpi, slot: index)
}
/// Broadcasts a BATTERY_STATUS (0x1000) event, as the mouse does when its level changes.
func reportBattery(percentage: UInt8, charging: Bool = false) {
- guard let index = features.firstIndex(of: 0x1000) else { return }
- emit(HIDPPMessage(reportID: .long, deviceIndex: deviceIndex, subID: UInt8(index), address: 0x00, parameters: [percentage, 50, charging ? 1 : 0]).bytes)
+ guard let index = mouseIndex else { return }
+ simulator.reportBattery(percentage: Int(percentage), charging: charging, slot: index)
}
/// Reports raw movement for a held raw-XY control, like divertedRawMouseXYEvent.
func move(dx: Int, dy: Int) {
- let params = UInt16(bitPattern: Int16(dx)).bigEndianBytes + UInt16(bitPattern: Int16(dy)).bigEndianBytes
- emit(HIDPPMessage(reportID: .long, deviceIndex: deviceIndex, subID: Self.reprogIndex, address: 0x10, parameters: params).bytes)
+ guard let index = mouseIndex else { return }
+ simulator.move(dx: dx, dy: dy, slot: index)
}
/// Makes setCidReporting for `cid` fail with HID++ 2.0 error `code`, `times` times.
func failDiversion(of cid: UInt16, code: UInt8, times: Int = .max) {
- lock.locked { _cidErrors[cid] = (code, times) }
+ guard let index = mouseIndex else { return }
+ simulator.failDiversion(of: cid, code: code, times: times, slot: index)
}
/// Re-pairs the mouse to `slot`. `announceUnpair` first reports 0x40 for the old slot.
func repair(toSlot slot: UInt8, announceUnpair: Bool) {
- let oldIndex = lock.locked { () -> UInt8? in
- guard let index = _mouseIndex, var device = _devices.removeValue(forKey: index) else { return nil }
- device.awake = true
- device.forgetVolatileState()
- _devices[slot] = device
- _mouseIndex = slot
- return index
- }
- if announceUnpair, let oldIndex { emit(unpairNotification(slot: oldIndex)) }
- emit(connectionNotification(slot: slot, linkUp: true))
+ guard let receiver, let index = mouseIndex else { return }
+ lock.locked { _mouseIndex = slot }
+ receiver.repair(from: index, to: slot, announceUnpair: announceUnpair)
}
/// Removes the mouse's pairing; the receiver reports 0x40.
func unpair() {
- let index = lock.locked { () -> UInt8? in
- guard let index = _mouseIndex else { return nil }
- _devices[index] = nil
- _mouseIndex = nil
- return index
- }
- if let index { emit(unpairNotification(slot: index)) }
+ guard let receiver, let index = mouseIndex else { return }
+ lock.locked { _mouseIndex = nil }
+ receiver.unpair(slot: index)
}
/// Reports the currently held buttons. Like the real mouse, only diverted ones are reported.
func hold(_ cids: [UInt16]) {
- let reported = cids.filter { diverted.contains($0) }
- let params = reported.flatMap { [UInt8($0 >> 8), UInt8($0 & 0xFF)] }
- emit(HIDPPMessage(reportID: .long, deviceIndex: deviceIndex, subID: Self.reprogIndex, address: 0x00, parameters: params).bytes)
+ guard let index = mouseIndex else { return }
+ simulator.hold(cids, slot: index)
}
func disconnect() {
- queue.async { self.onClose?() }
+ simulator.disconnect()
}
// MARK: Request counters
/// Requests sent to `feature` (by feature ID), optionally only for one function.
func requestCount(feature: UInt16, function: UInt8? = nil) -> Int {
- guard let index = features.firstIndex(of: feature) else { return 0 }
- return sentReports.filter { $0[0] == HIDPPReportID.long.rawValue && Int($0[2]) == index && (function == nil || $0[3] >> 4 == function!) }.count
+ simulator.requestCount(feature: feature, function: function)
}
- /// setCidReporting requests sent to the mouse.
- var setReportingCount: Int {
- sentReports.filter { $0[0] == HIDPPReportID.long.rawValue && $0[2] == Self.reprogIndex && $0[3] >> 4 == 3 }.count
- }
+ /// setCidReporting requests sent to the mouse (and anything sharing its receiver).
+ var setReportingCount: Int { simulator.setReportingCount }
/// Root.getProtocolVersion (ping) requests sent to `index`.
func pingCount(to index: UInt8) -> Int {
- sentReports.filter { $0[1] == index && $0[2] == 0 && $0[3] >> 4 == 1 }.count
+ simulator.pingCount(slot: index)
}
/// Receiver register writes to `address`.
func registerWriteCount(_ address: UInt8) -> Int {
- sentReports.filter { $0[0] == 0x10 && $0[1] == 0xFF && $0[2] == 0x80 && $0[3] == address }.count
+ receiver?.registerWriteCount(address) ?? 0
}
// MARK: HIDPPTransport
func send(_ bytes: [UInt8]) throws {
- lock.locked { _sent.append(bytes) }
- guard let request = HIDPPMessage(bytes: bytes) else { return }
- for reply in respond(to: request) {
- emit(reply.bytes)
- }
- }
-
- // MARK: Behavior
-
- private var isReceiver: Bool {
- if case .receiver = mode { return true }
- return false
- }
-
- private func emit(_ bytes: [UInt8]) {
- let latency = self.latency
- if latency > 0 {
- queue.asyncAfter(deadline: .now() + latency) { self.onReport?(bytes) }
- } else {
- queue.async { self.onReport?(bytes) }
- }
- }
-
- private func connectionNotification(slot: UInt8, linkUp: Bool) -> [UInt8] {
- let pid = lock.locked { _devices[slot]?.wirelessPID ?? MXVertical.wirelessProductID }
- return HIDPPMessage(reportID: .short, deviceIndex: slot, subID: 0x41, address: 0x04, parameters: [linkUp ? 0x00 : 0x40, UInt8(pid & 0xFF), UInt8(pid >> 8)]).bytes
- }
-
- private func unpairNotification(slot: UInt8) -> [UInt8] {
- HIDPPMessage(reportID: .short, deviceIndex: slot, subID: 0x40, address: 0x02, parameters: []).bytes
- }
-
- private func wirelessStatusEvent(index: UInt8) -> [UInt8] {
- HIDPPMessage(reportID: .long, deviceIndex: index, subID: Self.wirelessStatusIndex, address: 0x00, parameters: [1, 1, 0]).bytes
- }
-
- private func respond(to request: HIDPPMessage) -> [HIDPPMessage] {
- if request.deviceIndex == 0xFF, isReceiver {
- return respondAsReceiver(to: request)
- }
- if lock.locked({ _dropsRequests }) { return [] }
- guard let device = lock.locked({ _devices[request.deviceIndex] }) else {
- return isReceiver ? [error10(request, code: 0x08)] : []
- }
- guard device.awake else {
- return isReceiver ? [error10(request, code: 0x09)] : []
- }
- return respond(to: request, device: device).map { [$0] } ?? []
- }
-
- private func respondAsReceiver(to request: HIDPPMessage) -> [HIDPPMessage] {
- let p = request.parameters
- switch (request.subID, request.address) {
- case (0x80, 0x02) where p[0] == 0x02:
- // Fake device arrival: announce every paired device.
- let slots = lock.locked { _devices.filter { $0.key != 0xFF }.map { ($0.key, $0.value.awake) } }
- return [request] + slots.sorted { $0.0 < $1.0 }.map {
- HIDPPMessage(bytes: connectionNotification(slot: $0.0, linkUp: $0.1))!
- }
- case (0x80, _):
- return [request]
- case (0x83, 0xB5):
- guard lock.locked({ _supportsPairingInfo }) else { return [error10(request, code: 0x02)] }
- let slot = p[0] &- 0x1F
- guard (0x20...0x25).contains(p[0]), let device = lock.locked({ _devices[slot] }) else {
- return [error10(request, code: 0x03)]
- }
- return [HIDPPMessage(reportID: .long, deviceIndex: 0xFF, subID: 0x83, address: 0xB5,
- parameters: [p[0], 0x00, 0x08, UInt8(device.wirelessPID >> 8), UInt8(device.wirelessPID & 0xFF), 0, 0, 0x02])]
- default:
- return [error10(request, code: 0x01)]
- }
- }
-
- private func respond(to request: HIDPPMessage, device: Device) -> HIDPPMessage? {
- let p = request.parameters
- guard Int(request.featureIndex) < features.count else { return error20(request, code: 0x06) }
- let feature = features[Int(request.featureIndex)]
- switch (feature, request.function) {
- case (0x0000, 0):
- let id = p.uint16(at: 0)
- let index = features.firstIndex(of: id).map(UInt8.init) ?? 0
- return with(request, [index, 0, Self.featureVersions[id] ?? 0])
- case (0x0000, 1):
- return with(request, [4, 5, p[2]])
- case (0x0001, 0):
- return with(request, [UInt8(features.count - 1)])
- case (0x0001, 1):
- let id = features[Int(p[0])]
- return with(request, id.bigEndianBytes + [0, Self.featureVersions[id] ?? 0])
- case (0x0005, 0):
- return with(request, [UInt8(device.name.utf8.count)])
- case (0x0005, 1):
- return with(request, Array(device.name.utf8.dropFirst(Int(p[0])).prefix(16)))
- case (0x1000, 0):
- return with(request, [80, 50, 0])
-
- case (0x0003, 0):
- // [entity count, unit ID (4), transport (2), model ID (6), extended model, capabilities]
- return with(request, [3, 0xAB, 0xCD, 0x12, 0x34, 0x00, 0x0F, 0xB0, 0x20, 0x40, 0x7B, 0xC0, 0x8A, 0x00, 0x01])
- case (0x0003, 1):
- switch p[0] {
- case 0: return with(request, [0x00] + Array("RBM".utf8) + [0x14, 0x00, 0x00, 0x15])
- case 1: return with(request, [0x01] + Array("BOT".utf8) + [0x12, 0x03, 0x00, 0x00])
- case 2: return with(request, [0x02, 0x48])
- default: return error20(request, code: 0x02)
- }
- case (0x0003, 2):
- return with(request, Array(Self.serialNumber.utf8))
-
- case (0x2201, 0):
- return with(request, [1])
- case (0x2201, 1):
- return with(request, [p[0]] + Self.dpiList.flatMap(\.bigEndianBytes))
- case (0x2201, 2):
- return with(request, [p[0]] + UInt16(device.dpi).bigEndianBytes + UInt16(Self.defaultDPI).bigEndianBytes)
- case (0x2201, 3):
- let dpi = Int(p.uint16(at: 1))
- guard (400...4000).contains(dpi), dpi % 50 == 0 else { return error20(request, code: 0x02) }
- lock.locked { _devices[request.deviceIndex]?.dpi = dpi }
- return with(request, Array(p.prefix(3)))
-
- case (0x1814, 0):
- return with(request, [UInt8(Self.hostNames.count), device.currentHost, 0])
- case (0x1814, 1):
- // The mouse leaves for the other host without answering.
- lock.locked {
- _hostSwitches.append(p[0])
- _devices[request.deviceIndex]?.currentHost = p[0]
- }
- goToSleep()
- return nil
- case (0x1815, 0):
- return with(request, [0x01, 0, UInt8(Self.hostNames.count), device.currentHost])
- case (0x1815, 1):
- guard Int(p[0]) < Self.hostNames.count else { return error20(request, code: 0x02) }
- let name = Self.hostNames[Int(p[0])]
- let length = UInt8(name?.utf8.count ?? 0)
- return with(request, [p[0], name == nil ? 0 : 1, 0x01, 1, length, 32])
- case (0x1815, 3):
- guard Int(p[0]) < Self.hostNames.count, let name = Self.hostNames[Int(p[0])] else { return error20(request, code: 0x02) }
- return with(request, [p[0], p[1]] + Array(name.utf8.dropFirst(Int(p[1])).prefix(14)))
-
- case (0x1B04, 0):
- return with(request, [UInt8(Self.controls.count)])
- case (0x1B04, 1):
- let (cid, flags, additional) = Self.controls[Int(p[0])]
- return with(request, cid.bigEndianBytes + [0, UInt8(p[0]), flags, p[0], 0, 0, additional])
- case (0x1B04, 2):
- let cid = p.uint16(at: 0)
- let flags: UInt8 = (device.diverted.contains(cid) ? 0x01 : 0) | (device.rawXY.contains(cid) ? 0x10 : 0)
- return with(request, cid.bigEndianBytes + [flags, 0, 0])
- case (0x1B04, 3):
- let cid = p.uint16(at: 0)
- guard let control = Self.controls.first(where: { $0.0 == cid }), control.1 & 0x20 != 0 else {
- return error20(request, code: 0x02)
- }
- if p[2] & 0x30 == 0x30, control.2 & 0x01 == 0 {
- return error20(request, code: 0x02)
- }
- let injected = lock.locked { () -> UInt8? in
- guard let entry = _cidErrors[cid], entry.remaining > 0 else { return nil }
- _cidErrors[cid] = (entry.code, entry.remaining - 1)
- return entry.code
- }
- if let injected { return error20(request, code: injected) }
- lock.locked {
- if p[2] & 0x02 != 0 {
- if p[2] & 0x01 != 0 {
- _devices[request.deviceIndex]?.diverted.insert(cid)
- } else {
- _devices[request.deviceIndex]?.diverted.remove(cid)
- }
- }
- if p[2] & 0x20 != 0 {
- if p[2] & 0x10 != 0 {
- _devices[request.deviceIndex]?.rawXY.insert(cid)
- } else {
- _devices[request.deviceIndex]?.rawXY.remove(cid)
- }
- }
- }
- return with(request, Array(p.prefix(5)))
- default:
- return error20(request, code: 0x07)
- }
- }
-
- private func with(_ request: HIDPPMessage, _ params: [UInt8]) -> HIDPPMessage {
- HIDPPMessage(reportID: request.reportID, deviceIndex: request.deviceIndex, subID: request.subID, address: request.address, parameters: params)
- }
-
- private func error10(_ request: HIDPPMessage, code: UInt8) -> HIDPPMessage {
- HIDPPMessage(reportID: .short, deviceIndex: request.deviceIndex, subID: 0x8F, address: request.subID, parameters: [request.address, code, 0])
- }
-
- private func error20(_ request: HIDPPMessage, code: UInt8) -> HIDPPMessage {
- HIDPPMessage(reportID: .long, deviceIndex: request.deviceIndex, subID: 0xFF, address: request.subID, parameters: [request.address, code])
+ try simulator.send(bytes)
}
}
diff --git a/UprightKit/Tests/UprightKitTests/SimulatorTests.swift b/UprightKit/Tests/UprightKitTests/SimulatorTests.swift
new file mode 100644
index 0000000..bf77fd1
--- /dev/null
+++ b/UprightKit/Tests/UprightKitTests/SimulatorTests.swift
@@ -0,0 +1,358 @@
+import XCTest
+@testable import UprightKit
+
+/// The simulator itself: profiles answer like their dumps, receivers expose their pairing
+/// layouts and notifications, and per-slot state stays per slot.
+@MainActor
+final class SimulatorTests: XCTestCase {
+ private func makeClient(_ transport: HIDPPTransport) -> HIDPPClient {
+ let client = HIDPPClient(transport: transport)
+ client.defaultTimeout = 0.5
+ return client
+ }
+
+ private func readRegister(_ client: HIDPPClient, _ subRegister: UInt8) async throws -> [UInt8] {
+ try await client.send(.receiverLongRegisterRead(0xB5, parameters: [subRegister])).parameters
+ }
+
+ private func pairings(_ client: HIDPPClient, layout: PairingLayout) async throws -> [PairingRecord] {
+ var records: [PairingRecord] = []
+ for slot in UInt8(1)...6 {
+ do {
+ let reply = try await readRegister(client, layout.pairingSubRegister(slot: slot))
+ if let record = ReceiverDirectory.parsePairing(reply, slot: slot, layout: layout) { records.append(record) }
+ } catch HIDPPError.hidpp10(0x03) {
+ continue // empty slot
+ }
+ }
+ return records
+ }
+
+ // MARK: Profiles
+
+ func testMouseProfilesAnswerFromTheirTables() async throws {
+ let mice = [
+ DeviceProfiles.mxVertical, DeviceProfiles.mxMaster3S, DeviceProfiles.lift, DeviceProfiles.mxMaster3,
+ DeviceProfiles.m720, DeviceProfiles.mxErgo, DeviceProfiles.m185,
+ ]
+ for profile in mice {
+ let device = HIDPPDevice(client: makeClient(SimulatedDirect(profile: profile)), index: 0xFF)
+ let model = profile.codename
+
+ let version = try await device.protocolVersion()
+ XCTAssertEqual([version.major, version.minor], [profile.protocolMajor, profile.protocolMinor], model)
+ let table = try await device.featureTable()
+ XCTAssertEqual(table.map(\.featureID), profile.features.map(\.id), model)
+ XCTAssertEqual(table.map(\.version), profile.features.map(\.version), model)
+ XCTAssertEqual(table.map(\.flags), profile.features.map(\.flags), model)
+ let name = try await device.name()
+ XCTAssertEqual(name, profile.name, model)
+ let type = try await device.call(.deviceName, function: 2)
+ XCTAssertEqual(DeviceKind(hidpp20: type[0]), profile.kind, model)
+ let controls = try await ReprogControls(device: device).allControls()
+ XCTAssertEqual(controls, profile.controls, model)
+ let firmware = try await DeviceFeatures(device: device).firmware()
+ XCTAssertEqual(firmware.map(\.kind), profile.firmware.map(\.kind), model)
+ let battery = await device.battery()
+ XCTAssertEqual(battery?.percentage, profile.batteryLevel, model)
+ if profile.feature(0x2201) != nil {
+ let dpi = try await DeviceFeatures(device: device).dpi()
+ XCTAssertEqual(dpi.supported, profile.supportedDPI, model)
+ XCTAssertEqual(dpi.current, profile.defaultDPI, model)
+ }
+ }
+ }
+
+ func testMXVerticalMatchesItsDump() async throws {
+ let device = HIDPPDevice(client: makeClient(SimulatedDirect(profile: DeviceProfiles.mxVertical)), index: 0xFF)
+ let firmware = try await DeviceFeatures(device: device).firmware()
+ XCTAssertEqual(firmware.map(\.displayString), ["BOT 78.00.B0009", "MPM 16.00.B0009", ""])
+ let serial = try await DeviceFeatures(device: device).serialNumber()
+ XCTAssertEqual(serial, "DEMO-0001")
+ // "Model ID: B020407BC08A", Transport IDs btleid, wpid, usbid.
+ let reply = DeviceProfiles.mxVertical.firmwareIDsReply
+ XCTAssertEqual(reply[6], 0x0E)
+ XCTAssertEqual(Array(reply[7...12]), [0xB0, 0x20, 0x40, 0x7B, 0xC0, 0x8A])
+ let controls = try await ReprogControls(device: device).allControls()
+ XCTAssertEqual(controls.filter(\.isConfigurable).map(\.cid), [0x52, 0x53, 0x56, 0xFD])
+ XCTAssertTrue(controls.filter(\.isConfigurable).allSatisfy(\.supportsRawXY))
+ }
+
+ func testFirmwareIDsGiveCanonicalModelKeysAndFakeUnits() async throws {
+ let expected: [(SimulatedDeviceProfile, UInt16)] = [
+ (DeviceProfiles.mxVertical, 0xB020), (DeviceProfiles.mxMaster3S, 0xB034), (DeviceProfiles.lift, 0xB031),
+ (DeviceProfiles.mxMaster3, 0xB023), (DeviceProfiles.m720, 0xB015), (DeviceProfiles.mxErgo, 0xB01D),
+ (DeviceProfiles.m185, 0x4055), (DeviceProfiles.g502Lightspeed, 0x407F), (DeviceProfiles.mxKeys, 0xB35B),
+ ]
+ for (profile, pid) in expected {
+ let ids = try XCTUnwrap(DeviceIdentity.parseFirmwareIDs(profile.firmwareIDsReply), profile.codename)
+ XCTAssertEqual(ids.modelIDs, profile.modelIDs, profile.codename)
+ XCTAssertEqual(ids.modelIDs.canonical, pid, profile.codename)
+ XCTAssertEqual(ids.unitID, profile.unitID, profile.codename)
+ }
+
+ let units = DeviceProfiles.all.compactMap(\.unitID)
+ XCTAssertEqual(Set(units).count, units.count, "every simulated unit is distinct")
+ XCTAssertTrue(units.allSatisfy { $0 & 0x00FF_FF00 == 0x002B_3C00 }, "unit IDs follow the fake pattern")
+ let serials = DeviceProfiles.all.compactMap(\.serialNumber)
+ XCTAssertEqual(Set(serials).count, serials.count)
+ XCTAssertTrue(serials.allSatisfy { $0.hasPrefix("DEMO-") })
+ XCTAssertNil(DeviceProfiles.mouseWithoutUnitID.unitID)
+ XCTAssertNil(DeviceProfiles.mouseWithoutUnitID.serialNumber)
+ XCTAssertEqual(DeviceProfiles.mxVerticalSecondUnit.modelIDs, DeviceProfiles.mxVertical.modelIDs)
+ XCTAssertNotEqual(DeviceProfiles.mxVerticalSecondUnit.unitID, DeviceProfiles.mxVertical.unitID)
+ }
+
+ func testKeyboardAndUnsupportedProfiles() async throws {
+ let receiver = SimulatedReceiver.unifying([1: DeviceProfiles.mxKeys, 2: DeviceProfiles.m510HIDPP10])
+ let client = makeClient(receiver)
+
+ let keyboard = HIDPPDevice(client: client, index: 1)
+ let type = try await keyboard.call(.deviceName, function: 2)
+ XCTAssertEqual(DeviceKind(hidpp20: type[0]), .keyboard)
+ let keys = try await ReprogControls(device: keyboard).allControls()
+ XCTAssertFalse(keys.filter(\.isConfigurable).isEmpty, "only its kind keeps a keyboard from being diverted")
+
+ let old = try await HIDPPDevice(client: client, index: 2).protocolVersion()
+ XCTAssertEqual([old.major, old.minor], [1, 0])
+ let records = try await pairings(client, layout: .unifying)
+ XCTAssertEqual(records, [
+ PairingRecord(slot: 1, wirelessPID: 0x408A, kind: .keyboard),
+ PairingRecord(slot: 2, wirelessPID: 0x1025, kind: .mouse),
+ ])
+
+ let gaming = HIDPPDevice(client: makeClient(SimulatedDirect(profile: DeviceProfiles.g502Lightspeed)), index: 0xFF)
+ let reprog = try await gaming.featureIndex(for: .reprogControlsV4)
+ XCTAssertNil(reprog)
+ }
+
+ // MARK: Receivers
+
+ func testUnifyingPairingRegisters() async throws {
+ let receiver = SimulatedReceiver.unifying([
+ 1: DeviceProfiles.mxKeys, 2: DeviceProfiles.mxVertical, 3: DeviceProfiles.mxVerticalSecondUnit,
+ ])
+ XCTAssertEqual(receiver.transportKind, .receiver(.unifying))
+ let client = makeClient(receiver)
+
+ let records = try await pairings(client, layout: .unifying)
+ XCTAssertEqual(records, [
+ PairingRecord(slot: 1, wirelessPID: 0x408A, kind: .keyboard),
+ PairingRecord(slot: 2, wirelessPID: 0x407B, kind: .mouse),
+ PairingRecord(slot: 3, wirelessPID: 0x407B, kind: .mouse),
+ ])
+ // Extended pairing information: the unit ID is the slot's serial.
+ let extended = try await readRegister(client, 0x31)
+ XCTAssertEqual(Array(extended[1...4]), [0x1A, 0x2B, 0x3C, 0x4D])
+ let codename = try await readRegister(client, 0x41)
+ XCTAssertEqual(String(decoding: codename[2..<(2 + Int(codename[1]))], as: UTF8.self), "MX Vertical")
+ let info = try await readRegister(client, 0x03)
+ XCTAssertEqual(info[6], 6)
+ XCTAssertEqual(receiver.registerReadCount(0xB5), 9)
+ }
+
+ func testBoltPairingRegisters() async throws {
+ let receiver = SimulatedReceiver.bolt([1: DeviceProfiles.mxMaster3S, 2: DeviceProfiles.lift])
+ XCTAssertEqual(receiver.transportKind, .receiver(.bolt))
+ let client = makeClient(receiver)
+
+ let records = try await pairings(client, layout: .bolt)
+ XCTAssertEqual(records, [
+ PairingRecord(slot: 1, wirelessPID: 0xB034, kind: .mouse),
+ PairingRecord(slot: 2, wirelessPID: 0xB031, kind: .mouse),
+ ])
+ let record = try await readRegister(client, 0x52)
+ XCTAssertEqual(Array(record[4...7]), [0x3A, 0x2B, 0x3C, 0x4D])
+ let name = try await readRegister(client, 0x61)
+ XCTAssertEqual(String(decoding: name[3..<(3 + Int(name[2]))], as: UTF8.self), "MX Master 3S")
+ do {
+ _ = try await readRegister(client, 0x20)
+ XCTFail("Bolt has no Unifying pairing sub-registers")
+ } catch HIDPPError.hidpp10(let code) {
+ XCTAssertEqual(code, 0x03)
+ }
+ }
+
+ func testNanoUsesTheUnifyingLayout() async throws {
+ let receiver = SimulatedReceiver.nano([1: DeviceProfiles.m185])
+ XCTAssertEqual(receiver.transportKind, .receiver(.nano))
+ let records = try await pairings(makeClient(receiver), layout: .unifying)
+ XCTAssertEqual(records, [PairingRecord(slot: 1, wirelessPID: 0x4055, kind: .mouse)])
+
+ receiver.supportsPairingInfo = false
+ do {
+ _ = try await readRegister(makeClient(receiver), 0x20)
+ XCTFail("pairing information is off")
+ } catch HIDPPError.hidpp10(let code) {
+ XCTAssertEqual(code, 0x02)
+ }
+ }
+
+ func testLinkNotificationsAndAnnouncements() async throws {
+ let receiver = SimulatedReceiver.unifying([2: DeviceProfiles.mxVertical])
+ let client = makeClient(receiver)
+ let messages = Recorder()
+ client.onUnsolicited = { messages.append($0) }
+
+ receiver.goToSleep(slot: 2)
+ receiver.wakeUp(slot: 2)
+ receiver.pair(DeviceProfiles.mxKeys, slot: 3)
+ receiver.unpair(slot: 3)
+ await eventually("four notifications") { messages.count == 4 }
+ let links = messages.records.prefix(3).compactMap(ReceiverDirectory.parseConnection)
+ XCTAssertEqual(links.map { $0.slot }, [2, 2, 3])
+ XCTAssertEqual(links.map { $0.linkUp }, [false, true, true])
+ XCTAssertEqual(links.map { $0.wirelessPID }, [0x407B, 0x407B, 0x408A])
+ XCTAssertEqual(links.map { $0.kind }, [.mouse, .mouse, .keyboard])
+ XCTAssertEqual(ReceiverDirectory.isUnpair(messages.records[3]), 3)
+
+ // Asking for announcements reports every paired device with its link state.
+ receiver.pair(DeviceProfiles.mxKeys, slot: 1, announce: false)
+ receiver.goToSleep(slot: 2)
+ await eventually("link loss") { messages.count == 5 }
+ messages.removeAll()
+ _ = try await client.send(.receiverRegisterWrite(0x02, parameters: [0x02, 0x00, 0x00]))
+ await eventually("two announcements") { messages.count == 2 }
+ let announced = messages.records.compactMap(ReceiverDirectory.parseConnection)
+ XCTAssertEqual(announced.map { $0.slot }, [1, 2])
+ XCTAssertEqual(announced.map { $0.linkUp }, [true, false])
+ XCTAssertEqual(receiver.registerWriteCount(0x02), 1)
+ }
+
+ func testNotificationFlagsAreReadableAndCanGateLinkNotifications() async throws {
+ let receiver = SimulatedReceiver.unifying([1: DeviceProfiles.mxVertical])
+ receiver.notificationFlags = [0x10, 0x00, 0x00]
+ receiver.linkNotificationsNeedFlag = true
+ let client = makeClient(receiver)
+ let messages = Recorder()
+ client.onUnsolicited = { messages.append($0) }
+
+ let read = try await client.send(HIDPPMessage(reportID: .short, deviceIndex: 0xFF, subID: 0x81, address: 0x00))
+ let current = Array(read.parameters.prefix(3))
+ XCTAssertEqual(current, [0x10, 0x00, 0x00])
+ receiver.goToSleep(slot: 1)
+ _ = try await client.send(.receiverRegisterWrite(0x00, parameters: ReceiverDirectory.mergedNotificationFlags(current: current)))
+ XCTAssertEqual(receiver.notificationFlags, [0x10, 0x09, 0x00])
+ XCTAssertEqual(receiver.registerReadCount(0x00), 1)
+ XCTAssertEqual(receiver.registerWriteCount(0x00), 1)
+
+ receiver.wakeUp(slot: 1)
+ await eventually("link-up") { messages.count == 1 }
+ await pause(0.05)
+ XCTAssertEqual(messages.count, 1, "the link loss before notifications were enabled was not sent")
+ XCTAssertEqual(ReceiverDirectory.parseConnection(messages.records[0])?.linkUp, true)
+ }
+
+ // MARK: Per-slot state
+
+ func testIdenticalMiceKeepSeparateState() async throws {
+ let receiver = SimulatedReceiver.unifying([1: DeviceProfiles.mxVertical, 2: DeviceProfiles.mxVerticalSecondUnit])
+ let client = makeClient(receiver)
+ let first = HIDPPDevice(client: client, index: 1)
+ let second = HIDPPDevice(client: client, index: 2)
+ let messages = Recorder()
+ client.onUnsolicited = { messages.append($0) }
+
+ try await ReprogControls(device: first).setDiverted(true, cid: KnownControl.back)
+ try await ReprogControls(device: second).setReporting(cid: KnownControl.forward, diverted: true, rawXY: true)
+ XCTAssertEqual(receiver.diverted(slot: 1), [KnownControl.back])
+ XCTAssertEqual(receiver.diverted(slot: 2), [KnownControl.forward])
+ XCTAssertEqual(receiver.rawXY(slot: 1), [])
+ XCTAssertEqual(receiver.rawXY(slot: 2), [KnownControl.forward])
+ XCTAssertEqual(receiver.setReportingCount(slot: 1), 1)
+ XCTAssertEqual(receiver.setReportingCount(slot: 2), 1)
+ XCTAssertEqual(receiver.setReportingCount, 2)
+
+ let firstIDs = try await first.call(.deviceFirmwareVersion, function: 0)
+ let secondIDs = try await second.call(.deviceFirmwareVersion, function: 0)
+ XCTAssertEqual(DeviceIdentity.parseFirmwareIDs(firstIDs)?.unitID, 0x1A2B_3C4D)
+ XCTAssertEqual(DeviceIdentity.parseFirmwareIDs(secondIDs)?.unitID, 0x1A2B_3C4E)
+
+ // Reports carry their own slot and list only that mouse's diverted controls.
+ receiver.hold([KnownControl.back, KnownControl.forward], slot: 1)
+ receiver.move(dx: -5, dy: 300, slot: 2)
+ await eventually("two reports") { messages.count == 2 }
+ XCTAssertEqual(messages.records[0].deviceIndex, 1)
+ XCTAssertEqual(ReprogControls.pressedControls(fromEventParameters: messages.records[0].parameters), [KnownControl.back])
+ XCTAssertEqual(messages.records[1].deviceIndex, 2)
+ let delta = ReprogControls.rawXYDelta(fromEventParameters: messages.records[1].parameters)
+ XCTAssertEqual([delta.dx, delta.dy], [-5, 300])
+
+ // One asleep, the other answering.
+ receiver.goToSleep(slot: 1)
+ do {
+ _ = try await first.protocolVersion()
+ XCTFail("an asleep slot doesn't answer")
+ } catch HIDPPError.hidpp10(let code) {
+ XCTAssertEqual(code, 0x09)
+ }
+ _ = try await second.protocolVersion()
+ XCTAssertEqual(receiver.diverted(slot: 2), [KnownControl.forward])
+
+ // Waking with its state cleared only affects that mouse.
+ receiver.wakeUp(slot: 1)
+ XCTAssertEqual(receiver.diverted(slot: 1), [])
+ XCTAssertEqual(receiver.diverted(slot: 2), [KnownControl.forward])
+ }
+
+ func testEasySwitchSleepsOnlyTheAddressedSlot() async throws {
+ let receiver = SimulatedReceiver.bolt([1: DeviceProfiles.mxMaster3S, 2: DeviceProfiles.lift])
+ let client = makeClient(receiver)
+ let messages = Recorder()
+ client.onUnsolicited = { messages.append($0) }
+ let index = try await HIDPPDevice(client: client, index: 1).featureIndex(for: .changeHost)
+ let changeHost = try XCTUnwrap(index)
+
+ client.sendWithoutReply(DeviceFeatures.changeHostMessage(deviceIndex: 1, featureIndex: changeHost, host: 1, softwareID: client.softwareID))
+ await eventually("asleep") { !receiver.isAwake(slot: 1) }
+ await eventually("link loss") { messages.count == 1 }
+ XCTAssertEqual(receiver.hostSwitches(slot: 1), [1])
+ XCTAssertEqual(receiver.currentHost(slot: 1), 1)
+ XCTAssertTrue(receiver.isAwake(slot: 2))
+ XCTAssertEqual(receiver.hostSwitches(slot: 2), [])
+ let link = try XCTUnwrap(ReceiverDirectory.parseConnection(messages.records[0]))
+ XCTAssertEqual(link.slot, 1)
+ XCTAssertFalse(link.linkUp)
+ }
+
+ func testRequestsInFlightMaxSeesOverlappingRequests() async throws {
+ let receiver = SimulatedReceiver.unifying([1: DeviceProfiles.mxVertical, 2: DeviceProfiles.lift])
+ receiver.latency = 0.05
+ let client = makeClient(receiver)
+
+ _ = try await HIDPPDevice(client: client, index: 1).protocolVersion()
+ _ = try await HIDPPDevice(client: client, index: 2).protocolVersion()
+ XCTAssertEqual(receiver.requestsInFlightMax, 1, "one after the other")
+
+ let first = Task { try await HIDPPDevice(client: client, index: 1).protocolVersion() }
+ let second = Task { try await HIDPPDevice(client: client, index: 2).protocolVersion() }
+ _ = try await (first.value, second.value)
+ XCTAssertEqual(receiver.requestsInFlightMax, 2, "both waiting at once")
+
+ // Requests nobody answers only count when asked to.
+ receiver.resetTraffic()
+ receiver.setDropsRequests(true, slot: 1)
+ receiver.unansweredRequestWindow = 0.2
+ let ping: [UInt8] = [0, 0, 0x5A]
+ client.sendWithoutReply(.request(deviceIndex: 1, featureIndex: 0, function: 1, softwareID: client.softwareID, parameters: ping))
+ client.sendWithoutReply(.request(deviceIndex: 1, featureIndex: 0, function: 1, softwareID: client.softwareID, parameters: ping))
+ XCTAssertEqual(receiver.requestsInFlightMax, 2)
+ XCTAssertEqual(receiver.pingCount(slot: 1), 2)
+ }
+
+ func testBatteryReportsFollowTheProfilesFeature() async throws {
+ let receiver = SimulatedReceiver.bolt([1: DeviceProfiles.mxMaster3S])
+ let client = makeClient(receiver)
+ let messages = Recorder()
+ client.onUnsolicited = { messages.append($0) }
+
+ receiver.reportBattery(percentage: 9, charging: true, slot: 1)
+ await eventually("battery event") { messages.count == 1 }
+ let event = messages.records[0]
+ XCTAssertEqual(event.featureIndex, DeviceProfiles.mxMaster3S.featureIndex(of: 0x1004))
+ XCTAssertEqual(BatteryStatus(unifiedBatteryParameters: event.parameters), BatteryStatus(percentage: 9, isCharging: true))
+ let battery = await HIDPPDevice(client: client, index: 1).battery()
+ XCTAssertEqual(battery, BatteryStatus(percentage: 9, isCharging: true))
+ XCTAssertEqual(receiver.batteryLevel(slot: 1), 9)
+ }
+}
diff --git a/UprightKit/Tests/UprightKitTests/TestSupport.swift b/UprightKit/Tests/UprightKitTests/TestSupport.swift
new file mode 100644
index 0000000..a7a2be9
--- /dev/null
+++ b/UprightKit/Tests/UprightKitTests/TestSupport.swift
@@ -0,0 +1,104 @@
+import Foundation
+import XCTest
+@testable import UprightKit
+
+// Shared helpers for tests that drive hubs, controllers and the simulator. They are free
+// functions on purpose: a test class may still declare private helpers with the same names
+// (those win inside the class), and declarations in an XCTestCase extension couldn't be
+// redeclared by subclasses.
+
+extension ControllerTuning {
+ /// Short timeouts and no reconnect delay, so controller tests finish quickly.
+ static var fastTests: ControllerTuning {
+ var tuning = ControllerTuning()
+ tuning.probeTimeout = 0.2
+ tuning.reconnectDelay = 0
+ tuning.retryBaseDelay = 0.05
+ tuning.retryMaxDelay = 0.2
+ tuning.announcementWait = 0.05
+ return tuning
+ }
+}
+
+/// A hub on `transport` with `ControllerTuning.fastTests`.
+@MainActor
+func makeLink(
+ _ transport: HIDPPTransport,
+ kind: TransportKind,
+ link: UInt32 = 1,
+ tuning: ControllerTuning = .fastTests
+) -> HIDPPLink {
+ HIDPPLink(transport: transport, kind: kind, link: link, tuning: tuning)
+}
+
+/// A hub on a simulated receiver or direct link, with the matching transport kind.
+@MainActor
+func makeLink(_ simulator: SimulatedHIDPPTransport, link: UInt32 = 1, tuning: ControllerTuning = .fastTests) -> HIDPPLink {
+ makeLink(simulator, kind: simulator.transportKind, link: link, tuning: tuning)
+}
+
+/// Polls `condition` every 10 ms until it holds, failing the test after `timeout`.
+@MainActor
+func eventually(
+ _ description: String,
+ timeout: TimeInterval = 3,
+ file: StaticString = #filePath,
+ line: UInt = #line,
+ _ condition: () -> Bool
+) async {
+ let deadline = Date().addingTimeInterval(timeout)
+ while !condition() {
+ if Date() > deadline {
+ XCTFail("Timed out waiting for: \(description)", file: file, line: line)
+ return
+ }
+ try? await Task.sleep(nanoseconds: 10_000_000)
+ }
+}
+
+func pause(_ seconds: TimeInterval) async {
+ try? await Task.sleep(nanoseconds: UInt64(max(0, seconds) * 1_000_000_000))
+}
+
+/// Whether the endpoint at `index` on `link` is connected.
+@MainActor
+func isConnected(_ link: HIDPPLink, at index: UInt8) -> Bool {
+ if case .connected = link.endpoints[index]?.state { return true }
+ return false
+}
+
+/// Whether the endpoint at `index` on `link` is asleep (found, but not answering).
+@MainActor
+func isAsleep(_ link: HIDPPLink, at index: UInt8) -> Bool {
+ if case .asleep = link.endpoints[index]?.state { return true }
+ return false
+}
+
+/// Collects values delivered to a callback, for later assertions.
+final class Recorder: @unchecked Sendable {
+ private let lock = NSLock()
+ private var values: [Value] = []
+
+ init() {}
+
+ func append(_ value: Value) {
+ lock.lock()
+ values.append(value)
+ lock.unlock()
+ }
+
+ /// Everything appended so far, in order.
+ var records: [Value] {
+ lock.lock()
+ defer { lock.unlock() }
+ return values
+ }
+
+ var count: Int { records.count }
+
+ func removeAll() {
+ lock.lock()
+ values.removeAll()
+ lock.unlock()
+ }
+}
diff --git a/docs/DEVICES.md b/docs/DEVICES.md
new file mode 100644
index 0000000..1b6bbb9
--- /dev/null
+++ b/docs/DEVICES.md
@@ -0,0 +1,136 @@
+# Supported devices
+
+> **This page is derived from [`DeviceCatalog.swift`](../UprightKit/Sources/UprightKit/Device/DeviceCatalog.swift)**
+> (`DeviceCatalog.models`, `DeviceCatalog.receivers` and `DeviceCatalog.knownUnsupported`), plus the
+> interface rules in
+> [`InterfaceClassifier.swift`](../UprightKit/Sources/UprightKit/Device/InterfaceClassifier.swift).
+> If the code and this page disagree, the code is right. Update this page whenever the catalog changes.
+
+Upright works with Logitech mice that speak **HID++ 2.0** and expose the programmable-button feature
+(`REPROG_CONTROLS_V4`, `0x1B04`). You can use several mice at once, in any mix of USB cable,
+Bluetooth and Unifying, Bolt or Nano receivers.
+
+**Only the MX Vertical has been tested on real hardware.** Every other mouse on this page is built
+from published device data (Solaar and libratbag dumps) and has been exercised only against
+Upright's built-in mouse simulator. Please treat all of them as unverified until someone with the
+mouse confirms it.
+
+## What the catalog is, and isn't
+
+The catalog gives a model its name, its list of controls in display order, a suggested setup for
+onboarding, and which product IDs belong to it. It does **not** decide whether a mouse is served.
+That is decided by what the mouse answers: HID++ 2.0, a pointer, and `0x1B04` with at least one
+control Upright can divert. A Logitech mouse that isn't listed below can therefore still work, and a
+listed one can still turn out not to (for example because of firmware differences). Upright shows
+such a mouse under **Settings → Mice → Other Logitech devices** with the reason.
+
+## Status values
+
+| Status | Meaning |
+| --- | --- |
+| **Verified on hardware** | Tested by the maintainer on a real mouse. Only the MX Vertical. |
+| **Published data** | The catalog entry (product IDs, control list) comes from published device dumps. Simulator-tested in Upright; **not tested on hardware**. |
+| **Untested** | Not in the catalog. If the mouse answers HID++ 2.0 with programmable buttons, Upright serves it, but nothing is known about it. |
+
+## Mice in the catalog
+
+PIDs are hexadecimal USB product IDs under Logitech's vendor ID `046D`. "Receiver / Bolt wireless
+PIDs" are what a Unifying, Nano or Bolt receiver reports for the mouse; a `Bxxx` value there means
+the mouse reports its Bluetooth ID through a Bolt receiver, which is why those rows list "Bolt
+receiver". The catalog does not say which of Unifying or Nano a given mouse shipped with.
+Upright reads that from the receiver itself. "Controls" are the remappable controls in the order
+the catalog lists them. What a particular unit really exposes is read from the mouse.
+
+| Model | USB PID | Bluetooth PIDs | Receiver / Bolt wireless PIDs | Connections | Controls | Status |
+| --- | --- | --- | --- | --- | --- | --- |
+| MX Vertical | `C08A` | `B020` | `407B` | USB cable, Bluetooth, Unifying receiver (it can't pair with Bolt) | Back, Forward, Top (DPI) button, Wheel click | Verified on hardware |
+| MX Master | - | `B012`, `B01E` | `4041`, `4071` | Bluetooth, Unifying / Nano receiver | Back, Forward, Gesture button, SmartShift button, Wheel click | Published data (untested on hardware) |
+| MX Master 2S | - | `B019` | `4069` | Bluetooth, Unifying / Nano receiver | Back, Forward, Gesture button, SmartShift button, Wheel click | Published data (untested on hardware) |
+| MX Master 3 | - | `B023` | `4082` | Bluetooth, Unifying / Nano receiver | Back, Forward, Gesture button, SmartShift button, Wheel click | Published data (untested on hardware) |
+| MX Master 3 for Business | - | `B028` | `B028` | Bluetooth, Bolt receiver | Back, Forward, Gesture button, SmartShift button, Wheel click | Published data (untested on hardware) |
+| MX Master 3S | - | `B034` | `B034` | Bluetooth, Bolt receiver | Back, Forward, Gesture button, SmartShift button, Wheel click | Published data (untested on hardware) |
+| MX Master 4 | - | `B042` | - | Bluetooth | Back, Forward, Gesture button, Haptic Sense button, SmartShift button, Wheel click | Published data (untested on hardware) |
+| MX Anywhere 2 | - | `B013`, `B018`, `B01F` | `404A`, `4072`, `4063` | Bluetooth, Unifying / Nano receiver | Back, Forward, Wheel click, Tilt left, Tilt right | Published data (untested on hardware) |
+| MX Anywhere 2S | - | `B01A` | `406A` | Bluetooth, Unifying / Nano receiver | Back, Forward, Wheel click, Tilt left, Tilt right | Published data (untested on hardware) |
+| MX Anywhere 3 | - | `B025` | `4090` | Bluetooth, Unifying / Nano receiver | Back, Forward, SmartShift button, Wheel click | Published data (untested on hardware) |
+| MX Anywhere 3 for Business | - | `B02D` | `B02D` | Bluetooth, Bolt receiver | Back, Forward, SmartShift button, Wheel click | Published data (untested on hardware) |
+| MX Ergo | - | `B01D` | `406F` | Bluetooth, Unifying / Nano receiver | Back, Forward, Precision button (DPI), Wheel click, Tilt left, Tilt right | Published data (untested on hardware) |
+| Lift | - | `B031` | `B031` | Bluetooth, Bolt receiver | Back, Forward, Top (DPI) button, Wheel click | Published data (untested on hardware) |
+| Lift for Business | - | `B033` | `B033` | Bluetooth, Bolt receiver | Back, Forward, Top (DPI) button, Wheel click | Published data (untested on hardware) |
+| M720 Triathlon | - | `B015` | `405E` | Bluetooth, Unifying / Nano receiver | Back, Forward, Gesture button (multi-platform), Wheel click, Tilt left, Tilt right | Published data (untested on hardware) |
+| M585 / M590 | - | `B01B` | `406B` | Bluetooth, Unifying / Nano receiver | Back, Forward, Wheel click, Tilt left, Tilt right | Published data (untested on hardware) |
+| Signature M650 L | - | `B02A` | `B02A` | Bluetooth, Bolt receiver | Back, Forward, Wheel click | Published data (untested on hardware) |
+| Signature M550 | - | `B02B` | `B02B` | Bluetooth, Bolt receiver | Wheel click | Published data (untested on hardware) |
+| ERGO M575 | - | `B027` | `4096` | Bluetooth, Unifying / Nano receiver | Back, Forward, Wheel click | Published data (untested on hardware) |
+| Marathon M705 | - | - | `406D` | Unifying / Nano receiver | Back, Forward, Wheel click, Tilt left, Tilt right | Published data (untested on hardware) |
+| Pebble M350 | - | `B021` | `4080` | Bluetooth, Unifying / Nano receiver | Wheel click | Published data (untested on hardware) |
+| M510 (v2) | - | - | `4051` | Unifying / Nano receiver | Back, Forward, Wheel click, Tilt left, Tilt right | Published data (untested on hardware) |
+| M185 / M235 / M310 | - | - | `4055` | Unifying / Nano receiver | Wheel click | Published data (untested on hardware) |
+
+An empty cell (`-`) means the catalog has no ID of that kind for the model, not that the mouse can't
+do it. The MX Vertical row is the one entry that is **verified on hardware**, and its Bluetooth
+path is best effort because it depends on macOS exposing the mouse's HID++ collection.
+
+Notes on individual controls (all unverified on hardware outside the MX Vertical):
+
+- The **Signature M550** and **Pebble M350** list only the wheel click as remappable. (The catalog
+ notes that the M550's thumb buttons are plain mouse buttons, not programmable controls.)
+- On the **M185 / M235 / M310** family the left and right buttons report as divertable, but Upright
+ never diverts primary clicks (`ControlCatalog.neverDivert`).
+- The **MX Ergo** uses the "Precision Button" wording for its DPI control (`0x00ED`).
+- Pointer speed (DPI) is shown only for mice that offer `ADJUSTABLE_DPI` (`0x2201`). Mice that
+ don't (the M720 Triathlon and MX Ergo dumps list a different speed feature) get no pointer-speed
+ controls. This is derived from published dumps and not tested on hardware.
+
+## Mice that aren't in the catalog
+
+Upright doesn't need a catalog entry to try a mouse:
+
+- **Receivers.** Any mouse paired to a supported receiver (below) is probed in every slot. If it
+ reports itself as a pointer and offers `0x1B04`, it is served.
+- **Cable and Bluetooth.** A Logitech USB product ID from `C07D` to `C094` or a Bluetooth ID from
+ `B012` to `B0FF` is opened as a candidate mouse. Keyboard ID ranges (USB `C32B` to `C344`,
+ Bluetooth `B317` to `B3FF`) are never opened.
+- Mappings for a mouse that isn't in the catalog are kept per detected model, the same as for
+ catalog models. All of this is **untested** on hardware.
+
+If your mouse isn't listed, or is listed and misbehaves, first use **Settings → Mice → Copy Device
+Report** while it is connected, then quit Upright and run `make probe ARGS=--all`. The probe
+doesn't divert or change any button (it only turns on a receiver's notification flags), but it
+uses the same HID++ software ID (`0x0A`) as Upright on a shared interface, so the two must not run
+at the same time. Attach both to an issue on GitHub. That is how a model moves from "Published data" to "Verified".
+
+## Receivers
+
+| Receiver | Product IDs | Opened? | Pairing layout | Status |
+| --- | --- | --- | --- | --- |
+| Unifying | `C52B`, `C532` | Yes | Unifying | Supported for the MX Vertical (verified on hardware); other mice behind one are untested |
+| Bolt | `C548` | Yes | Bolt | Implemented from Solaar's documentation and **simulator-tested only**; untested on a real Bolt receiver |
+| Nano | `C52F`, `C531`, `C534`, `C537`, `C518`, `C51A`, `C51B`, `C521`, `C525`, `C526`, `C52E`, `C535` | Yes | Unifying (Nano receivers use the same pairing registers) | Untested on hardware |
+| Lightspeed | `C539`, `C53A`, `C53D`, `C53F`, `C541`, `C545`, `C547`, `C54D` | **Never** | none | Excluded, see below |
+| 27 MHz (pre-Unifying) | `C517` | **Never** | none | Excluded: HID++ 1.0 devices only |
+
+**Why Lightspeed receivers are excluded.** Every G-series and PRO mouse in the research data that
+sits behind a Lightspeed receiver lacks the programmable-button feature (`0x1B04`): those mice keep
+their buttons in onboard profiles. The `C53A` receiver also hosts a PowerPlay charging chip, and
+Logitech G HUB owns these receivers. Upright therefore never opens them, which also means it can't
+interfere with them. Re-enabling a receiver would need an entry in `DeviceCatalog.receivers` with a
+pairing layout.
+
+Any other Logitech receiver ID in the `C500` to `C5FF` range is left alone ("unrecognized Logitech
+receiver"). Receivers are scanned in slots 1 to 6.
+
+## Gaming mice that are known not to work
+
+`DeviceCatalog.knownUnsupported` lists mice whose buttons live in onboard profiles instead of
+programmable controls. They are documented for logs only; if one is opened it simply ends up
+"No programmable buttons" and is never diverted:
+
+G Pro Wireless, G304 / G305, G502 Lightspeed, G502 X Plus, G604, G703 Lightspeed, G903
+Lightspeed, PRO X Wireless and PRO X 2.
+
+## Devices Upright never configures
+
+Keyboards, numpads, presenters, remote controls and receivers are never diverted, and devices that
+answer only HID++ 1.0 are not served. They appear under **Other Logitech devices** with the reason
+and keep working as usual.
diff --git a/docs/HIDPP.md b/docs/HIDPP.md
index 3ff2acc..63da2f0 100644
--- a/docs/HIDPP.md
+++ b/docs/HIDPP.md
@@ -1,22 +1,325 @@
# HID++ notes
-This page lists every message Upright exchanges with the MX Vertical and its receiver. All
+This page lists every message Upright exchanges with a Logitech mouse and its receiver, and
+explains how Upright organizes the conversations when several mice are connected at once. All
values come from public HID++ documentation and the open-source projects credited in the
README. Bytes are hex; multi-byte values are big-endian unless noted.
-## Transports
+**What has been tested on hardware.** Only the MX Vertical (over a Unifying receiver, a USB cable
+and Bluetooth LE) has been checked against a real device, and that testing predates the
+multi-mouse rewrite: the link/endpoint split (`HIDPPLink`/`EndpointController`), the request
+gate, the generic vendor-`046D` mouse-usage matching and the mapping v3 migration have not yet
+run on a real device. Everything about other models, about
+Bolt and Nano receivers, and about several mice at once comes from published data and from
+Upright's simulator (`UprightKit/Sources/UprightKit/Simulation`). Those places are marked
+*unverified* below, and [What is not verified](#what-is-not-verified) collects them. If you have
+the hardware, `make probe ARGS=--all` output is the most useful thing you can send; see
+[CONTRIBUTING.md](../CONTRIBUTING.md).
+
+Source files named below are relative to `UprightKit/Sources/UprightKit/` unless they start with
+`Upright/` (the app).
+
+## Contents
+
+- [Architecture: hub and endpoint](#architecture-hub-and-endpoint)
+- [Interfaces and transports](#interfaces-and-transports)
+- [Pairing layouts](#pairing-layouts)
+- [Identity](#identity)
+- [The capability gate](#the-capability-gate)
+- [The request gate](#the-request-gate)
+- [Framing](#framing)
+- [Receiver registers (HID++ 1.0)](#receiver-registers-hid-10)
+- [Discovery](#discovery)
+- [Features used](#features-used)
+- [Button diversion](#button-diversion-0x1b04-reprog_controls_v4)
+- [Pointer speed](#pointer-speed-0x2201-adjustable_dpi)
+- [Firmware, serial and identity](#firmware-serial-and-identity-0x0003-device_fw_version)
+- [Easy-Switch](#easy-switch-0x1814-change_host-0x1815-hosts_info)
+- [Battery](#battery)
+- [Staying configured](#staying-configured)
+- [Connection states](#connection-states)
+- [What is not verified](#what-is-not-verified)
+
+## Architecture: hub and endpoint
+
+Upright opens one HID interface per receiver or directly connected mouse. Two classes split the
+work, and they never swap jobs:
+
+| Class | File | One per | Owns |
+| --- | --- | --- | --- |
+| `HIDPPLink`, the **hub** | `Device/HIDPPLink.swift` | opened HID interface (a receiver, or a mouse on USB or Bluetooth) | the `HIDPPClient` (and its request gate), the receiver's notification flags, pairing discovery, routing of incoming reports by device index |
+| `EndpointController`, the **endpoint** | `Device/EndpointController.swift` | mouse: a receiver slot (`1`-`6`) or the direct link (`FF`) | the identification, the configuration that is applied, retry and backoff, wake-up debouncing, battery refresh, pointer-speed bookkeeping |
-Upright opens one HID interface per connection and speaks HID++ over it.
+```
+IOKit (HIDDeviceMonitor) Demo Mode (SimulatedLinkSource)
+ | |
+ +------------ LinkSource ------------+ Upright/App/LinkSource.swift
+ |
+ InterfaceClassifier which interfaces to open, and as what
+ |
+ DeviceManager: one HIDPPLink per interface Upright/App/DeviceManager.swift
+ |
+ HIDPPLink --- spawns ---> EndpointController (one per mouse)
+ | |
+ | DeviceSession reads identity, applies the gate,
+ | | diverts buttons, turns reports into events
+ | |
+ +-- DeviceRegistry <-----------+ every endpoint, roles, display names
+ |
+ ConfigurationPlanner what each endpoint should be doing
+ |
+ AppModel pushes EndpointConfiguration back down to the endpoint
+```
-| Connection | Product ID | Interface |
-| --- | --- | --- |
-| Unifying receiver | `C52B`, `C532` | Vendor usage page `FF00`; device index `01`–`06` |
-| Bolt receiver | `C548` | Same layout. The MX Vertical can't pair with Bolt, so this only matters for other models. |
-| USB cable | `C08A` | Vendor usage page `FF00` (the mouse interface is skipped when it exists); device index `FF` |
-| Bluetooth | `B020` | Usage page `FF43`, or the mouse interface itself when macOS exposes no separate HID++ one; device index `FF` |
+How the pieces cooperate:
+
+- **Pull, then push.** The moment an endpoint has identified its mouse, and before anything is
+ diverted, the hub asks `configurationProvider` for that mouse's `EndpointConfiguration`
+ (diverted controls, raw XY controls, pointer speed). Every later change (a new mapping, a new
+ frontmost app, a role change) is pushed with `HIDPPLink.setConfiguration(_:at:)`. A mouse is
+ therefore never diverted before the app knows which mouse it is, and every button event
+ already carries its source.
+- **Everything is addressed.** Button events and raw movement carry a `DeviceAddress` (see
+ [Identity](#identity)), so two mice with the same buttons never share state.
+- **The hub handles the receiver, the endpoint handles the mouse.** Receiver-level reports
+ (`40`, `41`) and receiver registers belong to the hub. Reports with a slot's device index go to
+ that slot's endpoint. A direct link has one endpoint at index `FF` and no receiver logic.
+- **One endpoint cannot hold up another** beyond the [request gate](#the-request-gate): each has
+ its own operation queue, retry timer, backoff counter and wake-up debounce.
+- **Nothing is written to a mouse before the gate passes.** Identification only reads (see
+ [The capability gate](#the-capability-gate)). The one write before that goes to the receiver
+ itself: its notification flags, once per link.
+- **On quit or removal** the endpoint hands diverted buttons back with fire-and-forget
+ `setCidReporting` messages (`restoreMessages`), and releases any held button first.
+
+`MXVerticalController` (also in `Device/EndpointController.swift`) is a facade over a private
+one-link hub with the single-mouse API of earlier versions. Only the older controller tests use
+it; the app and the probe talk to `HIDPPLink`.
+
+## Interfaces and transports
+
+Upright watches for Logitech (`046D`) HID interfaces on usage page `FF00` (receivers and USB),
+usage page `FF43` (Bluetooth LE) and the generic mouse usage (`0001:0002`; many Bluetooth mice
+carry HID++ inside their mouse interface). `InterfaceClassifier.classify` then decides, from the
+product ID, the IOHID `Transport` string and the usage, what to do with each match:
+
+| Connection | Product IDs | Interface | Device index |
+| --- | --- | --- | --- |
+| Unifying receiver | `C52B`, `C532` | Vendor page `FF00` | `01`-`06` |
+| Bolt receiver (*unverified*) | `C548` | Vendor page `FF00` | `01`-`06` |
+| Nano receivers (*unverified*) | `C52F`, `C531`, `C534`, `C537`, `C518`, `C51A`, `C51B`, `C521`, `C525`, `C526`, `C52E`, `C535` | Vendor page `FF00`; pairing registers as on Unifying | `01`-`06` |
+| USB cable | The model's own ID (MX Vertical `C08A`), or `C07D`-`C094` | Vendor page `FF00` when it exists, else the mouse interface | `FF` |
+| Bluetooth | The model's own ID (MX Vertical `B020`), or `B012`-`B0FF` | Vendor page `FF43`, or the mouse interface itself when macOS exposes no separate HID++ one | `FF` |
+
+Never opened, with the reason logged once per interface:
+
+- **Lightspeed receivers** (`C539`, `C53A`, `C53D`, `C53F`, `C541`, `C545`, `C547`, `C54D`): every
+ G-series and PRO mouse in the research data keeps its buttons in onboard profiles (`0x8100`)
+ and lacks `0x1B04`; G HUB owns these receivers, and the `C53A` also carries a PowerPlay chip.
+ Re-enabling one is a table change in `InterfaceClassifier` and `DeviceCatalog.receivers`, if a
+ probe dump ever shows `0x1B04` behind it.
+- The 27 MHz receiver `C517` (HID++ 1.0 only) and any other unrecognized receiver in `C500`-`C5FF`.
+- **Keyboards**: the product ID ranges `C32B`-`C344` (USB) and `B317`-`B3FF` (Bluetooth). A model
+ listed in the catalog wins over these ranges. This is the first of two anti-hijack layers; the
+ second is the [capability gate](#the-capability-gate).
+- A receiver on a Bluetooth transport, a receiver's boot-mouse or keyboard interface, and any
+ other transport string than USB or Bluetooth.
+
+**Twin interfaces.** A USB device or a receiver publishes a vendor-page HID++ interface and a
+mouse interface; a Bluetooth mouse can appear twice. Opening two interfaces of one device would
+interleave HID++ replies, so `InterfaceClassifier.twinsToReplace` keeps one: interfaces with the
+same vendor, product and (non-zero) location ID whose serial numbers don't contradict each other
+are the same device, and the vendor-page interface wins. An interface without a location ID is
+never treated as a twin. `identityKey` (vendor, product, usage page, usage, then serial, else
+location ID, else registry entry ID) identifies an interface across re-matching.
The wireless product ID a receiver reports for an MX Vertical is `407B`.
+## Pairing layouts
+
+How mice show up depends on how they are connected. This table is what Upright does in each
+layout. "Link" is one opened HID interface; "endpoint" is one mouse.
+
+| Layout | Links | Endpoints | Notes |
+| --- | --- | --- | --- |
+| One mouse on a receiver | 1 | 1, at the mouse's slot | The usual case. The 1.0 single-device code was verified with a Unifying receiver and an MX Vertical; the link/endpoint path in this release is simulator-tested, hardware check pending. |
+| Several mice on one receiver | 1 | One per mouse slot | All share the receiver's [request gate](#the-request-gate). Slots `1`-`6` are always scanned, whatever capacity the receiver claims. Simulator only. |
+| A mouse and a keyboard on one receiver | 1 | 1 (the mouse) | A slot the pairing table lists as a keyboard (or numpad, presenter, remote) gets no endpoint and no traffic. A slot listed with an unknown kind is probed read-only, and is then either served or ends in `.unsupported`. |
+| Several receivers | One each | One per mouse | Each link has its own client, gate and notification flags. Slot numbers repeat across receivers (slot `1` of two receivers is not the same slot); `DeviceAddress` includes the link number. |
+| Bluetooth mouse | 1 | 1, at `FF` | One endpoint per interface. The 1.0 single-device code was verified with an MX Vertical over BLE; the link/endpoint path in this release is simulator-tested, hardware check pending. |
+| USB cable | 1 | 1, at `FF` | As above. The 1.0 single-device code was verified with an MX Vertical; the link/endpoint path in this release is simulator-tested, hardware check pending. |
+| A mix of the above | One per interface | One per mouse | For example an MX Vertical on a Unifying receiver, an MX Master 3S on a Bolt receiver and a Lift over Bluetooth: three links, three endpoints (this is Demo Mode's fleet). |
+| Two identical mice | As above | Two, with different `DeviceID`s | They get separate state, preferences and a "#2" suffix, and share one mapping setup (the mappings belong to the model). |
+| **The same unit on two links** | 2 | 2, same `DeviceID` | See below. |
+
+### The same unit on two transports
+
+A mouse can be reachable twice at once: a cable plus its receiver, a receiver plus Bluetooth, or
+a mouse switched between Easy-Switch channels. When two endpoints identify as the same stable
+`DeviceID`, `DeviceRegistry` gives each a role:
+
+- One is `.active`: it is served, and its events are handled.
+- The others are `.standby(of:)` the active one: they are kept native (nothing diverted unless a
+ button is still held), their button presses and movement are dropped, and they are not counted
+ twice in `FleetSummary`.
+
+The winner is the endpoint that is *connected* (one that sleeps or waits loses), then the better
+link (USB, then receiver, then Bluetooth, `DeviceRegistry.linkRank`), then the one seen first.
+Roles are recomputed on every change, so when the active endpoint leaves or stops answering, the
+standby is promoted (the mouse switched to its Bluetooth channel leaves its receiver slot
+asleep, and the Bluetooth endpoint becomes active). Without a stable `DeviceID` (no unit ID and
+no serial), Upright cannot know the two endpoints are one mouse and treats them as two.
+
+*Unverified:* that the `0x0003` unit ID is identical across the transports of a real MX Vertical,
+and whether MX mice keep their wireless link while cabled. `upright-probe --all` over two
+transports shows both (look for the `Unit ID` line under `Identity`); if the IDs differ, only
+this deduplication is lost.
+
+### Receiver events that change a layout
+
+| Event | Reaction |
+| --- | --- |
+| A mouse is **re-paired to another slot** of the same receiver | The new slot gets a new endpoint. When it identifies with the same stable `DeviceID` as an endpoint on another slot of that hub, the old endpoint is *retired*: held buttons released, diverted buttons handed back (fire-and-forget), `onEndpointRemoved(.retired(movedTo:))`. |
+| A slot is **un-paired** (`40` with the un-pair bit) | Only that endpoint is removed (`.unpaired`, nothing is sent to the gone device). |
+| A slot now holds **another device** (a `41` with another wireless PID) | The old endpoint is removed (`.replaced`) and a new one starts. An unsupported endpoint whose wireless PID was never learned is checked once more. |
+| The HID interface goes away | Every endpoint of the link is dropped (`.transportClosed`) and the link number is never reused. |
+
+### Receiver pairing registers
+
+The pairing table is a HID++ 1.0 long register, `B5`, read through device index `FF`. Where the
+slot is listed depends on the receiver family (`PairingLayout`, `Device/ReceiverDirectory.swift`):
+
+| Layout | Used by | Sub-register for slot *n* | Reply parameters |
+| --- | --- | --- | --- |
+| `unifying` | Unifying, and every Nano receiver | `20 + (n - 1)`, so `20`-`25` | ` > > `; kind is the low nibble of byte 7 |
+| `bolt` (*unverified*) | Bolt | `50 + n`, so `51`-`56` | ` …`; kind is the low nibble of byte 1 |
+
+On Bolt, the wireless PID a slot reports is the mouse's Bluetooth LE ID (an MX Master 3S shows
+`B034` there). The Bolt layout and the Nano receivers' use of the Unifying layout follow Solaar
+and are simulator-tested only.
+
+Kind nibbles (HID++ 1.0, pairing records and `41` notifications): `0` unknown, `1` keyboard,
+`2` mouse, `3` numpad, `4` presenter, `7` remote, `8` trackball, `9` touchpad. A zero wireless
+PID marks an empty slot. A device's own `0x0005` answer outranks the nibble.
+
+## Identity
+
+Four different identifiers exist, because "which mouse is this" has four different answers:
+
+| Identifier | Type | Built from | Lifetime | Used for |
+| --- | --- | --- | --- | --- |
+| **`DeviceAddress`** | `link` (per opened HID interface, counted from 1, never reused in a process) + `index` (`1`-`6`, or `FF`) | where the endpoint lives right now | One connection. A re-pair, a transport change or a re-plug gives a new address. | Routing: button events, raw movement, engine and gesture state, the applied-configuration cache, device actions, battery policy, the UI selection. Always unique among live endpoints, and known before the mouse identifies. Never persisted. |
+| **`DeviceID`** | `u::`, else `s::`, else `t:.` | the unit ID (`0x0003` function 0, bytes 1-4, 8 hex digits, if non-zero), else the serial (`0x0003` function 2, version 4 and later), else a transient ID | Permanent for `u:` and `s:` IDs. A `t:` ID lasts one connection and is never saved. | Per-mouse preferences (nickname, "Remap this mouse", pointer speed, last seen), the same-unit dedupe, re-pair detection, notification text. Never routes events. |
+| **`ModelKey`** | `046D:XXXX`, else `name:` | see below | Permanent, and the same on every transport | Mappings (one setup per model), catalog lookups, control names, suggested setups. |
+| IOKit identity key | `vendor:product:page:usage` plus serial, else location ID, else registry entry ID | the IOHID properties | One `IOHIDDevice` | Twin-interface dedupe at match time only. |
+
+Consequences worth remembering:
+
+- Two units of one model have different `DeviceID`s but one `ModelKey`, so they share a button
+ setup and keep independent held buttons, gesture state and pointer speed.
+- A mouse that reports neither unit ID nor serial still works (its address is unique). Its
+ preferences fall back to the model's seed, the Mice pane says that settings for it can't be
+ remembered, and the probe prints `Device ID: t:… (transient …)`.
+- The receiver's own copy of a slot's serial (`B5` sub-register `30 + (n - 1)` on Unifying and
+ Nano, the pairing record on Bolt) can be read with `ReceiverDirectory.readSerial`, but only
+ `upright-probe --all` does; it does not feed `DeviceID`.
+
+### The model key
+
+`ModelKey` is the same whichever way the mouse is connected. `DeviceCatalog.modelKey(for:)` takes:
+
+1. the first catalog model that matches any model ID the mouse reports in `0x0003` function 0
+ (the catalog key is that model's first Bluetooth ID, else its first wireless ID, else its USB
+ ID);
+2. otherwise the first of the reported IDs in the order Bluetooth, Bluetooth LE, wireless, USB;
+3. otherwise the transport PID (the wireless PID from the receiver, or the IOHID product ID),
+ through the catalog's aliases;
+4. otherwise `name:`.
+
+The MX Vertical reports `407B`, `C08A` and `B020` on its three transports and always gets
+`046D:B020` (`ModelKey.mxVertical`). A mouse the catalog doesn't know yet gets
+`046D:`, which is exactly the key the catalog entry will have once
+someone adds it, so saved mappings survive that addition. Do not change an existing catalog key.
+
+### Reading the identity
+
+`DeviceFeatures.identity` and `DeviceIdentity.parseFirmwareIDs` read `0x0003` function 0
+(`getEntityCount`). The reply is
+` > `:
+
+- The transport bits (byte 6) say which model IDs follow: `01` Bluetooth, `02` Bluetooth LE,
+ `04` wireless (receiver), `08` USB. One big-endian ID per set bit is packed into bytes 7-12 in
+ that order, so at most three fit.
+- A unit ID of `00000000` means "none".
+
+`getDeviceSerialNumber` (function 2, only from `0x0003` version 4) gives the serial, and
+`DEVICE_NAME` (`0x0005`) function 2 gives the device kind: `0` keyboard, `1` remote, `2` numpad,
+`3` mouse, `4` touchpad, `5` trackball, `6` presenter, `7` receiver.
+
+## The capability gate
+
+What Upright serves is decided by what a device answers, never by its name or product ID. The
+gate lives in `DeviceSession.start()` and runs on every endpoint, receiver slot or direct link.
+In order, with reads only:
+
+1. **Protocol version** (`Root` function 1 ping). HID++ 1.0 answers `01` (invalid sub-ID), or
+ the version is below 2: `.unsupported(.hidpp10)`.
+2. Feature table (`0x0001`), name (`0x0005`), identity (`0x0005` kind, `0x0003` IDs and serial).
+3. **Pointer?** If the device reported its own kind (`0x0005` function 2), that is
+ authoritative: mouse, trackball and touchpad pass, anything else gives
+ `.unsupported(.notAPointer)`. Without an answer, the receiver's pairing kind stands in for
+ it: a keyboard, numpad, presenter, remote or receiver is rejected, and a mouse, trackball or
+ touchpad passes. If neither source says (kind unknown), the device passes only when one of
+ its controls carries the `mse` (mouse button) flag.
+4. **`0x1B04` REPROG_CONTROLS_V4** must exist: otherwise `.unsupported(.noReprogControls)`.
+ This is where the G-series and PRO mice end.
+5. At least one **configurable control** (divertable, not virtual, not a primary click):
+ otherwise `.unsupported(.noConfigurableControls)`. Touchpads usually end here.
+
+Only then does the session read the battery, firmware, pointer speed and Easy-Switch channels,
+and only after the hub's configuration is known does anything get written
+(`setCidReporting`).
+
+`.unsupported` is terminal: no retry, no writes, never diverted. The endpoint stays in the
+registry (the Mice pane lists it under "Other Logitech devices" with the reason), and a
+receiver slot is re-checked only if another device shows up in it. A keyboard that the receiver
+lists as a keyboard never gets an endpoint at all. Left and Right click are never diverted,
+whatever a device's flags say (`ControlCatalog.neverDivert`).
+
+Because the decision is by capability, a Logitech mouse that is not in `DeviceCatalog` can still
+be served, and a catalog model can still be rejected if its firmware answers differently.
+
+## The request gate
+
+A `HIDPPClient` (one per `HIDPPLink`, so one per HID interface) multiplexes requests and replies.
+Its gate (`RequestGate` in `HIDPP/HIDPPClient.swift`) limits how many requests wait for a reply
+at once:
+
+- **One request in flight per transport.** `ControllerTuning.maxInFlight` is `1`, and the hub
+ applies it to its client. A bare `HIDPPClient` is unlimited. Requests beyond the limit queue
+ in FIFO order. Two links (two receivers, or a receiver and a Bluetooth mouse) have separate
+ gates and do not wait for each other.
+- **Two lanes.** `Lane.interactive` requests (button diversion, pointer speed) go ahead of
+ `Lane.background` ones (probing, the feature table, battery, device information). The lane is
+ the task-local `HIDPPClient.lane`; `EndpointController` sets `.interactive` around
+ `applyDiversion` and `setDPI`, and everything else is background.
+- The timeout counts from the moment the request leaves the gate, not from when it was queued.
+ A "busy" reply is sent again (up to 2 more times, 50 ms apart); each attempt goes through the
+ gate again.
+- `sendWithoutReply` bypasses the gate. It carries the shutdown "restore" messages, retiring an
+ endpoint's undivert, and the Easy-Switch `setCurrentHost`, none of which are answered.
+
+The effect: a sleeping slot's probe interleaves request by request with the other slots instead
+of blocking them for a whole `start()`, and the worst delay for a diversion change is one request
+timeout (1 s for a receiver ping, 2 s for any other request) behind a probe that gets no answer.
+One mouse sees the same wire pattern as before the gate existed.
+
+**Why one?** It is *unverified* whether real receivers tolerate concurrent requests to different
+slots. The limit is the conservative guess; relaxing it is one value (`maxInFlight`), and
+hardware evidence should come first. Replies are matched to requests by device index, sub-ID
+and address, and the simulator records the highest overlap it saw (`requestsInFlightMax`,
+`MultiDeviceControllerTests.testOneRequestInFlightPerTransport`).
+
## Framing
| Report | ID | Length | Used for |
@@ -30,7 +333,7 @@ The wireless product ID a receiver reports for an MX Vertical is `407B`.
```
- **Device index.** `FF` means the receiver itself, or a mouse connected by cable or Bluetooth.
- `01`–`06` is a receiver slot.
+ `01`-`06` is a receiver slot.
- **Software ID.** Upright uses `A`. Events from the device always carry software ID `0`, which
is how replies are told apart from events.
- **Errors.** HID++ 2.0 errors come back as `11 idx FF `. HID++ 1.0
@@ -38,7 +341,8 @@ The wireless product ID a receiver reports for an MX Vertical is `407B`.
pending request.
- **Busy.** HID++ 2.0 error `08` and HID++ 1.0 error `07` mean "ask again". `HIDPPClient`
resends the request up to 2 more times, 50 ms apart, before returning the error.
-- **Timeouts.** A request that gets no reply fails after 2 s (1 s for discovery pings).
+- **Timeouts.** A request that gets no reply fails after 2 s (1 s for receiver pings and pairing
+ reads, `ControllerTuning.probeTimeout`).
### Errors that mean "not reachable right now"
@@ -48,44 +352,66 @@ away, not as a fatal error. See [Connection states](#connection-states).
## Receiver registers (HID++ 1.0)
-Registers are addressed through sub-IDs `80` (write, short reply) and `83` (long read). Upright
-sends them as short reports to device index `FF`.
+Registers are addressed through sub-IDs `80` (write, short reply), `81` (short read) and `83`
+(long read). Upright sends them as short reports to device index `FF`.
| Register | Request | Purpose |
| --- | --- | --- |
-| `00` notifications | `10 FF 80 00 00 09 00` | Parameter byte 1 = `09`: bit `01` wireless notifications, bit `08` software present. Without this the receiver doesn't announce link changes. Sent at probe time and on every reconfiguration. |
+| `00` notifications | read `10 FF 81 00 00 00 00`; write `10 FF 80 00 ` | Three flag bytes. Bit `01` of byte 1 is wireless notifications, bit `08` is software present. Without them the receiver doesn't announce link changes. |
| `02` connection state | `10 FF 80 02 02 00 00` | Writing `02` makes the receiver send a `41` notification for every paired device ("fake arrival"). Used only when the pairing list can't be read. |
-| `B5` pairing info | `10 FF 83 B5 <20+n-1> 00 00` | Long read, sub-register `20` to `25` for slots 1 to 6. Reply parameters: `