Compare commits

...

2 Commits

18 changed files with 680 additions and 36 deletions
+7
View File
@@ -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
+199 -13
View File
@@ -55,6 +55,16 @@
"isa" = "PBXBuildFile";
"fileRef" = "76FB6BFFC478599567424755";
};
"EE2357BD5CA3755AFEB3171A" = {
"isa" = "PBXFileReference";
"lastKnownFileType" = "sourcecode.swift";
"path" = "LiveActivityManager.swift";
"sourceTree" = "<group>";
};
"181CACDC331104A1E16CFC20" = {
"isa" = "PBXBuildFile";
"fileRef" = "EE2357BD5CA3755AFEB3171A";
};
"124CF3F5AC075D1D6042DF64" = {
"isa" = "PBXFileReference";
"lastKnownFileType" = "sourcecode.swift";
@@ -109,7 +119,57 @@
"isa" = "PBXGroup";
"path" = "MusicBridge";
"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" = {
"isa" = "PBXFileReference";
@@ -131,6 +191,16 @@
"isa" = "PBXBuildFile";
"fileRef" = "C9F705D432CA45B6056BC572";
};
"9819FF7CC21CAAD1BDB8D1B8" = {
"isa" = "PBXFileReference";
"lastKnownFileType" = "sourcecode.swift";
"path" = "LiveActivityTests.swift";
"sourceTree" = "<group>";
};
"BD0B3FAC7F1259D249134283" = {
"isa" = "PBXBuildFile";
"fileRef" = "9819FF7CC21CAAD1BDB8D1B8";
};
"ACD0393DCDDDF044F2966340" = {
"isa" = "PBXFileReference";
"lastKnownFileType" = "sourcecode.swift";
@@ -165,7 +235,7 @@
"isa" = "PBXGroup";
"path" = "MusicBridgeTests";
"sourceTree" = "<group>";
"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" = "<group>";
"children" = ("7C35A0276A762371ABAE5596", "5E4BE508157A7B2CB7B4C2F2",);
"children" = ("7C35A0276A762371ABAE5596", "5E4BE508157A7B2CB7B4C2F2", "CE9225C1B8E80F534681689F",);
};
"0D6E4079E36703EBD37C0072" = {
"isa" = "PBXGroup";
"sourceTree" = "<group>";
"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";
+13
View File
@@ -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() }
}
+2
View File
@@ -24,6 +24,8 @@
<true/>
<key>UILaunchScreen</key>
<dict/>
<key>NSSupportsLiveActivities</key>
<true/>
<key>UISupportedInterfaceOrientations</key>
<array>
<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
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) } }
}
}
+6
View File
@@ -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<Void, Never>?
@@ -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 = "Нужно повторное сопряжение"
+7
View File
@@ -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 {
+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)
}
}
}
+47 -6
View File
@@ -43,8 +43,11 @@ Windows-агент — соседний проект `C:/Users/areso/MusicBridge
- [x] Реализованы понятные ошибки сопряжения по кодам сервера, повтор загрузки обложки после 404 и ручной повтор.
- [x] Иконка приложения и обновлённый интерфейс подключения/плеера.
- [x] Просмотрены снимки 0.4.0: подключение, светлая/тёмная темы, iPhone SE и крупный текст.
- [ ] Проверить новый интерфейс и иконку 0.4.0 на физическом iPhone.
- [ ] Live Activities / Dynamic Island / App Intents — отдельный последующий этап.
- [x] Пользователь подтвердил работу 0.4.0 на iPhone: «всё вроде работает».
- [x] Локальная Live Activity и Dynamic Island: реализация отображения трека в 0.5.0.
- [ ] Проверить Live Activity на экране блокировки и Dynamic Island после подписи через iloader.
- [ ] App Intents: прямые действия из Live Activity и команды Siri.
- [ ] APNs: отдельный проект обновлений при приостановленном приложении, если нужен постоянный актуальный трек в фоне.
Ограничения: хранится один доверенный ПК; адрес при смене IP восстанавливается через
Bonjour, если доступны объявления того же агента. Новый адрес сохраняется только
@@ -93,6 +96,9 @@ Bonjour, если доступны объявления того же агент
- `QRScanner.swift`: камера AVFoundation, доступ, сканирование и жизненный цикл.
- `DiscoveryStore.swift`: Bonjour, разрешение адресов, TXT и безопасный выбор адреса.
- `ConnectionStorage.swift`: доступ к Keychain и подмена хранилища для изолированных тестов жизненного цикла.
- `LiveActivityManager.swift`: запуск, обновление, устаревание и завершение одной локальной Live Activity.
- `Shared/`: ограниченный публичный снимок трека и общие SwiftUI-компоненты карточки.
- `MusicBridgeWidgets/`: расширение WidgetKit, экран блокировки и все представления Dynamic Island.
- `MusicBridgeTests/`: XCTest и пример Windows JSON.
- `tools/`: генератор проекта, структурные проверки, выбор симулятора для CI.
- `.github/workflows/ios.yml`: CI на macOS.
@@ -136,12 +142,47 @@ Bonjour, если доступны объявления того же агент
2. **Интерфейс — версия 0.4.0 (build 5).** Добавлены иконка, компактный экран QR,
раскрываемые ручные поля, крупная play/pause, процент громкости и меню ПК.
Сборка и 23 XCTest прошли; просмотрены темы, компактный экран и крупный текст.
Сценарии QR и ручного подключения сохранены; нужна проверка на телефоне.
3. **Системные интеграции.** Сначала исследовать актуальные ограничения Live Activities,
Dynamic Island, App Intents и локальной связи в фоне; затем реализовать доступные
сценарии. Не имитировать фоновое аудио. Не обещать постоянный WSS при блокировке.
Пользователь подтвердил работу обновления на телефоне перед началом этапа 3.
3. **Системные интеграции — 0.5.0 (build 6).** Локальная Live Activity и Dynamic Island
реализованы; 26 XCTest и Release archive прошли. Карточка показывает последний снимок
с ПК, открывает плеер, помечает устаревшие данные. App Intents и APNs не реализованы.
Не имитировать фоновое аудио. Не обещать постоянный WSS при блокировке.
4. **Несколько сохранённых ПК** — последующее расширение, сейчас сохраняется один.
Изменения третьего этапа — Live Activities 0.5.0:
- Итоговый `ddd44e8`: [Actions 34747772034](https://github.com/polskikh13/musicbridgeios/actions/runs/34747772034)
— 26 XCTest без отказов, сборка приложения и WidgetKit-расширения, успешный Release archive.
IPA скачана в `build/iPhone-0.5.0/MusicBridge-iPhone.ipa`; проверены оба ARM64
iPhoneOS исполняемых файла, структура `.appex`, версии и NSSupportsLiveActivities.
Светлый/тёмный предпросмотр карточки просмотрен: текст и отметка устаревания читаемы.
Установка/регистрация Live Activity на физическом iPhone остаются ручной проверкой.
- ActivityKit без push: старт только кнопкой пользователя при активном приложении
и медиасессии. Восстанавливается существующая активность, дубли завершаются;
после удаления пользователем автоматического пересоздания нет.
- WidgetKit-расширение встроено в приложение. В attributes/content нет адреса ПК,
токена, fingerprint или обложки; только публичные данные трека, ограниченные
по UTF-8 (по 256 байт для названия и исполнителя).
- Обычные позиции объединяются до одного обновления в пять секунд; смена трека,
длительности или состояния воспроизведения отправляется сразу. Прогресс статический,
соответствует последнему снимку. В фоне/без сети помечается устаревшим;
`staleDate` через 15 секунд страхует внезапную остановку приложения.
- Нет медиасессии, отзыв доступа, «Забыть компьютер» или ручное выключение — завершение.
Нажатие карточки открывает `musicbridge://player`, не попадает в импорт QR.
- Новые XCTest: позиция относительно startSeconds, нулевая длительность/пауза,
чрезмерные Unicode-метаданные и размер Codable payload. Проверка IPA требует
встроенную ARM64 iPhoneOS `.appex`, совпадающие версии и поддержку Live Activities.
- CI-предпросмотр общей разметки карточки проверяет светлую/тёмную темы и устаревшие
данные. Это не проверка системной регистрации ActivityKit/Island. На физическом
iPhone проверить старт, блокировку, раскрытие Island, переход в плеер, возврат,
удаление карточки, отсутствие автопересоздания, окончание сессии и отзыв доступа.
- Следующие отдельные задачи: LiveActivityIntent с безопасным выполнением команды
(проверить доступность Keychain/локальной сети при блокировке); Siri; APNs, если
требуются актуальные данные после приостановки приложения. Постоянный WSS в фоне
не реализован и не обещается. Windows-протокол не менялся.
- Основание: [Apple ActivityKit](https://developer.apple.com/documentation/activitykit/displaying-live-data-with-live-activities)
и [WidgetKit extension](https://developer.apple.com/documentation/widgetkit/creating-a-widget-extension).
Изменения второго этапа:
- Итоговый `aa0b0f0`: [Actions 34746477145](https://github.com/polskikh13/musicbridgeios/actions/runs/34746477145)
+33 -3
View File
@@ -17,7 +17,8 @@
**Статус:** пользователь подтвердил установку через iloader, рабочий пилот с
Windows-агентом и подключение по QR. В 0.4.0 добавлены иконка и обновлённый
интерфейс. Автоматические проверки включают 23 XCTest и Release archive;
интерфейс, в 0.5.0 — локальная Live Activity и Dynamic Island. CI собирает
приложение и расширение WidgetKit, выполняет XCTest и Release archive;
полная проверка устойчивости на физических устройствах остаётся в `PLAN.md`.
## Сборка на Mac
@@ -67,8 +68,8 @@ Apple ID и сертификаты для этой проверки не тре
## Установка на iPhone через Sideloadly
Пользователь также подтвердил установку через iloader. Для повторной установки
с новым интерфейсом используйте версию **0.4.0 (build 5)**:
`build/iPhone-0.4.0/MusicBridge-iPhone.ipa`. Выберите этот файл в iloader и
с Live Activity используйте версию **0.5.0 (build 6)**:
`build/iPhone-0.5.0/MusicBridge-iPhone.ipa`. Выберите этот файл в iloader и
подпишите тем же Apple Account, которым установлена первая версия.
В 0.4.0 добавлены иконка, карточка QR, раскрываемые ручные поля, крупная
@@ -105,6 +106,35 @@ Sideloadly (в том числе его функцией автоматичес
[Sideloadly FAQ](https://sideloadly.io/faq) ·
[Apple: режим разработчика](https://developer.apple.com/documentation/Xcode/enabling-developer-mode-on-a-device)
## Live Activity и Dynamic Island
После подключения к ПК и появления трека нажмите под плеером
«Показать на экране блокировки». Та же карточка появится в Dynamic Island
на моделях, где он доступен. Долгое нажатие на Island раскрывает представление;
нажатие на карточку или «Открыть плеер» возвращает в приложение.
Кнопка «Убрать с экрана блокировки» завершает активность. Она также завершается,
когда ПК сообщает об отсутствии медиасессии, доступ отозван или компьютер забыт.
Удалённая пользователем карточка не создаётся снова автоматически.
Это **последний снимок состояния ПК**: название, исполнитель, состояние и прогресс.
В обычном режиме позиция обновляется не чаще раза в пять секунд; смена трека
и play/pause отправляются сразу. Прогресс не выдумывает движение без связи с ПК.
При уходе приложения в фон или потере связи отображается отметка устаревших данных;
резервный `staleDate` — 15 секунд, если приложение было внезапно остановлено.
Для обновления нажмите карточку. Прямых кнопок управления внутри неё пока нет.
Live Activity не поддерживает постоянный сетевой поток сама по себе. Для обновления
при приостановленном приложении нужен отдельный механизм ActivityKit push через
APNs. В этой версии нет push-токенов, сервера APNs, фонового аудио и App Intents.
[Ограничения и жизненный цикл ActivityKit](https://developer.apple.com/documentation/activitykit/displaying-live-data-with-live-activities).
В IPA включено расширение `MusicBridgeWidgets.appex`; при установке iloader должен
сохранить и подписать его вместе с приложением. Если Live Activity не появилась,
проверьте её разрешение в настройках MusicBridge и наличие расширения в логе установки.
Автоматические снимки `live-card-*-screen.png` показывают общую SwiftUI-разметку
карточки в Debug-предпросмотре: это не доказательство её регистрации на реальном
экране блокировки или системного отображения Dynamic Island.
## Источники
- [Apple: проверка доверия сервера](https://developer.apple.com/documentation/Foundation/performing-manual-server-trust-authentication)
+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
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>")
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="<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"),
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.")
+4 -2
View File
@@ -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"] == {
+19
View File
@@ -29,5 +29,24 @@ with zipfile.ZipFile(path) as archive:
platforms.append(struct.unpack_from("<I", binary, offset + 8)[0])
offset += size
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("PASS: embedded WidgetKit extension, matching version, ARM64 iPhoneOS and Live Activities support.")
print("Requires local signing with Sideloadly before installation on an iPhone.")