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/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/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/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/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..06c5c4d --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,36 @@ +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@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + - name: Show Swift version + run: swift --version + - name: Run tests + run: swift test --parallel + - 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..69aa494 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,43 @@ +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: Create draft 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 \ + --draft \ + --prerelease 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/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..8f6c462 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,12 @@ +# 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 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/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/Package.swift b/Package.swift new file mode 100644 index 0000000..03ee02a --- /dev/null +++ b/Package.swift @@ -0,0 +1,72 @@ +// 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"]), + .executable(name: "RemoMouse", targets: ["RemoMouseApp"]), + ], + targets: [ + .target( + name: "RemoMouseDomain", + linkerSettings: [.linkedFramework("CryptoKit")] + ), + .target( + name: "RemoMouseHardware", + dependencies: ["RemoMouseDomain", "CMultitouchBridge"], + linkerSettings: [ + .linkedFramework("CoreHID"), + .linkedFramework("GameController"), + .linkedFramework("IOKit"), + ] + ), + .target( + name: "CMultitouchBridge", + linkerSettings: [.linkedFramework("IOKit")] + ), + .target( + name: "RemoMouseCapture", + dependencies: ["RemoMouseDomain", "RemoMouseHardware"], + linkerSettings: [.linkedFramework("CryptoKit")] + ), + .executableTarget( + name: "RemoMouseProbe", + dependencies: [ + "RemoMouseDomain", + "RemoMouseHardware", + "RemoMouseCapture", + ], + linkerSettings: [ + .linkedFramework("AppKit"), + .linkedFramework("SwiftUI"), + ] + ), + .executableTarget( + name: "RemoMouseApp", + dependencies: ["RemoMouseHardware"], + linkerSettings: [ + .linkedFramework("AppKit"), + .linkedFramework("ApplicationServices"), + .linkedFramework("SwiftUI"), + ] + ), + .testTarget( + name: "RemoMouseDomainTests", + dependencies: ["RemoMouseDomain"] + ), + .testTarget( + name: "RemoMouseHardwareTests", + dependencies: ["RemoMouseHardware"] + ), + .testTarget( + name: "RemoMouseCaptureTests", + dependencies: ["RemoMouseCapture"] + ), + ] +) diff --git a/README.md b/README.md new file mode 100644 index 0000000..54da1f5 --- /dev/null +++ b/README.md @@ -0,0 +1,70 @@ +# RemoMouse + +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. + +## Requirements + +- macOS Tahoe 26.0 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 +swift test --parallel +Scripts/package-app.sh release +open .build/RemoMouse.app +``` + +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/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/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/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. 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/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/CMultitouchBridge/CMultitouchBridge.c b/Sources/CMultitouchBridge/CMultitouchBridge.c new file mode 100644 index 0000000..5309819 --- /dev/null +++ b/Sources/CMultitouchBridge/CMultitouchBridge.c @@ -0,0 +1,117 @@ +#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 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; + + 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; + CFMutableArrayRef startedDevices = 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; + + registerFrame(device, receiveFrame, context); + int32_t status = startDevice(device, 0); + if (status == 0) { + CFArrayAppendValue(startedDevices, device); + started = true; + } + } + CFRelease(devices); + if (started) { + if (retainedDevices) CFRelease(retainedDevices); + retainedDevices = startedDevices; + } else { + CFRelease(startedDevices); + } + 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 new file mode 100644 index 0000000..433f8e8 --- /dev/null +++ b/Sources/RemoMouseApp/PointerController.swift @@ -0,0 +1,318 @@ +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() + 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? + + 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 handle(_ sample: SiriRemoteMultitouchSample, mode: RemoMouseInputMode) { + let timestamp = ProcessInfo.processInfo.systemUptime + guard sample.isContact else { + finishScrollGesture(timestamp: timestamp) + lastMultitouch = nil + touchIntentFilter.reset() + return + } + let touchIntent = touchIntentFilter.update( + x: sample.x, + y: sample.y, + isContact: true + ) + guard let prior = lastMultitouch else { + cancelScrollMomentum() + 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: timestamp) { + lastMultitouch = sample + return + } + + if sample.touchCount > 1 || mode == .scroll || touchIntent == .verticalScroll { + 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 if touchIntent == .pointer { + 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() { + let url = URL(fileURLWithPath: "/System/Applications/Mission Control.app") + NSWorkspace.shared.openApplication( + at: url, + configuration: NSWorkspace.OpenConfiguration() + ) + } + + func releaseAll() { + cancelScrollMomentum() + if scrollGestureActive { + scroll(.zero, phase: .ended, allowZero: true) + scrollGestureActive = false + } + if lastButtonMask & 0x80 != 0 { + postMouse(.leftMouseUp, button: .left, clickState: leftClickState) + } + if lastButtonMask & 0x20 != 0 { postMouse(.rightMouseUp, button: .right) } + lastButtonMask = 0 + lastTouch = nil + lastX = nil + lastY = nil + lastMultitouch = nil + scrollFilter.reset() + touchIntentFilter.reset() + clickGuard.setPressed(false, timestamp: ProcessInfo.processInfo.systemUptime) + clickSequence.reset() + leftClickState = 1 + } + + 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 { + 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: timestamp + ) + 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) + } + 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 = 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, clickState: Int = 1) { + guard isTrusted else { return } + 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 + } + + + 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 + var lastTick = ProcessInfo.processInfo.systemUptime + + while !Task.isCancelled { + do { + try await Task.sleep(for: .milliseconds(8)) + } catch { + return + } + 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 + } + 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 allowZero || vertical != 0 || horizontal != 0 else { return } + guard let event = CGEvent( + scrollWheelEvent2Source: nil, + units: .pixel, + wheelCount: 2, + wheel1: vertical, + wheel2: horizontal, + wheel3: 0 + ) 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/Sources/RemoMouseApp/RemoMouseApp.swift b/Sources/RemoMouseApp/RemoMouseApp.swift new file mode 100644 index 0000000..7cfdfa1 --- /dev/null +++ b/Sources/RemoMouseApp/RemoMouseApp.swift @@ -0,0 +1,91 @@ +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) + } + + LabeledContent("Mode", value: model.mode.rawValue) + + Text(model.lastInput) + .font(.caption) + .foregroundStyle(.secondary) + + 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() + } + 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..7365433 --- /dev/null +++ b/Sources/RemoMouseApp/RemoMouseModel.swift @@ -0,0 +1,229 @@ +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 + 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 } + + init() { + monitor = SiriRemoteHIDMonitor { [weak self] event in + Task { @MainActor in self?.handle(event) } + } + monitor?.start() + multitouchMonitor = SiriRemoteMultitouchMonitor { [weak self] sample in + Task { @MainActor in self?.handle(sample) } + } + startTouchIfNeeded() + if ProcessInfo.processInfo.arguments.contains("--capture-motion") { + Task { @MainActor [weak self] in + self?.startMotionCapture() + } + } + } + + func toggle() { + isEnabled.toggle() + if !isEnabled { pointer.releaseAll() } + } + + 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: + connection = .connected + startTouchIfNeeded() + case .disconnected: + connection = .sleeping + isTouchStarted = false + 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 + lastInput = "Touch input received" + 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 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 { + 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/RemoMouseCapture/CaptureDocument.swift b/Sources/RemoMouseCapture/CaptureDocument.swift new file mode 100644 index 0000000..e0ec2a6 --- /dev/null +++ b/Sources/RemoMouseCapture/CaptureDocument.swift @@ -0,0 +1,132 @@ +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 { + 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).. 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/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/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/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 new file mode 100644 index 0000000..daf5693 --- /dev/null +++ b/Sources/RemoMouseDomain/DeviceIdentity.swift @@ -0,0 +1,17 @@ +public enum RemoteGeneration: String, Codable, Sendable { + case firstGeneration + + 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/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/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/Sources/RemoMouseHardware/CoreHIDInventory.swift b/Sources/RemoMouseHardware/CoreHIDInventory.swift new file mode 100644 index 0000000..8d05f5c --- /dev/null +++ b/Sources/RemoMouseHardware/CoreHIDInventory.swift @@ -0,0 +1,242 @@ +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 { + !isBuiltIn && (vendorID == 1452 || (vendorID == 76 && productID == 621)) + } +} + +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(vendorID: 76, productID: 621), + ] + ) + 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 summary = HIDDeviceSummary( + deviceToken: token, + vendorID: 76, + productID: 621, + isBuiltIn: false, + transport: "Bluetooth Low Energy", + product: "First-generation Siri Remote" + ) + 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: [] + ) + 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) + } + +} 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/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/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/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/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/SiriRemoteHIDMonitor.swift b/Sources/RemoMouseHardware/SiriRemoteHIDMonitor.swift new file mode 100644 index 0000000..50c0cdb --- /dev/null +++ b/Sources/RemoMouseHardware/SiriRemoteHIDMonitor.swift @@ -0,0 +1,139 @@ +@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, + kIOHIDPrimaryUsagePageKey: 12, + kIOHIDPrimaryUsageKey: 1, + ] + 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(kIOHIDOptionsTypeSeizeDevice)) + } + + 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/SiriRemoteInputFilters.swift b/Sources/RemoMouseHardware/SiriRemoteInputFilters.swift new file mode 100644 index 0000000..3245a11 --- /dev/null +++ b/Sources/RemoMouseHardware/SiriRemoteInputFilters.swift @@ -0,0 +1,149 @@ +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 + 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, + timestamp: TimeInterval? = nil + ) -> 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 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 + 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() { + self = SiriRemoteScrollFilter() + } +} + +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? + + 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/SiriRemoteMotionCapture.swift b/Sources/RemoMouseHardware/SiriRemoteMotionCapture.swift new file mode 100644 index 0000000..8855a9b --- /dev/null +++ b/Sources/RemoMouseHardware/SiriRemoteMotionCapture.swift @@ -0,0 +1,71 @@ +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 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 + 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..84ee32b --- /dev/null +++ b/Sources/RemoMouseHardware/SiriRemoteMotionMonitor.swift @@ -0,0 +1,155 @@ +@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 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)) + 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/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/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/Sources/RemoMouseHardware/SiriRemoteTouchIntentFilter.swift b/Sources/RemoMouseHardware/SiriRemoteTouchIntentFilter.swift new file mode 100644 index 0000000..91e8a6c --- /dev/null +++ b/Sources/RemoMouseHardware/SiriRemoteTouchIntentFilter.swift @@ -0,0 +1,67 @@ +import Foundation + +public enum SiriRemoteTouchIntent: Equatable, Sendable { + case pending + 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 isEdgeEligible ? .pending : .pointer + } + + switch lock { + case .pointer: return .pointer + case .scroll: return .verticalScroll + case .undecided: break + } + + 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 { + lock = .scroll + return .verticalScroll + } + 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 .pending + } + + public mutating func reset() { + origin = nil + isEdgeEligible = false + lock = .undecided + } +} diff --git a/Sources/RemoMouseProbe/ProbeModel.swift b/Sources/RemoMouseProbe/ProbeModel.swift new file mode 100644 index 0000000..289f121 --- /dev/null +++ b/Sources/RemoMouseProbe/ProbeModel.swift @@ -0,0 +1,239 @@ +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 { + 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, + 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 new file mode 100644 index 0000000..cff0f07 --- /dev/null +++ b/Sources/RemoMouseProbe/ProbeView.swift @@ -0,0 +1,157 @@ +import RemoMouseDomain +import SwiftUI + +struct ProbeView: View { + @State private var model = ProbeModel() + + var body: some View { + 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") + ) + .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 new file mode 100644 index 0000000..4286f91 --- /dev/null +++ b/Sources/RemoMouseProbe/RemoMouseProbeApp.swift @@ -0,0 +1,12 @@ +import SwiftUI + +@main +struct RemoMouseProbeApp: App { + var body: some Scene { + WindowGroup("RemoMouse Hardware Probe") { + ProbeView() + } + .defaultSize(width: 740, height: 760) + .windowResizability(.contentMinSize) + } +} 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..6d7ba75 --- /dev/null +++ b/Tests/RemoMouseCaptureTests/ProbeReportRendererTests.swift @@ -0,0 +1,82 @@ +import Foundation +import RemoMouseCapture +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"), + 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")) +} 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/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)") +} 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)) +} 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 == "") +} diff --git a/Tests/RemoMouseHardwareTests/CoreHIDInventoryTests.swift b/Tests/RemoMouseHardwareTests/CoreHIDInventoryTests.swift new file mode 100644 index 0000000..147f43a --- /dev/null +++ b/Tests/RemoMouseHardwareTests/CoreHIDInventoryTests.swift @@ -0,0 +1,44 @@ +import Foundation +import RemoMouseHardware +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) +} + +@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) +} 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 + )), + ]) +} diff --git a/Tests/RemoMouseHardwareTests/SiriRemoteMotionCaptureTests.swift b/Tests/RemoMouseHardwareTests/SiriRemoteMotionCaptureTests.swift new file mode 100644 index 0000000..7098be1 --- /dev/null +++ b/Tests/RemoMouseHardwareTests/SiriRemoteMotionCaptureTests.swift @@ -0,0 +1,41 @@ +import Foundation +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) +} + +@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/Tests/RemoMouseHardwareTests/SiriRemoteReportDecoderTests.swift b/Tests/RemoMouseHardwareTests/SiriRemoteReportDecoderTests.swift new file mode 100644 index 0000000..ca33035 --- /dev/null +++ b/Tests/RemoMouseHardwareTests/SiriRemoteReportDecoderTests.swift @@ -0,0 +1,156 @@ +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) +} + +@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 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) + + 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) == .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) +} 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. 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/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. 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" +``` 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/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("