Spotify на 196 МБ вместо 2 ГБ, видеоплеер с пультом на iPhone и скринсейвер «Матрица»: что бывает, когда остались токены

—

от автора

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

Проект Kinrox (GitHub) родился ровно из этого состояния, когда в одной точке сошлись две бытовые боли и одно вечное эстетическое требование: сделать удобный видеоплеер с пультом на iPhone, лёгкий проигрыватель Spotify и, самое главное, встроить туда скринсейвер «Матрицы».

Первая боль — диван и проектор. MacBook подключён по HDMI к HD-проектору, ты лежишь под пледом, смотришь кино, и тут нужно поставить на паузу, убавить звук, переключить звуковую дорожку из вложенной папки или включить следующую серию. Вставать с дивана и идти к ноутбуку каждый раз — преступление против лени.

Вторая боль — цены на память у Apple. На моём MacBook Pro (MacBookPro18,2, Apple Silicon) всего 32 ГБ оперативки, и официальный клиент Spotify.app с удовольствием отъедает от них около 2 ГБ просто за право крутить музыку в фоне.

В англоязычном посте про релиз я коротко пошутил:

Memory is so expensive these days. My MacBook only has 32 GB, and Spotify takes up 2 GB of it. So I decided to rewrite the Spotify client in Rust. Now it uses 196 MB and three times less CPU. The app is open source, of course. It also has a built-in video player with iPhone remote control. Since Apple’s review process can now take 2-3 weeks, the app is currently available for Mac download only. Now, when I watch videos on a projector through HDMI, I can control everything from my iPhone and hardly have to get up at all. Hehe.

В коротком твите написать «переписал на Rust» проще всего, но для Хабра дадим полную раскладку без сокращений. Сам плеер и интерфейс написаны на нативном Swift 6 и SwiftUI (плюс крошечный сторожевой процесс на C), а в поисках лёгкого движка для музыки мы прошли через целый зоопарк клиентов Spotify: от обычного Web API и изучения Rust-крейта librespot до совсем свежего официального headless-бинаря Spotify Soloist, который вышел буквально на днях и ест 25-27 МиБ памяти.

Короткая видеодемонстрация того, как всё это работает вживую:

Поехали.

Диван, HDMI и почему существующие плееры не подошли

С видеоплеером задача выглядела простой: нужен минималистичный плеер на macOS, который всеядно крутит тяжёлые MKV и MP4, не перегружен сотней кнопок и управляется с родного iOS-приложения по локальной сети. Без облаков, регистраций, внешних серверов и телеметрии.

Казалось бы, есть IINA и VLC. Но у IINA нет официального нативного пульта под iPhone, а веб-интерфейс VLC с паролем в мобильном браузере — это последнее, во что хочется тыкать в темноте на диване.

Мы взяли проверенное ядро libmpv 0.41.0 (FFmpeg 9.0.1, OpenGL и аппаратное декодирование через VideoToolbox), обернули его в чистый Swift-протокол PlayerEngine, чтобы типы C (mpv_handle, mpv_event) никогда не протекали наружу из модуля MPVKit, и собрали пару PlayerMac + RemoteiOS.

На пальцах. Как устроен пульт, чтобы не нужно было ничего настраивать руками: Mac-плеер поднимает локальный сервер через Network.framework и анонсирует себя в подсети по Bonjour → iPhone мгновенно находит Mac → при первой связке на экране ноутбука появляется 6-значный PIN-код → приложения проводят криптографический хендшейк через CryptoKit, сохраняют токен в системный Data Protection Keychain и дальше гоняют зашифрованные JSON-фреймы по локальному WebSocket.

Попутно для видео закрыли три вещи, которые постоянно бесят при просмотре сериалов с торрентов. Например:

  • Рекурсивный поиск внешних дорожек и субтитров. Если озвучка и .ass/.srt лежат в соседних вложенных папках, плеер сам находит их и запоминает выбор для следующего эпизода в этой директории.

  • Живой плейлист папки с естественной сортировкой (2 идёт перед 10). Пока ты смотришь первую серию, а в ту же папку докачивается вторая, кнопка Next на iPhone загорается сама. Временные хвосты недокачанных файлов вроде .part, .crdownload и .!qB фильтр игнорирует, пока скачивание не завершится.

  • Единый координатор PlaybackCoordinator («один звук за раз»). Нажал Play в фильме — музыка гарантированно встала на паузу; включил трек — остановилось видео.

