diff --git a/MusicBridge/ContentView.swift b/MusicBridge/ContentView.swift index a74097c..fa2d745 100644 --- a/MusicBridge/ContentView.swift +++ b/MusicBridge/ContentView.swift @@ -32,8 +32,10 @@ struct ContentView: View { Label(systemPlayer.running ? "Выключить системный пульт" : "Включить системный пульт iOS", systemImage: "play.rectangle") }.disabled(systemPlayer.busy || (!systemPlayer.running && (!store.connected || store.state?.hasSession != true))) if systemPlayer.busy { ProgressView() } - Text("Музыка ПК в Пункте управления. Звук остаётся на компьютере.") - .font(.caption).foregroundStyle(.secondary) + Text("Музыка ПК в Пункте управления. Звук остаётся на компьютере.") + .font(.caption).foregroundStyle(.secondary) + Text("Без фоновых push-обновлений трек обновляется при открытом приложении или после команды пульта.") + .font(.caption).foregroundStyle(.secondary) if let error = systemPlayer.error { Text(error).font(.caption).foregroundStyle(.red).textSelection(.enabled) } }.frame(maxWidth: .infinity, alignment: .leading).bridgeCard() } diff --git a/MusicBridge/SystemPlayerManager.swift b/MusicBridge/SystemPlayerManager.swift index 1ffe405..92749e5 100644 --- a/MusicBridge/SystemPlayerManager.swift +++ b/MusicBridge/SystemPlayerManager.swift @@ -27,6 +27,7 @@ final class SystemPlayerManager: ObservableObject { do { try SystemPlayerCredentials.save(connection, id: newID) _ = try SystemPlayerCredentials.load(newID) + UserDefaults.standard.set(newID, forKey: savedIDKey) let session = try await RemoteMediaSession.start(attributes: SystemPlayerAttributes(id: newID, state: state)) guard generation == operation else { try? await session.end(); return } storage = session diff --git a/MusicBridgeRemote/MusicBridgeRemote.swift b/MusicBridgeRemote/MusicBridgeRemote.swift index 640d8df..b027f53 100644 --- a/MusicBridgeRemote/MusicBridgeRemote.swift +++ b/MusicBridgeRemote/MusicBridgeRemote.swift @@ -45,7 +45,8 @@ final class PCMediaSession: RemoteMediaSessionRepresentable { } var commands: [MediaCommand] { - [ + guard attributes.hasSession else { return [] } + return [ .play { try await self.send(.play) }.enabled(attributes.capabilities.play), .pause { try await self.send(.pause) }.enabled(attributes.capabilities.pause), .next { try await self.send(.next) }.enabled(attributes.capabilities.next), @@ -62,7 +63,15 @@ final class PCMediaSession: RemoteMediaSessionRepresentable { } private func send(_ action: SystemPlayerAction, value: Double? = nil) async throws { - let state = try await transport.send(action, value: value, sessionID: id) - attributes = SystemPlayerAttributes(id: id, state: state) + do { + let state = try await transport.send(action, value: value, sessionID: id) + attributes = SystemPlayerAttributes(id: id, state: state) + } catch BridgeError.unauthorized { + try SystemPlayerCredentials.delete(id) + attributes.hasSession = false + attributes.playing = false + attributes.volume = nil + throw BridgeError.unauthorized + } } } diff --git a/PLAN.md b/PLAN.md index c65dd85..12fa75a 100644 --- a/PLAN.md +++ b/PLAN.md @@ -1,9 +1,38 @@ # MusicBridge iOS — план -Обновлено: 2026-09-13. Пользователь разрешил начать нативное iOS-приложение. +Обновлено: 2026-09-14. Пользователь разрешил начать нативное iOS-приложение. Репозиторий: `C:/Users/areso/iOS MusicBridge`; origin — dev.yukinoki.ru/musicbridge/ios. Windows-агент — соседний проект `C:/Users/areso/MusicBridge/MusicBridge.Agent`. +## Системный пульт 0.7.0 (8) — текущий этап + +- [x] Реализация NowPlaying RemoteMediaSession и отдельного ExtensionKit target для iOS 27. +- [x] Кнопка включения/выключения в приложении; музыка продолжает играть на ПК. +- [x] Метаданные, обложка, play/pause, предыдущий/следующий трек, перемотка, громкость ПК. +- [x] Перед командой проверяются свежие capabilities и действующее сопряжение; команды не повторяются автоматически. +- [x] Токен хранится в общем Keychain, не передаётся в атрибутах системной сессии. +- [x] Перемотка переводится из позиции относительно трека в диапазон Windows API. +- [x] Выключение и удаление сопряжения удаляют доступ расширения; возврат запоздавшего restore не восстанавливает сессию. +- [ ] Успешная итоговая сборка и XCTest на Xcode 27; первая попытка 34783380221 выявила Double/Float и исправлена. +- [ ] Установка с подписью обоих расширений и общим Keychain; проверка на физическом iPhone. +- [ ] Пункт управления: кнопки, перемотка, громкость; блокировка телефона, сон ПК, отзыв токена, повторная подпись. + +**Ограничение iloader:** проверен официальный исходник `nab138/isideload`, ветка +`apple-codesign-quick`, commit `f6a4d5dba717d72fc2af63eaba26b27ba44116be`, который использует текущий iloader. +`isideload/src/sideload/bundle.rs` обнаруживает расширения только в `PlugIns`, +а NowPlaying размещается в `Extensions`. `sign.rs` берёт entitlements из profile, +добавление shared Keychain реализовано для отдельных SpecialApp, не для MusicBridge. +Поэтому обычную установку 0.7.0 через iloader нельзя считать поддержанной. +Не переносить ExtensionKit в PlugIns наугад и не передавать токен через публичные атрибуты. +Следующий этап установки: совместимый подписыватель или доработка iloader для +ExtensionKit и общего Keychain с реальными provisioning profiles. Xcode signing +всех трёх targets — вариант при наличии совместимого Mac и профилей; разрешение +нового extension point бесплатной командой ещё не подтверждено на устройстве. + +Без APNs расширение читает ПК при команде; открытое приложение обновляет сессию +при смене трека/статуса и периодически. Смена трека на самом ПК при приостановленном +приложении не гарантирует автоматического обновления карточки. Фиктивное аудио не используется. + ## Инфраструктура — текущее состояние - SSH к macOS проверен: `aresov@10.67.67.28`, macOS 26.6.2, x86_64, @@ -73,7 +102,7 @@ Windows-агент — соседний проект `C:/Users/areso/MusicBridge - [x] LiveActivityIntent: предыдущий/следующий трек, play/pause и обновление — 0.6.0. - [ ] Проверить команды из Live Activity/Island при блокировке на физическом iPhone. - [ ] Команды Siri. -- [ ] NowPlaying RemoteMediaSession (iOS 27+): перейти на SDK 27, добавить remote-media extension и проверить системный Control Center. +- [x] NowPlaying RemoteMediaSession (iOS 27+): SDK 27 и remote-media extension реализованы в 0.7.0; установка и физическая проверка — в текущем этапе выше. - [ ] APNs: отдельный проект обновлений при приостановленном приложении, если нужен постоянный актуальный трек в фоне. Ограничения: хранится один доверенный ПК; адрес при смене IP восстанавливается через diff --git a/README.md b/README.md index a2174da..a2451b4 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,23 @@ Windows-агентом и подключение по QR. В 0.4.0 добавл ## Сборка на Mac +Версия **0.7.0 (8)** добавляет системный пульт iOS 27: в плеере нажать +«Включить системный пульт iOS», затем открыть Пункт управления. Реализованы +play/pause, следующий/предыдущий трек, перемотка и громкость ПК. Звук остаётся на ПК. +Проверка этой функции на физическом iPhone пока не завершена. + +Для этой версии нужно подписать **MusicBridge, MusicBridgeWidgets и MusicBridgeRemote**. +У приложения и MusicBridgeRemote должны совпадать разрешённые Keychain Sharing +groups: `ru.yukinoki.musicbridge.remote-control`. +Префикс определяется из подписи при запуске, без хранения Apple Account в проекте. +MusicBridgeRemote — ExtensionKit в `Extensions`, а WidgetKit — в `PlugIns`. +Текущий iloader не обрабатывает эту комбинацию полностью: обычную переподпись +0.7.0 считать поддержанной нельзя. Для прежней установки оставлена IPA 0.6.0 ниже. +Состояние работ и требования к подписывателю описаны в `PLAN.md`. + +Без APNs трек обновляется из открытого приложения или после команды системного +пульта. Изменения на ПК при приостановленном приложении могут отображаться с задержкой. + 1. Открыть `MusicBridge.xcodeproj` в Xcode 27. 2. Выбрать схему MusicBridge и симулятор iPhone. Run запускает приложение, Test выполняет `MusicBridgeTests`.