| ... | @@ -2,14 +2,15 @@ import ExpoModulesCore | ... | @@ -2,14 +2,15 @@ import ExpoModulesCore |
| 2 | import Security | 2 | import Security |
| 3 | import UIKit | 3 | import UIKit |
| 4 | | 4 | |
| | 5 | // Expo Modules 2.0: plain members marked @JS are bound by the macro, while the |
| | 6 | // views and main-queue presenters stay in definition(). Both are merged. |
| | 7 | @ExpoModule("T3NativeControls") |
| 5 | public final class T3NativeControlsModule: Module { | 8 | public final class T3NativeControlsModule: Module { |
| 6 | private let presentationSources = T3PresentationSources() | 9 | private let presentationSources = T3PresentationSources() |
| 7 | private var videoPresentation: T3NativeVideoPresentation? | 10 | private var videoPresentation: T3NativeVideoPresentation? |
| 8 | private var filePresentation: T3NativeFilePresentation? | 11 | private var filePresentation: T3NativeFilePresentation? |
| 9 | | 12 | |
| 10 | public func definition() -> ModuleDefinition { | 13 | public func definition() -> ModuleDefinition { |
| 11 | Name("T3NativeControls") | | |
| 12 | | | |
| 13 | AsyncFunction("presentVideo") { (url: URL, title: String, sourceIdentifier: String, identifier: String, promise: Promise) in | 14 | AsyncFunction("presentVideo") { (url: URL, title: String, sourceIdentifier: String, identifier: String, promise: Promise) in |
| 14 | try self.presentVideo( | 15 | try self.presentVideo( |
| 15 | url: url, | 16 | url: url, |
| ... | @@ -20,19 +21,11 @@ public final class T3NativeControlsModule: Module { | ... | @@ -20,19 +21,11 @@ public final class T3NativeControlsModule: Module { |
| 20 | ) | 21 | ) |
| 21 | }.runOnQueue(.main) | 22 | }.runOnQueue(.main) |
| 22 | | 23 | |
| 23 | AsyncFunction("dismissVideo") { (identifier: String) in | | |
| 24 | self.dismissVideo(identifier: identifier) | | |
| 25 | }.runOnQueue(.main) | | |
| 26 | | | |
| 27 | AsyncFunction("presentFile") { (url: URL, title: String, sourceIdentifier: String, identifier: String, promise: Promise) in | 24 | AsyncFunction("presentFile") { (url: URL, title: String, sourceIdentifier: String, identifier: String, promise: Promise) in |
| 28 | try self.presentFile(url: url, title: title, sourceIdentifier: sourceIdentifier, | 25 | try self.presentFile(url: url, title: title, sourceIdentifier: sourceIdentifier, |
| 29 | identifier: identifier, promise: promise) | 26 | identifier: identifier, promise: promise) |
| 30 | }.runOnQueue(.main) | 27 | }.runOnQueue(.main) |
| 31 | | 28 | |
| 32 | AsyncFunction("dismissFile") { (identifier: String) in | | |
| 33 | self.dismissFile(identifier: identifier) | | |
| 34 | }.runOnQueue(.main) | | |
| 35 | | | |
| 36 | OnDestroy { | 29 | OnDestroy { |
| 37 | let presentation = self.videoPresentation | 30 | let presentation = self.videoPresentation |
| 38 | let file = self.filePresentation | 31 | let file = self.filePresentation |
| ... | @@ -60,103 +53,109 @@ public final class T3NativeControlsModule: Module { | ... | @@ -60,103 +53,109 @@ public final class T3NativeControlsModule: Module { |
| 60 | AsyncFunction("shareFileFromSource") { (url: URL, title: String, identifier: String, promise: Promise) in | 53 | AsyncFunction("shareFileFromSource") { (url: URL, title: String, identifier: String, promise: Promise) in |
| 61 | try self.shareFile(url: url, title: title, sourceIdentifier: identifier, promise: promise) | 54 | try self.shareFile(url: url, title: title, sourceIdentifier: identifier, promise: promise) |
| 62 | }.runOnQueue(.main) | 55 | }.runOnQueue(.main) |
| | 56 | } |
| 63 | | 57 | |
| 64 | Function("getShowcasePairingUrl") { | 58 | // UIKit presenters are main-actor isolated rather than run on the JS thread |
| 65 | let arguments = ProcessInfo.processInfo.arguments | 59 | // the macro would otherwise pick. |
| 66 | guard | 60 | @JS |
| 67 | let flagIndex = arguments.firstIndex(of: "--showcasePairingUrl"), | 61 | @MainActor |
| 68 | arguments.indices.contains(flagIndex + 1) | 62 | func dismissVideo(identifier: String) async { |
| 69 | else { | 63 | dismissVideoPresentation(identifier: identifier) |
| 70 | return nil as String? | 64 | } |
| 71 | } | 65 | |
| 72 | return arguments[flagIndex + 1] | 66 | @JS |
| 73 | } | 67 | @MainActor |
| | 68 | func dismissFile(identifier: String) async { |
| | 69 | dismissFilePresentation(identifier: identifier) |
| | 70 | } |
| 74 | | 71 | |
| 75 | Function("getShowcaseScene") { () -> String? in | 72 | // Rotates the interface without Simulator menu UI scripting, which CI |
| 76 | let scenePath = NSHomeDirectory() + "/Library/Caches/T3ShowcaseScene" | 73 | // runners cannot perform (osascript is denied Accessibility access there). |
| 77 | if let storedScene = try? String(contentsOfFile: scenePath, encoding: .utf8) | 74 | @JS |
| 78 | .trimmingCharacters(in: .whitespacesAndNewlines), !storedScene.isEmpty { | 75 | @MainActor |
| 79 | return storedScene | 76 | func applyShowcaseOrientation(orientation: String) async { |
| | 77 | let mask: UIInterfaceOrientationMask = orientation == "landscape" ? .landscapeRight : .portrait |
| | 78 | for case let windowScene as UIWindowScene in UIApplication.shared.connectedScenes { |
| | 79 | windowScene.requestGeometryUpdate(.iOS(interfaceOrientations: mask)) { error in |
| | 80 | NSLog("T3NativeControls applyShowcaseOrientation(\(orientation)) failed: \(error)") |
| 80 | } | 81 | } |
| 81 | let arguments = ProcessInfo.processInfo.arguments | 82 | for window in windowScene.windows { |
| 82 | guard | 83 | window.rootViewController?.setNeedsUpdateOfSupportedInterfaceOrientations() |
| 83 | let flagIndex = arguments.firstIndex(of: "--showcaseScene"), | | |
| 84 | arguments.indices.contains(flagIndex + 1) | | |
| 85 | else { | | |
| 86 | return nil as String? | | |
| 87 | } | 84 | } |
| 88 | return arguments[flagIndex + 1] | | |
| 89 | } | 85 | } |
| | 86 | } |
| 90 | | 87 | |
| 91 | // The palette is fixed for the whole capture, so it only ever arrives as a | 88 | // The geometry request above can fail transiently (for example before the |
| 92 | // launch argument — unlike the scene, which the runner rewrites in place. | 89 | // scene is foreground-active), so callers poll this until it settles. |
| 93 | Function("getShowcaseTheme") { () -> String? in | 90 | // Screen bounds — not the scene's interface orientation — decide the |
| 94 | let arguments = ProcessInfo.processInfo.arguments | 91 | // answer because they match the captured framebuffer: with iPadOS |
| 95 | guard | 92 | // windowing active, a floating landscape window still reports a portrait |
| 96 | let flagIndex = arguments.firstIndex(of: "--showcaseTheme"), | 93 | // screen, and screenshots would come out portrait. |
| 97 | arguments.indices.contains(flagIndex + 1) | 94 | @JS |
| 98 | else { | 95 | @MainActor |
| 99 | return nil as String? | 96 | func getInterfaceOrientation() async -> String { |
| 100 | } | 97 | guard |
| 101 | return arguments[flagIndex + 1] | 98 | let windowScene = UIApplication.shared.connectedScenes |
| | 99 | .compactMap({ $0 as? UIWindowScene }) |
| | 100 | .first |
| | 101 | else { |
| | 102 | return "unknown" |
| 102 | } | 103 | } |
| | 104 | let bounds = windowScene.screen.coordinateSpace.bounds |
| | 105 | return bounds.width > bounds.height ? "landscape" : "portrait" |
| | 106 | } |
| 103 | | 107 | |
| 104 | Function("getShowcaseOrientation") { () -> String? in | 108 | private func launchArgument(_ flag: String) -> String? { |
| 105 | let arguments = ProcessInfo.processInfo.arguments | 109 | let arguments = ProcessInfo.processInfo.arguments |
| 106 | guard | 110 | guard |
| 107 | let flagIndex = arguments.firstIndex(of: "--showcaseOrientation"), | 111 | let flagIndex = arguments.firstIndex(of: flag), |
| 108 | arguments.indices.contains(flagIndex + 1) | 112 | arguments.indices.contains(flagIndex + 1) |
| 109 | else { | 113 | else { |
| 110 | return nil as String? | 114 | return nil |
| 111 | } | | |
| 112 | return arguments[flagIndex + 1] | | |
| 113 | } | 115 | } |
| | 116 | return arguments[flagIndex + 1] |
| | 117 | } |
| 114 | | 118 | |
| 115 | // Rotates the interface without Simulator menu UI scripting, which CI | 119 | @JS |
| 116 | // runners cannot perform (osascript is denied Accessibility access there). | 120 | func getShowcasePairingUrl() -> String? { |
| 117 | AsyncFunction("applyShowcaseOrientation") { (orientation: String) in | 121 | launchArgument("--showcasePairingUrl") |
| 118 | guard #available(iOS 16.0, *) else { return } | 122 | } |
| 119 | let mask: UIInterfaceOrientationMask = orientation == "landscape" ? .landscapeRight : .portrait | | |
| 120 | for case let windowScene as UIWindowScene in UIApplication.shared.connectedScenes { | | |
| 121 | windowScene.requestGeometryUpdate(.iOS(interfaceOrientations: mask)) { error in | | |
| 122 | NSLog("T3NativeControls applyShowcaseOrientation(\(orientation)) failed: \(error)") | | |
| 123 | } | | |
| 124 | for window in windowScene.windows { | | |
| 125 | window.rootViewController?.setNeedsUpdateOfSupportedInterfaceOrientations() | | |
| 126 | } | | |
| 127 | } | | |
| 128 | }.runOnQueue(.main) | | |
| 129 | | | |
| 130 | // The geometry request above can fail transiently (for example before the | | |
| 131 | // scene is foreground-active), so callers poll this until it settles. | | |
| 132 | // Screen bounds — not the scene's interface orientation — decide the | | |
| 133 | // answer because they match the captured framebuffer: with iPadOS | | |
| 134 | // windowing active, a floating landscape window still reports a portrait | | |
| 135 | // screen, and screenshots would come out portrait. | | |
| 136 | AsyncFunction("getInterfaceOrientation") { () -> String in | | |
| 137 | guard | | |
| 138 | let windowScene = UIApplication.shared.connectedScenes | | |
| 139 | .compactMap({ $0 as? UIWindowScene }) | | |
| 140 | .first | | |
| 141 | else { | | |
| 142 | return "unknown" | | |
| 143 | } | | |
| 144 | let bounds = windowScene.screen.coordinateSpace.bounds | | |
| 145 | return bounds.width > bounds.height ? "landscape" : "portrait" | | |
| 146 | }.runOnQueue(.main) | | |
| 147 | | 123 | |
| 148 | Function("prepareShowcaseCapture") { | 124 | @JS |
| 149 | for itemClass in [kSecClassGenericPassword, kSecClassInternetPassword] { | 125 | func getShowcaseScene() -> String? { |
| 150 | SecItemDelete([kSecClass as String: itemClass] as CFDictionary) | 126 | let scenePath = NSHomeDirectory() + "/Library/Caches/T3ShowcaseScene" |
| 151 | } | 127 | if let storedScene = try? String(contentsOfFile: scenePath, encoding: .utf8) |
| | 128 | .trimmingCharacters(in: .whitespacesAndNewlines), !storedScene.isEmpty { |
| | 129 | return storedScene |
| 152 | } | 130 | } |
| | 131 | return launchArgument("--showcaseScene") |
| | 132 | } |
| | 133 | |
| | 134 | // The palette is fixed for the whole capture, so it only ever arrives as a |
| | 135 | // launch argument — unlike the scene, which the runner rewrites in place. |
| | 136 | @JS |
| | 137 | func getShowcaseTheme() -> String? { |
| | 138 | launchArgument("--showcaseTheme") |
| | 139 | } |
| 153 | | 140 | |
| 154 | Function("markShowcaseReady") { (scene: String) in | 141 | @JS |
| 155 | let readyPath = NSHomeDirectory() + "/Library/Caches/T3ShowcaseReadyScene" | 142 | func getShowcaseOrientation() -> String? { |
| 156 | try? scene.write(toFile: readyPath, atomically: true, encoding: .utf8) | 143 | launchArgument("--showcaseOrientation") |
| | 144 | } |
| | 145 | |
| | 146 | @JS |
| | 147 | func prepareShowcaseCapture() { |
| | 148 | for itemClass in [kSecClassGenericPassword, kSecClassInternetPassword] { |
| | 149 | SecItemDelete([kSecClass as String: itemClass] as CFDictionary) |
| 157 | } | 150 | } |
| 158 | } | 151 | } |
| 159 | | 152 | |
| | 153 | @JS(.concurrent) |
| | 154 | func markShowcaseReady(scene: String) async { |
| | 155 | let readyPath = NSHomeDirectory() + "/Library/Caches/T3ShowcaseReadyScene" |
| | 156 | try? scene.write(toFile: readyPath, atomically: true, encoding: .utf8) |
| | 157 | } |
| | 158 | |
| 160 | private func presentVideo(url: URL, title: String, sourceIdentifier: String, identifier: String, promise: Promise) throws { | 159 | private func presentVideo(url: URL, title: String, sourceIdentifier: String, identifier: String, promise: Promise) throws { |
| 161 | let isPlayableURL = url.isFileURL | 160 | let isPlayableURL = url.isFileURL |
| 162 | ? FileManager.default.isReadableFile(atPath: url.path) | 161 | ? FileManager.default.isReadableFile(atPath: url.path) |
| ... | @@ -179,7 +178,7 @@ public final class T3NativeControlsModule: Module { | ... | @@ -179,7 +178,7 @@ public final class T3NativeControlsModule: Module { |
| 179 | presentation.present(from: presenter, sources: presentationSources, sourceIdentifier: sourceIdentifier) | 178 | presentation.present(from: presenter, sources: presentationSources, sourceIdentifier: sourceIdentifier) |
| 180 | } | 179 | } |
| 181 | | 180 | |
| 182 | private func dismissVideo(identifier: String) { | 181 | private func dismissVideoPresentation(identifier: String) { |
| 183 | if videoPresentation?.identifier == identifier { videoPresentation?.dismiss() } | 182 | if videoPresentation?.identifier == identifier { videoPresentation?.dismiss() } |
| 184 | } | 183 | } |
| 185 | | 184 | |
| ... | @@ -197,7 +196,7 @@ public final class T3NativeControlsModule: Module { | ... | @@ -197,7 +196,7 @@ public final class T3NativeControlsModule: Module { |
| 197 | file.present(url: url, title: title, from: presenter) | 196 | file.present(url: url, title: title, from: presenter) |
| 198 | } | 197 | } |
| 199 | | 198 | |
| 200 | private func dismissFile(identifier: String) { | 199 | private func dismissFilePresentation(identifier: String) { |
| 201 | if filePresentation?.identifier == identifier { filePresentation?.dismiss() } | 200 | if filePresentation?.identifier == identifier { filePresentation?.dismiss() } |
| 202 | } | 201 | } |
| 203 | | 202 | |