Видеоплеер взлетел за пять дней (версии 0.1.0 — 0.3.0). И тут взгляд упал на Activity Monitor, где рядом со скромным Kinrox сидел двухгигабайтный Spotify.app, а на счету всё ещё оставались свободные токены.

Иллюзия быстрой победы: почему у Spotify столько клиентов и как мы написали «экономитель памяти», требующий открытого Spotify

Ну а теперь начнём разбираться, как задача «быстренько прикрутить музыку на выходные» превратилась в многосерийное детективное расследование.

Изначально план казался железобетонным: делаем во вкладке Music классический трёхколоночный интерфейс в духе старого Winamp (Source -> Albums -> Tracks, никаких гигантских обложек на пол-экрана, всё плотно и быстро), авторизуемся по OAuth PKCE со своим Client ID и играем музыку. Главное правило, которое мы записали в спецификацию первой строкой: никаких WKWebView, скрытых браузеров и Chromium внутри приложения. Иначе борьба с двухгигабайтным Electron-клиентом теряет всякий смысл.

Но когда начинаешь копать экосистему Spotify в 2026 году, выясняется, что «клиентов Spotify» на самом деле несколько, и у каждого своя засада:

  1. Официальный Spotify Web API (/v1/me/player, он же Spotify Connect) сам декодировать аудиопоток не умеет. Он только отдаёт команды активному устройству в сети.

  2. Браузерный Web Playback SDK требует среду с EME/DRM, то есть тот самый WebView, который мы запретили на пороге.

  3. Неофициальные клиенты на базе реверс-инжиниринга протокола (librespot на Rust, go-librespot) умеют играть сами, но тянут за собой возню с закрытым протоколом и правилами платформы.

  4. И наконец, Spotify Soloist (com.spotify.soloist) — совсем свежий официальный headless-клиент от самой Spotify для Apple Silicon и Core Audio, управляемый по локальному WebSocket.

Поначалу (в релизе 0.4.0) мы пошли по первому, самому «правильному» пути — через официальный Web API и Spotify Connect. Сделали авторизацию, подтянули библиотеку, выпустили билд… и тут до нас дошла вся комичность ситуации.

Чтобы SpotifyConnectMusicEngine издал хотя бы один звук через /v1/me/player, на Маке должен быть запущен активный приёмник Spotify Connect. То есть официальный Spotify.app должен быть открыт!

Понимаете уровень инженерии? Мы затеяли всю эту историю ради того, чтобы убить прожорливый Spotify.app и освободить 2 ГБ оперативки, а в первой версии написали для него… внешнюю панель управления, которая без запущенного Spotify.app выдаёт SpotifyError.noDevice и вежливо просит открыть официальный клиент. План был отличный, реализация тоже ничего.

Но даже в роли пульта для Spotify.app первая реализация устроила нам показательную порку.

Грабли №1. HTTP 204, который молча убивает воспроизведение

По документации Spotify Web API, чтобы запустить свою очередь треков, можно отправить массив uris в PUT /v1/me/player/play. Мы так и сделали. Сервер бодро ответил HTTP 204 No Content, после чего десктопный клиент Spotify… просто очистил плеер и замолчал. А любая следующая команда падала с 403 Restriction violated.

Оказалось, что это известный баг актуального десктопного клиента при передаче сырого списка URI. Пришлось городить обходной манёвр: создавать в аккаунте приватный технический плейлист Kinrox Queue, зеркалировать туда локальную очередь и запускать воспроизведение уже в контексте этого плейлиста.

Грабли №2. Один трек и вечная заморозка из-за гонки поллинга

Выкатываем 0.4.0, включаем альбом. Первая песня играет отлично, а дальше интерфейс Kinrox намертво замирает на старом треке и перестаёт реагировать на кнопки.

