1import ExpoModulesCore
2import Security
3import 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")
8public 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}