makc — кроссплатформенное управление мышью и клавиатурой на Go без cgo
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 → платформенная реализация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-захватом слота.

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 напрямую.

Listener.EventsLinux: 150 сисколлов на Mouse.Down(...)
Сейчас fd кэшируются лениво при первом запросе и переиспользуются. На ENODEV/ENXIO девайс выкидывается из кэша.
Linux Wayland: portal вместо libei
Wayland-композиторы игнорируют uinput-устройства, не привязанные к сессии. Стандартный путь — org.freedesktop.portal.RemoteDesktop через D-Bus.

Работает на 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, плазма на разных версиях).