Вкладка Overview: сводка по CPU, памяти, swap, диску и батарее
Вкладка Overview: сводка по CPU, памяти, swap, диску и батарее

Сразу скажу: я не программист

Вообще. Код на работе не пишу, пет‑проектов на гитхабе нет, да и SwiftUI изнутри не знаю совсем. Просто захотелось понять, каково это — делать софт сейчас, когда под рукой есть ИИ. Тему взял такую, чтоб самому не скучно было: здоровье собственного Mac. Так и вышел MacDashboard — нативное приложение под macOS в одно окно, без внешних зависимостей и с лицензией MIT.

И это не пост в духе «смотрите какой я молодец». Это честный разбор, как оно писалось. Он про то, как я оркестрировал нескольких ИИ‑агентов, про инструмент для скриншотов UI без окна, и про историю с CPU, где самое интересное, что оптимизировать в итоге оказалось нечего.

Одно число напишу сразу, потому что мимо него не пройти. Да, в какой‑то момент мой скролл по CPU оказался на уровне, а то и ниже штатного Finder на одном и том же Mac. Нет, это не значит, что я написал SwiftUI лучше, чем Apple пишет AppKit, ха‑ха. А почему — будет в главе про скролл, и это самая честная часть текста.

С чего все началось: Python, браузер и легкий стыд

Первая версия «дашборда» приложением не была вообще, это была пачка скриптов: один собирал отчет о системе в текстовый файл, второй рендерил его в статический HTML, а третий раздавал живые метрики в браузер через SSE. Да, это работало, но ощущалось дико: браузер плюс локальный сервер плюс питон, и все это ради пары графиков про мой же Mac.

Поэтому я решил переписать все с нуля в нативный SwiftUI: одно окно, ноль зависимостей, но главное, что в фоне ничего лишнего. И тут вылезла первая приятная деталь: собирать это можно без Xcode. build_app.sh работает на голых Command Line Tools — swiftc --triple по каждой архитектуре, lipo сшивает universal‑бинарь, ad‑hoc codesign подписывает. Полный Xcode на 12 гигов не нужен.

Обратная сторона честно написана в README: так как подпись ad‑hoc и без нотаризации, скачанную копию при первом запуске блокирует Gatekeeper. Снимается это один раз командой xattr -dr com.apple.quarantine или через System Settings, “Open Anyway”. Правый клик «Открыть» на macOS новее 15 уже не спасает, кстати. А если собрал из исходников сам, то и карантина нет вообще.

Как это вообще писалось: «Матрешка» из ИИ‑агентов

Вот тут самое интересное, и об этом, насколько я могу судить, обычно молчат. Я не сидел в одном‑единственном чате с фразой «напиши мне приложение и чтоб летало и красиво было». Работа шла через оркестрацию: главная сессия держит архитектуру и общается со мной, а само исполнение уходит на самый дешевый уровень, который справится без проблем, но не сожрет все лимиты.

  • легкая модель (Haiku) — механические правки по точной спеке, boilerplate

  • средняя (Sonnet) — обычная реализация

  • она же на высоком «усилии» — конкурентность, хитрые места, отладка

  • отдельные роли: debugger ищет первопричину, reviewer смотрит дифф против спеки и сам ничего не правит, tester собирает, гоняет тесты, запускает и меряет.

Ну и венец творения Anthropic, модель Fable, работала только на планировании: писала спеки настолько подробные, что исполнителю не остается проектных решений: точные файлы, контракты, крайние случаи и команды проверки. Она самая дорогая, так что после готовой спеки уходит за кулисы.

Поверх этого всего дисциплина, которую я в шутку зову zero‑context. Есть PLAN.md, единственный источник правды по текущему блоку. Готовые блоки уезжают в HISTORY.md, который моделью никогда не перечитывается — это экономит контекст, читай деньги. А блок закрыт только после свежей пересборки и моего явного «ок» после того, как я глазами прошелся по живому приложению. Зеленые тесты агентов для меня — условие необходимое, но не достаточное.

Забегая вперед: в цикле даже и сторонние модели оказывались. План одного из заходов по скроллу пришел от Grok, а способ ключевой проверки (об этом я расскажу ниже) подсказала модель посильнее. Такая честная многомодельная кухня.

