From aac04c40084d74be6201c637dc760fe602f797f1 Mon Sep 17 00:00:00 2001 From: Davide Date: Sun, 8 Dec 2024 16:24:23 +0100 Subject: [PATCH] Move processor implementations to concrete objects (#983) Formerly via blocks, now with final classes. App: - ProfileProcessor - AppTunnelProcessor - Implemented by DefaultAppProcessor in app - Implemented by MockAppProcessor in UILibrary (for previews) Tunnel: - PacketTunnelProcessor - Implemented by DefaultTunnelProcessor --- .../AppData+Preferences.swift | 6 +- .../Business/ExtendedTunnel.swift | 4 +- .../Strategy/InAppProcessor.swift | 93 ------------------- ...rofileProcessor.swift => Processors.swift} | 14 ++- .../Previews/AppContext+Previews.swift | 22 +---- .../UILibrary/Strategy/MockAppProcessor.swift | 64 +++++++++++++ .../Mock/MockTunnelProcessor.swift | 2 +- Passepartout.xcodeproj/project.pbxproj | 8 ++ Passepartout/Shared/AppContext+Shared.swift | 2 +- Passepartout/Shared/DefaultAppProcessor.swift | 83 +++++++++++++++++ .../Shared/DefaultTunnelProcessor.swift | 19 +++- Passepartout/Shared/Shared.swift | 44 --------- .../Shared/Testing/AppContext+Testing.swift | 2 +- .../Tunnel/PacketTunnelProvider.swift | 4 +- 14 files changed, 193 insertions(+), 174 deletions(-) delete mode 100644 Library/Sources/CommonLibrary/Strategy/InAppProcessor.swift rename Library/Sources/CommonLibrary/Strategy/{ProfileProcessor.swift => Processors.swift} (79%) create mode 100644 Library/Sources/UILibrary/Strategy/MockAppProcessor.swift create mode 100644 Passepartout/Shared/DefaultAppProcessor.swift rename Library/Sources/CommonLibrary/Strategy/TunnelProcessor.swift => Passepartout/Shared/DefaultTunnelProcessor.swift (66%) diff --git a/Library/Sources/AppDataPreferences/AppData+Preferences.swift b/Library/Sources/AppDataPreferences/AppData+Preferences.swift index 25758ae3..a772a792 100644 --- a/Library/Sources/AppDataPreferences/AppData+Preferences.swift +++ b/Library/Sources/AppDataPreferences/AppData+Preferences.swift @@ -28,12 +28,10 @@ import CoreData import Foundation extension AppData { - - @MainActor - public static let cdPreferencesModel: NSManagedObjectModel = { + public static var cdPreferencesModel: NSManagedObjectModel { guard let model: NSManagedObjectModel = .mergedModel(from: [.module]) else { fatalError("Unable to build Core Data model (Preferences v3)") } return model - }() + } } diff --git a/Library/Sources/CommonLibrary/Business/ExtendedTunnel.swift b/Library/Sources/CommonLibrary/Business/ExtendedTunnel.swift index a5c2f4fd..4515b5d9 100644 --- a/Library/Sources/CommonLibrary/Business/ExtendedTunnel.swift +++ b/Library/Sources/CommonLibrary/Business/ExtendedTunnel.swift @@ -33,7 +33,7 @@ public final class ExtendedTunnel: ObservableObject { private let environment: TunnelEnvironment - private let processor: TunnelProcessor? + private let processor: AppTunnelProcessor? private let interval: TimeInterval @@ -56,7 +56,7 @@ public final class ExtendedTunnel: ObservableObject { public init( tunnel: Tunnel, environment: TunnelEnvironment, - processor: TunnelProcessor? = nil, + processor: AppTunnelProcessor? = nil, interval: TimeInterval ) { self.tunnel = tunnel diff --git a/Library/Sources/CommonLibrary/Strategy/InAppProcessor.swift b/Library/Sources/CommonLibrary/Strategy/InAppProcessor.swift deleted file mode 100644 index bbf88125..00000000 --- a/Library/Sources/CommonLibrary/Strategy/InAppProcessor.swift +++ /dev/null @@ -1,93 +0,0 @@ -// -// InAppProcessor.swift -// Passepartout -// -// Created by Davide De Rosa on 10/6/24. -// Copyright (c) 2024 Davide De Rosa. All rights reserved. -// -// https://github.com/passepartoutvpn -// -// This file is part of Passepartout. -// -// Passepartout is free software: you can redistribute it and/or modify -// it under the terms of the GNU General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// Passepartout is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU General Public License for more details. -// -// You should have received a copy of the GNU General Public License -// along with Passepartout. If not, see . -// - -import Foundation -import PassepartoutKit - -public final class InAppProcessor: ObservableObject, Sendable { - private let iapManager: IAPManager - - public nonisolated let _title: (Profile) -> String - - private nonisolated let _isIncluded: (IAPManager, Profile) -> Bool - - private nonisolated let _preview: (Profile) -> ProfilePreview - - private nonisolated let _requiredFeatures: (IAPManager, Profile) -> Set? - - private nonisolated let _willRebuild: (IAPManager, Profile.Builder) throws -> Profile.Builder - - private nonisolated let _willInstall: (IAPManager, Profile) throws -> Profile - - public init( - iapManager: IAPManager, - title: @escaping (Profile) -> String, - isIncluded: @escaping (IAPManager, Profile) -> Bool, - preview: @escaping (Profile) -> ProfilePreview, - requiredFeatures: @escaping (IAPManager, Profile) -> Set?, - willRebuild: @escaping (IAPManager, Profile.Builder) throws -> Profile.Builder, - willInstall: @escaping (IAPManager, Profile) throws -> Profile - ) { - self.iapManager = iapManager - _title = title - _isIncluded = isIncluded - _preview = preview - _requiredFeatures = requiredFeatures - _willRebuild = willRebuild - _willInstall = willInstall - } -} - -// MARK: - ProfileProcessor - -extension InAppProcessor: ProfileProcessor { - public func title(for profile: Profile) -> String { - _title(profile) - } - - public func isIncluded(_ profile: Profile) -> Bool { - _isIncluded(iapManager, profile) - } - - public func preview(from profile: Profile) -> ProfilePreview { - _preview(profile) - } - - public func requiredFeatures(_ profile: Profile) -> Set? { - _requiredFeatures(iapManager, profile) - } - - public func willRebuild(_ builder: Profile.Builder) throws -> Profile.Builder { - try _willRebuild(iapManager, builder) - } -} - -// MARK: - TunnelProcessor - -extension InAppProcessor: TunnelProcessor { - public func willInstall(_ profile: Profile) throws -> Profile { - try _willInstall(iapManager, profile) - } -} diff --git a/Library/Sources/CommonLibrary/Strategy/ProfileProcessor.swift b/Library/Sources/CommonLibrary/Strategy/Processors.swift similarity index 79% rename from Library/Sources/CommonLibrary/Strategy/ProfileProcessor.swift rename to Library/Sources/CommonLibrary/Strategy/Processors.swift index 01b8ee97..1aba6bc7 100644 --- a/Library/Sources/CommonLibrary/Strategy/ProfileProcessor.swift +++ b/Library/Sources/CommonLibrary/Strategy/Processors.swift @@ -1,5 +1,5 @@ // -// ProfileProcessor.swift +// Processors.swift // Passepartout // // Created by Davide De Rosa on 11/20/24. @@ -26,6 +26,7 @@ import Foundation import PassepartoutKit +@MainActor public protocol ProfileProcessor { func isIncluded(_ profile: Profile) -> Bool @@ -35,3 +36,14 @@ public protocol ProfileProcessor { func willRebuild(_ builder: Profile.Builder) throws -> Profile.Builder } + +@MainActor +public protocol AppTunnelProcessor { + func title(for profile: Profile) -> String + + func willInstall(_ profile: Profile) throws -> Profile +} + +public protocol PacketTunnelProcessor { + nonisolated func willStart(_ profile: Profile) throws -> Profile +} diff --git a/Library/Sources/UILibrary/Previews/AppContext+Previews.swift b/Library/Sources/UILibrary/Previews/AppContext+Previews.swift index acd8d2d5..2fb95290 100644 --- a/Library/Sources/UILibrary/Previews/AppContext+Previews.swift +++ b/Library/Sources/UILibrary/Previews/AppContext+Previews.swift @@ -39,27 +39,7 @@ extension AppContext { [] } ) - let processor = InAppProcessor( - iapManager: iapManager, - title: { - "Passepartout.Mock: \($0.name)" - }, - isIncluded: { _, _ in - true - }, - preview: { - $0.localizedPreview - }, - requiredFeatures: { _, _ in - nil - }, - willRebuild: { _, builder in - builder - }, - willInstall: { _, profile in - profile - } - ) + let processor = MockAppProcessor(iapManager: iapManager) let profileManager = { let profiles: [Profile] = (0..<20) .reduce(into: []) { list, _ in diff --git a/Library/Sources/UILibrary/Strategy/MockAppProcessor.swift b/Library/Sources/UILibrary/Strategy/MockAppProcessor.swift new file mode 100644 index 00000000..6e9bb22e --- /dev/null +++ b/Library/Sources/UILibrary/Strategy/MockAppProcessor.swift @@ -0,0 +1,64 @@ +// +// MockAppProcessor.swift +// Passepartout +// +// Created by Davide De Rosa on 12/8/24. +// Copyright (c) 2024 Davide De Rosa. All rights reserved. +// +// https://github.com/passepartoutvpn +// +// This file is part of Passepartout. +// +// Passepartout is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Passepartout is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Passepartout. If not, see . +// + +import CommonLibrary +import Foundation +import PassepartoutKit + +final class MockAppProcessor { + private let iapManager: IAPManager + + init(iapManager: IAPManager) { + self.iapManager = iapManager + } +} + +extension MockAppProcessor: ProfileProcessor { + func isIncluded(_ profile: Profile) -> Bool { + true + } + + func preview(from profile: Profile) -> ProfilePreview { + profile.localizedPreview + } + + func requiredFeatures(_ profile: Profile) -> Set? { + nil + } + + func willRebuild(_ builder: Profile.Builder) throws -> Profile.Builder { + builder + } +} + +extension MockAppProcessor: AppTunnelProcessor { + func title(for profile: Profile) -> String { + "Passepartout.Mock: \(profile.name)" + } + + func willInstall(_ profile: Profile) throws -> Profile { + profile + } +} diff --git a/Library/Tests/CommonLibraryTests/Mock/MockTunnelProcessor.swift b/Library/Tests/CommonLibraryTests/Mock/MockTunnelProcessor.swift index 08df1a96..24c79d24 100644 --- a/Library/Tests/CommonLibraryTests/Mock/MockTunnelProcessor.swift +++ b/Library/Tests/CommonLibraryTests/Mock/MockTunnelProcessor.swift @@ -27,7 +27,7 @@ import CommonLibrary import Foundation import PassepartoutKit -final class MockTunnelProcessor: TunnelProcessor { +final class MockTunnelProcessor: AppTunnelProcessor { var titleCount = 0 var willInstallCount = 0 diff --git a/Passepartout.xcodeproj/project.pbxproj b/Passepartout.xcodeproj/project.pbxproj index 89e3aaf3..d034a1ac 100644 --- a/Passepartout.xcodeproj/project.pbxproj +++ b/Passepartout.xcodeproj/project.pbxproj @@ -33,6 +33,8 @@ 0E916B7C2CF811EB0072921A /* XCUIElement+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E916B7B2CF811EB0072921A /* XCUIElement+Extensions.swift */; }; 0E94EE582B93554B00588243 /* PacketTunnelProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E7E3D672B9345FD002BBDB4 /* PacketTunnelProvider.swift */; }; 0EAD6A1B2CF7F79A00CC1F02 /* ScreenshotTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0EAD6A1A2CF7F79A00CC1F02 /* ScreenshotTests.swift */; }; + 0EAEC8A92D05DB8D001AA50C /* DefaultAppProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0EAEC8A62D05DB8D001AA50C /* DefaultAppProcessor.swift */; }; + 0EAEC8AA2D05DB8D001AA50C /* DefaultTunnelProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0EAEC8A72D05DB8D001AA50C /* DefaultTunnelProcessor.swift */; }; 0EB08B982CA46F4900A02591 /* AppPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = 0EB08B962CA46F4900A02591 /* AppPlist.strings */; }; 0EBE80DC2BF55C0E00E36A20 /* TunnelLibrary in Frameworks */ = {isa = PBXBuildFile; productRef = 0EBE80DB2BF55C0E00E36A20 /* TunnelLibrary */; }; 0EC066D12C7DC47600D88A94 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 0EC066D02C7DC47600D88A94 /* LaunchScreen.storyboard */; platformFilter = ios; }; @@ -161,6 +163,8 @@ 0E916B7B2CF811EB0072921A /* XCUIElement+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "XCUIElement+Extensions.swift"; sourceTree = ""; }; 0E94EE5C2B93570600588243 /* Tunnel.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Tunnel.plist; sourceTree = ""; }; 0EAD6A1A2CF7F79A00CC1F02 /* ScreenshotTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenshotTests.swift; sourceTree = ""; }; + 0EAEC8A62D05DB8D001AA50C /* DefaultAppProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DefaultAppProcessor.swift; sourceTree = ""; }; + 0EAEC8A72D05DB8D001AA50C /* DefaultTunnelProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DefaultTunnelProcessor.swift; sourceTree = ""; }; 0EB08B972CA46F4900A02591 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/AppPlist.strings; sourceTree = ""; }; 0EBE80DD2BF55C9100E36A20 /* Library */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = Library; sourceTree = ""; }; 0EC066D02C7DC47600D88A94 /* LaunchScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; path = LaunchScreen.storyboard; sourceTree = ""; }; @@ -341,6 +345,8 @@ children = ( 0E6EEEE62CF8CB090076E2B0 /* Testing */, 0EC797402B9378E000C093B7 /* AppContext+Shared.swift */, + 0EAEC8A62D05DB8D001AA50C /* DefaultAppProcessor.swift */, + 0EAEC8A72D05DB8D001AA50C /* DefaultTunnelProcessor.swift */, 0E8195592CFDA75200CC8FFD /* Dependencies.swift */, 0EC797412B9378E000C093B7 /* Shared.swift */, 0E483E822CE6501100584B32 /* Shared+App.swift */, @@ -681,6 +687,7 @@ 0E7C3CCD2C9AF44600B72E69 /* AppDelegate.swift in Sources */, 0ED61CFA2CD04192008FE259 /* App+iOS.swift in Sources */, 0E7E3D6B2B9345FD002BBDB4 /* PassepartoutApp.swift in Sources */, + 0EAEC8A92D05DB8D001AA50C /* DefaultAppProcessor.swift in Sources */, 0EC797422B9378E000C093B7 /* AppContext+Shared.swift in Sources */, 0EE8D7E12CD112C200F6600C /* App+tvOS.swift in Sources */, 0E81955A2CFDA75200CC8FFD /* Dependencies.swift in Sources */, @@ -729,6 +736,7 @@ buildActionMask = 2147483647; files = ( 0E81955B2CFDA7BF00CC8FFD /* Dependencies.swift in Sources */, + 0EAEC8AA2D05DB8D001AA50C /* DefaultTunnelProcessor.swift in Sources */, 0E94EE582B93554B00588243 /* PacketTunnelProvider.swift in Sources */, 0E483E812CE64D6B00584B32 /* Shared+Tunnel.swift in Sources */, 0EC797442B93790600C093B7 /* Shared.swift in Sources */, diff --git a/Passepartout/Shared/AppContext+Shared.swift b/Passepartout/Shared/AppContext+Shared.swift index 66b0cf8e..548adbb5 100644 --- a/Passepartout/Shared/AppContext+Shared.swift +++ b/Passepartout/Shared/AppContext+Shared.swift @@ -39,7 +39,7 @@ import UITesting extension AppContext { static let shared: AppContext = { let iapManager: IAPManager = .sharedForApp - let processor = InAppProcessor.sharedImplementation(with: iapManager) { + let processor = DefaultAppProcessor(iapManager: iapManager) { $0.localizedPreview } diff --git a/Passepartout/Shared/DefaultAppProcessor.swift b/Passepartout/Shared/DefaultAppProcessor.swift new file mode 100644 index 00000000..86c48b68 --- /dev/null +++ b/Passepartout/Shared/DefaultAppProcessor.swift @@ -0,0 +1,83 @@ +// +// DefaultAppProcessor.swift +// Passepartout +// +// Created by Davide De Rosa on 10/6/24. +// Copyright (c) 2024 Davide De Rosa. All rights reserved. +// +// https://github.com/passepartoutvpn +// +// This file is part of Passepartout. +// +// Passepartout is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Passepartout is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Passepartout. If not, see . +// + +import CommonLibrary +import Foundation +import PassepartoutKit + +final class DefaultAppProcessor: Sendable { + private let iapManager: IAPManager + + private let preview: @Sendable (Profile) -> ProfilePreview + + init(iapManager: IAPManager, preview: @escaping @Sendable (Profile) -> ProfilePreview) { + self.iapManager = iapManager + self.preview = preview + } +} + +extension DefaultAppProcessor: ProfileProcessor { + func isIncluded(_ profile: Profile) -> Bool { + Dependencies.ProfileManager.isIncluded(iapManager, profile) + } + + func preview(from profile: Profile) -> ProfilePreview { + preview(profile) + } + + func requiredFeatures(_ profile: Profile) -> Set? { + do { + try iapManager.verify(profile) + return nil + } catch AppError.ineligibleProfile(let requiredFeatures) { + return requiredFeatures + } catch { + return nil + } + } + + func willRebuild(_ builder: Profile.Builder) throws -> Profile.Builder { + builder + } +} + +extension DefaultAppProcessor: AppTunnelProcessor { + func title(for profile: Profile) -> String { + Dependencies.ProfileManager.sharedTitle(profile) + } + + func willInstall(_ profile: Profile) throws -> Profile { + try iapManager.verify(profile) + + // validate provider modules + do { + _ = try profile.withProviderModules() + return profile + } catch { + pp_log(.app, .error, "Unable to inject provider modules: \(error)") + throw error + } + } +} diff --git a/Library/Sources/CommonLibrary/Strategy/TunnelProcessor.swift b/Passepartout/Shared/DefaultTunnelProcessor.swift similarity index 66% rename from Library/Sources/CommonLibrary/Strategy/TunnelProcessor.swift rename to Passepartout/Shared/DefaultTunnelProcessor.swift index 73b431fe..14c9b1cd 100644 --- a/Library/Sources/CommonLibrary/Strategy/TunnelProcessor.swift +++ b/Passepartout/Shared/DefaultTunnelProcessor.swift @@ -1,8 +1,8 @@ // -// TunnelProcessor.swift +// DefaultTunnelProcessor.swift // Passepartout // -// Created by Davide De Rosa on 11/20/24. +// Created by Davide De Rosa on 12/8/24. // Copyright (c) 2024 Davide De Rosa. All rights reserved. // // https://github.com/passepartoutvpn @@ -23,11 +23,20 @@ // along with Passepartout. If not, see . // +import CommonLibrary import Foundation import PassepartoutKit -public protocol TunnelProcessor { - func title(for profile: Profile) -> String +final class DefaultTunnelProcessor: Sendable { + private let preferencesManager: PreferencesManager - func willInstall(_ profile: Profile) throws -> Profile + init(preferencesManager: PreferencesManager) { + self.preferencesManager = preferencesManager + } +} + +extension DefaultTunnelProcessor: PacketTunnelProcessor { + func willStart(_ profile: Profile) throws -> Profile { + profile + } } diff --git a/Passepartout/Shared/Shared.swift b/Passepartout/Shared/Shared.swift index 8e2e1c5b..081bf7ed 100644 --- a/Passepartout/Shared/Shared.swift +++ b/Passepartout/Shared/Shared.swift @@ -89,51 +89,7 @@ extension TunnelEnvironment where Self == AppGroupEnvironment { } } -extension InAppProcessor { - - @MainActor - static func sharedImplementation(with iapManager: IAPManager, preview: @escaping (Profile) -> ProfilePreview) -> InAppProcessor { - InAppProcessor( - iapManager: iapManager, - title: { - Dependencies.ProfileManager.sharedTitle($0) - }, - isIncluded: { - Dependencies.ProfileManager.isIncluded($0, $1) - }, - preview: preview, - requiredFeatures: { iap, profile in - do { - try iap.verify(profile) - return nil - } catch AppError.ineligibleProfile(let requiredFeatures) { - return requiredFeatures - } catch { - return nil - } - }, - willRebuild: { _, builder in - builder - }, - willInstall: { iap, profile in - try iap.verify(profile) - - // validate provider modules - do { - _ = try profile.withProviderModules() - return profile - } catch { - pp_log(.app, .error, "Unable to inject provider modules: \(error)") - throw error - } - } - ) - } -} - extension PreferencesManager { - - @MainActor static func sharedImplementation(withCloudKit: Bool) -> PreferencesManager { let preferencesStore = CoreDataPersistentStore( logger: .default, diff --git a/Passepartout/Shared/Testing/AppContext+Testing.swift b/Passepartout/Shared/Testing/AppContext+Testing.swift index 33f4bc29..7ec9f00f 100644 --- a/Passepartout/Shared/Testing/AppContext+Testing.swift +++ b/Passepartout/Shared/Testing/AppContext+Testing.swift @@ -40,7 +40,7 @@ extension AppContext { [] } ) - let processor = InAppProcessor.sharedImplementation(with: iapManager) { + let processor = DefaultAppProcessor(iapManager: iapManager) { $0.localizedPreview } diff --git a/Passepartout/Tunnel/PacketTunnelProvider.swift b/Passepartout/Tunnel/PacketTunnelProvider.swift index 9a165977..af2a9e89 100644 --- a/Passepartout/Tunnel/PacketTunnelProvider.swift +++ b/Passepartout/Tunnel/PacketTunnelProvider.swift @@ -36,12 +36,14 @@ final class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable { parameters: Constants.shared.log, logsPrivateData: UserDefaults.appGroup.bool(forKey: AppPreference.logsPrivateData.key) ) + let processor = DefaultTunnelProcessor(preferencesManager: .sharedForTunnel) do { fwd = try await NEPTPForwarder( provider: self, decoder: Registry.sharedProtocolCoder, registry: .shared, - environment: .shared + environment: .shared, + profileBlock: processor.willStart ) guard let fwd else { fatalError("NEPTPForwarder nil without throwing error?")