pythonnative 0.35.0__py3-none-any.whl → 0.37.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pythonnative/__init__.py +6 -4
- pythonnative/_ios_log.py +1 -1
- pythonnative/alerts.py +34 -88
- pythonnative/bootstrap.py +90 -0
- pythonnative/bridge/__init__.py +306 -0
- pythonnative/bridge/android.py +78 -0
- pythonnative/bridge/codec.py +164 -0
- pythonnative/bridge/fake.py +236 -0
- pythonnative/bridge/ios.py +138 -0
- pythonnative/cli/pn.py +24 -6
- pythonnative/components/layout.py +65 -3
- pythonnative/gestures.py +13 -10
- pythonnative/hooks.py +41 -26
- pythonnative/hosts/__init__.py +13 -36
- pythonnative/hosts/native.py +348 -0
- pythonnative/images.py +6 -5
- pythonnative/mutations.py +21 -0
- pythonnative/native_modules/__init__.py +30 -29
- pythonnative/native_modules/app_state.py +10 -6
- pythonnative/native_modules/battery.py +20 -84
- pythonnative/native_modules/biometrics.py +11 -124
- pythonnative/native_modules/camera.py +15 -291
- pythonnative/native_modules/clipboard.py +14 -109
- pythonnative/native_modules/desktop.py +322 -0
- pythonnative/native_modules/file_system.py +15 -21
- pythonnative/native_modules/haptics.py +19 -129
- pythonnative/native_modules/linking.py +24 -137
- pythonnative/native_modules/location.py +28 -180
- pythonnative/native_modules/net_info.py +25 -135
- pythonnative/native_modules/notifications.py +36 -257
- pythonnative/native_modules/permissions.py +20 -184
- pythonnative/native_modules/registry.py +513 -0
- pythonnative/native_modules/secure_store.py +18 -155
- pythonnative/native_modules/share.py +8 -113
- pythonnative/native_views/__init__.py +67 -86
- pythonnative/native_views/bridge_backend.py +264 -0
- pythonnative/platform.py +19 -15
- pythonnative/platform_metrics.py +3 -3
- pythonnative/project/android.py +0 -3
- pythonnative/project/builder.py +38 -16
- pythonnative/project/config.py +12 -2
- pythonnative/project/plugins.py +366 -0
- pythonnative/py.typed +0 -0
- pythonnative/runtime.py +35 -203
- pythonnative/sdk/__init__.py +48 -33
- pythonnative/sdk/_components.py +57 -76
- pythonnative/storage.py +18 -243
- pythonnative/templates/android_template/app/build.gradle +6 -9
- pythonnative/templates/android_template/app/src/main/java/com/pythonnative/android_template/MainActivity.kt +34 -166
- pythonnative/templates/android_template/app/src/main/java/com/pythonnative/android_template/ScreenFragment.kt +13 -151
- pythonnative/templates/android_template/pythonnative/build.gradle +48 -0
- pythonnative/templates/android_template/pythonnative/src/main/AndroidManifest.xml +11 -0
- pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/PNBridge.kt +214 -0
- pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/PythonHost.kt +20 -0
- pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/animation/AnimationSpecs.kt +141 -0
- pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/animation/PNAnimator.kt +249 -0
- pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/bridge/JsonUtil.kt +175 -0
- pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/bridge/MainThread.kt +32 -0
- pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/bridge/PNLog.kt +33 -0
- pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/bridge/PNRegistry.kt +82 -0
- pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/bridge/PNTransaction.kt +80 -0
- pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/bridge/TransactionApplier.kt +115 -0
- pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/bridge/ViewRegistry.kt +74 -0
- pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/components/BuiltinComponents.kt +35 -0
- pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/components/ButtonManager.kt +33 -0
- pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/components/ComponentManager.kt +213 -0
- pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/components/ContainerManagers.kt +69 -0
- pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/components/ControlManagers.kt +186 -0
- pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/components/DatePickerManager.kt +123 -0
- pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/components/ImageLoader.kt +141 -0
- pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/components/ImageManager.kt +156 -0
- pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/components/ModalManager.kt +134 -0
- pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/components/PNColor.kt +201 -0
- pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/components/PickerManager.kt +87 -0
- pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/components/PortalManager.kt +55 -0
- pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/components/PressableManager.kt +122 -0
- pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/components/ScrollViewManager.kt +214 -0
- pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/components/SegmentedControlManager.kt +113 -0
- pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/components/StatusBarManager.kt +52 -0
- pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/components/TabBarManager.kt +131 -0
- pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/components/TextInputManager.kt +245 -0
- pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/components/TextManager.kt +233 -0
- pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/components/ViewStyler.kt +355 -0
- pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/components/VirtualListManager.kt +179 -0
- pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/components/WebViewManager.kt +149 -0
- pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/gestures/GestureArbiter.kt +233 -0
- pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/gestures/GestureCoordinator.kt +127 -0
- pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/gestures/GestureRecognizers.kt +527 -0
- pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/modules/BuiltinModules.kt +73 -0
- pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/modules/DeviceModules.kt +316 -0
- pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/modules/HostModule.kt +97 -0
- pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/modules/MediaModules.kt +200 -0
- pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/modules/NativeModule.kt +135 -0
- pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/modules/NotificationsModule.kt +100 -0
- pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/modules/PermissionsModule.kt +88 -0
- pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/modules/StorageModules.kt +89 -0
- pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/modules/SystemModules.kt +178 -0
- pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/plugins/GeneratedPlugins.kt +16 -0
- pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/screens/Navigator.kt +116 -0
- pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/screens/PNScreenFragment.kt +223 -0
- pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/screens/ScreenRegistry.kt +80 -0
- pythonnative/templates/android_template/{app/src/main/java/com/pythonnative/android_template → pythonnative/src/main/java/com/pythonnative/runtime/views}/PNAccessibilityDelegate.kt +5 -4
- pythonnative/templates/android_template/{app/src/main/java/com/pythonnative/android_template → pythonnative/src/main/java/com/pythonnative/runtime/views}/PNBorderDrawable.kt +12 -3
- pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/views/PNEditText.kt +18 -0
- pythonnative/templates/android_template/{app/src/main/java/com/pythonnative/android_template → pythonnative/src/main/java/com/pythonnative/runtime/views}/PNFrameLayout.kt +9 -10
- pythonnative/templates/android_template/{app/src/main/java/com/pythonnative/android_template → pythonnative/src/main/java/com/pythonnative/runtime/views}/PNVirtualListView.java +9 -12
- pythonnative/templates/android_template/pythonnative/src/test/java/com/pythonnative/runtime/AnimationSpecsTest.kt +75 -0
- pythonnative/templates/android_template/pythonnative/src/test/java/com/pythonnative/runtime/GestureArbiterTest.kt +197 -0
- pythonnative/templates/android_template/pythonnative/src/test/java/com/pythonnative/runtime/PNColorTest.kt +47 -0
- pythonnative/templates/android_template/pythonnative/src/test/java/com/pythonnative/runtime/PNTransactionTest.kt +78 -0
- pythonnative/templates/android_template/pythonnative/src/test/java/com/pythonnative/runtime/PromiseTest.kt +75 -0
- pythonnative/templates/android_template/settings.gradle +1 -0
- pythonnative/templates/ios_template/PythonNativeKit/Package.swift +21 -0
- pythonnative/templates/ios_template/PythonNativeKit/Sources/PythonNativeKit/Animation/PNAnimator.swift +301 -0
- pythonnative/templates/ios_template/PythonNativeKit/Sources/PythonNativeKit/Bridge/PNBridge.swift +191 -0
- pythonnative/templates/ios_template/PythonNativeKit/Sources/PythonNativeKit/Bridge/PNRegistry.swift +140 -0
- pythonnative/templates/ios_template/PythonNativeKit/Sources/PythonNativeKit/Bridge/PNTransaction.swift +165 -0
- pythonnative/templates/ios_template/PythonNativeKit/Sources/PythonNativeKit/Bridge/PNViewRegistry.swift +53 -0
- pythonnative/templates/ios_template/PythonNativeKit/Sources/PythonNativeKit/Components/PNButtonManagers.swift +206 -0
- pythonnative/templates/ios_template/PythonNativeKit/Sources/PythonNativeKit/Components/PNColor.swift +173 -0
- pythonnative/templates/ios_template/PythonNativeKit/Sources/PythonNativeKit/Components/PNComponentManager.swift +183 -0
- pythonnative/templates/ios_template/PythonNativeKit/Sources/PythonNativeKit/Components/PNContainerView.swift +56 -0
- pythonnative/templates/ios_template/PythonNativeKit/Sources/PythonNativeKit/Components/PNControlManagers.swift +281 -0
- pythonnative/templates/ios_template/PythonNativeKit/Sources/PythonNativeKit/Components/PNImageManager.swift +221 -0
- pythonnative/templates/ios_template/PythonNativeKit/Sources/PythonNativeKit/Components/PNOverlayManagers.swift +243 -0
- pythonnative/templates/ios_template/PythonNativeKit/Sources/PythonNativeKit/Components/PNPressableManager.swift +114 -0
- pythonnative/templates/ios_template/PythonNativeKit/Sources/PythonNativeKit/Components/PNScrollViewManager.swift +197 -0
- pythonnative/templates/ios_template/PythonNativeKit/Sources/PythonNativeKit/Components/PNTabBarManager.swift +106 -0
- pythonnative/templates/ios_template/PythonNativeKit/Sources/PythonNativeKit/Components/PNTextInputManager.swift +289 -0
- pythonnative/templates/ios_template/PythonNativeKit/Sources/PythonNativeKit/Components/PNTextManager.swift +230 -0
- pythonnative/templates/ios_template/PythonNativeKit/Sources/PythonNativeKit/Components/PNTransform.swift +82 -0
- pythonnative/templates/ios_template/PythonNativeKit/Sources/PythonNativeKit/Components/PNViewManager.swift +86 -0
- pythonnative/templates/ios_template/PythonNativeKit/Sources/PythonNativeKit/Components/PNViewState.swift +87 -0
- pythonnative/templates/ios_template/PythonNativeKit/Sources/PythonNativeKit/Components/PNViewStyler.swift +340 -0
- pythonnative/templates/ios_template/PythonNativeKit/Sources/PythonNativeKit/Components/PNVirtualListManager.swift +257 -0
- pythonnative/templates/ios_template/PythonNativeKit/Sources/PythonNativeKit/Components/PNWebViewManager.swift +124 -0
- pythonnative/templates/ios_template/PythonNativeKit/Sources/PythonNativeKit/Gestures/PNGestureCoordinator.swift +260 -0
- pythonnative/templates/ios_template/PythonNativeKit/Sources/PythonNativeKit/Modules/AlertModule.swift +92 -0
- pythonnative/templates/ios_template/PythonNativeKit/Sources/PythonNativeKit/Modules/HostModule.swift +158 -0
- pythonnative/templates/ios_template/PythonNativeKit/Sources/PythonNativeKit/Modules/LifecycleModules.swift +174 -0
- pythonnative/templates/ios_template/PythonNativeKit/Sources/PythonNativeKit/Modules/MediaModules.swift +177 -0
- pythonnative/templates/ios_template/PythonNativeKit/Sources/PythonNativeKit/Modules/PNNativeModule.swift +125 -0
- pythonnative/templates/ios_template/PythonNativeKit/Sources/PythonNativeKit/Modules/PermissionModules.swift +251 -0
- pythonnative/templates/ios_template/PythonNativeKit/Sources/PythonNativeKit/Modules/SystemModules.swift +251 -0
- pythonnative/templates/ios_template/PythonNativeKit/Sources/PythonNativeKit/Plugins/PNPluginRegistration.swift +8 -0
- pythonnative/templates/ios_template/PythonNativeKit/Sources/PythonNativeKit/Screens/PNScreenRegistry.swift +46 -0
- pythonnative/templates/ios_template/PythonNativeKit/Sources/PythonNativeKit/Screens/PNViewController.swift +251 -0
- pythonnative/templates/ios_template/PythonNativeKit/Sources/PythonNativeKit/Support/PNCompat.swift +46 -0
- pythonnative/templates/ios_template/PythonNativeKit/Sources/PythonNativeKit/Support/PNJSON.swift +173 -0
- pythonnative/templates/ios_template/PythonNativeKit/Sources/PythonNativeKit/Support/PNLog.swift +47 -0
- pythonnative/templates/ios_template/PythonNativeKit/Sources/PythonNativeKit/Support/PNWindow.swift +51 -0
- pythonnative/templates/ios_template/PythonNativeKit/Tests/PythonNativeKitTests/PNColorTests.swift +50 -0
- pythonnative/templates/ios_template/PythonNativeKit/Tests/PythonNativeKitTests/PNGestureTests.swift +101 -0
- pythonnative/templates/ios_template/PythonNativeKit/Tests/PythonNativeKitTests/PNManagerTests.swift +124 -0
- pythonnative/templates/ios_template/PythonNativeKit/Tests/PythonNativeKitTests/PNModuleTests.swift +98 -0
- pythonnative/templates/ios_template/PythonNativeKit/Tests/PythonNativeKitTests/PNTransactionTests.swift +91 -0
- pythonnative/templates/ios_template/ios_template/AppDelegate.swift +8 -50
- pythonnative/templates/ios_template/ios_template/Info.plist +4 -4
- pythonnative/templates/ios_template/ios_template/PythonRuntime.swift +38 -46
- pythonnative/templates/ios_template/ios_template/SceneDelegate.swift +11 -16
- pythonnative/templates/ios_template/ios_template/ViewController.swift +12 -142
- pythonnative/templates/ios_template/ios_template.xcodeproj/project.pbxproj +47 -1
- pythonnative/utils.py +4 -82
- pythonnative/virtual_rows.py +3 -3
- {pythonnative-0.35.0.dist-info → pythonnative-0.37.0.dist-info}/METADATA +3 -5
- pythonnative-0.37.0.dist-info/RECORD +263 -0
- pythonnative/hosts/android.py +0 -269
- pythonnative/hosts/ios.py +0 -404
- pythonnative/native_views/android.py +0 -3702
- pythonnative/native_views/ios.py +0 -5253
- pythonnative/templates/android_template/app/src/main/java/com/pythonnative/android_template/Navigator.kt +0 -43
- pythonnative-0.35.0.dist-info/RECORD +0 -155
- {pythonnative-0.35.0.dist-info → pythonnative-0.37.0.dist-info}/WHEEL +0 -0
- {pythonnative-0.35.0.dist-info → pythonnative-0.37.0.dist-info}/entry_points.txt +0 -0
- {pythonnative-0.35.0.dist-info → pythonnative-0.37.0.dist-info}/licenses/LICENSE +0 -0
- {pythonnative-0.35.0.dist-info → pythonnative-0.37.0.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import AVFoundation
|
|
2
|
+
import Contacts
|
|
3
|
+
import CoreLocation
|
|
4
|
+
import LocalAuthentication
|
|
5
|
+
import Photos
|
|
6
|
+
import UIKit
|
|
7
|
+
import UserNotifications
|
|
8
|
+
|
|
9
|
+
/// `Permissions`: `check` (sync) and `request` (async) for camera,
|
|
10
|
+
/// microphone, location, photos, notifications, and contacts.
|
|
11
|
+
public final class PermissionsModule: PNNativeModule {
|
|
12
|
+
public static let name = "Permissions"
|
|
13
|
+
|
|
14
|
+
private var locationRequester: PNLocationPermissionRequester?
|
|
15
|
+
|
|
16
|
+
public init() {}
|
|
17
|
+
|
|
18
|
+
public func call(_ method: String, args: [String: Any], promise: PNPromise) {
|
|
19
|
+
let permission = PNProps.string(args["permission"]) ?? ""
|
|
20
|
+
switch method {
|
|
21
|
+
case "check":
|
|
22
|
+
check(permission) { promise.resolve($0) }
|
|
23
|
+
case "request":
|
|
24
|
+
request(permission) { promise.resolve($0) }
|
|
25
|
+
default:
|
|
26
|
+
promise.reject("Permissions has no method '\(method)'", code: "unknown_method")
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// MARK: - Check
|
|
31
|
+
|
|
32
|
+
private func check(_ permission: String, _ done: @escaping (String) -> Void) {
|
|
33
|
+
switch permission {
|
|
34
|
+
case "camera":
|
|
35
|
+
done(PermissionsModule.status(AVCaptureDevice.authorizationStatus(for: .video)))
|
|
36
|
+
case "microphone":
|
|
37
|
+
done(PermissionsModule.status(AVCaptureDevice.authorizationStatus(for: .audio)))
|
|
38
|
+
case "photos":
|
|
39
|
+
done(PermissionsModule.status(PHPhotoLibrary.pnReadWriteAuthorizationStatus()))
|
|
40
|
+
case "contacts":
|
|
41
|
+
done(PermissionsModule.status(CNContactStore.authorizationStatus(for: .contacts)))
|
|
42
|
+
case "location":
|
|
43
|
+
done(PermissionsModule.status(CLLocationManager().pnAuthorizationStatus))
|
|
44
|
+
case "notifications":
|
|
45
|
+
UNUserNotificationCenter.current().getNotificationSettings { settings in
|
|
46
|
+
let status: String
|
|
47
|
+
switch settings.authorizationStatus {
|
|
48
|
+
case .authorized, .provisional, .ephemeral: status = "granted"
|
|
49
|
+
case .denied: status = "blocked"
|
|
50
|
+
case .notDetermined: status = "undetermined"
|
|
51
|
+
@unknown default: status = "undetermined"
|
|
52
|
+
}
|
|
53
|
+
DispatchQueue.main.async { done(status) }
|
|
54
|
+
}
|
|
55
|
+
default:
|
|
56
|
+
done("undetermined")
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// MARK: - Request
|
|
61
|
+
|
|
62
|
+
private func request(_ permission: String, _ done: @escaping (String) -> Void) {
|
|
63
|
+
let finish: (String) -> Void = { status in DispatchQueue.main.async { done(status) } }
|
|
64
|
+
switch permission {
|
|
65
|
+
case "camera":
|
|
66
|
+
AVCaptureDevice.requestAccess(for: .video) { finish($0 ? "granted" : "blocked") }
|
|
67
|
+
case "microphone":
|
|
68
|
+
AVCaptureDevice.requestAccess(for: .audio) { finish($0 ? "granted" : "blocked") }
|
|
69
|
+
case "photos":
|
|
70
|
+
PHPhotoLibrary.pnRequestReadWriteAuthorization { finish(PermissionsModule.status($0)) }
|
|
71
|
+
case "contacts":
|
|
72
|
+
CNContactStore().requestAccess(for: .contacts) { granted, _ in finish(granted ? "granted" : "blocked") }
|
|
73
|
+
case "notifications":
|
|
74
|
+
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .badge, .sound]) { granted, _ in
|
|
75
|
+
finish(granted ? "granted" : "blocked")
|
|
76
|
+
}
|
|
77
|
+
case "location":
|
|
78
|
+
let requester = PNLocationPermissionRequester { [weak self] status in
|
|
79
|
+
self?.locationRequester = nil
|
|
80
|
+
finish(status)
|
|
81
|
+
}
|
|
82
|
+
locationRequester = requester
|
|
83
|
+
requester.start()
|
|
84
|
+
default:
|
|
85
|
+
done("undetermined")
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// MARK: - Status mapping
|
|
90
|
+
|
|
91
|
+
static func status(_ s: AVAuthorizationStatus) -> String {
|
|
92
|
+
switch s {
|
|
93
|
+
case .authorized: return "granted"
|
|
94
|
+
case .denied: return "blocked"
|
|
95
|
+
case .restricted: return "blocked"
|
|
96
|
+
case .notDetermined: return "undetermined"
|
|
97
|
+
@unknown default: return "undetermined"
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
static func status(_ s: PHAuthorizationStatus) -> String {
|
|
102
|
+
switch s {
|
|
103
|
+
case .authorized, .limited: return "granted"
|
|
104
|
+
case .denied, .restricted: return "blocked"
|
|
105
|
+
case .notDetermined: return "undetermined"
|
|
106
|
+
@unknown default: return "undetermined"
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
static func status(_ s: CNAuthorizationStatus) -> String {
|
|
111
|
+
switch s {
|
|
112
|
+
case .authorized: return "granted"
|
|
113
|
+
case .denied, .restricted: return "blocked"
|
|
114
|
+
case .notDetermined: return "undetermined"
|
|
115
|
+
// `.limited` (iOS 18) and future cases still permit access.
|
|
116
|
+
default: return "granted"
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
static func status(_ s: CLAuthorizationStatus) -> String {
|
|
121
|
+
switch s {
|
|
122
|
+
case .authorizedAlways, .authorizedWhenInUse: return "granted"
|
|
123
|
+
case .denied, .restricted: return "blocked"
|
|
124
|
+
case .notDetermined: return "undetermined"
|
|
125
|
+
@unknown default: return "undetermined"
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/// One-shot when-in-use authorization request.
|
|
131
|
+
final class PNLocationPermissionRequester: NSObject, CLLocationManagerDelegate {
|
|
132
|
+
private let manager = CLLocationManager()
|
|
133
|
+
private let done: (String) -> Void
|
|
134
|
+
private var finished = false
|
|
135
|
+
|
|
136
|
+
init(done: @escaping (String) -> Void) {
|
|
137
|
+
self.done = done
|
|
138
|
+
super.init()
|
|
139
|
+
manager.delegate = self
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
func start() {
|
|
143
|
+
let current = manager.pnAuthorizationStatus
|
|
144
|
+
if current != .notDetermined {
|
|
145
|
+
finish(PermissionsModule.status(current))
|
|
146
|
+
return
|
|
147
|
+
}
|
|
148
|
+
manager.requestWhenInUseAuthorization()
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
|
|
152
|
+
let status = manager.pnAuthorizationStatus
|
|
153
|
+
if status != .notDetermined {
|
|
154
|
+
finish(PermissionsModule.status(status))
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
private func finish(_ status: String) {
|
|
159
|
+
if finished { return }
|
|
160
|
+
finished = true
|
|
161
|
+
done(status)
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/// `Notifications`: local notifications plus APNs registration.
|
|
166
|
+
public final class NotificationsModule: PNNativeModule {
|
|
167
|
+
public static let name = "Notifications"
|
|
168
|
+
|
|
169
|
+
private static var tokenWaiters: [PNPromise] = []
|
|
170
|
+
|
|
171
|
+
public init() {}
|
|
172
|
+
|
|
173
|
+
public func call(_ method: String, args: [String: Any], promise: PNPromise) {
|
|
174
|
+
let center = UNUserNotificationCenter.current()
|
|
175
|
+
switch method {
|
|
176
|
+
case "request_permission":
|
|
177
|
+
center.requestAuthorization(options: [.alert, .badge, .sound]) { granted, _ in
|
|
178
|
+
DispatchQueue.main.async { promise.resolve(granted) }
|
|
179
|
+
}
|
|
180
|
+
case "schedule":
|
|
181
|
+
let content = UNMutableNotificationContent()
|
|
182
|
+
content.title = PNProps.string(args["title"]) ?? ""
|
|
183
|
+
content.body = PNProps.string(args["body"]) ?? ""
|
|
184
|
+
if PNProps.bool(args["sound"]) != false { content.sound = .default }
|
|
185
|
+
if let badge = PNProps.int(args["badge"]) { content.badge = NSNumber(value: badge) }
|
|
186
|
+
let delay = max(0.1, PNProps.double(args["delay_seconds"]) ?? 0)
|
|
187
|
+
let trigger = UNTimeIntervalNotificationTrigger(timeInterval: delay, repeats: false)
|
|
188
|
+
let identifier = PNProps.string(args["identifier"]) ?? "default"
|
|
189
|
+
center.add(UNNotificationRequest(identifier: identifier, content: content, trigger: trigger)) { error in
|
|
190
|
+
DispatchQueue.main.async {
|
|
191
|
+
if let error = error {
|
|
192
|
+
PNLog.modules.error("schedule failed: \(error.localizedDescription)")
|
|
193
|
+
}
|
|
194
|
+
promise.resolve(error == nil)
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
case "cancel":
|
|
198
|
+
let identifier = PNProps.string(args["identifier"]) ?? "default"
|
|
199
|
+
center.removePendingNotificationRequests(withIdentifiers: [identifier])
|
|
200
|
+
center.removeDeliveredNotifications(withIdentifiers: [identifier])
|
|
201
|
+
promise.resolve(nil)
|
|
202
|
+
case "get_device_token":
|
|
203
|
+
NotificationsModule.tokenWaiters.append(promise)
|
|
204
|
+
UIApplication.shared.registerForRemoteNotifications()
|
|
205
|
+
default:
|
|
206
|
+
promise.reject("Notifications has no method '\(method)'", code: "unknown_method")
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/// Called by the app delegate with the APNs token.
|
|
211
|
+
public static func deliverToken(_ token: Data) {
|
|
212
|
+
let hex = token.map { String(format: "%02x", $0) }.joined()
|
|
213
|
+
let waiters = tokenWaiters
|
|
214
|
+
tokenWaiters = []
|
|
215
|
+
for waiter in waiters { waiter.resolve(hex) }
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/// Called by the app delegate when APNs registration fails.
|
|
219
|
+
public static func deliverError(_ error: Error) {
|
|
220
|
+
let waiters = tokenWaiters
|
|
221
|
+
tokenWaiters = []
|
|
222
|
+
for waiter in waiters { waiter.reject(String(describing: error), code: "apns") }
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/// `Biometrics`: `LAContext` availability and authentication.
|
|
227
|
+
public final class BiometricsModule: PNNativeModule {
|
|
228
|
+
public static let name = "Biometrics"
|
|
229
|
+
|
|
230
|
+
public init() {}
|
|
231
|
+
|
|
232
|
+
public func call(_ method: String, args: [String: Any], promise: PNPromise) {
|
|
233
|
+
switch method {
|
|
234
|
+
case "is_available":
|
|
235
|
+
let context = LAContext()
|
|
236
|
+
var error: NSError?
|
|
237
|
+
promise.resolve(context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error))
|
|
238
|
+
case "authenticate":
|
|
239
|
+
let context = LAContext()
|
|
240
|
+
let reason = PNProps.string(args["reason"]) ?? "Authenticate"
|
|
241
|
+
var error: NSError?
|
|
242
|
+
let policy: LAPolicy = context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error)
|
|
243
|
+
? .deviceOwnerAuthenticationWithBiometrics : .deviceOwnerAuthentication
|
|
244
|
+
context.evaluatePolicy(policy, localizedReason: reason) { success, _ in
|
|
245
|
+
DispatchQueue.main.async { promise.resolve(success) }
|
|
246
|
+
}
|
|
247
|
+
default:
|
|
248
|
+
promise.reject("Biometrics has no method '\(method)'", code: "unknown_method")
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import AudioToolbox
|
|
2
|
+
import Security
|
|
3
|
+
import UIKit
|
|
4
|
+
|
|
5
|
+
/// `Device`: static device and app-path information.
|
|
6
|
+
public final class DeviceModule: PNNativeModule {
|
|
7
|
+
public static let name = "Device"
|
|
8
|
+
|
|
9
|
+
public init() {}
|
|
10
|
+
|
|
11
|
+
public func call(_ method: String, args: [String: Any], promise: PNPromise) {
|
|
12
|
+
switch method {
|
|
13
|
+
case "info":
|
|
14
|
+
promise.resolve(DeviceModule.info())
|
|
15
|
+
default:
|
|
16
|
+
promise.reject("Device has no method '\(method)'", code: "unknown_method")
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/// The `Device.info()` payload.
|
|
21
|
+
public static func info() -> [String: Any] {
|
|
22
|
+
let paths = FileManager.default
|
|
23
|
+
let documents = paths.urls(for: .documentDirectory, in: .userDomainMask).first?.path ?? NSHomeDirectory() + "/Documents"
|
|
24
|
+
let caches = paths.urls(for: .cachesDirectory, in: .userDomainMask).first?.path ?? NSTemporaryDirectory()
|
|
25
|
+
return [
|
|
26
|
+
"os": "ios",
|
|
27
|
+
"os_version": UIDevice.current.systemVersion,
|
|
28
|
+
"model": UIDevice.current.model,
|
|
29
|
+
"name": UIDevice.current.name,
|
|
30
|
+
"app_dir": documents,
|
|
31
|
+
"cache_dir": caches,
|
|
32
|
+
"locale": Locale.current.identifier,
|
|
33
|
+
"scale": Double(PNWindow.screenScale()),
|
|
34
|
+
"is_simulator": DeviceModule.isSimulator,
|
|
35
|
+
]
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
static var isSimulator: Bool {
|
|
39
|
+
#if targetEnvironment(simulator)
|
|
40
|
+
return true
|
|
41
|
+
#else
|
|
42
|
+
return false
|
|
43
|
+
#endif
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/// `Storage`: `AsyncStorage` over the `pn_async_storage` defaults suite.
|
|
48
|
+
public final class StorageModule: PNNativeModule {
|
|
49
|
+
public static let name = "Storage"
|
|
50
|
+
static let suiteName = "pn_async_storage"
|
|
51
|
+
|
|
52
|
+
private let defaults: UserDefaults
|
|
53
|
+
|
|
54
|
+
public init() {
|
|
55
|
+
defaults = UserDefaults(suiteName: StorageModule.suiteName) ?? .standard
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
public func call(_ method: String, args: [String: Any], promise: PNPromise) {
|
|
59
|
+
switch method {
|
|
60
|
+
case "get":
|
|
61
|
+
guard let key = PNProps.string(args["key"]) else { return promise.reject("missing key", code: "bad_args") }
|
|
62
|
+
promise.resolve(defaults.string(forKey: key))
|
|
63
|
+
case "set":
|
|
64
|
+
guard let key = PNProps.string(args["key"]) else { return promise.reject("missing key", code: "bad_args") }
|
|
65
|
+
defaults.set(PNProps.string(args["value"]) ?? "", forKey: key)
|
|
66
|
+
promise.resolve(nil)
|
|
67
|
+
case "delete":
|
|
68
|
+
guard let key = PNProps.string(args["key"]) else { return promise.reject("missing key", code: "bad_args") }
|
|
69
|
+
defaults.removeObject(forKey: key)
|
|
70
|
+
promise.resolve(nil)
|
|
71
|
+
case "all_keys":
|
|
72
|
+
let keys: [String] = defaults.persistentDomain(forName: StorageModule.suiteName)?.keys.map { $0 } ?? []
|
|
73
|
+
promise.resolve(keys.sorted())
|
|
74
|
+
case "clear":
|
|
75
|
+
defaults.removePersistentDomain(forName: StorageModule.suiteName)
|
|
76
|
+
promise.resolve(nil)
|
|
77
|
+
default:
|
|
78
|
+
promise.reject("Storage has no method '\(method)'", code: "unknown_method")
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/// `SecureStore`: Keychain generic passwords under one service.
|
|
84
|
+
public final class SecureStoreModule: PNNativeModule {
|
|
85
|
+
public static let name = "SecureStore"
|
|
86
|
+
static let service = "com.pythonnative.securestore"
|
|
87
|
+
|
|
88
|
+
public init() {}
|
|
89
|
+
|
|
90
|
+
public func call(_ method: String, args: [String: Any], promise: PNPromise) {
|
|
91
|
+
guard let key = PNProps.string(args["key"]) else {
|
|
92
|
+
promise.reject("missing key", code: "bad_args")
|
|
93
|
+
return
|
|
94
|
+
}
|
|
95
|
+
switch method {
|
|
96
|
+
case "set_item":
|
|
97
|
+
promise.resolve(SecureStoreModule.set(key, PNProps.string(args["value"]) ?? ""))
|
|
98
|
+
case "get_item":
|
|
99
|
+
promise.resolve(SecureStoreModule.get(key))
|
|
100
|
+
case "delete_item":
|
|
101
|
+
promise.resolve(SecureStoreModule.delete(key))
|
|
102
|
+
default:
|
|
103
|
+
promise.reject("SecureStore has no method '\(method)'", code: "unknown_method")
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
private static func query(_ key: String) -> [String: Any] {
|
|
108
|
+
[
|
|
109
|
+
kSecClass as String: kSecClassGenericPassword,
|
|
110
|
+
kSecAttrService as String: service,
|
|
111
|
+
kSecAttrAccount as String: key,
|
|
112
|
+
]
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
static func set(_ key: String, _ value: String) -> Bool {
|
|
116
|
+
let data = Data(value.utf8)
|
|
117
|
+
var q = query(key)
|
|
118
|
+
let status = SecItemCopyMatching(q as CFDictionary, nil)
|
|
119
|
+
if status == errSecSuccess {
|
|
120
|
+
return SecItemUpdate(q as CFDictionary, [kSecValueData as String: data] as CFDictionary) == errSecSuccess
|
|
121
|
+
}
|
|
122
|
+
q[kSecValueData as String] = data
|
|
123
|
+
q[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock
|
|
124
|
+
return SecItemAdd(q as CFDictionary, nil) == errSecSuccess
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
static func get(_ key: String) -> String? {
|
|
128
|
+
var q = query(key)
|
|
129
|
+
q[kSecReturnData as String] = true
|
|
130
|
+
q[kSecMatchLimit as String] = kSecMatchLimitOne
|
|
131
|
+
var result: CFTypeRef?
|
|
132
|
+
guard SecItemCopyMatching(q as CFDictionary, &result) == errSecSuccess, let data = result as? Data else { return nil }
|
|
133
|
+
return String(data: data, encoding: .utf8)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
static func delete(_ key: String) -> Bool {
|
|
137
|
+
SecItemDelete(query(key) as CFDictionary) == errSecSuccess
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/// `Clipboard`: the general pasteboard.
|
|
142
|
+
public final class ClipboardModule: PNNativeModule {
|
|
143
|
+
public static let name = "Clipboard"
|
|
144
|
+
|
|
145
|
+
public init() {}
|
|
146
|
+
|
|
147
|
+
public func call(_ method: String, args: [String: Any], promise: PNPromise) {
|
|
148
|
+
switch method {
|
|
149
|
+
case "set_string":
|
|
150
|
+
UIPasteboard.general.string = PNProps.string(args["text"]) ?? ""
|
|
151
|
+
promise.resolve(nil)
|
|
152
|
+
case "get_string":
|
|
153
|
+
promise.resolve(UIPasteboard.general.string ?? "")
|
|
154
|
+
default:
|
|
155
|
+
promise.reject("Clipboard has no method '\(method)'", code: "unknown_method")
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/// `Share`: `UIActivityViewController`, resolving whether a share completed.
|
|
161
|
+
public final class ShareModule: PNNativeModule {
|
|
162
|
+
public static let name = "Share"
|
|
163
|
+
|
|
164
|
+
public init() {}
|
|
165
|
+
|
|
166
|
+
public func call(_ method: String, args: [String: Any], promise: PNPromise) {
|
|
167
|
+
guard method == "share" else {
|
|
168
|
+
promise.reject("Share has no method '\(method)'", code: "unknown_method")
|
|
169
|
+
return
|
|
170
|
+
}
|
|
171
|
+
var items: [Any] = []
|
|
172
|
+
if let message = PNProps.string(args["message"]), !message.isEmpty { items.append(message) }
|
|
173
|
+
if let url = PNProps.string(args["url"]).flatMap({ URL(string: $0) }) { items.append(url) }
|
|
174
|
+
guard !items.isEmpty, let top = PNWindow.topViewController() else {
|
|
175
|
+
promise.resolve(false)
|
|
176
|
+
return
|
|
177
|
+
}
|
|
178
|
+
let controller = UIActivityViewController(activityItems: items, applicationActivities: nil)
|
|
179
|
+
if let title = PNProps.string(args["title"]) {
|
|
180
|
+
controller.setValue(title, forKey: "subject")
|
|
181
|
+
}
|
|
182
|
+
controller.completionWithItemsHandler = { _, completed, _, _ in
|
|
183
|
+
promise.resolve(completed)
|
|
184
|
+
}
|
|
185
|
+
if let popover = controller.popoverPresentationController {
|
|
186
|
+
popover.sourceView = top.view
|
|
187
|
+
popover.sourceRect = CGRect(x: top.view.bounds.midX, y: top.view.bounds.midY, width: 1, height: 1)
|
|
188
|
+
popover.permittedArrowDirections = []
|
|
189
|
+
}
|
|
190
|
+
top.present(controller, animated: true)
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/// `Haptics`: feedback generators plus the legacy vibration sound.
|
|
195
|
+
public final class HapticsModule: PNNativeModule {
|
|
196
|
+
public static let name = "Haptics"
|
|
197
|
+
|
|
198
|
+
public init() {}
|
|
199
|
+
|
|
200
|
+
public func call(_ method: String, args: [String: Any], promise: PNPromise) {
|
|
201
|
+
switch method {
|
|
202
|
+
case "impact":
|
|
203
|
+
let generator = UIImpactFeedbackGenerator(style: HapticsModule.impactStyle(PNProps.string(args["style"])))
|
|
204
|
+
generator.prepare()
|
|
205
|
+
generator.impactOccurred()
|
|
206
|
+
promise.resolve(nil)
|
|
207
|
+
case "notification":
|
|
208
|
+
let generator = UINotificationFeedbackGenerator()
|
|
209
|
+
generator.prepare()
|
|
210
|
+
generator.notificationOccurred(HapticsModule.notificationType(PNProps.string(args["type"])))
|
|
211
|
+
promise.resolve(nil)
|
|
212
|
+
case "selection":
|
|
213
|
+
let generator = UISelectionFeedbackGenerator()
|
|
214
|
+
generator.prepare()
|
|
215
|
+
generator.selectionChanged()
|
|
216
|
+
promise.resolve(nil)
|
|
217
|
+
case "vibrate":
|
|
218
|
+
let duration = PNProps.int(args["duration_ms"]) ?? 400
|
|
219
|
+
if duration <= 100 {
|
|
220
|
+
let generator = UIImpactFeedbackGenerator(style: .heavy)
|
|
221
|
+
generator.prepare()
|
|
222
|
+
generator.impactOccurred()
|
|
223
|
+
} else {
|
|
224
|
+
AudioServicesPlaySystemSound(kSystemSoundID_Vibrate)
|
|
225
|
+
}
|
|
226
|
+
promise.resolve(nil)
|
|
227
|
+
case "cancel":
|
|
228
|
+
promise.resolve(nil)
|
|
229
|
+
default:
|
|
230
|
+
promise.reject("Haptics has no method '\(method)'", code: "unknown_method")
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
static func impactStyle(_ name: String?) -> UIImpactFeedbackGenerator.FeedbackStyle {
|
|
235
|
+
switch name {
|
|
236
|
+
case "light": return .light
|
|
237
|
+
case "heavy": return .heavy
|
|
238
|
+
case "soft": return .soft
|
|
239
|
+
case "rigid": return .rigid
|
|
240
|
+
default: return .medium
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
static func notificationType(_ name: String?) -> UINotificationFeedbackGenerator.FeedbackType {
|
|
245
|
+
switch name {
|
|
246
|
+
case "warning": return .warning
|
|
247
|
+
case "error": return .error
|
|
248
|
+
default: return .success
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// Generated by `pn build`. Do not edit: the PythonNative build tool
|
|
2
|
+
// overwrites this file with one `register(into:)` call per bundled plugin.
|
|
3
|
+
|
|
4
|
+
/// Registration hook for plugins copied into `Sources/Plugins` by `pn build`.
|
|
5
|
+
public enum PNGeneratedPlugins {
|
|
6
|
+
/// Register every generated plugin into `registry`.
|
|
7
|
+
public static func registerAll(into registry: PNRegistry) {}
|
|
8
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import UIKit
|
|
2
|
+
|
|
3
|
+
/// Screen id <-> `PNViewController` table.
|
|
4
|
+
///
|
|
5
|
+
/// Python addresses screens by the integer id assigned here; the
|
|
6
|
+
/// controller is held weakly so a popped screen is reclaimed normally.
|
|
7
|
+
public final class PNScreenRegistry {
|
|
8
|
+
public static let shared = PNScreenRegistry()
|
|
9
|
+
|
|
10
|
+
private final class WeakBox {
|
|
11
|
+
weak var controller: PNViewController?
|
|
12
|
+
init(_ controller: PNViewController) { self.controller = controller }
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
private var screens: [Int64: WeakBox] = [:]
|
|
16
|
+
private var nextId: Int64 = 1
|
|
17
|
+
|
|
18
|
+
private init() {}
|
|
19
|
+
|
|
20
|
+
/// Assign a fresh id to `controller`.
|
|
21
|
+
func register(_ controller: PNViewController) -> Int64 {
|
|
22
|
+
let id = nextId
|
|
23
|
+
nextId += 1
|
|
24
|
+
screens[id] = WeakBox(controller)
|
|
25
|
+
return id
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/// Forget `id` (called from the controller's deinit).
|
|
29
|
+
func unregister(_ id: Int64) {
|
|
30
|
+
screens.removeValue(forKey: id)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/// The controller for `id`, if it's still alive.
|
|
34
|
+
public func controller(for id: Int64) -> PNViewController? {
|
|
35
|
+
guard let box = screens[id] else { return nil }
|
|
36
|
+
if box.controller == nil {
|
|
37
|
+
screens.removeValue(forKey: id)
|
|
38
|
+
}
|
|
39
|
+
return box.controller
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/// Ids of every live screen.
|
|
43
|
+
public var screenIds: [Int64] {
|
|
44
|
+
screens.compactMap { $0.value.controller == nil ? nil : $0.key }.sorted()
|
|
45
|
+
}
|
|
46
|
+
}
|