Control PC playback through interactive Live Activity intents
This commit is contained in:
@@ -55,6 +55,16 @@
|
|||||||
"isa" = "PBXBuildFile";
|
"isa" = "PBXBuildFile";
|
||||||
"fileRef" = "76FB6BFFC478599567424755";
|
"fileRef" = "76FB6BFFC478599567424755";
|
||||||
};
|
};
|
||||||
|
"581AD2E9199DAED730DC0F6E" = {
|
||||||
|
"isa" = "PBXFileReference";
|
||||||
|
"lastKnownFileType" = "sourcecode.swift";
|
||||||
|
"path" = "LiveActivityCommands.swift";
|
||||||
|
"sourceTree" = "<group>";
|
||||||
|
};
|
||||||
|
"D6D68067600EEBE36972550E" = {
|
||||||
|
"isa" = "PBXBuildFile";
|
||||||
|
"fileRef" = "581AD2E9199DAED730DC0F6E";
|
||||||
|
};
|
||||||
"EE2357BD5CA3755AFEB3171A" = {
|
"EE2357BD5CA3755AFEB3171A" = {
|
||||||
"isa" = "PBXFileReference";
|
"isa" = "PBXFileReference";
|
||||||
"lastKnownFileType" = "sourcecode.swift";
|
"lastKnownFileType" = "sourcecode.swift";
|
||||||
@@ -119,7 +129,7 @@
|
|||||||
"isa" = "PBXGroup";
|
"isa" = "PBXGroup";
|
||||||
"path" = "MusicBridge";
|
"path" = "MusicBridge";
|
||||||
"sourceTree" = "<group>";
|
"sourceTree" = "<group>";
|
||||||
"children" = ("065A9D559DC80AAF876DA757", "763FA6A137E5E94274AC56A3", "971C0AE92F167DE7AB145086", "5F6F408829AB46D3B85FB911", "76FB6BFFC478599567424755", "EE2357BD5CA3755AFEB3171A", "124CF3F5AC075D1D6042DF64", "ADF0CD56882201BD3AC1581B", "9C432A0DA0BEC173839EBF86", "6B0F2027221CDD63E8FD4C59", "7148B52E915554F67E875C26", "8BF729FFE074CAEE622C0292",);
|
"children" = ("065A9D559DC80AAF876DA757", "763FA6A137E5E94274AC56A3", "971C0AE92F167DE7AB145086", "5F6F408829AB46D3B85FB911", "76FB6BFFC478599567424755", "581AD2E9199DAED730DC0F6E", "EE2357BD5CA3755AFEB3171A", "124CF3F5AC075D1D6042DF64", "ADF0CD56882201BD3AC1581B", "9C432A0DA0BEC173839EBF86", "6B0F2027221CDD63E8FD4C59", "7148B52E915554F67E875C26", "8BF729FFE074CAEE622C0292",);
|
||||||
};
|
};
|
||||||
"7C5E653CCD928B143AB68F09" = {
|
"7C5E653CCD928B143AB68F09" = {
|
||||||
"isa" = "PBXFileReference";
|
"isa" = "PBXFileReference";
|
||||||
@@ -141,11 +151,21 @@
|
|||||||
"isa" = "PBXBuildFile";
|
"isa" = "PBXBuildFile";
|
||||||
"fileRef" = "7C0FE298B442263CA281ABB7";
|
"fileRef" = "7C0FE298B442263CA281ABB7";
|
||||||
};
|
};
|
||||||
|
"6EDC1D9A7204CF776CE3B281" = {
|
||||||
|
"isa" = "PBXFileReference";
|
||||||
|
"lastKnownFileType" = "sourcecode.swift";
|
||||||
|
"path" = "MusicControlIntent.swift";
|
||||||
|
"sourceTree" = "<group>";
|
||||||
|
};
|
||||||
|
"962BED0D8E17961AD72EC24F" = {
|
||||||
|
"isa" = "PBXBuildFile";
|
||||||
|
"fileRef" = "6EDC1D9A7204CF776CE3B281";
|
||||||
|
};
|
||||||
"E3C4B39D6D5013477C98CD58" = {
|
"E3C4B39D6D5013477C98CD58" = {
|
||||||
"isa" = "PBXGroup";
|
"isa" = "PBXGroup";
|
||||||
"path" = "Shared";
|
"path" = "Shared";
|
||||||
"sourceTree" = "<group>";
|
"sourceTree" = "<group>";
|
||||||
"children" = ("7C5E653CCD928B143AB68F09", "7C0FE298B442263CA281ABB7",);
|
"children" = ("7C5E653CCD928B143AB68F09", "7C0FE298B442263CA281ABB7", "6EDC1D9A7204CF776CE3B281",);
|
||||||
};
|
};
|
||||||
"ADFE24674E0FB119E649EC54" = {
|
"ADFE24674E0FB119E649EC54" = {
|
||||||
"isa" = "PBXFileReference";
|
"isa" = "PBXFileReference";
|
||||||
@@ -171,6 +191,10 @@
|
|||||||
"isa" = "PBXBuildFile";
|
"isa" = "PBXBuildFile";
|
||||||
"fileRef" = "7C0FE298B442263CA281ABB7";
|
"fileRef" = "7C0FE298B442263CA281ABB7";
|
||||||
};
|
};
|
||||||
|
"41D7F12D1C7D61DAF319E724" = {
|
||||||
|
"isa" = "PBXBuildFile";
|
||||||
|
"fileRef" = "6EDC1D9A7204CF776CE3B281";
|
||||||
|
};
|
||||||
"8BF729FFE074CAEE622C0292" = {
|
"8BF729FFE074CAEE622C0292" = {
|
||||||
"isa" = "PBXFileReference";
|
"isa" = "PBXFileReference";
|
||||||
"lastKnownFileType" = "folder.assetcatalog";
|
"lastKnownFileType" = "folder.assetcatalog";
|
||||||
@@ -191,6 +215,16 @@
|
|||||||
"isa" = "PBXBuildFile";
|
"isa" = "PBXBuildFile";
|
||||||
"fileRef" = "C9F705D432CA45B6056BC572";
|
"fileRef" = "C9F705D432CA45B6056BC572";
|
||||||
};
|
};
|
||||||
|
"65CE96DA4162DE4255412BC7" = {
|
||||||
|
"isa" = "PBXFileReference";
|
||||||
|
"lastKnownFileType" = "sourcecode.swift";
|
||||||
|
"path" = "LiveActivityCommandTests.swift";
|
||||||
|
"sourceTree" = "<group>";
|
||||||
|
};
|
||||||
|
"E87BA6EF1948C84AC29D8EA8" = {
|
||||||
|
"isa" = "PBXBuildFile";
|
||||||
|
"fileRef" = "65CE96DA4162DE4255412BC7";
|
||||||
|
};
|
||||||
"9819FF7CC21CAAD1BDB8D1B8" = {
|
"9819FF7CC21CAAD1BDB8D1B8" = {
|
||||||
"isa" = "PBXFileReference";
|
"isa" = "PBXFileReference";
|
||||||
"lastKnownFileType" = "sourcecode.swift";
|
"lastKnownFileType" = "sourcecode.swift";
|
||||||
@@ -235,7 +269,7 @@
|
|||||||
"isa" = "PBXGroup";
|
"isa" = "PBXGroup";
|
||||||
"path" = "MusicBridgeTests";
|
"path" = "MusicBridgeTests";
|
||||||
"sourceTree" = "<group>";
|
"sourceTree" = "<group>";
|
||||||
"children" = ("C9F705D432CA45B6056BC572", "9819FF7CC21CAAD1BDB8D1B8", "ACD0393DCDDDF044F2966340", "464F6C65BC2FAB6BCCC77F46", "8BED860F1BD59C500D215E8D", "F16D05EC6B29248D2C61ADB1", "4731785CAB9BB914B8834AE6",);
|
"children" = ("C9F705D432CA45B6056BC572", "65CE96DA4162DE4255412BC7", "9819FF7CC21CAAD1BDB8D1B8", "ACD0393DCDDDF044F2966340", "464F6C65BC2FAB6BCCC77F46", "8BED860F1BD59C500D215E8D", "F16D05EC6B29248D2C61ADB1", "4731785CAB9BB914B8834AE6",);
|
||||||
};
|
};
|
||||||
"F16D05EC6B29248D2C61ADB1" = {
|
"F16D05EC6B29248D2C61ADB1" = {
|
||||||
"isa" = "PBXFileReference";
|
"isa" = "PBXFileReference";
|
||||||
@@ -267,7 +301,7 @@
|
|||||||
"72B74C8A2143F64FD53D71E5" = {
|
"72B74C8A2143F64FD53D71E5" = {
|
||||||
"isa" = "PBXSourcesBuildPhase";
|
"isa" = "PBXSourcesBuildPhase";
|
||||||
"buildActionMask" = 2147483647;
|
"buildActionMask" = 2147483647;
|
||||||
"files" = ("82F1C33CE4F8B8F641FF0461", "40D2920E2C996D62B7190BA4", "16EA630DE1FE9F0A16580C9D", "842E87CD23DCB5BBCA4340F6", "DA06B3B12E52FB69A6A34887", "181CACDC331104A1E16CFC20", "DEDCCD95A140162B6D654871", "48E2FDCA11A6694953C86E6D", "47693934E8A974162CF08A38", "287044ADBD143F929035BC61", "BE808A0D13A16FE67D32B7FB", "C7506B09240305F44C61EB60", "4FD4638045EDA8D7CCFD00AF",);
|
"files" = ("82F1C33CE4F8B8F641FF0461", "40D2920E2C996D62B7190BA4", "16EA630DE1FE9F0A16580C9D", "842E87CD23DCB5BBCA4340F6", "DA06B3B12E52FB69A6A34887", "D6D68067600EEBE36972550E", "181CACDC331104A1E16CFC20", "DEDCCD95A140162B6D654871", "48E2FDCA11A6694953C86E6D", "47693934E8A974162CF08A38", "287044ADBD143F929035BC61", "BE808A0D13A16FE67D32B7FB", "C7506B09240305F44C61EB60", "4FD4638045EDA8D7CCFD00AF", "962BED0D8E17961AD72EC24F",);
|
||||||
"runOnlyForDeploymentPostprocessing" = 0;
|
"runOnlyForDeploymentPostprocessing" = 0;
|
||||||
};
|
};
|
||||||
"734C4650C7A5604092763C32" = {
|
"734C4650C7A5604092763C32" = {
|
||||||
@@ -324,8 +358,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" = "6";
|
"CURRENT_PROJECT_VERSION" = "7";
|
||||||
"MARKETING_VERSION" = "0.5.0";
|
"MARKETING_VERSION" = "0.6.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";
|
||||||
@@ -346,8 +380,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" = "6";
|
"CURRENT_PROJECT_VERSION" = "7";
|
||||||
"MARKETING_VERSION" = "0.5.0";
|
"MARKETING_VERSION" = "0.6.0";
|
||||||
"ASSETCATALOG_COMPILER_APPICON_NAME" = "AppIcon";
|
"ASSETCATALOG_COMPILER_APPICON_NAME" = "AppIcon";
|
||||||
"SWIFT_OPTIMIZATION_LEVEL" = "-O";
|
"SWIFT_OPTIMIZATION_LEVEL" = "-O";
|
||||||
};
|
};
|
||||||
@@ -379,7 +413,7 @@
|
|||||||
"AB453A3F0E1490D48374A69A" = {
|
"AB453A3F0E1490D48374A69A" = {
|
||||||
"isa" = "PBXSourcesBuildPhase";
|
"isa" = "PBXSourcesBuildPhase";
|
||||||
"buildActionMask" = 2147483647;
|
"buildActionMask" = 2147483647;
|
||||||
"files" = ("1DD7BB0437E9F53586831F22", "BD0B3FAC7F1259D249134283", "17E248E58CB43C680B48DD51", "EFDB280E0B23F7E2A246BC89", "DC1A5B0B94F4C98F1CA36676",);
|
"files" = ("1DD7BB0437E9F53586831F22", "E87BA6EF1948C84AC29D8EA8", "BD0B3FAC7F1259D249134283", "17E248E58CB43C680B48DD51", "EFDB280E0B23F7E2A246BC89", "DC1A5B0B94F4C98F1CA36676",);
|
||||||
"runOnlyForDeploymentPostprocessing" = 0;
|
"runOnlyForDeploymentPostprocessing" = 0;
|
||||||
};
|
};
|
||||||
"9AA2A4C344B5C474AF5D336C" = {
|
"9AA2A4C344B5C474AF5D336C" = {
|
||||||
@@ -471,7 +505,7 @@
|
|||||||
"FF6BDEA70DDE9A3C7EEA9DCF" = {
|
"FF6BDEA70DDE9A3C7EEA9DCF" = {
|
||||||
"isa" = "PBXSourcesBuildPhase";
|
"isa" = "PBXSourcesBuildPhase";
|
||||||
"buildActionMask" = 2147483647;
|
"buildActionMask" = 2147483647;
|
||||||
"files" = ("F942C4AC2D1ABF86575E779A", "D739DF07A523D6FA1D29EECD", "E41BBFD90B0D341CC92C100E",);
|
"files" = ("F942C4AC2D1ABF86575E779A", "D739DF07A523D6FA1D29EECD", "E41BBFD90B0D341CC92C100E", "41D7F12D1C7D61DAF319E724",);
|
||||||
"runOnlyForDeploymentPostprocessing" = 0;
|
"runOnlyForDeploymentPostprocessing" = 0;
|
||||||
};
|
};
|
||||||
"49C806A88B3E70B1004AB10D" = {
|
"49C806A88B3E70B1004AB10D" = {
|
||||||
@@ -500,14 +534,14 @@
|
|||||||
"CODE_SIGN_STYLE" = "Automatic";
|
"CODE_SIGN_STYLE" = "Automatic";
|
||||||
"GENERATE_INFOPLIST_FILE" = "NO";
|
"GENERATE_INFOPLIST_FILE" = "NO";
|
||||||
"INFOPLIST_FILE" = "MusicBridgeWidgets/Info.plist";
|
"INFOPLIST_FILE" = "MusicBridgeWidgets/Info.plist";
|
||||||
"CURRENT_PROJECT_VERSION" = "6";
|
"CURRENT_PROJECT_VERSION" = "7";
|
||||||
"MARKETING_VERSION" = "0.5.0";
|
"MARKETING_VERSION" = "0.6.0";
|
||||||
"APPLICATION_EXTENSION_API_ONLY" = "YES";
|
"APPLICATION_EXTENSION_API_ONLY" = "YES";
|
||||||
"SKIP_INSTALL" = "YES";
|
"SKIP_INSTALL" = "YES";
|
||||||
"LD_RUNPATH_SEARCH_PATHS" = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks";
|
"LD_RUNPATH_SEARCH_PATHS" = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks";
|
||||||
"SWIFT_OPTIMIZATION_LEVEL" = "-Onone";
|
"SWIFT_OPTIMIZATION_LEVEL" = "-Onone";
|
||||||
"ENABLE_TESTABILITY" = "YES";
|
"ENABLE_TESTABILITY" = "YES";
|
||||||
"SWIFT_ACTIVE_COMPILATION_CONDITIONS" = "DEBUG";
|
"SWIFT_ACTIVE_COMPILATION_CONDITIONS" = "DEBUG MUSICBRIDGE_WIDGET";
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
"6A281BFCCFF96276806F5934" = {
|
"6A281BFCCFF96276806F5934" = {
|
||||||
@@ -524,12 +558,13 @@
|
|||||||
"CODE_SIGN_STYLE" = "Automatic";
|
"CODE_SIGN_STYLE" = "Automatic";
|
||||||
"GENERATE_INFOPLIST_FILE" = "NO";
|
"GENERATE_INFOPLIST_FILE" = "NO";
|
||||||
"INFOPLIST_FILE" = "MusicBridgeWidgets/Info.plist";
|
"INFOPLIST_FILE" = "MusicBridgeWidgets/Info.plist";
|
||||||
"CURRENT_PROJECT_VERSION" = "6";
|
"CURRENT_PROJECT_VERSION" = "7";
|
||||||
"MARKETING_VERSION" = "0.5.0";
|
"MARKETING_VERSION" = "0.6.0";
|
||||||
"APPLICATION_EXTENSION_API_ONLY" = "YES";
|
"APPLICATION_EXTENSION_API_ONLY" = "YES";
|
||||||
"SKIP_INSTALL" = "YES";
|
"SKIP_INSTALL" = "YES";
|
||||||
"LD_RUNPATH_SEARCH_PATHS" = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks";
|
"LD_RUNPATH_SEARCH_PATHS" = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks";
|
||||||
"SWIFT_OPTIMIZATION_LEVEL" = "-O";
|
"SWIFT_OPTIMIZATION_LEVEL" = "-O";
|
||||||
|
"SWIFT_ACTIVE_COMPILATION_CONDITIONS" = " MUSICBRIDGE_WIDGET";
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
"5DCAC255CDC0CA72231320F4" = {
|
"5DCAC255CDC0CA72231320F4" = {
|
||||||
|
|||||||
@@ -59,11 +59,11 @@ final class BridgeClient {
|
|||||||
private let session: URLSession
|
private let session: URLSession
|
||||||
private var socket: URLSessionWebSocketTask?
|
private var socket: URLSessionWebSocketTask?
|
||||||
|
|
||||||
init(_ connection: Connection) {
|
init(_ connection: Connection, timeout: TimeInterval = 10) {
|
||||||
self.connection = connection
|
self.connection = connection
|
||||||
let config = URLSessionConfiguration.ephemeral
|
let config = URLSessionConfiguration.ephemeral
|
||||||
config.timeoutIntervalForRequest = 10
|
config.timeoutIntervalForRequest = timeout
|
||||||
config.timeoutIntervalForResource = 15
|
config.timeoutIntervalForResource = timeout + 5
|
||||||
config.urlCache = nil
|
config.urlCache = nil
|
||||||
config.httpCookieStorage = nil
|
config.httpCookieStorage = nil
|
||||||
session = URLSession(configuration: config, delegate: PinnedDelegate(connection), delegateQueue: nil)
|
session = URLSession(configuration: config, delegate: PinnedDelegate(connection), delegateQueue: nil)
|
||||||
|
|||||||
@@ -23,11 +23,11 @@ struct ContentView: View {
|
|||||||
VStack(alignment: .leading, spacing: 10) {
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
Button {
|
Button {
|
||||||
if liveActivity.running { liveActivity.end() }
|
if liveActivity.running { liveActivity.end() }
|
||||||
else if let state = store.state { liveActivity.start(state) }
|
else if let state = store.state, let connection = store.connection { liveActivity.start(state, connection: connection) }
|
||||||
} label: {
|
} label: {
|
||||||
Label(liveActivity.running ? "Убрать с экрана блокировки" : "Показать на экране блокировки", systemImage: "waveform.rectangle")
|
Label(liveActivity.running ? "Убрать с экрана блокировки" : "Показать на экране блокировки", systemImage: "waveform.rectangle")
|
||||||
}.disabled(!liveActivity.running && (!store.connected || store.state?.hasSession != true))
|
}.disabled(!liveActivity.running && (!store.connected || store.state?.hasSession != true))
|
||||||
Text("Live Activity и Dynamic Island показывают последний трек. В фоне данные могут устареть; нажмите карточку, чтобы открыть плеер.")
|
Text("Переключайте треки на экране блокировки и в развёрнутом Dynamic Island. Кнопка обновления получает текущее состояние ПК.")
|
||||||
.font(.caption).foregroundStyle(.secondary)
|
.font(.caption).foregroundStyle(.secondary)
|
||||||
if let error = liveActivity.error { Text(error).font(.caption).foregroundStyle(.red) }
|
if let error = liveActivity.error { Text(error).font(.caption).foregroundStyle(.red) }
|
||||||
}.frame(maxWidth: .infinity, alignment: .leading).bridgeCard()
|
}.frame(maxWidth: .infinity, alignment: .leading).bridgeCard()
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ enum KeychainStore {
|
|||||||
|
|
||||||
static func save(_ connection: Connection) throws {
|
static func save(_ connection: Connection) throws {
|
||||||
let values: [String: Any] = [kSecValueData as String: try JSONEncoder().encode(connection),
|
let values: [String: Any] = [kSecValueData as String: try JSONEncoder().encode(connection),
|
||||||
kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly]
|
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly]
|
||||||
var status = SecItemUpdate(query as CFDictionary, values as CFDictionary)
|
var status = SecItemUpdate(query as CFDictionary, values as CFDictionary)
|
||||||
if status == errSecItemNotFound {
|
if status == errSecItemNotFound {
|
||||||
status = SecItemAdd(query.merging(values) { _, new in new } as CFDictionary, nil)
|
status = SecItemAdd(query.merging(values) { _, new in new } as CFDictionary, nil)
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import ActivityKit
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
enum LiveActivityCommands {
|
||||||
|
private static var busy = false
|
||||||
|
static let bindingKey = "MusicBridge.liveActivityPC"
|
||||||
|
|
||||||
|
static func perform(_ action: MusicRemoteAction, activityID: String) async {
|
||||||
|
guard !busy, let activity = Activity<MusicActivityAttributes>.activities.first(where: { $0.id == activityID }),
|
||||||
|
activity.activityState == .active || activity.activityState == .stale else { return }
|
||||||
|
busy = true
|
||||||
|
defer { busy = false }
|
||||||
|
do {
|
||||||
|
guard let saved = try KeychainStore.load(),
|
||||||
|
UserDefaults.standard.string(forKey: bindingKey + activityID) == saved.fingerprint else {
|
||||||
|
throw BridgeError.message("Откройте приложение и включите карточку заново.")
|
||||||
|
}
|
||||||
|
let client = BridgeClient(saved, timeout: 5)
|
||||||
|
defer { client.close() }
|
||||||
|
let fresh = try await execute(action, client: client) {
|
||||||
|
(activity.activityState == .active || activity.activityState == .stale) &&
|
||||||
|
UserDefaults.standard.string(forKey: bindingKey + activityID) == saved.fingerprint
|
||||||
|
}
|
||||||
|
guard fresh.hasSession else { await activity.end(nil, dismissalPolicy: .immediate); return }
|
||||||
|
await activity.update(ActivityContent(state: LiveActivityManager.content(fresh), staleDate: Date().addingTimeInterval(15)))
|
||||||
|
} catch {
|
||||||
|
if case BridgeError.unauthorized = error {
|
||||||
|
await activity.end(nil, dismissalPolicy: .immediate)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var value = activity.content.state
|
||||||
|
value.message = MusicActivityAttributes.ContentState.bounded(BridgeError.describe(error))
|
||||||
|
await activity.update(ActivityContent(state: value, staleDate: Date()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A fresh capability check, one command at most, then a fresh snapshot. No command retries.
|
||||||
|
static func execute(_ action: MusicRemoteAction, client: BridgeClient,
|
||||||
|
stillValid: () -> Bool = { true }) async throws -> MediaState {
|
||||||
|
let before = try await client.state()
|
||||||
|
guard stillValid() else { throw BridgeError.message("Карточка закрыта. Откройте MusicBridge.") }
|
||||||
|
if action == .refresh { return before }
|
||||||
|
guard before.hasSession else { return before }
|
||||||
|
let command = try command(action, state: before)
|
||||||
|
do { try await client.command(Command(type: command, value: nil)) }
|
||||||
|
catch {
|
||||||
|
if error is URLError { throw BridgeError.message("Результат команды неизвестен. Нажмите обновление перед повтором.") }
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
do { return try await client.state() }
|
||||||
|
catch {
|
||||||
|
if case BridgeError.unauthorized = error { throw error }
|
||||||
|
throw BridgeError.message("Команда выполнена. Нажмите обновление, чтобы получить новый трек.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func command(_ action: MusicRemoteAction, state: MediaState) throws -> String {
|
||||||
|
switch action {
|
||||||
|
case .previous where state.capabilities.previous: return "previous"
|
||||||
|
case .next where state.capabilities.next: return "next"
|
||||||
|
case .toggle where state.capabilities.toggle: return "toggle"
|
||||||
|
case .toggle where state.playbackStatus == "Playing" && state.capabilities.pause: return "pause"
|
||||||
|
case .toggle where state.playbackStatus != "Playing" && state.capabilities.play: return "play"
|
||||||
|
default: throw BridgeError.message("Плеер на ПК не поддерживает эту команду.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -32,18 +32,21 @@ final class LiveActivityManager: ObservableObject {
|
|||||||
duration: max(0, state.endSeconds - state.startSeconds), updatedAt: now, connected: true)
|
duration: max(0, state.endSeconds - state.startSeconds), updatedAt: now, connected: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
func start(_ state: MediaState) {
|
func start(_ state: MediaState, connection: Connection) {
|
||||||
guard !running, state.hasSession, UIApplication.shared.applicationState == .active else { return }
|
guard !running, state.hasSession, UIApplication.shared.applicationState == .active else { return }
|
||||||
guard ActivityAuthorizationInfo().areActivitiesEnabled else {
|
guard ActivityAuthorizationInfo().areActivitiesEnabled else {
|
||||||
error = "Разрешите Live Activities в настройках MusicBridge на iPhone."
|
error = "Разрешите Live Activities в настройках MusicBridge на iPhone."
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
do {
|
do {
|
||||||
|
// Migrate existing pairing so an explicit lock-screen action can read it after first unlock.
|
||||||
|
try KeychainStore.save(connection)
|
||||||
let value = Self.content(state)
|
let value = Self.content(state)
|
||||||
let new = try Activity.request(attributes: MusicActivityAttributes(),
|
let new = try Activity.request(attributes: MusicActivityAttributes(),
|
||||||
content: ActivityContent(state: value, staleDate: Date().addingTimeInterval(15)),
|
content: ActivityContent(state: value, staleDate: Date().addingTimeInterval(15)),
|
||||||
pushType: nil)
|
pushType: nil)
|
||||||
activity = new
|
activity = new
|
||||||
|
UserDefaults.standard.set(connection.fingerprint, forKey: LiveActivityCommands.bindingKey + new.id)
|
||||||
last = value
|
last = value
|
||||||
running = true
|
running = true
|
||||||
error = nil
|
error = nil
|
||||||
@@ -72,6 +75,7 @@ final class LiveActivityManager: ObservableObject {
|
|||||||
|
|
||||||
func end() {
|
func end() {
|
||||||
guard let activity else { return }
|
guard let activity else { return }
|
||||||
|
UserDefaults.standard.removeObject(forKey: LiveActivityCommands.bindingKey + activity.id)
|
||||||
self.activity = nil
|
self.activity = nil
|
||||||
running = false
|
running = false
|
||||||
last = nil
|
last = nil
|
||||||
|
|||||||
@@ -257,7 +257,6 @@ final class PlayerStore: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func stop() {
|
private func stop() {
|
||||||
liveActivity?.unavailable()
|
|
||||||
generation = UUID()
|
generation = UUID()
|
||||||
runner?.cancel()
|
runner?.cancel()
|
||||||
runner = nil
|
runner = nil
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import XCTest
|
||||||
|
@testable import MusicBridge
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class LiveActivityCommandTests: XCTestCase {
|
||||||
|
private func client(_ token: String) throws -> BridgeClient {
|
||||||
|
let url = try XCTUnwrap(Bundle(for: Self.self).url(forResource: "tls-fixture", withExtension: "json"))
|
||||||
|
let fixtures = try JSONDecoder().decode([String: [String: String]].self, from: Data(contentsOf: url))
|
||||||
|
let f = try XCTUnwrap(fixtures["valid"])
|
||||||
|
return BridgeClient(try Connection(endpoint: XCTUnwrap(f["endpoint"]), fingerprint: XCTUnwrap(f["fingerprint"]), token: token))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testNextUsesOneCommandAndReturnsUpdatedTrack() async throws {
|
||||||
|
let client = try client("live-next"); defer { client.close() }
|
||||||
|
let result = try await LiveActivityCommands.execute(.next, client: client)
|
||||||
|
XCTAssertEqual(result.title, "Track 1")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testRefreshDoesNotSendCommand() async throws {
|
||||||
|
let client = try client("live-refresh"); defer { client.close() }
|
||||||
|
let result = try await LiveActivityCommands.execute(.refresh, client: client)
|
||||||
|
XCTAssertEqual(result.title, "Track 0")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testClosedActivityDoesNotSendCommand() async throws {
|
||||||
|
let client = try client("live-closed"); defer { client.close() }
|
||||||
|
do { _ = try await LiveActivityCommands.execute(.next, client: client, stillValid: { false }); XCTFail("Must reject") }
|
||||||
|
catch { }
|
||||||
|
let state = try await client.state()
|
||||||
|
XCTAssertEqual(state.title, "Track 0")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testUnsupportedCommandIsNotSent() async throws {
|
||||||
|
let client = try client("live-unsupported"); defer { client.close() }
|
||||||
|
do { _ = try await LiveActivityCommands.execute(.next, client: client); XCTFail("Must reject") }
|
||||||
|
catch { }
|
||||||
|
let state = try await client.state()
|
||||||
|
XCTAssertEqual(state.title, "Track 0")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testRejectedCommandIsNotRetried() async throws {
|
||||||
|
let client = try client("live-reject"); defer { client.close() }
|
||||||
|
do { _ = try await LiveActivityCommands.execute(.next, client: client); XCTFail("Must reject") }
|
||||||
|
catch { }
|
||||||
|
let state = try await client.state()
|
||||||
|
XCTAssertEqual(state.title, "Track 1")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testFailedReadAfterSuccessReportsCommandCompleted() async throws {
|
||||||
|
let client = try client("live-refresh-fail"); defer { client.close() }
|
||||||
|
do { _ = try await LiveActivityCommands.execute(.next, client: client); XCTFail("Must report refresh failure") }
|
||||||
|
catch { XCTAssertTrue(error.localizedDescription.contains("Команда выполнена")) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,7 +10,7 @@ struct MusicBridgeWidgets: WidgetBundle {
|
|||||||
struct MusicBridgeLiveActivity: Widget {
|
struct MusicBridgeLiveActivity: Widget {
|
||||||
var body: some WidgetConfiguration {
|
var body: some WidgetConfiguration {
|
||||||
ActivityConfiguration(for: MusicActivityAttributes.self) { context in
|
ActivityConfiguration(for: MusicActivityAttributes.self) { context in
|
||||||
MusicActivityCard(state: context.state, stale: context.isStale)
|
MusicActivityCard(state: context.state, stale: context.isStale, activityID: context.activityID)
|
||||||
.activityBackgroundTint(Color(.secondarySystemBackground))
|
.activityBackgroundTint(Color(.secondarySystemBackground))
|
||||||
.activitySystemActionForegroundColor(.indigo)
|
.activitySystemActionForegroundColor(.indigo)
|
||||||
.widgetURL(URL(string: "musicbridge://player"))
|
.widgetURL(URL(string: "musicbridge://player"))
|
||||||
@@ -27,8 +27,7 @@ struct MusicBridgeLiveActivity: Widget {
|
|||||||
TrackLabels(state: context.state)
|
TrackLabels(state: context.state)
|
||||||
ProgressView(value: context.state.progress).tint(.cyan)
|
ProgressView(value: context.state.progress).tint(.cyan)
|
||||||
ActivityStatus(state: context.state, stale: context.isStale)
|
ActivityStatus(state: context.state, stale: context.isStale)
|
||||||
Link("Открыть плеер", destination: URL(string: "musicbridge://player")!)
|
MusicActivityControls(activityID: context.activityID).foregroundStyle(.cyan)
|
||||||
.font(.caption.weight(.semibold)).foregroundStyle(.cyan)
|
|
||||||
}.padding(.bottom, 4)
|
}.padding(.bottom, 4)
|
||||||
}
|
}
|
||||||
} compactLeading: {
|
} compactLeading: {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ struct MusicActivityAttributes: ActivityAttributes {
|
|||||||
var duration: Double
|
var duration: Double
|
||||||
var updatedAt: Date
|
var updatedAt: Date
|
||||||
var connected: Bool
|
var connected: Bool
|
||||||
|
var message: String? = nil
|
||||||
|
|
||||||
// Bound UTF-8, including pathological combining characters, below ActivityKit's 4 KB limit.
|
// Bound UTF-8, including pathological combining characters, below ActivityKit's 4 KB limit.
|
||||||
static func bounded(_ text: String) -> String {
|
static func bounded(_ text: String) -> String {
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import AppIntents
|
||||||
|
|
||||||
struct MusicActivityCard: View {
|
struct MusicActivityCard: View {
|
||||||
let state: MusicActivityAttributes.ContentState
|
let state: MusicActivityAttributes.ContentState
|
||||||
let stale: Bool
|
let stale: Bool
|
||||||
|
var activityID: String = ""
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 10) {
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
HStack(spacing: 12) {
|
HStack(spacing: 12) {
|
||||||
@@ -14,7 +16,7 @@ struct MusicActivityCard: View {
|
|||||||
}
|
}
|
||||||
ProgressView(value: state.progress).tint(.indigo)
|
ProgressView(value: state.progress).tint(.indigo)
|
||||||
ActivityStatus(state: state, stale: stale)
|
ActivityStatus(state: state, stale: stale)
|
||||||
Label("Открыть плеер", systemImage: "arrow.up.forward.app").font(.caption.weight(.semibold))
|
MusicActivityControls(activityID: activityID)
|
||||||
}.padding(16)
|
}.padding(16)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -34,13 +36,35 @@ struct ActivityStatus: View {
|
|||||||
let state: MusicActivityAttributes.ContentState
|
let state: MusicActivityAttributes.ContentState
|
||||||
let stale: Bool
|
let stale: Bool
|
||||||
var body: some View {
|
var body: some View {
|
||||||
if stale || !state.connected {
|
if let message = state.message {
|
||||||
Text("Данные устарели · откройте плеер").font(.caption).foregroundStyle(.secondary)
|
Text(message).font(.caption).lineLimit(2)
|
||||||
|
} else if !state.connected {
|
||||||
|
Text("Нет связи с ПК · нажмите обновление").font(.caption).foregroundStyle(.secondary)
|
||||||
} else {
|
} else {
|
||||||
HStack(spacing: 4) {
|
HStack(spacing: 4) {
|
||||||
Text("Снимок с ПК в")
|
Text("Обновлено в")
|
||||||
Text(state.updatedAt, style: .time)
|
Text(state.updatedAt, style: .time)
|
||||||
}.font(.caption).foregroundStyle(.secondary)
|
}.font(.caption).foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct MusicActivityControls: View {
|
||||||
|
let activityID: String
|
||||||
|
var body: some View {
|
||||||
|
HStack {
|
||||||
|
control(.previous, icon: "backward.end.fill", label: "Предыдущий трек")
|
||||||
|
Spacer(minLength: 4)
|
||||||
|
control(.toggle, icon: "playpause.fill", label: "Воспроизведение или пауза")
|
||||||
|
Spacer(minLength: 4)
|
||||||
|
control(.next, icon: "forward.end.fill", label: "Следующий трек")
|
||||||
|
Spacer(minLength: 4)
|
||||||
|
control(.refresh, icon: "arrow.clockwise", label: "Обновить трек")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
private func control(_ action: MusicRemoteAction, icon: String, label: String) -> some View {
|
||||||
|
Button(intent: MusicControlIntent(action, activityID: activityID)) {
|
||||||
|
Image(systemName: icon).font(.title3).frame(minWidth: 44, minHeight: 44)
|
||||||
|
}.buttonStyle(.plain).accessibilityLabel(label).disabled(activityID.isEmpty)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import AppIntents
|
||||||
|
|
||||||
|
enum MusicRemoteAction: String, AppEnum {
|
||||||
|
case previous, toggle, next, refresh
|
||||||
|
static var typeDisplayRepresentation: TypeDisplayRepresentation = "Команда плеера"
|
||||||
|
static var caseDisplayRepresentations: [Self: DisplayRepresentation] = [
|
||||||
|
.previous: "Предыдущий трек", .toggle: "Воспроизведение / пауза", .next: "Следующий трек", .refresh: "Обновить"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
struct MusicControlIntent: LiveActivityIntent {
|
||||||
|
static var title: LocalizedStringResource = "Управлять музыкой на ПК"
|
||||||
|
static var openAppWhenRun: Bool = false
|
||||||
|
static var isDiscoverable: Bool = false
|
||||||
|
static var authenticationPolicy: IntentAuthenticationPolicy = .alwaysAllowed
|
||||||
|
@Parameter(title: "Команда") var action: MusicRemoteAction
|
||||||
|
@Parameter(title: "Активность") var activityID: String
|
||||||
|
|
||||||
|
init() {}
|
||||||
|
init(_ action: MusicRemoteAction, activityID: String) { self.action = action; self.activityID = activityID }
|
||||||
|
|
||||||
|
func perform() async throws -> some IntentResult {
|
||||||
|
#if !MUSICBRIDGE_WIDGET
|
||||||
|
await LiveActivityCommands.perform(action, activityID: activityID)
|
||||||
|
#endif
|
||||||
|
return .result()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,6 +22,8 @@ def configurations(name, settings):
|
|||||||
values.update(SWIFT_OPTIMIZATION_LEVEL="-Onone" if mode == "Debug" else "-O")
|
values.update(SWIFT_OPTIMIZATION_LEVEL="-Onone" if mode == "Debug" else "-O")
|
||||||
if mode == "Debug":
|
if mode == "Debug":
|
||||||
values.update(ENABLE_TESTABILITY="YES", SWIFT_ACTIVE_COMPILATION_CONDITIONS="DEBUG")
|
values.update(ENABLE_TESTABILITY="YES", SWIFT_ACTIVE_COMPILATION_CONDITIONS="DEBUG")
|
||||||
|
if name == "MusicBridgeWidgets":
|
||||||
|
values["SWIFT_ACTIVE_COMPILATION_CONDITIONS"] = values.get("SWIFT_ACTIVE_COMPILATION_CONDITIONS", "") + " MUSICBRIDGE_WIDGET"
|
||||||
configs.append(add(name + mode, "XCBuildConfiguration", name=mode, buildSettings=values))
|
configs.append(add(name + mode, "XCBuildConfiguration", name=mode, buildSettings=values))
|
||||||
return add(name + "configs", "XCConfigurationList", buildConfigurations=configs,
|
return add(name + "configs", "XCConfigurationList", buildConfigurations=configs,
|
||||||
defaultConfigurationIsVisible=0, defaultConfigurationName="Release")
|
defaultConfigurationIsVisible=0, defaultConfigurationName="Release")
|
||||||
@@ -70,11 +72,11 @@ for name, sources, is_test in [("MusicBridge", app_sources, False), ("MusicBridg
|
|||||||
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:
|
elif is_widget:
|
||||||
settings.update(INFOPLIST_FILE="MusicBridgeWidgets/Info.plist", CURRENT_PROJECT_VERSION="6", MARKETING_VERSION="0.5.0",
|
settings.update(INFOPLIST_FILE="MusicBridgeWidgets/Info.plist", CURRENT_PROJECT_VERSION="7", MARKETING_VERSION="0.6.0",
|
||||||
APPLICATION_EXTENSION_API_ONLY="YES", SKIP_INSTALL="YES",
|
APPLICATION_EXTENSION_API_ONLY="YES", SKIP_INSTALL="YES",
|
||||||
LD_RUNPATH_SEARCH_PATHS="$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks")
|
LD_RUNPATH_SEARCH_PATHS="$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks")
|
||||||
else:
|
else:
|
||||||
settings.update(INFOPLIST_FILE="MusicBridge/Info.plist", CURRENT_PROJECT_VERSION="6", MARKETING_VERSION="0.5.0", ASSETCATALOG_COMPILER_APPICON_NAME="AppIcon")
|
settings.update(INFOPLIST_FILE="MusicBridge/Info.plist", CURRENT_PROJECT_VERSION="7", MARKETING_VERSION="0.6.0", ASSETCATALOG_COMPILER_APPICON_NAME="AppIcon")
|
||||||
embed = add("embed-widget", "PBXBuildFile", fileRef=uid("MusicBridgeWidgetsproduct"), settings=dict(ATTRIBUTES=["RemoveHeadersOnCopy"]))
|
embed = add("embed-widget", "PBXBuildFile", fileRef=uid("MusicBridgeWidgetsproduct"), settings=dict(ATTRIBUTES=["RemoveHeadersOnCopy"]))
|
||||||
phases.append(add("embed-extensions", "PBXCopyFilesBuildPhase", buildActionMask=2147483647,
|
phases.append(add("embed-extensions", "PBXCopyFilesBuildPhase", buildActionMask=2147483647,
|
||||||
dstPath="", dstSubfolderSpec=13, files=[embed], name="Embed App Extensions", runOnlyForDeploymentPostprocessing=0))
|
dstPath="", dstSubfolderSpec=13, files=[embed], name="Embed App Extensions", runOnlyForDeploymentPostprocessing=0))
|
||||||
|
|||||||
@@ -67,11 +67,25 @@ class Handler(http.server.BaseHTTPRequestHandler):
|
|||||||
|
|
||||||
def snapshot(self, token):
|
def snapshot(self, token):
|
||||||
result = dict(state)
|
result = dict(state)
|
||||||
|
if token.startswith("live-"):
|
||||||
|
with counts_lock:
|
||||||
|
commands = counts.get((token, "command"), 0)
|
||||||
|
result["title"] = "Track " + str(commands)
|
||||||
|
if token == "live-unsupported":
|
||||||
|
result["capabilities"] = {key: False for key in result["capabilities"]}
|
||||||
result["artworkId"] = "A" * 64 if token in ("artwork", "artwork-fail", "late-art") else None
|
result["artworkId"] = "A" * 64 if token in ("artwork", "artwork-fail", "late-art") else None
|
||||||
return result
|
return result
|
||||||
|
|
||||||
def do_POST(self):
|
def do_POST(self):
|
||||||
body = json.loads(self.rfile.read(int(self.headers.get("Content-Length", "0"))))
|
body = json.loads(self.rfile.read(int(self.headers.get("Content-Length", "0"))))
|
||||||
|
token = self.headers.get("Authorization", "").removeprefix("Bearer ")
|
||||||
|
if token.startswith("live-"):
|
||||||
|
count(token, "command")
|
||||||
|
if token == "live-reject":
|
||||||
|
self.reply(200, {"success": False, "code": "unsupported", "message": "Rejected"})
|
||||||
|
else:
|
||||||
|
self.reply(200, {"success": True, "code": "ok", "message": "OK"})
|
||||||
|
return
|
||||||
if self.path == "/v1/pair":
|
if self.path == "/v1/pair":
|
||||||
code = body.get("code")
|
code = body.get("code")
|
||||||
if code == "33333333":
|
if code == "33333333":
|
||||||
@@ -86,6 +100,12 @@ class Handler(http.server.BaseHTTPRequestHandler):
|
|||||||
def do_GET(self):
|
def do_GET(self):
|
||||||
token = self.headers.get("Authorization", "").removeprefix("Bearer ")
|
token = self.headers.get("Authorization", "").removeprefix("Bearer ")
|
||||||
number = count(token, self.path)
|
number = count(token, self.path)
|
||||||
|
if token == "live-refresh-fail" and self.path == "/v1/state":
|
||||||
|
with counts_lock:
|
||||||
|
commands = counts.get((token, "command"), 0)
|
||||||
|
if commands:
|
||||||
|
self.reply(503, {})
|
||||||
|
return
|
||||||
if token == "revoked":
|
if token == "revoked":
|
||||||
self.reply(401, {})
|
self.reply(401, {})
|
||||||
return
|
return
|
||||||
|
|||||||
Reference in New Issue
Block a user