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 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)
}
}
}
+47 -6
View File
@@ -43,8 +43,11 @@ Windows-агент — соседний проект `C:/Users/areso/MusicBridge
- [x] Реализованы понятные ошибки сопряжения по кодам сервера, повтор загрузки обложки после 404 и ручной повтор. - [x] Реализованы понятные ошибки сопряжения по кодам сервера, повтор загрузки обложки после 404 и ручной повтор.
- [x] Иконка приложения и обновлённый интерфейс подключения/плеера. - [x] Иконка приложения и обновлённый интерфейс подключения/плеера.
- [x] Просмотрены снимки 0.4.0: подключение, светлая/тёмная темы, iPhone SE и крупный текст. - [x] Просмотрены снимки 0.4.0: подключение, светлая/тёмная темы, iPhone SE и крупный текст.
- [ ] Проверить новый интерфейс и иконку 0.4.0 на физическом iPhone. - [x] Пользователь подтвердил работу 0.4.0 на iPhone: «всё вроде работает».
- [ ] Live Activities / Dynamic Island / App Intents — отдельный последующий этап. - [x] Локальная Live Activity и Dynamic Island: реализация отображения трека в 0.5.0.
- [ ] Проверить Live Activity на экране блокировки и Dynamic Island после подписи через iloader.
- [ ] App Intents: прямые действия из Live Activity и команды Siri.
- [ ] APNs: отдельный проект обновлений при приостановленном приложении, если нужен постоянный актуальный трек в фоне.
Ограничения: хранится один доверенный ПК; адрес при смене IP восстанавливается через Ограничения: хранится один доверенный ПК; адрес при смене IP восстанавливается через
Bonjour, если доступны объявления того же агента. Новый адрес сохраняется только Bonjour, если доступны объявления того же агента. Новый адрес сохраняется только
@@ -93,6 +96,9 @@ Bonjour, если доступны объявления того же агент
- `QRScanner.swift`: камера AVFoundation, доступ, сканирование и жизненный цикл. - `QRScanner.swift`: камера AVFoundation, доступ, сканирование и жизненный цикл.
- `DiscoveryStore.swift`: Bonjour, разрешение адресов, TXT и безопасный выбор адреса. - `DiscoveryStore.swift`: Bonjour, разрешение адресов, TXT и безопасный выбор адреса.
- `ConnectionStorage.swift`: доступ к Keychain и подмена хранилища для изолированных тестов жизненного цикла. - `ConnectionStorage.swift`: доступ к Keychain и подмена хранилища для изолированных тестов жизненного цикла.
- `LiveActivityManager.swift`: запуск, обновление, устаревание и завершение одной локальной Live Activity.
- `Shared/`: ограниченный публичный снимок трека и общие SwiftUI-компоненты карточки.
- `MusicBridgeWidgets/`: расширение WidgetKit, экран блокировки и все представления Dynamic Island.
- `MusicBridgeTests/`: XCTest и пример Windows JSON. - `MusicBridgeTests/`: XCTest и пример Windows JSON.
- `tools/`: генератор проекта, структурные проверки, выбор симулятора для CI. - `tools/`: генератор проекта, структурные проверки, выбор симулятора для CI.
- `.github/workflows/ios.yml`: CI на macOS. - `.github/workflows/ios.yml`: CI на macOS.
@@ -136,12 +142,47 @@ Bonjour, если доступны объявления того же агент
2. **Интерфейс — версия 0.4.0 (build 5).** Добавлены иконка, компактный экран QR, 2. **Интерфейс — версия 0.4.0 (build 5).** Добавлены иконка, компактный экран QR,
раскрываемые ручные поля, крупная play/pause, процент громкости и меню ПК. раскрываемые ручные поля, крупная play/pause, процент громкости и меню ПК.
Сборка и 23 XCTest прошли; просмотрены темы, компактный экран и крупный текст. Сборка и 23 XCTest прошли; просмотрены темы, компактный экран и крупный текст.
Сценарии QR и ручного подключения сохранены; нужна проверка на телефоне. Пользователь подтвердил работу обновления на телефоне перед началом этапа 3.
3. **Системные интеграции.** Сначала исследовать актуальные ограничения Live Activities, 3. **Системные интеграции — 0.5.0 (build 6).** Локальная Live Activity и Dynamic Island
Dynamic Island, App Intents и локальной связи в фоне; затем реализовать доступные реализованы; 26 XCTest и Release archive прошли. Карточка показывает последний снимок
сценарии. Не имитировать фоновое аудио. Не обещать постоянный WSS при блокировке. с ПК, открывает плеер, помечает устаревшие данные. App Intents и APNs не реализованы.
Не имитировать фоновое аудио. Не обещать постоянный WSS при блокировке.
4. **Несколько сохранённых ПК** — последующее расширение, сейчас сохраняется один. 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) - Итоговый `aa0b0f0`: [Actions 34746477145](https://github.com/polskikh13/musicbridgeios/actions/runs/34746477145)
+33 -3
View File
@@ -17,7 +17,8 @@
**Статус:** пользователь подтвердил установку через iloader, рабочий пилот с **Статус:** пользователь подтвердил установку через iloader, рабочий пилот с
Windows-агентом и подключение по QR. В 0.4.0 добавлены иконка и обновлённый Windows-агентом и подключение по QR. В 0.4.0 добавлены иконка и обновлённый
интерфейс. Автоматические проверки включают 23 XCTest и Release archive; интерфейс, в 0.5.0 — локальная Live Activity и Dynamic Island. CI собирает
приложение и расширение WidgetKit, выполняет XCTest и Release archive;
полная проверка устойчивости на физических устройствах остаётся в `PLAN.md`. полная проверка устойчивости на физических устройствах остаётся в `PLAN.md`.
## Сборка на Mac ## Сборка на Mac
@@ -67,8 +68,8 @@ Apple ID и сертификаты для этой проверки не тре
## Установка на iPhone через Sideloadly ## Установка на iPhone через Sideloadly
Пользователь также подтвердил установку через iloader. Для повторной установки Пользователь также подтвердил установку через iloader. Для повторной установки
с новым интерфейсом используйте версию **0.4.0 (build 5)**: с Live Activity используйте версию **0.5.0 (build 6)**:
`build/iPhone-0.4.0/MusicBridge-iPhone.ipa`. Выберите этот файл в iloader и `build/iPhone-0.5.0/MusicBridge-iPhone.ipa`. Выберите этот файл в iloader и
подпишите тем же Apple Account, которым установлена первая версия. подпишите тем же Apple Account, которым установлена первая версия.
В 0.4.0 добавлены иконка, карточка QR, раскрываемые ручные поля, крупная В 0.4.0 добавлены иконка, карточка QR, раскрываемые ручные поля, крупная
@@ -105,6 +106,35 @@ Sideloadly (в том числе его функцией автоматичес
[Sideloadly FAQ](https://sideloadly.io/faq) · [Sideloadly FAQ](https://sideloadly.io/faq) ·
[Apple: режим разработчика](https://developer.apple.com/documentation/Xcode/enabling-developer-mode-on-a-device) [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) - [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 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.")