
Знаете этот опасный момент в конце расчётного периода, когда задачи по основным проектам закрыты, а на балансе остались неистраченные токены? Нормальный человек ложится спать. Разработчик с остатком лимитов начинает творить всякую дичь.
Проект 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» на самом деле несколько, и у каждого своя засада:
-
Официальный Spotify Web API (
/v1/me/player, он же Spotify Connect) сам декодировать аудиопоток не умеет. Он только отдаёт команды активному устройству в сети. -
Браузерный Web Playback SDK требует среду с EME/DRM, то есть тот самый
WebView, который мы запретили на пороге. -
Неофициальные клиенты на базе реверс-инжиниринга протокола (
librespotна Rust,go-librespot) умеют играть сами, но тянут за собой возню с закрытым протоколом и правилами платформы. -
И наконец, 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 показало классическую асинхронную гонку:
-
Поскольку у Web API нет пуш-уведомлений, движок опрашивал
GET /v1/me/playerкаждые 2 секунды. -
Пока в воздухе висел старый запрос поллинга от предыдущего состояния, пользователь нажимал
Playна новом альбоме. -
Команда
Playуспевала полностью выполниться и подтвердить старт новой очереди. ФлагisCommandRunningснова становилсяfalse. -
И в этот момент возвращался тот самый запоздавший ответ от старого поллинга (со старым контекстом или случайным
HTTP 503). -
Движок видел чужой контекст при
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 мы вылечили оба узла:
-
Заменили
VStackнаLazyVStackв колонке источников, убрали лишние.helpсо строк, вынеслиGeometryReaderиз строк таблицы на уровень контейнера, а.popoverи.dropDestinationстали подключать только к активной строке. -
Заменили тяжёлый
SwiftUI.Slider(на базе AppKitNSSlider) на собственный лёгкий слайдер из нативныхCapsule+Circle. -
Квантовали часы деки до целых секунд (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) |
Уровень уверенности |
|---|---|---|---|
|
Память |
~2000 МБ ( |
~180-196 МБ ( |
Твёрдо (30-минутный soak-тест и |
|
Нагрузка на CPU при игре музыки |
93,96% ядра (первый UI-прогон) / 7-10% (до |
<1% у |
Твёрдо ( |
|
Потребление |
Вылет с |
0 МБ RAM, 0% CPU (процесс припаркован, позиция сохранена) |
Твёрдо |
|
Фоновые запросы к Web API при игре и на паузе |
1800 запросов/ч (игра) и 360 запросов/ч (пауза) |
0 периодических запросов (локальные часы + 1 проверка в конце трека) |
Твёрдо |
|
Открытие кэшированной медиатеки (10 000 треков) |
Полный последовательный обход по сети |
40,98 мс (p95) с диска из SQLite, 0 HTTP-запросов |
Твёрдо (синтетический стенд) |
|
Выборка плейлиста на 10 000 треков в памяти |
138,61 мс ( |
2,08 мс ( |
Твёрдо |
|
Команды с физического iPhone по LAN (200 циклов, 1003 команды) |
9-11 HTTP-запросов на каждый |
|
Твёрдо ( |
|
Локализация интерфейса и сайта |
Только EN/RU на старте |
30 языков (включая RTL: арабский, иврит, персидский, урду) |
Твёрдо (автопроверка 348 ключей и 120 страниц) |
Что из этого следует: три инженерных паттерна, которые можно унести к себе
Зачем мы вообще так упоролись с генерациями эпох, vmmap и сишным сторожем в домашнем pet-проекте? Чтобы написать систему, которую понятно поддерживать через два года и которая не разваливается под реальной нагрузкой. Из наших шишек складываются три универсальных правила, которые работают в любом десктопном приложении на macOS:
-
Если ваш SwiftUI-интерфейс показывает посекундный таймер или прогресс-бар, никогда не кормите
@Observable-модель дробными значениями с частотой 4-60 Гц и не оставляйте текстовые счётчики без фиксированногоframe(width:height:). Квантуйте обновления по границе реально отображаемой единицы (раз в секунду) и гасите UI-цикл поNSWindow.didChangeOcclusionStateNotification, когда окно закрыто другими программами. -
Когда управляете внешним асинхронным движком (
libmpv, Web API или локальным WebSocket-демоном), никогда не верьте ответуHTTP 204или факту отправки команды как доказательству перехода в новое состояние. Храните монотонный счётчик эпох (sessionEpoch/commandID) и переводите UI только после подтверждённого события от самого движка, отбрасывая ответы из прошлых эпох. -
Если приложение спавнит дочерний демон, обычного
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-пульта открыт для личного некоммерческого использования и форков):
-
Сайт и скачивание DMG для Mac: https://kinrox.app/
-
Исходники, документация и все расследования в
docs/RESEARCHES: https://github.com/awaik/Kinrox
Берите, пользуйтесь, форкайте, модифицируйте под себя или закидывайте issue и pull request’ы в репозиторий. А в комментариях расскажите: чем вы сами управляете домашним кинотеатром с Мака и сколько гигабайт у вас прямо сейчас отъедает фоновая музыка?
ссылка на оригинал статьи https://habr.com/ru/articles/1104706/