Расследование в 0.4.1 показало классическую асинхронную гонку:

  1. Поскольку у Web API нет пуш-уведомлений, движок опрашивал GET /v1/me/player каждые 2 секунды.

  2. Пока в воздухе висел старый запрос поллинга от предыдущего состояния, пользователь нажимал Play на новом альбоме.

  3. Команда Play успевала полностью выполниться и подтвердить старт новой очереди. Флаг isCommandRunning снова становился false.

  4. И в этот момент возвращался тот самый запоздавший ответ от старого поллинга (со старым контекстом или случайным HTTP 503).

  5. Движок видел чужой контекст при isCommandRunning == false, решал, что воспроизведение перехватили извне, выбрасывал ошибку и обнулял задачу поллинга (polling = nil). Новую задачу никто не создавал, потому что в момент старта Play старая ещё числилась живой.

Вылечили введением монотонного счётчика поколений commandGeneration: каждая команда сдвигает эпоху, и любой ответ поллинга со старым номером поколения отправляется в мусорку.

Грабли №3. Почему переключение трека делало 11 HTTP-запросов, а библиотека грузилась вечность

Когда мы посмотрели в сетевой лог 0.4.1 под микроскопом, волосы встали дыбом.

Во-первых, при каждом холодном запуске приложение грузило с нуля всю медиатеку строго последовательно: все страницы лайкнутых треков → все сохранённые альбомы → всех артистов → список плейлистов → все треки каждого редактируемого плейлиста. Пока этот паровоз не доезжал до конца, экран альбомов ждал. Самое обидное: метод GET /v1/me/albums уже возвращает внутри себя до 50 треков каждого альбома (SpotifyAlbum.tracks), но наш парсер их просто выбрасывал и при клике на альбом делал ещё 2 HTTP-запроса! А слияние каталога в памяти крутило линейные поиски first(where:) со сложностью O(N²) по тысячам треков прямо на @MainActor.

Во-вторых, когда пользователь нажимал Next, Previous или даже обычный Resume после паузы, вызывался полный цикл startQueue. Выглядело это так: от 9 до 11 последовательных HTTP-запросов (проверить девайсы → прочитать плеер → поставить на паузу → подтвердить паузу → проверить плейлист Kinrox Queue → перезаписать в нём треки → выключить шаффл → выставить репит → запустить play → дождаться подтверждения). При этом проверка подтверждения спала на том же фиксированном интервале в 2 секунды! Даже добавление одной песни в очередь на лету (addToQueue) ставило музыку на паузу, переписывало весь плейлист и перезапускало текущий трек. А фоновый поллинг раз в 2 секунды сжигал по 1800 запросов в час, рискуя упереться в HTTP 429 и лимиты Spotify Development Mode.

В версии 0.4.2 мы вычистили это под корень:

  • Подняли локальный кэш в SQLite (libsqlite3 в отдельном сериализованном акторе SpotifyCacheStore, 5 компактных таблиц без лишних join-таблиц, лимит 100 МиБ). Теперь при запуске медиатека на 10 000 треков открывается с диска за 40,98 мс (на 100 треках — за 16,65 мс) вообще без единого сетевого запроса.

  • Заменили линейные сканы в MusicCatalog на словари O(1): выбор плейлиста на 10 000 треков ускорился со 138,61 мс до 2,08 мс (почти в 67 раз).

  • Сделали трёхволновую загрузку (Wave 0 с диска → Wave 1 альбомы со встроенными треками → Wave 2/3 остальное в фоне с приоритетным планировщиком запросов, где команды управления всегда обгоняют синхронизацию) и поддержку ETag / HTTP 304.

  • Убили периодический поллинг. Совсем. На паузе Kinrox теперь делает 0 фоновых запросов, а во время игры двигает прогресс по локальным монотонным часам ContinuousClock и ставит ровно один одноразовый таймер на ожидаемую границу конца трека (duration - position + 0.35 с).

  • Сделали быстрые пути (Fast Paths) для Resume, Next и Previous по смещению (offset) и точечную вставку в очередь через POST /v1/playlists/{id}/items без остановки музыки, а интервалы подтверждения перевели на быстрый бэкофф 80 → 150 → 300 мс.