И да, все это на личной подписке Claude Pro. Лимиты я жег регулярно, можно сказать, жил от лимита до лимита.

Инструмент, которым я «фоткал» невидимые экраны

Быстро выяснилось, что у приложения есть состояния, которые просто адски больно воспроизводить руками: ошибка чтения SMART у внешнего диска, живой прогресс brew upgrade, баннеры ошибок, спиннеры «занято». Каждый раз подгонять систему под нужное состояние казалось безумием.

Поэтому родился маленький инструмент, headless render harness. Он рендерит SwiftUI‑вью в PNG оффскрин, вообще без окна. Пишешь короткий сценарий строк на двадцать, инжектишь в модель нужное состояние и получаешь картинку.

Внутри ни ImageRenderer, ни настоящего NSWindow, а связка AppKit:

let hosting = NSHostingView(rootView: rootView)
hosting.frame = CGRect(origin: .zero, size: hosting.fittingSize)
hosting.layoutSubtreeIfNeeded()
let rep = hosting.bitmapImageRepForCachingDisplay(in: hosting.bounds)!
rep.size = hosting.bounds.size
hosting.cacheDisplay(in: hosting.bounds, to: rep)
let png = rep.representation(using: .png, properties: [:])

Скрипт компилирует сценарий как main.swift вместе со всеми исходниками (минус @main‑точку входа) через swiftc. А дальше я несколько раз поймал грабли лбом и записал каждую прямо рядом с инструментом, чтобы не переучиваться каждый раз. Вот это, наверное, самое полезное для разработчика:

  • NavigationSplitView рендерится пустой белой панелью. Сайдбару с List нужно настоящее окно и appearance‑контекст, которых у оффскрин‑рендера нет. Detail‑панель при этом рисуется нормально. Значит сценарии скоупим на отдельные карточки, а сайдбары проверяем в живом приложении.

  • Язык надо пиннить ПЕРВЫМ. L10nStore.shared.language = .ru — самый первый оператор, идет до создания модели и вью. Иначе EN‑first системная локаль отрендерит английский, и ты будешь долго тереть глаза.

  • Все приложение @MainActor, а верх main.swift — нет. Поэтому тело сценария заворачивается в MainActor.assumeIsolated { … }. Без этого просто‑напросто не собирается.

  • По мелочи: имя файла обязано быть main.swift; не линковать -framework Observation (это SDK‑модуль Swift, а не фреймворк, линковка падает); в harness никогда не звать методы, которые запускают работу (start(), refreshReport()) — состояние инжектится прямо в поля.

Ничего революционного, но это именно тот класс знаний, который нигде не гуглится и добывается только через боль.

CPU‑детектив, часть 1: анимация, которая жрала процессор (сильно)

Теперь про производительность. И главный поворот в том, что «злодеем» оказалась не тяжелая логика, а декоративные бесконечные анимации.

Улика раз (блок P). Диагностический агент с замерами (sample, счетчики спавнов top на переходах состояний) нашел, что радужная рамка‑акцент RainbowBorder в .onAppear запускала withAnimation(.repeatForever) безусловно и навсегда, независимо от того, активно вообще окно или нет. Итог — постоянный фон ~5-6% CPU во всех состояниях, включая свернутое и скрытое окно. sample показывал анимацию доминирующим кадром, даже когда коллекторы уже стояли на паузе. Починка: завязать анимацию на активность окна, а бегущий repeatForever гасить транзакцией disablesAnimations = true (простое переприсваивание его надежно не отцепляет, это отдельная маленькая грабля SwiftUI). Плюс расширить сам детектор паузы, чтоб ловил еще и «окно закрыто, но приложение фронтовое», и «свернуто».

Улика два (блок PERF). Через день idle CPU снова пополз вверх, ~27% против базовой линии в ~5.1%. Изоляционные тесты по каждому месту с repeatForever указали почти целиком (~16 процентных пунктов из ~18-23, то есть почти 90% всего разрыва) на один источник: «дышащий» бейдж‑предупреждение об осиротевших элементах автозапуска в AutostartCard.swift. Снова repeatForever, снова безусловный. Починка тут: перевести его на конечный repeatCount(5, autoreverses: true) (секунд десять, заканчивается на ярком состоянии, которое и так статически флажит проблему) и гейтить по видимости окна.

