diff --git a/Package.swift b/Package.swift index 03ee02a..6d4322c 100644 --- a/Package.swift +++ b/Package.swift @@ -68,5 +68,9 @@ let package = Package( name: "RemoMouseCaptureTests", dependencies: ["RemoMouseCapture"] ), + .testTarget( + name: "RemoMouseAppTests", + dependencies: ["RemoMouseApp"] + ), ] ) diff --git a/README.md b/README.md index 54da1f5..f724d5a 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,12 @@ # 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. +RemoMouse turns a first-generation Siri Remote into a one-handed 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. +RemoMouse 0.1.0 is the current public beta. The repository's development build adds a redesigned right-thumb experience: adaptive pointer filtering, click anchoring, click-and-drag, a dedicated right-edge scroll rail, trackpad-style scroll phases and momentum, persistent settings, and guided personal calibration. -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. +The menu-bar popover keeps everyday controls compact. The native Settings window contains Pointer & Click, Scrolling, Buttons, Air Pointer, Keyboard, and Diagnostics sections. Per-app profiles, editable button mappings, launch at login, notarization, and automatic updates remain future work. ## Download @@ -23,14 +23,19 @@ Building from source additionally requires Xcode 26.6 or newer. | Remote input | Action | | --- | --- | -| Touch | Move pointer; swipe the right edge or use two fingers for trackpad-style scrolling | -| Touch click | Primary click and drag | +| One-thumb touch | Move the pointer with adaptive precision | +| Physical touch-surface click | Primary click, anchored so the pointer does not jump | +| Click and move | Drag with the primary button held | +| Right-edge thumb gesture | Smooth scrolling with momentum | +| Two-finger touch | Optional precision scrolling | | Menu | Secondary click | | Play/Pause | Toggle Pointer and Scroll modes | | Volume | Adjust pointer speed | | Home/TV | Mission Control | | Siri | Pause/resume | +The default profile is optimized for holding the remote in the right hand and operating it with one thumb. Open **Settings → Pointer & Click → Calibrate** for a guided six-step personal fit. Calibration can be skipped; conservative defaults are applied immediately. + ## Build and test ```bash @@ -39,13 +44,13 @@ 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. +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; the input service retries when the remote becomes active again. ## 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. +Air Pointer appears only when macOS delivers real motion samples from the connected remote. It stays unavailable when the hardware or operating system exposes no usable motion stream; touch and button input continue normally. The evidence and release gate are documented in the [motion capability report](docs/hardware/first-generation-motion-report.md). ## Privacy @@ -53,7 +58,6 @@ RemoMouse is local-only. Hardware captures stay under the user's Application Sup ## 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 @@ -64,6 +68,7 @@ Contributions are welcome under [CONTRIBUTING.md](CONTRIBUTING.md). Report vulne ## Project documentation - [Product and technical design](docs/superpowers/specs/2026-08-09-remomouse-design.md) +- [One-thumb ergonomic redesign](docs/superpowers/specs/2026-08-10-one-thumb-redesign-design.md) - [Hardware-validation plan](docs/superpowers/plans/2026-08-09-remomouse-hardware-validation.md) - [Hardware-validation issue](https://github.com/kefrulz/RemoMouse/issues/1) diff --git a/Resources/AppIcon-master.png b/Resources/AppIcon-master.png new file mode 100644 index 0000000..13268cc Binary files /dev/null and b/Resources/AppIcon-master.png differ diff --git a/Resources/RemoMouse.icns b/Resources/RemoMouse.icns new file mode 100644 index 0000000..2e1d6af Binary files /dev/null and b/Resources/RemoMouse.icns differ diff --git a/Resources/RemoMouseInfo.plist b/Resources/RemoMouseInfo.plist index 152a2ce..e651b72 100644 --- a/Resources/RemoMouseInfo.plist +++ b/Resources/RemoMouseInfo.plist @@ -4,14 +4,18 @@ CFBundleDevelopmentRegionen CFBundleExecutableRemoMouse + CFBundleIconFileRemoMouse CFBundleIdentifiercom.kefrulz.RemoMouse CFBundleInfoDictionaryVersion6.0 CFBundleNameRemoMouse CFBundlePackageTypeAPPL CFBundleShortVersionString0.1.0 CFBundleVersion1 + GCSupportsMultipleMicroGamepads LSMinimumSystemVersion26.0 LSUIElement NSBluetoothAlwaysUsageDescriptionRemoMouse reads input from your paired Siri Remote. + NSHighResolutionCapable + NSPrincipalClassNSApplication diff --git a/Scripts/generate-icon.swift b/Scripts/generate-icon.swift new file mode 100755 index 0000000..c976437 --- /dev/null +++ b/Scripts/generate-icon.swift @@ -0,0 +1,75 @@ +#!/usr/bin/env swift + +import AppKit + +let arguments = CommandLine.arguments +guard arguments.count == 3 else { + fputs("Usage: generate-icon.swift \n", stderr) + exit(64) +} + +let sourceURL = URL(fileURLWithPath: arguments[1]) +let iconsetURL = URL(fileURLWithPath: arguments[2], isDirectory: true) +guard let source = NSImage(contentsOf: sourceURL) else { + fputs("Unable to read source image at \(sourceURL.path)\n", stderr) + exit(66) +} + +try FileManager.default.createDirectory(at: iconsetURL, withIntermediateDirectories: true) + +func pngData(size: Int, rounded: Bool) -> Data? { + guard let bitmap = NSBitmapImageRep( + bitmapDataPlanes: nil, + pixelsWide: size, + pixelsHigh: size, + bitsPerSample: 8, + samplesPerPixel: 4, + hasAlpha: true, + isPlanar: false, + colorSpaceName: .deviceRGB, + bytesPerRow: 0, + bitsPerPixel: 0 + ) else { return nil } + + bitmap.size = NSSize(width: size, height: size) + NSGraphicsContext.saveGraphicsState() + guard let context = NSGraphicsContext(bitmapImageRep: bitmap) else { return nil } + NSGraphicsContext.current = context + context.imageInterpolation = .high + NSColor.clear.setFill() + NSRect(x: 0, y: 0, width: size, height: size).fill() + + let bounds = NSRect(x: 0, y: 0, width: size, height: size) + if rounded { + NSBezierPath( + roundedRect: bounds, + xRadius: CGFloat(size) * 0.215, + yRadius: CGFloat(size) * 0.215 + ).addClip() + } + source.draw(in: bounds, from: .zero, operation: .sourceOver, fraction: 1) + context.flushGraphics() + NSGraphicsContext.restoreGraphicsState() + return bitmap.representation(using: .png, properties: [:]) +} + +let variants: [(String, Int)] = [ + ("icon_16x16.png", 16), + ("icon_16x16@2x.png", 32), + ("icon_32x32.png", 32), + ("icon_32x32@2x.png", 64), + ("icon_128x128.png", 128), + ("icon_128x128@2x.png", 256), + ("icon_256x256.png", 256), + ("icon_256x256@2x.png", 512), + ("icon_512x512.png", 512), + ("icon_512x512@2x.png", 1024), +] + +for (name, size) in variants { + guard let data = pngData(size: size, rounded: true) else { + fputs("Unable to render \(name)\n", stderr) + exit(70) + } + try data.write(to: iconsetURL.appendingPathComponent(name), options: .atomic) +} diff --git a/Scripts/package-app.sh b/Scripts/package-app.sh index 5cb4f12..8f0392d 100755 --- a/Scripts/package-app.sh +++ b/Scripts/package-app.sh @@ -9,5 +9,6 @@ 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" +cp Resources/RemoMouse.icns "$app_path/Contents/Resources/RemoMouse.icns" codesign --force --deep --sign - "$app_path" echo "$PWD/$app_path" diff --git a/Sources/CMultitouchBridge/CMultitouchBridge.c b/Sources/CMultitouchBridge/CMultitouchBridge.c index 5309819..d6bf7c8 100644 --- a/Sources/CMultitouchBridge/CMultitouchBridge.c +++ b/Sources/CMultitouchBridge/CMultitouchBridge.c @@ -2,6 +2,7 @@ #include #include #include +#include typedef const void *MTDeviceRef; @@ -31,6 +32,8 @@ 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); +typedef int32_t (*StopFunction)(MTDeviceRef); +typedef bool (*IsRunningFunction)(MTDeviceRef); static RMTouchCallback outputCallback = NULL; static void *outputContext = NULL; @@ -56,13 +59,17 @@ static void receiveFrame(MTDeviceRef device, RMTouch *touches, size_t count, (void)device; (void)timestamp; (void)frame; (void)context; if (!outputCallback) return; if (count == 0 || !touches) { - outputCallback(0, 0, 0, 0, 0, outputContext); + outputCallback(timestamp, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, outputContext); return; } RMTouch touch = touches[0]; - outputCallback(touch.normalizedVector.position.x, + outputCallback(timestamp, + touch.normalizedVector.position.x, touch.normalizedVector.position.y, - touch.pressure, touch.state, count, outputContext); + touch.normalizedVector.velocity.x, + touch.normalizedVector.velocity.y, + touch.pressure, touch.majorAxis, touch.minorAxis, + touch.density, touch.state, count, outputContext); } bool RMStartRemoteTouch(RMTouchCallback callback, void *context) { @@ -79,6 +86,7 @@ bool RMStartRemoteTouch(RMTouchCallback callback, void *context) { GetDeviceServiceFunction getService = (GetDeviceServiceFunction)dlsym(framework, "MTDeviceGetService"); RegisterFunction registerFrame = (RegisterFunction)dlsym(framework, "MTRegisterContactFrameCallbackWithRefcon"); StartFunction startDevice = (StartFunction)dlsym(framework, "MTDeviceStart"); + IsRunningFunction isRunning = (IsRunningFunction)dlsym(framework, "MTDeviceIsRunning"); if (!createList || !getService || !registerFrame || !startDevice) return false; CFArrayRef devices = createList(); @@ -99,9 +107,15 @@ bool RMStartRemoteTouch(RMTouchCallback callback, void *context) { bool productMatches = numberPropertyEquals(service, CFSTR("ProductID"), 621); if (!vendorMatches || !productMatches) continue; - registerFrame(device, receiveFrame, context); - int32_t status = startDevice(device, 0); - if (status == 0) { + bool alreadyRegistered = retainedDevices && CFArrayContainsValue( + retainedDevices, + CFRangeMake(0, CFArrayGetCount(retainedDevices)), + device + ); + if (!alreadyRegistered) registerFrame(device, receiveFrame, context); + bool running = isRunning ? isRunning(device) : false; + int32_t status = running ? 0 : startDevice(device, 0); + if (status == 0 || (isRunning && isRunning(device))) { CFArrayAppendValue(startedDevices, device); started = true; } @@ -115,3 +129,36 @@ bool RMStartRemoteTouch(RMTouchCallback callback, void *context) { } return started; } + +bool RMRestartRemoteTouch(void) { + if (!retainedDevices || CFArrayGetCount(retainedDevices) == 0) return false; + + void *framework = dlopen( + "/System/Library/PrivateFrameworks/MultitouchSupport.framework/MultitouchSupport", + RTLD_NOW | RTLD_LOCAL + ); + if (!framework) return false; + + StartFunction startDevice = (StartFunction)dlsym(framework, "MTDeviceStart"); + StopFunction stopDevice = (StopFunction)dlsym(framework, "MTDeviceStop"); + IsRunningFunction isRunning = (IsRunningFunction)dlsym(framework, "MTDeviceIsRunning"); + if (!startDevice) return false; + + bool restarted = false; + CFIndex count = CFArrayGetCount(retainedDevices); + for (CFIndex index = 0; index < count; index++) { + MTDeviceRef device = CFArrayGetValueAtIndex(retainedDevices, index); + if (stopDevice) { + stopDevice(device); + if (isRunning) { + for (int attempt = 0; attempt < 25 && isRunning(device); attempt++) { + usleep(10000); + } + } + } + if (startDevice(device, 0) == 0 || (isRunning && isRunning(device))) { + restarted = true; + } + } + return restarted; +} diff --git a/Sources/CMultitouchBridge/include/CMultitouchBridge.h b/Sources/CMultitouchBridge/include/CMultitouchBridge.h index 0c85f2f..9653781 100644 --- a/Sources/CMultitouchBridge/include/CMultitouchBridge.h +++ b/Sources/CMultitouchBridge/include/CMultitouchBridge.h @@ -5,9 +5,14 @@ #include #include -typedef void (*RMTouchCallback)(float x, float y, float pressure, uint32_t state, - size_t touchCount, void *context); +typedef void (*RMTouchCallback)(double timestamp, float x, float y, + float velocityX, float velocityY, + float pressure, float majorAxis, + float minorAxis, float density, + uint32_t state, size_t touchCount, + void *context); bool RMStartRemoteTouch(RMTouchCallback callback, void *context); +bool RMRestartRemoteTouch(void); #endif diff --git a/Sources/RemoMouseApp/CalibrationView.swift b/Sources/RemoMouseApp/CalibrationView.swift new file mode 100644 index 0000000..a16b1ed --- /dev/null +++ b/Sources/RemoMouseApp/CalibrationView.swift @@ -0,0 +1,192 @@ +import SwiftUI +import RemoMouseHardware + +struct CalibrationView: View { + private struct Stage { + let title: String + let instruction: String + let symbol: String + } + + private static let stages = [ + Stage(title: "Natural Rest", instruction: "Place and lift your thumb five times without trying to move it.", symbol: "hand.point.up.left"), + Stage(title: "Small Targets", instruction: "Move slowly between the targets. Accuracy matters more than speed.", symbol: "scope"), + Stage(title: "Across the Screen", instruction: "Make several comfortable, faster sweeps across the surface.", symbol: "arrow.left.and.right"), + Stage(title: "Precise Click", instruction: "Aim at the center and physically click ten times.", symbol: "cursorarrow.click"), + Stage(title: "Click and Drag", instruction: "Press, hold, and move past the target three times.", symbol: "rectangle.and.hand.point.up.left"), + Stage(title: "Natural Scroll", instruction: "Start at the right edge: scroll slowly, then finish with a flick.", symbol: "scroll"), + ] + + @Bindable var model: RemoMouseModel + @Environment(\.dismiss) private var dismiss + @State private var stageIndex = 0 + @State private var isReviewing = false + @State private var didApply = false + @State private var lastSample: SiriRemoteMultitouchSample? + @State private var stationaryDeltas: [Double] = [] + @State private var contactAreas: [Double] = [] + @State private var clickTravel: [Double] = [] + @State private var scrollStarts: [Double] = [] + + var body: some View { + VStack(spacing: 0) { + HStack { + Label("Personalize for My Thumb", systemImage: "hand.point.up.left.fill") + .font(.headline) + Spacer() + Text(isReviewing ? "Ready" : "\(stageIndex + 1) of \(Self.stages.count)") + .foregroundStyle(.secondary) + .monospacedDigit() + } + .padding(20) + + Divider() + + VStack(spacing: 24) { + if isReviewing { + review + } else { + stage + } + } + .padding(32) + .frame(maxWidth: .infinity, maxHeight: .infinity) + + Divider() + + HStack { + Button("Cancel", role: .cancel) { cancel() } + Spacer() + if isReviewing { + Button("Apply Personalization") { apply() } + .buttonStyle(.borderedProminent) + } else { + Button(stageIndex == Self.stages.count - 1 ? "Review" : "Continue") { + advance() + } + .buttonStyle(.borderedProminent) + } + } + .padding(20) + } + .frame(width: 620, height: 560) + .onAppear { model.beginCalibration() } + .onDisappear { + if !didApply { model.endCalibration() } + } + .onChange(of: model.touchPreviewSample) { _, sample in record(sample) } + .interactiveDismissDisabled(model.isCalibrating) + } + + private var stage: some View { + let current = Self.stages[stageIndex] + return VStack(spacing: 20) { + Image(systemName: current.symbol) + .font(.system(size: 40, weight: .medium)) + .symbolRenderingMode(.hierarchical) + .foregroundStyle(.tint) + Text(current.title) + .font(.title2.bold()) + Text(current.instruction) + .font(.body) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .frame(maxWidth: 430) + RemoteTouchPreview(sample: model.touchPreviewSample, profile: model.ergonomicProfile) + .frame(width: 390) + if stageIndex == 1 { + HStack(spacing: 54) { + target + target + target + } + } + } + } + + private var review: some View { + let candidate = fittedProfile + return VStack(alignment: .leading, spacing: 20) { + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 46)) + .foregroundStyle(.green) + .frame(maxWidth: .infinity) + Text("Your one-thumb profile is ready") + .font(.title2.bold()) + .frame(maxWidth: .infinity) + Text("RemoMouse used your stationary contact, click movement, and edge approach to keep aiming stable without making movement feel heavy.") + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + Grid(alignment: .leading, horizontalSpacing: 28, verticalSpacing: 12) { + GridRow { Text("Pointer stability"); Text(candidate.pointerDeadZone, format: .number.precision(.fractionLength(5))).monospacedDigit() } + GridRow { Text("Drag threshold"); Text(candidate.dragThreshold, format: .percent.precision(.fractionLength(0))).monospacedDigit() } + GridRow { Text("Thumb rail"); Text(candidate.railWidth, format: .percent.precision(.fractionLength(0))).monospacedDigit() } + } + .frame(maxWidth: .infinity) + } + } + + private var target: some View { + Circle() + .fill(.tint.opacity(0.18)) + .overlay(Circle().stroke(.tint, lineWidth: 2)) + .frame(width: 22, height: 22) + .accessibilityHidden(true) + } + + private var fittedProfile: SiriRemoteErgonomicProfile { + var fitted = SiriRemoteCalibrationFitter.fit( + stationaryDeltas: stationaryDeltas, + contactAreas: contactAreas, + clickTravel: clickTravel, + scrollStarts: scrollStarts + ) + fitted.pointerSpeed = model.ergonomicProfile.pointerSpeed + fitted.scrollSpeed = model.ergonomicProfile.scrollSpeed + fitted.handedness = model.ergonomicProfile.handedness + fitted.tapToClick = model.ergonomicProfile.tapToClick + fitted.momentumEnabled = model.ergonomicProfile.momentumEnabled + return fitted.validated() + } + + private func advance() { + lastSample = nil + if stageIndex < Self.stages.count - 1 { + stageIndex += 1 + } else { + isReviewing = true + } + } + + private func record(_ sample: SiriRemoteMultitouchSample?) { + guard !isReviewing else { return } + guard let sample, sample.isContact else { + lastSample = nil + return + } + contactAreas.append(sample.contactArea) + if lastSample == nil, stageIndex == 5 { + scrollStarts.append(sample.x) + } + if let prior = lastSample { + let travel = hypot(sample.x - prior.x, sample.y - prior.y) + switch stageIndex { + case 0: stationaryDeltas.append(travel) + case 3, 4: clickTravel.append(travel) + default: break + } + } + lastSample = sample + } + + private func apply() { + didApply = true + model.endCalibration(applying: fittedProfile) + dismiss() + } + + private func cancel() { + model.endCalibration() + dismiss() + } +} diff --git a/Sources/RemoMouseApp/DisplayCoordinateResolver.swift b/Sources/RemoMouseApp/DisplayCoordinateResolver.swift new file mode 100644 index 0000000..795c2ee --- /dev/null +++ b/Sources/RemoMouseApp/DisplayCoordinateResolver.swift @@ -0,0 +1,22 @@ +import CoreGraphics + +enum DisplayCoordinateResolver { + static func clamp(_ point: CGPoint, to frames: [CGRect]) -> CGPoint { + let usableFrames = frames.filter { !$0.isEmpty && !$0.isNull && !$0.isInfinite } + guard !usableFrames.isEmpty else { return point } + if usableFrames.contains(where: { $0.contains(point) }) { return point } + + return usableFrames + .map { frame -> (point: CGPoint, distance: CGFloat) in + let maximumX = frame.maxX - 1 + let maximumY = frame.maxY - 1 + let candidate = CGPoint( + x: min(max(point.x, frame.minX), maximumX), + y: min(max(point.y, frame.minY), maximumY) + ) + let distance = pow(candidate.x - point.x, 2) + pow(candidate.y - point.y, 2) + return (candidate, distance) + } + .min(by: { $0.distance < $1.distance })?.point ?? point + } +} diff --git a/Sources/RemoMouseApp/FocusedTextObserver.swift b/Sources/RemoMouseApp/FocusedTextObserver.swift new file mode 100644 index 0000000..a438a29 --- /dev/null +++ b/Sources/RemoMouseApp/FocusedTextObserver.swift @@ -0,0 +1,105 @@ +import AppKit +import ApplicationServices + +struct FocusedTextContext: Equatable { + let frame: CGRect + let isSecure: Bool +} + +@MainActor +final class FocusedTextObserver { + private(set) var currentContext: FocusedTextContext? + private var task: Task? + private let handler: (FocusedTextContext?) -> Void + + init(handler: @escaping (FocusedTextContext?) -> Void) { + self.handler = handler + task = Task { @MainActor [weak self] in + while !Task.isCancelled { + self?.refresh() + try? await Task.sleep(for: .milliseconds(300)) + } + } + } + + deinit { task?.cancel() } + + func stop() { + task?.cancel() + task = nil + } + + private func refresh() { + guard AXIsProcessTrusted(), let element = focusedElement() else { + update(nil) + return + } + var roleValue: CFTypeRef? + guard AXUIElementCopyAttributeValue(element, kAXRoleAttribute as CFString, &roleValue) == .success, + let role = roleValue as? String + else { + update(nil) + return + } + + let editableRoles: Set = [ + kAXTextFieldRole as String, + kAXTextAreaRole as String, + kAXComboBoxRole as String, + "AXSearchField", + ] + var settable = DarwinBoolean(false) + let canSetValue = AXUIElementIsAttributeSettable( + element, + kAXValueAttribute as CFString, + &settable + ) == .success && settable.boolValue + guard editableRoles.contains(role), canSetValue else { + update(nil) + return + } + + var subroleValue: CFTypeRef? + _ = AXUIElementCopyAttributeValue(element, kAXSubroleAttribute as CFString, &subroleValue) + let subrole = subroleValue as? String + update(FocusedTextContext( + frame: frame(of: element), + isSecure: subrole == "AXSecureTextField" + )) + } + + private func focusedElement() -> AXUIElement? { + let system = AXUIElementCreateSystemWide() + var value: CFTypeRef? + guard AXUIElementCopyAttributeValue( + system, + kAXFocusedUIElementAttribute as CFString, + &value + ) == .success else { return nil } + return (value as! AXUIElement) + } + + private func frame(of element: AXUIElement) -> CGRect { + var positionValue: CFTypeRef? + var sizeValue: CFTypeRef? + var position = CGPoint.zero + var size = CGSize.zero + if AXUIElementCopyAttributeValue(element, kAXPositionAttribute as CFString, &positionValue) == .success, + let positionValue, + CFGetTypeID(positionValue) == AXValueGetTypeID() { + AXValueGetValue(positionValue as! AXValue, .cgPoint, &position) + } + if AXUIElementCopyAttributeValue(element, kAXSizeAttribute as CFString, &sizeValue) == .success, + let sizeValue, + CFGetTypeID(sizeValue) == AXValueGetTypeID() { + AXValueGetValue(sizeValue as! AXValue, .cgSize, &size) + } + return CGRect(origin: position, size: size) + } + + private func update(_ context: FocusedTextContext?) { + guard context != currentContext else { return } + currentContext = context + handler(context) + } +} diff --git a/Sources/RemoMouseApp/KeyboardOverlayController.swift b/Sources/RemoMouseApp/KeyboardOverlayController.swift new file mode 100644 index 0000000..b4b18cf --- /dev/null +++ b/Sources/RemoMouseApp/KeyboardOverlayController.swift @@ -0,0 +1,220 @@ +import AppKit +import Observation +import SwiftUI + +@MainActor +@Observable +final class KeyboardOverlayState { + var isShifted = false + var isSecure = false + var action: (String) -> Void = { _ in } + + let rows = [ + ["1", "2", "3", "4", "5", "6", "7", "8", "9", "0"], + ["q", "w", "e", "r", "t", "y", "u", "i", "o", "p"], + ["a", "s", "d", "f", "g", "h", "j", "k", "l"], + ["z", "x", "c", "v", "b", "n", "m", ",", ".", "?"], + ] + + func label(for key: String) -> String { + isShifted ? key.uppercased() : key + } + + func press(_ key: String) { + action(label(for: key)) + if isShifted { isShifted = false } + } +} + +struct KeyboardOverlayView: View { + @Bindable var state: KeyboardOverlayState + let dismiss: () -> Void + + var body: some View { + VStack(spacing: 9) { + HStack { + Label(state.isSecure ? "Secure Keyboard" : "Smart Keyboard", + systemImage: state.isSecure ? "lock.fill" : "keyboard") + .font(.headline) + Spacer() + Button("Hide", systemImage: "chevron.down", action: dismiss) + .buttonStyle(.borderless) + } + .padding(.horizontal, 6) + + ForEach(Array(state.rows.enumerated()), id: \.offset) { _, row in + HStack(spacing: 7) { + ForEach(row, id: \.self) { key in + keyButton(state.label(for: key)) { state.press(key) } + } + } + } + + HStack(spacing: 8) { + keyButton("⇧", width: 78, selected: state.isShifted) { + state.isShifted.toggle() + } + keyButton("⌫", width: 78) { state.action("backspace") } + keyButton("space", width: 390) { state.action(" ") } + keyButton("return", width: 120) { state.action("return") } + } + } + .padding(14) + .background(.ultraThinMaterial, in: .rect(cornerRadius: 22)) + .overlay { + RoundedRectangle(cornerRadius: 22) + .stroke(.white.opacity(0.18), lineWidth: 1) + } + .shadow(color: .black.opacity(0.28), radius: 30, y: 12) + .padding(20) + } + + private func keyButton( + _ title: String, + width: CGFloat = 72, + selected: Bool = false, + action: @escaping () -> Void + ) -> some View { + Button(action: action) { + Text(title) + .font(.system(size: 18, weight: .medium, design: .rounded)) + .frame(width: width, height: 43) + .contentShape(.rect) + } + .buttonStyle(.plain) + .background(selected ? Color.accentColor : Color.primary.opacity(0.10), + in: .rect(cornerRadius: 10)) + .overlay { + RoundedRectangle(cornerRadius: 10) + .stroke(.white.opacity(selected ? 0.35 : 0.12), lineWidth: 1) + } + .accessibilityLabel(title) + } +} + +@MainActor +final class KeyboardOverlayController { + private let state = KeyboardOverlayState() + private var panel: NSPanel? + var onVisibilityChanged: (Bool) -> Void = { _ in } + + init() { + state.action = { [weak self] key in self?.send(key) } + } + + func show(context: FocusedTextContext) { + state.isSecure = context.isSecure + let panel = panel ?? makePanel() + self.panel = panel + position(panel, near: context.frame) + panel.orderFrontRegardless() + onVisibilityChanged(true) + } + + func showManually() { + let panel = panel ?? makePanel() + self.panel = panel + let screen = NSScreen.main?.visibleFrame ?? .zero + panel.setFrameOrigin(CGPoint( + x: screen.midX - panel.frame.width / 2, + y: screen.minY + 28 + )) + panel.orderFrontRegardless() + onVisibilityChanged(true) + } + + func hide() { + panel?.orderOut(nil) + onVisibilityChanged(false) + } + + private func makePanel() -> NSPanel { + let panel = NSPanel( + contentRect: CGRect(x: 0, y: 0, width: 860, height: 330), + styleMask: [.borderless, .nonactivatingPanel], + backing: .buffered, + defer: false + ) + panel.level = .floating + panel.isOpaque = false + panel.backgroundColor = .clear + panel.hasShadow = false + panel.hidesOnDeactivate = false + panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] + panel.isMovableByWindowBackground = true + panel.contentView = NSHostingView(rootView: KeyboardOverlayView( + state: state, + dismiss: { [weak self] in self?.hide() } + )) + return panel + } + + private func position(_ panel: NSPanel, near focusedFrame: CGRect) { + let quartzCenter = CGPoint(x: focusedFrame.midX, y: focusedFrame.midY) + let match = NSScreen.screens.first { screen in + guard let number = screen.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? NSNumber + else { return false } + return CGDisplayBounds(CGDirectDisplayID(number.uint32Value)).contains(quartzCenter) + } + guard let screen = match ?? NSScreen.main else { return } + let visible = screen.visibleFrame + let displayID = (screen.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? NSNumber) + .map { CGDirectDisplayID($0.uint32Value) } + let quartzBounds = displayID.map(CGDisplayBounds) ?? CGRect( + x: screen.frame.minX, + y: 0, + width: screen.frame.width, + height: screen.frame.height + ) + let field = CGRect( + x: screen.frame.minX + focusedFrame.minX - quartzBounds.minX, + y: screen.frame.maxY - (focusedFrame.maxY - quartzBounds.minY), + width: focusedFrame.width, + height: focusedFrame.height + ) + let x = min(max(field.midX - panel.frame.width / 2, visible.minX + 12), + visible.maxX - panel.frame.width - 12) + let gap: CGFloat = 16 + let above = field.maxY + gap + let below = field.minY - gap - panel.frame.height + let y: CGFloat + if below >= visible.minY + 12 { + y = below + } else if above + panel.frame.height <= visible.maxY - 12 { + y = above + } else if field.midY > visible.midY { + y = visible.minY + 12 + } else { + y = visible.maxY - panel.frame.height - 12 + } + panel.setFrameOrigin(CGPoint(x: x, y: y)) + } + + private func send(_ key: String) { + if key == "backspace" { + postKey(code: 51) + } else if key == "return" { + postKey(code: 36) + } else { + postText(key) + } + } + + private func postKey(code: CGKeyCode) { + CGEvent(keyboardEventSource: nil, virtualKey: code, keyDown: true)?.post(tap: .cghidEventTap) + CGEvent(keyboardEventSource: nil, virtualKey: code, keyDown: false)?.post(tap: .cghidEventTap) + } + + private func postText(_ text: String) { + let utf16 = Array(text.utf16) + utf16.withUnsafeBufferPointer { buffer in + guard let base = buffer.baseAddress else { return } + let down = CGEvent(keyboardEventSource: nil, virtualKey: 0, keyDown: true) + down?.keyboardSetUnicodeString(stringLength: buffer.count, unicodeString: base) + down?.post(tap: .cghidEventTap) + let up = CGEvent(keyboardEventSource: nil, virtualKey: 0, keyDown: false) + up?.keyboardSetUnicodeString(stringLength: buffer.count, unicodeString: base) + up?.post(tap: .cghidEventTap) + } + } +} diff --git a/Sources/RemoMouseApp/PointerController.swift b/Sources/RemoMouseApp/PointerController.swift index 433f8e8..c20784b 100644 --- a/Sources/RemoMouseApp/PointerController.swift +++ b/Sources/RemoMouseApp/PointerController.swift @@ -1,30 +1,33 @@ import AppKit import ApplicationServices +import RemoMouseDomain import RemoMouseHardware enum RemoMouseInputMode: String { case pointer = "Pointer" case scroll = "Scroll" + case airPointer = "Air Pointer" } @MainActor final class PointerController { var speed = 7.0 + private var profile = SiriRemoteErgonomicProfile.balanced private var lastTouch: SiriRemoteTouch? private var lastX: Double? private var lastY: Double? - private var lastMultitouch: SiriRemoteMultitouchSample? + private var latestMultitouch: 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 touchSession = SiriRemoteTouchSessionInterpreter() + private var isDragging = false private var scrollGestureActive = false private var scrollMomentumTask: Task? + private var airPointerFilter = SiriRemoteAirPointerFilter() var isTrusted: Bool { AXIsProcessTrusted() } @@ -33,6 +36,12 @@ final class PointerController { _ = AXIsProcessTrustedWithOptions(options as CFDictionary) } + func apply(profile: SiriRemoteErgonomicProfile) { + releaseAll() + self.profile = profile.validated() + speed = self.profile.pointerSpeed + } + func handle(_ frame: SiriRemoteFrame) { updateButtons(frame.buttonMask) guard let touch = frame.touch else { return } @@ -40,6 +49,11 @@ final class PointerController { } func handle(_ element: SiriRemoteElementValue) { + if let pressed = element.primaryButtonPressed { + let mask = pressed ? lastButtonMask | 0x80 : lastButtonMask & ~0x80 + updateButtons(mask) + return + } guard element.logicalMaximum > element.logicalMinimum else { return } if element.usagePage == 0x01, element.usage == 0x30 { let x = normalized(element) @@ -56,57 +70,20 @@ final class PointerController { } 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 + if sample.isContact { cancelScrollMomentum() } + latestMultitouch = sample.isContact ? sample : nil + let actions = touchSession.update( + sample: sample, + physicalPressed: lastButtonMask & 0x80 != 0, + profile: profile, + forceScroll: sample.touchCount > 1 || mode == .scroll || mode == .airPointer ) - 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 - } + handle(actions) + } - 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 handle(_ sample: MotionSample) { + guard let delta = airPointerFilter.update(sample) else { return } + move(dx: delta.x * speed / 7, dy: delta.y * speed / 7) } func showMissionControl() { @@ -118,6 +95,7 @@ final class PointerController { } func releaseAll() { + handle(touchSession.cancel()) cancelScrollMomentum() if scrollGestureActive { scroll(.zero, phase: .ended, allowZero: true) @@ -131,10 +109,9 @@ final class PointerController { lastTouch = nil lastX = nil lastY = nil - lastMultitouch = nil - scrollFilter.reset() - touchIntentFilter.reset() - clickGuard.setPressed(false, timestamp: ProcessInfo.processInfo.systemUptime) + latestMultitouch = nil + isDragging = false + airPointerFilter.reset() clickSequence.reset() leftClickState = 1 } @@ -171,10 +148,6 @@ final class PointerController { y: position.y ) } - clickGuard.setPressed( - mask & 0x80 != 0, - timestamp: timestamp - ) postMouse( mask & 0x80 != 0 ? .leftMouseDown : .leftMouseUp, button: .left, @@ -186,7 +159,12 @@ final class PointerController { x: position.x, y: position.y ) + isDragging = false } + updateTouchSessionForPhysicalButton( + pressed: mask & 0x80 != 0, + timestamp: timestamp + ) } if changed & 0x20 != 0 { postMouse(mask & 0x20 != 0 ? .rightMouseDown : .rightMouseUp, button: .right) @@ -202,7 +180,11 @@ final class PointerController { 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 proposed = CGPoint(x: current.x + dx, y: current.y + dy) + let target = DisplayCoordinateResolver.clamp( + proposed, + to: NSScreen.screens.map(\.frame) + ) let motion = SiriRemotePointerMotion(buttonMask: lastButtonMask) let eventType: CGEventType let button: CGMouseButton @@ -221,6 +203,74 @@ final class PointerController { .post(tap: .cghidEventTap) } + private func updateTouchSessionForPhysicalButton( + pressed: Bool, + timestamp: TimeInterval + ) { + guard let sample = latestMultitouch, sample.isContact else { return } + let synchronized = SiriRemoteMultitouchSample( + timestamp: timestamp, + x: sample.x, + y: sample.y, + velocityX: 0, + velocityY: 0, + pressure: sample.pressure, + majorAxis: sample.majorAxis, + minorAxis: sample.minorAxis, + density: sample.density, + state: sample.state, + touchCount: sample.touchCount + ) + handle(touchSession.update( + sample: synchronized, + physicalPressed: pressed, + profile: profile + )) + } + + private func handle(_ actions: [SiriRemoteTouchSessionAction]) { + for action in actions { + switch action { + case let .pointer(delta): + move(dx: delta.x, dy: -delta.y) + case .dragBegan: + isDragging = true + case let .drag(delta): + move(dx: delta.x, dy: -delta.y) + case let .scroll(delta, phase): + let eventPhase: NSEvent.Phase + switch phase { + case .began: eventPhase = .began + case .changed: eventPhase = .changed + case .ended: eventPhase = .ended + } + scroll(delta, phase: eventPhase, allowZero: phase == .ended) + scrollGestureActive = phase != .ended + case .tap: + postTapClick() + case let .ended(momentum): + if let momentum { startScrollMomentum(momentum) } + } + } + } + + private func postTapClick() { + let timestamp = ProcessInfo.processInfo.systemUptime + let position = currentPointerLocation() + let clickState = clickSequence.press( + timestamp: timestamp, + x: position.x, + y: position.y + ) + postMouse(.leftMouseDown, button: .left, clickState: clickState) + postMouse(.leftMouseUp, button: .left, clickState: clickState) + _ = clickSequence.release( + timestamp: timestamp, + x: position.x, + y: position.y + ) + } + private func postMouse(_ type: CGEventType, button: CGMouseButton, clickState: Int = 1) { guard isTrusted else { return } let position = currentPointerLocation() @@ -239,18 +289,6 @@ final class PointerController { } - 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 diff --git a/Sources/RemoMouseApp/RemoMouseApp.swift b/Sources/RemoMouseApp/RemoMouseApp.swift index 7cfdfa1..621d4fd 100644 --- a/Sources/RemoMouseApp/RemoMouseApp.swift +++ b/Sources/RemoMouseApp/RemoMouseApp.swift @@ -1,91 +1,47 @@ import SwiftUI +private final class RemoMouseAppDelegate: NSObject, NSApplicationDelegate { + private var previewWindow: NSWindow? + private var previewModel: RemoMouseModel? + + func applicationDidFinishLaunching(_ notification: Notification) { + let arguments = ProcessInfo.processInfo.arguments + guard arguments.contains("--preview-popover") || arguments.contains("--preview-settings") else { return } + + let model = RemoMouseModel() + previewModel = model + let showsPopover = arguments.contains("--preview-popover") + let content = showsPopover + ? AnyView(RemoMousePopoverView(model: model)) + : AnyView(RemoMouseSettingsView(model: model)) + let window = NSWindow( + contentRect: NSRect(origin: .zero, size: showsPopover ? NSSize(width: 390, height: 650) : NSSize(width: 940, height: 680)), + styleMask: [.titled, .closable, .miniaturizable, .resizable], + backing: .buffered, + defer: false + ) + window.title = showsPopover ? "RemoMouse Popover Preview" : "RemoMouse Settings Preview" + window.contentView = NSHostingView(rootView: content) + window.center() + window.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + previewWindow = window + } +} + @main struct RemoMouseApp: App { + @NSApplicationDelegateAdaptor(RemoMouseAppDelegate.self) private var appDelegate @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) + RemoMousePopoverView(model: model) } .menuBarExtraStyle(.window) + + Settings { + RemoMouseSettingsView(model: model) + } } } diff --git a/Sources/RemoMouseApp/RemoMouseModel.swift b/Sources/RemoMouseApp/RemoMouseModel.swift index 7365433..76d569a 100644 --- a/Sources/RemoMouseApp/RemoMouseModel.swift +++ b/Sources/RemoMouseApp/RemoMouseModel.swift @@ -1,5 +1,6 @@ import AppKit import Observation +import RemoMouseDomain import RemoMouseHardware @MainActor @@ -14,35 +15,92 @@ final class RemoMouseModel { private(set) var connection = ConnectionState.looking var isEnabled = true private(set) var mode = RemoMouseInputMode.pointer - var speed = 7.0 { didSet { pointer.speed = speed } } + private(set) var ergonomicProfile = SiriRemoteErgonomicProfile.balanced + var speed = 7.0 { + didSet { + guard speed != ergonomicProfile.pointerSpeed else { return } + ergonomicProfile.pointerSpeed = speed + ergonomicProfile = ergonomicProfile.validated() + pointer.apply(profile: ergonomicProfile) + } + } 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(set) var hasAirPointer = false + private(set) var touchPreviewSample: SiriRemoteMultitouchSample? + private(set) var isCalibrating = false private let pointer = PointerController() + private let settingsStore: RemoMouseSettingsStore private var monitor: SiriRemoteHIDMonitor? private var multitouchMonitor: SiriRemoteMultitouchMonitor? private var isTouchStarted = false + private var lastTouchFrameAt = ProcessInfo.processInfo.systemUptime + private var lastTouchRestartAt = 0.0 + private var lastRemoteInteractionAt = 0.0 + private var touchRecoveryTask: Task? private var buttonInterpreter = SiriRemoteButtonInterpreter() + private let gameController = GameControllerTransport() + private var gameControllerTask: Task? private var motionMonitor: SiriRemoteMotionMonitor? private var motionEventTask: Task? private var motionTimerTask: Task? private var motionReports: [SiriRemoteRawMotionReport] = [] private var motionBudget = SiriRemoteMotionCaptureBudget() + private var keyboardOverlay: KeyboardOverlayController! + private var focusedTextObserver: FocusedTextObserver! + var automaticKeyboard = true { + didSet { if !automaticKeyboard { keyboardOverlay?.hide() } } + } + private(set) var isKeyboardVisible = false var hasAccessibility: Bool { pointer.isTrusted } init() { + settingsStore = RemoMouseSettingsStore() + ergonomicProfile = settingsStore.profile + speed = ergonomicProfile.pointerSpeed + pointer.apply(profile: ergonomicProfile) monitor = SiriRemoteHIDMonitor { [weak self] event in Task { @MainActor in self?.handle(event) } } monitor?.start() + gameControllerTask = Task { [weak self] in + guard let self else { return } + do { + for try await event in await gameController.events() { + guard !Task.isCancelled else { return } + self.handle(event) + } + } catch { + self.hasAirPointer = false + if self.mode == .airPointer { self.mode = .pointer } + } + } multitouchMonitor = SiriRemoteMultitouchMonitor { [weak self] sample in Task { @MainActor in self?.handle(sample) } } startTouchIfNeeded() + keyboardOverlay = KeyboardOverlayController() + keyboardOverlay.onVisibilityChanged = { [weak self] visible in + self?.isKeyboardVisible = visible + } + focusedTextObserver = FocusedTextObserver { [weak self] context in + guard let self else { return } + let wasRemoteDriven = ProcessInfo.processInfo.systemUptime + - self.lastRemoteInteractionAt <= 1.2 + if self.automaticKeyboard, wasRemoteDriven, let context { + self.keyboardOverlay.show(context: context) + } else if context == nil { + self.keyboardOverlay.hide() + } + } + if ProcessInfo.processInfo.arguments.contains("--show-keyboard") { + Task { @MainActor [weak self] in self?.showKeyboard() } + } if ProcessInfo.processInfo.arguments.contains("--capture-motion") { Task { @MainActor [weak self] in self?.startMotionCapture() @@ -52,18 +110,61 @@ final class RemoMouseModel { func toggle() { isEnabled.toggle() - if !isEnabled { pointer.releaseAll() } + if !isEnabled { + pointer.releaseAll() + keyboardOverlay?.hide() + } + } + + func selectMode(_ newMode: RemoMouseInputMode) { + guard newMode != .airPointer || hasAirPointer else { return } + guard mode != newMode else { return } + pointer.releaseAll() + mode = newMode + lastInput = switch newMode { + case .pointer: "Trackpad mode" + case .scroll: "Scroll mode" + case .airPointer: "Air Pointer mode" + } } func requestAccessibility() { pointer.requestAccessibility() } + func applyErgonomicProfile(_ profile: SiriRemoteErgonomicProfile) { + ergonomicProfile = profile.validated() + speed = ergonomicProfile.pointerSpeed + pointer.apply(profile: ergonomicProfile) + _ = settingsStore.save(ergonomicProfile) + } + + func resetErgonomicProfile() { + applyErgonomicProfile(.balanced) + } + + func beginCalibration() { + pointer.releaseAll() + isCalibrating = true + } + + func endCalibration(applying profile: SiriRemoteErgonomicProfile? = nil) { + isCalibrating = false + if let profile { applyErgonomicProfile(profile) } + } + func quit() { cancelMotionCapture() + gameControllerTask?.cancel() + Task { await gameController.stop() } pointer.releaseAll() monitor?.stop() + focusedTextObserver?.stop() + keyboardOverlay?.hide() NSApplication.shared.terminate(nil) } + func showKeyboard() { keyboardOverlay.showManually() } + func hideKeyboard() { keyboardOverlay.hide() } + var motionCaptureInstruction: String { switch motionCaptureElapsed { case 0...2: "Hold the remote still" @@ -112,6 +213,7 @@ final class RemoMouseModel { } private func handle(_ event: SiriRemoteHIDEvent) { + lastRemoteInteractionAt = ProcessInfo.processInfo.systemUptime switch event { case .connected: connection = .connected @@ -119,15 +221,18 @@ final class RemoMouseModel { case .disconnected: connection = .sleeping isTouchStarted = false + touchRecoveryTask?.cancel() + touchRecoveryTask = nil pointer.releaseAll() case let .frame(frame): connection = .connected - startTouchIfNeeded() + refreshTouchAfterIdleIfNeeded() lastInput = "Remote input received" handleCommands(buttonInterpreter.commands(for: frame.buttonMask)) if isEnabled { pointer.handle(frame) } case let .element(value): connection = .connected + refreshTouchAfterIdleIfNeeded() lastInput = "Touch input received" if isEnabled { pointer.handle(value) } } @@ -140,9 +245,65 @@ final class RemoMouseModel { } private func handle(_ sample: SiriRemoteMultitouchSample) { + lastRemoteInteractionAt = ProcessInfo.processInfo.systemUptime + lastTouchFrameAt = ProcessInfo.processInfo.systemUptime + isTouchStarted = true + touchRecoveryTask?.cancel() + touchRecoveryTask = nil connection = .connected + touchPreviewSample = sample.isContact ? sample : nil lastInput = sample.touchCount > 1 ? "Two-finger touch" : "Touch surface active" - if isEnabled { pointer.handle(sample, mode: mode) } + if isEnabled, !isCalibrating { pointer.handle(sample, mode: mode) } + } + + private func handle(_ event: RemoteEvent) { + switch event { + case let .motion(sample): + hasAirPointer = true + if isEnabled, mode == .airPointer { + pointer.handle(sample) + lastInput = "Air Pointer active" + } + case .disconnected: + hasAirPointer = false + if mode == .airPointer { + mode = .pointer + pointer.releaseAll() + lastInput = "Motion disconnected — Trackpad mode" + } + default: + break + } + } + + private func refreshTouchAfterIdleIfNeeded() { + let now = ProcessInfo.processInfo.systemUptime + guard now - lastTouchFrameAt >= 2, now - lastTouchRestartAt >= 1 else { + startTouchIfNeeded() + return + } + guard touchRecoveryTask == nil else { return } + lastTouchRestartAt = now + let staleFrameTime = lastTouchFrameAt + pointer.releaseAll() + lastInput = "Reconnecting touch…" + touchRecoveryTask = Task { @MainActor [weak self] in + guard let self else { return } + for delay in [0, 350, 900, 1_500] { + if delay > 0 { + try? await Task.sleep(for: .milliseconds(delay)) + } + guard !Task.isCancelled, self.lastTouchFrameAt <= staleFrameTime else { + self.touchRecoveryTask = nil + return + } + self.isTouchStarted = await self.multitouchMonitor?.restartAsync() == true + } + if self.lastTouchFrameAt <= staleFrameTime { + self.lastInput = "Touch unavailable — press a remote button" + } + self.touchRecoveryTask = nil + } } private func handle(_ event: SiriRemoteMotionEvent) { @@ -211,9 +372,15 @@ final class RemoMouseModel { for command in commands { switch command { case .toggleMode: - mode = mode == .pointer ? .scroll : .pointer + if mode == .pointer { + mode = hasAirPointer ? .airPointer : .scroll + } else { + mode = .pointer + } pointer.releaseAll() - lastInput = "\(mode.rawValue) mode" + lastInput = mode == .airPointer + ? "Hold still briefly to calibrate Air Pointer" + : "\(mode.rawValue) mode" case .toggleEnabled: toggle() lastInput = isEnabled ? "RemoMouse enabled" : "RemoMouse paused" diff --git a/Sources/RemoMouseApp/RemoMousePopoverView.swift b/Sources/RemoMouseApp/RemoMousePopoverView.swift new file mode 100644 index 0000000..02c116e --- /dev/null +++ b/Sources/RemoMouseApp/RemoMousePopoverView.swift @@ -0,0 +1,230 @@ +import SwiftUI + +struct RemoMousePopoverView: View { + @Bindable var model: RemoMouseModel + + var body: some View { + ZStack { + LinearGradient( + colors: [RemoMouseVisualLanguage.accent.opacity(0.12), .clear, RemoMouseVisualLanguage.mint.opacity(0.055)], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + .ignoresSafeArea() + + VStack(spacing: 14) { + header + + if !model.hasAccessibility { permissionCard } + + touchSurface + modeControl + speedControl + footer + } + .padding(18) + } + .frame(width: 390) + } + + private var header: some View { + HStack(spacing: 12) { + ZStack { + RoundedRectangle(cornerRadius: 13, style: .continuous) + .fill(RemoMouseVisualLanguage.accent.gradient) + Image(systemName: "appletvremote.gen1.fill") + .font(.system(size: 22, weight: .medium)) + .foregroundStyle(.white) + } + .frame(width: 46, height: 46) + .shadow(color: RemoMouseVisualLanguage.accent.opacity(0.32), radius: 11, y: 5) + + VStack(alignment: .leading, spacing: 4) { + Text("RemoMouse") + .font(.headline) + RemoMouseStatusPill(text: connectionText, symbol: connectionSymbol, color: connectionColor) + } + + Spacer() + + Button { model.toggle() } label: { + Image(systemName: model.isEnabled ? "pause.fill" : "play.fill") + .font(.system(size: 13, weight: .semibold)) + .frame(width: 34, height: 34) + } + .buttonStyle(.plain) + .glassEffect(.regular.interactive(), in: .circle) + .help(model.isEnabled ? "Pause RemoMouse" : "Enable RemoMouse") + } + } + + private var touchSurface: some View { + RemoMouseCard(padding: 16) { + VStack(alignment: .leading, spacing: 12) { + HStack { + VStack(alignment: .leading, spacing: 2) { + Text("TOUCH SURFACE") + .font(.caption2.weight(.bold)) + .tracking(0.7) + .foregroundStyle(.secondary) + Text(model.touchPreviewSample == nil ? "Ready for your thumb" : model.lastInput) + .font(.subheadline.weight(.medium)) + } + Spacer() + Image(systemName: model.touchPreviewSample == nil ? "hand.point.up.left" : "dot.radiowaves.left.and.right") + .symbolEffect(.pulse, isActive: model.touchPreviewSample != nil) + .foregroundStyle(model.touchPreviewSample == nil ? AnyShapeStyle(.secondary) : AnyShapeStyle(RemoMouseVisualLanguage.accent)) + } + + RemoteTouchPreview(sample: model.touchPreviewSample, profile: model.ergonomicProfile) + .frame(height: 164) + + HStack(spacing: 12) { + Label("Move anywhere", systemImage: "cursorarrow.motionlines") + Spacer() + Label("Scroll on blue rail", systemImage: "arrow.up.and.down") + } + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + + private var modeControl: some View { + HStack(spacing: 6) { + modeButton(.pointer, title: "Pointer", symbol: "cursorarrow") + modeButton(.scroll, title: "Scroll", symbol: "scroll") + modeButton(.airPointer, title: "Air", symbol: "gyroscope", enabled: model.hasAirPointer) + } + .padding(5) + .background(.primary.opacity(0.055), in: .rect(cornerRadius: 16)) + } + + private func modeButton( + _ mode: RemoMouseInputMode, + title: String, + symbol: String, + enabled: Bool = true + ) -> some View { + Button { model.selectMode(mode) } label: { + Label(title, systemImage: symbol) + .font(.caption.weight(.semibold)) + .frame(maxWidth: .infinity) + .padding(.vertical, 9) + .contentShape(.rect) + } + .buttonStyle(.plain) + .foregroundStyle(model.mode == mode ? .primary : .secondary) + .background { + if model.mode == mode { + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(.background.opacity(0.78)) + .shadow(color: .black.opacity(0.10), radius: 7, y: 3) + } + } + .opacity(enabled ? 1 : 0.42) + .disabled(!enabled) + .help(enabled ? "Use (title) mode" : "Motion is not available from this remote") + } + + private var speedControl: some View { + RemoMouseCard(padding: 14) { + HStack(spacing: 13) { + Image(systemName: "cursorarrow.motionlines") + .foregroundStyle(RemoMouseVisualLanguage.accent) + VStack(alignment: .leading, spacing: 5) { + HStack { + Text("Pointer Speed").font(.caption.weight(.medium)) + Spacer() + Text(speedName).font(.caption).foregroundStyle(.secondary) + } + Slider(value: $model.speed, in: 2...14, step: 0.5) + .tint(RemoMouseVisualLanguage.accent) + .accessibilityLabel("Pointer speed") + } + } + } + } + + private var footer: some View { + HStack(spacing: 8) { + Button { + model.isKeyboardVisible ? model.hideKeyboard() : model.showKeyboard() + } label: { + Label(model.isKeyboardVisible ? "Hide Keyboard" : "Keyboard", systemImage: "keyboard") + } + .buttonStyle(.bordered) + + SettingsLink { + Label("Settings", systemImage: "gearshape") + } + .buttonStyle(.bordered) + + Spacer() + + Menu { + Button("Quit RemoMouse", systemImage: "power", action: model.quit) + } label: { + Image(systemName: "ellipsis") + .frame(width: 20) + } + .menuStyle(.borderlessButton) + .fixedSize() + } + .controlSize(.regular) + } + + private var permissionCard: some View { + HStack(spacing: 12) { + Image(systemName: "hand.raised.fill") + .foregroundStyle(.orange) + .font(.title3) + VStack(alignment: .leading, spacing: 2) { + Text("Accessibility Needed").font(.subheadline.weight(.semibold)) + Text("Allow pointer control once in System Settings.") + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + Button("Allow…") { model.requestAccessibility() } + .buttonStyle(.borderedProminent) + } + .padding(13) + .background(.orange.opacity(0.10), in: .rect(cornerRadius: 14)) + } + + private var speedName: String { + switch model.speed { + case ..<5.75: "Precise" + case 8.75...: "Fast" + default: "Balanced" + } + } + + private var connectionText: String { + if !model.isEnabled { return "Paused" } + switch model.connection { + case .connected: return "Connected" + case .sleeping: return "Sleeping" + case .looking: return "Searching" + } + } + + private var connectionSymbol: String { + if !model.isEnabled { return "pause.circle.fill" } + switch model.connection { + case .connected: return "checkmark.circle.fill" + case .sleeping: return "moon.zzz.fill" + case .looking: return "antenna.radiowaves.left.and.right" + } + } + + private var connectionColor: Color { + if !model.isEnabled { return .secondary } + switch model.connection { + case .connected: return RemoMouseVisualLanguage.mint + case .sleeping: return .orange + case .looking: return RemoMouseVisualLanguage.accent + } + } +} diff --git a/Sources/RemoMouseApp/RemoMouseSettingsStore.swift b/Sources/RemoMouseApp/RemoMouseSettingsStore.swift new file mode 100644 index 0000000..43a6276 --- /dev/null +++ b/Sources/RemoMouseApp/RemoMouseSettingsStore.swift @@ -0,0 +1,53 @@ +import Foundation +import Observation +import RemoMouseHardware + +@MainActor +@Observable +final class RemoMouseSettingsStore { + private(set) var profile: SiriRemoteErgonomicProfile + private let fileURL: URL + + init(fileURL: URL? = nil) { + self.fileURL = fileURL ?? Self.defaultFileURL() + if let data = try? Data(contentsOf: self.fileURL) { + profile = SiriRemoteErgonomicProfile.decodeOrDefault(data) + } else { + profile = .balanced + } + } + + @discardableResult + func save(_ profile: SiriRemoteErgonomicProfile) -> Bool { + let validated = profile.validated() + do { + try FileManager.default.createDirectory( + at: fileURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + let data = try JSONEncoder().encode(validated) + try data.write(to: fileURL, options: .atomic) + self.profile = validated + return true + } catch { + return false + } + } + + @discardableResult + func reset() -> Bool { + save(.balanced) + } + + private static func defaultFileURL() -> URL { + let base = (try? FileManager.default.url( + for: .applicationSupportDirectory, + in: .userDomainMask, + appropriateFor: nil, + create: true + )) ?? FileManager.default.temporaryDirectory + return base + .appending(path: "RemoMouse", directoryHint: .isDirectory) + .appending(path: "settings-v1.json") + } +} diff --git a/Sources/RemoMouseApp/RemoMouseSettingsView.swift b/Sources/RemoMouseApp/RemoMouseSettingsView.swift new file mode 100644 index 0000000..5db09fd --- /dev/null +++ b/Sources/RemoMouseApp/RemoMouseSettingsView.swift @@ -0,0 +1,514 @@ +import SwiftUI +import RemoMouseHardware + +private enum RemoMouseSettingsDestination: String, CaseIterable, Identifiable { + case general = "General" + case pointer = "Pointer & Click" + case scrolling = "Scrolling" + case buttons = "Remote Buttons" + case airPointer = "Air Pointer" + case keyboard = "Keyboard" + case diagnostics = "Diagnostics" + + var id: Self { self } + + var symbol: String { + switch self { + case .general: "switch.2" + case .pointer: "cursorarrow.motionlines" + case .scrolling: "arrow.up.and.down" + case .buttons: "appletvremote.gen1" + case .airPointer: "gyroscope" + case .keyboard: "keyboard" + case .diagnostics: "waveform.path.ecg" + } + } + + var subtitle: String { + switch self { + case .general: "Connection and hand preference" + case .pointer: "Movement, precision, and physical click" + case .scrolling: "One-thumb rail, speed, and momentum" + case .buttons: "A clear map of every physical control" + case .airPointer: "Move the pointer by moving the remote" + case .keyboard: "Remote-friendly text entry" + case .diagnostics: "Connection health and motion capture" + } + } +} + +struct RemoMouseSettingsView: View { + @Bindable var model: RemoMouseModel + @State private var selection = RemoMouseSettingsDestination.pointer + @State private var showsCalibration = false + + var body: some View { + NavigationSplitView { + sidebar + .navigationSplitViewColumnWidth(min: 205, ideal: 218, max: 240) + } detail: { + ZStack { + LinearGradient( + colors: [RemoMouseVisualLanguage.accent.opacity(0.075), .clear, RemoMouseVisualLanguage.mint.opacity(0.035)], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + .ignoresSafeArea() + + ScrollView { + VStack(alignment: .leading, spacing: 24) { + RemoMouseSectionTitle( + title: selection.rawValue, + subtitle: selection.subtitle, + symbol: selection.symbol + ) + destinationView + } + .frame(maxWidth: 720, alignment: .topLeading) + .padding(32) + } + } + } + .frame(minWidth: 860, idealWidth: 940, minHeight: 610, idealHeight: 680) + .sheet(isPresented: $showsCalibration) { + CalibrationView(model: model) + } + } + + private var sidebar: some View { + VStack(spacing: 0) { + HStack(spacing: 11) { + Image(systemName: "appletvremote.gen1.fill") + .font(.system(size: 19, weight: .medium)) + .foregroundStyle(.white) + .frame(width: 38, height: 38) + .background(RemoMouseVisualLanguage.accent.gradient, in: .rect(cornerRadius: 11)) + VStack(alignment: .leading, spacing: 2) { + Text("RemoMouse").font(.headline) + Text(model.connection == .connected ? "Remote connected" : model.connection.rawValue) + .font(.caption2) + .foregroundStyle(.secondary) + } + Spacer() + } + .padding(16) + + List(RemoMouseSettingsDestination.allCases, selection: $selection) { destination in + Label(destination.rawValue, systemImage: destination.symbol) + .tag(destination) + .padding(.vertical, 2) + } + .listStyle(.sidebar) + + HStack(spacing: 7) { + Circle() + .fill(model.isEnabled ? RemoMouseVisualLanguage.mint : .secondary) + .frame(width: 7, height: 7) + Text(model.isEnabled ? "Remote control active" : "RemoMouse paused") + .font(.caption) + .foregroundStyle(.secondary) + Spacer() + } + .padding(16) + } + } + + @ViewBuilder + private var destinationView: some View { + switch selection { + case .general: generalView + case .pointer: pointerView + case .scrolling: scrollingView + case .buttons: buttonsView + case .airPointer: airPointerView + case .keyboard: keyboardView + case .diagnostics: diagnosticsView + } + } + + private var generalView: some View { + VStack(spacing: 18) { + RemoMouseCard { + HStack(spacing: 18) { + ZStack { + Circle().fill(connectionColor.opacity(0.14)) + Image(systemName: connectionSymbol) + .font(.system(size: 25, weight: .medium)) + .foregroundStyle(connectionColor) + } + .frame(width: 58, height: 58) + VStack(alignment: .leading, spacing: 4) { + Text(model.isEnabled ? model.connection.rawValue : "RemoMouse paused") + .font(.title3.weight(.semibold)) + Text(model.hasAccessibility ? "Ready to control this Mac" : "Accessibility permission is still required") + .foregroundStyle(.secondary) + } + Spacer() + Toggle("Enabled", isOn: enabledBinding).labelsHidden() + } + } + + RemoMouseCard { + VStack(spacing: 14) { + RemoMouseSettingRow("Holding Hand", detail: "Places the smart scroll rail under your natural thumb approach") { + Picker("Holding Hand", selection: handednessBinding) { + Text("Left").tag(SiriRemoteHandedness.left) + Text("Right").tag(SiriRemoteHandedness.right) + } + .labelsHidden() + .pickerStyle(.segmented) + .frame(width: 180) + } + if !model.hasAccessibility { + RemoMouseDivider() + RemoMouseSettingRow("Accessibility", detail: "Needed to move, click, scroll, and type") { + Button("Open Settings…") { model.requestAccessibility() } + .buttonStyle(.borderedProminent) + } + } + } + } + } + } + + private var pointerView: some View { + VStack(spacing: 18) { + HStack(alignment: .top, spacing: 18) { + RemoMouseCard { + VStack(alignment: .leading, spacing: 12) { + Text("LIVE THUMB POSITION") + .font(.caption2.weight(.bold)) + .tracking(0.7) + .foregroundStyle(.secondary) + RemoteTouchPreview(sample: model.touchPreviewSample, profile: model.ergonomicProfile) + .frame(height: 175) + Label("Physical clicks stay anchored until you deliberately drag", systemImage: "scope") + .font(.caption) + .foregroundStyle(.secondary) + } + } + .frame(maxWidth: .infinity) + + RemoMouseCard { + VStack(alignment: .leading, spacing: 13) { + Label("One Thumb, Three Intentions", systemImage: "hand.point.up.left.fill") + .font(.headline) + intentRow("Aim", detail: "Glide anywhere on the surface", symbol: "cursorarrow.motionlines") + intentRow("Click", detail: "Press without pointer jump", symbol: "cursorarrow.click") + intentRow("Drag", detail: "Press, then move deliberately", symbol: "rectangle.and.hand.point.up.left") + } + } + .frame(width: 285) + } + + RemoMouseCard { + VStack(spacing: 14) { + RemoMouseSettingRow("Feel", detail: "Affects pointer acceleration and scrolling together") { + Picker("Feel", selection: presetBinding) { + Text("Precise").tag(SiriRemoteSpeedPreset.precise) + Text("Balanced").tag(SiriRemoteSpeedPreset.balanced) + Text("Fast").tag(SiriRemoteSpeedPreset.fast) + } + .labelsHidden() + .pickerStyle(.segmented) + .frame(width: 285) + } + RemoMouseDivider() + RemoMouseSettingRow("Pointer Speed") { + HStack { + Image(systemName: "tortoise.fill").foregroundStyle(.secondary) + Slider(value: pointerSpeedBinding, in: 2...14, step: 0.5) + .tint(RemoMouseVisualLanguage.accent) + Image(systemName: "hare.fill").foregroundStyle(.secondary) + } + .frame(width: 300) + } + RemoMouseDivider() + RemoMouseSettingRow("Tap to Click", detail: "Physical press remains the most stable default") { + Toggle("Tap to Click", isOn: tapToClickBinding).labelsHidden() + } + } + } + + HStack { + Button("Personalize for My Thumb…", systemImage: "wand.and.stars") { showsCalibration = true } + .buttonStyle(.borderedProminent) + .controlSize(.large) + Button("Restore Balanced Defaults") { model.resetErgonomicProfile() } + .controlSize(.large) + Spacer() + } + } + } + + private var scrollingView: some View { + VStack(spacing: 18) { + RemoMouseCard { + HStack(spacing: 26) { + RemoteTouchPreview(sample: model.touchPreviewSample, profile: model.ergonomicProfile) + .frame(width: 330, height: 205) + VStack(alignment: .leading, spacing: 12) { + Label("Smart Thumb Rail", systemImage: "arrow.up.and.down.circle.fill") + .font(.title3.weight(.semibold)) + .foregroundStyle(RemoMouseVisualLanguage.accent) + Text("Begin vertically on the blue edge. Once scrolling locks, your thumb can drift inward naturally without moving the pointer.") + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + Label("Horizontal intent escapes back to pointer control", systemImage: "arrow.turn.up.left") + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + + RemoMouseCard { + VStack(spacing: 14) { + RemoMouseSettingRow("Rail Width", detail: "Wider is easier to find; narrower leaves more aiming space") { + HStack { + Slider(value: railWidthBinding, in: 0.14...0.24, step: 0.01) + .tint(RemoMouseVisualLanguage.accent) + Text(model.ergonomicProfile.railWidth, format: .percent.precision(.fractionLength(0))) + .monospacedDigit() + .frame(width: 40, alignment: .trailing) + } + .frame(width: 300) + } + RemoMouseDivider() + RemoMouseSettingRow("Scroll Speed") { + Slider(value: scrollSpeedBinding, in: 2...14, step: 0.5) + .tint(RemoMouseVisualLanguage.accent) + .frame(width: 300) + } + RemoMouseDivider() + RemoMouseSettingRow("Momentum", detail: "Continue naturally after a deliberate flick") { + Toggle("Momentum", isOn: momentumBinding).labelsHidden() + } + RemoMouseDivider() + RemoMouseSettingRow("Direction", detail: "Uses the Natural scrolling choice in macOS") { + Text("Follow macOS").foregroundStyle(.secondary) + } + } + } + } + } + + private var buttonsView: some View { + HStack(alignment: .top, spacing: 18) { + buttonGroup("Touch Surface", rows: [ + ("Physical Press", "Primary Click", "cursorarrow.click"), + ("Press + Move", "Drag", "rectangle.and.hand.point.up.left"), + ("Right Thumb Rail", "Scroll", "arrow.up.and.down") + ]) + buttonGroup("Remote Buttons", rows: [ + ("Menu", "Pause / Resume", "pause.circle"), + ("Play / Pause", "Change Mode", "arrow.triangle.2.circlepath"), + ("Home / TV", "Mission Control", "rectangle.3.group"), + ("Volume", "Pointer Speed", "cursorarrow.motionlines") + ]) + } + } + + private func buttonGroup(_ title: String, rows: [(String, String, String)]) -> some View { + RemoMouseCard { + VStack(alignment: .leading, spacing: 14) { + Text(title).font(.headline) + ForEach(Array(rows.enumerated()), id: \.offset) { index, row in + if index > 0 { RemoMouseDivider() } + HStack(spacing: 12) { + Image(systemName: row.2) + .foregroundStyle(RemoMouseVisualLanguage.accent) + .frame(width: 28, height: 28) + .background(RemoMouseVisualLanguage.accent.opacity(0.10), in: .rect(cornerRadius: 8)) + VStack(alignment: .leading, spacing: 2) { + Text(row.0).font(.subheadline.weight(.medium)) + Text(row.1).font(.caption).foregroundStyle(.secondary) + } + Spacer() + } + } + } + } + .frame(maxWidth: .infinity) + } + + private var airPointerView: some View { + RemoMouseCard { + VStack(spacing: 20) { + ZStack { + Circle().fill((model.hasAirPointer ? RemoMouseVisualLanguage.accent : Color.secondary).opacity(0.11)) + Image(systemName: "gyroscope") + .font(.system(size: 38, weight: .medium)) + .foregroundStyle(model.hasAirPointer ? RemoMouseVisualLanguage.accent : .secondary) + } + .frame(width: 82, height: 82) + Text(model.hasAirPointer ? "Motion Ready" : "Motion Not Detected") + .font(.title2.weight(.semibold)) + Text(model.hasAirPointer + ? "Choose Air Pointer in the menu-bar popover, then hold still briefly to establish a neutral pose." + : "Air Pointer appears automatically when this remote and macOS provide real gyroscope samples. Touch control remains fully available.") + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .frame(maxWidth: 480) + if model.hasAirPointer { + Button("Use Air Pointer", systemImage: "gyroscope") { model.selectMode(.airPointer) } + .buttonStyle(.borderedProminent) + .controlSize(.large) + } + } + .frame(maxWidth: .infinity) + .padding(.vertical, 26) + } + } + + private var keyboardView: some View { + VStack(spacing: 18) { + RemoMouseCard { + VStack(spacing: 18) { + HStack(spacing: 7) { + ForEach(["Q", "W", "E", "R", "T", "Y", "U"], id: \.self) { key in + Text(key) + .font(.caption.weight(.medium)) + .frame(width: 42, height: 34) + .background(.primary.opacity(0.07), in: .rect(cornerRadius: 8)) + } + } + Text("The keyboard opens only after the remote focuses a text field and positions itself away from what you are typing.") + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .frame(maxWidth: 520) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 10) + } + RemoMouseCard { + VStack(spacing: 14) { + RemoMouseSettingRow("Show Automatically", detail: "Only for text fields focused using RemoMouse") { + Toggle("Show Automatically", isOn: $model.automaticKeyboard).labelsHidden() + } + RemoMouseDivider() + RemoMouseSettingRow("Keyboard Preview", detail: "Secure fields never expose their contents") { + Button(model.isKeyboardVisible ? "Hide Keyboard" : "Show Keyboard") { + model.isKeyboardVisible ? model.hideKeyboard() : model.showKeyboard() + } + } + } + } + } + } + + private var diagnosticsView: some View { + VStack(spacing: 18) { + RemoMouseCard { + VStack(spacing: 14) { + diagnosticRow("Connection", value: model.connection.rawValue, symbol: connectionSymbol, color: connectionColor) + RemoMouseDivider() + diagnosticRow("Last Activity", value: model.lastInput, symbol: "waveform", color: RemoMouseVisualLanguage.accent) + RemoMouseDivider() + diagnosticRow("Accessibility", value: model.hasAccessibility ? "Allowed" : "Required", symbol: "hand.raised.fill", color: model.hasAccessibility ? RemoMouseVisualLanguage.mint : .orange) + } + } + RemoMouseCard { + VStack(alignment: .leading, spacing: 14) { + Label("Motion Capture", systemImage: "gyroscope") + .font(.headline) + if model.isCapturingMotion { + ProgressView(value: Double(model.motionCaptureElapsed), total: 20) + .tint(RemoMouseVisualLanguage.accent) + Text(model.motionCaptureInstruction).foregroundStyle(.secondary) + HStack { + Text("\(model.motionCaptureReportCount) reports").font(.caption).foregroundStyle(.secondary) + Spacer() + Button("Cancel", role: .cancel) { model.cancelMotionCapture() } + } + } else { + Text(model.motionCaptureStatus.isEmpty ? "Capture a short local sensor trace when diagnosing Air Pointer availability." : model.motionCaptureStatus) + .foregroundStyle(.secondary) + Button("Capture Motion Diagnostics…") { model.startMotionCapture() } + .buttonStyle(.borderedProminent) + } + } + } + } + } + + private func intentRow(_ title: String, detail: String, symbol: String) -> some View { + HStack(spacing: 10) { + Image(systemName: symbol) + .foregroundStyle(RemoMouseVisualLanguage.accent) + .frame(width: 22) + VStack(alignment: .leading, spacing: 1) { + Text(title).font(.subheadline.weight(.medium)) + Text(detail).font(.caption).foregroundStyle(.secondary) + } + } + } + + private func diagnosticRow(_ title: String, value: String, symbol: String, color: Color) -> some View { + HStack(spacing: 12) { + Image(systemName: symbol).foregroundStyle(color).frame(width: 24) + Text(title) + Spacer() + Text(value).foregroundStyle(.secondary) + } + } + + private var enabledBinding: Binding { + Binding(get: { model.isEnabled }, set: { value in + if value != model.isEnabled { model.toggle() } + }) + } + + private var handednessBinding: Binding { profileBinding(\.handedness) } + private var pointerSpeedBinding: Binding { profileBinding(\.pointerSpeed) } + private var scrollSpeedBinding: Binding { profileBinding(\.scrollSpeed) } + private var railWidthBinding: Binding { profileBinding(\.railWidth) } + private var tapToClickBinding: Binding { profileBinding(\.tapToClick) } + private var momentumBinding: Binding { profileBinding(\.momentumEnabled) } + + private var presetBinding: Binding { + Binding( + get: { + switch model.ergonomicProfile.pointerSpeed { + case ..<5.75: .precise + case 8.75...: .fast + default: .balanced + } + }, + set: { preset in + var profile = model.ergonomicProfile + profile.pointerSpeed = preset.profile.pointerSpeed + profile.scrollSpeed = preset.profile.scrollSpeed + model.applyErgonomicProfile(profile) + } + ) + } + + private func profileBinding(_ keyPath: WritableKeyPath) -> Binding { + Binding( + get: { model.ergonomicProfile[keyPath: keyPath] }, + set: { value in + var profile = model.ergonomicProfile + profile[keyPath: keyPath] = value + model.applyErgonomicProfile(profile) + } + ) + } + + private var connectionColor: Color { + if !model.isEnabled { return .secondary } + return switch model.connection { + case .connected: RemoMouseVisualLanguage.mint + case .sleeping: .orange + case .looking: RemoMouseVisualLanguage.accent + } + } + + private var connectionSymbol: String { + if !model.isEnabled { return "pause.circle.fill" } + return switch model.connection { + case .connected: "checkmark.circle.fill" + case .sleeping: "moon.zzz.fill" + case .looking: "antenna.radiowaves.left.and.right" + } + } +} diff --git a/Sources/RemoMouseApp/RemoMouseVisualLanguage.swift b/Sources/RemoMouseApp/RemoMouseVisualLanguage.swift new file mode 100644 index 0000000..b355f54 --- /dev/null +++ b/Sources/RemoMouseApp/RemoMouseVisualLanguage.swift @@ -0,0 +1,95 @@ +import SwiftUI + +enum RemoMouseVisualLanguage { + static let accent = Color(red: 0.30, green: 0.55, blue: 1.0) + static let mint = Color(red: 0.28, green: 0.82, blue: 0.66) + static let cardRadius: CGFloat = 18 +} + +struct RemoMouseCard: View { + var padding: CGFloat = 18 + @ViewBuilder var content: Content + + var body: some View { + content + .padding(padding) + .background(.background.opacity(0.64), in: .rect(cornerRadius: RemoMouseVisualLanguage.cardRadius)) + .overlay { + RoundedRectangle(cornerRadius: RemoMouseVisualLanguage.cardRadius, style: .continuous) + .strokeBorder(.white.opacity(0.09)) + } + .shadow(color: .black.opacity(0.07), radius: 16, y: 7) + } +} + +struct RemoMouseSectionTitle: View { + let title: String + let subtitle: String + let symbol: String + + var body: some View { + HStack(alignment: .top, spacing: 14) { + Image(systemName: symbol) + .font(.system(size: 20, weight: .semibold)) + .foregroundStyle(RemoMouseVisualLanguage.accent) + .frame(width: 38, height: 38) + .background(RemoMouseVisualLanguage.accent.opacity(0.13), in: .rect(cornerRadius: 11)) + VStack(alignment: .leading, spacing: 3) { + Text(title) + .font(.title2.weight(.semibold)) + Text(subtitle) + .font(.callout) + .foregroundStyle(.secondary) + } + } + } +} + +struct RemoMouseStatusPill: View { + let text: String + let symbol: String + var color = RemoMouseVisualLanguage.mint + + var body: some View { + Label(text, systemImage: symbol) + .font(.caption.weight(.medium)) + .foregroundStyle(color) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(color.opacity(0.12), in: .capsule) + } +} + +struct RemoMouseSettingRow: View { + let title: String + let detail: String? + @ViewBuilder var content: Content + + init(_ title: String, detail: String? = nil, @ViewBuilder content: () -> Content) { + self.title = title + self.detail = detail + self.content = content() + } + + var body: some View { + HStack(alignment: .center, spacing: 18) { + VStack(alignment: .leading, spacing: 3) { + Text(title) + if let detail { + Text(detail) + .font(.caption) + .foregroundStyle(.secondary) + } + } + Spacer(minLength: 28) + content + } + .padding(.vertical, 5) + } +} + +struct RemoMouseDivider: View { + var body: some View { + Divider().opacity(0.55) + } +} diff --git a/Sources/RemoMouseApp/RemoteTouchPreview.swift b/Sources/RemoMouseApp/RemoteTouchPreview.swift new file mode 100644 index 0000000..4335a54 --- /dev/null +++ b/Sources/RemoMouseApp/RemoteTouchPreview.swift @@ -0,0 +1,70 @@ +import SwiftUI +import RemoMouseHardware + +struct RemoteTouchPreview: View { + let sample: SiriRemoteMultitouchSample? + let profile: SiriRemoteErgonomicProfile + var showsRail = true + + var body: some View { + GeometryReader { proxy in + let bounds = CGRect(origin: .zero, size: proxy.size) + ZStack(alignment: profile.handedness == .right ? .trailing : .leading) { + RoundedRectangle(cornerRadius: 22, style: .continuous) + .fill( + LinearGradient( + colors: [.white.opacity(0.11), .black.opacity(0.11)], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + RoundedRectangle(cornerRadius: 22, style: .continuous) + .strokeBorder(.white.opacity(0.16), lineWidth: 1) + + Circle() + .stroke(.primary.opacity(0.055), lineWidth: 1) + .frame(width: min(proxy.size.width, proxy.size.height) * 0.64) + + Circle() + .fill(.primary.opacity(0.03)) + .frame(width: 7, height: 7) + + if showsRail { + Rectangle() + .fill( + LinearGradient( + colors: [RemoMouseVisualLanguage.accent.opacity(0.08), RemoMouseVisualLanguage.accent.opacity(0.24)], + startPoint: profile.handedness == .right ? .leading : .trailing, + endPoint: profile.handedness == .right ? .trailing : .leading + ) + ) + .frame(width: proxy.size.width * profile.railWidth) + .mask(RoundedRectangle(cornerRadius: 22, style: .continuous)) + + Image(systemName: "arrow.up.and.down") + .font(.caption2.weight(.semibold)) + .foregroundStyle(RemoMouseVisualLanguage.accent.opacity(0.72)) + .padding(.horizontal, max(7, proxy.size.width * profile.railWidth / 2 - 7)) + } + + if let sample, sample.isContact { + let areaScale = min(max(sample.contactArea / 0.012, 0.35), 1.4) + Ellipse() + .fill(RemoMouseVisualLanguage.accent.opacity(0.86)) + .frame(width: 30 * areaScale, height: 22 * areaScale) + .position( + x: min(max(sample.x, 0), 1) * bounds.width, + y: min(max(sample.y, 0), 1) * bounds.height + ) + .overlay(Ellipse().stroke(.white.opacity(0.55), lineWidth: 1)) + .shadow(color: RemoMouseVisualLanguage.accent.opacity(0.45), radius: 8) + } + } + .shadow(color: .black.opacity(0.14), radius: 18, y: 9) + } + .aspectRatio(1.55, contentMode: .fit) + .accessibilityElement(children: .ignore) + .accessibilityLabel("Remote touch surface preview") + .accessibilityValue(sample?.isContact == true ? "Thumb detected" : "No touch") + } +} diff --git a/Sources/RemoMouseHardware/GameControllerTransport.swift b/Sources/RemoMouseHardware/GameControllerTransport.swift index 736a995..28bb37a 100644 --- a/Sources/RemoMouseHardware/GameControllerTransport.swift +++ b/Sources/RemoMouseHardware/GameControllerTransport.swift @@ -24,6 +24,7 @@ public final class GameControllerTransport: RemoteTransport { let pair = AsyncThrowingStream.makeStream() continuation?.finish() continuation = pair.continuation + GCController.shouldMonitorBackgroundEvents = true installObserversIfNeeded() GCController.controllers().forEach(consider) GCController.startWirelessControllerDiscovery {} @@ -99,6 +100,9 @@ public final class GameControllerTransport: RemoteTransport { let sample = Self.sample(controller: candidate, microGamepad: gamepad) Task { @MainActor [weak self] in self?.consume(sample) } } + if candidate.motion?.sensorsRequireManualActivation == true { + candidate.motion?.sensorsActive = true + } candidate.motion?.valueChangedHandler = { [weak self] motion in let sample = Self.sample( controller: candidate, @@ -148,6 +152,9 @@ public final class GameControllerTransport: RemoteTransport { private func clearHandlers() { controller?.microGamepad?.valueChangedHandler = nil controller?.motion?.valueChangedHandler = nil + if controller?.motion?.sensorsRequireManualActivation == true { + controller?.motion?.sensorsActive = false + } } private static func sample( diff --git a/Sources/RemoMouseHardware/SiriRemoteAirPointerFilter.swift b/Sources/RemoMouseHardware/SiriRemoteAirPointerFilter.swift new file mode 100644 index 0000000..a3542fa --- /dev/null +++ b/Sources/RemoMouseHardware/SiriRemoteAirPointerFilter.swift @@ -0,0 +1,127 @@ +import Foundation +import RemoMouseDomain + +public struct SiriRemoteAirPointerDelta: Equatable, Sendable { + public let x: Double + public let y: Double + + public init(x: Double, y: Double) { + self.x = x + self.y = y + } + + public static let zero = SiriRemoteAirPointerDelta(x: 0, y: 0) +} + +public struct SiriRemoteAirPointerFilter: Sendable { + public private(set) var isCalibrated = false + + private let calibrationNanoseconds: UInt64 + private let staleNanoseconds: UInt64 = 100_000_000 + private var calibrationStartedAt: UInt64? + private var calibrationHorizontal = 0.0 + private var calibrationVertical = 0.0 + private var calibrationCount = 0 + private var horizontalBias = 0.0 + private var verticalBias = 0.0 + private var smoothedHorizontal = 0.0 + private var smoothedVertical = 0.0 + private var lastTimestamp: UInt64? + + public init(calibrationDuration: TimeInterval = 0.5) { + calibrationNanoseconds = UInt64(max(calibrationDuration, 0) * 1_000_000_000) + } + + public mutating func update( + _ sample: MotionSample, + now: UInt64 = DispatchTime.now().uptimeNanoseconds + ) -> SiriRemoteAirPointerDelta? { + guard now <= sample.timestampNanoseconds + || now - sample.timestampNanoseconds <= staleNanoseconds, + abs(sample.rotationX) <= 20, + abs(sample.rotationY) <= 20, + abs(sample.rotationZ) <= 20 + else { return nil } + + let rates = projectedRates(sample) + if !isCalibrated { + let started = calibrationStartedAt ?? sample.timestampNanoseconds + calibrationStartedAt = started + calibrationHorizontal += rates.horizontal + calibrationVertical += rates.vertical + calibrationCount += 1 + guard sample.timestampNanoseconds - started >= calibrationNanoseconds else { + lastTimestamp = sample.timestampNanoseconds + return nil + } + horizontalBias = calibrationHorizontal / Double(calibrationCount) + verticalBias = calibrationVertical / Double(calibrationCount) + isCalibrated = true + lastTimestamp = sample.timestampNanoseconds + return .zero + } + + guard let previousTimestamp = lastTimestamp, + sample.timestampNanoseconds > previousTimestamp + else { + lastTimestamp = sample.timestampNanoseconds + return .zero + } + lastTimestamp = sample.timestampNanoseconds + let elapsed = min(Double(sample.timestampNanoseconds - previousTimestamp) / 1_000_000_000, 0.05) + + let horizontal = rates.horizontal - horizontalBias + let vertical = rates.vertical - verticalBias + let deadZone = 0.035 + if hypot(horizontal, vertical) < deadZone { + smoothedHorizontal = 0 + smoothedVertical = 0 + horizontalBias += horizontal * 0.002 + verticalBias += vertical * 0.002 + return .zero + } + + let magnitude = hypot(horizontal, vertical) + let smoothing = min(max(0.30 + magnitude * 0.10, 0.30), 0.62) + smoothedHorizontal += (horizontal - smoothedHorizontal) * smoothing + smoothedVertical += (vertical - smoothedVertical) * smoothing + let acceleration = 1 + min(max((magnitude - 0.10) * 0.65, 0), 2.2) + let pointsPerRadian = 680.0 + return SiriRemoteAirPointerDelta( + x: smoothedHorizontal * elapsed * pointsPerRadian * acceleration, + y: -smoothedVertical * elapsed * pointsPerRadian * acceleration + ) + } + + public mutating func reset() { + self = SiriRemoteAirPointerFilter( + calibrationDuration: Double(calibrationNanoseconds) / 1_000_000_000 + ) + } + + private func projectedRates(_ sample: MotionSample) -> (horizontal: Double, vertical: Double) { + let gravityLength = max(hypot(sample.gravityX, hypot(sample.gravityY, sample.gravityZ)), 0.0001) + let up = ( + x: -sample.gravityX / gravityLength, + y: -sample.gravityY / gravityLength, + z: -sample.gravityZ / gravityLength + ) + let horizontal = sample.rotationX * up.x + + sample.rotationY * up.y + + sample.rotationZ * up.z + + // The top edge of a Siri Remote is its forward direction. Crossing it + // with gravity yields a roll-independent pitch axis for wand aiming. + var right = (x: up.z, y: 0.0, z: -up.x) + let rightLength = hypot(right.x, right.z) + if rightLength < 0.05 { + right = (x: 1, y: 0, z: 0) + } else { + right = (x: right.x / rightLength, y: 0, z: right.z / rightLength) + } + let vertical = sample.rotationX * right.x + + sample.rotationY * right.y + + sample.rotationZ * right.z + return (horizontal, vertical) + } +} diff --git a/Sources/RemoMouseHardware/SiriRemoteCalibrationFitter.swift b/Sources/RemoMouseHardware/SiriRemoteCalibrationFitter.swift new file mode 100644 index 0000000..c79f77d --- /dev/null +++ b/Sources/RemoMouseHardware/SiriRemoteCalibrationFitter.swift @@ -0,0 +1,40 @@ +import Foundation + +public enum SiriRemoteCalibrationFitter { + public static func fit( + stationaryDeltas: [Double], + contactAreas: [Double], + clickTravel: [Double], + scrollStarts: [Double] + ) -> SiriRemoteErgonomicProfile { + let stationary = usable(stationaryDeltas) + let areas = usable(contactAreas) + let clicks = usable(clickTravel) + let scroll = usable(scrollStarts) + guard !stationary.isEmpty, !areas.isEmpty, !clicks.isEmpty, !scroll.isEmpty else { + return .balanced + } + + let area = percentile(areas, 0.75) + let areaFactor = min(max((area - 0.002) / 0.012, 0), 1) + var result = SiriRemoteErgonomicProfile.balanced + result.pointerDeadZone = percentile(stationary, 0.95) * (1.30 + areaFactor * 0.20) + result.dragThreshold = percentile(clicks, 0.95) * 1.75 + result.railWidth = 1 - percentile(scroll, 0.50) + return result.validated() + } + + private static func usable(_ values: [Double]) -> [Double] { + values.filter { $0.isFinite && $0 >= 0 }.sorted() + } + + private static func percentile(_ sorted: [Double], _ fraction: Double) -> Double { + guard sorted.count > 1 else { return sorted.first ?? 0 } + let position = min(max(fraction, 0), 1) * Double(sorted.count - 1) + let lower = Int(position.rounded(.down)) + let upper = Int(position.rounded(.up)) + guard lower != upper else { return sorted[lower] } + let weight = position - Double(lower) + return sorted[lower] + (sorted[upper] - sorted[lower]) * weight + } +} diff --git a/Sources/RemoMouseHardware/SiriRemoteErgonomicProfile.swift b/Sources/RemoMouseHardware/SiriRemoteErgonomicProfile.swift new file mode 100644 index 0000000..f3ae09a --- /dev/null +++ b/Sources/RemoMouseHardware/SiriRemoteErgonomicProfile.swift @@ -0,0 +1,100 @@ +import Foundation + +public enum SiriRemoteHandedness: String, Codable, CaseIterable, Sendable { + case right + case left +} + +public enum SiriRemoteSpeedPreset: String, Codable, CaseIterable, Sendable { + case precise + case balanced + case fast + + public var profile: SiriRemoteErgonomicProfile { + switch self { + case .precise: + SiriRemoteErgonomicProfile(pointerSpeed: 4.5, scrollSpeed: 5.0) + case .balanced: + .balanced + case .fast: + SiriRemoteErgonomicProfile(pointerSpeed: 10.0, scrollSpeed: 9.5) + } + } +} + +public struct SiriRemoteErgonomicProfile: Codable, Equatable, Sendable { + public static let currentSchemaVersion = 1 + + public var schemaVersion: Int + public var handedness: SiriRemoteHandedness + public var pointerSpeed: Double + public var scrollSpeed: Double + public var railWidth: Double + public var pointerDeadZone: Double + public var dragThreshold: Double + public var compressionInterval: TimeInterval + public var momentumEnabled: Bool + public var tapToClick: Bool + + public init( + schemaVersion: Int = currentSchemaVersion, + handedness: SiriRemoteHandedness = .right, + pointerSpeed: Double = 7, + scrollSpeed: Double = 7, + railWidth: Double = 0.18, + pointerDeadZone: Double = 0.00045, + dragThreshold: Double = 0.032, + compressionInterval: TimeInterval = 0.10, + momentumEnabled: Bool = true, + tapToClick: Bool = false + ) { + self.schemaVersion = schemaVersion + self.handedness = handedness + self.pointerSpeed = pointerSpeed + self.scrollSpeed = scrollSpeed + self.railWidth = railWidth + self.pointerDeadZone = pointerDeadZone + self.dragThreshold = dragThreshold + self.compressionInterval = compressionInterval + self.momentumEnabled = momentumEnabled + self.tapToClick = tapToClick + } + + public static let balanced = SiriRemoteErgonomicProfile() + + public func validated() -> Self { + var result = self + result.schemaVersion = Self.currentSchemaVersion + result.pointerSpeed = Self.clamp(pointerSpeed, to: 2...14, fallback: 7) + result.scrollSpeed = Self.clamp(scrollSpeed, to: 2...14, fallback: 7) + result.railWidth = Self.clamp(railWidth, to: 0.14...0.24, fallback: 0.18) + result.pointerDeadZone = Self.clamp( + pointerDeadZone, + to: 0.0002...0.0015, + fallback: 0.00045 + ) + result.dragThreshold = Self.clamp(dragThreshold, to: 0.02...0.08, fallback: 0.032) + result.compressionInterval = Self.clamp( + compressionInterval, + to: 0.06...0.16, + fallback: 0.10 + ) + return result + } + + public static func decodeOrDefault(_ data: Data) -> Self { + guard let decoded = try? JSONDecoder().decode(Self.self, from: data), + decoded.schemaVersion == currentSchemaVersion + else { return .balanced } + return decoded.validated() + } + + private static func clamp( + _ value: Double, + to range: ClosedRange, + fallback: Double + ) -> Double { + guard value.isFinite else { return fallback } + return min(max(value, range.lowerBound), range.upperBound) + } +} diff --git a/Sources/RemoMouseHardware/SiriRemoteHIDMonitor.swift b/Sources/RemoMouseHardware/SiriRemoteHIDMonitor.swift index 50c0cdb..9ded4bd 100644 --- a/Sources/RemoMouseHardware/SiriRemoteHIDMonitor.swift +++ b/Sources/RemoMouseHardware/SiriRemoteHIDMonitor.swift @@ -7,6 +7,14 @@ public struct SiriRemoteElementValue: Sendable { public let value: Int public let logicalMinimum: Int public let logicalMaximum: Int + + /// macOS may expose the remote's touch-surface compression either in the + /// vendor report or as a standard HID Button 1 value, depending on the + /// host and Bluetooth stack. + public var primaryButtonPressed: Bool? { + guard usagePage == 0x09, usage == 0x01 else { return nil } + return value != 0 + } } public enum SiriRemoteHIDEvent: Sendable { @@ -17,6 +25,8 @@ public enum SiriRemoteHIDEvent: Sendable { } public final class SiriRemoteHIDMonitor: @unchecked Sendable { + static let managerOpenOptions = IOOptionBits(kIOHIDOptionsTypeNone) + public typealias Handler = @Sendable (SiriRemoteHIDEvent) -> Void private final class ReportBuffer { @@ -60,7 +70,10 @@ public final class SiriRemoteHIDMonitor: @unchecked Sendable { IOHIDManagerRegisterDeviceRemovalCallback(manager, Self.deviceRemoved, context) IOHIDManagerRegisterInputValueCallback(manager, Self.inputValue, context) IOHIDManagerScheduleWithRunLoop(manager, CFRunLoopGetMain(), CFRunLoopMode.commonModes.rawValue) - IOHIDManagerOpen(manager, IOOptionBits(kIOHIDOptionsTypeSeizeDevice)) + // Do not seize the consumer-control interface. GameController needs to + // coalesce it with the remote's sensor interface before it can publish + // GCMotion. Seizing here made touch/buttons work while starving motion. + IOHIDManagerOpen(manager, Self.managerOpenOptions) } public func stop() { diff --git a/Sources/RemoMouseHardware/SiriRemoteInputFilters.swift b/Sources/RemoMouseHardware/SiriRemoteInputFilters.swift index 3245a11..c09c7bc 100644 --- a/Sources/RemoMouseHardware/SiriRemoteInputFilters.swift +++ b/Sources/RemoMouseHardware/SiriRemoteInputFilters.swift @@ -35,10 +35,13 @@ public struct SiriRemoteScrollFilter: Sendable { public mutating func update( dx: Double, dy: Double, + contactArea: Double = 0.006, scale: Double, timestamp: TimeInterval? = nil ) -> SiriRemotePixelDelta { - guard hypot(dx, dy) >= 0.0008 else { return .zero } + let areaFactor = min(max((contactArea - 0.002) / 0.010, 0), 1) + let jitterThreshold = 0.00055 + areaFactor * 0.00055 + guard hypot(dx, dy) >= jitterThreshold else { return .zero } if axis == .undecided { if abs(dy) > abs(dx) * 1.5 { @@ -54,7 +57,7 @@ public struct SiriRemoteScrollFilter: Sendable { 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 + let smoothing = 0.38 - areaFactor * 0.10 smoothedX += (filteredX - smoothedX) * smoothing smoothedY += (filteredY - smoothedY) * smoothing let pixelsX = smoothedX * scale diff --git a/Sources/RemoMouseHardware/SiriRemoteMotionMonitor.swift b/Sources/RemoMouseHardware/SiriRemoteMotionMonitor.swift index 84ee32b..8f22cd4 100644 --- a/Sources/RemoMouseHardware/SiriRemoteMotionMonitor.swift +++ b/Sources/RemoMouseHardware/SiriRemoteMotionMonitor.swift @@ -8,9 +8,16 @@ public enum SiriRemoteMotionEvent: Sendable { case warning(String) } -/// Passively observes only the first-generation Siri Remote sensor interfaces. +/// Passively observes only the first-generation Siri Remote sensor interface. /// It never seizes a HID device and never generates pointer output. public final class SiriRemoteMotionMonitor: @unchecked Sendable { + static let matchingCriteria: [String: Int] = [ + kIOHIDVendorIDKey: 76, + kIOHIDProductIDKey: 621, + kIOHIDPrimaryUsagePageKey: 0xFF00, + kIOHIDPrimaryUsageKey: 0x10, + ] + private final class ReportBuffer { let bytes: UnsafeMutablePointer let capacity: Int @@ -47,13 +54,8 @@ public final class SiriRemoteMotionMonitor: @unchecked Sendable { 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) + IOHIDManagerSetDeviceMatching(manager, Self.matchingCriteria as CFDictionary) IOHIDManagerRegisterDeviceMatchingCallback(manager, Self.deviceMatched, context) IOHIDManagerRegisterDeviceRemovalCallback(manager, Self.deviceRemoved, context) IOHIDManagerScheduleWithRunLoop( @@ -87,13 +89,12 @@ public final class SiriRemoteMotionMonitor: @unchecked Sendable { guard reportBuffers[key] == nil else { return } let requestedInterval = NSNumber(value: 8_000) - guard IOHIDDeviceSetProperty( + if !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) diff --git a/Sources/RemoMouseHardware/SiriRemoteMultitouchMonitor.swift b/Sources/RemoMouseHardware/SiriRemoteMultitouchMonitor.swift index 6627268..30286e6 100644 --- a/Sources/RemoMouseHardware/SiriRemoteMultitouchMonitor.swift +++ b/Sources/RemoMouseHardware/SiriRemoteMultitouchMonitor.swift @@ -2,12 +2,46 @@ import CMultitouchBridge import Foundation public struct SiriRemoteMultitouchSample: Equatable, Sendable { + public let timestamp: TimeInterval public let x: Double public let y: Double + public let velocityX: Double + public let velocityY: Double public let pressure: Double + public let majorAxis: Double + public let minorAxis: Double + public let density: Double public let state: UInt32 public let touchCount: Int + public init( + timestamp: TimeInterval = 0, + x: Double, + y: Double, + velocityX: Double = 0, + velocityY: Double = 0, + pressure: Double, + majorAxis: Double = 0.06, + minorAxis: Double = 0.04, + density: Double = 0, + state: UInt32, + touchCount: Int + ) { + self.timestamp = timestamp + self.x = x + self.y = y + self.velocityX = velocityX + self.velocityY = velocityY + self.pressure = pressure + self.majorAxis = majorAxis + self.minorAxis = minorAxis + self.density = density + self.state = state + self.touchCount = touchCount + } + + public var contactArea: Double { max(majorAxis * minorAxis, 0) } + public var isContact: Bool { touchCount > 0 && state >= 3 && state <= 5 } @@ -23,17 +57,36 @@ public final class SiriRemoteMultitouchMonitor: @unchecked Sendable { @discardableResult public func start() -> Bool { - RMStartRemoteTouch({ x, y, pressure, state, touchCount, context in + RMStartRemoteTouch({ timestamp, x, y, velocityX, velocityY, pressure, majorAxis, minorAxis, density, state, touchCount, context in guard let context else { return } let monitor = Unmanaged .fromOpaque(context).takeUnretainedValue() monitor.handler(SiriRemoteMultitouchSample( + timestamp: timestamp, x: Double(x), y: Double(y), + velocityX: Double(velocityX), + velocityY: Double(velocityY), pressure: Double(pressure), + majorAxis: Double(majorAxis), + minorAxis: Double(minorAxis), + density: Double(density), state: state, touchCount: touchCount )) }, Unmanaged.passUnretained(self).toOpaque()) } + + @discardableResult + public func restart() -> Bool { + RMRestartRemoteTouch() || start() + } + + public func restartAsync() async -> Bool { + await withCheckedContinuation { continuation in + DispatchQueue.global(qos: .userInitiated).async { [self] in + continuation.resume(returning: restart()) + } + } + } } diff --git a/Sources/RemoMouseHardware/SiriRemotePointerFilter.swift b/Sources/RemoMouseHardware/SiriRemotePointerFilter.swift new file mode 100644 index 0000000..c1a7341 --- /dev/null +++ b/Sources/RemoMouseHardware/SiriRemotePointerFilter.swift @@ -0,0 +1,87 @@ +import Foundation + +public struct SiriRemoteContinuousDelta: Equatable, Sendable { + public var x: Double + public var y: Double + + public init(x: Double, y: Double) { + self.x = x + self.y = y + } + + public static let zero = SiriRemoteContinuousDelta(x: 0, y: 0) +} + +public struct SiriRemotePointerFilter: Sendable { + private var lastTimestamp: TimeInterval? + private var smoothedX = 0.0 + private var smoothedY = 0.0 + private var previousInputX = 0.0 + private var previousInputY = 0.0 + + public init() {} + + public mutating func update( + dx: Double, + dy: Double, + velocityX: Double = 0, + velocityY: Double = 0, + contactArea: Double = 0.006, + timestamp: TimeInterval, + profile: SiriRemoteErgonomicProfile + ) -> SiriRemoteContinuousDelta { + guard dx.isFinite, dy.isFinite, velocityX.isFinite, velocityY.isFinite, + contactArea.isFinite, timestamp.isFinite + else { + reset() + return .zero + } + + guard let priorTimestamp = lastTimestamp else { + lastTimestamp = timestamp + return .zero + } + let elapsed = timestamp - priorTimestamp + lastTimestamp = timestamp + guard elapsed > 0, elapsed <= 0.10 else { + smoothedX = 0 + smoothedY = 0 + previousInputX = 0 + previousInputY = 0 + return .zero + } + + let safeProfile = profile.validated() + let areaFactor = min(max((contactArea - 0.002) / 0.010, 0), 1) + let deadZone = safeProfile.pointerDeadZone * (0.90 + areaFactor * 0.75) + let distance = hypot(dx, dy) + guard distance >= deadZone else { + previousInputX = dx + previousInputY = dy + return .zero + } + + if dx * previousInputX < 0 { smoothedX = 0 } + if dy * previousInputY < 0 { smoothedY = 0 } + previousInputX = dx + previousInputY = dy + + let measuredVelocity = min(distance / elapsed, 4) + let hardwareVelocity = min(hypot(velocityX, velocityY), 4) + let velocity = max(measuredVelocity, hardwareVelocity) + let acceleration = 0.88 + min(velocity * 0.72, 2.15) + let smoothing = min(max(0.30 + velocity * 0.15 - areaFactor * 0.07, 0.24), 0.80) + smoothedX += (dx - smoothedX) * smoothing + smoothedY += (dy - smoothedY) * smoothing + + let scale = safeProfile.pointerSpeed * 14.5 * acceleration + return SiriRemoteContinuousDelta( + x: smoothedX * scale, + y: smoothedY * scale + ) + } + + public mutating func reset() { + self = SiriRemotePointerFilter() + } +} diff --git a/Sources/RemoMouseHardware/SiriRemoteTouchSessionInterpreter.swift b/Sources/RemoMouseHardware/SiriRemoteTouchSessionInterpreter.swift new file mode 100644 index 0000000..5b78b6e --- /dev/null +++ b/Sources/RemoMouseHardware/SiriRemoteTouchSessionInterpreter.swift @@ -0,0 +1,309 @@ +import Foundation + +public enum SiriRemoteTouchSessionState: Equatable, Sendable { + case resting + case aiming + case scrollPending + case scrolling + case pressed + case dragging + case ended +} + +public enum SiriRemoteScrollPhase: Equatable, Sendable { + case began + case changed + case ended +} + +public enum SiriRemoteTouchSessionAction: Sendable { + case pointer(SiriRemoteContinuousDelta) + case scroll(SiriRemotePixelDelta, SiriRemoteScrollPhase) + case dragBegan + case drag(SiriRemoteContinuousDelta) + case tap + case ended(momentum: SiriRemoteScrollMomentum?) +} + +public struct SiriRemoteTouchSessionInterpreter: Sendable { + public private(set) var state = SiriRemoteTouchSessionState.resting + + private var origin: (x: Double, y: Double)? + private var lastSample: SiriRemoteMultitouchSample? + private var contactStartedAt: TimeInterval? + private var pressStartedAt: TimeInterval? + private var pressOrigin: (x: Double, y: Double)? + private var physicalWasPressed = false + private var hadPhysicalPress = false + private var maximumTravel = 0.0 + private var scrollOutputActive = false + private var pointerFilter = SiriRemotePointerFilter() + private var scrollFilter = SiriRemoteScrollFilter() + + public init() {} + + public mutating func update( + sample: SiriRemoteMultitouchSample, + physicalPressed: Bool, + profile: SiriRemoteErgonomicProfile, + forceScroll: Bool = false + ) -> [SiriRemoteTouchSessionAction] { + let safeProfile = profile.validated() + guard sample.isContact else { + return finish(timestamp: sample.timestamp, profile: safeProfile) + } + + guard let prior = lastSample, let origin else { + return begin( + sample: sample, + physicalPressed: physicalPressed, + profile: safeProfile, + forceScroll: forceScroll + ) + } + + let dx = sample.x - prior.x + let dy = sample.y - prior.y + let totalX = sample.x - origin.x + let totalY = sample.y - origin.y + maximumTravel = max(maximumTravel, hypot(totalX, totalY)) + lastSample = sample + + if physicalPressed, !physicalWasPressed { + physicalWasPressed = true + hadPhysicalPress = true + pressStartedAt = sample.timestamp + pressOrigin = (sample.x, sample.y) + pointerFilter.reset() + _ = pointerFilter.update( + dx: 0, + dy: 0, + contactArea: sample.contactArea, + timestamp: sample.timestamp, + profile: safeProfile + ) + var actions: [SiriRemoteTouchSessionAction] = [] + if state == .scrolling, scrollOutputActive { + actions.append(.scroll(.zero, .ended)) + } + scrollOutputActive = false + scrollFilter.reset() + state = .pressed + return actions + } + + if !physicalPressed, physicalWasPressed { + physicalWasPressed = false + pressStartedAt = nil + pressOrigin = nil + pointerFilter.reset() + _ = pointerFilter.update( + dx: 0, + dy: 0, + contactArea: sample.contactArea, + timestamp: sample.timestamp, + profile: safeProfile + ) + state = forceScroll ? .scrolling : .aiming + return [] + } + + switch state { + case .resting, .ended: + return begin( + sample: sample, + physicalPressed: physicalPressed, + profile: safeProfile, + forceScroll: forceScroll + ) + case .scrollPending: + return resolvePending( + sample: sample, + dx: dx, + dy: dy, + totalX: totalX, + totalY: totalY, + profile: safeProfile + ) + case .aiming: + let delta = pointerFilter.update( + dx: dx, + dy: dy, + velocityX: sample.velocityX, + velocityY: sample.velocityY, + contactArea: sample.contactArea, + timestamp: sample.timestamp, + profile: safeProfile + ) + return delta == .zero ? [] : [.pointer(delta)] + case .scrolling: + return updateScroll(sample: sample, dx: dx, dy: dy, profile: safeProfile) + case .pressed: + guard physicalPressed, let pressStartedAt, let pressOrigin else { return [] } + let pressTravel = hypot(sample.x - pressOrigin.x, sample.y - pressOrigin.y) + guard sample.timestamp - pressStartedAt >= safeProfile.compressionInterval, + pressTravel >= safeProfile.dragThreshold + else { return [] } + state = .dragging + pointerFilter.reset() + _ = pointerFilter.update( + dx: 0, + dy: 0, + contactArea: sample.contactArea, + timestamp: sample.timestamp, + profile: safeProfile + ) + return [.dragBegan] + case .dragging: + guard physicalPressed else { return [] } + let delta = pointerFilter.update( + dx: dx, + dy: dy, + velocityX: sample.velocityX, + velocityY: sample.velocityY, + contactArea: sample.contactArea, + timestamp: sample.timestamp, + profile: safeProfile + ) + return delta == .zero ? [] : [.drag(delta)] + } + } + + public mutating func cancel() -> [SiriRemoteTouchSessionAction] { + var actions: [SiriRemoteTouchSessionAction] = [] + if state == .scrolling, scrollOutputActive { + actions.append(.scroll(.zero, .ended)) + } + actions.append(.ended(momentum: nil)) + reset(to: .ended) + return actions + } + + private mutating func begin( + sample: SiriRemoteMultitouchSample, + physicalPressed: Bool, + profile: SiriRemoteErgonomicProfile, + forceScroll: Bool + ) -> [SiriRemoteTouchSessionAction] { + origin = (sample.x, sample.y) + lastSample = sample + contactStartedAt = sample.timestamp + physicalWasPressed = physicalPressed + hadPhysicalPress = physicalPressed + maximumTravel = 0 + pointerFilter.reset() + scrollFilter.reset() + scrollOutputActive = false + + if physicalPressed { + state = .pressed + pressStartedAt = sample.timestamp + pressOrigin = (sample.x, sample.y) + } else if forceScroll { + state = .scrolling + } else { + let onRail = profile.handedness == .right + ? sample.x >= 1 - profile.railWidth + : sample.x <= profile.railWidth + state = onRail ? .scrollPending : .aiming + } + + _ = pointerFilter.update( + dx: 0, + dy: 0, + contactArea: sample.contactArea, + timestamp: sample.timestamp, + profile: profile + ) + return [] + } + + private mutating func resolvePending( + sample: SiriRemoteMultitouchSample, + dx: Double, + dy: Double, + totalX: Double, + totalY: Double, + profile: SiriRemoteErgonomicProfile + ) -> [SiriRemoteTouchSessionAction] { + let activationDistance = 0.024 + if abs(totalY) >= activationDistance, abs(totalY) >= abs(totalX) * 1.5 { + state = .scrolling + return updateScroll(sample: sample, dx: dx, dy: dy, profile: profile) + } + + let inward = profile.handedness == .right ? totalX < 0 : totalX > 0 + let horizontalIntent = abs(totalX) >= activationDistance && abs(totalX) > abs(totalY) * 1.5 + if (inward && abs(totalX) >= activationDistance) || horizontalIntent || hypot(totalX, totalY) >= 0.048 { + state = .aiming + pointerFilter.reset() + _ = pointerFilter.update( + dx: 0, + dy: 0, + contactArea: sample.contactArea, + timestamp: sample.timestamp, + profile: profile + ) + } + return [] + } + + private mutating func updateScroll( + sample: SiriRemoteMultitouchSample, + dx: Double, + dy: Double, + profile: SiriRemoteErgonomicProfile + ) -> [SiriRemoteTouchSessionAction] { + let pixels = scrollFilter.update( + dx: dx, + dy: dy, + contactArea: sample.contactArea, + scale: profile.scrollSpeed * 28, + timestamp: sample.timestamp + ) + guard pixels != .zero else { return [] } + let phase: SiriRemoteScrollPhase = scrollOutputActive ? .changed : .began + scrollOutputActive = true + return [.scroll(pixels, phase)] + } + + private mutating func finish( + timestamp: TimeInterval, + profile: SiriRemoteErgonomicProfile + ) -> [SiriRemoteTouchSessionAction] { + guard state != .resting, state != .ended else { return [] } + var actions: [SiriRemoteTouchSessionAction] = [] + var momentum: SiriRemoteScrollMomentum? + if state == .scrolling, scrollOutputActive { + actions.append(.scroll(.zero, .ended)) + if profile.momentumEnabled { + momentum = scrollFilter.end(timestamp: timestamp) + } + } else if profile.tapToClick, + !hadPhysicalPress, + state == .aiming, + let contactStartedAt, + timestamp - contactStartedAt <= 0.22, + maximumTravel <= 0.012 { + actions.append(.tap) + } + actions.append(.ended(momentum: momentum)) + reset(to: .ended) + return actions + } + + private mutating func reset(to newState: SiriRemoteTouchSessionState) { + state = newState + origin = nil + lastSample = nil + contactStartedAt = nil + pressStartedAt = nil + pressOrigin = nil + physicalWasPressed = false + hadPhysicalPress = false + maximumTravel = 0 + scrollOutputActive = false + pointerFilter.reset() + scrollFilter.reset() + } +} diff --git a/Tests/RemoMouseAppTests/DisplayCoordinateResolverTests.swift b/Tests/RemoMouseAppTests/DisplayCoordinateResolverTests.swift new file mode 100644 index 0000000..a65e33d --- /dev/null +++ b/Tests/RemoMouseAppTests/DisplayCoordinateResolverTests.swift @@ -0,0 +1,28 @@ +import CoreGraphics +import Testing +@testable import RemoMouseApp + +@Test func coordinateResolverClampsAcrossDisplaysAboveAndLeftOfPrimary() { + let frames = [ + CGRect(x: 0, y: 0, width: 1_440, height: 900), + CGRect(x: -1_080, y: -300, width: 1_080, height: 1_920), + ] + + #expect( + DisplayCoordinateResolver.clamp(CGPoint(x: -500, y: 1_200), to: frames) + == CGPoint(x: -500, y: 1_200) + ) + #expect(DisplayCoordinateResolver.clamp(CGPoint(x: 2_000, y: 500), to: frames).x == 1_439) +} + +@Test func coordinateResolverChoosesNearestDisplayAcrossLayoutGap() { + let frames = [ + CGRect(x: 0, y: 0, width: 1_000, height: 800), + CGRect(x: 1_200, y: 200, width: 800, height: 600), + ] + + #expect( + DisplayCoordinateResolver.clamp(CGPoint(x: 1_100, y: 100), to: frames) + == CGPoint(x: 999, y: 100) + ) +} diff --git a/Tests/RemoMouseAppTests/RemoMouseSettingsStoreTests.swift b/Tests/RemoMouseAppTests/RemoMouseSettingsStoreTests.swift new file mode 100644 index 0000000..24adf1b --- /dev/null +++ b/Tests/RemoMouseAppTests/RemoMouseSettingsStoreTests.swift @@ -0,0 +1,30 @@ +import Foundation +import Testing +@testable import RemoMouseApp +import RemoMouseHardware + +@MainActor +@Test func settingsStoreSavesValidatedProfileAndReloadsIt() throws { + let directory = FileManager.default.temporaryDirectory + .appending(path: UUID().uuidString, directoryHint: .isDirectory) + let url = directory.appending(path: "settings-v1.json") + let store = RemoMouseSettingsStore(fileURL: url) + var profile = SiriRemoteErgonomicProfile.balanced + profile.pointerSpeed = 11 + + #expect(store.save(profile)) + #expect(RemoMouseSettingsStore(fileURL: url).profile.pointerSpeed == 11) + try? FileManager.default.removeItem(at: directory) +} + +@MainActor +@Test func settingsStoreRecoversFromCorruptData() throws { + let directory = FileManager.default.temporaryDirectory + .appending(path: UUID().uuidString, directoryHint: .isDirectory) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let url = directory.appending(path: "settings-v1.json") + try Data("not-json".utf8).write(to: url) + + #expect(RemoMouseSettingsStore(fileURL: url).profile == .balanced) + try? FileManager.default.removeItem(at: directory) +} diff --git a/Tests/RemoMouseHardwareTests/SiriRemoteAirPointerFilterTests.swift b/Tests/RemoMouseHardwareTests/SiriRemoteAirPointerFilterTests.swift new file mode 100644 index 0000000..edb9fc4 --- /dev/null +++ b/Tests/RemoMouseHardwareTests/SiriRemoteAirPointerFilterTests.swift @@ -0,0 +1,88 @@ +import RemoMouseDomain +import Testing +@testable import RemoMouseHardware + +private func motion( + time: UInt64, + gravity: (Double, Double, Double) = (0, 0, -1), + rotation: (Double, Double, Double) +) -> MotionSample { + MotionSample( + timestampNanoseconds: time, + gravityX: gravity.0, + gravityY: gravity.1, + gravityZ: gravity.2, + rotationX: rotation.0, + rotationY: rotation.1, + rotationZ: rotation.2 + ) +} + +@Test func airPointerCalibratesBeforeEmittingMotion() { + var filter = SiriRemoteAirPointerFilter(calibrationDuration: 0.5) + + #expect(filter.update(motion(time: 0, rotation: (0.01, 0, -0.01)), now: 0) == nil) + #expect(filter.update(motion(time: 250_000_000, rotation: (0.01, 0, -0.01)), now: 250_000_000) == nil) + #expect(filter.update(motion(time: 500_000_000, rotation: (0.01, 0, -0.01)), now: 500_000_000) == .zero) + #expect(filter.isCalibrated) +} + +@Test func airPointerMapsYawAndPitchWithoutIdleDrift() { + var filter = SiriRemoteAirPointerFilter(calibrationDuration: 0) + _ = filter.update(motion(time: 0, rotation: (0, 0, 0)), now: 0) + + let yaw = filter.update( + motion(time: 16_000_000, rotation: (0, 0, 0.7)), + now: 16_000_000 + ) + let pitch = filter.update( + motion(time: 32_000_000, rotation: (0.7, 0, 0)), + now: 32_000_000 + ) + + #expect((yaw?.x ?? 0) != 0) + #expect(abs(yaw?.y ?? 0) < abs(yaw?.x ?? 0)) + #expect((pitch?.y ?? 0) != 0) + #expect(abs(pitch?.x ?? 0) < abs(pitch?.y ?? 0)) + + for index in 3...30 { + #expect(filter.update( + motion(time: UInt64(index) * 16_000_000, rotation: (0, 0, 0)), + now: UInt64(index) * 16_000_000 + ) == .zero) + } +} + +@Test func airPointerRejectsStaleAndImplausibleSamples() { + var filter = SiriRemoteAirPointerFilter(calibrationDuration: 0) + _ = filter.update(motion(time: 0, rotation: (0, 0, 0)), now: 0) + + #expect(filter.update( + motion(time: 10_000_000, rotation: (0, 0, 0.8)), + now: 200_000_000 + ) == nil) + #expect(filter.update( + motion(time: 210_000_000, rotation: (0, 0, 30)), + now: 210_000_000 + ) == nil) +} + +@Test func airPointerUsesGravityToRemainStableWhenRemoteRolls() { + var flat = SiriRemoteAirPointerFilter(calibrationDuration: 0) + var rolled = SiriRemoteAirPointerFilter(calibrationDuration: 0) + _ = flat.update(motion(time: 0, gravity: (0, 0, -1), rotation: (0, 0, 0)), now: 0) + _ = rolled.update(motion(time: 0, gravity: (-1, 0, 0), rotation: (0, 0, 0)), now: 0) + + let flatYaw = flat.update( + motion(time: 16_000_000, gravity: (0, 0, -1), rotation: (0, 0, 0.7)), + now: 16_000_000 + ) + let rolledYaw = rolled.update( + motion(time: 16_000_000, gravity: (-1, 0, 0), rotation: (0.7, 0, 0)), + now: 16_000_000 + ) + + #expect(abs(flatYaw?.x ?? 0) > 0) + #expect(abs(rolledYaw?.x ?? 0) > 0) + #expect((flatYaw?.x ?? 0).sign == (rolledYaw?.x ?? 0).sign) +} diff --git a/Tests/RemoMouseHardwareTests/SiriRemoteCalibrationFitterTests.swift b/Tests/RemoMouseHardwareTests/SiriRemoteCalibrationFitterTests.swift new file mode 100644 index 0000000..5d851e9 --- /dev/null +++ b/Tests/RemoMouseHardwareTests/SiriRemoteCalibrationFitterTests.swift @@ -0,0 +1,32 @@ +import Testing +@testable import RemoMouseHardware + +@Test func fitterBoundsRailAndDeadZoneFromNoisyLargeThumbSamples() { + let result = SiriRemoteCalibrationFitter.fit( + stationaryDeltas: [0.0002, 0.0005, 0.0007], + contactAreas: [0.004, 0.010, 0.014], + clickTravel: [0.008, 0.012], + scrollStarts: [0.86, 0.89, 0.91] + ) + + #expect((0.0002...0.0015).contains(result.pointerDeadZone)) + #expect((0.14...0.24).contains(result.railWidth)) + #expect((0.02...0.08).contains(result.dragThreshold)) +} + +@Test func fitterIgnoresNonfiniteSamplesAndFallsBackWhenEmpty() { + #expect(SiriRemoteCalibrationFitter.fit( + stationaryDeltas: [], + contactAreas: [], + clickTravel: [], + scrollStarts: [] + ) == .balanced) + + let result = SiriRemoteCalibrationFitter.fit( + stationaryDeltas: [.nan, 0.0004], + contactAreas: [.infinity, 0.006], + clickTravel: [.nan, 0.02], + scrollStarts: [.infinity, 0.88] + ) + #expect(result == result.validated()) +} diff --git a/Tests/RemoMouseHardwareTests/SiriRemoteErgonomicProfileTests.swift b/Tests/RemoMouseHardwareTests/SiriRemoteErgonomicProfileTests.swift new file mode 100644 index 0000000..f1f0903 --- /dev/null +++ b/Tests/RemoMouseHardwareTests/SiriRemoteErgonomicProfileTests.swift @@ -0,0 +1,38 @@ +import Foundation +import Testing +@testable import RemoMouseHardware + +@Test func ergonomicProfileClampsUnsafeValues() { + let profile = SiriRemoteErgonomicProfile( + pointerSpeed: 50, + scrollSpeed: -2, + railWidth: 0.8, + pointerDeadZone: 0, + dragThreshold: 1, + tapToClick: true + ).validated() + + #expect(profile.pointerSpeed == 14) + #expect(profile.scrollSpeed == 2) + #expect(profile.railWidth == 0.24) + #expect(profile.pointerDeadZone == 0.0002) + #expect(profile.dragThreshold == 0.08) +} + +@Test func ergonomicProfileRoundTripsAndRecoversFromUnknownVersion() throws { + let encoded = try JSONEncoder().encode(SiriRemoteErgonomicProfile.balanced) + #expect(try JSONDecoder().decode(SiriRemoteErgonomicProfile.self, from: encoded) == .balanced) + + var object = try #require(JSONSerialization.jsonObject(with: encoded) as? [String: Any]) + object["schemaVersion"] = 999 + let future = try JSONSerialization.data(withJSONObject: object) + #expect(SiriRemoteErgonomicProfile.decodeOrDefault(future) == .balanced) +} + +@Test func ergonomicPresetsRemainValidAndPhysicalClickIsDefault() { + for preset in SiriRemoteSpeedPreset.allCases { + #expect(preset.profile == preset.profile.validated()) + } + #expect(SiriRemoteErgonomicProfile.balanced.handedness == .right) + #expect(!SiriRemoteErgonomicProfile.balanced.tapToClick) +} diff --git a/Tests/RemoMouseHardwareTests/SiriRemoteHIDAccessPolicyTests.swift b/Tests/RemoMouseHardwareTests/SiriRemoteHIDAccessPolicyTests.swift new file mode 100644 index 0000000..3649e0d --- /dev/null +++ b/Tests/RemoMouseHardwareTests/SiriRemoteHIDAccessPolicyTests.swift @@ -0,0 +1,15 @@ +import IOKit.hid +@testable import RemoMouseHardware +import Testing + +@Test func consumerControlMonitorDoesNotStarveGameControllerMotion() { + #expect(SiriRemoteHIDMonitor.managerOpenOptions == IOOptionBits(kIOHIDOptionsTypeNone)) +} + +@Test func rawMotionFallbackTargetsFirstGenerationVendorSensorInterface() { + let criteria = SiriRemoteMotionMonitor.matchingCriteria + #expect(criteria[kIOHIDVendorIDKey] == 76) + #expect(criteria[kIOHIDProductIDKey] == 621) + #expect(criteria[kIOHIDPrimaryUsagePageKey] == 0xFF00) + #expect(criteria[kIOHIDPrimaryUsageKey] == 0x10) +} diff --git a/Tests/RemoMouseHardwareTests/SiriRemotePointerFilterTests.swift b/Tests/RemoMouseHardwareTests/SiriRemotePointerFilterTests.swift new file mode 100644 index 0000000..b7049db --- /dev/null +++ b/Tests/RemoMouseHardwareTests/SiriRemotePointerFilterTests.swift @@ -0,0 +1,66 @@ +import Testing +@testable import RemoMouseHardware + +@Test func pointerFilterRejectsLandingAndLargeThumbJitter() { + var filter = SiriRemotePointerFilter() + let profile = SiriRemoteErgonomicProfile.balanced + + #expect(filter.update( + dx: 0, + dy: 0, + contactArea: 0.012, + timestamp: 1, + profile: profile + ) == .zero) + #expect(filter.update( + dx: 0.0003, + dy: -0.0002, + contactArea: 0.012, + timestamp: 1.01, + profile: profile + ) == .zero) +} + +@Test func pointerFilterKeepsSlowMotionAndAcceleratesFastTravel() { + var slow = SiriRemotePointerFilter() + var fast = SiriRemotePointerFilter() + let profile = SiriRemoteErgonomicProfile.balanced + _ = slow.update(dx: 0, dy: 0, timestamp: 1, profile: profile) + _ = fast.update(dx: 0, dy: 0, timestamp: 1, profile: profile) + + let slowDelta = slow.update( + dx: 0.002, + dy: 0, + contactArea: 0.004, + timestamp: 1.01, + profile: profile + ) + let fastDelta = fast.update( + dx: 0.04, + dy: 0, + velocityX: 2, + contactArea: 0.004, + timestamp: 1.01, + profile: profile + ) + + #expect(slowDelta.x > 0) + #expect(fastDelta.x / 0.04 > slowDelta.x / 0.002) +} + +@Test func pointerFilterResetsAfterGapAndReversal() { + var filter = SiriRemotePointerFilter() + let profile = SiriRemoteErgonomicProfile.balanced + _ = filter.update(dx: 0, dy: 0, timestamp: 1, profile: profile) + _ = filter.update(dx: 0.02, dy: 0, timestamp: 1.01, profile: profile) + + #expect(filter.update(dx: -0.02, dy: 0, timestamp: 1.02, profile: profile).x < 0) + #expect(filter.update(dx: 0.20, dy: 0, timestamp: 1.30, profile: profile) == .zero) +} + +@Test func pointerFilterRejectsNonfiniteSamples() { + var filter = SiriRemotePointerFilter() + let profile = SiriRemoteErgonomicProfile.balanced + _ = filter.update(dx: 0, dy: 0, timestamp: 1, profile: profile) + #expect(filter.update(dx: .nan, dy: 0, timestamp: 1.01, profile: profile) == .zero) +} diff --git a/Tests/RemoMouseHardwareTests/SiriRemoteReportDecoderTests.swift b/Tests/RemoMouseHardwareTests/SiriRemoteReportDecoderTests.swift index ca33035..4d61d55 100644 --- a/Tests/RemoMouseHardwareTests/SiriRemoteReportDecoderTests.swift +++ b/Tests/RemoMouseHardwareTests/SiriRemoteReportDecoderTests.swift @@ -9,6 +9,38 @@ import Testing #expect(frame?.touch == nil) } +@Test func recognizesStandardHIDPrimaryButtonElement() { + let pressed = SiriRemoteElementValue( + usagePage: 0x09, + usage: 0x01, + value: 1, + logicalMinimum: 0, + logicalMaximum: 1 + ) + let released = SiriRemoteElementValue( + usagePage: 0x09, + usage: 0x01, + value: 0, + logicalMinimum: 0, + logicalMaximum: 1 + ) + + #expect(pressed.primaryButtonPressed == true) + #expect(released.primaryButtonPressed == false) +} + +@Test func doesNotTreatOtherHIDButtonsAsPrimaryClick() { + let secondary = SiriRemoteElementValue( + usagePage: 0x09, + usage: 0x02, + value: 1, + logicalMinimum: 0, + logicalMaximum: 1 + ) + + #expect(secondary.primaryButtonPressed == nil) +} + @Test func decodesTouchReportWithContact() { let bytes: [UInt8] = [ 1, 0x80, 50, 0, 0, 0, diff --git a/Tests/RemoMouseHardwareTests/SiriRemoteTouchSessionInterpreterTests.swift b/Tests/RemoMouseHardwareTests/SiriRemoteTouchSessionInterpreterTests.swift new file mode 100644 index 0000000..43ef342 --- /dev/null +++ b/Tests/RemoMouseHardwareTests/SiriRemoteTouchSessionInterpreterTests.swift @@ -0,0 +1,113 @@ +import Foundation +import Testing +@testable import RemoMouseHardware + +private func touch( + _ time: TimeInterval, + _ x: Double, + _ y: Double, + area: Double = 0.006, + contact: Bool = true +) -> SiriRemoteMultitouchSample { + SiriRemoteMultitouchSample( + timestamp: time, + x: x, + y: y, + pressure: contact ? 1 : 0, + majorAxis: area, + minorAxis: 1, + state: contact ? 4 : 0, + touchCount: contact ? 1 : 0 + ) +} + +@Test func rightRailVerticalIntentLocksForWholeContact() { + var interpreter = SiriRemoteTouchSessionInterpreter() + let profile = SiriRemoteErgonomicProfile.balanced + + #expect(interpreter.update(sample: touch(1, 0.90, 0.20), physicalPressed: false, profile: profile).isEmpty) + #expect(interpreter.state == .scrollPending) + _ = interpreter.update(sample: touch(1.02, 0.90, 0.25), physicalPressed: false, profile: profile) + #expect(interpreter.state == .scrolling) + _ = interpreter.update(sample: touch(1.04, 0.70, 0.30), physicalPressed: false, profile: profile) + #expect(interpreter.state == .scrolling) +} + +@Test func rightRailInwardTravelEscapesToPointer() { + var interpreter = SiriRemoteTouchSessionInterpreter() + let profile = SiriRemoteErgonomicProfile.balanced + + _ = interpreter.update(sample: touch(1, 0.90, 0.20), physicalPressed: false, profile: profile) + _ = interpreter.update(sample: touch(1.02, 0.84, 0.205), physicalPressed: false, profile: profile) + #expect(interpreter.state == .aiming) + let actions = interpreter.update(sample: touch(1.04, 0.80, 0.205), physicalPressed: false, profile: profile) + #expect(actions.containsPointer) +} + +@Test func centerContactNeverBecomesScroll() { + var interpreter = SiriRemoteTouchSessionInterpreter() + let profile = SiriRemoteErgonomicProfile.balanced + + _ = interpreter.update(sample: touch(1, 0.50, 0.20), physicalPressed: false, profile: profile) + _ = interpreter.update(sample: touch(1.02, 0.50, 0.50), physicalPressed: false, profile: profile) + #expect(interpreter.state == .aiming) +} + +@Test func physicalCompressionDoesNotMovePointer() { + var interpreter = SiriRemoteTouchSessionInterpreter() + let profile = SiriRemoteErgonomicProfile.balanced + + _ = interpreter.update(sample: touch(1, 0.50, 0.50), physicalPressed: false, profile: profile) + let actions = interpreter.update( + sample: touch(1.02, 0.505, 0.495, area: 0.014), + physicalPressed: true, + profile: profile + ) + #expect(interpreter.state == .pressed) + #expect(!actions.containsPointer) + #expect(!actions.containsDrag) +} + +@Test func deliberatePressedTravelBeginsSmoothDrag() { + var interpreter = SiriRemoteTouchSessionInterpreter() + let profile = SiriRemoteErgonomicProfile.balanced + + _ = interpreter.update(sample: touch(1, 0.50, 0.50), physicalPressed: false, profile: profile) + _ = interpreter.update(sample: touch(1.01, 0.50, 0.50), physicalPressed: true, profile: profile) + let began = interpreter.update(sample: touch(1.13, 0.55, 0.50), physicalPressed: true, profile: profile) + #expect(interpreter.state == .dragging) + #expect(began.containsDragBegan) + let moved = interpreter.update(sample: touch(1.15, 0.58, 0.50), physicalPressed: true, profile: profile) + #expect(moved.containsDrag) +} + +@Test func clickDuringPendingScrollCancelsScroll() { + var interpreter = SiriRemoteTouchSessionInterpreter() + let profile = SiriRemoteErgonomicProfile.balanced + + _ = interpreter.update(sample: touch(1, 0.90, 0.20), physicalPressed: false, profile: profile) + _ = interpreter.update(sample: touch(1.02, 0.90, 0.20), physicalPressed: true, profile: profile) + #expect(interpreter.state == .pressed) +} + +@Test func liftEndsScrollExactlyOnce() { + var interpreter = SiriRemoteTouchSessionInterpreter() + let profile = SiriRemoteErgonomicProfile.balanced + + _ = interpreter.update(sample: touch(1, 0.90, 0.20), physicalPressed: false, profile: profile) + _ = interpreter.update(sample: touch(1.02, 0.90, 0.26), physicalPressed: false, profile: profile) + let ended = interpreter.update(sample: touch(1.04, 0, 0, contact: false), physicalPressed: false, profile: profile) + let repeated = interpreter.update(sample: touch(1.06, 0, 0, contact: false), physicalPressed: false, profile: profile) + + #expect(ended.scrollEndCount == 1) + #expect(repeated.scrollEndCount == 0) +} + +private extension Array where Element == SiriRemoteTouchSessionAction { + var containsPointer: Bool { contains { if case .pointer = $0 { true } else { false } } } + var containsDrag: Bool { contains { if case .drag = $0 { true } else { false } } } + var containsDragBegan: Bool { contains { if case .dragBegan = $0 { true } else { false } } } + var scrollEndCount: Int { + count { if case .scroll(_, .ended) = $0 { true } else { false } } + } +} diff --git a/docs/INSTALLATION.md b/docs/INSTALLATION.md index c4b02fa..2aebfe1 100644 --- a/docs/INSTALLATION.md +++ b/docs/INSTALLATION.md @@ -18,23 +18,40 @@ 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. +## Run the current development build + +The repository build contains the latest one-thumb redesign and can be installed locally with: + +```bash +swift test --parallel +Scripts/package-app.sh release +ditto .build/RemoMouse.app /Applications/RemoMouse.app +open /Applications/RemoMouse.app +``` + +Replacing the app with a differently signed build can make macOS ask for Accessibility approval again. Reopening an unchanged installed build should retain approval. + ## 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 | +| One-thumb touch | Move the pointer with adaptive precision | +| Touch-surface click | Anchored primary click; hold and move to drag | +| Right-edge thumb gesture | Continuous scroll with macOS-style phases and momentum | +| Two-finger touch | Optional precision scroll | | 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 | +The default profile is designed for the remote in the right hand with one-thumb operation. Open **Settings → Pointer & Click → Calibrate** to fit movement, click stability, and scroll-rail placement to your thumb. Calibration is optional. + ## Troubleshooting -- If touch is unavailable after launch, press a remote button. The app retries the multitouch service when the remote wakes. +- If touch is unavailable after launch or a long idle period, press a remote button. The app retries the multitouch service when the remote wakes. If Bluetooth itself shows disconnected, reconnect it in System Settings first. - 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. +- Air Pointer is shown as ready only after the app receives actual motion samples. Some first-generation Siri Remote/macOS combinations expose touch and buttons but no motion stream. - The remote cannot control Apple TV while paired to the Mac. diff --git a/docs/superpowers/plans/2026-08-10-one-thumb-redesign-implementation.md b/docs/superpowers/plans/2026-08-10-one-thumb-redesign-implementation.md new file mode 100644 index 0000000..b150283 --- /dev/null +++ b/docs/superpowers/plans/2026-08-10-one-thumb-redesign-implementation.md @@ -0,0 +1,461 @@ +# One-Thumb RemoMouse Redesign 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:** Make the first-generation Siri Remote feel like a precise one-handed macOS trackpad, with anchored physical clicking, deliberate dragging, a right-thumb scroll rail, personal calibration, and a quiet native Settings experience. + +**Architecture:** A pure `SiriRemoteTouchSessionInterpreter` classifies each contact once, while independent pointer and scroll filters transform locked intent into fractional output. `PointerController` remains the only Core Graphics event owner, `RemoMouseModel` coordinates connection and UI state, and a versioned ergonomic profile supplies bounded settings to both input and calibration views. + +**Tech Stack:** Swift 6.2, SwiftUI, AppKit, Core Graphics, Observation, Swift Testing, existing IOKit/GameController/CMultitouch transports. + +## Global Constraints + +- Target macOS Tahoe 26 and the paired first-generation black Siri Remote. +- Optimize defaults for right-handed, one-hand, one-thumb use. +- Physical touch-surface press is primary click; tap-to-click is optional and off by default. +- Do not require two-finger gestures or explicit mode switching for normal pointer use. +- Keep all preferences and diagnostics local; add no account, telemetry, or network dependency. +- Use native macOS controls, materials, typography, accessibility, and appearance behavior. +- Do not expose Air Pointer unless real motion samples are observed. +- Never copy Apple production artwork. +- Every disconnect, pause, recovery, and quit path must release generated mouse and scroll state. + +--- + +### Task 1: Preserve and commit the motion-access baseline + +**Files:** +- Modify: `Sources/RemoMouseHardware/SiriRemoteHIDMonitor.swift` +- Modify: `Sources/RemoMouseHardware/SiriRemoteMotionMonitor.swift` +- Test: `Tests/RemoMouseHardwareTests/SiriRemoteHIDAccessPolicyTests.swift` + +**Interfaces:** +- Consumes: `IOHIDManager`, first-generation vendor/product IDs. +- Produces: nonexclusive consumer-control access and fallback matching for usage page `0xFF00`, usage `0x10`. + +- [ ] **Step 1: Run the focused regression tests** + +Run: `swift test --filter 'consumerControlMonitorDoesNotStarveGameControllerMotion|rawMotionFallbackTargetsFirstGenerationVendorSensorInterface'` + +Expected: both tests pass. + +- [ ] **Step 2: Run the complete baseline suite** + +Run: `swift test --parallel` + +Expected: 47 tests pass with zero failures. + +- [ ] **Step 3: Commit only the motion baseline** + +```bash +git add Sources/RemoMouseHardware/SiriRemoteHIDMonitor.swift \ + Sources/RemoMouseHardware/SiriRemoteMotionMonitor.swift \ + Tests/RemoMouseHardwareTests/SiriRemoteHIDAccessPolicyTests.swift +git commit -m "Fix Siri Remote motion interface ownership" +``` + +### Task 2: Add a bounded ergonomic profile and persistence codec + +**Files:** +- Create: `Sources/RemoMouseHardware/SiriRemoteErgonomicProfile.swift` +- Create: `Tests/RemoMouseHardwareTests/SiriRemoteErgonomicProfileTests.swift` + +**Interfaces:** +- Consumes: user/calibration values as `Double` and `Bool`. +- Produces: `SiriRemoteErgonomicProfile`, `SiriRemoteHandedness`, `SiriRemoteSpeedPreset`, `validated()`, and JSON encode/decode through `Codable`. + +- [ ] **Step 1: Write failing profile tests** + +```swift +@Test func ergonomicProfileClampsUnsafeValues() { + let profile = SiriRemoteErgonomicProfile( + pointerSpeed: 50, scrollSpeed: -2, railWidth: 0.8, + pointerDeadZone: 0, dragThreshold: 1, tapToClick: true + ).validated() + #expect(profile.pointerSpeed == 14) + #expect(profile.scrollSpeed == 2) + #expect(profile.railWidth == 0.24) + #expect(profile.pointerDeadZone == 0.0002) + #expect(profile.dragThreshold == 0.08) +} + +@Test func ergonomicProfileRoundTripsAndRecoversFromUnknownVersion() throws { + let encoded = try JSONEncoder().encode(SiriRemoteErgonomicProfile.balanced) + #expect(try JSONDecoder().decode(SiriRemoteErgonomicProfile.self, from: encoded) == .balanced) + var object = try #require(JSONSerialization.jsonObject(with: encoded) as? [String: Any]) + object["schemaVersion"] = 999 + let future = try JSONSerialization.data(withJSONObject: object) + #expect(SiriRemoteErgonomicProfile.decodeOrDefault(future) == .balanced) +} +``` + +- [ ] **Step 2: Run tests and confirm red** + +Run: `swift test --filter ergonomicProfile` + +Expected: compile failure because `SiriRemoteErgonomicProfile` does not exist. + +- [ ] **Step 3: Implement the profile** + +Create `SiriRemoteErgonomicProfile` as `Codable`, `Equatable`, and `Sendable` with schema version `1`, right-handed default, balanced values of pointer speed `7`, scroll speed `7`, rail width `0.18`, pointer dead zone `0.00045`, drag threshold `0.032`, compression interval `0.10`, momentum enabled, and tap-to-click disabled. Clamp values in `validated()` to the exact ranges asserted by tests; `decodeOrDefault(_:)` must reject unsupported schema versions. + +- [ ] **Step 4: Run focused and full tests** + +Run: `swift test --filter ergonomicProfile && swift test --parallel` + +Expected: all tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add Sources/RemoMouseHardware/SiriRemoteErgonomicProfile.swift \ + Tests/RemoMouseHardwareTests/SiriRemoteErgonomicProfileTests.swift +git commit -m "Add ergonomic input profile" +``` + +### Task 3: Build the timestamp-aware pointer filter + +**Files:** +- Create: `Sources/RemoMouseHardware/SiriRemotePointerFilter.swift` +- Create: `Tests/RemoMouseHardwareTests/SiriRemotePointerFilterTests.swift` + +**Interfaces:** +- Consumes: `update(dx:dy:velocityX:velocityY:contactArea:timestamp:profile:)`. +- Produces: `SiriRemoteContinuousDelta(x:y:)`, preserving fractional movement and resetting across gaps over `0.10` seconds. + +- [ ] **Step 1: Write failing filter tests** + +```swift +@Test func pointerFilterRejectsLandingAndLargeThumbJitter() { + var filter = SiriRemotePointerFilter() + let profile = SiriRemoteErgonomicProfile.balanced + #expect(filter.update(dx: 0, dy: 0, contactArea: 0.012, timestamp: 1, profile: profile) == .zero) + #expect(filter.update(dx: 0.0003, dy: -0.0002, contactArea: 0.012, timestamp: 1.01, profile: profile) == .zero) +} + +@Test func pointerFilterKeepsSlowMotionAndAcceleratesFastTravel() { + var slow = SiriRemotePointerFilter() + var fast = SiriRemotePointerFilter() + let p = SiriRemoteErgonomicProfile.balanced + let slowDelta = slow.update(dx: 0.002, dy: 0, contactArea: 0.004, timestamp: 1.01, profile: p) + let fastDelta = fast.update(dx: 0.04, dy: 0, velocityX: 2, contactArea: 0.004, timestamp: 1.01, profile: p) + #expect(slowDelta.x > 0) + #expect(fastDelta.x / 0.04 > slowDelta.x / 0.002) +} + +@Test func pointerFilterResetsAfterGapAndReversal() { + var filter = SiriRemotePointerFilter() + let p = SiriRemoteErgonomicProfile.balanced + _ = filter.update(dx: 0.02, dy: 0, timestamp: 1, profile: p) + #expect(filter.update(dx: -0.02, dy: 0, timestamp: 1.01, profile: p).x < 0) + #expect(filter.update(dx: 0.20, dy: 0, timestamp: 1.30, profile: p) == .zero) +} +``` + +- [ ] **Step 2: Run tests and confirm red** + +Run: `swift test --filter pointerFilter` + +Expected: compile failure because the filter is absent. + +- [ ] **Step 3: Implement the pure filter** + +Use a contact-area-adjusted dead zone, measured/hardware velocity, a continuous bounded gain curve, velocity-sensitive exponential smoothing, and immediate smoothing reset on direction reversal. Treat the first update and gaps over `0.10` seconds as baseline-only. Keep output as `Double`; do not round in this type. + +- [ ] **Step 4: Run focused and full tests** + +Run: `swift test --filter pointerFilter && swift test --parallel` + +Expected: all tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add Sources/RemoMouseHardware/SiriRemotePointerFilter.swift \ + Tests/RemoMouseHardwareTests/SiriRemotePointerFilterTests.swift +git commit -m "Add adaptive one-thumb pointer filter" +``` + +### Task 4: Replace competing heuristics with one touch-session interpreter + +**Files:** +- Create: `Sources/RemoMouseHardware/SiriRemoteTouchSessionInterpreter.swift` +- Create: `Tests/RemoMouseHardwareTests/SiriRemoteTouchSessionInterpreterTests.swift` +- Retain temporarily: `Sources/RemoMouseHardware/SiriRemoteTouchIntentFilter.swift` + +**Interfaces:** +- Consumes: `update(sample:physicalPressed:profile:forceScroll:)` and `cancel()`. +- Produces: `[SiriRemoteTouchSessionAction]` with `.pointer(SiriRemoteContinuousDelta)`, `.scroll(SiriRemotePixelDelta, SiriRemoteScrollPhase)`, `.dragBegan`, `.drag(SiriRemoteContinuousDelta)`, `.tap`, and `.ended(momentum:)`. + +- [ ] **Step 1: Write failing intent tests** + +Cover these exact traces: + +```swift +@Test func rightRailVerticalIntentLocksForWholeContact() { /* x 0.90, y 0.20 -> y 0.25 -> x 0.70 */ } +@Test func rightRailInwardTravelEscapesToPointer() { /* x 0.90 -> x 0.84 with low dy */ } +@Test func centerContactNeverBecomesScroll() { /* x 0.50 with large vertical travel */ } +@Test func physicalCompressionDoesNotMovePointer() { /* pressed, area grows, coordinates wobble */ } +@Test func deliberatePressedTravelBeginsSmoothDrag() { /* pressed > 0.10s and cumulative > threshold */ } +@Test func clickDuringPendingScrollCancelsScroll() { /* rail contact then pressed */ } +@Test func liftEndsScrollExactlyOnce() { /* locked scroll then repeated no-contact samples */ } +``` + +Assert state/action values, not private implementation details. + +- [ ] **Step 2: Run tests and confirm red** + +Run: `swift test --filter 'rightRail|centerContact|physicalCompression|deliberatePressed|clickDuringPending|liftEndsScroll'` + +Expected: compile failure because the interpreter and actions are absent. + +- [ ] **Step 3: Implement the state machine** + +Implement explicit `resting`, `aiming`, `scrollPending`, `scrolling`, `pressed`, `dragging`, and `ended` states. The first sample establishes origin. A rail contact remains pending until vertical dominance of `1.5` over activation distance `0.024`, inward/horizontal pointer intent, or ambiguous travel of `0.048`. Pointer and scroll locks last for the entire contact. On physical press, reset the pointer filter baseline and suppress compression; after the profile's compression interval and drag threshold, emit `.dragBegan` followed by bounded accumulated movement. + +- [ ] **Step 4: Feed locked scroll through `SiriRemoteScrollFilter`** + +Use `profile.scrollSpeed * 28` as scale, contact area, and hardware timestamp. Emit begin only on first nonzero scroll output, changed thereafter, and end once on lift. Momentum remains bounded by the existing `SiriRemoteScrollMomentum` implementation and is disabled when `profile.momentumEnabled` is false. + +- [ ] **Step 5: Run focused and full tests** + +Run: `swift test --filter SiriRemoteTouchSession && swift test --parallel` + +Expected: all tests pass. + +- [ ] **Step 6: Commit** + +```bash +git add Sources/RemoMouseHardware/SiriRemoteTouchSessionInterpreter.swift \ + Tests/RemoMouseHardwareTests/SiriRemoteTouchSessionInterpreterTests.swift +git commit -m "Add unified one-thumb gesture interpreter" +``` + +### Task 5: Integrate precise intent with Core Graphics output + +**Files:** +- Modify: `Sources/RemoMouseApp/PointerController.swift` +- Modify: `Sources/RemoMouseApp/RemoMouseModel.swift` +- Create: `Sources/RemoMouseApp/DisplayCoordinateResolver.swift` +- Modify: `Package.swift` +- Create: `Tests/RemoMouseAppTests/DisplayCoordinateResolverTests.swift` + +**Interfaces:** +- Consumes: touch-session actions, HID button masks, current pointer location, ergonomic profile. +- Produces: correctly ordered mouse down/up/drag, pixel scroll phases, momentum, and display-clamped pointer movement. + +- [ ] **Step 1: Add `RemoMouseAppTests` and failing coordinate tests** + +```swift +@Test func coordinateResolverClampsAcrossDisplaysAboveAndLeftOfPrimary() { + let frames = [CGRect(x: 0, y: 0, width: 1440, height: 900), + CGRect(x: -1080, y: -300, width: 1080, height: 1920)] + #expect(DisplayCoordinateResolver.clamp(CGPoint(x: -500, y: 1200), to: frames) == CGPoint(x: -500, y: 1200)) + #expect(DisplayCoordinateResolver.clamp(CGPoint(x: 2000, y: 500), to: frames).x == 1439) +} +``` + +Run: `swift test --filter coordinateResolver` + +Expected: compile failure because the resolver is absent. + +- [ ] **Step 2: Implement coordinate resolution** + +Clamp to the union of actual screen frames while selecting the nearest valid point when the union contains gaps. Never invert coordinates using only `NSScreen.screens.first`. + +- [ ] **Step 3: Replace legacy multitouch heuristics in `PointerController`** + +Remove `lastMultitouch`, `clickGuard`, `touchIntentFilter`, `smoothedPointerX/Y`, and `lastPointerTimestamp`. Add `touchSession`, `profile`, and `isDragging`. Route each multitouch sample through the interpreter. Post `.leftMouseDragged` after `.dragBegan`; never post `.mouseMoved` while the generated left button is down. Preserve double-click click-state values on down and up. + +- [ ] **Step 4: Make button edges and touch sessions cooperate** + +Physical down cancels momentum and anchors at the current pointer. Physical up posts exactly one mouse-up. Scroll pending/active is canceled by physical click. `releaseAll()` calls `touchSession.cancel()`, ends scroll/momentum once, and releases each held button once. + +- [ ] **Step 5: Apply the ergonomic profile from the model** + +Expose `apply(profile:)` on `PointerController`. `RemoMouseModel` owns a validated profile and applies changes atomically between contacts after calling `releaseAll()`. + +- [ ] **Step 6: Run tests and build** + +Run: `swift test --parallel && swift build -c release --product RemoMouse` + +Expected: all tests and production build pass. + +- [ ] **Step 7: Commit** + +```bash +git add Package.swift Sources/RemoMouseApp/PointerController.swift \ + Sources/RemoMouseApp/RemoMouseModel.swift Sources/RemoMouseApp/DisplayCoordinateResolver.swift \ + Tests/RemoMouseAppTests/DisplayCoordinateResolverTests.swift +git commit -m "Integrate precise click drag and scrolling" +``` + +### Task 6: Add local settings and bounded calibration + +**Files:** +- Create: `Sources/RemoMouseApp/RemoMouseSettingsStore.swift` +- Create: `Sources/RemoMouseHardware/SiriRemoteCalibrationFitter.swift` +- Create: `Tests/RemoMouseHardwareTests/SiriRemoteCalibrationFitterTests.swift` +- Modify: `Sources/RemoMouseApp/RemoMouseModel.swift` + +**Interfaces:** +- Consumes: stationary contact areas/deltas, target-acquisition traces, click compression traces, drag traces, and scroll traces. +- Produces: a validated `SiriRemoteErgonomicProfile`; persists it under `Application Support/RemoMouse/settings-v1.json` with atomic writes. + +- [ ] **Step 1: Write failing calibration tests** + +```swift +@Test func fitterBoundsRailAndDeadZoneFromNoisyLargeThumbSamples() { + let result = SiriRemoteCalibrationFitter.fit( + stationaryDeltas: [0.0002, 0.0005, 0.0007], + contactAreas: [0.004, 0.010, 0.014], + clickTravel: [0.008, 0.012], scrollStarts: [0.86, 0.89, 0.91] + ) + #expect((0.0002...0.0015).contains(result.pointerDeadZone)) + #expect((0.14...0.24).contains(result.railWidth)) + #expect((0.02...0.08).contains(result.dragThreshold)) +} +``` + +- [ ] **Step 2: Run tests and confirm red** + +Run: `swift test --filter fitter` + +Expected: compile failure because the fitter is absent. + +- [ ] **Step 3: Implement percentile-based bounded fitting** + +Use robust sorted percentiles rather than averages: stationary 95th percentile for dead zone, click-travel 95th percentile plus safety margin for drag threshold, and median scroll-start distance for rail width. Return `.balanced` when required samples are empty or nonfinite. Always call `validated()`. + +- [ ] **Step 4: Implement atomic settings storage** + +`RemoMouseSettingsStore` loads once, falls back to `.balanced` for missing/corrupt/future data, and writes atomically. It exposes `profile`, `reset()`, and `save(_:)`; saving notifies `RemoMouseModel`, which applies the profile between sessions. + +- [ ] **Step 5: Run tests and commit** + +Run: `swift test --parallel` + +```bash +git add Sources/RemoMouseHardware/SiriRemoteCalibrationFitter.swift \ + Tests/RemoMouseHardwareTests/SiriRemoteCalibrationFitterTests.swift \ + Sources/RemoMouseApp/RemoMouseSettingsStore.swift Sources/RemoMouseApp/RemoMouseModel.swift +git commit -m "Add personal ergonomic calibration" +``` + +### Task 7: Redesign the menu-bar popover and native Settings window + +**Files:** +- Create: `Sources/RemoMouseApp/RemoMousePopoverView.swift` +- Create: `Sources/RemoMouseApp/RemoMouseSettingsView.swift` +- Create: `Sources/RemoMouseApp/RemoteTouchPreview.swift` +- Create: `Sources/RemoMouseApp/CalibrationView.swift` +- Modify: `Sources/RemoMouseApp/RemoMouseApp.swift` +- Modify: `Sources/RemoMouseApp/RemoMouseModel.swift` + +**Interfaces:** +- Consumes: observable model connection, permission, profile, input preview, keyboard, motion availability, and diagnostics state. +- Produces: compact everyday popover plus `Settings` scene with General, Pointer & Click, Scrolling, Buttons, Air Pointer, Keyboard, and Diagnostics destinations. + +- [ ] **Step 1: Extract the popover** + +Move everyday controls into `RemoMousePopoverView`: connection header, prominent enable/pause button, active profile, compact speed control, contextual keyboard command, Settings link, and Quit. Remove motion capture and raw diagnostic text from this view. + +- [ ] **Step 2: Build the Settings shell** + +Use `NavigationSplitView` with a typed destination enum and native `Form` content. Use semantic system images and standard controls. Persist sidebar selection for the running session only. + +- [ ] **Step 3: Build Pointer & Click and Scrolling controls** + +Bind to a draft profile so changes preview immediately and can be canceled or reset. Show semantic Precise/Balanced/Fast presets first, advanced numeric controls in a `DisclosureGroup`, tap-to-click off by default, rail width `14...24%`, and momentum toggle. `RemoteTouchPreview` draws only an abstract rounded surface, contact ellipse, and rail tint. + +- [ ] **Step 4: Build guided calibration** + +Create six short stages matching the design spec. Collect model-provided live samples without posting pointer output inside the calibration preview. Fit and preview a candidate profile, then require Apply or Cancel. Respect Reduce Motion and provide VoiceOver instructions and keyboard alternatives. + +- [ ] **Step 5: Move engineering controls to Diagnostics** + +Put permission status, last input, reconnect guidance, redacted export, and motion capture in Diagnostics. Air Pointer settings remain disabled with an explanation until `hasAirPointer` is true. + +- [ ] **Step 6: Verify UI compilation and accessibility surface** + +Run: `swift test --parallel && swift build -c release --product RemoMouse` + +Expected: build and tests pass; no hard-coded foreground/background colors; every icon-only control has an accessibility label. + +- [ ] **Step 7: Commit** + +```bash +git add Sources/RemoMouseApp/RemoMousePopoverView.swift \ + Sources/RemoMouseApp/RemoMouseSettingsView.swift Sources/RemoMouseApp/RemoteTouchPreview.swift \ + Sources/RemoMouseApp/CalibrationView.swift Sources/RemoMouseApp/RemoMouseApp.swift \ + Sources/RemoMouseApp/RemoMouseModel.swift +git commit -m "Redesign RemoMouse for one-thumb use" +``` + +### Task 8: Package, install, and conduct the hardware checkpoint + +**Files:** +- Modify if required by findings: input and app files from Tasks 2–7. +- Update: `README.md` +- Update: `docs/INSTALLATION.md` + +**Interfaces:** +- Consumes: production app and paired remote. +- Produces: installed signed checkpoint and a concise hardware test result. + +- [ ] **Step 1: Run fresh automated verification** + +Run: `swift test --parallel && git diff --check` + +Expected: every test passes and diff check is clean. + +- [ ] **Step 2: Build and sign the production app** + +```bash +Scripts/package-app.sh release +codesign --force --deep --sign 'Apple Development: Ovidiu Ciurila (4K2LCXGTHJ)' \ + --options runtime .build/RemoMouse.app +codesign --verify --deep --strict --verbose=2 .build/RemoMouse.app +``` + +- [ ] **Step 3: Install without changing the designated path** + +```bash +pkill -x RemoMouse 2>/dev/null || true +/usr/bin/ditto .build/RemoMouse.app /Applications/RemoMouse.app +open -a /Applications/RemoMouse.app +``` + +- [ ] **Step 4: Ask the user for the first hardware checkpoint** + +Test only these four actions first: + +1. click ten small controls without press-induced cursor movement; +2. click-hold-drag a window or selected item three times; +3. begin on the right edge and slowly scroll a page; +4. flick from the right edge and immediately stop momentum with a new touch. + +Record exact failures before tuning. Do not ask the user to adjust numeric settings. + +- [ ] **Step 5: Tune from evidence and rerun regression tests** + +Change only bounded profile defaults or pure filters implicated by the hardware result. Add a sanitized deterministic regression trace for each corrected failure. Rebuild and reinstall once. + +- [ ] **Step 6: Run sleep/wake acceptance** + +Ask the user to let the remote sleep, press one button, and confirm pointer recovery. Repeat ten cycles only after the four core interactions pass. + +- [ ] **Step 7: Update user documentation and commit** + +Document one-thumb defaults, the right-thumb rail, physical click/drag behavior, calibration, Accessibility continuity, and sleep/wake recovery. + +```bash +git add README.md docs/INSTALLATION.md +git commit -m "Document one-thumb RemoMouse controls" +``` + +- [ ] **Step 8: Final verification before GitHub publication** + +Run: `swift test --parallel && Scripts/package-app.sh release && codesign --verify --deep --strict .build/RemoMouse.app && git status --short` + +Expected: tests/build/signature pass; worktree contains no unintended changes. GitHub publication and release remain a separate explicit gate after local hardware acceptance. diff --git a/docs/superpowers/specs/2026-08-09-adaptive-input-keyboard-design.md b/docs/superpowers/specs/2026-08-09-adaptive-input-keyboard-design.md new file mode 100644 index 0000000..4ee2f8f --- /dev/null +++ b/docs/superpowers/specs/2026-08-09-adaptive-input-keyboard-design.md @@ -0,0 +1,163 @@ +# Adaptive Input and Smart Keyboard Design + +## Goal + +Make the first-generation Siri Remote wake reliably, provide precise contact-aware pointer and trackpad-style scrolling, and present a native on-screen keyboard automatically when a macOS text field receives focus. + +## Release sequence + +The work ships in two live-testable checkpoints on one feature branch: + +1. touch-session recovery plus adaptive pointer and scroll processing; +2. the focus-aware keyboard overlay built on the verified input foundation. + +Each checkpoint must pass the full automated suite, produce a release bundle, preserve held-button safety, and be installed locally for hardware feedback before the next checkpoint begins. + +## Confirmed reconnect failure + +The button HID interface continues reporting after the remote wakes, while pointer input remains unavailable. The current model stores one Boolean after `MTDeviceStart` succeeds and clears it only when the consumer HID interface is removed. macOS can leave that interface enumerated while the private MultitouchSupport stream sleeps. A later button report calls `startTouchIfNeeded`, but the stale Boolean prevents any touch health check or restart. + +The fix treats button availability and touch-stream liveness as separate states. Button input must remain available while touch recovery runs. + +## Touch-session supervision + +`SiriRemoteTouchSessionSupervisor` is a pure state machine driven by monotonic timestamps. It receives button activity, touch frames, successful starts, failed starts, and timer ticks. It produces only explicit actions: no action, ensure running, restart the retained session, or re-enumerate devices. + +A button event following at least two seconds without a touch frame is a wake hint. The supervisor requests one immediate touch health check. If no new touch frame arrives, it retries after 250, 750, and 1,500 milliseconds. A new touch frame cancels the retry sequence. Four failed attempts leave buttons active, show a recoverable touch-unavailable status, and permit the next user wake event to begin a new bounded cycle. + +The C bridge retains only successfully started first-generation Siri Remote devices. It dynamically resolves `MTDeviceIsRunning`, `MTDeviceStop`, `MTDeviceStart`, and the existing registration APIs. Recovery follows this order on a dedicated serial queue: + +1. inspect retained matching devices; +2. start a retained device that reports stopped without registering a second callback; +3. for a stale running device, stop it, wait until `MTDeviceIsRunning` becomes false within a 250-millisecond bound, then start it with its existing callback; +4. if the retained device is invalid or restart fails, create a fresh device list, register each newly matching remote once, and retain only successful starts. + +Disconnect, pause, mode change, app termination, and recovery start release held mouse state and cancel momentum. Mac workspace wake also triggers the same bounded health cycle. No remote identifier is logged. + +## Complete touch samples + +The bridge currently discards useful fields already supplied by MultitouchSupport. `SiriRemoteMultitouchSample` gains: + +- the framework timestamp; +- normalized position and velocity; +- pressure; +- major and minor contact axes; +- density; +- contact count and state. + +No field is persisted. Invalid non-finite values and implausible coordinate jumps are rejected before filtering. + +## Adaptive pointer processing + +`SiriRemotePointerFilter` is a pure value type. A new contact establishes a baseline and a contact-size baseline. Each subsequent sample produces a fractional pointer delta or no output. + +The filter combines: + +- timestamp-aware adaptive low-pass filtering: strong smoothing for slow jitter and progressively lower smoothing for deliberate movement; +- a contact-size dead zone derived from the major/minor ellipse, so a flattened thumb suppresses more tremor than a small fingertip; +- bounded velocity gain, with near-unity gain for targeting and higher gain only for fast travel; +- fractional-pixel accumulation so small deliberate motion is not discarded; +- a 120-millisecond click anchor that continues updating the baseline, followed by deliberate drag output without a cursor jump; +- reset on contact end, timestamp gaps above 100 milliseconds, mode change, pause, disconnect, or recovery. + +Contact size changes gain only within a narrow tested range. A large thumb must not make the pointer feel stuck, and a small fingertip must not amplify noise. + +## Trackpad-style scrolling + +The existing right-edge and two-finger gesture rules remain. Right-edge contacts stay pending until vertical or horizontal intent locks for the full contact. Two-finger and explicit Scroll mode support both axes. + +`SiriRemoteScrollFilter` gains touch-shape and timestamp input. It uses contact-area-adaptive jitter rejection, an axis-confidence window, filtered physical velocity, and fractional pixel accumulation. Slow movement emits precise continuous pixel deltas. Faster movement increases gain within a fixed bound. Reversing direction clears residual velocity and cancels momentum immediately. + +Core Graphics output preserves a native gesture sequence: + +- continuous pixel events; +- scroll phase began, changed, and ended; +- momentum phase began, changed, and ended; +- measured elapsed time rather than nominal timer intervals; +- a maximum momentum lifetime of 650 milliseconds; +- system natural-scroll direction read from the global scrolling preference. + +A click, new contact, pause, disconnect, recovery, mode change, or keyboard appearance ends the active gesture exactly once. + +## Smart keyboard activation + +`FocusedTextObserver` watches the frontmost application with `AXObserver` and `kAXFocusedUIElementChangedNotification`. It also refreshes observation when the active application changes. The keyboard appears only when the focused accessibility element is editable and has a supported role or subrole, including text fields, text areas, search fields, web text controls, and numeric fields. + +The overlay does not appear for RemoMouse itself, noneditable labels, terminal raw-input surfaces that reject accessibility text semantics, or transient focus changes shorter than 120 milliseconds. It dismisses when focus leaves an editable element, the user presses Home, RemoMouse pauses, or the active application exits. + +Secure text fields may show a basic keyboard but disable suggestions, typed-context retention, and diagnostics. RemoMouse never reads the secure field value. + +## Keyboard window and appearance + +`KeyboardOverlayController` owns one borderless, nonactivating `NSPanel` at the screen-saver window level only while needed. The panel does not become the key window, so the target field retains focus. It is placed above or below the focused field using AX position and size, constrained to the visible frame of the containing display, and animates with a short reduce-motion-aware transition. + +The visual language uses system materials, semantic colors, SF Symbols, rounded key caps, native typography, high contrast, and generous spacing. It supports light, dark, increased-contrast, and reduced-transparency modes. There is no copied Apple artwork. + +## Keyboard interaction + +The primary interaction is hybrid: + +- the remote touch surface moves the pointer across keys; +- touch click activates the hovered key; +- a stationary physical click in the outer 20 percent of the touch surface moves a visible focus ring in that direction, while a center click activates the focused or hovered key; +- Menu sends Backspace and repeats after the system key-repeat delay; +- Play/Pause toggles Shift, with double activation toggling Caps Lock; +- Home dismisses the keyboard without changing the text field; +- two-finger or right-edge scrolling moves through suggestions or alternate-key rows instead of scrolling the application beneath the panel. + +When the pointer is inside the keyboard panel, RemoMouse routes click and scroll intent to keyboard hit testing rather than reposting it to the application. Pointer movement remains visible and bounded to the current display. + +The key layout follows the active macOS input source through Text Input Source Services and maps displayed labels to real virtual key codes. Email, URL, and numeric accessibility metadata select compact contextual rows. Long press reveals local alternate characters. Suggestions use `NSSpellChecker` and only the word fragment typed through the overlay during the current focus session. Context is cleared when focus changes and never leaves the Mac. + +The menu-bar popover also provides Show Keyboard and Hide Keyboard commands as a fallback for applications whose accessibility trees do not identify their custom editor correctly. + +## Event architecture + +- `SiriRemoteHIDMonitor` supplies button wake hints. +- `SiriRemoteMultitouchMonitor` supplies complete touch samples and touch heartbeat events. +- `SiriRemoteTouchSessionSupervisor` owns recovery timing and actions. +- `SiriRemotePointerFilter` and `SiriRemoteScrollFilter` transform touch samples without posting events. +- `PointerController` remains the sole Core Graphics mouse and scroll output owner. +- `FocusedTextObserver` reports editable focus context. +- `KeyboardOverlayController` owns window placement, key hit testing, and overlay state. +- `KeyboardEventController` emits keyboard events to the still-focused target application. +- `RemoMouseModel` coordinates modes, health state, keyboard visibility, and safe cancellation. + +No transport owns UI state, and no UI component decodes hardware. + +## Air Pointer boundary + +Air Pointer remains unavailable. The controlled first-generation Siri Remote trace delivered zero usable motion reports through validated user-space routes. The compatibility investigation may continue, but no motion-driven cursor mode appears until two real changing axes, cadence, range, and a stable neutral region are measured on target hardware. + +## Testing + +Automated tests cover: + +- wake hint, touch heartbeat, bounded retry, cancellation, and exhausted recovery state; +- complete touch-field conversion and invalid-value rejection; +- contact-size jitter suppression, fingertip precision, slow targeting, fast travel, timestamp gaps, and click-to-drag continuity; +- slow scroll, fast scroll, contact-size wobble rejection, axis locking, reversal, natural direction, phase ordering, and momentum cancellation; +- editable and noneditable AX role classification, secure-field policy, delayed activation, focus changes, and app termination; +- keyboard layout mapping, contextual rows, shift/caps state, repeat timing, hit testing, secure-field context clearing, and target-focus preservation; +- forced mouse-up and scroll-end output on every recovery and dismissal path. + +Manual hardware checks cover ten idle/wake cycles, Bluetooth off/on, Mac sleep/wake, pointer targeting, long travel, click and drag, right-edge and two-finger scroll, opposite-direction cancellation, multi-display placement, common native and web text fields, secure fields, keyboard dismissal, and Accessibility revocation/restoration. + +## Release criteria + +The update is ready to publish only when: + +- ten consecutive idle/wake cycles restore pointer input without relaunching; +- buttons remain responsive during every touch recovery attempt; +- no duplicate touch callbacks or repeated mouse events occur; +- pointer and scroll filters pass deterministic fixtures for small and large contact patches; +- the keyboard never steals text-field focus and never reads secure values; +- all automated tests, release packaging, signature verification, privacy scans, and GitHub Actions pass; +- the installed local checkpoint has been exercised with the paired remote. + +## References + +- [Apple AX notification overview](https://developer.apple.com/documentation/applicationservices/axnotificationconstants_h) +- [Apple `AXObserverCreate`](https://developer.apple.com/documentation/applicationservices/1460133-axobservercreate) +- [Apple scroll momentum phase](https://developer.apple.com/documentation/appkit/nsevent/momentumphase) +- [Open-source MultitouchSupport declarations](https://gist.github.com/fiveNinePlusR/bf5330a9626cd37af9ce2186bbce83e0) diff --git a/docs/superpowers/specs/2026-08-10-one-thumb-redesign-design.md b/docs/superpowers/specs/2026-08-10-one-thumb-redesign-design.md new file mode 100644 index 0000000..36d4019 --- /dev/null +++ b/docs/superpowers/specs/2026-08-10-one-thumb-redesign-design.md @@ -0,0 +1,185 @@ +# RemoMouse One-Thumb Experience Redesign + +**Date:** 2026-08-10 + +**Status:** Approved direction; awaiting written-spec review + +**Target hardware:** First-generation black Siri Remote held in the right hand + +**Primary constraint:** Every essential pointer action must work comfortably with one thumb + +## Product outcome + +RemoMouse should feel like a small handheld macOS trackpad, not a remote that happens to move a cursor. A user must be able to aim, click, double-click, drag, and scroll without changing grip, looking at the remote, remembering a mode, or using a second finger. Slow movement must be precise enough for small controls; a faster sweep must cross a laptop display without repeated swipes. + +The redesign covers both the input experience and the application interface. It replaces overlapping pointer, edge-scroll, click-freeze, and mode heuristics with one deterministic touch-session interpreter. It also separates everyday controls from diagnostics in a native macOS Settings experience. + +## Ergonomic principles + +- Optimize for the natural arc of a right thumb while the remote rests securely in the palm. +- Keep the central and left portions of the glass surface available for unconstrained pointing. +- Put scrolling under the thumb's approach side without requiring a grip change. +- Treat physical touch-surface press as the primary click. Tap-to-click is optional and disabled by default. +- Never move the pointer merely because the thumb landed, pressed harder, changed contact shape, or lifted. +- Prefer stable intent for the lifetime of a contact over switching behavior midway through a gesture. +- Make expert behavior immediate while teaching invisible gesture regions through setup and live previews. + +## Unified touch-session model + +`SiriRemoteTouchSessionInterpreter` owns one contact from initial touch through release. It receives timestamped position, velocity, contact area, touch count, and physical-button transitions. It emits device-independent intents rather than macOS events: + +- pointer delta; +- primary-button down or up; +- drag delta; +- scroll begin, change, or end; +- scroll momentum; +- canceled session. + +The session has explicit states: `resting`, `aiming`, `scrollPending`, `scrolling`, `pressed`, `dragging`, and `ended`. A session may make only the transitions defined below. It cannot change from ordinary aiming to scrolling after pointer intent locks, and it cannot change from scrolling to dragging until the current contact ends. + +The first valid contact sample establishes an origin and emits nothing. Timestamp gaps, implausible coordinate jumps, transport recovery, pause, mode change, keyboard presentation, or remote sleep cancel the session and safely end held mouse or scroll output. + +## Pointer movement + +Pointer intent begins immediately outside the scroll rail. Inside the rail, it begins after horizontal or inward movement establishes pointer intent. Once pointer intent locks, the entire remaining contact is pointer movement even if the thumb crosses the rail. + +Movement filtering is timestamp-aware and contact-area-aware: + +- very small stationary variation is rejected with a dead zone that grows only slightly with contact area; +- slow movement uses low gain and minimal smoothing for small-target accuracy; +- medium movement uses a continuous acceleration curve; +- fast movement uses bounded gain for full-screen travel; +- smoothing decreases as deliberate velocity rises, preventing lag during fast movement; +- direction reversal clears residual filtered velocity immediately; +- long sample gaps reset the baseline rather than creating a jump. + +Filtering operates on fractional values until Core Graphics output. Subpixel residuals accumulate so deliberate slow movement is not lost. Pointer output uses the correct display coordinate space and supports multi-display layouts without assuming the primary screen height. + +## Precise physical clicking and dragging + +Physical click remains responsive: mouse-down is posted immediately at the pointer's anchored location. The interpreter freezes pointer output during the short mechanical compression phase while continuing to update its internal touch baseline. Contact-area growth alone never counts as motion. + +A pressed session becomes a drag only when both conditions are met: + +1. cumulative intentional travel exceeds a contact-area-adjusted threshold; and +2. the movement persists beyond the mechanical click transient or has clearly high velocity. + +When drag begins, accumulated intentional movement is introduced over a few frames instead of as one jump. Further movement posts `leftMouseDragged`, not `mouseMoved`. Releasing posts mouse-up exactly once. A press and release that never entered dragging is a click at the anchored point. A second click follows the system double-click interval and distance, including the correct click-state field on down and up events. + +Clicking during a pending or active scroll cancels scroll output, anchors the pointer, and performs a primary click. This prevents the scroll rail from making controls near the screen pointer difficult to activate. + +Tap-to-click is an optional setting. When enabled, it requires a short contact, low total travel, no scroll lock, and no physical click. Tap-to-drag is not included because it creates ambiguity on the small surface. + +## One-thumb scrolling + +The outer right portion of the surface is the smart thumb rail. Its default width is 18 percent and calibration may tune it within a bounded 14–24 percent range. + +A contact beginning in the rail enters `scrollPending`. The pending state emits neither pointer nor scroll output until one intent becomes clear: + +- predominantly vertical travel locks to vertical scrolling; +- predominantly horizontal or inward travel locks to ordinary pointer movement; +- ambiguous travel beyond a bounded distance resolves to pointer movement; +- a short stationary contact followed by physical click remains a normal click. + +After scroll lock, the whole surface becomes usable for that scroll contact, so the thumb may drift inward naturally. Horizontal noise is suppressed after vertical lock. Slow thumb travel produces precise pixel scrolling. Faster travel uses bounded acceleration. Output follows the macOS natural-scroll preference and includes correct `began`, `changed`, and `ended` phases. + +A deliberate release flick may start momentum. Momentum uses measured terminal velocity, exponential decay, fractional-pixel accumulation, and a short maximum lifetime. New contact, click, reversal, pause, disconnect, mode change, or keyboard appearance cancels it immediately and emits one momentum end event. + +Two-finger scrolling remains supported as an optional compatibility gesture, but onboarding and default help never require it. Explicit Scroll mode remains available only as an accessibility fallback and is not part of the normal workflow. + +## Guided personal calibration + +First run and Settings provide a one-minute calibration made of real tasks rather than technical sliders: + +1. place and lift the thumb several times to measure stationary contact variation and contact-area range; +2. acquire small targets to tune low-speed gain and dead zone; +3. sweep between distant targets to tune acceleration without changing maximum safety bounds; +4. click small targets to measure mechanical compression and set the click-stability window; +5. drag one object to confirm drag threshold and accumulated-motion release; +6. scroll a card slowly and flick it to tune rail width, scroll gain, and momentum. + +Calibration stores a versioned ergonomic profile locally. It never changes behavior during a gesture. The user may rerun individual sections, compare against defaults, undo changes while Settings is open, or reset everything. + +Advanced numeric controls remain available, but the main UI uses semantic presets such as Precise, Balanced, and Fast. Balanced is the default generated by calibration. + +## Application redesign + +### Menu-bar popover + +The popover is a calm status and quick-control surface, not a diagnostics panel. It contains: + +- a compact remote header with Connected, Sleeping, Recovering, Paused, or Needs Permission state; +- one prominent Enable/Pause control; +- the active interaction profile; +- a compact pointer-speed control with a reset affordance; +- Show Keyboard when relevant; +- Settings and Quit commands. + +Motion capture, raw report counts, engineering status strings, and troubleshooting details move to Diagnostics. Connection state never relies on color alone. The popover uses native SwiftUI controls, semantic materials, SF Symbols, standard typography, and macOS spacing. + +### Settings window + +A standard resizable Settings window uses a native sidebar with these destinations: + +- **General:** launch behavior, menu-bar behavior, handedness, and enable state; +- **Pointer & Click:** guided target exercise, semantic speed preset, tap-to-click option, drag behavior, and advanced tuning disclosure; +- **Scrolling:** live scroll card, rail visualization and width, speed, natural direction policy, and momentum; +- **Buttons:** clear remote illustration with assignable actions and reset; +- **Air Pointer:** availability, calibration, recentering, and sensitivity only when real motion is observed; +- **Keyboard:** automatic presentation, manual command, layout, and secure-field policy; +- **Diagnostics:** permissions, transport, last input, reconnect, redacted export, motion capture, and troubleshooting. + +Pointer & Click and Scrolling show a live abstract touch surface. It visualizes contact position, contact patch, locked intent, and output without copying Apple production artwork. The rail appears in Settings and onboarding, but remains invisible during normal use. + +### Visual and accessibility language + +The app uses standard macOS window, toolbar, sidebar, form, slider, toggle, help, confirmation, and keyboard-navigation patterns. Tahoe materials are reserved for navigation and floating functional surfaces rather than decorative glass cards. It supports light and dark appearance, increased contrast, reduced transparency, reduced motion, keyboard navigation, VoiceOver, and full text scaling. Custom color is accent only and never the sole status indicator. + +## Architecture + +- `SiriRemoteTouchSessionInterpreter` is a pure state machine that classifies a contact and emits intents. +- `SiriRemotePointerFilter` converts aiming or dragging samples into fractional pointer deltas. +- `SiriRemoteScrollFilter` converts locked scroll samples into fractional pixel deltas and bounded momentum. +- `SiriRemoteErgonomicProfile` stores validated calibration values and schema version. +- `PointerController` remains the sole owner of Core Graphics mouse and scroll event posting. +- `RemoteSessionSupervisor` owns sleep, wake, reconnect, retry, and forced-release behavior. +- `RemoMouseModel` coordinates user-visible state and settings but contains no gesture heuristics. +- Settings views consume testable view models and never sit on the input-processing path. + +The interpreter, pointer filter, scroll filter, and calibration fitter are independent value types with deterministic inputs and outputs. Hardware adapters only normalize reports. UI components never decode hardware, and transport code never owns gesture or presentation state. + +## Reliability and errors + +The remote may sleep to preserve battery. RemoMouse keeps passive discovery active, treats any button report as a wake hint, restarts touch monitoring with bounded retries, and returns to ready state after the first valid touch sample. Recovery never duplicates callbacks. Leaving ready state releases all generated mouse buttons, ends scrolling and momentum, resets touch baselines, and preserves the user's selected profile. + +The interface distinguishes an expected sleeping state from a recoverable transport failure. Sleeping guidance says to press any remote button once. Repeated alerts are prohibited. Accessibility is requested only when genuinely absent; a stable signed installation at `/Applications/RemoMouse.app` must not prompt on every launch. + +## Testing + +Automated tests use synthetic and sanitized recorded sessions to cover: + +- first-contact suppression, stationary jitter, contact-area changes, slow aiming, fast travel, reversal, sample gaps, and multi-display coordinates; +- click compression, stationary click, click-state sequencing, double-click, drag threshold, smooth drag onset, click during scroll pending, forced mouse-up, and disconnect during drag; +- rail eligibility, ambiguous intent, vertical lock, inward pointer escape, full-contact lock, slow scrolling, fast scrolling, natural direction, reversal, phases, momentum, and cancellation; +- calibration bounds, corrupt-profile recovery, schema migration, presets, reset, and Undo; +- sleep/wake transitions, bounded retry, callback ownership, and forced output release; +- menu and Settings states, accessibility labels, keyboard navigation, appearance variants, and reduced-motion behavior. + +Hardware acceptance on the paired remote requires: + +- twenty small targets clicked without press-induced pointer displacement; +- repeated click-drag-release with no jump and no stuck mouse-down; +- slow scrolling that can move a document by a few pixels at a time; +- fast scrolling through a long page with controllable momentum; +- pointer gestures beginning near the rail that do not accidentally scroll; +- scroll gestures that remain scrolling when the thumb drifts inward; +- ten consecutive remote sleep/wake cycles restoring touch without relaunch; +- a continuous 30-minute session without duplicate input, stuck state, growing latency, or material CPU growth. + +## Scope boundaries + +This redesign does not add a virtual game controller, cloud sync, an account system, telemetry, copied Apple artwork, or speculative motion behavior. Air Pointer appears only after actual motion samples are observed on the connected hardware. The initial implementation targets the paired first-generation remote and this Mac, while keeping handedness configurable for future left-hand support. + +## Release gate + +The redesign is ready for release only after automated tests, production packaging, signature verification, Accessibility continuity, the complete hardware acceptance exercise, and GitHub Actions pass. A release is created as a draft for explicit user review before publication.