├── .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 |
--------------------------------------------------------------------------------