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