| 1 | import ExpoModulesCore |
| 2 | import Security |
| 3 | import UIKit |
| 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") |
| 8 | public final class T3NativeControlsModule: Module { |
| 9 | private let presentationSources = T3PresentationSources() |
| 10 | private var videoPresentation: T3NativeVideoPresentation? |
| 11 | private var filePresentation: T3NativeFilePresentation? |
| 12 | |
| 13 | public func definition() -> ModuleDefinition { |
| 14 | Constants { |
| 15 | if #available(iOS 26.0, *) { |
| 16 | return [ |
| 17 | "supportsWorkspaceColumns": NSClassFromString("RNSSplitHostComponentView") != nil, |
| 18 | ] |
| 19 | } |
| 20 | return ["supportsWorkspaceColumns": false] |
| 21 | } |
| 22 | View(T3LayoutMetricsView.self) { |
| 23 | ViewName("LayoutMetrics") |
| 24 | Events("onMetricsChange") |
| 25 | } |
| 26 | |
| 27 | AsyncFunction("presentVideo") { (url: URL, title: String, sourceIdentifier: String, identifier: String, promise: Promise) in |
| 28 | try self.presentVideo( |
| 29 | url: url, |
| 30 | title: title, |
| 31 | sourceIdentifier: sourceIdentifier, |
| 32 | identifier: identifier, |
| 33 | promise: promise |
| 34 | ) |
| 35 | }.runOnQueue(.main) |
| 36 | |
| 37 | AsyncFunction("presentFile") { (url: URL, title: String, sourceIdentifier: String, identifier: String, promise: Promise) in |
| 38 | try self.presentFile(url: url, title: title, sourceIdentifier: sourceIdentifier, |
| 39 | identifier: identifier, promise: promise) |
| 40 | }.runOnQueue(.main) |
| 41 | |
| 42 | AsyncFunction("presentFiles") { (urls: [URL], titles: [String], selectedIndex: Int, identifier: String, promise: Promise) in |
| 43 | guard !urls.isEmpty, urls.count == titles.count, urls.indices.contains(selectedIndex) else { |
| 44 | throw URLError(.badURL) |
| 45 | } |
| 46 | let file = try self.makeFilePresentation(sourceIdentifier: "", identifier: identifier, promise: promise) |
| 47 | guard let presenter = self.appContext?.utilities?.currentViewController() else { |
| 48 | file.dismiss() |
| 49 | return |
| 50 | } |
| 51 | file.present(urls: urls, titles: titles, selectedIndex: selectedIndex, from: presenter) |
| 52 | }.runOnQueue(.main) |
| 53 | |
| 54 | OnDestroy { |
| 55 | let presentation = self.videoPresentation |
| 56 | let file = self.filePresentation |
| 57 | DispatchQueue.main.async { |
| 58 | presentation?.dismiss() |
| 59 | file?.dismiss() |
| 60 | } |
| 61 | } |
| 62 | |
| 63 | View(T3PresentationSourceView.self) { |
| 64 | ViewName("PresentationSource") |
| 65 | Prop("identifier") { (view: T3PresentationSourceView, identifier: String) in |
| 66 | view.sources = self.presentationSources |
| 67 | view.identifier = identifier |
| 68 | } |
| 69 | } |
| 70 | |
| 71 | View(T3FrostedCutoutView.self) { |
| 72 | ViewName("FrostedCutout") |
| 73 | Prop("cutoutTop") { (view: T3FrostedCutoutView, value: Double) in view.cutoutTop = CGFloat(value) } |
| 74 | Prop("cutoutWidth") { (view: T3FrostedCutoutView, value: Double) in view.cutoutWidth = CGFloat(value) } |
| 75 | Prop("cutoutHeight") { (view: T3FrostedCutoutView, value: Double) in view.cutoutHeight = CGFloat(value) } |
| 76 | Prop("cutoutRadius") { (view: T3FrostedCutoutView, value: Double) in view.cutoutRadius = CGFloat(value) } |
| 77 | Prop("appearance") { (view: T3FrostedCutoutView, value: String) in view.appearance = value } |
| 78 | } |
| 79 | |
| 80 | View(T3ContextSheetSizeView.self) { |
| 81 | ViewName("ContextSheetSize") |
| 82 | Prop("contentHeight") { (view: T3ContextSheetSizeView, height: Double) in |
| 83 | view.contentHeight = CGFloat(height) |
| 84 | } |
| 85 | } |
| 86 | |
| 87 | AsyncFunction("shareFileFromSource") { (url: URL, title: String, identifier: String, promise: Promise) in |
| 88 | try self.shareFile(url: url, title: title, sourceIdentifier: identifier, promise: promise) |
| 89 | }.runOnQueue(.main) |
| 90 | } |
| 91 | |
| 92 | // UIKit presenters are main-actor isolated rather than run on the JS thread |
| 93 | // the macro would otherwise pick. |
| 94 | @JS |
| 95 | @MainActor |
| 96 | func dismissVideo(identifier: String) async { |
| 97 | dismissVideoPresentation(identifier: identifier) |
| 98 | } |
| 99 | |
| 100 | @JS |
| 101 | @MainActor |
| 102 | func dismissFile(identifier: String) async { |
| 103 | dismissFilePresentation(identifier: identifier) |
| 104 | } |
| 105 | |
| 106 | // Rotates the interface without Simulator menu UI scripting, which CI |
| 107 | // runners cannot perform (osascript is denied Accessibility access there). |
| 108 | @JS |
| 109 | @MainActor |
| 110 | func applyShowcaseOrientation(orientation: String) async { |
| 111 | let mask: UIInterfaceOrientationMask = orientation == "landscape" ? .landscapeRight : .portrait |
| 112 | for case let windowScene as UIWindowScene in UIApplication.shared.connectedScenes { |
| 113 | windowScene.requestGeometryUpdate(.iOS(interfaceOrientations: mask)) { error in |
| 114 | NSLog("T3NativeControls applyShowcaseOrientation(\(orientation)) failed: \(error)") |
| 115 | } |
| 116 | for window in windowScene.windows { |
| 117 | window.rootViewController?.setNeedsUpdateOfSupportedInterfaceOrientations() |
| 118 | } |
| 119 | } |
| 120 | } |
| 121 | |
| 122 | // The geometry request above can fail transiently (for example before the |
| 123 | // scene is foreground-active), so callers poll this until it settles. |
| 124 | // Screen bounds — not the scene's interface orientation — decide the |
| 125 | // answer because they match the captured framebuffer: with iPadOS |
| 126 | // windowing active, a floating landscape window still reports a portrait |
| 127 | // screen, and screenshots would come out portrait. |
| 128 | @JS |
| 129 | @MainActor |
| 130 | func getInterfaceOrientation() async -> String { |
| 131 | guard |
| 132 | let windowScene = UIApplication.shared.connectedScenes |
| 133 | .compactMap({ $0 as? UIWindowScene }) |
| 134 | .first |
| 135 | else { |
| 136 | return "unknown" |
| 137 | } |
| 138 | let bounds = windowScene.screen.coordinateSpace.bounds |
| 139 | return bounds.width > bounds.height ? "landscape" : "portrait" |
| 140 | } |
| 141 | |
| 142 | private func launchArgument(_ flag: String) -> String? { |
| 143 | let arguments = ProcessInfo.processInfo.arguments |
| 144 | guard |
| 145 | let flagIndex = arguments.firstIndex(of: flag), |
| 146 | arguments.indices.contains(flagIndex + 1) |
| 147 | else { |
| 148 | return nil |
| 149 | } |
| 150 | return arguments[flagIndex + 1] |
| 151 | } |
| 152 | |
| 153 | @JS |
| 154 | func getShowcasePairingUrl() -> String? { |
| 155 | launchArgument("--showcasePairingUrl") |
| 156 | } |
| 157 | |
| 158 | @JS |
| 159 | func getShowcaseScene() -> String? { |
| 160 | let scenePath = NSHomeDirectory() + "/Library/Caches/T3ShowcaseScene" |
| 161 | if let storedScene = try? String(contentsOfFile: scenePath, encoding: .utf8) |
| 162 | .trimmingCharacters(in: .whitespacesAndNewlines), !storedScene.isEmpty { |
| 163 | return storedScene |
| 164 | } |
| 165 | return launchArgument("--showcaseScene") |
| 166 | } |
| 167 | |
| 168 | // The palette is fixed for the whole capture, so it only ever arrives as a |
| 169 | // launch argument — unlike the scene, which the runner rewrites in place. |
| 170 | @JS |
| 171 | func getShowcaseTheme() -> String? { |
| 172 | launchArgument("--showcaseTheme") |
| 173 | } |
| 174 | |
| 175 | @JS |
| 176 | func getShowcaseOrientation() -> String? { |
| 177 | launchArgument("--showcaseOrientation") |
| 178 | } |
| 179 | |
| 180 | @JS |
| 181 | func prepareShowcaseCapture() { |
| 182 | for itemClass in [kSecClassGenericPassword, kSecClassInternetPassword] { |
| 183 | SecItemDelete([kSecClass as String: itemClass] as CFDictionary) |
| 184 | } |
| 185 | } |
| 186 | |
| 187 | @JS(.concurrent) |
| 188 | func markShowcaseReady(scene: String) async { |
| 189 | let readyPath = NSHomeDirectory() + "/Library/Caches/T3ShowcaseReadyScene" |
| 190 | try? scene.write(toFile: readyPath, atomically: true, encoding: .utf8) |
| 191 | } |
| 192 | |
| 193 | private func presentVideo(url: URL, title: String, sourceIdentifier: String, identifier: String, promise: Promise) throws { |
| 194 | let isPlayableURL = url.isFileURL |
| 195 | ? FileManager.default.isReadableFile(atPath: url.path) |
| 196 | : (["https", "http"].contains(url.scheme?.lowercased() ?? "") && url.host != nil) |
| 197 | guard videoPresentation == nil, filePresentation == nil, |
| 198 | let presenter = appContext?.utilities?.currentViewController(), |
| 199 | isPlayableURL |
| 200 | else { |
| 201 | throw NSError( |
| 202 | domain: "T3NativeVideo", |
| 203 | code: 2, |
| 204 | userInfo: [NSLocalizedDescriptionKey: "The video preview is no longer available."] |
| 205 | ) |
| 206 | } |
| 207 | let presentation = T3NativeVideoPresentation(identifier: identifier, url: url, title: title) { [weak self] error in |
| 208 | self?.videoPresentation = nil |
| 209 | if let error { promise.reject(error) } else { promise.resolve(nil) } |
| 210 | } |
| 211 | videoPresentation = presentation |
| 212 | presentation.present(from: presenter, sources: presentationSources, sourceIdentifier: sourceIdentifier) |
| 213 | } |
| 214 | |
| 215 | private func dismissVideoPresentation(identifier: String) { |
| 216 | if videoPresentation?.identifier == identifier { videoPresentation?.dismiss() } |
| 217 | } |
| 218 | |
| 219 | private func makeFilePresentation(sourceIdentifier: String, identifier: String, |
| 220 | promise: Promise) throws -> T3NativeFilePresentation { |
| 221 | guard filePresentation == nil, videoPresentation == nil, |
| 222 | appContext?.utilities?.currentViewController() != nil |
| 223 | else { throw URLError(.cannotLoadFromNetwork) } |
| 224 | let file = T3NativeFilePresentation(identifier: identifier, sources: presentationSources, |
| 225 | sourceIdentifier: sourceIdentifier) { [weak self] error in |
| 226 | self?.filePresentation = nil |
| 227 | if let error { promise.reject(error) } else { promise.resolve(nil) } |
| 228 | } |
| 229 | filePresentation = file |
| 230 | return file |
| 231 | } |
| 232 | |
| 233 | private func presentFile(url: URL, title: String, sourceIdentifier: String, |
| 234 | identifier: String, promise: Promise) throws { |
| 235 | let file = try makeFilePresentation(sourceIdentifier: sourceIdentifier, identifier: identifier, promise: promise) |
| 236 | guard let presenter = appContext?.utilities?.currentViewController() else { file.dismiss(); return } |
| 237 | file.present(url: url, title: title, from: presenter) |
| 238 | } |
| 239 | |
| 240 | private func dismissFilePresentation(identifier: String) { |
| 241 | if filePresentation?.identifier == identifier { filePresentation?.dismiss() } |
| 242 | } |
| 243 | |
| 244 | private func shareFile(url: URL, title: String, sourceIdentifier: String, promise: Promise) throws { |
| 245 | guard let presenter = appContext?.utilities?.currentViewController() else { |
| 246 | throw NSError( |
| 247 | domain: "T3NativePresentation", |
| 248 | code: 2, |
| 249 | userInfo: [NSLocalizedDescriptionKey: "The presenting screen is no longer open."] |
| 250 | ) |
| 251 | } |
| 252 | try presentFileShare( |
| 253 | url: url, |
| 254 | title: title, |
| 255 | source: presentationSources.view(for: sourceIdentifier), |
| 256 | presenter: presenter, |
| 257 | promise: promise |
| 258 | ) |
| 259 | } |
| 260 | } |