TL;DR. makc — небольшая Go-библиотека для синтеза ввода с клавиатуры и мыши на Windows, macOS и Linux. Без cgo, без встроенных DLL, без обёрток над xdotool/AppleScript. Биндинги к нативным API напрямую через purego и golang.org/x/sys. На Wayland работает через XDG Desktop Portal.

Зачем ещё одна

Под Go таких библиотек хватает: robotgo, kbinput, keybd_event. Все так или иначе тянут cgo. cgo — это блокировка горутины на сисколле, сложности с кросс-компиляцией (нужен MinGW для сборки под Windows из Linux), проблемы с санитайзерами и отладкой. Когда у тебя инструмент, который должен быть быстрым в горячем пути (1000 Гц инжект), cgo — так себе товарищ.

makc обходится без cgo:

  • На Windows — windows.LazyProc из x/sys/windows. Это syscall.Syscall6 под капотом с правильным сохранением GetLastError.

  • На macOS — purego.Dlopen + purego.RegisterFunc. Прямые вызовы CoreGraphics и ApplicationServices без Objective-C runtime.

  • На Linux — unix.Syscall для ioctl к /dev/uinput, purego для опционального libX11, и godbus/dbus для XDG-портала на Wayland.

Общая архитектура

Общая архитектура: Client → systemBackend → платформенная реализация
Общая архитектура: Client → systemBackend → платформенная реализация

makc.Open подбирает реализацию по GOOS, поднимает разрешения если надо и возвращает *Client. Каждый метод принимает context.Context — отмена работает.

Hello, click

package main

import (
    "context"
    "log"

    "github.com/aiwaki/makc"
)

func main() {
    client, err := makc.Open()
    if err != nil {
        log.Fatal(err)
    }
    defer client.Close()

    ctx := context.Background()
    client.Mouse.MoveTo(ctx, 500, 500)
    client.Mouse.Click(ctx, makc.ButtonLeft)
    client.Keyboard.TypeText(ctx, "hello")
    client.Keyboard.Combo(ctx, makc.KeyLeftControl, makc.KeyA)
}

Что важного под капотом

Windows: GetLastError не переживает планировщик Go

Когда вызываешь Win32 через purego.RegisterFunc, между сисколлом и windows.GetLastError() Go может перешедулить горутину на другой M, у которого в TLS лежит errno от другого вызова. Логи показывают неправильные коды ошибок. Лечится переходом на windows.LazyProc.Call, который возвращает syscall.Errno третьим возвратным значением — без TLS-хопа.

Windows: один thunk на бэкенд, а не на каждый Listen

windows.NewCallback выделяет слот в глобальной таблице — около двух тысяч штук, освободить нельзя. Если открывать-закрывать слушатель в цикле, через сутки получаешь panic. Решение: три singleton-коллбэка на жизнь бэкенда, активный слушатель подключается через atomic.Pointer[hookEmitter] с CAS-захватом слота.

Один thunk на бэкенд: захват слота активным слушателем через CAS
Один thunk на бэкенд: захват слота активным слушателем через CAS

macOS: CGEventSourceCreate ел весь горячий путь

В оригинале CGEventSourceCreate вызывался на КАЖДОЕ событие. Это выделение ресурсов в IOKit и WindowServer. На 1000 Гц инжекте это доминирующая стоимость. Кэш одного source-а на жизнь бэкенда — около ×10 улучшение по задержке.

macOS: «injected»-флаг через PID источника

В Win32 есть LLMHF_INJECTED. На macOS аналога нет. Но kCGEventSourceUnixProcessID для реальных HID-событий = 0, а для всего, что приходит через CGEventPost — PID процесса-отправителя. Это и используем как маркер инжектированных событий в слушателе.

macOS: подводный камень с runloop

CGEventTap работает через mach-port + CFRunLoopSource. Слушатель пинит горутину к OS-потоку (runtime.LockOSThread) и крутит свой runloop в отдельном M. И тут засада: CFRunLoopAddSource(rl, src, kCFRunLoopCommonModes) привязывает source только к тем модам, которые зарегистрированы как common. На свежесозданном runloop'е никаких мод там ещё нет, source оказывается ничейным, callback не фаерится. Лечится привязкой к конкретному kCFRunLoopDefaultMode напрямую.

Путь события на macOS: от инжекта до Listener.Events
Путь события на macOS: от инжекта до Listener.Events

Linux: 150 сисколлов на Mouse.Down(...)

Сейчас fd кэшируются лениво при первом запросе и переиспользуются. На ENODEV/ENXIO девайс выкидывается из кэша.

Linux Wayland: portal вместо libei

Wayland-композиторы игнорируют uinput-устройства, не привязанные к сессии. Стандартный путь — org.freedesktop.portal.RemoteDesktop через D-Bus.

Жизненный цикл сессии XDG portal RemoteDesktop
Жизненный цикл сессии XDG portal RemoteDesktop

Работает на GNOME ≥ 41, KDE Plasma ≥ 5.27 и wlroots-композиторах с portal-wlr.

client, err := makc.Open(
    makc.WithMouseXDGPortal(),
    makc.WithKeyboardXDGPortal(),
)

Авторизационный диалог — единственный интерактивный момент за всю жизнь клиента. Сессия живёт до Close.

Слушатели и backpressure

listener, _ := client.Listen(ctx, makc.ListenOptions{Mask: makc.ListenAll})
defer listener.Close()

for event := range listener.Events {
    // ...
}

stats := listener.Stats()
log.Printf("delivered=%d dropped=%d", stats.Delivered, stats.Dropped)

Listener.Stats() — атомарные счётчики delivered/dropped. Если канал переполнен, события дропаются молча (в WH_MOUSE_LL ты обязан вернуть управление за <300 мс, иначе ОС снимает хук). Раньше был просто select { default } без счётчика — отлаживать «почему пропустил клик» было нечем.

Чего сознательно нет

makc не вычищает «injected»-маркеры (LLMHF_INJECTED на Windows, kCGEventSourceUnixProcessID на macOS) из shared kernel-структур перед форвардингом другим хукам в системе. Эти флаги существуют ровно для того, чтобы a11y-софт, security-инструменты и античиты могли отличить синтетику от настоящего ввода. Затирание их — обман других потребителей системного ввода. Если ваш сценарий — обход античита в играх, makc вам не подходит (или можете просто форкнуть репу, хаха).

Что есть: WithInputTag чтобы ваш слушатель мог отличить ваш собственный ввод. Listener.NormalizeOwnInjected чистит флаги в Go-копии события до того, как ваш код его увидит. Это коллбэк-локальная операция, kernel-структура остаётся нетронутой.

Состояние и планы

  • Pre-1.0. API может пошатнуться до v1.

  • go test -race ./... зелёный на всех трёх ОС в CI.

  • Реальное железо тестировалось на Windows 11 (Parallels), Linux X11 (Ubuntu/Debian), macOS — на машине разработки.

  • Wayland-portal путь смотрят на GNOME, на других DE проверка приветствуется.

Дорожная карта:

  • libei напрямую вместо portal (нужно для headless-сценариев).

  • macOS Accessibility API улучшения.

  • Tagged release v0.2 после фидбека.

Репозиторий: https://github.com/aiwaki/makc

Документация: https://pkg.go.dev/github.com/aiwaki/makc

Буду рад фидбеку — особенно по Wayland-композиторам, которые я не тестил (Hyprland, Sway, плазма на разных версиях).