Fix remote player SDK types and signing prefix; test remote commands
This commit is contained in:
@@ -337,6 +337,16 @@
|
|||||||
"isa" = "PBXBuildFile";
|
"isa" = "PBXBuildFile";
|
||||||
"fileRef" = "464F6C65BC2FAB6BCCC77F46";
|
"fileRef" = "464F6C65BC2FAB6BCCC77F46";
|
||||||
};
|
};
|
||||||
|
"7749A66489642A16382E8E42" = {
|
||||||
|
"isa" = "PBXFileReference";
|
||||||
|
"lastKnownFileType" = "sourcecode.swift";
|
||||||
|
"path" = "SystemPlayerTests.swift";
|
||||||
|
"sourceTree" = "<group>";
|
||||||
|
};
|
||||||
|
"340F55A9E43E8FFA66453825" = {
|
||||||
|
"isa" = "PBXBuildFile";
|
||||||
|
"fileRef" = "7749A66489642A16382E8E42";
|
||||||
|
};
|
||||||
"8BED860F1BD59C500D215E8D" = {
|
"8BED860F1BD59C500D215E8D" = {
|
||||||
"isa" = "PBXFileReference";
|
"isa" = "PBXFileReference";
|
||||||
"lastKnownFileType" = "sourcecode.swift";
|
"lastKnownFileType" = "sourcecode.swift";
|
||||||
@@ -351,7 +361,7 @@
|
|||||||
"isa" = "PBXGroup";
|
"isa" = "PBXGroup";
|
||||||
"path" = "MusicBridgeTests";
|
"path" = "MusicBridgeTests";
|
||||||
"sourceTree" = "<group>";
|
"sourceTree" = "<group>";
|
||||||
"children" = ("C9F705D432CA45B6056BC572", "65CE96DA4162DE4255412BC7", "9819FF7CC21CAAD1BDB8D1B8", "ACD0393DCDDDF044F2966340", "464F6C65BC2FAB6BCCC77F46", "8BED860F1BD59C500D215E8D", "F16D05EC6B29248D2C61ADB1", "4731785CAB9BB914B8834AE6",);
|
"children" = ("C9F705D432CA45B6056BC572", "65CE96DA4162DE4255412BC7", "9819FF7CC21CAAD1BDB8D1B8", "ACD0393DCDDDF044F2966340", "464F6C65BC2FAB6BCCC77F46", "7749A66489642A16382E8E42", "8BED860F1BD59C500D215E8D", "F16D05EC6B29248D2C61ADB1", "4731785CAB9BB914B8834AE6",);
|
||||||
};
|
};
|
||||||
"F16D05EC6B29248D2C61ADB1" = {
|
"F16D05EC6B29248D2C61ADB1" = {
|
||||||
"isa" = "PBXFileReference";
|
"isa" = "PBXFileReference";
|
||||||
@@ -525,7 +535,7 @@
|
|||||||
"AB453A3F0E1490D48374A69A" = {
|
"AB453A3F0E1490D48374A69A" = {
|
||||||
"isa" = "PBXSourcesBuildPhase";
|
"isa" = "PBXSourcesBuildPhase";
|
||||||
"buildActionMask" = 2147483647;
|
"buildActionMask" = 2147483647;
|
||||||
"files" = ("1DD7BB0437E9F53586831F22", "E87BA6EF1948C84AC29D8EA8", "BD0B3FAC7F1259D249134283", "17E248E58CB43C680B48DD51", "EFDB280E0B23F7E2A246BC89", "DC1A5B0B94F4C98F1CA36676",);
|
"files" = ("1DD7BB0437E9F53586831F22", "E87BA6EF1948C84AC29D8EA8", "BD0B3FAC7F1259D249134283", "17E248E58CB43C680B48DD51", "EFDB280E0B23F7E2A246BC89", "340F55A9E43E8FFA66453825", "DC1A5B0B94F4C98F1CA36676",);
|
||||||
"runOnlyForDeploymentPostprocessing" = 0;
|
"runOnlyForDeploymentPostprocessing" = 0;
|
||||||
};
|
};
|
||||||
"9AA2A4C344B5C474AF5D336C" = {
|
"9AA2A4C344B5C474AF5D336C" = {
|
||||||
|
|||||||
@@ -73,6 +73,6 @@
|
|||||||
</dict>
|
</dict>
|
||||||
</dict>
|
</dict>
|
||||||
<key>MusicBridgeSharedKeychainGroup</key>
|
<key>MusicBridgeSharedKeychainGroup</key>
|
||||||
<string>$(AppIdentifierPrefix)ru.yukinoki.musicbridge.remote-control</string>
|
<string>ru.yukinoki.musicbridge.remote-control</string>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@@ -44,21 +44,29 @@ final class SystemPlayerManager: ObservableObject {
|
|||||||
func restore(connection: Connection?) async {
|
func restore(connection: Connection?) async {
|
||||||
guard #available(iOS 27.0, *), id == nil, !busy,
|
guard #available(iOS 27.0, *), id == nil, !busy,
|
||||||
let savedID = UserDefaults.standard.string(forKey: savedIDKey) else { return }
|
let savedID = UserDefaults.standard.string(forKey: savedIDKey) else { return }
|
||||||
|
busy = true
|
||||||
|
id = savedID
|
||||||
|
let operation = generation
|
||||||
|
defer { if generation == operation { busy = false } }
|
||||||
do {
|
do {
|
||||||
let saved = try SystemPlayerCredentials.load(savedID)
|
let saved = try SystemPlayerCredentials.load(savedID)
|
||||||
guard let connection, saved.fingerprint == connection.fingerprint, saved.token == connection.token else {
|
guard let connection, saved.fingerprint == connection.fingerprint, saved.token == connection.token else {
|
||||||
try SystemPlayerCredentials.delete(savedID)
|
try SystemPlayerCredentials.delete(savedID)
|
||||||
UserDefaults.standard.removeObject(forKey: savedIDKey)
|
UserDefaults.standard.removeObject(forKey: savedIDKey)
|
||||||
for session in try await RemoteMediaSession<SystemPlayerAttributes>.sessions() where session.id == savedID { try await session.end() }
|
for session in try await RemoteMediaSession<SystemPlayerAttributes>.sessions() where session.id == savedID { try await session.end() }
|
||||||
|
if generation == operation { id = nil }
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if let session = try await RemoteMediaSession<SystemPlayerAttributes>.sessions().first(where: { $0.id == savedID }) {
|
if let session = try await RemoteMediaSession<SystemPlayerAttributes>.sessions().first(where: { $0.id == savedID }) {
|
||||||
|
guard generation == operation else { return }
|
||||||
storage = session; id = savedID; running = true
|
storage = session; id = savedID; running = true
|
||||||
} else {
|
} else {
|
||||||
|
guard generation == operation else { return }
|
||||||
try SystemPlayerCredentials.delete(savedID)
|
try SystemPlayerCredentials.delete(savedID)
|
||||||
UserDefaults.standard.removeObject(forKey: savedIDKey)
|
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) {
|
func receive(_ state: MediaState, connection: Connection) {
|
||||||
|
|||||||
@@ -26,7 +26,7 @@
|
|||||||
<string>com.apple.nowplaying.remote-media</string>
|
<string>com.apple.nowplaying.remote-media</string>
|
||||||
</dict>
|
</dict>
|
||||||
<key>MusicBridgeSharedKeychainGroup</key>
|
<key>MusicBridgeSharedKeychainGroup</key>
|
||||||
<string>$(AppIdentifierPrefix)ru.yukinoki.musicbridge.remote-control</string>
|
<string>ru.yukinoki.musicbridge.remote-control</string>
|
||||||
<key>NSLocalNetworkUsageDescription</key>
|
<key>NSLocalNetworkUsageDescription</key>
|
||||||
<string>MusicBridge подключается к вашему ПК, чтобы показывать музыку и управлять воспроизведением.</string>
|
<string>MusicBridge подключается к вашему ПК, чтобы показывать музыку и управлять воспроизведением.</string>
|
||||||
<key>NSAppTransportSecurity</key>
|
<key>NSAppTransportSecurity</key>
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ final class PCMediaSession: RemoteMediaSessionRepresentable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var playbackSnapshot: MediaPlaybackSnapshot? {
|
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)
|
elapsedTime: attributes.position, timestamp: attributes.timestamp)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,13 +2,33 @@ import Foundation
|
|||||||
import Security
|
import Security
|
||||||
|
|
||||||
enum SystemPlayerCredentials {
|
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] {
|
private static func query(_ id: String) throws -> [String: Any] {
|
||||||
guard let group = Bundle.main.object(forInfoDictionaryKey: "MusicBridgeSharedKeychainGroup") as? String,
|
guard let suffix = Bundle.main.object(forInfoDictionaryKey: "MusicBridgeSharedKeychainGroup") as? String,
|
||||||
!group.isEmpty, !group.contains("$(") else {
|
suffix == "ru.yukinoki.musicbridge.remote-control" else {
|
||||||
throw BridgeError.message("Подпись приложения не настроила общий Keychain для системного пульта.")
|
throw BridgeError.message("Подпись приложения не настроила общий Keychain для системного пульта.")
|
||||||
}
|
}
|
||||||
return [kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: "MusicBridge.system-player.v1",
|
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 {
|
static func save(_ connection: Connection, id: String) throws {
|
||||||
|
|||||||
@@ -162,11 +162,11 @@ widget_info = dict(CFBundleDevelopmentRegion="ru", CFBundleDisplayName="MusicBri
|
|||||||
NSExtension=dict(NSExtensionPointIdentifier="com.apple.widgetkit-extension"))
|
NSExtension=dict(NSExtensionPointIdentifier="com.apple.widgetkit-extension"))
|
||||||
(ROOT / "MusicBridgeWidgets" / "Info.plist").write_bytes(plistlib.dumps(widget_info, sort_keys=False))
|
(ROOT / "MusicBridgeWidgets" / "Info.plist").write_bytes(plistlib.dumps(widget_info, sort_keys=False))
|
||||||
shared_group_name = "$(AppIdentifierPrefix)ru.yukinoki.musicbridge.remote-control"
|
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))
|
(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 = {k: v for k, v in widget_info.items() if k != "NSExtension"}
|
||||||
remote_info.update(EXAppExtensionAttributes=dict(EXExtensionPointIdentifier="com.apple.nowplaying.remote-media"),
|
remote_info.update(EXAppExtensionAttributes=dict(EXExtensionPointIdentifier="com.apple.nowplaying.remote-media"),
|
||||||
MusicBridgeSharedKeychainGroup=shared_group_name,
|
MusicBridgeSharedKeychainGroup=info["MusicBridgeSharedKeychainGroup"],
|
||||||
NSLocalNetworkUsageDescription=info["NSLocalNetworkUsageDescription"],
|
NSLocalNetworkUsageDescription=info["NSLocalNetworkUsageDescription"],
|
||||||
NSAppTransportSecurity=info["NSAppTransportSecurity"])
|
NSAppTransportSecurity=info["NSAppTransportSecurity"])
|
||||||
(ROOT / "MusicBridgeRemote" / "Info.plist").write_bytes(plistlib.dumps(remote_info, sort_keys=False))
|
(ROOT / "MusicBridgeRemote" / "Info.plist").write_bytes(plistlib.dumps(remote_info, sort_keys=False))
|
||||||
|
|||||||
+20
-1
@@ -47,6 +47,25 @@ with zipfile.ZipFile(path) as archive:
|
|||||||
platforms.append(struct.unpack_from("<I", binary, offset + 8)[0])
|
platforms.append(struct.unpack_from("<I", binary, offset + 8)[0])
|
||||||
offset += size
|
offset += size
|
||||||
assert platforms == [2], "Widget is not built for iOS devices"
|
assert platforms == [2], "Widget is not built for iOS devices"
|
||||||
|
remote_path = prefix + "Extensions/MusicBridgeRemote.appex/"
|
||||||
|
remote = plistlib.loads(archive.read(remote_path + "Info.plist"))
|
||||||
|
assert remote["CFBundleIdentifier"] == info["CFBundleIdentifier"] + ".remote"
|
||||||
|
assert remote["CFBundleShortVersionString"] == info["CFBundleShortVersionString"]
|
||||||
|
assert remote["CFBundleVersion"] == info["CFBundleVersion"]
|
||||||
|
assert remote["MinimumOSVersion"] == "27.0"
|
||||||
|
assert remote["EXAppExtensionAttributes"]["EXExtensionPointIdentifier"] == "com.apple.nowplaying.remote-media"
|
||||||
|
assert remote["MusicBridgeSharedKeychainGroup"] == info["MusicBridgeSharedKeychainGroup"] == "ru.yukinoki.musicbridge.remote-control"
|
||||||
|
binary = archive.read(remote_path + remote["CFBundleExecutable"])
|
||||||
|
assert struct.unpack_from("<II", binary) == (0xFEEDFACF, 0x0100000C)
|
||||||
|
offset, platforms = 32, []
|
||||||
|
for _ in range(struct.unpack_from("<I", binary, 16)[0]):
|
||||||
|
command, size = struct.unpack_from("<II", binary, offset)
|
||||||
|
assert size >= 8 and offset + size <= len(binary)
|
||||||
|
if command == 0x32:
|
||||||
|
platforms.append(struct.unpack_from("<I", binary, offset + 8)[0])
|
||||||
|
offset += size
|
||||||
|
assert platforms == [2], "Remote extension is not built for iOS devices"
|
||||||
print(f"PASS: {path.name}: valid IPA, iPhoneOS ARM64, iOS 17+, ru.yukinoki.musicbridge.")
|
print(f"PASS: {path.name}: valid IPA, iPhoneOS ARM64, iOS 17+, ru.yukinoki.musicbridge.")
|
||||||
print("PASS: embedded WidgetKit extension, matching version, ARM64 iPhoneOS and Live Activities support.")
|
print("PASS: embedded WidgetKit extension, matching version, ARM64 iPhoneOS and Live Activities support.")
|
||||||
print("Requires local signing with Sideloadly before installation on an iPhone.")
|
print("PASS: embedded iOS 27 NowPlaying ExtensionKit extension and matching shared Keychain metadata.")
|
||||||
|
print("Requires signing all extensions and shared Keychain entitlements before installation.")
|
||||||
|
|||||||
Reference in New Issue
Block a user