From a85902458d6aaacbb14174d931edc89dbba85023 Mon Sep 17 00:00:00 2001 From: Ozair Khan Date: Mon, 14 Sep 2026 22:15:32 -0400 Subject: [PATCH 1/5] Fix workspace switching on macOS 27 --- LICENSE-FasterSwiper.txt | 201 ++++++++++++ README.md | 48 +++ SECURITY.md | 19 +- Scripts/bundle.sh | 3 + Scripts/test.sh | 20 ++ Sources/CStrafe/CStrafe.c | 205 +++++++----- Sources/CStrafe/EventSerialization.h | 126 ++++++++ Sources/CStrafe/include/CStrafe.h | 36 ++- Sources/strafe/HotkeyManager.swift | 13 +- Sources/strafe/MissionControlMonitor.swift | 100 ++++++ Sources/strafe/Permissions.swift | 2 +- Sources/strafe/SwipeInterceptor.swift | 243 +++++++++----- Sources/strafe/SwitchDiagnostics.swift | 66 ++++ Sources/strafe/SwitchEngine.swift | 320 ++++++++++++++----- Sources/strafe/main.swift | 94 +++++- THIRD-PARTY-LICENSES.txt | 37 +++ Tests/CStrafeTests.c | 183 +++++++++++ Tests/SwitchEngineTests.swift | 291 +++++++++++++++++ docs/AGENT-HANDOFF.md | 351 +++++++++++++++++++++ docs/MACOS-27-RESEARCH.md | 240 ++++++++++++++ 20 files changed, 2338 insertions(+), 260 deletions(-) create mode 100644 LICENSE-FasterSwiper.txt create mode 100644 Scripts/test.sh create mode 100644 Sources/CStrafe/EventSerialization.h create mode 100644 Sources/strafe/MissionControlMonitor.swift create mode 100644 Sources/strafe/SwitchDiagnostics.swift create mode 100644 THIRD-PARTY-LICENSES.txt create mode 100644 Tests/CStrafeTests.c create mode 100644 Tests/SwitchEngineTests.swift create mode 100644 docs/AGENT-HANDOFF.md create mode 100644 docs/MACOS-27-RESEARCH.md diff --git a/LICENSE-FasterSwiper.txt b/LICENSE-FasterSwiper.txt new file mode 100644 index 0000000..025d133 --- /dev/null +++ b/LICENSE-FasterSwiper.txt @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2026 Matthew Bowen + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md index 6d5aa54..774e9ff 100644 --- a/README.md +++ b/README.md @@ -122,6 +122,54 @@ The app is about 1,080 lines of Swift and C with no third-party dependencies — strafe # start the menu-bar app ``` +## macOS 27 diagnostic build + +This checkout now includes an experimental macOS 27 compatibility path. It uses +an embedded IOHID gesture payload and paced asynchronous posting. Physical +interception is restricted to horizontal HID23 gestures; ambiguous HID32 events +are logged in diagnostic mode but passed through. Live switching and time-to-interactivity +still need verification on the target machine; the macOS 26 measurements above +do not describe this build. See [the research report](docs/MACOS-27-RESEARCH.md). + +Build with `./Scripts/bundle.sh`, quit any running Strafe instance, then run: + +```bash +STRAFE_DIAGNOSTICS=1 ./build/strafe.app/Contents/MacOS/strafe 2>&1 | tee /tmp/strafe-macos27.log +``` + +Test Control + Option + Left/Right from a middle Space, one press at a time. +Diagnostics distinguish posted phases from an observed neighboring Space, or a +timeout. Indices in the logs are **zero-based**. The engine serializes requests +and waits for a stable live destination rather than counting unconfirmed moves. +This diagnostic policy includes 100 ms of destination stability before servicing +the next queued request; it is not the final rapid-switch latency tuning. + +Options are read from the environment at launch: + +| Variable | Values / default | +|---|---| +| `STRAFE_EVENT_PROFILE` | `auto` (macOS 27+ payload, older OS legacy), `legacy`, `macos27` | +| `STRAFE_PHASE_GAP_MS` | `0`–`100`; default `10` for macOS27, `0` for legacy | +| `STRAFE_INVERT_DIRECTION` | Defaults to `1` for augmented output, `0` for legacy; explicit `0`/`1` overrides | +| `STRAFE_INVERT_SWIPE_DIRECTION` | Defaults to `1` on macOS 27+, `0` earlier; independent physical-progress mapping | +| `STRAFE_INTERCEPT_SWIPES` | `1` (default); `0` leaves trackpad gestures native for hotkey-only testing | +| `STRAFE_DIAGNOSTICS` | `0` (default), `1` for request/gesture diagnostics | + +Run `bash Scripts/test.sh` for non-posting serialization and mock-engine tests. +These tests work with Command Line Tools and do not require XCTest. The existing +`bench/` raw poster remains the **legacy** path; its timing results do not validate +the new asynchronous app path. + +The initial live test confirmed instant switching but reversed output signs. +The current build corrects those signs separately from physical swipe progress, +and observes Dock's Accessibility Exposé notifications to pass Mission Control, +App Exposé and Show Desktop gestures through. Notification delivery and the +corrected mappings still require validation on the target machine. +If delivery times out, goes to an unexpected Space, or cannot be prepared, +trackpad interception is disabled automatically; it can be re-enabled from the +menu. Hotkeys remain available. For initial hotkey-only diagnosis, prefix the +launch command with `STRAFE_INTERCEPT_SWIPES=0`. + ## Permissions strafe needs **Accessibility** permission, and only that. macOS requires it to diff --git a/SECURITY.md b/SECURITY.md index c9878c6..626bc7a 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -5,7 +5,7 @@ tap. That is a lot of trust to ask for, so this document states exactly what strafe can and cannot do, and how to verify every claim yourself. Every claim below points at a file and line you can read or a command you can run. -The whole program is about **1,120 lines** of Swift + C (`wc -l Sources/**`). +The program is a small Swift + C codebase (`wc -l Sources/strafe/*.swift Sources/CStrafe/*.{c,h} Sources/CStrafe/include/*.h`). You can build it from source in about 30 seconds (`swift build`) and audit it in an afternoon. @@ -26,11 +26,12 @@ do so. The tap's event mask is defined in exactly one place, and it covers ```c uint64_t strafe_tap_event_mask(void) { - return (1ULL << kCGSEventGesture) | (1ULL << kCGSEventDockControl); + return (1ULL << kCGSEventGesture) | (1ULL << kCGSEventDockControl) + | (1ULL << kCGSEventFluidTouchGesture); } ``` - That is `(1<<29) | (1<<30)` — the two private trackpad-gesture event types + That is `(1<<29) | (1<<30) | (1<<31)` — the private trackpad-gesture event types and nothing else. There is no `kCGEventKeyDown`/`kCGEventKeyUp` bit. - **Why keys are excluded — determination comment:** immediately above that @@ -62,8 +63,10 @@ the wrappers in `Sources/CStrafe/CStrafe.c` (lines ~205–225): - swipe motion axis (field 123) — `strafe_event_swipe_motion` - gesture phase (field 132) — `strafe_event_gesture_phase` - swipe progress (field 124) — `strafe_event_swipe_progress` +- generic macOS 27 swipe progress (field 119) — `strafe_event_generic_progress` - swipe velocity X (field 129) — `strafe_event_swipe_velocity_x` - source process id — `strafe_event_source_pid` +- source user-data marker — `strafe_event_is_strafe`, to exclude our own output That is the entire surface of event data strafe inspects: enough to tell a real horizontal 3-finger space swipe from anything else, and its direction. No @@ -77,6 +80,11 @@ pick which display to switch on (`copy_cursor_display_identifier`, same file ~line 96). Neither the window list nor the cursor position is stored or transmitted; both are read, used for that one decision, and discarded. +`Sources/strafe/MissionControlMonitor.swift` also registers an Accessibility +observer on Dock for Exposé/Mission Control/Show Desktop entry and exit. It +tracks only overlay state, and reconnects if Dock relaunches. This uses the +existing Accessibility permission and does not observe application contents. + --- ## What strafe never does @@ -101,7 +109,10 @@ Each of these is verifiable with a single grep over `Sources/`. `stderr` (`grep -rn FileHandle.standardError Sources/`) and `stdout` (the `strafe status` CLI readout in `Permissions.printStatus`, `Sources/strafe/Permissions.swift` ~line 28) — never to a network socket, a file, or an analytics sink. Nothing - batches, serializes, or transmits usage. + transmits usage. Opt-in `STRAFE_DIAGNOSTICS=1` logs gesture metadata (up to 64 + candidate samples), display/Space IDs, requested direction, phase timing and + transition results to stderr. Errors are logged even without that option. + A shell command using `tee` saves these diagnostics to a file chosen by the user. - **No auto-update.** strafe never downloads or executes anything. There is no updater, no Sparkle, no download URL (covered by the network grep above). diff --git a/Scripts/bundle.sh b/Scripts/bundle.sh index 7d1871a..03fa92f 100755 --- a/Scripts/bundle.sh +++ b/Scripts/bundle.sh @@ -34,6 +34,9 @@ echo "==> Assembling $APP_NAME.app…" rm -rf "$APP_DIR" mkdir -p "$MACOS_DIR" cp "$BIN_PATH" "$MACOS_DIR/$BIN_NAME" +mkdir -p "$APP_DIR/Contents/Resources" +cp "$ROOT_DIR/LICENSE" "$ROOT_DIR/THIRD-PARTY-LICENSES.txt" \ + "$ROOT_DIR/LICENSE-FasterSwiper.txt" "$APP_DIR/Contents/Resources/" # Strip symbols from the SHIPPED copy (not the .build artifact) BEFORE signing — # stripping mutates the binary and would invalidate a prior signature. -rSTx diff --git a/Scripts/test.sh b/Scripts/test.sh new file mode 100644 index 0000000..7f5a0b4 --- /dev/null +++ b/Scripts/test.sh @@ -0,0 +1,20 @@ +#!/bin/bash +# Non-posting tests, including on machines with Command Line Tools only. +set -euo pipefail +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" +swift build +BIN="$(swift build --show-bin-path)" +TMP="$(mktemp -d "${TMPDIR:-/tmp}/strafe-tests.XXXXXX")" +trap 'rm -rf "$TMP"' EXIT +clang -std=c11 -Wall -Wextra -Werror -fsanitize=address,undefined \ + -fno-omit-frame-pointer -I Sources/CStrafe/include Tests/CStrafeTests.c \ + -framework ApplicationServices -framework CoreFoundation -o "$TMP/CStrafeTests" +"$TMP/CStrafeTests" +swiftc -swift-version 6 -target "$(uname -m)-apple-macosx15.0" \ + -I "$BIN/CStrafe.build" Sources/strafe/SwitchDiagnostics.swift \ + Sources/strafe/SwitchEngine.swift Sources/strafe/MissionControlMonitor.swift \ + Sources/strafe/SwipeInterceptor.swift Tests/SwitchEngineTests.swift \ + "$BIN/CStrafe.build/CStrafe.c.o" -framework ApplicationServices \ + -framework CoreFoundation -framework AppKit -o "$TMP/SwitchEngineTests" +"$TMP/SwitchEngineTests" diff --git a/Sources/CStrafe/CStrafe.c b/Sources/CStrafe/CStrafe.c index 529a278..072919b 100644 --- a/Sources/CStrafe/CStrafe.c +++ b/Sources/CStrafe/CStrafe.c @@ -1,7 +1,8 @@ // CStrafe.c — implementation of the synthetic dock-swipe mechanism. // -// An independent reimplementation of the technique from -// jurplel/InstantSpaceSwitcher (MIT). Every magic number here is documented in +// Legacy technique from jurplel/InstantSpaceSwitcher (MIT); macOS 27 synthesis +// adapted from pinned ISS and FasterSwiper. See THIRD-PARTY-LICENSES.txt. +// Legacy magic numbers are documented in // docs/SPEC.md §1–2. Treat the field indices and event-type values as // version-fragile (SPEC §7). @@ -12,6 +13,8 @@ #include #include #include +#include +#include "EventSerialization.h" // --- Private CGEventField indices (SPEC §1.2) ----------------------------- static const CGEventField kCGSEventTypeField = (CGEventField)55; // private CGSEventType selector @@ -61,34 +64,70 @@ bool strafe_cgs_available(void) { } // --- Synthesis (SPEC §1.5) ------------------------------------------------ -static bool post_dock_swipe(CGSGesturePhase phase, StrafeDirection direction, double velocity) { - const bool isRight = (direction == StrafeDirectionRight); +// Modified from pinned ISS ISS.c; see THIRD-PARTY-LICENSES.txt. +static const int64_t kStrafeEventMarker = INT64_C(0x5354524146450001); + +CGEventRef strafe_create_switch_event(StrafeDirection direction, double velocity, + int64_t phase, bool augmented, bool inverted) { + if ((direction != StrafeDirectionLeft && direction != StrafeDirectionRight) || + (phase != 1 && phase != 2 && phase != 4 && phase != 8) || + !isfinite(velocity) || velocity < 0 || velocity > FLT_MAX) return NULL; + const bool isRight = (direction == StrafeDirectionRight) != inverted; // Empirically, ±FLT_TRUE_MIN used in this way makes switching instant. - const double progress = isRight ? (double)FLT_TRUE_MIN : -(double)FLT_TRUE_MIN; + const double magnitude = augmented ? 0.000016 : (double)FLT_TRUE_MIN; + const double progress = isRight ? magnitude : -magnitude; // Velocity of gesture based on speed setting. const double vel = isRight ? velocity : -velocity; + int32_t fixed; + // Validate even before Ended so an entire sequence can be prebuilt safely. + if (augmented && !strafe_fixed(vel, &fixed)) return NULL; CGEventRef ev = CGEventCreate(NULL); - if (!ev) { return false; } + if (!ev) { return NULL; } + CGEventSetIntegerValueField(ev, kCGEventSourceUserData, kStrafeEventMarker); + CGEventSetIntegerValueField(ev, kCGEventSourceUnixProcessID, getpid()); CGEventSetIntegerValueField(ev, kCGSEventTypeField, kCGSEventDockControl); CGEventSetIntegerValueField(ev, kCGEventGestureHIDType, kIOHIDEventTypeDockSwipe); CGEventSetIntegerValueField(ev, kCGEventGesturePhase, phase); CGEventSetDoubleValueField (ev, kCGEventGestureSwipeProgress, progress); CGEventSetIntegerValueField(ev, kCGEventGestureSwipeMotion, kCGGestureMotionHorizontal); - CGEventSetDoubleValueField (ev, kCGEventGestureSwipeVelocityX, vel); - CGEventSetDoubleValueField (ev, kCGEventGestureSwipeVelocityY, vel); - CGEventPost(kCGSessionEventTap, ev); - CFRelease(ev); - return true; + if (augmented) { + CGEventSetIntegerValueField(ev, (CGEventField)134, phase); + CGEventSetDoubleValueField(ev, (CGEventField)125, 0.1); + CGEventSetDoubleValueField(ev, (CGEventField)138, 3.0); + CGEventSetDoubleValueField(ev, (CGEventField)169, (double)mach_absolute_time()); + if (phase == kCGSGesturePhaseEnded) + CGEventSetDoubleValueField(ev, kCGEventGestureSwipeVelocityX, vel); + CGEventRef result = strafe_augment(ev); + CFRelease(ev); + // CGEventCreateData omits source user data on macOS 27. Stamp AFTER + // reconstruction too, so every event returned to the scheduler is marked. + if (result) { + CGEventSetIntegerValueField(result, kCGEventSourceUserData, kStrafeEventMarker); + CGEventSetIntegerValueField(result, kCGEventSourceUnixProcessID, getpid()); + } + return result; + } + CGEventSetDoubleValueField(ev, kCGEventGestureSwipeVelocityX, vel); + CGEventSetDoubleValueField(ev, kCGEventGestureSwipeVelocityY, vel); + return ev; } bool strafe_post_switch_gesture(StrafeDirection direction, double velocity) { - // Send three gesture events--began, changed, and ended. - // If we only send two then mission control doesn't work. - return post_dock_swipe(kCGSGesturePhaseBegan, direction, velocity) - && post_dock_swipe(kCGSGesturePhaseChanged, direction, velocity) - && post_dock_swipe(kCGSGesturePhaseEnded, direction, velocity); + // Legacy synchronous benchmark path. Prebuild to avoid partial allocation posts. + CGEventRef events[3] = {NULL, NULL, NULL}; + const int64_t phases[] = {1, 2, 4}; + bool ready = true; + for (size_t i = 0; i < 3; ++i) { + events[i] = strafe_create_switch_event(direction, velocity, phases[i], false, false); + if (!events[i]) ready = false; + } + for (size_t i = 0; i < 3; ++i) { + if (ready) CGEventPost(kCGSessionEventTap, events[i]); + if (events[i]) CFRelease(events[i]); + } + return ready; } // --- Topology (SPEC §6) --------------------------------------------------- @@ -111,23 +150,78 @@ static CFStringRef copy_cursor_display_identifier(void) { return str; // caller releases } +static bool strafe_has_type(CFTypeRef value, CFTypeID type) { + return value && CFGetTypeID(value) == type; +} + +static bool strafe_space_id(CFTypeRef dictionary, uint64_t *out) { + if (!strafe_has_type(dictionary, CFDictionaryGetTypeID())) return false; + CFNumberRef number = CFDictionaryGetValue(dictionary, CFSTR("id64")); + int64_t value = 0; + if (!strafe_has_type(number, CFNumberGetTypeID()) || CFNumberIsFloatType(number) || + !CFNumberGetValue(number, kCFNumberSInt64Type, &value) || value <= 0) return false; + *out = (uint64_t)value; + return true; +} + +static bool strafe_extract_space_info(CFTypeRef display, StrafeInfo *outInfo) { + if (!strafe_has_type(display, CFDictionaryGetTypeID())) return false; + CFStringRef ident = CFDictionaryGetValue(display, CFSTR("Display Identifier")); + CFArrayRef spaces = CFDictionaryGetValue(display, CFSTR("Spaces")); + uint64_t current; + if (!strafe_has_type(ident, CFStringGetTypeID()) || CFStringGetLength(ident) == 0 || + !strafe_has_type(spaces, CFArrayGetTypeID()) || + !strafe_space_id(CFDictionaryGetValue(display, CFSTR("Current Space")), ¤t)) return false; + CFIndex count = CFArrayGetCount(spaces); + if (count <= 0 || (uint64_t)count > UINT_MAX) return false; + StrafeInfo result = {0}; + if (!CFStringGetCString(ident, result.displayID, sizeof(result.displayID), kCFStringEncodingUTF8)) return false; + bool found = false; + for (CFIndex i = 0; i < count; ++i) { + uint64_t sid; + if (!strafe_space_id(CFArrayGetValueAtIndex(spaces, i), &sid)) return false; + if (sid == current) { + if (found) return false; + result.currentIndex = (unsigned int)i; + found = true; + } + } + if (!found) return false; + result.currentSpaceID = current; + result.spaceCount = (unsigned int)count; + *outInfo = result; + return true; +} + bool strafe_get_space_info(StrafeInfo *outInfo) { - if (!outInfo) { return false; } - if (!strafe_cgs_available()) { return false; } + return strafe_get_space_info_for_display(NULL, outInfo); +} +bool strafe_get_space_info_for_display(const char *displayID, StrafeInfo *outInfo) { + if (!outInfo) { return false; } + // Copy before clearing output: displayID may point into outInfo->displayID. + CFStringRef targetDisplay = displayID + ? CFStringCreateWithCString(NULL, displayID, kCFStringEncodingUTF8) : NULL; memset(outInfo, 0, sizeof(*outInfo)); - + if (!strafe_cgs_available()) { + if (targetDisplay) CFRelease(targetDisplay); + return false; + } CGSConnectionID conn = CGSMainConnectionID(); - - CFStringRef targetDisplay = copy_cursor_display_identifier(); + if (!displayID) targetDisplay = copy_cursor_display_identifier(); // Fall back to the menu-bar display identifier if cursor lookup failed. - if (!targetDisplay && (&CGSCopyActiveMenuBarDisplayIdentifier != NULL)) { + if (!displayID && !targetDisplay && (&CGSCopyActiveMenuBarDisplayIdentifier != NULL)) { targetDisplay = CGSCopyActiveMenuBarDisplayIdentifier(conn); } + if (!strafe_has_type(targetDisplay, CFStringGetTypeID()) || CFStringGetLength(targetDisplay) == 0) { + if (targetDisplay) CFRelease(targetDisplay); + return false; + } CFArrayRef displaySpaces = CGSCopyManagedDisplaySpaces(conn, NULL); - if (!displaySpaces) { + if (!strafe_has_type(displaySpaces, CFArrayGetTypeID())) { if (targetDisplay) { CFRelease(targetDisplay); } + if (displaySpaces) CFRelease(displaySpaces); return false; } @@ -138,64 +232,20 @@ bool strafe_get_space_info(StrafeInfo *outInfo) { return false; } - // Pick the matching display dict; if the target isn't found, fall back to - // the first display in the list (SPEC §6). + // Exact display identity is required, including subsequent verification reads. CFDictionaryRef displayDict = NULL; if (targetDisplay) { for (CFIndex d = 0; d < displayCount; d++) { CFDictionaryRef candidate = (CFDictionaryRef)CFArrayGetValueAtIndex(displaySpaces, d); - if (!candidate) { continue; } + if (!strafe_has_type(candidate, CFDictionaryGetTypeID())) { continue; } CFStringRef ident = (CFStringRef)CFDictionaryGetValue(candidate, CFSTR("Display Identifier")); - if (ident && CFStringCompare(ident, targetDisplay, 0) == kCFCompareEqualTo) { + if (strafe_has_type(ident, CFStringGetTypeID()) && CFEqual(ident, targetDisplay)) { displayDict = candidate; break; } } } - if (!displayDict) { - displayDict = (CFDictionaryRef)CFArrayGetValueAtIndex(displaySpaces, 0); - } - - bool found = false; - if (displayDict) { - CFStringRef displayIdentifier = (CFStringRef)CFDictionaryGetValue(displayDict, CFSTR("Display Identifier")); - - CFArrayRef spaces = (CFArrayRef)CFDictionaryGetValue(displayDict, CFSTR("Spaces")); - if (spaces) { - CFIndex count = CFArrayGetCount(spaces); - outInfo->spaceCount = (unsigned int)count; - - // Determine the current space id for this display. - CGSSpaceID currentSpaceID = 0; - CFDictionaryRef currentSpace = (CFDictionaryRef)CFDictionaryGetValue(displayDict, CFSTR("Current Space")); - if (currentSpace) { - CFNumberRef id64 = (CFNumberRef)CFDictionaryGetValue(currentSpace, CFSTR("id64")); - if (id64) { CFNumberGetValue(id64, kCFNumberSInt64Type, ¤tSpaceID); } - } - if (currentSpaceID == 0) { - currentSpaceID = CGSGetActiveSpace(conn); - } - - // Map the current space id to a zero-based index within this display. - outInfo->currentIndex = 0; - for (CFIndex s = 0; s < count; s++) { - CFDictionaryRef spaceDict = (CFDictionaryRef)CFArrayGetValueAtIndex(spaces, s); - if (!spaceDict) { continue; } - CFNumberRef idNum = (CFNumberRef)CFDictionaryGetValue(spaceDict, CFSTR("id64")); - CGSSpaceID sid = 0; - if (idNum) { CFNumberGetValue(idNum, kCFNumberSInt64Type, &sid); } - if (sid == currentSpaceID) { - outInfo->currentIndex = (unsigned int)s; - break; - } - } - - if (displayIdentifier) { - CFStringGetCString(displayIdentifier, outInfo->displayID, sizeof(outInfo->displayID), kCFStringEncodingUTF8); - } - found = true; - } - } + bool found = strafe_extract_space_info(displayDict, outInfo); if (targetDisplay) { CFRelease(targetDisplay); } CFRelease(displaySpaces); @@ -203,6 +253,14 @@ bool strafe_get_space_info(StrafeInfo *outInfo) { } // --- Event inspection helpers (SPEC §2.2, §2.3) --------------------------- +bool strafe_event_is_strafe(CGEventRef event) { + return event && CGEventGetIntegerValueField(event, kCGEventSourceUserData) == kStrafeEventMarker; +} +double strafe_event_generic_progress(CGEventRef event) { + return event ? CGEventGetDoubleValueField(event, (CGEventField)119) : 0; +} +int64_t strafe_cgs_event_fluid_touch(void) { return kCGSEventFluidTouchGesture; } +int64_t strafe_iohid_event_generic_swipe(void) { return 32; } int64_t strafe_event_cgs_type(CGEventRef event) { return CGEventGetIntegerValueField(event, kCGSEventTypeField); } @@ -248,7 +306,7 @@ int32_t strafe_field_swipe_velocity_x(void) { return (int32_t)kCGEventGestureSwi int32_t strafe_field_swipe_velocity_y(void) { return (int32_t)kCGEventGestureSwipeVelocityY; } int32_t strafe_field_gesture_phase(void) { return (int32_t)kCGEventGesturePhase; } -// Raw tap mask: (1<<29) gesture | (1<<30) dock-control ONLY. +// Raw tap mask: gesture29, dock-control30 and fluid-touch31. // // KEY-EVENTS-IN-MASK DETERMINATION (see docs/SPEC.md §2.1): // The upstream reference (and this file's earlier revision) also OR'd in @@ -271,7 +329,8 @@ int32_t strafe_field_gesture_phase(void) { return (int32_t)kCGEventGesturePha // real space-swipe gestures, dropping idle keyboard wakeups to zero. Behavior is // unchanged because the removed events were never acted upon. uint64_t strafe_tap_event_mask(void) { - return (1ULL << kCGSEventGesture) | (1ULL << kCGSEventDockControl); + return (1ULL << kCGSEventGesture) | (1ULL << kCGSEventDockControl) + | (1ULL << kCGSEventFluidTouchGesture); } // --- Overlay / Exposé detection (SPEC §2.5) ------------------------------- diff --git a/Sources/CStrafe/EventSerialization.h b/Sources/CStrafe/EventSerialization.h new file mode 100644 index 0000000..9c5d208 --- /dev/null +++ b/Sources/CStrafe/EventSerialization.h @@ -0,0 +1,126 @@ +// Modified adaptation of ISS bf32cf9732c706119e8307d2d70ae795b2b11c1c +// event_serialize.c and FasterSwiper src/gesture-serialization.cc. +// Rewritten for checked byte access, v2 validation and payload replacement. +// Copyright (c) 2026 jurplel; Copyright 2026 Matthew Bowen. +// See third-party license files at the repository root. +#ifndef STRAFE_EVENT_SERIALIZATION_H +#define STRAFE_EVENT_SERIALIZATION_H +#include +#include +#include +#include + +static bool strafe_fixed(double value, int32_t *out) { + double scaled = value * 65536.0; + if (!isfinite(scaled) || scaled < INT32_MIN || scaled > INT32_MAX) return false; + *out = (int32_t)scaled; + if (!*out && value != 0) *out = value > 0 ? 1 : -1; + return true; +} + +static void strafe_le(uint8_t *bytes, size_t offset, uint64_t value, size_t width) { + for (size_t i = 0; i < width; ++i) bytes[offset + i] = (uint8_t)(value >> (8 * i)); +} + +static uint16_t strafe_be16(const uint8_t *p) { + return (uint16_t)(((uint16_t)p[0] << 8) | p[1]); +} + +// The caller supplies at least 96 bytes. Offsets are wire offsets, not C structs. +static bool strafe_payload(CGEventRef event, uint8_t bytes[96], size_t *length) { + int64_t phase = CGEventGetIntegerValueField(event, (CGEventField)132); + int64_t motion = CGEventGetIntegerValueField(event, (CGEventField)123); + int64_t mask = CGEventGetIntegerValueField(event, (CGEventField)115); + if (phase < 0 || phase > 255 || motion < 0 || motion > UINT16_MAX || + mask < 0 || mask > UINT32_MAX) return false; + int32_t values[5]; + const int fields[] = {125, 126, 124, 129, 130}; + for (size_t i = 0; i < 5; ++i) { + if (!strafe_fixed(CGEventGetDoubleValueField(event, (CGEventField)fields[i]), &values[i])) return false; + } + bool velocity = values[3] != 0 || values[4] != 0 || phase == 4; + *length = velocity ? 96 : 68; + memset(bytes, 0, 96); + uint64_t time = CGEventGetTimestamp(event); + strafe_le(bytes, 0, time ? time : mach_absolute_time(), 8); + strafe_le(bytes, 24, velocity ? 2 : 1, 4); + strafe_le(bytes, 28, 40, 4); + strafe_le(bytes, 32, 23, 4); + strafe_le(bytes, 36, (uint32_t)phase << 24, 4); + strafe_le(bytes, 44, (uint32_t)values[0], 4); + strafe_le(bytes, 48, (uint32_t)values[1], 4); + strafe_le(bytes, 56, (uint32_t)mask, 4); + strafe_le(bytes, 60, (uint16_t)motion, 2); + strafe_le(bytes, 62, 3, 2); + strafe_le(bytes, 64, (uint32_t)values[2], 4); + if (velocity) { + strafe_le(bytes, 68, 28, 4); + strafe_le(bytes, 72, 9, 4); + bytes[80] = 1; + strafe_le(bytes, 84, (uint32_t)values[3], 4); + strafe_le(bytes, 88, (uint32_t)values[4], 4); + } + return true; +} + +// Reject unknown tags, illegal sizes, truncation and duplicate fields. +// Tag 0 size 1 is int64; other sizes are byte counts (including field4205). +static CFDataRef strafe_replace_payload(CFDataRef data, const uint8_t *payload, size_t payloadLength) { + if (!data || !payload || (payloadLength != 68 && payloadLength != 96)) return NULL; + CFIndex signedLength = CFDataGetLength(data); + if (signedLength < 4 || signedLength > LONG_MAX - 100) return NULL; + size_t length = (size_t)signedLength; + const uint8_t *bytes = CFDataGetBytePtr(data); + if (memcmp(bytes, "\0\0\0\2", 4)) return NULL; + uint8_t seen[16384 / 8] = {0}; + size_t oldOffset = length, oldLength = 0; + for (size_t offset = 4; offset < length;) { + if (length - offset < 4) return NULL; + uint16_t count = strafe_be16(bytes + offset); + uint16_t header = strafe_be16(bytes + offset + 2); + unsigned tag = header >> 14, field = header & 0x3fff; + if (seen[field / 8] & (1u << (field % 8))) return NULL; + seen[field / 8] |= (uint8_t)(1u << (field % 8)); + size_t size; + if (tag == 0 && count > 0) size = count == 1 ? 8 : count; + else if (tag == 1 && count == 1) size = 4; + else if (tag == 3 && (count == 1 || count == 2)) size = count * 4; + else return NULL; + if (size > length - offset - 4) return NULL; + if (field == 4205) { + if (tag != 0 || count <= 1) return NULL; + oldOffset = offset; + oldLength = size + 4; + } + offset += size + 4; + } + size_t newLength = length - oldLength + 4 + payloadLength; + uint8_t *result = malloc(newLength); + if (!result) return NULL; + memcpy(result, bytes, oldOffset); + result[oldOffset] = 0; + result[oldOffset + 1] = (uint8_t)payloadLength; + result[oldOffset + 2] = 0x10; + result[oldOffset + 3] = 0x6d; + memcpy(result + oldOffset + 4, payload, payloadLength); + memcpy(result + oldOffset + 4 + payloadLength, bytes + oldOffset + oldLength, + length - oldOffset - oldLength); + CFDataRef output = CFDataCreate(NULL, result, (CFIndex)newLength); + free(result); + return output; +} + +static CGEventRef strafe_augment(CGEventRef event) { + uint8_t payload[96]; + size_t length; + if (!strafe_payload(event, payload, &length)) return NULL; + CFDataRef original = CGEventCreateData(NULL, event); + if (!original) return NULL; + CFDataRef data = strafe_replace_payload(original, payload, length); + CFRelease(original); + if (!data) return NULL; + CGEventRef result = CGEventCreateFromData(NULL, data); + CFRelease(data); + return result; +} +#endif diff --git a/Sources/CStrafe/include/CStrafe.h b/Sources/CStrafe/include/CStrafe.h index 2d9c915..d1ebba1 100644 --- a/Sources/CStrafe/include/CStrafe.h +++ b/Sources/CStrafe/include/CStrafe.h @@ -2,9 +2,8 @@ // // This C target owns every undocumented magic number and private CGS symbol so // the Swift side never has to touch @_silgen_name or raw CGEventField indices. -// The mechanism is an independent reimplementation of the technique from -// jurplel/InstantSpaceSwitcher (MIT); see docs/SPEC.md for the field-by-field -// derivation. +// Legacy fields are described in docs/SPEC.md. macOS 27 synthesis adapts ISS +// and FasterSwiper; see THIRD-PARTY-LICENSES.txt for provenance and notices. #ifndef CSTRAFE_H #define CSTRAFE_H @@ -17,6 +16,8 @@ extern "C" { #endif +CF_ASSUME_NONNULL_BEGIN + // Direction of a single-step space switch. typedef enum { StrafeDirectionLeft = 0, // previous space @@ -28,6 +29,7 @@ typedef struct { unsigned int currentIndex; // zero-based index of the active space on this display unsigned int spaceCount; // number of spaces on this display char displayID[128]; // display UUID string, used as the prediction-dictionary key + uint64_t currentSpaceID; // active identity, verified in this display's Spaces array } StrafeInfo; // --- Capability check (SPEC §1.1, §6) ------------------------------------- @@ -36,16 +38,30 @@ typedef struct { bool strafe_cgs_available(void); // --- Synthesis (SPEC §1.4, §1.5) ------------------------------------------ +// Create a single retained event; never posts or sleeps. Caller chooses profile +// and inversion (no OS/config inference). Right is positive unless inverted. +// velocity must be a finite nonnegative magnitude; augmented requires signed +// 16.16 range. Phases: 1/2/4/8. NULL on invalid input or unsupported serialization. +// Returned events carry our PID and user-data marker, restored after augmentation. +// Serializing again with CGEventCreateData may discard source user data. +CGEventRef _Nullable strafe_create_switch_event(StrafeDirection direction, double velocity, + int64_t phase, bool augmented, bool inverted) CF_RETURNS_RETAINED; + +// Legacy synchronous benchmark API, numeric profile and no inversion. // Post one instant single-step dock swipe: began -> changed -> ended, all // immediately with no delay, to kCGSessionEventTap. Returns false only if an -// event could not be created. `velocity` is the gesture speed magnitude +// event could not be created or arguments are invalid. `velocity` is the gesture speed magnitude // (default 2000.0 == "instant"); sign is applied internally from `direction`. bool strafe_post_switch_gesture(StrafeDirection direction, double velocity); // --- Topology (SPEC §6) --------------------------------------------------- // Fill `outInfo` for the display under the cursor. Returns false if the CGS // symbols are unavailable or topology could not be read. -bool strafe_get_space_info(StrafeInfo *outInfo); +bool strafe_get_space_info(StrafeInfo * _Nullable outInfo); +// NULL selects cursor (menu-bar fallback if cursor lookup fails); otherwise exact +// display identity only. False on empty/malformed topology or missing current ID. +// Output is zeroed on failure. No global-active-Space substitution. +bool strafe_get_space_info_for_display(const char * _Nullable displayID, StrafeInfo * _Nullable outInfo); // --- Event inspection helpers for the interceptor (SPEC §2.2, §2.3) ------- // These wrap CGEventGet*ValueField with the private field indices so the Swift @@ -57,11 +73,15 @@ int64_t strafe_event_gesture_phase(CGEventRef event); // field 132 -> CGSGesture double strafe_event_swipe_progress(CGEventRef event);// field 124 -> progress (double) double strafe_event_swipe_velocity_x(CGEventRef event); // field 129 -> velocityX (double) int64_t strafe_event_source_pid(CGEventRef event); // kCGEventSourceUnixProcessID +bool strafe_event_is_strafe(CGEventRef _Nullable event); // user-data marker, independent of PID +double strafe_event_generic_progress(CGEventRef event); // field119 value // --- Constants the interceptor compares against (SPEC §1.3) --------------- // Exposed as functions to keep the enum values single-sourced in C. int64_t strafe_cgs_event_dock_control(void); // 30 int64_t strafe_cgs_event_gesture(void); // 29 +int64_t strafe_cgs_event_fluid_touch(void); // 31 +int64_t strafe_iohid_event_generic_swipe(void); // 32 int64_t strafe_iohid_event_dock_swipe(void); // 23 int64_t strafe_gesture_motion_horizontal(void); // 1 int64_t strafe_gesture_phase_began(void); // 1 @@ -85,8 +105,8 @@ int32_t strafe_field_swipe_velocity_x(void); // 129 -> velocityX (double) int32_t strafe_field_swipe_velocity_y(void); // 130 -> velocityY (double) int32_t strafe_field_gesture_phase(void); // 132 -> CGSGesturePhase -// Raw event mask the tap must register: the two private gesture types by raw -// bit shift (1<<29)|(1<<30). Key events are deliberately NOT masked — see the +// Raw event mask the tap must register: the three private gesture types by raw +// bit shift (1<<29)|(1<<30)|(1<<31). Key events are deliberately NOT masked — see the // KEY-EVENTS-IN-MASK DETERMINATION at the definition in CStrafe.c. uint64_t strafe_tap_event_mask(void); @@ -94,6 +114,8 @@ uint64_t strafe_tap_event_mask(void); // True when App Exposé or Mission Control is up (Dock windows at layers 18/20). bool strafe_is_expose_active(void); +CF_ASSUME_NONNULL_END + #ifdef __cplusplus } #endif diff --git a/Sources/strafe/HotkeyManager.swift b/Sources/strafe/HotkeyManager.swift index 37a0df3..84f6181 100644 --- a/Sources/strafe/HotkeyManager.swift +++ b/Sources/strafe/HotkeyManager.swift @@ -61,7 +61,7 @@ final class HotkeyManager { let userInfo = Unmanaged.passUnretained(self).toOpaque() - InstallEventHandler( + let status = InstallEventHandler( GetApplicationEventTarget(), { _, event, userInfo -> OSStatus in guard let userInfo, let event else { return OSStatus(eventNotHandledErr) } @@ -90,6 +90,9 @@ final class HotkeyManager { userInfo, &eventHandler ) + if status != noErr { + SwitchDiagnostics.log("[HotkeyManager] InstallEventHandler failed (status \(status))") + } } private func registerHotKey(keyCode: UInt32, id: UInt32, modifiers: UInt32) -> EventHotKeyRef? { @@ -104,9 +107,7 @@ final class HotkeyManager { &ref ) guard status == noErr else { - FileHandle.standardError.write( - Data("[HotkeyManager] RegisterEventHotKey failed (status \(status)) for id \(id)\n".utf8) - ) + SwitchDiagnostics.log("[HotkeyManager] RegisterEventHotKey failed (status \(status)) for id \(id)") return nil } return ref @@ -122,9 +123,7 @@ final class HotkeyManager { do { try engine.switchSpace(direction) } catch { - FileHandle.standardError.write( - Data("[HotkeyManager] switchSpace failed: \(error)\n".utf8) - ) + SwitchDiagnostics.log("[HotkeyManager] switchSpace enqueue failed: \(error)") } } } diff --git a/Sources/strafe/MissionControlMonitor.swift b/Sources/strafe/MissionControlMonitor.swift new file mode 100644 index 0000000..aae2bb2 --- /dev/null +++ b/Sources/strafe/MissionControlMonitor.swift @@ -0,0 +1,100 @@ +import AppKit +import ApplicationServices +import CStrafe + +/// Dock exposes these notifications through Accessibility. The names are also +/// used by yabai's src/mission_control.c. This tracks Mission Control, App Exposé +/// and Show Desktop independently of Dock's version-sensitive window layers. +/// Start/stop and AX callbacks are main-runloop confined; the engine reads the +/// state from its serial queue under `lock`. +final class MissionControlMonitor: @unchecked Sendable { + static let shared = MissionControlMonitor() + private let lock = NSLock() + private var active = false + private var observer: AXObserver? + private var dock: AXUIElement? + private var launchObserver: (any NSObjectProtocol)? + private let names = ["AXExposeShowAllWindows", "AXExposeShowFrontWindows", + "AXExposeShowDesktop", "AXExposeExit"] + + var isActive: Bool { + lock.lock() + defer { lock.unlock() } + return active + } + + private func receive(_ name: String) { + lock.lock() + active = name != "AXExposeExit" + let state = active + lock.unlock() + SwitchDiagnostics.log("overlay active=\(state) notification=\(name)") + } + + func start() { + precondition(Thread.isMainThread) + attach() + if launchObserver == nil { + launchObserver = NSWorkspace.shared.notificationCenter.addObserver( + forName: NSWorkspace.didLaunchApplicationNotification, object: nil, queue: .main + ) { [weak self] notification in + guard let app = notification.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication, + app.bundleIdentifier == "com.apple.dock" else { return } + self?.detach() + self?.attach() + } + } + } + + private func attach() { + guard observer == nil, + let pid = NSRunningApplication.runningApplications(withBundleIdentifier: "com.apple.dock").first?.processIdentifier else { return } + let application = AXUIElementCreateApplication(pid) + var created: AXObserver? + let result = AXObserverCreate(pid, { _, _, notification, refcon in + guard let refcon else { return } + Unmanaged.fromOpaque(refcon).takeUnretainedValue().receive(notification as String) + }, &created) + guard result == .success, let created else { + SwitchDiagnostics.log("overlay observer unavailable AXError=\(result.rawValue)") + return + } + let refcon = Unmanaged.passUnretained(self).toOpaque() + var registered = 0 + for name in names { + let status = AXObserverAddNotification(created, application, name as CFString, refcon) + if status == .success || status == .notificationAlreadyRegistered { registered += 1 } + else { SwitchDiagnostics.log("overlay notification=\(name) registration AXError=\(status.rawValue)") } + } + observer = created + dock = application + CFRunLoopAddSource(CFRunLoopGetMain(), AXObserverGetRunLoopSource(created), .commonModes) + // Initial snapshot covers starting while an overlay is already open. + // Notifications are authoritative after this legacy fallback snapshot. + lock.lock() + active = strafe_is_expose_active() + lock.unlock() + SwitchDiagnostics.log("overlay observer registered=\(registered)/4 initialActive=\(isActive)") + } + + private func detach() { + if let observer { + if let dock { + for name in names { AXObserverRemoveNotification(observer, dock, name as CFString) } + } + CFRunLoopRemoveSource(CFRunLoopGetMain(), AXObserverGetRunLoopSource(observer), .commonModes) + } + observer = nil + dock = nil + lock.lock() + active = false + lock.unlock() + } + + func stop() { + precondition(Thread.isMainThread) + detach() + if let launchObserver { NSWorkspace.shared.notificationCenter.removeObserver(launchObserver) } + launchObserver = nil + } +} diff --git a/Sources/strafe/Permissions.swift b/Sources/strafe/Permissions.swift index 3c8deb6..0f4f071 100644 --- a/Sources/strafe/Permissions.swift +++ b/Sources/strafe/Permissions.swift @@ -31,7 +31,7 @@ enum Permissions { let cgs = cgsAvailable ? "yes" : "no" print("strafe status") print(" Accessibility granted: \(ax)") - print(" Event tap running: \(tap)") + print(" Event tap running: \(tap) (this process only; not resident-app status)") print(" CGS symbols resolved: \(cgs)") } } diff --git a/Sources/strafe/SwipeInterceptor.swift b/Sources/strafe/SwipeInterceptor.swift index f3657e3..3bcdff5 100644 --- a/Sources/strafe/SwipeInterceptor.swift +++ b/Sources/strafe/SwipeInterceptor.swift @@ -8,29 +8,61 @@ import CStrafe /// /// Concurrency: the tap source is installed on the **main** run loop in /// `kCFRunLoopCommonModes` (SPEC §2.1), so `eventTapCallback` always runs on -/// the main thread. All mutable state (`swipeTracking`, `swipeFired`, -/// `isRunning`) is therefore touched only from that single run loop and needs +/// the main thread. All mutable gesture and lifecycle state +/// is therefore touched only from that single run loop and needs /// no locking. The class is `@unchecked Sendable` because the C callback /// reaches it through an opaque pointer; that confinement invariant is what /// makes the unchecked conformance sound. final class SwipeInterceptor: @unchecked Sendable { private let engine: SwitchEngine + private let invertDirection: Bool + private let overlayActive: @Sendable () -> Bool + private let overlaySnapshot: @Sendable () -> Bool private var eventTap: CFMachPort? private var runLoopSource: CFRunLoopSource? /// Whether the tap is currently created and enabled. - private(set) var isRunning: Bool = false + var isRunning: Bool { + guard let eventTap else { return false } + return CGEvent.tapIsEnabled(tap: eventTap) + } /// Whether interception is active. When false the callback passes every /// event through untouched (SPEC §2.2: "only acts when swipeOverrideEnabled"). - var overrideEnabled: Bool = true + var overrideEnabled: Bool = true { + didSet { + if overrideEnabled != oldValue { resetGesture() } + } + } // MARK: - State machine (SPEC §2.3). Main-run-loop confined. - private var swipeTracking = false + private enum GestureFamily: Int, Sendable { + case legacy, fluid, generic + } + private var activeFamily: GestureFamily? private var swipeFired = false + private let ownPID = Int64(ProcessInfo.processInfo.processIdentifier) + private let diagnosticsEnabled = ProcessInfo.processInfo.environment["STRAFE_DIAGNOSTICS"] == "1" + private let diagnosticsQueue = DispatchQueue(label: "strafe.swipe-diagnostics", qos: .utility) + private var diagnosticBudget = 64 + private var diagnosticChangedFamilies = 0 + private var errorBudget = 8 + private var lastOwnedEvent: TimeInterval = 0 + + private func resetGesture() { + activeFamily = nil + swipeFired = false + lastOwnedEvent = 0 + } - init(engine: SwitchEngine) { + init(engine: SwitchEngine, configuration: SwitchConfiguration, + overlayActive: @escaping @Sendable () -> Bool = { MissionControlMonitor.shared.isActive }, + overlaySnapshot: @escaping @Sendable () -> Bool = { strafe_is_expose_active() }) { self.engine = engine + self.invertDirection = configuration.invertSwipeDirection + self.overrideEnabled = configuration.interceptSwipes + self.overlayActive = overlayActive + self.overlaySnapshot = overlaySnapshot } // MARK: - Lifecycle @@ -42,8 +74,8 @@ final class SwipeInterceptor: @unchecked Sendable { return } - // Gesture (1<<29) | dock-control (1<<30) only, sourced from C so the raw - // private type bits are single-sourced with the synthesizer. Key events + // Gesture (1<<29) | dock-control (1<<30) | fluid-touch (1<<31). + // Private type bits are single-sourced in C with the synthesizer. Key events // are intentionally excluded (they were never acted on and only added // per-keystroke latency) — see the determination comment in CStrafe.c. let mask = CGEventMask(strafe_tap_event_mask()) @@ -82,15 +114,15 @@ final class SwipeInterceptor: @unchecked Sendable { /// Enable the tap if it exists. func enable() { guard let eventTap else { return } + if !CGEvent.tapIsEnabled(tap: eventTap) { resetGesture() } CGEvent.tapEnable(tap: eventTap, enable: true) - isRunning = true } /// Disable the tap without tearing it down (can be re-enabled cheaply). func disable() { + resetGesture() guard let eventTap else { return } CGEvent.tapEnable(tap: eventTap, enable: false) - isRunning = false } /// Fully remove the tap from the main run loop and release it. @@ -103,14 +135,13 @@ final class SwipeInterceptor: @unchecked Sendable { } runLoopSource = nil eventTap = nil - isRunning = false - swipeTracking = false - swipeFired = false + resetGesture() } // MARK: - Callback (runs on the main run loop) - private func handle(type: CGEventType, event: CGEvent) -> Unmanaged? { + // Internal so non-posting tests can replay captured gesture representations. + func handle(type: CGEventType, event: CGEvent) -> Unmanaged? { // HOT PATH — runs for every gesture/dock-control event the tap sees. // Invariant: the reject path (any non-candidate event) must do zero // allocations, no Swift string work, no logging, and acquire no lock. @@ -126,101 +157,145 @@ final class SwipeInterceptor: @unchecked Sendable { // A disable can swallow a gesture's `ended`/`cancelled`, leaving the // state machine mid-track. Reset before re-enabling so a dropped // gesture-end can't leave us stuck suppressing companion events. - swipeTracking = false - swipeFired = false + resetGesture() if let eventTap { CGEvent.tapEnable(tap: eventTap, enable: true) } return passthrough } - // Only act when interception is on (SPEC §2.2). - guard overrideEnabled else { return passthrough } + // Reject our output before *any* gesture/diagnostic state changes. The + // marker also covers serialized output whose source PID is rewritten. + let pid = strafe_event_source_pid(event) + if pid == ownPID || strafe_event_is_strafe(event) { return passthrough } + guard overrideEnabled || diagnosticsEnabled else { return passthrough } - // Read the private CGSEventType (field 55). We only care about the - // dock-control swipe and its companion gesture events. let cgsType = strafe_event_cgs_type(event) let dockControl = strafe_cgs_event_dock_control() let gesture = strafe_cgs_event_gesture() - - guard cgsType == dockControl || cgsType == gesture else { + let family: GestureFamily + let hid = strafe_event_hid_type(event) + if cgsType == dockControl || cgsType == strafe_cgs_event_fluid_touch() { + if cgsType == dockControl && pid != 0 { return passthrough } + guard hid == strafe_iohid_event_dock_swipe() else { return passthrough } + family = cgsType == dockControl ? .legacy : .fluid + } else if cgsType == gesture && hid == strafe_iohid_event_generic_swipe() { + family = .generic + } else { + // Unknown companion subtypes may be unrelated gestures. Never + // blanket-suppress type29 merely because a swipe is active. return passthrough } - // SPEC §2.2 step 3: real HID gestures originate in the kernel with - // source pid == 0. Synthetic events (ours + any other app's) have a - // nonzero pid — pass them through so we don't re-trap our own posts. - if strafe_event_source_pid(event) != 0 { + // Decoding reference: jurplel/InstantSpaceSwitcher PR77, pinned at + // 4602184328ec13b01e52e4456ad6ec3239df5152 (selective protocol findings). + // HID32 as a *horizontal* swipe is empirically inferred, not proven for + // every macOS 27 gesture. Its axis discriminator remains unresolved; + // field123 is established only for HID23, so do not invent a HID32 check. + let phase = strafe_event_gesture_phase(event) + let progress = family == .generic + ? strafe_event_generic_progress(event) : strafe_event_swipe_progress(event) + diagnose(event: event, family: family, cgsType: cgsType, hid: hid, + pid: pid, phase: phase, progress: progress) + guard overrideEnabled else { return passthrough } + // This host's confirmed desktop stream is HID23. HID32 also appeared + // during the user's overlay/navigation tests, without a known axis. + // Observe it diagnostically, but do not own or suppress that stream. + guard family != .generic else { return passthrough } + if strafe_event_swipe_motion(event) != strafe_gesture_motion_horizontal() { return passthrough } - // Companion gesture events (type 29) are dropped while tracking (SPEC §2.3). - if cgsType == gesture { - return swipeTracking ? nil : passthrough - } - - // From here: a real (pid 0) dock-control event. - // SPEC §2.2 step 4: require a horizontal dock swipe; anything else - // (vertical / App Exposé) passes through untouched. - guard strafe_event_hid_type(event) == strafe_iohid_event_dock_swipe(), - strafe_event_swipe_motion(event) == strafe_gesture_motion_horizontal() - else { + if overlayActive() { + resetGesture() return passthrough } - - // SPEC §2.3 state machine, driven by the gesture phase (field 132). - let phase = strafe_event_gesture_phase(event) + let now = ProcessInfo.processInfo.systemUptime + // Recover from an absent Ended without latching suppression forever. + if activeFamily != nil && now - lastOwnedEvent > 0.5 { resetGesture() } if phase == strafe_gesture_phase_began() { - // Let real gestures through while an overlay (Exposé) is up (SPEC §2.5). - if strafe_is_expose_active() { return passthrough } - swipeTracking = true + if overlaySnapshot() { + resetGesture() + return passthrough + } + // Both duplicate Began and another representation's Began belong + // to the current swipe; neither may reset the once-only fire latch. + if activeFamily != nil { return nil } + activeFamily = family swipeFired = false - return nil // SUPPRESS the real 'began' + lastOwnedEvent = now + return nil + } - } else if phase == strafe_gesture_phase_changed() { - guard swipeTracking else { return passthrough } - if !swipeFired { - let progress = strafe_event_swipe_progress(event) - if progress != 0.0 { - // Direction is the sign of progress; fire as soon as known. - let dir: SwitchDirection = progress > 0 ? .right : .left - swipeFired = true - try? engine.switchSpace(dir) - } - } - return nil // SUPPRESS + guard let activeFamily else { return passthrough } + // Window enumeration is intentionally restricted to Began. Repeating + // it for every Changed event can stall the active input tap. + // Only the family that began tracking can drive/fire/end this stream. + // In particular, a companion Ended must not clear the owner's latch. + guard family == activeFamily else { return nil } + lastOwnedEvent = now + if phase == strafe_gesture_phase_changed() { + fireIfNeeded(progress) } else if phase == strafe_gesture_phase_ended() { - guard swipeTracking else { return passthrough } - if !swipeFired { - // Fallback: derive direction from the end velocity's sign. - let velocity = strafe_event_swipe_velocity_x(event) - if velocity != 0.0 { - let dir: SwitchDirection = velocity > 0 ? .right : .left - swipeFired = true - try? engine.switchSpace(dir) - } else { - // Direction was never determined (no nonzero-progress - // `changed`, and zero end velocity), so strafe never acted on - // this gesture. Reset state and pass the original 'ended' - // through so the OS handles the gesture it still owns, rather - // than suppressing an event we never overrode. - swipeTracking = false - swipeFired = false - return passthrough - } + // Generic progress119 is known; velocity129 is not established for + // HID32. Preserve the velocity fallback only for HID23 paths. + fireIfNeeded(family == .generic ? progress : strafe_event_swipe_velocity_x(event)) + let fired = swipeFired + resetGesture() + return fired ? nil : passthrough + } else if phase == strafe_gesture_phase_cancelled() { + resetGesture() + } + return nil + } + + private func fireIfNeeded(_ value: Double) { + guard !swipeFired, value.isFinite, value != 0 else { return } + swipeFired = true + do { + // The engine enqueues asynchronously; never sleep in the tap. + // Physical displacement and synthetic output have separate sign + // conventions on macOS 27; never feed raw progress into bounds. + try engine.switchSpace(Self.direction(for: value, inverted: invertDirection)) + } catch { + guard errorBudget > 0 else { return } + errorBudget -= 1 + diagnosticsQueue.async { + FileHandle.standardError.write(Data("[SwipeInterceptor] switch failed: \(error) (first 8 errors only)\n".utf8)) } - swipeTracking = false - swipeFired = false - return nil // SUPPRESS + } + } - } else if phase == strafe_gesture_phase_cancelled() { - swipeTracking = false - swipeFired = false - return nil + static func direction(for progress: Double, inverted: Bool) -> SwitchDirection { + ((progress > 0) != inverted) ? .right : .left + } - } else { - // Any other phase (mayBegin/none): suppress only while tracking. - return swipeTracking ? nil : passthrough + /// At most 64 candidate phase samples per instance, even when override is + /// off. No CGEvent escapes the callback; formatting/I/O happen off-thread. + private func diagnose(event: CGEvent, family: GestureFamily, cgsType: Int64, + hid: Int64, pid: Int64, phase: Int64, progress: Double) { + guard diagnosticsEnabled, diagnosticBudget > 0 else { return } + let bit = 1 << family.rawValue + if phase == strafe_gesture_phase_began() { + diagnosticChangedFamilies &= ~bit + } else if phase == strafe_gesture_phase_changed() { + guard diagnosticChangedFamilies & bit == 0 else { return } + diagnosticChangedFamilies |= bit + } else if phase != strafe_gesture_phase_ended() && phase != strafe_gesture_phase_cancelled() { + return + } + diagnosticBudget -= 1 + let motion = strafe_event_swipe_motion(event) + let progress119 = strafe_event_generic_progress(event) + let progress124 = strafe_event_swipe_progress(event) + let velocity = strafe_event_swipe_velocity_x(event) + let enabled = overrideEnabled + diagnosticsQueue.async { + FileHandle.standardError.write(Data(( + "[SwipeInterceptor] candidate type=\(cgsType) hid=\(hid) pid=\(pid) phase=\(phase) " + + "motion=\(motion) p119=\(progress119) p124=\(progress124) selected=\(progress) " + + "vx=\(velocity) override=\(enabled) (64 sample cap)\n" + ).utf8)) } } } diff --git a/Sources/strafe/SwitchDiagnostics.swift b/Sources/strafe/SwitchDiagnostics.swift new file mode 100644 index 0000000..72db991 --- /dev/null +++ b/Sources/strafe/SwitchDiagnostics.swift @@ -0,0 +1,66 @@ +import Foundation + +/// Writes happen on a dedicated queue, never in an event-tap callback. +enum SwitchDiagnostics { + private static let queue = DispatchQueue(label: "strafe.diagnostics") + + static func log(_ message: String) { + queue.async { + FileHandle.standardError.write(Data("[strafe] \(message)\n".utf8)) + } + } + + /// CLI calls this before exiting so the last observation/error is retained. + static func flush() { queue.sync {} } +} + +struct SwitchConfiguration: Sendable { + let profile: String + let augmented: Bool + let phaseGapMS: Double + let inverted: Bool + let invertSwipeDirection: Bool + let interceptSwipes: Bool + let diagnostics: Bool + + struct Invalid: Error, CustomStringConvertible { + let description: String + } + + static func load( + environment: [String: String] = ProcessInfo.processInfo.environment, + osMajorVersion: Int = ProcessInfo.processInfo.operatingSystemVersion.majorVersion + ) throws -> SwitchConfiguration { + let profile = environment["STRAFE_EVENT_PROFILE"] ?? "auto" + guard ["auto", "legacy", "macos27"].contains(profile) else { + throw Invalid(description: "STRAFE_EVENT_PROFILE must be auto|legacy|macos27; got '\(profile)'") + } + let augmented = profile == "macos27" || (profile == "auto" && osMajorVersion >= 27) + var gap = augmented ? 10.0 : 0.0 + if let raw = environment["STRAFE_PHASE_GAP_MS"] { + guard !raw.isEmpty, raw.allSatisfy({ $0.isASCII && ($0.isNumber || $0 == ".") }), + let value = Double(raw), value.isFinite, (0...100).contains(value) else { + throw Invalid(description: "STRAFE_PHASE_GAP_MS must be a finite decimal in 0...100; got '\(raw)'") + } + gap = value + } + func flag(_ name: String, default fallback: Bool = false) throws -> Bool { + guard let raw = environment[name] else { return fallback } + guard raw == "0" || raw == "1" else { + throw Invalid(description: "\(name) must be 0|1; got '\(raw)'") + } + return raw == "1" + } + return try SwitchConfiguration( + profile: profile, augmented: augmented, phaseGapMS: gap, + inverted: flag("STRAFE_INVERT_DIRECTION", default: augmented), + invertSwipeDirection: flag("STRAFE_INVERT_SWIPE_DIRECTION", default: osMajorVersion >= 27), + interceptSwipes: flag("STRAFE_INTERCEPT_SWIPES", default: true), + diagnostics: flag("STRAFE_DIAGNOSTICS") + ) + } + + var summary: String { + "profile=\(profile) resolved=\(augmented ? "macos27" : "legacy") augmented=\(augmented) phaseGapMS=\(phaseGapMS) inverted=\(inverted) invertSwipeDirection=\(invertSwipeDirection) interceptSwipes=\(interceptSwipes) diagnostics=\(diagnostics) prediction=disabled queueLimit=16 observationMS=750 settleMS=100" + } +} diff --git a/Sources/strafe/SwitchEngine.swift b/Sources/strafe/SwitchEngine.swift index 07f42a8..7f42641 100644 --- a/Sources/strafe/SwitchEngine.swift +++ b/Sources/strafe/SwitchEngine.swift @@ -1,8 +1,8 @@ import Foundation +import CoreGraphics import CStrafe -/// The direction to move between macOS Spaces. -enum SwitchDirection { +enum SwitchDirection: Sendable { case left case right @@ -11,110 +11,288 @@ enum SwitchDirection { } } -/// Error surfaced when a synthetic switch could not be produced. -enum SwitchEngineError: Error { - /// The bounds guard blocked a swipe past the first/last space (SPEC §2.4). +enum SwitchEngineError: Error, Sendable { case atEdge - /// `CGEventCreate` failed — no event could be posted. case postFailed + case topologyUnavailable + case unexpectedChange + case observationTimedOut + case queueFull + case pendingDropped + case overlayActive } -/// Abstraction over the mechanism that actually moves between Spaces. -/// -/// The real implementation (`GestureSwitchEngine`) posts synthetic -/// high-velocity dock-swipe gestures and is being specced separately. -/// Everything in this app is built against this protocol so the engine -/// can be swapped in without touching call sites. protocol SwitchEngine { + /// Enqueues only; asynchronous failures are reported by the real engine. func switchSpace(_ direction: SwitchDirection) throws } -/// No-op engine used during development, tests, and dry runs. Logs the -/// requested switch and returns. Kept around as a safe stand-in for the real -/// engine (e.g. when Accessibility isn't granted, or in unit tests). struct StubSwitchEngine: SwitchEngine { func switchSpace(_ direction: SwitchDirection) throws { - let arrow = direction == .left ? "←" : "→" - FileHandle.standardError.write( - Data("[StubSwitchEngine] switchSpace(\(arrow) \(direction))\n".utf8) - ) + SwitchDiagnostics.log("[StubSwitchEngine] switchSpace(\(direction))") } } -/// The real engine: posts a synthetic high-velocity dock-swipe gesture (SPEC -/// §1) to jump to the neighboring Space instantly. -/// -/// Concurrency: `switchSpace` may be called from the main actor (hotkeys, CLI) -/// or from the event-tap run loop (the interceptor). All mutable prediction -/// state is guarded by an `NSLock`, so the type is safe to share across those -/// contexts; the actual CGEvent posting (`strafe_post_switch_gesture`) is a -/// stateless C call. +/// All request/event/observation state is confined to `queue`. Admission is +/// lock-protected so even requests waiting for that queue have a bounded count. +/// Scheduled closures retain the engine through completion (including in CLI). final class GestureSwitchEngine: SwitchEngine, @unchecked Sendable { - /// Gesture velocity magnitude. 2000.0 is the "Instant" preset — the only - /// value that truly skips the slide animation (SPEC §1.4, §5). Lower values - /// keep a (shortened) animation. + typealias Completion = @Sendable (Result) -> Void static let instantVelocity: Double = 2000.0 - private let velocity: Double + private let configuration: SwitchConfiguration + private let dependencies: Dependencies + private let queue = DispatchQueue(label: "strafe.switch-engine") + private let admission = NSLock() + private var admitted = 0 + private var generation: UInt64 = 0 + private var pending: [Request] = [] + private var active: Request? + private var events: [CGEvent] = [] + private var origin: Topology? + private var target: UInt32 = 0 + private var deadline: TimeInterval = 0 + private var candidateID: UInt64? + private var candidateSince: TimeInterval = 0 + private var deliveryFailureHandler: (@Sendable (SwitchEngineError) -> Void)? + + private struct Request: Sendable { + let id = UUID() + let direction: SwitchDirection + let completion: Completion? + } + + struct Topology: Sendable { + let display: String + let index: UInt32 + let count: UInt32 + let id: UInt64 + + var summary: String { "display=\(display) index=\(index) count=\(count) currentID=\(id)" } + + static func read(display: String? = nil) -> Topology? { + var info = StrafeInfo() + let available: Bool + if let display { + available = display.withCString { strafe_get_space_info_for_display($0, &info) } + } else { + available = strafe_get_space_info(&info) + } + guard available, info.spaceCount > 0, info.currentIndex < info.spaceCount, + info.currentSpaceID != 0 else { return nil } + let name = withUnsafeBytes(of: info.displayID) { bytes in + String(decoding: bytes.prefix(while: { $0 != 0 }), as: UTF8.self) + } + guard !name.isEmpty, display == nil || display == name else { return nil } + return Topology(display: name, index: info.currentIndex, count: info.spaceCount, id: info.currentSpaceID) + } + } - /// Per-display predicted current-space index, keyed by display UUID - /// (SPEC §2.4). Avoids rebounding off the laggy live active-space query. - private let lock = NSLock() - private var predictions: [String: UInt32] = [:] + /// The test seam substitutes topology and event delivery; tests never post + /// real gestures or require a particular desktop layout. + struct Dependencies: Sendable { + var read: @Sendable (String?) -> Topology? = { Topology.read(display: $0) } + var build: @Sendable (SwitchDirection, Double, Int64, Bool, Bool) -> CGEvent? = { + strafe_create_switch_event($0.cDirection, $1, $2, $3, $4) + } + var post: @Sendable (CGEvent) -> Void = { $0.post(tap: .cgSessionEventTap) } + var overlayActive: @Sendable () -> Bool = { MissionControlMonitor.shared.isActive } + } - init(velocity: Double = GestureSwitchEngine.instantVelocity) { + init(velocity: Double = GestureSwitchEngine.instantVelocity, + configuration: SwitchConfiguration, dependencies: Dependencies = Dependencies()) { self.velocity = velocity + self.configuration = configuration + self.dependencies = dependencies + if configuration.diagnostics { SwitchDiagnostics.log("startup \(configuration.summary)") } } - /// Whether the private CGS topology symbols resolved (SPEC §1.1, §6). var cgsAvailable: Bool { strafe_cgs_available() } + var topologyStatus: String { Topology.read()?.summary ?? "unavailable" } func switchSpace(_ direction: SwitchDirection) throws { - // Read live topology once. If CGS symbols are unavailable we can't do - // bounds/prediction bookkeeping — fall back to posting unconditionally. - var info = StrafeInfo() - let haveInfo = strafe_get_space_info(&info) - - if haveInfo { - let displayID = withUnsafeBytes(of: info.displayID) { raw -> String in - let ptr = raw.baseAddress!.assumingMemoryBound(to: CChar.self) - return String(cString: ptr) + try switchSpace(direction, completion: nil) + } + + /// Completion runs on the engine queue, exactly once for an admitted request. + /// A synchronous admission error throws without invoking completion. + func switchSpace(_ direction: SwitchDirection, completion: Completion?) throws { + admission.lock() + guard admitted < 16 else { + admission.unlock() + SwitchDiagnostics.log("request direction=\(direction) rejected: queueFull") + throw SwitchEngineError.queueFull + } + admitted += 1 + let epoch = generation + let request = Request(direction: direction, completion: completion) + queue.async { + self.admission.lock() + let stale = epoch != self.generation + self.admission.unlock() + if stale { + self.complete(request, .failure(.pendingDropped)) + return } + self.pending.append(request) + self.startNext() + } + // Preserve admission order even when callers arrive on different threads. + admission.unlock() + } - lock.lock() - let current = predictions[displayID] ?? info.currentIndex + /// Kept for the workspace notification seam. Live polling is authoritative; + /// notifications must not invalidate a legitimate in-flight transition. + func resetPredictions() {} - // Bounds guard (SPEC §2.4): never swipe past the first/last space. - if direction == .left { - if current == 0 { lock.unlock(); throw SwitchEngineError.atEdge } - } else { - if current + 1 >= info.spaceCount { lock.unlock(); throw SwitchEngineError.atEdge } - } + /// Restore native swiping if replacement delivery is not behaving as + /// expected. Ordinary boundaries and overlay passthrough are not failures. + func setDeliveryFailureHandler(_ handler: @escaping @Sendable (SwitchEngineError) -> Void) { + queue.async { self.deliveryFailureHandler = handler } + } - let target: UInt32 = direction == .left ? current - 1 : current + 1 - lock.unlock() + private func trace(_ message: String) { + if configuration.diagnostics { SwitchDiagnostics.log(message) } + } - guard strafe_post_switch_gesture(direction.cDirection, velocity) else { - throw SwitchEngineError.postFailed + private func startNext() { + guard active == nil, !pending.isEmpty else { return } + let request = pending.removeFirst() + active = request + guard !dependencies.overlayActive() else { + finish(.failure(.overlayActive), dropPending: true) + return + } + guard let live = dependencies.read(nil) else { + trace("request=\(request.id) direction=\(request.direction) live=unavailable predicted=none target=unavailable") + finish(.failure(.topologyUnavailable), dropPending: true) + return + } + origin = live + let atEdge = request.direction == .left ? live.index == 0 : live.index == live.count - 1 + trace("request=\(request.id) direction=\(request.direction) live={\(live.summary)} predicted=none target=\(atEdge ? "edge" : String(request.direction == .left ? live.index - 1 : live.index + 1))") + guard !atEdge else { finish(.failure(.atEdge)); return } + target = request.direction == .left ? live.index - 1 : live.index + 1 + // Allocate the whole sequence before posting Began. A builder failure + // must not strand Dock with a partially constructed gesture. + for phase in [strafe_gesture_phase_began(), strafe_gesture_phase_changed(), strafe_gesture_phase_ended()] { + guard let event = dependencies.build(request.direction, velocity, phase, + configuration.augmented, configuration.inverted) else { + finish(.failure(.postFailed), dropPending: true) + return } + events.append(event) + } + // Posting routes via the cursor. Recheck after construction and before + // Began; all subsequent observation stays pinned to this display. + guard let routed = dependencies.read(nil), routed.display == live.display, + routed.id == live.id, routed.index == live.index, routed.count == live.count else { + finish(.failure(.unexpectedChange), dropPending: true) + return + } + postPhase(0) + } - // Advance the optimistic prediction only after a successful post. - lock.lock() - predictions[displayID] = target - lock.unlock() + private func postPhase(_ index: Int) { + guard let request = active else { return } + if dependencies.overlayActive() { + // An overlay opened between phases. Close our partial synthetic + // gesture before dropping queued requests; never leave it Began. + if index > 0, let cancelled = dependencies.build(request.direction, velocity, + strafe_gesture_phase_cancelled(), configuration.augmented, configuration.inverted) { + dependencies.post(cancelled) + } + finish(.failure(.overlayActive), dropPending: true) + return + } + dependencies.post(events[index]) + trace("request=\(request.id) posted phase=\(["Began", "Changed", "Ended"][index]) uptime=\(ProcessInfo.processInfo.systemUptime)") + if index < 2 { + queue.asyncAfter(deadline: .now() + configuration.phaseGapMS / 1000) { + self.postPhase(index + 1) + } } else { - guard strafe_post_switch_gesture(direction.cDirection, velocity) else { - throw SwitchEngineError.postFailed + deadline = ProcessInfo.processInfo.systemUptime + 0.750 + poll() + } + } + + private func poll() { + guard let request = active, let origin else { return } + guard !dependencies.overlayActive() else { + finish(.failure(.overlayActive), dropPending: true) + return + } + let now = ProcessInfo.processInfo.systemUptime + guard let live = dependencies.read(origin.display) else { + trace("request=\(request.id) observation live=unavailable") + finish(.failure(.topologyUnavailable), dropPending: true) + return + } + if now >= deadline { + trace("request=\(request.id) timeout live={\(live.summary)} target=\(target)") + finish(.failure(.observationTimedOut), dropPending: true) + return + } + guard live.count == origin.count, + (live.index == origin.index && live.id == origin.id) || + (live.index == target && live.id != origin.id) else { + trace("request=\(request.id) unexpected transition live={\(live.summary)}") + finish(.failure(.unexpectedChange), dropPending: true) + return + } + if live.index == target { + if let candidateID, candidateID != live.id { + trace("request=\(request.id) target ID changed live={\(live.summary)}") + finish(.failure(.unexpectedChange), dropPending: true) + return + } + if candidateID == nil { candidateID = live.id; candidateSince = now } + // Stable destination plus settle time reduces races with the next + // request. This is not a measurement of destination input unlock. + if now - candidateSince >= 0.100 { + trace("request=\(request.id) observed transition live={\(live.summary)} inputUnlock=unverified") + finish(.success(())) + return } + } else if candidateID != nil { + trace("request=\(request.id) transition reverted live={\(live.summary)}") + finish(.failure(.unexpectedChange), dropPending: true) + return } + queue.asyncAfter(deadline: .now() + 0.025) { self.poll() } } - /// Reset all predictions to live CGS data. Call on - /// `NSWorkspace.activeSpaceDidChangeNotification` (SPEC §2.4, §5) so rapid - /// repeated swipes don't overshoot bounds or snap back off a stale index. - func resetPredictions() { - lock.lock() - predictions.removeAll(keepingCapacity: true) - lock.unlock() + private func complete(_ request: Request, _ result: Result) { + admission.lock() + admitted -= 1 + admission.unlock() + if case .failure(let error) = result { + SwitchDiagnostics.log("request=\(request.id) direction=\(request.direction) failed: \(error)") + switch error { + case .unexpectedChange, .observationTimedOut, .postFailed, .topologyUnavailable: + deliveryFailureHandler?(error) + default: break + } + } + request.completion?(result) + } + + private func finish(_ result: Result, dropPending: Bool = false) { + guard let request = active else { return } + active = nil + events.removeAll() + origin = nil + candidateID = nil + if dropPending { + admission.lock() + generation &+= 1 + admission.unlock() + let dropped = pending + pending.removeAll() + for request in dropped { complete(request, .failure(.pendingDropped)) } + } + complete(request, result) + queue.async { self.startNext() } } } diff --git a/Sources/strafe/main.swift b/Sources/strafe/main.swift index b9ff788..b50f509 100644 --- a/Sources/strafe/main.swift +++ b/Sources/strafe/main.swift @@ -7,22 +7,51 @@ import AppKit /// The engine seam. `GestureSwitchEngine` posts real synthetic dock-swipe /// gestures (SPEC §1). `StubSwitchEngine` remains available for tests / dry runs. -let engine = GestureSwitchEngine() +let engine: GestureSwitchEngine +let configuration: SwitchConfiguration +do { + configuration = try SwitchConfiguration.load() + engine = GestureSwitchEngine(configuration: configuration) +} catch { + SwitchDiagnostics.log("configuration error: \(error)") + SwitchDiagnostics.flush() + exit(2) +} let args = Array(CommandLine.arguments.dropFirst()) if args.isEmpty { runMenuBarApp(engine: engine) } else { - exit(runCLI(args, engine: engine)) + let status = runCLI(args, engine: engine) + SwitchDiagnostics.flush() + exit(status) } // MARK: - CLI mode +/// The engine completes on its serial queue while the CLI pumps the main loop. +final class CLISwitchResult: @unchecked Sendable { + private let lock = NSLock() + private var result: Result? + + func store(_ result: Result) { + lock.lock() + self.result = result + lock.unlock() + } + + func load() -> Result? { + lock.lock() + defer { lock.unlock() } + return result + } +} + func runCLI(_ args: [String], engine: GestureSwitchEngine) -> Int32 { switch args.first { case "switch": - guard args.count >= 2 else { + guard args.count == 2 else { FileHandle.standardError.write(Data("usage: strafe switch left|right\n".utf8)) return 2 } @@ -35,17 +64,43 @@ func runCLI(_ args: [String], engine: GestureSwitchEngine) -> Int32 { return 2 } do { - try engine.switchSpace(direction) - return 0 + MissionControlMonitor.shared.start() + defer { MissionControlMonitor.shared.stop() } + let completion = CLISwitchResult() + try engine.switchSpace(direction) { completion.store($0) } + // Covers the maximum configured phase gaps (200 ms), 750 ms + // observation, and scheduling slack. Never exit merely on enqueue. + let deadline = ProcessInfo.processInfo.systemUptime + 2.0 + // A timer supplies a run-loop source even in this headless process. + let timer = Timer(timeInterval: 0.01, repeats: true) { _ in } + RunLoop.current.add(timer, forMode: .default) + defer { timer.invalidate() } + while completion.load() == nil && ProcessInfo.processInfo.systemUptime < deadline { + RunLoop.current.run(until: Date(timeIntervalSinceNow: 0.01)) + } + guard let result = completion.load() else { + SwitchDiagnostics.log("switch failed: CLI completion deadline exceeded (2 seconds)") + return 1 + } + switch result { + case .success: return 0 + case .failure(let error): + SwitchDiagnostics.log("switch failed: \(error)") + return 1 + } } catch { - FileHandle.standardError.write(Data("switch failed: \(error)\n".utf8)) + SwitchDiagnostics.log("switch failed: \(error)") return 1 } case "status": + MissionControlMonitor.shared.start() + defer { MissionControlMonitor.shared.stop() } // No live tap in CLI mode, so report tap as not running. CGS symbol // resolution is the capability check per SPEC §1.1 / §6. Permissions.printStatus(tapRunning: false, cgsAvailable: engine.cgsAvailable) + print(" Live topology: \(engine.topologyStatus)") + print(" Overlay active: \(MissionControlMonitor.shared.isActive)") return 0 default: @@ -73,7 +128,7 @@ func runMenuBarApp(engine: GestureSwitchEngine) { let delegate = AppDelegate(engine: engine) app.delegate = delegate - app.run() + withExtendedLifetime(delegate) { app.run() } } @MainActor @@ -82,6 +137,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { private var interceptor: SwipeInterceptor! private var hotkeys: HotkeyManager! private var statusItem: StatusItemController! + private var spaceObserver: (any NSObjectProtocol)? init(engine: GestureSwitchEngine) { self.engine = engine @@ -92,16 +148,23 @@ final class AppDelegate: NSObject, NSApplicationDelegate { // Prompt for accessibility up front so the tap can be created. Permissions.checkAccessibility(prompt: true) - interceptor = SwipeInterceptor(engine: engine) + MissionControlMonitor.shared.start() + interceptor = SwipeInterceptor(engine: engine, configuration: configuration) + engine.setDeliveryFailureHandler { [weak interceptor] error in + DispatchQueue.main.async { + guard let interceptor, interceptor.overrideEnabled else { return } + interceptor.overrideEnabled = false + SwitchDiagnostics.log("trackpad interception paused after \(error); native swipes restored. Re-enable from the menu after diagnosis.") + } + } statusItem = StatusItemController(interceptor: interceptor) hotkeys = HotkeyManager(engine: engine) hotkeys.register() - // SPEC §2.4 / §5: reset the prediction dictionary to live CGS data - // whenever the OS reports a real space change, so rapid repeated swipes - // don't overshoot bounds or snap back off a stale predicted index. - NSWorkspace.shared.notificationCenter.addObserver( + // Compatibility notification seam: live polling now reconciles state; + // this notification must not cancel the switch it is reporting. + spaceObserver = NSWorkspace.shared.notificationCenter.addObserver( forName: NSWorkspace.activeSpaceDidChangeNotification, object: nil, queue: .main @@ -110,11 +173,16 @@ final class AppDelegate: NSObject, NSApplicationDelegate { } interceptor.start() + SwitchDiagnostics.log("app ready accessibility=\(Permissions.isAccessibilityGranted) tapEnabled=\(interceptor.isRunning)") } func applicationWillTerminate(_ notification: Notification) { interceptor?.teardown() hotkeys?.unregister() - NSWorkspace.shared.notificationCenter.removeObserver(self) + MissionControlMonitor.shared.stop() + if let spaceObserver { + NSWorkspace.shared.notificationCenter.removeObserver(spaceObserver) + } + spaceObserver = nil } } diff --git a/THIRD-PARTY-LICENSES.txt b/THIRD-PARTY-LICENSES.txt new file mode 100644 index 0000000..be979c3 --- /dev/null +++ b/THIRD-PARTY-LICENSES.txt @@ -0,0 +1,37 @@ +Synthesis and serialization provenance +====================================== +Sources/CStrafe/CStrafe.c and EventSerialization.h adapt InstantSpaceSwitcher +Sources/ISS/ISS.c and event_serialize.c from the macos-27 branch pinned at +bf32cf9732c706119e8307d2d70ae795b2b11c1c: +https://github.com/jurplel/InstantSpaceSwitcher/tree/bf32cf9732c706119e8307d2d70ae795b2b11c1c + +The serializer and augmented shape also derive from FasterSwiper, including +src/gesture-serialization.cc (https://github.com/mgbowen/FasterSwiper). +Inspected HEAD resolved to 68f5c9b80a7d4876463d05bbf48d1a004651e143. +Copyright 2026 Matthew Bowen. Licensed under Apache-2.0; full text follows +in LICENSE-FasterSwiper.txt. The relevant ISS attribution is retained below. + +Strafe modifications: explicit endian writes instead of packed structs; +checked finite fixed-point conversion; validated v2 fields and replacement of +4205; event creation separated from posting; explicit inversion; strict topology. + +InstantSpaceSwitcher — MIT License +Copyright (c) 2026 jurplel + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Tests/CStrafeTests.c b/Tests/CStrafeTests.c new file mode 100644 index 0000000..640e7a6 --- /dev/null +++ b/Tests/CStrafeTests.c @@ -0,0 +1,183 @@ +// Standalone, non-posting tests. Include implementation to exercise malformed +// wire data and topology fixtures without exposing test-only public API. +#include "../Sources/CStrafe/CStrafe.c" +#include +#include + +static uint32_t le32(const uint8_t *p) { + return (uint32_t)p[0] | ((uint32_t)p[1] << 8) | + ((uint32_t)p[2] << 16) | ((uint32_t)p[3] << 24); +} + +static void check_payload(CFDataRef data, int phase, int sign) { + const uint8_t *p = CFDataGetBytePtr(data); + size_t length = (size_t)CFDataGetLength(data), hits = 0; + for (size_t i = 4; i < length;) { + assert(length - i >= 4); + unsigned count = ((unsigned)p[i] << 8) | p[i + 1]; + unsigned header = ((unsigned)p[i + 2] << 8) | p[i + 3]; + unsigned tag = header >> 14; + size_t size = tag == 0 ? (count == 1 ? 8 : count) : count * 4; + assert(size <= length - i - 4); + if ((header & 0x3fff) == 4205) { + ++hits; + assert(tag == 0 && size == (phase == 4 ? 96 : 68)); + const uint8_t *b = p + i + 4; + assert(le32(b + 24) == (phase == 4 ? 2u : 1u)); + assert(le32(b + 28) == 40 && le32(b + 32) == 23); + assert(le32(b + 36) == (uint32_t)phase << 24); + assert(le32(b + 40) == 0 && le32(b + 44) == 6553); + assert(le32(b + 48) == 0 && le32(b + 52) == 0 && le32(b + 56) == 0); + assert(b[60] == 1 && b[61] == 0 && b[62] == 3 && b[63] == 0); + assert((int32_t)le32(b + 64) == sign); + if (phase == 4) { + assert(le32(b + 68) == 28 && le32(b + 72) == 9); + assert(le32(b + 76) == 0 && le32(b + 80) == 1); + assert((int32_t)le32(b + 84) == sign * 2000 * 65536); + assert(le32(b + 88) == 0 && le32(b + 92) == 0); + } + } + i += size + 4; + } + assert(hits == 1); +} + +static void events(void) { + const int phases[] = {1, 2, 4, 8}; + for (int modern = 0; modern < 2; ++modern) + for (int inverted = 0; inverted < 2; ++inverted) + for (int dir = 0; dir < 2; ++dir) + for (size_t i = 0; i < 4; ++i) { + int phase = phases[i], sign = (dir != inverted) ? 1 : -1; + CGEventRef event = strafe_create_switch_event((StrafeDirection)dir, 2000, phase, modern, inverted); + assert(event && strafe_event_is_strafe(event)); + CFDataRef data = CGEventCreateData(NULL, event); + assert(data); + CGEventRef copy = CGEventCreateFromData(NULL, data); + // Source user data is omitted by this host's CGEventCreateData; the + // augmented builder must restore it after its internal round-trip. + assert(copy); + CGEventRef retainedCopy = CGEventCreateCopy(event); + assert(retainedCopy && strafe_event_is_strafe(retainedCopy)); + CFRelease(retainedCopy); + assert(strafe_event_source_pid(copy) == getpid()); + assert(strafe_event_cgs_type(copy) == 30 && strafe_event_hid_type(copy) == 23); + assert(strafe_event_gesture_phase(copy) == phase && strafe_event_swipe_motion(copy) == 1); + double progress = strafe_event_swipe_progress(copy); + assert(sign * progress > 0); + assert(fabs(progress - sign * (modern ? 0.000016 : FLT_TRUE_MIN)) < (modern ? 1e-10 : FLT_TRUE_MIN)); + assert(strafe_event_swipe_velocity_x(copy) == (modern && phase != 4 ? 0 : sign * 2000)); + assert(CGEventGetDoubleValueField(copy, (CGEventField)130) == (modern ? 0 : sign * 2000)); + if (modern) { + assert(CGEventGetIntegerValueField(copy, (CGEventField)134) == phase); + assert(fabs(CGEventGetDoubleValueField(copy, (CGEventField)125) - 0.1) < 1e-7); + assert(CGEventGetDoubleValueField(copy, (CGEventField)138) == 3); + assert(CGEventGetDoubleValueField(copy, (CGEventField)169) > 0); + check_payload(data, phase, sign); + CGEventRef again = strafe_augment(copy); + assert(again); + CFDataRef replaced = CGEventCreateData(NULL, again); + check_payload(replaced, phase, sign); + assert(CFDataGetLength(data) == CFDataGetLength(replaced)); + CFRelease(replaced); + CFRelease(again); + } + CFRelease(copy); + CFRelease(data); + CFRelease(event); + } + CGEventRef plain = CGEventCreate(NULL); + assert(plain && !strafe_event_is_strafe(plain) && !strafe_event_is_strafe(NULL)); + CGEventSetIntegerValueField(plain, (CGEventField)55, 29); + CGEventSetIntegerValueField(plain, (CGEventField)110, 32); + CGEventSetDoubleValueField(plain, (CGEventField)119, -0.25); + assert(strafe_event_generic_progress(plain) == -0.25); + CFRelease(plain); + assert(strafe_tap_event_mask() == UINT64_C(0xe0000000)); + assert(strafe_cgs_event_fluid_touch() == 31 && strafe_iohid_event_generic_swipe() == 32); +} + +static void invalid(void) { + const double bad[] = {-1, NAN, INFINITY, -INFINITY, DBL_MAX}; + for (size_t i = 0; i < sizeof(bad) / sizeof(*bad); ++i) + for (int modern = 0; modern < 2; ++modern) + assert(!strafe_create_switch_event(StrafeDirectionRight, bad[i], 1, modern, false)); + assert(!strafe_create_switch_event((StrafeDirection)42, 2000, 1, false, false)); + assert(!strafe_create_switch_event(StrafeDirectionRight, 2000, 3, true, false)); + assert(!strafe_create_switch_event(StrafeDirectionRight, 32768, 1, true, false)); + int32_t fixed; + assert(strafe_fixed(-32768, &fixed) && fixed == INT32_MIN); + assert(strafe_fixed((double)INT32_MAX / 65536, &fixed) && fixed == INT32_MAX); + assert(!strafe_fixed(32768, &fixed) && !strafe_fixed(NAN, &fixed)); + assert(strafe_fixed(-FLT_TRUE_MIN, &fixed) && fixed == -1); + uint8_t payload[96] = {0}; + const uint8_t badWire[][12] = { + {0,0,0,3}, // unsupported version + {0,0,0,2, 0,1,0x80,1}, // reserved tag + {0,0,0,2, 0,0,0,1}, // zero blob size + {0,0,0,2, 0,2,0x40,1}, // int32 size != 1 + {0,0,0,2, 0,3,0xc0,1}, // invalid floating size + {0,0,0,2, 0,1,0,1}, // truncated int64 + {0,0,0,2, 0,1,0x50,0x6d,0,0,0,0} // 4205 is not a blob + }; + for (size_t i = 0; i < sizeof(badWire) / sizeof(*badWire); ++i) { + CFDataRef data = CFDataCreate(NULL, badWire[i], i == 6 ? 12 : 8); + assert(!strafe_replace_payload(data, payload, 68)); + CFRelease(data); + } + const uint8_t duplicate[] = {0,0,0,2, 0,1,0x40,1,0,0,0,0, 0,1,0x40,1,0,0,0,0}; + CFDataRef data = CFDataCreate(NULL, duplicate, sizeof(duplicate)); + assert(!strafe_replace_payload(data, payload, 68)); + CFRelease(data); + const uint8_t shortWire[] = {0,0,0,2,0,1,0x40,1,0,0,0,0}; + for (size_t n = 0; n < sizeof(shortWire); ++n) { + if (n == 4) continue; // an empty v2 field list is valid + data = CFDataCreate(NULL, shortWire, n); + assert(!strafe_replace_payload(data, payload, 68)); + CFRelease(data); + } +} + +static CFDictionaryRef space(int64_t id) { + CFNumberRef number = CFNumberCreate(NULL, kCFNumberSInt64Type, &id); + const void *keys[] = {CFSTR("id64")}, *values[] = {number}; + CFDictionaryRef result = CFDictionaryCreate(NULL, keys, values, 1, + &kCFTypeDictionaryKeyCallBacks, &kCFTypeDictionaryValueCallBacks); + CFRelease(number); + return result; +} + +static void topology(void) { + CFDictionaryRef a = space(42), b = space(99), missing = space(123); + const void *items[] = {a, b}; + CFArrayRef spaces = CFArrayCreate(NULL, items, 2, &kCFTypeArrayCallBacks); + CFMutableDictionaryRef display = CFDictionaryCreateMutable(NULL, 0, + &kCFTypeDictionaryKeyCallBacks, &kCFTypeDictionaryValueCallBacks); + CFDictionarySetValue(display, CFSTR("Display Identifier"), CFSTR("test-display")); + CFDictionarySetValue(display, CFSTR("Spaces"), spaces); + CFDictionarySetValue(display, CFSTR("Current Space"), b); + StrafeInfo info = {0}; + assert(strafe_extract_space_info(display, &info)); + assert(info.currentIndex == 1 && info.currentSpaceID == 99 && info.spaceCount == 2); + assert(!strcmp(info.displayID, "test-display")); + CFDictionarySetValue(display, CFSTR("Current Space"), missing); + assert(!strafe_extract_space_info(display, &info)); + CFDictionarySetValue(display, CFSTR("Current Space"), CFSTR("wrong type")); + assert(!strafe_extract_space_info(display, &info)); + CFDictionarySetValue(display, CFSTR("Current Space"), b); + CFDictionarySetValue(display, CFSTR("Spaces"), b); + assert(!strafe_extract_space_info(display, &info)); + CFArrayRef empty = CFArrayCreate(NULL, NULL, 0, &kCFTypeArrayCallBacks); + CFDictionarySetValue(display, CFSTR("Spaces"), empty); + assert(!strafe_extract_space_info(display, &info)); + assert(!strafe_extract_space_info(CFSTR("wrong type"), &info)); + assert(!strafe_get_space_info_for_display(NULL, NULL)); + CFRelease(empty); CFRelease(display); CFRelease(spaces); + CFRelease(a); CFRelease(b); CFRelease(missing); +} + +int main(void) { + events(); invalid(); topology(); + puts("CStrafe: 32 event round-trips, raw payload/replacement, invalid input/wire and topology tests passed (no posting)."); + return 0; +} diff --git a/Tests/SwitchEngineTests.swift b/Tests/SwitchEngineTests.swift new file mode 100644 index 0000000..baac429 --- /dev/null +++ b/Tests/SwitchEngineTests.swift @@ -0,0 +1,291 @@ +import Foundation +import CoreGraphics +import CStrafe + +// Standalone runner: works with Command Line Tools without XCTest/Xcode. +// Compile alongside SwitchEngine.swift and SwitchDiagnostics.swift. +@main +struct SwitchEngineTests { + final class CompletionWaiter: @unchecked Sendable { + let semaphore = DispatchSemaphore(value: 0) + func fulfill() { semaphore.signal() } + func wait() { precondition(semaphore.wait(timeout: .now() + 3) == .success, "Completion timed out") } + } + + static func main() throws { + let tests = SwitchEngineTests() + try tests.testConfigurationDefaultsAndInvalidValues() + try tests.testIgnoredPostsTimeoutDropQueueAndRecoverFromRealIndex() + try tests.testSequencesArePacedOrderedAndObserveOriginalDisplay() + try tests.testWrongDirectionIsNotReportedAsSuccess() + try tests.testBuilderFailureNeverPostsPartialGesture() + try tests.testTrueEdgeDoesNotPost() + try tests.testMacOS27DirectionAndBothEdges() + try tests.testOverlayBlocksAndCancelsPendingGesture() + try tests.testPhysicalGestureMappingAndPassthrough() + SwitchDiagnostics.flush() + print("Swift: 9 configuration/engine/interceptor tests passed (mock delivery; no events posted).") + } + private final class Desktop: @unchecked Sendable { + let lock = NSLock() + var index: UInt32 = 1 + var moves = false + var reverse = false + var overlay = false + var openOverlayOnBegin = false + var phases: [Int64] = [] + var times: [TimeInterval] = [] + var readDisplays: [String?] = [] + + func read(_ display: String?) -> GestureSwitchEngine.Topology { + lock.lock() + defer { lock.unlock() } + readDisplays.append(display) + return .init(display: "display-A", index: index, count: 4, id: UInt64(index) + 10) + } + + func post(_ event: CGEvent) { + lock.lock() + defer { lock.unlock() } + let phase = strafe_event_gesture_phase(event) + phases.append(phase) + times.append(ProcessInfo.processInfo.systemUptime) + if openOverlayOnBegin && phase == 1 { overlay = true } + if moves && phase == 4 { + let right = (strafe_event_swipe_progress(event) > 0) != reverse + index = right ? index + 1 : index - 1 + } + } + + func enableMoves() { + lock.lock() + moves = true + lock.unlock() + } + + func snapshot() -> (UInt32, [Int64], [TimeInterval], [String?]) { + lock.lock() + defer { lock.unlock() } + return (index, phases, times, readDisplays) + } + + var dependencies: GestureSwitchEngine.Dependencies { + .init(read: { self.read($0) }, post: { self.post($0) }, overlayActive: { + self.lock.lock() + defer { self.lock.unlock() } + return self.overlay + }) + } + } + + private func config(gap: String = "0") throws -> SwitchConfiguration { + try .load(environment: ["STRAFE_EVENT_PROFILE": "legacy", "STRAFE_PHASE_GAP_MS": gap], osMajorVersion: 27) + } + + func testConfigurationDefaultsAndInvalidValues() throws { + let modern = try SwitchConfiguration.load(environment: [:], osMajorVersion: 27) + precondition(modern.augmented && modern.phaseGapMS == 10 && modern.inverted && modern.invertSwipeDirection) + let legacy = try SwitchConfiguration.load(environment: [:], osMajorVersion: 26) + let forced = try config() + precondition(!legacy.augmented && !forced.augmented) + for (key, value) in [("STRAFE_EVENT_PROFILE", "bad"), ("STRAFE_PHASE_GAP_MS", "nan"), + ("STRAFE_PHASE_GAP_MS", "-1"), ("STRAFE_PHASE_GAP_MS", "101"), + ("STRAFE_INVERT_DIRECTION", "yes"), ("STRAFE_DIAGNOSTICS", "true"), + ("STRAFE_INVERT_SWIPE_DIRECTION", "yes"), ("STRAFE_INTERCEPT_SWIPES", "yes")] { + do { + _ = try SwitchConfiguration.load(environment: [key: value]) + fatalError("Invalid configuration accepted: \(key)=\(value)") + } catch is SwitchConfiguration.Invalid { } + } + } + + func testIgnoredPostsTimeoutDropQueueAndRecoverFromRealIndex() throws { + let desktop = Desktop() + let engine = GestureSwitchEngine(configuration: try config(), dependencies: desktop.dependencies) + let failed = CompletionWaiter() + let dropped = CompletionWaiter() + let fallback = CompletionWaiter() + engine.setDeliveryFailureHandler { error in + guard case .observationTimedOut = error else { fatalError("Unexpected fallback: \(error)") } + fallback.fulfill() + } + try engine.switchSpace(.left) { result in + guard case .failure(.observationTimedOut) = result else { fatalError("Expected timeout: \(result)") } + failed.fulfill() + } + try engine.switchSpace(.right) { result in + guard case .failure(.pendingDropped) = result else { fatalError("Expected pending drop: \(result)") } + dropped.fulfill() + } + failed.wait() + dropped.wait() + fallback.wait() + precondition(desktop.snapshot().0 == 1) + precondition(desktop.snapshot().1 == [1, 2, 4]) + desktop.enableMoves() + let recovered = CompletionWaiter() + try engine.switchSpace(.left) { result in + guard case .success = result else { fatalError("Expected recovery: \(result)") } + recovered.fulfill() + } + recovered.wait() + precondition(desktop.snapshot().0 == 0) + } + + func testSequencesArePacedOrderedAndObserveOriginalDisplay() throws { + let desktop = Desktop() + desktop.enableMoves() + let engine = GestureSwitchEngine(configuration: try config(gap: "10"), dependencies: desktop.dependencies) + let finished = CompletionWaiter() + for direction in [SwitchDirection.right, .left] { + try engine.switchSpace(direction) { result in + guard case .success = result else { fatalError("Expected success: \(result)") } + finished.fulfill() + } + } + engine.resetPredictions() // Workspace notifications must not cancel work. + finished.wait() + finished.wait() + let (index, phases, times, displays) = desktop.snapshot() + precondition(index == 1) + precondition(phases == [1, 2, 4, 1, 2, 4]) + for i in [1, 2, 4, 5] { precondition(times[i] - times[i - 1] >= 0.009) } + precondition(displays.contains { $0 == "display-A" }) + precondition(displays.allSatisfy { $0 == nil || $0 == "display-A" }) + } + + func testWrongDirectionIsNotReportedAsSuccess() throws { + let desktop = Desktop() + desktop.reverse = true + desktop.enableMoves() + let engine = GestureSwitchEngine(configuration: try config(), dependencies: desktop.dependencies) + let finished = CompletionWaiter() + try engine.switchSpace(.right) { result in + guard case .failure(.unexpectedChange) = result else { fatalError("Expected mismatch: \(result)") } + finished.fulfill() + } + finished.wait() + } + + func testBuilderFailureNeverPostsPartialGesture() throws { + let desktop = Desktop() + var dependencies = desktop.dependencies + dependencies.build = { direction, velocity, phase, augmented, inverted in + guard phase != 4 else { return nil } + return strafe_create_switch_event(direction.cDirection, velocity, phase, augmented, inverted) + } + let engine = GestureSwitchEngine(configuration: try config(), dependencies: dependencies) + let finished = CompletionWaiter() + try engine.switchSpace(.right) { result in + guard case .failure(.postFailed) = result else { fatalError("Expected construction failure: \(result)") } + finished.fulfill() + } + finished.wait() + precondition(desktop.snapshot().1.isEmpty) + } + + func testTrueEdgeDoesNotPost() throws { + let desktop = Desktop() + desktop.index = 0 + let engine = GestureSwitchEngine(configuration: try config(), dependencies: desktop.dependencies) + let finished = CompletionWaiter() + try engine.switchSpace(.left) { result in + guard case .failure(.atEdge) = result else { fatalError("Expected edge: \(result)") } + finished.fulfill() + } + finished.wait() + precondition(desktop.snapshot().1.isEmpty) + } + + func testMacOS27DirectionAndBothEdges() throws { + let desktop = Desktop() + desktop.reverse = true // Observed OS27 behavior: negative output moves right. + desktop.enableMoves() + let config = try SwitchConfiguration.load(environment: [:], osMajorVersion: 27) + let engine = GestureSwitchEngine(configuration: config, dependencies: desktop.dependencies) + // Reproduce walking to both boundaries and then returning inward. + for (direction, expectedIndex) in [(SwitchDirection.left, UInt32(0)), (.right, 1), + (.right, 2), (.right, 3), (.left, 2)] { + let finished = CompletionWaiter() + try engine.switchSpace(direction) { result in + guard case .success = result else { fatalError("OS27 direction failed: \(result)") } + finished.fulfill() + } + finished.wait() + precondition(desktop.snapshot().0 == expectedIndex) + } + } + + func testOverlayBlocksAndCancelsPendingGesture() throws { + for alreadyOpen in [true, false] { + let desktop = Desktop() + desktop.overlay = alreadyOpen + desktop.openOverlayOnBegin = !alreadyOpen + let engine = GestureSwitchEngine(configuration: try config(gap: "10"), dependencies: desktop.dependencies) + let finished = CompletionWaiter() + try engine.switchSpace(.right) { result in + guard case .failure(.overlayActive) = result else { fatalError("Overlay not respected: \(result)") } + finished.fulfill() + } + finished.wait() + precondition(desktop.snapshot().1 == (alreadyOpen ? [] : [1, 8])) + } + } + + private final class GestureSink: SwitchEngine { + var directions: [SwitchDirection] = [] + func switchSpace(_ direction: SwitchDirection) throws { directions.append(direction) } + } + + func testPhysicalGestureMappingAndPassthrough() throws { + let sink = GestureSink() + let config = try SwitchConfiguration.load(environment: [:], osMajorVersion: 27) + let interceptor = SwipeInterceptor(engine: sink, configuration: config, + overlayActive: { false }, overlaySnapshot: { false }) + func event(_ phase: Int64, right: Bool = false, generic: Bool = false, vertical: Bool = false) -> CGEvent { + // A separate source avoids inheriting the builder's source marker. + let result = CGEvent(source: CGEventSource(stateID: .privateState))! + result.setIntegerValueField(.eventSourceUnixProcessID, value: 0) + result.setIntegerValueField(CGEventField(rawValue: 55)!, value: 30) + result.setIntegerValueField(CGEventField(rawValue: 110)!, value: 23) + result.setIntegerValueField(CGEventField(rawValue: 123)!, value: 1) + result.setIntegerValueField(CGEventField(rawValue: 132)!, value: phase) + result.setDoubleValueField(CGEventField(rawValue: 124)!, value: right ? 0.1 : -0.1) + result.setDoubleValueField(CGEventField(rawValue: 129)!, value: right ? 1 : -1) + if generic { + result.setIntegerValueField(CGEventField(rawValue: 55)!, value: 29) + result.setIntegerValueField(CGEventField(rawValue: 110)!, value: 32) + result.setDoubleValueField(CGEventField(rawValue: 119)!, value: 0.25) + } + if vertical { result.setIntegerValueField(CGEventField(rawValue: 123)!, value: 2) } + return result + } + // Negative physical progress means the next workspace on this host. + for phase: Int64 in [1, 1, 2, 2, 4] { + let sample = event(phase) + precondition(interceptor.handle(type: CGEventType(rawValue: 30)!, event: sample) == nil, + "phase=\(phase) pid=\(strafe_event_source_pid(sample)) marker=\(strafe_event_is_strafe(sample)) type=\(strafe_event_cgs_type(sample)) hid=\(strafe_event_hid_type(sample)) axis=\(strafe_event_swipe_motion(sample))") + } + precondition(sink.directions == [.right]) + for phase: Int64 in [1, 2, 4] { + precondition(interceptor.handle(type: CGEventType(rawValue: 30)!, event: event(phase, right: true)) == nil) + } + precondition(sink.directions == [.right, .left]) + // Generic HID32 and vertical Dock gestures remain native, even while + // Strafe owns a horizontal swipe. They must not drive or clear its latch. + _ = interceptor.handle(type: CGEventType(rawValue: 30)!, event: event(1)) + for phase: Int64 in [1, 2, 4] { + precondition(interceptor.handle(type: CGEventType(rawValue: 29)!, event: event(phase, generic: true)) != nil) + precondition(interceptor.handle(type: CGEventType(rawValue: 30)!, event: event(phase, vertical: true)) != nil) + } + _ = interceptor.handle(type: CGEventType(rawValue: 30)!, event: event(2)) + _ = interceptor.handle(type: CGEventType(rawValue: 30)!, event: event(4)) + precondition(sink.directions == [.right, .left, .right]) + let overlay = SwipeInterceptor(engine: sink, configuration: config, + overlayActive: { true }, overlaySnapshot: { false }) + for phase: Int64 in [1, 2, 4] { + precondition(overlay.handle(type: CGEventType(rawValue: 30)!, event: event(phase)) != nil) + } + precondition(sink.directions.count == 3) + } +} diff --git a/docs/AGENT-HANDOFF.md b/docs/AGENT-HANDOFF.md new file mode 100644 index 0000000..8d2381b --- /dev/null +++ b/docs/AGENT-HANDOFF.md @@ -0,0 +1,351 @@ +# Strafe macOS 27 repair — agent handoff + +## Resume here + +**Update 2026-09-15:** second diagnostic build hotkey test **passed**. +`/tmp/strafe-macos27-v2.log` shows ~17/17 observed transitions correct in both +directions plus correct atEdge at index0 and index3, no timeouts/unexpected. +Startup was `inverted=true invertSwipeDirection=true interceptSwipes=false`. +User confirms hotkeys correct; trackpad swipes feel "disabled" = running at +native macOS speed. That is expected: this run forced `STRAFE_INTERCEPT_SWIPES=0`, +and the log's 64 candidate lines all show `override=false` passthrough. +Next: test with interception **enabled** (default). See command below. + +```bash +STRAFE_DIAGNOSTICS=1 STRAFE_INTERCEPT_SWIPES=0 \ + ~/strafe/build/strafe.app/Contents/MacOS/strafe \ + 2>&1 | tee /tmp/strafe-macos27-v2.log +``` + +From workspace 2, Control + Option + Left should go to workspace 1; after a +second, Control + Option + Right should return to workspace 2. Both should feel +instant. Leave interception disabled for this first test. + +**Do not replace `/Applications/strafe.app` yet.** That remains the original app. +The new build is at `/Users/ozairkhan/strafe/build/strafe.app`. + +## User intent and interaction constraints + +- Restore instant **native macOS Spaces** switching after upgrading from macOS + 26 to 27, including trackpad gestures, fullscreen Spaces, correct boundaries, + and Mission Control passthrough. +- User initially explicitly authorized delegation. Later clarified: subagents + must be **GPT Sol, not Astra**. The available `task` tool did not expose model + selection or IDs; we told the user we would stop spawning subagents without + that control. Respect this constraint. Do not silently use inherited agents. +- User's usage budget is nearly exhausted; this document is for another agent + to continue efficiently. Avoid repeating the research or dumping huge logs. +- User quits the app between tests. We incorrectly inferred that an absent + process explained failed hotkeys; user corrected us. A `pgrep` snapshot after + a test says nothing about whether it was running during the test. +- Keep updates concise. Do not declare the repair complete based on build/tests. +- No commits, pushes, PRs, or installation were requested/performed. + +## Environment and working tree + +- Repo: `/Users/ozairkhan/strafe`, HEAD at investigation start: `31a240e`. +- OS: macOS **27.0 (26A428)**, arm64, SIP enabled. +- Command Line Tools, SDK 26.5, Swift 6.3.3. Full Xcode/XCTest/Swift Testing modules + are not available in this environment. `swift test` failed for missing XCTest; + use the standalone test script described below. +- User had four workspaces and was usually on workspace 2. Latest read-only + topology snapshot: display `37D8832A-2D66-02CA-B9F7-8F30A301B230`, zero-based + index1, count4, current Space ID3. +- Repo was initially clean. All current application changes and added documents + are this session's uncommitted work. Still inspect status before modifying. +- No app was running at the last process check before the second-build edits. + User may have launched it since. + +## What was established before implementing + +Original Strafe intercepted gesture types29/30, recognized type30/HID23 with +motion123=1, required PID0, and read progress124. It synthesized three numeric +CGEvents (Began/Changed/Ended) at velocity2000 and ±FLT_TRUE_MIN, back-to-back. + +The user demonstrated that both hotkeys did nothing but the internal prediction +counter advanced until `atEdge`. Hotkeys definitely reached the engine. The +running app had an enabled active gesture tap; TCC logs explicitly allowed +Accessibility, event listening and posting. CGS symbols still resolved. This +was not explained by missing hotkey registration or denied permissions. + +`CGEventPost` returns void. The original engine treated successful event creation +as a successful switch and poisoned its optimistic prediction when OS ignored it. + +Deep research is in `docs/MACOS-27-RESEARCH.md`. Key primary sources: + +- ISS issue72 payload findings: + https://github.com/jurplel/InstantSpaceSwitcher/issues/72#issuecomment-4663746037 +- Pinned serializer/output branch: + https://github.com/jurplel/InstantSpaceSwitcher/tree/bf32cf9732c706119e8307d2d70ae795b2b11c1c +- PR88 phase pacing and CLI lifetime: + https://github.com/jurplel/InstantSpaceSwitcher/pull/88 +- `26A428` success report: + https://github.com/jurplel/InstantSpaceSwitcher/pull/88#issuecomment-5644946567 +- PR77 input investigation, pinned `4602184328ec13b01e52e4456ad6ec3239df5152`: + https://github.com/jurplel/InstantSpaceSwitcher/pull/77 +- Conflicting upstream direction/lifetime report: + https://github.com/jurplel/InstantSpaceSwitcher/issues/72#issuecomment-5277119315 +- Dock AX overlay notifications reference: + https://github.com/asmvik/yabai/blob/master/src/mission_control.c + +macOS27 output can need an embedded serialized IOHID payload in field4205. +Outer CGEvent data uses a v2 prefix and big-endian field headers; payload is packed +little-endian, with signed16.16 progress and velocity. The minimal nonzero progress +is 1/65536, not FLT_TRUE_MIN. Two 10ms phase gaps have working reports. Related +projects disagree about output inversion, and some report legacy output still +working on release27. These are empirical private contracts, not Apple guarantees. + +## First diagnostic build: real user findings + +First build defaulted to augmented output, 10ms gaps, **inverted=false**, and +trackpad interception enabled (including generic29/HID32). + +User reported: + +1. First Left hotkey moved **right instantly**. +2. Subsequent navigation got worse, especially at the left edge; had to force quit. +3. It consumed unexpected actions inside Mission Control. +4. On a restart, a rightward trackpad gesture did move right, but edge behavior + eventually failed again. + +The available `/tmp/strafe-macos27.log` was overwritten by the later launch and +contains the trackpad session, not necessarily the initial keyboard session. +It has decisive evidence: + +```text +direction=left live index=1 target=0 -> actual index=2 unexpectedChange +direction=right live index=2 target=3 -> actual index=1 unexpectedChange +direction=right live index=1 target=2 -> actual index=0 unexpectedChange +direction=left live index=0 target=edge -> atEdge +direction=right live index=0 target=1 -> posted but timeout at index0 +``` + +Normal physical desktop swipes on **this machine** were type30/HID23, PID0, +motion1, progress124. Negative progress requested logical left in that build, +but the uninverted synthetic output moved right. Thus blindly fixing output +alone would also reverse the physical behavior that the user said felt correct. +The second build fixes **input and output mappings separately**. + +The log also had vertical motion2 gestures, which were passed through, and later +generic29/HID32 events during the user's navigation/overlay experiments. We cannot +prove those generic events were all horizontal Space swipes. The second build +no longer intercepts them. Do not blindly re-enable PR77's broad HID32 rule. + +## Current implementation (second diagnostic build) + +### C event builder and topology + +`Sources/CStrafe/CStrafe.c`, `include/CStrafe.h`, new `EventSerialization.h`: + +- `strafe_create_switch_event(direction, velocity, phase, augmented, inverted)` + returns a retained single CGEvent, without posting or sleeping. +- Augmented output uses numeric type30/HID23, motion1, progress magnitude0.000016, + metadata fields134/125/138/169, Ended-only velocityX, plus embedded field4205. +- Payload: queue header28 bytes + fluid gesture40 + optional velocity child28; + total68/96 bytes. Checked endian writes, fixed-point finite/range validation, + v2 field validation, replacement of an existing4205, no silent fallback. +- Each built event gets own PID and a Strafe user-data marker. **On this host, + CGEvent serialization omits source user data**, so the builder stamps it again + after deserialization. Tests also found clearing user data on an event from + the marked source can still read the source marker; physical test fixtures + therefore use a fresh private CGEventSource. Actual delivered marker preservation + is not independently proven; own-PID filtering is also retained. +- `strafe_post_switch_gesture()` remains a synchronous **legacy-only** triplet + used by the old benchmark. It does not measure the new app path. +- Mask includes29/30/31, no keyboard events. +- Strict topology CF-type checks reject unknown current IDs/empty topology + instead of silently mapping them to index0. +- `StrafeInfo` adds `currentSpaceID`. +- `strafe_get_space_info_for_display()` observes the exact original display. + +### Async engine and configuration + +`Sources/strafe/SwitchEngine.swift`, new `SwitchDiagnostics.swift`: + +- Serial request queue capped at16 including active. Whole event triplets cannot + interleave. Phases are scheduled asynchronously, not slept inside the tap. +- Reads real topology at execution; **no optimistic predictions remain**. +- Prebuilds all three events to avoid partial allocation failures, then posts them. +- Checks for a new Space ID at the expected adjacent index on the original + display, stable100ms, within750ms after Ended. Polls every25ms. +- Missing topology, wrong destination, construction failure, timeout drops queued + requests. New requests start from real state. Boundaries are real live checks. +- `resetPredictions()` is now a compatibility no-op. Notifications do not cancel + valid in-flight work. +- Completion distinguishes enqueue/post/observed transition. Logs explicitly say + `inputUnlock=unverified`; topology observation is not latency measurement. +- CLI pumps the main loop until completion, with a2-second outer deadline. +- A delivery-failure callback disables trackpad interception on unexpectedChange, + observationTimedOut, postFailed, topologyUnavailable. Normal atEdge or overlay + activity does not disable it. Hotkeys remain usable. + +Environment configuration (strictly parsed at startup): + +| Variable | Current default / values | +|---|---| +| `STRAFE_EVENT_PROFILE` | `auto`; resolves augmented on27+, legacy earlier. Also `legacy`, `macos27` | +| `STRAFE_PHASE_GAP_MS` | 10 for augmented, 0 legacy; explicit0–100 | +| `STRAFE_INVERT_DIRECTION` | **1 for augmented**, 0 legacy; explicit0/1 overrides | +| `STRAFE_INVERT_SWIPE_DIRECTION` | **1 on27+**, 0 earlier; independent of output profile | +| `STRAFE_INTERCEPT_SWIPES` | 1; use0 for the next hotkey-only test | +| `STRAFE_DIAGNOSTICS` | 0; use1 for logs | + +Input and output defaults were changed after the first live test. Verify startup +logs show `inverted=true invertSwipeDirection=true` for the revised build. + +### Swipe interceptor + +`Sources/strafe/SwipeInterceptor.swift`: + +- Only recognized horizontal HID23 type30 or31 streams are currently intercepted. +- **Generic29/HID32 is diagnostic-only passthrough**, even while owning a swipe. +- Type30 keeps nonzero-PID passthrough; own PID/marker bypass all gesture handling. +- On27+, negative physical progress maps to logical **right**, positive to left. + This is separate from the synthetic builder's output inversion. +- Tracks owning gesture family; duplicate Began cannot reset/fire twice; + companion endings cannot clear owner's latch. +- Resets after500ms idle between owned events, on toggling, tap disable, teardown. +- Vertical/unknown gestures pass through. +- Window-layer overlay scan only on Began (repeating on every Changed caused + unacceptable potential tap overhead during review). Cached AX overlay state + is cheap to check on every candidate. +- `isRunning` queries `CGEvent.tapIsEnabled` rather than an optimistic flag. +- Candidate diagnostics capped at64 samples. Handler admission errors capped8. + +### Mission Control protection (new in second build) + +New `Sources/strafe/MissionControlMonitor.swift`: + +- Shared lock-protected overlay state, AX observer/runloop setup on main thread. +- Observes Dock's `AXExposeShowAllWindows`, `AXExposeShowFrontWindows`, + `AXExposeShowDesktop`, `AXExposeExit`. +- Reattaches if Dock relaunches. Uses old layer heuristic as initial snapshot. +- Local read-only check successfully registered **4/4 notifications**, initial + active=false. **Actual notification delivery during Mission Control is untested**. +- Interceptor passes through when active. Engine rejects queued switches while + active; if it opens between synthetic phases, posts Cancelled before dropping + the request. Polling aborts if an overlay opens. +- Monitor is started/stopped for GUI and CLI switching/status. +- If notifications fail to arrive, MC protection may still need work. A successful + registration is not proof of delivery; starting while MC is already open still + depends on the legacy snapshot heuristic. + +### Other changes + +- `main.swift`: validated config, CLI completion waiting, monitor lifecycle, + delivery-failure hook, startup accessibility/tap status, fixed delegate lifetime + and retained workspace observer token. +- `HotkeyManager.swift`: registration/handler errors use deferred diagnostics. +- `Permissions.swift`: CLI tap status explicitly says it is not resident status. +- `status` now prints live topology and overlay state. +- `Scripts/bundle.sh`: includes license files in bundle Resources. +- Added `THIRD-PARTY-LICENSES.txt`, `LICENSE-FasterSwiper.txt` for MIT ISS and + Apache-2.0 FasterSwiper-derived code. Preserve these notices. +- README/SECURITY/research report updated with diagnostic behavior and caveats. + +## Verification already completed + +Latest command, after second-build fixes: + +```bash +TMPDIR=/var/folders/16/swy3r08s34q762v41d7tyqb00000gn/T/opencode \ + bash Scripts/test.sh +bash Scripts/bundle.sh +codesign --verify --strict --verbose=2 build/strafe.app +git diff --check +``` + +All passed. Do not needlessly repeat before any new code change. + +Tests: + +- `Tests/CStrafeTests.c`:32 event round-trips, raw payload layout/replacement, + marker/copy checks, malformed formats, invalid inputs/fixed-point limits, topology + fixtures. Compiled with AddressSanitizer/UBSan, no findings. +- `Tests/SwitchEngineTests.swift`: standalone runner compiled alongside engine, + diagnostics, monitor, interceptor with mock event delivery. **No events posted.** + Nine tests cover config, timeout/pending-drop/failure-callback/recovery, ordered + pacing/fixed-display observation, wrong destination, construction failure before + any posting, true boundary, observed27 sign convention with both edges and + inward movement, overlay rejection/cancellation, physical direction and + generic/vertical/overlay passthrough. +- Tests use a fresh private CGEventSource for physical fixtures to avoid inheriting + the synthetic marker; an earlier fixture incorrectly reused the marked source + and failed before this correction. +- `Scripts/test.sh` works without XCTest. `Package.swift` has no net changes; + the briefly attempted XCTest target was removed. +- Signed release artifact valid. Last bundle size after stripping:155352 bytes. + +No automated live switching was performed by the agent. All reported real +switches came from the user testing the **first** diagnostic build. + +## Next work, in order + +1. **Get second-build hotkey results.** Read `/tmp/strafe-macos27-v2.log`. Confirm + revised startup flags, phase posts, correct observed destination, latency as + perceived by user. Logs use **zero-based** indices. +2. If directions are now correct, test both boundaries and inward return before + introducing trackpad interception. Do not mistake real atEdge for a failure. +3. Check Mission Control entry/exit logs (`overlay active=... notification=...`) + while interception is still off. Normal gestures should remain native. If AX + notifications are absent, investigate before enabling broad interception. +4. Enable interception via Strafe's menu **Enable** (no restart needed), then + test physical directions, boundaries, cancellation and Mission Control. If a + failure disables interception, the log says so; subsequent native animation + is expected, not proof that the patched fast path slowed down. +5. If sign mapping differs with natural scrolling/settings, use the independent + input/output environment switches to isolate it. Never change topology index + meaning to compensate for a raw gesture sign. +6. Once correct and stable, tune rapid switching and measure actual input unlock. + The current100ms confirmation stability deliberately slows queued requests; + this is a diagnostic policy, not the final performance goal. +7. Update/repair `bench/` before using it for new-path performance claims. Its + strafe mode still calls legacy synchronous C posting, and native benchmark + setup itself uses that legacy poster to place windows on Spaces. +8. Install only once user-tested. A moved/rebuilt ad-hoc app may need its + Accessibility entry re-granted; do not reset permissions speculatively. + +## Remaining risks worth keeping in mind + +- All private payload conventions, AX notification behavior and direction mappings + are empirical. We have demonstrated payload acceptance/instant movement, not + complete correctness of the revised build. +- Current input sign default is based on this user's data; natural-scrolling + interpretation may vary. Generic HID32 remains intentionally unowned. +- Overlay startup snapshot is still an old heuristic. Successful AX registration + has been verified, but no live enter/exit capture yet. +- Async phases are prebuilt, so embedded timestamps are created close together, + despite actual posting gaps. First build did switch instantly, but investigate + timestamp refresh if pacing still behaves inconsistently; don't alter payload + that already works without evidence. +- Cursor routing can change between phases. Engine rechecks before Began and + observes original display; it cannot explicitly direct the OS to a display. +-500ms idle gesture recovery is a heuristic; test slow held swipes if needed. +- Fallback disables interception after delivery failure, but cannot replay an + already suppressed physical Began. Later fresh swipes are native. +- The new serializer header lives under Sources/CStrafe and is used internally; + standalone C tests include implementation to exercise private parser helpers. + +## Useful commands + +```bash +# Inspect state without switching. +git status --short +pgrep -fl strafe +STRAFE_DIAGNOSTICS=1 ~/strafe/build/strafe.app/Contents/MacOS/strafe status + +# First revised test: hotkeys only, native trackpad still available. +STRAFE_DIAGNOSTICS=1 STRAFE_INTERCEPT_SWIPES=0 \ + ~/strafe/build/strafe.app/Contents/MacOS/strafe \ + 2>&1 | tee /tmp/strafe-macos27-v2.log + +# Non-posting tests / rebuild after changes. +bash Scripts/test.sh +bash Scripts/bundle.sh +codesign --verify --strict --verbose=2 build/strafe.app +``` + +If an app launched through Finder has no useful errors, its stdout/stderr likely +point to `/dev/null`; system logs do not recover those messages. The foreground +Terminal launch above captures them. Do not run a second resident copy while +testing hotkeys or gesture taps. diff --git a/docs/MACOS-27-RESEARCH.md b/docs/MACOS-27-RESEARCH.md new file mode 100644 index 0000000..2221a0c --- /dev/null +++ b/docs/MACOS-27-RESEARCH.md @@ -0,0 +1,240 @@ +# macOS 27 compatibility investigation + +Research date: September 14, 2026. Checkout inspected: `31a240e`. +Local host: macOS **27.0 (26A428)**, arm64, SIP enabled. + +## Conclusion + +Strafe depends on undocumented gesture representations that changed in macOS 27. +Two independently actionable incompatibilities have been reported upstream: + +1. **Incoming physical swipes:** a capture on this exact OS build found generic + event type **29**, HID subtype **32**, progress field **119**, and potentially + non-kernel source PIDs. Strafe only initiates switching from type **30**, + HID subtype **23**, progress field **124**, with source PID zero. +2. **Outgoing synthetic swipes:** macOS 27 investigations found consumers requiring + an embedded IOHID payload, added through CGEvent serialization. Strafe sets + only the older numeric fields. A compatibility implementation exists, with + successful switching reports on `26A428`. + +These are not necessarily both active on every setup. In particular, the latest +physical-input investigation reports legacy numeric synthetic switching still +working on `26A428`. Do not infer that serialization is universally mandatory, +or that fixing output alone will restore physical swiping. + +Instant-style switching remains plausible and has positive upstream reports. +Restoring Strafe's measured macOS 26 latency on this machine is **not yet verified**. +No live gestures were captured, no events posted, and no application code changed +during this investigation. + +## What this checkout does + +| Component | Current implementation | Compatibility risk | +|---|---|---| +| `Sources/CStrafe/CStrafe.c:64–91` | Three session-tap CGEvents: began/changed/ended, no gaps | Different representation or timing requirements | +| Same | Fields 55=30, 110=23, 123=1, 124=±FLT_TRUE_MIN, 129/130=±velocity, 132=phase | No embedded IOHID payload; extremely small progress | +| `Sources/strafe/SwitchEngine.swift:53–109` | Velocity 2000; topology bounds and optimistic target prediction | Posting success is not transition confirmation | +| `Sources/strafe/SwipeInterceptor.swift:38–79` | Event mask includes only 29 and 30 | Type31 cannot arrive at this tap | +| Same, `138–187` | Reject nonzero PID; treat type29 as companion; recognize type30/HID23 | Newly observed physical path passes through | +| `Sources/CStrafe/CStrafe.c:277–308` | Dock window layers 18/20 identify Mission Control/Exposé | Empirical overlay heuristic may require revalidation | +| `Sources/strafe/main.swift:104–109` | Reset predictions on Space-change notification | Helps actual changes, but not ignored requests | + +Private CGS functions read Space topology; Strafe does not inject a scripting +addition into Dock or invoke a direct private Space-switch operation. + +## Evidence and strength + +### Physical-input change: particularly relevant to this host + +[InstantSpaceSwitcher PR77][pr77], revision `4602184`, reports on macOS +`27.0 (26A428)`: + +> generic type `29`, HID type `32`, with gesture phase in field `132` and +> fractional progress in field `119` + +It also reports that macOS 27 gestures can have non-kernel PIDs. Its patch handles +types 29/30/31 and retains the kernel-PID restriction only for legacy type30. +Type31/HID23 uses the legacy horizontal motion/progress fields. Type29/HID32 is +the directly captured path; type31 has weaker hardware-validation evidence. + +The PR reports a consumed synthetic type29/HID32 test, working menu switching, +and a physical capture. Those do not establish Strafe's end-to-end swipe latency. +The PR remains **open/unmerged**, and includes unrelated speed/defaults/process +management changes that should not be ported wholesale. + +### Output representation: concrete workaround, mixed applicability + +[ISS issue72][payload-report] documents both ISS and FasterSwiper breaking on +macOS 27, and describes the workaround: + +1. Create and populate a CGEvent. +2. Serialize with `CGEventCreateData()`. +3. Insert an embedded IOHID queue/gesture payload under serialized field **4205**. +4. Recreate with `CGEventCreateFromData()` and post normally. + +[Inspectable C implementation][serializer] exists on ISS's `macos-27` branch. +Its embedded progress/velocity use signed **16.16 fixed point**. `FLT_TRUE_MIN` +would truncate to zero; the implementation preserves a nonzero sign and uses +progress `0.000016`, encoded as one fixed-point unit (1/65536). + +[PR88][pr88] adds 10 ms between phases and a 0.3-second CLI run-loop pump. +[A September 12 report][release-test] confirms left/right switching and the app +working on **26A428**. The PR remains **open/unmerged**. + +However, [another report][derivative-report] required a resident process despite +the pump, and removed the branch's direction inversion. It reports 20/20 correct +switches and 51–66 ms timing in a derivative implementation on a beta. This is +encouraging, but is not a Strafe benchmark or a guarantee for the release build. + +[A yabai fork][yabai-port] also ports the event-serialization fix. This is useful +cross-project corroboration, although it shares the same implementation lineage. + +### Local binary inspection: what did NOT simply disappear + +Read-only inspection of the installed CoreGraphics/SkyLight/Dock binaries found: + +- CoreGraphics still re-exports all four topology symbols used here: + `CGSMainConnectionID`, `CGSGetActiveSpace`, `CGSCopyManagedDisplaySpaces`, + `CGSCopyActiveMenuBarDisplayIdentifier`. +- SkyLight still implements setters for fields 55/110/123/124/129/130/132. + Progress and velocity are converted from double to **float** internally. +- Dock's `-[DOCKGestures handleDockControlEvent:]` still checks field110=23, + reads field132, and dispatches fluid-gesture start/progress/end. +- Dock also calls WindowManager's `SpaceSwapSystemGesture` APIs, whose trackpad + information exposes progress, velocityX, and natural-scrolling state. + +This supports coexistence of gesture paths rather than wholesale removal of the +legacy constants. It does not prove which path handles this machine's events, +or when each path was introduced: no macOS 26 binary baseline was available. + +Apple's [macOS 27 release notes][apple-notes] mention Dock/Mission Control fixes, +but do not document the field4205 or physical-event changes. The precise private +protocol findings come from developer investigations, not an Apple API contract. + +## Recommended changes + +### 1. Add macOS 27 input recognition independently of output selection + +In `CStrafe.c`, its public header, and `SwipeInterceptor.swift`: + +- Extend the gesture-only event mask to bits **29/30/31** (`0xe0000000`). +- Decode legacy 30/HID23, observed 31/HID23, and new 29/HID32 separately. +- Read progress119 for 29/HID32; preserve progress124 and horizontal motion123 + handling for the Dock-swipe paths. +- Do not reject every nonzero PID on the new input paths. +- Mark Strafe's synthetic events with `kCGEventSourceUserData` and ignore that + marker before state-machine handling; retain an own-PID guard as appropriate. + Verify the marker survives serialization and actual delivery. +- Avoid duplicate firing when companion representations describe one gesture. +- Validate vertical swipes, Mission Control/Exposé, cancellation and reversal: + PR77 does not prove every 29/HID32 event is a horizontal Space swipe. + +### 2. Introduce a reviewed macOS 27 output profile + +Keep numeric and augmented output selectable during diagnosis. If raw legacy +output already works on this host, first establish whether the input patch alone +restores the desired behavior. + +For augmented output, adapt the pinned ISS serializer and matching event builder, +not just one field from it. Important implementation constraints: + +- Outer CGEvent format has a version prefix and big-endian field headers; + embedded IOHID records use packed little-endian layouts on this architecture. +- Queue header is **28 bytes**, fluid gesture **40 bytes**, optional velocity + child **28 bytes**: payload sizes are **68/96 bytes**, not padded structures. +- Field4205 stores the blob length in **bytes**. Validate field parsing, replace + an existing payload rather than blindly duplicating it, and reject unexpected + serialization formats. +- Fluid type remains **23**, output CGEvent type remains **30**. Type31 is an + incoming-event compatibility path, not a replacement output type. +- Encode finite, range-checked 16.16 values; preserve nonzero progress sign. +- The upstream augmented builder also changes phase/position metadata and uses + X velocity at Ended rather than copying Strafe's X/Y velocity into every phase. +- Treat direction inversion as unresolved. Test direction separately from + topology bounds and physical-input sign; do not blindly invert the engine. +- Select using product OS version, not the `26A` build-number prefix. +- Preserve relevant MIT/Apache-2.0 notices when adapting upstream code, including + FasterSwiper-derived material. + +### 3. Schedule paced events without blocking interception + +Strafe calls the engine synchronously inside its main-runloop tap callback. +Copying PR88's `usleep(10000)` there would block input interception. + +Separate event construction from sequence scheduling. Enqueue a complete gesture, +return from the callback, and serialize asynchronous phase posting with measured +gaps. Prevent rapid requests from interleaving separate gestures. Two 10 ms gaps +add at least 20 ms before Ended; measure whether this is needed on the release OS. + +For CLI use, keep the run loop alive until scheduled posting completes and allow +bounded observation of transition. A fixed sleep is not proof of delivery. +ISS's CLI creates a tap; Strafe's CLI does not, so its exact lifetime failure +explanation must not be transferred without testing. + +### 4. Reconcile prediction and expose meaningful health + +`CGEventPost` returns void. Current success means event allocation succeeded, +yet the engine advances its predicted index. An ignored post can therefore +create an imaginary edge and cause later swipes to be suppressed without moving. + +- Distinguish queued, posted and observed transitions. +- Bound prediction lifetime and reconcile with live topology after failures or + changed Space counts, while retaining rapid-switch support. +- Surface swallowed engine errors through bounded diagnostic counters/logging. +- Validate actual tap enablement with `CGEventTapIsEnabled`. +- Do not use CLI `status` as resident-app health: it currently always supplies + `tapRunning: false` and only checks symbols/trust for the calling process. + +## Validation order + +1. Establish whether physical swipes, resident hotkeys and one-shot CLI differ. + Normal animated swipes with working instant hotkeys strongly favor input decoding. +2. Capture only gesture metadata with interception disabled: type, HID subtype, + PID, phase, motion, progress119/124 and velocities. Test horizontal/vertical. +3. From a middle Space, compare raw legacy output and augmented output independently + of engine bounds/prediction. Test phase pacing separately. +4. Reintroduce interception, verify no self-interception or duplicate moves, then + test rapid reversals, both edges, fullscreen apps, multiple displays and + natural-scrolling direction. +5. Measure visible transition and destination input availability. A correct final + Space ID, passing unit tests or successful event allocation is insufficient. +6. Verify the legacy profile on macOS 26 if a machine is available. + +The existing benchmark can isolate raw synthesis, but its **native-mode setup +also uses Strafe's instant poster** to place windows. If that poster is broken, +native benchmark failure does not show that native-profile synthesis is broken. +Fix/setup-check that dependency before treating benchmark results as evidence. + +## Sources + +### Follow-up: first diagnostic build, local live test + +The first augmented build produced instant workspace changes, confirming payload +acceptance on this host. Its uninverted output had the wrong direction: +`direction=left`, origin index1, target0 landed at index2; positive/right requests +landed one Space left. At index0 this made outward/inward edge handling disagree +with the movement the user intended. The revised output defaults to inversion +for augmented events, separately from the macOS27 physical-progress mapping. + +The captured normal desktop stream was **type30/HID23**, horizontal motion1, +not exclusively type29/HID32. Generic HID32 appeared later during the user's +navigation/overlay testing. Its horizontal classification remains unproven, so +the revised interceptor logs but does not suppress it. Dock AX notifications +`AXExposeShowAllWindows`, `AXExposeShowFrontWindows`, `AXExposeShowDesktop`, and +`AXExposeExit` all registered successfully in a read-only local check; actual +notification delivery remains a live-test requirement. The old window-layer +heuristic is retained only as an additional snapshot check. + +Mocked regression tests cover the observed reversed output convention, both +edges/inward recovery, physical input sign mapping, generic/vertical passthrough, +and overlay rejection/cancellation. These do not prove real OS behavior. The app +now restores native trackpad handling after failed replacement delivery. + +[pr77]: https://github.com/jurplel/InstantSpaceSwitcher/pull/77 +[payload-report]: https://github.com/jurplel/InstantSpaceSwitcher/issues/72#issuecomment-4663746037 +[serializer]: https://github.com/jurplel/InstantSpaceSwitcher/blob/bf32cf9732c706119e8307d2d70ae795b2b11c1c/Sources/ISS/event_serialize.c +[pr88]: https://github.com/jurplel/InstantSpaceSwitcher/pull/88 +[release-test]: https://github.com/jurplel/InstantSpaceSwitcher/pull/88#issuecomment-5644946567 +[derivative-report]: https://github.com/jurplel/InstantSpaceSwitcher/issues/72#issuecomment-5277119315 +[yabai-port]: https://github.com/agg23/yabai/commit/d0d387d1445048415fd1f3566393a191fe3c5097 +[apple-notes]: https://developer.apple.com/documentation/macos-release-notes/macos-27-release-notes From 187497a4c73f53c351e04adb5e00533f10d5cfff Mon Sep 17 00:00:00 2001 From: Ozair Khan Date: Mon, 14 Sep 2026 22:26:41 -0400 Subject: [PATCH 2/5] Default to upstream swipe-direction mapping --- README.md | 2 +- Sources/strafe/SwitchDiagnostics.swift | 9 ++++++++- Tests/SwitchEngineTests.swift | 15 ++++++++++----- docs/AGENT-HANDOFF.md | 2 +- 4 files changed, 20 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 774e9ff..8d665ad 100644 --- a/README.md +++ b/README.md @@ -151,7 +151,7 @@ Options are read from the environment at launch: | `STRAFE_EVENT_PROFILE` | `auto` (macOS 27+ payload, older OS legacy), `legacy`, `macos27` | | `STRAFE_PHASE_GAP_MS` | `0`–`100`; default `10` for macOS27, `0` for legacy | | `STRAFE_INVERT_DIRECTION` | Defaults to `1` for augmented output, `0` for legacy; explicit `0`/`1` overrides | -| `STRAFE_INVERT_SWIPE_DIRECTION` | Defaults to `1` on macOS 27+, `0` earlier; independent physical-progress mapping | +| `STRAFE_INVERT_SWIPE_DIRECTION` | Defaults to `0` (upstream convention: positive progress means right); `1` flips the physical-progress mapping | | `STRAFE_INTERCEPT_SWIPES` | `1` (default); `0` leaves trackpad gestures native for hotkey-only testing | | `STRAFE_DIAGNOSTICS` | `0` (default), `1` for request/gesture diagnostics | diff --git a/Sources/strafe/SwitchDiagnostics.swift b/Sources/strafe/SwitchDiagnostics.swift index 72db991..24f74a6 100644 --- a/Sources/strafe/SwitchDiagnostics.swift +++ b/Sources/strafe/SwitchDiagnostics.swift @@ -14,6 +14,10 @@ enum SwitchDiagnostics { static func flush() { queue.sync {} } } +/// Launch-time behavior switches, parsed strictly from the environment. +/// Defaults select the legacy output on older systems and the augmented +/// macOS 27 output (with inverted synthetic direction) on 27+. Every value +/// is validated; unknown keys are ignored and malformed values are fatal. struct SwitchConfiguration: Sendable { let profile: String let augmented: Bool @@ -54,7 +58,10 @@ struct SwitchConfiguration: Sendable { return try SwitchConfiguration( profile: profile, augmented: augmented, phaseGapMS: gap, inverted: flag("STRAFE_INVERT_DIRECTION", default: augmented), - invertSwipeDirection: flag("STRAFE_INVERT_SWIPE_DIRECTION", default: osMajorVersion >= 27), + // Upstream mapping: positive physical progress means the next + // (right) workspace. Set STRAFE_INVERT_SWIPE_DIRECTION=1 to flip + // the physical-progress mapping without touching output. + invertSwipeDirection: flag("STRAFE_INVERT_SWIPE_DIRECTION", default: false), interceptSwipes: flag("STRAFE_INTERCEPT_SWIPES", default: true), diagnostics: flag("STRAFE_DIAGNOSTICS") ) diff --git a/Tests/SwitchEngineTests.swift b/Tests/SwitchEngineTests.swift index baac429..b43a419 100644 --- a/Tests/SwitchEngineTests.swift +++ b/Tests/SwitchEngineTests.swift @@ -84,7 +84,11 @@ struct SwitchEngineTests { func testConfigurationDefaultsAndInvalidValues() throws { let modern = try SwitchConfiguration.load(environment: [:], osMajorVersion: 27) - precondition(modern.augmented && modern.phaseGapMS == 10 && modern.inverted && modern.invertSwipeDirection) + precondition(modern.augmented && modern.phaseGapMS == 10 && modern.inverted) + precondition(!modern.invertSwipeDirection) + let flipped = try SwitchConfiguration.load( + environment: ["STRAFE_INVERT_SWIPE_DIRECTION": "1"], osMajorVersion: 27) + precondition(flipped.invertSwipeDirection) let legacy = try SwitchConfiguration.load(environment: [:], osMajorVersion: 26) let forced = try config() precondition(!legacy.augmented && !forced.augmented) @@ -260,17 +264,18 @@ struct SwitchEngineTests { if vertical { result.setIntegerValueField(CGEventField(rawValue: 123)!, value: 2) } return result } - // Negative physical progress means the next workspace on this host. + // Upstream convention (now the default): negative physical progress + // means the previous (left) workspace, positive means right. for phase: Int64 in [1, 1, 2, 2, 4] { let sample = event(phase) precondition(interceptor.handle(type: CGEventType(rawValue: 30)!, event: sample) == nil, "phase=\(phase) pid=\(strafe_event_source_pid(sample)) marker=\(strafe_event_is_strafe(sample)) type=\(strafe_event_cgs_type(sample)) hid=\(strafe_event_hid_type(sample)) axis=\(strafe_event_swipe_motion(sample))") } - precondition(sink.directions == [.right]) + precondition(sink.directions == [.left]) for phase: Int64 in [1, 2, 4] { precondition(interceptor.handle(type: CGEventType(rawValue: 30)!, event: event(phase, right: true)) == nil) } - precondition(sink.directions == [.right, .left]) + precondition(sink.directions == [.left, .right]) // Generic HID32 and vertical Dock gestures remain native, even while // Strafe owns a horizontal swipe. They must not drive or clear its latch. _ = interceptor.handle(type: CGEventType(rawValue: 30)!, event: event(1)) @@ -280,7 +285,7 @@ struct SwitchEngineTests { } _ = interceptor.handle(type: CGEventType(rawValue: 30)!, event: event(2)) _ = interceptor.handle(type: CGEventType(rawValue: 30)!, event: event(4)) - precondition(sink.directions == [.right, .left, .right]) + precondition(sink.directions == [.left, .right, .left]) let overlay = SwipeInterceptor(engine: sink, configuration: config, overlayActive: { true }, overlaySnapshot: { false }) for phase: Int64 in [1, 2, 4] { diff --git a/docs/AGENT-HANDOFF.md b/docs/AGENT-HANDOFF.md index 8d2381b..13ffdda 100644 --- a/docs/AGENT-HANDOFF.md +++ b/docs/AGENT-HANDOFF.md @@ -186,7 +186,7 @@ Environment configuration (strictly parsed at startup): | `STRAFE_EVENT_PROFILE` | `auto`; resolves augmented on27+, legacy earlier. Also `legacy`, `macos27` | | `STRAFE_PHASE_GAP_MS` | 10 for augmented, 0 legacy; explicit0–100 | | `STRAFE_INVERT_DIRECTION` | **1 for augmented**, 0 legacy; explicit0/1 overrides | -| `STRAFE_INVERT_SWIPE_DIRECTION` | **1 on27+**, 0 earlier; independent of output profile | +| `STRAFE_INVERT_SWIPE_DIRECTION` | **0 by default** (upstream convention; user live-tested both and chose this); explicit1 flips physical mapping | | `STRAFE_INTERCEPT_SWIPES` | 1; use0 for the next hotkey-only test | | `STRAFE_DIAGNOSTICS` | 0; use1 for logs | From 6bc73c88f1dbd40ebf2a891d24c9880b12e74db3 Mon Sep 17 00:00:00 2001 From: Ozair Khan Date: Mon, 14 Sep 2026 23:26:26 -0400 Subject: [PATCH 3/5] Tolerate overlay transitions before pausing interception Mashing swipes through Mission Control's close animation could pause trackpad interception after a single failed request: the failure breaker fired on the first unexpectedChange, and overlay detection is silent on some systems so no grace applied. Now a changing anomaly polls on until the deadline, only a stable wrong destination fails fast, and pausing takes three delivery failures in a row (any success resets). A failure within a second of an overlay is still neither counted nor reported. --- README.md | 10 +++-- Sources/strafe/SwitchEngine.swift | 54 +++++++++++++++++------ Tests/SwitchEngineTests.swift | 72 ++++++++++++++++++++++++------- 3 files changed, 104 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index 20841e0..b154d47 100644 --- a/README.md +++ b/README.md @@ -189,10 +189,12 @@ The initial live test confirmed instant switching but reversed output signs. The current build corrects those signs separately from physical swipe progress. Mission Control, App Exposé and Show Desktop gestures pass through while an overlay is detected (Dock Accessibility notifications plus a window-layer -snapshot); a failure within a second of an overlay can never disable trackpad -interception, and a single odd topology read never fails a switch. If overlay -detection misbehaves on your macOS version, run `strafe mc-probe`, open and -close Mission Control while it samples, and share the log. +snapshot). A failure within a second of an overlay is never counted, a merely +changing topology never fails a switch early, and interception pauses only +after three delivery failures in a row — so mashing swipes through Mission +Control's close animation can't kill it. If overlay detection misbehaves on +your macOS version, run `strafe mc-probe`, open and close Mission Control +while it samples, and share the log. If delivery times out, goes to an unexpected Space, or cannot be prepared, trackpad interception is disabled automatically; it can be re-enabled from the menu. Hotkeys remain available. For initial hotkey-only diagnosis, prefix the diff --git a/Sources/strafe/SwitchEngine.swift b/Sources/strafe/SwitchEngine.swift index fee0292..a06bce7 100644 --- a/Sources/strafe/SwitchEngine.swift +++ b/Sources/strafe/SwitchEngine.swift @@ -69,8 +69,17 @@ final class GestureSwitchEngine: SwitchEngine, @unchecked Sendable { private let speedLock = NSLock() private var speed: TransitionSpeed = .default private var activeGapMS: Double = 0 - private var anomalies = 0 private var lastOverlaySeen: TimeInterval = 0 + /// Consecutive delivery-class failures (unexpectedChange, + /// observationTimedOut, postFailed, topologyUnavailable). One is expected + /// from time to time — a mashed swipe into Mission Control's close + /// animation, a rearranging Space — so interception pauses only after + /// three in a row, which means the replacement path itself is broken. + /// Any success resets the count. + private var consecutiveFailures = 0 + private static let maxConsecutiveFailures = 3 + private var anomalySignature: String? + private var anomalyStreak = 0 struct Topology: Sendable { let display: String @@ -195,7 +204,8 @@ final class GestureSwitchEngine: SwitchEngine, @unchecked Sendable { guard active == nil, !pending.isEmpty else { return } let request = pending.removeFirst() active = request - anomalies = 0 + anomalySignature = nil + anomalyStreak = 0 guard !overlayUp() else { finish(.failure(.overlayActive), dropPending: true) return @@ -290,12 +300,20 @@ final class GestureSwitchEngine: SwitchEngine, @unchecked Sendable { } } - /// A single odd topology read proves nothing: Mission Control's close - /// animation can leave CGS mid-flight for a poll or two. Only consecutive - /// anomalies fail the request; any clean read resets the count. + /// An odd topology read proves nothing on its own: Mission Control's close + /// animation can leave CGS mid-flight for hundreds of milliseconds. A + /// *changing* anomaly keeps polling until the deadline, but the *same* + /// anomalous destination three polls running means the switch genuinely + /// went somewhere wrong — fail fast so the next swipe starts from truth. private func anomalous(_ request: Request, _ message: String, live: Topology) { - anomalies += 1 - guard anomalies >= 2 else { + let signature = "\(live.index):\(live.id):\(live.count)" + if signature == anomalySignature { + anomalyStreak += 1 + } else { + anomalySignature = signature + anomalyStreak = 1 + } + guard anomalyStreak >= 3 else { trace("request=\(request.id) transient \(message) live={\(live.summary)}") queue.asyncAfter(deadline: .now() + 0.025) { self.poll() } return @@ -344,7 +362,8 @@ final class GestureSwitchEngine: SwitchEngine, @unchecked Sendable { anomalous(request, "transition reverted", live: live) return } - anomalies = 0 + anomalySignature = nil + anomalyStreak = 0 queue.asyncAfter(deadline: .now() + 0.025) { self.poll() } } @@ -352,20 +371,29 @@ final class GestureSwitchEngine: SwitchEngine, @unchecked Sendable { admission.lock() admitted -= 1 admission.unlock() - if case .failure(let error) = result { + switch result { + case .success: + consecutiveFailures = 0 + case .failure(let error): SwitchDiagnostics.log("request=\(request.id) direction=\(request.direction) failed: \(error)") switch error { case .unexpectedChange, .observationTimedOut, .postFailed, .topologyUnavailable: // Grace period: an overlay was up within the last second, so // this failure likely describes Mission Control's close - // animation settling — not a broken replacement path. Never - // disable interception over that. + // animation settling — not a broken replacement path. Neither + // count nor report it. if error == .unexpectedChange || error == .observationTimedOut, ProcessInfo.processInfo.systemUptime - lastOverlaySeen < 1.0 { SwitchDiagnostics.log("request=\(request.id) failure callback suppressed (recent overlay)") - } else { - deliveryFailureHandler?(error) + break + } + consecutiveFailures += 1 + guard consecutiveFailures >= Self.maxConsecutiveFailures else { + SwitchDiagnostics.log("request=\(request.id) failure \(consecutiveFailures)/\(Self.maxConsecutiveFailures) (interception stays on)") + break } + consecutiveFailures = 0 + deliveryFailureHandler?(error) default: break } } diff --git a/Tests/SwitchEngineTests.swift b/Tests/SwitchEngineTests.swift index cf3efe8..77e7654 100644 --- a/Tests/SwitchEngineTests.swift +++ b/Tests/SwitchEngineTests.swift @@ -25,10 +25,11 @@ struct SwitchEngineTests { try tests.testPhysicalGestureMappingAndPassthrough() try tests.testRampSpeedPostsBeganChangedStreamAndEnded() try tests.testTransientAnomalyRecovers() - try tests.testPersistentAnomalyStillFailsAndReports() + try tests.testSingleDeliveryFailureKeepsInterception() + try tests.testThreeConsecutiveFailuresPauseInterception() try tests.testOverlayGraceSuppressesFailureCallback() SwitchDiagnostics.flush() - print("Swift: 13 configuration/engine/interceptor tests passed (mock delivery; no events posted).") + print("Swift: 14 configuration/engine/interceptor tests passed (mock delivery; no events posted).") } private final class Desktop: @unchecked Sendable { let lock = NSLock() @@ -115,11 +116,9 @@ struct SwitchEngineTests { let engine = GestureSwitchEngine(configuration: try config(), dependencies: desktop.dependencies) let failed = CompletionWaiter() let dropped = CompletionWaiter() - let fallback = CompletionWaiter() - engine.setDeliveryFailureHandler { error in - guard case .observationTimedOut = error else { fatalError("Unexpected fallback: \(error)") } - fallback.fulfill() - } + // A lone timeout must not pause interception; the three-strikes rule + // is covered by testThreeConsecutiveFailuresPauseInterception. + engine.setDeliveryFailureHandler { _ in fatalError("Single timeout must not pause interception") } try engine.switchSpace(.left) { result in guard case .failure(.observationTimedOut) = result else { fatalError("Expected timeout: \(result)") } failed.fulfill() @@ -130,7 +129,6 @@ struct SwitchEngineTests { } failed.wait() dropped.wait() - fallback.wait() precondition(desktop.snapshot().0 == 1) precondition(desktop.snapshot().1 == [1, 2, 4]) desktop.enableMoves() @@ -321,25 +319,69 @@ struct SwitchEngineTests { precondition(scripted.phases == [1, 2, 4]) } - func testPersistentAnomalyStillFailsAndReports() throws { + func testSingleDeliveryFailureKeepsInterception() throws { let scripted = Scripted() let origin = scripted.topology(display: "display-A", index: 1, id: 11) let anomaly = scripted.topology(display: "display-A", index: 0, id: 99) scripted.reads = [origin, origin, anomaly] scripted.overlays = [false] let engine = GestureSwitchEngine(configuration: try config(), dependencies: scripted.dependencies) - let reported = CompletionWaiter() - engine.setDeliveryFailureHandler { error in - guard case .unexpectedChange = error else { fatalError("Unexpected fallback: \(error)") } - reported.fulfill() - } + engine.setDeliveryFailureHandler { _ in fatalError("Single failure must not pause interception") } let finished = CompletionWaiter() try engine.switchSpace(.right) { result in guard case .failure(.unexpectedChange) = result else { fatalError("Expected mismatch: \(result)") } finished.fulfill() } finished.wait() - reported.wait() + } + + func testThreeConsecutiveFailuresPauseInterception() throws { + let desktop = Desktop() + desktop.enableMoves() + desktop.index = 2 + let engine = GestureSwitchEngine(configuration: try config(), dependencies: desktop.dependencies) + final class Counter: @unchecked Sendable { + private let lock = NSLock() + private var count = 0 + func increment() { lock.lock(); count += 1; lock.unlock() } + var value: Int { lock.lock(); defer { lock.unlock() }; return count } + } + let reports = Counter() + engine.setDeliveryFailureHandler { error in + guard case .unexpectedChange = error else { fatalError("Unexpected fallback: \(error)") } + reports.increment() + } + func attempt(_ direction: SwitchDirection, expect expected: SwitchEngineError) throws { + let finished = CompletionWaiter() + try engine.switchSpace(direction) { result in + guard case .failure(let error) = result, error == expected else { + fatalError("Expected \(expected): \(result)") + } + finished.fulfill() + } + finished.wait() + } + func reportsNow() -> Int { reports.value } + // Three wrong landings in a row: silent, silent, then pause. + desktop.reverse = true + try attempt(.right, expect: .unexpectedChange) + try attempt(.left, expect: .unexpectedChange) + precondition(reportsNow() == 0) + try attempt(.right, expect: .unexpectedChange) + precondition(reportsNow() == 1) + // A success resets the count: the next lone failure stays silent. + desktop.reverse = false + for direction in [SwitchDirection.left, .right] { + let finished = CompletionWaiter() + try engine.switchSpace(direction) { result in + guard case .success = result else { fatalError("Expected recovery: \(result)") } + finished.fulfill() + } + finished.wait() + } + desktop.reverse = true + try attempt(.right, expect: .unexpectedChange) + precondition(reportsNow() == 1) } func testOverlayGraceSuppressesFailureCallback() throws { From 2b75106ff9c17c832f51065f1c64f25c98f9acbf Mon Sep 17 00:00:00 2001 From: Ozair Khan Date: Mon, 14 Sep 2026 23:35:18 -0400 Subject: [PATCH 4/5] Detect Mission Control from the Dock layer census on macOS 27 mc-probe showed Dock's AX overlay notifications never arrive on 27, while Mission Control presents exactly one Dock window at layer 20 (closed desktop shows none). The snapshot heuristic required a layer-18 window, so it never fired and synthetic switches went into Mission Control. A lone layer-20 Dock window is now an overlay signal, and the engine consults the snapshot at request admission in addition to the AX flag. Interceptor behavior is unchanged: Began already passed through on the snapshot. --- Sources/CStrafe/CStrafe.c | 8 ++++++++ Sources/strafe/SwitchEngine.swift | 14 ++++++++++---- Tests/SwitchEngineTests.swift | 24 +++++++++++++++++++++++- 3 files changed, 41 insertions(+), 5 deletions(-) diff --git a/Sources/CStrafe/CStrafe.c b/Sources/CStrafe/CStrafe.c index 84627f7..edac9f6 100644 --- a/Sources/CStrafe/CStrafe.c +++ b/Sources/CStrafe/CStrafe.c @@ -420,5 +420,13 @@ bool strafe_is_expose_active(void) { // Mission Control: layer18Count > 0 && layer20Count > layer18Count. if (layer18Count > 0 && layer20Count > 0 && layer20Count <= layer18Count) { return true; } if (layer18Count > 0 && layer20Count > layer18Count) { return true; } + // macOS 27 (mc-probe): Mission Control shows a single Dock window at + // layer 20 with none at layer 18 (closed desktop shows no Dock windows at + // all under ExcludeDesktopElements), and Dock's AX overlay notifications + // no longer arrive — so a lone layer-20 window is the MC signal there. A + // Dock-owned layer-20 window is overlay chrome on older systems too, and + // the failure mode here is fail-safe: a false positive only passes one + // gesture through natively. + if (layer20Count > 0) { return true; } return false; } diff --git a/Sources/strafe/SwitchEngine.swift b/Sources/strafe/SwitchEngine.swift index a06bce7..85c8bfc 100644 --- a/Sources/strafe/SwitchEngine.swift +++ b/Sources/strafe/SwitchEngine.swift @@ -119,6 +119,7 @@ final class GestureSwitchEngine: SwitchEngine, @unchecked Sendable { } var post: @Sendable (CGEvent) -> Void = { $0.post(tap: .cgSessionEventTap) } var overlayActive: @Sendable () -> Bool = { MissionControlMonitor.shared.isActive } + var overlaySnapshot: @Sendable () -> Bool = { strafe_is_expose_active() } } init(velocity: Double = GestureSwitchEngine.instantVelocity, @@ -193,9 +194,14 @@ final class GestureSwitchEngine: SwitchEngine, @unchecked Sendable { /// True when an overlay is up, recording the sighting for the failure /// grace period. All queue-confined callers use this instead of reading - /// the dependency directly. - private func overlayUp() -> Bool { - guard dependencies.overlayActive() else { return false } + /// the dependency directly. The window-layer snapshot runs only at request + /// admission: it costs a window-list scan, so the per-phase and per-poll + /// checks stay on the cheap AX flag (a mid-sequence overlay opening is a + /// ~10 ms race not worth scanning for). + private func overlayUp(includeSnapshot: Bool = false) -> Bool { + guard dependencies.overlayActive() || (includeSnapshot && dependencies.overlaySnapshot()) else { + return false + } lastOverlaySeen = ProcessInfo.processInfo.systemUptime return true } @@ -206,7 +212,7 @@ final class GestureSwitchEngine: SwitchEngine, @unchecked Sendable { active = request anomalySignature = nil anomalyStreak = 0 - guard !overlayUp() else { + guard !overlayUp(includeSnapshot: true) else { finish(.failure(.overlayActive), dropPending: true) return } diff --git a/Tests/SwitchEngineTests.swift b/Tests/SwitchEngineTests.swift index 77e7654..7dff92c 100644 --- a/Tests/SwitchEngineTests.swift +++ b/Tests/SwitchEngineTests.swift @@ -22,6 +22,7 @@ struct SwitchEngineTests { try tests.testTrueEdgeDoesNotPost() try tests.testMacOS27DirectionAndBothEdges() try tests.testOverlayBlocksAndCancelsPendingGesture() + try tests.testSnapshotOverlayBlocksAdmissionWithoutPosting() try tests.testPhysicalGestureMappingAndPassthrough() try tests.testRampSpeedPostsBeganChangedStreamAndEnded() try tests.testTransientAnomalyRecovers() @@ -29,7 +30,7 @@ struct SwitchEngineTests { try tests.testThreeConsecutiveFailuresPauseInterception() try tests.testOverlayGraceSuppressesFailureCallback() SwitchDiagnostics.flush() - print("Swift: 14 configuration/engine/interceptor tests passed (mock delivery; no events posted).") + print("Swift: 15 configuration/engine/interceptor tests passed (mock delivery; no events posted).") } private final class Desktop: @unchecked Sendable { let lock = NSLock() @@ -37,6 +38,7 @@ struct SwitchEngineTests { var moves = false var reverse = false var overlay = false + var snapshotOverlay = false var openOverlayOnBegin = false var phases: [Int64] = [] var times: [TimeInterval] = [] @@ -82,6 +84,10 @@ struct SwitchEngineTests { self.lock.lock() defer { self.lock.unlock() } return self.overlay + }, overlaySnapshot: { + self.lock.lock() + defer { self.lock.unlock() } + return self.snapshotOverlay }) } } @@ -410,6 +416,22 @@ struct SwitchEngineTests { second.wait() } + func testSnapshotOverlayBlocksAdmissionWithoutPosting() throws { + // AX flag silent (as on macOS 27) but the window-layer snapshot sees + // the overlay: the request must fail cleanly before any post. + let desktop = Desktop() + desktop.snapshotOverlay = true + let engine = GestureSwitchEngine(configuration: try config(), dependencies: desktop.dependencies) + engine.setDeliveryFailureHandler { _ in fatalError("Overlay refusal must not pause interception") } + let finished = CompletionWaiter() + try engine.switchSpace(.right) { result in + guard case .failure(.overlayActive) = result else { fatalError("Snapshot overlay not respected: \(result)") } + finished.fulfill() + } + finished.wait() + precondition(desktop.snapshot().1.isEmpty) + } + private final class GestureSink: SwitchEngine { var directions: [SwitchDirection] = [] func switchSpace(_ direction: SwitchDirection) throws { directions.append(direction) } From fe85fd979a9df8728a27d5c52e3fbcb29ff38f23 Mon Sep 17 00:00:00 2001 From: Ozair Khan Date: Mon, 14 Sep 2026 23:45:57 -0400 Subject: [PATCH 5/5] README: describe macOS 27 support as verified --- README.md | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index b154d47..d44ffc7 100644 --- a/README.md +++ b/README.md @@ -147,14 +147,16 @@ The app is about 1,486 lines of Swift and C with no third-party dependencies — strafe # start the menu-bar app ``` -## macOS 27 diagnostic build +## macOS 27 support -This checkout now includes an experimental macOS 27 compatibility path. It uses -an embedded IOHID gesture payload and paced asynchronous posting. Physical +On macOS 27 the legacy synthetic gesture is ignored, so strafe uses an +embedded IOHID gesture payload with paced asynchronous posting there, selected +automatically (`STRAFE_EVENT_PROFILE=legacy` forces the old path). Physical interception is restricted to horizontal HID23 gestures; ambiguous HID32 events -are logged in diagnostic mode but passed through. Live switching and time-to-interactivity -still need verification on the target machine; the macOS 26 measurements above -do not describe this build. +are logged in diagnostic mode but passed through. Hotkeys and trackpad +switching are verified live on 27 including both edges; ramp presets and +pre-27 behavior still want a second machine's confirmation, and the macOS 26 +measurements above do not describe the new output path. Build with `./Scripts/bundle.sh`, quit any running Strafe instance, then run: