fix(runtime): harden application service boundaries

This commit is contained in:
edde746
2026-07-24 03:46:46 +02:00
parent 658da37b48
commit e0bf66eea8
309 changed files with 32574 additions and 4369 deletions
+4 -9
View File
@@ -12,7 +12,6 @@ class MpvPlayerCore: MpvPlayerCoreBase {
private weak var window: UIWindow?
private var mainBlankView: UIView?
private var isVisible = false
private var isDisposed = false
private static var activeDisplayCriteriaKey: String?
private var lastDisplayCriteriaMutation: DisplayCriteriaMutation = .skipped
#if os(tvOS)
@@ -777,11 +776,7 @@ class MpvPlayerCore: MpvPlayerCoreBase {
#endif
func dispose(preserveDisplayCriteria: Bool = false) {
// Guard double-dispose: the plugin calls dispose() then drops the
// strong ref, which fires deinit dispose() again. The second call
// would re-enter and crash on weak-ref formation during dealloc.
guard !isDisposed else { return }
isDisposed = true
guard beginDisposal() else { return }
#if os(tvOS)
if preserveDisplayCriteria {
@@ -867,7 +862,7 @@ class MpvPlayerCore: MpvPlayerCoreBase {
}
@objc private func enterBackground() {
isBackgrounded = true
setBackgrounded(true)
if isPipActive || isPipStarting {
print("[MpvPlayerCore] Entering background - PiP active/starting, keeping video")
return
@@ -878,7 +873,7 @@ class MpvPlayerCore: MpvPlayerCoreBase {
}
@objc private func enterForeground() {
isBackgrounded = false
setBackgrounded(false)
if isPipActive {
print("[MpvPlayerCore] Entering foreground - PiP active, skipping vid restore")
return
@@ -890,7 +885,7 @@ class MpvPlayerCore: MpvPlayerCoreBase {
#if os(iOS)
@objc private func sceneDidActivate() {
isBackgrounded = false
setBackgrounded(false)
if isPipActive {
return
}
+4 -2
View File
@@ -239,7 +239,8 @@ class MpvPlayerPlugin: NSObject, FlutterPlugin, FlutterStreamHandler, MpvPluginS
isManualPipRequest = false
stopPipTimebaseSync()
if pause {
playerCore?.setPropertyAsync("pause", value: "yes") { [weak self] _ in
playerCore?.setPropertyAsync("pause", value: "yes") { [weak self] propertyResult in
guard case .success = propertyResult else { return }
self?.pipController?.invalidatePlaybackState()
self?.syncPipTimebase()
}
@@ -485,7 +486,8 @@ extension MpvPlayerPlugin: MpvPipDelegate {
}
func pipSetPlaying(_ playing: Bool) {
playerCore?.setPropertyAsync("pause", value: playing ? "no" : "yes") { [weak self] _ in
playerCore?.setPropertyAsync("pause", value: playing ? "no" : "yes") { [weak self] propertyResult in
guard case .success = propertyResult else { return }
self?.pipController?.invalidatePlaybackState()
self?.syncPipTimebase()
}
+206
View File
@@ -0,0 +1,206 @@
import Flutter
import XCTest
@testable import Runner
final class ControllablePropertyCore: MpvPlayerCoreBase {
var nextResult: Result<Void, Error>?
private(set) var propertyCalls: [(String, String)] = []
private var pendingCompletion: ((Result<Void, Error>) -> Void)?
override func setPropertyAsync(
_ name: String,
value: String,
completion: @escaping (Result<Void, Error>) -> Void
) {
propertyCalls.append((name, value))
if let nextResult {
self.nextResult = nil
completion(nextResult)
} else {
pendingCompletion = completion
}
}
func finish(_ result: Result<Void, Error>) {
let completion = pendingCompletion
pendingCompletion = nil
completion?(result)
}
}
final class RecordingMpvPlugin: MpvPluginShared {
var coreBase: MpvPlayerCoreBase?
var eventSink: FlutterEventSink?
var nameToId: [String: Int] = [:]
private(set) var pauseHookValues: [String] = []
init(core: MpvPlayerCoreBase?) {
coreBase = core
}
func setPlayerVisible(_ visible: Bool, restoreOnWindowVisible: Bool) {}
func updatePlayerFrame() {}
func didSetPauseProperty(value: String) {
pauseHookValues.append(value)
}
}
final class MpvPlayerContractTests: XCTestCase {
private let failure = NSError(
domain: "MpvPlayerContractTests",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "controlled failure"]
)
func testSharedSetPropertyMapsSuccessFailureMissingCoreAndInvalidArguments() {
let core = ControllablePropertyCore()
let plugin = RecordingMpvPlugin(core: core)
core.nextResult = .success(())
let success = invokeSetProperty(plugin, name: "pause", value: "no")
XCTAssertEqual(success.count, 1)
XCTAssertNil(success[0])
XCTAssertEqual(plugin.pauseHookValues, ["no"])
core.nextResult = .failure(failure)
let rejected = invokeSetProperty(plugin, name: "pause", value: "yes")
XCTAssertEqual(rejected.count, 1)
XCTAssertEqual((rejected[0] as? FlutterError)?.code, "SET_PROPERTY_FAILED")
XCTAssertEqual(plugin.pauseHookValues, ["no"])
plugin.coreBase = nil
let missing = invokeSetProperty(plugin, name: "volume", value: "50")
XCTAssertEqual(missing.count, 1)
XCTAssertEqual((missing[0] as? FlutterError)?.code, "NOT_INITIALIZED")
var invalidResults: [Any?] = []
plugin.handleSetProperty(
call: FlutterMethodCall(methodName: "setProperty", arguments: ["name": "pause"])
) {
invalidResults.append($0)
}
XCTAssertEqual(invalidResults.count, 1)
XCTAssertEqual((invalidResults[0] as? FlutterError)?.code, "INVALID_ARGS")
}
func testRealSetPropertyValidInvalidNonexistentAndPauseCache() {
let core = MpvAudioPlayerCore()
XCTAssertTrue(core.initialize())
defer {
core.dispose()
core.queue.sync {}
}
XCTAssertSuccess(awaitProperty(core, name: "volume", value: "50"))
XCTAssertTrue(core.isPaused)
XCTAssertFailure(awaitProperty(core, name: "pause", value: "not-a-flag"))
XCTAssertTrue(core.isPaused, "A rejected raw pause write must not change the cache")
XCTAssertFailure(
awaitProperty(core, name: "plezy-property-does-not-exist", value: "ignored")
)
XCTAssertTrue(core.isPaused)
XCTAssertSuccess(awaitProperty(core, name: "pause", value: "no"))
XCTAssertFalse(core.isPaused, "The accepted pause write must commit before completion")
}
func testPendingSetPropertyIsCancelledExactlyOnceOnDispose() {
let core = MpvAudioPlayerCore()
XCTAssertTrue(core.initialize())
let queueEntered = expectation(description: "mpv queue blocked")
let releaseQueue = DispatchSemaphore(value: 0)
core.queue.async {
queueEntered.fulfill()
releaseQueue.wait()
}
wait(for: [queueEntered], timeout: 2)
let completion = expectation(description: "cancelled property completion")
completion.assertForOverFulfill = true
var completionCount = 0
core.setPropertyAsync("volume", value: "51") { result in
completionCount += 1
if case .success = result {
XCTFail("Disposal must fail an accepted-but-pending property request")
}
completion.fulfill()
}
core.dispose()
releaseQueue.signal()
wait(for: [completion], timeout: 2)
core.queue.sync {}
XCTAssertEqual(completionCount, 1)
XCTAssertFailure(awaitProperty(core, name: "volume", value: "52"))
}
func testRapidAudioCoreReplacementOwnsLifecycleOnce() {
for _ in 0..<5 {
autoreleasepool {
let core = MpvAudioPlayerCore()
XCTAssertTrue(core.initialize())
core.dispose()
core.dispose()
core.queue.sync {}
XCTAssertFalse(core.hasActiveMpv)
}
}
}
private func invokeSetProperty(
_ plugin: RecordingMpvPlugin,
name: String,
value: String
) -> [Any?] {
var results: [Any?] = []
plugin.handleSetProperty(
call: FlutterMethodCall(
methodName: "setProperty",
arguments: ["name": name, "value": value]
)
) {
results.append($0)
}
return results
}
private func awaitProperty(
_ core: MpvPlayerCoreBase,
name: String,
value: String
) -> Result<Void, Error> {
let completion = expectation(description: "set \(name)")
var propertyResult: Result<Void, Error>?
core.setPropertyAsync(name, value: value) {
propertyResult = $0
completion.fulfill()
}
wait(for: [completion], timeout: 2)
return propertyResult ?? .failure(failure)
}
private func XCTAssertSuccess(
_ result: Result<Void, Error>,
file: StaticString = #filePath,
line: UInt = #line
) {
if case .failure(let error) = result {
XCTFail("Expected success, received \(error)", file: file, line: line)
}
}
private func XCTAssertFailure(
_ result: Result<Void, Error>,
file: StaticString = #filePath,
line: UInt = #line
) {
if case .success = result {
XCTFail("Expected failure", file: file, line: line)
}
}
}