fix(native): bound cross-platform lifecycle ownership

This commit is contained in:
edde746
2026-07-24 03:56:40 +02:00
parent 09656fa4d3
commit 9f2e050797
98 changed files with 12908 additions and 1599 deletions
+3
View File
@@ -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|
+47
View File
@@ -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)
}
}
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
)
}
}
+138
View File
@@ -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
View File
@@ -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
+2
View File
@@ -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