Я тащу файл в Finder, коротко нажимаю Shift, и под курсором раскрывается колесо с действиями: «в JPG», «сжать до 2 МБ», «распознать текст», «склеить в PDF». Бросаю файл на лепесток, и результат появляется рядом с оригиналом. Расскажу, как поймать такой жест без разрешения Accessibility, зачем пришлось собирать ffmpeg и девять кодеков из исходников, насколько VideoToolbox быстрее libx264 и как всё это писалось вместе с Claude от Anthropic.
Kumquat – это открытый (MIT) конвертер файлов, который живёт в строке меню macOS. Работает на Apple Silicon с macOS 14 и новее, интерфейс на русском и английском.
Сразу о соавторстве. Приложение сделано мной (kipperstudio) вместе с Claude, ИИ-моделью Anthropic. Большую часть кода написал Claude под моим руководством, а я придумывал, ставил задачи, проверял руками и решал, что выпускать. В конце статьи есть отдельный раздел о том, как это было устроено и где ИИ ошибался.
Проблема
У меня это выглядело так: несколько раз за день нужно не открыть файл, а что-то с ним сделать. Перевести снимок с iPhone в формат, который понимает сайт. Ужать запись экрана до лимита почты. Достать текст из скриншота с ошибкой. Склеить сканы договора в один документ. Вычистить геометки перед тем, как выложить фото.
Каждая такая мелочь превращалась в маленький квест: найти подходящую утилиту или веб-сервис, загрузить туда файл (часто вполне личный), выставить настройки, скачать результат обратно. Мне хотелось, чтобы действие было там, где уже находится файл: под курсором, в момент, когда я его тащу.
Идея жеста: перетаскивание плюс короткий Shift
Перетаскивание на macOS и так означает «я хочу что-то сделать с этим файлом». Не хватает только вопроса «что именно». Поэтому жест такой:
-
Начинаете тащить файлы откуда угодно: из Finder, с рабочего стола, из почты.
-
Не отпуская кнопку мыши, коротко нажимаете Shift.
-
Под курсором появляется колесо. Задержка на категории раскрывает её, задержка на центре возвращает назад.
-
Бросаете файлы на лепесток. Результат сохраняется рядом с оригиналом, оригинал не меняется.
Почему колесо, а не обычное меню? В радиальном меню все пункты на одинаковом расстоянии от курсора, а направление запоминается мышечной памятью: через неделю рука сама уходит «вверх» к конвертации. К тому же во время перетаскивания нельзя кликать, есть только движение и отпускание, а колесо идеально подходит под «навёл и бросил».
Колесо показывает только то, что подходит ко всем перетаскиваемым файлам сразу. Если тащить HEIC и MOV вместе, пункта «в MP3» не будет.
Как поймать жест без Accessibility
Самый очевидный путь – глобальный CGEventTap или NSEvent.addGlobalMonitorForEvents. Но мониторинг клавиатуры в чужих приложениях требует разрешения Accessibility или Input Monitoring. Для конвертера файлов просить у пользователя доступ ко всем нажатиям клавиш – перебор, и я бы сам такое приложение не поставил.
Оказалось, что всё нужное можно получить опросом публичного состояния, без единого разрешения:
-
NSEvent.pressedMouseButtons– нажата ли сейчас кнопка мыши; -
NSEvent.modifierFlags– какие модификаторы зажаты; -
NSPasteboard(name: .drag)– системный буфер перетаскивания. Когда начинается drag, егоchangeCountменяется, а внутри лежат URL файлов.
Таймер тикает редко (раз в 120 мс), пока кнопка мыши отпущена, и переходит на 60 Гц, как только кнопку нажали. Вот ядро DragGestureMonitor.swift:
private func tick() {
let mouseDown = NSEvent.pressedMouseButtons & 1 != 0
if mouseDown && !wasMouseDown {
changeCountAtMouseDown = dragPasteboard.changeCount
isDraggingFiles = false
draggedURLs = []
if !isPollingActively { schedule(interval: activeInterval) }
}
if mouseDown && !isDraggingFiles && dragPasteboard.changeCount != changeCountAtMouseDown {
let urls = readFileURLs()
if !urls.isEmpty {
isDraggingFiles = true
draggedURLs = urls
previousModifiers = NSEvent.modifierFlags.intersection(relevantFlags)
pressedUnion = []
} else {
// Not a file drag; ignore until next mouse down.
changeCountAtMouseDown = dragPasteboard.changeCount
}
}
if isDraggingFiles && mouseDown {
trackModifierTap()
}
// ... отпускание кнопки: onDragEnded, возврат на медленный таймер
Ключевая деталь – сравнение changeCount с моментом нажатия кнопки. Просто проверять «в буфере есть файлы» нельзя: там остаются данные от прошлого перетаскивания.
Тап, а не удержание
Второй момент: Shift во время перетаскивания люди жмут и по другим причинам. Поэтому колесо открывается только на короткое нажатие (до 0,6 с) ровно одного модификатора. Если в процессе зажали Shift+Option, это уже не наш жест. Какой модификатор открывает колесо, настраивается.
private func trackModifierTap() {
let now = ProcessInfo.processInfo.systemUptime
let mods = NSEvent.modifierFlags.intersection(relevantFlags)
defer { previousModifiers = mods }
guard mods != previousModifiers else { return }
if previousModifiers.isEmpty && !mods.isEmpty {
pressStarted = now
pressedUnion = mods
} else if !mods.isEmpty {
pressedUnion.formUnion(mods)
} else if !previousModifiers.isEmpty && mods.isEmpty {
// Released: a tap if it was short and exactly one modifier key was involved.
defer { pressedUnion = [] }
let held = now - pressStarted
guard held <= tapGraceInterval else { return }
guard let key = ModifierKey.allCases.first(where: { pressedUnion == $0.flag }),
let mode = AppSettings.shared.mode(for: key) else { return }
// Re-read in case more files were added to the drag.
let urls = readFileURLs()
onTrigger?(mode, NSEvent.mouseLocation, urls.isEmpty ? draggedURLs : urls)
}
}
pressedUnion накапливает все модификаторы, которые были зажаты за одно нажатие, так что «Shift, потом добавил Cmd, потом всё отпустил» корректно отбрасывается.
Панель, которая принимает drop и не крадёт фокус
Колесо – это NSPanel со стилем [.borderless, .nonactivatingPanel] на уровне .popUpMenu. Non-activating означает, что Finder остаётся активным приложением и перетаскивание не прерывается. Панель сама зарегистрирована как приёмник drag-and-drop, поэтому файлы падают прямо на лепесток, а не «куда-то под» окно. Ключевым окном она становится только в режиме клика (когда колесо открыто из меню или горячей клавишей), чтобы работал Esc.
Есть и другие входы: пункт в строке меню, Finder › Службы, горячая клавиша ⌃⌥K для выделенных в Finder файлов, наблюдаемые папки и командная строка. Но перетаскивание с Shift – главный сценарий, ради которого всё затевалось.
Колесо: геометрия и стекло
Геометрия живёт в отдельной чистой структуре FanLayout без единой зависимости от AppKit-вьюх, поэтому её легко тестировать. Каждый лепесток – кольцевой сектор. Несколько решений, которые выросли из практики:
-
Минимум три слота. Если действий одно или два, они всё равно рисуются как лепестки на кольце из трёх слотов, а не как полный бублик. Два лепестка ставятся симметрично относительно верха.
-
Два кольца. Когда пунктов больше десяти, они делятся между внутренним и внешним кольцом пропорционально длине окружности, чтобы дуги лепестков были примерно одинаковыми. Внешнее кольцо повёрнуто на полпетали, чтобы швы не совпадали.
-
Ровный зазор. Между соседними лепестками зазор постоянной ширины в 5 pt, а не постоянного угла. Для этого края сектора смещаются на
asin(gap / 2r)отдельно для внешней и внутренней дуги, иначе у центра щели схлопываются. -
Скруглённые углы. Сектор сначала уменьшается на радиус скругления, затем к нему добавляется его же обводка шириной 2r с круглыми соединениями. Получается аккуратный скруглённый лепесток без ручной работы с дугами.
-
Задержки. Категория раскрывается через 0,32 с наведения, возврат через центр срабатывает через 0,45 с. Меньше – случайные срабатывания при пролёте курсора, больше – ощущение тормозов.
Стекло сделано через NSVisualEffectView с материалом .hudWindow и blendingMode = .behindWindow. Чтобы размытие было только под лепестками, объединение всех путей рисуется в maskImage. Тень отдельной вьюхой с shadowPath той же формы. Колесо всегда в тёмном оформлении, а у каждой категории свой цвет из палитры: конвертация – цвета кумквата, оптимизация – листа, редактирование – мёда и так далее.
Движки
Внутри Kumquat – набор движков за общим протоколом ConversionEngine. Что во что можно конвертировать, описано в одном switch в ConversionCatalog.swift, и колесо строится из него же. Например, картинки можно перевести в JPG, PNG, HEIC, WebP, AVIF, TIFF, BMP, GIF и PDF, а если в сборке нет кодера WebP, этот пункт просто исчезает.
Для нескольких файлов commonOutputs(for:) берёт пересечение списков. Главный принцип – по максимуму системные фреймворки, сторонний код только там, где без него никак.
Картинки идут через ImageIO и Core Image. Отдельная боль – ориентация EXIF: фото с телефона хранится как 400×300 с тегом «повернуть на 90°», а показывается как 300×400. Если про это забыть, конвертированная картинка ляжет набок. В тестах есть специальная фикстура ровно для этого случая.
Vision отвечает за распознавание текста (русский и английский), поиск лиц для размытия и удаление фона через VNGenerateForegroundInstanceMaskRequest. Всё локально, ничего не уходит в сеть.
Keynote, Pages, Numbers конвертируются без самих приложений iWork. Старые документы iWork '09 содержат готовый QuickLook/Preview.pdf. Новые хранят только превью первой страницы, поэтому Kumquat просит системный генератор Quick Look (qlmanage -p -o) отрисовать все страницы в векторный PDF и склеивает их.
EPUB и офисные форматы рендерятся в PDF через оффскрин WKWebView, у которого выключен JavaScript и заблокированы все нелокальные запросы. EPUB 3 на выходе собирается вручную: по главе на каждый <h1>, mimetype первым файлом без сжатия, плюс NCX для старых читалок.
Таблицы XLSX читаются и пишутся без сторонних библиотек: OOXML – это ZIP с XML внутри, а для упаковки хватает системных /usr/bin/zip и unzip. Отдельно пришлось научиться понимать, какие числовые форматы ячеек на самом деле даты.
Плюс интерактивные редакторы: разметка скриншотов, закрашивание, коррекция фото, страницы PDF, обрезка видео и аудио на таймлайне, «запикивание» и закрашивание областей на видео.
Видео быстрее в два раза: VideoToolbox против libx264
Встроенный ffmpeg собран под LGPL, а значит в нём нет libx264 и libx265 (они GPL). H.264 и HEVC кодируются аппаратно через VideoToolbox, медиадвижок Apple Silicon. Вопрос был в том, как сопоставить качество: у x264 есть привычный CRF, а у h264_videotoolbox – постоянное качество -q:v от 1 до 100 с совершенно другой шкалой.
Я взял ролик 1080p30 длиной 45 секунд, прогнал его через libx264 -preset medium с шестью значениями CRF и через VideoToolbox, подбирая -q:v так, чтобы VMAF совпадал в пределах примерно одного пункта:
|
libx264 CRF |
Время |
Размер |
VMAF |
VT -q:v |
Время |
Размер |
VMAF |
|---|---|---|---|---|---|---|---|
|
18 |
18,9 с |
39,2 МБ |
96,0 |
77 |
7,1 с |
36,6 МБ |
95,4 |
|
21 |
17,5 с |
25,0 МБ |
94,8 |
73 |
7,1 с |
29,2 МБ |
94,6 |
|
23 |
17,2 с |
18,3 МБ |
93,6 |
68 |
8,2 с |
21,5 МБ |
93,4 |
|
26 |
13,6 с |
11,2 МБ |
90,8 |
62 |
7,2 с |
14,0 МБ |
91,1 |
|
28 |
12,1 с |
7,9 МБ |
88,2 |
57 |
7,1 с |
10,1 МБ |
88,8 |
|
32 |
10,7 с |
4,0 МБ |
81,1 |
48 |
7,0 с |
5,7 МБ |
82,5 |
Выводы:
-
VideoToolbox быстрее в 1,5–2,7 раза, и его время почти не зависит от качества: около 7 секунд на любом пресете.
-
Платить приходится байтами: при равном VMAF файл от VideoToolbox на 0–40% больше. Хуже всего на низких битрейтах, где x264 со своим анализом движения выигрывает заметно.
-
Для сжатия до целевого размера («не больше 25 МБ») используется ограниченный средний битрейт. Файлы получаются в пределах 96–98,5% от цели, то есть не перелетают лимит.
Эти шесть пар легли в код как опорные точки, между которыми vtQuality(crf:hevc:) интерполирует линейно. Для HEVC к качеству добавляется +5: при том же -q:v HEVC через VideoToolbox даёт VMAF ниже, чем H.264.
Аппаратный кодер не всемогущ. H.264 в VideoToolbox не берёт кадры шире 4096 px, сессия кодера может не создаться. Поэтому при ошибке FFmpeg.run проходит по цепочке запасных кодеров и переписывает аргументы под каждый (-q:v в CRF, профиль, тег avc1/hvc1):
a = ["-c:v", hevc ? "hevc_videotoolbox" : "h264_videotoolbox", "-allow_sw", "1",
"-profile:v", hevc ? "main" : "high", "-pix_fmt", "yuv420p",
"-g", "(keyframeInterval)", "-bf", "2"]
if let br = s.videoBitrateKbps {
a += bitrateArgs(br)
} else {
a += ["-q:v", "(vtQuality(crf: crf, hevc: hevc))"]
}
// ...
static func encoderFallbacks(_ args: [String]) -> [[String]] {
guard let current = videoEncoder(in: args), vtEncoders.contains(current) else { return [] }
var chain: [String] = []
if current == "h264_videotoolbox", FFmpeg.encoders.contains("hevc_videotoolbox") { chain.append("hevc_videotoolbox") }
if current == "hevc_videotoolbox", FFmpeg.encoders.contains("libx265") { chain.append("libx265") }
if FFmpeg.encoders.contains("libx264") { chain.append("libx264") }
chain.append("mpeg4")
return chain.map { rewrite(args, to: $0) }
}
Цепочка такая: VT H.264 → VT HEVC (на Apple Silicon тянет до 8K) → libx264/libx265, если ffmpeg собран с ними → LGPL-кодер mpeg4 как последний рубеж. Ещё одна мелочь, которую легко пропустить: без -g 250 VideoToolbox ставит ключевой кадр каждые 12 кадров, и файл раздувается на ровном месте.
ffmpeg под LGPL для macOS 14
Первая версия просто копировала в бандл ffmpeg из Homebrew. На моей машине всё работало. Потом я посмотрел на загрузочные команды библиотек (otool -l, секция LC_BUILD_VERSION) и увидел minos 26.0. Бутылки Homebrew собираются под ту версию macOS, на которой стоит Homebrew, и на macOS 14 такое приложение просто не запустится. Вдобавок ffmpeg из Homebrew собран с --enable-gpl (x264, x265), что для MIT-приложения с встроенными бинарниками означает GPL на всё распространяемое.
Решение – собирать всё самому. scripts/build-deps.sh скачивает из официальных источников lame, opus, libvpx, dav1d, SVT-AV1, libwebp, libpng, libjpeg-turbo и xz. Каждый архив закреплён по версии и SHA-256, сборка идёт под arm64 с MACOSX_DEPLOYMENT_TARGET=14.0, а заголовки и библиотеки Homebrew явно исключены из путей поиска. Homebrew остаётся только поставщиком инструментов сборки: pkgconf, meson, ninja, cmake.
# fetch <name> -> sets SRC to the extracted, verified source directory
fetch() {
local name="$1" spec ver url sha file dir
for spec in "${PACKAGES[@]}"; do [[ "${spec%%|*}" == "$name" ]] && break; done
IFS='|' read -r _ ver url sha <<<"$spec"
file="$SRC_ROOT/$name-$ver.tarball" # tar auto-detects gz/bz2/xz
if [[ ! -f "$file" ]]; then
echo "==> Downloading $url"
curl -fsSL --proto '=https' --tlsv1.2 --connect-timeout 30 --retry 5 --retry-all-errors -o "$file.part" "$url"
mv "$file.part" "$file"
fi
local actual; actual="$(shasum -a 256 "$file" | awk '{print $1}')"
if [[ "$actual" != "$sha" ]]; then
echo "Checksum mismatch for $file: $actual (want $sha)" >&2; rm -f "$file"; exit 1
fi
echo "==> $name $ver (sha256 $sha OK)"
# ... распаковка в build/deps-src
}
Затем scripts/build-ffmpeg.sh собирает ffmpeg 9.0.2 с --disable-gpl --disable-nonfree, без сети (--disable-network, только протоколы file и pipe), с VideoToolbox и AudioToolbox. Важно, как всё линкуется:
-
кодеки (opus, vpx, dav1d, SVT-AV1, webp и остальные) – статически;
-
libav*и LAME (они под LGPL) – динамически, вTools/lib, чтобы пользователь мог заменить их своей сборкой, как того требует LGPL.
В итоге число встроенных Mach-O файлов сократилось с 24 до 12. Скрипт сам проверяет лицензию и падает, если что-то GPL просочилось в сборку:
LICENSE_TEXT="$("$PREFIX/bin/ffmpeg" -hide_banner -L | head -3)"
echo "$LICENSE_TEXT"
if "$PREFIX/bin/ffmpeg" -hide_banner -buildconf | grep -Eq -- '--enable-(gpl|nonfree|libx264|libx265)'; then
echo "GPL/nonfree option found in -buildconf" >&2; exit 1
fi
echo "$LICENSE_TEXT" | grep -q "Lesser General Public" || { echo "ffmpeg -L is not LGPL" >&2; exit 1; }
Последний рубеж – scripts/bundle-tools.py. Он копирует бинарники в бандл, переписывает пути через install_name_tool на @loader_path/, проверяет, что ничего не ссылается за пределы приложения, и читает LC_BUILD_VERSION у каждого Mach-O. Если хоть у одного minos выше 14.0, сборка падает. Тексты лицензий всех десяти компонентов кладутся в Contents/Resources/Tools/licenses и доступны в «Настройки › О программе › Лицензии…».
Без Xcode
Kumquat собирается Swift Package Manager и Command Line Tools. Проекта Xcode нет вообще, весь Package.swift умещается в полтора десятка строк. Около 24 тысяч строк Swift в 95 файлах.
scripts/build-app.sh делает то, что обычно делает Xcode:
-
swift build -c release --arch arm64; -
собирает структуру
Kumquat.app, копирует ресурсы и.lprojс проверкойplutil -lint; -
генерирует
Info.plistсLSUIElement(приложение только в строке меню, без иконки в Dock) иLSMinimumSystemVersion; -
встраивает ffmpeg, ffprobe и cwebp через
bundle-tools.py; -
ставит ad-hoc подпись и проверяет её через
codesign --verify --deep --strict.
scripts/release.sh упаковывает DMG, а если в окружении есть DEVELOPER_ID и NOTARY_PROFILE, подписывает каждый Mach-O изнутри наружу с hardened runtime и отправляет на нотаризацию через notarytool. Он тоже есть в Command Line Tools, Xcode для этого не нужен. Для установки есть Homebrew tap: brew install --cask thealexray/tap/kumquat.
Из минусов жизни без Xcode: часть SwiftUI-кода, завязанная на макросы (их раскрывают плагины компилятора, которые идут с Xcode), в чистых Command Line Tools не собиралась. Поэтому модели представлений здесь написаны по-старому, через ObservableObject и @Published. Превью SwiftUI тоже нет, так что интерфейс проверялся скриншотами (о них ниже).
Как это писалось с ИИ
Теперь о самом интересном для меня. Kumquat писала команда параллельных агентов Claude, а я был кем-то вроде техлида и продакт-менеджера одновременно.
Как была устроена работа
-
Общие контракты. Файл
Model/Contracts.swiftописывает протоколы и типы, через которые общаются модули:ConversionEngine,ConversionOptions,ProgressHandler, правила именования результатов. Первая строка файла: «Change only with the coordinator's agreement». Агенты, пишущие движки, не трогают контракты без согласования. -
Файлы-заглушки. Перед параллельной работой создавались пустые реализации с правильными сигнатурами, чтобы проект компилировался с первого дня, а каждый агент заполнял свой файл.
-
Владение файлами. У каждого агента был свой набор файлов. Агент движка изображений не правит видео, агент колеса не лезет в настройки. Это убрало почти все конфликты слияния.
-
Тесты как страховка.
scripts/test-suite.shгоняет приложение через его же CLI (Kumquat --convert webp photo.png) и проверяет не код возврата, а сам результат: размеры и ориентацию картинок через ImageIO, кодеки и длительность через ffprobe, число страниц и текстовый слой PDF через PDFKit, кириллицу в документах, кавычки и переносы строк в CSV после круга через XLSX. Каталог конвертаций изConversionCatalog.swiftзеркалится в тестах, и если в приложении появился формат, о котором тесты не знают, проверка--listпадает.
Сейчас в наборе 393 случая: 389 проходят, 4 пропускаются (исходники XLS, Keynote, Pages и Numbers невозможно детерминированно сгенерировать без Excel и iWork). Быстрая версия набора и проверка локализации запускаются в GitHub Actions на macos-15 на каждый пуш.
Что пошло не так
Было бы нечестно писать только про успехи.
-
Жест мышью агенты проверить не могли. Реальное перетаскивание с нажатием Shift не воспроизвести из терминала синтетическими событиями так, чтобы это было похоже на руку человека. Логика с
changeCount, порогом «тапа» в 0,6 с и задержками раскрытия проверялась по отдельности: колесо рендерилось оффскрин, обработчики вызывались из тестовой программы. А целиком жест можно проверить только руками. Поэтому в код заложены подробные отладочные логи жеста вos.Logger: если у кого-то колесо не появится, по ним видно, на каком шаге всё сломалось. -
Оффскрин-рендер не показывает стекло и видео. Для проверки интерфейса агенты рендерили окна в картинки. Но
NSVisualEffectViewв таком рендере не размывает то, что под окном, а плеер видео остаётся пустым прямоугольником. Внешний вид колеса и редакторов видео приходилось проверять глазами на живом приложении. -
SwiftUI-макросы без Xcode. Агенты по привычке писали современный SwiftUI, где состояние (
@Stateи компания) строится на макросах, а Command Line Tools отказывались это собирать. Пришлось переписывать на классическийObservableObject. -
Первый прогон CI упал. Тестовая фикстура AVIF создавалась через
sips, а на раннерах с macOS 15sipsне умеет писать AVIF. Теперь при неудаче фикстура делается через SVT-AV1 во встроенном ffmpeg. -
Локализация сломала тест. Когда приложение перевели на русский, переведёнными оказались и сообщения командной строки, и тест, который искал английский текст ошибки, упал. С тех пор вывод CLI всегда на английском, чтобы на него могли опираться скрипты.
Общий вывод: ИИ отлично пишет код по чётким контрактам и сам себя проверяет, если есть автоматические тесты. А вот всё, что про ощущения (как жест ложится в руку, как выглядит стекло, не раздражает ли задержка), остаётся на человеке.
Итог
Главные выводы, которые я вынес из проекта:
-
Глобальный жест можно поймать без Accessibility: достаточно опрашивать состояние мыши, модификаторов и
changeCountу drag-пастборда. Для пользователя это проще, а пугающих разрешений не нужно. -
Аппаратный VideoToolbox в 1,5–2,7 раза быстрее libx264 при сопоставимом VMAF, но на низких битрейтах ему нужно до 40% больше байтов. Для конвертера «по кнопке» это хороший компромисс.
-
Бутылки Homebrew нельзя просто положить внутрь приложения: они собраны под версию macOS машины сборки. Если нужна поддержка старых систем, зависимости придётся собирать из исходников с нужным deployment target.
-
Без Xcode вполне можно жить: SwiftPM, Command Line Tools и пара скриптов закрывают сборку, подпись и DMG.
-
С командой ИИ-агентов скорость упирается не в написание кода, а в проверку: общие контракты, владение файлами и автотесты оказались важнее любых промптов.
Нотаризации пока нет, поэтому первый запуск – через правый клик › «Открыть». Отдельно интересно мнение о самом жесте: насколько естественным кажется Shift во время перетаскивания и что было бы удобнее.
Исходники и релизы: https://github.com/TheAlexRay/kumquat
Автор: TheAlexRay
