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
This commit is contained in:
parent
a4ebea1f95
commit
aac04c4008
|
@ -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
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 <http://www.gnu.org/licenses/>.
|
||||
//
|
||||
|
||||
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<AppFeature>?
|
||||
|
||||
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<AppFeature>?,
|
||||
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<AppFeature>? {
|
||||
_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)
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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 <http://www.gnu.org/licenses/>.
|
||||
//
|
||||
|
||||
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<AppFeature>? {
|
||||
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
|
||||
}
|
||||
}
|
|
@ -27,7 +27,7 @@ import CommonLibrary
|
|||
import Foundation
|
||||
import PassepartoutKit
|
||||
|
||||
final class MockTunnelProcessor: TunnelProcessor {
|
||||
final class MockTunnelProcessor: AppTunnelProcessor {
|
||||
var titleCount = 0
|
||||
|
||||
var willInstallCount = 0
|
||||
|
|
|
@ -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 = "<group>"; };
|
||||
0E94EE5C2B93570600588243 /* Tunnel.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Tunnel.plist; sourceTree = "<group>"; };
|
||||
0EAD6A1A2CF7F79A00CC1F02 /* ScreenshotTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenshotTests.swift; sourceTree = "<group>"; };
|
||||
0EAEC8A62D05DB8D001AA50C /* DefaultAppProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DefaultAppProcessor.swift; sourceTree = "<group>"; };
|
||||
0EAEC8A72D05DB8D001AA50C /* DefaultTunnelProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DefaultTunnelProcessor.swift; sourceTree = "<group>"; };
|
||||
0EB08B972CA46F4900A02591 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/AppPlist.strings; sourceTree = "<group>"; };
|
||||
0EBE80DD2BF55C9100E36A20 /* Library */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = Library; sourceTree = "<group>"; };
|
||||
0EC066D02C7DC47600D88A94 /* LaunchScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; path = LaunchScreen.storyboard; sourceTree = "<group>"; };
|
||||
|
@ -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 */,
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -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 <http://www.gnu.org/licenses/>.
|
||||
//
|
||||
|
||||
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<AppFeature>? {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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 <http://www.gnu.org/licenses/>.
|
||||
//
|
||||
|
||||
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
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
|
|
|
@ -40,7 +40,7 @@ extension AppContext {
|
|||
[]
|
||||
}
|
||||
)
|
||||
let processor = InAppProcessor.sharedImplementation(with: iapManager) {
|
||||
let processor = DefaultAppProcessor(iapManager: iapManager) {
|
||||
$0.localizedPreview
|
||||
}
|
||||
|
||||
|
|
|
@ -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?")
|
||||
|
|
Loading…
Reference in New Issue