Вывод. Приложение полетело, но главная заноза осталась: рядом в системе всё ещё висел двухгигабайтный Spotify.app. Пришло время резать по живому.

Переход на Spotify Soloist: одно чиним — другое взрывается

Итак, задача для релиза 0.5.0 звучала бескомпромиссно: Spotify.app должен быть полностью закрыт (pgrep -x Spotify обязан возвращать пустоту на протяжении всего теста). Никаких свёрнутых окон и скрытых браузеров.

Мы изучили варианты и остановились на Spotify Soloist (com.spotify.soloist) — свежем официальном консольном движке от самой Spotify под macOS (arm64, Core Audio). По условиям Spotify его нельзя вшивать внутрь чужого .dmg, поэтому Kinrox при настройке сам скачивает архив с официального CDN Spotify, проверяет цифровую подпись Apple Developer ID (Team ID 2FNC3A47ZF) и нотаризацию через SecStaticCodeCheckValidity, распаковывает в изолированную папку и общается с ним по локальному WebSocket на 127.0.0.1. Один раз спариваешь его через приложение Spotify на телефоне в одной Wi-Fi-сети, закрываешь Spotify везде — и музыка играет напрямую через Core Audio.

И вот тут начался настоящий триллер.

Серия 1. Трек длится 5 минут, а потом врывается бессмертный Autoplay

Запускаем в Soloist обычный постоянный режим (persistent mode). Нам важно, чтобы очередью треков на 100% владел сам Kinrox: у нас свои правила шаффла, повтора и истории. Поэтому на iPhone в настройках Spotify мы явно выключаем Autoplay, а по WebSocket отправляем в Soloist команды shuffle: false и repeat: "off".

Загружаем длинный трек на 323 секунды (5 с лишним минут). Терпеливо ждём все пять минут, пока он доиграет до естественного конца (EOF), ожидая, что плеер спокойно перейдёт к следующему треку нашей очереди.

Вместо этого на 324-й секунде Soloist радостно игнорирует все выключенные тумблеры и врубает левый рекомендованный трек с пометкой source: autoplay! Оказалось, что в постоянном режиме внутренний движок после окончания одиночного URI всё равно лезет за авторекомендациями Spotify, плевать он хотел на ваши локальные планы.

Серия 2. Включаем —single-track и получаем бомбу с часовым механизмом на 60 секунд

Хорошо, раз постоянный режим своевольничает, переводим Soloist в режим --single-track: один процесс на один трек из очереди. Отыграл песню → чисто завершился с exit code 0 → наш редьюсер проверил, что это реальный конец трека, и запустил следующий. Автоплей побеждён!

Нажимаем паузу, чтобы налить чаю. Проходит ровно минута. На отметке 60,6 секунды (во втором контрольном прогоне — 61,75 секунды) стоящий на паузе процесс soloist молча умирает с exit code 1! Кнопки управления в плеере превращаются в тыкву.

Снова лезем в трассировки: в режиме --single-track движок считает любую паузу длиннее ~60 секунд брошенной сессией и аварийно выходит.

Как сделать так, чтобы пауза могла длиться хоть сутки, но процесс не падал через минуту? Мы придумали механизм 20-секундной парковки:

  • Пока пауза короткая (до 20 секунд) — процесс soloist ждёт наготове, и Resume срабатывает за 11-22 мс.

  • Как только пауза переваливает за 20 секунд (задолго до роковой 60-й секунды!), Kinrox запоминает точную подтверждённую позицию в миллисекундах и сам аккуратно гасит дочерний процесс soloist. В итоге на длинной паузе фоновый движок потребляет 0 МБ памяти и 0% CPU.

  • Когда ты возвращаешься через полчаса и жмёшь Space, Kinrox поднимает свежий процесс soloist с громкостью 0% (чтобы не было слышно начала песни с первой секунды), делает seek на сохранённую позицию, ждёт подтверждения от декодера и только после этого возвращает твою громкость. Всё восстановление занимает 1,6-2,1 секунды.