Результат замерил tester: средний CPU и во фронтовом простое, и под окклюзией — 0.01%. Падение примерно в 2000 раз. Это, если что, единственное число из всей статьи, которое я считаю железобетонным, комментарий с ним до сих пор живет в коде на AutostartCard.swift:70.

(Поправка от 27.07: пошел перепроверять число, чтобы оно было совсем уж железобетонным, и честный замер оказался все же скромнее. ps %cpu печатает проценты с точностью до десятой, поэтому все, что ниже 0.05%, он показывает нулем — усредняя такие сэмплы, получаешь заниженный результат. Если считать разностью накопленного cputime за 120-секундное окно, счетчик ядра не теряет ничего, и после фикса выходит 0.025%, то есть падение — примерно в 1000 раз. Числитель при этом подтвердился: на пересобранном коммите до фикса сегодня 22.7%. Отдельная оговорка — «дышащая» анимация запускалась только при наличии осиротевших элементов автозапуска, так что без них приложение этих процентов не показывало вовсе. Старый прибор льстил мне ровно там, где такие приборы обычно льстят всем — на маленьких значениях. Если интересны детали перезамера — спрашивайте в комментариях, отвечу с методикой.)

Мораль сей басни простая и полезная: любая repeatForever‑анимация — тихий вампир. Она заставляет NSHostingView перекладываться каждый кадр вечно. Гейтите по активности окна и предпочитайте конечные repeatCount.

CPU‑детектив, часть 2: как я не смог ускорить скролл (и почему это даже хорошо)

А вот теперь мое любимое: после 50-минутного стресс‑теста я заметил: CPU при скролле вкладки «Обзор» скачет выше 20%. Ну непорядок же (в тот момент я подумал, что все к чертям сломал). Ну ладно, открыли отдельный блок. Дальше пять подходов, и спойлер: каждая гипотеза о причине оказалась ложной, а блок закрыт как «дефект не найден».

  • Подход 1. Тоггл .allowsHitTesting(false) во время скролла — пустышка. Он гейтит AppKit‑хиттест кликов, а не hover‑диспетчеризацию SwiftUI.

  • Подход 2. Заморозка кадра через ImageRenderer. Доля упала почти в ноль, но я это отклонил: белые плитки в темной теме, курсор «not‑allowed», реальный лаг ввода в начале жеста. Лечить симптом хуже болезни.

  • Подход 3. Свести per‑row .onHover в per‑card onContinuousHover (число респондеров 85–300 упало до 13–17). Реализация чистая, ревью APPROVE, а замер — плоско, без улучшения. Чтение call‑tree: ~51% стоимости это обход дерева NSView.hitTest: вообще вне responder‑системы SwiftUI.

  • Подход 4 (диагностика). lldb показал, что живое дерево NSView крошечное: 53 вью, глубина 11. Рычаг «упростить иерархию» мертв. Парный sample (курсор над контентом против курсора вне окна) дал идентичные доли — стоимость не зависит от курсора. И, главное, я перестал смотреть на относительную долю и померил абсолютный CPU через top -pid: активный скролл это устойчивые 28–33% одного ядра, спад до нуля за секунду после остановки. Реально!

Дальше я поднял планку до «максимально нативно, чтоб не было стыдно» — и подход 5 пошел вширь.

  • Перевод строк процессов на NSTableView плюс NSTrackingArea — идентично базе. FAIL.

  • Абляция до нуля. Я физически закомментировал каждый .onHover и .hoverTip на вкладке (проверил grep‑ом и sample, где счетчик hover‑апдейтов упал в 0). Скролл‑CPU держался 35–41%, так же или чуть выше. Это доказало, что hover вообще никогда не был драйвером. А в верхушке стека sample доминировала общая машинерия render‑графа SwiftUI, AttributeGraph: AG::Graph::UpdateStack::update, AG::Graph::propagate_dirty, AGGraphGetValue. Одного «виноватого» кадра нет, стоимость размазана.

  • Ядерный вариант. Заменить внешний SwiftUI ScrollView целиком на тру AppKit NSScrollView плюс NSHostingView, контент оставив как есть. Собралось с первого раза, проскроллилось идеально. Замер: база 24.5% против POC 25.4%, елки‑палки, идентично. Гипотеза про контейнер опровергнута. Задним числом очевидно: SwiftUI ScrollView на macOS сам сделан поверх NSScrollView и NSClipView. Я поменял контейнер на его же реализацию.

