Add local Live Activity and Dynamic Island track display

This commit is contained in:
2026-09-13 11:27:58 +03:00
parent 2ca6201ad6
commit ddd44e8edb
16 changed files with 600 additions and 27 deletions
+7
View File
@@ -86,6 +86,13 @@ jobs:
xcrun simctl launch "$SIMULATOR_ID" ru.yukinoki.musicbridge --preview-player --preview-large-text xcrun simctl launch "$SIMULATOR_ID" ru.yukinoki.musicbridge --preview-player --preview-large-text
sleep 2 sleep 2
xcrun simctl io "$SIMULATOR_ID" screenshot build/player-large-text-screen.png 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)))') 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") COMPACT_ID=$(xcrun simctl create MusicBridge-Compact com.apple.CoreSimulator.SimDeviceType.iPhone-SE-3rd-generation "$RUNTIME")
xcrun simctl bootstatus "$COMPACT_ID" -b xcrun simctl bootstatus "$COMPACT_ID" -b
+199 -13
View File
@@ -55,6 +55,16 @@
"isa" = "PBXBuildFile"; "isa" = "PBXBuildFile";
"fileRef" = "76FB6BFFC478599567424755"; "fileRef" = "76FB6BFFC478599567424755";
}; };
"EE2357BD5CA3755AFEB3171A" = {
"isa" = "PBXFileReference";
"lastKnownFileType" = "sourcecode.swift";
"path" = "LiveActivityManager.swift";
"sourceTree" = "<group>";
};
"181CACDC331104A1E16CFC20" = {
"isa" = "PBXBuildFile";
"fileRef" = "EE2357BD5CA3755AFEB3171A";
};
"124CF3F5AC075D1D6042DF64" = { "124CF3F5AC075D1D6042DF64" = {
"isa" = "PBXFileReference"; "isa" = "PBXFileReference";
"lastKnownFileType" = "sourcecode.swift"; "lastKnownFileType" = "sourcecode.swift";
@@ -109,7 +119,57 @@
"isa" = "PBXGroup"; "isa" = "PBXGroup";
"path" = "MusicBridge"; "path" = "MusicBridge";
"sourceTree" = "<group>"; "sourceTree" = "<group>";
"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" = "<group>";
};
"C7506B09240305F44C61EB60" = {
"isa" = "PBXBuildFile";
"fileRef" = "7C5E653CCD928B143AB68F09";
};
"7C0FE298B442263CA281ABB7" = {
"isa" = "PBXFileReference";
"lastKnownFileType" = "sourcecode.swift";
"path" = "MusicActivityViews.swift";
"sourceTree" = "<group>";
};
"4FD4638045EDA8D7CCFD00AF" = {
"isa" = "PBXBuildFile";
"fileRef" = "7C0FE298B442263CA281ABB7";
};
"E3C4B39D6D5013477C98CD58" = {
"isa" = "PBXGroup";
"path" = "Shared";
"sourceTree" = "<group>";
"children" = ("7C5E653CCD928B143AB68F09", "7C0FE298B442263CA281ABB7",);
};
"ADFE24674E0FB119E649EC54" = {
"isa" = "PBXFileReference";
"lastKnownFileType" = "sourcecode.swift";
"path" = "MusicBridgeWidgets.swift";
"sourceTree" = "<group>";
};
"F942C4AC2D1ABF86575E779A" = {
"isa" = "PBXBuildFile";
"fileRef" = "ADFE24674E0FB119E649EC54";
};
"F932ED2EB70210651D1121CB" = {
"isa" = "PBXGroup";
"path" = "MusicBridgeWidgets";
"sourceTree" = "<group>";
"children" = ("ADFE24674E0FB119E649EC54",);
};
"D739DF07A523D6FA1D29EECD" = {
"isa" = "PBXBuildFile";
"fileRef" = "7C5E653CCD928B143AB68F09";
};
"E41BBFD90B0D341CC92C100E" = {
"isa" = "PBXBuildFile";
"fileRef" = "7C0FE298B442263CA281ABB7";
}; };
"8BF729FFE074CAEE622C0292" = { "8BF729FFE074CAEE622C0292" = {
"isa" = "PBXFileReference"; "isa" = "PBXFileReference";
@@ -131,6 +191,16 @@
"isa" = "PBXBuildFile"; "isa" = "PBXBuildFile";
"fileRef" = "C9F705D432CA45B6056BC572"; "fileRef" = "C9F705D432CA45B6056BC572";
}; };
"9819FF7CC21CAAD1BDB8D1B8" = {
"isa" = "PBXFileReference";
"lastKnownFileType" = "sourcecode.swift";
"path" = "LiveActivityTests.swift";
"sourceTree" = "<group>";
};
"BD0B3FAC7F1259D249134283" = {
"isa" = "PBXBuildFile";
"fileRef" = "9819FF7CC21CAAD1BDB8D1B8";
};
"ACD0393DCDDDF044F2966340" = { "ACD0393DCDDDF044F2966340" = {
"isa" = "PBXFileReference"; "isa" = "PBXFileReference";
"lastKnownFileType" = "sourcecode.swift"; "lastKnownFileType" = "sourcecode.swift";
@@ -165,7 +235,7 @@
"isa" = "PBXGroup"; "isa" = "PBXGroup";
"path" = "MusicBridgeTests"; "path" = "MusicBridgeTests";
"sourceTree" = "<group>"; "sourceTree" = "<group>";
"children" = ("C9F705D432CA45B6056BC572", "ACD0393DCDDDF044F2966340", "464F6C65BC2FAB6BCCC77F46", "8BED860F1BD59C500D215E8D", "F16D05EC6B29248D2C61ADB1", "4731785CAB9BB914B8834AE6",); "children" = ("C9F705D432CA45B6056BC572", "9819FF7CC21CAAD1BDB8D1B8", "ACD0393DCDDDF044F2966340", "464F6C65BC2FAB6BCCC77F46", "8BED860F1BD59C500D215E8D", "F16D05EC6B29248D2C61ADB1", "4731785CAB9BB914B8834AE6",);
}; };
"F16D05EC6B29248D2C61ADB1" = { "F16D05EC6B29248D2C61ADB1" = {
"isa" = "PBXFileReference"; "isa" = "PBXFileReference";
@@ -197,7 +267,7 @@
"72B74C8A2143F64FD53D71E5" = { "72B74C8A2143F64FD53D71E5" = {
"isa" = "PBXSourcesBuildPhase"; "isa" = "PBXSourcesBuildPhase";
"buildActionMask" = 2147483647; "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; "runOnlyForDeploymentPostprocessing" = 0;
}; };
"734C4650C7A5604092763C32" = { "734C4650C7A5604092763C32" = {
@@ -212,6 +282,34 @@
"files" = ("5522B3B3B2EACC1C8F9BEAAC",); "files" = ("5522B3B3B2EACC1C8F9BEAAC",);
"runOnlyForDeploymentPostprocessing" = 0; "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" = { "E3322BA79DBD9A84650B2CAE" = {
"isa" = "XCBuildConfiguration"; "isa" = "XCBuildConfiguration";
"name" = "Debug"; "name" = "Debug";
@@ -226,8 +324,8 @@
"CODE_SIGN_STYLE" = "Automatic"; "CODE_SIGN_STYLE" = "Automatic";
"GENERATE_INFOPLIST_FILE" = "NO"; "GENERATE_INFOPLIST_FILE" = "NO";
"INFOPLIST_FILE" = "MusicBridge/Info.plist"; "INFOPLIST_FILE" = "MusicBridge/Info.plist";
"CURRENT_PROJECT_VERSION" = "5"; "CURRENT_PROJECT_VERSION" = "6";
"MARKETING_VERSION" = "0.4.0"; "MARKETING_VERSION" = "0.5.0";
"ASSETCATALOG_COMPILER_APPICON_NAME" = "AppIcon"; "ASSETCATALOG_COMPILER_APPICON_NAME" = "AppIcon";
"SWIFT_OPTIMIZATION_LEVEL" = "-Onone"; "SWIFT_OPTIMIZATION_LEVEL" = "-Onone";
"ENABLE_TESTABILITY" = "YES"; "ENABLE_TESTABILITY" = "YES";
@@ -248,8 +346,8 @@
"CODE_SIGN_STYLE" = "Automatic"; "CODE_SIGN_STYLE" = "Automatic";
"GENERATE_INFOPLIST_FILE" = "NO"; "GENERATE_INFOPLIST_FILE" = "NO";
"INFOPLIST_FILE" = "MusicBridge/Info.plist"; "INFOPLIST_FILE" = "MusicBridge/Info.plist";
"CURRENT_PROJECT_VERSION" = "5"; "CURRENT_PROJECT_VERSION" = "6";
"MARKETING_VERSION" = "0.4.0"; "MARKETING_VERSION" = "0.5.0";
"ASSETCATALOG_COMPILER_APPICON_NAME" = "AppIcon"; "ASSETCATALOG_COMPILER_APPICON_NAME" = "AppIcon";
"SWIFT_OPTIMIZATION_LEVEL" = "-O"; "SWIFT_OPTIMIZATION_LEVEL" = "-O";
}; };
@@ -267,9 +365,9 @@
"productReference" = "7C35A0276A762371ABAE5596"; "productReference" = "7C35A0276A762371ABAE5596";
"productType" = "com.apple.product-type.application"; "productType" = "com.apple.product-type.application";
"buildConfigurationList" = "C6336ED5908EABEBF60AE675"; "buildConfigurationList" = "C6336ED5908EABEBF60AE675";
"buildPhases" = ("72B74C8A2143F64FD53D71E5", "734C4650C7A5604092763C32", "69816CB820E8173702C54C37",); "buildPhases" = ("72B74C8A2143F64FD53D71E5", "734C4650C7A5604092763C32", "69816CB820E8173702C54C37", "AFC32963E7959E7A34264826",);
"buildRules" = (); "buildRules" = ();
"dependencies" = (); "dependencies" = ("A4C2D386AB0259F33DCDC427",);
}; };
"5E4BE508157A7B2CB7B4C2F2" = { "5E4BE508157A7B2CB7B4C2F2" = {
"isa" = "PBXFileReference"; "isa" = "PBXFileReference";
@@ -281,7 +379,7 @@
"AB453A3F0E1490D48374A69A" = { "AB453A3F0E1490D48374A69A" = {
"isa" = "PBXSourcesBuildPhase"; "isa" = "PBXSourcesBuildPhase";
"buildActionMask" = 2147483647; "buildActionMask" = 2147483647;
"files" = ("1DD7BB0437E9F53586831F22", "17E248E58CB43C680B48DD51", "EFDB280E0B23F7E2A246BC89", "DC1A5B0B94F4C98F1CA36676",); "files" = ("1DD7BB0437E9F53586831F22", "BD0B3FAC7F1259D249134283", "17E248E58CB43C680B48DD51", "EFDB280E0B23F7E2A246BC89", "DC1A5B0B94F4C98F1CA36676",);
"runOnlyForDeploymentPostprocessing" = 0; "runOnlyForDeploymentPostprocessing" = 0;
}; };
"9AA2A4C344B5C474AF5D336C" = { "9AA2A4C344B5C474AF5D336C" = {
@@ -363,16 +461,104 @@
"buildRules" = (); "buildRules" = ();
"dependencies" = ("F9A94CEF8CF7D85CE881E703",); "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" = { "0A3E27B8CA818264D75C8D81" = {
"isa" = "PBXGroup"; "isa" = "PBXGroup";
"name" = "Products"; "name" = "Products";
"sourceTree" = "<group>"; "sourceTree" = "<group>";
"children" = ("7C35A0276A762371ABAE5596", "5E4BE508157A7B2CB7B4C2F2",); "children" = ("7C35A0276A762371ABAE5596", "5E4BE508157A7B2CB7B4C2F2", "CE9225C1B8E80F534681689F",);
}; };
"0D6E4079E36703EBD37C0072" = { "0D6E4079E36703EBD37C0072" = {
"isa" = "PBXGroup"; "isa" = "PBXGroup";
"sourceTree" = "<group>"; "sourceTree" = "<group>";
"children" = ("026F39792BBA0E5032536CED", "E1AD768E105BF42350625991", "0A3E27B8CA818264D75C8D81",); "children" = ("026F39792BBA0E5032536CED", "E3C4B39D6D5013477C98CD58", "F932ED2EB70210651D1121CB", "E1AD768E105BF42350625991", "0A3E27B8CA818264D75C8D81",);
}; };
"73EBF6644258C120893E685D" = { "73EBF6644258C120893E685D" = {
"isa" = "XCBuildConfiguration"; "isa" = "XCBuildConfiguration";
@@ -414,7 +600,7 @@
"productRefGroup" = "0A3E27B8CA818264D75C8D81"; "productRefGroup" = "0A3E27B8CA818264D75C8D81";
"projectDirPath" = ""; "projectDirPath" = "";
"projectRoot" = ""; "projectRoot" = "";
"targets" = ("F380385F14BF54F0FA81EEAE", "C2A8D97FC5416EA6103FBF9A",); "targets" = ("F380385F14BF54F0FA81EEAE", "C2A8D97FC5416EA6103FBF9A", "BFE9A9DC8C194372006C3ACC",);
}; };
}; };
"rootObject" = "244210E48437B6556980A702"; "rootObject" = "244210E48437B6556980A702";
+13
View File
@@ -2,6 +2,7 @@ import SwiftUI
struct ContentView: View { struct ContentView: View {
@ObservedObject var store: PlayerStore @ObservedObject var store: PlayerStore
@ObservedObject var liveActivity: LiveActivityManager
@State private var showForget = false @State private var showForget = false
var body: some View { var body: some View {
@@ -19,6 +20,18 @@ struct ContentView: View {
} else { } else {
PlayerView(store: store, coverSize: min(280, max(120, min(viewport.size.width - 40, viewport.size.height * 0.30)))) 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 { if !store.connected && !store.needsPairing {
Button("Подключиться снова") { store.resume() } Button("Подключиться снова") { store.resume() }
} }
+2
View File
@@ -24,6 +24,8 @@
<true/> <true/>
<key>UILaunchScreen</key> <key>UILaunchScreen</key>
<dict/> <dict/>
<key>NSSupportsLiveActivities</key>
<true/>
<key>UISupportedInterfaceOrientations</key> <key>UISupportedInterfaceOrientations</key>
<array> <array>
<string>UIInterfaceOrientationPortrait</string> <string>UIInterfaceOrientationPortrait</string>
+99
View File
@@ -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<MusicActivityAttributes>?
private var updates: Task<Void, Never>?
private var observer: Task<Void, Never>?
private var last: MusicActivityAttributes.ContentState?
init() {
let existing = Activity<MusicActivityAttributes>.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<MusicActivityAttributes>) {
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
}
}
}
}
}
+21 -3
View File
@@ -3,14 +3,32 @@ import SwiftUI
@main @main
struct MusicBridgeApp: App { struct MusicBridgeApp: App {
@StateObject private var store = PreviewSupport.makeStore() @StateObject private var store = PreviewSupport.makeStore()
@StateObject private var liveActivity = LiveActivityManager()
@Environment(\.scenePhase) private var phase @Environment(\.scenePhase) private var phase
var body: some Scene { var body: some Scene {
WindowGroup { 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) .dynamicTypeSize(PreviewSupport.largeText ? DynamicTypeSize.accessibility2...DynamicTypeSize.accessibility2 : DynamicTypeSize.xSmall...DynamicTypeSize.accessibility5)
.onOpenURL { store.importInvitation($0.absoluteString) } .onOpenURL { if $0.absoluteString != "musicbridge://player" { store.importInvitation($0.absoluteString) } }
.task { if !PreviewSupport.enabled { store.setActive(phase == .active) } } .task {
store.liveActivity = liveActivity
if !PreviewSupport.enabled { store.setActive(phase == .active) }
}
.onChange(of: phase) { _, value in if !PreviewSupport.enabled { store.setActive(value == .active) } } .onChange(of: phase) { _, value in if !PreviewSupport.enabled { store.setActive(value == .active) } }
} }
} }
+6
View File
@@ -14,6 +14,7 @@ final class PlayerStore: ObservableObject {
@Published var artworkError: String? @Published var artworkError: String?
@Published var incomingInvitation: String? @Published var incomingInvitation: String?
let discovery = DiscoveryStore() let discovery = DiscoveryStore()
var liveActivity: LiveActivityManager?
private(set) var receivedAt = Date() private(set) var receivedAt = Date()
private var client: BridgeClient? private var client: BridgeClient?
private var runner: Task<Void, Never>? private var runner: Task<Void, Never>?
@@ -143,6 +144,7 @@ final class PlayerStore: ObservableObject {
client.close() client.close()
guard !Task.isCancelled, self.generation == current else { return } guard !Task.isCancelled, self.generation == current else { return }
self.connected = false self.connected = false
self.liveActivity?.unavailable()
self.state = nil self.state = nil
self.artworkTask?.cancel() self.artworkTask?.cancel()
self.artwork = nil self.artwork = nil
@@ -167,6 +169,7 @@ final class PlayerStore: ObservableObject {
receivedAt = Date() receivedAt = Date()
connected = true connected = true
status = "Подключено" status = "Подключено"
liveActivity?.receive(snapshot)
guard artworkID != snapshot.artworkId else { return } guard artworkID != snapshot.artworkId else { return }
artworkID = snapshot.artworkId artworkID = snapshot.artworkId
artworkTask?.cancel() artworkTask?.cancel()
@@ -244,6 +247,7 @@ final class PlayerStore: ObservableObject {
func forget() { func forget() {
do { try storage.delete() } do { try storage.delete() }
catch { self.error = error.localizedDescription; return } catch { self.error = error.localizedDescription; return }
liveActivity?.end()
stop() stop()
cancelPairing() cancelPairing()
needsPairing = false needsPairing = false
@@ -253,6 +257,7 @@ final class PlayerStore: ObservableObject {
} }
private func stop() { private func stop() {
liveActivity?.unavailable()
generation = UUID() generation = UUID()
runner?.cancel() runner?.cancel()
runner = nil runner = nil
@@ -268,6 +273,7 @@ final class PlayerStore: ObservableObject {
} }
private func revokeAccess() { private func revokeAccess() {
liveActivity?.end()
stop() stop()
needsPairing = true needsPairing = true
status = "Нужно повторное сопряжение" status = "Нужно повторное сопряжение"
+7
View File
@@ -17,6 +17,13 @@ enum PreviewSupport {
false false
#endif #endif
} }
static var liveCard: Bool {
#if DEBUG
enabled && ProcessInfo.processInfo.arguments.contains("--preview-live-card")
#else
false
#endif
}
static func makeStore() -> PlayerStore { static func makeStore() -> PlayerStore {
#if DEBUG #if DEBUG
if enabled { if enabled {
+45
View File
@@ -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))
}
}
+29
View File
@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>ru</string>
<key>CFBundleDisplayName</key>
<string>MusicBridge</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>XPC!</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.widgetkit-extension</string>
</dict>
</dict>
</plist>
@@ -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)
}
}
}
+26
View File
@@ -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 }
}
}
+46
View File
@@ -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)
}
}
}
+31 -9
View File
@@ -35,6 +35,11 @@ def source_group(folder):
return add(folder, "PBXGroup", path=folder, sourceTree="<group>", children=refs), builds return add(folder, "PBXGroup", path=folder, sourceTree="<group>", children=refs), builds
app_group, app_sources = source_group("MusicBridge") 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="<group>") assets = add("assets", "PBXFileReference", lastKnownFileType="folder.assetcatalog", path="Assets.xcassets", sourceTree="<group>")
objects[app_group]["children"].append(assets) objects[app_group]["children"].append(assets)
assets_build = add("assets-build", "PBXBuildFile", fileRef=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) tls_fixture_build = add("tls-fixture-build", "PBXBuildFile", fileRef=tls_fixture)
products = [] products = []
targets = [] targets = []
for name, sources, is_test in [("MusicBridge", app_sources, False), ("MusicBridgeTests", test_sources, True)]: for name, sources, is_test in [("MusicBridge", app_sources, False), ("MusicBridgeTests", test_sources, True), ("MusicBridgeWidgets", widget_sources, False)]:
product = add(name + "product", "PBXFileReference", explicitFileType="wrapper.cfbundle" if is_test else "wrapper.application", is_widget = name == "MusicBridgeWidgets"
includeInIndex=0, path=name + (".xctest" if is_test else ".app"), sourceTree="BUILT_PRODUCTS_DIR") 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) products.append(product)
phases = [add(name + "sources", "PBXSourcesBuildPhase", buildActionMask=2147483647, files=sources, runOnlyForDeploymentPostprocessing=0), phases = [add(name + "sources", "PBXSourcesBuildPhase", buildActionMask=2147483647, files=sources, runOnlyForDeploymentPostprocessing=0),
add(name + "frameworks", "PBXFrameworksBuildPhase", buildActionMask=2147483647, files=[], 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)] 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" + (".tests" if is_test else ""), 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", SWIFT_VERSION="5.0", IPHONEOS_DEPLOYMENT_TARGET="17.0", SDKROOT="iphoneos",
SUPPORTED_PLATFORMS="iphoneos iphonesimulator", TARGETED_DEVICE_FAMILY="1", CODE_SIGN_STYLE="Automatic", SUPPORTED_PLATFORMS="iphoneos iphonesimulator", TARGETED_DEVICE_FAMILY="1", CODE_SIGN_STYLE="Automatic",
GENERATE_INFOPLIST_FILE="YES" if is_test else "NO") 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)") 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") proxy = add("test-proxy", "PBXContainerItemProxy", containerPortal=uid("project"), proxyType=1, remoteGlobalIDString=uid("MusicBridgetarget"), remoteInfo="MusicBridge")
deps = [add("test-dependency", "PBXTargetDependency", target=uid("MusicBridgetarget"), targetProxy=proxy)] deps = [add("test-dependency", "PBXTargetDependency", target=uid("MusicBridgetarget"), targetProxy=proxy)]
elif is_widget:
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: 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, 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)) buildConfigurationList=configurations(name, settings), buildPhases=phases, buildRules=[], dependencies=deps))
product_group = add("products", "PBXGroup", name="Products", sourceTree="<group>", children=products) product_group = add("products", "PBXGroup", name="Products", sourceTree="<group>", children=products)
main_group = add("main", "PBXGroup", sourceTree="<group>", children=[app_group, test_group, product_group]) main_group = add("main", "PBXGroup", sourceTree="<group>", children=[app_group, shared_group, widget_group, test_group, product_group])
project = add("project", "PBXProject", attributes=dict(LastUpgradeCheck="1600", BuildIndependentTargetsInParallel="YES"), project = add("project", "PBXProject", attributes=dict(LastUpgradeCheck="1600", BuildIndependentTargetsInParallel="YES"),
buildConfigurationList=configurations("project", dict(CLANG_ENABLE_MODULES="YES", CLANG_ENABLE_OBJC_ARC="YES")), buildConfigurationList=configurations("project", dict(CLANG_ENABLE_MODULES="YES", CLANG_ENABLE_OBJC_ARC="YES")),
compatibilityVersion="Xcode 14.0", developmentRegion="ru", knownRegions=["ru", "en", "Base"], 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)", info = dict(CFBundleDevelopmentRegion="ru", CFBundleDisplayName="MusicBridge", CFBundleExecutable="$(EXECUTABLE_NAME)",
CFBundleIdentifier="$(PRODUCT_BUNDLE_IDENTIFIER)", CFBundleInfoDictionaryVersion="6.0", CFBundleName="$(PRODUCT_NAME)", CFBundleIdentifier="$(PRODUCT_BUNDLE_IDENTIFIER)", CFBundleInfoDictionaryVersion="6.0", CFBundleName="$(PRODUCT_NAME)",
CFBundlePackageType="APPL", CFBundleShortVersionString="$(MARKETING_VERSION)", CFBundleVersion="$(CURRENT_PROJECT_VERSION)", CFBundlePackageType="APPL", CFBundleShortVersionString="$(MARKETING_VERSION)", CFBundleVersion="$(CURRENT_PROJECT_VERSION)",
LSRequiresIPhoneOS=True, UILaunchScreen={}, LSRequiresIPhoneOS=True, UILaunchScreen={}, NSSupportsLiveActivities=True,
UISupportedInterfaceOrientations=["UIInterfaceOrientationPortrait"], UISupportedInterfaceOrientations=["UIInterfaceOrientationPortrait"],
NSLocalNetworkUsageDescription="MusicBridge подключается к вашему ПК, чтобы показывать музыку и управлять воспроизведением.", NSLocalNetworkUsageDescription="MusicBridge подключается к вашему ПК, чтобы показывать музыку и управлять воспроизведением.",
NSCameraUsageDescription="Камера сканирует QR в 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") 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)) (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.") print("Generated Xcode project, shared scheme and Info.plist.")
+4 -2
View File
@@ -11,12 +11,12 @@ import xml.etree.ElementTree as ET
root = pathlib.Path(__file__).resolve().parents[1] root = pathlib.Path(__file__).resolve().parents[1]
generated = [root / "MusicBridge.xcodeproj/project.pbxproj", generated = [root / "MusicBridge.xcodeproj/project.pbxproj",
root / "MusicBridge.xcodeproj/xcshareddata/xcschemes/MusicBridge.xcscheme", 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] before = [p.read_bytes() for p in generated]
subprocess.run([sys.executable, str(root / "tools/generate_project.py")], check=True) 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." 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") 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"): for source in (root / folder).glob("*.swift"):
key = hashlib.sha256(source.relative_to(root).as_posix().encode()).hexdigest()[:24].upper() key = hashlib.sha256(source.relative_to(root).as_posix().encode()).hexdigest()[:24].upper()
assert key in project, f"Missing source: {source.name}" 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()) info = plistlib.loads(generated[2].read_bytes())
assert info["NSLocalNetworkUsageDescription"] assert info["NSLocalNetworkUsageDescription"]
assert info["NSCameraUsageDescription"] 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["NSBonjourServices"] == ["_musicbridge._tcp"]
assert info["CFBundleURLTypes"][0]["CFBundleURLSchemes"] == ["musicbridge"] assert info["CFBundleURLTypes"][0]["CFBundleURLSchemes"] == ["musicbridge"]
assert info["NSAppTransportSecurity"] == { assert info["NSAppTransportSecurity"] == {
+19
View File
@@ -29,5 +29,24 @@ 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], "Mach-O is not built for iOS devices" assert platforms == [2], "Mach-O is not built for iOS devices"
assert info["NSSupportsLiveActivities"] is True
extension = prefix + "PlugIns/MusicBridgeWidgets.appex/"
widget = plistlib.loads(archive.read(extension + "Info.plist"))
assert widget["CFBundleIdentifier"] == info["CFBundleIdentifier"] + ".widgets"
assert widget["CFBundleShortVersionString"] == info["CFBundleShortVersionString"]
assert widget["CFBundleVersion"] == info["CFBundleVersion"]
assert widget["NSExtension"]["NSExtensionPointIdentifier"] == "com.apple.widgetkit-extension"
assert widget["CFBundleSupportedPlatforms"] == ["iPhoneOS"]
binary = archive.read(extension + widget["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], "Widget 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("Requires local signing with Sideloadly before installation on an iPhone.") print("Requires local signing with Sideloadly before installation on an iPhone.")