Правда, и тут мы умудрились наступить на грабли: сначала таймер парковки запускался только тогда, когда паузу нажали внутри самого Kinrox. Во время тестового прослушивания пауза прилетела внешним событием, таймер на 20 секунд не взвёлся, и через 60 секунд soloist снова гордо вышел с кодом 1. Починили: теперь любое наблюдаемое состояние paused ставит процесс на парковку.

Серия 3. «Не удалось открыть видео: Spotify не разрешил»

Казалось бы, музыка наконец играет без Spotify.app. Решаем проверить, не сломали ли мы обычный видеоплеер. Открываем локальный .mkv с фильмом… и получаем на весь экран красную плашку:

Failed to open video / Soloist could not confirm the playback command.

Немая сцена. Ты открываешь локальный видеофайл с диска в видеоплеере, а тебе отказывают, потому что музыкальный движок Spotify не подтвердил команду!

Причина оказалась анекдотичной: наш PlaybackCoordinator перед открытием фильма честно выполнял правило «один звук за раз» и просил музыкальный движок встать на паузу. А новый SoloistMusicEngine при получении setPaused(true) проверял, есть ли в очереди активный трек (occurrenceID), видел nil (музыку-то с момента запуска ещё никто не включал!) и выбрасывал ошибку неподтверждённой команды, которая отменяла открытие видео.

Серия 4. Зомби после SIGKILL и релиз с громкостью 0%

Дальше всплыли ещё два сюрприза.

Во-первых, проблема процесса-сироты при жёстком убийстве приложения. Поскольку Kinrox запускает soloist как дочерний процесс, при обычном выходе мы его аккуратно гасим. Но если сам Kinrox.app прибить через kill -9 (SIGKILL), дочерний soloist останется сиротой и продолжит играть музыку из ниоткуда без единого окна в системе. Чтобы это исключить, мы написали крошечный сторожевой процесс на чистом C — KinroxSoloistHost (весит 53 КБ на диске, 0,94 МиБ в памяти и 0% CPU). Ему не передаются никакие ключи или токены — только унаследованный файловый дескриптор пайпа (pipe FD), PID процесса soloist, время его старта и путь к бинарю. Если Kinrox внезапно умирает от SIGKILL, пайп закрывается ядром ОС, KinroxSoloistHost просыпается, сверяет proc_pidpath и время старта (чтобы случайно не убить чужой процесс, если ОС переиспользовала PID) и гасит soloist за 139,7 мс.

Во-вторых, курьёз со звуком в 0.5.0. Чтобы прогнать тысячи автоматических тестов (включая 30-минутный прогон и стресс-тест на 1003 команды с реального iPhone) и не оглохнуть, все тесты по правилам репозитория обязаны запускаться с gain = 0 (0% громкости внутри плеера). Мы так привыкли видеть в логах заветное confirmed gain zero, что в релизе 0.5.0 оставили 0% стартовой громкостью по умолчанию для новых установок! Включаешь трек, время бежит, всё работает — а в колонках тишина. В 0.5.1 вернули дефолтные 100% и сохранение громкости между сессиями.

Финальный босс (0.5.2): почему soloist ест 27 МБ, а наш «лёгкий» SwiftUI — 342 МБ и 94% ядра?

Когда в 0.5.1 вся цепочка заработала как часы, мы открыли top, vmmap и sample, чтобы полюбоваться на сэкономленную память.

За картину со стороны soloist можно было только порадоваться: 24,7-27,3 МиБ физического футпринта и 0,7-1,06% одного ядра CPU во время воспроизведения.

А вот сам процесс Kinrox.app в режиме музыки внезапно показал 342 МБ памяти с постоянно мигающим плюсиком (342M+ в top) и на первых замерах отъедал до 93,96% одного ядра CPU (а после первой заплатки на таймер — стабильные 7-10% CPU даже в свёрнутом виде)!

