From f1d75b002bc173f326c1bc2d1d67893c48181c0c Mon Sep 17 00:00:00 2001 From: Daghan Percinel <4314475+dpdev69@users.noreply.github.com> Date: Tue, 13 Oct 2026 22:43:00 +0300 Subject: [PATCH 01/14] Multi-mouse: wave 0 Co-Authored-By: Claude Haiku 5.5 --- Upright.xcodeproj/project.pbxproj | 80 +++++ Upright/App/DemoMode.swift | 26 ++ Upright/App/DeviceManager.swift | 2 +- Upright/App/SnapshotExporter.swift | 22 ++ Upright/Views/DeviceListView.swift | 16 + Upright/Views/ModelPicker.swift | 20 ++ Upright/Views/SnapshotAnchor.swift | 25 ++ .../Device/ButtonStateTracker.swift | 30 +- .../Device/ConfigurationPlanner.swift | 80 +++++ .../UprightKit/Device/ControlCatalog.swift | 128 +++++++ .../UprightKit/Device/DeviceCatalog.swift | 138 +++++++ .../UprightKit/Device/DeviceIdentity.swift | 336 ++++++++++++++++++ .../UprightKit/Device/DevicePreferences.swift | 83 +++++ .../UprightKit/Device/DeviceRegistry.swift | 206 +++++++++++ .../Sources/UprightKit/Device/HIDPPLink.swift | 107 ++++++ .../Device/InterfaceClassifier.swift | 107 ++++++ .../UprightKit/Device/MXVertical.swift | 56 +-- .../UprightKit/Device/ReceiverDirectory.swift | 76 ++++ .../UprightKit/Mapping/MappingLibrary.swift | 201 +++++++++++ .../Simulation/DeviceProfiles.swift | 41 +++ .../Simulation/SimulatedDeviceProfile.swift | 91 +++++ .../Simulation/SimulatedReceiver.swift | 89 +++++ 22 files changed, 1902 insertions(+), 58 deletions(-) create mode 100644 Upright/App/DemoMode.swift create mode 100644 Upright/App/SnapshotExporter.swift create mode 100644 Upright/Views/DeviceListView.swift create mode 100644 Upright/Views/ModelPicker.swift create mode 100644 Upright/Views/SnapshotAnchor.swift create mode 100644 UprightKit/Sources/UprightKit/Device/ConfigurationPlanner.swift create mode 100644 UprightKit/Sources/UprightKit/Device/ControlCatalog.swift create mode 100644 UprightKit/Sources/UprightKit/Device/DeviceCatalog.swift create mode 100644 UprightKit/Sources/UprightKit/Device/DeviceIdentity.swift create mode 100644 UprightKit/Sources/UprightKit/Device/DevicePreferences.swift create mode 100644 UprightKit/Sources/UprightKit/Device/DeviceRegistry.swift create mode 100644 UprightKit/Sources/UprightKit/Device/HIDPPLink.swift create mode 100644 UprightKit/Sources/UprightKit/Device/InterfaceClassifier.swift create mode 100644 UprightKit/Sources/UprightKit/Device/ReceiverDirectory.swift create mode 100644 UprightKit/Sources/UprightKit/Mapping/MappingLibrary.swift create mode 100644 UprightKit/Sources/UprightKit/Simulation/DeviceProfiles.swift create mode 100644 UprightKit/Sources/UprightKit/Simulation/SimulatedDeviceProfile.swift create mode 100644 UprightKit/Sources/UprightKit/Simulation/SimulatedReceiver.swift diff --git a/Upright.xcodeproj/project.pbxproj b/Upright.xcodeproj/project.pbxproj index 04e3aeb..6a69ab1 100644 --- a/Upright.xcodeproj/project.pbxproj +++ b/Upright.xcodeproj/project.pbxproj @@ -61,6 +61,24 @@ 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 */; }; + 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 */ @@ -119,6 +137,24 @@ 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 = ""; }; + 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 +177,7 @@ C110137C9980F9208BE059DC /* Mapping */, 6B774AC9BDF0D0197E7F0032 /* Platform */, 45899E710FE7DFADD7E85678 /* Scrolling */, + 445BD16AB392BAE1C75A5A04 /* Simulation */, ); name = UprightKit; path = UprightKit/Sources/UprightKit; @@ -203,11 +240,13 @@ 2F53FBF5620FAA73BC3A99CB /* AppModel.swift */, 766EE0AB39B7FB46D7AA53A3 /* AppSettings.swift */, 267982B81F3F104CE213696C /* BatteryNotifier.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 +257,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 */, + 365D919A9D58C624CF3F4698 /* ReceiverDirectory.swift */, ); path = Device; sourceTree = ""; @@ -233,6 +281,7 @@ CC1BC2079D9F95E2C057D6C1 /* GestureRecognizer.swift */, 76B4A8E1A747B6E1AD914A9B /* GestureSet.swift */, 12AD4FC3D5D5D7249968FD4D /* KeyCombo.swift */, + 58CD3C8604BFD731B3266405 /* MappingLibrary.swift */, 94ED828840698FEAA23AE873 /* MappingProfiles.swift */, 0C437667DE6EE25F7E57FF85 /* MappingStore.swift */, 169D6EC9F98C734C0473CB6A /* RemapEngine.swift */, @@ -248,10 +297,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 +311,7 @@ 0AB3759151C30F6EE5A20245 /* SettingsView.swift */, 3DEEC269F06A06FB9D38D923 /* SettingsWindowController.swift */, 14F79AC6294C781265A79167 /* ShortcutRecorder.swift */, + 67A4A6FB17F2B29099B50EAC /* SnapshotAnchor.swift */, ); path = Views; sourceTree = ""; @@ -282,6 +334,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 +426,18 @@ 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 */, + 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,14 +448,18 @@ 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 */, 62DA65C5FE1B769E669BD22B /* OnboardingView.swift in Sources */, @@ -392,6 +467,7 @@ 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 +477,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 */, ); diff --git a/Upright/App/DemoMode.swift b/Upright/App/DemoMode.swift new file mode 100644 index 0000000..de0dc50 --- /dev/null +++ b/Upright/App/DemoMode.swift @@ -0,0 +1,26 @@ +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 scratchpad DEMO-MODE-SPEC.md. +/// +/// Wave 0 scaffold: argument parsing only. W5.5 owns this file. +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 } + + /// Call first thing at launch with `CommandLine.arguments`. + static func configure(arguments: [String]) { + exportDirectory = nil + if let flag = arguments.firstIndex(of: "--export-snapshots"), arguments.indices.contains(flag + 1) { + exportDirectory = URL(fileURLWithPath: arguments[flag + 1], isDirectory: true) + } + isActive = arguments.contains("--demo") || exportDirectory != nil + } +} diff --git a/Upright/App/DeviceManager.swift b/Upright/App/DeviceManager.swift index 7624dbc..6f7c642 100644 --- a/Upright/App/DeviceManager.swift +++ b/Upright/App/DeviceManager.swift @@ -405,7 +405,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/SnapshotExporter.swift b/Upright/App/SnapshotExporter.swift new file mode 100644 index 0000000..b07f94e --- /dev/null +++ b/Upright/App/SnapshotExporter.swift @@ -0,0 +1,22 @@ +import Foundation + +/// Renders the essential UI states offscreen in light and dark, and writes `..png` +/// files plus `manifest.json` (sizes and `snapshotAnchor` rects) to a directory. +/// +/// Wave 0 scaffold: W5.5 owns this file and implements it. +@MainActor +enum SnapshotExporter { + enum ExportError: Error, CustomStringConvertible { + case notImplemented + + var description: String { + switch self { + case .notImplemented: return "Snapshot export is not available in this build." + } + } + } + + static func run(model: AppModel, to directory: URL) async throws { + throw ExportError.notImplemented + } +} diff --git a/Upright/Views/DeviceListView.swift b/Upright/Views/DeviceListView.swift new file mode 100644 index 0000000..6360d04 --- /dev/null +++ b/Upright/Views/DeviceListView.swift @@ -0,0 +1,16 @@ +import SwiftUI + +/// The Mice pane's list of devices. +/// +/// Wave 0 scaffold: W5.1 owns this file (sections for connected, remembered and unsupported +/// devices; connection glyphs, battery, state; duplicate-name numbering). +struct DeviceListView: View { + let records: [DeviceRegistry.Record] + @Binding var selection: DeviceAddress? + + var body: some View { + List(records, selection: $selection) { record in + Text(verbatim: record.displayName) + } + } +} diff --git a/Upright/Views/ModelPicker.swift b/Upright/Views/ModelPicker.swift new file mode 100644 index 0000000..5bdc6df --- /dev/null +++ b/Upright/Views/ModelPicker.swift @@ -0,0 +1,20 @@ +import SwiftUI + +/// Chooses which mouse model the Buttons pane configures. +/// +/// Wave 0 scaffold: W5.2 owns this file (connected, then remembered, then catalog models). +struct ModelPicker: View { + let models: [ModelKey] + @Binding var selection: ModelKey + + var body: some View { + Picker(selection: $selection) { + ForEach(models, id: \.self) { model in + Text(verbatim: DeviceCatalog.model(model)?.name ?? model.rawValue).tag(model) + } + } label: { + EmptyView() + } + .labelsHidden() + } +} diff --git a/Upright/Views/SnapshotAnchor.swift b/Upright/Views/SnapshotAnchor.swift new file mode 100644 index 0000000..b5f78ca --- /dev/null +++ b/Upright/Views/SnapshotAnchor.swift @@ -0,0 +1,25 @@ +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. + @ViewBuilder + func snapshotAnchor(_ name: String) -> some View { + if DemoMode.isExporting { + transformAnchorPreference(key: SnapshotAnchorKey.self, value: .bounds) { value, anchor in + value[name] = anchor + } + } else { + self + } + } +} 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..84cbf54 --- /dev/null +++ b/UprightKit/Sources/UprightKit/Device/ConfigurationPlanner.swift @@ -0,0 +1,80 @@ +import Foundation + +// Wave 0 scaffold: final signatures and a first implementation. W2.2 owns this file and +// proves it golden-equal to today's AppModel.syncDiversion for an MX Vertical. + +/// 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`). + public var isActive: Bool + 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 + } +} + +public enum ConfigurationPlanner { + /// The configuration for one endpoint. + /// + /// - Remapping off: nothing diverted. + /// - Inactive endpoint or "Remap this mouse" off: only held controls stay diverted (so their + /// release still arrives), held gestures keep raw movement. + /// - Otherwise: the mapped controls plus held ones; gesture controls also report raw XY + /// when the control can. + /// + /// Diversion is limited to the device's configurable controls (never the primary clicks). + /// `dpi` snaps the preferred speed to what the device supports; without it the value is + /// passed through. + public static func configuration( + mappings: ButtonMappings, + controls: [ControlInfo], + dpi: DPIInfo? = nil, + inputs: ConfigurationInputs + ) -> EndpointConfiguration { + let speed = inputs.preferredDPI.map { dpi?.nearest(to: $0) ?? $0 } + guard inputs.remappingEnabled else { return EndpointConfiguration(dpi: speed) } + + let divert: Set + let gestures: Set + if inputs.isActive && inputs.deviceRemapEnabled { + divert = mappings.divertedControls(accessibilityGranted: inputs.accessibilityGranted).union(inputs.held) + gestures = mappings.gestureControls(accessibilityGranted: inputs.accessibilityGranted).union(inputs.gesturesHeld) + } else { + divert = inputs.held + gestures = inputs.gesturesHeld + } + + 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 + ) + } +} diff --git a/UprightKit/Sources/UprightKit/Device/ControlCatalog.swift b/UprightKit/Sources/UprightKit/Device/ControlCatalog.swift new file mode 100644 index 0000000..7277189 --- /dev/null +++ b/UprightKit/Sources/UprightKit/Device/ControlCatalog.swift @@ -0,0 +1,128 @@ +import Foundation + +// Wave 0 scaffold: final signatures. W1.1 owns this file and completes the name table. + +/// 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] + + /// Model-independent names, from Solaar's special_keys table. W1.1 completes this table. + static let genericText: [UInt16: ControlText] = [ + KnownControl.middleClick: ControlText( + name: NSLocalizedString("Wheel Click", comment: "Mouse control name"), + detail: NSLocalizedString("Press the scroll wheel", comment: "Mouse control description"), + symbolName: "smallcircle.filled.circle" + ), + KnownControl.back: ControlText( + name: NSLocalizedString("Back Button", comment: "Mouse control name"), + detail: NSLocalizedString("Thumb button", comment: "Mouse control description"), + symbolName: "chevron.backward.circle" + ), + KnownControl.forward: ControlText( + name: NSLocalizedString("Forward Button", comment: "Mouse control name"), + detail: NSLocalizedString("Thumb button", comment: "Mouse control description"), + symbolName: "chevron.forward.circle" + ), + ] +} + +/// 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..8d8e97b --- /dev/null +++ b/UprightKit/Sources/UprightKit/Device/DeviceCatalog.swift @@ -0,0 +1,138 @@ +import Foundation + +// Wave 0 scaffold: final signatures, MX Vertical and the receivers Upright already used. +// W1.1 owns this file and fills in the models and receivers from devices.verified.json. + +/// 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. + public var allPIDs: [UInt16] { bluetoothPIDs + wirelessPIDs + usbPIDs } +} + +/// A Logitech receiver product. `layout == nil` means the receiver is never opened. +public struct ReceiverModel: Hashable, Sendable { + public var productID: UInt16 + public var kind: ConnectionKind + public var layout: PairingLayout? + + public init(productID: UInt16, kind: ConnectionKind, layout: PairingLayout?) { + self.productID = productID + self.kind = kind + self.layout = layout + } +} + +public enum DeviceCatalog { + public static let vendorID: Int = 0x046D + + public static let models: [DeviceModel] = [mxVertical] + + 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) + } + return table + }() + + public static func model(_ key: ModelKey) -> DeviceModel? { + models.first { $0.key == key } + } + + public static func model(forPID pid: UInt16) -> DeviceModel? { + models.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) + } + + // MARK: Models + + 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 + ) +} + +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..ac01d49 --- /dev/null +++ b/UprightKit/Sources/UprightKit/Device/DevicePreferences.swift @@ -0,0 +1,83 @@ +import Foundation + +// Wave 0 scaffold: final signatures and a first implementation. W1.7 owns this file +// (tolerant decoding, tests). + +/// Settings and memory for one physical mouse (or the seed for a model's first-seen units). +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 + } +} + +/// Every mouse's preferences, persisted under `defaultsKey`. Only stable device IDs are stored. +public struct DevicePreferencesStore: Codable, Hashable, Sendable { + public static let defaultsKey = "devicePreferences.v1" + + /// Seeds (preferred speed) for units of a model seen for the first time. + public var models: [ModelKey: DevicePreferences] + public var devices: [DeviceID: DevicePreferences] + + public init(models: [ModelKey: DevicePreferences] = [:], devices: [DeviceID: DevicePreferences] = [:]) { + self.models = models + self.devices = devices + } + + /// The unit's own preferences, else its model's seed, else defaults. + public func preferences(for id: DeviceID, model: ModelKey) -> DevicePreferences { + if id.isStable, let own = devices[id] { return own } + return models[model] ?? DevicePreferences() + } + + /// 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. Transient IDs are ignored. + public mutating func record(_ id: DeviceID, snapshot: DeviceSnapshot) { + guard id.isStable else { return } + var prefs = devices[id] ?? id.model.flatMap { models[$0] } ?? DevicePreferences() + prefs.lastModel = id.model ?? prefs.lastModel + prefs.lastName = snapshot.name + prefs.lastControls = snapshot.configurableControls.map(\.cid) + prefs.lastConnection = snapshot.connection.rawValue + prefs.lastSeen = Date() + 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. + public mutating func migrateLegacy(preferredDPI: Int?) { + guard models[.mxVertical] == nil else { return } + models[.mxVertical] = DevicePreferences(preferredDPI: preferredDPI) + } + + public mutating func forget(_ id: DeviceID) { + devices[id] = nil + } +} diff --git a/UprightKit/Sources/UprightKit/Device/DeviceRegistry.swift b/UprightKit/Sources/UprightKit/Device/DeviceRegistry.swift new file mode 100644 index 0000000..ea9d632 --- /dev/null +++ b/UprightKit/Sources/UprightKit/Device/DeviceRegistry.swift @@ -0,0 +1,206 @@ +import Foundation + +// Wave 0 scaffold: final signatures and a minimal single-device implementation (no same-unit +// dedupe, no standby). W2.2 owns this file and implements §B7 / §C step 6. + +/// Every endpoint Upright knows about, across all links, with its role. +@MainActor +public final class DeviceRegistry { + public struct Record: Identifiable, Hashable, Sendable { + public var address: DeviceAddress + public var connection: ConnectionKind + public var state: EndpointController.State + public var deviceID: DeviceID? + public var model: ModelKey? + public var displayName: String + public var role: Role + /// First-seen order, stable for the process. + 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 stable device ID. + public var nameProvider: ((DeviceID) -> String?)? + public var onChange: (() -> Void)? + + /// Pointers first by ordinal; unsupported last. + public private(set) var records: [Record] = [] + + private var nextOrdinal = 0 + + public init() {} + + public func update(_ a: DeviceAddress, connection: ConnectionKind, state: EndpointController.State) { + var entry = record(a) ?? Record( + address: a, + connection: connection, + state: state, + displayName: "", + role: .pending, + ordinal: takeOrdinal() + ) + entry.connection = connection + entry.state = state + let snapshot = entry.snapshot + if let snapshot { entry.displayName = snapshot.name } + if let id = entry.deviceID, let nickname = nameProvider?(id) { entry.displayName = nickname } + entry.role = snapshot == nil ? .pending : .active + if let index = records.firstIndex(where: { $0.address == a }) { + records[index] = entry + } else { + records.append(entry) + } + sort() + onChange?() + } + + public func remove(_ a: DeviceAddress) { + guard records.contains(where: { $0.address == a }) else { return } + records.removeAll { $0.address == a } + onChange?() + } + + /// 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 [] } + records.removeAll { $0.address.link == link } + onChange?() + return removed + } + + public func record(_ a: DeviceAddress) -> Record? { + records.first { $0.address == a } + } + + 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 + } + + public var summary: FleetSummary { + var summary = FleetSummary() + for record in records { + switch record.state { + case .connected(let snapshot): + summary.anyConnected = true + summary.anyPresent = true + 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 static func linkRank(_ c: ConnectionKind) -> Int { + switch c { + case .usb: return 0 + case .unifying, .bolt, .nano: return 1 + case .bluetooth: return 2 + } + } + + private func takeOrdinal() -> Int { + defer { nextOrdinal += 1 } + return nextOrdinal + } + + private func sort() { + records.sort { lhs, rhs in + let l = lhs.role == .unsupported ? 1 : 0 + let r = rhs.role == .unsupported ? 1 : 0 + return l == r ? lhs.ordinal < rhs.ordinal : l < r + } + } +} + +/// The fleet at a glance, for the menu-bar label, scroll gating and alerts. +public struct FleetSummary: Hashable, Sendable { + public var anyConnected = false + /// Connected or asleep. + public var anyPresent = false + public var connectedCount = 0 + /// The connected mouse with the lowest battery. + 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/HIDPPLink.swift b/UprightKit/Sources/UprightKit/Device/HIDPPLink.swift new file mode 100644 index 0000000..d56e828 --- /dev/null +++ b/UprightKit/Sources/UprightKit/Device/HIDPPLink.swift @@ -0,0 +1,107 @@ +import Foundation + +// Wave 0 scaffold: final signatures only. W3.1 owns this file and implements the hub +// (D1, D10, D11, D12; §C steps 3–5 and 11). Until then nothing creates an HIDPPLink, and an +// instance stays inert: it never touches its transport and serves no endpoint. + +/// The per-endpoint controller. Until W3.1 renames the class (and keeps +/// `MXVerticalController` as a typealias of it), this alias points the other way. +public typealias EndpointController = MXVerticalController + +/// 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 served mouse, +/// and routes messages to them by device index. +@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 + public let tuning: ControllerTuning + + /// 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] } + } + + /// 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)? + public var onEndpointChange: ((DeviceAddress, EndpointController.State) -> Void)? + public var onEndpointRemoved: ((DeviceAddress, RemovalReason) -> Void)? + public var onButtonEvent: ((ButtonEvent) -> Void)? + public var onPointerDelta: ((PointerDelta) -> Void)? + /// The pointer speed changed natively on that mouse (and only that one). + public var onDeviceDPIChange: ((DeviceAddress, Int?) -> Void)? + public var onLog: ((DeviceAddress?, String) -> Void)? + + private let transport: HIDPPTransport + + public init(transport: HIDPPTransport, kind: TransportKind, link: UInt32, tuning: ControllerTuning = .init()) { + self.transport = transport + self.kind = kind + self.link = link + self.tuning = tuning + } + + public func address(of index: UInt8) -> DeviceAddress { + DeviceAddress(link: link, index: index) + } + + public func start() {} + + /// Re-applies configuration (after the Mac wakes): one slot, or every endpoint. + public func refresh(index: UInt8? = nil) {} + + /// Hands every endpoint's buttons back to its mouse. Terminal. + public func shutdown() {} + + public func setConfiguration(_ c: EndpointConfiguration, at index: UInt8) {} + + public func cycleDPI(at index: UInt8, presets: [Int]) {} + + public func setDPI(_ dpi: Int?, at index: UInt8) {} + + /// Easy-Switch the mouse at `index` to a zero-based channel. + public func switchToHost(_ host: Int, at index: UInt8) {} + + /// Re-reads battery, pointer speed and Easy-Switch channel: one slot, or every endpoint. + public func refreshDeviceInfo(at index: UInt8?) {} + + /// Reconnects one endpoint, or every endpoint on this link. + public func reconnect(index: UInt8?) {} + + /// Returns once no endpoint has queued work (tests). + public func waitUntilIdle() async {} +} diff --git a/UprightKit/Sources/UprightKit/Device/InterfaceClassifier.swift b/UprightKit/Sources/UprightKit/Device/InterfaceClassifier.swift new file mode 100644 index 0000000..fc619c2 --- /dev/null +++ b/UprightKit/Sources/UprightKit/Device/InterfaceClassifier.swift @@ -0,0 +1,107 @@ +import Foundation + +// Wave 0 scaffold: final signatures, today's acceptance rules. W1.2 owns this file and +// implements D9 (generic mouse ranges, keyboard exclusion, Lightspeed and 27 MHz ignored). + +/// 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", ... + 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 + } +} + +/// 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 + } + } +} + +public enum InterfaceClassifier { + 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") } + if let receiver = DeviceCatalog.receivers[pid] { + guard let layout = receiver.layout else { return .ignored("receiver not supported") } + return .receiver(receiver.kind, layout) + } + if let model = DeviceCatalog.model(forPID: pid) { + if model.usbPIDs.contains(pid) { return .direct(.usb) } + if model.bluetoothPIDs.contains(pid) { return .direct(.bluetooth) } + } + return .ignored("not a supported mouse") + } + + /// Identity used to dedupe twin interfaces at match time: + /// vendor:product:page:usage plus the serial, else the location ID, else the registry ID. + public static func identityKey(_ d: HIDInterfaceDescriptor) -> String { + let base = String(format: "%04X:%04X:%04X:%04X", d.vendorID, d.productID, d.usagePage, d.usage) + if !d.serial.isEmpty { return base + ":s:" + d.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). + 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 + } + + static func isSamePhysicalDevice(_ a: HIDInterfaceDescriptor, _ b: HIDInterfaceDescriptor) -> Bool { + a.locationID != 0 && a.vendorID == b.vendorID && a.productID == b.productID && a.locationID == b.locationID + } +} diff --git a/UprightKit/Sources/UprightKit/Device/MXVertical.swift b/UprightKit/Sources/UprightKit/Device/MXVertical.swift index b1b8f6f..2fbbbd4 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" } } } @@ -74,57 +76,7 @@ public enum MXVertical { 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] - +extension KnownControl { /// 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..fa80f9d --- /dev/null +++ b/UprightKit/Sources/UprightKit/Device/ReceiverDirectory.swift @@ -0,0 +1,76 @@ +import Foundation + +// Wave 0 scaffold: final signatures. W1.3 owns this file (parsing tests, Bolt details). + +/// Where a receiver lists its pairings (HID++ 1.0 long register 0xB5). +public enum PairingLayout: Hashable, Sendable { + /// Sub-register 0x20 + (slot - 1): [sub, destination ID, interval, wpid hi, wpid lo, ?, ?, kind]. + /// Nano receivers use it too. + case unifying + /// Sub-register 0x50 + slot: [sub, kind, wpid lo, wpid hi, ...]. + case bolt + + public func pairingSubRegister(slot: UInt8) -> UInt8 { + switch self { + case .unifying: return 0x20 + 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 + } +} + +/// Value-level parsing of receiver registers and notifications, used by `HIDPPLink`. +public enum ReceiverDirectory { + /// 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 (1...6).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) + } + + /// A 0x41 device-connection notification: params[0] bit 6 set = link lost, low nibble = kind; + /// params[1...2] = wireless PID (little-endian). + public static func parseConnection(_ m: HIDPPMessage) -> (slot: UInt8, wirelessPID: UInt16, kind: DeviceKind, linkUp: Bool)? { + guard m.subID == 0x41, (1...6).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, (1...6).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 + } +} diff --git a/UprightKit/Sources/UprightKit/Mapping/MappingLibrary.swift b/UprightKit/Sources/UprightKit/Mapping/MappingLibrary.swift new file mode 100644 index 0000000..040a0d2 --- /dev/null +++ b/UprightKit/Sources/UprightKit/Mapping/MappingLibrary.swift @@ -0,0 +1,201 @@ +import Foundation + +// Wave 0 scaffold: final signatures and a first implementation of the §E format. W1.5 owns +// this file (MappingStore integration, export mirror, import, tests). + +/// 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. +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. + 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] ?? DeviceCatalog.model(model)?.name ?? model.rawValue + } + + // MARK: Coding + + private static let knownKeys: Set = ["schemaVersion", "models", "devices", "global", "apps"] + + /// One model's section: `MappingProfiles` without `schemaVersion`, plus `name`. + private struct Section: Codable { + var name: String? + var profiles: MappingProfiles + + init(name: String?, profiles: MappingProfiles) { + self.name = name + self.profiles = profiles + } + + private enum CodingKeys: String, CodingKey { case name, global, apps } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + name = (try? container.decodeIfPresent(String.self, forKey: .name)).flatMap { $0 } + profiles = try MappingProfiles(from: decoder) + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encodeIfPresent(name, forKey: .name) + try container.encode(profiles.global, forKey: .global) + try container.encode(profiles.apps, forKey: .apps) + } + } + + /// 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")) { + 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) + models[model] = section.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 { + 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")) + for (model, profiles) in models { + try table.encode(Section(name: name(of: model), profiles: profiles), forKey: AnyCodingKey(model.rawValue)) + } + try container.encode(reservedDevices, forKey: AnyCodingKey("devices")) + for (key, value) in unknownTopLevel where !Self.knownKeys.contains(key) { + try container.encode(value, forKey: AnyCodingKey(key)) + } + } +} + +/// 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/Simulation/DeviceProfiles.swift b/UprightKit/Sources/UprightKit/Simulation/DeviceProfiles.swift new file mode 100644 index 0000000..11b67c2 --- /dev/null +++ b/UprightKit/Sources/UprightKit/Simulation/DeviceProfiles.swift @@ -0,0 +1,41 @@ +import Foundation + +// Wave 0 scaffold: the MX Vertical only. W1.6 adds the other profiles (MX Master 3S, Lift, +// MX Keys, an HID++ 1.0 device, ...) from devices.verified.json, noting each source. + +/// Ready-made simulated devices, for tests and Demo Mode. +public enum DeviceProfiles { + /// PIDs and features from devices.verified.json (Solaar dump: btleid B020, wpid 407B, + /// usbid C08A). The DPI list mirrors the test simulator; unit ID and serial are fake. + public static let mxVertical = SimulatedDeviceProfile( + name: "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(0x0005), .init(0x1000), .init(0x1B04, version: 4), .init(0x1D4B), + .init(0x0003, version: 4), .init(0x2201), .init(0x1814), .init(0x1815), + ], + // Control table from the published dump in devices.verified.json (flags; additional + // flags bit 0 = raw XY). Task IDs aren't published, so they are 0. + controls: [ + ControlInfo(cid: 0x0050, taskID: 0, flags: [.mouseButton, .reprogrammable]), + ControlInfo(cid: 0x0051, taskID: 0, flags: [.mouseButton, .reprogrammable]), + ControlInfo(cid: 0x0052, taskID: 0, flags: [.mouseButton, .reprogrammable, .divertable, .persistentlyDivertable], additionalFlags: 0x01), + ControlInfo(cid: 0x0053, taskID: 0, flags: [.mouseButton, .reprogrammable, .divertable, .persistentlyDivertable], additionalFlags: 0x01), + ControlInfo(cid: 0x0056, taskID: 0, flags: [.mouseButton, .reprogrammable, .divertable, .persistentlyDivertable], additionalFlags: 0x01), + ControlInfo(cid: 0x00FD, taskID: 0, flags: [.mouseButton, .reprogrammable, .divertable, .persistentlyDivertable], additionalFlags: 0x01), + ControlInfo(cid: 0x00D7, taskID: 0, flags: [.divertable, .virtual], additionalFlags: 0x01), + ], + dpiList: [400, UInt16(0xE000) | UInt16(50), 4000], + defaultDPI: 1000, + battery: .status, + hostCount: 3 + ) + + public static let all: [SimulatedDeviceProfile] = [mxVertical] +} diff --git a/UprightKit/Sources/UprightKit/Simulation/SimulatedDeviceProfile.swift b/UprightKit/Sources/UprightKit/Simulation/SimulatedDeviceProfile.swift new file mode 100644 index 0000000..0d7b317 --- /dev/null +++ b/UprightKit/Sources/UprightKit/Simulation/SimulatedDeviceProfile.swift @@ -0,0 +1,91 @@ +import Foundation + +// Wave 0 scaffold. W1.6 owns `Simulation/` and may reshape these types freely: nothing outside +// the simulator depends on them yet. Data must come from devices.verified.json (high/medium +// confidence), and unit IDs and serials must be obviously fake. + +/// Describes one simulated HID++ device: what it reports and how it answers. +public struct SimulatedDeviceProfile: Hashable, Sendable { + public struct Feature: Hashable, Sendable { + public var id: UInt16 + public var version: UInt8 + + public init(_ id: UInt16, version: UInt8 = 0) { + self.id = id + self.version = version + } + } + + /// 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 + } + + public var name: 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. + public var unitID: UInt32? + public var modelIDs: ModelIDs + public var serialNumber: String? + /// In feature-table order; index 0 is always ROOT (0x0000). + public var features: [Feature] + public var controls: [ControlInfo] + /// getSensorDpiList values as the device encodes them (0xE000 | step marks a range step). + public var dpiList: [UInt16] + public var defaultDPI: Int? + public var battery: Battery + public var hostCount: Int + + public init( + name: String, + kind: DeviceKind = .mouse, + protocolMajor: UInt8 = 4, + protocolMinor: UInt8 = 5, + wirelessPID: UInt16? = nil, + bluetoothPID: UInt16? = nil, + usbPID: UInt16? = nil, + unitID: UInt32? = nil, + modelIDs: ModelIDs = ModelIDs(), + serialNumber: String? = nil, + features: [Feature] = [], + controls: [ControlInfo] = [], + dpiList: [UInt16] = [], + defaultDPI: Int? = nil, + battery: Battery = .none, + hostCount: Int = 0 + ) { + self.name = 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.serialNumber = serialNumber + self.features = features + self.controls = controls + self.dpiList = dpiList + self.defaultDPI = defaultDPI + self.battery = battery + self.hostCount = hostCount + } + + /// 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) + } +} diff --git a/UprightKit/Sources/UprightKit/Simulation/SimulatedReceiver.swift b/UprightKit/Sources/UprightKit/Simulation/SimulatedReceiver.swift new file mode 100644 index 0000000..3ca7bed --- /dev/null +++ b/UprightKit/Sources/UprightKit/Simulation/SimulatedReceiver.swift @@ -0,0 +1,89 @@ +import Foundation + +// Wave 0 scaffold: inert transports with the planned per-slot API. W1.6 owns `Simulation/` +// and implements them (HID++ answers, 0x40/0x41, register 0x00/0x02/0xB5, faults, counters), +// and may reshape the API. Until then they answer nothing, so requests time out. + +/// A software Unifying, Nano or Bolt receiver with simulated devices in slots 1...6. +public final class SimulatedReceiver: HIDPPTransport { + public var onReport: (([UInt8]) -> Void)? + public var onClose: (() -> Void)? + + public let productID: UInt16 + public let layout: PairingLayout + /// Paired devices by slot. + public private(set) var slots: [UInt8: SimulatedDeviceProfile] + + public init(productID: UInt16 = 0xC52B, layout: PairingLayout = .unifying, slots: [UInt8: SimulatedDeviceProfile] = [:]) { + self.productID = productID + self.layout = layout + self.slots = slots + } + + public func send(_ bytes: [UInt8]) throws {} + + // MARK: Pairing + + public func pair(_ profile: SimulatedDeviceProfile, slot: UInt8) { + slots[slot] = profile + } + + public func unpair(slot: UInt8) { + slots[slot] = nil + } + + /// Moves a device to another slot, keeping its unit ID (a re-pair). + public func repair(from: UInt8, to: UInt8) { + guard let profile = slots.removeValue(forKey: from) else { return } + slots[to] = profile + } + + // MARK: Per-slot behavior + + public func hold(_ cids: [UInt16], slot: UInt8) {} + public func release(slot: UInt8) {} + public func move(dx: Int, dy: Int, slot: UInt8) {} + public func goToSleep(slot: UInt8) {} + public func wakeUp(slot: UInt8) {} + public func setDPINatively(_ dpi: Int, slot: UInt8) {} + + // MARK: Per-slot state + + public func diverted(slot: UInt8) -> Set { [] } + public func rawXY(slot: UInt8) -> Set { [] } + public func dpi(slot: UInt8) -> Int? { nil } + + // MARK: Faults and counters + + public func setDropsRequests(_ drops: Bool, slot: UInt8) {} + public func setLatency(_ latency: TimeInterval, slot: UInt8) {} + public func setReportingCount(slot: UInt8) -> Int { 0 } + public func pingCount(slot: UInt8) -> Int { 0 } + /// The most requests that were ever unanswered at once (the request gate keeps it at 1). + public var requestsInFlightMax: Int { 0 } + + /// The receiver is unplugged. + public func disconnect() { + onClose?() + } +} + +/// One simulated device connected directly (USB cable or Bluetooth), at device index 0xFF. +public final class SimulatedDirect: HIDPPTransport { + public var onReport: (([UInt8]) -> Void)? + public var onClose: (() -> Void)? + + public let profile: SimulatedDeviceProfile + public let connection: ConnectionKind + + public init(profile: SimulatedDeviceProfile, connection: ConnectionKind = .usb) { + self.profile = profile + self.connection = connection + } + + public func send(_ bytes: [UInt8]) throws {} + + public func disconnect() { + onClose?() + } +} From 9b0c5926a673d2648bff17b17237648297ee3c86 Mon Sep 17 00:00:00 2001 From: Daghan Percinel <4314475+dpdev69@users.noreply.github.com> Date: Tue, 13 Oct 2026 23:23:14 +0300 Subject: [PATCH 02/14] Multi-mouse: wave 1 Co-Authored-By: Claude Haiku 5.5 --- .../UprightKit/Device/ControlCatalog.swift | 205 +++- .../UprightKit/Device/DeviceCatalog.swift | 249 ++++- .../UprightKit/Device/DevicePreferences.swift | 154 ++- .../Device/InterfaceClassifier.swift | 203 +++- .../UprightKit/Device/ReceiverDirectory.swift | 143 ++- .../UprightKit/HIDPP/DeviceFeatures.swift | 63 ++ .../UprightKit/HIDPP/HIDPPDevice.swift | 57 +- .../Sources/UprightKit/HIDPP/HIDPPError.swift | 13 + .../UprightKit/HIDPP/HIDPPFeature.swift | 93 +- .../UprightKit/Mapping/ButtonMappings.swift | 18 + .../UprightKit/Mapping/MappingLibrary.swift | 100 +- .../UprightKit/Mapping/MappingProfiles.swift | 11 +- .../UprightKit/Mapping/MappingStore.swift | 468 +++++++-- .../UprightKit/Mapping/RemapEngine.swift | 208 +++- .../UprightKit/Platform/IOKitHID.swift | 172 ++-- .../UprightKit/Scrolling/ScrollSettings.swift | 27 +- .../Simulation/DeviceProfiles.swift | 472 ++++++++- .../Simulation/SimulatedDeviceProfile.swift | 197 +++- .../Simulation/SimulatedReceiver.swift | 906 +++++++++++++++++- .../Tests/UprightKitTests/CatalogTests.swift | 375 ++++++++ .../DevicePreferencesTests.swift | 386 ++++++++ .../UprightKitTests/HIDPPIdentityTests.swift | 383 ++++++++ .../UprightKitTests/HIDPPMessageTests.swift | 36 +- .../InterfaceClassifierTests.swift | 310 ++++++ .../UprightKitTests/MappingLibraryTests.swift | 700 ++++++++++++++ .../MappingPersistenceTests.swift | 4 +- .../ReceiverDirectoryTests.swift | 305 ++++++ .../RemapEngineMultiSourceTests.swift | 371 +++++++ .../ScrollTransformTests.swift | 36 +- .../UprightKitTests/SimulatedMXVertical.swift | 417 ++------ .../UprightKitTests/SimulatorTests.swift | 358 +++++++ .../Tests/UprightKitTests/TestSupport.swift | 104 ++ 32 files changed, 6861 insertions(+), 683 deletions(-) create mode 100644 UprightKit/Tests/UprightKitTests/CatalogTests.swift create mode 100644 UprightKit/Tests/UprightKitTests/DevicePreferencesTests.swift create mode 100644 UprightKit/Tests/UprightKitTests/HIDPPIdentityTests.swift create mode 100644 UprightKit/Tests/UprightKitTests/InterfaceClassifierTests.swift create mode 100644 UprightKit/Tests/UprightKitTests/MappingLibraryTests.swift create mode 100644 UprightKit/Tests/UprightKitTests/ReceiverDirectoryTests.swift create mode 100644 UprightKit/Tests/UprightKitTests/RemapEngineMultiSourceTests.swift create mode 100644 UprightKit/Tests/UprightKitTests/SimulatorTests.swift create mode 100644 UprightKit/Tests/UprightKitTests/TestSupport.swift diff --git a/UprightKit/Sources/UprightKit/Device/ControlCatalog.swift b/UprightKit/Sources/UprightKit/Device/ControlCatalog.swift index 7277189..29fee96 100644 --- a/UprightKit/Sources/UprightKit/Device/ControlCatalog.swift +++ b/UprightKit/Sources/UprightKit/Device/ControlCatalog.swift @@ -1,7 +1,5 @@ import Foundation -// Wave 0 scaffold: final signatures. W1.1 owns this file and completes the name table. - /// 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 { @@ -57,23 +55,218 @@ public enum ControlCatalog { /// 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] - /// Model-independent names, from Solaar's special_keys table. W1.1 completes this table. + /// 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] = [ - KnownControl.middleClick: 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" ), - KnownControl.back: ControlText( + 0x0053: ControlText( name: NSLocalizedString("Back Button", comment: "Mouse control name"), detail: NSLocalizedString("Thumb button", comment: "Mouse control description"), symbolName: "chevron.backward.circle" ), - KnownControl.forward: ControlText( + 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" + ), ] } diff --git a/UprightKit/Sources/UprightKit/Device/DeviceCatalog.swift b/UprightKit/Sources/UprightKit/Device/DeviceCatalog.swift index 8d8e97b..1f425a8 100644 --- a/UprightKit/Sources/UprightKit/Device/DeviceCatalog.swift +++ b/UprightKit/Sources/UprightKit/Device/DeviceCatalog.swift @@ -1,7 +1,10 @@ import Foundation -// Wave 0 scaffold: final signatures, MX Vertical and the receivers Upright already used. -// W1.1 owns this file and fills in the models and receivers from devices.verified.json. +// 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 { @@ -52,27 +55,105 @@ public struct DeviceModel: Hashable, Sendable, Identifiable { self.verification = verification } - /// Every product ID the model is known by, on any transport. - public var allPIDs: [UInt16] { bluetoothPIDs + wirelessPIDs + usbPIDs } + /// 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?) { + 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 - public static let models: [DeviceModel] = [mxVertical] + /// 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] = [:] @@ -85,15 +166,60 @@ public enum DeviceCatalog { 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? { - models.first { $0.key == key } + modelsByKey[key] } + /// The model any of whose USB, Bluetooth or wireless product IDs is `pid`. public static func model(forPID pid: UInt16) -> DeviceModel? { - models.first { $0.allPIDs.contains(pid) } + 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, @@ -112,8 +238,71 @@ public enum DeviceCatalog { 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", @@ -131,6 +320,50 @@ public enum DeviceCatalog { 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 { diff --git a/UprightKit/Sources/UprightKit/Device/DevicePreferences.swift b/UprightKit/Sources/UprightKit/Device/DevicePreferences.swift index ac01d49..48485ce 100644 --- a/UprightKit/Sources/UprightKit/Device/DevicePreferences.swift +++ b/UprightKit/Sources/UprightKit/Device/DevicePreferences.swift @@ -1,9 +1,19 @@ import Foundation -// Wave 0 scaffold: final signatures and a first implementation. W1.7 owns this file -// (tolerant decoding, tests). +/// 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. @@ -36,48 +46,166 @@ public struct DevicePreferences: Codable, Hashable, Sendable { 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 (preferred speed) for units of a model seen for the first time. + /// 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 + self.devices = devices.filter { $0.key.isStable } } - /// The unit's own preferences, else its model's seed, else defaults. + // 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 models[model] ?? DevicePreferences() + 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. Transient IDs are ignored. - public mutating func record(_ id: DeviceID, snapshot: DeviceSnapshot) { + /// 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 } - var prefs = devices[id] ?? id.model.flatMap { models[$0] } ?? DevicePreferences() - prefs.lastModel = id.model ?? prefs.lastModel + 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 = Date() + 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. + /// 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) + 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 + + /// Reads the store saved in `defaults`; unreadable or missing data gives an empty store. + public static func load(from defaults: UserDefaults = .standard) -> DevicePreferencesStore { + guard let data = defaults.data(forKey: defaultsKey), + let store = try? JSONDecoder().decode(DevicePreferencesStore.self, from: data) + else { return DevicePreferencesStore() } + return store + } + + 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/InterfaceClassifier.swift b/UprightKit/Sources/UprightKit/Device/InterfaceClassifier.swift index fc619c2..847087f 100644 --- a/UprightKit/Sources/UprightKit/Device/InterfaceClassifier.swift +++ b/UprightKit/Sources/UprightKit/Device/InterfaceClassifier.swift @@ -1,13 +1,14 @@ import Foundation -// Wave 0 scaffold: final signatures, today's acceptance rules. W1.2 owns this file and -// implements D9 (generic mouse ranges, keyboard exclusion, Lightspeed and 27 MHz ignored). +// 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 @@ -43,6 +44,37 @@ public struct HIDInterfaceDescriptor: Hashable, Sendable { 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. @@ -61,28 +93,160 @@ public enum InterfaceClass: Hashable, Sendable { 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") } - if let receiver = DeviceCatalog.receivers[pid] { - guard let layout = receiver.layout else { return .ignored("receiver not supported") } - return .receiver(receiver.kind, layout) + + // 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") } - if let model = DeviceCatalog.model(forPID: pid) { - if model.usbPIDs.contains(pid) { return .direct(.usb) } - if model.bluetoothPIDs.contains(pid) { return .direct(.bluetooth) } + // 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 = String(format: "%04X:%04X:%04X:%04X", d.vendorID, d.productID, d.usagePage, d.usage) - if !d.serial.isEmpty { return base + ":s:" + d.serial } + 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) } @@ -91,6 +255,9 @@ public enum InterfaceClassifier { /// 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) } @@ -101,7 +268,19 @@ public enum InterfaceClassifier { return twins.isEmpty ? [] : nil } - static func isSamePhysicalDevice(_ a: HIDInterfaceDescriptor, _ b: HIDInterfaceDescriptor) -> Bool { - a.locationID != 0 && a.vendorID == b.vendorID && a.productID == b.productID && a.locationID == b.locationID + 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/ReceiverDirectory.swift b/UprightKit/Sources/UprightKit/Device/ReceiverDirectory.swift index fa80f9d..fd6e5aa 100644 --- a/UprightKit/Sources/UprightKit/Device/ReceiverDirectory.swift +++ b/UprightKit/Sources/UprightKit/Device/ReceiverDirectory.swift @@ -1,13 +1,13 @@ import Foundation -// Wave 0 scaffold: final signatures. W1.3 owns this file (parsing tests, Bolt details). - -/// Where a receiver lists its pairings (HID++ 1.0 long register 0xB5). +/// 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: [sub, kind, wpid lo, wpid hi, ...]. + /// Sub-register 0x50 + slot (not slot - 1): [sub, kind, wpid lo, wpid hi, serial (4), ...]. case bolt public func pairingSubRegister(slot: UInt8) -> UInt8 { @@ -16,6 +16,14 @@ public enum PairingLayout: Hashable, Sendable { 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. @@ -31,12 +39,25 @@ public struct PairingRecord: Hashable, Sendable { } } -/// Value-level parsing of receiver registers and notifications, used by `HIDPPLink`. +/// 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 (1...6).contains(slot), reply.count >= 8, reply[0] == layout.pairingSubRegister(slot: slot) else { return nil } + guard slots.contains(slot), reply.count >= 8, reply[0] == layout.pairingSubRegister(slot: slot) else { return nil } let pid: UInt16 let kind: DeviceKind switch layout { @@ -51,10 +72,23 @@ public enum ReceiverDirectory { 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). + /// 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, (1...6).contains(m.deviceIndex) else { return nil } + 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) @@ -62,7 +96,7 @@ public enum ReceiverDirectory { /// 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, (1...6).contains(m.deviceIndex), m.address & 0x02 != 0 else { return nil } + guard m.subID == 0x40, slots.contains(m.deviceIndex), m.address & 0x02 != 0 else { return nil } return m.deviceIndex } @@ -73,4 +107,95 @@ public enum ReceiverDirectory { 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/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..a359166 100644 --- a/UprightKit/Sources/UprightKit/HIDPP/HIDPPError.swift +++ b/UprightKit/Sources/UprightKit/HIDPP/HIDPPError.swift @@ -9,6 +9,10 @@ public enum HIDPPError: Error, Equatable, Sendable, CustomStringConvertible { case featureNotSupported(UInt16) case unsupportedProtocol(major: UInt8, minor: UInt8) case malformedResponse + /// 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?) + /// Superseded by `unsupportedDevice`; kept until the endpoint controller stops using it. case notMXVertical(String) public var description: String { @@ -29,11 +33,20 @@ 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 .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)." case .notMXVertical(let name): return "Found “\(name)”, which is not an MX Vertical." } } + /// 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/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 index 040a0d2..1edfd86 100644 --- a/UprightKit/Sources/UprightKit/Mapping/MappingLibrary.swift +++ b/UprightKit/Sources/UprightKit/Mapping/MappingLibrary.swift @@ -1,8 +1,5 @@ import Foundation -// Wave 0 scaffold: final signatures and a first implementation of the §E format. W1.5 owns -// this file (MappingStore integration, export mirror, import, tests). - /// 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. /// @@ -10,12 +7,15 @@ import Foundation /// "models": { "046D:B020": { "name": "MX Vertical", "global": {…}, "apps": […] } }, /// "devices": {} } /// -/// The `devices` section and unknown top-level keys are preserved but unused. +/// 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. + /// 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] @@ -59,36 +59,50 @@ public struct MappingLibrary: Codable, Hashable, Sendable { /// 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] ?? DeviceCatalog.model(model)?.name ?? model.rawValue + 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 - private static let knownKeys: Set = ["schemaVersion", "models", "devices", "global", "apps"] + /// 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 + } - /// One model's section: `MappingProfiles` without `schemaVersion`, plus `name`. - private struct Section: Codable { - var name: String? - var profiles: MappingProfiles + /// Encodes the export format (see `Format.export`). + struct ExportFile: Encodable { + let library: MappingLibrary - init(name: String?, profiles: MappingProfiles) { - self.name = name - self.profiles = profiles + func encode(to encoder: Encoder) throws { + try library.encode(to: encoder, format: .export) } + } - private enum CodingKeys: String, CodingKey { case name, global, apps } + private static let knownKeys: Set = ["schemaVersion", "models", "devices", "global", "apps"] - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - name = (try? container.decodeIfPresent(String.self, forKey: .name)).flatMap { $0 } - profiles = try MappingProfiles(from: decoder) - } + private enum SectionKeys: String, CodingKey { case name, global, apps } - func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try container.encodeIfPresent(name, forKey: .name) - try container.encode(profiles.global, forKey: .global) - try container.encode(profiles.apps, forKey: .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 } } @@ -106,6 +120,7 @@ public struct MappingLibrary: Codable, Hashable, Sendable { 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)”") @@ -113,7 +128,10 @@ public struct MappingLibrary: Codable, Hashable, Sendable { } do { let section = try table.decode(Section.self, forKey: key) - models[model] = section.profiles + 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))") @@ -146,15 +164,35 @@ public struct MappingLibrary: Codable, Hashable, Sendable { } 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")) - for (model, profiles) in models { - try table.encode(Section(name: name(of: model), profiles: profiles), forKey: AnyCodingKey(model.rawValue)) + 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) + } } - try container.encode(reservedDevices, forKey: AnyCodingKey("devices")) - for (key, value) in unknownTopLevel where !Self.knownKeys.contains(key) { - try container.encode(value, forKey: AnyCodingKey(key)) + + 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")) + } } } } 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..6bc4284 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() { @@ -116,25 +150,18 @@ public final class HIDDeviceMonitor { public func start() -> IOReturn { 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() @@ -160,10 +187,17 @@ public final class HIDDeviceMonitor { 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 +223,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 +239,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) } 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 index 11b67c2..297d155 100644 --- a/UprightKit/Sources/UprightKit/Simulation/DeviceProfiles.swift +++ b/UprightKit/Sources/UprightKit/Simulation/DeviceProfiles.swift @@ -1,14 +1,29 @@ import Foundation -// Wave 0 scaffold: the MX Vertical only. W1.6 adds the other profiles (MX Master 3S, Lift, -// MX Keys, an HID++ 1.0 device, ...) from devices.verified.json, noting each source. - /// 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 { - /// PIDs and features from devices.verified.json (Solaar dump: btleid B020, wpid 407B, - /// usbid C08A). The DPI list mirrors the test simulator; unit ID and serial are fake. + // 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", + name: "MX Vertical Advanced Ergonomic Mouse", + codename: "MX Vertical", kind: .mouse, wirelessPID: 0x407B, bluetoothPID: 0xB020, @@ -17,25 +32,442 @@ public enum DeviceProfiles { modelIDs: ModelIDs(bluetoothLE: 0xB020, wireless: 0x407B, usb: 0xC08A), serialNumber: "DEMO-0001", features: [ - .init(0x0000), .init(0x0001), .init(0x0005), .init(0x1000), .init(0x1B04, version: 4), .init(0x1D4B), - .init(0x0003, version: 4), .init(0x2201), .init(0x1814), .init(0x1815), + .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), ], - // Control table from the published dump in devices.verified.json (flags; additional - // flags bit 0 = raw XY). Task IDs aren't published, so they are 0. controls: [ - ControlInfo(cid: 0x0050, taskID: 0, flags: [.mouseButton, .reprogrammable]), - ControlInfo(cid: 0x0051, taskID: 0, flags: [.mouseButton, .reprogrammable]), - ControlInfo(cid: 0x0052, taskID: 0, flags: [.mouseButton, .reprogrammable, .divertable, .persistentlyDivertable], additionalFlags: 0x01), - ControlInfo(cid: 0x0053, taskID: 0, flags: [.mouseButton, .reprogrammable, .divertable, .persistentlyDivertable], additionalFlags: 0x01), - ControlInfo(cid: 0x0056, taskID: 0, flags: [.mouseButton, .reprogrammable, .divertable, .persistentlyDivertable], additionalFlags: 0x01), - ControlInfo(cid: 0x00FD, taskID: 0, flags: [.mouseButton, .reprogrammable, .divertable, .persistentlyDivertable], additionalFlags: 0x01), - ControlInfo(cid: 0x00D7, taskID: 0, flags: [.divertable, .virtual], additionalFlags: 0x01), + 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), ], - dpiList: [400, UInt16(0xE000) | UInt16(50), 4000], + firmware: [.bootloader("BOT", 0x95, 0x01, build: 0x0015), .firmware("MPM", 0x19, 0x01, build: 0x0015), .other], + dpiList: [200, step(50), 4000], defaultDPI: 1000, battery: .status, - hostCount: 3 + 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 ) - public static let all: [SimulatedDeviceProfile] = [mxVertical] + /// 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 index 0d7b317..cde4562 100644 --- a/UprightKit/Sources/UprightKit/Simulation/SimulatedDeviceProfile.swift +++ b/UprightKit/Sources/UprightKit/Simulation/SimulatedDeviceProfile.swift @@ -1,18 +1,24 @@ import Foundation -// Wave 0 scaffold. W1.6 owns `Simulation/` and may reshape these types freely: nothing outside -// the simulator depends on them yet. Data must come from devices.verified.json (high/medium -// confidence), and unit IDs and serials must be obviously fake. - -/// Describes one simulated HID++ device: what it reports and how it answers. +/// 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) { + public init(_ id: UInt16, version: UInt8 = 0, flags: UInt8 = 0) { self.id = id self.version = version + self.flags = flags } } @@ -25,9 +31,72 @@ public struct SimulatedDeviceProfile: Hashable, Sendable { /// 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 @@ -35,21 +104,32 @@ public struct SimulatedDeviceProfile: Hashable, Sendable { public var wirelessPID: UInt16? public var bluetoothPID: UInt16? public var usbPID: UInt16? - /// 0x0003 function 0. + /// 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, @@ -58,15 +138,20 @@ public struct SimulatedDeviceProfile: Hashable, Sendable { 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, - hostCount: Int = 0 + batteryLevel: Int = 80, + hostCount: Int = 0, + hostNames: [String?] = [] ) { self.name = name + self.codename = codename ?? name self.kind = kind self.protocolMajor = protocolMajor self.protocolMinor = protocolMinor @@ -75,17 +160,113 @@ public struct SimulatedDeviceProfile: Hashable, Sendable { 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 index 3ca7bed..58ea319 100644 --- a/UprightKit/Sources/UprightKit/Simulation/SimulatedReceiver.swift +++ b/UprightKit/Sources/UprightKit/Simulation/SimulatedReceiver.swift @@ -1,77 +1,868 @@ import Foundation -// Wave 0 scaffold: inert transports with the planned per-slot API. W1.6 owns `Simulation/` -// and implements them (HID++ answers, 0x40/0x41, register 0x00/0x02/0xB5, faults, counters), -// and may reshape the API. Until then they answer nothing, so requests time out. - -/// A software Unifying, Nano or Bolt receiver with simulated devices in slots 1...6. -public final class SimulatedReceiver: HIDPPTransport { +/// 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 - /// Paired devices by slot. - public private(set) var slots: [UInt8: SimulatedDeviceProfile] + 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 - self.slots = slots + 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)) } } } - public func send(_ bytes: [UInt8]) throws {} + /// 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 - public func pair(_ profile: SimulatedDeviceProfile, slot: UInt8) { - slots[slot] = profile + /// 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) { - slots[slot] = nil + 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) } } - /// Moves a device to another slot, keeping its unit ID (a re-pair). - public func repair(from: UInt8, to: UInt8) { - guard let profile = slots.removeValue(forKey: from) else { return } - slots[to] = profile + /// 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) } } } - // MARK: Per-slot behavior + /// 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 + } - public func hold(_ cids: [UInt16], slot: UInt8) {} - public func release(slot: UInt8) {} - public func move(dx: Int, dy: Int, slot: UInt8) {} - public func goToSleep(slot: UInt8) {} - public func wakeUp(slot: UInt8) {} - public func setDPINatively(_ dpi: Int, slot: UInt8) {} + /// 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)]) + } - // MARK: Per-slot state + private static func unpairNotification(slot: UInt8) -> HIDPPMessage { + HIDPPMessage(reportID: .short, deviceIndex: slot, subID: 0x40, address: 0x02, parameters: []) + } - public func diverted(slot: UInt8) -> Set { [] } - public func rawXY(slot: UInt8) -> Set { [] } - public func dpi(slot: UInt8) -> Int? { nil } + /// A made-up receiver serial number. + private var serialBytes: [UInt8] { [0xDE, 0x30, UInt8(productID >> 8), UInt8(productID & 0xFF)] } - // MARK: Faults and counters + 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)) } - public func setDropsRequests(_ drops: Bool, slot: UInt8) {} - public func setLatency(_ latency: TimeInterval, slot: UInt8) {} - public func setReportingCount(slot: UInt8) -> Int { 0 } - public func pingCount(slot: UInt8) -> Int { 0 } - /// The most requests that were ever unanswered at once (the request gate keeps it at 1). - public var requestsInFlightMax: Int { 0 } + 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) + } + } - /// The receiver is unplugged. - public func disconnect() { - onClose?() + /// 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. -public final class SimulatedDirect: HIDPPTransport { - public var onReport: (([UInt8]) -> Void)? - public var onClose: (() -> Void)? +/// 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 @@ -79,11 +870,38 @@ public final class SimulatedDirect: HIDPPTransport { public init(profile: SimulatedDeviceProfile, connection: ConnectionKind = .usb) { self.profile = profile self.connection = connection + super.init(units: [Self.index: profile], transportKind: .direct(connection)) } - public func send(_ bytes: [UInt8]) throws {} + /// 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 func disconnect() { - onClose?() + 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/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/DevicePreferencesTests.swift b/UprightKit/Tests/UprightKitTests/DevicePreferencesTests.swift new file mode 100644 index 0000000..06ed65c --- /dev/null +++ b/UprightKit/Tests/UprightKitTests/DevicePreferencesTests.swift @@ -0,0 +1,386 @@ +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()) + } +} 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/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() + } +} From 54914132ef5b73789d3cc929f285d718cc30f811 Mon Sep 17 00:00:00 2001 From: Daghan Percinel <4314475+dpdev69@users.noreply.github.com> Date: Tue, 13 Oct 2026 23:44:52 +0300 Subject: [PATCH 03/14] Multi-mouse: wave 2 (session capability gate, registry, planner) Gate: 352 tests green. Co-Authored-By: Claude Opus 5.5 --- .../Device/ConfigurationPlanner.swift | 60 ++- .../UprightKit/Device/DeviceRegistry.swift | 299 +++++++++-- .../UprightKit/Device/DeviceSession.swift | 201 +++++-- .../UprightKit/HIDPP/ReprogControls.swift | 9 +- .../ConfigurationPlannerTests.swift | 480 +++++++++++++++++ .../UprightKitTests/DeviceFeatureTests.swift | 478 +++++++++++++++++ .../UprightKitTests/DeviceRegistryTests.swift | 493 ++++++++++++++++++ 7 files changed, 1911 insertions(+), 109 deletions(-) create mode 100644 UprightKit/Tests/UprightKitTests/ConfigurationPlannerTests.swift create mode 100644 UprightKit/Tests/UprightKitTests/DeviceRegistryTests.swift diff --git a/UprightKit/Sources/UprightKit/Device/ConfigurationPlanner.swift b/UprightKit/Sources/UprightKit/Device/ConfigurationPlanner.swift index 84cbf54..99de246 100644 --- a/UprightKit/Sources/UprightKit/Device/ConfigurationPlanner.swift +++ b/UprightKit/Sources/UprightKit/Device/ConfigurationPlanner.swift @@ -1,8 +1,5 @@ import Foundation -// Wave 0 scaffold: final signatures and a first implementation. W2.2 owns this file and -// proves it golden-equal to today's AppModel.syncDiversion for an MX Vertical. - /// Everything besides the mappings that decides one endpoint's configuration. public struct ConfigurationInputs: Hashable, Sendable { /// The app-wide "Remap buttons" switch. @@ -10,8 +7,10 @@ public struct ConfigurationInputs: Hashable, Sendable { 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`). + /// 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 @@ -37,35 +36,40 @@ public struct ConfigurationInputs: Hashable, Sendable { } } +/// 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. - /// - Inactive endpoint or "Remap this mouse" off: only held controls stay diverted (so their - /// release still arrives), held gestures keep raw movement. - /// - Otherwise: the mapped controls plus held ones; gesture controls also report raw XY - /// when the control can. + /// - 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. /// - /// Diversion is limited to the device's configurable controls (never the primary clicks). - /// `dpi` snaps the preferred speed to what the device supports; without it the value is - /// passed through. + /// 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? = nil, + dpi dpiInfo: DPIInfo? = nil, inputs: ConfigurationInputs ) -> EndpointConfiguration { - let speed = inputs.preferredDPI.map { dpi?.nearest(to: $0) ?? $0 } + 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) } - let divert: Set - let gestures: Set - if inputs.isActive && inputs.deviceRemapEnabled { - divert = mappings.divertedControls(accessibilityGranted: inputs.accessibilityGranted).union(inputs.held) - gestures = mappings.gestureControls(accessibilityGranted: inputs.accessibilityGranted).union(inputs.gesturesHeld) - } else { - divert = inputs.held - gestures = inputs.gesturesHeld + 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) } @@ -77,4 +81,16 @@ public enum ConfigurationPlanner { 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/DeviceRegistry.swift b/UprightKit/Sources/UprightKit/Device/DeviceRegistry.swift index ea9d632..89c7ee9 100644 --- a/UprightKit/Sources/UprightKit/Device/DeviceRegistry.swift +++ b/UprightKit/Sources/UprightKit/Device/DeviceRegistry.swift @@ -1,20 +1,33 @@ import Foundation -// Wave 0 scaffold: final signatures and a minimal single-device implementation (no same-unit -// dedupe, no standby). W2.2 owns this file and implements §B7 / §C step 6. - -/// Every endpoint Upright knows about, across all links, with its role. +/// 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. + /// First-seen order, stable for the process (per unit once identified). public var ordinal: Int public var id: DeviceAddress { address } @@ -64,45 +77,76 @@ public final class DeviceRegistry { case pending } - /// Nicknames, by stable device ID. + /// 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] = [] - private var nextOrdinal = 0 + /// 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) { - var entry = record(a) ?? Record( - address: a, - connection: connection, - state: state, - displayName: "", - role: .pending, - ordinal: takeOrdinal() - ) - entry.connection = connection - entry.state = state - let snapshot = entry.snapshot - if let snapshot { entry.displayName = snapshot.name } - if let id = entry.deviceID, let nickname = nameProvider?(id) { entry.displayName = nickname } - entry.role = snapshot == nil ? .pending : .active - if let index = records.firstIndex(where: { $0.address == a }) { - records[index] = entry - } else { - records.append(entry) + 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 } - sort() - onChange?() } public func remove(_ a: DeviceAddress) { guard records.contains(where: { $0.address == a }) else { return } - records.removeAll { $0.address == a } - onChange?() + commit { forget([a]) } } /// Removes every endpoint of a closed link and returns their addresses. @@ -110,15 +154,22 @@ public final class DeviceRegistry { public func removeLink(_ link: UInt32) -> [DeviceAddress] { let removed = records.filter { $0.address.link == link }.map(\.address) guard !removed.isEmpty else { return [] } - records.removeAll { $0.address.link == link } - onChange?() + 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) } @@ -128,13 +179,31 @@ public final class DeviceRegistry { 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 { + 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) @@ -151,7 +220,7 @@ public final class DeviceRegistry { } /// usb 0 < receivers 1 < bluetooth 2: the better link wins same-unit dedupe. - public static func linkRank(_ c: ConnectionKind) -> Int { + public nonisolated static func linkRank(_ c: ConnectionKind) -> Int { switch c { case .usb: return 0 case .unifying, .bolt, .nano: return 1 @@ -159,27 +228,173 @@ public final class DeviceRegistry { } } - private func takeOrdinal() -> Int { - defer { nextOrdinal += 1 } - return nextOrdinal + // 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 } - private func sort() { + // 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 ? 1 : 0 - let r = rhs.role == .unsupported ? 1 : 0 - return l == r ? lhs.ordinal < rhs.ordinal : l < r + 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 let payload = unsupportedPayload(of: state) { return (payload.partial, payload.reason) } + return (nil, nil) + } + + /// The reason and partial snapshot of a terminal `.unsupported(UnsupportedReason, + /// DeviceSnapshot?)` state (§B4). + /// + /// That case arrives with the hub (W3.1). Until then the registry recognizes it by its name + /// and payload types, so it handles the verdict the moment the case exists; afterwards this + /// is equivalent to `if case .unsupported(let reason, let partial) = state`, which should + /// replace it. + nonisolated static func unsupportedPayload(of state: Any) -> (reason: UnsupportedReason, partial: DeviceSnapshot?)? { + let mirror = Mirror(reflecting: state) + guard mirror.displayStyle == .enum, let payload = mirror.children.first, payload.label == "unsupported" else { + return nil } + if let reason = payload.value as? UnsupportedReason { return (reason, nil) } + let values = Mirror(reflecting: payload.value).children.map(\.value) + guard let reason = values.first as? UnsupportedReason else { return nil } + return (reason, values.dropFirst().first.flatMap { $0 as? DeviceSnapshot }) } } -/// The fleet at a glance, for the menu-bar label, scroll gating and alerts. +/// 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 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] = [] 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/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/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/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/DeviceRegistryTests.swift b/UprightKit/Tests/UprightKitTests/DeviceRegistryTests.swift new file mode 100644 index 0000000..d159497 --- /dev/null +++ b/UprightKit/Tests/UprightKitTests/DeviceRegistryTests.swift @@ -0,0 +1,493 @@ +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 + ) +} + +/// A same-shaped stand-in for `EndpointController.State` once W3.1 adds the unsupported case. +private enum PlannedState { + case probing + case connected(DeviceSnapshot) + case failed(String) + case unsupported(UnsupportedReason, DeviceSnapshot?) +} + +private enum LabeledState { + case unsupported(reason: UnsupportedReason, partial: DeviceSnapshot?) +} + +private enum ReasonOnlyState { + case unsupported(UnsupportedReason) +} + +@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 testUnsupportedStateIsRecognizedByShape() async { + let partial = identified(DeviceProfiles.mxKeys, connection: .bolt, at: receiver) + let withPartial = DeviceRegistry.unsupportedPayload(of: PlannedState.unsupported(.notAPointer(.keyboard), partial)) + XCTAssertEqual(withPartial?.reason, .notAPointer(.keyboard)) + XCTAssertEqual(withPartial?.partial, partial) + + let bare = DeviceRegistry.unsupportedPayload(of: PlannedState.unsupported(.hidpp10(major: 1, minor: 0), nil)) + XCTAssertEqual(bare?.reason, .hidpp10(major: 1, minor: 0)) + XCTAssertNil(bare?.partial ?? nil) + + XCTAssertEqual(DeviceRegistry.unsupportedPayload(of: LabeledState.unsupported(reason: .noReprogControls, partial: partial))?.partial, partial) + XCTAssertEqual(DeviceRegistry.unsupportedPayload(of: ReasonOnlyState.unsupported(.noConfigurableControls))?.reason, .noConfigurableControls) + + XCTAssertNil(DeviceRegistry.unsupportedPayload(of: PlannedState.probing)) + XCTAssertNil(DeviceRegistry.unsupportedPayload(of: PlannedState.connected(partial))) + XCTAssertNil(DeviceRegistry.unsupportedPayload(of: PlannedState.failed("unsupported"))) + let today: [EndpointController.State] = [.probing, .waitingForDevice, .connected(partial), .asleep(partial), .failed("unsupported")] + for state in today { + XCTAssertNil(DeviceRegistry.unsupportedPayload(of: state), "\(state)") + } + XCTAssertNil(DeviceRegistry.unsupportedPayload(of: "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)) + } +} From b312e958647591809fc44cc93ad045e08dbcb8a3 Mon Sep 17 00:00:00 2001 From: Daghan Percinel <4314475+dpdev69@users.noreply.github.com> Date: Wed, 14 Oct 2026 00:35:26 +0300 Subject: [PATCH 04/14] Multi-mouse: wave 3 (HIDPPLink hub, per-endpoint controller, request gate, probe --all, end-to-end tests) Gate: 383 tests green, make app green. Co-Authored-By: Claude Opus 5.5 --- Makefile | 7 +- .../Sources/UprightKit/Device/HIDPPLink.swift | 527 +++++++++- .../Device/MXVerticalController.swift | 878 +++++++++------- .../UprightKit/HIDPP/HIDPPClient.swift | 121 ++- .../Sources/UprightKit/HIDPP/HIDPPError.swift | 4 - UprightKit/Sources/upright-probe/Probe.swift | 244 +++-- .../Sources/upright-probe/ProbeReport.swift | 791 +++++++++++++++ .../UprightKitTests/ControllerTests.swift | 71 +- .../EndToEndRoutingTests.swift | 940 ++++++++++++++++++ .../Tests/UprightKitTests/FleetHarness.swift | 387 +++++++ .../MultiDeviceControllerTests.swift | 581 +++++++++++ 11 files changed, 4011 insertions(+), 540 deletions(-) create mode 100644 UprightKit/Sources/upright-probe/ProbeReport.swift create mode 100644 UprightKit/Tests/UprightKitTests/EndToEndRoutingTests.swift create mode 100644 UprightKit/Tests/UprightKitTests/FleetHarness.swift create mode 100644 UprightKit/Tests/UprightKitTests/MultiDeviceControllerTests.swift diff --git a/Makefile b/Makefile index f6c0c53..742f537 100644 --- a/Makefile +++ b/Makefile @@ -24,7 +24,12 @@ 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) diff --git a/UprightKit/Sources/UprightKit/Device/HIDPPLink.swift b/UprightKit/Sources/UprightKit/Device/HIDPPLink.swift index d56e828..63cd639 100644 --- a/UprightKit/Sources/UprightKit/Device/HIDPPLink.swift +++ b/UprightKit/Sources/UprightKit/Device/HIDPPLink.swift @@ -1,13 +1,5 @@ import Foundation -// Wave 0 scaffold: final signatures only. W3.1 owns this file and implements the hub -// (D1, D10, D11, D12; §C steps 3–5 and 11). Until then nothing creates an HIDPPLink, and an -// instance stays inert: it never touches its transport and serves no endpoint. - -/// The per-endpoint controller. Until W3.1 renames the class (and keeps -/// `MXVerticalController` as a typealias of it), this alias points the other way. -public typealias EndpointController = MXVerticalController - /// 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. @@ -28,8 +20,21 @@ public struct ControllerTuning: Hashable, Sendable { } /// One opened HID interface (a receiver or a directly connected mouse): owns the HID++ -/// client, the receiver's pairing directory, and one `EndpointController` per served mouse, -/// and routes messages to them by device index. +/// 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. @@ -46,7 +51,18 @@ public final class HIDPPLink { public let link: UInt32 public let kind: TransportKind - public let tuning: ControllerTuning + /// 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] = [:] @@ -58,50 +74,503 @@ public final class HIDPPLink { /// 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 changed natively on that mouse (and only that one). + /// 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)? - private let transport: HIDPPTransport + /// 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)? - public init(transport: HIDPPTransport, kind: TransportKind, link: UInt32, tuning: ControllerTuning = .init()) { - self.transport = transport + 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) } - public func start() {} + // MARK: Public API - /// Re-applies configuration (after the Mac wakes): one slot, or every endpoint. - public func refresh(index: UInt8? = nil) {} + 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) + } + } - /// Hands every endpoint's buttons back to its mouse. Terminal. - public func shutdown() {} + /// 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) {} + public func setConfiguration(_ c: EndpointConfiguration, at index: UInt8) { + endpoints[index]?.apply(c) + } - public func cycleDPI(at index: UInt8, presets: [Int]) {} + 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) + } - public func setDPI(_ dpi: Int?, at index: UInt8) {} + /// 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) {} + 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?) {} + 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 - /// Reconnects one endpoint, or every endpoint on this link. - public func reconnect(index: UInt8?) {} + 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 + } - /// Returns once no endpoint has queued work (tests). - public func waitUntilIdle() async {} + 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/MXVerticalController.swift b/UprightKit/Sources/UprightKit/Device/MXVerticalController.swift index e89280f..a875b42 100644 --- a/UprightKit/Sources/UprightKit/Device/MXVerticalController.swift +++ b/UprightKit/Sources/UprightKit/Device/MXVerticalController.swift @@ -1,29 +1,62 @@ 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) +} + +/// 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 +66,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 +74,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 +110,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 +123,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 +266,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 +293,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 +308,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 +331,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 +370,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 +386,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 +430,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 +445,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 +465,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 +493,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 +528,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 +585,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 +631,39 @@ 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 + 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 +680,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 +693,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 +717,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 +730,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 +747,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 +777,7 @@ public final class MXVerticalController { old.releaseAll() old.onButtonEvent = nil old.onPointerDelta = nil + old.onSourcedPointerDelta = nil old.onReconfigurationNeeded = nil old.onBatteryChange = nil } @@ -753,134 +801,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 +863,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 +874,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/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/HIDPPError.swift b/UprightKit/Sources/UprightKit/HIDPP/HIDPPError.swift index a359166..201a84a 100644 --- a/UprightKit/Sources/UprightKit/HIDPP/HIDPPError.swift +++ b/UprightKit/Sources/UprightKit/HIDPP/HIDPPError.swift @@ -12,8 +12,6 @@ public enum HIDPPError: Error, Equatable, Sendable, CustomStringConvertible { /// 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?) - /// Superseded by `unsupportedDevice`; kept until the endpoint controller stops using it. - case notMXVertical(String) public var description: String { switch self { @@ -36,8 +34,6 @@ public enum HIDPPError: Error, Equatable, Sendable, CustomStringConvertible { 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)." - case .notMXVertical(let name): - return "Found “\(name)”, which is not an MX Vertical." } } diff --git a/UprightKit/Sources/upright-probe/Probe.swift b/UprightKit/Sources/upright-probe/Probe.swift index 693b554..fa53f4d 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,132 @@ 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) } } + monitor.onIgnored = { [weak self] info, reason in + MainActorDelivery.run { self?.console.say("· Ignoring \(info.summary): \(reason)") } + } 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))") + console.say(String(format: "Watching for Logitech HID++ interfaces (IOHIDManagerOpen: 0x%08X)", UInt32(bitPattern: result))) + 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. + """ +} + +// 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) + } + 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/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/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/MultiDeviceControllerTests.swift b/UprightKit/Tests/UprightKitTests/MultiDeviceControllerTests.swift new file mode 100644 index 0000000..bb5f5b8 --- /dev/null +++ b/UprightKit/Tests/UprightKitTests/MultiDeviceControllerTests.swift @@ -0,0 +1,581 @@ +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. + 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: 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() + + await eventually("three connected") { 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)) + } +} From a5640c7a8f7ab5282b41c19108213bf1a0f3bf60 Mon Sep 17 00:00:00 2001 From: Daghan Percinel <4314475+dpdev69@users.noreply.github.com> Date: Wed, 14 Oct 2026 00:35:26 +0300 Subject: [PATCH 05/14] Add multi-mouse design, handoff notes and orchestration scripts docs/dev/NEXT-STEPS.md describes the remaining waves (app core, UI, demo mode, docs, audit) and the launch video. Co-Authored-By: Claude Opus 5.5 --- docs/dev/AGENT-RULES.md | 73 +++ docs/dev/DEMO-MODE-SPEC.md | 39 ++ docs/dev/DESIGN-amendments.md | 41 ++ docs/dev/DESIGN-multimouse.md | 905 ++++++++++++++++++++++++++ docs/dev/NEXT-STEPS.md | 61 ++ docs/dev/launch-video-spec.json | 81 +++ docs/dev/workflows/launch-video.js | 113 ++++ docs/dev/workflows/multi-mouse-app.js | 100 +++ 8 files changed, 1413 insertions(+) create mode 100644 docs/dev/AGENT-RULES.md create mode 100644 docs/dev/DEMO-MODE-SPEC.md create mode 100644 docs/dev/DESIGN-amendments.md create mode 100644 docs/dev/DESIGN-multimouse.md create mode 100644 docs/dev/NEXT-STEPS.md create mode 100644 docs/dev/launch-video-spec.json create mode 100644 docs/dev/workflows/launch-video.js create mode 100644 docs/dev/workflows/multi-mouse-app.js diff --git a/docs/dev/AGENT-RULES.md b/docs/dev/AGENT-RULES.md new file mode 100644 index 0000000..bb2a9c7 --- /dev/null +++ b/docs/dev/AGENT-RULES.md @@ -0,0 +1,73 @@ +# Upright improvement plan (shared brief for all agents) + +Project root: /Users/dpercinel/cursor/mouseapp/Untitled/Upright-macos-source +Upright is a macOS 13+ SwiftUI menu-bar app that remaps Logitech MX Vertical buttons over HID++ 2.0 (IOKit). +- `UprightKit/` is a SwiftPM package (pure Swift core + IOKit layer guarded by `#if canImport(IOKit)`); tests run on Linux too, so + anything outside `Platform/` must stay Foundation-only. +- `Upright/` is the app. The Xcode target compiles UprightKit sources DIRECTLY (same module, no `import UprightKit`). +- Baseline copy of the original source (read-only reference): scratchpad/baseline/ + +## Rules for every agent +- Only edit the files your task OWNS. You may read anything. If you need a change in a file you don't own, don't make it; + describe it precisely in your final report under "Needs from other owners". +- Do NOT edit Upright.xcodeproj/project.pbxproj unless your task explicitly owns it. If you create new Swift files, + list them in your report under "New files" (the orchestrator registers them). +- Match the surrounding code style: small focused types, doc comments only where they add info, no over-commenting, + Swift 5.9 language features, macOS 13 deployment target (gate 14+ APIs with #available). +- Verify before reporting: + - `swift test --package-path UprightKit` (from project root) must pass. + - `scratchpad/typecheck.sh` must print `typecheck exit: 0` with no errors (typechecks app + kit as one module). +- Do not commit, push, or touch git. Do not run the app with real devices; do not post synthetic events in tests. +- Final report: summary of changes per file, new files, public API added/changed (signatures), tests added, + verification output (last lines), known limitations, "Needs from other owners". + +## Product decisions (already made — do not re-litigate) +- Fresh-install defaults: NO buttons diverted (Back/Forward/Top stay native: buttons 4/5 and DPI cycling). + Existing users' saved mappings must be preserved via migration. +- Mappings get a schema version and tolerant per-entry decoding; unreadable raw data is backed up, never silently lost. +- Per-app profiles: a global mapping table plus per-app (bundle ID) overrides that fall back to global per button. +- New actions: Cycle DPI / set DPI preset, Launch App, Open URL, Run Shortcut (Shortcuts.app via `shortcuts run`), + plus a hold-and-move Gestures action for a button (4 directions + tap) — gestures come in a later wave. +- Scroll settings (app-level CGEventTap): reverse mouse scrolling independently of trackpad (non-continuous events only), + speed multiplier, optional smooth scrolling, default all off. +- No Sparkle / notarization implementation that needs the user's Apple account; CI gets optional signing steps + that run only when secrets exist. + +## Status log +- Wave 1 DONE (85 tests green, typecheck 0): connection state machine (A), mappings v2 / MappingProfiles / MappingStore / + new actions / drag synthesis / permissions (C), hardened runtime + CI (D). + Read the current code; the API summaries are in the source. Key types: MappingProfiles, MappingStore, AppProfile, + ButtonAction (.launchApp/.openURL/.runShortcut/.device(DeviceAction)), DeviceAction (.cycleDPI, .setDPI(Int)), + RemapEngine.onDeviceAction, AppModel.performDeviceAction (currently logs only), FrontmostAppMonitor. +- New files not yet in project.pbxproj (orchestrator registers at the end): Upright/App/FrontmostAppMonitor.swift, + UprightKit/Sources/UprightKit/Mapping/MappingProfiles.swift, MappingStore.swift. + +## Toolchain constraint +CI builds with Xcode 16.2 / Swift 6.0 (local is Xcode 27 / Swift 6.4). Don't use language/library features newer than +Swift 6.0 or SDK APIs newer than macOS 13 without #available. Language mode stays Swift 5. +- Wave 2 DONE (131 tests, typecheck 0): DPI (0x2201) / firmware / serial / Easy-Switch (0x1814/0x1815) / raw XY in + DeviceManager + snapshot (B); AppSettings (scrollSettings, batteryAlertsEnabled, showBatteryInMenuBar, preferredDPI, + dpiPresets), ScrollController, BatteryNotifier, menu-bar battery label (F). + More files to register later: Upright/App/AppSettings.swift, ScrollController.swift, BatteryNotifier.swift, + UprightKit/.../Scrolling/{ScrollSettings,ScrollTransform,SmoothScrollAnimator}.swift, Battery/BatteryAlertPolicy.swift, + HIDPP/DeviceFeatures.swift. + +## Wave 3 contract (gestures ↔ UI) +- Agent G adds `indirect case gestures(GestureSet)` to ButtonAction and `public struct GestureSet: Codable, Hashable, Sendable` + { tap, up, down, left, right: ButtonAction (non-gesture; default .disabled/.deviceDefault semantics documented in code) }. +- Agent G creates Upright/Views/GestureEditor.swift: `struct GestureEditor: View { init(gestures: Binding) }` + (a compact form editing the 5 slots with the standard action picker for each slot). +- UI agents use exactly that API; G creates these types FIRST so the tree compiles early. +- Shared action picker: agent E1 owns ButtonRow.swift and should factor the action menu into a reusable + `ActionPicker(selection: Binding, allowsGestures: Bool)` in a NEW file Upright/Views/ActionPicker.swift, + created early; G's GestureEditor uses ActionPicker(selection:allowsGestures: false). Until it exists G may use a temporary + private picker and switch when it appears. +- Localization: write user-facing strings so they're extractable (Text("literal") / LocalizedStringKey, or + String(localized:) for strings built in code). A later wave adds the String Catalog. + +## CI lessons (Oct 13, after first push to github.com/dpdev69/upright) +- Swift 6.0 can't infer `0xE000 | 50` as UInt16 inside a [UInt16] literal — type such expressions explicitly. +- Linux SwiftPM 6.0 test discovery: every test method in a @MainActor XCTestCase class MUST be `async` + (synchronous ones fail to compile in the generated __allTests__ table). macOS doesn't catch this. +- Repo is now its own git repo (Upright-macos-source), remote origin = dpdev69/upright, CI green at 01c22a1. + Agents: never `git add -A`; only the orchestrator commits. diff --git a/docs/dev/DEMO-MODE-SPEC.md b/docs/dev/DEMO-MODE-SPEC.md new file mode 100644 index 0000000..6b9ab61 --- /dev/null +++ b/docs/dev/DEMO-MODE-SPEC.md @@ -0,0 +1,39 @@ +# Demo mode + snapshot export (requirement for the multi-mouse implementation) + +Purpose: real-UI marketing captures for the launch video / README screenshots, and a way for +contributors without hardware to see the app with several mice. + +## Invocation +- `Upright --demo` — runs the normal UI driven by SIMULATED mice (MX Vertical, MX Master 3S, Lift) from + the multi-device simulator. Must be fully isolated: + - no IOKit device matching/opening, no HID++ traffic to real devices, no diversion of the real mouse; + - no CGEventTaps (scroll, drag, gesture fallback), no synthetic events, no permission prompts, no login item changes; + - uses its own UserDefaults suite (e.g. `io.github.upright.Upright.demo`, wiped at start) — never reads or writes the user's settings; + - does not conflict with a normally running Upright instance (skip single-instance logic; distinct status item is fine). +- `Upright --demo --export-snapshots ` — renders the real SwiftUI views OFFSCREEN (NSHostingView/ImageRenderer + + cacheDisplay or bitmapImageRep, no Screen Recording permission), writes PNGs + `manifest.json`, then exits 0. + No status item, no windows shown, no Dock icon. + +## Output +- PNG per state per appearance: `..png`, default scale 2x; `menubar.label` at 6x. +- Settings window states rendered at the real settings window size (sidebar + pane) — same view tree as the app. +- `manifest.json`: `[{ id, appearance, file, scale, size:{w,h} (points), rects: { "": {x,y,w,h} (points, top-left origin) } }]` + Rects come from a no-op-unless-exporting modifier (e.g. `.snapshotAnchor("row.back")`) collected via anchor preferences. + Needed rect names: each button row (`row.`), profile picker (`profilePicker`), "In effect now" line (`inEffect`), + overridden rows, gesture editor rows (`gesture.tap|up|down|left|right`), pointer-speed slider (`dpiSlider`), presets (`dpiPresets`), + device list rows (`device.`), scrolling toggles (`scroll.reverseVertical`, `scroll.speed`, `scroll.smooth`), menu panel battery rows + (`battery.`), General "behavior" rows. + +## State ids (each in light and dark) +Essential: `buttons.global`, `buttons.profile` (an app profile selected — use a real installed app such as Safari so its icon +renders — with "In effect now" and overridden rows), `gesture.editor` (one button with all 5 slots filled), +`device.pointer` (Device pane: device list of the 3 simulated mice, pointer speed slider + presets), `scrolling` +(reverse + smooth ON, Experimental label visible), `menubar.panel` (3 mice with battery rows), `general.behavior`. +Nice to have: `buttons.global.1`…`.6` (Top Button row label = Mission Control, Space Left/Right, a media action, Open App, +Open URL, Run Shortcut); `buttons.profile.2` (a second app); `device.pointer.800|1600|3200`; +`menubar.panel.lowbattery` (one mouse at ≤10%); `menubar.label` (status-item icon + %, 6x); +Device activity log containing "Mac woke up; reapplying button configuration" (or the app's real equivalent). + +## Privacy +Simulated devices only: fake serials like `DEMO-0001`; never real device data or the user's real settings/app list beyond icons +of standard Apple apps. diff --git a/docs/dev/DESIGN-amendments.md b/docs/dev/DESIGN-amendments.md new file mode 100644 index 0000000..84d3d08 --- /dev/null +++ b/docs/dev/DESIGN-amendments.md @@ -0,0 +1,41 @@ +# Amendments to DESIGN-multimouse.md (orchestrator decisions — these override the design where they conflict) + +## A1. Simulator lives in the Kit (for Demo Mode) +- The multi-device simulator (SimulatedReceiver, SimulatedDeviceProfile, DeviceProfiles, the HIDPPTransport test double) + goes in `UprightKit/Sources/UprightKit/Simulation/` (public, Foundation-only, Linux-safe) instead of `T/Simulation/`, + so the app's Demo Mode can drive the real UI with simulated mice. `T/SimulatedMXVertical.swift` becomes a thin facade + over it; `FleetHarness` stays in tests as `T/FleetHarness.swift`. W1.6 owns `K/Simulation/*` instead of `T/Simulation/*`. + Profiles needed at minimum: MX Vertical, MX Master 3S, Lift, plus a keyboard (MX Keys, for the anti-hijack test) and an + unsupported/HID++1.0 device. Simulated serials/unit IDs must be obviously fake (e.g. "DEMO-0001"). +- W0 registers the new `K/Simulation/*.swift` stub files in project.pbxproj (they compile into the app too). + +## A2. Demo Mode + snapshot export (spec: scratchpad/DEMO-MODE-SPEC.md — read it) +- W0 creates compiling stubs and registers them in pbxproj: + `Upright/App/DemoMode.swift` (`enum DemoMode { static var isActive: Bool; static var exportDirectory: URL?; static func configure(arguments: [String]) }`), + `Upright/App/SnapshotExporter.swift` (stub `@MainActor enum SnapshotExporter { static func run(model: AppModel, to: URL) async throws }`), + `Upright/Views/SnapshotAnchor.swift` (`extension View { func snapshotAnchor(_ name: String) -> some View }` — no-op unless exporting; + a PreferenceKey collecting `[String: Anchor]`). +- W4.1 DeviceManager: interface discovery goes through an injectable source protocol (e.g. `LinkSource`): the IOKit monitor + is the default; Demo Mode supplies simulated links built from `K/Simulation`. With a non-IOKit source DeviceManager must + never touch IOKit (no matching, no open, no IOHIDRequestAccess). +- W4.2 AppModel: an environment switch (e.g. `AppEnvironment.live / .demo`). In demo: its own UserDefaults suite + (`io.github.upright.Upright.demo`, cleared at start), no CGEventTaps (scroll, drag rewriter, gesture fallback), a no-op action + performer, PermissionsMonitor reports granted without prompting, LoginItem and BatteryNotifier untouched/no-op. +- W5.1–W5.3: add `.snapshotAnchor("")` to the views at the rect names listed in DEMO-MODE-SPEC.md (rows, pickers, + sliders, toggles, battery rows). W5.1 also wires launch args in `UprightApp.swift`: call `DemoMode.configure(arguments:)` + first; when exporting, show no status item/windows/Dock icon, run the exporter, then exit. +- New task W5.5 (opus, runs AFTER W5.1–W5.3): owns `DemoMode.swift`, `SnapshotExporter.swift`, `SnapshotAnchor.swift`, and the + `Makefile` target `make snapshots` (→ `media/snapshots/`). Acceptance: `Upright --demo --export-snapshots ` writes every + essential state id (light + dark, 2x; `menubar.label` 6x) and as many nice-to-haves as possible, plus `manifest.json` with + rects; exits 0; no IOKit device opened; the user's `io.github.upright.Upright` defaults domain unchanged before/after + (`defaults export io.github.upright.Upright -` diff); runs fine while a normal Upright instance is running. + +## A3. Process rules for parallel agents in one tree +- Build/test with your OWN scratch path to avoid SwiftPM lock contention: + `swift build --package-path UprightKit --scratch-path /build-` (same for `swift test`). +- Files outside your ownership may be mid-edit by another agent. If a build fails ONLY in files you don't own, wait ~1 min + and retry (up to 5 times), then report it — never edit them. +- Never run git commands that change state (no add/commit/stash/checkout/reset). The orchestrator commits at wave gates. +- Swift 6.0 / Linux rules from PLAN.md "CI lessons" apply (explicit integer literal typing; all test methods in @MainActor + XCTestCase classes are `async`). +- Don't run the real app against real hardware and don't run `upright-probe --divert`. diff --git a/docs/dev/DESIGN-multimouse.md b/docs/dev/DESIGN-multimouse.md new file mode 100644 index 0000000..bbe9215 --- /dev/null +++ b/docs/dev/DESIGN-multimouse.md @@ -0,0 +1,905 @@ +# Upright: final multi-mouse and multi-model design + +Status: final architecture for implementation. The project is read-only for this document; nothing in the repo was changed. +Inputs: three candidate designs (minimal, robust, ux), the four-layer assumption inventory, and `scratchpad/research/devices.verified.json`. Facts checked against the Solaar clone in `scratchpad/research/Solaar`: +- HID++ 2.0 `DEVICE_KIND`: keyboard 0, remote 1, numpad 2, mouse 3, touchpad 4, trackball 5, presenter 6, receiver 7. +- HID++ 1.0 kind nibble: unknown 0, keyboard 1, mouse 2, numpad 3, presenter 4, remote 7, trackball 8, touchpad 9. +- 0x41 kind = `data[0] & 0x0F`. +- 0x0003 fn0 ids: unit = bytes[1..4], transport bits = byte[6] (bt 0x01, btle 0x02, wpid 0x04, usb 0x08), model PIDs packed in bytes[7..12] in that bit order. +- Test baseline is 163 tests: Controller 29, DeviceFeature 24, Gesture 23, MappingPersistence 22, Mapping 19, ScrollTransform 17, HIDPPMessage 12, Profile 9, BatteryAlertPolicy 8. + +--- + +## Scorecard + +Scores are 0–10. "Overall" is the unweighted mean. + +| Criterion | minimal | robust | ux | +|---|---|---|---| +| Correctness with several mice at once | 7: per-slot child keeps the proven state machine and dedupes by unit. Routing and diversion planning stay in the untested app target. Global request gate. | 9: per-slot everything, same-unit standby, per-slot echo, read-modify-write of receiver flags, undivert on retire, seeded fuzz test. | 8: address routing is always unique, config is pulled before the first diversion (no race), dedupe with promotion. Parallel per-slot probing on real receivers is unverified. | +| Migration safety for MX Vertical users | 9: v2 goes to the MX Vertical model, backup copied, same key. Writing v3 under the old key name confuses downgrades. | 8: exact equivalence proof by golden test. Three-scope precedence (allMice/model/unit × base/app) is easy to get subtly wrong. | 9: new v3 key, v2 never written, upgrade-without-data path kept. Slug keys need a re-keying step once the catalog learns a model. | +| Implementation risk and size | 8: smallest. Reuses `MXVerticalController` as a per-slot child. | 5: largest. Rewrites the controller into Link/Slot, adds registry, preferences, planner, three-scope mappings, renames in wave 0. | 6: medium. Renames the controller, Wave 0 touches DeviceSession and DeviceManager, final integration agent "owns all files". | +| UX clarity | 6: device picker, but no per-mouse enable or nickname, thin onboarding. | 7: complete, but the All mice / model / unit scope picker is a concept most users don't need. | 9: Mice pane, new-model banner, "Untested model" badge, "Copy device report", Remap-this-mouse, Forget, per-model Buttons picker. | +| Testability | 6: DeviceDirectory is pure, but planner and routing stay in AppModel and can't be tested on Linux. | 10: registry, planner and end-to-end routing all in the Kit, with fake links. | 9: registry and planner in the Kit, simulator v2, end-to-end via real controllers. | +| **Overall** | **7.2** | **7.8** | **8.2** | + +**Base:** the ux design, for its routing model, pull-based configuration, Kit-hosted registry and planner, per-model mappings, and UI. + +**Grafts:** +- From minimal: the low-risk controller topology (hub plus per-endpoint child that reuses today's state machine), the transport request gate, and the backup-before-first-v3-save. +- From robust: PID-canonical model keys from 0x0003 (no re-keying ever), same-unit standby across links, read-modify-write of receiver notification flags once per link, per-slot echo suppression, retire-on-identity re-pair, `DevicePreferences`, the end-to-end fuzz test, and keyboard-range exclusion at classification. + +--- + +## (A) Decisions and rationale + +Each conflict between the three designs is resolved explicitly; the losing options are named. + +**D1. Topology: a hub per HID interface plus an endpoint controller per mouse.** *(minimal; rejects the robust rewrite and the ux single-class InterfaceController)* +- New `HIDPPLink` (hub, one per opened IOHIDDevice) owns the `HIDPPClient`, the `ReceiverDirectory` (pairing table, 0x40/0x41, notification flags, announcements) and routing by device index. +- Each served mouse is one `EndpointController` child: receiver slot 1…6, or 0xFF for a direct link. This is today's `MXVerticalController` with receiver discovery removed. It keeps the existing probe/connect/configure/retry/backoff/reconfigure-debounce/battery/DPI-reconcile/Easy-Switch logic, now scoped to one index, together with its own timers, retry counter, echo bookkeeping, op coalescing and restore list. +- The class is renamed `EndpointController`, with `public typealias MXVerticalController = EndpointController` kept for one release. The file is renamed only in the final wave, together with pbxproj, to avoid churn across waves. +- The direct-link init `MXVerticalController(transport:kind:)` is kept: it builds a private one-endpoint hub, so the direct-link tests run unchanged. +- *Why:* the state machine is the part the user has tested on hardware ("reconnects pretty fast"). Moving it intact into a per-slot child keeps that behaviour and confines the new code to discovery and routing. + +**D2. Support is decided by capability, never by name or PID.** *(all three agree)* +An endpoint is served if all of these hold: +1. It answers as HID++ ≥ 2.0. +2. It is a pointer. The 0x41 or pairing kind nibble (HID++ 1.0 kinds 2 mouse, 8 trackball, 9 touchpad, 0 unknown) may pre-filter. The 0x0005 fn2 kind (3 mouse, 5 trackball, 4 touchpad) is authoritative when present. With neither available, a control carrying the `mse` flag (0x01) counts. +3. It has 0x1B04 with at least one configurable control. + +Anything else ends in a terminal `.unsupported(reason, partialSnapshot)`: no retry, no writes, never diverted. +- Touchpads (for example T650) pass the pointer gate, but have no divertable 0x1B04 controls in practice, so they end `.unsupported(.noConfigurableControls)`. +- Keyboards (MX Keys has 0x1B04) are rejected by kind. This is the anti-hijack gate. +- `HIDPPError.notMXVertical` is replaced by `.unsupportedDevice(UnsupportedReason)`. + +**D3. Runtime routing key is `DeviceAddress`; persistence key is `DeviceID`.** *(ux address plus robust unit identity; rejects the minimal `DeviceID(transport,index)` as a persisted key and the robust use of `DeviceID` for routing)* +- `DeviceAddress(link: UInt32, index: UInt8)` is always unique among live endpoints and exists before identification. Button events, pointer deltas, engine state, diversion caches and device actions all use it. +- `DeviceID` (unit-based, §D) keys persisted per-mouse preferences and same-unit dedupe. It never routes events, so two units with colliding or zero unit IDs can never cross-talk. + +**D4. Mappings are per model, not per unit, with no "all mice" layer in v3.** *(minimal and ux; rejects robust's three-scope precedence)* +- Each `ModelKey` holds today's v2 shape: a global table plus per-app profiles. Per-app profiles work unchanged inside each model. +- Two identical mice share one setup, which is what nearly everyone wants. The key survives transport and slot changes, and is portable in exports. +- The schema reserves a `devices` section (decoded, preserved, unused) for a later per-unit override, so no v4 is needed for it. +- A Buttons-pane action "Copy setup from…" and "Use this setup for all my mice" covers the all-mice case without a precedence model. + +**D5. ModelKey is PID-canonical from 0x0003.** *(robust; rejects the slug keys of minimal and ux, which need re-keying)* +- `ModelKey("046D:XXXX")`, where XXXX is the first present of bt, btle, wpid, usb in the 0x0003 model-ID list. This is the same on every transport: MX Vertical reports 407B/C08A/B020 and gets `046D:B020`. +- If 0x0003 is missing or has no model IDs: use the transport PID (the receiver's wpid or the IOHID PID), mapped through catalog aliases to the canonical key. Otherwise `046D:`. Last resort: `name:`. +- Unknown models therefore get the same key that the catalog will later give them, so no re-keying step exists. +- Export files carry a `name` beside every key, so they stay readable. +- `ModelKey.mxVertical = "046D:B020"`. + +**D6. v2 to v3 migration goes into the MX Vertical model; the new key is `mappingProfiles.v3`.** *(ux and robust key handling plus the minimal backup)* +- Only an MX Vertical could have produced v2 data, so placing v2 in `models[046D:B020]` reproduces today's behaviour exactly. +- Every other model starts native: nothing is diverted, so a new mouse is never hijacked. +- After migration the v2 key is never written or deleted, so a downgrade reads the user's last v2 state. +- A one-time byte copy `mappingProfiles.v2.preV3Backup` is written before the first v3 save. +- Details in §E. + +**D7. Per-device runtime state.** Diversion, raw XY, DPI (including adopting a speed set on the mouse), held buttons, gestures, gesture threshold and source, battery-alert policy, Easy-Switch and DPI actions, recovery, access-denied, and the Remap-this-mouse switch are all per device. +- The cross-controller DPI broadcast is removed. +- `connectedController` is deleted. + +**D8. Receivers.** +- Supported: + - Unifying C52B and C532. + - Bolt C548, with its own pairing layout. + - Nano C52F, C531, C534, C537, C518, C51A, C51B, C521, C525, C526, C52E, C535 (vendor 046D), using the Unifying register layout. *Why:* the verified DB has 40xx HID++ 2.0 mice with 0x1B04 behind C534 and C52F (M185 family, M705), and Solaar uses the same 0x20+(n−1) layout for Nano. +- **Lightspeed** (C539, C53A, C53D, C53F, C541, C545, C547, C54D) is never opened. Every one of the 9 G and PRO mice in the verified DB lacks 0x1B04; their buttons live in 0x8100 onboard profiles. C53A also carries a PowerPlay chip in slot 7, and G HUB owns these receivers. They are logged once as "Lightspeed receiver: not supported (no programmable-button feature on its mice)". Re-enabling one is a one-line table change if a probe dump ever shows 0x1B04. +- 27 MHz (C517) and pre-Unifying HID++ 1.0 receivers are excluded. +- Lenovo 17EF:6042 is not matched. That would be the first non-046D match, for a low-value Nano clone; it is noted as a future addition. +- Slots 1…6 are always scanned. `maxSlots` is never used as a bound, per the C53A note. + +**D9. Direct (USB or Bluetooth) acceptance.** +- An interface qualifies if its PID is in the catalog, or falls in Solaar's receiverless mouse ranges: USB C07D–C094, Bluetooth B012–B0FF. +- Keyboard ranges USB C32B–C344 and Bluetooth B317–B3FF are excluded at classification, so keyboards are never opened. This avoids Input Monitoring prompts and conflicts with Options+. +- The transport comes from the IOHID `Transport` string ("USB", "Bluetooth", "Bluetooth Low Energy"). +- The matching dictionary drops the MX Vertical PIDs and adds a generic vendor 046D, GenericDesktop/Mouse entry beside the existing 0xFF00/0xFF43 vendor pages. + +**D10. Wire concurrency: one request in flight per transport, with two priority lanes.** *(minimal gate plus a lane; rejects ux's parallel per-slot probing)* +- `HIDPPClient` gets a FIFO gate. Interactive requests (setCidReporting, DPI set, host switch) jump ahead of background requests (probe, feature table, battery, info). +- Head-of-line delay is bounded by one request timeout. One mouse sees today's wire pattern unchanged. +- Per-endpoint op queues sit above the gate, so a sleeping slot's probe interleaves request by request with other slots and never blocks them for a whole `start()`. +- Coalescing keys become `(index, kind)`. +- *Why:* whether real receivers tolerate concurrent requests to different slots is unverified. The gate costs at most ~1 s latency in the worst case and can be relaxed later behind `ControllerTuning.maxInFlight`. + +**D11. Receiver-wide setup happens once per link.** Register 0x00 notification flags use read-modify-write (set bits 0x01 wireless and 0x08 software-present in byte 1, keep others), at link start and on Mac wake, never on per-slot reconfigure. The 0x02 announcement request is sent only when the pairing table is unreadable. Echo suppression is per slot. + +**D12. Same physical unit on two links at once** (cable plus wireless; a receiver plus Bluetooth twin). +- When two live endpoints identify with the same stable `DeviceID`, the registry keeps one active, preferring USB, then receiver, then Bluetooth, then first seen. +- The other becomes `.standby`: native configuration (undiverted), events dropped. It is promoted when the active one leaves. +- **Re-pair on the same receiver:** a link-up on a new slot is always a new endpoint. If its stable `DeviceID` equals an endpoint on another slot of the same hub, the old endpoint is retired: fire-and-forget undivert, release holds, `onEndpointRemoved`. The old "the current session didn't answer" heuristic and the "staying on the first MX Vertical" rule are deleted. + +**D13. Multi-device decision logic lives in UprightKit.** `DeviceRegistry`, `ConfigurationPlanner`, `InterfaceClassifier`, `DevicePreferences`, `DeviceCatalog` and `ControlCatalog` are all Foundation-only and tested on Linux. The app target becomes IOKit and AppKit glue plus SwiftUI. + +**D14. Scroll keeps today's semantics.** The tap is active while any supported mouse is connected or asleep. That is today's "has a snapshot" rule, so existing users see no change. Copy says it affects all mouse wheels. The Swift name becomes `onlyWhileMouseConnected` and the Codable key stays `onlyWhileMXVerticalConnected`. *(rejects ux's "connected only")* + +**D15. Additive, defaulted APIs.** +- `ButtonEvent(cid:phase:)` still compiles (`source` defaults to `.unassigned`). +- `RemapEngine.mappings`, `onDeviceAction` and `pressedControls` stay as compatibility shims. +- The 163 tests remain the regression suite. Only the tests listed in §G-3, which encode single-session or MX-only behaviour, are inverted. + +**D16. Raw-XY quirk.** When the 0x1B04 version is ≥ 5, `DeviceSession` drops the first raw-XY report after each press of a raw-XY control. This follows the Solaar note; it is hardware-unverified and isolated to one function. + +**D17. DPI.** +- Only 0x2201 is supported for speed. Devices with only 0x2202 or 0x2205 (for example the M720) show no DPI UI, and their DPI actions are hidden. This is a known limitation in the README. +- Global `dpiPresets` stay as the seed list and are snapped per device at apply time. +- 0x1001 battery voltage is added as a read-only battery source (Solaar voltage table). + +--- + +## (B) Core types and signatures + +K = `UprightKit/Sources/UprightKit/`. Everything new is `Sendable`, and `@MainActor` where it holds callbacks. No tuple dictionary keys. + +### B1. Identity: `K/Device/DeviceIdentity.swift` (new, complete in Wave 0) + +```swift +public struct DeviceAddress: Hashable, Comparable, Sendable, CustomStringConvertible { + public var link: UInt32 // per opened IOHIDDevice, monotonic, never reused in a process + public var index: UInt8 // 1...6 receiver slot, 0xFF direct + public static let unassigned = DeviceAddress(link: 0, index: 0xFF) + public static let direct: UInt8 = 0xFF +} + +public struct ModelKey: RawRepresentable, Hashable, Codable, Comparable, Sendable { + public var rawValue: String // "046D:B020" | "name:" + public init?(rawValue: String) + public init(productID: UInt16) // "046D:%04X" + public static let mxVertical = ModelKey(productID: 0xB020) +} + +public struct ModelIDs: Hashable, Sendable { // parsed 0x0003 fn0 model PIDs + public var bluetooth, bluetoothLE, wireless, usb: UInt16? + public var canonical: UInt16? { bluetooth ?? bluetoothLE ?? wireless ?? usb } +} + +public struct DeviceID: RawRepresentable, Hashable, Codable, Comparable, Sendable { + // "u::" | "s::" | "t:." (transient, never persisted) + public var rawValue: String + public var isStable: Bool { !rawValue.hasPrefix("t:") } +} + +public enum DeviceKind: Hashable, Sendable { + case keyboard, remote, numpad, mouse, touchpad, trackball, presenter, receiver, other(UInt8), unknown + public init(hidpp20 value: UInt8) // 0x0005 fn2: 0 kb,1 remote,2 numpad,3 mouse,4 touchpad,5 trackball,6 presenter,7 receiver + public init(hidpp10Nibble value: UInt8) // pairing/0x41: 0 unknown,1 kb,2 mouse,3 numpad,4 presenter,7 remote,8 trackball,9 touchpad + public var isPointer: Bool // mouse, trackball, touchpad + public var isDefinitelyNotPointer: Bool // keyboard, numpad, presenter, remote, receiver +} + +public struct DeviceIdentity: Hashable, Sendable { + public var name: String + public var kind: DeviceKind + public var unitID: UInt32? // nil when absent or zero + public var serialNumber: String? + public var modelIDs: ModelIDs + public var extendedModelID: UInt8? + public var transportPID: UInt16? // wpid on a receiver, IOHID PID on a direct link + public var model: ModelKey // via DeviceCatalog.modelKey(for:) + public func deviceID(at address: DeviceAddress) -> DeviceID + public static func parseFirmwareIDs(_ reply: [UInt8]) -> (unitID: UInt32?, modelIDs: ModelIDs, extendedModelID: UInt8?)? +} + +public enum UnsupportedReason: Hashable, Sendable { + case hidpp10(major: UInt8, minor: UInt8), notAPointer(DeviceKind), noReprogControls, noConfigurableControls + public var message: String // localized, user-facing +} +``` + +### B2. Events: `K/Device/ButtonStateTracker.swift` + +```swift +public struct ButtonEvent: Hashable, Sendable { + public var cid: UInt16; public var phase: Phase + public var source: DeviceAddress = .unassigned // stamped by EndpointController +} +public struct PointerDelta: Hashable, Sendable { + public var source: DeviceAddress; public var dx: Int; public var dy: Int +} +``` + +### B3. Session and snapshot: `K/Device/DeviceSession.swift` + +Every `DeviceSnapshot` addition has a default, so existing call sites compile: +```swift +public var identity: DeviceIdentity? // nil only in legacy test fixtures +public var address: DeviceAddress = .unassigned +public var reprogVersion: UInt8 = 0 +public var modelKey: ModelKey { identity?.model ?? .mxVertical } // compat for fixtures +``` +- `start()` is read-only, in this order: protocol, feature table, name, 0x0005 kind, 0x0003 identity (fn0 plus serial), then the 0x1B04 check and controls, then battery (0x1004, 0x1000 or 0x1001), DPI (0x2201), hosts (0x1814). +- It throws `HIDPPError.unsupportedDevice(UnsupportedReason, partial: DeviceSnapshot?)` when the device fails the gate. +- Name fallback: the IOHID product string, else "Logitech mouse". +- `restoreMessages()` is kept. It is used on every drop or retire while the device may still be reachable. + +### B4. Hub and endpoint + +`K/Device/HIDPPLink.swift` (new): +```swift +@MainActor public final class HIDPPLink { + public init(transport: HIDPPTransport, kind: TransportKind, link: UInt32, tuning: ControllerTuning = .init()) + public private(set) var endpoints: [UInt8: EndpointController] // sorted access via `orderedEndpoints` + public var configurationProvider: (@MainActor (DeviceAddress, DeviceSnapshot) -> EndpointConfiguration)? + public var onEndpointChange: ((DeviceAddress, EndpointController.State) -> Void)? + public var onEndpointRemoved: ((DeviceAddress, RemovalReason) -> Void)? // .unpaired, .retired(movedTo:), .replaced, .transportClosed + public var onButtonEvent: ((ButtonEvent) -> Void)? + public var onPointerDelta: ((PointerDelta) -> Void)? + public var onDeviceDPIChange: ((DeviceAddress, Int?) -> Void)? // native change on THAT mouse only + public var onLog: ((DeviceAddress?, String) -> Void)? + public func start(); func refresh(index: UInt8? = nil); func shutdown() // shutdown restores every endpoint + public func setConfiguration(_ c: EndpointConfiguration, at index: UInt8) + public func cycleDPI(at: UInt8, presets: [Int]); func setDPI(_: Int?, at: UInt8); func switchToHost(_: Int, at: UInt8) + public func refreshDeviceInfo(at: UInt8?); func reconnect(index: UInt8?) + public func waitUntilIdle() async // tests +} +``` + +`K/Device/ReceiverDirectory.swift` (new, value-level parsing plus the receiver ops used by `HIDPPLink`): +```swift +public enum PairingLayout: Sendable { case unifying /* 0x20+(n-1), Nano too */, bolt /* 0x50+n */ } +public struct PairingRecord: Hashable, Sendable { public var slot: UInt8; public var wirelessPID: UInt16; public var kind: DeviceKind } +public enum ReceiverDirectory { + public static func parsePairing(_ reply: [UInt8], slot: UInt8, layout: PairingLayout) -> PairingRecord? + public static func parseConnection(_ m: HIDPPMessage) -> (slot: UInt8, wirelessPID: UInt16, kind: DeviceKind, linkUp: Bool)? // 0x41 + public static func isUnpair(_ m: HIDPPMessage) -> UInt8? // 0x40 with unpair bit → slot + public static func mergedNotificationFlags(current: [UInt8]) -> [UInt8] // read-modify-write of register 0x00 +} +``` +Layouts: +- Unifying/Nano: wpid = bytes[3..4] big-endian, kind = byte[7] & 0x0F. +- Bolt: wpid = b[3]<<8 | b[2], kind = b[1] & 0x0F. +- 0x41: wpid = d[2]<<8 | d[1], online = !(d[0] & 0x40), kind = d[0] & 0x0F. + +`K/Device/MXVerticalController.swift` keeps its file name until the final wave. Its class becomes `EndpointController`, with `public typealias MXVerticalController = EndpointController`: +```swift +public enum State { case probing, waitingForDevice, connected(DeviceSnapshot), asleep(DeviceSnapshot), + unsupported(UnsupportedReason, DeviceSnapshot?), failed(String) } +init(client: HIDPPClient, kind: TransportKind, address: DeviceAddress, hub: EndpointHub) // used by HIDPPLink +public convenience init(transport: HIDPPTransport, kind: TransportKind) // direct only; legacy tests +public let address: DeviceAddress +var retriesWhileWaiting: Bool // hub: true when slot is in pairing table or notifications are off +func receive(_ m: HIDPPMessage); func linkChanged(up: Bool, announcementEcho: Bool); func retire(); func transportDidClose() +// Legacy surface kept on the direct convenience path: state, snapshot, setDesiredDiversion/RawXY/DPI, onLog, onPointerDelta, onButtonEvent +``` + +`EndpointConfiguration` (in `K/Device/DeviceIdentity.swift`): +```swift +public struct EndpointConfiguration: Hashable, Sendable { + public var divert: Set = []; public var rawXY: Set = []; public var dpi: Int? = nil + public static let native = EndpointConfiguration() +} +``` + +### B5. Catalog: `K/Device/DeviceCatalog.swift` and `K/Device/ControlCatalog.swift` (new; KnownControl and ControlDescriptor move here) + +```swift +public struct DeviceModel: Hashable, Sendable, Identifiable { + public var key: ModelKey; public var name: String + public var usbPIDs, bluetoothPIDs, wirelessPIDs: [UInt16] // first wireless/bt may alias to key + public var controlOrder: [UInt16] // display order + public var controlText: [UInt16: ControlText] // per-model wording overrides + public var suggested: ButtonMappings // onboarding template + public var verification: Verification // .hardware (MX Vertical) | .publishedDump | .unverified + public var id: ModelKey { key } +} +public struct ReceiverModel: Hashable, Sendable { public var productID: UInt16; public var kind: ConnectionKind; public var layout: PairingLayout? /* nil = excluded */ } +public enum DeviceCatalog { + public static let vendorID: Int = 0x046D + public static let models: [DeviceModel] + public static let receivers: [UInt16: ReceiverModel] + public static func model(_ key: ModelKey) -> DeviceModel? + public static func model(forPID pid: UInt16) -> DeviceModel? + public static func modelKey(for identity: DeviceIdentity) -> ModelKey // canonical rule from D5, plus aliases +} +public struct ControlText: Hashable, Sendable { public var name, detail, symbolName: String } +public enum ControlCatalog { + public static func text(for cid: UInt16, model: ModelKey?) -> ControlText // model override → 33 Solaar names → "Button 0x%04X" + public static let neverDivert: Set = [0x50, 0x51] +} +extension ControlDescriptor { public init(cid: UInt16, model: ModelKey? = nil) } // old init(cid:) = MX Vertical wording +// KnownControl gains: gestureButton 0xC3, smartShift 0xC4, tiltLeft 0x5B, tiltRight 0x5D, +// multiPlatformGesture 0xD0, virtualGesture 0xD7, hapticSense 0x1A0. +// ConnectionKind gains .nano (exhaustive switches updated). +``` + +### B6. Classification: `K/Device/InterfaceClassifier.swift` (new, Linux-safe) + +```swift +public struct HIDInterfaceDescriptor: Hashable, Sendable { + public var vendorID, productID: Int; public var transport: String; public var usagePage, usage: Int + public var locationID: Int; public var serial: String; public var registryID: UInt64; public var productName: String +} +public enum InterfaceClass: Hashable, Sendable { + case receiver(ConnectionKind, PairingLayout), direct(ConnectionKind), ignored(String) +} +public enum InterfaceClassifier { + public static func classify(_ d: HIDInterfaceDescriptor) -> InterfaceClass + public static func identityKey(_ d: HIDInterfaceDescriptor) -> String // vendor:product:page:usage + (serial | location | registryID) + public static func twinsToReplace(adding d: HIDInterfaceDescriptor, open: [K: HIDInterfaceDescriptor]) -> [K]? +} +``` +`MXVertical.classify` forwards here until it is removed in the final wave. `HIDInterfaceInfo` in `IOKitHID.swift` builds an `HIDInterfaceDescriptor`, using `IORegistryEntryGetRegistryEntryID` on the device service. + +### B7. Registry and planner: `K/Device/DeviceRegistry.swift` and `K/Device/ConfigurationPlanner.swift` (new) + +```swift +@MainActor public final class DeviceRegistry { + public struct Record: Identifiable, Hashable, Sendable { + public var address: DeviceAddress; public var connection: ConnectionKind + public var state: EndpointController.State + public var deviceID: DeviceID?; public var model: ModelKey?; public var displayName: String + public var role: Role // .active | .standby(of: DeviceAddress) | .unsupported | .pending + public var ordinal: Int // first-seen order, stable for the process + public var id: DeviceAddress { address } + } + public enum Role: Hashable, Sendable { case active, standby(of: DeviceAddress), unsupported, pending } + public var nameProvider: ((DeviceID) -> String?)? // nicknames + public var onChange: (() -> Void)? + public private(set) var records: [Record] // pointers first by ordinal; unsupported last + public func update(_ a: DeviceAddress, connection: ConnectionKind, state: EndpointController.State) + public func remove(_ a: DeviceAddress); func removeLink(_ link: UInt32) -> [DeviceAddress] + public func record(_ a: DeviceAddress) -> Record? + public func addresses(of id: DeviceID) -> [DeviceAddress] + public func isActive(_ a: DeviceAddress) -> Bool // false for standby/unsupported/pending + public var summary: FleetSummary // anyConnected, anyPresent (connected||asleep), connectedCount, + // lowestBattery: (DeviceAddress, BatteryStatus)?, problems: [DeviceAddress] + public static func linkRank(_ c: ConnectionKind) -> Int // usb 0 < unifying/bolt/nano 1 < bluetooth 2 +} + +public struct ConfigurationInputs: Hashable, Sendable { + public var remappingEnabled, accessibilityGranted, deviceRemapEnabled, isActive: Bool + public var preferredDPI: Int? + public var held: Set; public var gesturesHeld: Set +} +public enum ConfigurationPlanner { + public static func configuration(mappings: ButtonMappings, controls: [ControlInfo], inputs: ConfigurationInputs) -> EndpointConfiguration + // !isActive || !deviceRemapEnabled → divert only `held` (keep-alive until release), else divertedControls ∪ held ∪ gesturesHeld, + // ∩ that device's configurable controls; rawXY = gesture controls ∩ supportsRawXY; dpi = preferredDPI snapped to the device list. +} +``` + +### B8. Engine: `K/Mapping/RemapEngine.swift` (additions; existing API kept) + +```swift +public struct ControlKey: Hashable, Sendable { public var source: DeviceAddress; public var cid: UInt16 } +// held: [ControlKey: ActionEffect]; gestures: [ControlKey: GestureState]; pressed: Set +public var mappingsProvider: (@MainActor (DeviceAddress) -> ButtonMappings)? // nil → `mappings` (compat) +public var rawThresholdProvider: (@MainActor (DeviceAddress) -> Double)? // captured into GestureState at press +public var gestureSourceFor: (@MainActor (DeviceAddress, UInt16) -> PointerDeltaSource)? +public var onSourcedDeviceAction: ((DeviceAction, DeviceAddress) -> Void)? // compat onDeviceAction still fires if set +public func pressedControls(for: DeviceAddress) -> Set +public func gestureControls(for: DeviceAddress) -> Set +public func handlePointerDelta(_ d: PointerDelta) // raw: only gestures with key.source == d.source +public func handleCursorDelta(dx: Int, dy: Int) // cursor-source gestures (unattributable; documented) +public func releaseAll(source: DeviceAddress) +public var pressedControls: Set { get } // compat union +``` +The lost-release heuristic (a repeated press releases the previous one) applies per `ControlKey`. + +### B9. Mappings: `K/Mapping/MappingProfiles.swift` (v3) and the new `K/Mapping/MappingLibrary.swift` + +```swift +// MappingProfiles keeps its v2 shape (global + apps) and its tolerant decoder; it is the per-model payload. +public struct MappingLibrary: Codable, Hashable, Sendable { + public static let schemaVersion = 3 + public var models: [ModelKey: MappingProfiles] + public var modelNames: [ModelKey: String] // display, written to exports + public var reservedDevices: [String: JSONValue] // "devices" section: preserved, unused + public var unknownTopLevel: [String: JSONValue] // forward-compat preservation + public init(migrating v2: MappingProfiles) // → [.mxVertical: v2] + public subscript(model: ModelKey) -> MappingProfiles { get set } // empty default + public func effectiveMappings(model: ModelKey?, bundleID: String?) -> ButtonMappings // nil/unknown → empty + public var allActiveActions: [ButtonAction] // union over models (needsAccessibility) +} +// JSONValue: small Codable enum in MappingLibrary.swift (no new dependency). +``` + +`MappingStore` changes: +- `profilesKey = "mappingProfiles.v3"`, `v2Key = "mappingProfiles.v2"`, `preV3BackupKey = "mappingProfiles.v2.preV3Backup"`. +- `load() -> (MappingLibrary, LoadReport)` +- `save(_ MappingLibrary)` +- `export(_:) -> Data` +- `prepareImport(_ data: Data, targetModel: ModelKey) -> ImportPreview` +- `ReviewItem` gains `model: ModelKey` and `modelName`. + +`ButtonMappings.suggested(for model: ModelKey, controls: [UInt16]) -> ButtonMappings`: +- MX Vertical returns `legacyDefaults`. +- Models with 0x53/0x56 get Back → Cmd+[ and Forward → Cmd+]. +- A gesture button (0xC3 or 0xD0), if present, gets Mission Control. +- Otherwise the result is empty. + +### B10. Per-device preferences: `K/Device/DevicePreferences.swift` (new) + +```swift +public struct DevicePreferences: Codable, Hashable, Sendable { + public var nickname: String?; public var remapEnabled = true; public var preferredDPI: Int? + public var lastName: String?; public var lastModel: ModelKey?; public var lastControls: [UInt16] = [] + public var lastConnection: String?; public var lastSeen: Date? +} +public struct DevicePreferencesStore: Codable, Hashable, Sendable { // UserDefaults key "devicePreferences.v1" + public var models: [ModelKey: DevicePreferences] // seeds (preferredDPI) for first-seen units + public var devices: [DeviceID: DevicePreferences] // only stable IDs are stored + public func preferences(for id: DeviceID, model: ModelKey) -> DevicePreferences // device ?? model seed ?? default + public mutating func record(_ id: DeviceID, snapshot: DeviceSnapshot) // lastName/controls/seen + public mutating func migrateLegacy(preferredDPI: Int?) // → models[.mxVertical].preferredDPI, once + public mutating func forget(_ id: DeviceID) +} +``` + +### B11. App-layer contracts (DeviceManager and AppModel; frozen at the start of Wave 4) + +```swift +// Upright/App/DeviceManager.swift +@Published private(set) var records: [DeviceRegistry.Record] +@Published private(set) var summary: FleetSummary +@Published private(set) var blockedInterfaces: [String] // names of interfaces macOS refused +var needsInputMonitoring: Bool { !blockedInterfaces.isEmpty } +func snapshot(_ a: DeviceAddress) -> DeviceSnapshot? +func controls(for model: ModelKey, address: DeviceAddress?) -> [ControlDescriptor] // live → prefs.lastControls → catalog +func perform(_ action: DeviceAction, on a: DeviceAddress, presets: [Int]) +func setDPI(_ dpi: Int?, on a: DeviceAddress); func switchToHost(_ host: Int, on a: DeviceAddress) +func setConfiguration(_ c: EndpointConfiguration, for a: DeviceAddress) +func reconnect(_ a: DeviceAddress?) // nil = Reconnect All (today's restart) +func refreshDeviceInfo(_ a: DeviceAddress?) +var configurationProvider: (@MainActor (DeviceAddress, DeviceSnapshot) -> EndpointConfiguration)? +var onButtonEvent: ((ButtonEvent) -> Void)?; var onPointerDelta: ((PointerDelta) -> Void)? +var onEndpointsRemoved: (([DeviceAddress]) -> Void)?; var onDeviceDPIChange: ((DeviceAddress, Int?) -> Void)? +// Compat until Wave 5 views land: status, snapshot, controls, isConnected → primary record (first active connected by ordinal). + +// Upright/App/AppModel.swift +@Published var selectedDevice: DeviceAddress? // Mice pane / menu target +@Published var selectedModel: ModelKey // Buttons pane target; defaults to selected device's model, else .mxVertical +var focusedDevice: DeviceAddress? // last device that sent a press +var knownModels: [ModelKey] // connected ∪ remembered ∪ library keys, ordered +func binding(for cid: UInt16, model: ModelKey) -> Binding +func overrideBinding(for cid: UInt16, model: ModelKey, bundleID: String) -> Binding +func copySetup(from: ModelKey, to: ModelKey); func applySetupToAllModels(from: ModelKey) +func preferences(_ id: DeviceID) -> Binding; func forget(_ id: DeviceID) +struct PressedControl { var cid: UInt16; var source: DeviceAddress; var date: Date } +// binding(for:) / overrideBinding(for:bundleID:) without model → selectedModel (compat) +``` + +--- + +## (C) Data flow + +1. **Match.** `HIDDeviceMonitor` matches vendor pages 0xFF00 and 0xFF43, plus {vendor 046D, GenericDesktop 0x01, Mouse 0x02}. Each match becomes an `HIDInterfaceDescriptor`, which is passed to `InterfaceClassifier.classify`. + - Receiver PIDs come from the catalog. Lightspeed and 27 MHz give `.ignored`, logged once. + - Direct devices are accepted by catalog PID or mouse PID range; keyboard ranges are ignored. + - Twins are deduped by `identityKey`, whose fallback is serial, then locationID, then registryID, so two BLE mice never collapse. + - Every skipped interface gets one log line. +2. **Attach.** `DeviceManager.attach` assigns `link = nextLink++`, opens the transport, creates `HIDPPLink` and wires its callbacks into `DeviceRegistry` and the app. + - A refused open is recorded as blocked for that interface only. + - `accessDenied` is no longer reset by a later successful attach. +3. **Hub start.** + - *Direct link:* one endpoint at 0xFF. Its retry and backoff behaviour is exactly today's. + - *Receiver:* + - (a) Read register 0x00 and write back the merged flags. + - (b) Read pairing records for slots 1…6 with the layout for this receiver. + - (c) Spawn an `EndpointController` for each occupied slot whose kind is not `isDefinitelyNotPointer`. Its `retriesWhileWaiting` is true. + - (d) If the pairing table can't be read: request 0x02 announcements and spawn on each link-up. If those don't arrive within `announcementWait`, ping slots 1…6 once each through the gate. + - Probing never stops at the first success. +4. **Hub routing.** + - **0x41 for slot n:** + - No endpoint: spawn one, unless n is excluded for (n, wpid). + - Endpoint exists and the wpid is nonzero and differs from the endpoint's: the device was replaced. Retire the old endpoint with undivert and spawn a new one. + - Otherwise: `endpoint.linkChanged(up:announcementEcho:)`. The echo flag is per slot: the announcement was requested less than 2 s ago and this slot was configured after the request. + - **0x40 unpair for slot n:** remove the endpoint (`.unpaired`) and clear (n, *) from the exclusion list. + - **Any other message:** delivered to `endpoints[deviceIndex]`. A message from an unknown slot 1…6 with software ID 0 spawns an endpoint, so a missed 0x41 self-heals. Messages for 0xFF go to the direct endpoint. + - The exclusion list is keyed (slot, wpid). It is filled by `.unsupported` results and cleared by 0x40 or a different wpid. +5. **Endpoint connect.** + - `DeviceSession.start()` (read-only) produces a snapshot with identity, or throws `.unsupportedDevice`, which is terminal. + - The hub runs the retire check: same stable `DeviceID` on another slot of this hub means the old slot is retired. + - The hub calls `configurationProvider(address, snapshot)` synchronously on the main actor. The answer comes from AppModel through `ConfigurationPlanner`, using that model's effective mappings for the frontmost app, that device's preferences, its held set (empty) and whether the registry marks it active. If the provider is nil, `.native` is used. + - The configuration is applied inside the same connect operation. Diversion therefore never precedes identification, so no event is ever anonymous. + - Then `.connected`, then `onEndpointChange`, then `registry.update`. +6. **Registry.** + - Attaches the `DeviceID` and `ModelKey`, assigns the ordinal, and computes the role. + - If the stable `DeviceID` is already active on another link, the one with the better `linkRank` wins (USB, then receiver, then Bluetooth), with ties going to the earlier ordinal. The loser becomes `.standby`, and AppModel pushes it `.native` (keeping held buttons only until they are released). + - `onChange` reaches the published records in DeviceManager. +7. **Events.** + - The endpoint stamps `ButtonEvent.source` and `PointerDelta.source` with its address. + - DeviceManager drops events whose `registry.isActive(source)` is false. + - `AppModel.handle(event)` records `focusedDevice` and `lastPressedControl`, then calls `engine.handle(event)`. + - Inside the engine: + - `mappingsProvider(source)` resolves to `library.effectiveMappings(model: registry.record(source)?.model, bundleID: frontmost)`. + - On press, the effect is snapshotted into `held[ControlKey]`. `gestureSourceFor(source, cid)` reads that device's own `ControlInfo.supportsRawXY`. `rawThresholdProvider(source)` uses that device's `dpi.current`. Both are captured per gesture. + - Raw deltas reach only gestures with the same source. Cursor deltas (PointerMovementMonitor) reach only cursor-source gestures. +8. **Actions.** `engine.onSourcedDeviceAction(action, source)` → `DeviceManager.perform(action, on: source, presets:)` → `link.cycleDPI(at: source.index, …)`. UI actions pass `selectedDevice` or the row's address. +9. **Resync.** On an app switch, mapping edit, Accessibility change, remap toggle, preference change or release, AppModel recomputes the configuration of each record through `ConfigurationPlanner`. It uses that device's `engine.pressedControls(for:)` and `gestureControls(for:)`. A configuration differing from `appliedConfig[address]` is sent through `setConfiguration`. +10. **DPI.** + - A native change on a mouse fires `onDeviceDPIChange(address, dpi)`. If the record has a stable ID, the value is stored in `prefs.devices[id].preferredDPI`. Nothing is broadcast. + - On first identification of a stable ID with no stored preferences, the model seed applies. For the MX Vertical that is the migrated legacy `preferredDPI`; for other models it is nil, meaning the mouse keeps its own speed. +11. **Teardown.** + - Retiring or removing an endpoint releases its tracker (stamped releases), sends fire-and-forget `restoreMessages()` when it may still be reachable, and calls `onEndpointRemoved`. + - The app then runs `engine.releaseAll(source:)`, removes the record, drops the battery policy for that address, and promotes any standby endpoint of the same unit. + - Closing a transport removes all of that link's endpoints. + - `shutdown()` restores every endpoint on every link. +12. **Recovery.** + - Per link: the failure counter and recovery task are stored in the entry. Recovery re-matches only that IOHIDDevice and is gated on that link's own state. + - "Reconnect" targets one device: `link.reconnect(index:)`. + - "Reconnect All" keeps today's full restart. + - `.unsupported` never triggers recovery. + +--- + +## (D) Identity strategy + +| Key | Built from | Purpose | Lifetime | +|---|---|---|---| +| `DeviceAddress(link, index)` | monotonic link number + slot/0xFF | event routing, engine state, applied-config cache, actions, battery policy, UI selection | one connection of one interface; a new address after a re-pair or a transport change | +| `DeviceID` | `u::` from 0x0003 fn0 bytes 1–4 (nonzero); else `s::` (0x0003 v4+ serial, then Unifying extended pairing 0xB5 sub 0x30+n−1 bytes 1–4); else `t:.` | per-unit preferences (nickname, DPI, remap-enabled, last controls), same-unit dedupe, re-pair detection, notification text | permanent for stable IDs; transient IDs are never persisted and fall back to model seeds | +| `ModelKey` | canonical 0x0003 model PID (bt ≻ btle ≻ wpid ≻ usb); else transport PID through catalog aliases; else `name:` | mappings, catalog lookup, control names, suggested setup | permanent; the same for every transport | +| IOKit identity key | vendor:product:page:usage + serial, else locationID, else registry entry ID | twin-interface dedupe at match time only | one IOHIDDevice | + +Rules: +- Two identical mice have different unit IDs, so they get two records, two preference sets, and independent held, gesture and DPI state. They share the model's mappings (D4). +- If a firmware reports no unit ID and no serial, both mice still work (addresses are unique). Their preferences fall back to the model seed, and the Mice pane shows "Settings for this mouse can't be remembered" under the device. +- Unit-ID equality across transports is asserted by Solaar, but is hardware-unverified for the MX Vertical. §I includes a probe step to confirm it. If it turns out false, the only effect is that same-unit dedupe doesn't trigger; nothing breaks. + +--- + +## (E) Schema v3 and migration rules + +Stored payload (key `mappingProfiles.v3`) and export format: +```json +{ + "schemaVersion": 3, + "models": { + "046D:B020": { "name": "MX Vertical", "global": { "actions": { "83": {…} }, "gestures": {…} }, "apps": [ … ] }, + "046D:B034": { "name": "MX Master 3S", "global": { "actions": {} }, "apps": [] } + }, + "devices": {}, + "global": { … }, "apps": [ … ] +} +``` +- The top-level `global` and `apps` keys appear **only in exported files**. They mirror `046D:B020` so that a 2.x build importing a v3 file still gets the MX Vertical setup. On read they are ignored whenever `models` is present. +- CID keys stay decimal strings, read by the existing `ActionTable` decoder. +- Inner payloads are `MappingProfiles` encoded without `schemaVersion`, plus `name`. + +**Load order** (`MappingStore.load`), first match wins: +1. `mappingProfiles.v3` present: decode the `MappingLibrary`. Unknown models, `devices` and unknown top-level keys are preserved. A `schemaVersion` greater than 3 gives today's "newer version" note plus the raw backup, and decoding continues tolerantly. +2. Else `mappingProfiles.v2` present: decode with today's tolerant v2 path (corrupt-entry skipping and backups unchanged), then `MappingLibrary(migrating:)` puts it in `models[046D:B020]`. Write the v2 bytes once to `mappingProfiles.v2.preV3Backup`, then save v3. +3. Else the 1.x `legacyKey` is present: today's v1 migration exactly, placed in `models[046D:B020]`. +4. Else `priorLaunchKeys` show an upgrade without saved data: `legacyDefaults` go in `models[046D:B020]` (today's `testUpgradeWithoutSavedDataKeepsOldDefaults` behaviour). +5. Else (fresh install): an empty library. Every model is native. + +**Invariants:** +- After step 2, `mappingProfiles.v2` is never written or deleted, so a downgrade reads the last v2 state. Edits made in v3 are not visible to a downgraded build; the CHANGELOG says so. +- **Exact behaviour for existing users.** For every v2 fixture f and every bundle ID b: `library.effectiveMappings(model: .mxVertical, bundleID: b) == f.effectiveMappings(forBundleID: b)`, and the planner's diversion and raw-XY sets for an MX Vertical snapshot equal today's `syncDiversion` output. These are golden tests. +- `needsAccessibility` uses `allActiveActions` across every model, a superset of today's, which is equal for a single-model library. +- **Legacy `preferredDPI`** (AppSettings key `preferredDPI`) is copied once into `DevicePreferencesStore.models[046D:B020].preferredDPI`. The legacy key is left in place for downgrade. The first MX Vertical identified with no device entry inherits that value, so it is today's behaviour. +- **`dpiPresets`** stay global and unchanged. +- **Scroll.** The Codable key `onlyWhileMXVerticalConnected` is unchanged. + +**Import:** +- A v3 file merges per model: each model section replaces that model's profiles after review. Sections for models not owned are kept, shown as "Not connected", and imported. +- A v2 or v1 file is reviewed as "Made for MX Vertical". It gets a target-model picker (default: the selected model, else MX Vertical) and is imported into that one model. +- `ReviewItem` shows the model name. +- Validation is unchanged: the primary-click guard covers `ControlCatalog.neverDivert`, plus any control a connected device flags as `mse` and non-divertable. + +**Export** writes model sections, names and the compatibility mirror. It never writes `DevicePreferences` (per Mac) or the `devices` section. + +--- + +## (F) UI changes per view + +| View or file | Change | +|---|---| +| `UprightApp.swift` (menu-bar label) | Filled when `summary.anyConnected`. "NN%" shows the lowest battery among connected mice, with a red tint at or below the critical level. The accessibility label lists every mouse: "Upright, MX Vertical connected 80 %, MX Master 3S asleep". | +| `MenuBarView.swift` | One row per pointer record in ordinal order: nickname or name, connection glyph (USB, Unifying, Bolt, Nano, Bluetooth), battery, state. Clicking a row opens Settings › Mice with it selected. The button summary is for `focusedDevice`, else the first connected. DPI presets and Easy-Switch act on that same device, with its name in the section header. "Reconnect Mouse" becomes "Reconnect All Mice". A new-model banner reads "New: MX Master 3S. Its buttons work as usual. Set up…". Connection hints add Bolt and Nano. | +| `SettingsView.swift` | The sidebar entry "Device" becomes "Mice". The other panes are unchanged. | +| `DevicePane.swift` → Mice pane | List and detail layout. The list has three sections: connected, then remembered offline (from `DevicePreferencesStore`), then a collapsed "Other Logitech devices" section with the unsupported reason ("no programmable buttons", "HID++ 1.0 device", "keyboard"). Duplicate names get "#2". The detail keeps today's header, battery and info rows, and adds: a nickname field; "Remap this mouse" (`remapEnabled`); Reconnect for this mouse; Forget (for offline mice); an "Untested model" badge when `verification != .hardware`; "Copy device report" (feature table, control table with cid, tid, flags and raw-XY, identity, connection) for GitHub issues. The failed-details block is per device. The Activity log prefixes each line with the device label ("MX Master 3S · Bolt slot 2") and has a device filter. | +| `PointerSpeedSection.swift` | Bound to the selected device's DPI and `DPIInfo`. Hidden when the device has no 0x2201. "Use Suggested" writes the global presets from the selected device. "Let the Mouse Decide" clears only this device's preference. | +| `Components.swift` (DeviceHeader, BatteryLabel, ConnectionHint) | Take a `Record` or snapshot parameter. Copy is model-neutral. The Input Monitoring callout is driven by `needsInputMonitoring` and names the blocked interface. | +| `ButtonsPane.swift` | A "Configure: [MX Vertical ▾]" model picker, listing connected models, then remembered models, then catalog models (so a mouse can be set up before it arrives). Rows come from `controls(for: selectedModel, address:)`. When two or more units of the model are present, a caption reads "Applies to every MX Vertical". The footer is generated from `ControlCatalog` text. A "Copy setup from…" menu and "Use this setup for all my mice" are added. The raw-XY note uses the selected device's controls. The empty state says "Connect a Logitech mouse". | +| `ButtonRow.swift`, `GestureEditor.swift` | Take `model:`. Names, symbols and details come from `ControlDescriptor(cid:model:)`. Rows flash only for `lastPressedControl` whose source belongs to the selected model. | +| `ProfileSection.swift` | The app-profile list is per model (`library[selectedModel].apps`). | +| `ActionPicker.swift` | Easy-Switch channels follow the selected device's `hosts.count` (falling back to 3 when offline). The Pointer Speed submenu is hidden when the selected device lacks 0x2201. "Set to N DPI" is validated against that device's range. | +| `GeneralPane.swift` | The import review shows the model per item, and adds the target-model picker for v2/v1 files. Battery-in-menu-bar copy says "lowest battery". | +| `OnboardingView.swift` | "Connect your Logitech mouse". "Suggested" applies `ButtonMappings.suggested(for: detected model)` to that model only. The Try-it line names the device ("MX Master 3S: Back → Cmd+[ "). | +| `PermissionsPane.swift` | The privacy text reads "Upright only listens to the HID++ channel of Logitech receivers and supported Logitech mice." The Input Monitoring row lists blocked interfaces. | +| `ScrollPane.swift` | "Only while a Logitech mouse is connected", with a note: "Applies to every mouse wheel. macOS doesn't say which mouse a scroll came from." The paused status says "Paused until a Logitech mouse connects". | +| `AboutPane.swift` | "Remaps Logitech mice over HID++. Built and tested on the MX Vertical." | +| `BatteryNotifier.swift` | `[DeviceAddress: BatteryAlertPolicy]`, with notification ID `upright.battery.` and the device name or nickname in the text. A policy is dropped when its address is removed, and seeded from the last level when a stable ID reconnects. | +| `SystemActionPerformer.swift` | Synthetic mouse buttons (down/up, click state, drag rewriter) and media-key repeats are refcounted per button or key, as `modifierHolds` already is. | +| `Localizable.xcstrings` | Sync every changed literal. The 11 "MX Vertical" keys are reworded, and new keys are added for Mice, unsupported reasons, banners and badges. English only, so there are no translations to migrate. | + +--- + +## (G) Test plan + +T = `UprightKit/Tests/UprightKitTests/`. All suites are XCTest on Linux and macOS, with Swift 6.0 strict concurrency as today. `@MainActor` tests are `async`, per commit 01c22a1. + +### G-1. Simulator extension (`T/Simulation/`) +- **`SimulatedDeviceProfile`** describes one mouse: name, `DeviceKind`, protocol version (including a HID++ 1.0 variant), wpid, bt and usb PIDs, 0x0003 unit ID and model IDs, serial, ordered features with versions, control table (cid, tid, flags, additional flags), DPI list and default, battery feature (0x1000, 0x1004 or 0x1001), host count. Event feature indices are looked up from the profile, never hard-coded. +- **`SimulatedReceiver`** has the Unifying, Nano and Bolt pairing layouts, slots 1–6, per-slot link state, 0x40 and 0x41, register 0x00 read and write, and 0x02 announcements. +- **Per-slot API:** `hold(cid, slot:)`, `release`, `move(dx, dy, slot:)`, `goToSleep(slot:)`, `wakeUp(slot:)`, `diverted(slot:)`, `rawXY(slot:)`, `dpi(slot:)`, `setDPINatively(slot:)`, `repair(from:to:)`, `unpair(slot:)`, `pair(profile, slot:)`. +- **Per-slot faults:** `dropsRequests(slot:)`, `latency(slot:)`, `cidErrors[(slot, cid)]`. +- **Per-slot counters:** `setReportingCount(slot:)`, `pingCount(slot:)`, `requestsInFlightMax` (asserts the gate). +- The Easy-Switch handler sleeps the addressed slot. +- **`SimulatedDirect`** wraps one profile at 0xFF for USB and Bluetooth. +- **`SimulatedMXVertical`** remains a facade with its current API, acting on the primary slot. The existing tests compile and pass unchanged. +- **`DeviceProfiles.swift`** fixtures, from `devices.verified.json` (high and medium confidence only): + - MX Vertical: 407B/B020/C08A, unit 0x1A2B3C4D. + - MX Vertical #2, a different unit. + - MX Master 3 and MX Master 3S: B034 Bolt, 0x1004, 0x1B04 v5, 0xC3 and 0xC4. + - MX Anywhere 3. + - Lift. + - M720: 0x5B, 0x5D, 0xD0, no 0x2201, 0x2205 only. + - MX Ergo: trackball. + - M185: Left and Right flagged divertable, no DPI. + - MX Keys: keyboard with 0x1B04. + - A generic HID++ 1.0 device. + - G502 Lightspeed-like: no 0x1B04. + - A no-unit-ID mouse. +- **`T/TestSupport.swift`** holds the shared helpers (`makeLink`, `eventually`, `pause`, `isConnected(at:)`, `records`), replacing the copies in ControllerTests and DeviceFeatureTests. + +### G-2. New suites (named tests) + +**`MultiDeviceControllerTests`** (HIDPPLink with the simulator): +- `testTwoMXVerticalsOnOneReceiverAreBothServed` +- `testMixedModelsOnUnifyingEachGetOwnDiversion` (Vertical in slot 1, Master 3 in slot 2, MX Keys in slot 3) +- `testKeyboardOnSharedReceiverIsNeverWritten` (zero setCidReporting to its slot) +- `testHIDPP10DeviceIsUnsupportedAndNotRetried` +- `testMouseWithoutReprogControlsIsTerminal` +- `testBoltReceiverPairingLayoutFindsBothMice` (Master 3S and Lift) +- `testNanoReceiverUsesUnifyingLayout` +- `testStartupProbesEverySlot` +- `testSleepingSlotDoesNotDelayOtherSlotBeyondOneTimeout` +- `testBackoffAndCountersArePerSlot` +- `testWakePingPongKeepsBothSessions` +- `testRePairByUnitIDRetiresOldSlotWithUndivert` +- `testUnpairRemovesOnlyThatSlot` +- `testDifferentWPIDInOccupiedSlotReplacesEndpoint` +- `testMissedLinkUpRecoveredFromReport` +- `testAnnouncementEchoSuppressionIsPerSlot` +- `testNotificationFlagsAreReadModifyWrittenOncePerLink` +- `testEasySwitchOnBLeavesALone` +- `testShutdownRestoresEverySlot` +- `testDroppedEndpointSendsRestore` +- `testConfigurationIsPulledBeforeFirstDiversion` +- `testOneRequestInFlightPerTransport` +- `testFirstRawXYReportDroppedOnReprogV5` +- `testUnsupportedExclusionClearsOnNewWPID` + +**`RemapEngineMultiSourceTests`:** +- `testSameCIDHeldOnTwoSourcesIsIndependent` +- `testInterleavedReleasesEndOnlyTheirOwnHold` +- `testLostReleaseHeuristicIsPerSource` +- `testGestureDeltasRouteBySource` +- `testCursorDeltasOnlyReachCursorGestures` +- `testReleaseAllSourceLeavesOthersHeld` +- `testDeviceActionCarriesSource` +- `testPerSourceThresholdCapturedAtPress` +- `testPerSourceMappingsProvider` +- `testCompatPressedControlsIsUnion` + +**`MappingLibraryTests`:** +- `testV2MigratesToMXVerticalEquivalentForEveryBundle` (golden, every action kind and gesture) +- `testV1LegacyMigratesIntoMXVertical` +- `testUpgradeWithoutDataGivesLegacyDefaultsOnMXVertical` +- `testFreshInstallIsEmptyForEveryModel` +- `testV2KeyNeverWrittenAfterMigration` +- `testPreV3BackupWrittenOnce` +- `testRoundTripPreservesUnknownModelsDevicesAndTopLevel` +- `testNewerSchemaNoteAndBackup` +- `testExportHasCompatMirrorAndNames` +- `testImportV3MergesPerModel` +- `testImportV2IntoChosenModel` +- `testUnknownModelResolvesToEmpty` +- `testAllActiveActionsUnionsModels` +- `testSuggestedPerModel` + +**`ConfigurationPlannerTests`:** +- `testMatchesTodaysSyncDiversionForMXVertical` (golden) +- `testNewModelIsNative` +- `testRemapDisabledKeepsOnlyHeldUntilRelease` +- `testStandbyIsNative` +- `testHeldKeepAliveIsPerDevice` +- `testRawXYOnlyForCapableControls` +- `testNeverDivertPrimaryClicksEvenIfFlaggedDivertable` (M185) +- `testDPISnappedToDeviceList` + +**`DeviceRegistryTests`:** +- `testOrderIsFirstSeenAndStable` +- `testSameUnitOnUSBAndReceiverPrefersUSB` +- `testStandbyPromotedWhenActiveLeaves` +- `testTransientIDsNeverDeduped` +- `testUnsupportedSortedLastAndNotAProblem` +- `testSummaryLowestBatteryAndAnyPresent` +- `testRemoveLinkRemovesAllItsEndpoints` + +**`DeviceIdentityTests`:** +- `testParseFirmwareIDsPacksByTransportBits` +- `testCanonicalModelKeyIsTransportIndependent` (MX Vertical over BT, USB and Unifying all give 046D:B020) +- `testCatalogAliasesMapToCanonical` +- `testDeviceIDFallbackChain` +- `testDeviceKindTables` + +**`InterfaceClassifierTests`:** +- `testUnifyingBoltNanoAccepted` +- `testLightspeedAnd27MHzIgnored` +- `testDirectMouseRangesAcceptedKeyboardRangesIgnored` +- `testBLETwinsWithEmptySerialStaySeparateByRegistryID` +- `testTwinInterfacesOfOneDeviceCollapse` + +**`CatalogTests`:** +- `testEveryCatalogPIDUniqueAcrossModels` +- `testMXVerticalEntryMatchesShippedBehaviour` +- `testEveryControlOrderCIDIsNamed` +- `testControlTextFallbacks` +- `testOldControlDescriptorInitKeepsMXVerticalWording` + +**`DevicePreferencesTests`:** +- `testLegacyPreferredDPISeedsMXVerticalOnce` +- `testFirstSeenOtherModelKeepsOwnDPI` +- `testTransientIDsNotPersisted` +- `testForget` + +**`EndToEndRoutingTests`** (real HIDPPLinks, simulator, DeviceRegistry, ConfigurationPlanner, RemapEngine; a small Kit-side `FleetHarness` mirrors the AppModel wiring): +- `testTwoHubsUSBVerticalAndReceiverMasterNoCrossTalk` +- `testBothHoldBackIndependentKeyEffects` +- `testDetachingADoesNotReleaseB` +- `testCycleDPIOnBChangesOnlyB` +- `testNativeDPIChangeOnANotPropagated` +- `testAppSwitchMidHoldOnAKeepsADivertedAndBFollowsNewApp` +- `testSeededLinkLossFuzzNeverLeavesStuckEffects` (1000 random steps across press, release, sleep, wake, unpair, re-pair and transport close; seeded, so failures reproduce; asserts that held effects end empty and every down has a matching up) + +### G-3. Regression + +All 163 existing tests stay green unchanged, except the following, which are deliberately rewritten: + +| Existing test | Becomes | +|---|---| +| `ControllerTests.testSecondLiveMXVerticalDoesNotStealTheSession` | `testSecondLiveMXVerticalGetsItsOwnSession` | +| `ControllerTests.testReceiverIgnoresOtherDevices` | `testReceiverIgnoresKeyboardButServesOtherMouse` (MX Keys untouched, MX Master served) | +| `ControllerTests.testReceiverUsesPairingInfoInsteadOfPingingEverySlot` | Keeps its intent, with pings only to unoccupied slots equal to 0. The `registerWriteCount(0x02) == 0` assertion stays; the 407B-only shortcut assertion is dropped. | +| `HIDPPMessageTests` classify cases (C077, "MX Master 3" unsupported) | Inverted against `InterfaceClassifier`. | +| `MappingPersistenceTests` schema pins (`schemaVersion 2` in export text; 3 used as the "newer" sample) | Pins move to 3, and the "newer" sample to 4. | +| `ScrollTransformTests` (`ScrollSettings(onlyWhileMXVerticalConnected:)`) | Uses the renamed initializer label. The Codable key test is unchanged. | + +**Named behaviours that must keep passing as-is** (they encode what the user tested on hardware): +- `testDirectConnectionDiscoversAndDivertsButtons` +- `testSleepingMouseIsReconfiguredWhenItWakes` +- `testLinkUpAndWirelessStatusTogetherReconfigureOnce` +- `testMouseThatArrivesLaterIsPickedUp` +- `testReportFromASilentlyWokenMouseTriggersReconnect` +- `testRefreshAfterMacWakeReappliesDiversion` +- `testDirectTimeoutKeepsSessionAndButtonsKeepWorking` +- `testUnansweringDirectMouseIsRetriedWithBackoff` +- `testAsleepDirectMouseRecoversThroughBackoffAlone` +- `testHeldButtonIsReleasedWhenTheMouseStopsAnswering` +- `testTransportRemovalReleasesHeldButtons` +- `testShutdownHandsButtonsBackToTheMouse` +- `testRePairToANewSlotMovesTheSession` (now passes through the unit-ID retire path; the simulator's `repair` keeps the unit ID) +- `testUnpairThenPairOnANewSlot` +- `testUnpairDropsTheSessionAndReleasesHeldButtons` +- every `MappingPersistenceTests` v1/v2 migration test +- `testRepeatedPressReleasesThePreviousOne` (single source) +- `testProfileSwitchMidHoldKeepsTheGestureFromThePress` +- `testRawThresholdIsAPhysicalDistance` + +### G-4. CI and hardware +- **CI:** `swift test` on macOS and in a `swift:6.0` Linux container. `make app` (Xcode 16.2, Debug and Release, hardened runtime, macOS 13 deployment target). +- **Hardware smoke checklist** on the user's MX Vertical, run over USB, Bluetooth and Unifying: + - mappings and DPI identical before and after upgrade; + - sleep and wake reconnect latency unchanged; + - quit restores native buttons; + - re-pair to another slot; + - `upright-probe --all` shows the same unit ID on Bluetooth and Unifying; + - with a borrowed second Logitech mouse, if available: two rows, independent Back. + +--- + +## (H) Implementation plan + +**Rules for every wave:** +- Owned files are disjoint within a wave. +- A task edits only the files it owns and codes against the §B signatures. +- Data comes only from `scratchpad/research/devices.verified.json`, using high and medium confidence entries. Placeholder entries ("NOT FOUND") and G-series mice are listed only as known-unsupported. +- Wave gates: + - Waves 0–3: `cd UprightKit && swift build && swift test`. The Kit stays green. + - Wave 4 onward: also `make app`. +- The app target is allowed to be red between the end of Wave 1 and the end of Wave 4, but only for the app-target files that Wave 4 owns. Compat shims keep it building as far as possible. +- No new dependencies; the Kit stays Foundation-only outside `Platform/`. + +### Wave 0: contracts and scaffold (sequential, 1 agent) + +| id | owner | files owned | deps | acceptance | +|---|---|---|---|---| +| W0 | opus | `K/Device/DeviceIdentity.swift` (complete §B1 plus `EndpointConfiguration`). Compiling stubs with the final signatures: `K/Device/{HIDPPLink, ReceiverDirectory, DeviceCatalog, ControlCatalog, InterfaceClassifier, DeviceRegistry, ConfigurationPlanner, DevicePreferences}.swift`, `K/Mapping/MappingLibrary.swift`. Also `K/Device/ButtonStateTracker.swift` (`source`, `PointerDelta`), `K/Device/MXVertical.swift` (`ConnectionKind.nano`), `Upright/App/DeviceManager.swift` (exhaustive `.nano` switch cases only), `Upright/Views/{DeviceListView, ModelPicker}.swift` (stubs), `Upright.xcodeproj/project.pbxproj` (register every new Kit file in the app target, and both new view files). | none | `swift build`, all 163 tests and `make app` green. Every new file is in pbxproj. The stubs `fatalError` nowhere on existing code paths. | + +### Wave 1: Kit foundations (parallel, all depend on W0) + +| id | owner | files owned | deps | acceptance | +|---|---|---|---|---| +| W1.1 Catalog | sonnet | `K/Device/DeviceCatalog.swift`, `K/Device/ControlCatalog.swift` (move `KnownControl` and `ControlDescriptor` here; keep the old `init(cid:)` wording), `T/CatalogTests.swift` | W0 | Models: MX Vertical (verification `.hardware`), MX Master 2S/3/3B/3S/4, MX Anywhere 2/2S/3/3B, MX Ergo, Lift (+B), M720, M585/M590, M650 L, M550, M575, M705, Pebble M350, M510 v2, M185 family. Receivers per D8. All 33 Solaar CID names. `modelKey(for:)` follows D5. CatalogTests green. | +| W1.2 Classifier and IOKit | sonnet | `K/Device/InterfaceClassifier.swift`, `K/Platform/IOKitHID.swift` (generic mouse matching dict, `HIDInterfaceDescriptor` from the device incl. `IORegistryEntryGetRegistryEntryID`, identity key via the classifier), `T/InterfaceClassifierTests.swift`, `T/HIDPPMessageTests.swift` (inverted classify cases) | W0 | D9 and the twin rules hold. `MXVertical.classify` is left in place (W5.4 removes it). Builds on Linux, with IOKit code still `#if canImport(IOKit)`. | +| W1.3 HID++ reads | opus | `K/HIDPP/{HIDPPFeature, DeviceFeatures, HIDPPDevice, HIDPPError}.swift`, `K/Device/ReceiverDirectory.swift`, `T/HIDPPIdentityTests.swift`, `T/ReceiverDirectoryTests.swift` | W0 | `DeviceFeatures.identity()` (0x0003 fn0, serial), `deviceKind()` (0x0005 fn2), 0x1001 battery voltage, feature names 0x1B00–0x1B03, 0x2202, 0x2205, 0x1C00. `HIDPPError.unsupportedDevice` added; `notMXVertical` is kept until W3.1. Pairing and 0x41 parsing for both layouts, and the notification-flag merge. DeviceIdentityTests and ReceiverDirectoryTests green. | +| W1.4 Engine | opus | `K/Mapping/RemapEngine.swift`, `K/Mapping/GestureRecognizer.swift`, `T/RemapEngineMultiSourceTests.swift` | W0 | §B8. MappingTests, GestureTests and ProfileTests pass **unchanged**. New suite green. | +| W1.5 Mapping v3 | opus | `K/Mapping/{MappingLibrary, MappingStore, MappingProfiles, ButtonMappings}.swift`, `T/MappingLibraryTests.swift`, `T/MappingPersistenceTests.swift` (only the pin updates), `T/ProfileTests.swift` (unchanged unless the compiler forces it) | W0 | §E load order and invariants. The golden equivalence test passes for every existing v2 fixture. Export round-trips. | +| W1.6 Simulator v2 | opus | `T/SimulatedMXVertical.swift` (facade), `T/Simulation/{SimulatedReceiver, SimulatedDeviceProfile, DeviceProfiles}.swift`, `T/TestSupport.swift` | W0 | The current suite passes against the **unchanged** controller. The new per-slot API compiles. Profiles are sourced from the verified JSON, with the source noted in comments. | +| W1.7 Preferences and scroll rename | sonnet | `K/Device/DevicePreferences.swift`, `T/DevicePreferencesTests.swift`, `K/Scrolling/ScrollSettings.swift` (Swift rename, Codable key kept), `T/ScrollTransformTests.swift` | W0 | §B10. Scroll Codable-key test unchanged and green. | + +### Wave 2: session, registry, planner (parallel) + +| id | owner | files owned | deps | acceptance | +|---|---|---|---|---| +| W2.1 Session | opus | `K/Device/DeviceSession.swift`, `K/HIDPP/ReprogControls.swift`, `T/DeviceFeatureTests.swift` | W1.1, W1.3, W1.6 | Read-only `start()` in §B3 order, with the capability gate and `.unsupportedDevice(reason, partial)`. Snapshot gets identity, address and reprogVersion. The raw-XY v5 first-report drop. Name fallback. Every event stamped with `source`. DeviceFeatureTests (24) green plus new identity and unsupported cases. | +| W2.2 Registry and planner | opus | `K/Device/DeviceRegistry.swift`, `K/Device/ConfigurationPlanner.swift`, `T/DeviceRegistryTests.swift`, `T/ConfigurationPlannerTests.swift` | W1.1, W1.7 | §B7. Planner golden-equal to today's `AppModel.syncDiversion` for an MX Vertical, re-derived from the current AppModel source and quoted in the test. Standby and promotion rules. | + +### Wave 3: hub and controller (sequential core, then parallel) + +| id | owner | files owned | deps | acceptance | +|---|---|---|---|---| +| W3.1 Hub and endpoint | opus | `K/Device/HIDPPLink.swift`, `K/Device/MXVerticalController.swift` (becomes `EndpointController` with the typealias), `K/HIDPP/HIDPPClient.swift` (FIFO gate with interactive and background lanes, `maxInFlight`), `K/HIDPP/HIDPPError.swift` (remove `notMXVertical`), `T/ControllerTests.swift`, `T/MultiDeviceControllerTests.swift` | W1.3, W1.6, W2.1 | D1, D10, D11, D12 and §C steps 3–5 and 11. All 29 ControllerTests green, with only the §G-3 rewrites. The MultiDeviceControllerTests list in §G-2 green. No name or PID gate is left (`grep -n "mx vertical\|407B" K/Device` returns only catalog data). | +| W3.2 Probe | sonnet | `UprightKit/Sources/upright-probe/Probe.swift`, `Makefile` (probe target help) | W3.1 | `--all` lists every Logitech HID++ interface, including unsupported ones, and dumps features and controls (cid, tid, flags, raw XY) plus identity, without needing classification. Every line is prefixed `[MX Master 3S · Bolt slot 2]`. Multiple devices are supported. `--divert` works per device. | +| W3.3 End-to-end | opus | `T/EndToEndRoutingTests.swift`, `T/Simulation/FleetHarness.swift` | W3.1, W2.2, W1.4, W1.5 | The §G-2 EndToEnd list green, including the seeded fuzz at 1000 steps under 20 s on Linux. | + +### Wave 4: app core (parallel; contract §B11 frozen) + +| id | owner | files owned | deps | acceptance | +|---|---|---|---|---| +| W4.1 DeviceManager | opus | `Upright/App/DeviceManager.swift` | W3.1, W2.2, W1.2 | §B11 DeviceManager. Per-link recovery and blocked interfaces. Per-device actions. Labelled log. Compat `status`, `snapshot` and `controls` derive from the primary record. `connectedController` deleted. Cross-controller DPI propagation deleted. | +| W4.2 AppModel and settings | opus | `Upright/App/{AppModel, AppSettings, PermissionsMonitor}.swift` | W1.4, W1.5, W1.7, W2.2, W4.1 contract | Engine providers wired per §C-7. `configurationProvider` uses the planner. Per-device resync with `appliedConfig[address]`. Library load and save. Legacy DPI seed. `selectedDevice`, `selectedModel`, `focusedDevice` and `knownModels`. Model-scoped bindings plus compat overloads. `PressedControl.source`. | +| W4.3 Peripherals | sonnet | `Upright/App/{BatteryNotifier, ScrollController, SystemActionPerformer}.swift`, `T/BatteryAlertPolicyTests.swift` (add per-device cases if `BatteryAlertPolicy` gains a helper; otherwise read-only) | W4.1 contract | Per-device battery policies and notification IDs. Scroll gate on `summary.anyPresent`. Refcounted mouse buttons and media repeats. | + +Gate: `make app` green. A manual run with the MX Vertical behaves as before; the Fable orchestrator runs this check. + +### Wave 5: UI and copy (parallel, depends on W4) + +| id | owner | files owned | deps | acceptance | +|---|---|---|---|---| +| W5.1 Mice UI | sonnet | `Upright/Views/{DevicePane, DeviceListView, PointerSpeedSection, Components, MenuBarView, SettingsView, SettingsWindowController}.swift`, `Upright/App/UprightApp.swift` | W4 | The §F rows for these files. No view reads the compat `status` or `snapshot`, except the primary-row fallback where documented. | +| W5.2 Buttons UI | sonnet | `Upright/Views/{ButtonsPane, ButtonRow, ProfileSection, GestureEditor, ActionPicker, ModelPicker, GeneralPane}.swift` | W4 | The §F rows. Editing a model never changes another model's table (verified by the reviewer). | +| W5.3 Onboarding and copy | sonnet | `Upright/Views/{OnboardingView, PermissionsPane, ScrollPane, AboutPane}.swift` | W4 | The §F rows. `grep -rn "MX Vertical" Upright/Views` matches only "Built and tested on the MX Vertical" and catalog-driven text. | +| W5.4 Kit cleanup | haiku | `K/Device/MXVertical.swift` (remove `matches`, `wirelessProductID`, `classify`, `mxVerticalDefaults`; keep `displayName`, `ConnectionKind` and `TransportKind`) | W4 | No remaining references (`grep`). Kit and app build. | + +### Wave 6: docs, strings, project, build (parallel, then sequential verification) + +| id | owner | files owned | deps | acceptance | +|---|---|---|---|---| +| W6.1 Strings | sonnet | `Upright/Localizable.xcstrings` | W5 | Every literal used in code has a key. Stale MX-only keys are removed. The JSON is valid, and Xcode's string catalog build step passes. | +| W6.2 Docs | sonnet | `README.md` (positioning, requirements, a supported-devices table with Verified on hardware: MX Vertical / Published data / Untested, the receivers table incl. Nano and the Lightspeed exclusion with the reason, limitations: per-model mappings, global scroll, cursor-fallback gestures, 0x2201-only DPI), new `docs/DEVICES.md` (generated from the catalog), `docs/HIDPP.md` (hub and endpoint architecture, pairing layouts, identity, capability gate, request gate), `INSTALL.md`, `CONTRIBUTING.md` (simulator profiles, the pbxproj checklist, how to add a model from a probe dump), `CHANGELOG.md` (Unreleased: multi-mouse, migration notes incl. downgrade behaviour), `.github/ISSUE_TEMPLATE/{bug_report, feature_request}.md` (model, number of mice, receiver layout, `upright-probe --all` output), new `.github/ISSUE_TEMPLATE/new_mouse.md`, `.github/pull_request_template.md` | W5 | No doc states "MX Vertical only" or "other mice out of scope". Every claim about non-MX-Vertical models is marked unverified on hardware. | +| W6.3 Project registration | sonnet | `Upright.xcodeproj/project.pbxproj`, plus `git mv K/Device/MXVerticalController.swift K/Device/EndpointController.swift` | W5 | The file rename is reflected in pbxproj. Every Kit source file is in the app target: a script diffs `find K -name '*.swift'` (excluding `upright-probe`) against the pbxproj file references and must be empty. | +| W6.4 Build and test | sonnet | none (read-only); writes logs to the scratchpad only | W6.1–W6.3 | `swift test` on macOS: 163 baseline (as adjusted per §G-3) plus every new suite green. `swift:6.0` Linux container `swift test` green. `make app` Debug and Release green, with hardened runtime and macOS 13 deployment target unchanged (`codesign -d --entitlements`, `otool -l` minos check). `Package.swift` has no new dependency. | + +### Wave 7: final audit + +| id | owner | files owned | deps | acceptance | +|---|---|---|---|---| +| W7 | opus | none (read-only) | W6 | Walks every inventory item (all four layers) and marks it fixed, accepted limitation or deferred, with file and line. Re-verifies: no cross-talk paths (search for any remaining CID-only keyed state); no path that drops an endpoint without release plus restore; the migration golden tests; the Swift 6.0 build; the hardened runtime. Produces the hardware smoke checklist (§G-4) for the user. | + +--- + +## (I) Risks, and what cannot be verified without hardware + +**Risks and mitigations** +1. **Controller refactor regressions.** The per-endpoint child keeps today's state machine. The 29 controller tests run unchanged through the facade simulator, and the user's "reconnects pretty fast" behaviour is covered by the named regression tests in §G-3. +2. **Request gate latency.** One request in flight per transport, with an interactive lane. In the worst case a diversion change waits one request timeout (about 1 s) behind a probe of a sleeping slot. It can be relaxed through `ControllerTuning.maxInFlight` once hardware shows that receivers accept concurrent slot traffic. +3. **Broader matching opens more interfaces.** Keyboard PID ranges are excluded at classification, Lightspeed and 27 MHz receivers are never opened, and unsupported endpoints are terminal and never written. A misclassified device is still never diverted, because a model without mappings is native. +4. **Model key drift.** A device lacking 0x0003 model IDs falls back to the transport PID, so the same mouse over two transports could get two keys. Catalog aliases cover all known models. For unknown models, "Copy setup from…" is the escape hatch and nothing is lost. +5. **Downgrade.** Older builds read the frozen v2 key: they get the user's pre-upgrade MX Vertical setup, not later edits. This is documented in the CHANGELOG. +6. **Cursor-fallback gestures can't be attributed to a mouse.** This only affects controls without raw XY, which is rare on MX-class mice, and is documented. +7. **Scroll rewriting is global** (CGEvent scroll events carry no source). Today's semantics are kept and documented. +8. **pbxproj drift.** Wave 0 registers new files, and W6.3 runs the diff check. +9. **Swift 6.0.** All new types are `Sendable`, callbacks are `@MainActor`, there are no tuple dictionary keys, and `@MainActor` tests are `async`. Every wave builds with the 6.0 toolchain. +10. **Options+ or Solaar running at the same time** can fight over diversion. Read-modify-write of the notification flags avoids clobbering their flags; detecting them is out of scope and documented. + +**Cannot be verified without hardware** (the user owns one MX Vertical). Each item has a mitigation path: + +| Item | How it gets verified or contained | +|---|---| +| Other models' Bluetooth/BLE HID++ interface matches the generic Mouse-usage or 0xFF43 entry, and uses device index 0xFF | `upright-probe --all` dumps from contributors; the README marks the model untested; the "Copy device report" button | +| Bolt pairing layout (0x50+n) and Bolt 0x41 behaviour | Implemented from Solaar, simulator-tested only; the announcement and ping fallbacks still find mice if the parse fails | +| Receivers tolerate interleaved requests to different slots | The gate keeps one in flight; this is today's single-mouse pattern | +| 0x0005 kinds and pairing nibbles on real firmware | Values verified against Solaar constants; the `mse`-flag fallback | +| 0x0003 unit ID identical across Bluetooth, USB and Unifying for the MX Vertical | **The user can verify this** with `upright-probe --all` over two transports. If it differs, only same-unit dedupe is lost | +| The raw-XY first-report quirk on 0x1B04 v5+ and raw-XY scale on non-Vertical models | Isolated in DeviceSession; gestures stay threshold-based in mm per device DPI | +| Whether MX mice keep their wireless link when cabled | Standby dedupe prevents double actions either way | +| BLE `SerialNumber` and `LocationID` reporting on macOS | Registry-ID fallback, plus registry dedupe as a backstop | +| Behaviour on Nano receivers with 1–2 slots | Slots 1–6 are scanned regardless; unanswered slots cost one gated ping each | +| Two physical mice at once | Simulator and end-to-end fuzz only. The README calls multi-mouse "simulator-verified; tested on hardware with one MX Vertical" until a second mouse is tried | diff --git a/docs/dev/NEXT-STEPS.md b/docs/dev/NEXT-STEPS.md new file mode 100644 index 0000000..bfd3cb3 --- /dev/null +++ b/docs/dev/NEXT-STEPS.md @@ -0,0 +1,61 @@ +# Multi-mouse support: handoff and next steps + +Last updated: 2026-10-14. Branch: `multi-mouse` (main stays at the last green release state). + +## Where things stand + +Goal: Upright works with any Logitech HID++ 2.0 mouse that has programmable buttons (REPROG_CONTROLS_V4), and with +several mice at once (any mix of USB, Bluetooth and Unifying/Bolt/Nano receivers, several mice on one receiver). + +Design: [`DESIGN-multimouse.md`](DESIGN-multimouse.md) (final, judged from three candidate architectures) plus +[`DESIGN-amendments.md`](DESIGN-amendments.md) (orchestrator overrides; they win). Demo Mode spec: +[`DEMO-MODE-SPEC.md`](DEMO-MODE-SPEC.md). Agent rules and CI lessons: [`AGENT-RULES.md`](AGENT-RULES.md). + +| Wave | Content | State | +|---|---|---| +| 0 | Contracts, stubs, pbxproj registration | Done, green, committed | +| 1 | Catalog, classifier + IOKit matching, HID++ identity reads, multi-source engine, mapping schema v3 + migration, Kit simulator, per-device preferences | Done, green (307 tests), committed | +| 2 | DeviceSession capability gate, DeviceRegistry, ConfigurationPlanner | Done, green (352 tests), committed | +| 3 | HIDPPLink hub + per-endpoint EndpointController, request gate, upright-probe `--all`, end-to-end routing tests + 1000-step fuzz | Done (383 tests); see the wave-3 commit for its gate result | +| 4 | App core: DeviceManager (records, LinkSource injection), AppModel (per-model bindings, planner, demo environment), peripherals | **Next** | +| 5 | UI: Mice pane, Buttons model picker, onboarding/copy, Kit cleanup | To do | +| 5b | Demo Mode + `--export-snapshots` (real-UI PNGs for the video/README) | To do | +| 6 | Strings catalog, docs (supported-devices table), pbxproj rename EndpointController.swift, full build/test | To do | +| 7 | Final Opus audit + hardware smoke checklist | To do | +| Video | 30 s launch video; pre-production done ([`launch-video-spec.json`](launch-video-spec.json)) | Waiting for wave 5b snapshots | + +The app target still builds through compatibility shims (`MXVerticalController`, compat `status`/`snapshot`), so the +current branch runs like before with one MX Vertical; the multi-mouse UI arrives in waves 4–5. + +## How to resume (next Claude Code session) + +1. Check out `multi-mouse`, run `swift test --package-path UprightKit` (expect 383+ green) and `make app`. +2. Run waves 4, 5 and 5b with the saved workflow script (it reads the design docs from `docs/dev/`): + `Workflow({ scriptPath: "/docs/dev/workflows/multi-mouse-app.js", args: { scratch: "", kitReport: "" } })` + It has a build/test/`make app` gate after each wave, a fix loop (max 2 rounds) and local checkpoint commits. +3. Wave 6 (parallel: strings, docs, project rename, then build/test) and wave 7 (final audit) per §H of the design. +4. Video: run `docs/dev/workflows/launch-video.js` with the same args the launch-video skill uses plus + `spec: ` (skips pre-production) once `media/snapshots/manifest.json` exists. + Brief/visuals/CTA text used for the spec is in the spec itself; size `M`, format `16:9`. +5. Merge `multi-mouse` into `main` once CI is green on the branch (Linux Swift 6.0 + macOS Xcode 16.2). + +## Hardware checks only the owner can do (one MX Vertical available) + +- `make probe ARGS=--all` over the receiver and over Bluetooth: confirms the 0x0003 unit ID is identical across + transports (same-unit dedupe), reports raw-XY capability per control, and that the generic IOKit matching still + finds the mouse. Read-only; does not divert buttons. +- After wave 4: run the app with the MX Vertical and confirm behaviour is unchanged (mappings migrated, fast reconnect + after sleep/power-cycle, no Input Monitoring prompt at launch). +- Everything about other models and two physical mice at once is simulator-verified only until a contributor with + that hardware reports `upright-probe --all` output (README marks those models "untested on hardware"). + +## Known decisions worth remembering + +- Support is decided by capability (HID++ ≥ 2.0, pointer kind, 0x1B04 with configurable controls), never by name/PID. + Keyboards are excluded by kind and PID range (anti-hijack); Lightspeed receivers are never opened. +- Runtime routing key `DeviceAddress(link, index)`; persisted key `DeviceID` (unit-based). Mappings are per model + (`ModelKey("046D:")`, PID-canonical from 0x0003); v2 data migrates into the MX Vertical model; v2 key frozen + for downgrades. +- One HID++ request in flight per transport with an interactive lane (unverified whether receivers tolerate more). +- CI gotchas: Swift 6.0 integer-literal typing; every test method in a `@MainActor` XCTestCase must be `async` for + Linux test discovery. diff --git a/docs/dev/launch-video-spec.json b/docs/dev/launch-video-spec.json new file mode 100644 index 0000000..0a2e9c7 --- /dev/null +++ b/docs/dev/launch-video-spec.json @@ -0,0 +1,81 @@ +{ + "duration": 30, + "titleCard": "Hook (0.0-3.0), type only on flat black: \"Your Logitech mouse,\" / \"without Logi Options+.\" (accent) / \"Meet Upright.\" There is no separate lockup scene. The real AppIcon appears only on the end card.", + "scenes": [ + { + "id": "hook", + "start": 0, + "end": 3, + "caption": "Your Logitech mouse, / without Logi Options+. / Meet Upright.", + "onScreen": "Centered type on flat #000, no UI. L1 \"Your Logitech mouse,\" in primary #F5F5F7, 104px/600. L2 \"without Logi Options+.\" in accent #0A84FF. Sub \"Meet Upright.\" at 40px dim #86868B, with \"Upright\" in primary bold. Logitech and Logi Options+ appear as plain nominative text only.", + "motion": "L1 enters at 0.15, L2 at 0.90 (0.5s outQuint, 12px rise, blur 6 to 0). The full hook is legible by 1.4. Sub enters at 2.0. All three exit together 2.70-2.95 (0.25s inCubic, opacity to 0, 10px rise out). SFX: soft ticks at 0.15 and 0.90.", + "visuals": "No real UI, no logos, no mice. Flat #000 background, theme dark. No strike-through or comparison treatment of Logi Options+, because the claims guardrail says no Options+ comparisons beyond the hook." + }, + { + "id": "remap", + "start": 3, + "end": 7, + "caption": "Every button. / Any action. -- Mission Control \u00b7 Spaces \u00b7 Media \u00b7 Open App \u00b7 Open URL \u00b7 Run Shortcut", + "onScreen": "Left column (x=120..860). Lead \"Every button.\" in primary and \"Any action.\" in accent, 68px/600. Sub is a two-line row of six typographic tokens (no pill backgrounds, so they do not read as app UI): Mission Control \u00b7 Spaces \u00b7 Media \u00b7 Open App \u00b7 Open URL \u00b7 Run Shortcut, 30px dim. The active token brightens to primary with a 2px accent underline. Right: the real Settings > Buttons window (dark) in a 960px-wide card.", + "motion": "Card enters 3.0-3.6 (y +40 to 0, scale .97 to 1, opacity, outQuint, soft shadow). Lead at 3.2, tokens at 3.6 (0.04s stagger). Active token steps every 0.5s: Mission Control 3.6, Spaces 4.1, Media 4.6, Open App 5.1, Open URL 5.6, Run Shortcut 6.1. Slow camera push z 1.0 to ~1.3 over 3.4-6.4 (inOutCubic) onto the button list. The highlight ring (2px accent, r10, 12px glow) appears at row 1 at 3.6 and steps down the rows in sync with the tokens (0.2s tween). It fades out 6.6-6.85. Caption exit 6.70-6.95. SFX: tick at 3.2.", + "visuals": "Asset buttons.global (dark), with row rects from manifest.json. Primary: single state, ring steps over the rows. Upgrade if demo mode exports label variants buttons.global.1-6 (Mission Control, Spaces, Media, Open App, Open URL, Run Shortcut): pin the ring on the Top Button row and swap its label in sync (0.12s crossfade plus 8px slide, changed region settles 1.015 to 1). Never animate a picker or menu that was not exported. Open NSMenu popups probably will not export. No fake UI." + }, + { + "id": "profiles", + "start": 7, + "end": 10.6, + "caption": "Switch apps. / Your buttons follow. -- Per-app profiles, applied automatically.", + "onScreen": "Lead \"Switch apps.\" in primary and \"Your buttons follow.\" in accent. Sub \"Per-app profiles, applied automatically.\" Real Settings > Buttons (dark) with an app profile selected: the \"Edit mappings for\" picker, the overridden rows, and the \"In effect now: the X profile\" line.", + "motion": "7.0: card content swaps to the profile state (0.12s crossfade plus settle). The camera glides from the list to the profile picker over 7.0-7.7 (inOutCubic). Lead 7.2, sub 7.6. Ring on the \"Edit mappings for\" picker 7.7-8.5. Pan down to the overridden rows (z ~1.3) 8.5-9.2 with an accent tint wash on those rows (0.35 to 0 over 0.5s). Ring on the \"In effect now\" line 9.3-10.2. Caption exit 10.30-10.55. Ring out by 10.45.", + "visuals": "Asset buttons.profile (dark), picked from installed apps so the icons render. Optional, only if demo mode exports a second real profile state (buttons.profile.2): swap at 8.9 with the tint wash, and add a mono (SF Mono 22px, dim, 1px connector) annotation chip naming the frontmost app, matching the app actually selected in each PNG. Without a second real state, show no chip, because a chip would imply a switch the image does not show." + }, + { + "id": "gestures", + "start": 10.6, + "end": 14.2, + "caption": "Hold. Move. Done. -- Five actions on one button: click, plus hold and move in four directions.", + "onScreen": "Lead \"Hold. Move. \" in primary and \"Done.\" in accent. Sub is the full script line, wrapped to 3 lines. Right: the real gesture editor, the Click, Up, Down, Left, Right grid, in the light theme. Below the copy at left: an abstract vector mouse silhouette (rounded capsule, 3px stroke, flat fill, one accent button zone), about 260px tall. No product detail, no logo.", + "motion": "Theme flip via a 0.4s feathered (~160px) wipe left to right, 10.5-10.9 (inOutCubic), covering bg, card PNG and text colors (render both themes, CSS mask). Accent becomes #0071E3. Card shows gesture-editor with the camera at z ~1.35 on the five-action grid. Lead 10.8, sub 11.2, silhouette enters 11.0 (scale .96 to 1). Ring plus silhouette action per row: Click 11.30 (ripple r8 to r44, 0.5s), Up 11.85, Down 12.40, Left 12.95, Right 13.50. For each directional action the silhouette's button zone fills accent for 0.1s, a dot travels 90px in that direction over 0.4s (inOutCubic, trail fades 0.3s), and the matching UI row gets a ring (in 0.18, hold ~0.3, out 0.25). The ring tweens between rows. Caption exit 13.90-14.15. SFX: gentle whoosh at 10.6, tick at 10.8.", + "visuals": "Asset gesture.editor (light), a button with all five actions filled. Silhouette is a vector drawn by the video lane and appears only in this scene. It is never drawn into captured UI. If the manifest lacks the five row rects, use ring rects measured by eye from the PNG and mark them for the audit." + }, + { + "id": "feel", + "start": 14.2, + "end": 18, + "caption": "Tune the feel. -- Pointer speed presets. Wheel scrolling, reversed and smoothed, independent of your trackpad.", + "onScreen": "Lead \"Tune the feel.\" Sub as scripted, wrapped to 3 lines. 14.2-16.1: real Settings > Device (light) with the pointer speed slider and presets. 16.1-18.0: real Settings > Scrolling (light) with the reverse, speed and smooth rows. The app's own Experimental label next to smooth scrolling must stay visible and must not be cropped out.", + "motion": "14.2: swap to the Device pane (0.12s crossfade plus settle). The camera glides to the pointer speed section (z ~1.3) over 14.2-14.9. Lead 14.4, sub 14.8. Ring on slider plus presets 14.9-15.95 (in 0.18, out 0.25). 16.1: swap to Scrolling (0.12s crossfade, 8px slide up), camera z ~1.2 on the option rows. Ring tweens: Reverse vertical scrolling 16.45-17.0, Speed 16.95-17.45, Smooth 17.4-17.85. Caption exit 17.70-17.95. SFX: tick at 14.4.", + "visuals": "Assets device.pointer (light) and scrolling (light, with reverse and smooth toggled on). Do not synthesize slider motion. Only if demo mode exports real variants device.pointer.800/1600/3200 may the compositor step through them (0.08s crossfades, DPI number pop 1.06 to 1 over 0.18s, at 15.0, 15.35, 15.7). Otherwise hold one static state. Scrolling is off by default and smooth is experimental, so it is not headlined." + }, + { + "id": "ready", + "start": 18, + "end": 22, + "caption": "Three mice. / One menu bar. -- Battery at a glance, with low-battery alerts. Back the moment they wake.", + "onScreen": "Lead \"Three mice.\" in primary and \"One menu bar.\" in accent. Sub \"Battery at a glance, with low-battery alerts. Back the moment they wake.\" Right: the real menu-bar panel (light) listing the simulated MX Vertical, MX Master 3S and Lift. The mouse names appear only as the app's own UI text. Optional status-item crop (mouse icon plus battery %) from 20.2.", + "motion": "18.0: the window card fades out (0.3s, scale .98) while the portrait panel enters (y +30 to 0, opacity, outQuint, fit height <=820, center x=1360). Lead 18.2, sub 18.6. Rings on the battery rows: MX Vertical 18.9, MX Master 3S 19.35, Lift 19.8 (0.45s each, tweened). If a low-battery state exists, two accent pulses on that row (period 0.5s) at 20.0-20.5. 20.2-22.0: cross-move to a crop of the status-item battery % (0.7s inOutCubic), ring pulse at 20.8, hold. Caption exit 21.70-21.95. SFX: tick at 18.2.", + "visuals": "Asset menubar.panel (light) with 3 mice. Optional menubar.panel.lowbattery (one mouse red at <=10%, Charging or approximate level) and menubar.label (status-item icon plus %, requested at 6x so it holds at 1:1). Fallback chain for 20.2-22.0: (1) menubar.label, (2) the Device pane activity-log line \"Mac woke up; reapplying button configuration\" if scripted in demo mode, supporting the wake claim, (3) a zoom on the low-battery or battery rows of the panel. Never draw a system menu bar and never fake a notification banner (system UI). Low-battery alerts (15% and 5%) are mentioned in the sub only." + }, + { + "id": "clean", + "start": 22, + "end": 25, + "caption": "Nothing left behind. -- No kernel extension. No daemon. No account. / (23.6) Nothing is written to the mouse. Quit, and every button goes back.", + "onScreen": "Lead \"Nothing left \" in primary and \"behind.\" in accent. Sub 1 (22.6): \"No kernel extension. No daemon. No account.\" Sub 2 (23.6): \"Nothing is written to the mouse. Quit, and every button goes back.\" Real Settings > General (dark), Behavior section, with the app's own lines \"Sleep and wake: Reapplied automatically\" and \"When Upright quits: Buttons return to their defaults\".", + "motion": "Theme returns to dark via a mirrored 0.4s wipe, right to left, 21.9-22.3. Panel exits (22.0, y -10, opacity) and the General window card enters 22.0-22.5 (y +40 to 0, scale .97 to 1). Lead 22.2, sub 1 at 22.6, sub 2 swaps in at 23.6 (0.2s crossfade, 8px slide). Camera pushes z 1.0 to ~1.4 onto Behavior 22.4-23.1. Ring on \"Sleep and wake\" 22.8-23.5 (also proof of the wake claim). Ring on \"When Upright quits\" plus the app's memory line, if present, 23.6-24.6. Caption exit 24.70-24.95. SFX: soft whoosh (lower) at 22.0, tick at 22.2.", + "visuals": "Asset general.behavior (dark). Gate before render: grep GeneralPane.swift for the exact strings. If \"nothing is written to the mouse's memory\" is absent, or the audit finds that pointer speed (DPI) persists in the device, change sub 2 to the script's wording \"Quit, and every button goes back to the mouse.\" Nothing here is drawn." + }, + { + "id": "end", + "start": 25, + "end": 30, + "caption": "Upright -- Free & open source \u00b7 MIT -- github.com/dpdev69/upright", + "onScreen": "Dark flat bg, centered stack. Optional real AppIcon (112px, from Upright/Assets.xcassets/AppIcon.appiconset, never redrawn). Wordmark \"Upright\" 96px/600. \"Free & open source \u00b7 MIT\" 36px dim. Primary line \"github.com/dpdev69/upright\" 64px/600 in accent, with a 3px accent underline. \"Download the DMG from GitHub Releases\" 30px dim. \"or build it\" 26px dim, then a mono code card (SF Mono 28px, ~1300px wide, faint fill, r14): git clone https://github.com/dpdev69/upright && cd upright && make app. \"Requires macOS 13+\" 28px dim, optionally joined with \" \u00b7 MX Vertical, MX Master, Lift and more\". Footer 22px dim: \"Not affiliated with Logitech. Logitech, MX and Logi Options+ are trademarks of their owners.\"", + "motion": "Everything enters with outQuint, 0.45s, 12px rise, blur 6 to 0. 25.0 icon plus wordmark, 25.4 tagline, 26.0 URL (SFX: low settle), underline wipes in 26.3-26.7, 26.7 DMG line, 27.2 \"or build it\" plus code card (y +16 to 0), 27.9 requires line, 28.3 footer. All visible by ~28.75, then hold. Content fades out 29.7-30.0 (0.3s, content only, background stays flat dark; last frame t=29.967).", + "visuals": "Type only, plus the optional real AppIcon. No Logitech logo, no product photo, no mouse. The mice list appears on the Requires line only if the README or the demo device list confirms it at render time. If not, drop it; the timing is unchanged." + } + ], + "endCard": "Upright (wordmark, optional real AppIcon) / Free & open source \u00b7 MIT / github.com/dpdev69/upright (accent, large) / Download the DMG from GitHub Releases / or build it: git clone https://github.com/dpdev69/upright && cd upright && make app (mono code card) / Requires macOS 13+ (optionally \u00b7 MX Vertical, MX Master, Lift and more, gated on README or device-list confirmation) / footer: Not affiliated with Logitech. Logitech, MX and Logi Options+ are trademarks of their owners. Timing 25.0 / 25.4 / 26.0 / 26.7 / 27.2 / 27.9 / 28.3, hold, content-only 0.3s fade-out 29.7-30.0.", + "posterAt": 5.4, + "posterNotes": "POSTER: frameAt(5.4) rendered with poster:true. It keeps B1 (dark Settings > Buttons card, camera mid-push, ring on a row, \"Open App\" token active) but replaces the lead with the hook \"Your Logitech mouse, / without Logi Options+.\" with \"Logi Options+\" in accent. This gives one still with real UI and the hook. A type-only frame at 2.4 would make a weak thumbnail. Export as a 1920x1080 PNG.\n\nGLOBAL SPEC\n- Canvas 1920x1080, 30fps, 900 frames, t=i/30, i=0..899. Pure frameAt(t). Pass 1 renders or resolves real UI states from the manifest. Pass 2 composites in HTML and ffmpeg encodes mp4 (H.264, yuv420p) plus the poster PNG.\n- Tiling: hook 0-3, remap 3-7, profiles 7-10.6, gestures 10.6-14.2, feel 14.2-18, ready 18-22, clean 22-25, end 25-30. No gaps.\n- Themes: dark 0-10.6, light 10.6-22.0, dark 22.0-30. Flip wipes at 10.5-10.9 (L to R) and 21.9-22.3 (R to L, mirrored). Plain flat or soft-radial bg only. Dark bg #0B0B0D (hook #000) with radial #17171C behind the card. Light bg #F5F5F7 with radial #FFFFFF. Text primary #F5F5F7 on dark and #1D1D1F on light, dim #86868B. Accent #0A84FF on dark and #0071E3 on light, via the .a class. Use .dim for secondary text.\n- Type: SF Pro Display. Hook 104px/600. Caption lead 68px/600, tracking -0.02em, line-height 1.08. Sub 30px/400 dim. Left column x=120..860, vertically centered on y~540. Annotation chips use SF Mono 22px so nothing reads as app UI.\n- Caption rule (every beat except hook and end): lead enters at start+0.2, sub at start+0.6 (0.45s outQuint, 12px rise, blur 6 to 0). Both exit together over 0.25s (inCubic) ending at end-0.05. Easings: outQuint for entrances, inCubic for exits, inOutCubic for camera (0.7-0.8s).\n- Layout: Settings window PNG (1560x1120 at 2x) sits in a persistent 960px-wide rounded card (r14, soft shadow added by the compositor), center (1360,540), from 3.0 to 18.0. The portrait menu panel fits to height <=820 at center x=1360. Camera layer {u,v,z}. Cap z so displayed px per PNG px is <=1.1 (about z<=1.75 for the window, lower for the panel). Ask the exporter for 3x or 6x instead of upscaling. Assets that need to be big (menubar.label) are requested at 6x.\n- Hard state swap: 0.12s linear crossfade, with a 1.015 to 1 settle on the changed region (0.2s outQuint). Highlight ring: 2px accent outline, r10, 12px glow, in 0.18 (outQuint), hold, out 0.25, tweens between targets over 0.2. Rings come from manifest rects. Rings, zooms, crops, pans and highlights are the only permitted edits to real PNGs.\n- Audio (no voiceover): soft bed plus subtle ticks. Tick on each lead entrance (0.15, 0.90, 3.2, 7.2, 10.8, 14.4, 18.2, 22.2). Gentle whoosh at 10.6 (a lower one at 22.0). Low settle at 26.0 on the URL.\n\nDEMO-MODE STATE IDS (dark and light each, 2x PNG plus manifest.json with row, slider and toggle rects)\nEssential: buttons.global, buttons.profile (with \"In effect now\" and overridden rows), gesture.editor (5 rows filled), device.pointer (slider, presets, device list), scrolling (reverse and smooth on), menubar.panel (MX Vertical, MX Master 3S, Lift, battery rows), general.behavior.\nNice to have: buttons.global.1-6 label variants; buttons.profile.2; device.pointer.800/1600/3200; menubar.panel.lowbattery; menubar.label (6x); a scripted activity-log line for wake. Fallbacks are in each scene's visuals. Single state plus ring, zoom and pan always works.\n\nSTATUS FINDINGS (checked just now): Demo Mode has NOT landed. grep for export-snapshots and --demo over Upright/, UprightKit/ and the Makefile returns nothing, and there is no exported manifest. Only Upright/Assets.xcassets/AppIcon.appiconset exists as a real asset. Do not run the built app with --demo yet: it would start a normal second instance that fights the user's running /Applications/Upright.app, which is diverting their real mouse. Never capture the real app (its Device pane shows a real serial number). Do not edit Swift sources or the Xcode project. Re-run the asset inventory against the real manifest when the demo flag lands. The proof-of-concept capture technique (same-process CGWindowListCreateImage on an opaque neutral backdrop) is in the assets notes.\n\nCONFLICTS RESOLVED (brief wins)\n1. Several mice: the flow notes said not to name multiple models and to use \"Built for the MX Vertical\" only. The brief explicitly wants several mice and names MX Master, Lift, etc. Resolution: keep the multi-mouse beat; names appear only as real UI text in the demo device list (MX Vertical, MX Master 3S, Lift). The end-card line \"MX Vertical, MX Master, Lift and more\" is included only if the README or device list confirms it at render time. M720 and Anywhere are never named on screen unless confirmed.\n2. \"Nothing written to the mouse\": the script omitted it over a DPI-persistence doubt. The brief lists it. Resolution: included in sub 2 of B6, gated by the app's own General pane wording. Fallback string provided if the audit finds DPI persists.\n3. \"Reconnects instantly\": the brief says instantly, the README says retried with backoff. Resolution: \"Back the moment they wake\", backed by the app's \"Reapplied automatically\" line (ringed in B6). Deliberate softening of \"instantly\".\n4. Beats and themes: script's 8-scene, 30s timing (6 beats plus hook and end card) over the flow lane's 9 scenes. The flow lockup is folded into the hook sub and end card, and its Trust scene into B6. The script's dark/light flip at 10.6 and return at 22.0 is kept, and the brief asks for light and dark.\n5. Profiles proof: Settings > Buttons profile state (script) instead of the flow's menu-bar header swaps. Panel variants are used only if exported as real states.\n6. Remap: ring-stepping (script) plus label swaps (flow), the latter only with real variants.\n7. Slider stepping: forbidden as synthesis (script), allowed as a sequence of real exported states (flow).\n8. Motion and typography: script's caption timing offsets, flow's outQuint and blur easing. Lead 68px (flow) instead of 84px, because 84px does not fit the left column beside a 960px card.\n9. End: script's 0.3s fade-out kept, but content-only (flow said no fade to black). End-card beat times tightened by 0.1-0.3s to give about 1s of full hold.\n\nCLAIM GATES FOR THE FINAL OPUS AUDIT\n- Check GeneralPane.swift for the exact strings quoted in B6. Check the README and CHANGELOG for no kext, no daemon, no account, MIT, macOS 13+. LICENSE is MIT.\n- No telemetry claim is made beyond \"No account\". No Options+ comparison beyond the hook.\n- No Logitech logos or product photography. The only mouse art is the abstract silhouette in B3.\n- The disclaimer is on the end card. The Experimental label stays visible in the Scrolling shot.\n- Fake-UI check: every UI pixel comes from exported PNGs. The only overlays are rings, tints, annotation chips, tokens and the silhouette." +} \ No newline at end of file diff --git a/docs/dev/workflows/launch-video.js b/docs/dev/workflows/launch-video.js new file mode 100644 index 0000000..ac91fae --- /dev/null +++ b/docs/dev/workflows/launch-video.js @@ -0,0 +1,113 @@ +export const meta = { + name: 'launch-video', + description: 'Launch video: Sonnet pre-production in parallel, Opus builds and renders, Sonnet QA', + whenToUse: 'Run from the launch-video skill with args {brief, size, projectDir, visuals, cta, formats}', + phases: [ + { title: 'Pre-production', detail: 'Sonnet lanes: script, flow, assets, CTA', model: 'sonnet' }, + { title: 'Spec', detail: 'merge lanes into one shot list', model: 'sonnet' }, + { title: 'Build', detail: 'Opus scaffold, scene builders, render', model: 'opus' }, + { title: 'QA', detail: 'Sonnet checks frames against the spec, Opus fixes', model: 'sonnet' }, + ], +} + +const A = args || {} +const SIZE = ['S', 'M', 'L'].includes(A.size) ? A.size : 'M' +const DIR = A.projectDir +const FORMATS = A.formats || ['16:9'] +const CODE = `${DIR}/scripts/launch` +const REF = '~/Documents/claude/projects/claude-tekken-hud/scripts (video.ts, render.ts)' +const CONTEXT = `Product launch video. +Brief: ${A.brief} +Project: ${DIR} +Real visuals: ${A.visuals} +CTA / end card: ${A.cta} +Formats: ${FORMATS.join(', ')} +Size: ${SIZE} (S ≈ 15s teaser, M ≈ 30s launch, L ≈ hero cut) +Reference pipeline to copy: ${REF}. It uses Playwright-core with the system Chrome, a pure frameAt(t), two passes (real UI states, then a 1920x1080 composite), and ffmpeg to mp4 plus a poster PNG.` + +const LANES = { + script: 'Write the script: the hook in the first 2s, 4-7 beats, and one short punchy caption per beat (HTML allowed for color spans), each with start/end seconds. Match the tone in the brief.', + flow: 'Design the flow and storyboard: scenes with start/end seconds, what is on screen, motion and transitions (easing, scale, shake, flash), and how real UI state changes over time. Give exact numbers.', + assets: 'Inventory the real visuals. Read the project, list each UI state or screenshot needed, and say exactly how to produce each one (which function or command). Try one capture to prove it works. Do not fake UI.', + cta: 'Write the title card, end card (CTA and URL) and poster frame (which moment, which overlay). If 9:16 is in the formats, also describe how each scene reframes for vertical.', +} +const lanes = { S: ['script', 'assets'], M: ['script', 'flow', 'assets'], L: ['script', 'flow', 'assets', 'cta'] }[SIZE] +if (SIZE === 'S') LANES.script += ' ' + LANES.flow + ' ' + LANES.cta + +let spec = A.spec +if (!spec) { +phase('Pre-production') +const notes = await parallel(lanes.map(k => () => + agent(`${CONTEXT}\n\nYour lane: ${k.toUpperCase()}. ${LANES[k]}\nReturn concise, concrete notes. Other lanes cover the rest.`, + { label: `pre:${k}`, phase: 'Pre-production', model: 'sonnet' }))) + +const SCENE = { + type: 'object', + properties: { + id: { type: 'string' }, start: { type: 'number' }, end: { type: 'number' }, + onScreen: { type: 'string' }, caption: { type: 'string' }, motion: { type: 'string' }, visuals: { type: 'string' }, + }, + required: ['id', 'start', 'end', 'onScreen', 'motion', 'visuals'], +} +const SPEC = { + type: 'object', + properties: { + duration: { type: 'number' }, + scenes: { type: 'array', items: SCENE }, + titleCard: { type: 'string' }, endCard: { type: 'string' }, + posterAt: { type: 'number' }, posterNotes: { type: 'string' }, + }, + required: ['duration', 'scenes', 'endCard', 'posterAt'], +} + +phase('Spec') +spec = await agent(`${CONTEXT}\n\nMerge these pre-production notes into one shot list. Resolve conflicts in favor of the brief. Scenes must tile 0..duration with no gaps.\n\n${ + lanes.map((k, i) => `## ${k}\n${notes[i] || '(lane failed: fill the gap yourself)'}`).join('\n\n')}`, + { label: 'spec', phase: 'Spec', model: 'sonnet', schema: SPEC }) +} +log(`spec: ${spec.scenes.length} scenes, ${spec.duration}s`) +if (A.stopAfter === 'spec') return { spec } + +phase('Build') +const SPEC_TXT = JSON.stringify(spec, null, 2) +if (SIZE === 'S') { + await agent(`${CONTEXT}\n\nBuild and render the whole video from this spec in ${CODE}/ (entry: ${CODE}/video.ts). Write the outputs to ${DIR}/media/. Install deps only if missing (playwright-core, tsx).\n\nSpec:\n${SPEC_TXT}`, + { label: 'build', phase: 'Build', model: 'opus' }) +} else { + // M: about 3 scenes per Opus builder. L: one builder per scene. + const groups = SIZE === 'L' ? spec.scenes.length : Math.ceil(spec.scenes.length / 3) + const chunks = Array.from({ length: groups }, (_, g) => spec.scenes.filter((_, i) => i % groups === g)) + await agent(`${CONTEXT}\n\nWrite the scaffold only, in ${CODE}/: +- video.ts: compositor, frame loop, ffmpeg, poster, and one output per format (${FORMATS.join(', ')}). +- shared helpers and title/end cards. +- a scene contract: scenes/.ts exports a function from local time to that scene's frame state, plus any page HTML/CSS it needs. +- real-UI capture (pass 1). +Make stub scene files for: ${spec.scenes.map(s => s.id).join(', ')}. Stubs must render as blank frames so the pipeline runs end to end. Run it once on a short range to prove the scaffold.\n\nSpec:\n${SPEC_TXT}`, + { label: 'scaffold', phase: 'Build', model: 'opus' }) + await parallel(chunks.map((c, g) => () => + agent(`${CONTEXT}\n\nThe scaffold is in ${CODE}/. Read video.ts and the scene contract first. Implement ONLY these scene files: ${c.map(s => `scenes/${s.id}.ts`).join(', ')}. Don't touch other files. If the contract needs a change, describe it in your reply instead.\n\nYour scenes:\n${JSON.stringify(c, null, 2)}`, + { label: `scene:${c.map(s => s.id).join('+')}`, phase: 'Build', model: 'opus' }))) + await agent(`${CONTEXT}\n\nThe scenes are implemented in ${CODE}/scenes/. Integrate: apply any contract changes the builders reported, smooth the transitions between scenes, then run the full render to ${DIR}/media/.\n\nSpec:\n${SPEC_TXT}`, + { label: 'integrate', phase: 'Build', model: 'opus' }) +} + +phase('QA') +const ISSUES = { + type: 'object', + properties: { + outputs: { type: 'array', items: { type: 'string' } }, + issues: { type: 'array', items: { type: 'object', properties: { at: { type: 'number' }, problem: { type: 'string' } }, required: ['at', 'problem'] } }, + }, + required: ['outputs', 'issues'], +} +const qa = () => agent(`${CONTEXT}\n\nQA the render in ${DIR}/media/. If it's missing, run ${CODE}/video.ts. Use ffmpeg to extract a frame at each scene's midpoint and at posterAt, look at each image, and compare it to the spec. Report only visible problems: wrong or illegible text, clipping, fake or missing UI, timing off by more than 0.3s, artifacts. List the output file paths.\n\nSpec:\n${SPEC_TXT}`, + { label: 'qa', phase: 'QA', model: 'sonnet', schema: ISSUES }) +let report = await qa() +for (let round = 0; report && report.issues.length && round < (SIZE === 'L' ? 2 : 1); round++) { + log(`fix round ${round + 1}: ${report.issues.length} issues`) + await agent(`${CONTEXT}\n\nFix these QA issues in ${CODE}/ and re-render:\n${JSON.stringify(report.issues, null, 2)}`, + { label: `fix:${round + 1}`, phase: 'QA', model: 'opus' }) + report = await qa() +} + +return { spec, code: CODE, ...report } diff --git a/docs/dev/workflows/multi-mouse-app.js b/docs/dev/workflows/multi-mouse-app.js new file mode 100644 index 0000000..f2b2613 --- /dev/null +++ b/docs/dev/workflows/multi-mouse-app.js @@ -0,0 +1,100 @@ +export const meta = { + name: 'multi-mouse-app', + description: 'Implement multi-mouse waves 4-5 (app core, UI, demo mode + snapshot export) with gates, fix loops and local commits', + phases: [ + { title: 'Wave 4', detail: 'DeviceManager, AppModel/settings, peripherals (parallel)' }, + { title: 'Wave 5', detail: 'Mice UI, Buttons UI, onboarding/copy, Kit cleanup (parallel)' }, + { title: 'Wave 5b', detail: 'Demo mode + snapshot exporter, then export run + verification' }, + { title: 'Gates', detail: 'swift test + make app gate, fix loop, local commit after every wave' }, + ], +} + +const ROOT = '/Users/dpercinel/cursor/mouseapp/Untitled/Upright-macos-source' +const S = (args && args.scratch) || '/tmp/upright-scratch' // pass the new session's scratchpad via args.scratch +const KIT_REPORT = (args && args.kitReport) || '(no kit report passed)' +const BASE = `Project: ${ROOT} (git repo; macOS 13+ SwiftUI app "Upright" + SwiftPM package UprightKit). You are implementing multi-mouse / multi-model support, app side. +Read FIRST: ${ROOT}/docs/dev/DESIGN-multimouse.md (esp. §A decisions, §B11 app contracts, §C data flow, §F UI changes, §H Waves 4-5), ${ROOT}/docs/dev/DESIGN-amendments.md (orchestrator overrides — they win), ${ROOT}/docs/dev/DEMO-MODE-SPEC.md, and the "Toolchain constraint" + "CI lessons" sections of ${ROOT}/docs/dev/AGENT-RULES.md. +The UprightKit side (waves 0-3: identity, catalog, classifier, registry/planner, session, hub + EndpointController, mapping v3, multi-source engine, Kit simulator) is DONE and committed — read the actual Kit code for the real signatures (they may deviate slightly from §B; the Kit code wins). Kit workflow summary: ${KIT_REPORT} +K = UprightKit/Sources/UprightKit/, T = UprightKit/Tests/UprightKitTests/. +Rules: edit ONLY the files your task owns (plus new files it creates — list them; the orchestrator registers new app files in project.pbxproj unless your task owns it). Use your own SwiftPM scratch path (${S}/build-) and for the app use \`xcodebuild -project Upright.xcodeproj -scheme Upright -configuration Debug -derivedDataPath ${S}/dd- CODE_SIGN_IDENTITY=- build\` (or scratchpad/typecheck.sh-style swiftc -typecheck). Files outside your ownership may be mid-edit by parallel agents: if a build fails ONLY there, wait ~1 min and retry (≤5), then report — never edit them. No git state changes. Don't run the app against real hardware (no plain launches of Upright, no upright-probe --divert). Strings must be localizable (String(localized:) / LocalizedStringKey; NSLocalizedString in Kit). macOS 13 target; Swift 6.0 compatible. +Final report: files changed/created, API changes, tests added, verification output, deviations, needs from other tasks.` + +const GATE_SCHEMA = { + type: 'object', + properties: { + green: { type: 'boolean' }, + testsExecuted: { type: 'integer' }, + failures: { type: 'array', items: { type: 'string' } }, + risks: { type: 'array', items: { type: 'string' }, description: 'Swift 6.0/Linux risks, unregistered new app files, IOKit use in demo paths, CID-only keyed state left in app code' }, + notes: { type: 'string' }, + }, + required: ['green', 'failures', 'risks'], +} + +async function gate(wave, extra) { + for (let round = 0; round < 3; round++) { + const g = await agent(`${BASE}\n\nYou are the GATE for ${wave}. Do not edit source files except project.pbxproj registration of new app/Kit Swift files that are missing from it (check: every .swift under Upright/ and K/ except upright-probe must be in the Upright target's Sources phase exactly once; register missing ones following existing entry style; plutil -lint must pass). From ${ROOT} run: \`swift test --package-path UprightKit --scratch-path ${S}/build-gate\`, then \`make app\` (Release). ${extra || ''} Report every compile error/warning in changed files and failing test precisely. Review \`git diff --stat\` for the risks listed in the schema. green = all builds succeed, all tests pass, no risks.`, + { label: `gate:${wave}:${round}`, phase: 'Gates', schema: GATE_SCHEMA, model: 'sonnet' }) + if (!g) return { green: false, failures: ['gate agent failed'], risks: [] } + log(`${wave} gate round ${round}: ${g.green ? 'GREEN' : 'RED'} (${g.testsExecuted ?? '?'} tests, ${g.failures.length} failures, ${g.risks.length} risks)`) + if (g.green) { + await agent(`In ${ROOT}: the user asked for this project's work to be committed to git, and the orchestrator has authorized local checkpoint commits at every green wave gate. Commit the current state as a local checkpoint now (do NOT push). Check \`git status --short\` first; nothing outside project sources/docs/tests/media may be staged (no build products). Then \`git add -A && git commit -m "Multi-mouse: ${wave}" -m "Co-Authored-By: Claude Opus 5.5 "\`. Report the hash and file list.`, + { label: `commit:${wave}`, phase: 'Gates', model: 'sonnet' }) + return g + } + if (round === 2) return g + await agent(`${BASE}\n\nYou are the FIXER for ${wave}. The wave's tasks are finished; no other agent is editing, so you may edit ANY file needed. Fix with minimal, design-consistent changes (never weaken tests to pass; explain any expectation change):\nFailures:\n${g.failures.join('\n')}\nRisks:\n${g.risks.join('\n')}\nNotes: ${g.notes || ''}\nVerify (swift test + make app), then report.`, + { label: `fix:${wave}:${round}`, phase: 'Gates', model: 'opus' }) + } +} + +// ---------- Wave 4 ---------- +phase('Wave 4') +await parallel([ + () => agent(`${BASE}\n\nTask W4.1 DeviceManager: owns Upright/App/DeviceManager.swift (+ a new Upright/App/LinkSource.swift if you split the source protocol out). §B11 DeviceManager contract: records/summary/blockedInterfaces/needsInputMonitoring, snapshot(_:), controls(for:address:), perform(_:on:presets:), setDPI(_:on:), switchToHost(_:on:), setConfiguration(_:for:), reconnect(_:), refreshDeviceInfo(_:), configurationProvider, onButtonEvent/onPointerDelta/onEndpointsRemoved/onDeviceDPIChange; per-link recovery; blocked interfaces; per-device actions; labelled log ("MX Master 3S · Bolt slot 2: ..."); compat status/snapshot/controls/isConnected derived from the primary record until Wave 5; delete connectedController and cross-controller DPI propagation. Amendment A2: interface discovery through an injectable LinkSource (IOKit default; a simulated source for Demo Mode built from K/Simulation) — with a non-IOKit source never touch IOKit (no matching/open/IOHIDRequestAccess). Keep the user-validated behaviours: no Input Monitoring prompt at launch, fast reconnect after wake, recovery backoff.`, { label: 'W4.1', phase: 'Wave 4', model: 'opus', effort: 'high' }), + () => agent(`${BASE}\n\nTask W4.2 AppModel and settings: owns Upright/App/{AppModel, AppSettings, PermissionsMonitor}.swift (+ new Upright/App/AppEnvironment.swift if needed). §B11 AppModel contract and §C-7: engine providers per source; configurationProvider via ConfigurationPlanner; per-device resync with appliedConfig[address]; MappingLibrary load/save (v3); legacy DPI seed into DevicePreferences; selectedDevice, selectedModel, focusedDevice, knownModels; model-scoped binding/overrideBinding + compat overloads (no model → selectedModel); copySetup/applySetupToAllModels; preferences(_:)/forget(_:); PressedControl.source; per-device gesture thresholds from that device's DPI; raw-XY fallback per device. Amendment A2: AppEnvironment .live/.demo — demo uses its own defaults suite io.github.upright.Upright.demo (cleared at start), no CGEventTaps (scroll, drag rewriter, gesture fallback), no-op action performer, PermissionsMonitor reports granted without prompting, LoginItem/BatteryNotifier no-op. Code against W4.1's §B11 DeviceManager contract (W4.1 is being written in parallel; if its API isn't there yet when you build, wait/retry).`, { label: 'W4.2', phase: 'Wave 4', model: 'opus', effort: 'high' }), + () => agent(`${BASE}\n\nTask W4.3 Peripherals: owns Upright/App/{BatteryNotifier, ScrollController, SystemActionPerformer}.swift, T/BatteryAlertPolicyTests.swift (only if BatteryAlertPolicy gains a helper). Per-device battery policies keyed by DeviceAddress with notification IDs upright.battery. and the device name/nickname in the text; policies dropped on endpoint removal and seeded on reconnect of the same stable ID. Scroll gate on summary.anyPresent (D14; copy says it affects all mouse wheels). SystemActionPerformer: refcount synthetic mouse buttons (down/up, click state, drag rewriter) and media-key repeats per button/key across devices, like modifierHolds. Code against the §B11 contract; W4.1/W4.2 are written in parallel.`, { label: 'W4.3', phase: 'Wave 4', model: 'sonnet' }), +]) +const g4 = await gate('wave 4') +if (!g4 || !g4.green) return { stoppedAt: 'wave 4', gate: g4 } + +// ---------- Wave 5 ---------- +phase('Wave 5') +const ANCHORS = 'Add `.snapshotAnchor("")` (Upright/Views/SnapshotAnchor.swift, no-op unless exporting) at the rect names listed in DEMO-MODE-SPEC.md for the views you own.' +await parallel([ + () => agent(`${BASE}\n\nTask W5.1 Mice UI: owns Upright/Views/{DevicePane, DeviceListView, PointerSpeedSection, Components, MenuBarView, SettingsView, SettingsWindowController}.swift and Upright/App/UprightApp.swift. Implement the §F rows for these files (Mice pane list/detail with connected / remembered / "Other Logitech devices" sections, nickname, Remap this mouse, Reconnect, Forget, Untested-model badge, Copy device report, per-device DPI and Easy-Switch, labelled+filterable activity log; menu bar rows per mouse with lowest-battery label and full accessibility label; new-model banner). No view reads the compat status/snapshot except the documented primary-row fallback. ${ANCHORS} Also amendment A2 launch wiring in UprightApp.swift: DemoMode.configure(arguments:) first; in export mode no status item/windows/Dock icon, run SnapshotExporter, exit.`, { label: 'W5.1', phase: 'Wave 5', model: 'opus' }), + () => agent(`${BASE}\n\nTask W5.2 Buttons UI: owns Upright/Views/{ButtonsPane, ButtonRow, ProfileSection, GestureEditor, ActionPicker, ModelPicker, GeneralPane}.swift. Implement the §F rows: "Configure: [model ▾]" picker (connected, remembered, catalog models), rows from controls(for:address:), "Applies to every " caption, catalog-generated footer, Copy setup from… / Use this setup for all my mice, per-model app profiles, Easy-Switch channel count and Pointer Speed submenu per selected device, import review with model per item + target-model picker for v1/v2 files. Editing one model must never change another model's table. ${ANCHORS}`, { label: 'W5.2', phase: 'Wave 5', model: 'opus' }), + () => agent(`${BASE}\n\nTask W5.3 Onboarding and copy: owns Upright/Views/{OnboardingView, PermissionsPane, ScrollPane, AboutPane}.swift. The §F rows ("Connect your Logitech mouse", Suggested applies ButtonMappings.suggested(for: detected model) to that model only, Try-it line names the device, privacy text, blocked-interface list, scroll copy "Applies to every mouse wheel…", About "Remaps Logitech mice over HID++. Built and tested on the MX Vertical."). ${ANCHORS}`, { label: 'W5.3', phase: 'Wave 5', model: 'sonnet' }), +]) +await agent(`${BASE}\n\nTask W5.4 Kit cleanup (Wave 5 UI tasks are finished): owns K/Device/MXVertical.swift — remove matches(name:), wirelessProductID, classify, mxVerticalDefaults if nothing references them anymore (grep the whole repo incl. tests and probe; if something still does, migrate that reference to the catalog/classifier only if it is a trivial one-line change, else keep the symbol and report). Keep displayName, ConnectionKind, TransportKind. Kit + app must build.`, { label: 'W5.4', phase: 'Wave 5', model: 'sonnet' }) +const g5 = await gate('wave 5', 'Also check no view reads compat status/snapshot outside documented fallbacks (`grep -rn "devices.status\\|devices.snapshot\\b" Upright/Views`).') +if (!g5 || !g5.green) return { stoppedAt: 'wave 5', gate: g5 } + +// ---------- Wave 5b: demo mode ---------- +phase('Wave 5b') +await agent(`${BASE}\n\nTask W5.5 Demo Mode + snapshot exporter (amendment A2, spec ${ROOT}/docs/dev/DEMO-MODE-SPEC.md — implement it fully): owns Upright/App/{DemoMode, SnapshotExporter}.swift, Upright/Views/SnapshotAnchor.swift, the Makefile (add \`make snapshots\` → media/snapshots/ via the built app with --demo --export-snapshots), and new helper files you create. Demo fleet: MX Vertical (Unifying), MX Master 3S (Bolt), Lift (Bluetooth) with realistic battery levels (one variant ≤10% for menubar.panel.lowbattery), DPI info, a few mappings incl. a gesture button and an app profile for Safari, scroll reverse+smooth on, activity log lines incl. the app's real wake/reapply message. Render real SwiftUI views offscreen (NSHostingView + bitmapImageRep/cacheDisplay or ImageRenderer) at 2x (menubar.label at 6x), light + dark (set NSAppearance on the hosting view), settings states at the real settings window size; collect rects via the SnapshotAnchor preference into manifest.json (points, top-left origin). Exit 0. Must never touch IOKit/real devices, never read/write the user's defaults domain (io.github.upright.Upright), never show windows/status item/Dock icon in export mode, and work while a normal Upright instance runs. Build with \`make app\` and RUN \`build/Build/Products/Release/Upright.app/Contents/MacOS/Upright --demo --export-snapshots ${S}/snapshots-test\` yourself; open several PNGs to check they look right (real UI, correct theme, not blank/clipped); iterate until good. Then run \`make snapshots\`.`, + { label: 'W5.5', phase: 'Wave 5b', model: 'opus', effort: 'high' }) + +const VERIFY_SCHEMA = { + type: 'object', + properties: { + ok: { type: 'boolean' }, + produced: { type: 'array', items: { type: 'string' } }, + missingEssential: { type: 'array', items: { type: 'string' } }, + visualProblems: { type: 'array', items: { type: 'string' } }, + isolationProblems: { type: 'array', items: { type: 'string' } }, + }, + required: ['ok', 'produced', 'missingEssential', 'visualProblems', 'isolationProblems'], +} +let v = null +for (let round = 0; round < 3; round++) { + v = await agent(`${BASE}\n\nVERIFY demo export (do not edit sources). 1) \`defaults export io.github.upright.Upright - > ${S}/defaults-before.plist\` (ok if the domain doesn't exist). 2) \`make app && make snapshots\`. 3) \`defaults export io.github.upright.Upright - > ${S}/defaults-after.plist\` and diff (must be identical). 4) Check media/snapshots/manifest.json against DEMO-MODE-SPEC.md: every essential state id in light+dark, scales, rect names present and inside image bounds. 5) LOOK at every essential PNG (Read the image files): real app UI, correct appearance, three simulated mice visible where expected, nothing blank/clipped/garbled, no real serial numbers or user data, Experimental label visible on scrolling. 6) Confirm in code that the demo path uses the simulated LinkSource and never IOKit. Report.`, + { label: `verify:demo:${round}`, phase: 'Wave 5b', schema: VERIFY_SCHEMA, model: 'sonnet' }) + if (!v) break + log(`demo verify round ${round}: ${v.ok ? 'OK' : 'issues'} (${v.produced.length} files, ${v.missingEssential.length} missing, ${v.visualProblems.length} visual, ${v.isolationProblems.length} isolation)`) + if (v.ok || round === 2) break + await agent(`${BASE}\n\nFix these demo-mode/snapshot problems (you may edit any file; no other agent is running): missing: ${v.missingEssential.join('; ')} | visual: ${v.visualProblems.join('; ')} | isolation: ${v.isolationProblems.join('; ')}. Re-run make app && make snapshots and look at the images.`, + { label: `fix:demo:${round}`, phase: 'Wave 5b', model: 'opus' }) +} +const g5b = await gate('wave 5b (demo mode)') +return { done: !!(g5b && g5b.green && v && v.ok), gates: { g4, g5, g5b }, demo: v } From 8f2e02cb6f5477f228af3c3edbd732ff10366c08 Mon Sep 17 00:00:00 2001 From: Daghan Percinel <4314475+dpdev69@users.noreply.github.com> Date: Wed, 14 Oct 2026 01:22:31 +0300 Subject: [PATCH 06/14] Multi-mouse: wave 4 Co-Authored-By: Claude Sonnet 5.5 --- Upright.xcodeproj/project.pbxproj | 8 + Upright/App/AppEnvironment.swift | 92 +++ Upright/App/AppModel.swift | 804 ++++++++++++++++++---- Upright/App/AppSettings.swift | 29 +- Upright/App/BatteryNotifier.swift | 66 +- Upright/App/DeviceManager.swift | 856 +++++++++++++++++------- Upright/App/LinkSource.swift | 262 ++++++++ Upright/App/PermissionsMonitor.swift | 29 +- Upright/App/ScrollController.swift | 19 +- Upright/App/SystemActionPerformer.swift | 34 +- 10 files changed, 1775 insertions(+), 424 deletions(-) create mode 100644 Upright/App/AppEnvironment.swift create mode 100644 Upright/App/LinkSource.swift diff --git a/Upright.xcodeproj/project.pbxproj b/Upright.xcodeproj/project.pbxproj index 6a69ab1..60d96a3 100644 --- a/Upright.xcodeproj/project.pbxproj +++ b/Upright.xcodeproj/project.pbxproj @@ -74,6 +74,8 @@ 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 */; }; @@ -150,6 +152,8 @@ 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 = ""; }; @@ -240,6 +244,8 @@ 2F53FBF5620FAA73BC3A99CB /* AppModel.swift */, 766EE0AB39B7FB46D7AA53A3 /* AppSettings.swift */, 267982B81F3F104CE213696C /* BatteryNotifier.swift */, + B0A1E0000000000000000002 /* AppEnvironment.swift */, + B0A1E0000000000000000004 /* LinkSource.swift */, CE13987809A1653431CA533B /* DemoMode.swift */, EAE0AE15A05BBAD2A85FEFE6 /* DeviceManager.swift */, FB6805FD19F67DDAFBA489A1 /* FrontmostAppMonitor.swift */, @@ -428,6 +434,8 @@ 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 */, 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..79e7c07 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) + library = loaded.library + isRemappingEnabled = enabled + self.devices = devices ?? environment.makeDeviceManager() + permissions = PermissionsMonitor(defaults: defaults, environment: environment) loginItem = LoginItemController() 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..ed6122f 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,13 @@ 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. + var store = DevicePreferencesStore.load(from: defaults) + 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/DeviceManager.swift b/Upright/App/DeviceManager.swift index 6f7c642..7b62949 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 + ?? (model == .mxVertical ? KnownControl.mxVerticalDefaults : []) + 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") } } diff --git a/Upright/App/LinkSource.swift b/Upright/App/LinkSource.swift new file mode 100644 index 0000000..9d0d90a --- /dev/null +++ b/Upright/App/LinkSource.swift @@ -0,0 +1,262 @@ +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: the vendor HID++ interfaces usually open without it. + // The UI offers `requestInputMonitoringAccess()` when access is actually refused. + let result = monitor.start() + guard result != kIOReturnSuccess else { return } + let message = String(format: "IOHIDManagerOpen returned 0x%08X", UInt32(bitPattern: result)) + if result == IOKitReturn.notPermitted || result == IOKitReturn.notPrivileged { + throw LinkSourceError.permissionDenied(message) + } + throw LinkSourceError.failed(message) + } + + 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/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/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 From 88a66aaf2f0eee2a81f6f5426a1e112b0d2433b4 Mon Sep 17 00:00:00 2001 From: Daghan Percinel <4314475+dpdev69@users.noreply.github.com> Date: Wed, 14 Oct 2026 01:43:07 +0300 Subject: [PATCH 07/14] Multi-mouse: wave 5 Co-Authored-By: Claude Sonnet 5.5 --- Upright/App/DeviceManager.swift | 2 +- Upright/App/UprightApp.swift | 106 ++-- Upright/Views/AboutPane.swift | 6 +- Upright/Views/ActionPicker.swift | 115 +++- Upright/Views/ButtonRow.swift | 33 +- Upright/Views/ButtonsPane.swift | 359 +++++++++-- Upright/Views/Components.swift | 217 ++++++- Upright/Views/DeviceListView.swift | 254 +++++++- Upright/Views/DevicePane.swift | 567 ++++++++++++++++-- Upright/Views/GeneralPane.swift | 244 ++++++-- Upright/Views/GestureEditor.swift | 14 + Upright/Views/MenuBarView.swift | 420 ++++++++++--- Upright/Views/ModelPicker.swift | 84 ++- Upright/Views/OnboardingView.swift | 226 ++++++- Upright/Views/PermissionsPane.swift | 31 +- Upright/Views/PointerSpeedSection.swift | 113 ++-- Upright/Views/ProfileSection.swift | 43 +- Upright/Views/ScrollPane.swift | 13 +- Upright/Views/SettingsView.swift | 6 +- Upright/Views/SettingsWindowController.swift | 12 +- .../UprightKit/Device/MXVertical.swift | 38 +- 21 files changed, 2449 insertions(+), 454 deletions(-) diff --git a/Upright/App/DeviceManager.swift b/Upright/App/DeviceManager.swift index 7b62949..799ac82 100644 --- a/Upright/App/DeviceManager.swift +++ b/Upright/App/DeviceManager.swift @@ -174,7 +174,7 @@ final class DeviceManager: ObservableObject { ?? registry.records.lazy.compactMap(reported).first ?? rememberedControls?(model).flatMap { $0.isEmpty ? nil : $0 } ?? DeviceCatalog.model(model)?.controlOrder - ?? (model == .mxVertical ? KnownControl.mxVerticalDefaults : []) + ?? [] return DeviceCatalog.sortedControls(cids, for: model).map { ControlDescriptor(cid: $0, model: model) } } 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/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..8552773 100644 --- a/Upright/Views/ButtonsPane.swift +++ b/Upright/Views/ButtonsPane.swift @@ -1,5 +1,9 @@ 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 @@ -12,24 +16,45 @@ struct ButtonsPane: View { @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 +72,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 +91,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 +124,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 +317,7 @@ struct ButtonsPane: View { } } - // MARK: Notes and footer + // MARK: Notes, reset and sharing private var remappingOffNote: some View { HStack(spacing: 10) { @@ -208,14 +340,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 +356,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 index 6360d04..6087549 100644 --- a/Upright/Views/DeviceListView.swift +++ b/Upright/Views/DeviceListView.swift @@ -1,16 +1,258 @@ import SwiftUI -/// The Mice pane's list of devices. +/// 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 index 5bdc6df..917fb12 100644 --- a/Upright/Views/ModelPicker.swift +++ b/Upright/Views/ModelPicker.swift @@ -1,20 +1,88 @@ import SwiftUI -/// Chooses which mouse model the Buttons pane configures. -/// -/// Wave 0 scaffold: W5.2 owns this file (connected, then remembered, then catalog models). +/// 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 { - let models: [ModelKey] + @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) { - ForEach(models, id: \.self) { model in - Text(verbatim: DeviceCatalog.model(model)?.name ?? model.rawValue).tag(model) + 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: { - EmptyView() + label } - .labelsHidden() + .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..abccdac 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) @@ -129,9 +132,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/UprightKit/Sources/UprightKit/Device/MXVertical.swift b/UprightKit/Sources/UprightKit/Device/MXVertical.swift index 2fbbbd4..431301f 100644 --- a/UprightKit/Sources/UprightKit/Device/MXVertical.swift +++ b/UprightKit/Sources/UprightKit/Device/MXVertical.swift @@ -41,42 +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" } - -extension KnownControl { - /// The MX Vertical's remappable buttons, shown before a mouse has been detected. - public static let mxVerticalDefaults: [UInt16] = [back, forward, dpiSwitch, middleClick] -} From 30bbb2b22b6e54e439efbf145d91b4a3650c34fd Mon Sep 17 00:00:00 2001 From: Daghan Percinel <4314475+dpdev69@users.noreply.github.com> Date: Wed, 14 Oct 2026 02:39:43 +0300 Subject: [PATCH 08/14] Multi-mouse: wave 5b (demo mode) Co-Authored-By: Claude Opus 5.5 --- Makefile | 12 +- Upright/App/DemoMode.swift | 247 ++- Upright/App/SnapshotExporter.swift | 1082 +++++++++- Upright/Views/ButtonsPane.swift | 5 +- Upright/Views/ScrollPane.swift | 2 + Upright/Views/SnapshotAnchor.swift | 65 + media/snapshots/buttons.global.1.dark.png | Bin 0 -> 195977 bytes media/snapshots/buttons.global.1.light.png | Bin 0 -> 187282 bytes media/snapshots/buttons.global.2.dark.png | Bin 0 -> 195506 bytes media/snapshots/buttons.global.2.light.png | Bin 0 -> 186707 bytes media/snapshots/buttons.global.3.dark.png | Bin 0 -> 195823 bytes media/snapshots/buttons.global.3.light.png | Bin 0 -> 187045 bytes media/snapshots/buttons.global.4.dark.png | Bin 0 -> 195695 bytes media/snapshots/buttons.global.4.light.png | Bin 0 -> 186924 bytes media/snapshots/buttons.global.5.dark.png | Bin 0 -> 197045 bytes media/snapshots/buttons.global.5.light.png | Bin 0 -> 188283 bytes media/snapshots/buttons.global.6.dark.png | Bin 0 -> 198621 bytes media/snapshots/buttons.global.6.light.png | Bin 0 -> 189745 bytes media/snapshots/buttons.global.dark.png | Bin 0 -> 195811 bytes media/snapshots/buttons.global.light.png | Bin 0 -> 186811 bytes media/snapshots/buttons.profile.2.dark.png | Bin 0 -> 188668 bytes media/snapshots/buttons.profile.2.light.png | Bin 0 -> 179293 bytes media/snapshots/buttons.profile.dark.png | Bin 0 -> 187502 bytes media/snapshots/buttons.profile.light.png | Bin 0 -> 178260 bytes media/snapshots/device.activity.dark.png | Bin 0 -> 251036 bytes media/snapshots/device.activity.light.png | Bin 0 -> 239183 bytes media/snapshots/device.pointer.1600.dark.png | Bin 0 -> 356389 bytes media/snapshots/device.pointer.1600.light.png | Bin 0 -> 345592 bytes media/snapshots/device.pointer.3200.dark.png | Bin 0 -> 356634 bytes media/snapshots/device.pointer.3200.light.png | Bin 0 -> 345821 bytes media/snapshots/device.pointer.800.dark.png | Bin 0 -> 356198 bytes media/snapshots/device.pointer.800.light.png | Bin 0 -> 345486 bytes media/snapshots/device.pointer.dark.png | Bin 0 -> 356389 bytes media/snapshots/device.pointer.light.png | Bin 0 -> 345592 bytes media/snapshots/general.behavior.dark.png | Bin 0 -> 187793 bytes media/snapshots/general.behavior.light.png | Bin 0 -> 178483 bytes media/snapshots/gesture.editor.dark.png | Bin 0 -> 218141 bytes media/snapshots/gesture.editor.light.png | Bin 0 -> 207416 bytes media/snapshots/manifest.json | 1746 +++++++++++++++++ media/snapshots/menubar.label.dark.png | Bin 0 -> 6679 bytes media/snapshots/menubar.label.light.png | Bin 0 -> 6171 bytes media/snapshots/menubar.panel.dark.png | Bin 0 -> 112263 bytes media/snapshots/menubar.panel.light.png | Bin 0 -> 109531 bytes .../menubar.panel.lowbattery.dark.png | Bin 0 -> 112499 bytes .../menubar.panel.lowbattery.light.png | Bin 0 -> 109585 bytes media/snapshots/scrolling.dark.png | Bin 0 -> 179982 bytes media/snapshots/scrolling.light.png | Bin 0 -> 170331 bytes 47 files changed, 3145 insertions(+), 14 deletions(-) create mode 100644 media/snapshots/buttons.global.1.dark.png create mode 100644 media/snapshots/buttons.global.1.light.png create mode 100644 media/snapshots/buttons.global.2.dark.png create mode 100644 media/snapshots/buttons.global.2.light.png create mode 100644 media/snapshots/buttons.global.3.dark.png create mode 100644 media/snapshots/buttons.global.3.light.png create mode 100644 media/snapshots/buttons.global.4.dark.png create mode 100644 media/snapshots/buttons.global.4.light.png create mode 100644 media/snapshots/buttons.global.5.dark.png create mode 100644 media/snapshots/buttons.global.5.light.png create mode 100644 media/snapshots/buttons.global.6.dark.png create mode 100644 media/snapshots/buttons.global.6.light.png create mode 100644 media/snapshots/buttons.global.dark.png create mode 100644 media/snapshots/buttons.global.light.png create mode 100644 media/snapshots/buttons.profile.2.dark.png create mode 100644 media/snapshots/buttons.profile.2.light.png create mode 100644 media/snapshots/buttons.profile.dark.png create mode 100644 media/snapshots/buttons.profile.light.png create mode 100644 media/snapshots/device.activity.dark.png create mode 100644 media/snapshots/device.activity.light.png create mode 100644 media/snapshots/device.pointer.1600.dark.png create mode 100644 media/snapshots/device.pointer.1600.light.png create mode 100644 media/snapshots/device.pointer.3200.dark.png create mode 100644 media/snapshots/device.pointer.3200.light.png create mode 100644 media/snapshots/device.pointer.800.dark.png create mode 100644 media/snapshots/device.pointer.800.light.png create mode 100644 media/snapshots/device.pointer.dark.png create mode 100644 media/snapshots/device.pointer.light.png create mode 100644 media/snapshots/general.behavior.dark.png create mode 100644 media/snapshots/general.behavior.light.png create mode 100644 media/snapshots/gesture.editor.dark.png create mode 100644 media/snapshots/gesture.editor.light.png create mode 100644 media/snapshots/manifest.json create mode 100644 media/snapshots/menubar.label.dark.png create mode 100644 media/snapshots/menubar.label.light.png create mode 100644 media/snapshots/menubar.panel.dark.png create mode 100644 media/snapshots/menubar.panel.light.png create mode 100644 media/snapshots/menubar.panel.lowbattery.dark.png create mode 100644 media/snapshots/menubar.panel.lowbattery.light.png create mode 100644 media/snapshots/scrolling.dark.png create mode 100644 media/snapshots/scrolling.light.png diff --git a/Makefile b/Makefile index 742f537..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: @@ -33,6 +33,16 @@ dmg: app 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/Upright/App/DemoMode.swift b/Upright/App/DemoMode.swift index de0dc50..beb6410 100644 --- a/Upright/App/DemoMode.swift +++ b/Upright/App/DemoMode.swift @@ -1,11 +1,15 @@ +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 scratchpad DEMO-MODE-SPEC.md. +/// then quits. See docs/dev/DEMO-MODE-SPEC.md. /// -/// Wave 0 scaffold: argument parsing only. W5.5 owns this file. +/// 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? @@ -15,12 +19,247 @@ enum DemoMode { 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 - if let flag = arguments.firstIndex(of: "--export-snapshots"), arguments.indices.contains(flag + 1) { - exportDirectory = URL(fileURLWithPath: arguments[flag + 1], isDirectory: true) + 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/SnapshotExporter.swift b/Upright/App/SnapshotExporter.swift index b07f94e..2a64949 100644 --- a/Upright/App/SnapshotExporter.swift +++ b/Upright/App/SnapshotExporter.swift @@ -1,22 +1,1090 @@ -import Foundation +import AppKit +import SwiftUI -/// Renders the essential UI states offscreen in light and dark, and writes `..png` -/// files plus `manifest.json` (sizes and `snapshotAnchor` rects) to a directory. +/// `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. /// -/// Wave 0 scaffold: W5.5 owns this file and implements it. +/// - 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 notImplemented + case miceDidNotConnect(String) + case missingStates([String]) + case writeFailed(String) var description: String { switch self { - case .notImplemented: return "Snapshot export is not available in this build." + 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 { - throw ExportError.notImplemented + 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(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/Views/ButtonsPane.swift b/Upright/Views/ButtonsPane.swift index 8552773..a7b1666 100644 --- a/Upright/Views/ButtonsPane.swift +++ b/Upright/Views/ButtonsPane.swift @@ -9,8 +9,9 @@ struct ButtonsPane: View { @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 diff --git a/Upright/Views/ScrollPane.swift b/Upright/Views/ScrollPane.swift index abccdac..b10d5f2 100644 --- a/Upright/Views/ScrollPane.swift +++ b/Upright/Views/ScrollPane.swift @@ -110,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 { diff --git a/Upright/Views/SnapshotAnchor.swift b/Upright/Views/SnapshotAnchor.swift index b5f78ca..0b4b191 100644 --- a/Upright/Views/SnapshotAnchor.swift +++ b/Upright/Views/SnapshotAnchor.swift @@ -12,14 +12,79 @@ struct SnapshotAnchorKey: PreferenceKey { 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/media/snapshots/buttons.global.1.dark.png b/media/snapshots/buttons.global.1.dark.png new file mode 100644 index 0000000000000000000000000000000000000000..781ca551d96124ffc89561d69990af59a918929b GIT binary patch literal 195977 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 b8)vMk&3)CR8st1U;H9Rl^;gLq^U(hSV~IMA literal 0 HcmV?d00001 diff --git a/media/snapshots/buttons.global.1.light.png b/media/snapshots/buttons.global.1.light.png new file mode 100644 index 0000000000000000000000000000000000000000..2088767d0cbd3cec09f87a18bd850d7546f81118 GIT binary patch literal 187282 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%0D|?<@ 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 =llLQH2-z@ z{{I~seaCv2^&2Qx1O}`YlZ7nqCtJGS+$@aQTdx1bTGatg{5{@_K^e%|kATPijBm>3 zO@;>#HzZoGfc(7r6WB$O87i#%tbdhg>wkhPLw{@1b$CwpRVdv6WHxs3X@bhS1p0V% zuSS=^oe1N874=Vt^_~#WN8ryi`DZ8q!A%IhY~>jo0V2m<*O`J80$#xA%#k`&VSxkM zegl1j;iHApe=Y?Oc;E;WXC4R#jARDNm0k_=L=HB3^(O%OmH~_aDX^?E3GpZM2<7Ma zXY5VKhMf^=pDr~Dd}bB!ZEWrtees=Y2t6={GetK0F`kcr%SILYt36T~mvmSt0rREw zLMo`8T|VNt1?55-SY?YO0ndx{t+|w6y~(lp&P(ZNRwoDG)jRNNr~!Berz6@f?Vcpv zrnl58{qz2Wd~{(KbO(*!WK3-w`GfDSp!24{0s*&xfah0^(CN-Fy@_>|wG?+zE7M-Z z0Y$L^lhCCr*E^1WW?1w;ikyE6#CUgLdXs6~e^h}_m>Y~g2XsT>hw)riv+3J&M87ga zLbdX-W^iX~VW}vQ43h(SErpOurS*IA>h-;a_y2r0=`D9q6i&viWF5dv`1-DGfFAfa zqu4s^PCD<^=`B9~#g-5db_dwy9)*7d6fOs=*I6K%rVCXsOcU_QsQrtg@t17`&)CP> zeR$$dJEM!AWTN@WF% zux;i)UJl-E^)Mah?n}jmaPW}ZHQ2(yLpFt7z+xrlpl~Vt#ZEM==wnSA1s?C{*iI*y zx_L;B(iN_GweEcXyn2i6zuc1B&*_e*fr>^CrXcH=+a#80u26NfWZ+AN!Y*La5{IsJ8vO!w2&ntrXd3T_T1FSx zJkd}b+hrTR`8kOFYBq-AtOt|5`PUQ9|y`oR|- zW%OAh>0a|9uS0;#V#*BoPp67+Eh@o%ac*&Kp}XO9-r+uSK%M)|dsG-i4tv;;Z-U=L z2wD3A@4%7Xfk70y1B>zIKR?n4rL%6yX6V!G?a>7M=g)t7#UDSWv%p}X`p+ob1y^-| z4H1@}?a_wLQwEHRu|0AdQm5HD`I!fW!4Fb>`~aK2KS-6-(a>g~SK(YS1iWD1ZX0Aom4YtKeUHjDIO|BsvvzYZ~IKRG;nTO!6w{GF? zfMI8_gnjs`@{@Z&byco_#Y=Xtfnus`G6@}3%`$pSJ{3P>fd#Vs@bESft2G4puKx57 zXB)WXc7oNP!l>h~Py1ws?pyUcYnv4lm35X(T8$Kl%ME?z{N7ds>~ zy{Q)sF)7^oq1#PT!{VgBetrXqcaQ^2uFq=S5QX#&9t@k@ytJtzTxgp8&Z#@S>vLB8BMSJ4V7Wd43GxKQWs&9<&~& z55j?)sXs>Z%#J(?@Xm2;t)@q?&oA)BY-hx>Ki3u(@A;(vTX5|>H~g&NQ@TR_@f3cR zv<q5T8W$#d!`DY zLp0ZVHb?v}@^37$CkGA)FuHM!-MO~mm@SN`e7nIudEpWh7Pcj)wb4=>A#okP<8f0f zvw`=~1KrmNdsey?0w|~Zg^j2ste?(-~T)C1c%OVIsepwc2Z0l2rZTOV9DUIYIoF5uj?2Uw9SfZtXMtVw3U zDgV1{Z=?W#=1%}%sTD@dk4%RbY^D_3!GidUXL1ZprpCvwL_o0Md02DUKFP)Zne{Ef z>FlMGzi@Zg_2rg;XN9$bhM}LJec$AzxHpqZ_SDP6eKxJtUZ41aFEQ8}D8bydab|hp zVHVw!m$M!`_#%h0X;wMzPx(I^<=JQeN3CtZ{`ttV-H@&k5eYfW1OgYG+ww>3Cewg& zJ++LD+C}7&j7G@)3HBY}M$G|)$OLi<9sqW__S^u|fCxVyheJ{R9>Jlwr=SG8avY;D zrZNoH!y6%XQWjoU%-4K1&qq_6@5inwzASs0pq=xj&v5S3NSbtvn!9|tXVemHs^2WV z?CKI;f%J*ws!DFJr3O`;8A+dVYSE$cX_mVMA~~+m_2p?bpskKW*jP;#uqesw&6Z8# z8VEoqu?A>NUYSmvV(gcIusk^|9m6<)hgakO@w(bEJ3S2Q{k}f-f=kZd2hu{g+DvJr&!RQfYPTx#aO4kKO zvQg3kM8y}4m~VR7P|`a3AP5V~9=J0CVEj@wiO6}n6+nT!QEWm7z-ev{FqI!3f~Zji zoVF_RxJpG&q>;k`sxd(fFdbxVt!is4$S;8iAo}|WRF~^r89yk>2Gs#}wFM?mJ?)*U z)F|U1%Ggh1a=Zgxr}c{+7EOQO&%Isx9;|?&T!T~@=SsKYd*X~j zOQ?OWkf&=znH{_BWP>KznjVE{)cHQhrDjsof^v8K=&nfVqY9)HoD@@6fVUcAYyc6# zOstOcjAPWg{W|Ebny<_x)&USn8v-`tw~(>yWVOwrl+`G_B9k_F%L!Q3T?LS5gLOde z+{CKp_0NZ0R z%I-*Mri72Nn5}ErF7S8cwfJa=J0O!lY}xPc{+0v15gH>+KNW7gl`d6K8o`^T9)2Di zaI!s@X-e~e3ONANXM_-1RkBF$6Q{`^CTrX^6>a81hE?NmEntm0KdPK^OH`#f(`36h zI#K`Kb)sUbkrV42jCSTbiUhtNaBaf(h?EV?6z|vA8vuhoE)CQ|fLxAbhkoZYjnXul z2U4LJ1|)!C;yA3d0TWq6;H6sv7|yS`ULTpdgINB-S2!3lF6<))ZcfODIm}twHDES> zH}jq_Bbr6Di9l(H*m*HqDF-00t@0dBX=hM*YCw(}6(6W*0%bbcrM^+4=}!h#0K9v) zd3WN+y@afIu2O@uI^fY{-|O#neK7-RC>W`Z2(hF^8D6qxs6PxY8Oad6x0@)9Eu(2& zDhHstGnvn)V27!K&{8_GtfLv=!d#?N?_4Wj{EsoEY;%_;>CNCCz9x~ zO@mXDb6JWDTAqDtY2K;tnA2;WW`%Mmx`3xz967G!?5EwaTzUU&(8@@y3T)2LpNN+c z@1d;$f51UtZHzJpv@F}ewy*%JM98S`I`o;fONAYCoD^m){H*hwRgua^)st~0`56#v z4M4#%)CyQnmkKOSflUN+0VaP3*v0szfG*SLiP$PR1BU>y=B=6tqp!V>d9Q;!{9Ojn zesI=d(3yWC$L}GEeuhjKw7Nc1{;K~{jn!&gRePmaiu_$fl64*JTB_6#WQG*71cBKj zWzRM;0*UeGsGEZLA7$ z3hIm+l`K4H@ehcsKNi?H6p6MDYp3y@qJbQl*GH1w*SGb_DyMssM^*nvK9#xi+4fV| zA^HKSq*xORxLsCy_|w~k*+AJvTYml;?5ovRQ{+Ie!VEH;& zt?Vwwpwza7zqo?8irhSE;95Wl@>LK#`|4ME0SekxPzzawu@Ak^LkTrLrF_RU&D52O z%5u-}CHhfIzR{Miu!Umh325(`Y=)oTK5$c`k&eu=Zzbq47iJ}+2*&pDZS4sc`Qse% z`zgfK1MbM0UaiiFQBl8}v4mkIq5ui&%XMBIN#)N`R3P`sZ>3D9$NP@pJsowrDJ9ym zaI5g+@}wzYH~*>eVN!tKQZssBRrNZNT(sK0-tiE10{?yxu&QE9;-;Syt*r41sdPvHg=8@@SBiYr*$WvI4PFXt{+_DyXq19D-wv_W z@_!^b9CEP7+1j>G6^%{hqEY22yfr&im2d7x{~SM{+3ruABezt4b!mQgaRD!1qStpB zp*grBzod>dRSlmq>$<&Z!(S`0C}m$Ys)#1JaC~A*o*;>cpy6cxB6TKYH}U$rl(%fO zdRPf#4nA9>u1!*7nnv@56v~j^4!#ER)rP!xFE2CpBA>CMSc5uo4UacqPb6uh za#Gm6gR_hA%?XDt0a?G!K$-jLJzar!m?4xaaIk+lnQ}0|K3zTr9p^vaeHL6zh2m73 z93!2m+xS}J_U2l4*LEki(x51Hf&mrwf*>Eg&;O{=Ol}~gK8qs(eu@SU*je6#j*@an z{Rv4Uk3UN)5*)pV`uZ}%2Rgi^8Q;{TQ*d)`QCA~dhextG$k_%SWzM*?GL9y*=;mVB zxWuHZug|y%(`)b~*F?-X(@5- zyCkr#X}tmV?Xz1mt+_VD5%k&``kU8VAV5}vZqoen^udV_VQT3MKE|VT*pAQ7@{;a3 zcxlq$9XMoQL3`v3(iQ$mc9ayXi(OXG0fN*0l^*_^X(kVy+M*c&l<(68)4Go=oVzf} zTlEsmQY#qdMl6RW`a`v+3LWZYwhI;ph%Xywgu@chE(PWu^BdTw8>g(5Z_M*KP}MWN z_t^c?SY$C(%-UU2KQWSj7ydzPx6|NCoSrpgT*)-(+k0#@<#&H`HXEG5PJl1>ERa+! z#pBmg*|);uPJ)pFs-{fRK^^WZOpb$Wk`i4Y=W})CFswR} zL8BUgC1meS@kGVH5O4WJio7poRy}|!GLx&;GkQy+ zD#d>8M%?4A8TKaXjp|!aW;D$d4=Dq6CBY`Yo@2&QKBV>C9QC2QPLdK9;gPLN9Fr^B zAy!-CGgF;a9+N20RNKcGrfN7@TB7z?84XX!-Ly>X|HyEDez=MqgR&^YImhhUz{B$S zlc$+Xr#YFU28%qGAX7#C4pI5yyunFWMg|MLBG>%G`+i6FeKMB#GWbrW8hSypk23z` z8GJh+@q7=cw4Qjf3-yplmRtd*REAKQGMnTcIBzx8DMx07-HKRYT?$d#0n6nkQ$_`| zaD`9XRdb14R|}0%Tze~Zc}(6cv|Rr0q6wI+zn{0G3%b7WR}Q|{QIRKCps3)wC}I^U z94C0O{@6yBY@&w>-8jYLJ~qA`%wN}~DQp${YTVS3SU3;)i4x4(Fr=`7dg zD|BkP43%dX3Q6YRG=;qQjRE0p&t&E@oRhnmBcuG>rWdzgQG<=6F>; z`E+}8ne^oqIFcCD0}{+O@YN`+l5av#G^UBw64)5cmi7n(&ZqKxh^le9V<0bk6-eUc z>fwD*xvoEtKmbS5jUowkG09sWK7!oTCC|sgpY5KYjBzR^`cIOJJDs~krk1m6XX=}x z$_H_Rpd{=2rmBkH)czAfNGyz$Lz(_AZ`C`iGJ*>oKTIK*6deI1_o91zhFp(7Yrp;O zsbn@K<_AmWA&`ct>88^yz(MZdwtYWg2MLx%;SVnW`#49Dw|%x9U;CYktv<*X%ZU2h zH{UxBf0ug^o91;vn>cXMaM4Pa?c>D_ZQeOTLzve~yR7$*+GTy(R|iuFicl-Q6+cZO zk4Tg6Fj3GEh>!;5{-?AzXNeqUkwhRbuL9=lv*H9+Sn}uewiWcaM@z~p6Fq65*}|Eh zMy@#4SDAe>bHfW>U67SvHGJwI3m_)XnfzK=Zs% zPj;l&XEjQDwPkxi$DD%_^dd3i*)2sK0$Q_B;85=BD><|Sh;*f(PY-pIEhG`#f3%qu z^{n^7fX`o#I3e%eFkm>cNbc(MZiRb3VEm2)k@52Mhx5HPxR}ej$;FNerl0M^Vucky z)c|~xOtB7_dxRJ|+$vK|4xDdDo)t0~gFMon7Ddk@C=GxxnoT zJv1>cj#@nLJat#7_fOY*E9f{spkQCr)4)}o%>nw#+BC|BP`{co1?uw`6VH47 zp)LYXK>X?(Z=wDJ+w4@fWSnzOZ`rwH!=v1-N zgm)cYqbpek%&K!RgX2qVMS5(T$i4DqHr2q2Ca@4qDaoqE0BI*b-?R4F`{h$PA^U-z283|TXT=f9 zO!TNlHV4Z+*qwte5yaFBIRF|Q`K*>}lU1XT9Oy`*(|4c`oF&VTuDfSNty3M##3Oe; zvQ^dsj;*OK7ywFCLH#-e_Y$GTnzp1?&*nxjJu=YD*P7aZUTMIJ`{wIb3h zFErVM*;o=o2LPE+9gp{lm+tP31av2U-hjSkeF-cvjeu)`_mTm8g zQ%!wSnZ_p<+AGPK`5~0jR{@?&xYVe{DJ z^}uPh=3BZx1IW~e8xWx`AtTM#z+4&Ui9uy;ZitwCjJ(>|!F&6ZvO(){tSUk!El$7@8E~&aMe=G;Qs#CX{h$(S2hkWL9#`ZTt zwtI`{V`~V=Yo#M6L|z}(8*X;mQKqHavu8gPQB;T~zwpU+McbaOq&KYsfH8$yNcJng zb2D0b->N^KRZU&tAnUYsoXJzvQTlQBHz&vkM1%*P-N4j)Vwo*pIU-3#<7EjDPC5ho zORTLM2u<^o{~|QGr?TxPm&C=W>1$DQgL&W=hcg|Vb=%zEmv7nj4T%qDg; z8>bzb%EXy2b#)Cg7h}ZS*X8;tKs1gi|4G?yly1y^-8_CBNB+8N%B%7u@%7RA3oEs4 zD>Gt^FKd@`^c-)#s6-VaSJJE1Hjr>Ui%^NkauU@hwD1Lh>M#}(1^T%~n%>mR^^LZ` zOy?uZFLMqiL+_D`BYY!;iH!IHx9&)dpPpL8>pYy-s6sw58;Mc0)w%7RmT~9Y+n>-? zy(dL-;p*d?;lj<=q)lC3;*D(8XlELQlg9fq1EU5%WT67 zjw3FJTN)CVr$4yywSS{E*lY}|HiyR+9O?fZ(0x|L^?R`rz9kjat|rD5=6*DS8Z|t_ zt+l7uiUa(_`p>S8_D1wCY&SfR1HuGvxHW?;LupxWUrkUzc{`#gvN_J9pYdWNq2sxnhY+Rikj6Ds~Sxy7at{@|y zY;3lT^#BHS>&rd01nmkIM2*{!4|bv2LtBwWexszOXkN&jaF+t6Ctsd?Q3}Cb=Kpw< zly6d|9d0jNwi=0|&9cHlnotnDC?G*|?4)ejg8i4=9jbv#O<=P^xA0xe+jw0sB^YbDTE0t*;kwFt-4PF<2i9z`H5P=wlY5 z`eDQV_z+^cn0LxLZ}fi3z1DDsmN?}*Cxuw-*I5ohP?$Ap0B+ITJ@VgWW?KLzmMo<(^F+voTR`O5C|ybiVTia)KQ%7CiRXH(lytQ0*S#vZYNI2&%@?Pjt*fgn z;39k4%U+FgWHFiHS-^dwM6KdzUhm7fMLHT><66>>Xj!Xpno|w*{AAE0Ovi$?^Aj{O zh!66{I4I)^2SAcrHYPUx#|oply_guu)@%Ky0S^ski5!GJe7VJ+Go3Ez0YYt$a&4k@xea$E0c! zAkmcf$3p`pM$Pw5E>L6p6VU+FQpT!j@~P_iM;)zEHH+S~N~4>Mr^)A?4j+R&ocwz$ zoM|0^$&BC(u$jX);+D{GfU-ldy}dGwe@{&|srWUTj#EP`-+?3NM4g2rC$~eB?2eDP zEUB3tsanA7ks5T4^lD3aT_7~TWTnYCeRMkDJ>ePSBsN3)`I^Uedn-PpA%34Tw9_f! z;p$=SVbSyLrAA|=A$M=Z{{*3b#Ur7}maUocrI&M6K@S~!5LA}=TT125rvi^Q#;BhR z04&q~91t#PRA>3ukL|C6dkRZC@maZi8Q>1!-^g|9;_=uUvx;)f zaRrytP_~0Z2H~Ui-Bn1&Fo`O;@)JaVydqK#uhkJ^)T#haSO9^}(NRKd2%=6)ueGut z`cj&O6((i9Whp7gQ3NZ^>wfcxwmY&IO81(+HFGVv^AxV#w!Rl3xazP{)dkNF;5wr)yFKPD9m z2M0*8Q7E(JtwtnZ1q~(|K+Rk?8%=zZ4k=wf4--8GN)>va3niWOT%XNTHRbFf%eN1D z7ou+@=_Ob-Dw+`S!buPG=?*mK?26j1ZSSzT^U-{p(iyZ|#5mKC3h*Q0ib~4P``Yb% z+%PKZp1_DRD`|3VesWXu9wkdga&<1|p%kTNPFx4NY!C4)|1yyVuOW<{7Fp&+ z)%Psl*1HJQF$8p*^R*5$t7Afd8(a|B&Yf&6pbFLF-$lwAgg);3DTrPj@w^-=GYYIyFQg$!KSl?|-0luVR zB4accnwu5MT}0#nf8i7!5Litlg$NG5dG9Z|WxELqKf>sX2^gYeW)`AC2~c{X!JeNr31KpUse>tciY63{~ECZO63 z7bxUPtxRB_QC)!4=2cJ2)Wk8pTH~tAW{iD^MyOo{)hngY{lqrB8VPe``3QHgit*uL ztoo@b-SQhy#Tpq_+x-c`ZeBSeekIhV+>Sh7(D$C9gG)$^e?e?2v%9&?Ihd^PZ`o*cAz{faV$G(Sh zvRH>ZCJv*P5ueZ1wjm6{V>RJX3AtImg!u|Bg64l*meZf6jlepVJt`Ea2RYvyh}Zbo zEV$aXKHC5W9WfM{!~}V7TqCH_!r$ANtF|fLDJ&l>15HonRDthn61Uw4&}qcM85jqy z{*dl*y+S&!1=JiX(3Gc&nE$oii+KS^w0`+ z5+_Z)V&-@~-4llVb}O`LElMgVlXwWuMz=T$p0R1B`Gl2st=U8kZIA|`qK6p;KJFP; zZjHhWG8xXq(!Io@THw)C|CO@brKzWw3~OlWsmM}%LBblR5V2%2yF91ZpATsed1UxK zl4-jZ!TD2zTcE3|qQLZ`(=!?uU#40IO3r+H#173fa9T)od8k9M1DP|TVlljX1H7~t zy+-d^m-SFlYW?hH1yxbSvRlBQ+FVeGA-{7>n6jxD5nnw9%g-wN3`3f+QrWSV0$!Z9 zY=CPnzL121x$#KxN}@L1X)>;xQ$847Nt1;NJrwl(aXS7DfW9mZvXxZK4Kl z@fh%38s;peRNa^<+q1tZeC`W;1uH0B8%{tke|yqu{%w9>Ab?2kMXl{AM)^Bk?lt0>-Fj46ZpUr)`Ji|6%7?@XFz+g zi&6y9-T4zlVt|0odg_*ys!ii$J=qKaIgkBX_rNUa0686HJ#=#f*MoG0im z+t%H_cEoXC6NZ>*7P~)mg_Lgy2kRz2Y?aI{oHn4jPkk-16&{aiaG|ELP zmf55N-{BVN8z>l#{>GV ze$XPX#Tf1M62&v12qE7bIp24EXth9LebGX`?z2M(2Rq@8v?hOPDSuNIdRE zRo|_pQ{;nj6XFIPIt%rhKX25H=H@(qOr3t$S@9`jp{P@$pl5eU#AC+IgBL8(*wlW| z9HNoYADKL~>P)6{-av6-R6k6^$dpW*h(kpV+;iWorjIYeIgn;BnWR$3zT%s++%hiD zSWM(6VVFw2_}qq%d|zz|d4b@s)etCy$_su|h`D^SR$RYR2ATr1cp+Er&Q^X*vFm(P z1(b5Q<|1zFmewkOVrejg-ZZo*i&U_mUt~&)_e=70u?6Jb#h!O z(DKLrXeR$5F6J(sytj`qO|xxfMj6`UxF`Rw z*d2vtEUwv0`w6umb<0NGs7fJW?RZ*5{%CYJs0}FSd5x<|f`y9mj_(kJ(}7T-)00E1 zt>^K+~sv017mXXEPvhK~nm6P8Sg$ zWxYJ-r?R<<1JMX@?n?Gdd9mdkfuj(ipPSRhDCNmi9M`AHQs$Acub|{AV6AC!cm{e0 z4_wnE2zq-ZKVOZpFV7@-elemrflgu7BA@b<6L(xHY3|@Ueu(d=^&ZK4pHEJ0BHJ8I zu*^iDvqu$JP+iAnObZ{eFw91@3#1dhFA_WUK=1Er&;#)qd=DswXC{#r$x0CpWRvLC zsp>8f0->gx9uP9oKIRC+!c$CNf9&4{wvQBD)z(eJt`W^jVt6X)K9FY`r5qN9R`1Ar zlVE1|Sdi3DH}hAjrSlfae&6bNCWYYnnXL#hIOP1#he@Hwl%GJS+*v^Pdn6cggrj~U zLZ0p-*SL9csingxx?7;mg!LR3Ueqqx^e0V!5>9oUZ}AICaWGDBjbV!tBYA>6bMGc! zQBLDdQAUuQk92MNfO`r<+=?%kRW@PxkbFw)#$-{_M&LF?)PDRt^gZ<}?N(Cy-`oz_ zgxMXXJVDy`wmOf5Rgrl8c}Uve?a`P#$d_Adu<~Rk5ocY!07|n3N~SDUzMEYRiX5R{ zg{F6PK|MY*d>yV{07`Y{Ed`dtMeRW{L!yC62zu7+FNbkW+yV!R&mkV?Pm8vxD!IPI zX`0jWZhdP1U?_BY4W3}f!?W)hQ4)%E>s?;}X@Us*fg*VgId!e7Ro3QswaHhg?=H>a1l^d#oBLvS3zh)WMWp&(Ep8E)UB)aDHwFJ*G zXeTPp1vxS+|Dc$Q`fL4ybeIrnT}KlHpf~-CbA27Hep1xv3o#a3IZx&GhC`re_D0AP zL*1cA)3VK7ExX6VlcC`)&!l!QuFA8e*XHCO@Ad#e#KVPM)4O`T2q~c?ps=LO^`4mj zX_cLeSP@VV1nH8Hkc$)~1Ze~bk&uv9O1is4K#=Z`?v|DY>F!QR=?3ZVT;6*> z+qn1r zii9?EGWs4_P(yG+YQ?f##=kjO&Ap;ovmeRbAkCop^t{aA3}^zuFwP4 zOGF&ls}EacCG)K1FtNx>v`}01q>m8eib`zynDc@s^`QJTQ?#F}sa%}kqW4KWxBrW! zWM#P*g)dX$6oZ`{q4@8dzLkH7|wuqubY%ef>ZQZ%2h{^{oB(DER@PiK&F{T zyEFTFRAn!TQTo%Izhl5m@4@_ovh&i9DPe>Bo>o^*^PyTpF||1%T;(M;ZF9iF@kZ}V zs{)t*+ExY4%*h%w#adTn*7hA@weJ1CY`}!kghW||+)NM?iA5hy+$zc>(&$mfse=d& zLdeOQgN_nK*fmj4zQXAbMi~zM+^kPm0p(R?1Zw>mexp{nLg+_=ZFzN6+1GBNYH;I~ z7-Ge9^KxF9hayn-{OwcPTmvm25t~w`szs_6Z>S(W90{=fD!oQ-0L?-mM4Otei@FHR8JxRQ)|9WG2 zB4}G_B2F;dG(1ce{kxMxv^wMB!6#Fa%1BQ#fQ=MFiSPCh;K?N&ZqLq!K`|A?)2;$k z>gP2!fjSO1ano#bk!hpL7n; z^QbNK9m#jdG%@=CXEb4u@_6={{0$5QlL^2iTkt9kgOg#Q<|}3!V4t>pISGucXy|z> z6cf5~lGGp26FOe&E(Kl|W3Jb4%xxSpW*Bb9n^LJhjo}3M@KBd9=%8 zwH{zt{E4kSjVN>TJApXiBC?JuiQxc|76)mhP${arYR6-91WIQa{cm-O!=hHztPxEM zT&b-&y>&GQQ@>GAziV|p`64^AjTHN=L?cyJh6dIGt_7pf|(8>$G+6pKf1D5Y(~sZv`0t@oIm%1GnYp zU?eP@Pp6*zvN`9BM^I~t6{^7%oVwaRg3Nab{%Nc+z5aqSxV5<@@MSkKg0R2TVr_(Mj~c^aAoGtn#E67XQcqc z>NWQbsGEO{Cw}@2#8n02td)%3n%_b3U%vK#C)mHH=KsYL%x$FmWcjbTdMNsOqD1eI zNUJ{EVla^8iA9<*DOXE-#f@MNV+@CY^i^j$H=|IE;wR8ukpT)>O@a@8g8sfj%Boo z0V7B6sa5RT4<%xj_aSA=pYyqI0}6MJT;Q&OnSsOR=2NUR#@(nq`7)uj2p<$WLkG}P z8EEOpv614l`I!^8f@DQR-XxV-c(`loNJ*a`d>H?npgh)!*>!oQo&MIFP3K8I%jg}& z1%>r8T{-fMxAO#lyO$(pL9QaQ33SH*e`G<13-mYE9<7~i(dlY0o@(N$r9(-*B}EfV z>9Gf%^!W7os{6L31Ifh8!yNr{y*EzBfri?WPBR@Zh#t0Dt5fQdqyOEr0fvAC*cpqR z&!@QHNk4tMrTgfrsC;s4fxm%Nl=j*HvWHy*w~Q>@yP@Xr+b8+^{wtc9PL(sQdqXQS zG`qEf?pBSICS`@e4dH`VMvq$;)sXZm;g`FjH6bIr?+(!>z!f1suc(7Y;#lWzW6z%# zrj!F*WbJSZ@fiy>ZV};uSTX{?weD=60*$E2_qSrWj#V0@k=ZoVOj816k-gYjVJNqS zGow2FUnX9pqGmad+|UsRj6Y)#|Cd5?V*tIVf4YbLH(LRVA=qsRj`rVJf!G`K zH-8M%go(~xe0W>cGSY-E{9@P2AMRAsA|?ymmn&$fnE@l#&!UENTSi?i(tw1Mi)bs! zkf6U}Pu$u-Y+)jfYYwcZq zQ#AdC$+p+~c7DfV-bhL6YxBk~xqS-g!=m_P5`5^rRqppy)kVpYc=(?LZN}sGmLiT< zJ{ErAk@#h2_YN7a`iY^_^DmKG+Qr2Cxyn_BLb@{QMPnKt#sL_owq!|LP;DN5C_BRK z_AfJx=fI^g#h-|Sxl&bT9IBmQK6ufkC}vwVk>1O-?^grrRiOG4=#K0Kiqkz1JO1%7 zK+fN}CzbWB{^8@(9RT^e9Jf`xf&5)Ces;wOwfWr@Bmar=HsM8uY`9uvcWb9%H0@1| zY`rCTTdN1?fl0MO9UdST&#TSQuEjut&~-M` zxq9RpV2Eu9(0~|K$K7T&gAqDl%CNEls0d6j|G!&e1Zd!#_g_&g1o%Z_FlN*}E+lT^ zDRN5o%TO^lF&xyd&KBBhST-lc=wXu7@a7Aj1;|qKv82!dsX(;MsQRcnBk3V~`QG@8 zc&gi$k`MCOTXEW(a(c=PzqbP#<0g=k1rkmH2MkLt?b-Vo{aMZz$gW%f@lghj!bZ*5 z3c0m{^vYegbztV)ED$e!I46=GC~7GEA3L9ax2=QR9Rlt>kzb?jgsVVN0xXn;3>gn* zoPYUbMCw+U0`18dsEFwUj4vLjNp!S7rkx9};sbtNLsJpN9<3wfzLI(BVgun%kgmPy z`VW%TPn)UR9x!>R>ixK#xc|lp114y%WXyl?wCfQ>!DEfk&wsH z_9JMoewQN6OXGH`$E!}x*MJ%CH?57GX!k>n0X7iLabQ*a8d!KJcdd&XQ~&@@qZ*D; z1=#vNkGKhno}ReFqJVFjv)mHLW~9VW(1rBi<7#*&{{XH}dd%?HuV$XX8s=?;l?kB>#$4%qKk76Hj);~xS1eBqLuS^Pji|gWwZ@?yr zT%3dm4mLhTr0OfD&Z&m0s4QWAsW*GM?xgIlr|I-~Aqzm!n9Aw-lzwu3h5$G-H6#Uc zu2B!d_Db3~`3O(4pX{}f)5;onDDpl%&|CuLv2yTW83Hv7B|eZ_$RzMYxGT9Vj{h~e zdml&(Ndw3LA%XcA*dgU;o`8ixG6=b!D_lbU384O*@WkpXHm83QQwfYA#{&@EzvRp> zmtN_HU@QS~tnb1y0R?k5FYr*I88+~bQNSUvg2}{u{)|yy5LdBP8nkGTa^!q9(_7#~ zXSCxGT^>zRVsa|NVk~pH7g;w3L|5^2zILS!KgmsbL$@~PX8z#$1oaH)e?U7u02}~74KnsC zCoF->t)MU;csz_f>u?8RZ?;kOg|jRxY<*4Mtk*&F3&8CnV3RfADQ^%N%gbO00`C;_gY z1IV0QTBwDfKZ5WmZxPXFu>BkMCGxj_?T8rF3x1p6Gy=>=A|5Y;0=yPvL9WVQ^LqQRw;sHIxEVNU!`McgRUT}XlPXY|Y;L_8M z`-@<@C$uXGxhNL_n;zcFyC-()lGvt78)+YvOll72A31`S3YxBZ7g50Q+Q10J5#`pK z6Lz?NAkdlapu&}=B0@r(lPq2;NAU9IV5-*6ajX85{(2B5KZEkE#Q0l&XB^acj`;^( z&SGEt?z6}$r(yKIWqjl*=akWU%pc-3;E`6aUwX)vC3`GCn+zD7Q@9vLBT&z_3Vnok zBR*#BfCOsjO=K>T|H9Y@R3tGcffyzkPJkhJ(VjZNZV{9jjLehB*@Z-b-d1kDrDQqi zEH%}=VVX%#f)ji9biE)}g{mU6<|%{Ch`P%~ROF%S6zM7`hn&gcnLu=a*nS07D>=g) z%)OH-d>BuCH~Z{Ii@R8aZ|fi$*+-d$CQQi^K{Rd_1^OlLF#IAv5gK80RIxVQeHDfn ziqo$TM)`|yg45n_DfkjqnA1eIX@@sylIsSDoK#owie63{Tu`c!=WhB?B00w#4H z0}>`lkbuMTc2;W}v&;>E1)-VxyfQ)9-PjXGAJ%PBhLtMZCc3_E=8BzM-3WZ5pIG~Gg2a7 zwG3CP5JSX32Z9Ot`i(g2NCZI{Q2iejhtxZkk*TOh%{a<*Y@zOa_i1ljT~F!O4Wvb< zeW@Vq35sHp-2gce-*^`lJA3R+LCOcH)GsIqh|jTqCHVU{4Moh#SyS*bC2J zf-=mo-Ro5WgUsxyE1e->oV)odB1gd=U==MP%mO%fPoB!(1~*q8JnW8zf}z-(_}Y~D zcT{^1v+qoEBG2eDqLelqPVnZ|p55)t={AW1c3_8d{Jer3%wsow9>&Ydtr%sCmyhejB5wtzR`=^vVm&R=3i=|n zm8G?m$Tx{98iI@nOW*T}&!OWV0N^C^DVgCN0+8?ZEWXw#)CHDe8UzBd2 zkOrSc$(%9Yj#;YPt>Slh)^yDzbh3IeC=H%5-Tj2+p(pB%p`i=i$gR#$$`YHVLK!{)$S z?0gt5U;Lg~e!Zl8>Iy4FK2K>Wd$G1f@-bvJQn6<#jEn%IDp_Sj@W_P^N$y@uoxiV` zaSoa(;7!x$HLA;_K+Gmf?^Mn>jmQra6&eFsR*I}54LjhBY$c)7U)aSBixlx8#T7)R z*+h1GE@3viMMXWWv=ylWBO86;nM$KAmc+BV2V(d^Ezw{cCiyORs7Hr!Pp47%U(P4* zC?j5d0i*<3Q@ztz85KXUU9oKk_G;B{FOr@*2>gxe7n zjKnBmo=t{~y+b3N<&LuorK`%5Lb^AqW&5)bP1 z@5l}#J{RlN{Be!Q7!vdLB?T@^Wjbec@39Fx)&GB6muytOm=`BY3ca~S1DlOmENjKS z&3fp~NDB`G-XbEr0xE8E$cTy@-@JXWZUI$>b`N*Ri<1ZJ*}$s~NHM;G6aD=zRftYB z@?*-J_c8B*cR;<8Jc`_IP@16Xhb?|&L?0EDG!HoiD*>VWigH~)3%W@It=XU$P9iArXF`Ky69Yo(V$Hg_7i-y4w|QZ<4hM^vB7>_)G_X^m5w>PG~PgB4W%_ue7hmB$`w6Kd+a-uE*1STmea&^uZ<|i`D zTgxKG(Q3ebAX5uCXk{FJ>nFdHQr|;N$S;pV?9y;{qGJ4&v=gxcP0U0E?Ou#+&`egF zY<{Ty;TpCw8X;Tm>jDs?Ccaj!pur0Kq79;-W5b^F1CWfgg;PF~;i9GzvXTjRzdx?f zP&E)D%S?I27P5&o+jv6}*S9ptP?Gp(kf__=RmJB;{6_kI_vRMi)y10Wbb!DFJC$A!xC5k6ChuxtBKl-QKi2 z!k0HUm?KY}R|t#A;~&PdJW{cBDCl8VlQKQWf|E!%u6td$6E}`M8BLGz1?t@xFD-bXZ*ob_piwx7<_p%2Yur~YpeSK1$S8G#N?vF ze(|tmA%g=zg%u2M-))l!6eRnAQs`3c+L~3z^}^3{R|vFCp3H5A`@Z(Zx?>dEkTUQ2MKX zh#Afu+a^Qd_Z8@rlQ!)~Z!EaZBQH8(*qxz9VJ^B)g(J;?gNXG{t|2e_gI~SOr(+2& zYTD-KUYyTL>IB3_y@J|P{V9~%;KO z8u%ITm&G8;l|YW=s5Zfd?9^A$%-X&XNYHQ2XY95GRL4c|eFV%}A_zjRbO>6diPt)0 zNWZc8X;p2yx6qP-Xy=>VM_>?=j_OjA*p@Z`QrFdCyO$NT=Kq$Z1jthqQJE=%6oAVB zs1Mu^*M>U}_Q(t{6=UeYn5WmpLX?-<>i6To83l7vgbZRKPLKz_yCO#nHh*gID=n=a z4vw=r&4%ILdH=Q^46!3gtIx!&h7p_G{@JA6ifQ&8Jxh3%))eqdW7ISu*X&}81p*(N+?lsm?4f$c#twQUzqzc)w(YFp7r!p6J>^X&e;S3EZT?Z7 z>OEAs2t^(Iv$NSNV#zh};GIC1b8_c_b#9B@px8=3bQ zAbzS&OUaGQ0urO#NX{PMg_dGUauo_Zf$5zSH;b-6?*O;}HoXvXjVz#wuu;+8^ih#p zC0}xns)V4S5fFspJrKls0Obw21FIi_Fr8_cwSewNd#_9^4j?hDabQpd+vUP+gu4m( zFUc-=3lr~$pELm73XY3{c)KY8PnsG9Zz~ORWq9z5b}l%Q=Yde>#201&4^I$s=)Pc1 zi&#g+N{)EL1>wiL@Ns2Zy-0zD+el9Vs@g$@k*TKTj#9L}(=67Vd>6{wo< zeyopu*~xJH%mqaZiA3B2mJqLwb31fN113_H81?wEZPG4VyX;}5^=@@}|JWfVu~hq@ z%(C34w{cPZ%T`JyUtK^$!7<3RA-_ZMQHee_vGCnw*KP(v!%zGO$HHpyma$=Frc#73 zvc+VP2W!*QfgO-?>KT#QyLRosf(Q6S6P-h4oWe4ODu8-rwxGJHYWYQka24$OpK;}$ z$mhRllVvvnOu3W+p@tk2z;egayg&VHw1_JxS!i95O?p}f z7hge5kxJtG+G!#!A;%*|C1w$_V64KwOhmLcgTgf81%ZQzX2{RASUydPL=PU|LFaI; z%6J=sC?>R`Rqq7v@!_<}Io;vfsW*S#jCc5)Z)6LV>`36Wtdy#}Nph5RbRCE6@v|wM zy}8J(eMP-fa#xyt*?g)4nL4n0vne=FvBSsEL%bsTO37exY@?RdI)rxFlf`F=mT}LQ zJk(-cQRO;}d`I_VgbXZmst_fa$~PlcPc7aj^&{1q$%F4afyWX7$HVQYy_Wr>v5esc zq;Y-#IWc<8SlEP7QP=OdiK`Q>!9bl@$d-zIizCaFy0!Aaq-qKW5-C8qIcMn8e>J-8P=gk}$B=4+ ziog@GbP8-MUfCuy59>L($6}C=avIp-}@WG2%2>J z#N@FvwntJe7Ms2Ipi{(srMjb>(ou@h@?oE&GL}HPx&3=8v_tCK9)~9knRNwT)TdV^ zzq^Hq4=1ntb!hYdSGN$GA^76Bo7!P0HDBJWzhq-u$5VL_cR2g}28sHnwpHRmQXnoh zp1n6urxo+rX~x5N_B%!c^A-;@#Zo6(4h$-agM|b&(ZQQeQ!H@i>s$)rL6r;H#Sl|X z!D99dLu@b9Qd12VX&^`N=J{2>E=6#?X0Q!m9hgqH+PaEGpp;58hp{;{kNkw_ew9@d z0HcfwWq!QUG3_0vipH<3_%^?KlayKXaI`||hL7d~#^wObP5s=E*L~GWe7n~F<$VQd zfKdBuQr}TgivD_Gg|1@p*^hvgMk%3luMtgfjr*CHzsDWUOc_5P$kiHj)Wokch>Ll) zfi3Z>ft+!zPk( zeDB@V8qB-^K&pUz7430nKpk%J-vt;nuM)21Ht{*d%?eP6l*sqnYlphJO>cDA&x0uR z12S>o4QhpIfEnE-BZd$@M>fuJ7tjSXYQ7IRyG&8AU`U^HH7kU*9R_bn;E*$zqn(%6 zU34E*t7=tz0)>k2lzOPQ=I&=9_I-oc>xoqG$pU1r5JcRqG%U?hWs_0QUZo53CztZG zZH}?wG#C73H*&6ZHuezfdCZ%#NnQ`-G>I!=4V91GC1ZFoGY}wZJW{f(k0~0-$>A?lU53ex?uusaAX#w-|PF ze-Or!q|50`%SOr`nGO}ODj+?ZkUZ!EXKDP3DV#7gWzUh#^Hry1z9Hw}gXRrwcE1o{ZkSaB<6az^aJH76>R( z#8b55l+{^nn6_ECL(yX1rI?vDYMYOu2+4 z;{a+w`n^GSA2yXkvFGIBF>z|SusUT?)fDr-eE+x=l}WV8LK;WxNH zYK~N*d|TQB`7UBL(s6x}2p(a`MbuJsZlKZAB5JP6+Suw*J81`K!5cu>qd!>ZV*7@H zbYjQ`AM~Q8L=ZeX+FY=&k>IpK@L(&?cpj}0_rxZ^7rEkXNGy5t%b*V_P)h*?apYvW zQSQIs#|-Z-)lTlvDFUB39^dsjnQyXAJe>%K(p)zKtAa}QfNwxvcF*LH5KW|n=T=tb z$>PLnj6uFnA#(OM*=>{=V_W-7%k3@e1B zya8!U9BRA~q6KQs1U^c6L`rZr58(D=hCJ!x(icS!9%@Mgg<@R-7ei;kJEUAhlTXuAk8nd>Zl zNfx}9!6XJ^W6f%W3X#3nz4pfcHfr(TjUd>FkuGN$d)D{`kX_S-fl-TjXlmfm61SY1 zRRv2lnw5!jscX}nud`MpDN?5{u`#M;s=^hdKHsH2)6K`(mW zc5HSm$cnG*l&VE7mr89$d#n6Nsv4o)1o%QBXGJWRM$Y_fhi=KM7^m7!@znGCmLnX6mjNnPRe)jBlS~-d0 zH*`M&izvRm!l%8|R_1u`%+{;9kSQ)vQZ_RqyL(pEEgU=N8a`cAmI~j*J`y%K?j_N( z7TeSavEgB#T;MEb*BKfbmSo&=(sP(U`q0Ds-QF}5Mc9h~*LT8btnGc`u0E}_O+Pug za}M|1nc>foHB>me28Rz1sY&q4gumJm7^t-A*3OX~K4|__jkm}jEEzW1fO>l`i2B*0 zXQJFbvO_A-HDf;08y?j}VU7HV?||d$$`|tXClssq%)mjtjB*=!HN_EAR1(cIGDP7i z$Nd5XG(p+--dy*T1VUyW_^+xH6M<5rwPsAFLb;4)&ZsFZx9d$o+8R?7LJ*KR!Q;UD zpSuU~DU!g}oCuE_ZDtwzDw^Kzwd(X%7wgupY+>{`C{K(y%c~j(o4wJ$WifO`!umD5 zw*RCr-lTNfX>+LCF!E__c23LiQJ$GTN^<~vnBea?IOO**<#KfaHArY4yXrPl>5Iae zXv1W4X#1F3_#%b{W@8&F!PdSRS+wvb04rh@RH6KZy%S5W)T(^Qd)M#7}B?GGuJskT`kp&toV5ap3(UW*8F6fIHKpj+k1u`^sP zWNWoSLFwj?=7H`D9!_S>8yLr&P3SngOOq0hvN`s+(8=av+dcd^^Hy59uzP2r5&e!{ z(<2@BI$gFo{YQ2WKWEbW98>nb507N6ax92hIOVu>Dn3j1vTm&C(>9Ks8m-6;?D!Rs zfw-1#bZ|ht{wdCCM6ur?k<#xr4;7j!1^E}NQH02=u(sGaU>o@1SvYNy!|_%YMzd4+ ziU$|!B9a-vP+P^eYUb#_A48D{@LajejXQu8KOHI{>U${aTN?=2Ms}Ht7r@!1i8J(p zWEq5f9j}g3dO#d77Awi=drGed-gW@@(JRR0iD(8; z67)~o9DV?HP<4gsTKuE8FSrV9FP3ON%bA>Q8qD8y(9of@e&I`qcYY4_+-Ah}BqRdp zm3G}px5@k~(8>N0;UgWtIMXjkfB_Il8B1q(1yG=J!1rmfXU=`^?S~Ac!w|g(u;Jpx z{Z!2turogaowh<^cn6!bzh?FZinxhC5lY&l6h!Df72SHez+W3fmc`FPO$ZCE9RG(GUz1WsXo%V#YuE2HbE- z-Q{6{HE=G8LP3&AUJs1`n=;fRB#uXAzy==+3r8&3^y#V!(p~`6)_mjQSn#nM>FSOM zXBkM?$AC}CCa9X00)h~!v*naFAR8P8oK$F(fo>tC9~dgB3FL9!;k7Gr0TPftKsJn+ zy5*|X+C%Ebu;SvgXbG;ue6z0;yE-d81b$czAIKDV$K^+y zD=&8L{T|C)zeRzhTY3!A3u7?TD^!7h?Vo)pe5*NeTMzB#z0kV?WWE5Yjpck}*K`OO zHA9V_9J$&T`<$N}eg9mVK*?+fwYy0tx+^L@u#l0sKoZa| zVVn_X?P~^iSk8A9eIkF>9+*CNHGX0ZDz)iXS~+qMEyVXu-hy4{&^uU%zMPAXK@H-( z-s~tFs+vIemXb z#&j^v>gB1WoVL}e#C^8WvDZQ1F(ohy-~zl(8CuD`c92yNZ}+0IjgcOI3NP50w|4x# z^OeahN<}e8t|gGf1hRiuNeXrR#_+Z}?@jGe*9XpDuuQ%|%qVUL>jTohH7D2*Hcz`1 zj>N2k9yj&M7m(rHn>8C%d>hA!=slp+!9X9k4MG@0P|){*@XfLo0s~ou+p9@XTcFIq z9%8$FqVQUAJJu2-<1G0Cq*XPFdohH!E}*iFZVr5MH))LJqg&t2X0t`id8L<{(Yoy~ znm0O}x{qeG`hAQ>nFcfPqVHCdy|}}HEBvDCrIJ%su%M75Z{yruN%q030!ZICZPt1( z{hgvYcJ~7)O|!J&J{)>!1ZH-xLhdjz3A?e1CFU?q#DV@D{1&L0Ty}h{MuEQH1e<(} zX04~~71R_A(iG-*gz(?~b($)nU0)&zdM0QPlop}X0kwl?8<*046U#b0L~AuKQZIv9nX*=19!4DgV5BdQx5 z9ZcFGYZJv|AM`|W>6P^-Amic6GIDq=It#~g%9zu z7=0TegsFW1ft2>HSN+CsCv6G(b^2U!#(aaGq!P=kZjMp&(=Q%LMEXO}6G-z* zRP3uNs|(-2m)nZdPwzdJzm4cU*S&L*uS4j4(etMNGNj=hmqGk^NpBZbwaF3Rjtb#D z%S4Nnw=6?#f-!&N)dE187U~jB@U#^pJ3i?kZlRcbExbUYgpt1l4pCX z^oMW0z{di=ci1ym6`@jJ)k123&JTZ!$fnRX4}ShZLohpjVzd@RJ8Kj0az=dAqlrSz z@%`~1wP3;NPg{+aIvu0#7BDxpF6JizL2K;lo40_ySnSU>k@>jb%{!Vx)7MJHI zWq=pe+Vt9Ycs1D?>Xw3MU>V4Lw9{yoQ!xW}k@USAh$!z{Lo`R2%s%os+525o0I+x> z;K`P5M`#j}1Sf-ZUB_@TUc6#5UNU4_{ev;LOSiN~TLvm|8uGiA;iWa(lAqo9W%qNd# zIDPOqx^WZ(GHl}Q=a6Btru%6Wv$ohd#6^=9-T3JE+`Lpa>K->mR)GMTn9RoDRt zdn(Ua*gXvg#vrl#SS0b61J#S{IL!c#^mu+9Aor3E&bKbTzX+h#KoTa1gt>kYN34EY zwM&{A*#b7hE6z}%#TBVTtNCuMC;HKYvJ{6MC?*M)SAs>X-2CnlMnCI&{ z&kX14-OScx(8b&7(~9IX&HxQBVeJ$`S7vqjSBx`HCP3{iZ2z|?aJZFR+Kdrju0LAUMW7*AwH zkacmtu^xElkoZ4kt_KyN+tybX$E|jq27HF%;a;{0AYVFy>YpIQS(pM1ih5OWD@iP; zt?n(76$2`9%h@x;NYKEKl|Tm2l(+2LJDTNDBsrGC+7>~B6c$SQNa+pGg{~0D+381b zKKbRKy9PgT|9TCteqjVozzJNnnAuM1Kzp-wxW0p#(SxVo{xYvifxBK$e%tTKy?r9(1Ij)4MrqWuNfhkDYwB%X46rXm1e>4AAd zKFlg+c3f?kZ^Gr|eUKep@Pf^iTM&KZ3*P!*Ey#@FGBaom(^@{)JR062cv+xUqhaP> zh;cu~wO2any^7l`V2tUiNRilod-;vRr~1Y~GWIFtV@#J}L9h+=jNo`59(K^|`{g0* zc^EO`5>nCOi1IVRKw`F~ivB5Pz+lJ25N&}g+&g{w30?j8+&hf`1(z!jPckShjX^%G6oel==;MQSGKL2|5u zjI11nvlQER9(up`Ccahb^T-gjmS7DE{F#o<^~#P$()CLF==6!x3sTJpZ~nd7gZgZP zd^5dxW~GWu!@a72v(nI;cm8-zR``3mhNa~iN(PAgnyHJ%b8R8L$3Etv zKMA{%dDr=x6rnKeq*g%P4(*^lBp3Ue-hz;Pjd?(tf>~-^CewTA{8X^TThoq6Ao$aRVqzdLF-R=&pQh*YOb#@(Th5 z$ktPYjN=#j{O=g^^*MZ=F(|F4ts$kWx*33T-_ z?!@GVodj189HLv1eFQ)M{N%(%z?(Ov%R?&nC9mV(DOpoScCHXe7ZQ!8q={X+w*ykA zC4nAW)#Jxf)n_H&D;CXQ`Du?w*4_i|09m{Y+*=<%Lgb_&qL4HS#x!;IlD5~6OlGR1 zLcgrjW;}iQA>XrFI)wIUX&`&|+beTz7eUQ_EAzD4NV&6+MXN&&+IPX`Y@cG)^=tQVClGF|uya`- zWA(9B%$jz`U>M&5KdBpD-0#$5huA85JPV(2&)}*zw(_87pxbmxVgy0;d!IkzF?F@7ZtT?WiJNs6Ak3quwp{qhWv;tit41@l&Udi)>e( zWSsFcaj^!lh)<_5r|Je6?}!|=^89)rLfAwpdc8+dsS2zG3AF8IgAbT7ck{n`jZ|gt z=q4qqD14n)o{}N}dB0C3Mj7f*2MOR7kazBq3rETO(vrB(kFTeMe-m4T9P&p9V>HIa z3(q3!&MEDg+9vB2Dz-}oUqt7$W2djU-6Z}5P@|@%YGzWA>x{jgKMn<~_*;BHZJ|kW#8C^Y z?^(`f+O5%S8~le5wZfh(CUr`F7O0ON!9G5ah{5u|jXY`zh&Znz8C2J-f>F?9x3%JL zfM&*lM(wvA8cmdNCOHx4Gdei~Y+?k|C^9W0-E=_P&HlEzmQYJO)T}X0)bMF2psjbk z-jDf1z@l6js==dfzQ}?>^5J{O7)2n~G$!*k7_QfHBo;oa<>pW=%H1`EeMsq{d6CP6 zJD;c34LIh0Ek01sF2I*7Rk5j@sU*=F>f<;}J9{R4Z`xatgUXSoLikGn$-dDZ(b;bS z0oZaPDEb(xZ?%r$)b8~w(CNos>QW{KoE!mT!-p`NQ;_(FSR|T&S&MKBO8lApeB_