И вот тут я задумался: а не врет ли сама методика? Как это проверить — прогнать тем же синтетическим драйвером заведомо нативное приложение — подсказала модель посильнее. Я направил тот же драйвер инъекции событий на нативный Finder (не‑SwiftUI‑first приложение Apple, так как скролл‑путь его списков — это классический AppKit NSTableView). Finder намерил медиану ~39.9%, выше моего приложения. А решающий ручной тест реальным трекпадом на обоих приложениях одной и той же командой захвата: MacDashboard ~32.3% медиана, Finder ~43.0%.

Вывод: специфичного для приложения, исправимого дефекта скролла в SwiftUI не существует. Каждый рычаг проверен и опровергнут в пух и прах, других механизмов в приложении нет, а на реальном вводе мой скролл по CPU на уровне или ниже полностью нативного AppKit‑списка. Блок закрыт, кода не меняли.

И вот обещанная честность. Почему я тогда «не хуже Finder»? Не потому что SwiftUI победил AppKit, а потому что read‑only дашборд из в основном статичных карточек делает на кадр гораздо меньше работы, чем живой файловый браузер, который рисует превью, иконки там, метаданные и QuickLook. Плюс стоимость скролла в SwiftUI, хоть и заметна, но не патологична. Вот и все: меньше работы на кадр и отсутствие патологии, и никакой магии.

Три метода, которые я унес из этой истории и рекомендую всем:

  1. Абсолютный CPU важнее относительной доли в профайлере. Доля говорит «где», но не «сколько» и не «почему».

  2. Физическая абляция честнее флага. Не «выключить hover настройкой», а вырезать целиком и посмотреть. Одно измерение закрыло целую ветку гипотез.

  3. Эталонный контроль ловит вранье методики. Прежде чем гордиться числом, прогони той же линейкой заведомо нативное приложение.

Выложил в паблик, и CI сразу нашел то, что мой Mac не видел

Отдельная волна работы — это сделать репозиторий публичным и не сесть в лужу по приватности. ИИ‑ассистент спрятан за compile‑time флагом (в дефолтном бинаре нет ни сетевых символов, ни строк провайдеров — проверено), из тестовых фикстур вычищены реальные серийники и hostname, а я лично sudo lsof‑ом убедился, что за сбор отчета приложение не открывает ни одного сетевого соединения. Историю из 74 коммитов я сначала заархивировал в отдельный приватный репозиторий, потом схлопнул публичный в один чистый коммит v1.0 «Cadia».

А дальше мой любимый урок. Первый же прогон CI на раннере GitHub macos-14-arm64 вскрыл то, что мой локальный тулчейн (Swift 6.3.3) пропускал присвистывая: ~36 ошибок actor‑isolation. Лечилось точечными @MainActor по 12 файлам плюс один [weak self]. Еще CI поймал пару выражений в фикстурах, взрывавших бюджет тайпчекера, и несколько проверок, которые молча предполагали именно мой MacBook (наличие батареи, число ядер и атрибут SMART). Их сделали портируемыми.

Вывод, который я, не‑программист, теперь считаю очевидным: «собирается и работает у меня» — это ну вот вообще не тест. CI на чужой машине это отдельная, очень отрезвляющая проверка.

И еще деталь, уже про раздачу. Готовый .app собирает отдельный релизный workflow и прикладывает к релизу. Но собрать его можно не на любом раннере: на Tahoe система Liquid Glass рендерит иконки и материалы в зависимости от мажорной версии SDK, поэтому workflow прибит к macos-latest с жестким ассертом sdk 26.*. Если GitHub однажды переедет на macOS 27, сборка релиза упадет. Причем намеренно и громко, чтоб я не отдал людям бинарь, который выглядит не так, как моя локальная сборка.

А сколько бы это стоило, если бы я платил за токены

