diff --git a/MusicBridge.xcodeproj/project.pbxproj b/MusicBridge.xcodeproj/project.pbxproj index f85f16f..fa33e3a 100644 --- a/MusicBridge.xcodeproj/project.pbxproj +++ b/MusicBridge.xcodeproj/project.pbxproj @@ -125,11 +125,93 @@ "isa" = "PBXBuildFile"; "fileRef" = "7148B52E915554F67E875C26"; }; + "088AC623B345717D980B3156" = { + "isa" = "PBXFileReference"; + "lastKnownFileType" = "sourcecode.swift"; + "path" = "SystemPlayerManager.swift"; + "sourceTree" = ""; + }; + "27227986C8A227728D784957" = { + "isa" = "PBXBuildFile"; + "fileRef" = "088AC623B345717D980B3156"; + }; "026F39792BBA0E5032536CED" = { "isa" = "PBXGroup"; "path" = "MusicBridge"; "sourceTree" = ""; - "children" = ("065A9D559DC80AAF876DA757", "763FA6A137E5E94274AC56A3", "971C0AE92F167DE7AB145086", "5F6F408829AB46D3B85FB911", "76FB6BFFC478599567424755", "581AD2E9199DAED730DC0F6E", "EE2357BD5CA3755AFEB3171A", "124CF3F5AC075D1D6042DF64", "ADF0CD56882201BD3AC1581B", "9C432A0DA0BEC173839EBF86", "6B0F2027221CDD63E8FD4C59", "7148B52E915554F67E875C26", "8BF729FFE074CAEE622C0292",); + "children" = ("065A9D559DC80AAF876DA757", "763FA6A137E5E94274AC56A3", "971C0AE92F167DE7AB145086", "5F6F408829AB46D3B85FB911", "76FB6BFFC478599567424755", "581AD2E9199DAED730DC0F6E", "EE2357BD5CA3755AFEB3171A", "124CF3F5AC075D1D6042DF64", "ADF0CD56882201BD3AC1581B", "9C432A0DA0BEC173839EBF86", "6B0F2027221CDD63E8FD4C59", "7148B52E915554F67E875C26", "088AC623B345717D980B3156", "8BF729FFE074CAEE622C0292",); + }; + "1C6CBD84758D03C1B40D19C7" = { + "isa" = "PBXFileReference"; + "lastKnownFileType" = "sourcecode.swift"; + "path" = "SystemPlayerAttributes.swift"; + "sourceTree" = ""; + }; + "0D17ED02E5B8770D785508A3" = { + "isa" = "PBXBuildFile"; + "fileRef" = "1C6CBD84758D03C1B40D19C7"; + }; + "77372299BB61F0ED174508F0" = { + "isa" = "PBXFileReference"; + "lastKnownFileType" = "sourcecode.swift"; + "path" = "SystemPlayerCredentials.swift"; + "sourceTree" = ""; + }; + "2599D4B5F13D672B723A4BD3" = { + "isa" = "PBXBuildFile"; + "fileRef" = "77372299BB61F0ED174508F0"; + }; + "897E8908B2E9AD60A31E6897" = { + "isa" = "PBXFileReference"; + "lastKnownFileType" = "sourcecode.swift"; + "path" = "SystemPlayerTransport.swift"; + "sourceTree" = ""; + }; + "804FFB386BC1AF4252EDA177" = { + "isa" = "PBXBuildFile"; + "fileRef" = "897E8908B2E9AD60A31E6897"; + }; + "D2DF40467DEC980974060FD5" = { + "isa" = "PBXGroup"; + "path" = "RemoteShared"; + "sourceTree" = ""; + "children" = ("1C6CBD84758D03C1B40D19C7", "77372299BB61F0ED174508F0", "897E8908B2E9AD60A31E6897",); + }; + "54D4A2CB4EFD85CA136E4B32" = { + "isa" = "PBXFileReference"; + "lastKnownFileType" = "sourcecode.swift"; + "path" = "MusicBridgeRemote.swift"; + "sourceTree" = ""; + }; + "ED075C376D7772FEBD3EC117" = { + "isa" = "PBXBuildFile"; + "fileRef" = "54D4A2CB4EFD85CA136E4B32"; + }; + "E2D7CC5D9CE02F4A7B8243D8" = { + "isa" = "PBXGroup"; + "path" = "MusicBridgeRemote"; + "sourceTree" = ""; + "children" = ("54D4A2CB4EFD85CA136E4B32",); + }; + "511889FA962E9B3AC1BC92DE" = { + "isa" = "PBXBuildFile"; + "fileRef" = "1C6CBD84758D03C1B40D19C7"; + }; + "A3C91023F16219D79F80723C" = { + "isa" = "PBXBuildFile"; + "fileRef" = "77372299BB61F0ED174508F0"; + }; + "823EB4050AFBFC9AE72A3C03" = { + "isa" = "PBXBuildFile"; + "fileRef" = "897E8908B2E9AD60A31E6897"; + }; + "8EE58DCF843B86D38CF45CC0" = { + "isa" = "PBXBuildFile"; + "fileRef" = "124CF3F5AC075D1D6042DF64"; + }; + "4AAE356B6279CE0B686A3B9D" = { + "isa" = "PBXBuildFile"; + "fileRef" = "065A9D559DC80AAF876DA757"; }; "7C5E653CCD928B143AB68F09" = { "isa" = "PBXFileReference"; @@ -301,7 +383,7 @@ "72B74C8A2143F64FD53D71E5" = { "isa" = "PBXSourcesBuildPhase"; "buildActionMask" = 2147483647; - "files" = ("82F1C33CE4F8B8F641FF0461", "40D2920E2C996D62B7190BA4", "16EA630DE1FE9F0A16580C9D", "842E87CD23DCB5BBCA4340F6", "DA06B3B12E52FB69A6A34887", "D6D68067600EEBE36972550E", "181CACDC331104A1E16CFC20", "DEDCCD95A140162B6D654871", "48E2FDCA11A6694953C86E6D", "47693934E8A974162CF08A38", "287044ADBD143F929035BC61", "BE808A0D13A16FE67D32B7FB", "C7506B09240305F44C61EB60", "4FD4638045EDA8D7CCFD00AF", "962BED0D8E17961AD72EC24F",); + "files" = ("82F1C33CE4F8B8F641FF0461", "40D2920E2C996D62B7190BA4", "16EA630DE1FE9F0A16580C9D", "842E87CD23DCB5BBCA4340F6", "DA06B3B12E52FB69A6A34887", "D6D68067600EEBE36972550E", "181CACDC331104A1E16CFC20", "DEDCCD95A140162B6D654871", "48E2FDCA11A6694953C86E6D", "47693934E8A974162CF08A38", "287044ADBD143F929035BC61", "BE808A0D13A16FE67D32B7FB", "27227986C8A227728D784957", "0D17ED02E5B8770D785508A3", "2599D4B5F13D672B723A4BD3", "804FFB386BC1AF4252EDA177", "C7506B09240305F44C61EB60", "4FD4638045EDA8D7CCFD00AF", "962BED0D8E17961AD72EC24F",); "runOnlyForDeploymentPostprocessing" = 0; }; "734C4650C7A5604092763C32" = { @@ -323,6 +405,13 @@ "ATTRIBUTES" = ("RemoveHeadersOnCopy",); }; }; + "013486AD4616563D96D6E9AC" = { + "isa" = "PBXBuildFile"; + "fileRef" = "F1BB1F45195BEB71A550539C"; + "settings" = { + "ATTRIBUTES" = ("RemoveHeadersOnCopy",); + }; + }; "AFC32963E7959E7A34264826" = { "isa" = "PBXCopyFilesBuildPhase"; "buildActionMask" = 2147483647; @@ -332,6 +421,15 @@ "name" = "Embed App Extensions"; "runOnlyForDeploymentPostprocessing" = 0; }; + "B2EC54BE7DEB4C17571E7035" = { + "isa" = "PBXCopyFilesBuildPhase"; + "buildActionMask" = 2147483647; + "dstPath" = "$(EXTENSIONS_FOLDER_PATH)"; + "dstSubfolderSpec" = 16; + "files" = ("013486AD4616563D96D6E9AC",); + "name" = "Embed ExtensionKit Extensions"; + "runOnlyForDeploymentPostprocessing" = 0; + }; "01E77404E10F254C43E64C55" = { "isa" = "PBXContainerItemProxy"; "containerPortal" = "244210E48437B6556980A702"; @@ -344,6 +442,18 @@ "target" = "BFE9A9DC8C194372006C3ACC"; "targetProxy" = "01E77404E10F254C43E64C55"; }; + "E73C0469E1928499B9BE1AC7" = { + "isa" = "PBXContainerItemProxy"; + "containerPortal" = "244210E48437B6556980A702"; + "proxyType" = 1; + "remoteGlobalIDString" = "714DB4A269580A6F5127F5CC"; + "remoteInfo" = "MusicBridgeRemote"; + }; + "F76CBC167D5B68BDC0376A94" = { + "isa" = "PBXTargetDependency"; + "target" = "714DB4A269580A6F5127F5CC"; + "targetProxy" = "E73C0469E1928499B9BE1AC7"; + }; "E3322BA79DBD9A84650B2CAE" = { "isa" = "XCBuildConfiguration"; "name" = "Debug"; @@ -358,9 +468,10 @@ "CODE_SIGN_STYLE" = "Automatic"; "GENERATE_INFOPLIST_FILE" = "NO"; "INFOPLIST_FILE" = "MusicBridge/Info.plist"; - "CURRENT_PROJECT_VERSION" = "7"; - "MARKETING_VERSION" = "0.6.0"; + "CURRENT_PROJECT_VERSION" = "8"; + "MARKETING_VERSION" = "0.7.0"; "ASSETCATALOG_COMPILER_APPICON_NAME" = "AppIcon"; + "CODE_SIGN_ENTITLEMENTS" = "MusicBridge/App.entitlements"; "SWIFT_OPTIMIZATION_LEVEL" = "-Onone"; "ENABLE_TESTABILITY" = "YES"; "SWIFT_ACTIVE_COMPILATION_CONDITIONS" = "DEBUG"; @@ -380,9 +491,10 @@ "CODE_SIGN_STYLE" = "Automatic"; "GENERATE_INFOPLIST_FILE" = "NO"; "INFOPLIST_FILE" = "MusicBridge/Info.plist"; - "CURRENT_PROJECT_VERSION" = "7"; - "MARKETING_VERSION" = "0.6.0"; + "CURRENT_PROJECT_VERSION" = "8"; + "MARKETING_VERSION" = "0.7.0"; "ASSETCATALOG_COMPILER_APPICON_NAME" = "AppIcon"; + "CODE_SIGN_ENTITLEMENTS" = "MusicBridge/App.entitlements"; "SWIFT_OPTIMIZATION_LEVEL" = "-O"; }; }; @@ -399,9 +511,9 @@ "productReference" = "7C35A0276A762371ABAE5596"; "productType" = "com.apple.product-type.application"; "buildConfigurationList" = "C6336ED5908EABEBF60AE675"; - "buildPhases" = ("72B74C8A2143F64FD53D71E5", "734C4650C7A5604092763C32", "69816CB820E8173702C54C37", "AFC32963E7959E7A34264826",); + "buildPhases" = ("72B74C8A2143F64FD53D71E5", "734C4650C7A5604092763C32", "69816CB820E8173702C54C37", "AFC32963E7959E7A34264826", "B2EC54BE7DEB4C17571E7035",); "buildRules" = (); - "dependencies" = ("A4C2D386AB0259F33DCDC427",); + "dependencies" = ("A4C2D386AB0259F33DCDC427", "F76CBC167D5B68BDC0376A94",); }; "5E4BE508157A7B2CB7B4C2F2" = { "isa" = "PBXFileReference"; @@ -534,8 +646,8 @@ "CODE_SIGN_STYLE" = "Automatic"; "GENERATE_INFOPLIST_FILE" = "NO"; "INFOPLIST_FILE" = "MusicBridgeWidgets/Info.plist"; - "CURRENT_PROJECT_VERSION" = "7"; - "MARKETING_VERSION" = "0.6.0"; + "CURRENT_PROJECT_VERSION" = "8"; + "MARKETING_VERSION" = "0.7.0"; "APPLICATION_EXTENSION_API_ONLY" = "YES"; "SKIP_INSTALL" = "YES"; "LD_RUNPATH_SEARCH_PATHS" = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks"; @@ -558,8 +670,8 @@ "CODE_SIGN_STYLE" = "Automatic"; "GENERATE_INFOPLIST_FILE" = "NO"; "INFOPLIST_FILE" = "MusicBridgeWidgets/Info.plist"; - "CURRENT_PROJECT_VERSION" = "7"; - "MARKETING_VERSION" = "0.6.0"; + "CURRENT_PROJECT_VERSION" = "8"; + "MARKETING_VERSION" = "0.7.0"; "APPLICATION_EXTENSION_API_ONLY" = "YES"; "SKIP_INSTALL" = "YES"; "LD_RUNPATH_SEARCH_PATHS" = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks"; @@ -584,16 +696,106 @@ "buildRules" = (); "dependencies" = (); }; + "F1BB1F45195BEB71A550539C" = { + "isa" = "PBXFileReference"; + "explicitFileType" = "wrapper.extensionkit-extension"; + "includeInIndex" = 0; + "path" = "MusicBridgeRemote.appex"; + "sourceTree" = "BUILT_PRODUCTS_DIR"; + }; + "C2BA95929A255B0F1FC04A79" = { + "isa" = "PBXSourcesBuildPhase"; + "buildActionMask" = 2147483647; + "files" = ("ED075C376D7772FEBD3EC117", "511889FA962E9B3AC1BC92DE", "A3C91023F16219D79F80723C", "823EB4050AFBFC9AE72A3C03", "8EE58DCF843B86D38CF45CC0", "4AAE356B6279CE0B686A3B9D",); + "runOnlyForDeploymentPostprocessing" = 0; + }; + "D3E3B87195B5FF1DA14795C1" = { + "isa" = "PBXFrameworksBuildPhase"; + "buildActionMask" = 2147483647; + "files" = (); + "runOnlyForDeploymentPostprocessing" = 0; + }; + "9C3BF80554FD9B39BDF1988D" = { + "isa" = "PBXResourcesBuildPhase"; + "buildActionMask" = 2147483647; + "files" = (); + "runOnlyForDeploymentPostprocessing" = 0; + }; + "809F689056832D3F2A4D91B6" = { + "isa" = "XCBuildConfiguration"; + "name" = "Debug"; + "buildSettings" = { + "PRODUCT_NAME" = "$(TARGET_NAME)"; + "PRODUCT_BUNDLE_IDENTIFIER" = "ru.yukinoki.musicbridge.remote"; + "SWIFT_VERSION" = "5.0"; + "IPHONEOS_DEPLOYMENT_TARGET" = "27.0"; + "SDKROOT" = "iphoneos"; + "SUPPORTED_PLATFORMS" = "iphoneos iphonesimulator"; + "TARGETED_DEVICE_FAMILY" = "1"; + "CODE_SIGN_STYLE" = "Automatic"; + "GENERATE_INFOPLIST_FILE" = "NO"; + "INFOPLIST_FILE" = "MusicBridgeRemote/Info.plist"; + "CURRENT_PROJECT_VERSION" = "8"; + "MARKETING_VERSION" = "0.7.0"; + "APPLICATION_EXTENSION_API_ONLY" = "YES"; + "SKIP_INSTALL" = "YES"; + "LD_RUNPATH_SEARCH_PATHS" = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks"; + "CODE_SIGN_ENTITLEMENTS" = "MusicBridgeRemote/Remote.entitlements"; + "SWIFT_OPTIMIZATION_LEVEL" = "-Onone"; + "ENABLE_TESTABILITY" = "YES"; + "SWIFT_ACTIVE_COMPILATION_CONDITIONS" = "DEBUG"; + }; + }; + "5E42ACAF0DE32186ED14E82F" = { + "isa" = "XCBuildConfiguration"; + "name" = "Release"; + "buildSettings" = { + "PRODUCT_NAME" = "$(TARGET_NAME)"; + "PRODUCT_BUNDLE_IDENTIFIER" = "ru.yukinoki.musicbridge.remote"; + "SWIFT_VERSION" = "5.0"; + "IPHONEOS_DEPLOYMENT_TARGET" = "27.0"; + "SDKROOT" = "iphoneos"; + "SUPPORTED_PLATFORMS" = "iphoneos iphonesimulator"; + "TARGETED_DEVICE_FAMILY" = "1"; + "CODE_SIGN_STYLE" = "Automatic"; + "GENERATE_INFOPLIST_FILE" = "NO"; + "INFOPLIST_FILE" = "MusicBridgeRemote/Info.plist"; + "CURRENT_PROJECT_VERSION" = "8"; + "MARKETING_VERSION" = "0.7.0"; + "APPLICATION_EXTENSION_API_ONLY" = "YES"; + "SKIP_INSTALL" = "YES"; + "LD_RUNPATH_SEARCH_PATHS" = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks"; + "CODE_SIGN_ENTITLEMENTS" = "MusicBridgeRemote/Remote.entitlements"; + "SWIFT_OPTIMIZATION_LEVEL" = "-O"; + }; + }; + "AD0B31AD2D7D0B6DCF3ACF58" = { + "isa" = "XCConfigurationList"; + "buildConfigurations" = ("809F689056832D3F2A4D91B6", "5E42ACAF0DE32186ED14E82F",); + "defaultConfigurationIsVisible" = 0; + "defaultConfigurationName" = "Release"; + }; + "714DB4A269580A6F5127F5CC" = { + "isa" = "PBXNativeTarget"; + "name" = "MusicBridgeRemote"; + "productName" = "MusicBridgeRemote"; + "productReference" = "F1BB1F45195BEB71A550539C"; + "productType" = "com.apple.product-type.extensionkit-extension"; + "buildConfigurationList" = "AD0B31AD2D7D0B6DCF3ACF58"; + "buildPhases" = ("C2BA95929A255B0F1FC04A79", "D3E3B87195B5FF1DA14795C1", "9C3BF80554FD9B39BDF1988D",); + "buildRules" = (); + "dependencies" = (); + }; "0A3E27B8CA818264D75C8D81" = { "isa" = "PBXGroup"; "name" = "Products"; "sourceTree" = ""; - "children" = ("7C35A0276A762371ABAE5596", "5E4BE508157A7B2CB7B4C2F2", "CE9225C1B8E80F534681689F",); + "children" = ("7C35A0276A762371ABAE5596", "5E4BE508157A7B2CB7B4C2F2", "CE9225C1B8E80F534681689F", "F1BB1F45195BEB71A550539C",); }; "0D6E4079E36703EBD37C0072" = { "isa" = "PBXGroup"; "sourceTree" = ""; - "children" = ("026F39792BBA0E5032536CED", "E3C4B39D6D5013477C98CD58", "F932ED2EB70210651D1121CB", "E1AD768E105BF42350625991", "0A3E27B8CA818264D75C8D81",); + "children" = ("026F39792BBA0E5032536CED", "E3C4B39D6D5013477C98CD58", "F932ED2EB70210651D1121CB", "D2DF40467DEC980974060FD5", "E2D7CC5D9CE02F4A7B8243D8", "E1AD768E105BF42350625991", "0A3E27B8CA818264D75C8D81",); }; "73EBF6644258C120893E685D" = { "isa" = "XCBuildConfiguration"; @@ -635,7 +837,7 @@ "productRefGroup" = "0A3E27B8CA818264D75C8D81"; "projectDirPath" = ""; "projectRoot" = ""; - "targets" = ("F380385F14BF54F0FA81EEAE", "C2A8D97FC5416EA6103FBF9A", "BFE9A9DC8C194372006C3ACC",); + "targets" = ("F380385F14BF54F0FA81EEAE", "C2A8D97FC5416EA6103FBF9A", "BFE9A9DC8C194372006C3ACC", "714DB4A269580A6F5127F5CC",); }; }; "rootObject" = "244210E48437B6556980A702"; diff --git a/MusicBridge/App.entitlements b/MusicBridge/App.entitlements new file mode 100644 index 0000000..9a3a643 --- /dev/null +++ b/MusicBridge/App.entitlements @@ -0,0 +1,11 @@ + + + + + keychain-access-groups + + $(AppIdentifierPrefix)$(PRODUCT_BUNDLE_IDENTIFIER) + $(AppIdentifierPrefix)ru.yukinoki.musicbridge.remote-control + + + diff --git a/MusicBridge/ContentView.swift b/MusicBridge/ContentView.swift index a9a0578..a74097c 100644 --- a/MusicBridge/ContentView.swift +++ b/MusicBridge/ContentView.swift @@ -3,6 +3,7 @@ import SwiftUI struct ContentView: View { @ObservedObject var store: PlayerStore @ObservedObject var liveActivity: LiveActivityManager + @ObservedObject var systemPlayer: SystemPlayerManager @State private var showForget = false var body: some View { @@ -20,6 +21,23 @@ struct ContentView: View { } else { PlayerView(store: store, coverSize: min(280, max(120, min(viewport.size.width - 40, viewport.size.height * 0.30)))) + if #available(iOS 27.0, *) { + VStack(alignment: .leading, spacing: 10) { + Button { + if systemPlayer.running { systemPlayer.end() } + else if let state = store.state, let connection = store.connection { + Task { await systemPlayer.start(state: state, connection: connection) } + } + } label: { + 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) + if let error = systemPlayer.error { Text(error).font(.caption).foregroundStyle(.red).textSelection(.enabled) } + }.frame(maxWidth: .infinity, alignment: .leading).bridgeCard() + } + VStack(alignment: .leading, spacing: 10) { Button { if liveActivity.running { liveActivity.end() } diff --git a/MusicBridge/Info.plist b/MusicBridge/Info.plist index 8294652..712246c 100644 --- a/MusicBridge/Info.plist +++ b/MusicBridge/Info.plist @@ -72,5 +72,7 @@ + MusicBridgeSharedKeychainGroup + $(AppIdentifierPrefix)ru.yukinoki.musicbridge.remote-control diff --git a/MusicBridge/Models.swift b/MusicBridge/Models.swift index 9d40d44..909b7eb 100644 --- a/MusicBridge/Models.swift +++ b/MusicBridge/Models.swift @@ -119,7 +119,7 @@ struct Invitation: Decodable { } } -struct Capabilities: Decodable { +struct Capabilities: Codable { let play, pause, toggle, next, previous, seek: Bool } diff --git a/MusicBridge/MusicBridgeApp.swift b/MusicBridge/MusicBridgeApp.swift index fd86e65..e5ae934 100644 --- a/MusicBridge/MusicBridgeApp.swift +++ b/MusicBridge/MusicBridgeApp.swift @@ -4,6 +4,7 @@ import SwiftUI struct MusicBridgeApp: App { @StateObject private var store = PreviewSupport.makeStore() @StateObject private var liveActivity = LiveActivityManager() + @StateObject private var systemPlayer = SystemPlayerManager() @Environment(\.scenePhase) private var phase var body: some Scene { @@ -20,13 +21,15 @@ struct MusicBridgeApp: App { }.padding() } } else { - ContentView(store: store, liveActivity: liveActivity) + ContentView(store: store, liveActivity: liveActivity, systemPlayer: systemPlayer) } } .dynamicTypeSize(PreviewSupport.largeText ? DynamicTypeSize.accessibility2...DynamicTypeSize.accessibility2 : DynamicTypeSize.xSmall...DynamicTypeSize.accessibility5) .onOpenURL { if $0.absoluteString != "musicbridge://player" { store.importInvitation($0.absoluteString) } } .task { store.liveActivity = liveActivity + store.systemPlayer = systemPlayer + if !PreviewSupport.enabled { await systemPlayer.restore(connection: store.connection) } if !PreviewSupport.enabled { store.setActive(phase == .active) } } .onChange(of: phase) { _, value in if !PreviewSupport.enabled { store.setActive(value == .active) } } diff --git a/MusicBridge/PlayerStore.swift b/MusicBridge/PlayerStore.swift index a218705..5e54024 100644 --- a/MusicBridge/PlayerStore.swift +++ b/MusicBridge/PlayerStore.swift @@ -15,6 +15,7 @@ final class PlayerStore: ObservableObject { @Published var incomingInvitation: String? let discovery = DiscoveryStore() var liveActivity: LiveActivityManager? + var systemPlayer: SystemPlayerManager? private(set) var receivedAt = Date() private var client: BridgeClient? private var runner: Task? @@ -170,6 +171,7 @@ final class PlayerStore: ObservableObject { connected = true status = "Подключено" liveActivity?.receive(snapshot) + if let connection { systemPlayer?.receive(snapshot, connection: connection) } guard artworkID != snapshot.artworkId else { return } artworkID = snapshot.artworkId artworkTask?.cancel() @@ -248,6 +250,7 @@ final class PlayerStore: ObservableObject { do { try storage.delete() } catch { self.error = error.localizedDescription; return } liveActivity?.end() + systemPlayer?.end() stop() cancelPairing() needsPairing = false @@ -273,6 +276,7 @@ final class PlayerStore: ObservableObject { private func revokeAccess() { liveActivity?.end() + systemPlayer?.end() stop() needsPairing = true status = "Нужно повторное сопряжение" diff --git a/MusicBridge/SystemPlayerManager.swift b/MusicBridge/SystemPlayerManager.swift new file mode 100644 index 0000000..7944c2e --- /dev/null +++ b/MusicBridge/SystemPlayerManager.swift @@ -0,0 +1,105 @@ +import SwiftUI +import NowPlaying + +@MainActor +final class SystemPlayerManager: ObservableObject { + @Published private(set) var running = false + @Published private(set) var busy = false + @Published private(set) var error: String? + private var storage: Any? + private var id: String? + private var generation = UUID() + private var updates: Task? + private var lastUpdate = Date.distantPast + private var lastTrack = "" + private var lastPlaying = false + private let savedIDKey = "MusicBridge.systemPlayerSession" + + func start(state: MediaState, connection: Connection) async { + guard !busy, !running, state.hasSession, connection.token != nil, + UIApplication.shared.applicationState == .active else { return } + guard #available(iOS 27.0, *) else { error = "Системный пульт требует iOS 27."; return } + busy = true; error = nil + let operation = generation + let newID = UUID().uuidString + id = newID + defer { if generation == operation { busy = false } } + do { + try SystemPlayerCredentials.save(connection, id: newID) + _ = try SystemPlayerCredentials.load(newID) + let session = try await RemoteMediaSession.start(attributes: SystemPlayerAttributes(id: newID, state: state)) + guard generation == operation else { try? await session.end(); return } + storage = session + try await session.requestToBecomeSystemPrimary() + guard generation == operation else { try? await session.end(); return } + running = true + UserDefaults.standard.set(newID, forKey: savedIDKey) + } catch { + guard generation == operation else { return } + self.error = "Не удалось включить системный пульт: " + BridgeError.describe(error) + end() + } + } + + func restore(connection: Connection?) async { + guard #available(iOS 27.0, *), id == nil, !busy, + let savedID = UserDefaults.standard.string(forKey: savedIDKey) else { return } + 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() } + return + } + if let session = try await RemoteMediaSession.sessions().first(where: { $0.id == savedID }) { + storage = session; id = savedID; running = true + } else { + try SystemPlayerCredentials.delete(savedID) + UserDefaults.standard.removeObject(forKey: savedIDKey) + } + } catch { self.error = BridgeError.describe(error) } + } + + func receive(_ state: MediaState, connection: Connection) { + guard #available(iOS 27.0, *), running, let id, + let session = storage as? RemoteMediaSession else { return } + guard state.hasSession else { end(); return } + let track = state.title + "\n" + state.artist + let playing = state.playbackStatus == "Playing" + guard track != lastTrack || playing != lastPlaying || Date().timeIntervalSince(lastUpdate) >= 5 else { return } + lastUpdate = Date(); lastTrack = track; lastPlaying = playing + let value = SystemPlayerAttributes(id: id, state: state) + let operation = generation + enqueue { [weak self] in + guard let self, self.generation == operation else { return } + do { + try SystemPlayerCredentials.save(connection, id: id) + try await session.update(value) + } catch { self.error = BridgeError.describe(error) } + } + } + + func end() { + generation = UUID() + busy = false; running = false + if let id { + do { try SystemPlayerCredentials.delete(id) } + catch { self.error = BridgeError.describe(error) } + } + UserDefaults.standard.removeObject(forKey: savedIDKey) + id = nil + if #available(iOS 27.0, *), let session = storage as? RemoteMediaSession { + enqueue { [weak self] in + do { try await session.end() } + catch { self?.error = BridgeError.describe(error) } + } + } + storage = nil + } + + private func enqueue(_ action: @escaping @MainActor () async -> Void) { + let previous = updates + updates = Task { await previous?.value; await action() } + } +} diff --git a/MusicBridgeRemote/Info.plist b/MusicBridgeRemote/Info.plist new file mode 100644 index 0000000..2a4c9b6 --- /dev/null +++ b/MusicBridgeRemote/Info.plist @@ -0,0 +1,56 @@ + + + + + CFBundleDevelopmentRegion + ru + CFBundleDisplayName + MusicBridge + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + XPC! + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + EXAppExtensionAttributes + + EXExtensionPointIdentifier + com.apple.nowplaying.remote-media + + MusicBridgeSharedKeychainGroup + $(AppIdentifierPrefix)ru.yukinoki.musicbridge.remote-control + NSLocalNetworkUsageDescription + MusicBridge подключается к вашему ПК, чтобы показывать музыку и управлять воспроизведением. + NSAppTransportSecurity + + NSAllowsLocalNetworking + + NSExceptionDomains + + 10.0.0.0/8 + + NSExceptionAllowsInsecureHTTPLoads + + + 172.16.0.0/12 + + NSExceptionAllowsInsecureHTTPLoads + + + 192.168.0.0/16 + + NSExceptionAllowsInsecureHTTPLoads + + + + + + diff --git a/MusicBridgeRemote/MusicBridgeRemote.swift b/MusicBridgeRemote/MusicBridgeRemote.swift new file mode 100644 index 0000000..8868c3a --- /dev/null +++ b/MusicBridgeRemote/MusicBridgeRemote.swift @@ -0,0 +1,68 @@ +import ExtensionFoundation +import NowPlaying +import Observation +import Foundation + +@main +struct MusicBridgeRemote: RemoteMediaSessionExtension { + var configuration: RemoteMediaSessionExtensionConfiguration { + RemoteMediaSessionExtensionConfiguration(extension: self) + } + func session(_ attributes: SystemPlayerAttributes) async throws -> PCMediaSession { + _ = try SystemPlayerCredentials.load(attributes.id) + return PCMediaSession(attributes) + } +} + +@MainActor @Observable +final class PCMediaSession: RemoteMediaSessionRepresentable { + let id: String + var attributes: SystemPlayerAttributes + private let transport = SystemPlayerTransport() + init(_ attributes: SystemPlayerAttributes) { id = attributes.id; self.attributes = attributes } + + func update(_ attributes: SystemPlayerAttributes) { self.attributes = attributes } + + var content: (any MediaContentRepresentable)? { + guard attributes.hasSession else { return nil } + let artwork = attributes.artworkID.map { artworkID in + Artwork(id: artworkID) { _ in + let saved = try SystemPlayerCredentials.load(self.id) + let client = BridgeClient(saved, timeout: 5) + defer { client.close() } + return try ArtworkRepresentation(data: await client.artwork(artworkID)) + } + } + return MusicContent(id: attributes.source + "/" + attributes.title + "/" + attributes.artist, + songTitle: attributes.title.isEmpty ? "Без названия" : attributes.title, + artistName: attributes.artist, albumName: "", type: .audio, + duration: attributes.duration > 0 ? .finite(attributes.duration) : nil, artwork: artwork) + } + + var playbackSnapshot: MediaPlaybackSnapshot? { + MediaPlaybackSnapshot(state: attributes.playing ? .playing(rate: attributes.rate) : .paused, + elapsedTime: attributes.position, timestamp: attributes.timestamp) + } + + var commands: [MediaCommand] { + [ + .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), + .previous { try await self.send(.previous) }.enabled(attributes.capabilities.previous), + .seekToPosition { try await self.send(.seek, value: $0) }.enabled(attributes.capabilities.seek) + ] + } + + var devices: [MediaDevice] { + let capabilities: [MediaDevice.Capability] = attributes.volume.map { volume in + [.absoluteVolume(Float(volume)) { try await self.send(.volume, value: Double($0)) }] + } ?? [] + return [MediaDevice(id: id, name: "MusicBridge · ПК", type: .speaker, capabilities: capabilities)] + } + + 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) + } +} diff --git a/MusicBridgeRemote/Remote.entitlements b/MusicBridgeRemote/Remote.entitlements new file mode 100644 index 0000000..9a3a643 --- /dev/null +++ b/MusicBridgeRemote/Remote.entitlements @@ -0,0 +1,11 @@ + + + + + keychain-access-groups + + $(AppIdentifierPrefix)$(PRODUCT_BUNDLE_IDENTIFIER) + $(AppIdentifierPrefix)ru.yukinoki.musicbridge.remote-control + + + diff --git a/RemoteShared/SystemPlayerAttributes.swift b/RemoteShared/SystemPlayerAttributes.swift new file mode 100644 index 0000000..ed74270 --- /dev/null +++ b/RemoteShared/SystemPlayerAttributes.swift @@ -0,0 +1,29 @@ +import Foundation +import NowPlaying + +@available(iOS 27.0, *) +struct SystemPlayerAttributes: RemoteMediaSessionAttributes { + let id: String + var title: String + var artist: String + var source: String + var artworkID: String? + var playing: Bool + var rate: Double + var position: Double + var duration: Double + var timestamp: Date + var capabilities: Capabilities + var volume: Double? + var hasSession: Bool + + init(id: String, state: MediaState, now: Date = Date()) { + self.id = id + title = String(state.title.prefix(256)); artist = String(state.artist.prefix(256)) + source = String(state.source.prefix(128)); artworkID = state.artworkId + playing = state.playbackStatus == "Playing"; rate = state.playbackRate + position = max(0, state.position(elapsed: 0) - state.startSeconds) + duration = max(0, state.endSeconds - state.startSeconds) + timestamp = now; capabilities = state.capabilities; volume = state.volume; hasSession = state.hasSession + } +} diff --git a/RemoteShared/SystemPlayerCredentials.swift b/RemoteShared/SystemPlayerCredentials.swift new file mode 100644 index 0000000..6f19a0f --- /dev/null +++ b/RemoteShared/SystemPlayerCredentials.swift @@ -0,0 +1,42 @@ +import Foundation +import Security + +enum SystemPlayerCredentials { + private static func query(_ id: String) throws -> [String: Any] { + guard let group = Bundle.main.object(forInfoDictionaryKey: "MusicBridgeSharedKeychainGroup") as? String, + !group.isEmpty, !group.contains("$(") 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] + } + + static func save(_ connection: Connection, id: String) throws { + let query = try query(id) + let values: [String: Any] = [kSecValueData as String: try JSONEncoder().encode(connection), + kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly] + var status = SecItemUpdate(query as CFDictionary, values as CFDictionary) + if status == errSecItemNotFound { status = SecItemAdd(query.merging(values) { _, v in v } as CFDictionary, nil) } + guard status == errSecSuccess else { throw failure(status) } + } + + static func load(_ id: String) throws -> Connection { + var query = try query(id) + query[kSecReturnData as String] = true; query[kSecMatchLimit as String] = kSecMatchLimitOne + var item: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &item) + guard status == errSecSuccess, let data = item as? Data else { throw failure(status) } + let saved = try JSONDecoder().decode(Connection.self, from: data) + guard let token = saved.token, Connection.isFingerprint(token) else { throw BridgeError.unauthorized } + return try Connection(endpoint: saved.endpoint.absoluteString, fingerprint: saved.fingerprint, token: token) + } + + static func delete(_ id: String) throws { + let status = SecItemDelete(try query(id) as CFDictionary) + guard status == errSecSuccess || status == errSecItemNotFound else { throw failure(status) } + } + + private static func failure(_ status: OSStatus) -> BridgeError { + .message("Системный пульт не получил доступ к Keychain (\(status)). Проверьте подпись приложения и расширения; обычный плеер остаётся доступен.") + } +} diff --git a/RemoteShared/SystemPlayerTransport.swift b/RemoteShared/SystemPlayerTransport.swift new file mode 100644 index 0000000..4500144 --- /dev/null +++ b/RemoteShared/SystemPlayerTransport.swift @@ -0,0 +1,56 @@ +import Foundation + +enum SystemPlayerAction { case play, pause, next, previous, seek, volume } + +@MainActor +final class SystemPlayerTransport { + private var busy = false + + func send(_ action: SystemPlayerAction, value: Double? = nil, sessionID: String) async throws -> MediaState { + guard !busy else { throw BridgeError.message("Предыдущая команда ещё выполняется.") } + busy = true + defer { busy = false } + let saved = try SystemPlayerCredentials.load(sessionID) + let client = BridgeClient(saved, timeout: 5) + defer { client.close() } + return try await Self.execute(action, value: value, client: client) { + let current = try SystemPlayerCredentials.load(sessionID) + guard current.endpoint == saved.endpoint, current.fingerprint == saved.fingerprint, current.token == saved.token else { + throw BridgeError.unauthorized + } + } + } + + static func execute(_ action: SystemPlayerAction, value: Double? = nil, client: BridgeClient, + validate: () throws -> Void = {}) async throws -> MediaState { + let state = try await client.state() + try validate() + let command = try command(action, value: value, state: state) + do { try await client.command(command) } + 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: SystemPlayerAction, value: Double?, state: MediaState) throws -> Command { + switch action { + case .play where state.hasSession && state.capabilities.play: return Command(type: "play", value: nil) + case .pause where state.hasSession && state.capabilities.pause: return Command(type: "pause", value: nil) + case .next where state.hasSession && state.capabilities.next: return Command(type: "next", value: nil) + case .previous where state.hasSession && state.capabilities.previous: return Command(type: "previous", value: nil) + case .seek where state.hasSession && state.capabilities.seek && state.maxSeekSeconds > state.minSeekSeconds: + guard let value, value.isFinite else { throw BridgeError.message("Некорректная позиция трека.") } + return Command(type: "seek", value: min(state.maxSeekSeconds, max(state.minSeekSeconds, state.startSeconds + value))) + case .volume where state.volume != nil: + guard let value, value.isFinite else { throw BridgeError.message("Некорректная громкость.") } + return Command(type: "volume", value: min(1, max(0, value))) + default: throw BridgeError.message("Плеер на ПК не поддерживает эту команду.") + } + } +} diff --git a/tools/generate_project.py b/tools/generate_project.py index 91643e9..6135ebb 100644 --- a/tools/generate_project.py +++ b/tools/generate_project.py @@ -37,6 +37,13 @@ def source_group(folder): return add(folder, "PBXGroup", path=folder, sourceTree="", children=refs), builds app_group, app_sources = source_group("MusicBridge") +remote_shared_group, remote_shared_sources = source_group("RemoteShared") +remote_group, remote_sources = source_group("MusicBridgeRemote") +remote_sources += [add("remote-shared/" + str(index), "PBXBuildFile", fileRef=objects[build]["fileRef"]) + for index, build in enumerate(remote_shared_sources)] +for filename in ("Models.swift", "BridgeClient.swift"): + remote_sources.append(add("remote-core/" + filename, "PBXBuildFile", fileRef=uid("MusicBridge/" + filename))) +app_sources += remote_shared_sources shared_group, shared_sources = source_group("Shared") widget_group, widget_sources = source_group("MusicBridgeWidgets") app_sources += shared_sources @@ -54,16 +61,18 @@ objects[test_group]["children"].append(tls_fixture) tls_fixture_build = add("tls-fixture-build", "PBXBuildFile", fileRef=tls_fixture) products = [] targets = [] -for name, sources, is_test in [("MusicBridge", app_sources, False), ("MusicBridgeTests", test_sources, True), ("MusicBridgeWidgets", widget_sources, False)]: +for name, sources, is_test in [("MusicBridge", app_sources, False), ("MusicBridgeTests", test_sources, True), ("MusicBridgeWidgets", widget_sources, False), ("MusicBridgeRemote", remote_sources, False)]: is_widget = name == "MusicBridgeWidgets" - product = add(name + "product", "PBXFileReference", explicitFileType="wrapper.app-extension" if is_widget else "wrapper.cfbundle" if is_test else "wrapper.application", - includeInIndex=0, path=name + (".appex" if is_widget else ".xctest" if is_test else ".app"), sourceTree="BUILT_PRODUCTS_DIR") + is_remote = name == "MusicBridgeRemote" + is_extension = is_widget or is_remote + product = add(name + "product", "PBXFileReference", explicitFileType="wrapper.extensionkit-extension" if is_remote else "wrapper.app-extension" if is_extension else "wrapper.cfbundle" if is_test else "wrapper.application", + includeInIndex=0, path=name + (".appex" if is_extension else ".xctest" if is_test else ".app"), sourceTree="BUILT_PRODUCTS_DIR") products.append(product) phases = [add(name + "sources", "PBXSourcesBuildPhase", buildActionMask=2147483647, files=sources, runOnlyForDeploymentPostprocessing=0), add(name + "frameworks", "PBXFrameworksBuildPhase", buildActionMask=2147483647, files=[], runOnlyForDeploymentPostprocessing=0), - add(name + "resources", "PBXResourcesBuildPhase", buildActionMask=2147483647, files=[] if is_widget else [fixture_build, tls_fixture_build] if is_test else [assets_build], runOnlyForDeploymentPostprocessing=0)] - settings = dict(PRODUCT_NAME="$(TARGET_NAME)", PRODUCT_BUNDLE_IDENTIFIER="ru.yukinoki.musicbridge" + (".widgets" if is_widget else ".tests" if is_test else ""), - SWIFT_VERSION="5.0", IPHONEOS_DEPLOYMENT_TARGET="17.0", SDKROOT="iphoneos", + add(name + "resources", "PBXResourcesBuildPhase", buildActionMask=2147483647, files=[] if is_extension else [fixture_build, tls_fixture_build] if is_test else [assets_build], runOnlyForDeploymentPostprocessing=0)] + settings = dict(PRODUCT_NAME="$(TARGET_NAME)", PRODUCT_BUNDLE_IDENTIFIER="ru.yukinoki.musicbridge" + (".remote" if is_remote else ".widgets" if is_widget else ".tests" if is_test else ""), + SWIFT_VERSION="5.0", IPHONEOS_DEPLOYMENT_TARGET="27.0" if is_remote else "17.0", SDKROOT="iphoneos", SUPPORTED_PLATFORMS="iphoneos iphonesimulator", TARGETED_DEVICE_FAMILY="1", CODE_SIGN_STYLE="Automatic", GENERATE_INFOPLIST_FILE="YES" if is_test else "NO") deps = [] @@ -71,24 +80,32 @@ for name, sources, is_test in [("MusicBridge", app_sources, False), ("MusicBridg settings.update(TEST_HOST="$(BUILT_PRODUCTS_DIR)/MusicBridge.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/MusicBridge", BUNDLE_LOADER="$(TEST_HOST)") 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)] - elif is_widget: - settings.update(INFOPLIST_FILE="MusicBridgeWidgets/Info.plist", CURRENT_PROJECT_VERSION="7", MARKETING_VERSION="0.6.0", + elif is_extension: + settings.update(INFOPLIST_FILE=name + "/Info.plist", CURRENT_PROJECT_VERSION="8", MARKETING_VERSION="0.7.0", APPLICATION_EXTENSION_API_ONLY="YES", SKIP_INSTALL="YES", LD_RUNPATH_SEARCH_PATHS="$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks") + if is_remote: + settings.update(CODE_SIGN_ENTITLEMENTS="MusicBridgeRemote/Remote.entitlements") else: - settings.update(INFOPLIST_FILE="MusicBridge/Info.plist", CURRENT_PROJECT_VERSION="7", MARKETING_VERSION="0.6.0", ASSETCATALOG_COMPILER_APPICON_NAME="AppIcon") + settings.update(INFOPLIST_FILE="MusicBridge/Info.plist", CURRENT_PROJECT_VERSION="8", MARKETING_VERSION="0.7.0", ASSETCATALOG_COMPILER_APPICON_NAME="AppIcon", CODE_SIGN_ENTITLEMENTS="MusicBridge/App.entitlements") embed = add("embed-widget", "PBXBuildFile", fileRef=uid("MusicBridgeWidgetsproduct"), settings=dict(ATTRIBUTES=["RemoveHeadersOnCopy"])) + embed_remote = add("embed-remote", "PBXBuildFile", fileRef=uid("MusicBridgeRemoteproduct"), settings=dict(ATTRIBUTES=["RemoveHeadersOnCopy"])) phases.append(add("embed-extensions", "PBXCopyFilesBuildPhase", buildActionMask=2147483647, dstPath="", dstSubfolderSpec=13, files=[embed], name="Embed App Extensions", runOnlyForDeploymentPostprocessing=0)) + phases.append(add("embed-remote-extensions", "PBXCopyFilesBuildPhase", buildActionMask=2147483647, + dstPath="$(EXTENSIONS_FOLDER_PATH)", dstSubfolderSpec=16, files=[embed_remote], name="Embed ExtensionKit Extensions", runOnlyForDeploymentPostprocessing=0)) proxy = add("widget-proxy", "PBXContainerItemProxy", containerPortal=uid("project"), proxyType=1, remoteGlobalIDString=uid("MusicBridgeWidgetstarget"), remoteInfo="MusicBridgeWidgets") deps = [add("widget-dependency", "PBXTargetDependency", target=uid("MusicBridgeWidgetstarget"), targetProxy=proxy)] + remote_proxy = add("remote-proxy", "PBXContainerItemProxy", containerPortal=uid("project"), proxyType=1, + remoteGlobalIDString=uid("MusicBridgeRemotetarget"), remoteInfo="MusicBridgeRemote") + deps.append(add("remote-dependency", "PBXTargetDependency", target=uid("MusicBridgeRemotetarget"), targetProxy=remote_proxy)) targets.append(add(name + "target", "PBXNativeTarget", name=name, productName=name, productReference=product, - productType="com.apple.product-type.app-extension" if is_widget else "com.apple.product-type.bundle.unit-test" if is_test else "com.apple.product-type.application", + productType="com.apple.product-type.extensionkit-extension" if is_remote else "com.apple.product-type.app-extension" if is_widget else "com.apple.product-type.bundle.unit-test" if is_test else "com.apple.product-type.application", buildConfigurationList=configurations(name, settings), buildPhases=phases, buildRules=[], dependencies=deps)) product_group = add("products", "PBXGroup", name="Products", sourceTree="", children=products) -main_group = add("main", "PBXGroup", sourceTree="", children=[app_group, shared_group, widget_group, test_group, product_group]) +main_group = add("main", "PBXGroup", sourceTree="", children=[app_group, shared_group, widget_group, remote_shared_group, remote_group, test_group, product_group]) project = add("project", "PBXProject", attributes=dict(LastUpgradeCheck="1600", BuildIndependentTargetsInParallel="YES"), buildConfigurationList=configurations("project", dict(CLANG_ENABLE_MODULES="YES", CLANG_ENABLE_OBJC_ARC="YES")), compatibilityVersion="Xcode 14.0", developmentRegion="ru", knownRegions=["ru", "en", "Base"], @@ -144,4 +161,16 @@ widget_info = dict(CFBundleDevelopmentRegion="ru", CFBundleDisplayName="MusicBri CFBundleShortVersionString="$(MARKETING_VERSION)", CFBundleVersion="$(CURRENT_PROJECT_VERSION)", 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 +(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, + NSLocalNetworkUsageDescription=info["NSLocalNetworkUsageDescription"], + NSAppTransportSecurity=info["NSAppTransportSecurity"]) +(ROOT / "MusicBridgeRemote" / "Info.plist").write_bytes(plistlib.dumps(remote_info, sort_keys=False)) +for folder, filename in (("MusicBridge", "App.entitlements"), ("MusicBridgeRemote", "Remote.entitlements")): + groups = ["$(AppIdentifierPrefix)$(PRODUCT_BUNDLE_IDENTIFIER)", shared_group_name] + (ROOT / folder / filename).write_bytes(plistlib.dumps({"keychain-access-groups": groups})) print("Generated Xcode project, shared scheme and Info.plist.") diff --git a/tools/validate_project.py b/tools/validate_project.py index 011998b..8106790 100644 --- a/tools/validate_project.py +++ b/tools/validate_project.py @@ -11,12 +11,13 @@ import xml.etree.ElementTree as ET root = pathlib.Path(__file__).resolve().parents[1] generated = [root / "MusicBridge.xcodeproj/project.pbxproj", root / "MusicBridge.xcodeproj/xcshareddata/xcschemes/MusicBridge.xcscheme", - root / "MusicBridge/Info.plist", root / "MusicBridgeWidgets/Info.plist"] + root / "MusicBridge/Info.plist", root / "MusicBridgeWidgets/Info.plist", + root / "MusicBridgeRemote/Info.plist", root / "MusicBridge/App.entitlements", root / "MusicBridgeRemote/Remote.entitlements"] before = [p.read_bytes() for p in generated] subprocess.run([sys.executable, str(root / "tools/generate_project.py")], check=True) assert before == [p.read_bytes() for p in generated], "Generated project is stale; review and commit regeneration." project = generated[0].read_text(encoding="utf-8") -for folder in ("MusicBridge", "MusicBridgeTests", "Shared", "MusicBridgeWidgets"): +for folder in ("MusicBridge", "MusicBridgeTests", "Shared", "MusicBridgeWidgets", "RemoteShared", "MusicBridgeRemote"): for source in (root / folder).glob("*.swift"): key = hashlib.sha256(source.relative_to(root).as_posix().encode()).hexdigest()[:24].upper() assert key in project, f"Missing source: {source.name}"