Интересно. В top на macOS суффикс + рядом с памятью (342M+) часто путают с бесконечной утечкой памяти. На самом деле наш 20-минутный прогон под leaks показал ровное плато в 341,9-342,5 МБ и всего 224 байта утечек на 2 миллиона аллокаций. Плюсик в top означает лишь то, что за последнюю секунду между двумя сэмплами аллокатор чуть прирос из-за постоянного пересоздания временных буферов SwiftUI, которые сборщик тут же освобождает. Но сам этот круговорот (churn) на ровном месте жрал процессор.

Мы сняли профили через sample и heap и нашли две прекрасные причины, о которые спотыкаются многие, кто пишет десктоп под macOS на SwiftUI.

Причина 1. База на 717 КБ превращается в 755 262 ноды AttributeGraph

Смотрим на диск: вся наша SQLite-база медиатеки (837 альбомов, 880 артистов, 1099 треков и 27 плейлистов) весит 717 КБ (около 2-3 МБ в декодированных структурах Swift). Откуда тогда в куче (Malloc + AttributeGraph) лишние 225 МБ и 2 003 806 живых аллокаций?!

Виновник нашёлся в одной строчке левой колонки SourceColumn.swift. Список источников и всех 880 артистов лежал внутри ScrollView в обычном не-ленивом VStack вместо LazyVStack!

Что сделал SwiftUI при открытии окна:

  • Жадным образом инстанцировал все 880 строк артистов одновременно, хотя в сайдбаре шириной 220 точек видно от силы 20 штук.

  • В каждой строке артиста было по две кнопки (Button выбора и сердечко лайка MusicHeart) и по два модификатора всплывающей подсказки .help(...).

  • В результате один этот VStack породил 1760 из 1965 всех кнопок Button во всём приложении (89,6%), 84 342 ноды HelpStyleConfiguration и 1760 трекинг-зон AppKit (_NSTrackingAreaAKViewHelper)!

  • Вдобавок в таблице треков каждая строка MusicTrackRow заворачивала себя в собственный GeometryReader, безусловно вешала .popover(LikePopover) и регистрировала .dropDestination даже там, где перетаскивание запрещено.

Причина 2. Четыре тика в секунду ради секундного таймера

Почему при этом горел CPU? В нижней деке (DeckView) крутился цикл, который каждые 250 мс (4 Гц) обновлял дробное свойство clock.position у @Observable-модели.

При этом текстовый индикатор времени на экране показывает целые секунды (1:04 → 1:05)! То есть 3 из 4 тиков каждую секунду впустую дёргали SwiftUI ради абсолютно той же строки текста, попутно пересоздавая словари AccessibilityAttachment и заставляя AppKit-обёртку стандартного слайдера (NSSlider внутри SwiftUI.Slider) инвалидировать layout. А поскольку у текстового таймера не было жёстко зафиксированных границ ширины и высоты, каждый тик раз в 250 мс помечал грязным корневой GeometryReader всего окна и заставлял SwiftUI/AppKit обходить тот самый раздутый граф из 755 262 нод и 2084 трекинг-зон! И всё это продолжало молотить даже тогда, когда окно плеера было полностью перекрыто другими программами или свёрнуто.

В релизе 0.5.2 мы вылечили оба узла:

  1. Заменили VStack на LazyVStack в колонке источников, убрали лишние .help со строк, вынесли GeometryReader из строк таблицы на уровень контейнера, а .popover и .dropDestination стали подключать только к активной строке.

  2. Заменили тяжёлый SwiftUI.Slider (на базе AppKit NSSlider) на собственный лёгкий слайдер из нативных Capsule + Circle.

  3. Квантовали часы деки до целых секунд (1 Гц вместо 4 Гц), заперли текстовые таймеры в фиксированные фреймы и подписались на NSWindow.didChangeOcclusionStateNotification: когда окно закрыто другими окнами или свёрнуто, UI-таймер спит совсем (0 пробуждений).

В итоге потребление памяти упало примерно на 150 МБ (до тех самых ~180-196 МБ в рабочем режиме против 2 ГБ у официального клиента), количество живых нод в куче сократилось с 2 миллионов до менее чем 350 тысяч, а нагрузка на CPU упала до <1% одного ядра при открытом окне и ~0% в фоне.

И конечно, куда же без скринсейвера «Матрицы»!

