From ddd44e8edbd52890a6ed8535f6f301536939543d Mon Sep 17 00:00:00 2001 From: ares Date: Sun, 13 Sep 2026 11:27:58 +0300 Subject: [PATCH] Add local Live Activity and Dynamic Island track display --- .github/workflows/ios.yml | 7 + MusicBridge.xcodeproj/project.pbxproj | 212 ++++++++++++++++++-- MusicBridge/ContentView.swift | 13 ++ MusicBridge/Info.plist | 2 + MusicBridge/LiveActivityManager.swift | 99 +++++++++ MusicBridge/MusicBridgeApp.swift | 24 ++- MusicBridge/PlayerStore.swift | 6 + MusicBridge/PreviewSupport.swift | 7 + MusicBridgeTests/LiveActivityTests.swift | 45 +++++ MusicBridgeWidgets/Info.plist | 29 +++ MusicBridgeWidgets/MusicBridgeWidgets.swift | 46 +++++ Shared/MusicActivityAttributes.swift | 26 +++ Shared/MusicActivityViews.swift | 46 +++++ tools/generate_project.py | 40 +++- tools/validate_project.py | 6 +- tools/verify_ipa.py | 19 ++ 16 files changed, 600 insertions(+), 27 deletions(-) create mode 100644 MusicBridge/LiveActivityManager.swift create mode 100644 MusicBridgeTests/LiveActivityTests.swift create mode 100644 MusicBridgeWidgets/Info.plist create mode 100644 MusicBridgeWidgets/MusicBridgeWidgets.swift create mode 100644 Shared/MusicActivityAttributes.swift create mode 100644 Shared/MusicActivityViews.swift diff --git a/.github/workflows/ios.yml b/.github/workflows/ios.yml index 193f9ef..86cd028 100644 --- a/.github/workflows/ios.yml +++ b/.github/workflows/ios.yml @@ -86,6 +86,13 @@ jobs: xcrun simctl launch "$SIMULATOR_ID" ru.yukinoki.musicbridge --preview-player --preview-large-text sleep 2 xcrun simctl io "$SIMULATOR_ID" screenshot build/player-large-text-screen.png + xcrun simctl terminate "$SIMULATOR_ID" ru.yukinoki.musicbridge + xcrun simctl launch "$SIMULATOR_ID" ru.yukinoki.musicbridge --preview-player --preview-live-card + sleep 2 + xcrun simctl io "$SIMULATOR_ID" screenshot build/live-card-dark-screen.png + xcrun simctl ui "$SIMULATOR_ID" appearance light + sleep 1 + xcrun simctl io "$SIMULATOR_ID" screenshot build/live-card-light-screen.png RUNTIME=$(python3 -c 'import json,os; d=json.load(open("/tmp/musicbridge-simulators.json")); print(next(k for k,v in d["devices"].items() if any(x["udid"]==os.environ["SIMULATOR_ID"] for x in v)))') COMPACT_ID=$(xcrun simctl create MusicBridge-Compact com.apple.CoreSimulator.SimDeviceType.iPhone-SE-3rd-generation "$RUNTIME") xcrun simctl bootstatus "$COMPACT_ID" -b diff --git a/MusicBridge.xcodeproj/project.pbxproj b/MusicBridge.xcodeproj/project.pbxproj index 081d83f..40605f5 100644 --- a/MusicBridge.xcodeproj/project.pbxproj +++ b/MusicBridge.xcodeproj/project.pbxproj @@ -55,6 +55,16 @@ "isa" = "PBXBuildFile"; "fileRef" = "76FB6BFFC478599567424755"; }; + "EE2357BD5CA3755AFEB3171A" = { + "isa" = "PBXFileReference"; + "lastKnownFileType" = "sourcecode.swift"; + "path" = "LiveActivityManager.swift"; + "sourceTree" = ""; + }; + "181CACDC331104A1E16CFC20" = { + "isa" = "PBXBuildFile"; + "fileRef" = "EE2357BD5CA3755AFEB3171A"; + }; "124CF3F5AC075D1D6042DF64" = { "isa" = "PBXFileReference"; "lastKnownFileType" = "sourcecode.swift"; @@ -109,7 +119,57 @@ "isa" = "PBXGroup"; "path" = "MusicBridge"; "sourceTree" = ""; - "children" = ("065A9D559DC80AAF876DA757", "763FA6A137E5E94274AC56A3", "971C0AE92F167DE7AB145086", "5F6F408829AB46D3B85FB911", "76FB6BFFC478599567424755", "124CF3F5AC075D1D6042DF64", "ADF0CD56882201BD3AC1581B", "9C432A0DA0BEC173839EBF86", "6B0F2027221CDD63E8FD4C59", "7148B52E915554F67E875C26", "8BF729FFE074CAEE622C0292",); + "children" = ("065A9D559DC80AAF876DA757", "763FA6A137E5E94274AC56A3", "971C0AE92F167DE7AB145086", "5F6F408829AB46D3B85FB911", "76FB6BFFC478599567424755", "EE2357BD5CA3755AFEB3171A", "124CF3F5AC075D1D6042DF64", "ADF0CD56882201BD3AC1581B", "9C432A0DA0BEC173839EBF86", "6B0F2027221CDD63E8FD4C59", "7148B52E915554F67E875C26", "8BF729FFE074CAEE622C0292",); + }; + "7C5E653CCD928B143AB68F09" = { + "isa" = "PBXFileReference"; + "lastKnownFileType" = "sourcecode.swift"; + "path" = "MusicActivityAttributes.swift"; + "sourceTree" = ""; + }; + "C7506B09240305F44C61EB60" = { + "isa" = "PBXBuildFile"; + "fileRef" = "7C5E653CCD928B143AB68F09"; + }; + "7C0FE298B442263CA281ABB7" = { + "isa" = "PBXFileReference"; + "lastKnownFileType" = "sourcecode.swift"; + "path" = "MusicActivityViews.swift"; + "sourceTree" = ""; + }; + "4FD4638045EDA8D7CCFD00AF" = { + "isa" = "PBXBuildFile"; + "fileRef" = "7C0FE298B442263CA281ABB7"; + }; + "E3C4B39D6D5013477C98CD58" = { + "isa" = "PBXGroup"; + "path" = "Shared"; + "sourceTree" = ""; + "children" = ("7C5E653CCD928B143AB68F09", "7C0FE298B442263CA281ABB7",); + }; + "ADFE24674E0FB119E649EC54" = { + "isa" = "PBXFileReference"; + "lastKnownFileType" = "sourcecode.swift"; + "path" = "MusicBridgeWidgets.swift"; + "sourceTree" = ""; + }; + "F942C4AC2D1ABF86575E779A" = { + "isa" = "PBXBuildFile"; + "fileRef" = "ADFE24674E0FB119E649EC54"; + }; + "F932ED2EB70210651D1121CB" = { + "isa" = "PBXGroup"; + "path" = "MusicBridgeWidgets"; + "sourceTree" = ""; + "children" = ("ADFE24674E0FB119E649EC54",); + }; + "D739DF07A523D6FA1D29EECD" = { + "isa" = "PBXBuildFile"; + "fileRef" = "7C5E653CCD928B143AB68F09"; + }; + "E41BBFD90B0D341CC92C100E" = { + "isa" = "PBXBuildFile"; + "fileRef" = "7C0FE298B442263CA281ABB7"; }; "8BF729FFE074CAEE622C0292" = { "isa" = "PBXFileReference"; @@ -131,6 +191,16 @@ "isa" = "PBXBuildFile"; "fileRef" = "C9F705D432CA45B6056BC572"; }; + "9819FF7CC21CAAD1BDB8D1B8" = { + "isa" = "PBXFileReference"; + "lastKnownFileType" = "sourcecode.swift"; + "path" = "LiveActivityTests.swift"; + "sourceTree" = ""; + }; + "BD0B3FAC7F1259D249134283" = { + "isa" = "PBXBuildFile"; + "fileRef" = "9819FF7CC21CAAD1BDB8D1B8"; + }; "ACD0393DCDDDF044F2966340" = { "isa" = "PBXFileReference"; "lastKnownFileType" = "sourcecode.swift"; @@ -165,7 +235,7 @@ "isa" = "PBXGroup"; "path" = "MusicBridgeTests"; "sourceTree" = ""; - "children" = ("C9F705D432CA45B6056BC572", "ACD0393DCDDDF044F2966340", "464F6C65BC2FAB6BCCC77F46", "8BED860F1BD59C500D215E8D", "F16D05EC6B29248D2C61ADB1", "4731785CAB9BB914B8834AE6",); + "children" = ("C9F705D432CA45B6056BC572", "9819FF7CC21CAAD1BDB8D1B8", "ACD0393DCDDDF044F2966340", "464F6C65BC2FAB6BCCC77F46", "8BED860F1BD59C500D215E8D", "F16D05EC6B29248D2C61ADB1", "4731785CAB9BB914B8834AE6",); }; "F16D05EC6B29248D2C61ADB1" = { "isa" = "PBXFileReference"; @@ -197,7 +267,7 @@ "72B74C8A2143F64FD53D71E5" = { "isa" = "PBXSourcesBuildPhase"; "buildActionMask" = 2147483647; - "files" = ("82F1C33CE4F8B8F641FF0461", "40D2920E2C996D62B7190BA4", "16EA630DE1FE9F0A16580C9D", "842E87CD23DCB5BBCA4340F6", "DA06B3B12E52FB69A6A34887", "DEDCCD95A140162B6D654871", "48E2FDCA11A6694953C86E6D", "47693934E8A974162CF08A38", "287044ADBD143F929035BC61", "BE808A0D13A16FE67D32B7FB",); + "files" = ("82F1C33CE4F8B8F641FF0461", "40D2920E2C996D62B7190BA4", "16EA630DE1FE9F0A16580C9D", "842E87CD23DCB5BBCA4340F6", "DA06B3B12E52FB69A6A34887", "181CACDC331104A1E16CFC20", "DEDCCD95A140162B6D654871", "48E2FDCA11A6694953C86E6D", "47693934E8A974162CF08A38", "287044ADBD143F929035BC61", "BE808A0D13A16FE67D32B7FB", "C7506B09240305F44C61EB60", "4FD4638045EDA8D7CCFD00AF",); "runOnlyForDeploymentPostprocessing" = 0; }; "734C4650C7A5604092763C32" = { @@ -212,6 +282,34 @@ "files" = ("5522B3B3B2EACC1C8F9BEAAC",); "runOnlyForDeploymentPostprocessing" = 0; }; + "310BC1A83962BC738E0B4980" = { + "isa" = "PBXBuildFile"; + "fileRef" = "CE9225C1B8E80F534681689F"; + "settings" = { + "ATTRIBUTES" = ("RemoveHeadersOnCopy",); + }; + }; + "AFC32963E7959E7A34264826" = { + "isa" = "PBXCopyFilesBuildPhase"; + "buildActionMask" = 2147483647; + "dstPath" = ""; + "dstSubfolderSpec" = 13; + "files" = ("310BC1A83962BC738E0B4980",); + "name" = "Embed App Extensions"; + "runOnlyForDeploymentPostprocessing" = 0; + }; + "01E77404E10F254C43E64C55" = { + "isa" = "PBXContainerItemProxy"; + "containerPortal" = "244210E48437B6556980A702"; + "proxyType" = 1; + "remoteGlobalIDString" = "BFE9A9DC8C194372006C3ACC"; + "remoteInfo" = "MusicBridgeWidgets"; + }; + "A4C2D386AB0259F33DCDC427" = { + "isa" = "PBXTargetDependency"; + "target" = "BFE9A9DC8C194372006C3ACC"; + "targetProxy" = "01E77404E10F254C43E64C55"; + }; "E3322BA79DBD9A84650B2CAE" = { "isa" = "XCBuildConfiguration"; "name" = "Debug"; @@ -226,8 +324,8 @@ "CODE_SIGN_STYLE" = "Automatic"; "GENERATE_INFOPLIST_FILE" = "NO"; "INFOPLIST_FILE" = "MusicBridge/Info.plist"; - "CURRENT_PROJECT_VERSION" = "5"; - "MARKETING_VERSION" = "0.4.0"; + "CURRENT_PROJECT_VERSION" = "6"; + "MARKETING_VERSION" = "0.5.0"; "ASSETCATALOG_COMPILER_APPICON_NAME" = "AppIcon"; "SWIFT_OPTIMIZATION_LEVEL" = "-Onone"; "ENABLE_TESTABILITY" = "YES"; @@ -248,8 +346,8 @@ "CODE_SIGN_STYLE" = "Automatic"; "GENERATE_INFOPLIST_FILE" = "NO"; "INFOPLIST_FILE" = "MusicBridge/Info.plist"; - "CURRENT_PROJECT_VERSION" = "5"; - "MARKETING_VERSION" = "0.4.0"; + "CURRENT_PROJECT_VERSION" = "6"; + "MARKETING_VERSION" = "0.5.0"; "ASSETCATALOG_COMPILER_APPICON_NAME" = "AppIcon"; "SWIFT_OPTIMIZATION_LEVEL" = "-O"; }; @@ -267,9 +365,9 @@ "productReference" = "7C35A0276A762371ABAE5596"; "productType" = "com.apple.product-type.application"; "buildConfigurationList" = "C6336ED5908EABEBF60AE675"; - "buildPhases" = ("72B74C8A2143F64FD53D71E5", "734C4650C7A5604092763C32", "69816CB820E8173702C54C37",); + "buildPhases" = ("72B74C8A2143F64FD53D71E5", "734C4650C7A5604092763C32", "69816CB820E8173702C54C37", "AFC32963E7959E7A34264826",); "buildRules" = (); - "dependencies" = (); + "dependencies" = ("A4C2D386AB0259F33DCDC427",); }; "5E4BE508157A7B2CB7B4C2F2" = { "isa" = "PBXFileReference"; @@ -281,7 +379,7 @@ "AB453A3F0E1490D48374A69A" = { "isa" = "PBXSourcesBuildPhase"; "buildActionMask" = 2147483647; - "files" = ("1DD7BB0437E9F53586831F22", "17E248E58CB43C680B48DD51", "EFDB280E0B23F7E2A246BC89", "DC1A5B0B94F4C98F1CA36676",); + "files" = ("1DD7BB0437E9F53586831F22", "BD0B3FAC7F1259D249134283", "17E248E58CB43C680B48DD51", "EFDB280E0B23F7E2A246BC89", "DC1A5B0B94F4C98F1CA36676",); "runOnlyForDeploymentPostprocessing" = 0; }; "9AA2A4C344B5C474AF5D336C" = { @@ -363,16 +461,104 @@ "buildRules" = (); "dependencies" = ("F9A94CEF8CF7D85CE881E703",); }; + "CE9225C1B8E80F534681689F" = { + "isa" = "PBXFileReference"; + "explicitFileType" = "wrapper.app-extension"; + "includeInIndex" = 0; + "path" = "MusicBridgeWidgets.appex"; + "sourceTree" = "BUILT_PRODUCTS_DIR"; + }; + "FF6BDEA70DDE9A3C7EEA9DCF" = { + "isa" = "PBXSourcesBuildPhase"; + "buildActionMask" = 2147483647; + "files" = ("F942C4AC2D1ABF86575E779A", "D739DF07A523D6FA1D29EECD", "E41BBFD90B0D341CC92C100E",); + "runOnlyForDeploymentPostprocessing" = 0; + }; + "49C806A88B3E70B1004AB10D" = { + "isa" = "PBXFrameworksBuildPhase"; + "buildActionMask" = 2147483647; + "files" = (); + "runOnlyForDeploymentPostprocessing" = 0; + }; + "64D7ED5162C7963DE736D0DF" = { + "isa" = "PBXResourcesBuildPhase"; + "buildActionMask" = 2147483647; + "files" = (); + "runOnlyForDeploymentPostprocessing" = 0; + }; + "D30B018A8B4A6A434860DC58" = { + "isa" = "XCBuildConfiguration"; + "name" = "Debug"; + "buildSettings" = { + "PRODUCT_NAME" = "$(TARGET_NAME)"; + "PRODUCT_BUNDLE_IDENTIFIER" = "ru.yukinoki.musicbridge.widgets"; + "SWIFT_VERSION" = "5.0"; + "IPHONEOS_DEPLOYMENT_TARGET" = "17.0"; + "SDKROOT" = "iphoneos"; + "SUPPORTED_PLATFORMS" = "iphoneos iphonesimulator"; + "TARGETED_DEVICE_FAMILY" = "1"; + "CODE_SIGN_STYLE" = "Automatic"; + "GENERATE_INFOPLIST_FILE" = "NO"; + "INFOPLIST_FILE" = "MusicBridgeWidgets/Info.plist"; + "CURRENT_PROJECT_VERSION" = "6"; + "MARKETING_VERSION" = "0.5.0"; + "APPLICATION_EXTENSION_API_ONLY" = "YES"; + "SKIP_INSTALL" = "YES"; + "LD_RUNPATH_SEARCH_PATHS" = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks"; + "SWIFT_OPTIMIZATION_LEVEL" = "-Onone"; + "ENABLE_TESTABILITY" = "YES"; + "SWIFT_ACTIVE_COMPILATION_CONDITIONS" = "DEBUG"; + }; + }; + "6A281BFCCFF96276806F5934" = { + "isa" = "XCBuildConfiguration"; + "name" = "Release"; + "buildSettings" = { + "PRODUCT_NAME" = "$(TARGET_NAME)"; + "PRODUCT_BUNDLE_IDENTIFIER" = "ru.yukinoki.musicbridge.widgets"; + "SWIFT_VERSION" = "5.0"; + "IPHONEOS_DEPLOYMENT_TARGET" = "17.0"; + "SDKROOT" = "iphoneos"; + "SUPPORTED_PLATFORMS" = "iphoneos iphonesimulator"; + "TARGETED_DEVICE_FAMILY" = "1"; + "CODE_SIGN_STYLE" = "Automatic"; + "GENERATE_INFOPLIST_FILE" = "NO"; + "INFOPLIST_FILE" = "MusicBridgeWidgets/Info.plist"; + "CURRENT_PROJECT_VERSION" = "6"; + "MARKETING_VERSION" = "0.5.0"; + "APPLICATION_EXTENSION_API_ONLY" = "YES"; + "SKIP_INSTALL" = "YES"; + "LD_RUNPATH_SEARCH_PATHS" = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks"; + "SWIFT_OPTIMIZATION_LEVEL" = "-O"; + }; + }; + "5DCAC255CDC0CA72231320F4" = { + "isa" = "XCConfigurationList"; + "buildConfigurations" = ("D30B018A8B4A6A434860DC58", "6A281BFCCFF96276806F5934",); + "defaultConfigurationIsVisible" = 0; + "defaultConfigurationName" = "Release"; + }; + "BFE9A9DC8C194372006C3ACC" = { + "isa" = "PBXNativeTarget"; + "name" = "MusicBridgeWidgets"; + "productName" = "MusicBridgeWidgets"; + "productReference" = "CE9225C1B8E80F534681689F"; + "productType" = "com.apple.product-type.app-extension"; + "buildConfigurationList" = "5DCAC255CDC0CA72231320F4"; + "buildPhases" = ("FF6BDEA70DDE9A3C7EEA9DCF", "49C806A88B3E70B1004AB10D", "64D7ED5162C7963DE736D0DF",); + "buildRules" = (); + "dependencies" = (); + }; "0A3E27B8CA818264D75C8D81" = { "isa" = "PBXGroup"; "name" = "Products"; "sourceTree" = ""; - "children" = ("7C35A0276A762371ABAE5596", "5E4BE508157A7B2CB7B4C2F2",); + "children" = ("7C35A0276A762371ABAE5596", "5E4BE508157A7B2CB7B4C2F2", "CE9225C1B8E80F534681689F",); }; "0D6E4079E36703EBD37C0072" = { "isa" = "PBXGroup"; "sourceTree" = ""; - "children" = ("026F39792BBA0E5032536CED", "E1AD768E105BF42350625991", "0A3E27B8CA818264D75C8D81",); + "children" = ("026F39792BBA0E5032536CED", "E3C4B39D6D5013477C98CD58", "F932ED2EB70210651D1121CB", "E1AD768E105BF42350625991", "0A3E27B8CA818264D75C8D81",); }; "73EBF6644258C120893E685D" = { "isa" = "XCBuildConfiguration"; @@ -414,7 +600,7 @@ "productRefGroup" = "0A3E27B8CA818264D75C8D81"; "projectDirPath" = ""; "projectRoot" = ""; - "targets" = ("F380385F14BF54F0FA81EEAE", "C2A8D97FC5416EA6103FBF9A",); + "targets" = ("F380385F14BF54F0FA81EEAE", "C2A8D97FC5416EA6103FBF9A", "BFE9A9DC8C194372006C3ACC",); }; }; "rootObject" = "244210E48437B6556980A702"; diff --git a/MusicBridge/ContentView.swift b/MusicBridge/ContentView.swift index 4a8157e..f3aecb1 100644 --- a/MusicBridge/ContentView.swift +++ b/MusicBridge/ContentView.swift @@ -2,6 +2,7 @@ import SwiftUI struct ContentView: View { @ObservedObject var store: PlayerStore + @ObservedObject var liveActivity: LiveActivityManager @State private var showForget = false var body: some View { @@ -19,6 +20,18 @@ struct ContentView: View { } else { PlayerView(store: store, coverSize: min(280, max(120, min(viewport.size.width - 40, viewport.size.height * 0.30)))) + VStack(alignment: .leading, spacing: 10) { + Button { + if liveActivity.running { liveActivity.end() } + else if let state = store.state { liveActivity.start(state) } + } label: { + Label(liveActivity.running ? "Убрать с экрана блокировки" : "Показать на экране блокировки", systemImage: "waveform.rectangle") + }.disabled(!liveActivity.running && (!store.connected || store.state?.hasSession != true)) + Text("Live Activity и Dynamic Island показывают последний трек. В фоне данные могут устареть; нажмите карточку, чтобы открыть плеер.") + .font(.caption).foregroundStyle(.secondary) + if let error = liveActivity.error { Text(error).font(.caption).foregroundStyle(.red) } + }.frame(maxWidth: .infinity, alignment: .leading).bridgeCard() + if !store.connected && !store.needsPairing { Button("Подключиться снова") { store.resume() } } diff --git a/MusicBridge/Info.plist b/MusicBridge/Info.plist index 8a6a496..8294652 100644 --- a/MusicBridge/Info.plist +++ b/MusicBridge/Info.plist @@ -24,6 +24,8 @@ UILaunchScreen + NSSupportsLiveActivities + UISupportedInterfaceOrientations UIInterfaceOrientationPortrait diff --git a/MusicBridge/LiveActivityManager.swift b/MusicBridge/LiveActivityManager.swift new file mode 100644 index 0000000..1a4d178 --- /dev/null +++ b/MusicBridge/LiveActivityManager.swift @@ -0,0 +1,99 @@ +import ActivityKit +import SwiftUI + +@MainActor +final class LiveActivityManager: ObservableObject { + @Published private(set) var running = false + @Published private(set) var error: String? + private var activity: Activity? + private var updates: Task? + private var observer: Task? + private var last: MusicActivityAttributes.ContentState? + + init() { + let existing = Activity.activities + .filter { $0.activityState == .active || $0.activityState == .stale } + if let first = existing.first { + activity = first + last = first.content.state + running = true + observe(first) + } + for extra in existing.dropFirst() { + Task { await extra.end(nil, dismissalPolicy: .immediate) } + } + } + + static func content(_ state: MediaState, now: Date = Date()) -> MusicActivityAttributes.ContentState { + .init(title: MusicActivityAttributes.ContentState.bounded(state.title.isEmpty ? "Без названия" : state.title), + artist: MusicActivityAttributes.ContentState.bounded(state.artist), + playing: state.playbackStatus == "Playing", + position: max(0, state.position(elapsed: 0) - state.startSeconds), + duration: max(0, state.endSeconds - state.startSeconds), updatedAt: now, connected: true) + } + + func start(_ state: MediaState) { + guard !running, state.hasSession, UIApplication.shared.applicationState == .active else { return } + guard ActivityAuthorizationInfo().areActivitiesEnabled else { + error = "Разрешите Live Activities в настройках MusicBridge на iPhone." + return + } + do { + let value = Self.content(state) + let new = try Activity.request(attributes: MusicActivityAttributes(), + content: ActivityContent(state: value, staleDate: Date().addingTimeInterval(15)), + pushType: nil) + activity = new + last = value + running = true + error = nil + observe(new) + } catch { self.error = "Не удалось включить Live Activity: " + error.localizedDescription } + } + + func receive(_ state: MediaState) { + guard state.hasSession else { end(); return } + guard let activity, running else { return } + let value = Self.content(state) + // Coalesce ordinary position ticks, but publish track/playback changes immediately. + if let last, last.connected, last.title == value.title, last.artist == value.artist, + last.playing == value.playing, last.duration == value.duration, + abs(last.position - value.position) < 5, value.updatedAt.timeIntervalSince(last.updatedAt) < 5 { return } + last = value + enqueue { await activity.update(ActivityContent(state: value, staleDate: value.updatedAt.addingTimeInterval(15))) } + } + + func unavailable() { + guard let activity, var value = last, value.connected else { return } + value.connected = false + last = value + enqueue { await activity.update(ActivityContent(state: value, staleDate: Date())) } + } + + func end() { + guard let activity else { return } + self.activity = nil + running = false + last = nil + observer?.cancel() + enqueue { await activity.end(nil, dismissalPolicy: .immediate) } + } + + private func enqueue(_ operation: @escaping @MainActor () async -> Void) { + let previous = updates + updates = Task { await previous?.value; await operation() } + } + + private func observe(_ activity: Activity) { + observer?.cancel() + observer = Task { [weak self] in + for await state in activity.activityStateUpdates { + guard !Task.isCancelled, let self, self.activity?.id == activity.id else { return } + if state == .dismissed || state == .ended { + self.activity = nil; self.running = false; self.last = nil + return + } + } + } + } +} diff --git a/MusicBridge/MusicBridgeApp.swift b/MusicBridge/MusicBridgeApp.swift index 75265cf..fd86e65 100644 --- a/MusicBridge/MusicBridgeApp.swift +++ b/MusicBridge/MusicBridgeApp.swift @@ -3,14 +3,32 @@ import SwiftUI @main struct MusicBridgeApp: App { @StateObject private var store = PreviewSupport.makeStore() + @StateObject private var liveActivity = LiveActivityManager() @Environment(\.scenePhase) private var phase var body: some Scene { WindowGroup { - ContentView(store: store) + Group { + if PreviewSupport.liveCard, let state = store.state { + ScrollView { + VStack(spacing: 24) { + Text("Live Activity · предпросмотр").font(.headline) + MusicActivityCard(state: LiveActivityManager.content(state), stale: false) + .background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 24)) + MusicActivityCard(state: LiveActivityManager.content(state), stale: true) + .background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 24)) + }.padding() + } + } else { + ContentView(store: store, liveActivity: liveActivity) + } + } .dynamicTypeSize(PreviewSupport.largeText ? DynamicTypeSize.accessibility2...DynamicTypeSize.accessibility2 : DynamicTypeSize.xSmall...DynamicTypeSize.accessibility5) - .onOpenURL { store.importInvitation($0.absoluteString) } - .task { if !PreviewSupport.enabled { store.setActive(phase == .active) } } + .onOpenURL { if $0.absoluteString != "musicbridge://player" { store.importInvitation($0.absoluteString) } } + .task { + store.liveActivity = liveActivity + 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 0b60ec4..68247fb 100644 --- a/MusicBridge/PlayerStore.swift +++ b/MusicBridge/PlayerStore.swift @@ -14,6 +14,7 @@ final class PlayerStore: ObservableObject { @Published var artworkError: String? @Published var incomingInvitation: String? let discovery = DiscoveryStore() + var liveActivity: LiveActivityManager? private(set) var receivedAt = Date() private var client: BridgeClient? private var runner: Task? @@ -143,6 +144,7 @@ final class PlayerStore: ObservableObject { client.close() guard !Task.isCancelled, self.generation == current else { return } self.connected = false + self.liveActivity?.unavailable() self.state = nil self.artworkTask?.cancel() self.artwork = nil @@ -167,6 +169,7 @@ final class PlayerStore: ObservableObject { receivedAt = Date() connected = true status = "Подключено" + liveActivity?.receive(snapshot) guard artworkID != snapshot.artworkId else { return } artworkID = snapshot.artworkId artworkTask?.cancel() @@ -244,6 +247,7 @@ final class PlayerStore: ObservableObject { func forget() { do { try storage.delete() } catch { self.error = error.localizedDescription; return } + liveActivity?.end() stop() cancelPairing() needsPairing = false @@ -253,6 +257,7 @@ final class PlayerStore: ObservableObject { } private func stop() { + liveActivity?.unavailable() generation = UUID() runner?.cancel() runner = nil @@ -268,6 +273,7 @@ final class PlayerStore: ObservableObject { } private func revokeAccess() { + liveActivity?.end() stop() needsPairing = true status = "Нужно повторное сопряжение" diff --git a/MusicBridge/PreviewSupport.swift b/MusicBridge/PreviewSupport.swift index 2fe7483..fa5ec3f 100644 --- a/MusicBridge/PreviewSupport.swift +++ b/MusicBridge/PreviewSupport.swift @@ -17,6 +17,13 @@ enum PreviewSupport { false #endif } + static var liveCard: Bool { + #if DEBUG + enabled && ProcessInfo.processInfo.arguments.contains("--preview-live-card") + #else + false + #endif + } static func makeStore() -> PlayerStore { #if DEBUG if enabled { diff --git a/MusicBridgeTests/LiveActivityTests.swift b/MusicBridgeTests/LiveActivityTests.swift new file mode 100644 index 0000000..4ec740e --- /dev/null +++ b/MusicBridgeTests/LiveActivityTests.swift @@ -0,0 +1,45 @@ +import XCTest +@testable import MusicBridge + +final class LiveActivityTests: XCTestCase { + @MainActor + func testSnapshotUsesTrackRelativePosition() throws { + let state = try fixture(position: 130, start: 100, end: 300) + let content = LiveActivityManager.content(state, now: Date(timeIntervalSince1970: 1000)) + XCTAssertEqual(content.position, 30) + XCTAssertEqual(content.duration, 200) + XCTAssertEqual(content.progress, 0.15, accuracy: 0.001) + XCTAssertEqual(content.updatedAt, Date(timeIntervalSince1970: 1000)) + } + + @MainActor + func testOversizedUnicodeMetadataStaysBelowActivityKitBudget() throws { + let malicious = String(repeating: "🎵\u{301}", count: 5000) + let state = try fixture(title: malicious, artist: malicious) + let content = LiveActivityManager.content(state) + XCTAssertLessThanOrEqual(content.title.utf8.count, 256) + XCTAssertLessThanOrEqual(content.artist.utf8.count, 256) + let data = try JSONEncoder().encode(content) + XCTAssertLessThan(data.count, 2048) + XCTAssertEqual(try JSONDecoder().decode(MusicActivityAttributes.ContentState.self, from: data), content) + } + + @MainActor + func testUnknownDurationAndPausedState() throws { + let state = try fixture(title: "", playback: "Paused", position: 42, start: 0, end: 0) + let content = LiveActivityManager.content(state) + XCTAssertEqual(content.title, "Без названия") + XCTAssertFalse(content.playing) + XCTAssertEqual(content.progress, 0) + XCTAssertTrue(content.connected) + } + + private func fixture(title: String = "Track", artist: String = "Artist", playback: String = "Playing", + position: Double = 42, start: Double = 0, end: Double = 200) throws -> MediaState { + let url = Bundle(for: Self.self).url(forResource: "state-v1", withExtension: "json")! + var json = try JSONSerialization.jsonObject(with: Data(contentsOf: url)) as! [String: Any] + json["title"] = title; json["artist"] = artist; json["playbackStatus"] = playback + json["positionSeconds"] = position; json["startSeconds"] = start; json["endSeconds"] = end + return try JSONDecoder().decode(MediaState.self, from: JSONSerialization.data(withJSONObject: json)) + } +} diff --git a/MusicBridgeWidgets/Info.plist b/MusicBridgeWidgets/Info.plist new file mode 100644 index 0000000..aec62d4 --- /dev/null +++ b/MusicBridgeWidgets/Info.plist @@ -0,0 +1,29 @@ + + + + + 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) + NSExtension + + NSExtensionPointIdentifier + com.apple.widgetkit-extension + + + diff --git a/MusicBridgeWidgets/MusicBridgeWidgets.swift b/MusicBridgeWidgets/MusicBridgeWidgets.swift new file mode 100644 index 0000000..7f0d05d --- /dev/null +++ b/MusicBridgeWidgets/MusicBridgeWidgets.swift @@ -0,0 +1,46 @@ +import ActivityKit +import SwiftUI +import WidgetKit + +@main +struct MusicBridgeWidgets: WidgetBundle { + var body: some Widget { MusicBridgeLiveActivity() } +} + +struct MusicBridgeLiveActivity: Widget { + var body: some WidgetConfiguration { + ActivityConfiguration(for: MusicActivityAttributes.self) { context in + MusicActivityCard(state: context.state, stale: context.isStale) + .activityBackgroundTint(Color(.secondarySystemBackground)) + .activitySystemActionForegroundColor(.indigo) + .widgetURL(URL(string: "musicbridge://player")) + } dynamicIsland: { context in + DynamicIsland { + DynamicIslandExpandedRegion(.leading) { + Image(systemName: "waveform").foregroundStyle(.cyan).padding(.leading, 4) + } + DynamicIslandExpandedRegion(.trailing) { + Image(systemName: context.isStale || !context.state.connected ? "arrow.clockwise" : context.state.playing ? "play.fill" : "pause.fill") + } + DynamicIslandExpandedRegion(.bottom) { + VStack(alignment: .leading, spacing: 8) { + TrackLabels(state: context.state) + ProgressView(value: context.state.progress).tint(.cyan) + ActivityStatus(state: context.state, stale: context.isStale) + Link("Открыть плеер", destination: URL(string: "musicbridge://player")!) + .font(.caption.weight(.semibold)).foregroundStyle(.cyan) + }.padding(.bottom, 4) + } + } compactLeading: { + Image(systemName: "waveform").foregroundStyle(.cyan) + } compactTrailing: { + Image(systemName: context.isStale || !context.state.connected ? "arrow.clockwise" : context.state.playing ? "play.fill" : "pause.fill") + .accessibilityLabel(context.isStale || !context.state.connected ? "Откройте MusicBridge для обновления" : "Музыка на ПК") + } minimal: { + Image(systemName: context.isStale || !context.state.connected ? "arrow.clockwise" : "waveform").foregroundStyle(.cyan) + } + .widgetURL(URL(string: "musicbridge://player")) + .keylineTint(.cyan) + } + } +} diff --git a/Shared/MusicActivityAttributes.swift b/Shared/MusicActivityAttributes.swift new file mode 100644 index 0000000..6726bc0 --- /dev/null +++ b/Shared/MusicActivityAttributes.swift @@ -0,0 +1,26 @@ +import ActivityKit +import Foundation + +struct MusicActivityAttributes: ActivityAttributes { + struct ContentState: Codable, Hashable { + var title: String + var artist: String + var playing: Bool + var position: Double + var duration: Double + var updatedAt: Date + var connected: Bool + + // Bound UTF-8, including pathological combining characters, below ActivityKit's 4 KB limit. + static func bounded(_ text: String) -> String { + var result = "" + for scalar in text.unicodeScalars { + guard result.utf8.count + scalar.utf8.count <= 256 else { break } + result.unicodeScalars.append(scalar) + } + return result + } + + var progress: Double { duration > 0 ? min(1, max(0, position / duration)) : 0 } + } +} diff --git a/Shared/MusicActivityViews.swift b/Shared/MusicActivityViews.swift new file mode 100644 index 0000000..df74f4e --- /dev/null +++ b/Shared/MusicActivityViews.swift @@ -0,0 +1,46 @@ +import SwiftUI + +struct MusicActivityCard: View { + let state: MusicActivityAttributes.ContentState + let stale: Bool + var body: some View { + VStack(alignment: .leading, spacing: 10) { + HStack(spacing: 12) { + Image(systemName: "waveform").font(.title).foregroundStyle(.indigo) + TrackLabels(state: state) + Spacer(minLength: 0) + Image(systemName: state.playing ? "play.fill" : "pause.fill") + .accessibilityLabel("Последнее состояние: " + (state.playing ? "воспроизведение" : "пауза")) + } + ProgressView(value: state.progress).tint(.indigo) + ActivityStatus(state: state, stale: stale) + Label("Открыть плеер", systemImage: "arrow.up.forward.app").font(.caption.weight(.semibold)) + }.padding(16) + } +} + +struct TrackLabels: View { + let state: MusicActivityAttributes.ContentState + var body: some View { + VStack(alignment: .leading, spacing: 3) { + Text(state.title).font(.headline).lineLimit(1) + Text(state.artist.isEmpty ? "MusicBridge · музыка на ПК" : state.artist) + .font(.caption).foregroundStyle(.secondary).lineLimit(1) + } + } +} + +struct ActivityStatus: View { + let state: MusicActivityAttributes.ContentState + let stale: Bool + var body: some View { + if stale || !state.connected { + Text("Данные устарели · откройте плеер").font(.caption).foregroundStyle(.secondary) + } else { + HStack(spacing: 4) { + Text("Снимок с ПК в") + Text(state.updatedAt, style: .time) + }.font(.caption).foregroundStyle(.secondary) + } + } +} diff --git a/tools/generate_project.py b/tools/generate_project.py index 1bef3e1..34a521f 100644 --- a/tools/generate_project.py +++ b/tools/generate_project.py @@ -35,6 +35,11 @@ def source_group(folder): return add(folder, "PBXGroup", path=folder, sourceTree="", children=refs), builds app_group, app_sources = source_group("MusicBridge") +shared_group, shared_sources = source_group("Shared") +widget_group, widget_sources = source_group("MusicBridgeWidgets") +app_sources += shared_sources +widget_sources += [add("widget-shared/" + str(index), "PBXBuildFile", fileRef=objects[build]["fileRef"]) + for index, build in enumerate(shared_sources)] assets = add("assets", "PBXFileReference", lastKnownFileType="folder.assetcatalog", path="Assets.xcassets", sourceTree="") objects[app_group]["children"].append(assets) assets_build = add("assets-build", "PBXBuildFile", fileRef=assets) @@ -47,14 +52,15 @@ 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)]: - product = add(name + "product", "PBXFileReference", explicitFileType="wrapper.cfbundle" if is_test else "wrapper.application", - includeInIndex=0, path=name + (".xctest" if is_test else ".app"), sourceTree="BUILT_PRODUCTS_DIR") +for name, sources, is_test in [("MusicBridge", app_sources, False), ("MusicBridgeTests", test_sources, True), ("MusicBridgeWidgets", widget_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") 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=[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" + (".tests" if is_test else ""), + 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", SUPPORTED_PLATFORMS="iphoneos iphonesimulator", TARGETED_DEVICE_FAMILY="1", CODE_SIGN_STYLE="Automatic", GENERATE_INFOPLIST_FILE="YES" if is_test else "NO") @@ -63,14 +69,24 @@ 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="6", MARKETING_VERSION="0.5.0", + APPLICATION_EXTENSION_API_ONLY="YES", SKIP_INSTALL="YES", + LD_RUNPATH_SEARCH_PATHS="$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks") else: - settings.update(INFOPLIST_FILE="MusicBridge/Info.plist", CURRENT_PROJECT_VERSION="5", MARKETING_VERSION="0.4.0", ASSETCATALOG_COMPILER_APPICON_NAME="AppIcon") + settings.update(INFOPLIST_FILE="MusicBridge/Info.plist", CURRENT_PROJECT_VERSION="6", MARKETING_VERSION="0.5.0", ASSETCATALOG_COMPILER_APPICON_NAME="AppIcon") + embed = add("embed-widget", "PBXBuildFile", fileRef=uid("MusicBridgeWidgetsproduct"), settings=dict(ATTRIBUTES=["RemoveHeadersOnCopy"])) + phases.append(add("embed-extensions", "PBXCopyFilesBuildPhase", buildActionMask=2147483647, + dstPath="", dstSubfolderSpec=13, files=[embed], name="Embed App 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)] targets.append(add(name + "target", "PBXNativeTarget", name=name, productName=name, productReference=product, - productType="com.apple.product-type.bundle.unit-test" if is_test else "com.apple.product-type.application", + 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", 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, test_group, product_group]) +main_group = add("main", "PBXGroup", sourceTree="", children=[app_group, shared_group, widget_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"], @@ -106,7 +122,7 @@ test_ref = reference("MusicBridgeTests", "MusicBridgeTests.xctest") info = dict(CFBundleDevelopmentRegion="ru", CFBundleDisplayName="MusicBridge", CFBundleExecutable="$(EXECUTABLE_NAME)", CFBundleIdentifier="$(PRODUCT_BUNDLE_IDENTIFIER)", CFBundleInfoDictionaryVersion="6.0", CFBundleName="$(PRODUCT_NAME)", CFBundlePackageType="APPL", CFBundleShortVersionString="$(MARKETING_VERSION)", CFBundleVersion="$(CURRENT_PROJECT_VERSION)", - LSRequiresIPhoneOS=True, UILaunchScreen={}, + LSRequiresIPhoneOS=True, UILaunchScreen={}, NSSupportsLiveActivities=True, UISupportedInterfaceOrientations=["UIInterfaceOrientationPortrait"], NSLocalNetworkUsageDescription="MusicBridge подключается к вашему ПК, чтобы показывать музыку и управлять воспроизведением.", NSCameraUsageDescription="Камера сканирует QR в MusicBridge на ПК для безопасного сопряжения.", @@ -120,4 +136,10 @@ info = dict(CFBundleDevelopmentRegion="ru", CFBundleDisplayName="MusicBridge", C for network in ("10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16") })) (ROOT / "MusicBridge" / "Info.plist").write_bytes(plistlib.dumps(info, sort_keys=False)) +widget_info = dict(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)", + NSExtension=dict(NSExtensionPointIdentifier="com.apple.widgetkit-extension")) +(ROOT / "MusicBridgeWidgets" / "Info.plist").write_bytes(plistlib.dumps(widget_info, sort_keys=False)) print("Generated Xcode project, shared scheme and Info.plist.") diff --git a/tools/validate_project.py b/tools/validate_project.py index a4bf959..011998b 100644 --- a/tools/validate_project.py +++ b/tools/validate_project.py @@ -11,12 +11,12 @@ 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 / "MusicBridge/Info.plist", root / "MusicBridgeWidgets/Info.plist"] 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"): +for folder in ("MusicBridge", "MusicBridgeTests", "Shared", "MusicBridgeWidgets"): 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}" @@ -26,6 +26,8 @@ assert defined == referenced, "Project has dangling object references." info = plistlib.loads(generated[2].read_bytes()) assert info["NSLocalNetworkUsageDescription"] assert info["NSCameraUsageDescription"] +assert info["NSSupportsLiveActivities"] is True +assert plistlib.loads(generated[3].read_bytes())["NSExtension"]["NSExtensionPointIdentifier"] == "com.apple.widgetkit-extension" assert info["NSBonjourServices"] == ["_musicbridge._tcp"] assert info["CFBundleURLTypes"][0]["CFBundleURLSchemes"] == ["musicbridge"] assert info["NSAppTransportSecurity"] == { diff --git a/tools/verify_ipa.py b/tools/verify_ipa.py index 5cd902a..f5fd8b6 100644 --- a/tools/verify_ipa.py +++ b/tools/verify_ipa.py @@ -29,5 +29,24 @@ 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("