Этот кусок я раскопал уже сильно после, когда приложение было готово. Напомню, что все делалось на личной подписке: я плачу фиксированную сумму в месяц и упираюсь в лимиты, а не в счет. Но мне стало любопытно, во что тот же самый объем работы обошелся бы по прайсу API, если платить за каждый токен. Транскрипты всех сессий лежат у меня локально в .jsonl, так что вопрос оказался чисто арифметическим.

Ответ: около 557$ за проект целиком, из них примерно 491 — основная фаза с 12 по 20 июля, от переписывания на SwiftUI до выкладывания в паблик. Это 45 сессий и 7 тысяч вызовов API. Средняя цена одного вызова 7,5 цента, средняя цена активного дня разработки около 62 долларов. Самый дорогой день — 17 июля, вышел в 129 баксов.

Дальше пошло интереснее, потому что по дороге я дважды чуть на порядок не ошибся.

Первая ловушка — а что вообще считать одним вызовом. Один ответ модели пишется в лог несколькими строками, по одной на блок контента: отдельно thinking, отдельно text, отдельно каждый tool_use. И во всех этих строках продублирован один и тот же объект usage. В среднем получается две строки на вызов. Если тупо просуммировать строки, счет удваивается. Правильно — группировать по requestId и брать запись с максимальным output_tokens. Кстати, встроенная сводная статистика (/usage, вкладка Overview) суммирует как раз построчно, поэтому завышает объем ровно в 2,11 раза. Число «10,4 миллиона токенов» на экране — это примерно 4,9 миллиона реальных.

Ловушка вторая, и вот она правда меняет картину мира. Смотрим, из чего вообще состоит трафик:

Тип токенов

Доля

Input, не из кэша

0,04%

Output, то есть ответы модели

1,0%

Запись в кэш

3,6%

Чтение из кэша

95,3%

То есть собственно вопросы и ответы — это один процент объема, а все остальное это prompt caching: каждый следующий запрос агента заново прочитывает весь предыдущий контекст сессии. Чтений из кэша в 96 раз больше, чем выданных моделью токенов, средний контекст на вызов — 72 тысячи токенов. И платится это все реальными деньгами: кэш дает 76% счета, а на сами ответы модели приходится меньше четверти.

Только вывод отсюда ровно обратный тому, который просится. Если бы кэша не было вовсе и каждый повторно прочитанный токен шел по обычной цене input, счет был бы 2737$. Кэш не раздул счет, он срезал его впятеро — круто.

И последнее, что меня порадовало отдельно. Помните матрешку из первой половины статьи и правило «Fable только на планировании»? Так вот, Fable сделала 15% вызовов и дала 44% счета: у нее 10$ за миллион входных токенов и 50$ за миллион выходных против 3$ и 15$ у Sonnet соответственно. Если пересчитать тот же самый трафик так, будто всю работу от начала до конца вела одна Fable, выходит около 1080$. То есть вдвое дороже. Правило я вводил из соображений качества, чтоб исполнителю не оставалось проектных решений, а по деньгам оно заодно срезало счет в два раза. Субагенты, если что, стоили 29% счета: у каждого свой контекст и свои вызовы.

Формулу я, само собой, не на глазок брал: она сверена с двумя официальными экранами /usage по конкретным сессиям, расхождение 0,1%. Если кому‑то интересна методика подробно, спрашивайте в комментариях, я распишу. А вообще, интересно послушать, как еще можно порезать косты, тоже буду рад вашим сообщениям.

Что дальше

Приложение живое, я продолжаю его потихоньку пилить. Теперь его можно и просто скачать готовым — universal .app лежит в релизах. Правда, пока без нотаризации: платную Apple Developer лицензию, как и более крутой тариф Claude, я пока не потяну, так что скачанная копия попросит разовый шаг с Gatekeeper. Если возиться совсем не хочется, можно собирать из исходников, там карантина нет вовсе. Код под MIT, лежит на гитхабе, багрепорты и идеи очень даже приветствую (в шаблоне issue есть напоминание, что безопасно прикладывать, а что нет).

Если из всего текста и уносить одну мысль, то пусть это будет не «ИИ написал приложение», а вот что: самой ценной оказалась не строчка кода, а дисциплина измерений. Абсолютные числа, физическая абляция и эталонный контроль. Их, в отличие от синтаксиса SwiftUI, стоит знать даже тем, кто, как я, программистом себя отнюдь не считает.