Вы же знаете главное негласное правило разработки: если ты пишешь своё приложение и тебя никто не может остановить, в нём обязан быть скринсейвер «Матрицы». Поэтому в Kinrox (начиная с версии 0.3.0) он есть и на Маке (Screensaver → Matrix), и отдельной кнопкой прямо в пульте на iPhone.

Работает это так: смотришь ты фильм на проекторе, решил отойти за чаем — жмёшь на айфоне Matrix. Плеер сначала дожидается от libmpv аппаратного подтверждения паузы, плавно гасит подсказку о выходе через 3 секунды (причём шевеление мышки подсказку обратно не возвращает, чтобы ничто не портило картинку на стене), и поверх кадра на SwiftUI Canvas в 30 fps начинают бежать зелёные потоки символов с яркими головными глифами. Нажал Esc на Маке или Stop на iPhone — вернулся ровно в тот же кадр на паузе, не вылетая из полноэкранного режима.

Даже со скринсейвером не обошлось без забавного бага: изначально анимация MatrixRainView опиралась на стандартный TimelineView и зависела от scenePhase. Когда мы запустили проверку в окне, вложенном в NSHostingView с фоновым scenePhase (типичная ситуация, когда окно висит на втором экране или проекторе, а ты кликнул в другое приложение), зелёный дождь просто замёрз на первом кадре! Пришлось отвязать таймлайн Canvas от scenePhase, написать регрессионный тест, который рендерит два кадра подряд в фоновом окне и побайтово проверяет, что капли реально сдвинулись, а заодно научить PlayerWindowController перехватывать первый Esc до того, как AppKit успеет свернуть нативный фулскрин.

Замеры и цифры: что получилось в версии 0.5.3

Сведём все замеры в одну таблицу. Окружение: MacBookPro18,2 (Apple Silicon, 32 ГБ RAM, macOS 26.5.2), релизные сборки Kinrox 0.5.0-0.5.3 и Spotify Soloist 1.3.10.36.

Показатель

До / Альтернатива

В Kinrox (0.5.2 — 0.5.3)

Уровень уверенности

Память Spotify.app против связки Kinrox + Soloist

~2000 МБ (Spotify.app)

~180-196 МБ (Kinrox) + 24,7-27,3 МиБ (soloist) + 0,94 МиБ (reaper)

Твёрдо (30-минутный soak-тест и vmmap)

Нагрузка на CPU при игре музыки

93,96% ядра (первый UI-прогон) / 7-10% (до 0.5.2)

<1% у Kinrox на экране (~0% в фоне) + 0,95-1,06% у soloist

Твёрдо (sample + proc_pid_rusage)

Потребление soloist на длинной паузе (>20 с)

Вылет с exit code 1 на 60-й секунде

0 МБ RAM, 0% CPU (процесс припаркован, позиция сохранена)

Твёрдо

Фоновые запросы к Web API при игре и на паузе

1800 запросов/ч (игра) и 360 запросов/ч (пауза)

0 периодических запросов (локальные часы + 1 проверка в конце трека)

Твёрдо

Открытие кэшированной медиатеки (10 000 треков)

Полный последовательный обход по сети

40,98 мс (p95) с диска из SQLite, 0 HTTP-запросов

Твёрдо (синтетический стенд)

Выборка плейлиста на 10 000 треков в памяти

138,61 мс (O(N²) линейный поиск)

2,08 мс (O(1) индекс в MusicCatalog)

Твёрдо

Команды с физического iPhone по LAN (200 циклов, 1003 команды)

9-11 HTTP-запросов на каждый Next в 0.4.0

Pause p50 72,5 мс, Resume 87,0 мс, Seek 83,0 мс, Next p50 2,10 с (p95 3,095 с, 0 ошибок)

Твёрдо (phone-200.xcresult)

Локализация интерфейса и сайта

Только EN/RU на старте

30 языков (включая RTL: арабский, иврит, персидский, урду)

Твёрдо (автопроверка 348 ключей и 120 страниц)

Что из этого следует: три инженерных паттерна, которые можно унести к себе