~`S|ql@F5Dd(Yx<%~}}O6{{B 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 literal 0 HcmV?d00001 diff --git a/media/snapshots/buttons.global.2.dark.png b/media/snapshots/buttons.global.2.dark.png new file mode 100644 index 0000000000000000000000000000000000000000..aa3a49911879b542f2c7d33bc2ad426cbfe79db3 GIT binary patch literal 195506 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 literal 0 HcmV?d00001 diff --git a/media/snapshots/buttons.global.2.light.png b/media/snapshots/buttons.global.2.light.png new file mode 100644 index 0000000000000000000000000000000000000000..d71a095cf4e4626fbf3ad04eac09f963992af779 GIT binary patch literal 186707 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%0D|?<@ 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 =llLQH2-z@ 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 literal 0 HcmV?d00001 diff --git a/media/snapshots/buttons.global.3.dark.png b/media/snapshots/buttons.global.3.dark.png new file mode 100644 index 0000000000000000000000000000000000000000..f8a5cab5cb0c6af895750ec14158969297bd1b03 GIT binary patch literal 195823 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�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�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 bFP@yU_JzNa5oNtV1{~VzdUwiIZ6p2z^-=*| literal 0 HcmV?d00001 diff --git a/media/snapshots/buttons.global.3.light.png b/media/snapshots/buttons.global.3.light.png new file mode 100644 index 0000000000000000000000000000000000000000..5118324c7885916dae0dcb188886d7ad9f54ddd6 GIT binary patch literal 187045 zcmeEuXH=6Q2vP(=nskw(AczpEN|T~U?;xFk(jf$-i;5r!C>^Cpml|4#6hWkf zUJ?ks6GBaZP;UI6_dV;bd+>a^AK$F?q|BPkJhNxd-oM?RSbbe}I%;-mA|fI>O^pXn ziHOMAh=@pSD9H&e948i}gb!lxr|POi<^9*!2tNWGOf(&Jbck*ezEcvB60;ML{pmuu z6p1asGEFHk-J2TK~ z_;YKGUnIP|%CAVpRfU=oHs1r!YQe$ zBJY+8x@-Pwkw}`voSwJ98{mDP)8!kal0H8d48EKtx1zA}=K^>yY#iXHR|4=k8ydt{r4a&M*-~5P*y5teV)uuD(RgW_uuNTvB8er}U^IKk%ui z$xbv^@?XI&$#&HKbuP;NC(7MXV`D~c9v(8>F26p$8ko-{+GP^bwU~plTdl-K3mpd_ zJx1}FHrt&J(3dbXSjcH$mJKEDiC)xg`_50Y?gL5GYrWbK*?FQc;~XMDm{l0e$LiWe z+Y)9)Eu6K*M?X1MX08S&w_N7CydK!j3eSTo;{X+#|3Eg zo~nQ8xf`1sYK9GmGGpahXVq?iL(quYZ2bzdP#cX~&iC@d%#PFbS24bqMm@G7ew;C{ zdd}y-#?f&91=EX(CwWN1E~6P6O<>7^blcw_MXYz`k`xiCLO7Au+0Sv-6=t>T7TMA_ zzS<@T>SbLQblvK?)H8|(dgVY4egh)zkXnh5Ky_~H*P%pz#^D}Z#B8(4f;m)QplT{? ziX!QKh>;=#%t2r>e6vigf{~VMq5|jMxwkP^mi~pqEh~zONpK%~4KP;nQF3B@51{yo ziSqgbl4M3Qe9#%smwCgBPf5eX|Q!=_bPn zRtEN@h|y6$HI72U}HQcpb6eECF8d_ahpqO$}KUsu@~IPQnV7DNMmSd=(n01vo-vn zA0R;TI~iAZYkIY%T~m2nosbb*X?$O3-x9;GH3yUZ1?Zq zSA^O9e52>Ax}upRc;IG|mlb}NzqGcn-xBgL`F+o*TtH}T^EHwtvflzkT0L05W{7uF zcH4la3k~+lMG`GQfYQ01KRansdS}lzh7;?6>DwOk!dS|*&R%;ne)Lnx&C?UU@2|Qt z)bWbT$}B~3Ya=B1^S*E8EohmKqGc+WG&)V$g)yfFywqRVbKF>gFRX}#iyB{rS3bOm z`-s9K^muenEX>W*aaZ1#7nhfxxvZIQeX!Yn6+9;7Ta&;FUsTbQ+rB+ac z9~$BCRXu(yacJpkd^JC_xX{8q$A#xzPF9 z>B?J8VzALXbO@i{XPkN1fIx;ci(AU3{FNZa%5o&xTCH_Jq!GY%uv+weka^e5h%zszE{}~?M~T2YRxTvR zn*kMH69PbIM=R|sjw}6{G9avbx~!+w;gs{8t75lr=KzCX`xeLbKGt1k^9S8mlrH3B z7b6Uv!; z=8ab7Tj~Jlk}URyfE_!~{-CgdxxaKu4JI2rPRA=`Y81$a=-1P z;cH@VsmSRy6qik3GaV0scm=6%7$4u;N<|j*%(t|>JpFNrKC+{2P@NlCm=)x@TEu|q zBglzkp&Z_yT*2Ea_-Ratla}-?lkYtHlta@u1=xNIu-cir>#V)J<@EMq06-D1y!msM zlQ|2k(DpK@+Ah3R&o3~M7eo+@Y5p(29gf|;%^+G3zYx+;_Iqe4&3bMwhPy6`h82+^ z=e_w(s$Y^Wv?H8|lcwCJ;qsqZZy8agrS{7sc9=WAjz;bzIobEGvK@<^0k9 z%PBoiZQMfT9WDqesAP0>bkZT!(vbjG;vmln>x|(4?$C1;|5GBwRvMFbBM?9 zPq*#e+ajr0W~2ov?S2MWc$8H61S+(gpW)Fh_^oOhiDt>(Y6i?t!p>mhUoAV;FT9uE z+#lA14C~65O%OKqvRybYxHKi1t81)tH=y@#l3qM4jzbnffG@z^#qJID_t)h__}QAx z)e7NZ`geWTN9Osz1E7A7uXgP1)KAF>G7YUrA8m?3rWggYpvUVaeH>Rj|G9_iAMZKk zHw)4|sFS|I&JHT*k)MPIWT3?^Yu;-M6Pp2wT-AS*$?7@z`9G!a48Fd?=cPEUYB&~g zPNVS4B>#sE7<_6)AON*p{yzj5GPM73z8#}fv$N2JM53@Vi8NOd32IG4nW_Z>J4Me) z6~PM>zvgEn14;#5VN){91^r$2f7yT++mymFITqpmCi)(8`|cnRUj5l@wIVAl(_+il z-sX1J+peX$o1&I?i96^+wlAeCg|=9}XeG1tnhcO24EbV~v})4*@bP+e5qve8jstsm z(h09NGu@KP_5dqni`#QwtXiXC%Dn=sItq|&Dgid#fiOX{E%Z}%<#%0(6!W{{!*|Cm z?A5QojLZyLFM@(;4;Ykfl&Dv$1D8KLA^t2;GSajG?sZ(9fq+(@J^|g_+(tx4Mn?A6b?jy(p`#%R zx-$=$?w=oaDb;@P7A~b;J|a z5#W)lxG~LE6;xYXTwLYJ77=s*GYYe2;yWMIBL>&8XxSG%tP_zjps`T4kH%bpt6kr${Lft`oAxwuq% z!vPQoB%h0fl9rbk?00Z&%&B32k28LBYX6MERZ&OfVwj=AKo}S<_KA^p?nfJoM?539d7&?-86e`2p*{LzHGcQrCw~B zUg#+7V$!sm@hmfr1oEc$EUD5*KNfTkI5F5bzw;wOFb)MFby1BuojtRQk91MnX zhMw%S8^jp#y;7DX2B&PSuWx=(=MuqxmN@lSVNDg?@H4o4F*3KApxOUqv%1zd6xMDc zB*^Z!HRba5s#sNzc+;&cJAy=|2z#YCOv5TJ4enf6f$rD-K1J%A#n}HvwFQv=7GxU= zYpdsm1o@&1$4#&V#Wg`t819=Bl@t$z>(*W=1aos~HQ|WkwQ+9=Lm#_uNR`8k#da_< z9`RBo0ip0H_h|_0GN9-@g$UyaaH(`qi)Vnp4McM57Mki?UPI@?Jv~ z{=PM0O9f~Wl}^9nSAyPwWq?wV^uE-@tJKUHT#h9FObQP=0SJY{o2>Kp18~={7t?3f zhH2F>oR?Gj7OTm#Ni}-Fb9HWTO&t4OSN*ueI%Y;1xtZe!jDguE_ZN`w>Y~8OYZ*nl zx2bYZFID@vK}fc#2UeP)Eq#l%I#|O3Z?4vZXhAnGbeNm}V}9q74%L3-w@BT7Wa-^a zkm>z3hH~D)7d9a-5&O@;b@6h`Z05}YFEu1Px>hk#-@V_Yx-LQ2i)ad^AOjP`M zN6weJG$L%DXNb4L-C1)0U(#**4$*rUJP_yzS9F+6wURWiM%>vLGbyP3nZ1!5oVYfX zrzRG<`mBdLB+_B|x|5=IS@s}Z;siKjE$9b_nOD1AQ}QkSCV4;8m1vL{3>fR+*X(R5 zpM8+`%g@uOY^h}Zmi#X z3pjYzG#AaW*((L74P|WvuVG}4(sA@@5^JaWKj{C$h|Z)~4M}01iQzBaYla6t%>+d3 z=I&k-%Or&LSyM~_*;t{68tn@w)o|%zfgQa`G@sp*4Glr+@7%{0=FCpbu}x`W_<8RX z^SY77#t?@iKF72^`N7S_;oe8@HAt|$XP-$`aFs7X4O2)(y>~h`xTEIA4N5fR4hkE6+|+x=*FMQFrjX8~ zsU1_FU@|dS`7@+xE)=+vVvV2?iw`tkLaR9V!^aO>YR$ckOT>WSfvo9GJ*_$0M&cg@`p_hPJ|aQwG|-S5asHYu0x}W6-%* zXc9#updlwWftdl9eZPOXq{iv98Qq$JJzCV*q-zgudiJ?c%zI2O1rU;D{Wb4FVl~@p zj#K3{uSjAwcGTY-|5mATsxxYAz{LK*{*g&C#k$nk8hW2MIyikNi^y!3s|pgF4trWw zyBRcLmK898FjBJdtuC)asKIyGQOTr3-0%_}!vf?ob)1iALu$%pQaVvAooZLl&GZ=^~OpmjZZ z;im#GPIkJ|75B6x`9n9(C`=tEkqvi7V|)hVWKqrpsfcjnC@CpvmwZWc^_Ju{hXYS0 znhRZP6qU;HRODn73yCP>&+qL`Q{lef-6!ufZMV_b9}rKjpRqHF%;XM?3Ph)mkh`JWkGj6NVUK%;0Po8WD6}0Je;)J==2Guz-zKrOTQp z3$fqAtUxJLc1stJ!NB0kX|14uCiASOZnMMA^b3i7+}mK z==S7GM-ts6f+545-PGPUj~@86Uz*(d(HVbwGw1o2aP;w}=(qBj!W5r*;Zu2C3}!s~ z@@#v^*T4ytIpBqFu5MV;he$oo8g@ORkqqxFA<5=qp=;!>RA?;7sTn9Aieo~a9m)dtUt7vx z6Uaj&6S+_ZykBE8Mjy!b`9|J>K6wp$6=ci3>Q1wTs{_GILdlmebdbU{xUN|53Zd5Dz zu0K2HpbRDb`5*#yDXip5Ta@{K;x<@%NqBmmexo&P_?R3hKVBbh6!| zP|fOliekUo%|wlkSF{s@oK1m;cwTH~qe z1A((>>Yxb~qbUDAg-PBFDpSZ_C6Y>`@?6`b0Cw@*=QZf-71-ugHW7;>WI{y?od zE^}6sOixfaH^=j|WcqU{BW1{^10JZX?7M=*BHSI%vfs_AYP+gj&A5$DUH6*ki1Ae_;!;zZf0g zGa+rH`@n2{lau+@v>ta!OEmHm!CTnulMQ)O4H~&a3im%fzU&5G^?}oS_jw66Ci;k_O;Wdb!Q~R5XXW)Te8W5XSmq30XjPx@(l|97yT4*iQtPd?&#{zL}tQx$?7=6GDFGNLPM;Ky3ky zoe04-&4?))Fd<+4r$ET=4?!|KkVsM;*zJimG94YzpsId`fNL<(7)NFnW1u25m}> zbyF0Gcc!_=4|U+?#)f9PKOVrK*v-N}=pGl57n^x^^U1I4kqrKnUZSgI9(2?rF>{Hk z4A!YdEs!dP(u(4u+T+TvWk*}`WpizB#iYJy@_NE_Z-dSUV+3lD**ShI2)~Krt`Luf z%Vm&8@|~Od6{O*QcX8p-^sYD|7q2N@i4iJ+YYNhsnKOFyn7$U*FXLUF8uOSI=hyj- z(d3x!f#)0C=M6fa&INnMjxw0DB%dUo)tFnk&Xi*_{Z^_W$1iks6h-RjN11P(;Bt6g zjwMl_amj@$(Y?=;j#UzCj$q`uX&|4hJebu9O0-er4^wR~D{txU)M0ahVV#oWm&|;o}Z%>DnP7P!$0?HIV4h+Z_q*8DPe?R0((^CbCpdTid|AhglHs1#w z%w^qvRqrxfunn9JqvjWU38ps$y{cM*ye&O~;3hta!~{_yQyGrN-%|_&G`Md}jB@Q` zHT)F$ub&vITKx!pCh}t~%=^G&+luJ86Z|?bh)2=XC%!pO&Zi1(QZwLpibZWdwjA!7 zQmwbFoDMlHD8$qp=uO!VzrUV3rQJ6=V8~AIr>+M%e?^08dp9jv+z$z)S&*OAHzkD74 zAenZBdD&PWUy5QWDMhTrN$T-_Q-72U(;n(lRpSpN8buac(M*==VK;&BfUhOZA<4Ml zfdoxME*pSPCuwLWz;9;HpEW!Eq(9MrVP*TufRokC$T*DHY#v6{zCuS{kxw02$O>*( z%(T(S=J<^d-B%BNK5pTy6Hi!-B|z%q^?NHB-tK#j+;wGbyDd1VXZ)gSb6!bFPssjP z?n&6$2CVbTywQ@e#mk>u-BET?ZSSLt?w{@tgMFar{K9(|#o5_N!|cLyA;fJyP=bFq znrA_|MjJK6GEP7x(ITRMG{;l|O3`~90lPmgO={iv zMBn-WXK%n`(Uqpg^UOeUro!+RDq>DcUO1ovH2(gYo0$K(pn?I*8?=6=)vce$jSWIf zGJuAOv`&ylOYXqi8(%$`&h)e0csEw6xxV9sVy40Z6BIYlza3pqOE>T+{oOaFIURTkRQ0J_)QRSYP|TB>Ba*x1kl}H?l;IzbcEe_$gLJgj~YS`CIiM zU+3f(*3b>iAj}WnXA=GT6(jAjERXoy9tS$2~e95PBQ^@+qDWl=+_?KXPkjYfXu!XVfI?%_NFTBo7=sO>*^H z_Zg0ibF8SMg|e}{?-br_%}mfYL`iOb$TsNwA98Oe}f9L7jvcJe@jCz7%udvvky z=99Ko+`jcHBSW!M>6lRPSasV2_?f$}B&8_P*LHJTWX&ME z9o?&%eC^e$;jP(a^NTTlK`>Iz&@Da*MWB7>-N2mwO9>6SdSL-}UC6S?FpmtJ6)(G1 zH~#kFlT2A<&whwkBh!-uZOYC{j{VxU)HW>q(9!rsQF^+vwnT#Y@l* zITq;$AHp#&2N#jIUvu}MwxeY~8Ho|ZF|r^GJK4IFxy*V2?Ui zU`M~gUax+E94jGi|3sl8_tRA|GlB*5I_i9z{@jKD4P@tYr-S$Uu$A%P(tzH}7pdS` zN`g;Q7+24B@9hftoz{dYDXJDHfHI^<=n-dwO-oEa;Du#=10kM)PNEPE;B2X>*sg1- zUBBWQRix%_9tMzjYGcv*B6%xI;s;#Bq&yDhtE&TQ?$^*p9Zx=9zC*AaX3e)-ngUFc&Nt;DX*HWnlu=UgMi zWxAfGq<#ae95J5G5S(q@VJ+3>7l+DR|6 zm0@r3)xDwyHajmBI)Y`^j_y)AtIPy#HB5(}v;TJXq$1BWc*-ZPWpVN+=3@+9H=NEo zB6#MQnP~I-wH`YsuI=waLh%qkLWK0{3TOcQm($4;ON86b@KMd33Nbe~|1?`0(}TCT zo|T*9@0MN*L^}GW1(?3{ARAA9tlx{eG-OMe=StrR;!(gXeRfrv+q-R2Mh|&{E}=k5 z%{8YQ2g<16*&lUpxY89-koACJbw#%owBgSp6;89D;IPHCW?$bL)1GQ%>C0yk)`%-4 zE9_|^8%g{nLJ^3}Cm?B+TC3jlQrvIjz;u9Q#W*cy*&%n@${4`@#sMYDcU}HL`zkpm zuu@FH;O41||1Zizd5CY_WStmN;c$i_$ov_oEeC-#K+_F{l)+2%(Qa`Nm<(;yqPeHH zuW#n)(YD521a6nz(`_P%B)@aR*dch=n%hidVNddz3!5-3J*yJ9SGV1Jc!Iib;OR`| ze2?ptBS+vAH$Hqe;Ceb+=SWD6!D1mW!2V@n*=u{Vir|^8vl8pmVkF&&=zMWW`MOa- zM3F&ZwC$TAA+C_ax&1jVe5KIC~#5UmI8>QY(UPSm-pq}ctrQGUQ zRdm#ypomdXp8d<)8||P)>5;^(&F<*nLX7K#fSyf2cT0_V>W1K;Ih0CTz*|uorFZmQ zUjCBdXJU5K)V1>ZI>#ZvC&}K zHGxzAd=#TW!DFh}D3g^~p{(z)!?&>S^r46CGT>vMKRFMd;#HD#sxJzhZz_cW7-{q4 z-?y#|?S?6AVQJ}goi<#{5JD0_&z)FBfH0@(1nMc-LM``i)$OOm0TMeokhTookLab} ze5*If8c*F05j)BO!cPw6M+~-9RPfm4vF%#`sS%u=7?dj7&kaJ+f7iw5bboNH*}eg? zW61Rw$8jVz;-RxyNVm!d0=^C%TaoP-lpBH*lx2?3F(1hH4Zlth^8I^WY z(RhCdNQYEZb+*&f!5>4uR-lbe5ou<_&nwIN(7Bne5T!QO+R-F_-VZta;ev&i1;&=f zqvV)R@;<2*@JbJXydrxOa1RDwXf?+7$1JLKnx)AD_dgBJea@YplY{P!S(-%0D|?<@ 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 =llLQH2-z@ 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