fix(native): bound cross-platform lifecycle ownership
This commit is contained in:
@@ -60,6 +60,9 @@ target 'Runner' do
|
||||
raise "tvos/Podfile: plugin '#{name}' not in .flutter-plugins-dependencies"
|
||||
pod name, :path => File.join(base, 'ios')
|
||||
end
|
||||
target 'RunnerTests' do
|
||||
inherit! :search_paths
|
||||
end
|
||||
end
|
||||
|
||||
post_install do |installer|
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
35DB0C8FEF635A3BCA0B722A /* PackageInfoPlusPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9426EFA282CDA8E0E98EEE9 /* PackageInfoPlusPlugin.swift */; };
|
||||
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
|
||||
5C71F5F7B33075F2B007825B /* MpvPlayerPluginShared.swift in Sources */ = {isa = PBXBuildFile; fileRef = 73645904F226A24585A092CE /* MpvPlayerPluginShared.swift */; };
|
||||
65AC2C222043B3E6723E2076 /* ConnectivityPlusPluginTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 934BC4E316D2AC788C954766 /* ConnectivityPlusPluginTests.swift */; };
|
||||
691577F0EB3F4EB1A5160280 /* MpvPlayerCoreBase.swift in Sources */ = {isa = PBXBuildFile; fileRef = D52A3BDA46E79969EA1DF3AC /* MpvPlayerCoreBase.swift */; };
|
||||
6F3C0DD6F2F8DA14E7E2F386 /* MpvPlayerCore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A12B8610AE5D580077264851 /* MpvPlayerCore.swift */; };
|
||||
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
|
||||
@@ -24,6 +25,7 @@
|
||||
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
|
||||
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
|
||||
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
|
||||
A5E1F001234567890ABCDE02 /* SystemShelfPluginTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5E1F001234567890ABCDE01 /* SystemShelfPluginTests.swift */; };
|
||||
A7596F780E93AF0BE3838A26 /* ConnectivityProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CCA7E80F3A99D8759F2E59F /* ConnectivityProvider.swift */; };
|
||||
AA34E3E5872B3792D6959EAA /* MpvPipController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2635E12EB9322B151EE5127 /* MpvPipController.swift */; };
|
||||
B1D51A6A2F00110000000014 /* MpvAudioPlayerCore.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1D51A6A2F00110000000015 /* MpvAudioPlayerCore.swift */; };
|
||||
@@ -36,6 +38,7 @@
|
||||
E3DEAD2AAFC347A2E55AC0F7 /* SharedPreferencesPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0455EBA0EF4A61D3B71D2D7 /* SharedPreferencesPlugin.swift */; };
|
||||
E79A4474D308631AFA59CAE7 /* DeviceInfoPlusPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E1F41676FEF1AB57076F8B9 /* DeviceInfoPlusPlugin.swift */; };
|
||||
EA0F4264E7B912702C490109 /* MPVKit in Frameworks */ = {isa = PBXBuildFile; productRef = EA0F4263E7B912702C490108 /* MPVKit */; };
|
||||
F9C95D532B6F6B72D187217D /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = EF0E0C298AEDE34FF7C8BFD5 /* Pods_RunnerTests.framework */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
@@ -85,10 +88,12 @@
|
||||
0760C00EDC55A4D718BCB406 /* SystemShelfPlugin.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SystemShelfPlugin.swift; sourceTree = "<group>"; };
|
||||
0C25F4F2367A30B47E945AD6 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = "<group>"; };
|
||||
1B17916D7270A141E3AC7B5D /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
1CC83B6BDD3EF9204BEF754B /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = "<group>"; };
|
||||
25B7925EFD8A2C1C7EB667D5 /* TVServices.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = TVServices.framework; path = System/Library/Frameworks/TVServices.framework; sourceTree = SDKROOT; };
|
||||
34CD411CCD84E381C4BF4C1B /* messages.g.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = messages.g.swift; sourceTree = "<group>"; };
|
||||
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
|
||||
3CCA7E80F3A99D8759F2E59F /* ConnectivityProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ConnectivityProvider.swift; sourceTree = "<group>"; };
|
||||
41369A2B262AECB35079F6CC /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = "<group>"; };
|
||||
420881FB6A648A2AFD39FFF2 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = "<group>"; };
|
||||
5E1F41676FEF1AB57076F8B9 /* DeviceInfoPlusPlugin.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DeviceInfoPlusPlugin.swift; sourceTree = "<group>"; };
|
||||
66F56950138FF220CA079EB3 /* TvosEventDeliveryCoordinator.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TvosEventDeliveryCoordinator.swift; sourceTree = "<group>"; };
|
||||
@@ -101,6 +106,7 @@
|
||||
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
|
||||
824D2C68D1F1206932E118C2 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
9165AF55B967D8845D042FE7 /* TopShelfExtension.entitlements */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.entitlements; path = TopShelfExtension.entitlements; sourceTree = "<group>"; };
|
||||
934BC4E316D2AC788C954766 /* ConnectivityPlusPluginTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ConnectivityPlusPluginTests.swift; sourceTree = "<group>"; };
|
||||
937C0D45D6114EF1E957F5F6 /* Runner.entitlements */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.entitlements; path = Runner.entitlements; sourceTree = "<group>"; };
|
||||
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
|
||||
9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = "<group>"; };
|
||||
@@ -111,13 +117,16 @@
|
||||
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
9D7A998830EDC8F77BF521D1 /* MpvPlayerPlugin.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = MpvPlayerPlugin.swift; path = ../ios/Runner/MpvPlayer/MpvPlayerPlugin.swift; sourceTree = "<source_root>"; };
|
||||
A12B8610AE5D580077264851 /* MpvPlayerCore.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = MpvPlayerCore.swift; path = ../ios/Runner/MpvPlayer/MpvPlayerCore.swift; sourceTree = "<source_root>"; };
|
||||
A2484B9C94406BF0A99EB64A /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = "<group>"; };
|
||||
A2635E12EB9322B151EE5127 /* MpvPipController.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = MpvPipController.swift; path = ../ios/Runner/MpvPlayer/MpvPipController.swift; sourceTree = "<source_root>"; };
|
||||
A5E1F001234567890ABCDE01 /* SystemShelfPluginTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SystemShelfPluginTests.swift; sourceTree = "<group>"; };
|
||||
B1D51A6A2F00110000000015 /* MpvAudioPlayerCore.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = MpvAudioPlayerCore.swift; path = ../shared/apple/MpvPlayer/MpvAudioPlayerCore.swift; sourceTree = "<source_root>"; };
|
||||
B1D51A6A2F00110000000017 /* MpvAudioPlayerPlugin.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = MpvAudioPlayerPlugin.swift; path = ../shared/apple/MpvPlayer/MpvAudioPlayerPlugin.swift; sourceTree = "<source_root>"; };
|
||||
BBCB49C8AE9E90DEF97A87CA /* Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
C0455EBA0EF4A61D3B71D2D7 /* SharedPreferencesPlugin.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SharedPreferencesPlugin.swift; sourceTree = "<group>"; };
|
||||
D41AA251EF365516E2AC5287 /* PathProviderPlugin.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PathProviderPlugin.swift; sourceTree = "<group>"; };
|
||||
D52A3BDA46E79969EA1DF3AC /* MpvPlayerCoreBase.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = MpvPlayerCoreBase.swift; path = ../shared/apple/MpvPlayer/MpvPlayerCoreBase.swift; sourceTree = "<source_root>"; };
|
||||
EF0E0C298AEDE34FF7C8BFD5 /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
F2F829B3F190657106F66379 /* TopShelfProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TopShelfProvider.swift; sourceTree = "<group>"; };
|
||||
F9426EFA282CDA8E0E98EEE9 /* PackageInfoPlusPlugin.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PackageInfoPlusPlugin.swift; sourceTree = "<group>"; };
|
||||
FE6C8125B201A8BC3261AE2B /* TvosEventDeliveryCoordinatorTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TvosEventDeliveryCoordinatorTests.swift; sourceTree = "<group>"; };
|
||||
@@ -136,6 +145,7 @@
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
F9C95D532B6F6B72D187217D /* Pods_RunnerTests.framework in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -198,6 +208,9 @@
|
||||
0C25F4F2367A30B47E945AD6 /* Pods-Runner.debug.xcconfig */,
|
||||
04DD35536DEE7C27FFA53862 /* Pods-Runner.release.xcconfig */,
|
||||
420881FB6A648A2AFD39FFF2 /* Pods-Runner.profile.xcconfig */,
|
||||
1CC83B6BDD3EF9204BEF754B /* Pods-RunnerTests.release.xcconfig */,
|
||||
A2484B9C94406BF0A99EB64A /* Pods-RunnerTests.debug.xcconfig */,
|
||||
41369A2B262AECB35079F6CC /* Pods-RunnerTests.profile.xcconfig */,
|
||||
);
|
||||
path = Pods;
|
||||
sourceTree = "<group>";
|
||||
@@ -208,6 +221,7 @@
|
||||
824D2C68D1F1206932E118C2 /* Pods_Runner.framework */,
|
||||
25B7925EFD8A2C1C7EB667D5 /* TVServices.framework */,
|
||||
6D9369A1A01A73D85CAEC2A6 /* tvOS */,
|
||||
EF0E0C298AEDE34FF7C8BFD5 /* Pods_RunnerTests.framework */,
|
||||
);
|
||||
name = Frameworks;
|
||||
sourceTree = "<group>";
|
||||
@@ -322,6 +336,8 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
FE6C8125B201A8BC3261AE2B /* TvosEventDeliveryCoordinatorTests.swift */,
|
||||
934BC4E316D2AC788C954766 /* ConnectivityPlusPluginTests.swift */,
|
||||
A5E1F001234567890ABCDE01 /* SystemShelfPluginTests.swift */,
|
||||
);
|
||||
name = RunnerTests;
|
||||
path = RunnerTests;
|
||||
@@ -379,6 +395,7 @@
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = E0F018DBFAD5E036F8B3DED3 /* Build configuration list for PBXNativeTarget "RunnerTests" */;
|
||||
buildPhases = (
|
||||
A61FC3E730D67F10B0AAB6F9 /* [CP] Check Pods Manifest.lock */,
|
||||
877FBDFAE7193C414BFC7581 /* Sources */,
|
||||
5E27C9DB84FD0E86AD0F4664 /* Frameworks */,
|
||||
BA52311F6E3823811234FBB7 /* Resources */,
|
||||
@@ -527,6 +544,28 @@
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "#/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build\n/bin/bash \"$SOURCE_ROOT/scripts/xcode_appletv.sh\" build\n";
|
||||
};
|
||||
A61FC3E730D67F10B0AAB6F9 /* [CP] Check Pods Manifest.lock */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
inputFileListPaths = (
|
||||
);
|
||||
inputPaths = (
|
||||
"${PODS_PODFILE_DIR_PATH}/Podfile.lock",
|
||||
"${PODS_ROOT}/Manifest.lock",
|
||||
);
|
||||
name = "[CP] Check Pods Manifest.lock";
|
||||
outputFileListPaths = (
|
||||
);
|
||||
outputPaths = (
|
||||
"$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt",
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
|
||||
showEnvVarsInLog = 0;
|
||||
};
|
||||
E2B5A6D75C9F4D1B9E8C7A63 /* Sync Version */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
@@ -549,6 +588,8 @@
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
D2004D7BB4A40340AB7A01E0 /* TvosEventDeliveryCoordinatorTests.swift in Sources */,
|
||||
65AC2C222043B3E6723E2076 /* ConnectivityPlusPluginTests.swift in Sources */,
|
||||
A5E1F001234567890ABCDE02 /* SystemShelfPluginTests.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -774,10 +815,12 @@
|
||||
};
|
||||
4F937E75F619E4ABE22B1F17 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = 1CC83B6BDD3EF9204BEF754B /* Pods-RunnerTests.release.xcconfig */;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.edde746.plezy.RunnerTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = appletvos;
|
||||
SUPPORTED_PLATFORMS = "appletvos appletvsimulator";
|
||||
SWIFT_VERSION = 5.0;
|
||||
@@ -1005,10 +1048,12 @@
|
||||
};
|
||||
AE855FD4D5DF4B132727705D /* Profile */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = 41369A2B262AECB35079F6CC /* Pods-RunnerTests.profile.xcconfig */;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.edde746.plezy.RunnerTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = appletvos;
|
||||
SUPPORTED_PLATFORMS = "appletvos appletvsimulator";
|
||||
SWIFT_VERSION = 5.0;
|
||||
@@ -1021,10 +1066,12 @@
|
||||
};
|
||||
CD5AF092D8A44891EB23CECC /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = A2484B9C94406BF0A99EB64A /* Pods-RunnerTests.debug.xcconfig */;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.edde746.plezy.RunnerTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = appletvos;
|
||||
SUPPORTED_PLATFORMS = "appletvos appletvsimulator";
|
||||
SWIFT_VERSION = 5.0;
|
||||
|
||||
@@ -5,14 +5,29 @@
|
||||
import Flutter
|
||||
import UIKit
|
||||
|
||||
private final class ConnectivityListenerEpoch {}
|
||||
|
||||
public class ConnectivityPlusPlugin: NSObject, FlutterPlugin, FlutterStreamHandler {
|
||||
private let connectivityProvider: ConnectivityProvider
|
||||
private let notificationCenter: NotificationCenter
|
||||
private let applicationState: () -> UIApplication.State
|
||||
private var eventSink: FlutterEventSink?
|
||||
private var activationObserver: NSObjectProtocol?
|
||||
private var pendingConnectivity: [String]?
|
||||
private var lastDeliveredConnectivity: [String]?
|
||||
private var activeListenerEpoch: ConnectivityListenerEpoch?
|
||||
|
||||
init(connectivityProvider: ConnectivityProvider) {
|
||||
init(
|
||||
connectivityProvider: ConnectivityProvider,
|
||||
notificationCenter: NotificationCenter = .default,
|
||||
applicationState: @escaping () -> UIApplication.State = {
|
||||
UIApplication.shared.applicationState
|
||||
}
|
||||
) {
|
||||
self.connectivityProvider = connectivityProvider
|
||||
self.notificationCenter = notificationCenter
|
||||
self.applicationState = applicationState
|
||||
super.init()
|
||||
self.connectivityProvider.connectivityUpdateHandler = connectivityUpdateHandler
|
||||
}
|
||||
|
||||
public static func register(with registrar: FlutterPluginRegistrar) {
|
||||
@@ -33,9 +48,43 @@ public class ConnectivityPlusPlugin: NSObject, FlutterPlugin, FlutterStreamHandl
|
||||
registrar.addMethodCallDelegate(instance, channel: channel)
|
||||
}
|
||||
|
||||
private func onMain<T>(_ block: () -> T) -> T {
|
||||
if Thread.isMainThread {
|
||||
return block()
|
||||
}
|
||||
return DispatchQueue.main.sync(execute: block)
|
||||
}
|
||||
|
||||
private func installProviderHandler() -> ConnectivityListenerEpoch {
|
||||
let epoch = ConnectivityListenerEpoch()
|
||||
activeListenerEpoch = epoch
|
||||
connectivityProvider.connectivityUpdateHandler = { [weak self] connectivityTypes in
|
||||
self?.connectivityUpdateHandler(connectivityTypes: connectivityTypes, epoch: epoch)
|
||||
}
|
||||
return epoch
|
||||
}
|
||||
|
||||
public func detachFromEngine(for registrar: FlutterPluginRegistrar) {
|
||||
eventSink = nil
|
||||
connectivityProvider.stop()
|
||||
onMain {
|
||||
stopListening(preservePendingConnectivity: false)
|
||||
}
|
||||
}
|
||||
|
||||
deinit {
|
||||
let observer = activationObserver
|
||||
let provider = connectivityProvider
|
||||
let center = notificationCenter
|
||||
let cleanup = {
|
||||
if let observer {
|
||||
center.removeObserver(observer)
|
||||
}
|
||||
provider.connectivityUpdateHandler = nil
|
||||
}
|
||||
if Thread.isMainThread {
|
||||
cleanup()
|
||||
} else {
|
||||
DispatchQueue.main.sync(execute: cleanup)
|
||||
}
|
||||
}
|
||||
|
||||
public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
|
||||
@@ -56,12 +105,12 @@ public class ConnectivityPlusPlugin: NSObject, FlutterPlugin, FlutterStreamHandl
|
||||
case .wiredEthernet:
|
||||
return "ethernet"
|
||||
case .other:
|
||||
return "other"
|
||||
return "other"
|
||||
case .none:
|
||||
return "none"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private func statusFrom(connectivityTypes: [ConnectivityType]) -> [String] {
|
||||
return connectivityTypes.map {
|
||||
self.statusFrom(connectivityType: $0)
|
||||
@@ -72,26 +121,85 @@ public class ConnectivityPlusPlugin: NSObject, FlutterPlugin, FlutterStreamHandl
|
||||
withArguments _: Any?,
|
||||
eventSink events: @escaping FlutterEventSink
|
||||
) -> FlutterError? {
|
||||
eventSink = events
|
||||
connectivityProvider.start()
|
||||
// Update this to handle a list
|
||||
connectivityUpdateHandler(connectivityTypes: connectivityProvider.currentConnectivityTypes)
|
||||
return nil
|
||||
}
|
||||
|
||||
private func connectivityUpdateHandler(connectivityTypes: [ConnectivityType]) {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self = self, let eventSink = self.eventSink else { return }
|
||||
// NWPathMonitor can emit after the FlutterEngine shell is torn down.
|
||||
// Do not call its event sink while tvOS is backgrounded.
|
||||
guard UIApplication.shared.applicationState != .background else { return }
|
||||
eventSink(self.statusFrom(connectivityTypes: connectivityTypes))
|
||||
onMain {
|
||||
eventSink = events
|
||||
let listenerEpoch = installProviderHandler()
|
||||
if activationObserver == nil {
|
||||
activationObserver = notificationCenter.addObserver(
|
||||
forName: UIApplication.didBecomeActiveNotification,
|
||||
object: nil,
|
||||
queue: .main
|
||||
) { [weak self] _ in
|
||||
self?.replayPendingConnectivityIfNeeded()
|
||||
}
|
||||
}
|
||||
connectivityProvider.start()
|
||||
guard applicationState() != .background else { return nil }
|
||||
let replayedConnectivity = pendingConnectivity
|
||||
if let replayedConnectivity {
|
||||
pendingConnectivity = nil
|
||||
lastDeliveredConnectivity = replayedConnectivity
|
||||
events(replayedConnectivity)
|
||||
}
|
||||
let currentConnectivity = statusFrom(
|
||||
connectivityTypes: connectivityProvider.currentConnectivityTypes)
|
||||
if currentConnectivity != replayedConnectivity {
|
||||
connectivityUpdateHandler(
|
||||
connectivityTypes: connectivityProvider.currentConnectivityTypes,
|
||||
epoch: listenerEpoch
|
||||
)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
public func onCancel(withArguments _: Any?) -> FlutterError? {
|
||||
connectivityProvider.stop()
|
||||
private func connectivityUpdateHandler(
|
||||
connectivityTypes: [ConnectivityType],
|
||||
epoch: ConnectivityListenerEpoch
|
||||
) {
|
||||
let status = statusFrom(connectivityTypes: connectivityTypes)
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self, self.activeListenerEpoch === epoch else { return }
|
||||
guard self.applicationState() != .background, let eventSink = self.eventSink else {
|
||||
// Keep only the newest user-visible state. Connectivity changes are
|
||||
// snapshots, not telemetry; one bounded slot is sufficient.
|
||||
self.pendingConnectivity = status
|
||||
return
|
||||
}
|
||||
self.pendingConnectivity = nil
|
||||
guard status != self.lastDeliveredConnectivity else { return }
|
||||
self.lastDeliveredConnectivity = status
|
||||
eventSink(status)
|
||||
}
|
||||
}
|
||||
|
||||
private func replayPendingConnectivityIfNeeded() {
|
||||
guard applicationState() == .active, let pendingConnectivity, let eventSink else { return }
|
||||
self.pendingConnectivity = nil
|
||||
guard pendingConnectivity != lastDeliveredConnectivity else { return }
|
||||
lastDeliveredConnectivity = pendingConnectivity
|
||||
eventSink(pendingConnectivity)
|
||||
}
|
||||
|
||||
private func stopListening(preservePendingConnectivity: Bool) {
|
||||
eventSink = nil
|
||||
return nil
|
||||
activeListenerEpoch = nil
|
||||
connectivityProvider.connectivityUpdateHandler = nil
|
||||
if !preservePendingConnectivity {
|
||||
pendingConnectivity = nil
|
||||
}
|
||||
lastDeliveredConnectivity = nil
|
||||
if let activationObserver {
|
||||
notificationCenter.removeObserver(activationObserver)
|
||||
self.activationObserver = nil
|
||||
}
|
||||
connectivityProvider.stop()
|
||||
}
|
||||
|
||||
public func onCancel(withArguments _: Any?) -> FlutterError? {
|
||||
onMain {
|
||||
stopListening(preservePendingConnectivity: true)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ public class PathMonitorConnectivityProvider: NSObject, ConnectivityProvider {
|
||||
// Use .utility, as it is intended for tasks that the user does not track actively.
|
||||
// See: https://developer.apple.com/documentation/dispatch/dispatchqos
|
||||
private let queue = DispatchQueue.global(qos: .utility)
|
||||
private let handlerLock = NSLock()
|
||||
private var updateHandler: ConnectivityUpdateHandler?
|
||||
|
||||
private var pathMonitor: NWPathMonitor?
|
||||
|
||||
@@ -36,7 +38,18 @@ public class PathMonitorConnectivityProvider: NSObject, ConnectivityProvider {
|
||||
return connectivityFrom(path: path)
|
||||
}
|
||||
|
||||
public var connectivityUpdateHandler: ConnectivityUpdateHandler?
|
||||
public var connectivityUpdateHandler: ConnectivityUpdateHandler? {
|
||||
get {
|
||||
handlerLock.lock()
|
||||
defer { handlerLock.unlock() }
|
||||
return updateHandler
|
||||
}
|
||||
set {
|
||||
handlerLock.lock()
|
||||
updateHandler = newValue
|
||||
handlerLock.unlock()
|
||||
}
|
||||
}
|
||||
|
||||
override init() {
|
||||
super.init()
|
||||
@@ -64,6 +77,13 @@ public class PathMonitorConnectivityProvider: NSObject, ConnectivityProvider {
|
||||
}
|
||||
|
||||
private func pathUpdateHandler(path: NWPath) {
|
||||
connectivityUpdateHandler?(connectivityFrom(path: path))
|
||||
deliver(connectivityFrom(path: path))
|
||||
}
|
||||
|
||||
func deliver(_ connectivityTypes: [ConnectivityType]) {
|
||||
handlerLock.lock()
|
||||
let handler = updateHandler
|
||||
handlerLock.unlock()
|
||||
handler?(connectivityTypes)
|
||||
}
|
||||
}
|
||||
|
||||
+876
-118
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,227 @@
|
||||
import Flutter
|
||||
import UIKit
|
||||
import XCTest
|
||||
|
||||
@testable import Runner
|
||||
|
||||
private final class FakeConnectivityProvider: NSObject, ConnectivityProvider {
|
||||
var currentConnectivityTypes: [ConnectivityType] = [.none]
|
||||
var connectivityUpdateHandler: ConnectivityUpdateHandler?
|
||||
private(set) var startCount = 0
|
||||
private(set) var stopCount = 0
|
||||
|
||||
func start() {
|
||||
startCount += 1
|
||||
}
|
||||
|
||||
func stop() {
|
||||
stopCount += 1
|
||||
}
|
||||
|
||||
func emit(_ connectivityTypes: [ConnectivityType]) {
|
||||
currentConnectivityTypes = connectivityTypes
|
||||
connectivityUpdateHandler?(connectivityTypes)
|
||||
}
|
||||
}
|
||||
|
||||
final class ConnectivityPlusPluginTests: XCTestCase {
|
||||
func testBackgroundUpdatesCoalesceAndReplayNewestStateOnceInOrder() {
|
||||
let provider = FakeConnectivityProvider()
|
||||
let notificationCenter = NotificationCenter()
|
||||
var applicationState = UIApplication.State.background
|
||||
let plugin = ConnectivityPlusPlugin(
|
||||
connectivityProvider: provider,
|
||||
notificationCenter: notificationCenter,
|
||||
applicationState: { applicationState }
|
||||
)
|
||||
var events: [[String]] = []
|
||||
|
||||
XCTAssertNil(
|
||||
plugin.onListen(withArguments: nil) { value in
|
||||
if let value = value as? [String] { events.append(value) }
|
||||
})
|
||||
provider.emit([.wifi])
|
||||
provider.emit([.wiredEthernet])
|
||||
drainMainQueue()
|
||||
XCTAssertTrue(events.isEmpty)
|
||||
|
||||
applicationState = .active
|
||||
notificationCenter.post(name: UIApplication.didBecomeActiveNotification, object: nil)
|
||||
drainMainQueue()
|
||||
XCTAssertEqual(events, [["ethernet"]])
|
||||
|
||||
notificationCenter.post(name: UIApplication.didBecomeActiveNotification, object: nil)
|
||||
drainMainQueue()
|
||||
XCTAssertEqual(events, [["ethernet"]], "The buffered snapshot must replay only once")
|
||||
}
|
||||
|
||||
func testCancelAndReattachReplayPendingStateBeforeCurrentWithoutDuplicate() {
|
||||
let provider = FakeConnectivityProvider()
|
||||
let notificationCenter = NotificationCenter()
|
||||
var applicationState = UIApplication.State.active
|
||||
let plugin = ConnectivityPlusPlugin(
|
||||
connectivityProvider: provider,
|
||||
notificationCenter: notificationCenter,
|
||||
applicationState: { applicationState }
|
||||
)
|
||||
var firstEvents: [[String]] = []
|
||||
XCTAssertNil(
|
||||
plugin.onListen(withArguments: nil) { value in
|
||||
if let value = value as? [String] { firstEvents.append(value) }
|
||||
})
|
||||
drainMainQueue()
|
||||
XCTAssertEqual(firstEvents, [["none"]])
|
||||
|
||||
applicationState = .background
|
||||
provider.emit([.wifi])
|
||||
drainMainQueue()
|
||||
XCTAssertNil(plugin.onCancel(withArguments: nil))
|
||||
|
||||
applicationState = .active
|
||||
var replacementEvents: [[String]] = []
|
||||
XCTAssertNil(
|
||||
plugin.onListen(withArguments: nil) { value in
|
||||
if let value = value as? [String] { replacementEvents.append(value) }
|
||||
})
|
||||
drainMainQueue()
|
||||
XCTAssertEqual(replacementEvents, [["wifi"]])
|
||||
XCTAssertEqual(provider.startCount, 2)
|
||||
XCTAssertEqual(provider.stopCount, 1)
|
||||
}
|
||||
|
||||
func testBackgroundReattachLeavesPendingStateUntilActivation() {
|
||||
let provider = FakeConnectivityProvider()
|
||||
let notificationCenter = NotificationCenter()
|
||||
var applicationState = UIApplication.State.active
|
||||
let plugin = ConnectivityPlusPlugin(
|
||||
connectivityProvider: provider,
|
||||
notificationCenter: notificationCenter,
|
||||
applicationState: { applicationState }
|
||||
)
|
||||
XCTAssertNil(plugin.onListen(withArguments: nil) { _ in })
|
||||
drainMainQueue()
|
||||
|
||||
applicationState = .background
|
||||
provider.emit([.wifi])
|
||||
drainMainQueue()
|
||||
XCTAssertNil(plugin.onCancel(withArguments: nil))
|
||||
|
||||
var replacementEvents: [[String]] = []
|
||||
XCTAssertNil(
|
||||
plugin.onListen(withArguments: nil) { value in
|
||||
if let value = value as? [String] { replacementEvents.append(value) }
|
||||
})
|
||||
drainMainQueue()
|
||||
XCTAssertTrue(replacementEvents.isEmpty)
|
||||
|
||||
notificationCenter.post(name: UIApplication.didBecomeActiveNotification, object: nil)
|
||||
drainMainQueue()
|
||||
XCTAssertTrue(replacementEvents.isEmpty, "A premature notification must not bypass background gating")
|
||||
|
||||
applicationState = .active
|
||||
notificationCenter.post(name: UIApplication.didBecomeActiveNotification, object: nil)
|
||||
drainMainQueue()
|
||||
XCTAssertEqual(replacementEvents, [["wifi"]])
|
||||
}
|
||||
|
||||
func testQueuedUpdateFromCancelledListenerIsNotDeliveredToReplacement() {
|
||||
let provider = FakeConnectivityProvider()
|
||||
let notificationCenter = NotificationCenter()
|
||||
let plugin = ConnectivityPlusPlugin(
|
||||
connectivityProvider: provider,
|
||||
notificationCenter: notificationCenter,
|
||||
applicationState: { .active }
|
||||
)
|
||||
var firstEvents: [[String]] = []
|
||||
XCTAssertNil(
|
||||
plugin.onListen(withArguments: nil) { value in
|
||||
if let value = value as? [String] { firstEvents.append(value) }
|
||||
})
|
||||
drainMainQueue()
|
||||
XCTAssertEqual(firstEvents, [["none"]])
|
||||
|
||||
provider.emit([.wifi])
|
||||
XCTAssertNil(plugin.onCancel(withArguments: nil))
|
||||
provider.currentConnectivityTypes = [.wiredEthernet]
|
||||
|
||||
var replacementEvents: [[String]] = []
|
||||
XCTAssertNil(
|
||||
plugin.onListen(withArguments: nil) { value in
|
||||
if let value = value as? [String] { replacementEvents.append(value) }
|
||||
})
|
||||
drainMainQueue()
|
||||
|
||||
XCTAssertEqual(firstEvents, [["none"]])
|
||||
XCTAssertEqual(replacementEvents, [["ethernet"]])
|
||||
}
|
||||
|
||||
func testCancellationBreaksObserverAndProviderOwnership() {
|
||||
let provider = FakeConnectivityProvider()
|
||||
let notificationCenter = NotificationCenter()
|
||||
weak var releasedPlugin: ConnectivityPlusPlugin?
|
||||
|
||||
autoreleasepool {
|
||||
var plugin: ConnectivityPlusPlugin? = ConnectivityPlusPlugin(
|
||||
connectivityProvider: provider,
|
||||
notificationCenter: notificationCenter,
|
||||
applicationState: { .active }
|
||||
)
|
||||
releasedPlugin = plugin
|
||||
XCTAssertNil(plugin?.onListen(withArguments: nil) { _ in })
|
||||
XCTAssertNil(plugin?.onCancel(withArguments: nil))
|
||||
plugin = nil
|
||||
}
|
||||
|
||||
XCTAssertNil(releasedPlugin)
|
||||
}
|
||||
|
||||
func testPathMonitorHandlerCanDetachItselfDuringDelivery() {
|
||||
let provider = PathMonitorConnectivityProvider()
|
||||
var received: [[ConnectivityType]] = []
|
||||
provider.connectivityUpdateHandler = { types in
|
||||
received.append(types)
|
||||
provider.connectivityUpdateHandler = nil
|
||||
}
|
||||
|
||||
provider.deliver([.wifi])
|
||||
provider.deliver([.wiredEthernet])
|
||||
provider.stop()
|
||||
|
||||
XCTAssertEqual(received.count, 1)
|
||||
if case .wifi? = received.first?.first {
|
||||
// Expected.
|
||||
} else {
|
||||
XCTFail("Expected the first delivered type to be Wi-Fi")
|
||||
}
|
||||
}
|
||||
|
||||
func testPathMonitorHandlerAccessIsSafeDuringConcurrentDetachAndDelivery() {
|
||||
let provider = PathMonitorConnectivityProvider()
|
||||
let queue = DispatchQueue(label: "connectivity-handler-race", attributes: .concurrent)
|
||||
let group = DispatchGroup()
|
||||
|
||||
for index in 0..<200 {
|
||||
group.enter()
|
||||
queue.async {
|
||||
if index.isMultiple(of: 2) {
|
||||
provider.connectivityUpdateHandler = { _ in }
|
||||
} else {
|
||||
provider.connectivityUpdateHandler = nil
|
||||
}
|
||||
_ = provider.connectivityUpdateHandler
|
||||
provider.deliver([.wifi])
|
||||
group.leave()
|
||||
}
|
||||
}
|
||||
|
||||
XCTAssertEqual(group.wait(timeout: .now() + 2), .success)
|
||||
provider.connectivityUpdateHandler = nil
|
||||
provider.stop()
|
||||
}
|
||||
|
||||
private func drainMainQueue() {
|
||||
let drained = expectation(description: "main queue drained")
|
||||
DispatchQueue.main.async { drained.fulfill() }
|
||||
wait(for: [drained], timeout: 2)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,617 @@
|
||||
import Foundation
|
||||
import Flutter
|
||||
import XCTest
|
||||
|
||||
@testable import Runner
|
||||
|
||||
private final class ShelfArtworkURLProtocol: URLProtocol {
|
||||
private static let lock = NSLock()
|
||||
private static var startHandler: ((ShelfArtworkURLProtocol) -> Void)?
|
||||
private static var stopHandler: (() -> Void)?
|
||||
|
||||
static func configure(
|
||||
start: @escaping (ShelfArtworkURLProtocol) -> Void,
|
||||
stop: (() -> Void)? = nil
|
||||
) {
|
||||
lock.lock()
|
||||
startHandler = start
|
||||
stopHandler = stop
|
||||
lock.unlock()
|
||||
}
|
||||
|
||||
static func reset() {
|
||||
lock.lock()
|
||||
startHandler = nil
|
||||
stopHandler = nil
|
||||
lock.unlock()
|
||||
}
|
||||
|
||||
override class func canInit(with request: URLRequest) -> Bool { true }
|
||||
|
||||
override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }
|
||||
|
||||
override func startLoading() {
|
||||
Self.lock.lock()
|
||||
let handler = Self.startHandler
|
||||
Self.lock.unlock()
|
||||
guard let handler else {
|
||||
client?.urlProtocol(self, didFailWithError: URLError(.resourceUnavailable))
|
||||
return
|
||||
}
|
||||
handler(self)
|
||||
}
|
||||
|
||||
override func stopLoading() {
|
||||
Self.lock.lock()
|
||||
let handler = Self.stopHandler
|
||||
Self.lock.unlock()
|
||||
handler?()
|
||||
}
|
||||
|
||||
func sendResponse(contentLength: Int? = nil) {
|
||||
var headers = ["Content-Type": "image/png"]
|
||||
if let contentLength { headers["Content-Length"] = String(contentLength) }
|
||||
let response = HTTPURLResponse(
|
||||
url: request.url!,
|
||||
statusCode: 200,
|
||||
httpVersion: "HTTP/1.1",
|
||||
headerFields: headers
|
||||
)!
|
||||
client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
|
||||
}
|
||||
|
||||
func send(_ data: Data) {
|
||||
client?.urlProtocol(self, didLoad: data)
|
||||
}
|
||||
|
||||
func finish() {
|
||||
client?.urlProtocolDidFinishLoading(self)
|
||||
}
|
||||
}
|
||||
|
||||
private final class SystemShelfSyncHarness {
|
||||
let defaults: UserDefaults
|
||||
let root: URL
|
||||
private(set) var state = SystemShelfMutationState()
|
||||
private(set) var epoch: UInt64 = 0
|
||||
|
||||
private struct ScheduledPrune {
|
||||
let deadline: TimeInterval
|
||||
let batch: SystemShelfPruneBatch
|
||||
}
|
||||
|
||||
private let suiteName: String
|
||||
private var pruneClock: TimeInterval = 0
|
||||
private var scheduledPrunes: [ScheduledPrune] = []
|
||||
|
||||
var now: () -> Date = { Date(timeIntervalSince1970: 1_000) }
|
||||
var loaderShouldFail = false
|
||||
private(set) var requestedURLs: [URL] = []
|
||||
private(set) var notificationCount = 0
|
||||
|
||||
init() throws {
|
||||
suiteName = "SystemShelfSyncHarness.\(UUID().uuidString)"
|
||||
defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName))
|
||||
root = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
epoch = state.beginEngineSession()
|
||||
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
|
||||
}
|
||||
|
||||
func cleanup() {
|
||||
defaults.removePersistentDomain(forName: suiteName)
|
||||
try? FileManager.default.removeItem(at: root)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func beginEngineSession() -> UInt64 {
|
||||
epoch = state.beginEngineSession()
|
||||
return epoch
|
||||
}
|
||||
|
||||
func sync(
|
||||
generation: Int64,
|
||||
items: [[String: Any]],
|
||||
ownerId: String = "profile-a",
|
||||
engineEpoch: UInt64? = nil
|
||||
) -> Bool {
|
||||
let environment = SystemShelfSyncEnvironment(
|
||||
defaults: defaults,
|
||||
artworkRoot: root,
|
||||
now: now,
|
||||
loadArtwork: { [unowned self] url, _, _ in
|
||||
requestedURLs.append(url)
|
||||
guard !loaderShouldFail else { return nil }
|
||||
return BoundedArtworkDownload(data: Self.png, mimeType: "image/png")
|
||||
},
|
||||
schedulePrune: { [unowned self] batches in
|
||||
scheduledPrunes.append(
|
||||
contentsOf: batches.map {
|
||||
ScheduledPrune(deadline: pruneClock + $0.delay, batch: $0)
|
||||
}
|
||||
)
|
||||
},
|
||||
notifyChange: { [unowned self] in
|
||||
notificationCount += 1
|
||||
}
|
||||
)
|
||||
return SystemShelfPlugin.sync(
|
||||
envelope: envelope(
|
||||
generation: generation,
|
||||
ownerId: ownerId,
|
||||
engineEpoch: engineEpoch
|
||||
),
|
||||
rawItems: items,
|
||||
state: &state,
|
||||
environment: environment
|
||||
)
|
||||
}
|
||||
|
||||
func clear(generation: Int64, ownerId: String = "profile-a") -> Bool {
|
||||
SystemShelfPlugin.clearCache(
|
||||
envelope: envelope(generation: generation, ownerId: ownerId),
|
||||
state: &state,
|
||||
defaults: defaults,
|
||||
artworkRoot: root,
|
||||
notifyChange: { [unowned self] in
|
||||
notificationCount += 1
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
func remove(generation: Int64, contentId: String) -> Bool {
|
||||
SystemShelfPlugin.removeItem(
|
||||
envelope: envelope(generation: generation),
|
||||
contentId: contentId,
|
||||
state: &state,
|
||||
defaults: defaults,
|
||||
artworkRoot: root,
|
||||
schedulePrune: { [unowned self] batches in
|
||||
scheduledPrunes.append(
|
||||
contentsOf: batches.map {
|
||||
ScheduledPrune(deadline: pruneClock + $0.delay, batch: $0)
|
||||
}
|
||||
)
|
||||
},
|
||||
notifyChange: { [unowned self] in
|
||||
notificationCount += 1
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
func envelope(
|
||||
generation: Int64,
|
||||
ownerId: String = "profile-a",
|
||||
engineEpoch: UInt64? = nil
|
||||
) -> SystemShelfMutationEnvelope {
|
||||
SystemShelfMutationEnvelope(
|
||||
ownerId: ownerId,
|
||||
generation: generation,
|
||||
engineEpoch: engineEpoch ?? epoch
|
||||
)
|
||||
}
|
||||
|
||||
func currentItems() throws -> [[String: Any]] {
|
||||
let data = try XCTUnwrap(defaults.data(forKey: SystemShelfPlugin.cacheDataKey))
|
||||
let payload = try XCTUnwrap(
|
||||
JSONSerialization.jsonObject(with: data) as? [String: Any]
|
||||
)
|
||||
let sections = try XCTUnwrap(payload["sections"] as? [[String: Any]])
|
||||
return sections.flatMap { $0["items"] as? [[String: Any]] ?? [] }
|
||||
}
|
||||
|
||||
func artworkURL(for key: String) -> URL? {
|
||||
guard
|
||||
let enumerator = FileManager.default.enumerator(
|
||||
at: root,
|
||||
includingPropertiesForKeys: nil
|
||||
)
|
||||
else { return nil }
|
||||
return enumerator.compactMap { $0 as? URL }.first { $0.lastPathComponent == key }
|
||||
}
|
||||
|
||||
func advancePrunes(by interval: TimeInterval) {
|
||||
pruneClock += interval
|
||||
let due = scheduledPrunes.filter { $0.deadline <= pruneClock }
|
||||
scheduledPrunes.removeAll { $0.deadline <= pruneClock }
|
||||
for scheduled in due {
|
||||
let candidates = state.claimPruning(scheduled.batch)
|
||||
SystemShelfPlugin.pruneUnreferenced(
|
||||
candidates,
|
||||
defaults: defaults,
|
||||
root: root
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
static func item(
|
||||
source: String? = nil,
|
||||
progress: Int = 0,
|
||||
contentId: String = "movie-1"
|
||||
) -> [String: Any] {
|
||||
var item: [String: Any] = [
|
||||
"contentId": contentId,
|
||||
"title": "Movie",
|
||||
"lastPlaybackPosition": progress,
|
||||
]
|
||||
if let source {
|
||||
item["posterSourceUri"] = source
|
||||
}
|
||||
return item
|
||||
}
|
||||
|
||||
static let png = Data(
|
||||
base64Encoded:
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="
|
||||
)!
|
||||
}
|
||||
|
||||
final class SystemShelfPluginTests: XCTestCase {
|
||||
override func tearDown() {
|
||||
ShelfArtworkURLProtocol.reset()
|
||||
super.tearDown()
|
||||
}
|
||||
|
||||
func testSyncRemoveAndClearUpdateCommittedShelf() throws {
|
||||
let harness = try SystemShelfSyncHarness()
|
||||
defer { harness.cleanup() }
|
||||
|
||||
XCTAssertTrue(
|
||||
harness.sync(
|
||||
generation: 1,
|
||||
items: [
|
||||
SystemShelfSyncHarness.item(
|
||||
source: "https://shelf.test/a.png",
|
||||
contentId: "movie-a"
|
||||
),
|
||||
SystemShelfSyncHarness.item(
|
||||
source: "https://shelf.test/b.png",
|
||||
contentId: "movie-b"
|
||||
),
|
||||
]
|
||||
)
|
||||
)
|
||||
let initialItems = try harness.currentItems()
|
||||
XCTAssertEqual(Set(initialItems.compactMap { $0["contentId"] as? String }), ["movie-a", "movie-b"])
|
||||
let removedKey = try XCTUnwrap(
|
||||
initialItems.first { $0["contentId"] as? String == "movie-a" }?["artworkKey"] as? String
|
||||
)
|
||||
let removedArtwork = try XCTUnwrap(harness.artworkURL(for: removedKey))
|
||||
|
||||
XCTAssertTrue(harness.remove(generation: 2, contentId: "movie-a"))
|
||||
XCTAssertEqual(try harness.currentItems().compactMap { $0["contentId"] as? String }, ["movie-b"])
|
||||
XCTAssertTrue(FileManager.default.fileExists(atPath: removedArtwork.path))
|
||||
harness.advancePrunes(by: 60)
|
||||
XCTAssertFalse(FileManager.default.fileExists(atPath: removedArtwork.path))
|
||||
|
||||
XCTAssertTrue(harness.clear(generation: 3))
|
||||
XCTAssertNil(harness.defaults.data(forKey: SystemShelfPlugin.cacheDataKey))
|
||||
XCTAssertFalse(FileManager.default.fileExists(atPath: harness.root.path))
|
||||
XCTAssertEqual(harness.notificationCount, 3)
|
||||
}
|
||||
|
||||
func testReplacementEngineRejectsStaleEngineMutation() throws {
|
||||
let harness = try SystemShelfSyncHarness()
|
||||
defer { harness.cleanup() }
|
||||
let staleEpoch = harness.epoch
|
||||
|
||||
XCTAssertTrue(
|
||||
harness.sync(
|
||||
generation: 50,
|
||||
items: [SystemShelfSyncHarness.item(progress: 50)]
|
||||
)
|
||||
)
|
||||
_ = harness.beginEngineSession()
|
||||
XCTAssertTrue(
|
||||
harness.sync(
|
||||
generation: 1,
|
||||
items: [SystemShelfSyncHarness.item(progress: 1)],
|
||||
ownerId: "profile-b"
|
||||
)
|
||||
)
|
||||
|
||||
XCTAssertFalse(
|
||||
harness.sync(
|
||||
generation: 51,
|
||||
items: [SystemShelfSyncHarness.item(progress: 99)],
|
||||
engineEpoch: staleEpoch
|
||||
)
|
||||
)
|
||||
let item = try XCTUnwrap(harness.currentItems().first)
|
||||
XCTAssertEqual(item["lastPlaybackPosition"] as? Int, 1)
|
||||
let data = try XCTUnwrap(
|
||||
harness.defaults.data(forKey: SystemShelfPlugin.cacheDataKey)
|
||||
)
|
||||
let payload = try XCTUnwrap(
|
||||
JSONSerialization.jsonObject(with: data) as? [String: Any]
|
||||
)
|
||||
XCTAssertEqual(payload["ownerId"] as? String, "profile-b")
|
||||
}
|
||||
|
||||
func testHigherGenerationRemainsLastWriter() throws {
|
||||
let harness = try SystemShelfSyncHarness()
|
||||
defer { harness.cleanup() }
|
||||
|
||||
XCTAssertTrue(
|
||||
harness.sync(
|
||||
generation: 2,
|
||||
items: [SystemShelfSyncHarness.item(progress: 20)]
|
||||
)
|
||||
)
|
||||
XCTAssertFalse(
|
||||
harness.sync(
|
||||
generation: 1,
|
||||
items: [SystemShelfSyncHarness.item(progress: 10)]
|
||||
)
|
||||
)
|
||||
XCTAssertEqual(
|
||||
try harness.currentItems().first?["lastPlaybackPosition"] as? Int,
|
||||
20
|
||||
)
|
||||
XCTAssertEqual(harness.notificationCount, 1)
|
||||
}
|
||||
|
||||
func testProgressOnlySyncReusesCommittedArtworkWithoutRequestingItAgain() throws {
|
||||
let harness = try SystemShelfSyncHarness()
|
||||
defer { harness.cleanup() }
|
||||
let source = "https://shelf.test/movie-a.png"
|
||||
|
||||
XCTAssertTrue(
|
||||
harness.sync(
|
||||
generation: 1,
|
||||
items: [SystemShelfSyncHarness.item(source: source, progress: 10)]
|
||||
)
|
||||
)
|
||||
let firstKey = try XCTUnwrap(harness.currentItems().first?["artworkKey"] as? String)
|
||||
let firstArtwork = try XCTUnwrap(harness.artworkURL(for: firstKey))
|
||||
|
||||
XCTAssertTrue(
|
||||
harness.sync(
|
||||
generation: 2,
|
||||
items: [SystemShelfSyncHarness.item(source: source, progress: 45)]
|
||||
)
|
||||
)
|
||||
let updatedItem = try XCTUnwrap(harness.currentItems().first)
|
||||
XCTAssertEqual(updatedItem["lastPlaybackPosition"] as? Int, 45)
|
||||
XCTAssertEqual(updatedItem["artworkKey"] as? String, firstKey)
|
||||
XCTAssertEqual(harness.requestedURLs.map(\.absoluteString), [source])
|
||||
XCTAssertTrue(FileManager.default.fileExists(atPath: firstArtwork.path))
|
||||
}
|
||||
|
||||
func testFailedReplacementKeepsLastCommittedArtwork() throws {
|
||||
let harness = try SystemShelfSyncHarness()
|
||||
defer { harness.cleanup() }
|
||||
let firstSource = "https://shelf.test/movie-a.png"
|
||||
|
||||
XCTAssertTrue(
|
||||
harness.sync(
|
||||
generation: 1,
|
||||
items: [SystemShelfSyncHarness.item(source: firstSource)]
|
||||
)
|
||||
)
|
||||
let firstKey = try XCTUnwrap(harness.currentItems().first?["artworkKey"] as? String)
|
||||
let firstArtwork = try XCTUnwrap(harness.artworkURL(for: firstKey))
|
||||
|
||||
harness.loaderShouldFail = true
|
||||
XCTAssertTrue(
|
||||
harness.sync(
|
||||
generation: 2,
|
||||
items: [
|
||||
SystemShelfSyncHarness.item(source: "https://shelf.test/movie-b.png", progress: 20)
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
let item = try XCTUnwrap(harness.currentItems().first)
|
||||
XCTAssertEqual(item["artworkKey"] as? String, firstKey)
|
||||
XCTAssertTrue(FileManager.default.fileExists(atPath: firstArtwork.path))
|
||||
XCTAssertEqual(
|
||||
harness.requestedURLs.map(\.absoluteString),
|
||||
[firstSource, "https://shelf.test/movie-b.png"]
|
||||
)
|
||||
}
|
||||
|
||||
func testEachSupersededArtworkReceivesFullPruneGrace() throws {
|
||||
let harness = try SystemShelfSyncHarness()
|
||||
defer { harness.cleanup() }
|
||||
|
||||
XCTAssertTrue(
|
||||
harness.sync(
|
||||
generation: 1,
|
||||
items: [SystemShelfSyncHarness.item(source: "https://shelf.test/a.png")]
|
||||
)
|
||||
)
|
||||
let firstKey = try XCTUnwrap(harness.currentItems().first?["artworkKey"] as? String)
|
||||
let firstArtwork = try XCTUnwrap(harness.artworkURL(for: firstKey))
|
||||
XCTAssertTrue(
|
||||
harness.sync(
|
||||
generation: 2,
|
||||
items: [SystemShelfSyncHarness.item(source: "https://shelf.test/b.png")]
|
||||
)
|
||||
)
|
||||
let secondKey = try XCTUnwrap(harness.currentItems().first?["artworkKey"] as? String)
|
||||
let secondArtwork = try XCTUnwrap(harness.artworkURL(for: secondKey))
|
||||
|
||||
harness.advancePrunes(by: 59)
|
||||
XCTAssertTrue(FileManager.default.fileExists(atPath: firstArtwork.path))
|
||||
XCTAssertTrue(
|
||||
harness.sync(
|
||||
generation: 3,
|
||||
items: [SystemShelfSyncHarness.item(source: "https://shelf.test/c.png")]
|
||||
)
|
||||
)
|
||||
let currentKey = try XCTUnwrap(harness.currentItems().first?["artworkKey"] as? String)
|
||||
let currentArtwork = try XCTUnwrap(harness.artworkURL(for: currentKey))
|
||||
|
||||
harness.advancePrunes(by: 1)
|
||||
XCTAssertFalse(FileManager.default.fileExists(atPath: firstArtwork.path))
|
||||
XCTAssertTrue(FileManager.default.fileExists(atPath: secondArtwork.path))
|
||||
harness.advancePrunes(by: 58)
|
||||
XCTAssertTrue(FileManager.default.fileExists(atPath: secondArtwork.path))
|
||||
harness.advancePrunes(by: 1)
|
||||
XCTAssertFalse(FileManager.default.fileExists(atPath: secondArtwork.path))
|
||||
XCTAssertTrue(FileManager.default.fileExists(atPath: currentArtwork.path))
|
||||
}
|
||||
|
||||
func testPruneRechecksCurrentArtworkAndRelaunchRecoveryPreservesIt() throws {
|
||||
let harness = try SystemShelfSyncHarness()
|
||||
defer { harness.cleanup() }
|
||||
|
||||
XCTAssertTrue(
|
||||
harness.sync(
|
||||
generation: 1,
|
||||
items: [SystemShelfSyncHarness.item(source: "https://shelf.test/a.png")]
|
||||
)
|
||||
)
|
||||
let firstKey = try XCTUnwrap(harness.currentItems().first?["artworkKey"] as? String)
|
||||
let firstArtwork = try XCTUnwrap(harness.artworkURL(for: firstKey))
|
||||
XCTAssertTrue(
|
||||
harness.sync(
|
||||
generation: 2,
|
||||
items: [SystemShelfSyncHarness.item(source: "https://shelf.test/b.png")]
|
||||
)
|
||||
)
|
||||
let secondKey = try XCTUnwrap(harness.currentItems().first?["artworkKey"] as? String)
|
||||
let secondArtwork = try XCTUnwrap(harness.artworkURL(for: secondKey))
|
||||
|
||||
try replaceCommittedArtworkKey(
|
||||
in: harness.defaults,
|
||||
with: firstKey
|
||||
)
|
||||
harness.advancePrunes(by: 60)
|
||||
XCTAssertTrue(FileManager.default.fileExists(atPath: firstArtwork.path))
|
||||
|
||||
let relaunchedAt = Date(timeIntervalSince1970: 10_000)
|
||||
let recovered = SystemShelfPlugin.recoverableArtwork(
|
||||
root: harness.root,
|
||||
keeping: [firstArtwork],
|
||||
now: relaunchedAt
|
||||
)
|
||||
XCTAssertEqual(
|
||||
recovered[secondArtwork],
|
||||
relaunchedAt.addingTimeInterval(60)
|
||||
)
|
||||
XCTAssertNil(recovered[firstArtwork])
|
||||
|
||||
var relaunchedState = SystemShelfMutationState()
|
||||
_ = relaunchedState.beginEngineSession()
|
||||
XCTAssertNotNil(relaunchedState.adoptPersistedOwner("profile-a"))
|
||||
relaunchedState.cancelPruning(keeping: [firstArtwork])
|
||||
let batches = relaunchedState.preparePruning(
|
||||
removing: Set(recovered.keys),
|
||||
after: 60
|
||||
)
|
||||
XCTAssertEqual(batches.count, 1)
|
||||
XCTAssertEqual(batches.first?.delay, 60)
|
||||
let candidates = relaunchedState.claimPruning(try XCTUnwrap(batches.first))
|
||||
SystemShelfPlugin.pruneUnreferenced(
|
||||
candidates,
|
||||
defaults: harness.defaults,
|
||||
root: harness.root
|
||||
)
|
||||
XCTAssertFalse(FileManager.default.fileExists(atPath: secondArtwork.path))
|
||||
XCTAssertTrue(FileManager.default.fileExists(atPath: firstArtwork.path))
|
||||
}
|
||||
|
||||
func testPruneDoesNotEscapeArtworkRoot() throws {
|
||||
let harness = try SystemShelfSyncHarness()
|
||||
defer { harness.cleanup() }
|
||||
let outside = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("\(UUID().uuidString).art")
|
||||
let inside = harness.root.appendingPathComponent("orphan.art")
|
||||
let outsideDirectory = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
let outsideViaSymlink = outsideDirectory.appendingPathComponent("linked.art")
|
||||
let linkedDirectory = harness.root.appendingPathComponent("linked", isDirectory: true)
|
||||
let linkedCandidate = linkedDirectory.appendingPathComponent("linked.art")
|
||||
try Data([0x01]).write(to: outside)
|
||||
try Data([0x02]).write(to: inside)
|
||||
try FileManager.default.createDirectory(
|
||||
at: outsideDirectory,
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
try Data([0x03]).write(to: outsideViaSymlink)
|
||||
try FileManager.default.createSymbolicLink(
|
||||
at: linkedDirectory,
|
||||
withDestinationURL: outsideDirectory
|
||||
)
|
||||
defer {
|
||||
try? FileManager.default.removeItem(at: outside)
|
||||
try? FileManager.default.removeItem(at: outsideDirectory)
|
||||
}
|
||||
|
||||
SystemShelfPlugin.pruneUnreferenced(
|
||||
[outside, inside, linkedCandidate],
|
||||
defaults: harness.defaults,
|
||||
root: harness.root
|
||||
)
|
||||
|
||||
XCTAssertTrue(FileManager.default.fileExists(atPath: outside.path))
|
||||
XCTAssertTrue(FileManager.default.fileExists(atPath: outsideViaSymlink.path))
|
||||
XCTAssertFalse(FileManager.default.fileExists(atPath: inside.path))
|
||||
}
|
||||
|
||||
func testArtworkLoaderCancelsUnknownLengthBodyAtConfiguredCap() {
|
||||
let stopped = expectation(description: "oversized request cancelled")
|
||||
ShelfArtworkURLProtocol.configure(
|
||||
start: { request in
|
||||
request.sendResponse()
|
||||
request.send(Data(repeating: 0x41, count: 5))
|
||||
request.send(Data(repeating: 0x42, count: 5))
|
||||
request.finish()
|
||||
},
|
||||
stop: { stopped.fulfill() }
|
||||
)
|
||||
let configuration = URLSessionConfiguration.ephemeral
|
||||
configuration.protocolClasses = [ShelfArtworkURLProtocol.self]
|
||||
let loader = BoundedArtworkLoader(maximumBytes: 8, configuration: configuration)
|
||||
|
||||
let result = loader.load(url: URL(string: "https://shelf.test/art.png")!, timeout: 1)
|
||||
|
||||
XCTAssertNil(result)
|
||||
XCTAssertTrue(loader.exceededLimit)
|
||||
XCTAssertLessThanOrEqual(loader.peakBufferedBytes, 8)
|
||||
wait(for: [stopped], timeout: 1)
|
||||
}
|
||||
|
||||
func testArtworkLoaderReturnsBoundedChunkedBodyAndMimeType() {
|
||||
let expected = Data([0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07])
|
||||
ShelfArtworkURLProtocol.configure { request in
|
||||
request.sendResponse()
|
||||
request.send(expected.prefix(3))
|
||||
request.send(expected.suffix(4))
|
||||
request.finish()
|
||||
}
|
||||
let configuration = URLSessionConfiguration.ephemeral
|
||||
configuration.protocolClasses = [ShelfArtworkURLProtocol.self]
|
||||
let loader = BoundedArtworkLoader(maximumBytes: 8, configuration: configuration)
|
||||
|
||||
let result = loader.load(url: URL(string: "https://shelf.test/art.png")!, timeout: 1)
|
||||
|
||||
XCTAssertEqual(result?.data, expected)
|
||||
XCTAssertEqual(result?.mimeType, "image/png")
|
||||
XCTAssertEqual(loader.peakBufferedBytes, expected.count)
|
||||
XCTAssertFalse(loader.exceededLimit)
|
||||
}
|
||||
|
||||
private func replaceCommittedArtworkKey(
|
||||
in defaults: UserDefaults,
|
||||
with key: String
|
||||
) throws {
|
||||
let data = try XCTUnwrap(
|
||||
defaults.data(forKey: SystemShelfPlugin.cacheDataKey)
|
||||
)
|
||||
var payload = try XCTUnwrap(
|
||||
JSONSerialization.jsonObject(with: data) as? [String: Any]
|
||||
)
|
||||
var sections = try XCTUnwrap(payload["sections"] as? [[String: Any]])
|
||||
var items = try XCTUnwrap(sections.first?["items"] as? [[String: Any]])
|
||||
items[0]["artworkKey"] = key
|
||||
sections[0]["items"] = items
|
||||
payload["sections"] = sections
|
||||
defaults.set(
|
||||
try JSONSerialization.data(withJSONObject: payload),
|
||||
forKey: SystemShelfPlugin.cacheDataKey
|
||||
)
|
||||
}
|
||||
}
|
||||
Executable
+138
@@ -0,0 +1,138 @@
|
||||
#!/usr/bin/env ruby
|
||||
|
||||
require 'fileutils'
|
||||
require 'json'
|
||||
require 'minitest/autorun'
|
||||
require 'open3'
|
||||
require 'rbconfig'
|
||||
require 'tmpdir'
|
||||
require 'xcodeproj'
|
||||
|
||||
class WireMpvTest < Minitest::Test
|
||||
SOURCE_NAMES = %w[
|
||||
MpvPlayerCoreBase.swift
|
||||
MpvPlayerPluginShared.swift
|
||||
MpvPlayerCore.swift
|
||||
MpvPlayerPlugin.swift
|
||||
MpvPipController.swift
|
||||
MpvAudioPlayerCore.swift
|
||||
MpvAudioPlayerPlugin.swift
|
||||
AtmosProbePlugin.swift
|
||||
].freeze
|
||||
AFFECTED_NAMES = %w[
|
||||
MpvAudioPlayerCore.swift
|
||||
MpvAudioPlayerPlugin.swift
|
||||
AtmosProbePlugin.swift
|
||||
].freeze
|
||||
MPVKIT_PIN = {
|
||||
'location' => 'https://github.com/edde746/MPVKit',
|
||||
'revision' => '93101dc1d0903c48fa3054652805acacbb75e856',
|
||||
'version' => '1.0.13',
|
||||
}.freeze
|
||||
|
||||
def setup
|
||||
@temporary_root = Dir.mktmpdir('wire-mpv-test')
|
||||
@tvos_root = File.join(@temporary_root, 'tvos')
|
||||
FileUtils.mkdir_p(File.join(@tvos_root, 'scripts'))
|
||||
FileUtils.cp_r(File.expand_path('../Runner.xcodeproj', __dir__), @tvos_root)
|
||||
FileUtils.cp(File.expand_path('wire_mpv.rb', __dir__), File.join(@tvos_root, 'scripts'))
|
||||
end
|
||||
|
||||
def teardown
|
||||
FileUtils.remove_entry(@temporary_root)
|
||||
end
|
||||
|
||||
def test_restores_missing_references_and_is_idempotent
|
||||
edit_project do |_project, _runner, group|
|
||||
AFFECTED_NAMES.each do |name|
|
||||
group.files.find { |file| file.display_name == name }&.remove_from_project
|
||||
end
|
||||
end
|
||||
|
||||
run_wire_mpv
|
||||
run_wire_mpv
|
||||
assert_complete_source_graph
|
||||
end
|
||||
|
||||
def test_restores_membership_when_references_remain
|
||||
edit_project do |_project, runner, group|
|
||||
affected = group.files.select { |file| AFFECTED_NAMES.include?(file.display_name) }
|
||||
runner.source_build_phase.files.each do |build_file|
|
||||
build_file.remove_from_project if affected.include?(build_file.file_ref)
|
||||
end
|
||||
end
|
||||
|
||||
run_wire_mpv
|
||||
assert_complete_source_graph
|
||||
end
|
||||
|
||||
def test_restores_missing_package_product_and_framework_edges
|
||||
edit_project do |_project, runner, _group|
|
||||
runner.package_product_dependencies
|
||||
.select { |product| product.product_name == 'MPVKit' }
|
||||
.each(&:remove_from_project)
|
||||
end
|
||||
|
||||
run_wire_mpv
|
||||
assert_complete_source_graph
|
||||
end
|
||||
|
||||
def test_all_apple_targets_resolve_the_same_mpvkit_source
|
||||
repository_root = File.expand_path('../..', __dir__)
|
||||
%w[ios macos tvos].each do |platform|
|
||||
resolved_path =
|
||||
File.join(
|
||||
repository_root,
|
||||
platform,
|
||||
'Runner.xcworkspace',
|
||||
'xcshareddata',
|
||||
'swiftpm',
|
||||
'Package.resolved'
|
||||
)
|
||||
resolved = JSON.parse(File.read(resolved_path))
|
||||
pin = resolved.fetch('pins').find { |candidate| candidate.fetch('identity') == 'mpvkit' }
|
||||
refute_nil pin, "#{platform} must resolve MPVKit"
|
||||
assert_equal MPVKIT_PIN['location'], pin['location'], "#{platform} MPVKit source"
|
||||
assert_equal MPVKIT_PIN['revision'], pin.dig('state', 'revision'), "#{platform} MPVKit revision"
|
||||
assert_equal MPVKIT_PIN['version'], pin.dig('state', 'version'), "#{platform} MPVKit version"
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def project_path
|
||||
File.join(@tvos_root, 'Runner.xcodeproj')
|
||||
end
|
||||
|
||||
def edit_project
|
||||
project = Xcodeproj::Project.open(project_path)
|
||||
runner = project.targets.find { |target| target.name == 'Runner' }
|
||||
group = project.main_group['Runner']['MpvPlayer']
|
||||
yield project, runner, group
|
||||
project.save
|
||||
end
|
||||
|
||||
def run_wire_mpv
|
||||
script = File.join(@tvos_root, 'scripts', 'wire_mpv.rb')
|
||||
output, status = Open3.capture2e(RbConfig.ruby, script)
|
||||
assert status.success?, output
|
||||
end
|
||||
|
||||
def assert_complete_source_graph
|
||||
project = Xcodeproj::Project.open(project_path)
|
||||
runner = project.targets.find { |target| target.name == 'Runner' }
|
||||
group = project.main_group['Runner']['MpvPlayer']
|
||||
|
||||
SOURCE_NAMES.each do |name|
|
||||
references = group.files.select { |file| file.display_name == name }
|
||||
assert_equal 1, references.count, "expected one reference for #{name}"
|
||||
memberships = runner.source_build_phase.files_references.count { |file| file == references.first }
|
||||
assert_equal 1, memberships, "expected one Runner source membership for #{name}"
|
||||
end
|
||||
|
||||
products = runner.package_product_dependencies.select { |product| product.product_name == 'MPVKit' }
|
||||
assert_equal 1, products.count, 'expected one MPVKit product dependency'
|
||||
framework_links = runner.frameworks_build_phase.files.count { |file| file.product_ref == products.first }
|
||||
assert_equal 1, framework_links, 'expected one MPVKit framework link'
|
||||
end
|
||||
end
|
||||
+35
-20
@@ -24,48 +24,63 @@ sources = [
|
||||
{ name: 'MpvPlayerCore.swift', path: '../ios/Runner/MpvPlayer/MpvPlayerCore.swift', tree: '<source_root>' },
|
||||
{ name: 'MpvPlayerPlugin.swift', path: '../ios/Runner/MpvPlayer/MpvPlayerPlugin.swift', tree: '<source_root>' },
|
||||
{ name: 'MpvPipController.swift', path: '../ios/Runner/MpvPlayer/MpvPipController.swift', tree: '<source_root>' },
|
||||
{ name: 'MpvAudioPlayerCore.swift', path: '../shared/apple/MpvPlayer/MpvAudioPlayerCore.swift', tree: '<source_root>' },
|
||||
{ name: 'MpvAudioPlayerPlugin.swift', path: '../shared/apple/MpvPlayer/MpvAudioPlayerPlugin.swift', tree: '<source_root>' },
|
||||
{ name: 'AtmosProbePlugin.swift', path: '../shared/apple/AtmosProbe/AtmosProbePlugin.swift', tree: '<source_root>' },
|
||||
]
|
||||
|
||||
sources_phase = runner_target.source_build_phase
|
||||
sources.each do |src|
|
||||
existing = mpv_group.files.find { |f| f.display_name == src[:name] }
|
||||
if existing
|
||||
puts "[skip] #{src[:name]} already present"
|
||||
next
|
||||
ref = mpv_group.files.find { |file| file.display_name == src[:name] }
|
||||
unless ref
|
||||
ref = mpv_group.new_file(src[:path])
|
||||
ref.name = src[:name]
|
||||
ref.source_tree = src[:tree]
|
||||
puts "[add ] #{src[:name]} reference"
|
||||
end
|
||||
|
||||
if sources_phase.files_references.include?(ref)
|
||||
puts "[skip] #{src[:name]} source membership already present"
|
||||
else
|
||||
sources_phase.add_file_reference(ref, true)
|
||||
puts "[add ] #{src[:name]} source membership"
|
||||
end
|
||||
ref = mpv_group.new_file(src[:path])
|
||||
ref.name = src[:name]
|
||||
ref.source_tree = src[:tree]
|
||||
sources_phase.add_file_reference(ref, true)
|
||||
puts "[add ] #{src[:name]}"
|
||||
end
|
||||
|
||||
# Swift Package: MPVKit.
|
||||
# Swift Package: MPVKit. Restore each graph edge independently so a project
|
||||
# with a surviving package reference cannot silently omit the Runner linkage.
|
||||
pkg_url = 'https://github.com/edde746/MPVKit'
|
||||
pkg_version = '1.0.13'
|
||||
existing_pkg = project.root_object.package_references.find do |p|
|
||||
p.repositoryURL == pkg_url rescue false
|
||||
pkg = project.root_object.package_references.find do |candidate|
|
||||
candidate.repositoryURL == pkg_url rescue false
|
||||
end
|
||||
|
||||
if existing_pkg
|
||||
existing_pkg.requirement = { 'kind' => 'exactVersion', 'version' => pkg_version }
|
||||
puts "[set ] MPVKit SPM package version"
|
||||
else
|
||||
unless pkg
|
||||
pkg = project.new(Xcodeproj::Project::Object::XCRemoteSwiftPackageReference)
|
||||
pkg.repositoryURL = pkg_url
|
||||
pkg.requirement = { 'kind' => 'exactVersion', 'version' => pkg_version }
|
||||
project.root_object.package_references << pkg
|
||||
puts "[add ] MPVKit SPM package reference"
|
||||
end
|
||||
pkg.requirement = { 'kind' => 'exactVersion', 'version' => pkg_version }
|
||||
puts "[set ] MPVKit SPM package version"
|
||||
|
||||
product = runner_target.package_product_dependencies.find do |candidate|
|
||||
candidate.product_name == 'MPVKit'
|
||||
end
|
||||
unless product
|
||||
product = project.new(Xcodeproj::Project::Object::XCSwiftPackageProductDependency)
|
||||
product.package = pkg
|
||||
product.product_name = 'MPVKit'
|
||||
runner_target.package_product_dependencies << product
|
||||
puts "[add ] MPVKit Runner product dependency"
|
||||
end
|
||||
product.package = pkg
|
||||
|
||||
frameworks_phase = runner_target.frameworks_build_phase
|
||||
frameworks_phase = runner_target.frameworks_build_phase
|
||||
unless frameworks_phase.files.any? { |build_file| build_file.product_ref == product }
|
||||
build_file = project.new(Xcodeproj::Project::Object::PBXBuildFile)
|
||||
build_file.product_ref = product
|
||||
frameworks_phase.files << build_file
|
||||
puts "[add ] MPVKit SPM package + framework linkage"
|
||||
puts "[add ] MPVKit framework linkage"
|
||||
end
|
||||
|
||||
project.save
|
||||
|
||||
@@ -84,6 +84,8 @@ project.files.select { |file| file.display_name == 'Foundation.framework' }.each
|
||||
end
|
||||
runner_test_sources = %w[
|
||||
TvosEventDeliveryCoordinatorTests.swift
|
||||
ConnectivityPlusPluginTests.swift
|
||||
SystemShelfPluginTests.swift
|
||||
]
|
||||
test_target.source_build_phase.files.delete_if do |build_file|
|
||||
file_ref = build_file.file_ref
|
||||
|
||||
Reference in New Issue
Block a user