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 249e650..d44ffc7 100644 --- a/README.md +++ b/README.md @@ -143,9 +143,65 @@ The app is about 1,486 lines of Swift and C with no third-party dependencies — strafe switch left|right # switch once and exit strafe status # print accessibility / tap status strafe speed [preset] # show or set transition speed + strafe mc-probe [seconds] # sample overlay detection for diagnosis strafe # start the menu-bar app ``` +## macOS 27 support + +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. 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: + +```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 `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 | + +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. +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 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 +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 3c2a480..9100a4f 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,486 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. @@ -32,11 +32,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. There is no second mask and no setting that widens this one. @@ -69,8 +70,10 @@ the wrappers in `Sources/CStrafe/CStrafe.c` (lines 221–242): - 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 @@ -84,6 +87,11 @@ pick which display to switch on (`copy_cursor_display_identifier`, same file line 112). 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 @@ -107,8 +115,12 @@ Each of these is verifiable with a single grep over `Sources/`. - **No analytics or telemetry.** strafe writes only to the process's own `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. + ~line 28) and the `speed` / `mc-probe` CLI readouts — never to a network + socket, a file, or an analytics sink. Nothing batches, serializes, or + 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, and no update check.** strafe never downloads or executes anything. There is no updater, no Sparkle, no download URL, and nothing that diff --git a/Scripts/bundle.sh b/Scripts/bundle.sh index f63fed2..2581da6 100755 --- a/Scripts/bundle.sh +++ b/Scripts/bundle.sh @@ -43,6 +43,9 @@ echo "==> Assembling $APP_NAME.app (version $VERSION)…" 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..2926273 --- /dev/null +++ b/Scripts/test.sh @@ -0,0 +1,21 @@ +#!/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/Preferences.swift Sources/strafe/TransitionSpeed.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 7aa1d46..edac9f6 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,50 +64,117 @@ bool strafe_cgs_available(void) { } // --- Synthesis (SPEC §1.5) ------------------------------------------------ -// One phase of a horizontal dock swipe, with progress and velocity supplied by -// the caller. Every field the WindowServer reads is set here; the two callers -// below differ only in the numbers they hand it. -static bool post_dock_swipe_shaped(CGSGesturePhase phase, double progress, double velocity) { +// 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 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, velocity); - CGEventSetDoubleValueField (ev, kCGEventGestureSwipeVelocityY, velocity); - 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; } -static bool post_dock_swipe(CGSGesturePhase phase, StrafeDirection direction, double velocity) { - const bool isRight = (direction == StrafeDirectionRight); - // Empirically, ±FLT_TRUE_MIN used in this way makes switching instant. - const double progress = isRight ? (double)FLT_TRUE_MIN : -(double)FLT_TRUE_MIN; - - // Velocity of gesture based on speed setting. +// One ramp phase for the animated Transition speed presets (SPEC §1.4): +// caller-chosen progress/velocity magnitudes, direction applied internally +// like the instant creator above. Unlike the instant shape — whose augmented +// form carries velocity on Ended only — a ramp carries its per-phase velocity +// on every phase; the climbing `changed` stream is what makes the WindowServer +// run its slide instead of flicking. +CGEventRef strafe_create_ramp_event(StrafeDirection direction, double velocity, + int64_t phase, double progress, bool augmented, + bool inverted) { + if ((direction != StrafeDirectionLeft && direction != StrafeDirectionRight) || + (phase != 1 && phase != 2 && phase != 4 && phase != 8) || + !isfinite(velocity) || velocity < 0 || velocity > FLT_MAX || + !isfinite(progress) || progress < 0) return NULL; + const bool isRight = (direction == StrafeDirectionRight) != inverted; + const double signedProgress = isRight ? progress : -progress; const double vel = isRight ? velocity : -velocity; + int32_t fixed; + if (augmented && (!strafe_fixed(signedProgress, &fixed) || !strafe_fixed(vel, &fixed))) return NULL; - return post_dock_swipe_shaped(phase, progress, vel); + CGEventRef ev = CGEventCreate(NULL); + 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, signedProgress); + CGEventSetIntegerValueField(ev, kCGEventGestureSwipeMotion, kCGGestureMotionHorizontal); + CGEventSetDoubleValueField(ev, kCGEventGestureSwipeVelocityX, vel); + CGEventSetDoubleValueField(ev, kCGEventGestureSwipeVelocityY, vel); + 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()); + CGEventRef result = strafe_augment(ev); + CFRelease(ev); + // Same macOS 27 source-data omission as above: re-stamp after rebuild. + if (result) { + CGEventSetIntegerValueField(result, kCGEventSourceUserData, kStrafeEventMarker); + CGEventSetIntegerValueField(result, kCGEventSourceUnixProcessID, getpid()); + } + return result; + } + 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); -} - -// One phase with caller-chosen progress and velocity, for the animated -// Transition speed presets (SPEC §1.4). A began, then a `changed` stream whose -// progress climbs, then an ended is what makes the WindowServer run its slide -// instead of flicking. The instant preset does not use this — it still goes -// through `strafe_post_switch_gesture` above, unchanged. -bool strafe_post_dock_swipe_phase(int64_t phase, double progress, double velocity) { - return post_dock_swipe_shaped((CGSGesturePhase)phase, progress, 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) --------------------------------------------------- @@ -127,23 +197,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; } @@ -154,64 +279,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); @@ -219,6 +300,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); } @@ -264,7 +353,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 @@ -287,18 +376,22 @@ 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) ------------------------------- -// Heuristic: count Dock-owned windows at layers 18 and 20. -bool strafe_is_expose_active(void) { +// Heuristic: count Dock-owned windows at layers 18 and 20. Counts are exposed +// separately so the mc-probe diagnostic can report what a given macOS version +// actually shows while an overlay is open. +void strafe_expose_counts(int *dockWindows, int *layer18, int *layer20) { + if (dockWindows) *dockWindows = 0; + if (layer18) *layer18 = 0; + if (layer20) *layer20 = 0; CFArrayRef windows = CGWindowListCopyWindowInfo( kCGWindowListOptionOnScreenOnly | kCGWindowListExcludeDesktopElements, kCGNullWindowID); - if (!windows) { return false; } + if (!windows) { return; } - int layer18Count = 0; - int layer20Count = 0; CFIndex count = CFArrayGetCount(windows); for (CFIndex i = 0; i < count; i++) { CFDictionaryRef win = (CFDictionaryRef)CFArrayGetValueAtIndex(windows, i); @@ -308,18 +401,32 @@ bool strafe_is_expose_active(void) { if (!owner || CFStringCompare(owner, CFSTR("Dock"), 0) != kCFCompareEqualTo) { continue; } + if (dockWindows) *dockWindows += 1; CFNumberRef layerNum = (CFNumberRef)CFDictionaryGetValue(win, kCGWindowLayer); if (!layerNum) { continue; } int layer = 0; CFNumberGetValue(layerNum, kCFNumberIntType, &layer); - if (layer == 18) { layer18Count++; } - else if (layer == 20) { layer20Count++; } + if (layer == 18) { if (layer18) *layer18 += 1; } + else if (layer == 20) { if (layer20) *layer20 += 1; } } CFRelease(windows); +} + +bool strafe_is_expose_active(void) { + int layer18Count = 0, layer20Count = 0; + strafe_expose_counts(NULL, &layer18Count, &layer20Count); // App Exposé: layer18Count > 0 && layer20Count > 0 && layer20Count <= layer18Count. // 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/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 119c601..ca4d87f 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 @@ -27,7 +28,8 @@ typedef enum { 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 + char displayID[128]; // display UUID string, pins observation to one display + uint64_t currentSpaceID; // active identity, verified in this display's Spaces array } StrafeInfo; // --- Capability check (SPEC §1.1, §6) ------------------------------------- @@ -36,23 +38,42 @@ 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); -// Post ONE phase of a horizontal dock swipe with an explicit progress and -// velocity, to the same tap. Used only by the animated Transition speed -// presets, which post a began -> ramped `changed` stream -> ended sequence -// (SPEC §1.4); the instant preset uses the function above. Returns false only -// if the event could not be created. -bool strafe_post_dock_swipe_phase(int64_t phase, double progress, double velocity); +// Create one retained ramp event for the animated Transition speed presets +// (SPEC §1.4): caller-chosen progress/velocity magnitudes with direction +// applied internally like above. Unlike the instant shape — augmented velocity +// on Ended only — a ramp carries its per-phase velocity on every phase; the +// climbing `changed` stream is what makes the WindowServer animate. Same +// validation plus finite nonnegative progress; NULL on invalid input. The +// engine prebuilds began -> ramped `changed` stream -> ended and schedules the +// phases itself, so one synchronous immediate-post helper (used only by the +// previous fire-and-forget ramp path) has no callers left and is gone. +CGEventRef _Nullable strafe_create_ramp_event(StrafeDirection direction, double velocity, + int64_t phase, double progress, bool augmented, bool inverted) CF_RETURNS_RETAINED; // --- 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 @@ -64,11 +85,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 @@ -79,8 +104,8 @@ int64_t strafe_gesture_phase_cancelled(void); // 8 // --- Private CGEventField indices (SPEC §1.2) ----------------------------- // Exposed as functions ONLY so an out-of-tree caller (the bench measurement // tool) can build a custom-shaped dock-swipe event without re-hardcoding the -// magic field numbers. The strafe app itself does not call these — its posters -// (`strafe_post_switch_gesture`, `strafe_post_dock_swipe_phase`) write the +// magic field numbers. The strafe app itself does not call these — its event +// creators (`strafe_create_switch_event`, `strafe_create_ramp_event`) write the // fields directly. These are pure value accessors: no behavior change, no new // event is posted, nothing in the app's code path reads them. The field numbers // stay single-sourced in CStrafe.c. See docs/SPEC.md §1.2. @@ -92,14 +117,20 @@ 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); // --- Overlay / Exposé passthrough (SPEC §2.5) ----------------------------- // True when App Exposé or Mission Control is up (Dock windows at layers 18/20). bool strafe_is_expose_active(void); +// Dock-window layer census behind the heuristic above, for the mc-probe +// diagnostic. NULL out-parameters are allowed. +void strafe_expose_counts(int * _Nullable dockWindows, int * _Nullable layer18, + int * _Nullable layer20); + +CF_ASSUME_NONNULL_END #ifdef __cplusplus } 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 c118fba..5def1cf 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)") print(" Transition speed: \(TransitionSpeed.stored.title)") } 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..24f74a6 --- /dev/null +++ b/Sources/strafe/SwitchDiagnostics.swift @@ -0,0 +1,73 @@ +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 {} } +} + +/// 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 + 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), + // 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") + ) + } + + 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 cbb1d4a..85c8bfc 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,190 +11,416 @@ 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? + } - /// Per-display predicted current-space index, keyed by display UUID - /// (SPEC §2.4). Avoids rebounding off the laggy live active-space query. - /// Also guards `speed`. - private let lock = NSLock() - private var predictions: [String: UInt32] = [:] + /// The transition speed (SPEC §1.4). Written from the main actor (menu, + /// CLI) and read on `queue` at request execution, hence its own lock. + /// Ramps are scheduled phase-by-phase on `queue` — never slept inside the + /// event tap — so a whole triplet can never interleave with another's. + private let speedLock = NSLock() private var speed: TransitionSpeed = .default + private var activeGapMS: Double = 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 + 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) + } + } + + /// 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 buildRamp: @Sendable (SwitchDirection, Double, Int64, Double, Bool, Bool) -> CGEvent? = { + strafe_create_ramp_event($0.cDirection, $1, $2, $3, $4, $5) + } + var post: @Sendable (CGEvent) -> Void = { $0.post(tap: .cgSessionEventTap) } + var overlayActive: @Sendable () -> Bool = { MissionControlMonitor.shared.isActive } + var overlaySnapshot: @Sendable () -> Bool = { strafe_is_expose_active() } + } - /// Serial queue for the ramped (animated) transition speeds. - /// - /// A ramp is a sequence of posts spread over 30–120 ms, and `switchSpace` is - /// called from the event-tap callback, which runs on the **main run loop** — - /// sleeping there would stall the tap and every other main-thread client for - /// the length of the animation. So ramps are posted off-thread. - /// - /// Serial, not concurrent, and deliberately so: two swipes in quick - /// succession must not interleave their began/changed/ended streams. The - /// second ramp starts only once the first has posted its `ended`. - private let rampQueue = DispatchQueue( - label: "com.rileycx.strafe.ramp", qos: .userInteractive - ) - - 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 { + 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() + } + + /// Kept for the workspace notification seam. Live polling is authoritative; + /// notifications must not invalidate a legitimate in-flight transition. + func resetPredictions() {} - /// The current transition speed. Read/written under the same lock as the - /// predictions because the menu (main actor) sets it while the event-tap - /// callback reads it. + /// 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 } + } + + /// The current transition speed (SPEC §1.4). The menu (main actor) and CLI + /// set it while the event-tap callback reads it, hence the lock. Read once + /// per request at execution; a change mid-flight applies to the next swipe. var transitionSpeed: TransitionSpeed { - lock.lock(); defer { lock.unlock() } + speedLock.lock(); defer { speedLock.unlock() } return speed } func setTransitionSpeed(_ newValue: TransitionSpeed) { - lock.lock() + speedLock.lock() speed = newValue - lock.unlock() - } - - /// Block until any in-flight ramp has finished posting. - /// - /// Only CLI mode needs this: it issues one switch and then exits the - /// process, which would kill a ramp partway through its `changed` stream and - /// leave the gesture unfinished. The menu-bar app never calls it — blocking - /// there is exactly what `rampQueue` exists to avoid. - func waitForPendingSwitch() { - rampQueue.sync {} - } - - /// Post one directional switch in whatever shape the current speed calls for. - /// - /// Returns whether the switch was *dispatched*, not whether it completed: - /// the instant path posts synchronously and can report a failed - /// `CGEventCreate`, while a ramp is handed to `rampQueue` and returns true - /// immediately. Either way the caller's optimistic prediction update is - /// correct, because that models where we are going, not where we are. - private func post(_ direction: SwitchDirection, speed: TransitionSpeed) -> Bool { - guard let rampMs = speed.rampMilliseconds else { - return strafe_post_switch_gesture(direction.cDirection, velocity) - } - - let sign: Double = direction == .right ? 1.0 : -1.0 - let steps = TransitionSpeed.rampSteps - let peak = TransitionSpeed.rampPeakProgress - let endVelocity = TransitionSpeed.rampEndVelocity - let perStep = UInt32((rampMs / Double(steps)) * 1000.0) // µs - - rampQueue.async { - // began: at rest. The motion in the `changed` stream below is what - // makes the WindowServer animate instead of jumping. - _ = strafe_post_dock_swipe_phase(strafe_gesture_phase_began(), 0.0, 0.0) - for step in 1...steps { - let frac = Double(step) / Double(steps) - _ = strafe_post_dock_swipe_phase( - strafe_gesture_phase_changed(), - sign * peak * frac, - sign * endVelocity * frac - ) - if perStep > 0 { usleep(perStep) } - } - // ended: a moderate velocity commits the switch with its animation. - _ = strafe_post_dock_swipe_phase( - strafe_gesture_phase_ended(), sign * peak, sign * endVelocity - ) + speedLock.unlock() + } + + private func trace(_ message: String) { + if configuration.diagnostics { SwitchDiagnostics.log(message) } + } + + /// 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. 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 } - 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) + private func startNext() { + guard active == nil, !pending.isEmpty else { return } + let request = pending.removeFirst() + active = request + anomalySignature = nil + anomalyStreak = 0 + guard !overlayUp(includeSnapshot: true) 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 + speedLock.lock() + let shape = speed + speedLock.unlock() + trace("request=\(request.id) direction=\(request.direction) speed=\(shape.name) 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. + if let rampMs = shape.rampMilliseconds { + activeGapMS = rampMs / Double(TransitionSpeed.rampSteps) + let peak = TransitionSpeed.rampPeakProgress + let endVelocity = TransitionSpeed.rampEndVelocity + var sequence: [(phase: Int64, progress: Double, velocity: Double)] = + [(strafe_gesture_phase_began(), 0.0, 0.0)] + for step in 1...TransitionSpeed.rampSteps { + let frac = Double(step) / Double(TransitionSpeed.rampSteps) + sequence.append((strafe_gesture_phase_changed(), peak * frac, endVelocity * frac)) } + sequence.append((strafe_gesture_phase_ended(), peak, endVelocity)) + for (phase, progress, stepVelocity) in sequence { + guard let event = dependencies.buildRamp(request.direction, stepVelocity, phase, + progress, configuration.augmented, + configuration.inverted) else { + finish(.failure(.postFailed), dropPending: true) + return + } + events.append(event) + } + } else { + activeGapMS = configuration.phaseGapMS + 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) + } - lock.lock() - let current = predictions[displayID] ?? info.currentIndex - - // 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 } + private func postPhase(_ index: Int) { + guard let request = active else { return } + if overlayUp() { + // 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=\(Self.phaseName(strafe_event_gesture_phase(events[index]))) uptime=\(ProcessInfo.processInfo.systemUptime)") + if index + 1 < events.count { + queue.asyncAfter(deadline: .now() + activeGapMS / 1000) { + self.postPhase(index + 1) } + } else { + deadline = ProcessInfo.processInfo.systemUptime + 0.750 + poll() + } + } - let target: UInt32 = direction == .left ? current - 1 : current + 1 - let shape = speed - lock.unlock() + private static func phaseName(_ phase: Int64) -> String { + switch phase { + case strafe_gesture_phase_began(): return "Began" + case strafe_gesture_phase_changed(): return "Changed" + case strafe_gesture_phase_ended(): return "Ended" + case strafe_gesture_phase_cancelled(): return "Cancelled" + default: return "phase\(phase)" + } + } + + /// 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) { + 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 + } + trace("request=\(request.id) \(message) live={\(live.summary)}") + finish(.failure(.unexpectedChange), dropPending: true) + } - guard post(direction, speed: shape) else { - throw SwitchEngineError.postFailed + private func poll() { + guard let request = active, let origin else { return } + guard !overlayUp() 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 { + anomalous(request, "unexpected transition", live: live) + return + } + if live.index == target { + if let candidateID, candidateID != live.id { + anomalous(request, "target ID changed", live: live) + 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 { + anomalous(request, "transition reverted", live: live) + return + } + anomalySignature = nil + anomalyStreak = 0 + queue.asyncAfter(deadline: .now() + 0.025) { self.poll() } + } - // Advance the optimistic prediction only after a successful post. - lock.lock() - predictions[displayID] = target - lock.unlock() - } else { - guard post(direction, speed: transitionSpeed) else { - throw SwitchEngineError.postFailed + private func complete(_ request: Request, _ result: Result) { + admission.lock() + admitted -= 1 + admission.unlock() + 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. 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)") + 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 } } + request.completion?(result) } - /// 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 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 9cb3922..5ff2acc 100644 --- a/Sources/strafe/main.swift +++ b/Sources/strafe/main.swift @@ -1,4 +1,5 @@ import AppKit +import CStrafe // MARK: - Entry point // @@ -7,22 +8,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 } @@ -38,26 +68,46 @@ func runCLI(_ args: [String], engine: GestureSwitchEngine) -> Int32 { // `strafe switch` and a real swipe look identical. engine.setTransitionSpeed(TransitionSpeed.stored) do { - try engine.switchSpace(direction) - // A ramped switch posts asynchronously; returning here exits the - // process, so drain it first or the gesture never finishes. - engine.waitForPendingSwitch() - // `CGEventPost` hands the event to the WindowServer asynchronously. - // Returning here exits immediately, and an exit that close behind the - // post loses the gesture — measured: without this pause `strafe - // switch` posts successfully and nothing moves. The menu-bar app - // never hits this because it stays alive. - usleep(120_000) - return 0 + MissionControlMonitor.shared.start() + defer { MissionControlMonitor.shared.stop() } + let completion = CLISwitchResult() + try engine.switchSpace(direction) { completion.store($0) } + // Covers ramp schedules (60 ms), 750 ms observation, and + // scheduling slack. Never exit merely on enqueue: `CGEventPost` + // hands the gesture to the WindowServer asynchronously, and an + // exit that close behind the post loses it. The menu-bar app never + // hits this because it stays alive. + 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 case "speed": @@ -84,6 +134,40 @@ func runCLI(_ args: [String], engine: GestureSwitchEngine) -> Int32 { print("transition speed: \(speed.title)") return 0 + case "mc-probe": + // Overlay-detection diagnostic: reports what Mission Control looks + // like to strafe while you open and close it. AX notifications (if + // Dock still posts them) log as they arrive; the layer histogram + // shows what the snapshot heuristic sees. Private overlay behavior + // changes between macOS versions, so re-probe there before trusting + // these numbers anywhere else. + let seconds: Double + if args.count >= 2, let value = Double(args[1]), value > 0, value <= 120 { + seconds = value + } else if args.count >= 2 { + FileHandle.standardError.write(Data("usage: strafe mc-probe [seconds 1-120]\n".utf8)) + return 2 + } else { + seconds = 15 + } + MissionControlMonitor.shared.start() + defer { MissionControlMonitor.shared.stop() } + print("mc-probe: open and close Mission Control within \(Int(seconds))s") + let end = ProcessInfo.processInfo.systemUptime + seconds + let timer = Timer(timeInterval: 0.25, repeats: true) { _ in + var dock = Int32(0), layer18 = Int32(0), layer20 = Int32(0) + strafe_expose_counts(&dock, &layer18, &layer20) + print("mc-probe ax=\(MissionControlMonitor.shared.isActive) " + + "snapshot=\(strafe_is_expose_active()) " + + "dock=\(dock) layer18=\(layer18) layer20=\(layer20)") + } + RunLoop.current.add(timer, forMode: .default) + defer { timer.invalidate() } + while ProcessInfo.processInfo.systemUptime < end { + RunLoop.current.run(until: Date(timeIntervalSinceNow: 0.1)) + } + return 0 + default: FileHandle.standardError.write(Data(""" strafe — near-instant macOS Spaces switching @@ -93,6 +177,7 @@ func runCLI(_ args: [String], engine: GestureSwitchEngine) -> Int32 { strafe switch left|right switch space once and exit strafe status print accessibility / tap status strafe speed [preset] show or set the swipe transition speed + strafe mc-probe [seconds] sample overlay detection for diagnosis """.utf8)) return 2 @@ -110,7 +195,7 @@ func runMenuBarApp(engine: GestureSwitchEngine) { let delegate = AppDelegate(engine: engine) app.delegate = delegate - app.run() + withExtendedLifetime(delegate) { app.run() } } @MainActor @@ -119,6 +204,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 @@ -129,16 +215,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, engine: engine) 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 @@ -147,11 +240,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..1e7c6c9 --- /dev/null +++ b/Tests/CStrafeTests.c @@ -0,0 +1,235 @@ +// 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); +} + +static void ramps(void) { + const int phases[] = {1, 2, 4, 8}; + const double steps[][2] = {{0.0, 0.0}, {0.1, 65.0}, {0.35, 130.0}}; + 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 < 3; ++i) { + int phase = phases[i], sign = (dir != inverted) ? 1 : -1; + double progress = steps[i][0], velocity = steps[i][1]; + CGEventRef event = strafe_create_ramp_event((StrafeDirection)dir, velocity, + phase, progress, modern, inverted); + assert(event && strafe_event_is_strafe(event)); + // Ramps carry per-phase velocity on every phase, unlike the instant shape. + assert(strafe_event_swipe_velocity_x(event) == sign * velocity); + assert(CGEventGetDoubleValueField(event, (CGEventField)130) == sign * velocity); + CFDataRef data = CGEventCreateData(NULL, event); + assert(data); + CGEventRef copy = CGEventCreateFromData(NULL, data); + assert(copy); + 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); + // Progress crosses a float conversion in serialization; velocities here + // are exactly representable and compare exactly. + assert(fabs(strafe_event_swipe_progress(copy) - sign * progress) < 1e-7); + assert(strafe_event_swipe_velocity_x(copy) == sign * velocity); + if (modern) { + assert(CGEventGetIntegerValueField(copy, (CGEventField)134) == phase); + // Re-augmenting the copy replaces (not duplicates) the payload. + CGEventRef again = strafe_augment(copy); + assert(again); + CFRelease(again); + } + CFRelease(copy); + CFRelease(data); + CFRelease(event); + } + // Invalid ramp inputs: bad direction/phase, negative or non-finite progress. + assert(!strafe_create_ramp_event((StrafeDirection)42, 130, 2, 0.1, false, false)); + assert(!strafe_create_ramp_event(StrafeDirectionRight, 130, 3, 0.1, true, false)); + assert(!strafe_create_ramp_event(StrafeDirectionRight, 130, 2, -0.1, false, false)); + assert(!strafe_create_ramp_event(StrafeDirectionRight, 130, 2, NAN, true, false)); + assert(!strafe_create_ramp_event(StrafeDirectionRight, -1, 2, 0.1, false, false)); +} + +static void expose(void) { + int dock = -1, layer18 = -1, layer20 = -1; + strafe_expose_counts(&dock, &layer18, &layer20); + assert(dock >= 0 && layer18 >= 0 && layer20 >= 0); + assert(layer18 + layer20 <= dock); + strafe_expose_counts(NULL, NULL, NULL); // NULL out-parameters are allowed +} + +int main(void) { + events(); invalid(); topology(); ramps(); expose(); + puts("CStrafe: 32 instant + 24 ramp event round-trips, invalid input/wire, topology and overlay-census tests passed (no posting)."); + return 0; +} diff --git a/Tests/SwitchEngineTests.swift b/Tests/SwitchEngineTests.swift new file mode 100644 index 0000000..7dff92c --- /dev/null +++ b/Tests/SwitchEngineTests.swift @@ -0,0 +1,492 @@ +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.testSnapshotOverlayBlocksAdmissionWithoutPosting() + try tests.testPhysicalGestureMappingAndPassthrough() + try tests.testRampSpeedPostsBeganChangedStreamAndEnded() + try tests.testTransientAnomalyRecovers() + try tests.testSingleDeliveryFailureKeepsInterception() + try tests.testThreeConsecutiveFailuresPauseInterception() + try tests.testOverlayGraceSuppressesFailureCallback() + SwitchDiagnostics.flush() + print("Swift: 15 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 snapshotOverlay = 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) + } + + var progresses: [Double] = [] + + func post(_ event: CGEvent) { + lock.lock() + defer { lock.unlock() } + let phase = strafe_event_gesture_phase(event) + phases.append(phase) + progresses.append(strafe_event_swipe_progress(event)) + 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?], [Double]) { + lock.lock() + defer { lock.unlock() } + return (index, phases, times, readDisplays, progresses) + } + + var dependencies: GestureSwitchEngine.Dependencies { + .init(read: { self.read($0) }, post: { self.post($0) }, overlayActive: { + self.lock.lock() + defer { self.lock.unlock() } + return self.overlay + }, overlaySnapshot: { + self.lock.lock() + defer { self.lock.unlock() } + return self.snapshotOverlay + }) + } + } + + 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) + 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) + 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() + // 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() + } + try engine.switchSpace(.right) { result in + guard case .failure(.pendingDropped) = result else { fatalError("Expected pending drop: \(result)") } + dropped.fulfill() + } + failed.wait() + dropped.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])) + } + } + + func testRampSpeedPostsBeganChangedStreamAndEnded() throws { + let desktop = Desktop() + desktop.enableMoves() + let engine = GestureSwitchEngine(configuration: try config(), dependencies: desktop.dependencies) + precondition(engine.transitionSpeed == .instant) + engine.setTransitionSpeed(.quick) + precondition(engine.transitionSpeed == .quick) + let finished = CompletionWaiter() + try engine.switchSpace(.right) { result in + guard case .success = result else { fatalError("Ramp failed: \(result)") } + finished.fulfill() + } + finished.wait() + let (index, phases, _, _, progresses) = desktop.snapshot() + precondition(index == 2) + precondition(phases == [1] + Array(repeating: Int64(2), count: TransitionSpeed.rampSteps) + [4]) + precondition(abs(progresses[0]) == 0) + let stream = progresses.dropFirst() + precondition(stream.allSatisfy { $0 > 0 }) + // Progress crosses a float conversion inside the event store. + precondition(abs(stream.last! - TransitionSpeed.rampPeakProgress) < 1e-6) + for pair in zip(stream, stream.dropFirst()) { precondition(pair.1 >= pair.0) } + } + + /// Scripted topology/overlay source for anomaly tests. Reads and overlay + /// answers play from fixed scripts (repeating the tail), so poll-count + /// timing never runs the script out. + private final class Scripted: @unchecked Sendable { + typealias Topology = GestureSwitchEngine.Topology + let lock = NSLock() + var reads: [Topology] = [] + var overlays: [Bool] = [] + var phases: [Int64] = [] + var readCalls = 0 + var overlayCalls = 0 + + func topology(display: String, index: UInt32, id: UInt64) -> Topology { + Topology(display: display, index: index, count: 4, id: id) + } + + var dependencies: GestureSwitchEngine.Dependencies { + .init(read: { [self] _ in + self.lock.lock() + defer { self.lock.unlock() } + self.readCalls += 1 + return self.reads[min(self.readCalls - 1, self.reads.count - 1)] + }, post: { [self] event in + self.lock.lock() + defer { self.lock.unlock() } + self.phases.append(strafe_event_gesture_phase(event)) + }, overlayActive: { [self] in + self.lock.lock() + defer { self.lock.unlock() } + self.overlayCalls += 1 + return self.overlays[min(self.overlayCalls - 1, self.overlays.count - 1)] + }) + } + } + + func testTransientAnomalyRecovers() 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) + let target = scripted.topology(display: "display-A", index: 2, id: 22) + // Admission read, cursor-routing recheck, one bad poll, then steady target. + scripted.reads = [origin, origin, anomaly, target, target, target, target, + target, target, target, target, target, target] + scripted.overlays = [false] + let engine = GestureSwitchEngine(configuration: try config(), dependencies: scripted.dependencies) + let finished = CompletionWaiter() + try engine.switchSpace(.right) { result in + guard case .success = result else { fatalError("Transient anomaly was fatal: \(result)") } + finished.fulfill() + } + finished.wait() + precondition(scripted.phases == [1, 2, 4]) + } + + 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) + 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() + } + + 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 { + 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] + // Overlay up for the first request only: it fails cleanly as + // overlayActive and stamps the grace window for what follows. + scripted.overlays = [true, false] + let engine = GestureSwitchEngine(configuration: try config(), dependencies: scripted.dependencies) + engine.setDeliveryFailureHandler { _ in fatalError("Grace failed: callback fired") } + let first = CompletionWaiter() + try engine.switchSpace(.right) { result in + guard case .failure(.overlayActive) = result else { fatalError("Overlay not respected: \(result)") } + first.fulfill() + } + first.wait() + // Topology still mid-flight from Mission Control's close animation: + // the request fails, but interception must survive it. + let second = CompletionWaiter() + try engine.switchSpace(.right) { result in + guard case .failure(.unexpectedChange) = result else { fatalError("Expected mismatch: \(result)") } + second.fulfill() + } + 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) } + } + + 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 + } + // 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 == [.left]) + for phase: Int64 in [1, 2, 4] { + precondition(interceptor.handle(type: CGEventType(rawValue: 30)!, event: event(phase, right: true)) == nil) + } + 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)) + 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 == [.left, .right, .left]) + 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) + } +}