diff --git a/MusicBridge.xcodeproj/project.pbxproj b/MusicBridge.xcodeproj/project.pbxproj index fa33e3a..c8fe7e3 100644 --- a/MusicBridge.xcodeproj/project.pbxproj +++ b/MusicBridge.xcodeproj/project.pbxproj @@ -337,6 +337,16 @@ "isa" = "PBXBuildFile"; "fileRef" = "464F6C65BC2FAB6BCCC77F46"; }; + "7749A66489642A16382E8E42" = { + "isa" = "PBXFileReference"; + "lastKnownFileType" = "sourcecode.swift"; + "path" = "SystemPlayerTests.swift"; + "sourceTree" = ""; + }; + "340F55A9E43E8FFA66453825" = { + "isa" = "PBXBuildFile"; + "fileRef" = "7749A66489642A16382E8E42"; + }; "8BED860F1BD59C500D215E8D" = { "isa" = "PBXFileReference"; "lastKnownFileType" = "sourcecode.swift"; @@ -351,7 +361,7 @@ "isa" = "PBXGroup"; "path" = "MusicBridgeTests"; "sourceTree" = ""; - "children" = ("C9F705D432CA45B6056BC572", "65CE96DA4162DE4255412BC7", "9819FF7CC21CAAD1BDB8D1B8", "ACD0393DCDDDF044F2966340", "464F6C65BC2FAB6BCCC77F46", "8BED860F1BD59C500D215E8D", "F16D05EC6B29248D2C61ADB1", "4731785CAB9BB914B8834AE6",); + "children" = ("C9F705D432CA45B6056BC572", "65CE96DA4162DE4255412BC7", "9819FF7CC21CAAD1BDB8D1B8", "ACD0393DCDDDF044F2966340", "464F6C65BC2FAB6BCCC77F46", "7749A66489642A16382E8E42", "8BED860F1BD59C500D215E8D", "F16D05EC6B29248D2C61ADB1", "4731785CAB9BB914B8834AE6",); }; "F16D05EC6B29248D2C61ADB1" = { "isa" = "PBXFileReference"; @@ -525,7 +535,7 @@ "AB453A3F0E1490D48374A69A" = { "isa" = "PBXSourcesBuildPhase"; "buildActionMask" = 2147483647; - "files" = ("1DD7BB0437E9F53586831F22", "E87BA6EF1948C84AC29D8EA8", "BD0B3FAC7F1259D249134283", "17E248E58CB43C680B48DD51", "EFDB280E0B23F7E2A246BC89", "DC1A5B0B94F4C98F1CA36676",); + "files" = ("1DD7BB0437E9F53586831F22", "E87BA6EF1948C84AC29D8EA8", "BD0B3FAC7F1259D249134283", "17E248E58CB43C680B48DD51", "EFDB280E0B23F7E2A246BC89", "340F55A9E43E8FFA66453825", "DC1A5B0B94F4C98F1CA36676",); "runOnlyForDeploymentPostprocessing" = 0; }; "9AA2A4C344B5C474AF5D336C" = { diff --git a/MusicBridge/Info.plist b/MusicBridge/Info.plist index 712246c..4e04652 100644 --- a/MusicBridge/Info.plist +++ b/MusicBridge/Info.plist @@ -73,6 +73,6 @@ MusicBridgeSharedKeychainGroup - $(AppIdentifierPrefix)ru.yukinoki.musicbridge.remote-control + ru.yukinoki.musicbridge.remote-control diff --git a/MusicBridge/SystemPlayerManager.swift b/MusicBridge/SystemPlayerManager.swift index 7944c2e..1ffe405 100644 --- a/MusicBridge/SystemPlayerManager.swift +++ b/MusicBridge/SystemPlayerManager.swift @@ -44,21 +44,29 @@ final class SystemPlayerManager: ObservableObject { func restore(connection: Connection?) async { guard #available(iOS 27.0, *), id == nil, !busy, let savedID = UserDefaults.standard.string(forKey: savedIDKey) else { return } + busy = true + id = savedID + let operation = generation + defer { if generation == operation { busy = false } } do { let saved = try SystemPlayerCredentials.load(savedID) guard let connection, saved.fingerprint == connection.fingerprint, saved.token == connection.token else { try SystemPlayerCredentials.delete(savedID) UserDefaults.standard.removeObject(forKey: savedIDKey) for session in try await RemoteMediaSession.sessions() where session.id == savedID { try await session.end() } + if generation == operation { id = nil } return } if let session = try await RemoteMediaSession.sessions().first(where: { $0.id == savedID }) { + guard generation == operation else { return } storage = session; id = savedID; running = true } else { + guard generation == operation else { return } try SystemPlayerCredentials.delete(savedID) UserDefaults.standard.removeObject(forKey: savedIDKey) + id = nil } - } catch { self.error = BridgeError.describe(error) } + } catch { if generation == operation { self.error = BridgeError.describe(error); id = nil } } } func receive(_ state: MediaState, connection: Connection) { diff --git a/MusicBridgeRemote/Info.plist b/MusicBridgeRemote/Info.plist index 2a4c9b6..38ec420 100644 --- a/MusicBridgeRemote/Info.plist +++ b/MusicBridgeRemote/Info.plist @@ -26,7 +26,7 @@ com.apple.nowplaying.remote-media MusicBridgeSharedKeychainGroup - $(AppIdentifierPrefix)ru.yukinoki.musicbridge.remote-control + ru.yukinoki.musicbridge.remote-control NSLocalNetworkUsageDescription MusicBridge подключается к вашему ПК, чтобы показывать музыку и управлять воспроизведением. NSAppTransportSecurity diff --git a/MusicBridgeRemote/MusicBridgeRemote.swift b/MusicBridgeRemote/MusicBridgeRemote.swift index 8868c3a..640d8df 100644 --- a/MusicBridgeRemote/MusicBridgeRemote.swift +++ b/MusicBridgeRemote/MusicBridgeRemote.swift @@ -40,7 +40,7 @@ final class PCMediaSession: RemoteMediaSessionRepresentable { } var playbackSnapshot: MediaPlaybackSnapshot? { - MediaPlaybackSnapshot(state: attributes.playing ? .playing(rate: attributes.rate) : .paused, + MediaPlaybackSnapshot(state: attributes.playing ? .playing(rate: Float(attributes.rate)) : .paused, elapsedTime: attributes.position, timestamp: attributes.timestamp) } diff --git a/MusicBridgeTests/SystemPlayerTests.swift b/MusicBridgeTests/SystemPlayerTests.swift new file mode 100644 index 0000000..2e562d5 --- /dev/null +++ b/MusicBridgeTests/SystemPlayerTests.swift @@ -0,0 +1,54 @@ +import XCTest +@testable import MusicBridge + +@MainActor +final class SystemPlayerTests: XCTestCase { + private func state(_ changes: [String: Any] = [:]) throws -> MediaState { + let url = try XCTUnwrap(Bundle(for: Self.self).url(forResource: "state-v1", withExtension: "json")) + var json = try XCTUnwrap(JSONSerialization.jsonObject(with: Data(contentsOf: url)) as? [String: Any]) + changes.forEach { json[$0.key] = $0.value } + return try JSONDecoder().decode(MediaState.self, from: JSONSerialization.data(withJSONObject: json)) + } + + func testSeekConvertsRelativePositionAndClampsToAllowedRange() throws { + let state = try state(["startSeconds": 100, "endSeconds": 300, "minSeekSeconds": 110, "maxSeekSeconds": 280]) + XCTAssertEqual(try SystemPlayerTransport.command(.seek, value: 30, state: state).value, 130) + XCTAssertEqual(try SystemPlayerTransport.command(.seek, value: -50, state: state).value, 110) + XCTAssertEqual(try SystemPlayerTransport.command(.seek, value: 500, state: state).value, 280) + XCTAssertThrowsError(try SystemPlayerTransport.command(.seek, value: .nan, state: state)) + } + + func testVolumeBoundsAndUnsupportedPlayback() throws { + let state = try state() + XCTAssertEqual(try SystemPlayerTransport.command(.volume, value: 2, state: state).value, 1) + XCTAssertEqual(try SystemPlayerTransport.command(.volume, value: -1, state: state).value, 0) + XCTAssertThrowsError(try SystemPlayerTransport.command(.volume, value: .infinity, state: state)) + XCTAssertThrowsError(try SystemPlayerTransport.command(.play, value: nil, state: state)) + XCTAssertThrowsError(try SystemPlayerTransport.command(.next, value: nil, state: self.state(["hasSession": false]))) + } + + func testRemoteSnapshotContainsOnlyPublicPlaybackMetadata() throws { + guard #available(iOS 27.0, *) else { throw XCTSkip("Requires NowPlaying") } + let snapshot = SystemPlayerAttributes(id: "session", state: try state(["positionSeconds": 130, "startSeconds": 100, "endSeconds": 300])) + XCTAssertEqual(snapshot.position, 30) + XCTAssertEqual(snapshot.duration, 200) + let json = try XCTUnwrap(JSONSerialization.jsonObject(with: JSONEncoder().encode(snapshot)) as? [String: Any]) + XCTAssertEqual(Set(json.keys), Set(["id", "title", "artist", "source", "playing", "rate", "position", "duration", "timestamp", "capabilities", "volume", "hasSession"])) + } + + func testRevokedSessionDoesNotSendCommandAndNextIsSentOnce() async throws { + 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"]) + let client = BridgeClient(try Connection(endpoint: XCTUnwrap(f["endpoint"]), fingerprint: XCTUnwrap(f["fingerprint"]), token: "live-system-next")) + defer { client.close() } + do { + _ = try await SystemPlayerTransport.execute(.next, client: client) { throw BridgeError.unauthorized } + XCTFail("Revoked session must be rejected") + } catch BridgeError.unauthorized { } + let unchanged = try await client.state() + XCTAssertEqual(unchanged.title, "Track 0") + let updated = try await SystemPlayerTransport.execute(.next, client: client) + XCTAssertEqual(updated.title, "Track 1") + } +} diff --git a/RemoteShared/SystemPlayerCredentials.swift b/RemoteShared/SystemPlayerCredentials.swift index 6f19a0f..69dbc1d 100644 --- a/RemoteShared/SystemPlayerCredentials.swift +++ b/RemoteShared/SystemPlayerCredentials.swift @@ -2,13 +2,33 @@ import Foundation import Security enum SystemPlayerCredentials { + // Derive the signing prefix from our own default Keychain group. Info.plist + // build substitutions cannot know the team that later re-signs an IPA. + private static func signingPrefix() throws -> String { + let marker: [String: Any] = [kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: "MusicBridge.signing-prefix.v1", kSecAttrAccount as String: "prefix"] + let status = SecItemAdd(marker.merging([kSecValueData as String: Data(), + kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly]) { _, v in v } as CFDictionary, nil) + guard status == errSecSuccess || status == errSecDuplicateItem else { throw failure(status) } + var result: CFTypeRef? + let read = SecItemCopyMatching(marker.merging([kSecReturnAttributes as String: true, + kSecMatchLimit as String: kSecMatchLimitOne]) { _, v in v } as CFDictionary, &result) + guard read == errSecSuccess else { throw failure(read) } + guard let attributes = result as? [String: Any], + let group = attributes[kSecAttrAccessGroup as String] as? String, + let separator = group.firstIndex(of: "."), separator != group.startIndex else { + throw failure(errSecMissingEntitlement) + } + return String(group[...separator]) + } + private static func query(_ id: String) throws -> [String: Any] { - guard let group = Bundle.main.object(forInfoDictionaryKey: "MusicBridgeSharedKeychainGroup") as? String, - !group.isEmpty, !group.contains("$(") else { + guard let suffix = Bundle.main.object(forInfoDictionaryKey: "MusicBridgeSharedKeychainGroup") as? String, + suffix == "ru.yukinoki.musicbridge.remote-control" else { throw BridgeError.message("Подпись приложения не настроила общий Keychain для системного пульта.") } return [kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: "MusicBridge.system-player.v1", - kSecAttrAccount as String: id, kSecAttrAccessGroup as String: group] + kSecAttrAccount as String: id, kSecAttrAccessGroup as String: try signingPrefix() + suffix] } static func save(_ connection: Connection, id: String) throws { diff --git a/tools/generate_project.py b/tools/generate_project.py index 6135ebb..ecadc98 100644 --- a/tools/generate_project.py +++ b/tools/generate_project.py @@ -162,11 +162,11 @@ widget_info = dict(CFBundleDevelopmentRegion="ru", CFBundleDisplayName="MusicBri NSExtension=dict(NSExtensionPointIdentifier="com.apple.widgetkit-extension")) (ROOT / "MusicBridgeWidgets" / "Info.plist").write_bytes(plistlib.dumps(widget_info, sort_keys=False)) shared_group_name = "$(AppIdentifierPrefix)ru.yukinoki.musicbridge.remote-control" -info["MusicBridgeSharedKeychainGroup"] = shared_group_name +info["MusicBridgeSharedKeychainGroup"] = "ru.yukinoki.musicbridge.remote-control" (ROOT / "MusicBridge" / "Info.plist").write_bytes(plistlib.dumps(info, sort_keys=False)) remote_info = {k: v for k, v in widget_info.items() if k != "NSExtension"} remote_info.update(EXAppExtensionAttributes=dict(EXExtensionPointIdentifier="com.apple.nowplaying.remote-media"), - MusicBridgeSharedKeychainGroup=shared_group_name, + MusicBridgeSharedKeychainGroup=info["MusicBridgeSharedKeychainGroup"], NSLocalNetworkUsageDescription=info["NSLocalNetworkUsageDescription"], NSAppTransportSecurity=info["NSAppTransportSecurity"]) (ROOT / "MusicBridgeRemote" / "Info.plist").write_bytes(plistlib.dumps(remote_info, sort_keys=False)) diff --git a/tools/verify_ipa.py b/tools/verify_ipa.py index f5fd8b6..0291924 100644 --- a/tools/verify_ipa.py +++ b/tools/verify_ipa.py @@ -47,6 +47,25 @@ with zipfile.ZipFile(path) as archive: platforms.append(struct.unpack_from("= 8 and offset + size <= len(binary) + if command == 0x32: + platforms.append(struct.unpack_from("