Compare commits

...

3 Commits

16 changed files with 353 additions and 65 deletions
+50 -15
View File
@@ -55,6 +55,16 @@
"isa" = "PBXBuildFile"; "isa" = "PBXBuildFile";
"fileRef" = "76FB6BFFC478599567424755"; "fileRef" = "76FB6BFFC478599567424755";
}; };
"581AD2E9199DAED730DC0F6E" = {
"isa" = "PBXFileReference";
"lastKnownFileType" = "sourcecode.swift";
"path" = "LiveActivityCommands.swift";
"sourceTree" = "<group>";
};
"D6D68067600EEBE36972550E" = {
"isa" = "PBXBuildFile";
"fileRef" = "581AD2E9199DAED730DC0F6E";
};
"EE2357BD5CA3755AFEB3171A" = { "EE2357BD5CA3755AFEB3171A" = {
"isa" = "PBXFileReference"; "isa" = "PBXFileReference";
"lastKnownFileType" = "sourcecode.swift"; "lastKnownFileType" = "sourcecode.swift";
@@ -119,7 +129,7 @@
"isa" = "PBXGroup"; "isa" = "PBXGroup";
"path" = "MusicBridge"; "path" = "MusicBridge";
"sourceTree" = "<group>"; "sourceTree" = "<group>";
"children" = ("065A9D559DC80AAF876DA757", "763FA6A137E5E94274AC56A3", "971C0AE92F167DE7AB145086", "5F6F408829AB46D3B85FB911", "76FB6BFFC478599567424755", "EE2357BD5CA3755AFEB3171A", "124CF3F5AC075D1D6042DF64", "ADF0CD56882201BD3AC1581B", "9C432A0DA0BEC173839EBF86", "6B0F2027221CDD63E8FD4C59", "7148B52E915554F67E875C26", "8BF729FFE074CAEE622C0292",); "children" = ("065A9D559DC80AAF876DA757", "763FA6A137E5E94274AC56A3", "971C0AE92F167DE7AB145086", "5F6F408829AB46D3B85FB911", "76FB6BFFC478599567424755", "581AD2E9199DAED730DC0F6E", "EE2357BD5CA3755AFEB3171A", "124CF3F5AC075D1D6042DF64", "ADF0CD56882201BD3AC1581B", "9C432A0DA0BEC173839EBF86", "6B0F2027221CDD63E8FD4C59", "7148B52E915554F67E875C26", "8BF729FFE074CAEE622C0292",);
}; };
"7C5E653CCD928B143AB68F09" = { "7C5E653CCD928B143AB68F09" = {
"isa" = "PBXFileReference"; "isa" = "PBXFileReference";
@@ -141,11 +151,21 @@
"isa" = "PBXBuildFile"; "isa" = "PBXBuildFile";
"fileRef" = "7C0FE298B442263CA281ABB7"; "fileRef" = "7C0FE298B442263CA281ABB7";
}; };
"6EDC1D9A7204CF776CE3B281" = {
"isa" = "PBXFileReference";
"lastKnownFileType" = "sourcecode.swift";
"path" = "MusicControlIntent.swift";
"sourceTree" = "<group>";
};
"962BED0D8E17961AD72EC24F" = {
"isa" = "PBXBuildFile";
"fileRef" = "6EDC1D9A7204CF776CE3B281";
};
"E3C4B39D6D5013477C98CD58" = { "E3C4B39D6D5013477C98CD58" = {
"isa" = "PBXGroup"; "isa" = "PBXGroup";
"path" = "Shared"; "path" = "Shared";
"sourceTree" = "<group>"; "sourceTree" = "<group>";
"children" = ("7C5E653CCD928B143AB68F09", "7C0FE298B442263CA281ABB7",); "children" = ("7C5E653CCD928B143AB68F09", "7C0FE298B442263CA281ABB7", "6EDC1D9A7204CF776CE3B281",);
}; };
"ADFE24674E0FB119E649EC54" = { "ADFE24674E0FB119E649EC54" = {
"isa" = "PBXFileReference"; "isa" = "PBXFileReference";
@@ -171,6 +191,10 @@
"isa" = "PBXBuildFile"; "isa" = "PBXBuildFile";
"fileRef" = "7C0FE298B442263CA281ABB7"; "fileRef" = "7C0FE298B442263CA281ABB7";
}; };
"41D7F12D1C7D61DAF319E724" = {
"isa" = "PBXBuildFile";
"fileRef" = "6EDC1D9A7204CF776CE3B281";
};
"8BF729FFE074CAEE622C0292" = { "8BF729FFE074CAEE622C0292" = {
"isa" = "PBXFileReference"; "isa" = "PBXFileReference";
"lastKnownFileType" = "folder.assetcatalog"; "lastKnownFileType" = "folder.assetcatalog";
@@ -191,6 +215,16 @@
"isa" = "PBXBuildFile"; "isa" = "PBXBuildFile";
"fileRef" = "C9F705D432CA45B6056BC572"; "fileRef" = "C9F705D432CA45B6056BC572";
}; };
"65CE96DA4162DE4255412BC7" = {
"isa" = "PBXFileReference";
"lastKnownFileType" = "sourcecode.swift";
"path" = "LiveActivityCommandTests.swift";
"sourceTree" = "<group>";
};
"E87BA6EF1948C84AC29D8EA8" = {
"isa" = "PBXBuildFile";
"fileRef" = "65CE96DA4162DE4255412BC7";
};
"9819FF7CC21CAAD1BDB8D1B8" = { "9819FF7CC21CAAD1BDB8D1B8" = {
"isa" = "PBXFileReference"; "isa" = "PBXFileReference";
"lastKnownFileType" = "sourcecode.swift"; "lastKnownFileType" = "sourcecode.swift";
@@ -235,7 +269,7 @@
"isa" = "PBXGroup"; "isa" = "PBXGroup";
"path" = "MusicBridgeTests"; "path" = "MusicBridgeTests";
"sourceTree" = "<group>"; "sourceTree" = "<group>";
"children" = ("C9F705D432CA45B6056BC572", "9819FF7CC21CAAD1BDB8D1B8", "ACD0393DCDDDF044F2966340", "464F6C65BC2FAB6BCCC77F46", "8BED860F1BD59C500D215E8D", "F16D05EC6B29248D2C61ADB1", "4731785CAB9BB914B8834AE6",); "children" = ("C9F705D432CA45B6056BC572", "65CE96DA4162DE4255412BC7", "9819FF7CC21CAAD1BDB8D1B8", "ACD0393DCDDDF044F2966340", "464F6C65BC2FAB6BCCC77F46", "8BED860F1BD59C500D215E8D", "F16D05EC6B29248D2C61ADB1", "4731785CAB9BB914B8834AE6",);
}; };
"F16D05EC6B29248D2C61ADB1" = { "F16D05EC6B29248D2C61ADB1" = {
"isa" = "PBXFileReference"; "isa" = "PBXFileReference";
@@ -267,7 +301,7 @@
"72B74C8A2143F64FD53D71E5" = { "72B74C8A2143F64FD53D71E5" = {
"isa" = "PBXSourcesBuildPhase"; "isa" = "PBXSourcesBuildPhase";
"buildActionMask" = 2147483647; "buildActionMask" = 2147483647;
"files" = ("82F1C33CE4F8B8F641FF0461", "40D2920E2C996D62B7190BA4", "16EA630DE1FE9F0A16580C9D", "842E87CD23DCB5BBCA4340F6", "DA06B3B12E52FB69A6A34887", "181CACDC331104A1E16CFC20", "DEDCCD95A140162B6D654871", "48E2FDCA11A6694953C86E6D", "47693934E8A974162CF08A38", "287044ADBD143F929035BC61", "BE808A0D13A16FE67D32B7FB", "C7506B09240305F44C61EB60", "4FD4638045EDA8D7CCFD00AF",); "files" = ("82F1C33CE4F8B8F641FF0461", "40D2920E2C996D62B7190BA4", "16EA630DE1FE9F0A16580C9D", "842E87CD23DCB5BBCA4340F6", "DA06B3B12E52FB69A6A34887", "D6D68067600EEBE36972550E", "181CACDC331104A1E16CFC20", "DEDCCD95A140162B6D654871", "48E2FDCA11A6694953C86E6D", "47693934E8A974162CF08A38", "287044ADBD143F929035BC61", "BE808A0D13A16FE67D32B7FB", "C7506B09240305F44C61EB60", "4FD4638045EDA8D7CCFD00AF", "962BED0D8E17961AD72EC24F",);
"runOnlyForDeploymentPostprocessing" = 0; "runOnlyForDeploymentPostprocessing" = 0;
}; };
"734C4650C7A5604092763C32" = { "734C4650C7A5604092763C32" = {
@@ -324,8 +358,8 @@
"CODE_SIGN_STYLE" = "Automatic"; "CODE_SIGN_STYLE" = "Automatic";
"GENERATE_INFOPLIST_FILE" = "NO"; "GENERATE_INFOPLIST_FILE" = "NO";
"INFOPLIST_FILE" = "MusicBridge/Info.plist"; "INFOPLIST_FILE" = "MusicBridge/Info.plist";
"CURRENT_PROJECT_VERSION" = "6"; "CURRENT_PROJECT_VERSION" = "7";
"MARKETING_VERSION" = "0.5.0"; "MARKETING_VERSION" = "0.6.0";
"ASSETCATALOG_COMPILER_APPICON_NAME" = "AppIcon"; "ASSETCATALOG_COMPILER_APPICON_NAME" = "AppIcon";
"SWIFT_OPTIMIZATION_LEVEL" = "-Onone"; "SWIFT_OPTIMIZATION_LEVEL" = "-Onone";
"ENABLE_TESTABILITY" = "YES"; "ENABLE_TESTABILITY" = "YES";
@@ -346,8 +380,8 @@
"CODE_SIGN_STYLE" = "Automatic"; "CODE_SIGN_STYLE" = "Automatic";
"GENERATE_INFOPLIST_FILE" = "NO"; "GENERATE_INFOPLIST_FILE" = "NO";
"INFOPLIST_FILE" = "MusicBridge/Info.plist"; "INFOPLIST_FILE" = "MusicBridge/Info.plist";
"CURRENT_PROJECT_VERSION" = "6"; "CURRENT_PROJECT_VERSION" = "7";
"MARKETING_VERSION" = "0.5.0"; "MARKETING_VERSION" = "0.6.0";
"ASSETCATALOG_COMPILER_APPICON_NAME" = "AppIcon"; "ASSETCATALOG_COMPILER_APPICON_NAME" = "AppIcon";
"SWIFT_OPTIMIZATION_LEVEL" = "-O"; "SWIFT_OPTIMIZATION_LEVEL" = "-O";
}; };
@@ -379,7 +413,7 @@
"AB453A3F0E1490D48374A69A" = { "AB453A3F0E1490D48374A69A" = {
"isa" = "PBXSourcesBuildPhase"; "isa" = "PBXSourcesBuildPhase";
"buildActionMask" = 2147483647; "buildActionMask" = 2147483647;
"files" = ("1DD7BB0437E9F53586831F22", "BD0B3FAC7F1259D249134283", "17E248E58CB43C680B48DD51", "EFDB280E0B23F7E2A246BC89", "DC1A5B0B94F4C98F1CA36676",); "files" = ("1DD7BB0437E9F53586831F22", "E87BA6EF1948C84AC29D8EA8", "BD0B3FAC7F1259D249134283", "17E248E58CB43C680B48DD51", "EFDB280E0B23F7E2A246BC89", "DC1A5B0B94F4C98F1CA36676",);
"runOnlyForDeploymentPostprocessing" = 0; "runOnlyForDeploymentPostprocessing" = 0;
}; };
"9AA2A4C344B5C474AF5D336C" = { "9AA2A4C344B5C474AF5D336C" = {
@@ -471,7 +505,7 @@
"FF6BDEA70DDE9A3C7EEA9DCF" = { "FF6BDEA70DDE9A3C7EEA9DCF" = {
"isa" = "PBXSourcesBuildPhase"; "isa" = "PBXSourcesBuildPhase";
"buildActionMask" = 2147483647; "buildActionMask" = 2147483647;
"files" = ("F942C4AC2D1ABF86575E779A", "D739DF07A523D6FA1D29EECD", "E41BBFD90B0D341CC92C100E",); "files" = ("F942C4AC2D1ABF86575E779A", "D739DF07A523D6FA1D29EECD", "E41BBFD90B0D341CC92C100E", "41D7F12D1C7D61DAF319E724",);
"runOnlyForDeploymentPostprocessing" = 0; "runOnlyForDeploymentPostprocessing" = 0;
}; };
"49C806A88B3E70B1004AB10D" = { "49C806A88B3E70B1004AB10D" = {
@@ -500,14 +534,14 @@
"CODE_SIGN_STYLE" = "Automatic"; "CODE_SIGN_STYLE" = "Automatic";
"GENERATE_INFOPLIST_FILE" = "NO"; "GENERATE_INFOPLIST_FILE" = "NO";
"INFOPLIST_FILE" = "MusicBridgeWidgets/Info.plist"; "INFOPLIST_FILE" = "MusicBridgeWidgets/Info.plist";
"CURRENT_PROJECT_VERSION" = "6"; "CURRENT_PROJECT_VERSION" = "7";
"MARKETING_VERSION" = "0.5.0"; "MARKETING_VERSION" = "0.6.0";
"APPLICATION_EXTENSION_API_ONLY" = "YES"; "APPLICATION_EXTENSION_API_ONLY" = "YES";
"SKIP_INSTALL" = "YES"; "SKIP_INSTALL" = "YES";
"LD_RUNPATH_SEARCH_PATHS" = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks"; "LD_RUNPATH_SEARCH_PATHS" = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks";
"SWIFT_OPTIMIZATION_LEVEL" = "-Onone"; "SWIFT_OPTIMIZATION_LEVEL" = "-Onone";
"ENABLE_TESTABILITY" = "YES"; "ENABLE_TESTABILITY" = "YES";
"SWIFT_ACTIVE_COMPILATION_CONDITIONS" = "DEBUG"; "SWIFT_ACTIVE_COMPILATION_CONDITIONS" = "DEBUG MUSICBRIDGE_WIDGET";
}; };
}; };
"6A281BFCCFF96276806F5934" = { "6A281BFCCFF96276806F5934" = {
@@ -524,12 +558,13 @@
"CODE_SIGN_STYLE" = "Automatic"; "CODE_SIGN_STYLE" = "Automatic";
"GENERATE_INFOPLIST_FILE" = "NO"; "GENERATE_INFOPLIST_FILE" = "NO";
"INFOPLIST_FILE" = "MusicBridgeWidgets/Info.plist"; "INFOPLIST_FILE" = "MusicBridgeWidgets/Info.plist";
"CURRENT_PROJECT_VERSION" = "6"; "CURRENT_PROJECT_VERSION" = "7";
"MARKETING_VERSION" = "0.5.0"; "MARKETING_VERSION" = "0.6.0";
"APPLICATION_EXTENSION_API_ONLY" = "YES"; "APPLICATION_EXTENSION_API_ONLY" = "YES";
"SKIP_INSTALL" = "YES"; "SKIP_INSTALL" = "YES";
"LD_RUNPATH_SEARCH_PATHS" = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks"; "LD_RUNPATH_SEARCH_PATHS" = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks";
"SWIFT_OPTIMIZATION_LEVEL" = "-O"; "SWIFT_OPTIMIZATION_LEVEL" = "-O";
"SWIFT_ACTIVE_COMPILATION_CONDITIONS" = " MUSICBRIDGE_WIDGET";
}; };
}; };
"5DCAC255CDC0CA72231320F4" = { "5DCAC255CDC0CA72231320F4" = {
+3 -3
View File
@@ -59,11 +59,11 @@ final class BridgeClient {
private let session: URLSession private let session: URLSession
private var socket: URLSessionWebSocketTask? private var socket: URLSessionWebSocketTask?
init(_ connection: Connection) { init(_ connection: Connection, timeout: TimeInterval = 10) {
self.connection = connection self.connection = connection
let config = URLSessionConfiguration.ephemeral let config = URLSessionConfiguration.ephemeral
config.timeoutIntervalForRequest = 10 config.timeoutIntervalForRequest = timeout
config.timeoutIntervalForResource = 15 config.timeoutIntervalForResource = timeout + 5
config.urlCache = nil config.urlCache = nil
config.httpCookieStorage = nil config.httpCookieStorage = nil
session = URLSession(configuration: config, delegate: PinnedDelegate(connection), delegateQueue: nil) session = URLSession(configuration: config, delegate: PinnedDelegate(connection), delegateQueue: nil)
+2 -2
View File
@@ -23,11 +23,11 @@ struct ContentView: View {
VStack(alignment: .leading, spacing: 10) { VStack(alignment: .leading, spacing: 10) {
Button { Button {
if liveActivity.running { liveActivity.end() } if liveActivity.running { liveActivity.end() }
else if let state = store.state { liveActivity.start(state) } else if let state = store.state, let connection = store.connection { liveActivity.start(state, connection: connection) }
} label: { } label: {
Label(liveActivity.running ? "Убрать с экрана блокировки" : "Показать на экране блокировки", systemImage: "waveform.rectangle") Label(liveActivity.running ? "Убрать с экрана блокировки" : "Показать на экране блокировки", systemImage: "waveform.rectangle")
}.disabled(!liveActivity.running && (!store.connected || store.state?.hasSession != true)) }.disabled(!liveActivity.running && (!store.connected || store.state?.hasSession != true))
Text("Live Activity и Dynamic Island показывают последний трек. В фоне данные могут устареть; нажмите карточку, чтобы открыть плеер.") Text("Переключайте треки на экране блокировки и в развёрнутом Dynamic Island. Кнопка обновления получает текущее состояние ПК.")
.font(.caption).foregroundStyle(.secondary) .font(.caption).foregroundStyle(.secondary)
if let error = liveActivity.error { Text(error).font(.caption).foregroundStyle(.red) } if let error = liveActivity.error { Text(error).font(.caption).foregroundStyle(.red) }
}.frame(maxWidth: .infinity, alignment: .leading).bridgeCard() }.frame(maxWidth: .infinity, alignment: .leading).bridgeCard()
+1 -1
View File
@@ -22,7 +22,7 @@ enum KeychainStore {
static func save(_ connection: Connection) throws { static func save(_ connection: Connection) throws {
let values: [String: Any] = [kSecValueData as String: try JSONEncoder().encode(connection), let values: [String: Any] = [kSecValueData as String: try JSONEncoder().encode(connection),
kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly] kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly]
var status = SecItemUpdate(query as CFDictionary, values as CFDictionary) var status = SecItemUpdate(query as CFDictionary, values as CFDictionary)
if status == errSecItemNotFound { if status == errSecItemNotFound {
status = SecItemAdd(query.merging(values) { _, new in new } as CFDictionary, nil) status = SecItemAdd(query.merging(values) { _, new in new } as CFDictionary, nil)
+68
View File
@@ -0,0 +1,68 @@
import ActivityKit
import Foundation
@MainActor
enum LiveActivityCommands {
private static var busy = false
static let bindingKey = "MusicBridge.liveActivityPC"
static func perform(_ action: MusicRemoteAction, activityID: String) async {
guard !busy, let activity = Activity<MusicActivityAttributes>.activities.first(where: { $0.id == activityID }),
activity.activityState == .active || activity.activityState == .stale else { return }
busy = true
defer { busy = false }
do {
guard let saved = try KeychainStore.load(),
UserDefaults.standard.string(forKey: bindingKey + activityID) == saved.fingerprint else {
throw BridgeError.message("Откройте приложение и включите карточку заново.")
}
let client = BridgeClient(saved, timeout: 5)
defer { client.close() }
let fresh = try await execute(action, client: client) {
(activity.activityState == .active || activity.activityState == .stale) &&
UserDefaults.standard.string(forKey: bindingKey + activityID) == saved.fingerprint
}
guard fresh.hasSession else { await activity.end(nil, dismissalPolicy: .immediate); return }
await activity.update(ActivityContent(state: LiveActivityManager.content(fresh), staleDate: Date().addingTimeInterval(15)))
} catch {
if case BridgeError.unauthorized = error {
await activity.end(nil, dismissalPolicy: .immediate)
return
}
var value = activity.content.state
value.message = MusicActivityAttributes.ContentState.bounded(BridgeError.describe(error))
await activity.update(ActivityContent(state: value, staleDate: Date()))
}
}
// A fresh capability check, one command at most, then a fresh snapshot. No command retries.
static func execute(_ action: MusicRemoteAction, client: BridgeClient,
stillValid: () -> Bool = { true }) async throws -> MediaState {
let before = try await client.state()
guard stillValid() else { throw BridgeError.message("Карточка закрыта. Откройте MusicBridge.") }
if action == .refresh { return before }
guard before.hasSession else { return before }
let command = try command(action, state: before)
do { try await client.command(Command(type: command, value: nil)) }
catch {
if error is URLError { throw BridgeError.message("Результат команды неизвестен. Нажмите обновление перед повтором.") }
throw error
}
do { return try await client.state() }
catch {
if case BridgeError.unauthorized = error { throw error }
throw BridgeError.message("Команда выполнена. Нажмите обновление, чтобы получить новый трек.")
}
}
static func command(_ action: MusicRemoteAction, state: MediaState) throws -> String {
switch action {
case .previous where state.capabilities.previous: return "previous"
case .next where state.capabilities.next: return "next"
case .toggle where state.capabilities.toggle: return "toggle"
case .toggle where state.playbackStatus == "Playing" && state.capabilities.pause: return "pause"
case .toggle where state.playbackStatus != "Playing" && state.capabilities.play: return "play"
default: throw BridgeError.message("Плеер на ПК не поддерживает эту команду.")
}
}
}
+6 -1
View File
@@ -32,18 +32,22 @@ final class LiveActivityManager: ObservableObject {
duration: max(0, state.endSeconds - state.startSeconds), updatedAt: now, connected: true) duration: max(0, state.endSeconds - state.startSeconds), updatedAt: now, connected: true)
} }
func start(_ state: MediaState) { func start(_ state: MediaState, connection: Connection) {
guard !running, state.hasSession, UIApplication.shared.applicationState == .active else { return } guard !running, state.hasSession, UIApplication.shared.applicationState == .active else { return }
guard connection.token != nil else { error = "Сначала выполните сопряжение с ПК."; return }
guard ActivityAuthorizationInfo().areActivitiesEnabled else { guard ActivityAuthorizationInfo().areActivitiesEnabled else {
error = "Разрешите Live Activities в настройках MusicBridge на iPhone." error = "Разрешите Live Activities в настройках MusicBridge на iPhone."
return return
} }
do { do {
// Migrate existing pairing so an explicit lock-screen action can read it after first unlock.
try KeychainStore.save(connection)
let value = Self.content(state) let value = Self.content(state)
let new = try Activity.request(attributes: MusicActivityAttributes(), let new = try Activity.request(attributes: MusicActivityAttributes(),
content: ActivityContent(state: value, staleDate: Date().addingTimeInterval(15)), content: ActivityContent(state: value, staleDate: Date().addingTimeInterval(15)),
pushType: nil) pushType: nil)
activity = new activity = new
UserDefaults.standard.set(connection.fingerprint, forKey: LiveActivityCommands.bindingKey + new.id)
last = value last = value
running = true running = true
error = nil error = nil
@@ -72,6 +76,7 @@ final class LiveActivityManager: ObservableObject {
func end() { func end() {
guard let activity else { return } guard let activity else { return }
UserDefaults.standard.removeObject(forKey: LiveActivityCommands.bindingKey + activity.id)
self.activity = nil self.activity = nil
running = false running = false
last = nil last = nil
-1
View File
@@ -257,7 +257,6 @@ final class PlayerStore: ObservableObject {
} }
private func stop() { private func stop() {
liveActivity?.unavailable()
generation = UUID() generation = UUID()
runner?.cancel() runner?.cancel()
runner = nil runner = nil
@@ -0,0 +1,54 @@
import XCTest
@testable import MusicBridge
@MainActor
final class LiveActivityCommandTests: XCTestCase {
private func client(_ token: String) throws -> BridgeClient {
let url = try XCTUnwrap(Bundle(for: Self.self).url(forResource: "tls-fixture", withExtension: "json"))
let fixtures = try JSONDecoder().decode([String: [String: String]].self, from: Data(contentsOf: url))
let f = try XCTUnwrap(fixtures["valid"])
return BridgeClient(try Connection(endpoint: XCTUnwrap(f["endpoint"]), fingerprint: XCTUnwrap(f["fingerprint"]), token: token))
}
func testNextUsesOneCommandAndReturnsUpdatedTrack() async throws {
let client = try client("live-next"); defer { client.close() }
let result = try await LiveActivityCommands.execute(.next, client: client)
XCTAssertEqual(result.title, "Track 1")
}
func testRefreshDoesNotSendCommand() async throws {
let client = try client("live-refresh"); defer { client.close() }
let result = try await LiveActivityCommands.execute(.refresh, client: client)
XCTAssertEqual(result.title, "Track 0")
}
func testClosedActivityDoesNotSendCommand() async throws {
let client = try client("live-closed"); defer { client.close() }
do { _ = try await LiveActivityCommands.execute(.next, client: client, stillValid: { false }); XCTFail("Must reject") }
catch { }
let state = try await client.state()
XCTAssertEqual(state.title, "Track 0")
}
func testUnsupportedCommandIsNotSent() async throws {
let client = try client("live-unsupported"); defer { client.close() }
do { _ = try await LiveActivityCommands.execute(.next, client: client); XCTFail("Must reject") }
catch { }
let state = try await client.state()
XCTAssertEqual(state.title, "Track 0")
}
func testRejectedCommandIsNotRetried() async throws {
let client = try client("live-reject"); defer { client.close() }
do { _ = try await LiveActivityCommands.execute(.next, client: client); XCTFail("Must reject") }
catch { }
let state = try await client.state()
XCTAssertEqual(state.title, "Track 1")
}
func testFailedReadAfterSuccessReportsCommandCompleted() async throws {
let client = try client("live-refresh-fail"); defer { client.close() }
do { _ = try await LiveActivityCommands.execute(.next, client: client); XCTFail("Must report refresh failure") }
catch { XCTAssertTrue(error.localizedDescription.contains("Команда выполнена")) }
}
}
+2 -3
View File
@@ -10,7 +10,7 @@ struct MusicBridgeWidgets: WidgetBundle {
struct MusicBridgeLiveActivity: Widget { struct MusicBridgeLiveActivity: Widget {
var body: some WidgetConfiguration { var body: some WidgetConfiguration {
ActivityConfiguration(for: MusicActivityAttributes.self) { context in ActivityConfiguration(for: MusicActivityAttributes.self) { context in
MusicActivityCard(state: context.state, stale: context.isStale) MusicActivityCard(state: context.state, stale: context.isStale, activityID: context.activityID)
.activityBackgroundTint(Color(.secondarySystemBackground)) .activityBackgroundTint(Color(.secondarySystemBackground))
.activitySystemActionForegroundColor(.indigo) .activitySystemActionForegroundColor(.indigo)
.widgetURL(URL(string: "musicbridge://player")) .widgetURL(URL(string: "musicbridge://player"))
@@ -27,8 +27,7 @@ struct MusicBridgeLiveActivity: Widget {
TrackLabels(state: context.state) TrackLabels(state: context.state)
ProgressView(value: context.state.progress).tint(.cyan) ProgressView(value: context.state.progress).tint(.cyan)
ActivityStatus(state: context.state, stale: context.isStale) ActivityStatus(state: context.state, stale: context.isStale)
Link("Открыть плеер", destination: URL(string: "musicbridge://player")!) MusicActivityControls(activityID: context.activityID).foregroundStyle(.cyan)
.font(.caption.weight(.semibold)).foregroundStyle(.cyan)
}.padding(.bottom, 4) }.padding(.bottom, 4)
} }
} compactLeading: { } compactLeading: {
+46 -5
View File
@@ -46,7 +46,10 @@ Windows-агент — соседний проект `C:/Users/areso/MusicBridge
- [x] Пользователь подтвердил работу 0.4.0 на iPhone: «всё вроде работает». - [x] Пользователь подтвердил работу 0.4.0 на iPhone: «всё вроде работает».
- [x] Локальная Live Activity и Dynamic Island: реализация отображения трека в 0.5.0. - [x] Локальная Live Activity и Dynamic Island: реализация отображения трека в 0.5.0.
- [ ] Проверить Live Activity на экране блокировки и Dynamic Island после подписи через iloader. - [ ] Проверить Live Activity на экране блокировки и Dynamic Island после подписи через iloader.
- [ ] App Intents: прямые действия из Live Activity и команды Siri. - [x] LiveActivityIntent: предыдущий/следующий трек, play/pause и обновление — 0.6.0.
- [ ] Проверить команды из Live Activity/Island при блокировке на физическом iPhone.
- [ ] Команды Siri.
- [ ] NowPlaying RemoteMediaSession (iOS 27+): перейти на SDK 27, добавить remote-media extension и проверить системный Control Center.
- [ ] APNs: отдельный проект обновлений при приостановленном приложении, если нужен постоянный актуальный трек в фоне. - [ ] APNs: отдельный проект обновлений при приостановленном приложении, если нужен постоянный актуальный трек в фоне.
Ограничения: хранится один доверенный ПК; адрес при смене IP восстанавливается через Ограничения: хранится один доверенный ПК; адрес при смене IP восстанавливается через
@@ -143,13 +146,51 @@ Bonjour, если доступны объявления того же агент
раскрываемые ручные поля, крупная play/pause, процент громкости и меню ПК. раскрываемые ручные поля, крупная play/pause, процент громкости и меню ПК.
Сборка и 23 XCTest прошли; просмотрены темы, компактный экран и крупный текст. Сборка и 23 XCTest прошли; просмотрены темы, компактный экран и крупный текст.
Пользователь подтвердил работу обновления на телефоне перед началом этапа 3. Пользователь подтвердил работу обновления на телефоне перед началом этапа 3.
3. **Системные интеграции — 0.5.0 (build 6).** Локальная Live Activity и Dynamic Island 3. **Системные интеграции — 0.6.0 (build 7).** Добавлены прямые действия LiveActivityIntent.
реализованы; 26 XCTest и Release archive прошли. Карточка показывает последний снимок Убрана ошибка устаревания при обычном сворачивании; каждое нажатие перечитывает ПК.
с ПК, открывает плеер, помечает устаревшие данные. App Intents и APNs не реализованы. 32 XCTest и Release archive прошли. APNs и встроенный системный музыкальный пульт ещё не реализованы.
Не имитировать фоновое аудио. Не обещать постоянный WSS при блокировке. Не имитировать фоновое аудио. Не обещать постоянный WSS при блокировке.
4. **Несколько сохранённых ПК** — последующее расширение, сейчас сохраняется один. 4. **Несколько сохранённых ПК** — последующее расширение, сейчас сохраняется один.
Изменения третьего этапа — Live Activities 0.5.0: Продолжение третьего этапа — 0.6.0:
- Итоговый `43c78b2`: [Actions 34748340408](https://github.com/polskikh13/musicbridgeios/actions/runs/34748340408)
— 32 XCTest без отказов, успешный Release archive. IPA 0.6.0 (7) скачана в
`build/iPhone-0.6.0/MusicBridge-iPhone.ipa`; проверены ARM64 iPhoneOS приложения
и расширения, версии, метаданные интентов. Просмотрены светлая и тёмная карточки.
Работа системного запуска LiveActivityIntent и Keychain после блокировки требует
проверки на телефоне: обновить IPA, выключить/включить карточку, заблокировать,
проверить next/previous/toggle/refresh; затем повторить после перезагрузки и первого unlock.
- Пользователь сообщил, что карточка постоянно пишет об устаревших данных.
Причина: `PlayerStore.stop()` вызывал `unavailable()` при каждом уходе в фон.
Удалён этот вызов; реальные сетевые отказы продолжают отмечаться отдельно.
Карточка показывает время последнего снимка и отдельные ошибки запросов.
- Shared `MusicControlIntent` соответствует LiveActivityIntent. WidgetKit содержит
определение интента, выполнение сети компилируется только в основной app target;
iOS запускает процесс приложения без открытия UI. На компактном/минимальном Island
кнопок нет: нужно раскрыть долгим нажатием. Кнопки не отключаются из-за старого снимка.
- `LiveActivityCommands`: одно действие одновременно, проверка живой Activity и связи
её ID с fingerprint текущего сопряжения. После чтения состояния повторно проверяется
актуальность карточки, затем проверяются capabilities и отправляется максимум одна
команда. Refresh вообще не отправляет команду. Повторов команд нет.
- После успеха перечитывается трек и обновляется Activity. 401 завершает её;
неизвестный результат и неудача чтения после успеха имеют разные сообщения.
Нет бесконечного WSS/Bonjour в фоне; после смены IP сначала открыть приложение.
- Keychain: AfterFirstUnlockThisDeviceOnly вместо WhenUnlockedThisDeviceOnly,
чтобы команды работали при заблокированном iPhone. Существующий токен мигрирует
при повторном включении карточки. Он не покидает Keychain и не попадает в payload.
В UserDefaults сохраняется только соответствие ID активности и публичного pin.
После обновления с 0.5.0 карточку выключить и включить заново в приложении.
- HTTPS-тесты проверяют одну отправку next и новый трек, refresh без команды,
закрытую карточку, неподдерживаемую команду, отсутствие повторов отказавшей команды,
ошибку чтения после успешной команды. Физический запуск интента проверяется отдельно.
- Ответ на вопрос пользователя о встроенном музыкальном центре: официальный
[NowPlaying RemoteMediaSession](https://developer.apple.com/documentation/nowplaying/publishing-remote-media-sessions)
предназначен для внешнего воспроизведения. Metadata Apple указывает iOS 27.0.
Текущий runner собирает Xcode 16.4: следующий этап — SDK/Xcode 27 и отдельное
`com.apple.nowplaying.remote-media` расширение. Интеграция ещё не реализована.
История третьего этапа — Live Activities 0.5.0 (поведение фона изменено в 0.6.0):
- Итоговый `ddd44e8`: [Actions 34747772034](https://github.com/polskikh13/musicbridgeios/actions/runs/34747772034) - Итоговый `ddd44e8`: [Actions 34747772034](https://github.com/polskikh13/musicbridgeios/actions/runs/34747772034)
— 26 XCTest без отказов, сборка приложения и WidgetKit-расширения, успешный Release archive. — 26 XCTest без отказов, сборка приложения и WidgetKit-расширения, успешный Release archive.
+38 -26
View File
@@ -68,8 +68,8 @@ Apple ID и сертификаты для этой проверки не тре
## Установка на iPhone через Sideloadly ## Установка на iPhone через Sideloadly
Пользователь также подтвердил установку через iloader. Для повторной установки Пользователь также подтвердил установку через iloader. Для повторной установки
с Live Activity используйте версию **0.5.0 (build 6)**: с Live Activity используйте версию **0.6.0 (build 7)**:
`build/iPhone-0.5.0/MusicBridge-iPhone.ipa`. Выберите этот файл в iloader и `build/iPhone-0.6.0/MusicBridge-iPhone.ipa`. Выберите этот файл в iloader и
подпишите тем же Apple Account, которым установлена первая версия. подпишите тем же Apple Account, которым установлена первая версия.
В 0.4.0 добавлены иконка, карточка QR, раскрываемые ручные поля, крупная В 0.4.0 добавлены иконка, карточка QR, раскрываемые ручные поля, крупная
@@ -106,34 +106,46 @@ Sideloadly (в том числе его функцией автоматичес
[Sideloadly FAQ](https://sideloadly.io/faq) · [Sideloadly FAQ](https://sideloadly.io/faq) ·
[Apple: режим разработчика](https://developer.apple.com/documentation/Xcode/enabling-developer-mode-on-a-device) [Apple: режим разработчика](https://developer.apple.com/documentation/Xcode/enabling-developer-mode-on-a-device)
## Live Activity и Dynamic Island ## Live Activity и Dynamic Island — 0.6.0
После подключения к ПК и появления трека нажмите под плеером После обновления с 0.5.0 уберите старую карточку кнопкой в приложении и нажмите
«Показать на экране блокировки». Та же карточка появится в Dynamic Island «Показать на экране блокировки» заново. Это связывает кнопки с текущим сопряжением
на моделях, где он доступен. Долгое нажатие на Island раскрывает представление; и переводит запись Keychain в режим доступа после первого разблокирования.
нажатие на карточку или «Открыть плеер» возвращает в приложение. Токен остаётся только на этом устройстве, не копируется в виджет или UserDefaults.
Кнопка «Убрать с экрана блокировки» завершает активность. Она также завершается,
когда ПК сообщает об отсутствии медиасессии, доступ отозван или компьютер забыт.
Удалённая пользователем карточка не создаётся снова автоматически.
Это **последний снимок состояния ПК**: название, исполнитель, состояние и прогресс. На экране блокировки и в развёрнутом Dynamic Island доступны предыдущий трек,
В обычном режиме позиция обновляется не чаще раза в пять секунд; смена трека play/pause, следующий трек и обновление. Компактный Island открывает приложение;
и play/pause отправляются сразу. Прогресс не выдумывает движение без связи с ПК. для кнопок удерживайте Island. Каждое действие выполняет отдельные HTTPS-запросы
При уходе приложения в фон или потере связи отображается отметка устаревших данных; с прежней проверкой сертификата. Оно работает через LiveActivityIntent в процессе
резервный `staleDate` — 15 секунд, если приложение было внезапно остановлено. приложения, без открытия интерфейса; постоянного сетевого потока в фоне нет.
Для обновления нажмите карточку. Прямых кнопок управления внутри неё пока нет.
Live Activity не поддерживает постоянный сетевой поток сама по себе. Для обновления Перед командой считываются возможности плеера, затем команда отправляется один раз
при приостановленном приложении нужен отдельный механизм ActivityKit push через и перечитывается состояние. Повторы команд отключены; при неизвестном результате
APNs. В этой версии нет push-токенов, сервера APNs, фонового аудио и App Intents. сначала используйте обновление. Одновременно выполняется одно действие карточки.
[Ограничения и жизненный цикл ActivityKit](https://developer.apple.com/documentation/activitykit/displaying-live-data-with-live-activities). После закрытия карточки или удаления её связи с ПК новые команды не отправляются.
Для нового адреса ПК сначала откройте приложение: Bonjour-перенос адреса остаётся
в основном приложении. При отзыве доступа карточка завершается.
В IPA включено расширение `MusicBridgeWidgets.appex`; при установке iloader должен При обычном сворачивании показывается время последнего обновления, а не ошибка
сохранить и подписать его вместе с приложением. Если Live Activity не появилась, «Данные устарели». Прогресс соответствует этому снимку. Реальная ошибка сети или
проверьте её разрешение в настройках MusicBridge и наличие расширения в логе установки. команды выводится на карточке. Изменения, сделанные непосредственно на ПК, при
Автоматические снимки `live-card-*-screen.png` показывают общую SwiftUI-разметку приостановленном приложении не появятся сами: нажмите обновление или откройте плеер.
карточки в Debug-предпросмотре: это не доказательство её регистрации на реальном Для постоянных фоновых обновлений остаётся отдельная задача APNs.
экране блокировки или системного отображения Dynamic Island.
В IPA есть расширение `MusicBridgeWidgets.appex`: iloader должен сохранить и подписать
его вместе с приложением. Проверка работы интентов в заблокированном состоянии и
системного отображения Island выполняется на iPhone; CI проверяет сборку, сетевую
логику команд и разметку. После перезагрузки сначала один раз разблокируйте iPhone.
### Встроенный музыкальный пульт iOS
Apple предлагает `RemoteMediaSession` в новом NowPlaying framework (iOS 27+)
для отображения музыки внешнего устройства на экране блокировки и в Control Center.
Это подходит архитектуре MusicBridge. Нужны SDK/Xcode 27 и отдельное расширение
`com.apple.nowplaying.remote-media`; текущая сборка использует Xcode 16.4.
Эта интеграция исследована, но ещё не реализована. Для фоновых обновлений удалённых
сессий Apple также предоставляет push-механизм.
[Документация remote media sessions](https://developer.apple.com/documentation/nowplaying/publishing-remote-media-sessions).
## Источники ## Источники
+1
View File
@@ -10,6 +10,7 @@ struct MusicActivityAttributes: ActivityAttributes {
var duration: Double var duration: Double
var updatedAt: Date var updatedAt: Date
var connected: Bool var connected: Bool
var message: String? = nil
// Bound UTF-8, including pathological combining characters, below ActivityKit's 4 KB limit. // Bound UTF-8, including pathological combining characters, below ActivityKit's 4 KB limit.
static func bounded(_ text: String) -> String { static func bounded(_ text: String) -> String {
+30 -6
View File
@@ -1,10 +1,12 @@
import SwiftUI import SwiftUI
import AppIntents
struct MusicActivityCard: View { struct MusicActivityCard: View {
let state: MusicActivityAttributes.ContentState let state: MusicActivityAttributes.ContentState
let stale: Bool let stale: Bool
var activityID: String = ""
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 10) { VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 12) { HStack(spacing: 12) {
Image(systemName: "waveform").font(.title).foregroundStyle(.indigo) Image(systemName: "waveform").font(.title).foregroundStyle(.indigo)
TrackLabels(state: state) TrackLabels(state: state)
@@ -14,8 +16,8 @@ struct MusicActivityCard: View {
} }
ProgressView(value: state.progress).tint(.indigo) ProgressView(value: state.progress).tint(.indigo)
ActivityStatus(state: state, stale: stale) ActivityStatus(state: state, stale: stale)
Label("Открыть плеер", systemImage: "arrow.up.forward.app").font(.caption.weight(.semibold)) MusicActivityControls(activityID: activityID)
}.padding(16) }.padding(12)
} }
} }
@@ -34,13 +36,35 @@ struct ActivityStatus: View {
let state: MusicActivityAttributes.ContentState let state: MusicActivityAttributes.ContentState
let stale: Bool let stale: Bool
var body: some View { var body: some View {
if stale || !state.connected { if let message = state.message {
Text("Данные устарели · откройте плеер").font(.caption).foregroundStyle(.secondary) Text(message).font(.caption).lineLimit(2)
} else if !state.connected {
Text("Нет связи с ПК · нажмите обновление").font(.caption).foregroundStyle(.secondary)
} else { } else {
HStack(spacing: 4) { HStack(spacing: 4) {
Text("Снимок с ПК в") Text("Обновлено в")
Text(state.updatedAt, style: .time) Text(state.updatedAt, style: .time)
}.font(.caption).foregroundStyle(.secondary) }.font(.caption).foregroundStyle(.secondary)
} }
} }
} }
struct MusicActivityControls: View {
let activityID: String
var body: some View {
HStack {
control(.previous, icon: "backward.end.fill", label: "Предыдущий трек")
Spacer(minLength: 4)
control(.toggle, icon: "playpause.fill", label: "Воспроизведение или пауза")
Spacer(minLength: 4)
control(.next, icon: "forward.end.fill", label: "Следующий трек")
Spacer(minLength: 4)
control(.refresh, icon: "arrow.clockwise", label: "Обновить трек")
}
}
private func control(_ action: MusicRemoteAction, icon: String, label: String) -> some View {
Button(intent: MusicControlIntent(action, activityID: activityID)) {
Image(systemName: icon).font(.title3).frame(minWidth: 44, minHeight: 44)
}.buttonStyle(.plain).accessibilityLabel(label).disabled(activityID.isEmpty)
}
}
+28
View File
@@ -0,0 +1,28 @@
import AppIntents
enum MusicRemoteAction: String, AppEnum {
case previous, toggle, next, refresh
static var typeDisplayRepresentation: TypeDisplayRepresentation = "Команда плеера"
static var caseDisplayRepresentations: [Self: DisplayRepresentation] = [
.previous: "Предыдущий трек", .toggle: "Воспроизведение / пауза", .next: "Следующий трек", .refresh: "Обновить"
]
}
struct MusicControlIntent: LiveActivityIntent {
static var title: LocalizedStringResource = "Управлять музыкой на ПК"
static var openAppWhenRun: Bool = false
static var isDiscoverable: Bool = false
static var authenticationPolicy: IntentAuthenticationPolicy = .alwaysAllowed
@Parameter(title: "Команда") var action: MusicRemoteAction
@Parameter(title: "Активность") var activityID: String
init() {}
init(_ action: MusicRemoteAction, activityID: String) { self.action = action; self.activityID = activityID }
func perform() async throws -> some IntentResult {
#if !MUSICBRIDGE_WIDGET
await LiveActivityCommands.perform(action, activityID: activityID)
#endif
return .result()
}
}
+4 -2
View File
@@ -22,6 +22,8 @@ def configurations(name, settings):
values.update(SWIFT_OPTIMIZATION_LEVEL="-Onone" if mode == "Debug" else "-O") values.update(SWIFT_OPTIMIZATION_LEVEL="-Onone" if mode == "Debug" else "-O")
if mode == "Debug": if mode == "Debug":
values.update(ENABLE_TESTABILITY="YES", SWIFT_ACTIVE_COMPILATION_CONDITIONS="DEBUG") values.update(ENABLE_TESTABILITY="YES", SWIFT_ACTIVE_COMPILATION_CONDITIONS="DEBUG")
if name == "MusicBridgeWidgets":
values["SWIFT_ACTIVE_COMPILATION_CONDITIONS"] = values.get("SWIFT_ACTIVE_COMPILATION_CONDITIONS", "") + " MUSICBRIDGE_WIDGET"
configs.append(add(name + mode, "XCBuildConfiguration", name=mode, buildSettings=values)) configs.append(add(name + mode, "XCBuildConfiguration", name=mode, buildSettings=values))
return add(name + "configs", "XCConfigurationList", buildConfigurations=configs, return add(name + "configs", "XCConfigurationList", buildConfigurations=configs,
defaultConfigurationIsVisible=0, defaultConfigurationName="Release") defaultConfigurationIsVisible=0, defaultConfigurationName="Release")
@@ -70,11 +72,11 @@ for name, sources, is_test in [("MusicBridge", app_sources, False), ("MusicBridg
proxy = add("test-proxy", "PBXContainerItemProxy", containerPortal=uid("project"), proxyType=1, remoteGlobalIDString=uid("MusicBridgetarget"), remoteInfo="MusicBridge") proxy = add("test-proxy", "PBXContainerItemProxy", containerPortal=uid("project"), proxyType=1, remoteGlobalIDString=uid("MusicBridgetarget"), remoteInfo="MusicBridge")
deps = [add("test-dependency", "PBXTargetDependency", target=uid("MusicBridgetarget"), targetProxy=proxy)] deps = [add("test-dependency", "PBXTargetDependency", target=uid("MusicBridgetarget"), targetProxy=proxy)]
elif is_widget: elif is_widget:
settings.update(INFOPLIST_FILE="MusicBridgeWidgets/Info.plist", CURRENT_PROJECT_VERSION="6", MARKETING_VERSION="0.5.0", settings.update(INFOPLIST_FILE="MusicBridgeWidgets/Info.plist", CURRENT_PROJECT_VERSION="7", MARKETING_VERSION="0.6.0",
APPLICATION_EXTENSION_API_ONLY="YES", SKIP_INSTALL="YES", APPLICATION_EXTENSION_API_ONLY="YES", SKIP_INSTALL="YES",
LD_RUNPATH_SEARCH_PATHS="$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks") LD_RUNPATH_SEARCH_PATHS="$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks")
else: else:
settings.update(INFOPLIST_FILE="MusicBridge/Info.plist", CURRENT_PROJECT_VERSION="6", MARKETING_VERSION="0.5.0", ASSETCATALOG_COMPILER_APPICON_NAME="AppIcon") settings.update(INFOPLIST_FILE="MusicBridge/Info.plist", CURRENT_PROJECT_VERSION="7", MARKETING_VERSION="0.6.0", ASSETCATALOG_COMPILER_APPICON_NAME="AppIcon")
embed = add("embed-widget", "PBXBuildFile", fileRef=uid("MusicBridgeWidgetsproduct"), settings=dict(ATTRIBUTES=["RemoveHeadersOnCopy"])) embed = add("embed-widget", "PBXBuildFile", fileRef=uid("MusicBridgeWidgetsproduct"), settings=dict(ATTRIBUTES=["RemoveHeadersOnCopy"]))
phases.append(add("embed-extensions", "PBXCopyFilesBuildPhase", buildActionMask=2147483647, phases.append(add("embed-extensions", "PBXCopyFilesBuildPhase", buildActionMask=2147483647,
dstPath="", dstSubfolderSpec=13, files=[embed], name="Embed App Extensions", runOnlyForDeploymentPostprocessing=0)) dstPath="", dstSubfolderSpec=13, files=[embed], name="Embed App Extensions", runOnlyForDeploymentPostprocessing=0))
+20
View File
@@ -67,11 +67,25 @@ class Handler(http.server.BaseHTTPRequestHandler):
def snapshot(self, token): def snapshot(self, token):
result = dict(state) result = dict(state)
if token.startswith("live-"):
with counts_lock:
commands = counts.get((token, "command"), 0)
result["title"] = "Track " + str(commands)
if token == "live-unsupported":
result["capabilities"] = {key: False for key in result["capabilities"]}
result["artworkId"] = "A" * 64 if token in ("artwork", "artwork-fail", "late-art") else None result["artworkId"] = "A" * 64 if token in ("artwork", "artwork-fail", "late-art") else None
return result return result
def do_POST(self): def do_POST(self):
body = json.loads(self.rfile.read(int(self.headers.get("Content-Length", "0")))) body = json.loads(self.rfile.read(int(self.headers.get("Content-Length", "0"))))
token = self.headers.get("Authorization", "").removeprefix("Bearer ")
if token.startswith("live-"):
count(token, "command")
if token == "live-reject":
self.reply(200, {"success": False, "code": "unsupported", "message": "Rejected"})
else:
self.reply(200, {"success": True, "code": "ok", "message": "OK"})
return
if self.path == "/v1/pair": if self.path == "/v1/pair":
code = body.get("code") code = body.get("code")
if code == "33333333": if code == "33333333":
@@ -86,6 +100,12 @@ class Handler(http.server.BaseHTTPRequestHandler):
def do_GET(self): def do_GET(self):
token = self.headers.get("Authorization", "").removeprefix("Bearer ") token = self.headers.get("Authorization", "").removeprefix("Bearer ")
number = count(token, self.path) number = count(token, self.path)
if token == "live-refresh-fail" and self.path == "/v1/state":
with counts_lock:
commands = counts.get((token, "command"), 0)
if commands:
self.reply(503, {})
return
if token == "revoked": if token == "revoked":
self.reply(401, {}) self.reply(401, {})
return return