├── .clang-format ├── accessibility.entitlements ├── .gitignore ├── src ├── aerospace.h ├── haptic.h ├── event_tap.h ├── event_tap.m ├── haptic.c ├── config.h ├── aerospace.c └── main.m ├── com.acsandmann.swipe.plist.in ├── LICENSE ├── install.sh ├── README.md ├── config.md ├── uninstall.sh ├── makefile └── .github └── workflows └── release.yml /.clang-format: -------------------------------------------------------------------------------- 1 | --- 2 | BasedOnStyle: WebKit 3 | TabWidth: '4' 4 | UseTab: Always 5 | 6 | ... 7 | 8 | -------------------------------------------------------------------------------- /accessibility.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.accessibility 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Compiled object files 2 | *.o 3 | *.obj 4 | 5 | # Precompiled Headers 6 | *.gch 7 | *.pch 8 | 9 | # Libraries 10 | *.lib 11 | *.a 12 | *.la 13 | *.lo 14 | 15 | # Executables 16 | *.exe 17 | *.out 18 | *.app 19 | 20 | swipe 21 | swipe.dSYM 22 | Swipe.app 23 | AerospaceSwipe.app 24 | AeroSpaceSwipe.app 25 | swipe.dSYM 26 | -------------------------------------------------------------------------------- /src/aerospace.h: -------------------------------------------------------------------------------- 1 | #define AEROSPACE_H 2 | 3 | #include 4 | #include 5 | #include 6 | 7 | typedef struct aerospace aerospace; 8 | 9 | aerospace* aerospace_new(const char* socketPath); 10 | 11 | int aerospace_is_initialized(aerospace* client); 12 | 13 | void aerospace_close(aerospace* client); 14 | 15 | char* aerospace_switch(aerospace* client, const char* direction); 16 | 17 | char* aerospace_workspace(aerospace* client, int wrap_around, const char* ws_command, const char* stdin_payload); 18 | 19 | char* aerospace_list_workspaces(aerospace* client, bool include_empty); 20 | -------------------------------------------------------------------------------- /src/haptic.h: -------------------------------------------------------------------------------- 1 | #define HAPTIC_H 2 | 3 | #include 4 | 5 | extern CFTypeRef MTActuatorCreateFromDeviceID(UInt64 deviceID); 6 | extern IOReturn MTActuatorOpen(CFTypeRef actuatorRef); 7 | extern IOReturn MTActuatorClose(CFTypeRef actuatorRef); 8 | extern IOReturn MTActuatorActuate(CFTypeRef actuatorRef, SInt32 actuationID, 9 | UInt32 unknown1, Float32 unknown2, 10 | Float32 unknown3); 11 | extern bool MTActuatorIsOpen(CFTypeRef actuatorRef); 12 | 13 | CFTypeRef haptic_open(uint64_t deviceID); 14 | CFTypeRef haptic_open_default(void); 15 | CFMutableArrayRef haptic_open_all(void); 16 | 17 | bool haptic_actuate(CFTypeRef actuator, int32_t pattern); 18 | void haptic_actuate_all(CFArrayRef actuators, int32_t pattern); 19 | 20 | void haptic_close(CFTypeRef actuator); 21 | void haptic_close_all(CFArrayRef actuators); 22 | -------------------------------------------------------------------------------- /com.acsandmann.swipe.plist.in: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Label 6 | com.acsandmann.swipe 7 | 8 | ProgramArguments 9 | 10 | @TARGET_PATH@ 11 | 12 | 13 | RunAtLoad 14 | 15 | 16 | KeepAlive 17 | 18 | 19 | LimitLoadToSessionType 20 | Aqua 21 | 22 | ProcessType 23 | Interactive 24 | 25 | Nice 26 | 0 27 | 28 | StandardOutPath 29 | /tmp/swipe.out 30 | StandardErrorPath 31 | /tmp/swipe.err 32 | 33 | 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /install.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | REPO="acsandmann/aerospace-swipe" 6 | INSTALL_DIR="$HOME/.local/share/aerospace-swipe" 7 | 8 | GREEN='\033[0;32m' 9 | BLUE='\033[0;34m' 10 | RED='\033[0;31m' 11 | NC='\033[0m' 12 | 13 | log_info() { echo -e "${BLUE}[INFO]${NC} $1"; } 14 | log_success() { echo -e "${GREEN}[SUCCESS]${NC} $1"; } 15 | log_error() { echo -e "${RED}[ERROR]${NC} $1"; } 16 | 17 | if [[ -f "makefile" && -f "src/main.m" ]]; then 18 | log_info "running in repository directory, using local code..." 19 | make install 20 | log_success "installation complete" 21 | exit 0 22 | fi 23 | 24 | log_info "downloading aerospace-swipe..." 25 | 26 | mkdir -p "$INSTALL_DIR" 27 | 28 | if [[ -d "$INSTALL_DIR/.git" ]]; then 29 | log_info "repository already exists, updating..." 30 | cd "$INSTALL_DIR" 31 | git pull 32 | else 33 | git clone "https://github.com/${REPO}.git" "$INSTALL_DIR" 34 | fi 35 | 36 | SOURCE_DIR="$INSTALL_DIR" 37 | 38 | if [[ ! -d "$SOURCE_DIR" || ! -f "$SOURCE_DIR/makefile" ]]; then 39 | log_error "could not find source directory with makefile" 40 | exit 1 41 | fi 42 | 43 | cd "$SOURCE_DIR" 44 | 45 | log_info "installing aerospace-swipe..." 46 | make install 47 | 48 | log_success "installation complete" 49 | echo 50 | echo "aerospace-swipe has been installed and should start automatically upon being given accessibility permission (it will prompt you)" 51 | -------------------------------------------------------------------------------- /src/event_tap.h: -------------------------------------------------------------------------------- 1 | #pragma once 2 | #include 3 | #import 4 | #import 5 | #include 6 | #include 7 | #include 8 | 9 | #define ACTIVATE_PCT 0.05f 10 | #define END_PHASE 8 // NSTouchPhaseEnded 11 | #define FAST_VEL_FACTOR 0.80f 12 | #define MAX_TOUCHES 16 13 | 14 | extern const char* get_name_for_pid(uint64_t pid); 15 | extern char* string_copy(char* s); 16 | 17 | struct event_tap { 18 | CFMachPortRef handle; 19 | CFRunLoopSourceRef runloop_source; 20 | CGEventMask mask; 21 | }; 22 | 23 | typedef struct { 24 | double x; 25 | double y; 26 | int phase; 27 | double timestamp; 28 | double velocity; 29 | bool is_palm; 30 | } touch; 31 | 32 | typedef struct { 33 | double x; 34 | double y; 35 | double timestamp; 36 | } touch_state; 37 | 38 | // Gesture state enumeration 39 | typedef enum { 40 | GS_IDLE, 41 | GS_ARMED, 42 | GS_COMMITTED 43 | } gesture_state; 44 | 45 | // Gesture context structure 46 | typedef struct { 47 | gesture_state state; 48 | float start_x, start_y, peak_velx; 49 | int dir, last_fire_dir; 50 | float prev_x[MAX_TOUCHES], base_x[MAX_TOUCHES]; 51 | } gesture_ctx; 52 | 53 | // Palm rejection tracking structure 54 | typedef struct { 55 | CGPoint start, last; 56 | CFTimeInterval t_start, t_last; 57 | CGFloat travel; 58 | bool is_palm, seen; 59 | } finger_track; 60 | 61 | @interface TouchConverter : NSObject 62 | + (touch)convert_nstouch:(id)nsTouch; 63 | @end 64 | 65 | extern struct event_tap g_event_tap; 66 | static CFMutableDictionaryRef touchStates; 67 | 68 | bool event_tap_enabled(struct event_tap* event_tap); 69 | bool event_tap_begin(struct event_tap* event_tap, CGEventRef (*reference)(CGEventTapProxy proxy, CGEventType type, CGEventRef event, void* userdata)); 70 | void event_tap_end(struct event_tap* event_tap); 71 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # aerospace workspace switching with trackpad swipes 2 | 3 | aerospace-swipe detects x-fingered(defaults to 3) swipes on your trackpad and correspondingly switches between [aerospace](https://github.com/nikitabobko/AeroSpace) workspaces. 4 | 5 | ## features 6 | - fast swipe detection and forwarding to aerospace (uses aerospace server's socket instead of cli) 7 | - works with any number of fingers (default is 3, can be changed in config) 8 | - skips empty workspaces (if enabled in config) 9 | - ignores your palm if it is resting on the trackpad 10 | - haptics on swipe (this is off by default) 11 | - customizable swipe directions (natural or inverted) 12 | - swipe will wrap around workspaces (ex 1-9 workspaces, swipe right from 9 will go to 1) 13 | - utilizes [yyjson](https://github.com/ibireme/yyjson) for performant json ser/de 14 | 15 | ## configuration 16 | config file is optional and only needed if you want to change the default settings(default settings are shown in the example below) 17 | 18 | > to restart after changing the config file, run `make restart`(this just unloads and reloads the launch agent) 19 | 20 | ```jsonc 21 | // ~/.config/aerospace-swipe/config.json 22 | { 23 | "haptic": false, 24 | "natural_swipe": false, 25 | "wrap_around": true, 26 | "skip_empty": true, 27 | "fingers": 3 28 | } 29 | ``` 30 | 31 | ## installation 32 | ### script 33 | ```bash 34 | curl -sSL https://raw.githubusercontent.com/acsandmann/aerospace-swipe/main/install.sh | bash 35 | ``` 36 | ### manual 37 | ```bash 38 | git clone https://github.com/acsandmann/aerospace-swipe.git 39 | cd aerospace-swipe 40 | 41 | make install # installs a launchd service 42 | ``` 43 | ## uninstallation 44 | ### script 45 | ```bash 46 | curl -sSL https://raw.githubusercontent.com/acsandmann/aerospace-swipe/main/uninstall.sh | bash 47 | ``` 48 | ### manual 49 | ```bash 50 | make uninstall 51 | ``` 52 | -------------------------------------------------------------------------------- /config.md: -------------------------------------------------------------------------------- 1 | # aerospace‑swipe advanced config 2 | aside from the basic config options presented in the readme, aerospace-swipe exposes a number of so-called tuning knobs in order to fine tune the swipe detection to your liking. everyone's hands and fingers are different, so the defaults(as much time as i spent on them) may not work for you. this prescribes the available options and how to use them. 3 | 4 | ## option reference 5 | beneath each key you will find its `type` and `default value`. thresholds expressed as percentages are relative to the full width of the track pad. 6 | 7 | ### `natural_swipe` · *bool* · default **false** 8 | 9 | reverses logical direction so a physical swipe **right** moves **forward** instead of back. 10 | 11 | ### `wrap_around` · *bool* · default **true** 12 | 13 | allows cycling from the last workspace directly to the first (and vice‑versa). 14 | 15 | ### `haptic` · *bool* · default **false** 16 | 17 | triggers a short haptic pulse after every successful workspace switch. 18 | 19 | ### `skip_empty` · *bool* · default **true** 20 | 21 | when *true*, empty workspaces are removed from the cycling order (`aerospace_list_workspaces()` is called with `!skip_empty`). 22 | 23 | ### `fingers` · *int* · default **3** 24 | 25 | exact finger count required for a gesture to register. 26 | 27 | ### `distance_pct` · *float* · default **0.12** 28 | 29 | horizontal travel needed (≥12%) before a **slow** swipe may fire. 30 | 31 | ### `velocity_pct` · *float* · default **0.50** 32 | 33 | velocity threshold expressed as fraction of pad-width/sec. 34 | 35 | * classifies a swipe as *fast* when `|v| ≥ velocity_pct × FAST_VEL_FACTOR`. 36 | * fires immediately when `|avg_vel| ≥ velocity_pct`. 37 | * works with `settle_factor` to decide when a motion has "coasted" to a stop. 38 | 39 | ### `settle_factor` · *float* · default **0.15** 40 | 41 | fraction of `velocity_pct` under which a swipe is considered settled. lower values end flicks sooner; higher values wait longer. 42 | 43 | ### `min_step` · *float* · default **0.005** 44 | 45 | minimum per‑frame horizontal movement each finger must keep while a **slow** gesture is tracked. prevents micro‑stutters from invalidating the gesture. 46 | 47 | ### `min_travel` · *float* · default **0.015** 48 | 49 | aggregate travel required to transition from *idle* -> *armed* while moving slowly. 50 | 51 | ### `min_step_fast` · *float* · default **0.0** 52 | 53 | reduced per frame requirement that applies only when the swipe is already classified as *fast*. 54 | 55 | ### `min_travel_fast` · *float* · default **0.006** 56 | 57 | smaller distance threshold to arm a *fast* swipe. 58 | -------------------------------------------------------------------------------- /uninstall.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | REPO="acsandmann/aerospace-swipe" 6 | INSTALL_DIR="$HOME/.local/share/aerospace-swipe" 7 | 8 | GREEN='\033[0;32m' 9 | BLUE='\033[0;34m' 10 | RED='\033[0;31m' 11 | YELLOW='\033[1;33m' 12 | NC='\033[0m' 13 | 14 | log_info() { echo -e "${BLUE}[INFO]${NC} $1"; } 15 | log_success() { echo -e "${GREEN}[SUCCESS]${NC} $1"; } 16 | log_error() { echo -e "${RED}[ERROR]${NC} $1"; } 17 | log_warning() { echo -e "${YELLOW}[WARNING]${NC} $1"; } 18 | 19 | if [[ "$1" == "--help" || "$1" == "-h" ]]; then 20 | echo "usage: $0 [OPTIONS]" 21 | echo "" 22 | echo "Options:" 23 | echo " -h, --help Show this help message" 24 | echo " --keep-source Keep the source directory after uninstalling" 25 | echo "" 26 | echo "this script will uninstall aerospace-swipe and optionally remove the source directory" 27 | exit 0 28 | fi 29 | 30 | echo "aerospace-swipe uninstaller" 31 | echo "===========================" 32 | 33 | if [[ -f "makefile" && -f "src/main.m" ]]; then 34 | log_info "running in repository directory, using local code..." 35 | if make uninstall; then 36 | log_success "uninstallation complete" 37 | else 38 | log_error "uninstallation failed" 39 | exit 1 40 | fi 41 | exit 0 42 | fi 43 | 44 | if [[ ! -d "$INSTALL_DIR" ]]; then 45 | log_warning "Installation directory not found at $INSTALL_DIR" 46 | log_info "aerospace-swipe may not be installed or was installed differently" 47 | exit 0 48 | fi 49 | 50 | if [[ ! -f "$INSTALL_DIR/makefile" ]]; then 51 | log_error "makefile not found in $INSTALL_DIR" 52 | log_info "the installation appears to be corrupted" 53 | read -p "remove the installation directory anyway? (y/N): " -n 1 -r 54 | echo 55 | if [[ $REPLY =~ ^[Yy]$ ]]; then 56 | rm -rf "$INSTALL_DIR" 57 | log_success "installation directory removed" 58 | fi 59 | exit 1 60 | fi 61 | 62 | cd "$INSTALL_DIR" 63 | 64 | log_info "uninstalling aerospace-swipe..." 65 | if make uninstall; then 66 | log_success "aerospace-swipe service uninstalled successfully" 67 | else 68 | log_error "failed to run make uninstall" 69 | exit 1 70 | fi 71 | 72 | if [[ "$1" != "--keep-source" ]]; then 73 | echo 74 | read -p "remove source directory at $INSTALL_DIR? (y/N): " -n 1 -r 75 | echo 76 | if [[ $REPLY =~ ^[Yy]$ ]]; then 77 | cd "$HOME" 78 | rm -rf "$INSTALL_DIR" 79 | log_success "source directory removed" 80 | else 81 | log_info "source directory kept at $INSTALL_DIR" 82 | fi 83 | else 84 | log_info "source directory kept at $INSTALL_DIR (--keep-source flag used)" 85 | fi 86 | 87 | log_success "uninstallation complete" 88 | echo 89 | echo "aerospace-swipe has been removed from your system" 90 | -------------------------------------------------------------------------------- /src/event_tap.m: -------------------------------------------------------------------------------- 1 | #import "event_tap.h" 2 | #import 3 | #include 4 | #include 5 | #include 6 | #include 7 | #include 8 | 9 | struct event_tap g_event_tap = { 0 }; 10 | 11 | @implementation TouchConverter 12 | 13 | + (touch)convert_nstouch:(id)nsTouch 14 | { 15 | NSTouch* touchObj = (NSTouch*)nsTouch; 16 | touch nt; 17 | 18 | CGPoint pos = [touchObj normalizedPosition]; 19 | nt.x = pos.x; 20 | nt.y = pos.y; 21 | 22 | nt.phase = (int)[touchObj phase]; 23 | nt.timestamp = [[touchObj valueForKey:@"timestamp"] doubleValue]; 24 | 25 | id touchIdentity = [touchObj identity]; 26 | 27 | if (!touchStates) { 28 | touchStates = CFDictionaryCreateMutable(NULL, 0, 29 | &kCFTypeDictionaryKeyCallBacks, 30 | NULL); 31 | } 32 | 33 | double velocity_x = 0.0; 34 | touch_state* state = (touch_state*)CFDictionaryGetValue(touchStates, (__bridge const void*)(touchIdentity)); 35 | if (state) { 36 | double dt = nt.timestamp - state->timestamp; 37 | if (dt > 0) 38 | velocity_x = (nt.x - state->x) / dt; 39 | state->x = nt.x; 40 | state->y = nt.y; 41 | state->timestamp = nt.timestamp; 42 | } else { 43 | state = malloc(sizeof(touch_state)); 44 | if (state) { 45 | state->x = nt.x; 46 | state->y = nt.y; 47 | state->timestamp = nt.timestamp; 48 | CFDictionarySetValue(touchStates, (__bridge const void*)(touchIdentity), state); 49 | } 50 | } 51 | nt.velocity = velocity_x; 52 | 53 | if (nt.phase == 8) { 54 | CFDictionaryRemoveValue(touchStates, (__bridge const void*)(touchIdentity)); 55 | if (state) 56 | free(state); 57 | } 58 | 59 | return nt; 60 | } 61 | 62 | @end 63 | 64 | bool event_tap_enabled(struct event_tap* event_tap) 65 | { 66 | bool result = (event_tap->handle && CGEventTapIsEnabled(event_tap->handle)); 67 | return result; 68 | } 69 | 70 | bool event_tap_begin(struct event_tap* event_tap, CGEventRef (*reference)(CGEventTapProxy proxy, CGEventType type, CGEventRef event, void* userdata)) 71 | { 72 | event_tap->mask = 1 << NSEventTypeGesture; 73 | event_tap->handle = CGEventTapCreate( 74 | kCGHIDEventTap, 75 | kCGHeadInsertEventTap, 76 | kCGEventTapOptionListenOnly, 77 | event_tap->mask, 78 | *reference, 79 | event_tap); 80 | 81 | bool result = event_tap_enabled(event_tap); 82 | if (result) { 83 | event_tap->runloop_source = CFMachPortCreateRunLoopSource( 84 | kCFAllocatorDefault, 85 | event_tap->handle, 86 | 0); 87 | CFRunLoopAddSource(CFRunLoopGetMain(), 88 | event_tap->runloop_source, 89 | kCFRunLoopDefaultMode); 90 | } 91 | 92 | return result; 93 | } 94 | 95 | void event_tap_end(struct event_tap* event_tap) 96 | { 97 | if (event_tap_enabled(event_tap)) { 98 | CGEventTapEnable(event_tap->handle, false); 99 | CFMachPortInvalidate(event_tap->handle); 100 | CFRunLoopRemoveSource(CFRunLoopGetMain(), 101 | event_tap->runloop_source, 102 | kCFRunLoopCommonModes); 103 | CFRelease(event_tap->runloop_source); 104 | CFRelease(event_tap->handle); 105 | event_tap->handle = NULL; 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /makefile: -------------------------------------------------------------------------------- 1 | CC = clang 2 | CFLAGS = -std=c99 -O3 -march=native -flto -fomit-frame-pointer -funroll-loops -g -Wall -Wextra -Wno-pointer-integer-compare -Wno-incompatible-pointer-types-discards-qualifiers -Wno-absolute-value -fobjc-arc 3 | FRAMEWORKS = -framework CoreFoundation -framework IOKit -F/System/Library/PrivateFrameworks -framework MultitouchSupport -framework ApplicationServices -framework Cocoa 4 | LDLIBS = -ldl 5 | TARGET = swipe 6 | 7 | LAUNCH_AGENTS_DIR = $(HOME)/Library/LaunchAgents 8 | PLIST_FILE = com.acsandmann.swipe.plist 9 | PLIST_TEMPLATE = com.acsandmann.swipe.plist.in 10 | 11 | SRC_FILES = src/aerospace.c src/yyjson.c src/haptic.c src/event_tap.m src/main.m 12 | 13 | BINARY = swipe 14 | BINARY_NAME = AerospaceSwipe 15 | APP_BUNDLE = $(BINARY_NAME).app 16 | APP_CONTENTS = $(APP_BUNDLE)/Contents 17 | APP_MACOS = $(APP_CONTENTS)/MacOS 18 | INFO_PLIST = $(APP_CONTENTS)/Info.plist 19 | 20 | ABS_TARGET_PATH = $(shell pwd)/$(APP_MACOS)/$(BINARY_NAME) 21 | 22 | .PHONY: all clean sign install_plist load_plist uninstall_plist install uninstall 23 | 24 | ifeq ($(shell uname -sm),Darwin arm64) 25 | ARCH= -arch arm64 26 | else 27 | ARCH= -arch x86_64 28 | endif 29 | 30 | bundle: $(BINARY) 31 | @echo "Creating app bundle $(APP_BUNDLE)..." 32 | mkdir -p $(APP_MACOS) 33 | mkdir -p $(APP_CONTENTS)/Resources 34 | cp $(BINARY) $(APP_MACOS)/$(BINARY_NAME) 35 | @echo "Generating Info.plist..." 36 | @echo '' > $(INFO_PLIST) 37 | @echo '' >> $(INFO_PLIST) 38 | @echo '' >> $(INFO_PLIST) 39 | @echo '' >> $(INFO_PLIST) 40 | @echo ' CFBundleExecutable' >> $(INFO_PLIST) 41 | @echo ' $(BINARY_NAME)' >> $(INFO_PLIST) 42 | @echo ' CFBundleIdentifier' >> $(INFO_PLIST) 43 | @echo ' com.example.swipe' >> $(INFO_PLIST) 44 | @echo ' CFBundleName' >> $(INFO_PLIST) 45 | @echo ' $(BINARY_NAME)' >> $(INFO_PLIST) 46 | @echo ' CFBundlePackageType' >> $(INFO_PLIST) 47 | @echo ' APPL' >> $(INFO_PLIST) 48 | @echo ' NSPrincipalClass' >> $(INFO_PLIST) 49 | @echo ' NSApplication' >> $(INFO_PLIST) 50 | @echo ' LSUIElement' >> $(INFO_PLIST) 51 | @echo ' ' >> $(INFO_PLIST) 52 | @echo '' >> $(INFO_PLIST) 53 | @echo '' >> $(INFO_PLIST) 54 | @echo "APPL????" > $(APP_CONTENTS)/PkgInfo 55 | codesign --entitlements accessibility.entitlements --sign - $(APP_BUNDLE) 56 | 57 | all: $(TARGET) 58 | 59 | $(TARGET): $(SRC_FILES) 60 | $(CC) $(CFLAGS) $(ARCH) -o $(TARGET) $(SRC_FILES) $(FRAMEWORKS) $(LDLIBS) 61 | 62 | sign: $(TARGET) 63 | @echo "Signing $(TARGET) with accessibility entitlement..." 64 | codesign --entitlements accessibility.entitlements --sign - $(TARGET) 65 | 66 | install_plist: 67 | @echo "Generating launch agent plist with binary path $(ABS_TARGET_PATH)..." 68 | mkdir -p $(LAUNCH_AGENTS_DIR) 69 | sed "s|@TARGET_PATH@|$(ABS_TARGET_PATH)|g" $(PLIST_TEMPLATE) > $(LAUNCH_AGENTS_DIR)/$(PLIST_FILE) 70 | @echo "Launch agent plist installed to $(LAUNCH_AGENTS_DIR)/$(PLIST_FILE)" 71 | 72 | load_plist: 73 | @echo "Loading launch agent..." 74 | launchctl load $(LAUNCH_AGENTS_DIR)/$(PLIST_FILE) 75 | 76 | unload_plist: 77 | @echo "Unloading launch agent..." 78 | launchctl unload $(LAUNCH_AGENTS_DIR)/$(PLIST_FILE) 79 | 80 | uninstall_plist: 81 | @echo "Removing launch agent plist from $(LAUNCH_AGENTS_DIR)..." 82 | rm -f $(LAUNCH_AGENTS_DIR)/$(PLIST_FILE) 83 | 84 | build: all sign 85 | 86 | install: all bundle install_plist load_plist 87 | 88 | uninstall: unload_plist uninstall_plist clean 89 | 90 | restart: unload_plist load_plist 91 | 92 | format: 93 | clang-format -i -- **/**.c **/**.h **/**.m 94 | 95 | clean: 96 | rm -rf $(TARGET) $(APP_BUNDLE) 97 | -------------------------------------------------------------------------------- /src/haptic.c: -------------------------------------------------------------------------------- 1 | #include "haptic.h" 2 | 3 | #include 4 | #include 5 | #include 6 | #include 7 | 8 | #define CF_RELEASE(obj) \ 9 | do { \ 10 | if ((obj) != NULL) { \ 11 | CFRelease(obj); \ 12 | (obj) = NULL; \ 13 | } \ 14 | } while (0) 15 | 16 | static const CFStringRef kMTRegistryKeyID = CFSTR("Multitouch ID"); 17 | 18 | CFTypeRef haptic_open(uint64_t deviceID) 19 | { 20 | CFTypeRef act = MTActuatorCreateFromDeviceID(deviceID); 21 | if (!act) { 22 | fprintf(stderr, "No actuator for device %llu\n", 23 | (unsigned long long)deviceID); 24 | return NULL; 25 | } 26 | IOReturn kr = MTActuatorOpen(act); 27 | if (kr != kIOReturnSuccess) { 28 | fprintf(stderr, "MTActuatorOpen: 0x%04x (%s)\n", 29 | kr, mach_error_string(kr)); 30 | CF_RELEASE(act); 31 | } 32 | return act; 33 | } 34 | 35 | static void iterate_multitouch(io_iterator_t iter, 36 | void (^callback)(uint64_t devID)) 37 | { 38 | io_object_t dev; 39 | while ((dev = IOIteratorNext(iter))) { 40 | 41 | CFNumberRef idRef = (CFNumberRef) 42 | IORegistryEntryCreateCFProperty(dev, kMTRegistryKeyID, 43 | kCFAllocatorDefault, 0); 44 | 45 | if (idRef && CFGetTypeID(idRef) == CFNumberGetTypeID()) { 46 | uint64_t id = 0; 47 | CFNumberGetValue(idRef, kCFNumberSInt64Type, &id); 48 | callback(id); 49 | } 50 | 51 | CF_RELEASE(idRef); 52 | IOObjectRelease(dev); 53 | } 54 | } 55 | 56 | static io_iterator_t matching_iterator(void) 57 | { 58 | io_iterator_t it = MACH_PORT_NULL; 59 | kern_return_t kr = IOServiceGetMatchingServices( 60 | kIOMainPortDefault, 61 | IOServiceMatching("AppleMultitouchDevice"), 62 | &it); 63 | if (kr != KERN_SUCCESS) 64 | return MACH_PORT_NULL; 65 | 66 | return it; 67 | } 68 | 69 | CFTypeRef haptic_open_default(void) 70 | { 71 | io_iterator_t it = matching_iterator(); 72 | if (it == MACH_PORT_NULL) 73 | return NULL; 74 | 75 | __block CFTypeRef chosen = NULL; 76 | 77 | iterate_multitouch(it, ^(uint64_t id) { 78 | if (!chosen) 79 | chosen = haptic_open(id); 80 | }); 81 | 82 | IOObjectRelease(it); 83 | return chosen; 84 | } 85 | 86 | CFMutableArrayRef haptic_open_all(void) 87 | { 88 | CFMutableArrayRef arr = CFArrayCreateMutable(kCFAllocatorDefault, 0, &kCFTypeArrayCallBacks); 89 | 90 | io_iterator_t it = matching_iterator(); 91 | if (it == MACH_PORT_NULL) 92 | return arr; 93 | 94 | iterate_multitouch(it, ^(uint64_t id) { 95 | CFTypeRef act = haptic_open(id); 96 | if (act) 97 | CFArrayAppendValue(arr, act); 98 | }); 99 | 100 | IOObjectRelease(it); 101 | return arr; 102 | } 103 | 104 | static inline IOReturn _actuate(CFTypeRef act, int32_t pattern) 105 | { 106 | if (!act || !MTActuatorIsOpen(act)) 107 | return kIOReturnNotOpen; 108 | 109 | return MTActuatorActuate(act, pattern, 0, 0.0f, 0.0f); 110 | } 111 | 112 | bool haptic_actuate(CFTypeRef act, int32_t pattern) 113 | { 114 | IOReturn kr = _actuate(act, pattern); 115 | if (kr != kIOReturnSuccess) { 116 | fprintf(stderr, "haptic_actuate: 0x%04x (%s)\n", kr, mach_error_string(kr)); 117 | return false; 118 | } 119 | return true; 120 | } 121 | 122 | void haptic_actuate_all(CFArrayRef arr, int32_t pattern) 123 | { 124 | if (!arr) 125 | return; 126 | CFIndex n = CFArrayGetCount(arr); 127 | for (CFIndex i = 0; i < n; ++i) 128 | _actuate(CFArrayGetValueAtIndex(arr, i), pattern); 129 | } 130 | 131 | void haptic_close(CFTypeRef act) 132 | { 133 | if (act && MTActuatorIsOpen(act)) 134 | MTActuatorClose(act); 135 | 136 | CF_RELEASE(act); 137 | } 138 | 139 | void haptic_close_all(CFArrayRef arr) 140 | { 141 | if (!arr) 142 | return; 143 | CFIndex n = CFArrayGetCount(arr); 144 | for (CFIndex i = 0; i < n; ++i) 145 | haptic_close((CFTypeRef)CFArrayGetValueAtIndex(arr, i)); 146 | 147 | CFRelease(arr); 148 | } 149 | -------------------------------------------------------------------------------- /src/config.h: -------------------------------------------------------------------------------- 1 | #include 2 | #define CONFIG_H 3 | 4 | #include "yyjson.h" 5 | #include 6 | #include 7 | #include 8 | #include 9 | #include 10 | #include 11 | 12 | typedef struct { 13 | bool natural_swipe; 14 | bool wrap_around; 15 | bool haptic; 16 | bool skip_empty; 17 | int fingers; 18 | int swipe_tolerance; 19 | float distance_pct; // distance 20 | float velocity_pct; // velocity 21 | float settle_factor; 22 | float min_step; 23 | float min_travel; 24 | float min_step_fast; 25 | float min_travel_fast; 26 | float palm_disp; 27 | CFTimeInterval palm_age; 28 | float palm_velocity; 29 | const char* swipe_left; 30 | const char* swipe_right; 31 | } Config; 32 | 33 | static Config default_config() 34 | { 35 | Config config; 36 | config.natural_swipe = false; 37 | config.wrap_around = true; 38 | config.haptic = false; 39 | config.skip_empty = true; 40 | config.fingers = 3; 41 | config.swipe_tolerance = 0; 42 | config.distance_pct = 0.08f; // ≥8 % travel triggers 43 | config.velocity_pct = 0.30f; // ≥0.30 × w pts / s triggers 44 | config.settle_factor = 0.15f; // ≤15 % of flick speed -> flick ended 45 | config.min_step = 0.005f; 46 | config.min_travel = 0.015f; 47 | config.min_step_fast = 0.0f; 48 | config.min_travel_fast = 0.003f; 49 | config.palm_disp = 0.025; // 2.5% pad from origin 50 | config.palm_age = 0.06; // 60ms before judgment 51 | config.palm_velocity = 0.1; // 10% of pad dimension per second 52 | config.swipe_left = "prev"; 53 | config.swipe_right = "next"; 54 | return config; 55 | } 56 | 57 | static int read_file_to_buffer(const char* path, char** out, size_t* size) 58 | { 59 | FILE* file = fopen(path, "rb"); 60 | if (!file) 61 | return 0; 62 | 63 | struct stat st; 64 | if (stat(path, &st) != 0) { 65 | fclose(file); 66 | return 0; 67 | } 68 | *size = st.st_size; 69 | 70 | *out = (char*)malloc(*size + 1); 71 | if (!*out) { 72 | fclose(file); 73 | return 0; 74 | } 75 | 76 | fread(*out, 1, *size, file); 77 | (*out)[*size] = '\0'; 78 | fclose(file); 79 | return 1; 80 | } 81 | 82 | static Config load_config() 83 | { 84 | Config config = default_config(); 85 | 86 | char* buffer = NULL; 87 | size_t buffer_size = 0; 88 | const char* paths[] = { "./config.json", NULL }; 89 | 90 | char fallback_path[512]; 91 | struct passwd* pw = getpwuid(getuid()); 92 | if (pw) { 93 | snprintf(fallback_path, sizeof(fallback_path), 94 | "%s/.config/aerospace-swipe/config.json", pw->pw_dir); 95 | paths[1] = fallback_path; 96 | } 97 | 98 | for (int i = 0; i < 2; ++i) { 99 | if (paths[i] && read_file_to_buffer(paths[i], &buffer, &buffer_size)) { 100 | printf("Loaded config from: %s\n", paths[i]); 101 | break; 102 | } 103 | } 104 | 105 | if (!buffer) { 106 | fprintf(stderr, "Using default configuration.\n"); 107 | return config; 108 | } 109 | 110 | yyjson_doc* doc = yyjson_read(buffer, buffer_size, 0); 111 | free(buffer); 112 | if (!doc) { 113 | fprintf(stderr, "Failed to parse config JSON. Using defaults.\n"); 114 | return config; 115 | } 116 | 117 | yyjson_val* root = yyjson_doc_get_root(doc); 118 | yyjson_val* item; 119 | 120 | item = yyjson_obj_get(root, "natural_swipe"); 121 | if (item && yyjson_is_bool(item)) 122 | config.natural_swipe = yyjson_get_bool(item); 123 | 124 | item = yyjson_obj_get(root, "wrap_around"); 125 | if (item && yyjson_is_bool(item)) 126 | config.wrap_around = yyjson_get_bool(item); 127 | 128 | item = yyjson_obj_get(root, "haptic"); 129 | if (item && yyjson_is_bool(item)) 130 | config.haptic = yyjson_get_bool(item); 131 | 132 | item = yyjson_obj_get(root, "skip_empty"); 133 | if (item && yyjson_is_bool(item)) 134 | config.skip_empty = yyjson_get_bool(item); 135 | 136 | item = yyjson_obj_get(root, "fingers"); 137 | if (item && yyjson_is_int(item)) 138 | config.fingers = (int)yyjson_get_int(item); 139 | 140 | item = yyjson_obj_get(root, "swipe_tolerance"); 141 | if (item && yyjson_is_int(item)) 142 | config.swipe_tolerance = (int)yyjson_get_int(item); 143 | 144 | item = yyjson_obj_get(root, "distance_pct"); 145 | if (item && yyjson_is_real(item)) 146 | config.distance_pct = (float)yyjson_get_real(item); 147 | 148 | item = yyjson_obj_get(root, "velocity_pct"); 149 | if (item && yyjson_is_real(item)) 150 | config.velocity_pct = (float)yyjson_get_real(item); 151 | 152 | item = yyjson_obj_get(root, "settle_factor"); 153 | if (item && yyjson_is_real(item)) 154 | config.settle_factor = (float)yyjson_get_real(item); 155 | 156 | config.swipe_left = config.natural_swipe ? "next" : "prev"; 157 | config.swipe_right = config.natural_swipe ? "prev" : "next"; 158 | 159 | yyjson_doc_free(doc); 160 | return config; 161 | } 162 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - "v*" 7 | 8 | jobs: 9 | build: 10 | runs-on: macos-latest 11 | permissions: 12 | contents: write 13 | 14 | steps: 15 | - uses: actions/checkout@v4 16 | 17 | - name: Set up Xcode 18 | uses: maxim-lobanov/setup-xcode@v1 19 | with: 20 | xcode-version: latest-stable 21 | 22 | - name: Build application 23 | run: | 24 | make bundle 25 | 26 | - name: Verify build output 27 | run: | 28 | if [ ! -d "AerospaceSwipe.app" ]; then 29 | echo "Error: AerospaceSwipe.app not found after build" 30 | ls -la 31 | exit 1 32 | fi 33 | echo "Build verification successful" 34 | 35 | - name: Remove quarantine attributes 36 | run: | 37 | echo "Removing quarantine attributes from the app..." 38 | xattr -cr AerospaceSwipe.app 39 | echo "Quarantine attributes removed" 40 | 41 | - name: Create release packages 42 | run: | 43 | VERSION=${GITHUB_REF#refs/tags/v} 44 | echo "Creating packages for version: $VERSION" 45 | mkdir -p release 46 | 47 | # Create ZIP package (for Homebrew and direct download) 48 | cd release 49 | ditto -c -k --sequesterRsrc --keepParent ../AerospaceSwipe.app "AerospaceSwipe-${VERSION}.zip" 50 | echo "ZIP package created: AerospaceSwipe-${VERSION}.zip" 51 | 52 | # Create DMG package with proper setup 53 | cd .. 54 | mkdir -p dmg-staging 55 | cp -R AerospaceSwipe.app dmg-staging/ 56 | 57 | # Remove quarantine attributes from the staged app 58 | xattr -cr dmg-staging/AerospaceSwipe.app 59 | 60 | # Create Applications symlink for easy installation 61 | ln -s /Applications dmg-staging/Applications 62 | 63 | # Create a temporary DMG 64 | hdiutil create -volname "AerospaceSwipe ${VERSION}" \ 65 | -srcfolder dmg-staging \ 66 | -ov -format UDRW \ 67 | -size 100m \ 68 | "temp-AerospaceSwipe-${VERSION}.dmg" 69 | 70 | # Mount the DMG to customize it 71 | MOUNT_DIR=$(mktemp -d) 72 | hdiutil attach "temp-AerospaceSwipe-${VERSION}.dmg" -mountpoint "$MOUNT_DIR" -nobrowse 73 | 74 | # Remove quarantine attributes from mounted contents 75 | xattr -cr "$MOUNT_DIR/AerospaceSwipe.app" 2>/dev/null || true 76 | 77 | # Unmount the DMG 78 | hdiutil detach "$MOUNT_DIR" 79 | 80 | # Convert to final compressed DMG 81 | hdiutil convert "temp-AerospaceSwipe-${VERSION}.dmg" \ 82 | -format UDZO \ 83 | -o "release/AerospaceSwipe-${VERSION}.dmg" 84 | 85 | # Clean up 86 | rm "temp-AerospaceSwipe-${VERSION}.dmg" 87 | rm -rf dmg-staging 88 | 89 | echo "DMG package created: AerospaceSwipe-${VERSION}.dmg" 90 | 91 | cd release 92 | ls -la 93 | 94 | - name: Calculate checksums 95 | id: checksums 96 | run: | 97 | cd release 98 | 99 | # Calculate SHA256 for ZIP 100 | ZIP_FILE=$(ls AerospaceSwipe-*.zip | head -1) 101 | if [ -z "$ZIP_FILE" ]; then 102 | echo "Error: No zip file found" 103 | exit 1 104 | fi 105 | ZIP_SHA256=$(shasum -a 256 "$ZIP_FILE" | cut -d' ' -f1) 106 | echo "ZIP SHA256: $ZIP_SHA256" 107 | echo "zip_sha256=$ZIP_SHA256" >> $GITHUB_OUTPUT 108 | echo "zip_file=$ZIP_FILE" >> $GITHUB_OUTPUT 109 | 110 | # Calculate SHA256 for DMG 111 | DMG_FILE=$(ls AerospaceSwipe-*.dmg | head -1) 112 | if [ -z "$DMG_FILE" ]; then 113 | echo "Error: No dmg file found" 114 | exit 1 115 | fi 116 | DMG_SHA256=$(shasum -a 256 "$DMG_FILE" | cut -d' ' -f1) 117 | echo "DMG SHA256: $DMG_SHA256" 118 | echo "dmg_sha256=$DMG_SHA256" >> $GITHUB_OUTPUT 119 | echo "dmg_file=$DMG_FILE" >> $GITHUB_OUTPUT 120 | 121 | - name: Get version 122 | id: version 123 | run: | 124 | VERSION=${GITHUB_REF#refs/tags/v} 125 | echo "version=$VERSION" >> $GITHUB_OUTPUT 126 | echo "Version: $VERSION" 127 | 128 | - name: Create Release 129 | uses: softprops/action-gh-release@v2 130 | with: 131 | files: | 132 | release/AerospaceSwipe-*.zip 133 | release/AerospaceSwipe-*.dmg 134 | generate_release_notes: true 135 | draft: false 136 | prerelease: false 137 | body: | 138 | ## Installation Options 139 | 140 | ### Option 1: Homebrew (Recommended) 141 | ```bash 142 | brew tap acsandmann/tap 143 | brew install --cask aerospace-swipe 144 | ``` 145 | 146 | ### Option 2: Direct Download 147 | - **DMG**: Download `AerospaceSwipe-${{ steps.version.outputs.version }}.dmg` and drag to Applications 148 | - **ZIP**: Download `AerospaceSwipe-${{ steps.version.outputs.version }}.zip` for manual installation 149 | 150 | ### Installation Notes 151 | If you see a security warning when opening the app: 152 | 1. Right-click the app and select "Open" 153 | 2. Or go to System Preferences → Security & Privacy → General and click "Open Anyway" 154 | 3. Or run: `xattr -d com.apple.quarantine /Applications/AerospaceSwipe.app` 155 | 156 | ### Checksums 157 | - DMG SHA256: `${{ steps.checksums.outputs.dmg_sha256 }}` 158 | - ZIP SHA256: `${{ steps.checksums.outputs.zip_sha256 }}` 159 | env: 160 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 161 | 162 | - name: Update Homebrew Cask 163 | env: 164 | HOMEBREW_TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }} 165 | run: | 166 | VERSION=${{ steps.version.outputs.version }} 167 | ZIP_SHA256=${{ steps.checksums.outputs.zip_sha256 }} 168 | 169 | echo "Updating Homebrew cask for version $VERSION with SHA256 $ZIP_SHA256" 170 | 171 | # Clone the homebrew tap repository with token authentication 172 | git clone https://x-access-token:${HOMEBREW_TAP_TOKEN}@github.com/acsandmann/homebrew-tap.git tap 173 | cd tap 174 | 175 | # Configure git 176 | git config user.name "github-actions[bot]" 177 | git config user.email "github-actions[bot]@users.noreply.github.com" 178 | 179 | # Create Casks directory if it doesn't exist 180 | mkdir -p Casks 181 | 182 | # Create the cask file (keep using ZIP for Homebrew as it's more common) 183 | cat > Casks/aerospace-swipe.rb << 'CASK_EOF' 184 | cask "aerospace-swipe" do 185 | version "VERSION_PLACEHOLDER" 186 | sha256 "SHA256_PLACEHOLDER" 187 | 188 | url "https://github.com/acsandmann/aerospace-swipe/releases/download/v#{version}/AerospaceSwipe-#{version}.zip" 189 | name "Aerospace Swipe" 190 | desc "Trackpad gesture support for AeroSpace window manager" 191 | homepage "https://github.com/acsandmann/aerospace-swipe" 192 | 193 | app "AerospaceSwipe.app" 194 | 195 | postflight do 196 | launch_agent_plist = "#{Dir.home}/Library/LaunchAgents/com.acsandmann.swipe.plist" 197 | 198 | plist_content = <<~EOS 199 | 200 | 201 | 202 | 203 | Label 204 | com.acsandmann.swipe 205 | ProgramArguments 206 | 207 | /Applications/AerospaceSwipe.app/Contents/MacOS/AerospaceSwipe 208 | 209 | RunAtLoad 210 | 211 | KeepAlive 212 | 213 | 214 | 215 | EOS 216 | 217 | File.write(launch_agent_plist, plist_content) 218 | system "launchctl", "load", launch_agent_plist 219 | end 220 | 221 | uninstall_preflight do 222 | launch_agent_plist = "#{Dir.home}/Library/LaunchAgents/com.acsandmann.swipe.plist" 223 | if File.exist?(launch_agent_plist) 224 | system "launchctl", "unload", launch_agent_plist 225 | File.delete(launch_agent_plist) 226 | end 227 | end 228 | 229 | zap trash: [ 230 | "~/Library/LaunchAgents/com.acsandmann.swipe.plist", 231 | ] 232 | end 233 | CASK_EOF 234 | 235 | # Replace placeholders with actual values 236 | sed -i '' "s/VERSION_PLACEHOLDER/$VERSION/g" Casks/aerospace-swipe.rb 237 | sed -i '' "s/SHA256_PLACEHOLDER/$ZIP_SHA256/g" Casks/aerospace-swipe.rb 238 | 239 | # Show the changes 240 | echo "Updated cask file:" 241 | cat Casks/aerospace-swipe.rb 242 | 243 | # Commit and push changes 244 | git add Casks/aerospace-swipe.rb 245 | if git diff --staged --quiet; then 246 | echo "No changes to commit" 247 | else 248 | git commit -m "Update aerospace-swipe to $VERSION" 249 | git push origin main 250 | echo "Successfully updated Homebrew cask" 251 | fi 252 | -------------------------------------------------------------------------------- /src/aerospace.c: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | #include 4 | #include 5 | #include 6 | #include 7 | #include 8 | #include 9 | #include 10 | #include 11 | #include 12 | #include 13 | 14 | #include "aerospace.h" 15 | #include "yyjson.h" 16 | 17 | #define READ_BUFFER_SIZE 8192 18 | 19 | static const char* ERROR_SOCKET_CREATE = "Failed to create Unix domain socket"; 20 | static const char* ERROR_SOCKET_RECEIVE = "Failed to receive data from socket"; 21 | static const char* ERROR_SOCKET_CLOSE = "Failed to close socket connection"; 22 | static const char* ERROR_JSON_PRINT = "Failed to print JSON to string"; 23 | static const char* WARN_CLI_FALLBACK = "Warning: Failed to connect to socket at %s: %s (errno %d). Falling back to CLI."; 24 | 25 | struct aerospace { 26 | int fd; 27 | char* socket_path; 28 | bool use_cli_fallback; 29 | char read_buf[READ_BUFFER_SIZE]; 30 | size_t read_buf_len; 31 | }; 32 | 33 | static void fatal_error(const char* fmt, ...) 34 | { 35 | va_list args; 36 | va_start(args, fmt); 37 | fprintf(stderr, "Fatal Error: "); 38 | vfprintf(stderr, fmt, args); 39 | if (errno != 0) 40 | fprintf(stderr, ": %s (errno %d)", strerror(errno), errno); 41 | fprintf(stderr, "\n"); 42 | va_end(args); 43 | exit(EXIT_FAILURE); 44 | } 45 | 46 | static char* get_default_socket_path(void) 47 | { 48 | uid_t uid = getuid(); 49 | struct passwd* pw = getpwuid(uid); 50 | 51 | if (uid == 0) { 52 | const char* sudo_user = getenv("SUDO_USER"); 53 | if (sudo_user) { 54 | struct passwd* pw_temp = getpwnam(sudo_user); 55 | if (pw_temp) 56 | pw = pw_temp; 57 | } else { 58 | const char* user_env = getenv("USER"); 59 | if (user_env && strcmp(user_env, "root") != 0) { 60 | struct passwd* pw_temp = getpwnam(user_env); 61 | if (pw_temp) 62 | pw = pw_temp; 63 | } 64 | } 65 | } 66 | 67 | if (!pw) 68 | fatal_error("Unable to determine user information for default socket path"); 69 | 70 | const char* username = pw->pw_name; 71 | size_t len = snprintf(NULL, 0, "/tmp/bobko.aerospace-%s.sock", username); 72 | char* path = malloc(len + 1); 73 | snprintf(path, len + 1, "/tmp/bobko.aerospace-%s.sock", username); 74 | return path; 75 | } 76 | 77 | static char* execute_cli_command(const char* command_string) 78 | { 79 | FILE* pipe = popen(command_string, "r"); 80 | if (!pipe) { 81 | fatal_error("popen() failed for command '%s'", command_string); 82 | } 83 | 84 | char* output = malloc(READ_BUFFER_SIZE + 1); 85 | if (!output) { 86 | pclose(pipe); 87 | fatal_error("Failed to allocate buffer for CLI output"); 88 | } 89 | 90 | size_t nread = fread(output, 1, READ_BUFFER_SIZE, pipe); 91 | output[nread] = '\0'; 92 | 93 | int status = pclose(pipe); 94 | if (status != 0) { 95 | if (WIFEXITED(status) && WEXITSTATUS(status) != 0) { 96 | fprintf(stderr, "Warning: CLI command failed with exit code %d: %s\n", WEXITSTATUS(status), command_string); 97 | } else if (status == -1) { 98 | fprintf(stderr, "Warning: pclose failed: %s\n", strerror(errno)); 99 | } 100 | } 101 | 102 | if (nread > 0 && output[nread - 1] == '\n') { 103 | output[nread - 1] = '\0'; 104 | } 105 | 106 | return output; 107 | } 108 | 109 | static char* execute_aerospace_command(aerospace* client, const char** args, int arg_count, const char* stdin_payload, const char* expected_output_field) 110 | { 111 | if (!client || !args || arg_count == 0) { 112 | errno = EINVAL; 113 | fprintf(stderr, "execute_aerospace_command: Invalid arguments\n"); 114 | return NULL; 115 | } 116 | 117 | if (client->use_cli_fallback) { 118 | size_t total_len = strlen("aerospace"); 119 | for (int i = 0; i < arg_count; i++) { 120 | total_len += 1 + strlen(args[i]); 121 | } 122 | 123 | char* cli_command_base = malloc(total_len + 1); 124 | if (!cli_command_base) { 125 | fatal_error("Failed to allocate memory for CLI command"); 126 | } 127 | 128 | char* p = cli_command_base; 129 | p += sprintf(p, "aerospace"); 130 | for (int i = 0; i < arg_count; i++) { 131 | p += sprintf(p, " %s", args[i]); 132 | } 133 | 134 | char* final_command; 135 | if (stdin_payload && strlen(stdin_payload) > 0) { 136 | const char* format = "echo '%s' | %s"; 137 | size_t len = snprintf(NULL, 0, format, stdin_payload, cli_command_base); 138 | final_command = malloc(len + 1); 139 | snprintf(final_command, len + 1, format, stdin_payload, cli_command_base); 140 | free(cli_command_base); 141 | } else { 142 | final_command = cli_command_base; 143 | } 144 | 145 | char* result = execute_cli_command(final_command); 146 | free(final_command); 147 | return result; 148 | } 149 | 150 | yyjson_mut_doc* doc = yyjson_mut_doc_new(NULL); 151 | yyjson_mut_val* root = yyjson_mut_obj(doc); 152 | yyjson_mut_doc_set_root(doc, root); 153 | yyjson_mut_obj_add_str(doc, root, "command", args[0]); 154 | yyjson_mut_obj_add_str(doc, root, "stdin", stdin_payload ? stdin_payload : ""); 155 | yyjson_mut_val* args_array = yyjson_mut_arr(doc); 156 | for (int i = 0; i < arg_count; i++) { 157 | yyjson_mut_arr_add_str(doc, args_array, args[i]); 158 | } 159 | yyjson_mut_obj_add_val(doc, root, "args", args_array); 160 | size_t len; 161 | const char* json_str = yyjson_mut_write(doc, 0, &len); 162 | yyjson_mut_doc_free(doc); 163 | if (!json_str) { 164 | fatal_error(ERROR_JSON_PRINT); 165 | } 166 | 167 | struct iovec iov[2]; 168 | char newline = '\n'; 169 | iov[0].iov_base = (void*)json_str; 170 | iov[0].iov_len = len; 171 | iov[1].iov_base = &newline; 172 | iov[1].iov_len = 1; 173 | 174 | if (writev(client->fd, iov, 2) < 0) { 175 | perror("writev failed"); 176 | } 177 | free((void*)json_str); 178 | 179 | yyjson_doc* resp_doc = NULL; 180 | yyjson_read_err err; 181 | size_t parsed_bytes = 0; 182 | 183 | while (true) { 184 | if (client->read_buf_len > 0) { 185 | resp_doc = yyjson_read_opts(client->read_buf, client->read_buf_len, YYJSON_READ_STOP_WHEN_DONE, NULL, &err); 186 | if (resp_doc) { 187 | parsed_bytes = yyjson_doc_get_read_size(resp_doc); 188 | break; 189 | } 190 | } 191 | if (client->read_buf_len >= READ_BUFFER_SIZE) { 192 | fprintf(stderr, "Error: Read buffer overflow, clearing buffer.\n"); 193 | client->read_buf_len = 0; 194 | return NULL; 195 | } 196 | ssize_t bytes_read = read(client->fd, client->read_buf + client->read_buf_len, READ_BUFFER_SIZE - client->read_buf_len); 197 | if (bytes_read <= 0) { 198 | fprintf(stderr, "%s\n", ERROR_SOCKET_RECEIVE); 199 | return NULL; 200 | } 201 | client->read_buf_len += bytes_read; 202 | } 203 | 204 | if (client->read_buf_len > parsed_bytes) { 205 | memmove(client->read_buf, client->read_buf + parsed_bytes, client->read_buf_len - parsed_bytes); 206 | } 207 | client->read_buf_len -= parsed_bytes; 208 | 209 | yyjson_val* resp_root = yyjson_doc_get_root(resp_doc); 210 | char* result = NULL; 211 | int exitCode = -1; 212 | yyjson_val* exitCodeItem = yyjson_obj_get(resp_root, "exitCode"); 213 | if (yyjson_is_int(exitCodeItem)) { 214 | exitCode = (int)yyjson_get_int(exitCodeItem); 215 | } else { 216 | fprintf(stderr, "Response does not contain valid %s field\n", "exitCode"); 217 | yyjson_doc_free(resp_doc); 218 | return NULL; 219 | } 220 | 221 | if (exitCode != 0) { 222 | yyjson_val* output_item = yyjson_obj_get(resp_root, "stderr"); 223 | if (yyjson_is_str(output_item)) { 224 | result = strdup(yyjson_get_str(output_item)); 225 | } 226 | } else if (expected_output_field) { 227 | yyjson_val* output_item = yyjson_obj_get(resp_root, expected_output_field); 228 | if (yyjson_is_str(output_item)) { 229 | result = strdup(yyjson_get_str(output_item)); 230 | } 231 | } 232 | 233 | yyjson_doc_free(resp_doc); 234 | return result; 235 | } 236 | 237 | aerospace* aerospace_new(const char* socketPath) 238 | { 239 | aerospace* client = malloc(sizeof(aerospace)); 240 | client->fd = -1; 241 | client->use_cli_fallback = false; 242 | client->read_buf_len = 0; 243 | 244 | if (socketPath) 245 | client->socket_path = strdup(socketPath); 246 | else 247 | client->socket_path = get_default_socket_path(); 248 | 249 | errno = 0; 250 | client->fd = socket(AF_UNIX, SOCK_STREAM, 0); 251 | if (client->fd < 0) { 252 | int socket_errno = errno; 253 | free(client->socket_path); 254 | free(client); 255 | errno = socket_errno; 256 | fatal_error("%s", ERROR_SOCKET_CREATE); 257 | } 258 | 259 | struct sockaddr_un addr; 260 | memset(&addr, 0, sizeof(struct sockaddr_un)); 261 | addr.sun_family = AF_UNIX; 262 | strncpy(addr.sun_path, client->socket_path, sizeof(addr.sun_path) - 1); 263 | addr.sun_path[sizeof(addr.sun_path) - 1] = '\0'; 264 | 265 | errno = 0; 266 | if (connect(client->fd, (struct sockaddr*)&addr, sizeof(addr)) < 0) { 267 | int connect_errno = errno; 268 | fprintf(stderr, WARN_CLI_FALLBACK, client->socket_path, strerror(connect_errno), connect_errno); 269 | close(client->fd); 270 | client->fd = -1; 271 | client->use_cli_fallback = true; 272 | } 273 | 274 | return client; 275 | } 276 | 277 | int aerospace_is_initialized(aerospace* client) 278 | { 279 | return (client && (client->fd >= 0 || client->use_cli_fallback)); 280 | } 281 | 282 | void aerospace_close(aerospace* client) 283 | { 284 | if (client) { 285 | if (client->fd >= 0) { 286 | errno = 0; 287 | if (close(client->fd) < 0) { 288 | fprintf(stderr, "%s: %s (errno %d)\n", ERROR_SOCKET_CLOSE, strerror(errno), errno); 289 | } 290 | client->fd = -1; 291 | } 292 | free(client->socket_path); 293 | client->socket_path = NULL; 294 | free(client); 295 | } 296 | } 297 | 298 | char* aerospace_switch(aerospace* client, const char* direction) 299 | { 300 | return aerospace_workspace(client, 0, direction, ""); 301 | } 302 | 303 | char* aerospace_workspace(aerospace* client, int wrap_around, const char* ws_command, 304 | const char* stdin_payload) 305 | { 306 | const char* args[3] = { "workspace", ws_command }; 307 | int arg_count = 2; 308 | if (wrap_around) { 309 | args[arg_count++] = "--wrap-around"; 310 | } 311 | return execute_aerospace_command(client, args, arg_count, stdin_payload, NULL); 312 | } 313 | 314 | char* aerospace_list_workspaces(aerospace* client, bool include_empty) 315 | { 316 | if (include_empty) { 317 | const char* args[] = { "list-workspaces", "--monitor", "focused" }; 318 | return execute_aerospace_command(client, args, 3, "", "stdout"); 319 | } else { 320 | const char* args[] = { "list-workspaces", "--monitor", "focused", "--empty", "no" }; 321 | return execute_aerospace_command(client, args, 5, "", "stdout"); 322 | } 323 | } 324 | -------------------------------------------------------------------------------- /src/main.m: -------------------------------------------------------------------------------- 1 | #include "Carbon/Carbon.h" 2 | #include "Cocoa/Cocoa.h" 3 | #include "aerospace.h" 4 | #include "config.h" 5 | #import "event_tap.h" 6 | #include "haptic.h" 7 | #include 8 | #import 9 | #include 10 | 11 | static aerospace* g_aerospace = NULL; 12 | static CFTypeRef g_haptic = NULL; 13 | static Config g_config; 14 | static pthread_mutex_t g_gesture_mutex = PTHREAD_MUTEX_INITIALIZER; 15 | static gesture_ctx g_gesture_ctx = { 0 }; 16 | static CFMutableDictionaryRef g_tracks = NULL; 17 | 18 | static void switch_workspace(const char* ws) 19 | { 20 | if (g_config.skip_empty || g_config.wrap_around) { 21 | char* workspaces = aerospace_list_workspaces(g_aerospace, !g_config.skip_empty); 22 | if (!workspaces) { 23 | fprintf(stderr, "Error: Unable to retrieve workspace list.\n"); 24 | return; 25 | } 26 | char* result = aerospace_workspace(g_aerospace, g_config.wrap_around, ws, workspaces); 27 | if (result) { 28 | fprintf(stderr, "Error: Failed to switch workspace to '%s'.\n", ws); 29 | } else { 30 | printf("Switched workspace successfully to '%s'.\n", ws); 31 | } 32 | free(workspaces); 33 | free(result); 34 | } else { 35 | char* result = aerospace_switch(g_aerospace, ws); 36 | if (result) { 37 | fprintf(stderr, "Error: Failed to switch workspace: '%s'\n", result); 38 | } else { 39 | printf("Switched workspace successfully to '%s'.\n", ws); 40 | } 41 | free(result); 42 | } 43 | 44 | if (g_config.haptic && g_haptic) 45 | haptic_actuate(g_haptic, 3); 46 | } 47 | 48 | static void reset_gesture_state(gesture_ctx* ctx) 49 | { 50 | ctx->state = GS_IDLE; 51 | ctx->last_fire_dir = 0; 52 | } 53 | 54 | static void fire_gesture(gesture_ctx* ctx, int direction) 55 | { 56 | if (direction == ctx->last_fire_dir) 57 | return; 58 | 59 | ctx->last_fire_dir = direction; 60 | ctx->state = GS_COMMITTED; 61 | 62 | dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ 63 | switch_workspace(direction > 0 ? g_config.swipe_right : g_config.swipe_left); 64 | }); 65 | } 66 | 67 | static void calculate_touch_averages(touch* touches, int count, 68 | float* avg_x, float* avg_y, float* avg_vel, 69 | float* min_x, float* max_x, float* min_y, float* max_y) 70 | { 71 | *avg_x = *avg_y = *avg_vel = 0; 72 | *min_x = *min_y = 1; 73 | *max_x = *max_y = 0; 74 | 75 | for (int i = 0; i < count; ++i) { 76 | *avg_x += touches[i].x; 77 | *avg_y += touches[i].y; 78 | *avg_vel += touches[i].velocity; 79 | 80 | if (touches[i].x < *min_x) 81 | *min_x = touches[i].x; 82 | if (touches[i].x > *max_x) 83 | *max_x = touches[i].x; 84 | if (touches[i].y < *min_y) 85 | *min_y = touches[i].y; 86 | if (touches[i].y > *max_y) 87 | *max_y = touches[i].y; 88 | } 89 | 90 | *avg_x /= count; 91 | *avg_y /= count; 92 | *avg_vel /= count; 93 | } 94 | 95 | static bool handle_committed_state(gesture_ctx* ctx, touch* touches, int count) 96 | { 97 | bool all_ended = true; 98 | for (int i = 0; i < count; ++i) { 99 | if (touches[i].phase != END_PHASE) { 100 | all_ended = false; 101 | break; 102 | } 103 | } 104 | 105 | if (!count || all_ended) { 106 | reset_gesture_state(ctx); 107 | return true; 108 | } 109 | 110 | float avg_x, avg_y, avg_vel, min_x, max_x, min_y, max_y; 111 | calculate_touch_averages(touches, count, &avg_x, &avg_y, &avg_vel, 112 | &min_x, &max_x, &min_y, &max_y); 113 | 114 | float dx = avg_x - ctx->start_x; 115 | if ((dx * ctx->last_fire_dir) < 0 && fabsf(dx) >= g_config.min_travel) { 116 | ctx->state = GS_ARMED; 117 | ctx->start_x = avg_x; 118 | ctx->start_y = avg_y; 119 | ctx->peak_velx = avg_vel; 120 | ctx->dir = (avg_vel >= 0) ? 1 : -1; 121 | 122 | for (int i = 0; i < count; ++i) 123 | ctx->base_x[i] = touches[i].x; 124 | } 125 | 126 | return true; 127 | } 128 | 129 | static void handle_idle_state(gesture_ctx* ctx, touch* touches, int count, 130 | float avg_x, float avg_y, float avg_vel) 131 | { 132 | bool fast = fabsf(avg_vel) >= g_config.velocity_pct * FAST_VEL_FACTOR; 133 | float need = fast ? g_config.min_travel_fast : g_config.min_travel; 134 | 135 | bool moved = true; 136 | for (int i = 0; i < count && moved; ++i) 137 | moved &= fabsf(touches[i].x - ctx->base_x[i]) >= need; 138 | 139 | float dx = avg_x - ctx->start_x; 140 | float dy = avg_y - ctx->start_y; 141 | 142 | if (moved && (fast || (fabsf(dx) >= ACTIVATE_PCT && fabsf(dx) > fabsf(dy)))) { 143 | ctx->state = GS_ARMED; 144 | ctx->start_x = avg_x; 145 | ctx->start_y = avg_y; 146 | ctx->peak_velx = avg_vel; 147 | ctx->dir = (avg_vel >= 0) ? 1 : -1; 148 | } 149 | } 150 | 151 | static void handle_armed_state(gesture_ctx* ctx, touch* touches, int count, 152 | float avg_x, float avg_y, float avg_vel) 153 | { 154 | float dx = avg_x - ctx->start_x; 155 | float dy = avg_y - ctx->start_y; 156 | 157 | if (fabsf(dy) > fabsf(dx)) { 158 | reset_gesture_state(ctx); 159 | return; 160 | } 161 | 162 | bool fast = fabsf(avg_vel) >= g_config.velocity_pct * FAST_VEL_FACTOR; 163 | float stepReq = fast ? g_config.min_step_fast : g_config.min_step; 164 | 165 | int mismatch_count = 0; 166 | for (int i = 0; i < count; ++i) { 167 | float ddx = touches[i].x - ctx->prev_x[i]; 168 | if (fabsf(ddx) < stepReq || (ddx * dx) < 0) { 169 | mismatch_count++; 170 | if (mismatch_count > g_config.swipe_tolerance) { 171 | reset_gesture_state(ctx); 172 | return; 173 | } 174 | } 175 | } 176 | 177 | if (fabsf(avg_vel) > fabsf(ctx->peak_velx)) { 178 | ctx->peak_velx = avg_vel; 179 | ctx->dir = (avg_vel >= 0) ? 1 : -1; 180 | } 181 | 182 | if (fabsf(avg_vel) >= g_config.velocity_pct) { 183 | fire_gesture(ctx, avg_vel > 0 ? 1 : -1); 184 | } else if (fabsf(dx) >= g_config.distance_pct && fabsf(avg_vel) <= g_config.velocity_pct * g_config.settle_factor) { 185 | fire_gesture(ctx, dx > 0 ? 1 : -1); 186 | } 187 | } 188 | 189 | static void gestureCallback(touch* touches, int count) 190 | { 191 | pthread_mutex_lock(&g_gesture_mutex); 192 | 193 | gesture_ctx* ctx = &g_gesture_ctx; 194 | 195 | if (ctx->state == GS_COMMITTED) { 196 | if (handle_committed_state(ctx, touches, count)) 197 | goto unlock; 198 | } 199 | 200 | if (count != g_config.fingers) { 201 | if (ctx->state == GS_ARMED) 202 | ctx->state = GS_IDLE; 203 | 204 | for (int i = 0; i < count; ++i) 205 | ctx->prev_x[i] = ctx->base_x[i] = touches[i].x; 206 | 207 | goto unlock; 208 | } 209 | 210 | float avg_x, avg_y, avg_vel, min_x, max_x, min_y, max_y; 211 | calculate_touch_averages(touches, count, &avg_x, &avg_y, &avg_vel, 212 | &min_x, &max_x, &min_y, &max_y); 213 | 214 | if (ctx->state == GS_IDLE) { 215 | handle_idle_state(ctx, touches, count, avg_x, avg_y, avg_vel); 216 | } else if (ctx->state == GS_ARMED) { 217 | handle_armed_state(ctx, touches, count, avg_x, avg_y, avg_vel); 218 | } 219 | 220 | for (int i = 0; i < count; ++i) { 221 | ctx->prev_x[i] = touches[i].x; 222 | if (ctx->state == GS_IDLE) 223 | ctx->base_x[i] = touches[i].x; 224 | } 225 | 226 | unlock: 227 | pthread_mutex_unlock(&g_gesture_mutex); 228 | } 229 | 230 | static void process_touches(NSSet* touches) 231 | { 232 | NSUInteger buf_capacity = touches.count > 0 ? touches.count : 4; 233 | touch* buf = malloc(sizeof(touch) * buf_capacity); 234 | NSUInteger i = 0; 235 | 236 | for (NSTouch* touch in touches) { 237 | if (touch.phase != (1 << 2)) { 238 | if (i >= buf_capacity) { 239 | buf_capacity *= 2; 240 | buf = realloc(buf, sizeof(touch) * buf_capacity); 241 | } 242 | buf[i++] = [TouchConverter convert_nstouch:touch]; 243 | } 244 | } 245 | 246 | dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ 247 | gestureCallback(buf, (int)i); 248 | free(buf); 249 | }); 250 | } 251 | 252 | static CGEventRef key_handler(__unused CGEventTapProxy proxy, CGEventType type, 253 | CGEventRef event, void* ref) 254 | { 255 | struct event_tap* event_tap_ref = (struct event_tap*)ref; 256 | 257 | if (!AXIsProcessTrusted()) { 258 | NSLog(@"Accessibility permission lost, disabling tap."); 259 | event_tap_end(event_tap_ref); 260 | return event; 261 | } 262 | 263 | if (type == kCGEventTapDisabledByTimeout || type == kCGEventTapDisabledByUserInput) { 264 | NSLog(@"Event-tap re-enabled."); 265 | CGEventTapEnable(event_tap_ref->handle, true); 266 | return event; 267 | } 268 | 269 | if (type != NSEventTypeGesture) 270 | return event; 271 | 272 | NSEvent* ev = [NSEvent eventWithCGEvent:event]; 273 | NSSet* touches = ev.allTouches; 274 | 275 | if (!touches.count) 276 | return event; 277 | 278 | process_touches(touches); 279 | 280 | return event; 281 | } 282 | 283 | static void acquire_lockfile(void) 284 | { 285 | char* user = getenv("USER"); 286 | if (!user) 287 | printf("Error: User variable not set.\n"), exit(1); 288 | 289 | char buffer[256]; 290 | snprintf(buffer, 256, "/tmp/aerospace-swipe-%s.lock", user); 291 | 292 | int handle = open(buffer, O_CREAT | O_WRONLY, 0600); 293 | if (handle == -1) { 294 | printf("Error: Could not create lock-file.\n"); 295 | exit(1); 296 | } 297 | 298 | struct flock lockfd = { 299 | .l_start = 0, 300 | .l_len = 0, 301 | .l_pid = getpid(), 302 | .l_type = F_WRLCK, 303 | .l_whence = SEEK_SET 304 | }; 305 | 306 | if (fcntl(handle, F_SETLK, &lockfd) == -1) { 307 | printf("Error: Could not acquire lock-file.\naerospace-swipe already running?\n"); 308 | exit(1); 309 | } 310 | } 311 | 312 | void waitForAccessibilityAndRestart(void) 313 | { 314 | while (!AXIsProcessTrusted()) { 315 | NSLog(@"Waiting for accessibility permission..."); 316 | sleep(1); 317 | } 318 | 319 | NSLog(@"Accessibility permission granted. Restarting app..."); 320 | 321 | NSString* bundlePath = [[NSBundle mainBundle] bundlePath]; 322 | [[NSWorkspace sharedWorkspace] openApplicationAtURL:[NSURL fileURLWithPath:bundlePath] configuration:[NSWorkspaceOpenConfiguration configuration] completionHandler:nil]; 323 | exit(0); 324 | } 325 | 326 | int main(int argc, const char* argv[]) 327 | { 328 | signal(SIGCHLD, SIG_IGN); 329 | signal(SIGPIPE, SIG_IGN); 330 | 331 | acquire_lockfile(); 332 | 333 | @autoreleasepool { 334 | NSDictionary* options = @{(__bridge id)kAXTrustedCheckOptionPrompt : @YES}; 335 | 336 | if (!AXIsProcessTrustedWithOptions((__bridge CFDictionaryRef)options)) { 337 | NSLog(@"Accessibility permission not granted. Prompting user..."); 338 | AXIsProcessTrustedWithOptions((__bridge CFDictionaryRef)options); 339 | 340 | dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ 341 | waitForAccessibilityAndRestart(); 342 | }); 343 | 344 | CFRunLoopRun(); 345 | } 346 | 347 | NSLog(@"Accessibility permission granted. Continuing app initialization..."); 348 | 349 | g_config = load_config(); 350 | NSLog(@"Loaded config: fingers=%d, skip_empty=%s, wrap_around=%s, haptic=%s, swipe_left='%s', swipe_right='%s'", 351 | g_config.fingers, 352 | g_config.skip_empty ? "YES" : "NO", 353 | g_config.wrap_around ? "YES" : "NO", 354 | g_config.haptic ? "YES" : "NO", 355 | g_config.swipe_left, 356 | g_config.swipe_right); 357 | 358 | g_aerospace = aerospace_new(NULL); 359 | if (!g_aerospace) { 360 | fprintf(stderr, "Error: Failed to initialize Aerospace client.\n"); 361 | exit(EXIT_FAILURE); 362 | } 363 | 364 | if (g_config.haptic) { 365 | g_haptic = haptic_open_default(); 366 | if (!g_haptic) 367 | fprintf(stderr, "Warning: Failed to initialize haptic actuator. Continuing without haptics.\n"); 368 | } 369 | 370 | g_tracks = CFDictionaryCreateMutable(NULL, 0, 371 | &kCFTypeDictionaryKeyCallBacks, 372 | NULL); 373 | 374 | event_tap_begin(&g_event_tap, key_handler); 375 | 376 | return NSApplicationMain(argc, argv); 377 | } 378 | } 379 | --------------------------------------------------------------------------------