Зачем мы вообще так упоролись с генерациями эпох, vmmap и сишным сторожем в домашнем pet-проекте? Чтобы написать систему, которую понятно поддерживать через два года и которая не разваливается под реальной нагрузкой. Из наших шишек складываются три универсальных правила, которые работают в любом десктопном приложении на macOS:

  1. Если ваш SwiftUI-интерфейс показывает посекундный таймер или прогресс-бар, никогда не кормите @Observable-модель дробными значениями с частотой 4-60 Гц и не оставляйте текстовые счётчики без фиксированного frame(width:height:). Квантуйте обновления по границе реально отображаемой единицы (раз в секунду) и гасите UI-цикл по NSWindow.didChangeOcclusionStateNotification, когда окно закрыто другими программами.

  2. Когда управляете внешним асинхронным движком (libmpv, Web API или локальным WebSocket-демоном), никогда не верьте ответу HTTP 204 или факту отправки команды как доказательству перехода в новое состояние. Храните монотонный счётчик эпох (sessionEpoch / commandID) и переводите UI только после подтверждённого события от самого движка, отбрасывая ответы из прошлых эпох.

  3. Если приложение спавнит дочерний демон, обычного terminationHandler недостаточно — он не спасёт при SIGKILL родителя. Маленький бинарь-сторож на C без секретов, следящий за закрытием унаследованного pipe FD и проверяющий proc_pidpath + время старта перед SIGTERM, решает проблему процессов-сирот раз и навсегда.

Где это не сработает и какие есть ограничения

Чтобы никто не строил ложных иллюзий, сразу обозначим границы и компромиссы текущей версии (0.5.3):

  • Для музыки нужен Spotify Premium, свой Client ID и ключ Soloist. Из-за лимитов Spotify Development Mode мы не можем раздать один общий Client ID на всех пользователей. В настройках нужно вставить свой Client ID из кабинета разработчика Spotify, получить персональный ключ на странице Spotify Soloist и один раз спарить движок с телефоном. Видеоплеер и пульт для фильмов работают вообще без интернета и без единого ключа.

  • Сборка Soloist живёт 90 дней. Это ограничение самой Spotify: через 90 дней после сборки бинарь завершается с exit code 10. Kinrox отслеживает этот код и предлагает обновить движок в один клик прямо из настроек.

  • Переключение трека (Next) занимает около 2 секунд (p50 = 2,1 с, p95 = 3,1 с). Поскольку ради защиты от автоплея мы запускаем отдельный --single-track процесс на каждый элемент очереди и каждый раз честно проверяем цифровую подпись бинаря перед стартом, бесшовного gapless-перехода между треками в первой версии нет.

  • В режиме музыки принципиально нет обложек альбомов. Это осознанное решение в стиле старого Winamp: три плотные текстовые колонки, мгновенная навигация с клавиатуры, сортировка по дате добавления (Added в 0.5.2) и поповеры с метаданными артистов и альбомов по клику.

  • Что с приложением для iPhone: пока проверка в App Store у Apple растягивается на 2-3 недели, готовый подписанный и нотаризованный .dmg выложен только для Mac (Apple Silicon, macOS 14+). Но весь исходный код пульта RemoteiOS лежит в том же репозитории — его можно за пару минут собрать в Xcode и залить на свой iPhone (плюс для тестирования даже без Мака в приложении есть демо-режим по флагу -KinroxDemo).

ИТОГ.

Это только первая версия (0.5.3), выросшая из желания не вставать с дивана к проектору и сделать наконец такой клиент для Spotify, которым приятно пользоваться самому — быстрый, нативный, со скринсейвером «Матрицы» и без двух гигабайт балласта в оперативке.

Проект открытый (Mac-плеер под GPL-3.0-only, код iPhone-пульта открыт для личного некоммерческого использования и форков):

Берите, пользуйтесь, форкайте, модифицируйте под себя или закидывайте issue и pull request’ы в репозиторий. А в комментариях расскажите: чем вы сами управляете домашним кинотеатром с Мака и сколько гигабайт у вас прямо сейчас отъедает фоновая музыка?

ссылка на оригинал статьи https://habr.com/ru/articles/1104706/