From 620ab7138c920feb5bd8e8693604a834d543788e Mon Sep 17 00:00:00 2001
From: kefrulz <9220848+kefrulz@users.noreply.github.com>
Date: Sun, 9 Aug 2026 21:10:02 +0200
Subject: [PATCH 01/20] Bootstrap hardware probe
---
.github/ISSUE_TEMPLATE/bug.yml | 42 ++++++++++++++++
.github/ISSUE_TEMPLATE/hardware.yml | 35 ++++++++++++++
.github/pull_request_template.md | 19 ++++++++
.github/workflows/ci.yml | 32 +++++++++++++
.gitignore | 11 +++++
Package.swift | 48 +++++++++++++++++++
README.md | 31 ++++++++++++
Resources/ProbeInfo.plist | 36 ++++++++++++++
Scripts/package-probe-app.sh | 23 +++++++++
Sources/RemoMouseCapture/CaptureModule.swift | 1 +
Sources/RemoMouseDomain/DeviceIdentity.swift | 5 ++
.../RemoMouseHardware/HardwareModule.swift | 1 +
Sources/RemoMouseProbe/ProbeView.swift | 12 +++++
.../RemoMouseProbe/RemoMouseProbeApp.swift | 10 ++++
.../FoundationTests.swift | 6 +++
15 files changed, 312 insertions(+)
create mode 100644 .github/ISSUE_TEMPLATE/bug.yml
create mode 100644 .github/ISSUE_TEMPLATE/hardware.yml
create mode 100644 .github/pull_request_template.md
create mode 100644 .github/workflows/ci.yml
create mode 100644 Package.swift
create mode 100644 README.md
create mode 100644 Resources/ProbeInfo.plist
create mode 100755 Scripts/package-probe-app.sh
create mode 100644 Sources/RemoMouseCapture/CaptureModule.swift
create mode 100644 Sources/RemoMouseDomain/DeviceIdentity.swift
create mode 100644 Sources/RemoMouseHardware/HardwareModule.swift
create mode 100644 Sources/RemoMouseProbe/ProbeView.swift
create mode 100644 Sources/RemoMouseProbe/RemoMouseProbeApp.swift
create mode 100644 Tests/RemoMouseDomainTests/FoundationTests.swift
diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml
new file mode 100644
index 0000000..53e6a9a
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/bug.yml
@@ -0,0 +1,42 @@
+name: Bug report
+description: Report reproducible RemoMouse behavior.
+title: "Bug: "
+labels: []
+body:
+ - type: textarea
+ id: behavior
+ attributes:
+ label: What happened?
+ description: Describe the behavior and what you expected instead.
+ validations:
+ required: true
+ - type: input
+ id: macos
+ attributes:
+ label: macOS version and build
+ placeholder: macOS 26.5.1 (25F80)
+ validations:
+ required: true
+ - type: dropdown
+ id: remote
+ attributes:
+ label: Siri Remote model
+ options:
+ - First generation (black glass)
+ - Second generation (silver, Lightning)
+ - Third generation (silver, USB-C)
+ validations:
+ required: true
+ - type: textarea
+ id: reproduction
+ attributes:
+ label: Reproduction steps
+ validations:
+ required: true
+ - type: checkboxes
+ id: privacy
+ attributes:
+ label: Privacy check
+ options:
+ - label: I removed serial numbers, Bluetooth addresses, usernames, and private paths.
+ required: true
diff --git a/.github/ISSUE_TEMPLATE/hardware.yml b/.github/ISSUE_TEMPLATE/hardware.yml
new file mode 100644
index 0000000..46dcf5e
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/hardware.yml
@@ -0,0 +1,35 @@
+name: Hardware compatibility report
+description: Report what macOS exposes for a Siri Remote.
+title: "Hardware: "
+labels: []
+body:
+ - type: dropdown
+ id: remote
+ attributes:
+ label: Siri Remote model
+ options:
+ - First generation A1513/A1962
+ - Second generation A2540
+ - Third generation A2854
+ validations:
+ required: true
+ - type: input
+ id: macos
+ attributes:
+ label: macOS version and build
+ validations:
+ required: true
+ - type: textarea
+ id: capabilities
+ attributes:
+ label: Generated capability summary
+ description: Paste only the sanitized Markdown produced by RemoMouse Probe.
+ validations:
+ required: true
+ - type: checkboxes
+ id: privacy
+ attributes:
+ label: Privacy check
+ options:
+ - label: The report contains no serial number, Bluetooth address, username, or home path.
+ required: true
diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
new file mode 100644
index 0000000..78335b8
--- /dev/null
+++ b/.github/pull_request_template.md
@@ -0,0 +1,19 @@
+## Summary
+
+
+
+Closes #
+
+## Evidence
+
+- Tests:
+- Hardware validation:
+- Accessibility impact:
+
+## Checklist
+
+- [ ] The linked issue's acceptance criteria are satisfied.
+- [ ] New behavior was developed test-first.
+- [ ] `swift test --parallel` passes.
+- [ ] Hardware captures and diagnostics are redacted.
+- [ ] Documentation reflects user-visible or architectural changes.
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..f391921
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,32 @@
+name: CI
+
+on:
+ pull_request:
+ push:
+ branches: [main]
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+concurrency:
+ group: ci-${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ test:
+ name: CI
+ runs-on: macos-26
+ timeout-minutes: 15
+ env:
+ DEVELOPER_DIR: /Applications/Xcode_26.6.app/Contents/Developer
+ steps:
+ - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
+ - name: Show Swift version
+ run: swift --version
+ - name: Run tests
+ run: swift test --parallel
+ - name: Package probe
+ run: Scripts/package-probe-app.sh debug
+ - name: Verify generated-file cleanliness
+ run: git diff --exit-code
diff --git a/.gitignore b/.gitignore
index e458ed5..00b14aa 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1 +1,12 @@
.worktrees/
+.build/
+DerivedData/
+*.xcuserstate
+.DS_Store
+Captures/
+*.remocapture
+*.p12
+*.cer
+*.provisionprofile
+*.mobileprovision
+diagnostics-*.zip
diff --git a/Package.swift b/Package.swift
new file mode 100644
index 0000000..2974b53
--- /dev/null
+++ b/Package.swift
@@ -0,0 +1,48 @@
+// swift-tools-version: 6.2
+
+import PackageDescription
+
+let package = Package(
+ name: "RemoMouse",
+ platforms: [.macOS(.v26)],
+ products: [
+ .library(name: "RemoMouseDomain", targets: ["RemoMouseDomain"]),
+ .library(name: "RemoMouseHardware", targets: ["RemoMouseHardware"]),
+ .library(name: "RemoMouseCapture", targets: ["RemoMouseCapture"]),
+ .executable(name: "RemoMouseProbe", targets: ["RemoMouseProbe"]),
+ ],
+ targets: [
+ .target(
+ name: "RemoMouseDomain",
+ linkerSettings: [.linkedFramework("CryptoKit")]
+ ),
+ .target(
+ name: "RemoMouseHardware",
+ dependencies: ["RemoMouseDomain"],
+ linkerSettings: [
+ .linkedFramework("CoreHID"),
+ .linkedFramework("GameController"),
+ ]
+ ),
+ .target(
+ name: "RemoMouseCapture",
+ dependencies: ["RemoMouseDomain"]
+ ),
+ .executableTarget(
+ name: "RemoMouseProbe",
+ dependencies: [
+ "RemoMouseDomain",
+ "RemoMouseHardware",
+ "RemoMouseCapture",
+ ],
+ linkerSettings: [
+ .linkedFramework("AppKit"),
+ .linkedFramework("SwiftUI"),
+ ]
+ ),
+ .testTarget(
+ name: "RemoMouseDomainTests",
+ dependencies: ["RemoMouseDomain"]
+ ),
+ ]
+)
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..2427757
--- /dev/null
+++ b/README.md
@@ -0,0 +1,31 @@
+# RemoMouse
+
+RemoMouse turns a first-generation Siri Remote into a precise, app-aware mouse and compact controller for macOS Tahoe.
+
+## Status
+
+Hardware validation is in progress. The current branch builds a local probe that determines which GameController and CoreHID inputs macOS exposes for the paired black Siri Remote. It does not generate system-wide pointer events yet.
+
+## Requirements
+
+- macOS Tahoe 26.0 or newer
+- Xcode 26.6 or newer
+- A first-generation black Siri Remote paired in System Settings → Bluetooth
+
+## Build and test
+
+```bash
+swift test --parallel
+Scripts/package-probe-app.sh debug
+open .build/RemoMouseProbe.app
+```
+
+## Privacy
+
+RemoMouse is local-only. Hardware captures stay under the user's Application Support directory, and committed reports must redact device identifiers, Bluetooth addresses, usernames, and home-directory paths.
+
+## Project documentation
+
+- [Product and technical design](docs/superpowers/specs/2026-08-09-remomouse-design.md)
+- [Hardware-validation plan](docs/superpowers/plans/2026-08-09-remomouse-hardware-validation.md)
+- [Hardware-validation issue](https://github.com/kefrulz/RemoMouse/issues/1)
diff --git a/Resources/ProbeInfo.plist b/Resources/ProbeInfo.plist
new file mode 100644
index 0000000..b920470
--- /dev/null
+++ b/Resources/ProbeInfo.plist
@@ -0,0 +1,36 @@
+
+
+
+
+ CFBundleDevelopmentRegion
+ en
+ CFBundleDisplayName
+ RemoMouse Hardware Probe
+ CFBundleExecutable
+ RemoMouseProbe
+ CFBundleIdentifier
+ dev.kefrulz.Remomouse.HardwareProbe
+ CFBundleInfoDictionaryVersion
+ 6.0
+ CFBundleName
+ RemoMouseProbe
+ CFBundlePackageType
+ APPL
+ CFBundleShortVersionString
+ 0.1.0
+ CFBundleVersion
+ 1
+ GCSupportsMultipleMicroGamepads
+
+ LSMinimumSystemVersion
+ 26.0
+ LSUIElement
+
+ NSBluetoothAlwaysUsageDescription
+ RemoMouse uses Bluetooth to receive input from your paired Siri Remote.
+ NSHighResolutionCapable
+
+ NSPrincipalClass
+ NSApplication
+
+
diff --git a/Scripts/package-probe-app.sh b/Scripts/package-probe-app.sh
new file mode 100755
index 0000000..e39c576
--- /dev/null
+++ b/Scripts/package-probe-app.sh
@@ -0,0 +1,23 @@
+#!/bin/bash
+set -euo pipefail
+
+configuration="${1:-debug}"
+case "$configuration" in
+ debug|release) ;;
+ *) echo "usage: $0 [debug|release]" >&2; exit 64 ;;
+esac
+
+repository_root="$(cd "$(dirname "$0")/.." && pwd -P)"
+bundle_path="$repository_root/.build/RemoMouseProbe.app"
+binary_path="$repository_root/.build/$configuration/RemoMouseProbe"
+
+cd "$repository_root"
+swift build --configuration "$configuration" --product RemoMouseProbe
+
+rm -rf "$bundle_path"
+mkdir -p "$bundle_path/Contents/MacOS"
+cp "$binary_path" "$bundle_path/Contents/MacOS/RemoMouseProbe"
+cp "$repository_root/Resources/ProbeInfo.plist" "$bundle_path/Contents/Info.plist"
+codesign --force --deep --sign - "$bundle_path"
+
+echo "$bundle_path"
diff --git a/Sources/RemoMouseCapture/CaptureModule.swift b/Sources/RemoMouseCapture/CaptureModule.swift
new file mode 100644
index 0000000..119fc74
--- /dev/null
+++ b/Sources/RemoMouseCapture/CaptureModule.swift
@@ -0,0 +1 @@
+public enum RemoMouseCaptureModule {}
diff --git a/Sources/RemoMouseDomain/DeviceIdentity.swift b/Sources/RemoMouseDomain/DeviceIdentity.swift
new file mode 100644
index 0000000..5131dac
--- /dev/null
+++ b/Sources/RemoMouseDomain/DeviceIdentity.swift
@@ -0,0 +1,5 @@
+public enum RemoteGeneration: String, Codable, Sendable {
+ case firstGeneration
+
+ public var displayName: String { "Siri Remote (1st generation)" }
+}
diff --git a/Sources/RemoMouseHardware/HardwareModule.swift b/Sources/RemoMouseHardware/HardwareModule.swift
new file mode 100644
index 0000000..e8904fb
--- /dev/null
+++ b/Sources/RemoMouseHardware/HardwareModule.swift
@@ -0,0 +1 @@
+public enum RemoMouseHardwareModule {}
diff --git a/Sources/RemoMouseProbe/ProbeView.swift b/Sources/RemoMouseProbe/ProbeView.swift
new file mode 100644
index 0000000..8cc6b90
--- /dev/null
+++ b/Sources/RemoMouseProbe/ProbeView.swift
@@ -0,0 +1,12 @@
+import SwiftUI
+
+struct ProbeView: View {
+ var body: some View {
+ ContentUnavailableView(
+ "Hardware probe not started",
+ systemImage: "appletvremote.gen1",
+ description: Text("Connect the probe to begin validating the remote.")
+ )
+ .frame(minWidth: 560, minHeight: 420)
+ }
+}
diff --git a/Sources/RemoMouseProbe/RemoMouseProbeApp.swift b/Sources/RemoMouseProbe/RemoMouseProbeApp.swift
new file mode 100644
index 0000000..b932cb3
--- /dev/null
+++ b/Sources/RemoMouseProbe/RemoMouseProbeApp.swift
@@ -0,0 +1,10 @@
+import SwiftUI
+
+@main
+struct RemoMouseProbeApp: App {
+ var body: some Scene {
+ WindowGroup("RemoMouse Hardware Probe") {
+ ProbeView()
+ }
+ }
+}
diff --git a/Tests/RemoMouseDomainTests/FoundationTests.swift b/Tests/RemoMouseDomainTests/FoundationTests.swift
new file mode 100644
index 0000000..53cb440
--- /dev/null
+++ b/Tests/RemoMouseDomainTests/FoundationTests.swift
@@ -0,0 +1,6 @@
+import Testing
+@testable import RemoMouseDomain
+
+@Test func firstGenerationHasUserFacingName() {
+ #expect(RemoteGeneration.firstGeneration.displayName == "Siri Remote (1st generation)")
+}
From 52efe8b53de2dc7d6157f6307272b543b89864a8 Mon Sep 17 00:00:00 2001
From: kefrulz <9220848+kefrulz@users.noreply.github.com>
Date: Sun, 9 Aug 2026 21:12:17 +0200
Subject: [PATCH 02/20] Model remote input safely
---
.../RemoMouseDomain/CapabilityReport.swift | 30 ++++++++
Sources/RemoMouseDomain/DeviceIdentity.swift | 12 ++++
Sources/RemoMouseDomain/RemoteEvent.swift | 71 +++++++++++++++++++
.../SensitiveValueRedactor.swift | 25 +++++++
.../CapabilityReportTests.swift | 19 +++++
.../RemoteEventTests.swift | 40 +++++++++++
.../SensitiveValueRedactorTests.swift | 37 ++++++++++
7 files changed, 234 insertions(+)
create mode 100644 Sources/RemoMouseDomain/CapabilityReport.swift
create mode 100644 Sources/RemoMouseDomain/RemoteEvent.swift
create mode 100644 Sources/RemoMouseDomain/SensitiveValueRedactor.swift
create mode 100644 Tests/RemoMouseDomainTests/CapabilityReportTests.swift
create mode 100644 Tests/RemoMouseDomainTests/RemoteEventTests.swift
create mode 100644 Tests/RemoMouseDomainTests/SensitiveValueRedactorTests.swift
diff --git a/Sources/RemoMouseDomain/CapabilityReport.swift b/Sources/RemoMouseDomain/CapabilityReport.swift
new file mode 100644
index 0000000..ff2babb
--- /dev/null
+++ b/Sources/RemoMouseDomain/CapabilityReport.swift
@@ -0,0 +1,30 @@
+public struct CapabilityReport: Codable, Equatable, Sendable {
+ public let device: DeviceIdentity?
+ public let transport: String
+ public let observedButtons: [RemoteButton]
+ public let hasTouch: Bool
+ public let hasMotion: Bool
+ public let hasBattery: Bool
+ public let reportIDs: [UInt32]
+ public let warnings: [String]
+
+ public init(
+ device: DeviceIdentity?,
+ transport: String,
+ observedButtons: [RemoteButton],
+ hasTouch: Bool,
+ hasMotion: Bool,
+ hasBattery: Bool,
+ reportIDs: [UInt32],
+ warnings: [String]
+ ) {
+ self.device = device
+ self.transport = transport
+ self.observedButtons = Array(Set(observedButtons)).sorted { $0.rawValue < $1.rawValue }
+ self.hasTouch = hasTouch
+ self.hasMotion = hasMotion
+ self.hasBattery = hasBattery
+ self.reportIDs = Array(Set(reportIDs)).sorted()
+ self.warnings = Array(Set(warnings)).sorted()
+ }
+}
diff --git a/Sources/RemoMouseDomain/DeviceIdentity.swift b/Sources/RemoMouseDomain/DeviceIdentity.swift
index 5131dac..daf5693 100644
--- a/Sources/RemoMouseDomain/DeviceIdentity.swift
+++ b/Sources/RemoMouseDomain/DeviceIdentity.swift
@@ -3,3 +3,15 @@ public enum RemoteGeneration: String, Codable, Sendable {
public var displayName: String { "Siri Remote (1st generation)" }
}
+
+public struct DeviceIdentity: Codable, Equatable, Sendable {
+ public let token: String
+ public let generation: RemoteGeneration
+ public let transport: String
+
+ public init(token: String, generation: RemoteGeneration, transport: String) {
+ self.token = token
+ self.generation = generation
+ self.transport = transport
+ }
+}
diff --git a/Sources/RemoMouseDomain/RemoteEvent.swift b/Sources/RemoMouseDomain/RemoteEvent.swift
new file mode 100644
index 0000000..9c43588
--- /dev/null
+++ b/Sources/RemoMouseDomain/RemoteEvent.swift
@@ -0,0 +1,71 @@
+public enum RemoteButton: String, Codable, CaseIterable, Equatable, Sendable {
+ case touchClick
+ case menu
+ case home
+ case playPause
+ case volumeUp
+ case volumeDown
+ case siri
+}
+
+public enum TouchPhase: String, Codable, Equatable, Sendable {
+ case began
+ case moved
+ case ended
+}
+
+public struct TouchSample: Codable, Equatable, Sendable {
+ public let timestampNanoseconds: UInt64
+ public let x: Double
+ public let y: Double
+ public let phase: TouchPhase
+
+ public init(
+ timestampNanoseconds: UInt64,
+ x: Double,
+ y: Double,
+ phase: TouchPhase
+ ) {
+ self.timestampNanoseconds = timestampNanoseconds
+ self.x = x
+ self.y = y
+ self.phase = phase
+ }
+}
+
+public struct MotionSample: Codable, Equatable, Sendable {
+ public let timestampNanoseconds: UInt64
+ public let gravityX: Double
+ public let gravityY: Double
+ public let gravityZ: Double
+ public let rotationX: Double
+ public let rotationY: Double
+ public let rotationZ: Double
+
+ public init(
+ timestampNanoseconds: UInt64,
+ gravityX: Double,
+ gravityY: Double,
+ gravityZ: Double,
+ rotationX: Double,
+ rotationY: Double,
+ rotationZ: Double
+ ) {
+ self.timestampNanoseconds = timestampNanoseconds
+ self.gravityX = gravityX
+ self.gravityY = gravityY
+ self.gravityZ = gravityZ
+ self.rotationX = rotationX
+ self.rotationY = rotationY
+ self.rotationZ = rotationZ
+ }
+}
+
+public enum RemoteEvent: Codable, Equatable, Sendable {
+ case connected(DeviceIdentity)
+ case disconnected(reason: String)
+ case touch(TouchSample)
+ case button(timestampNanoseconds: UInt64, button: RemoteButton, isPressed: Bool)
+ case motion(MotionSample)
+ case battery(timestampNanoseconds: UInt64, percentage: Int)
+}
diff --git a/Sources/RemoMouseDomain/SensitiveValueRedactor.swift b/Sources/RemoMouseDomain/SensitiveValueRedactor.swift
new file mode 100644
index 0000000..6be6d0f
--- /dev/null
+++ b/Sources/RemoMouseDomain/SensitiveValueRedactor.swift
@@ -0,0 +1,25 @@
+import CryptoKit
+import Foundation
+
+public struct SensitiveValueRedactor: Sendable {
+ private let replacements: [(value: String, token: String)]
+
+ public init(sensitiveValues: [String]) {
+ replacements = Array(Set(sensitiveValues.filter { !$0.isEmpty }))
+ .sorted {
+ if $0.count == $1.count { return $0 < $1 }
+ return $0.count > $1.count
+ }
+ .map { value in
+ let digest = SHA256.hash(data: Data(value.utf8))
+ let prefix = digest.prefix(6).map { String(format: "%02x", $0) }.joined()
+ return (value, "")
+ }
+ }
+
+ public func redact(_ text: String) -> String {
+ replacements.reduce(text) { result, replacement in
+ result.replacingOccurrences(of: replacement.value, with: replacement.token)
+ }
+ }
+}
diff --git a/Tests/RemoMouseDomainTests/CapabilityReportTests.swift b/Tests/RemoMouseDomainTests/CapabilityReportTests.swift
new file mode 100644
index 0000000..014eca3
--- /dev/null
+++ b/Tests/RemoMouseDomainTests/CapabilityReportTests.swift
@@ -0,0 +1,19 @@
+import Testing
+@testable import RemoMouseDomain
+
+@Test func capabilityReportSortsAndDeduplicatesCollections() {
+ let report = CapabilityReport(
+ device: nil,
+ transport: "CoreHID",
+ observedButtons: [.menu, .touchClick, .menu],
+ hasTouch: true,
+ hasMotion: false,
+ hasBattery: false,
+ reportIDs: [7, 1, 7],
+ warnings: ["zeta", "alpha", "zeta"]
+ )
+
+ #expect(report.observedButtons == [.menu, .touchClick])
+ #expect(report.reportIDs == [1, 7])
+ #expect(report.warnings == ["alpha", "zeta"])
+}
diff --git a/Tests/RemoMouseDomainTests/RemoteEventTests.swift b/Tests/RemoMouseDomainTests/RemoteEventTests.swift
new file mode 100644
index 0000000..2866e4f
--- /dev/null
+++ b/Tests/RemoMouseDomainTests/RemoteEventTests.swift
@@ -0,0 +1,40 @@
+import Foundation
+import Testing
+@testable import RemoMouseDomain
+
+@Test func touchEventRoundTripsThroughJSON() throws {
+ let event = RemoteEvent.touch(.init(
+ timestampNanoseconds: 42,
+ x: 0.25,
+ y: -0.75,
+ phase: .moved
+ ))
+
+ let data = try JSONEncoder().encode(event)
+
+ #expect(try JSONDecoder().decode(RemoteEvent.self, from: data) == event)
+}
+
+@Test(arguments: [
+ RemoteEvent.connected(.init(
+ token: "device-1",
+ generation: .firstGeneration,
+ transport: "GameController"
+ )),
+ .disconnected(reason: "remote slept"),
+ .button(timestampNanoseconds: 10, button: .menu, isPressed: true),
+ .motion(.init(
+ timestampNanoseconds: 11,
+ gravityX: 0.1,
+ gravityY: 0.2,
+ gravityZ: 0.3,
+ rotationX: 1.1,
+ rotationY: 1.2,
+ rotationZ: 1.3
+ )),
+ .battery(timestampNanoseconds: 12, percentage: 75),
+])
+func remoteEventCasesRoundTrip(event: RemoteEvent) throws {
+ let data = try JSONEncoder().encode(event)
+ #expect(try JSONDecoder().decode(RemoteEvent.self, from: data) == event)
+}
diff --git a/Tests/RemoMouseDomainTests/SensitiveValueRedactorTests.swift b/Tests/RemoMouseDomainTests/SensitiveValueRedactorTests.swift
new file mode 100644
index 0000000..27af683
--- /dev/null
+++ b/Tests/RemoMouseDomainTests/SensitiveValueRedactorTests.swift
@@ -0,0 +1,37 @@
+import Foundation
+import Testing
+@testable import RemoMouseDomain
+
+@Test func redactorRemovesEverySensitiveValue() {
+ let redactor = SensitiveValueRedactor(sensitiveValues: [
+ "SERIAL-EXAMPLE",
+ "AA:BB:CC:DD:EE:FF",
+ "/Users/example",
+ ])
+
+ let output = redactor.redact(
+ "SERIAL-EXAMPLE at AA:BB:CC:DD:EE:FF in /Users/example"
+ )
+
+ #expect(!output.contains("SERIAL-EXAMPLE"))
+ #expect(!output.contains("AA:BB:CC:DD:EE:FF"))
+ #expect(!output.contains("/Users/example"))
+ #expect(output.components(separatedBy: "")
+}
+
+@Test func longerSensitiveValuesAreReplacedBeforePrefixes() {
+ let redactor = SensitiveValueRedactor(sensitiveValues: [
+ "/Users/example",
+ "/Users/example/private",
+ ])
+
+ let output = redactor.redact("/Users/example/private")
+
+ #expect(output == "")
+}
From 292881a9769e138308973bafb0784fe81d15b4be Mon Sep 17 00:00:00 2001
From: kefrulz <9220848+kefrulz@users.noreply.github.com>
Date: Sun, 9 Aug 2026 21:16:40 +0200
Subject: [PATCH 03/20] Discover Siri Remote controllers
---
Package.swift | 4 +
.../GameControllerEventMapper.swift | 155 +++++++++++++++
.../GameControllerTransport.swift | 184 ++++++++++++++++++
.../RemoMouseHardware/RemoteTransport.swift | 8 +
.../GameControllerEventMapperTests.swift | 82 ++++++++
5 files changed, 433 insertions(+)
create mode 100644 Sources/RemoMouseHardware/GameControllerEventMapper.swift
create mode 100644 Sources/RemoMouseHardware/GameControllerTransport.swift
create mode 100644 Sources/RemoMouseHardware/RemoteTransport.swift
create mode 100644 Tests/RemoMouseHardwareTests/GameControllerEventMapperTests.swift
diff --git a/Package.swift b/Package.swift
index 2974b53..5d1deaf 100644
--- a/Package.swift
+++ b/Package.swift
@@ -44,5 +44,9 @@ let package = Package(
name: "RemoMouseDomainTests",
dependencies: ["RemoMouseDomain"]
),
+ .testTarget(
+ name: "RemoMouseHardwareTests",
+ dependencies: ["RemoMouseHardware"]
+ ),
]
)
diff --git a/Sources/RemoMouseHardware/GameControllerEventMapper.swift b/Sources/RemoMouseHardware/GameControllerEventMapper.swift
new file mode 100644
index 0000000..ccedb26
--- /dev/null
+++ b/Sources/RemoMouseHardware/GameControllerEventMapper.swift
@@ -0,0 +1,155 @@
+import RemoMouseDomain
+
+public struct ControllerMotionVector: Equatable, Sendable {
+ public var gravityX: Double
+ public var gravityY: Double
+ public var gravityZ: Double
+ public var rotationX: Double
+ public var rotationY: Double
+ public var rotationZ: Double
+
+ public init(
+ gravityX: Double,
+ gravityY: Double,
+ gravityZ: Double,
+ rotationX: Double,
+ rotationY: Double,
+ rotationZ: Double
+ ) {
+ self.gravityX = gravityX
+ self.gravityY = gravityY
+ self.gravityZ = gravityZ
+ self.rotationX = rotationX
+ self.rotationY = rotationY
+ self.rotationZ = rotationZ
+ }
+}
+
+public struct GameControllerSample: Equatable, Sendable {
+ public var x: Double
+ public var y: Double
+ public var isTouching: Bool
+ public var buttonA: Bool
+ public var buttonX: Bool
+ public var buttonMenu: Bool
+ public var buttonHome: Bool
+ public var motion: ControllerMotionVector?
+
+ public init(
+ x: Double = 0,
+ y: Double = 0,
+ isTouching: Bool = false,
+ buttonA: Bool = false,
+ buttonX: Bool = false,
+ buttonMenu: Bool = false,
+ buttonHome: Bool = false,
+ motion: ControllerMotionVector? = nil
+ ) {
+ self.x = x
+ self.y = y
+ self.isTouching = isTouching
+ self.buttonA = buttonA
+ self.buttonX = buttonX
+ self.buttonMenu = buttonMenu
+ self.buttonHome = buttonHome
+ self.motion = motion
+ }
+
+ public static let neutral = GameControllerSample()
+}
+
+public enum GameControllerEventMapper {
+ public static func map(
+ previous: GameControllerSample,
+ current: GameControllerSample,
+ timestampNanoseconds: UInt64
+ ) -> [RemoteEvent] {
+ var events: [RemoteEvent] = []
+
+ if !previous.isTouching, current.isTouching {
+ events.append(.touch(.init(
+ timestampNanoseconds: timestampNanoseconds,
+ x: clamp(current.x),
+ y: clamp(current.y),
+ phase: .began
+ )))
+ } else if previous.isTouching, current.isTouching,
+ previous.x != current.x || previous.y != current.y {
+ events.append(.touch(.init(
+ timestampNanoseconds: timestampNanoseconds,
+ x: clamp(current.x),
+ y: clamp(current.y),
+ phase: .moved
+ )))
+ } else if previous.isTouching, !current.isTouching {
+ events.append(.touch(.init(
+ timestampNanoseconds: timestampNanoseconds,
+ x: clamp(previous.x),
+ y: clamp(previous.y),
+ phase: .ended
+ )))
+ }
+
+ appendButtonEdge(
+ previous: previous.buttonA,
+ current: current.buttonA,
+ button: .touchClick,
+ timestampNanoseconds: timestampNanoseconds,
+ to: &events
+ )
+ appendButtonEdge(
+ previous: previous.buttonX,
+ current: current.buttonX,
+ button: .playPause,
+ timestampNanoseconds: timestampNanoseconds,
+ to: &events
+ )
+ appendButtonEdge(
+ previous: previous.buttonMenu,
+ current: current.buttonMenu,
+ button: .menu,
+ timestampNanoseconds: timestampNanoseconds,
+ to: &events
+ )
+ appendButtonEdge(
+ previous: previous.buttonHome,
+ current: current.buttonHome,
+ button: .home,
+ timestampNanoseconds: timestampNanoseconds,
+ to: &events
+ )
+
+ if current.motion != previous.motion, let motion = current.motion {
+ events.append(.motion(.init(
+ timestampNanoseconds: timestampNanoseconds,
+ gravityX: motion.gravityX,
+ gravityY: motion.gravityY,
+ gravityZ: motion.gravityZ,
+ rotationX: motion.rotationX,
+ rotationY: motion.rotationY,
+ rotationZ: motion.rotationZ
+ )))
+ }
+
+ return events
+ }
+
+ private static func appendButtonEdge(
+ previous: Bool,
+ current: Bool,
+ button: RemoteButton,
+ timestampNanoseconds: UInt64,
+ to events: inout [RemoteEvent]
+ ) {
+ guard previous != current else { return }
+ events.append(.button(
+ timestampNanoseconds: timestampNanoseconds,
+ button: button,
+ isPressed: current
+ ))
+ }
+
+ private static func clamp(_ value: Double) -> Double {
+ min(1, max(-1, value))
+ }
+}
diff --git a/Sources/RemoMouseHardware/GameControllerTransport.swift b/Sources/RemoMouseHardware/GameControllerTransport.swift
new file mode 100644
index 0000000..736a995
--- /dev/null
+++ b/Sources/RemoMouseHardware/GameControllerTransport.swift
@@ -0,0 +1,184 @@
+import Foundation
+import GameController
+import RemoMouseDomain
+
+private struct MainActorTransfer: @unchecked Sendable {
+ let value: Value
+}
+
+@MainActor
+public final class GameControllerTransport: RemoteTransport {
+ public nonisolated let name = "GameController"
+
+ private var controller: GCController?
+ private var continuation: AsyncThrowingStream.Continuation?
+ private var notificationTokens: [NSObjectProtocol] = []
+ private var previousSample = GameControllerSample.neutral
+ private var observedButtons = Set()
+ private var hasTouch = false
+ private var hasMotion = false
+
+ public init() {}
+
+ public func events() async -> AsyncThrowingStream {
+ let pair = AsyncThrowingStream.makeStream()
+ continuation?.finish()
+ continuation = pair.continuation
+ installObserversIfNeeded()
+ GCController.controllers().forEach(consider)
+ GCController.startWirelessControllerDiscovery {}
+ return pair.stream
+ }
+
+ public func capabilities() async throws -> CapabilityReport {
+ CapabilityReport(
+ device: controller.map { _ in identity },
+ transport: name,
+ observedButtons: Array(observedButtons),
+ hasTouch: hasTouch,
+ hasMotion: hasMotion,
+ hasBattery: false,
+ reportIDs: [],
+ warnings: controller == nil ? ["No first-generation Siri Remote detected"] : []
+ )
+ }
+
+ public func stop() async {
+ GCController.stopWirelessControllerDiscovery()
+ notificationTokens.forEach(NotificationCenter.default.removeObserver)
+ notificationTokens.removeAll()
+ clearHandlers()
+ controller = nil
+ continuation?.finish()
+ continuation = nil
+ previousSample = .neutral
+ }
+
+ private var identity: DeviceIdentity {
+ DeviceIdentity(
+ token: "gamecontroller-first-generation",
+ generation: .firstGeneration,
+ transport: name
+ )
+ }
+
+ private func installObserversIfNeeded() {
+ guard notificationTokens.isEmpty else { return }
+ let center = NotificationCenter.default
+ notificationTokens.append(center.addObserver(
+ forName: .GCControllerDidConnect,
+ object: nil,
+ queue: .main
+ ) { [weak self] notification in
+ guard let connected = notification.object as? GCController else { return }
+ let transfer = MainActorTransfer(value: connected)
+ Task { @MainActor [weak self] in self?.consider(transfer.value) }
+ })
+ notificationTokens.append(center.addObserver(
+ forName: .GCControllerDidDisconnect,
+ object: nil,
+ queue: .main
+ ) { [weak self] notification in
+ guard let disconnected = notification.object as? GCController else { return }
+ let transfer = MainActorTransfer(value: disconnected)
+ Task { @MainActor [weak self] in self?.didDisconnect(transfer.value) }
+ })
+ }
+
+ private func consider(_ candidate: GCController) {
+ guard controller == nil,
+ candidate.productCategory == GCProductCategorySiriRemote1stGen
+ || candidate.productCategory == GCProductCategoryCoalescedRemote,
+ let microGamepad = candidate.microGamepad
+ else { return }
+
+ controller = candidate
+ microGamepad.reportsAbsoluteDpadValues = true
+ microGamepad.allowsRotation = false
+ microGamepad.valueChangedHandler = { [weak self] gamepad, _ in
+ let sample = Self.sample(controller: candidate, microGamepad: gamepad)
+ Task { @MainActor [weak self] in self?.consume(sample) }
+ }
+ candidate.motion?.valueChangedHandler = { [weak self] motion in
+ let sample = Self.sample(
+ controller: candidate,
+ microGamepad: microGamepad,
+ motion: motion
+ )
+ Task { @MainActor [weak self] in self?.consume(sample) }
+ }
+ previousSample = Self.sample(controller: candidate, microGamepad: microGamepad)
+ continuation?.yield(.connected(identity))
+ }
+
+ private func didDisconnect(_ disconnected: GCController) {
+ guard disconnected === controller else { return }
+ clearHandlers()
+ controller = nil
+ previousSample = .neutral
+ continuation?.yield(.disconnected(reason: "GameController disconnected"))
+ }
+
+ private func consume(_ sample: GameControllerSample) {
+ let events = GameControllerEventMapper.map(
+ previous: previousSample,
+ current: sample,
+ timestampNanoseconds: DispatchTime.now().uptimeNanoseconds
+ )
+ previousSample = sample
+ for event in events {
+ observe(event)
+ continuation?.yield(event)
+ }
+ }
+
+ private func observe(_ event: RemoteEvent) {
+ switch event {
+ case .touch:
+ hasTouch = true
+ case let .button(_, button, _):
+ observedButtons.insert(button)
+ case .motion:
+ hasMotion = true
+ default:
+ break
+ }
+ }
+
+ private func clearHandlers() {
+ controller?.microGamepad?.valueChangedHandler = nil
+ controller?.motion?.valueChangedHandler = nil
+ }
+
+ private static func sample(
+ controller: GCController,
+ microGamepad: GCMicroGamepad,
+ motion: GCMotion? = nil
+ ) -> GameControllerSample {
+ let dpad = microGamepad.dpad
+ let touchedInput = dpad as? any GCTouchedStateInput
+ let isTouching = touchedInput?.isTouched
+ ?? (dpad.xAxis.value != 0 || dpad.yAxis.value != 0)
+ let motionVector = motion.map {
+ ControllerMotionVector(
+ gravityX: $0.gravity.x,
+ gravityY: $0.gravity.y,
+ gravityZ: $0.gravity.z,
+ rotationX: $0.rotationRate.x,
+ rotationY: $0.rotationRate.y,
+ rotationZ: $0.rotationRate.z
+ )
+ }
+
+ return GameControllerSample(
+ x: Double(dpad.xAxis.value),
+ y: Double(dpad.yAxis.value),
+ isTouching: isTouching,
+ buttonA: microGamepad.buttonA.isPressed,
+ buttonX: microGamepad.buttonX.isPressed,
+ buttonMenu: microGamepad.buttonMenu.isPressed,
+ buttonHome: controller.physicalInputProfile.buttons[GCInputButtonHome]?.isPressed ?? false,
+ motion: motionVector
+ )
+ }
+}
diff --git a/Sources/RemoMouseHardware/RemoteTransport.swift b/Sources/RemoMouseHardware/RemoteTransport.swift
new file mode 100644
index 0000000..b6fc712
--- /dev/null
+++ b/Sources/RemoMouseHardware/RemoteTransport.swift
@@ -0,0 +1,8 @@
+import RemoMouseDomain
+
+public protocol RemoteTransport: Sendable {
+ var name: String { get }
+ func capabilities() async throws -> CapabilityReport
+ func events() async -> AsyncThrowingStream
+ func stop() async
+}
diff --git a/Tests/RemoMouseHardwareTests/GameControllerEventMapperTests.swift b/Tests/RemoMouseHardwareTests/GameControllerEventMapperTests.swift
new file mode 100644
index 0000000..798db23
--- /dev/null
+++ b/Tests/RemoMouseHardwareTests/GameControllerEventMapperTests.swift
@@ -0,0 +1,82 @@
+import Testing
+@testable import RemoMouseDomain
+@testable import RemoMouseHardware
+
+@Test func mapperEmitsOnlyChangedButtonEdges() {
+ let previous = GameControllerSample.neutral
+ var current = previous
+ current.buttonA = true
+
+ let events = GameControllerEventMapper.map(
+ previous: previous,
+ current: current,
+ timestampNanoseconds: 10
+ )
+
+ #expect(events == [
+ .button(timestampNanoseconds: 10, button: .touchClick, isPressed: true),
+ ])
+ #expect(GameControllerEventMapper.map(
+ previous: current,
+ current: current,
+ timestampNanoseconds: 11
+ ).isEmpty)
+}
+
+@Test func mapperClampsTouchAndTracksContactPhases() {
+ let began = GameControllerSample(x: 1.4, y: -1.3, isTouching: true)
+ let moved = GameControllerSample(x: 0.5, y: 0.25, isTouching: true)
+ let ended = GameControllerSample(x: 0, y: 0, isTouching: false)
+
+ #expect(GameControllerEventMapper.map(
+ previous: .neutral,
+ current: began,
+ timestampNanoseconds: 1
+ ) == [.touch(.init(timestampNanoseconds: 1, x: 1, y: -1, phase: .began))])
+
+ #expect(GameControllerEventMapper.map(
+ previous: began,
+ current: moved,
+ timestampNanoseconds: 2
+ ) == [.touch(.init(timestampNanoseconds: 2, x: 0.5, y: 0.25, phase: .moved))])
+
+ #expect(GameControllerEventMapper.map(
+ previous: moved,
+ current: ended,
+ timestampNanoseconds: 3
+ ) == [.touch(.init(timestampNanoseconds: 3, x: 0.5, y: 0.25, phase: .ended))])
+}
+
+@Test func mapperMapsSecondaryButtonsAndMotion() {
+ var current = GameControllerSample.neutral
+ current.buttonX = true
+ current.buttonMenu = true
+ current.buttonHome = true
+ current.motion = .init(
+ gravityX: 0.1,
+ gravityY: 0.2,
+ gravityZ: 0.3,
+ rotationX: 1.1,
+ rotationY: 1.2,
+ rotationZ: 1.3
+ )
+
+ #expect(GameControllerEventMapper.map(
+ previous: .neutral,
+ current: current,
+ timestampNanoseconds: 9
+ ) == [
+ .button(timestampNanoseconds: 9, button: .playPause, isPressed: true),
+ .button(timestampNanoseconds: 9, button: .menu, isPressed: true),
+ .button(timestampNanoseconds: 9, button: .home, isPressed: true),
+ .motion(.init(
+ timestampNanoseconds: 9,
+ gravityX: 0.1,
+ gravityY: 0.2,
+ gravityZ: 0.3,
+ rotationX: 1.1,
+ rotationY: 1.2,
+ rotationZ: 1.3
+ )),
+ ])
+}
From 0cc7a12e5da892618157f13e1137f97fe8463dc8 Mon Sep 17 00:00:00 2001
From: kefrulz <9220848+kefrulz@users.noreply.github.com>
Date: Sun, 9 Aug 2026 21:19:11 +0200
Subject: [PATCH 04/20] Inspect external HID reports
---
.../RemoMouseHardware/CoreHIDInventory.swift | 271 ++++++++++++++++++
.../CoreHIDInventoryTests.swift | 43 +++
2 files changed, 314 insertions(+)
create mode 100644 Sources/RemoMouseHardware/CoreHIDInventory.swift
create mode 100644 Tests/RemoMouseHardwareTests/CoreHIDInventoryTests.swift
diff --git a/Sources/RemoMouseHardware/CoreHIDInventory.swift b/Sources/RemoMouseHardware/CoreHIDInventory.swift
new file mode 100644
index 0000000..3715e08
--- /dev/null
+++ b/Sources/RemoMouseHardware/CoreHIDInventory.swift
@@ -0,0 +1,271 @@
+import CoreHID
+import Foundation
+import RemoMouseDomain
+
+public struct HIDDeviceSummary: Codable, Equatable, Sendable {
+ public let deviceToken: String
+ public let vendorID: UInt32
+ public let productID: UInt32
+ public let isBuiltIn: Bool
+ public let transport: String?
+ public let product: String?
+ public let manufacturerToken: String?
+ public let descriptorByteCount: Int
+ public let elementCount: Int
+ public let reportIDs: [UInt32]
+
+ public init(
+ deviceToken: String = "unavailable",
+ vendorID: UInt32,
+ productID: UInt32,
+ isBuiltIn: Bool,
+ transport: String? = nil,
+ product: String? = nil,
+ manufacturerToken: String? = nil,
+ descriptorByteCount: Int = 0,
+ elementCount: Int = 0,
+ reportIDs: [UInt32] = []
+ ) {
+ self.deviceToken = deviceToken
+ self.vendorID = vendorID
+ self.productID = productID
+ self.isBuiltIn = isBuiltIn
+ self.transport = transport
+ self.product = product
+ self.manufacturerToken = manufacturerToken
+ self.descriptorByteCount = descriptorByteCount
+ self.elementCount = elementCount
+ self.reportIDs = Array(Set(reportIDs)).sorted()
+ }
+
+ public var isRemoteCandidate: Bool {
+ vendorID == 1452 && !isBuiltIn
+ }
+}
+
+public enum HIDInventoryEvent: Equatable, Sendable {
+ case matched(HIDDeviceSummary)
+ case report(
+ deviceToken: String,
+ reportID: UInt32,
+ bytes: Data,
+ timestampNanoseconds: UInt64
+ )
+ case removed(deviceToken: String)
+ case warning(String)
+}
+
+public struct HIDCaptureBudget: Equatable, Sendable {
+ public enum Decision: Equatable, Sendable {
+ case accepted
+ case limitReached
+ case ignored
+ }
+
+ public private(set) var reportCount = 0
+ public private(set) var byteCount = 0
+ private let maxReports: Int
+ private let maxBytes: Int
+ private var didReachLimit = false
+
+ public init(maxReports: Int = 10_000, maxBytes: Int = 16 * 1_024 * 1_024) {
+ self.maxReports = maxReports
+ self.maxBytes = maxBytes
+ }
+
+ public mutating func record(byteCount incomingByteCount: Int) -> Decision {
+ guard !didReachLimit else { return .ignored }
+ guard reportCount < maxReports,
+ byteCount <= maxBytes - incomingByteCount
+ else {
+ didReachLimit = true
+ return .limitReached
+ }
+ reportCount += 1
+ byteCount += incomingByteCount
+ return .accepted
+ }
+}
+
+public actor CoreHIDInventory {
+ private struct MonitoredDevice {
+ let token: String
+ let task: Task?
+ }
+
+ private let manager = HIDDeviceManager()
+ private var managerTask: Task?
+ private var devices: [HIDDeviceClient.DeviceReference: MonitoredDevice] = [:]
+ private var budgets: [String: HIDCaptureBudget] = [:]
+ private var continuation: AsyncThrowingStream.Continuation?
+
+ public init() {}
+
+ public func events() -> AsyncThrowingStream {
+ let pair = AsyncThrowingStream.makeStream()
+ continuation?.finish()
+ continuation = pair.continuation
+ startIfNeeded()
+ return pair.stream
+ }
+
+ public func stop() {
+ managerTask?.cancel()
+ managerTask = nil
+ devices.values.forEach { $0.task?.cancel() }
+ devices.removeAll()
+ budgets.removeAll()
+ continuation?.finish()
+ continuation = nil
+ }
+
+ private func startIfNeeded() {
+ guard managerTask == nil else { return }
+ let manager = manager
+ managerTask = Task { [weak self] in
+ do {
+ let stream = await manager.monitorNotifications(
+ matchingCriteria: [.init(isBuiltIn: false)]
+ )
+ for try await notification in stream {
+ guard !Task.isCancelled else { break }
+ await self?.handle(notification)
+ }
+ } catch is CancellationError {
+ return
+ } catch {
+ await self?.finish(throwing: error)
+ }
+ }
+ }
+
+ private func handle(_ notification: HIDDeviceManager.Notification) async {
+ switch notification {
+ case let .deviceMatched(reference):
+ await match(reference)
+ case let .deviceRemoved(reference):
+ remove(reference)
+ @unknown default:
+ continuation?.yield(.warning("Unknown HID manager notification"))
+ }
+ }
+
+ private func match(_ reference: HIDDeviceClient.DeviceReference) async {
+ guard devices[reference] == nil,
+ let client = HIDDeviceClient(deviceReference: reference)
+ else { return }
+
+ let token = redactedToken(String(reference.deviceID))
+ let vendorID = await client.vendorID
+ let productID = await client.productID
+ let isBuiltIn = await client.isBuiltIn
+ let transport = await client.transport.map(Self.transportName)
+ let product = await client.product
+ let manufacturer = await client.manufacturer
+ let descriptorByteCount = await client.descriptor.count
+ let elements = await client.elements
+ let summary = HIDDeviceSummary(
+ deviceToken: token,
+ vendorID: vendorID,
+ productID: productID,
+ isBuiltIn: isBuiltIn,
+ transport: transport,
+ product: product,
+ manufacturerToken: manufacturer.map(redactedToken),
+ descriptorByteCount: descriptorByteCount,
+ elementCount: elements.count
+ )
+ continuation?.yield(.matched(summary))
+
+ guard summary.isRemoteCandidate else {
+ devices[reference] = MonitoredDevice(token: token, task: nil)
+ return
+ }
+
+ budgets[token] = HIDCaptureBudget()
+ let monitorTask = Task { [weak self] in
+ do {
+ let stream = await client.monitorNotifications(
+ reportIDsToMonitor: [HIDReportID.allReports],
+ elementsToMonitor: elements
+ )
+ for try await notification in stream {
+ guard !Task.isCancelled else { break }
+ await self?.handleClientNotification(notification, token: token)
+ }
+ } catch is CancellationError {
+ return
+ } catch {
+ await self?.yieldWarning("HID monitor stopped: \(error.localizedDescription)")
+ }
+ }
+ devices[reference] = MonitoredDevice(token: token, task: monitorTask)
+ }
+
+ private func handleClientNotification(
+ _ notification: HIDDeviceClient.Notification,
+ token: String
+ ) {
+ guard case let .inputReport(id, data, _) = notification,
+ var budget = budgets[token]
+ else { return }
+
+ switch budget.record(byteCount: data.count) {
+ case .accepted:
+ budgets[token] = budget
+ continuation?.yield(.report(
+ deviceToken: token,
+ reportID: UInt32(id?.rawValue ?? 0),
+ bytes: data,
+ timestampNanoseconds: DispatchTime.now().uptimeNanoseconds
+ ))
+ case .limitReached:
+ budgets[token] = budget
+ continuation?.yield(.warning("Raw report capture limit reached"))
+ case .ignored:
+ budgets[token] = budget
+ }
+ }
+
+ private func remove(_ reference: HIDDeviceClient.DeviceReference) {
+ guard let device = devices.removeValue(forKey: reference) else { return }
+ device.task?.cancel()
+ budgets.removeValue(forKey: device.token)
+ continuation?.yield(.removed(deviceToken: device.token))
+ }
+
+ private func yieldWarning(_ warning: String) {
+ continuation?.yield(.warning(warning))
+ }
+
+ private func finish(throwing error: any Error) {
+ continuation?.finish(throwing: error)
+ continuation = nil
+ managerTask = nil
+ }
+
+ private func redactedToken(_ value: String) -> String {
+ SensitiveValueRedactor(sensitiveValues: [value]).redact(value)
+ }
+
+ private static func transportName(_ transport: HIDDeviceTransport) -> String {
+ switch transport {
+ case .usb: "USB"
+ case .bluetooth: "Bluetooth"
+ case .bluetoothLowEnergy: "Bluetooth Low Energy"
+ case .bluetoothAACP: "Bluetooth AACP"
+ case .aid: "AID"
+ case .i2c: "I2C"
+ case .spi: "SPI"
+ case .serial: "Serial"
+ case .iap: "iAP"
+ case .airPlay: "AirPlay"
+ case .spu: "SPU"
+ case .fifo: "FIFO"
+ case .inductiveInBand: "Inductive In-Band"
+ case .virtual: "Virtual"
+ case let .unknown(value): "Unknown (\(value))"
+ @unknown default: "Unknown"
+ }
+ }
+}
diff --git a/Tests/RemoMouseHardwareTests/CoreHIDInventoryTests.swift b/Tests/RemoMouseHardwareTests/CoreHIDInventoryTests.swift
new file mode 100644
index 0000000..67b8dd8
--- /dev/null
+++ b/Tests/RemoMouseHardwareTests/CoreHIDInventoryTests.swift
@@ -0,0 +1,43 @@
+import Foundation
+import RemoMouseHardware
+import Testing
+
+@Test func candidateRequiresExternalAppleDevice() {
+ #expect(HIDDeviceSummary(vendorID: 1452, productID: 1, isBuiltIn: false).isRemoteCandidate)
+ #expect(!HIDDeviceSummary(vendorID: 1452, productID: 1, isBuiltIn: true).isRemoteCandidate)
+ #expect(!HIDDeviceSummary(vendorID: 0, productID: 1, isBuiltIn: false).isRemoteCandidate)
+}
+
+@Test func summarySanitizesIdentityAndNormalizesMetadata() throws {
+ let summary = HIDDeviceSummary(
+ deviceToken: "",
+ vendorID: 1452,
+ productID: 999,
+ isBuiltIn: false,
+ transport: "Bluetooth",
+ product: "Remote",
+ manufacturerToken: "",
+ descriptorByteCount: 128,
+ reportIDs: [7, 1, 7, 3]
+ )
+
+ #expect(summary.reportIDs == [1, 3, 7])
+ #expect(summary.transport == "Bluetooth")
+ #expect(summary.descriptorByteCount == 128)
+
+ let encoded = try JSONEncoder().encode(summary)
+ let json = String(decoding: encoded, as: UTF8.self)
+ #expect(!json.localizedCaseInsensitiveContains("serial"))
+ #expect(!json.localizedCaseInsensitiveContains("uniqueID"))
+}
+
+@Test func captureBudgetStopsAtEitherLimitAndWarnsOnce() {
+ var budget = HIDCaptureBudget(maxReports: 2, maxBytes: 4)
+
+ #expect(budget.record(byteCount: 2) == .accepted)
+ #expect(budget.record(byteCount: 2) == .accepted)
+ #expect(budget.record(byteCount: 1) == .limitReached)
+ #expect(budget.record(byteCount: 1) == .ignored)
+ #expect(budget.reportCount == 2)
+ #expect(budget.byteCount == 4)
+}
From a7613cd4c0e0eac25edf225a0f191b021182c0c5 Mon Sep 17 00:00:00 2001
From: kefrulz <9220848+kefrulz@users.noreply.github.com>
Date: Sun, 9 Aug 2026 21:22:48 +0200
Subject: [PATCH 05/20] Record sanitized remote sessions
---
Package.swift | 7 +-
.../RemoMouseCapture/CaptureDocument.swift | 111 +++++++++++++++
Sources/RemoMouseCapture/EventRecorder.swift | 130 ++++++++++++++++++
.../ProbeReportRenderer.swift | 77 +++++++++++
.../EventRecorderTests.swift | 74 ++++++++++
.../ProbeReportRendererTests.swift | 72 ++++++++++
6 files changed, 470 insertions(+), 1 deletion(-)
create mode 100644 Sources/RemoMouseCapture/CaptureDocument.swift
create mode 100644 Sources/RemoMouseCapture/EventRecorder.swift
create mode 100644 Sources/RemoMouseCapture/ProbeReportRenderer.swift
create mode 100644 Tests/RemoMouseCaptureTests/EventRecorderTests.swift
create mode 100644 Tests/RemoMouseCaptureTests/ProbeReportRendererTests.swift
diff --git a/Package.swift b/Package.swift
index 5d1deaf..74c59c3 100644
--- a/Package.swift
+++ b/Package.swift
@@ -26,7 +26,8 @@ let package = Package(
),
.target(
name: "RemoMouseCapture",
- dependencies: ["RemoMouseDomain"]
+ dependencies: ["RemoMouseDomain", "RemoMouseHardware"],
+ linkerSettings: [.linkedFramework("CryptoKit")]
),
.executableTarget(
name: "RemoMouseProbe",
@@ -48,5 +49,9 @@ let package = Package(
name: "RemoMouseHardwareTests",
dependencies: ["RemoMouseHardware"]
),
+ .testTarget(
+ name: "RemoMouseCaptureTests",
+ dependencies: ["RemoMouseCapture"]
+ ),
]
)
diff --git a/Sources/RemoMouseCapture/CaptureDocument.swift b/Sources/RemoMouseCapture/CaptureDocument.swift
new file mode 100644
index 0000000..5a831e4
--- /dev/null
+++ b/Sources/RemoMouseCapture/CaptureDocument.swift
@@ -0,0 +1,111 @@
+import CryptoKit
+import Foundation
+import RemoMouseDomain
+import RemoMouseHardware
+
+public struct ProbeMetadata: Codable, Equatable, Sendable {
+ public let osVersion: String
+ public let osBuild: String
+ public let appVersion: String
+
+ public init(osVersion: String, osBuild: String, appVersion: String) {
+ self.osVersion = osVersion
+ self.osBuild = osBuild
+ self.appVersion = appVersion
+ }
+
+ public static var current: ProbeMetadata {
+ ProbeMetadata(
+ osVersion: ProcessInfo.processInfo.operatingSystemVersionString,
+ osBuild: "Unknown",
+ appVersion: Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String
+ ?? "Development"
+ )
+ }
+}
+
+public enum CaptureRecord: Codable, Equatable, Sendable {
+ case remote(RemoteEvent)
+ case hidReport(
+ timestampNanoseconds: UInt64,
+ deviceToken: String,
+ reportID: UInt32,
+ bytes: Data
+ )
+ case device(timestampNanoseconds: UInt64, summary: HIDDeviceSummary)
+ case note(timestampNanoseconds: UInt64, value: String)
+ case warning(timestampNanoseconds: UInt64, value: String)
+
+ public var timestampNanoseconds: UInt64 {
+ switch self {
+ case let .remote(event): Self.remoteTimestamp(event)
+ case let .hidReport(timestamp, _, _, _): timestamp
+ case let .device(timestamp, _): timestamp
+ case let .note(timestamp, _): timestamp
+ case let .warning(timestamp, _): timestamp
+ }
+ }
+
+ private static func remoteTimestamp(_ event: RemoteEvent) -> UInt64 {
+ switch event {
+ case .connected, .disconnected:
+ 0
+ case let .touch(sample):
+ sample.timestampNanoseconds
+ case let .button(timestamp, _, _):
+ timestamp
+ case let .motion(sample):
+ sample.timestampNanoseconds
+ case let .battery(timestamp, _):
+ timestamp
+ }
+ }
+
+ public var isHIDReport: Bool {
+ if case .hidReport = self { true } else { false }
+ }
+
+ public var warningValue: String? {
+ if case let .warning(_, value) = self { value } else { nil }
+ }
+}
+
+public struct CaptureDocument: Equatable, Sendable {
+ public let fileURL: URL
+ public let metadata: ProbeMetadata
+ public let records: [CaptureRecord]
+
+ public init(fileURL: URL, metadata: ProbeMetadata, records: [CaptureRecord]) {
+ self.fileURL = fileURL
+ self.metadata = metadata
+ self.records = records.sorted(by: Self.recordOrder)
+ }
+
+ private static func recordOrder(_ lhs: CaptureRecord, _ rhs: CaptureRecord) -> Bool {
+ if lhs.timestampNanoseconds != rhs.timestampNanoseconds {
+ return lhs.timestampNanoseconds < rhs.timestampNanoseconds
+ }
+ let encoder = JSONEncoder()
+ encoder.outputFormatting = [.sortedKeys]
+ let lhsData = (try? encoder.encode(lhs)) ?? Data()
+ let rhsData = (try? encoder.encode(rhs)) ?? Data()
+ return lhsData.lexicographicallyPrecedes(rhsData)
+ }
+}
+
+public enum CaptureDigest {
+ private struct Payload: Codable {
+ let metadata: ProbeMetadata
+ let records: [CaptureRecord]
+ }
+
+ public static func sha256(_ document: CaptureDocument) throws -> String {
+ let encoder = JSONEncoder()
+ encoder.outputFormatting = [.sortedKeys]
+ let data = try encoder.encode(Payload(
+ metadata: document.metadata,
+ records: document.records
+ ))
+ return SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined()
+ }
+}
diff --git a/Sources/RemoMouseCapture/EventRecorder.swift b/Sources/RemoMouseCapture/EventRecorder.swift
new file mode 100644
index 0000000..ba214ab
--- /dev/null
+++ b/Sources/RemoMouseCapture/EventRecorder.swift
@@ -0,0 +1,130 @@
+import Foundation
+import RemoMouseDomain
+
+public struct RecoveredCapture: Equatable, Sendable {
+ public let document: CaptureDocument
+ public let warnings: [String]
+}
+
+public actor EventRecorder {
+ private let directory: URL
+ private let partialURL: URL
+ private let metadata: ProbeMetadata
+ private let redactor: SensitiveValueRedactor
+ private let maxHIDReports: Int
+ private var hidReportCount = 0
+ private var didWriteLimitWarning = false
+ private var fileHandle: FileHandle?
+ private let encoder: JSONEncoder
+
+ public init(
+ directory: URL = EventRecorder.defaultCaptureDirectory,
+ metadata: ProbeMetadata = .current,
+ sensitiveValues: [String] = [],
+ maxHIDReports: Int = 10_000
+ ) throws {
+ self.directory = directory
+ self.metadata = metadata
+ self.redactor = SensitiveValueRedactor(sensitiveValues: sensitiveValues)
+ self.maxHIDReports = maxHIDReports
+ self.partialURL = directory.appending(path: "\(UUID().uuidString).partial")
+ self.encoder = JSONEncoder()
+ self.encoder.outputFormatting = [.sortedKeys]
+
+ try FileManager.default.createDirectory(
+ at: directory,
+ withIntermediateDirectories: true
+ )
+ guard FileManager.default.createFile(atPath: partialURL.path, contents: nil) else {
+ throw CocoaError(.fileWriteUnknown)
+ }
+ self.fileHandle = try FileHandle(forWritingTo: partialURL)
+ }
+
+ public static var defaultCaptureDirectory: URL {
+ FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
+ .appending(path: "RemoMouse/Captures", directoryHint: .isDirectory)
+ }
+
+ public func append(_ record: CaptureRecord) throws {
+ if record.isHIDReport {
+ guard hidReportCount < maxHIDReports else {
+ if !didWriteLimitWarning {
+ didWriteLimitWarning = true
+ try write(.warning(
+ timestampNanoseconds: record.timestampNanoseconds,
+ value: "Raw report capture limit reached"
+ ))
+ }
+ return
+ }
+ hidReportCount += 1
+ }
+ try write(record)
+ }
+
+ public func finish() throws -> CaptureDocument {
+ guard let fileHandle else { throw CocoaError(.fileWriteUnknown) }
+ try fileHandle.synchronize()
+ try fileHandle.close()
+ self.fileHandle = nil
+
+ let formatter = ISO8601DateFormatter()
+ formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
+ let timestamp = formatter.string(from: Date()).replacingOccurrences(of: ":", with: "-")
+ var finalURL = directory.appending(path: "\(timestamp)-hardware.remocapture")
+ if FileManager.default.fileExists(atPath: finalURL.path) {
+ finalURL = directory.appending(path: "\(timestamp)-\(UUID().uuidString)-hardware.remocapture")
+ }
+ try FileManager.default.moveItem(at: partialURL, to: finalURL)
+ let records = try Self.decodeCompleteLines(at: finalURL).records
+ return CaptureDocument(fileURL: finalURL, metadata: metadata, records: records)
+ }
+
+ public static func recoverPartialCaptures(
+ in directory: URL = defaultCaptureDirectory
+ ) throws -> [RecoveredCapture] {
+ guard FileManager.default.fileExists(atPath: directory.path) else { return [] }
+ return try FileManager.default.contentsOfDirectory(
+ at: directory,
+ includingPropertiesForKeys: nil
+ )
+ .filter { $0.pathExtension == "partial" }
+ .sorted { $0.lastPathComponent < $1.lastPathComponent }
+ .map { url in
+ let decoded = try decodeCompleteLines(at: url)
+ return RecoveredCapture(
+ document: CaptureDocument(fileURL: url, metadata: .current, records: decoded.records),
+ warnings: decoded.hadMalformedData
+ ? ["Ignored malformed trailing capture data"]
+ : []
+ )
+ }
+ }
+
+ private func write(_ record: CaptureRecord) throws {
+ guard let fileHandle else { throw CocoaError(.fileWriteUnknown) }
+ let encoded = try encoder.encode(record)
+ let sanitized = redactor.redact(String(decoding: encoded, as: UTF8.self))
+ var line = Data(sanitized.utf8)
+ line.append(0x0A)
+ try fileHandle.write(contentsOf: line)
+ }
+
+ private static func decodeCompleteLines(
+ at url: URL
+ ) throws -> (records: [CaptureRecord], hadMalformedData: Bool) {
+ let data = try Data(contentsOf: url)
+ let decoder = JSONDecoder()
+ var records: [CaptureRecord] = []
+ var malformed = false
+ for line in data.split(separator: 0x0A, omittingEmptySubsequences: true) {
+ do {
+ records.append(try decoder.decode(CaptureRecord.self, from: Data(line)))
+ } catch {
+ malformed = true
+ }
+ }
+ return (records, malformed)
+ }
+}
diff --git a/Sources/RemoMouseCapture/ProbeReportRenderer.swift b/Sources/RemoMouseCapture/ProbeReportRenderer.swift
new file mode 100644
index 0000000..33b8738
--- /dev/null
+++ b/Sources/RemoMouseCapture/ProbeReportRenderer.swift
@@ -0,0 +1,77 @@
+import Foundation
+import RemoMouseDomain
+
+public enum ProbeReportRenderer {
+ public static func markdown(
+ for document: CaptureDocument,
+ sensitiveValues: [String] = []
+ ) throws -> String {
+ let remoteEvents = document.records.compactMap { record -> RemoteEvent? in
+ if case let .remote(event) = record { event } else { nil }
+ }
+ let summaries = document.records.compactMap { record in
+ if case let .device(_, summary) = record { summary } else { nil }
+ }
+ let reports = document.records.compactMap { record -> UInt32? in
+ if case let .hidReport(_, _, reportID, _) = record { reportID } else { nil }
+ }
+ let buttons = remoteEvents.compactMap { event -> RemoteButton? in
+ if case let .button(_, button, _) = event { button } else { nil }
+ }
+ let touches = remoteEvents.compactMap { event -> TouchSample? in
+ if case let .touch(sample) = event { sample } else { nil }
+ }
+ let warnings = document.records.compactMap(\.warningValue)
+ let digest = try CaptureDigest.sha256(document)
+
+ let lines = [
+ "# Siri Remote Hardware Report",
+ "",
+ "- GameController detected: \(yesNo(remoteEvents.contains(where: isGameControllerConnection)))",
+ "- CoreHID candidate detected: \(yesNo(summaries.contains(where: \.isRemoteCandidate)))",
+ "- Touch observed: \(yesNo(!touches.isEmpty))",
+ "- Motion observed: \(yesNo(remoteEvents.contains(where: isMotion)))",
+ "- Battery observed: \(yesNo(remoteEvents.contains(where: isBattery)))",
+ "- Captured buttons: \(list(buttons.map(\.rawValue)))",
+ "- HID report IDs: \(list(reports.map(String.init)))",
+ "- OS: \(document.metadata.osVersion)",
+ "- OS build: \(document.metadata.osBuild)",
+ "- App version: \(document.metadata.appVersion)",
+ "- Remote events: \(remoteEvents.count)",
+ "- HID reports: \(reports.count)",
+ "- Touch X range: \(range(touches.map(\.x)))",
+ "- Touch Y range: \(range(touches.map(\.y)))",
+ "- Warnings: \(list(warnings))",
+ "- Capture SHA-256: \(digest)",
+ ]
+ let markdown = lines.joined(separator: "\n")
+ return SensitiveValueRedactor(sensitiveValues: sensitiveValues).redact(markdown)
+ }
+
+ private static func yesNo(_ value: Bool) -> String { value ? "Yes" : "No" }
+
+ private static func list(_ values: S) -> String where S.Element == String {
+ let normalized = Array(Set(values)).sorted()
+ return normalized.isEmpty ? "None" : normalized.joined(separator: ", ")
+ }
+
+ private static func range(_ values: [Double]) -> String {
+ guard let minimum = values.min(), let maximum = values.max() else { return "None" }
+ return String(format: "%.3f...%.3f", minimum, maximum)
+ }
+
+ private static func isGameControllerConnection(_ event: RemoteEvent) -> Bool {
+ if case let .connected(identity) = event {
+ return identity.transport == "GameController"
+ }
+ return false
+ }
+
+ private static func isMotion(_ event: RemoteEvent) -> Bool {
+ if case .motion = event { true } else { false }
+ }
+
+ private static func isBattery(_ event: RemoteEvent) -> Bool {
+ if case .battery = event { true } else { false }
+ }
+}
diff --git a/Tests/RemoMouseCaptureTests/EventRecorderTests.swift b/Tests/RemoMouseCaptureTests/EventRecorderTests.swift
new file mode 100644
index 0000000..103d975
--- /dev/null
+++ b/Tests/RemoMouseCaptureTests/EventRecorderTests.swift
@@ -0,0 +1,74 @@
+import Foundation
+import RemoMouseCapture
+import RemoMouseDomain
+import Testing
+
+@Test func recorderWritesOrderedJSONLines() async throws {
+ let folder = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString)
+ let recorder = try EventRecorder(directory: folder)
+ try await recorder.append(.note(timestampNanoseconds: 3, value: "menu pressed"))
+ try await recorder.append(.remote(.button(
+ timestampNanoseconds: 2,
+ button: .menu,
+ isPressed: true
+ )))
+
+ let document = try await recorder.finish()
+
+ #expect(document.records.map(\.timestampNanoseconds) == [2, 3])
+ #expect(document.fileURL.pathExtension == "remocapture")
+}
+
+@Test func recorderRedactsBeforeWritingAndWarnsOnlyOnceAtLimit() async throws {
+ let folder = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString)
+ let secret = "AA:BB:CC:DD:EE:FF"
+ let recorder = try EventRecorder(
+ directory: folder,
+ sensitiveValues: [secret],
+ maxHIDReports: 1
+ )
+ try await recorder.append(.note(timestampNanoseconds: 1, value: secret))
+ try await recorder.append(.hidReport(
+ timestampNanoseconds: 2,
+ deviceToken: "device",
+ reportID: 1,
+ bytes: Data([1])
+ ))
+ try await recorder.append(.hidReport(
+ timestampNanoseconds: 3,
+ deviceToken: "device",
+ reportID: 2,
+ bytes: Data([2])
+ ))
+ try await recorder.append(.hidReport(
+ timestampNanoseconds: 4,
+ deviceToken: "device",
+ reportID: 3,
+ bytes: Data([3])
+ ))
+
+ let document = try await recorder.finish()
+ let diskText = try String(contentsOf: document.fileURL, encoding: .utf8)
+
+ #expect(!diskText.contains(secret))
+ #expect(document.records.filter(\.isHIDReport).count == 1)
+ #expect(document.records.filter { $0.warningValue == "Raw report capture limit reached" }.count == 1)
+}
+
+@Test func interruptedCaptureRecoversCompleteLinesAndOneWarning() async throws {
+ let folder = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString)
+ try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
+ let partial = folder.appending(path: "interrupted.partial")
+ let encoder = JSONEncoder()
+ let complete = try encoder.encode(CaptureRecord.note(timestampNanoseconds: 1, value: "safe"))
+ var bytes = complete
+ bytes.append(0x0A)
+ bytes.append(contentsOf: Data("{broken".utf8))
+ try bytes.write(to: partial)
+
+ let recovered = try EventRecorder.recoverPartialCaptures(in: folder)
+
+ #expect(recovered.count == 1)
+ #expect(recovered[0].document.records.count == 1)
+ #expect(recovered[0].warnings == ["Ignored malformed trailing capture data"])
+}
diff --git a/Tests/RemoMouseCaptureTests/ProbeReportRendererTests.swift b/Tests/RemoMouseCaptureTests/ProbeReportRendererTests.swift
new file mode 100644
index 0000000..cab5e44
--- /dev/null
+++ b/Tests/RemoMouseCaptureTests/ProbeReportRendererTests.swift
@@ -0,0 +1,72 @@
+import Foundation
+import RemoMouseCapture
+import RemoMouseDomain
+import RemoMouseHardware
+import Testing
+
+@Test func rendererProducesDeterministicCapabilityReport() throws {
+ let document = CaptureDocument(
+ fileURL: URL(filePath: "/tmp/test.remocapture"),
+ metadata: .init(osVersion: "macOS 26.5.1", osBuild: "25F90", appVersion: "0.1.0"),
+ records: [
+ .remote(.connected(.init(
+ token: "safe-token",
+ generation: .firstGeneration,
+ transport: "GameController"
+ ))),
+ .device(timestampNanoseconds: 2, summary: .init(
+ deviceToken: "hid-token",
+ vendorID: 1452,
+ productID: 1,
+ isBuiltIn: false,
+ transport: "Bluetooth"
+ )),
+ .remote(.touch(.init(timestampNanoseconds: 3, x: -0.5, y: 0.75, phase: .began))),
+ .remote(.button(timestampNanoseconds: 4, button: .touchClick, isPressed: true)),
+ .remote(.button(timestampNanoseconds: 5, button: .menu, isPressed: true)),
+ .hidReport(timestampNanoseconds: 6, deviceToken: "hid-token", reportID: 2, bytes: Data([1])),
+ .hidReport(timestampNanoseconds: 7, deviceToken: "hid-token", reportID: 1, bytes: Data([2])),
+ ]
+ )
+ let digest = try CaptureDigest.sha256(document)
+
+ let markdown = try ProbeReportRenderer.markdown(for: document)
+
+ #expect(markdown == """
+ # Siri Remote Hardware Report
+
+ - GameController detected: Yes
+ - CoreHID candidate detected: Yes
+ - Touch observed: Yes
+ - Motion observed: No
+ - Battery observed: No
+ - Captured buttons: menu, touchClick
+ - HID report IDs: 1, 2
+ - OS: macOS 26.5.1
+ - OS build: 25F90
+ - App version: 0.1.0
+ - Remote events: 4
+ - HID reports: 2
+ - Touch X range: -0.500...-0.500
+ - Touch Y range: 0.750...0.750
+ - Warnings: None
+ - Capture SHA-256: \(digest)
+ """)
+}
+
+@Test func rendererNeverLeaksSuppliedSensitiveValues() throws {
+ let secret = "/Users/alice AA:BB:CC:DD"
+ let document = CaptureDocument(
+ fileURL: URL(filePath: "/tmp/test.remocapture"),
+ metadata: .init(osVersion: "macOS", osBuild: "build", appVersion: "1"),
+ records: [.note(timestampNanoseconds: 1, value: secret)]
+ )
+
+ let markdown = try ProbeReportRenderer.markdown(
+ for: document,
+ sensitiveValues: [secret, "alice"]
+ )
+
+ #expect(!markdown.contains(secret))
+ #expect(!markdown.contains("alice"))
+}
From a31488c811da644a395fa28e46677115270d4bdb Mon Sep 17 00:00:00 2001
From: kefrulz <9220848+kefrulz@users.noreply.github.com>
Date: Sun, 9 Aug 2026 21:26:33 +0200
Subject: [PATCH 06/20] Build live Siri Remote probe
---
Sources/RemoMouseDomain/ProbeState.swift | 81 ++++++
Sources/RemoMouseProbe/ProbeModel.swift | 233 ++++++++++++++++++
Sources/RemoMouseProbe/ProbeView.swift | 155 +++++++++++-
.../RemoMouseProbe/RemoMouseProbeApp.swift | 2 +
.../ProbeStateReducerTests.swift | 51 ++++
5 files changed, 517 insertions(+), 5 deletions(-)
create mode 100644 Sources/RemoMouseDomain/ProbeState.swift
create mode 100644 Sources/RemoMouseProbe/ProbeModel.swift
create mode 100644 Tests/RemoMouseDomainTests/ProbeStateReducerTests.swift
diff --git a/Sources/RemoMouseDomain/ProbeState.swift b/Sources/RemoMouseDomain/ProbeState.swift
new file mode 100644
index 0000000..293ede8
--- /dev/null
+++ b/Sources/RemoMouseDomain/ProbeState.swift
@@ -0,0 +1,81 @@
+public enum ObservedCapability: String, Codable, CaseIterable, Hashable, Sendable {
+ case touch
+ case motion
+ case battery
+ case gameController
+ case coreHID
+}
+
+public struct ProbeState: Equatable, Sendable {
+ public enum Phase: String, Equatable, Sendable {
+ case idle
+ case discovering
+ case capturing
+ case complete
+ case failed
+ }
+
+ public var phase: Phase
+ public var observed: Set
+ public var failureMessage: String?
+
+ public init(
+ phase: Phase,
+ observed: Set = [],
+ failureMessage: String? = nil
+ ) {
+ self.phase = phase
+ self.observed = observed
+ self.failureMessage = failureMessage
+ }
+
+ public static let idle = ProbeState(phase: .idle)
+}
+
+public enum ProbeAction: Equatable, Sendable {
+ case started
+ case gameControllerConnected
+ case coreHIDConnected
+ case eventObserved(ObservedCapability)
+ case captureStopped
+ case failed(String)
+ case retry
+}
+
+public enum ProbeStateReducer {
+ public static func reduce(_ state: ProbeState, _ action: ProbeAction) -> ProbeState {
+ switch action {
+ case .started, .retry:
+ return ProbeState(phase: .discovering)
+ case .gameControllerConnected:
+ return observing(.gameController, in: state)
+ case .coreHIDConnected:
+ return observing(.coreHID, in: state)
+ case let .eventObserved(capability):
+ return observing(capability, in: state)
+ case .captureStopped:
+ guard state.phase != .failed else { return state }
+ var result = state
+ result.phase = .complete
+ return result
+ case let .failed(message):
+ var result = state
+ result.phase = .failed
+ result.failureMessage = message
+ return result
+ }
+ }
+
+ private static func observing(
+ _ capability: ObservedCapability,
+ in state: ProbeState
+ ) -> ProbeState {
+ var result = state
+ result.observed.insert(capability)
+ result.failureMessage = nil
+ if result.phase == .discovering {
+ result.phase = .capturing
+ }
+ return result
+ }
+}
diff --git a/Sources/RemoMouseProbe/ProbeModel.swift b/Sources/RemoMouseProbe/ProbeModel.swift
new file mode 100644
index 0000000..646dd4d
--- /dev/null
+++ b/Sources/RemoMouseProbe/ProbeModel.swift
@@ -0,0 +1,233 @@
+import AppKit
+import Foundation
+import Observation
+import RemoMouseCapture
+import RemoMouseDomain
+import RemoMouseHardware
+
+@MainActor
+@Observable
+final class ProbeModel {
+ private(set) var state = ProbeState.idle
+ private(set) var currentTouch: TouchSample?
+ private(set) var currentMotion: MotionSample?
+ private(set) var pressedButtons: Set = []
+ private(set) var observedButtons: Set = []
+ private(set) var lastEvent = "Ready to begin"
+ private(set) var captureURL: URL?
+ private(set) var reportURL: URL?
+
+ private let gameController = GameControllerTransport()
+ private let hidInventory = CoreHIDInventory()
+ private var recorder: EventRecorder?
+ private var monitorTasks: [Task] = []
+
+ var isRunning: Bool {
+ state.phase == .discovering || state.phase == .capturing
+ }
+
+ func start() {
+ guard !isRunning else { return }
+ monitorTasks.forEach { $0.cancel() }
+ monitorTasks.removeAll()
+ state = ProbeStateReducer.reduce(state, state.phase == .failed ? .retry : .started)
+ currentTouch = nil
+ currentMotion = nil
+ pressedButtons = []
+ observedButtons = []
+ captureURL = nil
+ reportURL = nil
+ lastEvent = "Looking for the paired Siri Remote…"
+
+ do {
+ recorder = try EventRecorder(sensitiveValues: Self.localSensitiveValues)
+ } catch {
+ fail("Could not create the local capture: \(error.localizedDescription)")
+ return
+ }
+
+ monitorTasks = [
+ Task { [weak self] in await self?.monitorGameController() },
+ Task { [weak self] in await self?.monitorCoreHID() },
+ ]
+ }
+
+ func stop() {
+ guard isRunning else { return }
+ Task { [weak self] in await self?.finishCapture() }
+ }
+
+ func revealCapture() {
+ reveal(captureURL)
+ }
+
+ func revealReport() {
+ reveal(reportURL)
+ }
+
+ private func monitorGameController() async {
+ let stream = await gameController.events()
+ do {
+ for try await event in stream {
+ guard !Task.isCancelled else { return }
+ await handle(event)
+ }
+ } catch is CancellationError {
+ return
+ } catch {
+ await recordWarning("GameController monitor stopped: \(error.localizedDescription)")
+ }
+ }
+
+ private func monitorCoreHID() async {
+ let stream = await hidInventory.events()
+ do {
+ for try await event in stream {
+ guard !Task.isCancelled else { return }
+ await handle(event)
+ }
+ } catch is CancellationError {
+ return
+ } catch {
+ await recordWarning("CoreHID monitor stopped: \(error.localizedDescription)")
+ }
+ }
+
+ private func handle(_ event: RemoteEvent) async {
+ do {
+ try await recorder?.append(.remote(event))
+ } catch {
+ fail("Capture write failed: \(error.localizedDescription)")
+ return
+ }
+
+ switch event {
+ case .connected:
+ state = ProbeStateReducer.reduce(state, .gameControllerConnected)
+ lastEvent = "Siri Remote connected through GameController"
+ case let .disconnected(reason):
+ pressedButtons = []
+ lastEvent = reason
+ case let .touch(sample):
+ currentTouch = sample
+ state = ProbeStateReducer.reduce(state, .eventObserved(.touch))
+ lastEvent = "Touch \(sample.phase.rawValue)"
+ case let .button(_, button, isPressed):
+ if isPressed {
+ pressedButtons.insert(button)
+ observedButtons.insert(button)
+ } else {
+ pressedButtons.remove(button)
+ }
+ lastEvent = "\(button.title) \(isPressed ? "pressed" : "released")"
+ case let .motion(sample):
+ currentMotion = sample
+ state = ProbeStateReducer.reduce(state, .eventObserved(.motion))
+ lastEvent = "Motion updated"
+ case let .battery(_, percentage):
+ state = ProbeStateReducer.reduce(state, .eventObserved(.battery))
+ lastEvent = "Battery \(percentage)%"
+ }
+ }
+
+ private func handle(_ event: HIDInventoryEvent) async {
+ let now = DispatchTime.now().uptimeNanoseconds
+ do {
+ switch event {
+ case let .matched(summary):
+ try await recorder?.append(.device(timestampNanoseconds: now, summary: summary))
+ if summary.isRemoteCandidate {
+ state = ProbeStateReducer.reduce(state, .coreHIDConnected)
+ lastEvent = "Apple HID candidate detected"
+ }
+ case let .report(token, reportID, bytes, timestamp):
+ try await recorder?.append(.hidReport(
+ timestampNanoseconds: timestamp,
+ deviceToken: token,
+ reportID: reportID,
+ bytes: bytes
+ ))
+ lastEvent = "HID report \(reportID) · \(bytes.count) bytes"
+ case let .removed(token):
+ try await recorder?.append(.note(
+ timestampNanoseconds: now,
+ value: "HID device \(token) removed"
+ ))
+ lastEvent = "HID candidate disconnected"
+ case let .warning(value):
+ try await recorder?.append(.warning(timestampNanoseconds: now, value: value))
+ lastEvent = value
+ }
+ } catch {
+ fail("Capture write failed: \(error.localizedDescription)")
+ }
+ }
+
+ private func finishCapture() async {
+ lastEvent = "Finishing capture…"
+ monitorTasks.forEach { $0.cancel() }
+ monitorTasks.removeAll()
+ await gameController.stop()
+ await hidInventory.stop()
+
+ guard let recorder else {
+ fail("The capture recorder is unavailable")
+ return
+ }
+
+ do {
+ let document = try await recorder.finish()
+ let markdown = try ProbeReportRenderer.markdown(
+ for: document,
+ sensitiveValues: Self.localSensitiveValues
+ )
+ let reportURL = document.fileURL
+ .deletingPathExtension()
+ .appendingPathExtension("md")
+ try Data(markdown.utf8).write(to: reportURL, options: .atomic)
+ captureURL = document.fileURL
+ self.reportURL = reportURL
+ self.recorder = nil
+ state = ProbeStateReducer.reduce(state, .captureStopped)
+ lastEvent = "Hardware report generated"
+ } catch {
+ fail("Could not finish the report: \(error.localizedDescription)")
+ }
+ }
+
+ private func recordWarning(_ value: String) async {
+ lastEvent = value
+ try? await recorder?.append(.warning(
+ timestampNanoseconds: DispatchTime.now().uptimeNanoseconds,
+ value: value
+ ))
+ }
+
+ private func fail(_ message: String) {
+ state = ProbeStateReducer.reduce(state, .failed(message))
+ lastEvent = message
+ }
+
+ private func reveal(_ url: URL?) {
+ guard let url else { return }
+ NSWorkspace.shared.activateFileViewerSelecting([url])
+ }
+
+ private static var localSensitiveValues: [String] {
+ [NSUserName(), NSHomeDirectory()].filter { !$0.isEmpty }
+ }
+}
+
+private extension RemoteButton {
+ var title: String {
+ switch self {
+ case .touchClick: "Click"
+ case .menu: "Menu"
+ case .home: "Home"
+ case .playPause: "Play/Pause"
+ case .volumeUp: "Volume Up"
+ case .volumeDown: "Volume Down"
+ case .siri: "Siri"
+ }
+ }
+}
diff --git a/Sources/RemoMouseProbe/ProbeView.swift b/Sources/RemoMouseProbe/ProbeView.swift
index 8cc6b90..cff0f07 100644
--- a/Sources/RemoMouseProbe/ProbeView.swift
+++ b/Sources/RemoMouseProbe/ProbeView.swift
@@ -1,12 +1,157 @@
+import RemoMouseDomain
import SwiftUI
struct ProbeView: View {
+ @State private var model = ProbeModel()
+
var body: some View {
- ContentUnavailableView(
- "Hardware probe not started",
- systemImage: "appletvremote.gen1",
- description: Text("Connect the probe to begin validating the remote.")
+ Form {
+ Section("Connection") {
+ LabeledContent("GameController") {
+ statusLabel(
+ model.state.observed.contains(.gameController),
+ waiting: model.isRunning
+ )
+ }
+ LabeledContent("CoreHID diagnostics") {
+ statusLabel(
+ model.state.observed.contains(.coreHID),
+ waiting: model.isRunning
+ )
+ }
+ LabeledContent("Latest activity", value: model.lastEvent)
+ }
+
+ Section("Live Input") {
+ LabeledContent("Touch surface") {
+ if let touch = model.currentTouch {
+ Text("x \(touch.x, format: .number.precision(.fractionLength(3))) · y \(touch.y, format: .number.precision(.fractionLength(3)))")
+ .monospacedDigit()
+ } else {
+ Text("No touch yet").foregroundStyle(.secondary)
+ }
+ }
+ buttonStrip
+ LabeledContent("Motion") {
+ if let motion = model.currentMotion {
+ Text("g \(motion.gravityX, format: .number.precision(.fractionLength(2))), \(motion.gravityY, format: .number.precision(.fractionLength(2))), \(motion.gravityZ, format: .number.precision(.fractionLength(2)))")
+ .monospacedDigit()
+ } else {
+ Text("No motion yet").foregroundStyle(.secondary)
+ }
+ }
+ }
+
+ Section("Capture Script") {
+ scriptRow(1, "Wake the remote", done: connectionObserved)
+ scriptRow(2, "Move slowly left/right and up/down", done: touchObserved)
+ scriptRow(3, "Make four fast edge-to-edge swipes", done: touchObserved)
+ scriptRow(4, "Click, double-click, then hold for two seconds", done: model.observedButtons.contains(.touchClick))
+ scriptRow(5, "Press Menu, Home, Play/Pause, Volume Up, Volume Down, and Siri", done: requiredButtons.isSubset(of: model.observedButtons))
+ scriptRow(6, "Rotate and tilt on all three axes for five seconds", done: model.state.observed.contains(.motion))
+ scriptRow(7, "Leave the remote untouched for ten seconds", done: model.state.phase == .complete)
+ scriptRow(8, "Stop capture and generate the report", done: model.state.phase == .complete)
+ }
+
+ Section("Privacy") {
+ Label(
+ "Identifiers are redacted before writing. Raw captures remain local in Application Support.",
+ systemImage: "hand.raised.fill"
+ )
+ .foregroundStyle(.secondary)
+ }
+ }
+ .formStyle(.grouped)
+ .safeAreaInset(edge: .bottom) { actionBar }
+ .frame(minWidth: 680, idealWidth: 740, minHeight: 690, idealHeight: 760)
+ .navigationTitle("Siri Remote Hardware Probe")
+ }
+
+ private var actionBar: some View {
+ HStack {
+ if model.state.phase == .failed {
+ Label(model.state.failureMessage ?? "Probe failed", systemImage: "exclamationmark.triangle.fill")
+ .foregroundStyle(.red)
+ .lineLimit(2)
+ } else if model.state.phase == .complete {
+ Label("Capture complete", systemImage: "checkmark.circle.fill")
+ .foregroundStyle(.green)
+ }
+ Spacer()
+ Button("Reveal Capture") { model.revealCapture() }
+ .disabled(model.captureURL == nil)
+ Button("Reveal Report") { model.revealReport() }
+ .disabled(model.reportURL == nil)
+ if model.isRunning {
+ Button("Stop and Generate Report") { model.stop() }
+ .buttonStyle(.borderedProminent)
+ } else {
+ Button(model.state.phase == .failed ? "Retry" : "Start Capture") { model.start() }
+ .buttonStyle(.borderedProminent)
+ }
+ }
+ .padding()
+ .background(.bar)
+ }
+
+ private var buttonStrip: some View {
+ LabeledContent("Buttons") {
+ HStack(spacing: 6) {
+ ForEach(RemoteButton.allCases, id: \.self) { button in
+ Text(shortTitle(button))
+ .font(.caption)
+ .padding(.horizontal, 7)
+ .padding(.vertical, 4)
+ .background(
+ model.pressedButtons.contains(button) ? Color.accentColor : Color.secondary.opacity(0.12),
+ in: Capsule()
+ )
+ .foregroundStyle(model.pressedButtons.contains(button) ? .white : .secondary)
+ }
+ }
+ }
+ }
+
+ private func statusLabel(_ connected: Bool, waiting: Bool) -> some View {
+ Label(
+ connected ? "Detected" : (waiting ? "Searching" : "Not detected"),
+ systemImage: connected ? "checkmark.circle.fill" : (waiting ? "wave.3.right" : "minus.circle")
)
- .frame(minWidth: 560, minHeight: 420)
+ .foregroundStyle(connected ? .green : .secondary)
+ }
+
+ private func scriptRow(_ number: Int, _ title: String, done: Bool) -> some View {
+ HStack(spacing: 10) {
+ Image(systemName: done ? "checkmark.circle.fill" : "\(number).circle")
+ .foregroundStyle(done ? .green : .secondary)
+ .imageScale(.large)
+ Text(title)
+ Spacer()
+ }
+ .accessibilityElement(children: .combine)
+ }
+
+ private var connectionObserved: Bool {
+ model.state.observed.contains(.gameController) || model.state.observed.contains(.coreHID)
+ }
+
+ private var touchObserved: Bool {
+ model.state.observed.contains(.touch)
+ }
+
+ private var requiredButtons: Set {
+ [.touchClick, .menu, .home, .playPause, .volumeUp, .volumeDown, .siri]
+ }
+
+ private func shortTitle(_ button: RemoteButton) -> String {
+ switch button {
+ case .touchClick: "Click"
+ case .menu: "Menu"
+ case .home: "Home"
+ case .playPause: "Play"
+ case .volumeUp: "Vol+"
+ case .volumeDown: "Vol−"
+ case .siri: "Siri"
+ }
}
}
diff --git a/Sources/RemoMouseProbe/RemoMouseProbeApp.swift b/Sources/RemoMouseProbe/RemoMouseProbeApp.swift
index b932cb3..4286f91 100644
--- a/Sources/RemoMouseProbe/RemoMouseProbeApp.swift
+++ b/Sources/RemoMouseProbe/RemoMouseProbeApp.swift
@@ -6,5 +6,7 @@ struct RemoMouseProbeApp: App {
WindowGroup("RemoMouse Hardware Probe") {
ProbeView()
}
+ .defaultSize(width: 740, height: 760)
+ .windowResizability(.contentMinSize)
}
}
diff --git a/Tests/RemoMouseDomainTests/ProbeStateReducerTests.swift b/Tests/RemoMouseDomainTests/ProbeStateReducerTests.swift
new file mode 100644
index 0000000..09573df
--- /dev/null
+++ b/Tests/RemoMouseDomainTests/ProbeStateReducerTests.swift
@@ -0,0 +1,51 @@
+import RemoMouseDomain
+import Testing
+
+@Test func successfulProbeTracksCapabilitiesAndCompletes() {
+ let final = [
+ ProbeAction.started,
+ .gameControllerConnected,
+ .eventObserved(.touch),
+ .captureStopped,
+ ].reduce(ProbeState.idle, ProbeStateReducer.reduce)
+
+ #expect(final.phase == .complete)
+ #expect(final.observed.contains(.gameController))
+ #expect(final.observed.contains(.touch))
+ #expect(final.failureMessage == nil)
+}
+
+@Test func coreHIDOnlyRouteCanCaptureAndStop() {
+ let final = [
+ ProbeAction.started,
+ .coreHIDConnected,
+ .captureStopped,
+ ].reduce(ProbeState.idle, ProbeStateReducer.reduce)
+
+ #expect(final.phase == .complete)
+ #expect(final.observed == [.coreHID])
+}
+
+@Test func stopDuringDiscoveryCompletesCleanly() {
+ let final = [
+ ProbeAction.started,
+ .captureStopped,
+ ].reduce(ProbeState.idle, ProbeStateReducer.reduce)
+
+ #expect(final.phase == .complete)
+ #expect(final.observed.isEmpty)
+}
+
+@Test func retryClearsFailureAndPriorObservations() {
+ let failed = [
+ ProbeAction.started,
+ .eventObserved(.touch),
+ .failed("Bluetooth unavailable"),
+ ].reduce(ProbeState.idle, ProbeStateReducer.reduce)
+
+ #expect(failed.phase == .failed)
+ #expect(failed.failureMessage == "Bluetooth unavailable")
+
+ let retried = ProbeStateReducer.reduce(failed, .retry)
+ #expect(retried == ProbeState(phase: .discovering))
+}
From 76d4432280aaf74c4e241e84af00147bd3680820 Mon Sep 17 00:00:00 2001
From: kefrulz <9220848+kefrulz@users.noreply.github.com>
Date: Sun, 9 Aug 2026 21:35:21 +0200
Subject: [PATCH 07/20] Recognize paired BLE Siri Remote
---
.../RemoMouseCapture/CaptureDocument.swift | 27 ++++++++--
.../RemoMouseHardware/CoreHIDInventory.swift | 49 ++++---------------
Sources/RemoMouseProbe/ProbeModel.swift | 6 +++
.../ProbeReportRendererTests.swift | 10 ++++
.../CoreHIDInventoryTests.swift | 1 +
5 files changed, 51 insertions(+), 42 deletions(-)
diff --git a/Sources/RemoMouseCapture/CaptureDocument.swift b/Sources/RemoMouseCapture/CaptureDocument.swift
index 5a831e4..e0ec2a6 100644
--- a/Sources/RemoMouseCapture/CaptureDocument.swift
+++ b/Sources/RemoMouseCapture/CaptureDocument.swift
@@ -15,13 +15,34 @@ public struct ProbeMetadata: Codable, Equatable, Sendable {
}
public static var current: ProbeMetadata {
- ProbeMetadata(
- osVersion: ProcessInfo.processInfo.operatingSystemVersionString,
- osBuild: "Unknown",
+ fromOperatingSystemVersionString(
+ ProcessInfo.processInfo.operatingSystemVersionString,
appVersion: Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String
?? "Development"
)
}
+
+ public static func fromOperatingSystemVersionString(
+ _ value: String,
+ appVersion: String
+ ) -> ProbeMetadata {
+ let prefix = "Version "
+ let buildMarker = " (Build "
+ guard value.hasPrefix(prefix),
+ let markerRange = value.range(of: buildMarker),
+ value.hasSuffix(")")
+ else {
+ return ProbeMetadata(osVersion: value, osBuild: "Unknown", appVersion: appVersion)
+ }
+ let version = value[value.index(value.startIndex, offsetBy: prefix.count).. String {
- switch transport {
- case .usb: "USB"
- case .bluetooth: "Bluetooth"
- case .bluetoothLowEnergy: "Bluetooth Low Energy"
- case .bluetoothAACP: "Bluetooth AACP"
- case .aid: "AID"
- case .i2c: "I2C"
- case .spi: "SPI"
- case .serial: "Serial"
- case .iap: "iAP"
- case .airPlay: "AirPlay"
- case .spu: "SPU"
- case .fifo: "FIFO"
- case .inductiveInBand: "Inductive In-Band"
- case .virtual: "Virtual"
- case let .unknown(value): "Unknown (\(value))"
- @unknown default: "Unknown"
- }
- }
}
diff --git a/Sources/RemoMouseProbe/ProbeModel.swift b/Sources/RemoMouseProbe/ProbeModel.swift
index 646dd4d..289f121 100644
--- a/Sources/RemoMouseProbe/ProbeModel.swift
+++ b/Sources/RemoMouseProbe/ProbeModel.swift
@@ -176,6 +176,12 @@ final class ProbeModel {
}
do {
+ if state.observed.isEmpty {
+ try await recorder.append(.warning(
+ timestampNanoseconds: DispatchTime.now().uptimeNanoseconds,
+ value: "No public GameController or CoreHID input route was observed"
+ ))
+ }
let document = try await recorder.finish()
let markdown = try ProbeReportRenderer.markdown(
for: document,
diff --git a/Tests/RemoMouseCaptureTests/ProbeReportRendererTests.swift b/Tests/RemoMouseCaptureTests/ProbeReportRendererTests.swift
index cab5e44..6d7ba75 100644
--- a/Tests/RemoMouseCaptureTests/ProbeReportRendererTests.swift
+++ b/Tests/RemoMouseCaptureTests/ProbeReportRendererTests.swift
@@ -4,6 +4,16 @@ import RemoMouseDomain
import RemoMouseHardware
import Testing
+@Test func metadataSeparatesMarketingVersionAndBuild() {
+ let metadata = ProbeMetadata.fromOperatingSystemVersionString(
+ "Version 26.5.1 (Build 25F80)",
+ appVersion: "1.0"
+ )
+
+ #expect(metadata.osVersion == "macOS 26.5.1")
+ #expect(metadata.osBuild == "25F80")
+}
+
@Test func rendererProducesDeterministicCapabilityReport() throws {
let document = CaptureDocument(
fileURL: URL(filePath: "/tmp/test.remocapture"),
diff --git a/Tests/RemoMouseHardwareTests/CoreHIDInventoryTests.swift b/Tests/RemoMouseHardwareTests/CoreHIDInventoryTests.swift
index 67b8dd8..147f43a 100644
--- a/Tests/RemoMouseHardwareTests/CoreHIDInventoryTests.swift
+++ b/Tests/RemoMouseHardwareTests/CoreHIDInventoryTests.swift
@@ -4,6 +4,7 @@ import Testing
@Test func candidateRequiresExternalAppleDevice() {
#expect(HIDDeviceSummary(vendorID: 1452, productID: 1, isBuiltIn: false).isRemoteCandidate)
+ #expect(HIDDeviceSummary(vendorID: 76, productID: 621, isBuiltIn: false).isRemoteCandidate)
#expect(!HIDDeviceSummary(vendorID: 1452, productID: 1, isBuiltIn: true).isRemoteCandidate)
#expect(!HIDDeviceSummary(vendorID: 0, productID: 1, isBuiltIn: false).isRemoteCandidate)
}
From d37da8fd1af5c95ef1f254757fa0ff1c9844d39f Mon Sep 17 00:00:00 2001
From: kefrulz <9220848+kefrulz@users.noreply.github.com>
Date: Sun, 9 Aug 2026 21:45:20 +0200
Subject: [PATCH 08/20] Validate first-generation Siri Remote
---
docs/hardware/first-generation-siri-remote.md | 61 +++++++++++++++++++
1 file changed, 61 insertions(+)
create mode 100644 docs/hardware/first-generation-siri-remote.md
diff --git a/docs/hardware/first-generation-siri-remote.md b/docs/hardware/first-generation-siri-remote.md
new file mode 100644
index 0000000..49848ca
--- /dev/null
+++ b/docs/hardware/first-generation-siri-remote.md
@@ -0,0 +1,61 @@
+# First-Generation Siri Remote Hardware Report
+
+## Validated Environment
+
+- Hardware: black first-generation Siri Remote
+- Connection: paired directly to the Mac over Bluetooth Low Energy
+- Mac: Apple silicon MacBook Air
+- OS: macOS 26.5.1
+- OS build: 25F80
+- Probe version: 0.1.0
+
+## Observed Input Routes
+
+- GameController detected: No
+- CoreHID candidate detected: Yes
+- CoreHID interfaces detected: 8
+- Raw HID reports captured: 588
+- HID report IDs: 250
+- Touch decoded by the validation probe: No
+- Motion decoded by the validation probe: No
+- Battery decoded by the validation probe: No
+
+The remote enumerated as eight external Bluetooth Low Energy HID interfaces. Their top-level usages include consumer controls, digitizer input, sensors, proximity, and Apple vendor-defined device-management data. GameController did not publish a controller for the paired remote during the same session.
+
+Apple documents the CoreHID and IOKit HID APIs used by this project, but does not publish a direct-to-Mac compatibility profile for this remote generation. The interface topology, report identifier, and device-specific semantics recorded here are therefore hardware-derived compatibility data. They will be kept behind pure decoders and validated with sanitized replay fixtures plus physical-device regression tests.
+
+## Button Report Evidence
+
+Report ID `250` is a two-byte state report. The first byte remained `0xFA`. The second byte returned to `0x00` between presses and exposed seven distinct nonzero single-bit values during the ordered button exercise:
+
+`0x01`, `0x02`, `0x04`, `0x08`, `0x10`, `0x20`, `0x80`
+
+This establishes that all seven physical controls can be represented as independent press state without relying on timing-only inference. The raw capture remains local under Application Support and is intentionally not committed.
+
+## Production Transport Decision
+
+The production first-generation transport will use the public IOKit HID user-space API:
+
+1. Match the validated Apple Bluetooth vendor and remote product identifiers.
+2. Enumerate every interface belonging to the physical remote.
+3. Consume standard IOHID element callbacks for consumer-control buttons, digitizer coordinates/contact state, and sensor values.
+4. Normalize those values behind `RemoteTransport` and pure decoders with sanitized replay fixtures.
+5. Keep GameController as an optional future fast path, with duplicate suppression if macOS begins publishing this remote through both routes.
+
+This path avoids a kernel extension, DriverKit driver, external runtime, network dependency, and guessed Bluetooth protocol. It remains compatible with direct signed and notarized GitHub distribution.
+
+## Generated Capture Summary
+
+- GameController detected: No
+- CoreHID candidate detected: Yes
+- Touch observed: No
+- Motion observed: No
+- Battery observed: No
+- Captured buttons: None
+- HID report IDs: 250
+- Remote events: 0
+- HID reports: 588
+- Warnings: None
+- Capture SHA-256: `818ca2bda94c90fe1e32c8d0df36893e03fd925d2b21de996e6e7e1ecaa5224d`
+
+The `Touch observed`, `Motion observed`, and `Captured buttons` fields above refer to normalized domain events. The validation probe intentionally stored the hardware input as raw HID evidence; normalization is the next milestone.
From 93742ec7d671a3f1032d4854e45de92a4c22d2fb Mon Sep 17 00:00:00 2001
From: kefrulz <9220848+kefrulz@users.noreply.github.com>
Date: Sun, 9 Aug 2026 21:55:49 +0200
Subject: [PATCH 09/20] Build usable RemoMouse menu bar app
---
Package.swift | 11 ++
README.md | 8 +-
Resources/RemoMouseInfo.plist | 17 +++
Scripts/package-app.sh | 13 ++
Sources/RemoMouseApp/PointerController.swift | 104 +++++++++++++
Sources/RemoMouseApp/RemoMouseApp.swift | 52 +++++++
Sources/RemoMouseApp/RemoMouseModel.swift | 64 ++++++++
.../SiriRemoteHIDMonitor.swift | 137 ++++++++++++++++++
.../SiriRemoteReportDecoder.swift | 48 ++++++
.../SiriRemoteReportDecoderTests.swift | 28 ++++
10 files changed, 479 insertions(+), 3 deletions(-)
create mode 100644 Resources/RemoMouseInfo.plist
create mode 100755 Scripts/package-app.sh
create mode 100644 Sources/RemoMouseApp/PointerController.swift
create mode 100644 Sources/RemoMouseApp/RemoMouseApp.swift
create mode 100644 Sources/RemoMouseApp/RemoMouseModel.swift
create mode 100644 Sources/RemoMouseHardware/SiriRemoteHIDMonitor.swift
create mode 100644 Sources/RemoMouseHardware/SiriRemoteReportDecoder.swift
create mode 100644 Tests/RemoMouseHardwareTests/SiriRemoteReportDecoderTests.swift
diff --git a/Package.swift b/Package.swift
index 74c59c3..65df259 100644
--- a/Package.swift
+++ b/Package.swift
@@ -10,6 +10,7 @@ let package = Package(
.library(name: "RemoMouseHardware", targets: ["RemoMouseHardware"]),
.library(name: "RemoMouseCapture", targets: ["RemoMouseCapture"]),
.executable(name: "RemoMouseProbe", targets: ["RemoMouseProbe"]),
+ .executable(name: "RemoMouse", targets: ["RemoMouseApp"]),
],
targets: [
.target(
@@ -22,6 +23,7 @@ let package = Package(
linkerSettings: [
.linkedFramework("CoreHID"),
.linkedFramework("GameController"),
+ .linkedFramework("IOKit"),
]
),
.target(
@@ -41,6 +43,15 @@ let package = Package(
.linkedFramework("SwiftUI"),
]
),
+ .executableTarget(
+ name: "RemoMouseApp",
+ dependencies: ["RemoMouseHardware"],
+ linkerSettings: [
+ .linkedFramework("AppKit"),
+ .linkedFramework("ApplicationServices"),
+ .linkedFramework("SwiftUI"),
+ ]
+ ),
.testTarget(
name: "RemoMouseDomainTests",
dependencies: ["RemoMouseDomain"]
diff --git a/README.md b/README.md
index 2427757..10fecb8 100644
--- a/README.md
+++ b/README.md
@@ -4,7 +4,7 @@ RemoMouse turns a first-generation Siri Remote into a precise, app-aware mouse a
## Status
-Hardware validation is in progress. The current branch builds a local probe that determines which GameController and CoreHID inputs macOS exposes for the paired black Siri Remote. It does not generate system-wide pointer events yet.
+The first usable local build is available. It runs in the menu bar, reads the paired first-generation Siri Remote through macOS HID, and maps its touch surface and physical click to the Mac pointer. Menu acts as secondary click. Pointer speed and pause/resume are available from the menu-bar popover.
## Requirements
@@ -16,10 +16,12 @@ Hardware validation is in progress. The current branch builds a local probe that
```bash
swift test --parallel
-Scripts/package-probe-app.sh debug
-open .build/RemoMouseProbe.app
+Scripts/package-app.sh release
+open .build/RemoMouse.app
```
+On first launch, grant RemoMouse access in System Settings → Privacy & Security → Accessibility. Press a remote button to wake it after it has been idle.
+
## Privacy
RemoMouse is local-only. Hardware captures stay under the user's Application Support directory, and committed reports must redact device identifiers, Bluetooth addresses, usernames, and home-directory paths.
diff --git a/Resources/RemoMouseInfo.plist b/Resources/RemoMouseInfo.plist
new file mode 100644
index 0000000..152a2ce
--- /dev/null
+++ b/Resources/RemoMouseInfo.plist
@@ -0,0 +1,17 @@
+
+
+
+
+ CFBundleDevelopmentRegionen
+ CFBundleExecutableRemoMouse
+ CFBundleIdentifiercom.kefrulz.RemoMouse
+ CFBundleInfoDictionaryVersion6.0
+ CFBundleNameRemoMouse
+ CFBundlePackageTypeAPPL
+ CFBundleShortVersionString0.1.0
+ CFBundleVersion1
+ LSMinimumSystemVersion26.0
+ LSUIElement
+ NSBluetoothAlwaysUsageDescriptionRemoMouse reads input from your paired Siri Remote.
+
+
diff --git a/Scripts/package-app.sh b/Scripts/package-app.sh
new file mode 100755
index 0000000..5cb4f12
--- /dev/null
+++ b/Scripts/package-app.sh
@@ -0,0 +1,13 @@
+#!/bin/zsh
+set -euo pipefail
+
+configuration="${1:-release}"
+swift build -c "$configuration" --product RemoMouse
+
+app_path=".build/RemoMouse.app"
+binary_path=".build/$configuration/RemoMouse"
+mkdir -p "$app_path/Contents/MacOS" "$app_path/Contents/Resources"
+cp "$binary_path" "$app_path/Contents/MacOS/RemoMouse"
+cp Resources/RemoMouseInfo.plist "$app_path/Contents/Info.plist"
+codesign --force --deep --sign - "$app_path"
+echo "$PWD/$app_path"
diff --git a/Sources/RemoMouseApp/PointerController.swift b/Sources/RemoMouseApp/PointerController.swift
new file mode 100644
index 0000000..5b0b170
--- /dev/null
+++ b/Sources/RemoMouseApp/PointerController.swift
@@ -0,0 +1,104 @@
+import AppKit
+import ApplicationServices
+import RemoMouseHardware
+
+@MainActor
+final class PointerController {
+ var speed = 7.0
+ private var lastTouch: SiriRemoteTouch?
+ private var lastX: Double?
+ private var lastY: Double?
+ private var lastButtonMask: UInt8 = 0
+
+ var isTrusted: Bool { AXIsProcessTrusted() }
+
+ func requestAccessibility() {
+ let options = ["AXTrustedCheckOptionPrompt": true]
+ _ = AXIsProcessTrustedWithOptions(options as CFDictionary)
+ }
+
+ func handle(_ frame: SiriRemoteFrame) {
+ updateButtons(frame.buttonMask)
+ guard let touch = frame.touch else { return }
+ handleTouch(touch)
+ }
+
+ func handle(_ element: SiriRemoteElementValue) {
+ guard element.logicalMaximum > element.logicalMinimum else { return }
+ if element.usagePage == 0x01, element.usage == 0x30 {
+ let x = normalized(element)
+ if let lastX { move(dx: (x - lastX) * speed * 120, dy: 0) }
+ lastX = x
+ } else if element.usagePage == 0x01, element.usage == 0x31 {
+ let y = normalized(element)
+ if let lastY { move(dx: 0, dy: (y - lastY) * speed * 120) }
+ lastY = y
+ } else if element.usagePage == 0x0D, element.usage == 0x42, element.value == 0 {
+ lastX = nil
+ lastY = nil
+ }
+ }
+
+ func releaseAll() {
+ if lastButtonMask & 0x80 != 0 { postMouse(.leftMouseUp, button: .left) }
+ if lastButtonMask & 0x20 != 0 { postMouse(.rightMouseUp, button: .right) }
+ lastButtonMask = 0
+ lastTouch = nil
+ lastX = nil
+ lastY = nil
+ }
+
+ private func handleTouch(_ touch: SiriRemoteTouch) {
+ guard touch.isContact else {
+ lastTouch = nil
+ return
+ }
+ if let prior = lastTouch {
+ let dx = touch.x - prior.x
+ let dy = -(touch.y - prior.y)
+ let distance = hypot(dx, dy)
+ guard distance < 80 else {
+ lastTouch = touch
+ return
+ }
+ let acceleration = 1 + min(distance / 12, 2.5)
+ move(dx: dx * speed * acceleration, dy: dy * speed * acceleration)
+ }
+ lastTouch = touch
+ }
+
+ private func updateButtons(_ mask: UInt8) {
+ let changed = mask ^ lastButtonMask
+ if changed & 0x80 != 0 {
+ postMouse(mask & 0x80 != 0 ? .leftMouseDown : .leftMouseUp, button: .left)
+ }
+ if changed & 0x20 != 0 {
+ postMouse(mask & 0x20 != 0 ? .rightMouseDown : .rightMouseUp, button: .right)
+ }
+ lastButtonMask = mask
+ }
+
+ private func normalized(_ element: SiriRemoteElementValue) -> Double {
+ Double(element.value - element.logicalMinimum) /
+ Double(element.logicalMaximum - element.logicalMinimum)
+ }
+
+ private func move(dx: Double, dy: Double) {
+ guard isTrusted else { return }
+ let current = NSEvent.mouseLocation
+ let screenHeight = NSScreen.screens.first?.frame.height ?? 0
+ let quartzCurrent = CGPoint(x: current.x, y: screenHeight - current.y)
+ let target = CGPoint(x: quartzCurrent.x + dx, y: quartzCurrent.y + dy)
+ CGEvent(mouseEventSource: nil, mouseType: .mouseMoved, mouseCursorPosition: target, mouseButton: .left)?
+ .post(tap: .cghidEventTap)
+ }
+
+ private func postMouse(_ type: CGEventType, button: CGMouseButton) {
+ guard isTrusted else { return }
+ let current = NSEvent.mouseLocation
+ let screenHeight = NSScreen.screens.first?.frame.height ?? 0
+ let position = CGPoint(x: current.x, y: screenHeight - current.y)
+ CGEvent(mouseEventSource: nil, mouseType: type, mouseCursorPosition: position, mouseButton: button)?
+ .post(tap: .cghidEventTap)
+ }
+}
diff --git a/Sources/RemoMouseApp/RemoMouseApp.swift b/Sources/RemoMouseApp/RemoMouseApp.swift
new file mode 100644
index 0000000..d0bc97d
--- /dev/null
+++ b/Sources/RemoMouseApp/RemoMouseApp.swift
@@ -0,0 +1,52 @@
+import SwiftUI
+
+@main
+struct RemoMouseApp: App {
+ @State private var model = RemoMouseModel()
+
+ var body: some Scene {
+ MenuBarExtra("RemoMouse", systemImage: model.isEnabled ? "appletvremote.gen1.fill" : "pause.circle.fill") {
+ VStack(alignment: .leading, spacing: 14) {
+ HStack(spacing: 10) {
+ Image(systemName: "appletvremote.gen1.fill")
+ .font(.title2)
+ .frame(width: 32, height: 32)
+ .background(.quaternary, in: .rect(cornerRadius: 9))
+ VStack(alignment: .leading, spacing: 2) {
+ Text("RemoMouse").font(.headline)
+ Text(model.connection.rawValue).foregroundStyle(.secondary)
+ }
+ }
+
+ if !model.hasAccessibility {
+ Button("Allow Accessibility…", systemImage: "hand.raised.fill") {
+ model.requestAccessibility()
+ }
+ Text("Required to move and click the pointer.")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+
+ LabeledContent("Pointer speed") {
+ Slider(value: $model.speed, in: 2...14)
+ .frame(width: 140)
+ }
+
+ Text(model.lastInput)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+
+ Divider()
+
+ Button(model.isEnabled ? "Pause RemoMouse" : "Enable RemoMouse",
+ systemImage: model.isEnabled ? "pause.fill" : "play.fill") {
+ model.toggle()
+ }
+ Button("Quit RemoMouse", systemImage: "power") { model.quit() }
+ }
+ .padding(14)
+ .frame(width: 320)
+ }
+ .menuBarExtraStyle(.window)
+ }
+}
diff --git a/Sources/RemoMouseApp/RemoMouseModel.swift b/Sources/RemoMouseApp/RemoMouseModel.swift
new file mode 100644
index 0000000..3c425b2
--- /dev/null
+++ b/Sources/RemoMouseApp/RemoMouseModel.swift
@@ -0,0 +1,64 @@
+import AppKit
+import Observation
+import RemoMouseHardware
+
+@MainActor
+@Observable
+final class RemoMouseModel {
+ enum ConnectionState: String {
+ case looking = "Looking for Siri Remote"
+ case connected = "Siri Remote connected"
+ case sleeping = "Remote sleeping"
+ }
+
+ private(set) var connection = ConnectionState.looking
+ var isEnabled = true
+ var speed = 7.0 { didSet { pointer.speed = speed } }
+ private(set) var lastInput = "Waiting for input"
+
+ private let pointer = PointerController()
+ private var monitor: SiriRemoteHIDMonitor?
+
+ var hasAccessibility: Bool { pointer.isTrusted }
+
+ init() {
+ monitor = SiriRemoteHIDMonitor { [weak self] event in
+ Task { @MainActor in self?.handle(event) }
+ }
+ monitor?.start()
+ if !pointer.isTrusted {
+ pointer.requestAccessibility()
+ }
+ }
+
+ func toggle() {
+ isEnabled.toggle()
+ if !isEnabled { pointer.releaseAll() }
+ }
+
+ func requestAccessibility() { pointer.requestAccessibility() }
+
+ func quit() {
+ pointer.releaseAll()
+ monitor?.stop()
+ NSApplication.shared.terminate(nil)
+ }
+
+ private func handle(_ event: SiriRemoteHIDEvent) {
+ switch event {
+ case .connected:
+ connection = .connected
+ case .disconnected:
+ connection = .sleeping
+ pointer.releaseAll()
+ case let .frame(frame):
+ connection = .connected
+ lastInput = "Remote input received"
+ if isEnabled { pointer.handle(frame) }
+ case let .element(value):
+ connection = .connected
+ lastInput = "Touch input received"
+ if isEnabled { pointer.handle(value) }
+ }
+ }
+}
diff --git a/Sources/RemoMouseHardware/SiriRemoteHIDMonitor.swift b/Sources/RemoMouseHardware/SiriRemoteHIDMonitor.swift
new file mode 100644
index 0000000..cfb9df6
--- /dev/null
+++ b/Sources/RemoMouseHardware/SiriRemoteHIDMonitor.swift
@@ -0,0 +1,137 @@
+@preconcurrency import IOKit.hid
+import Foundation
+
+public struct SiriRemoteElementValue: Sendable {
+ public let usagePage: UInt32
+ public let usage: UInt32
+ public let value: Int
+ public let logicalMinimum: Int
+ public let logicalMaximum: Int
+}
+
+public enum SiriRemoteHIDEvent: Sendable {
+ case connected
+ case disconnected
+ case frame(SiriRemoteFrame)
+ case element(SiriRemoteElementValue)
+}
+
+public final class SiriRemoteHIDMonitor: @unchecked Sendable {
+ public typealias Handler = @Sendable (SiriRemoteHIDEvent) -> Void
+
+ private final class ReportBuffer {
+ let bytes: UnsafeMutablePointer
+ let capacity: Int
+
+ init(capacity: Int) {
+ self.capacity = capacity
+ bytes = .allocate(capacity: capacity)
+ bytes.initialize(repeating: 0, count: capacity)
+ }
+
+ deinit { bytes.deallocate() }
+ }
+
+ private let manager: IOHIDManager
+ private let handler: Handler
+ private var reportBuffers: [Int: ReportBuffer] = [:]
+ private var isStarted = false
+
+ public init(handler: @escaping Handler) {
+ self.handler = handler
+ manager = IOHIDManagerCreate(kCFAllocatorDefault, IOOptionBits(kIOHIDOptionsTypeNone))
+ }
+
+ deinit { stop() }
+
+ public func start() {
+ guard !isStarted else { return }
+ isStarted = true
+
+ let matching: [String: Any] = [
+ kIOHIDVendorIDKey: 76,
+ kIOHIDProductIDKey: 621,
+ ]
+ let context = Unmanaged.passUnretained(self).toOpaque()
+ IOHIDManagerSetDeviceMatching(manager, matching as CFDictionary)
+ IOHIDManagerRegisterDeviceMatchingCallback(manager, Self.deviceMatched, context)
+ IOHIDManagerRegisterDeviceRemovalCallback(manager, Self.deviceRemoved, context)
+ IOHIDManagerRegisterInputValueCallback(manager, Self.inputValue, context)
+ IOHIDManagerScheduleWithRunLoop(manager, CFRunLoopGetMain(), CFRunLoopMode.commonModes.rawValue)
+ IOHIDManagerOpen(manager, IOOptionBits(kIOHIDOptionsTypeNone))
+ }
+
+ public func stop() {
+ guard isStarted else { return }
+ isStarted = false
+ IOHIDManagerUnscheduleFromRunLoop(manager, CFRunLoopGetMain(), CFRunLoopMode.commonModes.rawValue)
+ IOHIDManagerClose(manager, IOOptionBits(kIOHIDOptionsTypeNone))
+ reportBuffers.removeAll()
+ }
+
+ private func didMatch(_ device: IOHIDDevice) {
+ let key = Self.key(for: device)
+ guard reportBuffers[key] == nil else { return }
+ let property = IOHIDDeviceGetProperty(device, kIOHIDMaxInputReportSizeKey as CFString)
+ let advertised = (property as? NSNumber)?.intValue ?? 128
+ let buffer = ReportBuffer(capacity: max(advertised, 128))
+ reportBuffers[key] = buffer
+ IOHIDDeviceRegisterInputReportCallback(
+ device,
+ buffer.bytes,
+ buffer.capacity,
+ Self.inputReport,
+ Unmanaged.passUnretained(self).toOpaque()
+ )
+ handler(.connected)
+ }
+
+ private func didRemove(_ device: IOHIDDevice) {
+ reportBuffers.removeValue(forKey: Self.key(for: device))
+ if reportBuffers.isEmpty { handler(.disconnected) }
+ }
+
+ private func didReceiveReport(bytes: UnsafeMutablePointer, length: CFIndex) {
+ guard length > 0 else { return }
+ let data = Data(bytes: bytes, count: length)
+ if let frame = SiriRemoteReportDecoder.decode(data) {
+ handler(.frame(frame))
+ }
+ }
+
+ private func didReceiveValue(_ value: IOHIDValue) {
+ let element = IOHIDValueGetElement(value)
+ handler(.element(SiriRemoteElementValue(
+ usagePage: IOHIDElementGetUsagePage(element),
+ usage: IOHIDElementGetUsage(element),
+ value: IOHIDValueGetIntegerValue(value),
+ logicalMinimum: IOHIDElementGetLogicalMin(element),
+ logicalMaximum: IOHIDElementGetLogicalMax(element)
+ )))
+ }
+
+ private static func key(for device: IOHIDDevice) -> Int {
+ Int(bitPattern: Unmanaged.passUnretained(device).toOpaque())
+ }
+
+ private static let deviceMatched: IOHIDDeviceCallback = { context, _, _, device in
+ guard let context else { return }
+ Unmanaged.fromOpaque(context).takeUnretainedValue().didMatch(device)
+ }
+
+ private static let deviceRemoved: IOHIDDeviceCallback = { context, _, _, device in
+ guard let context else { return }
+ Unmanaged.fromOpaque(context).takeUnretainedValue().didRemove(device)
+ }
+
+ private static let inputValue: IOHIDValueCallback = { context, _, _, value in
+ guard let context else { return }
+ Unmanaged.fromOpaque(context).takeUnretainedValue().didReceiveValue(value)
+ }
+
+ private static let inputReport: IOHIDReportCallback = { context, _, _, _, _, bytes, length in
+ guard let context else { return }
+ Unmanaged.fromOpaque(context).takeUnretainedValue()
+ .didReceiveReport(bytes: bytes, length: length)
+ }
+}
diff --git a/Sources/RemoMouseHardware/SiriRemoteReportDecoder.swift b/Sources/RemoMouseHardware/SiriRemoteReportDecoder.swift
new file mode 100644
index 0000000..24738bc
--- /dev/null
+++ b/Sources/RemoMouseHardware/SiriRemoteReportDecoder.swift
@@ -0,0 +1,48 @@
+import Foundation
+
+public struct SiriRemoteTouch: Equatable, Sendable {
+ public let x: Double
+ public let y: Double
+ public let pressure: UInt8
+
+ public var isContact: Bool { pressure > 0 }
+}
+
+public struct SiriRemoteFrame: Equatable, Sendable {
+ public let buttonMask: UInt8
+ public let touch: SiriRemoteTouch?
+}
+
+public enum SiriRemoteReportDecoder {
+ public static func decode(_ data: Data) -> SiriRemoteFrame? {
+ let bytes = Array(data)
+ guard !bytes.isEmpty else { return nil }
+
+ if bytes.count == 2, bytes[0] == 0xFA {
+ return SiriRemoteFrame(buttonMask: bytes[1], touch: nil)
+ }
+
+ let offset: Int
+ if bytes.count >= 13, bytes[2] == 50 {
+ offset = 0
+ } else if bytes.count >= 14, bytes[3] == 50 {
+ offset = 1
+ } else {
+ return nil
+ }
+
+ let buttonMask = bytes[offset + 1]
+ let finger = offset + 6
+ let x = Double(Int(bytes[finger]) + 255 * Int(bytes[finger + 1] & 0x07) - 230) / 15
+ let wrappedY = (bytes[finger + 2] & 0x80) != 0
+ ? Int(bytes[finger + 2])
+ : Int(bytes[finger + 2]) + 255
+ let y = Double(wrappedY - 188)
+ let pressure = bytes[finger + 5]
+
+ return SiriRemoteFrame(
+ buttonMask: buttonMask,
+ touch: SiriRemoteTouch(x: x, y: y, pressure: pressure)
+ )
+ }
+}
diff --git a/Tests/RemoMouseHardwareTests/SiriRemoteReportDecoderTests.swift b/Tests/RemoMouseHardwareTests/SiriRemoteReportDecoderTests.swift
new file mode 100644
index 0000000..df9fb4a
--- /dev/null
+++ b/Tests/RemoMouseHardwareTests/SiriRemoteReportDecoderTests.swift
@@ -0,0 +1,28 @@
+import Foundation
+import Testing
+@testable import RemoMouseHardware
+
+@Test func decodesFirstGenerationButtonReport() {
+ let frame = SiriRemoteReportDecoder.decode(Data([0xFA, 0xA8]))
+
+ #expect(frame?.buttonMask == 0xA8)
+ #expect(frame?.touch == nil)
+}
+
+@Test func decodesTouchReportWithContact() {
+ let bytes: [UInt8] = [
+ 1, 0x80, 50, 0, 0, 0,
+ 245, 1, 210, 0, 0, 12, 0,
+ ]
+
+ let frame = SiriRemoteReportDecoder.decode(Data(bytes))
+
+ #expect(frame?.buttonMask == 0x80)
+ #expect(frame?.touch?.x == 18)
+ #expect(frame?.touch?.y == 22)
+ #expect(frame?.touch?.isContact == true)
+}
+
+@Test func rejectsUnrelatedReports() {
+ #expect(SiriRemoteReportDecoder.decode(Data([1, 2, 3])) == nil)
+}
From cde4893f18001261cc0fa3e0d2a4fcd75ceb42fe Mon Sep 17 00:00:00 2001
From: kefrulz <9220848+kefrulz@users.noreply.github.com>
Date: Sun, 9 Aug 2026 22:14:19 +0200
Subject: [PATCH 10/20] Design ergonomic Air Pointer mode
---
.../specs/2026-08-09-air-pointer-design.md | 68 +++++++++++++++++++
1 file changed, 68 insertions(+)
create mode 100644 docs/superpowers/specs/2026-08-09-air-pointer-design.md
diff --git a/docs/superpowers/specs/2026-08-09-air-pointer-design.md b/docs/superpowers/specs/2026-08-09-air-pointer-design.md
new file mode 100644
index 0000000..d98fb63
--- /dev/null
+++ b/docs/superpowers/specs/2026-08-09-air-pointer-design.md
@@ -0,0 +1,68 @@
+# Air Pointer and Ergonomic Scrolling Design
+
+## Goal
+
+Add an optional LG Magic Remote-style Air Pointer while keeping one-handed thumb input precise for pointer movement, clicking, dragging, and scrolling.
+
+## Interaction model
+
+RemoMouse has two explicit modes selected with a short Play/Pause press.
+
+### Trackpad mode
+
+- One-thumb touch moves the pointer.
+- A physical touch-surface press performs primary click and drag.
+- Menu performs secondary click.
+- A vertical gesture beginning in the rightmost 18 percent of the touch surface scrolls vertically after 24 points of dominant vertical intent.
+- A two-finger gesture scrolls from any part of the surface when the hardware reports two contacts.
+- A gesture that begins outside the edge region remains pointer movement until the finger lifts.
+
+### Air Pointer mode
+
+- Gyroscope rotation moves the pointer using relative rate control.
+- The complete touch surface performs precision scrolling, leaving the thumb free for scrolling without changing modes.
+- A physical touch-surface press performs primary click.
+- Menu performs secondary click.
+- Play/Pause returns to Trackpad mode.
+
+Volume Up and Volume Down adjust the active pointer sensitivity. Home/TV opens Mission Control. Siri pauses or resumes all RemoMouse output in either mode.
+
+## Motion processing
+
+Air Pointer consumes timestamped rotation samples from the first-generation remote's macOS sensor interface. Each axis passes through calibration bias subtraction, a dead zone, exponential smoothing, implausible-jump rejection, and an acceleration curve. Rotation around the remote's vertical and horizontal axes maps to relative screen movement; roll is ignored by default.
+
+The first 500 milliseconds of steady samples after entering Air Pointer mode establish the neutral bias. Slow rotation provides pixel-level movement, while deliberate fast rotation traverses a display. When rotation remains inside the dead zone for 350 milliseconds, the pointer freezes and the bias adapts slowly to cancel thermal or hand drift. New deliberate rotation exits idle immediately.
+
+If macOS does not expose usable sensor samples, Air Pointer remains unavailable, the app stays in Trackpad mode, and the menu explains that motion input was not detected. Missing motion must never impair buttons or touch input.
+
+## Touch filtering and click stability
+
+Scrolling uses a contact-lifetime axis lock, dead-zone rejection, exponential smoothing, and fractional pixel accumulation. Trackpad edge scrolling activates only when the gesture begins in the right-edge region and vertical travel dominates horizontal travel. Opposite-direction input cancels accumulated momentum immediately.
+
+Physical click-down freezes touch-derived movement for 120 milliseconds while continuing to update the coordinate baseline. After that interval, continued contact can begin a drag without a cursor jump. Releasing the click clears the guard.
+
+## Architecture
+
+- `SiriRemoteMotionMonitor` reads and timestamps remote sensor input without generating macOS events.
+- `SiriRemoteAirPointerFilter` performs calibration, drift compensation, smoothing, and rate-to-pointer conversion as a pure testable type.
+- `SiriRemoteTouchIntentFilter` owns right-edge eligibility and contact-lifetime pointer/scroll intent.
+- `PointerController` remains the only component that posts CoreGraphics mouse and scroll events.
+- `RemoMouseModel` owns the explicit Trackpad/Air Pointer mode and hides Air Pointer when motion has not been observed.
+
+Motion decoding stays isolated from the touch and consumer-button transports. It may use a hardware-derived compatibility decoder when macOS publishes opaque sensor reports, but it must not seize unrelated sensors or built-in Mac hardware.
+
+## Safety and recovery
+
+- Pause, disconnect, remote sleep, app termination, or mode change releases held mouse state and clears filter history.
+- Stale motion samples older than 100 milliseconds are discarded.
+- Samples outside validated physical ranges are ignored and counted only in local diagnostics.
+- Waking the remote retries touch and motion discovery without restarting the app.
+- No sensor, touch, or diagnostic data leaves the Mac.
+
+## Verification
+
+Automated tests cover calibration, dead zone, steady-state drift, slow and fast movement, stale samples, jump rejection, edge-scroll activation, axis locking, click stabilization, mode transitions, and forced release. Hardware verification covers Air Pointer entry/exit, drift while stationary, slow targeting, full-screen travel, scrolling while aiming, click stability, drag, sleep/wake, and loss of motion input.
+
+## Release boundary
+
+Air Pointer ships in the public beta only after the target A1513/A1962 remote produces a validated motion trace and the hardware checks show no uncontrolled pointer movement. If that validation is incomplete, v0.1.0 ships Trackpad mode and tracks Air Pointer in a public issue rather than exposing an unreliable toggle.
From b6e2eb34b788ae149af56841e5af08bd55d9eb08 Mon Sep 17 00:00:00 2001
From: kefrulz <9220848+kefrulz@users.noreply.github.com>
Date: Sun, 9 Aug 2026 22:19:13 +0200
Subject: [PATCH 11/20] Plan ergonomic scrolling and motion discovery
---
...08-09-ergonomic-scroll-motion-discovery.md | 224 ++++++++++++++++++
1 file changed, 224 insertions(+)
create mode 100644 docs/superpowers/plans/2026-08-09-ergonomic-scroll-motion-discovery.md
diff --git a/docs/superpowers/plans/2026-08-09-ergonomic-scroll-motion-discovery.md b/docs/superpowers/plans/2026-08-09-ergonomic-scroll-motion-discovery.md
new file mode 100644
index 0000000..28e6065
--- /dev/null
+++ b/docs/superpowers/plans/2026-08-09-ergonomic-scroll-motion-discovery.md
@@ -0,0 +1,224 @@
+# Ergonomic Scroll and Motion Discovery Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Add reliable one-thumb right-edge scrolling and capture a sanitized first-generation Siri Remote sensor trace that makes the Air Pointer decoder concrete and testable.
+
+**Architecture:** A pure touch-intent filter decides pointer versus scroll for the lifetime of each contact. A separate passive sensor monitor captures only the remote's HID sensor interfaces and never generates cursor output; decoded Air Pointer behavior follows in a second plan after the target hardware trace establishes report layout and scale.
+
+**Tech Stack:** Swift 6.2, Swift Testing, IOKit HID, SwiftUI, CoreGraphics, macOS Tahoe 26.
+
+## Global Constraints
+
+- Match only Apple vendor ID 76 and product ID 621.
+- Do not seize sensor interfaces or built-in Mac hardware.
+- Keep raw motion captures local and redact device identity before any export.
+- Do not expose Air Pointer mode until a validated motion trace exists.
+- Preserve click stabilization, fractional scroll accumulation, and forced mouse-state release.
+
+---
+
+### Task 1: Contact-Lifetime Touch Intent
+
+**Files:**
+- Create: `Sources/RemoMouseHardware/SiriRemoteTouchIntentFilter.swift`
+- Modify: `Tests/RemoMouseHardwareTests/SiriRemoteReportDecoderTests.swift`
+
+**Interfaces:**
+- Consumes: normalized touch coordinates in the closed range `0...1`.
+- Produces: `SiriRemoteTouchIntentFilter.update(x:y:isContact:) -> SiriRemoteTouchIntent` where intent is `.pointer`, `.verticalScroll`, or `.ended`.
+
+- [ ] **Step 1: Write the failing intent tests**
+
+```swift
+@Test func rightEdgeVerticalGestureLocksToScrollForContactLifetime() {
+ var filter = SiriRemoteTouchIntentFilter(edgeWidth: 0.18, activationDistance: 0.024)
+ #expect(filter.update(x: 0.90, y: 0.20, isContact: true) == .pointer)
+ #expect(filter.update(x: 0.91, y: 0.25, isContact: true) == .verticalScroll)
+ #expect(filter.update(x: 0.70, y: 0.30, isContact: true) == .verticalScroll)
+ #expect(filter.update(x: 0.70, y: 0.30, isContact: false) == .ended)
+}
+
+@Test func centerGestureNeverBecomesEdgeScroll() {
+ var filter = SiriRemoteTouchIntentFilter(edgeWidth: 0.18, activationDistance: 0.024)
+ _ = filter.update(x: 0.50, y: 0.20, isContact: true)
+ #expect(filter.update(x: 0.50, y: 0.40, isContact: true) == .pointer)
+}
+```
+
+- [ ] **Step 2: Run the tests and verify RED**
+
+Run: `swift test --filter 'rightEdgeVerticalGesture|centerGestureNever'`
+
+Expected: compilation fails because `SiriRemoteTouchIntentFilter` does not exist.
+
+- [ ] **Step 3: Implement the pure state machine**
+
+```swift
+public enum SiriRemoteTouchIntent: Equatable, Sendable {
+ case pointer
+ case verticalScroll
+ case ended
+}
+
+public struct SiriRemoteTouchIntentFilter: Sendable {
+ public init(edgeWidth: Double = 0.18, activationDistance: Double = 0.024)
+ public mutating func update(x: Double, y: Double, isContact: Bool) -> SiriRemoteTouchIntent
+ public mutating func reset()
+}
+```
+
+The first contact records its origin and edge eligibility. Scroll locks only when vertical displacement exceeds `activationDistance` and is at least 1.5 times horizontal displacement. An ineligible contact always remains `.pointer`.
+
+- [ ] **Step 4: Verify GREEN**
+
+Run: `swift test --filter 'rightEdgeVerticalGesture|centerGestureNever'`
+
+Expected: both tests pass.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add Sources/RemoMouseHardware/SiriRemoteTouchIntentFilter.swift Tests/RemoMouseHardwareTests/SiriRemoteReportDecoderTests.swift
+git commit -m "Add one-thumb edge scroll intent"
+```
+
+### Task 2: Integrate Edge Scroll and Preserve Click Stability
+
+**Files:**
+- Modify: `Sources/RemoMouseApp/PointerController.swift`
+- Modify: `Sources/RemoMouseApp/RemoMouseModel.swift`
+- Modify: `README.md`
+
+**Interfaces:**
+- Consumes: `SiriRemoteMultitouchSample` and `SiriRemoteTouchIntentFilter`.
+- Produces: pointer output for center contacts and filtered vertical scroll for right-edge contacts.
+
+- [ ] **Step 1: Route each touch frame through the intent filter**
+
+In `PointerController.handle(_:mode:)`, update the filter before calculating output. Use scroll output when the mode is `.scroll`, the touch count is greater than one, or the contact intent is `.verticalScroll`. Reset the intent and scroll filters on contact end, mode change, pause, and disconnect.
+
+- [ ] **Step 2: Keep click stabilization ahead of both output routes**
+
+When `SiriRemoteClickGuard.suppressesMotion` is true, update `lastMultitouch` but emit neither pointer nor scroll output. After 120 milliseconds, permit drag movement from the updated baseline.
+
+- [ ] **Step 3: Update user documentation**
+
+Document right-edge one-thumb scrolling in the default controls and installation guide. Preserve two-finger scrolling as an optional whole-surface path.
+
+- [ ] **Step 4: Verify**
+
+Run: `swift test --parallel && Scripts/package-app.sh debug && codesign --verify --deep --strict .build/RemoMouse.app`
+
+Expected: all tests pass and the app signature verifies.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add Sources/RemoMouseApp/PointerController.swift Sources/RemoMouseApp/RemoMouseModel.swift README.md docs/INSTALLATION.md
+git commit -m "Polish thumb scrolling and click stability"
+```
+
+### Task 3: Passive Remote Sensor Monitor
+
+**Files:**
+- Create: `Sources/RemoMouseHardware/SiriRemoteMotionMonitor.swift`
+- Create: `Sources/RemoMouseHardware/SiriRemoteMotionCapture.swift`
+- Create: `Tests/RemoMouseHardwareTests/SiriRemoteMotionCaptureTests.swift`
+
+**Interfaces:**
+- Produces: `SiriRemoteRawMotionReport(reportID:bytes:timestampNanoseconds:)` and a bounded local JSON-lines capture.
+- Matches: vendor 76, product 621, primary usage page 32; no seize option.
+
+- [ ] **Step 1: Write failing capture-bound tests**
+
+```swift
+@Test func motionCaptureStopsAtReportAndByteLimits() {
+ var budget = SiriRemoteMotionCaptureBudget(maxReports: 2, maxBytes: 8)
+ #expect(budget.record(byteCount: 4) == .accepted)
+ #expect(budget.record(byteCount: 4) == .accepted)
+ #expect(budget.record(byteCount: 1) == .limitReached)
+ #expect(budget.record(byteCount: 1) == .ignored)
+}
+```
+
+- [ ] **Step 2: Verify RED**
+
+Run: `swift test --filter motionCaptureStopsAtReportAndByteLimits`
+
+Expected: compilation fails because the budget type does not exist.
+
+- [ ] **Step 3: Implement the budget and report model**
+
+```swift
+public struct SiriRemoteRawMotionReport: Sendable {
+ public let reportID: UInt32
+ public let bytes: Data
+ public let timestampNanoseconds: UInt64
+}
+
+public struct SiriRemoteMotionCaptureBudget: Sendable {
+ public enum Decision: Equatable, Sendable { case accepted, limitReached, ignored }
+ public init(maxReports: Int = 5_000, maxBytes: Int = 8 * 1_024 * 1_024)
+ public mutating func record(byteCount: Int) -> Decision
+}
+```
+
+- [ ] **Step 4: Implement passive IOKit monitoring**
+
+Create an `IOHIDManager` matching vendor 76, product 621, primary usage page 32. Register device matching, removal, and input-report callbacks. Open with `kIOHIDOptionsTypeNone`, allocate buffers from `kIOHIDMaxInputReportSizeKey`, timestamp at callback receipt, and expose an `AsyncStream`.
+
+- [ ] **Step 5: Verify GREEN and full suite**
+
+Run: `swift test --parallel`
+
+Expected: all tests pass.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add Sources/RemoMouseHardware/SiriRemoteMotionMonitor.swift Sources/RemoMouseHardware/SiriRemoteMotionCapture.swift Tests/RemoMouseHardwareTests/SiriRemoteMotionCaptureTests.swift
+git commit -m "Capture Siri Remote motion reports"
+```
+
+### Task 4: Controlled Hardware Motion Trace
+
+**Files:**
+- Modify: `Sources/RemoMouseApp/RemoMouseModel.swift`
+- Modify: `Sources/RemoMouseApp/RemoMouseApp.swift`
+- Create: `docs/hardware/first-generation-motion-report.md`
+
+**Interfaces:**
+- Consumes: `SiriRemoteMotionMonitor` reports.
+- Produces: one local raw capture and one sanitized Markdown capability summary.
+
+- [ ] **Step 1: Add a diagnostics-only motion capture action**
+
+The menu action starts a 20-second bounded capture with this script: hold still 3 seconds; yaw right then left; pitch up then down; roll clockwise then counterclockwise; hold still 3 seconds. Show elapsed time and report count. Raw bytes remain under Application Support.
+
+- [ ] **Step 2: Run the target hardware session**
+
+Run the packaged app, wake the A1513/A1962 remote, perform the on-screen script once, and stop capture automatically at 20 seconds.
+
+- [ ] **Step 3: Analyze report structure**
+
+Confirm whether at least two sensor axes vary monotonically with the controlled rotations, identify report IDs and stable byte ranges, and calculate sample cadence. Do not infer axis scale from uncontrolled motion.
+
+- [ ] **Step 4: Write the sanitized capability result**
+
+Record only macOS version, remote generation, report IDs, lengths, cadence, identified axes, and a SHA-256 digest of the local trace. Exclude raw bytes, serial, Bluetooth address, username, and paths.
+
+- [ ] **Step 5: Apply the release gate**
+
+If two usable rotation axes and a stable neutral region are validated, create the concrete Air Pointer decoder plan using the sanitized fixture. Otherwise keep Air Pointer absent from v0.1.0 and create a public issue containing the documented compatibility blocker.
+
+- [ ] **Step 6: Verify privacy and commit**
+
+Run: `rg -n '/Users/[^/ ]+|([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}|([0-9A-Fa-f]{2}-){5}[0-9A-Fa-f]{2}' docs/hardware/first-generation-motion-report.md; test $? -eq 1`
+
+Then:
+
+```bash
+git add Sources/RemoMouseApp/RemoMouseModel.swift Sources/RemoMouseApp/RemoMouseApp.swift docs/hardware/first-generation-motion-report.md
+git commit -m "Validate Siri Remote motion input"
+```
From e73487b06ad4ec49a6ad49290cdfdd0cd00b795d Mon Sep 17 00:00:00 2001
From: kefrulz <9220848+kefrulz@users.noreply.github.com>
Date: Sun, 9 Aug 2026 22:20:35 +0200
Subject: [PATCH 12/20] Enable multitouch and ergonomic controls
---
Package.swift | 6 +-
Sources/CMultitouchBridge/CMultitouchBridge.c | 109 ++++++++++++++++++
.../include/CMultitouchBridge.h | 13 +++
Sources/RemoMouseApp/PointerController.swift | 72 ++++++++++++
Sources/RemoMouseApp/RemoMouseApp.swift | 2 +
Sources/RemoMouseApp/RemoMouseModel.swift | 43 +++++++
.../SiriRemoteButtonInterpreter.swift | 24 ++++
.../SiriRemoteHIDMonitor.swift | 4 +-
.../SiriRemoteInputFilters.swift | 80 +++++++++++++
.../SiriRemoteMultitouchMonitor.swift | 39 +++++++
.../SiriRemoteTouchIntentFilter.swift | 56 +++++++++
.../SiriRemoteReportDecoderTests.swift | 59 ++++++++++
12 files changed, 505 insertions(+), 2 deletions(-)
create mode 100644 Sources/CMultitouchBridge/CMultitouchBridge.c
create mode 100644 Sources/CMultitouchBridge/include/CMultitouchBridge.h
create mode 100644 Sources/RemoMouseHardware/SiriRemoteButtonInterpreter.swift
create mode 100644 Sources/RemoMouseHardware/SiriRemoteInputFilters.swift
create mode 100644 Sources/RemoMouseHardware/SiriRemoteMultitouchMonitor.swift
create mode 100644 Sources/RemoMouseHardware/SiriRemoteTouchIntentFilter.swift
diff --git a/Package.swift b/Package.swift
index 65df259..03ee02a 100644
--- a/Package.swift
+++ b/Package.swift
@@ -19,13 +19,17 @@ let package = Package(
),
.target(
name: "RemoMouseHardware",
- dependencies: ["RemoMouseDomain"],
+ dependencies: ["RemoMouseDomain", "CMultitouchBridge"],
linkerSettings: [
.linkedFramework("CoreHID"),
.linkedFramework("GameController"),
.linkedFramework("IOKit"),
]
),
+ .target(
+ name: "CMultitouchBridge",
+ linkerSettings: [.linkedFramework("IOKit")]
+ ),
.target(
name: "RemoMouseCapture",
dependencies: ["RemoMouseDomain", "RemoMouseHardware"],
diff --git a/Sources/CMultitouchBridge/CMultitouchBridge.c b/Sources/CMultitouchBridge/CMultitouchBridge.c
new file mode 100644
index 0000000..75c1739
--- /dev/null
+++ b/Sources/CMultitouchBridge/CMultitouchBridge.c
@@ -0,0 +1,109 @@
+#include "CMultitouchBridge.h"
+#include
+#include
+#include
+
+typedef const void *MTDeviceRef;
+
+typedef struct { float x; float y; } RMPoint;
+typedef struct { RMPoint position; RMPoint velocity; } RMVector;
+typedef struct {
+ int32_t frame;
+ double timestamp;
+ int32_t pathIndex;
+ uint32_t state;
+ int32_t fingerID;
+ int32_t handID;
+ RMVector normalizedVector;
+ float pressure;
+ int32_t reserved1;
+ float angle;
+ float majorAxis;
+ float minorAxis;
+ RMVector absoluteVector;
+ int32_t reserved2;
+ int32_t reserved3;
+ float density;
+} RMTouch;
+
+typedef CFArrayRef (*CreateDeviceListFunction)(void);
+typedef io_service_t (*GetDeviceServiceFunction)(MTDeviceRef);
+typedef void (*FrameFunction)(MTDeviceRef, RMTouch *, size_t, double, size_t, void *);
+typedef void (*RegisterFunction)(MTDeviceRef, FrameFunction, void *);
+typedef int32_t (*StartFunction)(MTDeviceRef, int32_t);
+
+static RMTouchCallback outputCallback = NULL;
+static void *outputContext = NULL;
+static CFMutableArrayRef retainedDevices = NULL;
+static bool remoteStarted = false;
+
+static bool numberPropertyEquals(io_service_t service, CFStringRef key, int expected) {
+ CFTypeRef property = IORegistryEntrySearchCFProperty(
+ service, kIOServicePlane, key, kCFAllocatorDefault,
+ kIORegistryIterateRecursively | kIORegistryIterateParents
+ );
+ if (!property || CFGetTypeID(property) != CFNumberGetTypeID()) {
+ if (property) CFRelease(property);
+ return false;
+ }
+ int value = 0;
+ CFNumberGetValue((CFNumberRef)property, kCFNumberIntType, &value);
+ CFRelease(property);
+ return value == expected;
+}
+
+static void receiveFrame(MTDeviceRef device, RMTouch *touches, size_t count,
+ double timestamp, size_t frame, void *context) {
+ (void)device; (void)timestamp; (void)frame; (void)context;
+ if (!outputCallback) return;
+ if (count == 0 || !touches) {
+ outputCallback(0, 0, 0, 0, 0, outputContext);
+ return;
+ }
+ RMTouch touch = touches[0];
+ outputCallback(touch.normalizedVector.position.x,
+ touch.normalizedVector.position.y,
+ touch.pressure, touch.state, count, outputContext);
+}
+
+bool RMStartRemoteTouch(RMTouchCallback callback, void *context) {
+ outputCallback = callback;
+ outputContext = context;
+ if (remoteStarted) return true;
+
+ void *framework = dlopen(
+ "/System/Library/PrivateFrameworks/MultitouchSupport.framework/MultitouchSupport",
+ RTLD_NOW | RTLD_LOCAL
+ );
+ if (!framework) return false;
+
+ CreateDeviceListFunction createList = (CreateDeviceListFunction)dlsym(framework, "MTDeviceCreateList");
+ GetDeviceServiceFunction getService = (GetDeviceServiceFunction)dlsym(framework, "MTDeviceGetService");
+ RegisterFunction registerFrame = (RegisterFunction)dlsym(framework, "MTRegisterContactFrameCallbackWithRefcon");
+ StartFunction startDevice = (StartFunction)dlsym(framework, "MTDeviceStart");
+ if (!createList || !getService || !registerFrame || !startDevice) return false;
+
+ CFArrayRef devices = createList();
+ if (!devices) return false;
+ if (retainedDevices) CFRelease(retainedDevices);
+ retainedDevices = CFArrayCreateMutable(kCFAllocatorDefault, 0, &kCFTypeArrayCallBacks);
+
+ bool started = false;
+ CFIndex count = CFArrayGetCount(devices);
+ for (CFIndex index = 0; index < count; index++) {
+ MTDeviceRef device = CFArrayGetValueAtIndex(devices, index);
+ io_service_t service = getService(device);
+ if (!service) continue;
+ bool vendorMatches = numberPropertyEquals(service, CFSTR("VendorID"), 76);
+ bool productMatches = numberPropertyEquals(service, CFSTR("ProductID"), 621);
+ if (!vendorMatches || !productMatches) continue;
+
+ CFArrayAppendValue(retainedDevices, device);
+ registerFrame(device, receiveFrame, context);
+ int32_t status = startDevice(device, 0);
+ if (status == 0) started = true;
+ }
+ CFRelease(devices);
+ remoteStarted = started;
+ return started;
+}
diff --git a/Sources/CMultitouchBridge/include/CMultitouchBridge.h b/Sources/CMultitouchBridge/include/CMultitouchBridge.h
new file mode 100644
index 0000000..0c85f2f
--- /dev/null
+++ b/Sources/CMultitouchBridge/include/CMultitouchBridge.h
@@ -0,0 +1,13 @@
+#ifndef CMultitouchBridge_h
+#define CMultitouchBridge_h
+
+#include
+#include
+#include
+
+typedef void (*RMTouchCallback)(float x, float y, float pressure, uint32_t state,
+ size_t touchCount, void *context);
+
+bool RMStartRemoteTouch(RMTouchCallback callback, void *context);
+
+#endif
diff --git a/Sources/RemoMouseApp/PointerController.swift b/Sources/RemoMouseApp/PointerController.swift
index 5b0b170..56735c6 100644
--- a/Sources/RemoMouseApp/PointerController.swift
+++ b/Sources/RemoMouseApp/PointerController.swift
@@ -2,13 +2,21 @@ import AppKit
import ApplicationServices
import RemoMouseHardware
+enum RemoMouseInputMode: String {
+ case pointer = "Pointer"
+ case scroll = "Scroll"
+}
+
@MainActor
final class PointerController {
var speed = 7.0
private var lastTouch: SiriRemoteTouch?
private var lastX: Double?
private var lastY: Double?
+ private var lastMultitouch: SiriRemoteMultitouchSample?
private var lastButtonMask: UInt8 = 0
+ private var scrollFilter = SiriRemoteScrollFilter()
+ private var clickGuard = SiriRemoteClickGuard()
var isTrusted: Bool { AXIsProcessTrusted() }
@@ -39,6 +47,47 @@ final class PointerController {
}
}
+ func handle(_ sample: SiriRemoteMultitouchSample, mode: RemoMouseInputMode) {
+ guard sample.isContact else {
+ lastMultitouch = nil
+ scrollFilter.reset()
+ return
+ }
+ guard let prior = lastMultitouch else {
+ lastMultitouch = sample
+ return
+ }
+
+ let dx = sample.x - prior.x
+ let dy = sample.y - prior.y
+ guard abs(dx) < 0.35, abs(dy) < 0.35 else {
+ lastMultitouch = sample
+ return
+ }
+
+ if clickGuard.suppressesMotion(timestamp: ProcessInfo.processInfo.systemUptime) {
+ lastMultitouch = sample
+ return
+ }
+
+ if sample.touchCount > 1 || mode == .scroll {
+ let pixels = scrollFilter.update(dx: dx, dy: dy, scale: speed * 28)
+ scroll(pixels)
+ } else {
+ let distance = hypot(dx, dy)
+ let acceleration = 1 + min(distance * 14, 2.5)
+ move(dx: dx * speed * 120 * acceleration,
+ dy: -dy * speed * 120 * acceleration)
+ }
+ lastMultitouch = sample
+ }
+
+ func showMissionControl() {
+ guard isTrusted else { return }
+ CGEvent(keyboardEventSource: nil, virtualKey: 160, keyDown: true)?.post(tap: .cghidEventTap)
+ CGEvent(keyboardEventSource: nil, virtualKey: 160, keyDown: false)?.post(tap: .cghidEventTap)
+ }
+
func releaseAll() {
if lastButtonMask & 0x80 != 0 { postMouse(.leftMouseUp, button: .left) }
if lastButtonMask & 0x20 != 0 { postMouse(.rightMouseUp, button: .right) }
@@ -46,6 +95,9 @@ final class PointerController {
lastTouch = nil
lastX = nil
lastY = nil
+ lastMultitouch = nil
+ scrollFilter.reset()
+ clickGuard.setPressed(false, timestamp: ProcessInfo.processInfo.systemUptime)
}
private func handleTouch(_ touch: SiriRemoteTouch) {
@@ -70,6 +122,10 @@ final class PointerController {
private func updateButtons(_ mask: UInt8) {
let changed = mask ^ lastButtonMask
if changed & 0x80 != 0 {
+ clickGuard.setPressed(
+ mask & 0x80 != 0,
+ timestamp: ProcessInfo.processInfo.systemUptime
+ )
postMouse(mask & 0x80 != 0 ? .leftMouseDown : .leftMouseUp, button: .left)
}
if changed & 0x20 != 0 {
@@ -101,4 +157,20 @@ final class PointerController {
CGEvent(mouseEventSource: nil, mouseType: type, mouseCursorPosition: position, mouseButton: button)?
.post(tap: .cghidEventTap)
}
+
+
+ private func scroll(_ delta: SiriRemotePixelDelta) {
+ guard isTrusted else { return }
+ let vertical = -delta.y
+ let horizontal = delta.x
+ guard vertical != 0 || horizontal != 0 else { return }
+ CGEvent(
+ scrollWheelEvent2Source: nil,
+ units: .pixel,
+ wheelCount: 2,
+ wheel1: vertical,
+ wheel2: horizontal,
+ wheel3: 0
+ )?.post(tap: .cghidEventTap)
+ }
}
diff --git a/Sources/RemoMouseApp/RemoMouseApp.swift b/Sources/RemoMouseApp/RemoMouseApp.swift
index d0bc97d..f480349 100644
--- a/Sources/RemoMouseApp/RemoMouseApp.swift
+++ b/Sources/RemoMouseApp/RemoMouseApp.swift
@@ -32,6 +32,8 @@ struct RemoMouseApp: App {
.frame(width: 140)
}
+ LabeledContent("Mode", value: model.mode.rawValue)
+
Text(model.lastInput)
.font(.caption)
.foregroundStyle(.secondary)
diff --git a/Sources/RemoMouseApp/RemoMouseModel.swift b/Sources/RemoMouseApp/RemoMouseModel.swift
index 3c425b2..f15813d 100644
--- a/Sources/RemoMouseApp/RemoMouseModel.swift
+++ b/Sources/RemoMouseApp/RemoMouseModel.swift
@@ -13,11 +13,15 @@ final class RemoMouseModel {
private(set) var connection = ConnectionState.looking
var isEnabled = true
+ private(set) var mode = RemoMouseInputMode.pointer
var speed = 7.0 { didSet { pointer.speed = speed } }
private(set) var lastInput = "Waiting for input"
private let pointer = PointerController()
private var monitor: SiriRemoteHIDMonitor?
+ private var multitouchMonitor: SiriRemoteMultitouchMonitor?
+ private var isTouchStarted = false
+ private var buttonInterpreter = SiriRemoteButtonInterpreter()
var hasAccessibility: Bool { pointer.isTrusted }
@@ -26,6 +30,10 @@ final class RemoMouseModel {
Task { @MainActor in self?.handle(event) }
}
monitor?.start()
+ multitouchMonitor = SiriRemoteMultitouchMonitor { [weak self] sample in
+ Task { @MainActor in self?.handle(sample) }
+ }
+ startTouchIfNeeded()
if !pointer.isTrusted {
pointer.requestAccessibility()
}
@@ -48,12 +56,15 @@ final class RemoMouseModel {
switch event {
case .connected:
connection = .connected
+ startTouchIfNeeded()
case .disconnected:
connection = .sleeping
pointer.releaseAll()
case let .frame(frame):
connection = .connected
+ startTouchIfNeeded()
lastInput = "Remote input received"
+ handleCommands(buttonInterpreter.commands(for: frame.buttonMask))
if isEnabled { pointer.handle(frame) }
case let .element(value):
connection = .connected
@@ -61,4 +72,36 @@ final class RemoMouseModel {
if isEnabled { pointer.handle(value) }
}
}
+
+ private func startTouchIfNeeded() {
+ guard !isTouchStarted else { return }
+ isTouchStarted = multitouchMonitor?.start() == true
+ if !isTouchStarted { lastInput = "Wake the remote to enable touch" }
+ }
+
+ private func handle(_ sample: SiriRemoteMultitouchSample) {
+ connection = .connected
+ lastInput = sample.touchCount > 1 ? "Two-finger touch" : "Touch surface active"
+ if isEnabled { pointer.handle(sample, mode: mode) }
+ }
+
+ private func handleCommands(_ commands: [SiriRemoteCommand]) {
+ for command in commands {
+ switch command {
+ case .toggleMode:
+ mode = mode == .pointer ? .scroll : .pointer
+ pointer.releaseAll()
+ lastInput = "\(mode.rawValue) mode"
+ case .toggleEnabled:
+ toggle()
+ lastInput = isEnabled ? "RemoMouse enabled" : "RemoMouse paused"
+ case let .adjustSpeed(delta):
+ speed = min(max(speed + delta, 2), 14)
+ lastInput = "Pointer speed \(Int(speed))"
+ case .missionControl:
+ pointer.showMissionControl()
+ lastInput = "Mission Control"
+ }
+ }
+ }
}
diff --git a/Sources/RemoMouseHardware/SiriRemoteButtonInterpreter.swift b/Sources/RemoMouseHardware/SiriRemoteButtonInterpreter.swift
new file mode 100644
index 0000000..dced189
--- /dev/null
+++ b/Sources/RemoMouseHardware/SiriRemoteButtonInterpreter.swift
@@ -0,0 +1,24 @@
+public enum SiriRemoteCommand: Equatable, Sendable {
+ case toggleMode
+ case toggleEnabled
+ case adjustSpeed(Double)
+ case missionControl
+}
+
+public struct SiriRemoteButtonInterpreter: Sendable {
+ private var previousMask: UInt8 = 0
+
+ public init() {}
+
+ public mutating func commands(for mask: UInt8) -> [SiriRemoteCommand] {
+ let pressed = mask & ~previousMask
+ previousMask = mask
+ var commands: [SiriRemoteCommand] = []
+ if pressed & 0x08 != 0 { commands.append(.toggleMode) }
+ if pressed & 0x10 != 0 { commands.append(.toggleEnabled) }
+ if pressed & 0x02 != 0 { commands.append(.adjustSpeed(1)) }
+ if pressed & 0x04 != 0 { commands.append(.adjustSpeed(-1)) }
+ if pressed & 0x01 != 0 { commands.append(.missionControl) }
+ return commands
+ }
+}
diff --git a/Sources/RemoMouseHardware/SiriRemoteHIDMonitor.swift b/Sources/RemoMouseHardware/SiriRemoteHIDMonitor.swift
index cfb9df6..50c0cdb 100644
--- a/Sources/RemoMouseHardware/SiriRemoteHIDMonitor.swift
+++ b/Sources/RemoMouseHardware/SiriRemoteHIDMonitor.swift
@@ -51,6 +51,8 @@ public final class SiriRemoteHIDMonitor: @unchecked Sendable {
let matching: [String: Any] = [
kIOHIDVendorIDKey: 76,
kIOHIDProductIDKey: 621,
+ kIOHIDPrimaryUsagePageKey: 12,
+ kIOHIDPrimaryUsageKey: 1,
]
let context = Unmanaged.passUnretained(self).toOpaque()
IOHIDManagerSetDeviceMatching(manager, matching as CFDictionary)
@@ -58,7 +60,7 @@ public final class SiriRemoteHIDMonitor: @unchecked Sendable {
IOHIDManagerRegisterDeviceRemovalCallback(manager, Self.deviceRemoved, context)
IOHIDManagerRegisterInputValueCallback(manager, Self.inputValue, context)
IOHIDManagerScheduleWithRunLoop(manager, CFRunLoopGetMain(), CFRunLoopMode.commonModes.rawValue)
- IOHIDManagerOpen(manager, IOOptionBits(kIOHIDOptionsTypeNone))
+ IOHIDManagerOpen(manager, IOOptionBits(kIOHIDOptionsTypeSeizeDevice))
}
public func stop() {
diff --git a/Sources/RemoMouseHardware/SiriRemoteInputFilters.swift b/Sources/RemoMouseHardware/SiriRemoteInputFilters.swift
new file mode 100644
index 0000000..f83f0d7
--- /dev/null
+++ b/Sources/RemoMouseHardware/SiriRemoteInputFilters.swift
@@ -0,0 +1,80 @@
+import Foundation
+
+public struct SiriRemotePixelDelta: Equatable, Sendable {
+ public var x: Int32
+ public var y: Int32
+
+ public init(x: Int32, y: Int32) {
+ self.x = x
+ self.y = y
+ }
+
+ public static let zero = SiriRemotePixelDelta(x: 0, y: 0)
+
+ public static func += (left: inout Self, right: Self) {
+ left.x += right.x
+ left.y += right.y
+ }
+}
+
+public struct SiriRemoteScrollFilter: Sendable {
+ private enum Axis: Sendable { case undecided, horizontal, vertical, free }
+
+ private var axis = Axis.undecided
+ private var smoothedX = 0.0
+ private var smoothedY = 0.0
+ private var residualX = 0.0
+ private var residualY = 0.0
+
+ public init() {}
+
+ public mutating func update(dx: Double, dy: Double, scale: Double) -> SiriRemotePixelDelta {
+ guard hypot(dx, dy) >= 0.0008 else { return .zero }
+
+ if axis == .undecided {
+ if abs(dy) > abs(dx) * 1.5 {
+ axis = .vertical
+ } else if abs(dx) > abs(dy) * 1.5 {
+ axis = .horizontal
+ } else {
+ axis = .free
+ }
+ }
+
+ let filteredX = axis == .vertical ? 0 : dx
+ let filteredY = axis == .horizontal ? 0 : dy
+ let smoothing = 0.32
+ smoothedX += (filteredX - smoothedX) * smoothing
+ smoothedY += (filteredY - smoothedY) * smoothing
+ residualX += smoothedX * scale
+ residualY += smoothedY * scale
+
+ let pixelsX = Int32(residualX)
+ let pixelsY = Int32(residualY)
+ residualX -= Double(pixelsX)
+ residualY -= Double(pixelsY)
+ return SiriRemotePixelDelta(x: pixelsX, y: pixelsY)
+ }
+
+ public mutating func reset() {
+ self = SiriRemoteScrollFilter()
+ }
+}
+
+public struct SiriRemoteClickGuard: Sendable {
+ private let stabilizationInterval: TimeInterval
+ private var suppressUntil: TimeInterval?
+
+ public init(stabilizationInterval: TimeInterval = 0.12) {
+ self.stabilizationInterval = stabilizationInterval
+ }
+
+ public mutating func setPressed(_ pressed: Bool, timestamp: TimeInterval) {
+ suppressUntil = pressed ? timestamp + stabilizationInterval : nil
+ }
+
+ public func suppressesMotion(timestamp: TimeInterval) -> Bool {
+ guard let suppressUntil else { return false }
+ return timestamp < suppressUntil
+ }
+}
diff --git a/Sources/RemoMouseHardware/SiriRemoteMultitouchMonitor.swift b/Sources/RemoMouseHardware/SiriRemoteMultitouchMonitor.swift
new file mode 100644
index 0000000..6627268
--- /dev/null
+++ b/Sources/RemoMouseHardware/SiriRemoteMultitouchMonitor.swift
@@ -0,0 +1,39 @@
+import CMultitouchBridge
+import Foundation
+
+public struct SiriRemoteMultitouchSample: Equatable, Sendable {
+ public let x: Double
+ public let y: Double
+ public let pressure: Double
+ public let state: UInt32
+ public let touchCount: Int
+
+ public var isContact: Bool {
+ touchCount > 0 && state >= 3 && state <= 5
+ }
+}
+
+public final class SiriRemoteMultitouchMonitor: @unchecked Sendable {
+ public typealias Handler = @Sendable (SiriRemoteMultitouchSample) -> Void
+ private let handler: Handler
+
+ public init(handler: @escaping Handler) {
+ self.handler = handler
+ }
+
+ @discardableResult
+ public func start() -> Bool {
+ RMStartRemoteTouch({ x, y, pressure, state, touchCount, context in
+ guard let context else { return }
+ let monitor = Unmanaged
+ .fromOpaque(context).takeUnretainedValue()
+ monitor.handler(SiriRemoteMultitouchSample(
+ x: Double(x),
+ y: Double(y),
+ pressure: Double(pressure),
+ state: state,
+ touchCount: touchCount
+ ))
+ }, Unmanaged.passUnretained(self).toOpaque())
+ }
+}
diff --git a/Sources/RemoMouseHardware/SiriRemoteTouchIntentFilter.swift b/Sources/RemoMouseHardware/SiriRemoteTouchIntentFilter.swift
new file mode 100644
index 0000000..7d67317
--- /dev/null
+++ b/Sources/RemoMouseHardware/SiriRemoteTouchIntentFilter.swift
@@ -0,0 +1,56 @@
+public enum SiriRemoteTouchIntent: Equatable, Sendable {
+ case pointer
+ case verticalScroll
+ case ended
+}
+
+public struct SiriRemoteTouchIntentFilter: Sendable {
+ private enum Lock: Sendable { case undecided, pointer, scroll }
+
+ private let edgeWidth: Double
+ private let activationDistance: Double
+ private var origin: (x: Double, y: Double)?
+ private var isEdgeEligible = false
+ private var lock = Lock.undecided
+
+ public init(edgeWidth: Double = 0.18, activationDistance: Double = 0.024) {
+ self.edgeWidth = edgeWidth
+ self.activationDistance = activationDistance
+ }
+
+ public mutating func update(x: Double, y: Double, isContact: Bool) -> SiriRemoteTouchIntent {
+ guard isContact else {
+ reset()
+ return .ended
+ }
+ guard let origin else {
+ self.origin = (x, y)
+ isEdgeEligible = x >= 1 - edgeWidth
+ return .pointer
+ }
+
+ switch lock {
+ case .pointer: return .pointer
+ case .scroll: return .verticalScroll
+ case .undecided: break
+ }
+
+ guard isEdgeEligible else { return .pointer }
+ let dx = x - origin.x
+ let dy = y - origin.y
+ if abs(dy) >= activationDistance, abs(dy) >= abs(dx) * 1.5 {
+ lock = .scroll
+ return .verticalScroll
+ }
+ if abs(dx) >= activationDistance, abs(dx) > abs(dy) * 1.5 {
+ lock = .pointer
+ }
+ return .pointer
+ }
+
+ public mutating func reset() {
+ origin = nil
+ isEdgeEligible = false
+ lock = .undecided
+ }
+}
diff --git a/Tests/RemoMouseHardwareTests/SiriRemoteReportDecoderTests.swift b/Tests/RemoMouseHardwareTests/SiriRemoteReportDecoderTests.swift
index df9fb4a..e677d45 100644
--- a/Tests/RemoMouseHardwareTests/SiriRemoteReportDecoderTests.swift
+++ b/Tests/RemoMouseHardwareTests/SiriRemoteReportDecoderTests.swift
@@ -26,3 +26,62 @@ import Testing
@Test func rejectsUnrelatedReports() {
#expect(SiriRemoteReportDecoder.decode(Data([1, 2, 3])) == nil)
}
+
+@Test func multitouchContactTracksActiveStates() {
+ #expect(SiriRemoteMultitouchSample(x: 0, y: 0, pressure: 1, state: 4, touchCount: 1).isContact)
+ #expect(!SiriRemoteMultitouchSample(x: 0, y: 0, pressure: 0, state: 0, touchCount: 0).isContact)
+}
+
+@Test func buttonInterpreterEmitsCommandsOnlyOnPressEdges() {
+ var interpreter = SiriRemoteButtonInterpreter()
+
+ #expect(interpreter.commands(for: 0x08) == [.toggleMode])
+ #expect(interpreter.commands(for: 0x08).isEmpty)
+ #expect(interpreter.commands(for: 0).isEmpty)
+ #expect(interpreter.commands(for: 0x12) == [.toggleEnabled, .adjustSpeed(1)])
+}
+
+@Test func scrollFilterRejectsStationaryJitter() {
+ var filter = SiriRemoteScrollFilter()
+
+ for delta in [0.0003, -0.0004, 0.0002, -0.0002] {
+ #expect(filter.update(dx: delta, dy: delta, scale: 240) == .zero)
+ }
+}
+
+@Test func scrollFilterAccumulatesPreciseVerticalMotionWithoutCrossAxisWobble() {
+ var filter = SiriRemoteScrollFilter()
+ var output = SiriRemotePixelDelta.zero
+
+ for _ in 0..<12 {
+ output += filter.update(dx: 0.001, dy: 0.004, scale: 180)
+ }
+
+ #expect(output.x == 0)
+ #expect(output.y > 0)
+ #expect(output.y < 10)
+}
+
+@Test func clickGuardStabilizesPressThenAllowsDrag() {
+ var guardState = SiriRemoteClickGuard(stabilizationInterval: 0.12)
+
+ guardState.setPressed(true, timestamp: 10)
+ #expect(guardState.suppressesMotion(timestamp: 10.08))
+ #expect(!guardState.suppressesMotion(timestamp: 10.13))
+ guardState.setPressed(false, timestamp: 10.14)
+ #expect(!guardState.suppressesMotion(timestamp: 10.15))
+}
+
+@Test func rightEdgeVerticalGestureLocksToScrollForContactLifetime() {
+ var filter = SiriRemoteTouchIntentFilter(edgeWidth: 0.18, activationDistance: 0.024)
+ #expect(filter.update(x: 0.90, y: 0.20, isContact: true) == .pointer)
+ #expect(filter.update(x: 0.91, y: 0.25, isContact: true) == .verticalScroll)
+ #expect(filter.update(x: 0.70, y: 0.30, isContact: true) == .verticalScroll)
+ #expect(filter.update(x: 0.70, y: 0.30, isContact: false) == .ended)
+}
+
+@Test func centerGestureNeverBecomesEdgeScroll() {
+ var filter = SiriRemoteTouchIntentFilter(edgeWidth: 0.18, activationDistance: 0.024)
+ _ = filter.update(x: 0.50, y: 0.20, isContact: true)
+ #expect(filter.update(x: 0.50, y: 0.40, isContact: true) == .pointer)
+}
From c3499398964318b124dd2d9e415be5c4b32f7892 Mon Sep 17 00:00:00 2001
From: kefrulz <9220848+kefrulz@users.noreply.github.com>
Date: Sun, 9 Aug 2026 22:22:40 +0200
Subject: [PATCH 13/20] Add one-thumb edge scrolling
---
Sources/RemoMouseApp/PointerController.swift | 10 +++++++++-
1 file changed, 9 insertions(+), 1 deletion(-)
diff --git a/Sources/RemoMouseApp/PointerController.swift b/Sources/RemoMouseApp/PointerController.swift
index 56735c6..189b204 100644
--- a/Sources/RemoMouseApp/PointerController.swift
+++ b/Sources/RemoMouseApp/PointerController.swift
@@ -17,6 +17,7 @@ final class PointerController {
private var lastButtonMask: UInt8 = 0
private var scrollFilter = SiriRemoteScrollFilter()
private var clickGuard = SiriRemoteClickGuard()
+ private var touchIntentFilter = SiriRemoteTouchIntentFilter()
var isTrusted: Bool { AXIsProcessTrusted() }
@@ -51,8 +52,14 @@ final class PointerController {
guard sample.isContact else {
lastMultitouch = nil
scrollFilter.reset()
+ touchIntentFilter.reset()
return
}
+ let touchIntent = touchIntentFilter.update(
+ x: sample.x,
+ y: sample.y,
+ isContact: true
+ )
guard let prior = lastMultitouch else {
lastMultitouch = sample
return
@@ -70,7 +77,7 @@ final class PointerController {
return
}
- if sample.touchCount > 1 || mode == .scroll {
+ if sample.touchCount > 1 || mode == .scroll || touchIntent == .verticalScroll {
let pixels = scrollFilter.update(dx: dx, dy: dy, scale: speed * 28)
scroll(pixels)
} else {
@@ -97,6 +104,7 @@ final class PointerController {
lastY = nil
lastMultitouch = nil
scrollFilter.reset()
+ touchIntentFilter.reset()
clickGuard.setPressed(false, timestamp: ProcessInfo.processInfo.systemUptime)
}
From 5ad76cd3e4144181ab89ba3d9192cb7ea07b7fd7 Mon Sep 17 00:00:00 2001
From: kefrulz <9220848+kefrulz@users.noreply.github.com>
Date: Sun, 9 Aug 2026 22:25:10 +0200
Subject: [PATCH 14/20] Capture Siri Remote motion reports
---
.../SiriRemoteMotionCapture.swift | 47 ++++++
.../SiriRemoteMotionMonitor.swift | 145 ++++++++++++++++++
.../SiriRemoteMotionCaptureTests.swift | 13 ++
3 files changed, 205 insertions(+)
create mode 100644 Sources/RemoMouseHardware/SiriRemoteMotionCapture.swift
create mode 100644 Sources/RemoMouseHardware/SiriRemoteMotionMonitor.swift
create mode 100644 Tests/RemoMouseHardwareTests/SiriRemoteMotionCaptureTests.swift
diff --git a/Sources/RemoMouseHardware/SiriRemoteMotionCapture.swift b/Sources/RemoMouseHardware/SiriRemoteMotionCapture.swift
new file mode 100644
index 0000000..5794b04
--- /dev/null
+++ b/Sources/RemoMouseHardware/SiriRemoteMotionCapture.swift
@@ -0,0 +1,47 @@
+import Foundation
+
+public struct SiriRemoteRawMotionReport: Sendable {
+ public let reportID: UInt32
+ public let bytes: Data
+ public let timestampNanoseconds: UInt64
+
+ public init(reportID: UInt32, bytes: Data, timestampNanoseconds: UInt64) {
+ self.reportID = reportID
+ self.bytes = bytes
+ self.timestampNanoseconds = timestampNanoseconds
+ }
+}
+
+public struct SiriRemoteMotionCaptureBudget: Sendable {
+ public enum Decision: Equatable, Sendable {
+ case accepted
+ case limitReached
+ case ignored
+ }
+
+ public private(set) var reportCount = 0
+ public private(set) var byteCount = 0
+ private let maxReports: Int
+ private let maxBytes: Int
+ private var didReachLimit = false
+
+ public init(maxReports: Int = 5_000, maxBytes: Int = 8 * 1_024 * 1_024) {
+ self.maxReports = maxReports
+ self.maxBytes = maxBytes
+ }
+
+ public mutating func record(byteCount incomingByteCount: Int) -> Decision {
+ guard !didReachLimit else { return .ignored }
+ guard incomingByteCount >= 0,
+ reportCount < maxReports,
+ incomingByteCount <= maxBytes - byteCount
+ else {
+ didReachLimit = true
+ return .limitReached
+ }
+
+ reportCount += 1
+ byteCount += incomingByteCount
+ return .accepted
+ }
+}
diff --git a/Sources/RemoMouseHardware/SiriRemoteMotionMonitor.swift b/Sources/RemoMouseHardware/SiriRemoteMotionMonitor.swift
new file mode 100644
index 0000000..d4eaabf
--- /dev/null
+++ b/Sources/RemoMouseHardware/SiriRemoteMotionMonitor.swift
@@ -0,0 +1,145 @@
+@preconcurrency import IOKit.hid
+import Foundation
+
+public enum SiriRemoteMotionEvent: Sendable {
+ case connected
+ case disconnected
+ case report(SiriRemoteRawMotionReport)
+ case warning(String)
+}
+
+/// Passively observes only the first-generation Siri Remote sensor interfaces.
+/// It never seizes a HID device and never generates pointer output.
+public final class SiriRemoteMotionMonitor: @unchecked Sendable {
+ private final class ReportBuffer {
+ let bytes: UnsafeMutablePointer
+ let capacity: Int
+
+ init(capacity: Int) {
+ self.capacity = capacity
+ bytes = .allocate(capacity: capacity)
+ bytes.initialize(repeating: 0, count: capacity)
+ }
+
+ deinit { bytes.deallocate() }
+ }
+
+ private let manager: IOHIDManager
+ private var reportBuffers: [Int: ReportBuffer] = [:]
+ private var continuation: AsyncStream.Continuation?
+ private var isStarted = false
+
+ public init() {
+ manager = IOHIDManagerCreate(kCFAllocatorDefault, IOOptionBits(kIOHIDOptionsTypeNone))
+ }
+
+ deinit { stop() }
+
+ public func events() -> AsyncStream {
+ let pair = AsyncStream.makeStream()
+ continuation?.finish()
+ continuation = pair.continuation
+ start()
+ return pair.stream
+ }
+
+ public func start() {
+ guard !isStarted else { return }
+ isStarted = true
+
+ let matching: [String: Any] = [
+ kIOHIDVendorIDKey: 76,
+ kIOHIDProductIDKey: 621,
+ kIOHIDPrimaryUsagePageKey: 32,
+ ]
+ let context = Unmanaged.passUnretained(self).toOpaque()
+ IOHIDManagerSetDeviceMatching(manager, matching as CFDictionary)
+ IOHIDManagerRegisterDeviceMatchingCallback(manager, Self.deviceMatched, context)
+ IOHIDManagerRegisterDeviceRemovalCallback(manager, Self.deviceRemoved, context)
+ IOHIDManagerScheduleWithRunLoop(
+ manager,
+ CFRunLoopGetMain(),
+ CFRunLoopMode.commonModes.rawValue
+ )
+
+ let result = IOHIDManagerOpen(manager, IOOptionBits(kIOHIDOptionsTypeNone))
+ if result != kIOReturnSuccess {
+ continuation?.yield(.warning("Unable to open the Siri Remote sensor interface (\(result))"))
+ }
+ }
+
+ public func stop() {
+ guard isStarted else { return }
+ isStarted = false
+ IOHIDManagerUnscheduleFromRunLoop(
+ manager,
+ CFRunLoopGetMain(),
+ CFRunLoopMode.commonModes.rawValue
+ )
+ IOHIDManagerClose(manager, IOOptionBits(kIOHIDOptionsTypeNone))
+ reportBuffers.removeAll()
+ continuation?.finish()
+ continuation = nil
+ }
+
+ private func didMatch(_ device: IOHIDDevice) {
+ let key = Self.key(for: device)
+ guard reportBuffers[key] == nil else { return }
+
+ let property = IOHIDDeviceGetProperty(device, kIOHIDMaxInputReportSizeKey as CFString)
+ let advertised = (property as? NSNumber)?.intValue ?? 256
+ let buffer = ReportBuffer(capacity: max(advertised, 256))
+ reportBuffers[key] = buffer
+ IOHIDDeviceRegisterInputReportCallback(
+ device,
+ buffer.bytes,
+ buffer.capacity,
+ Self.inputReport,
+ Unmanaged.passUnretained(self).toOpaque()
+ )
+ continuation?.yield(.connected)
+ }
+
+ private func didRemove(_ device: IOHIDDevice) {
+ reportBuffers.removeValue(forKey: Self.key(for: device))
+ if reportBuffers.isEmpty {
+ continuation?.yield(.disconnected)
+ }
+ }
+
+ private func didReceiveReport(
+ reportID: UInt32,
+ bytes: UnsafeMutablePointer,
+ length: CFIndex
+ ) {
+ guard length > 0 else { return }
+ continuation?.yield(.report(SiriRemoteRawMotionReport(
+ reportID: reportID,
+ bytes: Data(bytes: bytes, count: length),
+ timestampNanoseconds: DispatchTime.now().uptimeNanoseconds
+ )))
+ }
+
+ private static func key(for device: IOHIDDevice) -> Int {
+ Int(bitPattern: Unmanaged.passUnretained(device).toOpaque())
+ }
+
+ private static let deviceMatched: IOHIDDeviceCallback = { context, _, _, device in
+ guard let context else { return }
+ Unmanaged.fromOpaque(context).takeUnretainedValue()
+ .didMatch(device)
+ }
+
+ private static let deviceRemoved: IOHIDDeviceCallback = { context, _, _, device in
+ guard let context else { return }
+ Unmanaged.fromOpaque(context).takeUnretainedValue()
+ .didRemove(device)
+ }
+
+ private static let inputReport: IOHIDReportCallback = {
+ context, _, _, _, reportID, bytes, length in
+ guard let context else { return }
+ Unmanaged.fromOpaque(context).takeUnretainedValue()
+ .didReceiveReport(reportID: reportID, bytes: bytes, length: length)
+ }
+}
diff --git a/Tests/RemoMouseHardwareTests/SiriRemoteMotionCaptureTests.swift b/Tests/RemoMouseHardwareTests/SiriRemoteMotionCaptureTests.swift
new file mode 100644
index 0000000..f09823b
--- /dev/null
+++ b/Tests/RemoMouseHardwareTests/SiriRemoteMotionCaptureTests.swift
@@ -0,0 +1,13 @@
+import Testing
+@testable import RemoMouseHardware
+
+@Test func motionCaptureStopsAtReportAndByteLimits() {
+ var budget = SiriRemoteMotionCaptureBudget(maxReports: 2, maxBytes: 8)
+
+ #expect(budget.record(byteCount: 4) == .accepted)
+ #expect(budget.record(byteCount: 4) == .accepted)
+ #expect(budget.record(byteCount: 1) == .limitReached)
+ #expect(budget.record(byteCount: 1) == .ignored)
+ #expect(budget.reportCount == 2)
+ #expect(budget.byteCount == 8)
+}
From 7b66a6e1207edc0134062d71d8f5f4b7c1cf0f7b Mon Sep 17 00:00:00 2001
From: kefrulz <9220848+kefrulz@users.noreply.github.com>
Date: Sun, 9 Aug 2026 22:35:40 +0200
Subject: [PATCH 15/20] Validate Siri Remote motion input
---
Sources/RemoMouseApp/RemoMouseApp.swift | 37 ++++++
Sources/RemoMouseApp/RemoMouseModel.swift | 125 +++++++++++++++++-
.../SiriRemoteMotionCapture.swift | 24 ++++
.../SiriRemoteMotionMonitor.swift | 10 ++
.../SiriRemoteMotionCaptureTests.swift | 28 ++++
.../first-generation-motion-report.md | 35 +++++
...026-08-09-remomouse-hardware-validation.md | 10 +-
7 files changed, 262 insertions(+), 7 deletions(-)
create mode 100644 docs/hardware/first-generation-motion-report.md
diff --git a/Sources/RemoMouseApp/RemoMouseApp.swift b/Sources/RemoMouseApp/RemoMouseApp.swift
index f480349..7cfdfa1 100644
--- a/Sources/RemoMouseApp/RemoMouseApp.swift
+++ b/Sources/RemoMouseApp/RemoMouseApp.swift
@@ -40,6 +40,43 @@ struct RemoMouseApp: App {
Divider()
+ if model.isCapturingMotion {
+ VStack(alignment: .leading, spacing: 8) {
+ HStack {
+ Label("Motion diagnostics", systemImage: "gyroscope")
+ .font(.subheadline.weight(.medium))
+ Spacer()
+ Text("\(model.motionCaptureElapsed)s / 20s")
+ .monospacedDigit()
+ .foregroundStyle(.secondary)
+ }
+ ProgressView(value: Double(model.motionCaptureElapsed), total: 20)
+ Text(model.motionCaptureInstruction)
+ .font(.headline)
+ Text("\(model.motionCaptureReportCount) sensor reports")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ Button("Cancel Capture", role: .cancel) {
+ model.cancelMotionCapture()
+ }
+ }
+ } else {
+ Button("Capture Motion Diagnostics…", systemImage: "gyroscope") {
+ model.startMotionCapture()
+ }
+ if !model.motionCaptureStatus.isEmpty {
+ Text(model.motionCaptureStatus)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ } else {
+ Text("20 seconds. Raw sensor data stays on this Mac.")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+ }
+
+ Divider()
+
Button(model.isEnabled ? "Pause RemoMouse" : "Enable RemoMouse",
systemImage: model.isEnabled ? "pause.fill" : "play.fill") {
model.toggle()
diff --git a/Sources/RemoMouseApp/RemoMouseModel.swift b/Sources/RemoMouseApp/RemoMouseModel.swift
index f15813d..27a3712 100644
--- a/Sources/RemoMouseApp/RemoMouseModel.swift
+++ b/Sources/RemoMouseApp/RemoMouseModel.swift
@@ -16,12 +16,21 @@ final class RemoMouseModel {
private(set) var mode = RemoMouseInputMode.pointer
var speed = 7.0 { didSet { pointer.speed = speed } }
private(set) var lastInput = "Waiting for input"
+ private(set) var isCapturingMotion = false
+ private(set) var motionCaptureElapsed = 0
+ private(set) var motionCaptureReportCount = 0
+ private(set) var motionCaptureStatus = ""
private let pointer = PointerController()
private var monitor: SiriRemoteHIDMonitor?
private var multitouchMonitor: SiriRemoteMultitouchMonitor?
private var isTouchStarted = false
private var buttonInterpreter = SiriRemoteButtonInterpreter()
+ private var motionMonitor: SiriRemoteMotionMonitor?
+ private var motionEventTask: Task?
+ private var motionTimerTask: Task?
+ private var motionReports: [SiriRemoteRawMotionReport] = []
+ private var motionBudget = SiriRemoteMotionCaptureBudget()
var hasAccessibility: Bool { pointer.isTrusted }
@@ -34,8 +43,10 @@ final class RemoMouseModel {
Task { @MainActor in self?.handle(sample) }
}
startTouchIfNeeded()
- if !pointer.isTrusted {
- pointer.requestAccessibility()
+ if ProcessInfo.processInfo.arguments.contains("--capture-motion") {
+ Task { @MainActor [weak self] in
+ self?.startMotionCapture()
+ }
}
}
@@ -47,11 +58,59 @@ final class RemoMouseModel {
func requestAccessibility() { pointer.requestAccessibility() }
func quit() {
+ cancelMotionCapture()
pointer.releaseAll()
monitor?.stop()
NSApplication.shared.terminate(nil)
}
+ var motionCaptureInstruction: String {
+ switch motionCaptureElapsed {
+ case 0...2: "Hold the remote still"
+ case 3...6: "Turn right, then left"
+ case 7...10: "Tilt up, then down"
+ case 11...14: "Roll clockwise, then back"
+ default: "Hold the remote still"
+ }
+ }
+
+ func startMotionCapture() {
+ guard !isCapturingMotion else { return }
+ isCapturingMotion = true
+ motionCaptureElapsed = 0
+ motionCaptureReportCount = 0
+ motionCaptureStatus = "Wake the remote, then follow the motion guide"
+ motionReports.removeAll(keepingCapacity: true)
+ motionBudget = SiriRemoteMotionCaptureBudget()
+
+ let monitor = SiriRemoteMotionMonitor()
+ motionMonitor = monitor
+ motionEventTask = Task { [weak self] in
+ for await event in monitor.events() {
+ guard !Task.isCancelled else { break }
+ self?.handle(event)
+ }
+ }
+ motionTimerTask = Task { [weak self] in
+ for second in 1...20 {
+ do {
+ try await Task.sleep(for: .seconds(1))
+ } catch {
+ return
+ }
+ guard let self, self.isCapturingMotion else { return }
+ self.motionCaptureElapsed = second
+ }
+ self?.finishMotionCapture()
+ }
+ }
+
+ func cancelMotionCapture() {
+ guard isCapturingMotion else { return }
+ motionCaptureStatus = "Capture cancelled"
+ stopMotionMonitor()
+ }
+
private func handle(_ event: SiriRemoteHIDEvent) {
switch event {
case .connected:
@@ -85,6 +144,68 @@ final class RemoMouseModel {
if isEnabled { pointer.handle(sample, mode: mode) }
}
+ private func handle(_ event: SiriRemoteMotionEvent) {
+ guard isCapturingMotion else { return }
+ switch event {
+ case .connected:
+ motionCaptureStatus = "Sensor connected"
+ case .disconnected:
+ motionCaptureStatus = "Remote sleeping — press a button to wake it"
+ case let .report(report):
+ switch motionBudget.record(byteCount: report.bytes.count) {
+ case .accepted:
+ motionReports.append(report)
+ motionCaptureReportCount = motionReports.count
+ case .limitReached:
+ motionCaptureStatus = "Capture limit reached"
+ finishMotionCapture()
+ case .ignored:
+ break
+ }
+ case let .warning(message):
+ motionCaptureStatus = message
+ }
+ }
+
+ private func finishMotionCapture() {
+ guard isCapturingMotion else { return }
+ do {
+ let data = try SiriRemoteMotionCaptureEncoder.encode(motionReports)
+ let fileManager = FileManager.default
+ let support = try fileManager.url(
+ for: .applicationSupportDirectory,
+ in: .userDomainMask,
+ appropriateFor: nil,
+ create: true
+ )
+ let directory = support
+ .appending(path: "RemoMouse", directoryHint: .isDirectory)
+ .appending(path: "Motion Captures", directoryHint: .isDirectory)
+ try fileManager.createDirectory(at: directory, withIntermediateDirectories: true)
+ let formatter = ISO8601DateFormatter()
+ let filename = "motion-\(formatter.string(from: Date()).replacingOccurrences(of: ":", with: "-" )).jsonl"
+ try data.write(to: directory.appending(path: filename), options: .atomic)
+ motionCaptureStatus = "Saved \(motionReports.count) reports locally"
+ } catch {
+ motionCaptureStatus = "Could not save capture: \(error.localizedDescription)"
+ }
+ stopMotionMonitor(keepStatus: true)
+ }
+
+ private func stopMotionMonitor(keepStatus: Bool = false) {
+ motionMonitor?.stop()
+ motionMonitor = nil
+ motionEventTask?.cancel()
+ motionEventTask = nil
+ motionTimerTask?.cancel()
+ motionTimerTask = nil
+ motionReports.removeAll(keepingCapacity: false)
+ isCapturingMotion = false
+ if !keepStatus && motionCaptureStatus.isEmpty {
+ motionCaptureStatus = "Capture stopped"
+ }
+ }
+
private func handleCommands(_ commands: [SiriRemoteCommand]) {
for command in commands {
switch command {
diff --git a/Sources/RemoMouseHardware/SiriRemoteMotionCapture.swift b/Sources/RemoMouseHardware/SiriRemoteMotionCapture.swift
index 5794b04..8855a9b 100644
--- a/Sources/RemoMouseHardware/SiriRemoteMotionCapture.swift
+++ b/Sources/RemoMouseHardware/SiriRemoteMotionCapture.swift
@@ -12,6 +12,30 @@ public struct SiriRemoteRawMotionReport: Sendable {
}
}
+public enum SiriRemoteMotionCaptureEncoder {
+ private struct Record: Encodable {
+ let reportID: UInt32
+ let bytes: Data
+ let timestampNanoseconds: UInt64
+ }
+
+ public static func encode(_ reports: [SiriRemoteRawMotionReport]) throws -> Data {
+ let encoder = JSONEncoder()
+ encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes]
+ var output = Data()
+
+ for report in reports {
+ output.append(try encoder.encode(Record(
+ reportID: report.reportID,
+ bytes: report.bytes,
+ timestampNanoseconds: report.timestampNanoseconds
+ )))
+ output.append(0x0A)
+ }
+ return output
+ }
+}
+
public struct SiriRemoteMotionCaptureBudget: Sendable {
public enum Decision: Equatable, Sendable {
case accepted
diff --git a/Sources/RemoMouseHardware/SiriRemoteMotionMonitor.swift b/Sources/RemoMouseHardware/SiriRemoteMotionMonitor.swift
index d4eaabf..84ee32b 100644
--- a/Sources/RemoMouseHardware/SiriRemoteMotionMonitor.swift
+++ b/Sources/RemoMouseHardware/SiriRemoteMotionMonitor.swift
@@ -86,6 +86,16 @@ public final class SiriRemoteMotionMonitor: @unchecked Sendable {
let key = Self.key(for: device)
guard reportBuffers[key] == nil else { return }
+ let requestedInterval = NSNumber(value: 8_000)
+ guard IOHIDDeviceSetProperty(
+ device,
+ kIOHIDReportIntervalKey as CFString,
+ requestedInterval
+ ) else {
+ continuation?.yield(.warning("The Siri Remote sensor rejected its sampling interval"))
+ return
+ }
+
let property = IOHIDDeviceGetProperty(device, kIOHIDMaxInputReportSizeKey as CFString)
let advertised = (property as? NSNumber)?.intValue ?? 256
let buffer = ReportBuffer(capacity: max(advertised, 256))
diff --git a/Tests/RemoMouseHardwareTests/SiriRemoteMotionCaptureTests.swift b/Tests/RemoMouseHardwareTests/SiriRemoteMotionCaptureTests.swift
index f09823b..7098be1 100644
--- a/Tests/RemoMouseHardwareTests/SiriRemoteMotionCaptureTests.swift
+++ b/Tests/RemoMouseHardwareTests/SiriRemoteMotionCaptureTests.swift
@@ -1,3 +1,4 @@
+import Foundation
import Testing
@testable import RemoMouseHardware
@@ -11,3 +12,30 @@ import Testing
#expect(budget.reportCount == 2)
#expect(budget.byteCount == 8)
}
+
+@Test func motionCaptureEncoderPreservesReportsAsJSONLines() throws {
+ let reports = [
+ SiriRemoteRawMotionReport(
+ reportID: 7,
+ bytes: Data([0x01, 0xFE]),
+ timestampNanoseconds: 42
+ ),
+ SiriRemoteRawMotionReport(
+ reportID: 9,
+ bytes: Data([0x10]),
+ timestampNanoseconds: 99
+ ),
+ ]
+
+ let data = try SiriRemoteMotionCaptureEncoder.encode(reports)
+ let lines = String(decoding: data, as: UTF8.self).split(separator: "\n")
+
+ #expect(lines.count == 2)
+ let first = try #require(JSONSerialization.jsonObject(with: Data(lines[0].utf8)) as? [String: Any])
+ let second = try #require(JSONSerialization.jsonObject(with: Data(lines[1].utf8)) as? [String: Any])
+ #expect(first["reportID"] as? Int == 7)
+ #expect(first["timestampNanoseconds"] as? Int == 42)
+ #expect(first["bytes"] as? String == "Af4=")
+ #expect(second["reportID"] as? Int == 9)
+ #expect(second["bytes"] as? String == "EA==")
+}
diff --git a/docs/hardware/first-generation-motion-report.md b/docs/hardware/first-generation-motion-report.md
new file mode 100644
index 0000000..8c4d034
--- /dev/null
+++ b/docs/hardware/first-generation-motion-report.md
@@ -0,0 +1,35 @@
+# First-Generation Siri Remote Motion Capability Report
+
+## Validated environment
+
+- Hardware: black first-generation Siri Remote
+- Connection: paired directly to an Apple silicon Mac over Bluetooth Low Energy
+- OS: macOS 26.5.1 (build 25F80)
+- App version: 0.1.0 beta
+
+## Sensor topology
+
+macOS publishes two remote-owned HID sensor interfaces on sensor usage page 32, with primary usages 66 and 224. Both interfaces match the validated first-generation Siri Remote vendor and product identifiers. The underlying Apple Bluetooth sensor driver is present and owns the vendor-defined physical sensor interface.
+
+The diagnostic monitor opened only those two remote sensor interfaces, without seizure. It requested an 8,000-microsecond report interval using the public per-client `ReportInterval` property documented in the installed macOS SDK.
+
+## Controlled capture result
+
+- Capture duration: 20 seconds
+- Motion sequence: neutral, yaw, pitch, roll, neutral
+- Sensor interfaces matched: 2
+- Raw reports delivered to the standard IOHID client: 0
+- Report IDs: none
+- Report lengths: none
+- Measured cadence: unavailable
+- Usable rotation axes: 0
+- Stable neutral region: not measurable
+- Local trace SHA-256: `e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855`
+
+The local IOHID client queues remained empty during controlled movement even after the sensor interval request was accepted. The raw physical sensor interface is marked privileged and is consumed by Apple's Bluetooth sensor driver. The public GameController route also does not enumerate this paired remote on the validated Mac. No raw bytes or device identifiers are included in this report.
+
+## Release gate
+
+Air Pointer does not ship in v0.1.0. The required two changing rotation axes and stable neutral region were not observable through the validated user-space routes, so exposing an Air Pointer toggle would risk drift or fabricated decoding. Trackpad pointer, click, buttons, and precision scrolling remain independent and available.
+
+Further work is limited to a documented compatibility investigation of the macOS HID event-service sensor route. Air Pointer may ship only after a future controlled trace establishes real axes, scale, cadence, and neutral behavior on the target hardware.
diff --git a/docs/superpowers/plans/2026-08-09-remomouse-hardware-validation.md b/docs/superpowers/plans/2026-08-09-remomouse-hardware-validation.md
index 92254e8..3333d8c 100644
--- a/docs/superpowers/plans/2026-08-09-remomouse-hardware-validation.md
+++ b/docs/superpowers/plans/2026-08-09-remomouse-hardware-validation.md
@@ -287,11 +287,11 @@ public enum RemoteEvent: Codable, Equatable, Sendable {
```swift
@Test func redactorRemovesSensitiveValues() {
let redactor = SensitiveValueRedactor(sensitiveValues: [
- "C08C47NMJ90M", "14:9D:99:23:B1:76", "/Users/example"
+ "REMOTE-SERIAL-EXAMPLE", "REMOTE-ADDRESS-EXAMPLE", "/Users/example"
])
- let output = redactor.redact("C08C47NMJ90M at 14:9D:99:23:B1:76 in /Users/example")
- #expect(!output.contains("C08C47NMJ90M"))
- #expect(!output.contains("14:9D:99:23:B1:76"))
+ let output = redactor.redact("REMOTE-SERIAL-EXAMPLE at REMOTE-ADDRESS-EXAMPLE in /Users/example")
+ #expect(!output.contains("REMOTE-SERIAL-EXAMPLE"))
+ #expect(!output.contains("REMOTE-ADDRESS-EXAMPLE"))
#expect(!output.contains("/Users/example"))
#expect(output.contains("
Date: Sun, 9 Aug 2026 22:40:31 +0200
Subject: [PATCH 16/20] Design native-style scrolling
---
...2026-08-09-native-scroll-implementation.md | 111 ++++++++++++++++++
.../specs/2026-08-09-native-scroll-design.md | 30 +++++
2 files changed, 141 insertions(+)
create mode 100644 docs/superpowers/plans/2026-08-09-native-scroll-implementation.md
create mode 100644 docs/superpowers/specs/2026-08-09-native-scroll-design.md
diff --git a/docs/superpowers/plans/2026-08-09-native-scroll-implementation.md b/docs/superpowers/plans/2026-08-09-native-scroll-implementation.md
new file mode 100644
index 0000000..55c06a9
--- /dev/null
+++ b/docs/superpowers/plans/2026-08-09-native-scroll-implementation.md
@@ -0,0 +1,111 @@
+# Native-Style Scrolling Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Add phased, continuous, momentum-based scrolling that feels like a macOS trackpad while preserving one-thumb edge intent and click stability.
+
+**Architecture:** Pure timestamped dynamics convert remote touch deltas into pixel output and a bounded momentum state. `PointerController` owns Core Graphics phase emission and a cancellable main-actor momentum task; touch intent and device transports remain unchanged.
+
+**Tech Stack:** Swift 6.2, Swift Testing, SwiftUI, CoreGraphics, macOS Tahoe 26.
+
+## Global Constraints
+
+- Keep right-edge eligibility at 18 percent and preserve two-finger whole-surface scrolling.
+- Use pixel-based continuous events with explicit scroll and momentum phases.
+- Cancel momentum on new contact, click, pause, mode change, disconnect, and quit.
+- Preserve dead-zone filtering, axis locking, fractional accumulation, click stabilization, and forced mouse-state release.
+- Add no dependency and emit no output without Accessibility trust.
+
+---
+
+### Task 1: Timestamped Scroll Dynamics
+
+**Files:**
+- Modify: `Sources/RemoMouseHardware/SiriRemoteInputFilters.swift`
+- Modify: `Tests/RemoMouseHardwareTests/SiriRemoteReportDecoderTests.swift`
+
+**Interfaces:**
+- Consumes: touch deltas, scale, and monotonic timestamp.
+- Produces: `SiriRemoteScrollFilter.update(dx:dy:scale:timestamp:) -> SiriRemotePixelDelta` and `SiriRemoteScrollFilter.end(timestamp:) -> SiriRemoteScrollMomentum?`.
+
+- [ ] **Step 1: Write failing tests for recent-swipe momentum and stale-swipe rejection**
+
+```swift
+@Test func scrollMomentumRequiresARecentDeliberateSwipe() {
+ var filter = SiriRemoteScrollFilter()
+ _ = filter.update(dx: 0, dy: 0.02, scale: 200, timestamp: 1.00)
+ _ = filter.update(dx: 0, dy: 0.03, scale: 200, timestamp: 1.02)
+ #expect(filter.end(timestamp: 1.03) != nil)
+
+ var stale = filter
+ #expect(stale.end(timestamp: 1.30) == nil)
+}
+```
+
+- [ ] **Step 2: Run the focused test and verify RED**
+
+Run: `swift test --filter scrollMomentumRequiresARecentDeliberateSwipe`
+
+Expected: compilation fails because the timestamped API and momentum type do not exist.
+
+- [ ] **Step 3: Implement timestamped filtering and bounded momentum**
+
+Add a public `SiriRemoteScrollMomentum` value type with `mutating func step(elapsed:) -> SiriRemotePixelDelta?`. Track the last meaningful timestamp and smoothed pixels-per-second velocity. Permit momentum only within 80 milliseconds of meaningful output and above 35 pixels per second. Apply exponential decay, stop below 4 pixels per second, and hard-stop at 650 milliseconds.
+
+- [ ] **Step 4: Add deterministic decay and termination tests**
+
+Advance one momentum value at 8-millisecond intervals. Assert that nonzero absolute output decreases over time, never reverses direction, and becomes `nil` by 650 milliseconds.
+
+- [ ] **Step 5: Verify focused and full tests**
+
+Run: `swift test --filter 'scrollMomentum|scrollFilter' && swift test --parallel`
+
+Expected: all scroll tests and the full suite pass.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add Sources/RemoMouseHardware/SiriRemoteInputFilters.swift Tests/RemoMouseHardwareTests/SiriRemoteReportDecoderTests.swift
+git commit -m "Add native scroll dynamics"
+```
+
+### Task 2: Continuous Scroll Phases and Momentum Clock
+
+**Files:**
+- Modify: `Sources/RemoMouseApp/PointerController.swift`
+- Modify: `README.md`
+- Modify: `docs/INSTALLATION.md`
+- Modify: `CHANGELOG.md`
+
+**Interfaces:**
+- Consumes: timestamped filtered deltas and `SiriRemoteScrollMomentum`.
+- Produces: continuous Core Graphics pixel events with begin/change/end phases and cancellable momentum events.
+
+- [ ] **Step 1: Pass monotonic timestamps into the filter**
+
+Use `ProcessInfo.processInfo.systemUptime` for every touch update and contact end. Start a gesture phase on the first nonzero scroll delta and end it when the contact ends or output is released.
+
+- [ ] **Step 2: Add the cancellable momentum clock**
+
+Advance momentum every 8 milliseconds in a main-actor task. Emit momentum begin/change/end phases. Cancel the task before any new contact, click, release, pause, mode change, disconnect, or quit.
+
+- [ ] **Step 3: Mark generated events continuous and phase-aware**
+
+Set `scrollWheelEventIsContinuous`, `scrollWheelEventScrollPhase`, and `scrollWheelEventMomentumPhase` on pixel scroll events. Do not post zero deltas except for the final phase event.
+
+- [ ] **Step 4: Update public documentation**
+
+Describe right-edge and two-finger scrolling as continuous, precision scrolling with momentum. State that a new touch stops momentum immediately.
+
+- [ ] **Step 5: Verify app and signature**
+
+Run: `swift test --parallel && Scripts/package-app.sh release && codesign --verify --deep --strict .build/RemoMouse.app`
+
+Expected: tests pass, the release app packages, and the signature verifies.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add Sources/RemoMouseApp/PointerController.swift README.md docs/INSTALLATION.md CHANGELOG.md
+git commit -m "Polish trackpad-style scrolling"
+```
diff --git a/docs/superpowers/specs/2026-08-09-native-scroll-design.md b/docs/superpowers/specs/2026-08-09-native-scroll-design.md
new file mode 100644
index 0000000..dccb2f8
--- /dev/null
+++ b/docs/superpowers/specs/2026-08-09-native-scroll-design.md
@@ -0,0 +1,30 @@
+# Native-Style Scrolling Design
+
+## Goal
+
+Make right-edge one-thumb and two-finger scrolling feel like a macOS trackpad: responsive at first contact, precise at low speed, fast when swiped, stable while clicking, and naturally decelerating after release.
+
+## Interaction
+
+The existing gesture rules remain unchanged. A contact that begins on the rightmost 18 percent of the touch surface locks to vertical scrolling after deliberate vertical travel. Two-finger touch and explicit Scroll mode use the whole surface. Pointer gestures never acquire scroll behavior midway through a contact unless they began in the eligible edge region.
+
+Scroll output is continuous and pixel-based. Each contact sends an explicit begin phase on its first nonzero output, changed phases while moving, and an end phase on release. A sufficiently recent, deliberate swipe starts a short momentum phase. New touch, click, pause, mode change, disconnect, or quit cancels momentum immediately.
+
+## Dynamics
+
+The pure scroll filter keeps the existing dead zone, contact-lifetime axis lock, exponential smoothing, and fractional-pixel accumulation. It additionally tracks timestamped output velocity. Motion below the jitter threshold never contributes velocity. Momentum is permitted only when the last meaningful sample is recent and exceeds a minimum speed. Momentum uses exponential decay, has a hard maximum duration, and preserves fractional pixels so slow tails end cleanly instead of stepping or oscillating.
+
+Speed uses the existing RemoMouse speed setting. Slow movement stays close to one-to-one pixel control; larger deltas receive a bounded acceleration curve so a thumb swipe can traverse long documents without excessive repetition.
+
+## Components
+
+- `SiriRemoteScrollFilter` converts timestamped touch deltas into pixel output and exposes a bounded momentum state on contact end.
+- `SiriRemoteScrollMomentum` is a pure value type advanced by elapsed time and covered by deterministic tests.
+- `PointerController` owns gesture phases, posts continuous Core Graphics scroll events, and runs the cancellable momentum clock on the main actor.
+- `RemoMouseModel` cancels active output through the existing release path whenever control state changes.
+
+## Safety and testing
+
+Automated tests cover jitter rejection, low-speed fractional accumulation, bounded acceleration, recent-swipe momentum, stale-swipe rejection, monotonic decay, termination, and cancellation. Hardware verification covers right-edge and two-finger initiation, click stability, short precision movement, fast swipes, immediate interruption, sleep/wake, and pointer-mode isolation.
+
+The implementation adds no dependency and never synthesizes scroll output without Accessibility trust.
From 68152bb11904ea092861aef03bd4a57cb7581626 Mon Sep 17 00:00:00 2001
From: kefrulz <9220848+kefrulz@users.noreply.github.com>
Date: Sun, 9 Aug 2026 22:42:09 +0200
Subject: [PATCH 17/20] Add native scroll dynamics
---
.../SiriRemoteInputFilters.swift | 91 ++++++++++++++++---
.../SiriRemoteReportDecoderTests.swift | 34 +++++++
2 files changed, 114 insertions(+), 11 deletions(-)
diff --git a/Sources/RemoMouseHardware/SiriRemoteInputFilters.swift b/Sources/RemoMouseHardware/SiriRemoteInputFilters.swift
index f83f0d7..3245a11 100644
--- a/Sources/RemoMouseHardware/SiriRemoteInputFilters.swift
+++ b/Sources/RemoMouseHardware/SiriRemoteInputFilters.swift
@@ -25,10 +25,19 @@ public struct SiriRemoteScrollFilter: Sendable {
private var smoothedY = 0.0
private var residualX = 0.0
private var residualY = 0.0
+ private var velocityX = 0.0
+ private var velocityY = 0.0
+ private var lastTimestamp: TimeInterval?
+ private var lastMeaningfulTimestamp: TimeInterval?
public init() {}
- public mutating func update(dx: Double, dy: Double, scale: Double) -> SiriRemotePixelDelta {
+ public mutating func update(
+ dx: Double,
+ dy: Double,
+ scale: Double,
+ timestamp: TimeInterval? = nil
+ ) -> SiriRemotePixelDelta {
guard hypot(dx, dy) >= 0.0008 else { return .zero }
if axis == .undecided {
@@ -41,19 +50,44 @@ public struct SiriRemoteScrollFilter: Sendable {
}
}
- let filteredX = axis == .vertical ? 0 : dx
- let filteredY = axis == .horizontal ? 0 : dy
+ let magnitude = hypot(dx, dy)
+ let acceleration = 1 + min(max((magnitude - 0.003) * 40, 0), 1.8)
+ let filteredX = axis == .vertical ? 0 : dx * acceleration
+ let filteredY = axis == .horizontal ? 0 : dy * acceleration
let smoothing = 0.32
smoothedX += (filteredX - smoothedX) * smoothing
smoothedY += (filteredY - smoothedY) * smoothing
- residualX += smoothedX * scale
- residualY += smoothedY * scale
-
- let pixelsX = Int32(residualX)
- let pixelsY = Int32(residualY)
- residualX -= Double(pixelsX)
- residualY -= Double(pixelsY)
- return SiriRemotePixelDelta(x: pixelsX, y: pixelsY)
+ let pixelsX = smoothedX * scale
+ let pixelsY = smoothedY * scale
+ residualX += pixelsX
+ residualY += pixelsY
+
+ if let timestamp {
+ if let lastTimestamp {
+ let interval = timestamp - lastTimestamp
+ if interval > 0, interval <= 0.1, hypot(pixelsX, pixelsY) >= 0.25 {
+ let velocitySmoothing = 0.35
+ velocityX += (pixelsX / interval - velocityX) * velocitySmoothing
+ velocityY += (pixelsY / interval - velocityY) * velocitySmoothing
+ lastMeaningfulTimestamp = timestamp
+ }
+ }
+ lastTimestamp = timestamp
+ }
+
+ let emittedX = Int32(residualX)
+ let emittedY = Int32(residualY)
+ residualX -= Double(emittedX)
+ residualY -= Double(emittedY)
+ return SiriRemotePixelDelta(x: emittedX, y: emittedY)
+ }
+
+ public func end(timestamp: TimeInterval) -> SiriRemoteScrollMomentum? {
+ guard let lastMeaningfulTimestamp,
+ timestamp - lastMeaningfulTimestamp <= 0.08,
+ hypot(velocityX, velocityY) >= 35
+ else { return nil }
+ return SiriRemoteScrollMomentum(velocityX: velocityX, velocityY: velocityY)
}
public mutating func reset() {
@@ -61,6 +95,41 @@ public struct SiriRemoteScrollFilter: Sendable {
}
}
+public struct SiriRemoteScrollMomentum: Sendable {
+ private var velocityX: Double
+ private var velocityY: Double
+ private var residualX = 0.0
+ private var residualY = 0.0
+ private var duration = 0.0
+ private var isFinished = false
+
+ init(velocityX: Double, velocityY: Double) {
+ self.velocityX = velocityX
+ self.velocityY = velocityY
+ }
+
+ public mutating func step(elapsed: TimeInterval) -> SiriRemotePixelDelta? {
+ guard !isFinished, elapsed > 0 else { return nil }
+ duration += elapsed
+ guard duration <= 0.65, hypot(velocityX, velocityY) >= 4 else {
+ isFinished = true
+ return nil
+ }
+
+ residualX += velocityX * elapsed
+ residualY += velocityY * elapsed
+ let decay = exp(-8 * elapsed)
+ velocityX *= decay
+ velocityY *= decay
+
+ let emittedX = Int32(residualX)
+ let emittedY = Int32(residualY)
+ residualX -= Double(emittedX)
+ residualY -= Double(emittedY)
+ return SiriRemotePixelDelta(x: emittedX, y: emittedY)
+ }
+}
+
public struct SiriRemoteClickGuard: Sendable {
private let stabilizationInterval: TimeInterval
private var suppressUntil: TimeInterval?
diff --git a/Tests/RemoMouseHardwareTests/SiriRemoteReportDecoderTests.swift b/Tests/RemoMouseHardwareTests/SiriRemoteReportDecoderTests.swift
index e677d45..3b71ceb 100644
--- a/Tests/RemoMouseHardwareTests/SiriRemoteReportDecoderTests.swift
+++ b/Tests/RemoMouseHardwareTests/SiriRemoteReportDecoderTests.swift
@@ -62,6 +62,40 @@ import Testing
#expect(output.y < 10)
}
+@Test func scrollMomentumRequiresARecentDeliberateSwipe() {
+ var recent = SiriRemoteScrollFilter()
+ _ = recent.update(dx: 0, dy: 0.02, scale: 200, timestamp: 1.00)
+ _ = recent.update(dx: 0, dy: 0.03, scale: 200, timestamp: 1.02)
+ #expect(recent.end(timestamp: 1.03) != nil)
+
+ var stale = SiriRemoteScrollFilter()
+ _ = stale.update(dx: 0, dy: 0.02, scale: 200, timestamp: 1.00)
+ _ = stale.update(dx: 0, dy: 0.03, scale: 200, timestamp: 1.02)
+ #expect(stale.end(timestamp: 1.30) == nil)
+}
+
+@Test func scrollMomentumDecaysWithoutReversingAndTerminates() throws {
+ var momentum = SiriRemoteScrollMomentum(velocityX: 0, velocityY: 300)
+ var windows: [Int32] = []
+
+ for _ in 0..<4 {
+ var windowTotal: Int32 = 0
+ for _ in 0..<10 {
+ guard let delta = momentum.step(elapsed: 0.008) else { break }
+ #expect(delta.y >= 0)
+ windowTotal += abs(delta.y)
+ }
+ windows.append(windowTotal)
+ }
+
+ #expect(windows[0] > windows[1])
+ #expect(windows[1] >= windows[2])
+ for _ in 0..<100 {
+ if momentum.step(elapsed: 0.008) == nil { break }
+ }
+ #expect(momentum.step(elapsed: 0.008) == nil)
+}
+
@Test func clickGuardStabilizesPressThenAllowsDrag() {
var guardState = SiriRemoteClickGuard(stabilizationInterval: 0.12)
From 17aca39076b6ce6dd3f1925a05cb1df6e7468720 Mon Sep 17 00:00:00 2001
From: kefrulz <9220848+kefrulz@users.noreply.github.com>
Date: Sun, 9 Aug 2026 22:44:12 +0200
Subject: [PATCH 18/20] Polish trackpad-style scrolling
---
CHANGELOG.md | 11 +++
README.md | 43 ++++++++-
Sources/RemoMouseApp/PointerController.swift | 95 ++++++++++++++++++--
docs/ARCHITECTURE.md | 19 ++++
docs/INSTALLATION.md | 40 +++++++++
5 files changed, 196 insertions(+), 12 deletions(-)
create mode 100644 CHANGELOG.md
create mode 100644 docs/ARCHITECTURE.md
create mode 100644 docs/INSTALLATION.md
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..2c3036a
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,11 @@
+# Changelog
+
+All notable user-visible changes are documented here. This project follows [Semantic Versioning](https://semver.org/).
+
+## 0.1.0 — 2026-08-09
+
+- Add a native macOS menu-bar application for first-generation Siri Remote hardware.
+- Add touch-surface pointer movement, primary click, secondary click, and scroll mode.
+- Add continuous pixel scrolling with gesture phases, fractional smoothing, bounded acceleration, cancellable momentum, axis locking, dead-zone filtering, and click stabilization.
+- Add Play/Pause mode switching, Siri pause/resume, speed controls, and Mission Control mapping.
+- Add local hardware diagnostics with privacy redaction.
diff --git a/README.md b/README.md
index 10fecb8..b92eaf1 100644
--- a/README.md
+++ b/README.md
@@ -1,17 +1,34 @@
# RemoMouse
-RemoMouse turns a first-generation Siri Remote into a precise, app-aware mouse and compact controller for macOS Tahoe.
+RemoMouse turns a first-generation Siri Remote into a precise mouse and compact controller for macOS Tahoe. It is native, local-only, and open source.
## Status
-The first usable local build is available. It runs in the menu bar, reads the paired first-generation Siri Remote through macOS HID, and maps its touch surface and physical click to the Mac pointer. Menu acts as secondary click. Pointer speed and pause/resume are available from the menu-bar popover.
+RemoMouse 0.1.0 is a public beta. It runs in the menu bar and supports touch pointer movement, click and drag, secondary click, continuous precision scrolling with momentum, pointer/scroll modes, speed control, Mission Control, and pause/resume.
+
+## Download
+
+Download the latest app and checksum from [GitHub Releases](https://github.com/kefrulz/RemoMouse/releases). See the [installation guide](docs/INSTALLATION.md) for pairing, Accessibility permission, and Gatekeeper instructions.
## Requirements
- macOS Tahoe 26.0 or newer
-- Xcode 26.6 or newer
- A first-generation black Siri Remote paired in System Settings → Bluetooth
+Building from source additionally requires Xcode 26.6 or newer.
+
+## Default controls
+
+| Remote input | Action |
+| --- | --- |
+| Touch | Move pointer; swipe the right edge or use two fingers for trackpad-style scrolling |
+| Touch click | Primary click and drag |
+| Menu | Secondary click |
+| Play/Pause | Toggle Pointer and Scroll modes |
+| Volume | Adjust pointer speed |
+| Home/TV | Mission Control |
+| Siri | Pause/resume |
+
## Build and test
```bash
@@ -20,14 +37,32 @@ Scripts/package-app.sh release
open .build/RemoMouse.app
```
-On first launch, grant RemoMouse access in System Settings → Privacy & Security → Accessibility. Press a remote button to wake it after it has been idle.
+On first launch, open the menu-bar popover and choose **Allow Accessibility…**, then enable RemoMouse in System Settings → Privacy & Security → Accessibility. RemoMouse never prompts automatically on later launches. Press a remote button to wake it after it has been idle.
+
+## Compatibility note
+
+Buttons use public IOKit HID APIs. On current macOS, first-generation touch data is exposed through Apple's private `MultitouchSupport` framework. That compatibility bridge is dynamically loaded, strictly filtered to the Siri Remote, and documented in [Architecture](docs/ARCHITECTURE.md). This prevents initial Mac App Store distribution and may require updates after macOS changes.
+
+Air Pointer is not enabled in 0.1.0 because the validated Mac did not deliver usable motion reports from this remote. The evidence and release gate are documented in the [motion capability report](docs/hardware/first-generation-motion-report.md); the working touch and button paths are unaffected.
## Privacy
RemoMouse is local-only. Hardware captures stay under the user's Application Support directory, and committed reports must redact device identifiers, Bluetooth addresses, usernames, and home-directory paths.
+## Roadmap
+
+- Optional gyroscope-based Air Pointer mode with deliberate activation and recentering
+- Per-app profiles and customizable mappings
+- Developer ID signing, notarization, and automatic updates
+
+## Contributing and security
+
+Contributions are welcome under [CONTRIBUTING.md](CONTRIBUTING.md). Report vulnerabilities privately according to [SECURITY.md](SECURITY.md). RemoMouse is licensed under the [MIT License](LICENSE).
+
## Project documentation
- [Product and technical design](docs/superpowers/specs/2026-08-09-remomouse-design.md)
- [Hardware-validation plan](docs/superpowers/plans/2026-08-09-remomouse-hardware-validation.md)
- [Hardware-validation issue](https://github.com/kefrulz/RemoMouse/issues/1)
+
+RemoMouse is an independent project and is not affiliated with or endorsed by Apple Inc. Apple TV and Siri Remote are trademarks of Apple Inc.
diff --git a/Sources/RemoMouseApp/PointerController.swift b/Sources/RemoMouseApp/PointerController.swift
index 189b204..df5f002 100644
--- a/Sources/RemoMouseApp/PointerController.swift
+++ b/Sources/RemoMouseApp/PointerController.swift
@@ -18,6 +18,8 @@ final class PointerController {
private var scrollFilter = SiriRemoteScrollFilter()
private var clickGuard = SiriRemoteClickGuard()
private var touchIntentFilter = SiriRemoteTouchIntentFilter()
+ private var scrollGestureActive = false
+ private var scrollMomentumTask: Task?
var isTrusted: Bool { AXIsProcessTrusted() }
@@ -49,9 +51,10 @@ final class PointerController {
}
func handle(_ sample: SiriRemoteMultitouchSample, mode: RemoMouseInputMode) {
+ let timestamp = ProcessInfo.processInfo.systemUptime
guard sample.isContact else {
+ finishScrollGesture(timestamp: timestamp)
lastMultitouch = nil
- scrollFilter.reset()
touchIntentFilter.reset()
return
}
@@ -61,6 +64,7 @@ final class PointerController {
isContact: true
)
guard let prior = lastMultitouch else {
+ cancelScrollMomentum()
lastMultitouch = sample
return
}
@@ -72,14 +76,25 @@ final class PointerController {
return
}
- if clickGuard.suppressesMotion(timestamp: ProcessInfo.processInfo.systemUptime) {
+ if clickGuard.suppressesMotion(timestamp: timestamp) {
lastMultitouch = sample
return
}
if sample.touchCount > 1 || mode == .scroll || touchIntent == .verticalScroll {
- let pixels = scrollFilter.update(dx: dx, dy: dy, scale: speed * 28)
- scroll(pixels)
+ let pixels = scrollFilter.update(
+ dx: dx,
+ dy: dy,
+ scale: speed * 28,
+ timestamp: timestamp
+ )
+ if pixels != .zero {
+ scroll(
+ pixels,
+ phase: scrollGestureActive ? .changed : .began
+ )
+ scrollGestureActive = true
+ }
} else {
let distance = hypot(dx, dy)
let acceleration = 1 + min(distance * 14, 2.5)
@@ -96,6 +111,11 @@ final class PointerController {
}
func releaseAll() {
+ cancelScrollMomentum()
+ if scrollGestureActive {
+ scroll(.zero, phase: .ended, allowZero: true)
+ scrollGestureActive = false
+ }
if lastButtonMask & 0x80 != 0 { postMouse(.leftMouseUp, button: .left) }
if lastButtonMask & 0x20 != 0 { postMouse(.rightMouseUp, button: .right) }
lastButtonMask = 0
@@ -130,6 +150,7 @@ final class PointerController {
private func updateButtons(_ mask: UInt8) {
let changed = mask ^ lastButtonMask
if changed & 0x80 != 0 {
+ cancelScrollMomentum()
clickGuard.setPressed(
mask & 0x80 != 0,
timestamp: ProcessInfo.processInfo.systemUptime
@@ -167,18 +188,76 @@ final class PointerController {
}
- private func scroll(_ delta: SiriRemotePixelDelta) {
+ private func finishScrollGesture(timestamp: TimeInterval) {
+ let momentum = scrollGestureActive ? scrollFilter.end(timestamp: timestamp) : nil
+ if scrollGestureActive {
+ scroll(.zero, phase: .ended, allowZero: true)
+ scrollGestureActive = false
+ }
+ scrollFilter.reset()
+ if let momentum {
+ startScrollMomentum(momentum)
+ }
+ }
+
+ private func startScrollMomentum(_ initialMomentum: SiriRemoteScrollMomentum) {
+ cancelScrollMomentum()
+ scrollMomentumTask = Task { @MainActor [weak self] in
+ var momentum = initialMomentum
+ var phase = NSEvent.Phase.began
+
+ while !Task.isCancelled {
+ do {
+ try await Task.sleep(for: .milliseconds(8))
+ } catch {
+ return
+ }
+ guard let delta = momentum.step(elapsed: 0.008) else {
+ self?.scroll(.zero, momentumPhase: .ended, allowZero: true)
+ self?.scrollMomentumTask = nil
+ return
+ }
+ guard delta != .zero else { continue }
+ self?.scroll(delta, momentumPhase: phase)
+ phase = .changed
+ }
+ }
+ }
+
+ private func cancelScrollMomentum() {
+ guard scrollMomentumTask != nil else { return }
+ scrollMomentumTask?.cancel()
+ scrollMomentumTask = nil
+ scroll(.zero, momentumPhase: .ended, allowZero: true)
+ }
+
+ private func scroll(
+ _ delta: SiriRemotePixelDelta,
+ phase: NSEvent.Phase = [],
+ momentumPhase: NSEvent.Phase = [],
+ allowZero: Bool = false
+ ) {
guard isTrusted else { return }
let vertical = -delta.y
let horizontal = delta.x
- guard vertical != 0 || horizontal != 0 else { return }
- CGEvent(
+ guard allowZero || vertical != 0 || horizontal != 0 else { return }
+ guard let event = CGEvent(
scrollWheelEvent2Source: nil,
units: .pixel,
wheelCount: 2,
wheel1: vertical,
wheel2: horizontal,
wheel3: 0
- )?.post(tap: .cghidEventTap)
+ ) else { return }
+ event.setIntegerValueField(.scrollWheelEventIsContinuous, value: 1)
+ event.setIntegerValueField(
+ .scrollWheelEventScrollPhase,
+ value: Int64(phase.rawValue)
+ )
+ event.setIntegerValueField(
+ .scrollWheelEventMomentumPhase,
+ value: Int64(momentumPhase.rawValue)
+ )
+ event.post(tap: .cghidEventTap)
}
}
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
new file mode 100644
index 0000000..3674b74
--- /dev/null
+++ b/docs/ARCHITECTURE.md
@@ -0,0 +1,19 @@
+# Architecture
+
+RemoMouse separates device input, interpretation, macOS output, and UI state so hardware compatibility code remains isolated and testable.
+
+## Input routes
+
+- Consumer buttons use public IOKit HID and exclusively open only the remote's consumer-control interface. This prevents macOS from also launching Music or changing volume for inputs RemoMouse owns.
+- The first-generation touch surface is published by macOS as `AppleEmbeddedBluetoothTouch`, backed by vendor report 255 rather than standard X/Y HID elements. RemoMouse dynamically loads Apple's private `MultitouchSupport` framework and filters its device list to Apple vendor 76, product 621. Built-in trackpads are ignored.
+- GameController remains an optional compatibility route for remote generations macOS publishes there.
+
+The private multitouch dependency is why the initial distribution targets GitHub rather than the Mac App Store. It is dynamically resolved at runtime, isolated in `CMultitouchBridge`, and fails closed if Apple removes or changes the symbols.
+
+## Input safety
+
+Touch frames pass through dead-zone filtering, exponential smoothing, axis locking, fractional pixel accumulation, bounded acceleration, jump rejection, and click stabilization. Scrolling emits continuous pixel events with explicit gesture phases and a short velocity-derived momentum tail. New contact, click, disconnect, pause, mode change, or termination cancels momentum and releases generated mouse state.
+
+## Privacy
+
+The app has no networking or telemetry. Hardware captures remain local. Exported reports redact usernames, home paths, device identifiers, and Bluetooth addresses before writing.
diff --git a/docs/INSTALLATION.md b/docs/INSTALLATION.md
new file mode 100644
index 0000000..c4b02fa
--- /dev/null
+++ b/docs/INSTALLATION.md
@@ -0,0 +1,40 @@
+# Installation
+
+## Requirements
+
+- macOS Tahoe 26.0 or newer
+- First-generation black Siri Remote (A1513/A1962)
+- Bluetooth and Accessibility access
+
+## Install a release
+
+1. Download `RemoMouse-v0.1.0-macOS.zip` from GitHub Releases.
+2. Verify the adjacent SHA-256 checksum if desired: `shasum -a 256 -c RemoMouse-v0.1.0-macOS.zip.sha256`.
+3. Unzip and move `RemoMouse.app` to Applications.
+4. Control-click the app and choose Open for the first launch.
+5. Open the RemoMouse menu-bar popover and choose **Allow Accessibility…**.
+6. Enable RemoMouse in System Settings → Privacy & Security → Accessibility.
+7. Pair the remote in System Settings → Bluetooth and press a button to wake it.
+
+Version 0.1.0 is ad-hoc signed by GitHub Actions and is not notarized. A future release can become normally double-clickable after a Developer ID identity and notarization credentials are configured.
+
+## Default controls
+
+| Remote input | Mac action |
+| --- | --- |
+| One-finger touch | Move pointer; a vertical right-edge gesture scrolls with momentum |
+| Touch-surface click | Primary click / drag |
+| Two-finger touch | Continuous precision scroll with momentum |
+| Menu | Secondary click |
+| Play/Pause | Toggle Pointer and Scroll modes |
+| Volume Up/Down | Adjust pointer speed |
+| Home/TV | Mission Control |
+| Siri | Pause or resume RemoMouse |
+
+## Troubleshooting
+
+- If touch is unavailable after launch, press a remote button. The app retries the multitouch service when the remote wakes.
+- A new touch, click, mode change, or pause stops scroll momentum immediately.
+- If output does not move the pointer, enable RemoMouse under System Settings → Privacy & Security → Accessibility.
+- RemoMouse does not request Accessibility automatically. A newly downloaded ad-hoc-signed beta may need one new approval after replacement; ordinary relaunches of the same build retain it.
+- The remote cannot control Apple TV while paired to the Mac.
From 4638b386311756588d1b3f2adf10e6b8c1f3b162 Mon Sep 17 00:00:00 2001
From: kefrulz <9220848+kefrulz@users.noreply.github.com>
Date: Sun, 9 Aug 2026 22:44:39 +0200
Subject: [PATCH 19/20] Prepare public beta release
---
.github/ISSUE_TEMPLATE/feature.yml | 30 +++++++++++++++++++++
.github/dependabot.yml | 7 +++++
.github/workflows/ci.yml | 8 ++++--
.github/workflows/release.yml | 42 ++++++++++++++++++++++++++++++
CODE_OF_CONDUCT.md | 5 ++++
CONTRIBUTING.md | 14 ++++++++++
LICENSE | 21 +++++++++++++++
SECURITY.md | 11 ++++++++
8 files changed, 136 insertions(+), 2 deletions(-)
create mode 100644 .github/ISSUE_TEMPLATE/feature.yml
create mode 100644 .github/dependabot.yml
create mode 100644 .github/workflows/release.yml
create mode 100644 CODE_OF_CONDUCT.md
create mode 100644 CONTRIBUTING.md
create mode 100644 LICENSE
create mode 100644 SECURITY.md
diff --git a/.github/ISSUE_TEMPLATE/feature.yml b/.github/ISSUE_TEMPLATE/feature.yml
new file mode 100644
index 0000000..12af792
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/feature.yml
@@ -0,0 +1,30 @@
+name: Feature request
+description: Propose a focused RemoMouse improvement.
+title: "Feature: "
+labels: []
+body:
+ - type: textarea
+ id: problem
+ attributes:
+ label: Problem
+ description: What workflow or limitation should this solve?
+ validations:
+ required: true
+ - type: textarea
+ id: behavior
+ attributes:
+ label: Proposed behavior
+ description: Describe the user-visible result rather than an implementation.
+ validations:
+ required: true
+ - type: dropdown
+ id: remote
+ attributes:
+ label: Remote generation
+ options:
+ - First generation (black glass)
+ - Second generation (silver, Lightning)
+ - Third generation (silver, USB-C)
+ - Not hardware-specific
+ validations:
+ required: true
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
new file mode 100644
index 0000000..8004983
--- /dev/null
+++ b/.github/dependabot.yml
@@ -0,0 +1,7 @@
+version: 2
+updates:
+ - package-ecosystem: github-actions
+ directory: /
+ schedule:
+ interval: weekly
+ open-pull-requests-limit: 5
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index f391921..06c5c4d 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -21,12 +21,16 @@ jobs:
env:
DEVELOPER_DIR: /Applications/Xcode_26.6.app/Contents/Developer
steps:
- - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
+ - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
- name: Show Swift version
run: swift --version
- name: Run tests
run: swift test --parallel
- - name: Package probe
+ - name: Package RemoMouse
+ run: Scripts/package-app.sh release
+ - name: Verify RemoMouse signature
+ run: codesign --verify --deep --strict .build/RemoMouse.app
+ - name: Package hardware probe
run: Scripts/package-probe-app.sh debug
- name: Verify generated-file cleanliness
run: git diff --exit-code
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 0000000..fae745d
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,42 @@
+name: Release
+
+on:
+ push:
+ tags: ["v*"]
+
+permissions:
+ contents: write
+
+jobs:
+ release:
+ name: Build and publish
+ runs-on: macos-26
+ timeout-minutes: 20
+ env:
+ DEVELOPER_DIR: /Applications/Xcode_26.6.app/Contents/Developer
+ steps:
+ - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
+ - name: Verify release tag
+ run: |
+ version=$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' Resources/RemoMouseInfo.plist)
+ test "$GITHUB_REF_NAME" = "v$version"
+ - name: Test
+ run: swift test --parallel
+ - name: Build app
+ run: Scripts/package-app.sh release
+ - name: Verify signature
+ run: codesign --verify --deep --strict .build/RemoMouse.app
+ - name: Archive and checksum
+ run: |
+ ditto -c -k --sequesterRsrc --keepParent .build/RemoMouse.app "RemoMouse-${GITHUB_REF_NAME}-macOS.zip"
+ shasum -a 256 "RemoMouse-${GITHUB_REF_NAME}-macOS.zip" > "RemoMouse-${GITHUB_REF_NAME}-macOS.zip.sha256"
+ - name: Publish GitHub Release
+ env:
+ GH_TOKEN: ${{ github.token }}
+ run: |
+ gh release create "$GITHUB_REF_NAME" \
+ "RemoMouse-${GITHUB_REF_NAME}-macOS.zip" \
+ "RemoMouse-${GITHUB_REF_NAME}-macOS.zip.sha256" \
+ --title "RemoMouse $GITHUB_REF_NAME" \
+ --generate-notes \
+ --prerelease
diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md
new file mode 100644
index 0000000..aefed24
--- /dev/null
+++ b/CODE_OF_CONDUCT.md
@@ -0,0 +1,5 @@
+# Code of Conduct
+
+RemoMouse follows the [Contributor Covenant, version 2.1](https://www.contributor-covenant.org/version/2/1/code_of_conduct/).
+
+Be respectful, constructive, and focused on the work. Report unacceptable behavior privately through the repository owner's GitHub profile. Maintainers may remove content or restrict participation when necessary to protect the community.
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 0000000..f7bd98c
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,14 @@
+# Contributing
+
+Thanks for improving RemoMouse. Keep changes focused, local-only, and safe for an always-running input utility.
+
+## Development
+
+1. Fork the repository and create a focused branch.
+2. Add or update tests before production behavior.
+3. Run `swift test --parallel` and `Scripts/package-app.sh debug`.
+4. Open a pull request using the repository template.
+
+Hardware reports must remove serial numbers, Bluetooth addresses, usernames, and private filesystem paths. Do not commit signing identities, provisioning profiles, captures, or generated app bundles.
+
+By contributing, you agree that your contribution is licensed under the MIT License.
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..260b197
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 kefrulz
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/SECURITY.md b/SECURITY.md
new file mode 100644
index 0000000..ff11310
--- /dev/null
+++ b/SECURITY.md
@@ -0,0 +1,11 @@
+# Security Policy
+
+## Supported versions
+
+Security fixes are applied to the latest published release and the `main` branch.
+
+## Reporting a vulnerability
+
+Do not open a public issue for a vulnerability or privacy leak. Use [GitHub private vulnerability reporting](https://github.com/kefrulz/RemoMouse/security/advisories/new) and include reproduction steps, impact, and the affected version.
+
+RemoMouse generates system input and uses Accessibility permission. Reports involving unintended input, permission handling, device spoofing, sensitive diagnostics, or release integrity are treated as security issues.
From 99a07fa392275f86846462c83e3c9206d5ac6922 Mon Sep 17 00:00:00 2001
From: kefrulz <9220848+kefrulz@users.noreply.github.com>
Date: Sun, 9 Aug 2026 22:55:47 +0200
Subject: [PATCH 20/20] Harden pointer controls for beta release
---
.github/workflows/release.yml | 3 +-
CHANGELOG.md | 3 +-
README.md | 4 +-
Sources/CMultitouchBridge/CMultitouchBridge.c | 22 +++--
Sources/RemoMouseApp/PointerController.swift | 93 +++++++++++++++----
Sources/RemoMouseApp/RemoMouseModel.swift | 1 +
.../SiriRemoteClickSequence.swift | 50 ++++++++++
.../SiriRemoteTouchIntentFilter.swift | 17 +++-
.../SiriRemoteReportDecoderTests.swift | 37 +++++++-
9 files changed, 197 insertions(+), 33 deletions(-)
create mode 100644 Sources/RemoMouseHardware/SiriRemoteClickSequence.swift
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index fae745d..69aa494 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -30,7 +30,7 @@ jobs:
run: |
ditto -c -k --sequesterRsrc --keepParent .build/RemoMouse.app "RemoMouse-${GITHUB_REF_NAME}-macOS.zip"
shasum -a 256 "RemoMouse-${GITHUB_REF_NAME}-macOS.zip" > "RemoMouse-${GITHUB_REF_NAME}-macOS.zip.sha256"
- - name: Publish GitHub Release
+ - name: Create draft GitHub Release
env:
GH_TOKEN: ${{ github.token }}
run: |
@@ -39,4 +39,5 @@ jobs:
"RemoMouse-${GITHUB_REF_NAME}-macOS.zip.sha256" \
--title "RemoMouse $GITHUB_REF_NAME" \
--generate-notes \
+ --draft \
--prerelease
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2c3036a..8f6c462 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,7 +5,8 @@ All notable user-visible changes are documented here. This project follows [Sema
## 0.1.0 — 2026-08-09
- Add a native macOS menu-bar application for first-generation Siri Remote hardware.
-- Add touch-surface pointer movement, primary click, secondary click, and scroll mode.
+- Add touch-surface pointer movement, primary and secondary click, macOS-style double-click, drag, and scroll mode.
- Add continuous pixel scrolling with gesture phases, fractional smoothing, bounded acceleration, cancellable momentum, axis locking, dead-zone filtering, and click stabilization.
- Add Play/Pause mode switching, Siri pause/resume, speed controls, and Mission Control mapping.
- Add local hardware diagnostics with privacy redaction.
+- Re-enumerate touch input after the remote disconnects and reconnects.
diff --git a/README.md b/README.md
index b92eaf1..54da1f5 100644
--- a/README.md
+++ b/README.md
@@ -1,11 +1,13 @@
# RemoMouse
-RemoMouse turns a first-generation Siri Remote into a precise mouse and compact controller for macOS Tahoe. It is native, local-only, and open source.
+RemoMouse turns a first-generation Siri Remote into a precise pointer and system controller for macOS Tahoe. It is native, local-only, and open source.
## Status
RemoMouse 0.1.0 is a public beta. It runs in the menu bar and supports touch pointer movement, click and drag, secondary click, continuous precision scrolling with momentum, pointer/scroll modes, speed control, Mission Control, and pause/resume.
+This beta intentionally ships the dependable core before customization. Per-app profiles, editable button mappings, launch at login, onboarding, a settings window, notarization, and Air Pointer are deferred and are not advertised as complete.
+
## Download
Download the latest app and checksum from [GitHub Releases](https://github.com/kefrulz/RemoMouse/releases). See the [installation guide](docs/INSTALLATION.md) for pairing, Accessibility permission, and Gatekeeper instructions.
diff --git a/Sources/CMultitouchBridge/CMultitouchBridge.c b/Sources/CMultitouchBridge/CMultitouchBridge.c
index 75c1739..5309819 100644
--- a/Sources/CMultitouchBridge/CMultitouchBridge.c
+++ b/Sources/CMultitouchBridge/CMultitouchBridge.c
@@ -35,7 +35,6 @@ typedef int32_t (*StartFunction)(MTDeviceRef, int32_t);
static RMTouchCallback outputCallback = NULL;
static void *outputContext = NULL;
static CFMutableArrayRef retainedDevices = NULL;
-static bool remoteStarted = false;
static bool numberPropertyEquals(io_service_t service, CFStringRef key, int expected) {
CFTypeRef property = IORegistryEntrySearchCFProperty(
@@ -69,7 +68,6 @@ static void receiveFrame(MTDeviceRef device, RMTouch *touches, size_t count,
bool RMStartRemoteTouch(RMTouchCallback callback, void *context) {
outputCallback = callback;
outputContext = context;
- if (remoteStarted) return true;
void *framework = dlopen(
"/System/Library/PrivateFrameworks/MultitouchSupport.framework/MultitouchSupport",
@@ -85,8 +83,11 @@ bool RMStartRemoteTouch(RMTouchCallback callback, void *context) {
CFArrayRef devices = createList();
if (!devices) return false;
- if (retainedDevices) CFRelease(retainedDevices);
- retainedDevices = CFArrayCreateMutable(kCFAllocatorDefault, 0, &kCFTypeArrayCallBacks);
+ CFMutableArrayRef startedDevices = CFArrayCreateMutable(
+ kCFAllocatorDefault,
+ 0,
+ &kCFTypeArrayCallBacks
+ );
bool started = false;
CFIndex count = CFArrayGetCount(devices);
@@ -98,12 +99,19 @@ bool RMStartRemoteTouch(RMTouchCallback callback, void *context) {
bool productMatches = numberPropertyEquals(service, CFSTR("ProductID"), 621);
if (!vendorMatches || !productMatches) continue;
- CFArrayAppendValue(retainedDevices, device);
registerFrame(device, receiveFrame, context);
int32_t status = startDevice(device, 0);
- if (status == 0) started = true;
+ if (status == 0) {
+ CFArrayAppendValue(startedDevices, device);
+ started = true;
+ }
}
CFRelease(devices);
- remoteStarted = started;
+ if (started) {
+ if (retainedDevices) CFRelease(retainedDevices);
+ retainedDevices = startedDevices;
+ } else {
+ CFRelease(startedDevices);
+ }
return started;
}
diff --git a/Sources/RemoMouseApp/PointerController.swift b/Sources/RemoMouseApp/PointerController.swift
index df5f002..433f8e8 100644
--- a/Sources/RemoMouseApp/PointerController.swift
+++ b/Sources/RemoMouseApp/PointerController.swift
@@ -17,6 +17,11 @@ final class PointerController {
private var lastButtonMask: UInt8 = 0
private var scrollFilter = SiriRemoteScrollFilter()
private var clickGuard = SiriRemoteClickGuard()
+ private var clickSequence = SiriRemoteClickSequence(
+ interval: NSEvent.doubleClickInterval,
+ maximumDistance: 4
+ )
+ private var leftClickState = 1
private var touchIntentFilter = SiriRemoteTouchIntentFilter()
private var scrollGestureActive = false
private var scrollMomentumTask: Task?
@@ -95,7 +100,7 @@ final class PointerController {
)
scrollGestureActive = true
}
- } else {
+ } else if touchIntent == .pointer {
let distance = hypot(dx, dy)
let acceleration = 1 + min(distance * 14, 2.5)
move(dx: dx * speed * 120 * acceleration,
@@ -105,9 +110,11 @@ final class PointerController {
}
func showMissionControl() {
- guard isTrusted else { return }
- CGEvent(keyboardEventSource: nil, virtualKey: 160, keyDown: true)?.post(tap: .cghidEventTap)
- CGEvent(keyboardEventSource: nil, virtualKey: 160, keyDown: false)?.post(tap: .cghidEventTap)
+ let url = URL(fileURLWithPath: "/System/Applications/Mission Control.app")
+ NSWorkspace.shared.openApplication(
+ at: url,
+ configuration: NSWorkspace.OpenConfiguration()
+ )
}
func releaseAll() {
@@ -116,7 +123,9 @@ final class PointerController {
scroll(.zero, phase: .ended, allowZero: true)
scrollGestureActive = false
}
- if lastButtonMask & 0x80 != 0 { postMouse(.leftMouseUp, button: .left) }
+ if lastButtonMask & 0x80 != 0 {
+ postMouse(.leftMouseUp, button: .left, clickState: leftClickState)
+ }
if lastButtonMask & 0x20 != 0 { postMouse(.rightMouseUp, button: .right) }
lastButtonMask = 0
lastTouch = nil
@@ -126,6 +135,8 @@ final class PointerController {
scrollFilter.reset()
touchIntentFilter.reset()
clickGuard.setPressed(false, timestamp: ProcessInfo.processInfo.systemUptime)
+ clickSequence.reset()
+ leftClickState = 1
}
private func handleTouch(_ touch: SiriRemoteTouch) {
@@ -151,11 +162,31 @@ final class PointerController {
let changed = mask ^ lastButtonMask
if changed & 0x80 != 0 {
cancelScrollMomentum()
+ let timestamp = ProcessInfo.processInfo.systemUptime
+ let position = currentPointerLocation()
+ if mask & 0x80 != 0 {
+ leftClickState = clickSequence.press(
+ timestamp: timestamp,
+ x: position.x,
+ y: position.y
+ )
+ }
clickGuard.setPressed(
mask & 0x80 != 0,
- timestamp: ProcessInfo.processInfo.systemUptime
+ timestamp: timestamp
)
- postMouse(mask & 0x80 != 0 ? .leftMouseDown : .leftMouseUp, button: .left)
+ postMouse(
+ mask & 0x80 != 0 ? .leftMouseDown : .leftMouseUp,
+ button: .left,
+ clickState: leftClickState
+ )
+ if mask & 0x80 == 0 {
+ _ = clickSequence.release(
+ timestamp: timestamp,
+ x: position.x,
+ y: position.y
+ )
+ }
}
if changed & 0x20 != 0 {
postMouse(mask & 0x20 != 0 ? .rightMouseDown : .rightMouseUp, button: .right)
@@ -170,21 +201,41 @@ final class PointerController {
private func move(dx: Double, dy: Double) {
guard isTrusted else { return }
- let current = NSEvent.mouseLocation
- let screenHeight = NSScreen.screens.first?.frame.height ?? 0
- let quartzCurrent = CGPoint(x: current.x, y: screenHeight - current.y)
- let target = CGPoint(x: quartzCurrent.x + dx, y: quartzCurrent.y + dy)
- CGEvent(mouseEventSource: nil, mouseType: .mouseMoved, mouseCursorPosition: target, mouseButton: .left)?
+ let current = currentPointerLocation()
+ let target = CGPoint(x: current.x + dx, y: current.y + dy)
+ let motion = SiriRemotePointerMotion(buttonMask: lastButtonMask)
+ let eventType: CGEventType
+ let button: CGMouseButton
+ switch motion {
+ case .moved:
+ eventType = .mouseMoved
+ button = .left
+ case .leftDragged:
+ eventType = .leftMouseDragged
+ button = .left
+ case .rightDragged:
+ eventType = .rightMouseDragged
+ button = .right
+ }
+ CGEvent(mouseEventSource: nil, mouseType: eventType, mouseCursorPosition: target, mouseButton: button)?
.post(tap: .cghidEventTap)
}
- private func postMouse(_ type: CGEventType, button: CGMouseButton) {
+ private func postMouse(_ type: CGEventType, button: CGMouseButton, clickState: Int = 1) {
guard isTrusted else { return }
- let current = NSEvent.mouseLocation
- let screenHeight = NSScreen.screens.first?.frame.height ?? 0
- let position = CGPoint(x: current.x, y: screenHeight - current.y)
- CGEvent(mouseEventSource: nil, mouseType: type, mouseCursorPosition: position, mouseButton: button)?
- .post(tap: .cghidEventTap)
+ let position = currentPointerLocation()
+ guard let event = CGEvent(
+ mouseEventSource: nil,
+ mouseType: type,
+ mouseCursorPosition: position,
+ mouseButton: button
+ ) else { return }
+ event.setIntegerValueField(.mouseEventClickState, value: Int64(clickState))
+ event.post(tap: .cghidEventTap)
+ }
+
+ private func currentPointerLocation() -> CGPoint {
+ CGEvent(source: nil)?.location ?? .zero
}
@@ -205,6 +256,7 @@ final class PointerController {
scrollMomentumTask = Task { @MainActor [weak self] in
var momentum = initialMomentum
var phase = NSEvent.Phase.began
+ var lastTick = ProcessInfo.processInfo.systemUptime
while !Task.isCancelled {
do {
@@ -212,7 +264,10 @@ final class PointerController {
} catch {
return
}
- guard let delta = momentum.step(elapsed: 0.008) else {
+ let now = ProcessInfo.processInfo.systemUptime
+ let elapsed = max(now - lastTick, 0)
+ lastTick = now
+ guard let delta = momentum.step(elapsed: elapsed) else {
self?.scroll(.zero, momentumPhase: .ended, allowZero: true)
self?.scrollMomentumTask = nil
return
diff --git a/Sources/RemoMouseApp/RemoMouseModel.swift b/Sources/RemoMouseApp/RemoMouseModel.swift
index 27a3712..7365433 100644
--- a/Sources/RemoMouseApp/RemoMouseModel.swift
+++ b/Sources/RemoMouseApp/RemoMouseModel.swift
@@ -118,6 +118,7 @@ final class RemoMouseModel {
startTouchIfNeeded()
case .disconnected:
connection = .sleeping
+ isTouchStarted = false
pointer.releaseAll()
case let .frame(frame):
connection = .connected
diff --git a/Sources/RemoMouseHardware/SiriRemoteClickSequence.swift b/Sources/RemoMouseHardware/SiriRemoteClickSequence.swift
new file mode 100644
index 0000000..ec8ae95
--- /dev/null
+++ b/Sources/RemoMouseHardware/SiriRemoteClickSequence.swift
@@ -0,0 +1,50 @@
+import Foundation
+
+public struct SiriRemoteClickSequence: Sendable {
+ private let interval: TimeInterval
+ private let maximumDistance: Double
+ private var lastRelease: (timestamp: TimeInterval, x: Double, y: Double)?
+ private var currentCount = 1
+
+ public init(interval: TimeInterval, maximumDistance: Double) {
+ self.interval = interval
+ self.maximumDistance = maximumDistance
+ }
+
+ public mutating func press(timestamp: TimeInterval, x: Double, y: Double) -> Int {
+ if let lastRelease,
+ timestamp - lastRelease.timestamp <= interval,
+ hypot(x - lastRelease.x, y - lastRelease.y) <= maximumDistance {
+ currentCount = min(currentCount + 1, 3)
+ } else {
+ currentCount = 1
+ }
+ return currentCount
+ }
+
+ public mutating func release(timestamp: TimeInterval, x: Double, y: Double) -> Int {
+ lastRelease = (timestamp, x, y)
+ return currentCount
+ }
+
+ public mutating func reset() {
+ lastRelease = nil
+ currentCount = 1
+ }
+}
+
+public enum SiriRemotePointerMotion: Equatable, Sendable {
+ case moved
+ case leftDragged
+ case rightDragged
+
+ public init(buttonMask: UInt8) {
+ if buttonMask & 0x80 != 0 {
+ self = .leftDragged
+ } else if buttonMask & 0x20 != 0 {
+ self = .rightDragged
+ } else {
+ self = .moved
+ }
+ }
+}
diff --git a/Sources/RemoMouseHardware/SiriRemoteTouchIntentFilter.swift b/Sources/RemoMouseHardware/SiriRemoteTouchIntentFilter.swift
index 7d67317..91e8a6c 100644
--- a/Sources/RemoMouseHardware/SiriRemoteTouchIntentFilter.swift
+++ b/Sources/RemoMouseHardware/SiriRemoteTouchIntentFilter.swift
@@ -1,4 +1,7 @@
+import Foundation
+
public enum SiriRemoteTouchIntent: Equatable, Sendable {
+ case pending
case pointer
case verticalScroll
case ended
@@ -26,7 +29,7 @@ public struct SiriRemoteTouchIntentFilter: Sendable {
guard let origin else {
self.origin = (x, y)
isEdgeEligible = x >= 1 - edgeWidth
- return .pointer
+ return isEdgeEligible ? .pending : .pointer
}
switch lock {
@@ -35,7 +38,10 @@ public struct SiriRemoteTouchIntentFilter: Sendable {
case .undecided: break
}
- guard isEdgeEligible else { return .pointer }
+ guard isEdgeEligible else {
+ lock = .pointer
+ return .pointer
+ }
let dx = x - origin.x
let dy = y - origin.y
if abs(dy) >= activationDistance, abs(dy) >= abs(dx) * 1.5 {
@@ -44,8 +50,13 @@ public struct SiriRemoteTouchIntentFilter: Sendable {
}
if abs(dx) >= activationDistance, abs(dx) > abs(dy) * 1.5 {
lock = .pointer
+ return .pointer
+ }
+ if hypot(dx, dy) >= activationDistance * 2 {
+ lock = .pointer
+ return .pointer
}
- return .pointer
+ return .pending
}
public mutating func reset() {
diff --git a/Tests/RemoMouseHardwareTests/SiriRemoteReportDecoderTests.swift b/Tests/RemoMouseHardwareTests/SiriRemoteReportDecoderTests.swift
index 3b71ceb..ca33035 100644
--- a/Tests/RemoMouseHardwareTests/SiriRemoteReportDecoderTests.swift
+++ b/Tests/RemoMouseHardwareTests/SiriRemoteReportDecoderTests.swift
@@ -108,14 +108,49 @@ import Testing
@Test func rightEdgeVerticalGestureLocksToScrollForContactLifetime() {
var filter = SiriRemoteTouchIntentFilter(edgeWidth: 0.18, activationDistance: 0.024)
- #expect(filter.update(x: 0.90, y: 0.20, isContact: true) == .pointer)
+ #expect(filter.update(x: 0.90, y: 0.20, isContact: true) == .pending)
+ #expect(filter.update(x: 0.90, y: 0.21, isContact: true) == .pending)
#expect(filter.update(x: 0.91, y: 0.25, isContact: true) == .verticalScroll)
#expect(filter.update(x: 0.70, y: 0.30, isContact: true) == .verticalScroll)
#expect(filter.update(x: 0.70, y: 0.30, isContact: false) == .ended)
}
+@Test func rightEdgeHorizontalGestureLocksToPointer() {
+ var filter = SiriRemoteTouchIntentFilter(edgeWidth: 0.18, activationDistance: 0.024)
+ #expect(filter.update(x: 0.90, y: 0.20, isContact: true) == .pending)
+ #expect(filter.update(x: 0.94, y: 0.20, isContact: true) == .pointer)
+ #expect(filter.update(x: 0.95, y: 0.25, isContact: true) == .pointer)
+}
+
@Test func centerGestureNeverBecomesEdgeScroll() {
var filter = SiriRemoteTouchIntentFilter(edgeWidth: 0.18, activationDistance: 0.024)
_ = filter.update(x: 0.50, y: 0.20, isContact: true)
#expect(filter.update(x: 0.50, y: 0.40, isContact: true) == .pointer)
}
+
+
+@Test func clickSequenceUsesSystemStyleCountsWithinTimeAndDistance() {
+ var sequence = SiriRemoteClickSequence(interval: 0.5, maximumDistance: 4)
+
+ #expect(sequence.press(timestamp: 10, x: 100, y: 100) == 1)
+ #expect(sequence.release(timestamp: 10.05, x: 100, y: 100) == 1)
+ #expect(sequence.press(timestamp: 10.30, x: 102, y: 101) == 2)
+ #expect(sequence.release(timestamp: 10.35, x: 102, y: 101) == 2)
+ #expect(sequence.press(timestamp: 10.60, x: 102, y: 101) == 3)
+}
+
+@Test func clickSequenceResetsAfterDelayOrPointerTravel() {
+ var sequence = SiriRemoteClickSequence(interval: 0.5, maximumDistance: 4)
+
+ _ = sequence.press(timestamp: 10, x: 100, y: 100)
+ _ = sequence.release(timestamp: 10.05, x: 100, y: 100)
+ #expect(sequence.press(timestamp: 10.60, x: 100, y: 100) == 1)
+ _ = sequence.release(timestamp: 10.65, x: 100, y: 100)
+ #expect(sequence.press(timestamp: 10.80, x: 110, y: 100) == 1)
+}
+
+@Test func pointerMotionReflectsHeldGeneratedButton() {
+ #expect(SiriRemotePointerMotion(buttonMask: 0) == .moved)
+ #expect(SiriRemotePointerMotion(buttonMask: 0x80) == .leftDragged)
+ #expect(SiriRemotePointerMotion(buttonMask: 0x20) == .rightDragged)
+}